This project is intentionally easy to fork — most customization is single-file edits. No code generators, no manifests, no DI containers.
node scripts/install.mjs --at 07:30 # re-registers at 07:30 local timeTimes are local system time. Default is 08:00 (a morning brief — before market open, with trading commentary fresh for the day ahead). Works on Windows / macOS / Linux.
The source registry lives in sources.config.json at the project root — the single source of truth. lib/sources/registry.ts is just a JSON loader + locale filter; you almost never edit the TS. Append a JSON object:
{
"id": "my-blog",
"name": "My Blog",
"type": "rss",
"url": "https://example.com/feed.xml",
"category": "tech",
"subcategory": "ai-news",
"enabled": true,
"useCurl": false,
"lang": "en",
"locales": ["zh", "en"],
"notes": "Why this source was added / any quirk"
}Field reference:
| Field | Required | Notes |
|---|---|---|
id |
✓ | Unique short identifier; routed by dispatch.ts to the matching fetcher |
name |
✓ | Display name in the UI |
type |
✓ | rss / api / scrape |
url |
✓ | Feed URL or API endpoint |
category |
✓ | tech / finance / politics — drives the L1 tab |
subcategory |
L2 grouping; see SUBCATEGORY_ORDER in lib/output/render.ts |
|
enabled |
Default true; set false to skip without deleting |
|
useCurl |
true if the host blocks Node's TLS fingerprint (Cloudflare) — fetcher shells out to curl |
|
lang |
zh means the source is already Chinese — enrich skips it when REPORT_LOCALE=zh |
|
locales |
Array of REPORT_LOCALE values where this source appears. Default ["zh", "en"] |
|
notes |
Free-form; useful for explaining enabled: false or unusual flags |
Workflow:
- Edit
sources.config.json, append the new entry npm run sources:check— validates the JSON schema (also handy as a pre-commit hook)npm run dry-run— verifies the fetcher actually returns articles (~30s, no LLM)- Next
npm run dailypicks it up automatically
For non-RSS source types (custom JSON API, scraping), create a new file in lib/sources/, export a fetchXxx(sourceId) function returning RawArticle[], then add a branch in lib/sources/dispatch.ts so type: "api" or type: "scrape" entries route there.
const CATEGORY_LABELS: Record<Category, string> = {
tech: "技术动态", // ← rename here
finance: "财经要点",
politics: "时政观察",
};L1 panel order is hardcoded in renderHtml() (search for <nav class="tabs">). Reorder the <button> lines.
- In
registry.ts: tag sources withsubcategory: "my-new-sub" - In
render.ts:- Add
"my-new-sub"toSUBCATEGORY_ORDER.tech - Add
"my-new-sub": "我的新栏目"toSUBCATEGORY_LABELS - Add to
TECH_MAIN_SUBS(orTECH_COMMUNITY_SUBSfor community panel) — controls which L1 panel renders it - Optionally:
SOURCE_DISPLAY_LIMITS["tech:my-new-sub"]for per-source cap - Optionally:
MERGED_SUBGROUP_LIMITS["tech:my-new-sub"]to merge sources into single time-sorted list
- Add
- In
lib/ai/enrich.ts, copy theXVIRAL_SYSTEM_PROMPTblock and adjust:- The system prompt (writing style, output structure)
- Add an
enrichXxxSummaries()function callingrunEnrichment(payload, MY_PROMPT, "scope label")
- In
scripts/daily.ts:- Add an
enrichXxx(articles)wrapper that filters to your subcategory and calls the function from step 1 - Add
await enrichXxx(articles)to themain()enrichment chain
- Add an
All CSS is inline inside renderHtml() in lib/output/render.ts. Search for <style> and edit. After saving, npm run render (1 second) regenerates the latest HTML using cached article data — no LLM cost.
lib/trading/watchlist.ts — WATCHLIST array. Each entry:
{ symbol: "AAPL", displayName: "苹果", group: "us-equity" }Valid group values are in AssetGroup (same file): us-equity / crypto / china-equity / commodity-fx / macro. The L2 trading sub-tabs render groups in ASSET_GROUP_ORDER order.
Comment out the runTrading() call near the end of scripts/daily.ts main(). The report will skip the "市场行情" L1 tab.
Two ways:
- Set all its sources to
enabled: falsein registry — category disappears automatically (empty) - Remove the L1 panel from
renderHtml()— search fordata-panel="finance"and delete that<button>+<section>
All LLM calls funnel through lib/ai/llm.ts runLlm(), which dispatches to one of five backends based on the LLM_BACKEND env var:
LLM_BACKEND |
Implementation | Auth |
|---|---|---|
claude-cli (default) |
lib/ai/backends/claude-cli.ts — spawns the local claude CLI |
Whatever the CLI is logged in as (e.g. Max subscription) |
anthropic |
lib/ai/backends/anthropic.ts — direct API |
ANTHROPIC_API_KEY |
openai / deepseek / minimax |
lib/ai/backends/openai-compat.ts — OpenAI-compatible Chat Completions |
OPENAI_API_KEY / DEEPSEEK_API_KEY / MINIMAX_API_KEY |
To switch backend: set LLM_BACKEND=... in .env.local. No code changes.
To add a new backend (e.g. Mistral): drop a new file in lib/ai/backends/, export a function matching the existing signatures (see claude-cli.ts as the simplest reference), then add a branch in runLlm() in lib/ai/llm.ts.
The prompts (in lib/ai/prompts.ts, enrich.ts, trading-commentary.ts) assume a Sonnet-class model — Chinese fluency, structured JSON output, long context. Switching to a smaller model may need prompt adjustments and JSON-repair fallbacks (already present, but less reliable on weaker models).
Whether you need any secret depends on how you've deployed:
| Setup | Secrets you need |
|---|---|
Local install + default claude-cli backend (reuses Claude Code OAuth) |
None — just be logged into claude CLI |
Local install + any API backend (anthropic / openai / deepseek / minimax) |
That backend's *_API_KEY in .env.local |
| GitHub Actions deploy | The chosen backend's API key as a GH Secret (Claude OAuth is unreachable from GH runners) — see README §"GH Actions" for the secret/variable matrix |
Adding a NEW secret (e.g. you wire up a paid data source like Bloomberg):
.env.localat project root (gitignored)- Add
MY_API_KEY=... - It's already loaded — every entry script does
import "./_env"first, which dotenv-loads.env.localbefore any other module init
Last run state (per-OS):
# Windows
Get-ScheduledTaskInfo -TaskName DailyBrief | Format-List LastRunTime, LastTaskResult# macOS
launchctl list | grep com.daily-brief
# Linux (cron doesn't track per-job state, so just inspect cron + log)
crontab -l | grep daily-briefTail today's log (date = local time, not UTC):
# Cross-platform (uses node — works in PowerShell / bash / zsh)
node -e "const fs=require('fs'),d=new Date(),pad=n=>String(n).padStart(2,'0');console.log(fs.readFileSync('logs/daily-'+d.getFullYear()+'-'+pad(d.getMonth()+1)+'-'+pad(d.getDate())+'.log','utf8').split('\n').slice(-40).join('\n'))"Check LLM call history:
npm run quota-reportCommon error shapes:
429/quotainlogs/llm-calls.jsonl— backend rate limit; wait or temporarily switchLLM_BACKENDin.env.local- single source
FAILED — <reason>in the daily log — read that source's fetcher inlib/sources/<id>.ts; per-source failures are non-fatal - empty trading watchlist — Sonnet's "no investment advice" guardrail occasionally bites;
trading-commentary.tsretries 3× with a softer prompt, then falls back to an empty panel
Decode LastTaskResult:
0= success267009= currently running267011= never run- Anything else = error code; check log
If the task didn't fire at all: ensure Task Scheduler service is running, your Windows user account was logged in at trigger time (or StartWhenAvailable will catch up after next login), and WakeToRun works on your hardware.
npm run daily # foreground, ~5-8 min, blocks the shellOr schedule with your own tooling (cron, Linux systemd timer, macOS launchd) — call npm run daily from the project root.