145 lines
4.8 KiB
TypeScript
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));
|
|
}
|