Files
Newsletter-Link-Catalog/src/config/config.ts
T
2026-05-17 14:05:25 -05:00

145 lines
4.8 KiB
TypeScript

import { readFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { resolve } from 'node:path';
import YAML from 'yaml';
import { z } from 'zod';
const outputSchema = z.object({
name: z.string().min(1),
sheetsApi: z
.object({
enabled: z.boolean().default(false),
credentials: z.string().optional(),
token: z.string().optional(),
spreadsheetId: z.string().optional()
})
.optional(),
excel: z
.object({
enabled: z.boolean().default(false),
path: z.string().default('./output/newsletter-catalog.xlsx')
})
.optional()
});
const configSchema = z
.object({
gmail: z.object({
folder: z.string().min(1),
credentials: z.string().default('~/.nlc/gmail-credentials.json'),
token: z.string().default('~/.nlc/gmail-token.json')
}),
output: outputSchema,
newsletters: z.record(z.string(), z.any()).default({}),
links: z
.object({
unwrapRedirects: z.boolean().default(true),
stripUtm: z.boolean().default(true),
trackingParams: z
.array(z.string())
.default(['utm_*', 'fbclid', 'gclid', 'mc_cid', 'mc_eid']),
redirectLimit: z.number().int().positive().default(5),
readMorePattern: z.string().default('(?i)^(read more|continue reading|learn more)$'),
sharePatterns: z.array(z.string()).default(['(?i)share', '(?i)forward to a friend']),
sponsorMarkers: z
.array(z.string())
.default(['(?i)sponsor', '(?i)sponsored', '(?i)advertisement', '(?i)partner']),
filterUnsubscribe: z.boolean().default(true),
filterSocialFooter: z.boolean().default(true),
filterShareLinks: z.boolean().default(true),
mergeReadMore: z.boolean().default(true)
})
.default({}),
categories: z
.object({
custom: z.array(z.string()).default([]),
llm: z
.object({
provider: z
.enum(['anthropic', 'openai', 'local', 'openai-compatible'])
.default('anthropic'),
model: z.string().default('claude-sonnet-4-6'),
apiKeyEnv: z.string().default('ANTHROPIC_API_KEY'),
baseUrl: z.string().nullable().optional(),
failureCategory: z.string().default('Uncategorized')
})
.default({})
})
.default({}),
enrichment: z
.object({
enabled: z.boolean().default(true),
concurrency: z.number().int().positive().default(3),
delayMs: z.number().int().nonnegative().default(1500),
retries: z.number().int().nonnegative().default(2),
timeoutMs: z.number().int().positive().default(10000)
})
.default({}),
rateLimit: z
.object({
gmailQps: z.number().positive().default(5),
linkConcurrency: z.number().int().positive().default(3)
})
.default({}),
database: z
.object({
enabled: z.boolean().default(true),
path: z.string().default('./data/newsletter-catalog.sqlite')
})
.default({}),
stateFile: z.string().default('~/.nlc/state.json'),
plugins: z.record(z.string(), z.any()).default({})
})
.transform((config) => ({
...config,
output: {
...config.output,
sheetsApi: config.output.sheetsApi ?? { enabled: false },
excel: config.output.excel ?? { enabled: false, path: './output/newsletter-catalog.xlsx' }
}
}));
export type AppConfig = z.infer<typeof configSchema>;
export type PartialConfig = Record<string, unknown>;
function camelize(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(camelize);
}
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, entry]) => [
key.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase()),
camelize(entry)
])
);
}
return value;
}
function objectFromNull(value: unknown): unknown {
return value === null ? {} : value;
}
export function expandHome(path: string): string {
return path.startsWith('~/') ? resolve(homedir(), path.slice(2)) : path;
}
export function loadConfigFromString(source: string): AppConfig {
const parsed = camelize(YAML.parse(source) ?? {}) as Record<string, any>;
parsed.newsletters = objectFromNull(parsed.newsletters);
parsed.categories = objectFromNull(parsed.categories) as Record<string, any>;
if (parsed.categories && typeof parsed.categories === 'object') {
parsed.categories.llm = objectFromNull(parsed.categories.llm);
}
return configSchema.parse(parsed);
}
export async function loadConfig(path: string): Promise<AppConfig> {
return loadConfigFromString(await readFile(expandHome(path), 'utf8'));
}
export function normalizeConfig(config: PartialConfig): AppConfig {
return configSchema.parse(camelize(config));
}