name: daily-brief description: Operational knowledge for the daily-brief digest pipeline (this project). RSS/API fetchers, pluggable LLM enrichment (default claude CLI on Max; also anthropic/openai/deepseek/minimax API), trading section, HTML rendering, cross-platform scheduler integration (Windows Task Scheduler / macOS launchd / Linux cron). Load when the user asks about running daily / regenerating sections / debugging a failed run / adding or disabling sources / LLM quota / scheduler / why a tab shows wrong data / why a source failed / switching LLM backend. Always prefer the documented npm commands over re-implementing logic. Diagnose by reading logs/daily-*.log first, then logs/llm-calls.jsonl for LLM-side issues.
daily-brief — Operational Skill
This project generates a single-page HTML daily digest covering tech / finance / politics / market data / community discussion. The pipeline runs locally via the OS scheduler (Windows Task Scheduler / macOS launchd / Linux cron, default 08:00 local time) and emits daily_reports/<YYYY-MM-DD>/<YYYY-MM-DD>.html + sidecar files (each date gets its own subdir). The date label uses the system local timezone by default — set REPORT_TZ (e.g. Asia/Shanghai, UTC) in .env.local to override.
Detailed architecture lives in code; this skill is a cheat sheet for operating and diagnosing, not a re-explanation of the system.
Project root assumption
All paths in this skill are relative to the project root (the directory that contains package.json, lib/, scripts/).
Before any command, ensure the working directory is the project root. Two cases:
Claude Code session opened inside the project — already there, no action needed
Session opened elsewhere — read the config file and
cd:# Cross-platform Node one-liner (prints the project root path): node -e "const fs=require('fs'),os=require('os'),path=require('path');const cfg=path.join(os.homedir(),'.daily-brief-config');if(fs.existsSync(cfg))console.log(fs.readFileSync(cfg,'utf8').trim());else process.exit(1)"Use the printed path:
cd "$(...)"on bash /Set-Location (...)in PowerShell.
The config file is written by node scripts/install.mjs --global. If it's missing the user hasn't done a global install — tell them to run it.
Quick command reference
| Need | Command | Cost |
|---|---|---|
| Full pipeline | npm run daily |
~5-8 min, ~6 Sonnet calls |
| Fetch sanity only (no LLM) | npm run dry-run |
~30s |
| Re-render existing sidecar | npm run render [date] |
<1s |
| Re-run trading section | npm run regen-trading [date] |
~2 min, 1 LLM call |
| Top-up missing summary | npm run regen-enrich <cat:sub> [date] |
~20-40s, 1 LLM call |
| Open today's report in Chrome | npm run open |
instant |
| Sonnet quota + call history | npm run quota-report |
instant |
[date] defaults to today's date in the report timezone (system local, or REPORT_TZ if set). The pipeline and the OS scheduler both run in local time, so the report's date label = the date when the trigger fired in the report timezone. A user with REPORT_TZ=Asia/Shanghai whose machine fires the trigger at 23:00 UTC-8 will get a "next-day Shanghai" file, e.g. daily_reports/2026-05-17/2026-05-17.html.
<cat:sub> accepted by regen-enrich: finance:news, politics:world, tech:ai-news. Single-source X 推文 (tech:x-viral) is enriched as part of daily only — no top-up path.
File map — where to change what
| Task | File |
|---|---|
| Add / disable / re-categorize a source | sources.config.json (project root — single source of truth; lib/sources/registry.ts is just a loader) |
| Rename L1 tab labels | lib/output/render.ts CATEGORY_LABELS |
| Reorder / rename L2 subcategories | SUBCATEGORY_ORDER + SUBCATEGORY_LABELS in same file |
| Change per-source item cap | SOURCE_DISPLAY_LIMITS |
| Change merged-timeline cap | MERGED_SUBGROUP_LIMITS |
| Add a Sonnet enrichment prompt | lib/ai/enrich.ts — copy XVIRAL_SYSTEM_PROMPT pattern |
| Wire an enrichment into pipeline | scripts/daily.ts — await enrichXxx(articles) in main() |
| Add a new fetcher type | New file in lib/sources/ + branch in lib/sources/dispatch.ts |
| Adjust HTML styling | inline <style> block in renderHtml() in lib/output/render.ts |
| Change scheduler trigger time | node scripts/install.mjs --at HH:MM (re-registers) |
| Wrapper script the scheduler invokes | scripts/run-daily.mjs |
How LLM enrichment works (mental model)
- Each merged L2 subcategory gets a Sonnet pass: GH-trending (per-source), finance:news, politics:world, tech:ai-news, tech:x-viral.
- Each pass = one batched Sonnet call for all items in that subgroup. Don't iterate per-item.
- Sources with
lang: "zh"in registry skip enrichment (already Chinese). - Failures are non-fatal: skipped articles just render without
summary.
Diagnostic flow
Order matters — top-to-bottom:
"今天日报没出来" / "Chrome 没弹"
- Check scheduled task state — platform-specific:
- Windows:
Get-ScheduledTaskInfo -TaskName DailyBrief→LastRunTime+LastTaskResult(0=success,267009=running, else failed) - macOS:
launchctl list | grep com.daily-brief(PID column + last exit code) - Linux: cron doesn't track per-job state; look at
logs/cron.log
- Windows:
- Tail today's log (date = local, not UTC):
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 report files exist:
ls daily_reports/<date>/(any platform) orGet-ChildItem daily_reports\<date>\(Windows)
"某个源数据不对 / 0 条"
- Look at fetch lines near top of log —
<id> <count>or<id> FAILED — <reason> - If specific source failed: read its fetcher in
lib/sources/<source>.ts - If Cloudflare-related: see "LinuxDo lesson" below
- Single-source failure must never kill the run (try/catch per source in
daily.ts)
"LLM 调用炸 / 中文摘要缺失"
npm run quota-report— per-backend summary; forclaude-clishows 5h window, for API backends shows 24h spending- If quota hot on
claude-cli: wait or temporarily switch via.env.local(LLM_BACKEND=openaietc.) - If specific phase missing summaries:
npm run regen-enrich <cat:sub> - Each call logged to
logs/llm-calls.jsonl(legacyclaude-calls.jsonlstill read for backwards-compat) — grep"success":false, seeerrorCategory(quota/timeout/auth/other) - Which backend is active =
LLM_BACKENDenv in.env.local; not set →claude-cli
"UI 出错 / 某个 tab 显示异常"
npm run render(1 second) — often fixes display-only bugs- If still wrong: read rendered HTML for the affected panel
renderRawCategoryPanel/renderSubContentchain inrender.tsis where panel structure lives
Recurring failure patterns (institutional knowledge)
LinuxDo / Cloudflare WAF
- LinuxDo is behind Cloudflare and frequently flags datacenter-IP exits with "Just a moment..." challenges
- Do NOT add aggressive retry to its fetcher — burst requests escalate the WAF flag, causing persistent blocks
- May ship with
enabled: falsedepending on current IP rep - Browser works because of cookies + JS challenge; curl can't do either
- If re-enabling: keep single attempt, accept intermittent failures
Run-daily.mjs wrapper notes
- Tees
npm run dailystdout+stderr tologs/daily-<local-date>.logvia stream pipes (real-time, not buffered) - Exit code from
npm run dailyis propagated to the OS scheduler - On exit 0: spawns
npm run opendetached so Chrome opens without blocking - Cross-platform: same
.mjsfile works on Windows / macOS / Linux
"X 推文 出现非英文"
- API's
lang=enparam is best-effort; some slip through - Fix in
lib/sources/attentionvc.tsisEnglish()— checkslangsDetected(most reliable) +lang === "zxx"(image/code-only, kept)
"社区讨论 tab 偶发空白"
- Was a JS scope bug: sub-tab/source-tab handlers used
data-cat="tech"selector. Tech main panel AND community panel both had sub-content withdata-cat="tech", so clicking AI 媒体 in tech panel deactivated cn-community in community panel - Fixed: handlers use
btn.closest('.panel')+btn.closest('.sub-content'). If regression, look at inline<script>block at end ofrenderHtml()
Trading commentary "watchlist empty"
- Sonnet occasionally returns valid JSON with empty watchlist. 1-shot retry built into
lib/ai/trading-commentary.tswith stronger prompt - If retry also fails, render falls back to empty trading panel — run isn't aborted
Source registry conventions
Sources live in sources.config.json at the project root. lib/sources/registry.ts only loads + validates that JSON at module-init; never hardcode sources in TS.
- Every source has:
id,name,type(rss/api/scrape),url,category,subcategory?,enabled?,useCurl?,lang?,locales? useCurl: truefor sources behind Cloudflare-style TLS-fingerprint blockslang: "zh"(or "en") for sources already in a specific language — enrich skips them when REPORT_LOCALE matcheslocales: ["zh"|"en"]filters which REPORT_LOCALE keeps the source. Omit → both- Disabled sources stay in the JSON with
enabled: false+ anotesfield explaining why — don't delete - Run
npm run sourcesto see the table by category with current enable/filter status
Render layout (current, may evolve)
L1 tabs in order: tech / trading / politics / finance / community
技术动态 (tech)
L2: GitHub Trending (per-source, cap 20)
L2: X 推文 (single source attentionvc-ai, cap 20, preserve fetch order)
L2: AI 媒体 (merged 7 RSS sources, cap 15, summary)
市场行情 (trading)
asset-group tabs: macro / 美股 / 加密 / 中港 / 商品外汇
时政观察 (politics:world)
merged single timeline, cap 15, summary, sports filtered
财经要点 (finance:news)
merged single timeline, cap 12, summary
社区讨论 (community)
Source tabs: V2EX / LinuxDo (cap 10 each)
Note: cn-community is registered under category=tech but rendered as its
own L1 panel — see TECH_MAIN_SUBS vs TECH_COMMUNITY_SUBS in render.ts
Scheduler integration (cross-platform)
scripts/install.mjs registers the daily trigger via the OS-native scheduler:
| OS | Mechanism | Wake-from-sleep |
|---|---|---|
| Windows | Task Scheduler "DailyBrief" (WakeToRun, AllowStartIfOnBatteries, StartWhenAvailable) + power-plan wake timers |
✓ wakes laptop |
| macOS | launchd plist ~/Library/LaunchAgents/com.daily-brief.plist |
✗ doesn't wake; configure pmset separately if needed |
| Linux | crontab entry tagged # daily-brief |
✗ cron doesn't fire while suspended — run skipped |
Common:
- Default trigger: 08:00 local time (
--at HH:MMto change) - Runs as current interactive user — required because claude CLI's OAuth token lives in user profile
- Execution timeout: 30 min (Windows only; macOS/Linux no built-in timeout)
- Set up:
node scripts/install.mjs [--at HH:MM] [--global] - Tear down:
node scripts/uninstall.mjs - Inspect:
Get-ScheduledTask DailyBrief | flortaskschd.mscGUI
What NOT to do
- Don't
console.logdebugging that won't survive — use structured logger or write tologs/ - Don't add
process.exit(1)deep in a fetcher; letdaily.ts's per-source try/catch handle it - Don't bypass
runLlm(lib/ai/llm.ts) by importing a specific backend directly — that defeats the LLM_BACKEND switch and pins call sites to one provider - Don't change the default backend silently; if user has Max subscription they almost certainly want
claude-clito keep using it. Switching to API costs them money - Don't put full AI digest briefs (tech_briefs / politics_briefs / editor_note / keywords) back into the HTML view — they're intentionally hidden. Still generated and live in
<date>.jsonand<date>.mdfor archive. Do surfacehero_headlinein the Hero anddaily_overviewin the About section — that's the narrative bridge, not the full digest. - Don't add Playwright / Puppeteer dependencies casually — project uses curl + JSON APIs to stay light