Commit d5995b60 authored by 谢宇轩's avatar 谢宇轩

Initial commit

parents
# Machine-specific runtime config (auto-generated from references/config-template.json)
config.json
config.json.bak
# Runtime dedup registry (harvester only)
data/
# Dependencies
node_modules/
# Logs / temp
*.log
/tmp/
# Local source recipes & fixtures (product files)
# These are installed from shareable bundles (bundles/*.nhsource.json) or
# authored locally — the bundle is the committed unit, not the helper.
helpers/
# IDE / agent workspace files
.zcode/
# News Hub Skills
Two independent skills for the news harvesting pipeline:
## news-source-analyzer (无状态分析器)
Analyze news websites, author fetch recipes, and produce installable source bundles (`.nhsource.json`). Stateless — no vault dependency, no source registry. Works on any machine with Chromium installed.
## news-harvester (采集器)
Harvest and archive news articles from configured sources to Obsidian vault with Chinese summaries + original text. Sources are installed from bundles produced by the analyzer. Manages vault path, push config, and source registry.
## Data flow
```
Analyzer (any machine) Harvester (collection machine)
───────────────── ─────────────────────────────
inspect → recipe.md install bundle.nhsource.json
↓ → helpers/<id>.md + config.sources
preview → verify article extraction ↓
↓ harvest → summaries → finalize → push
recipe-test → regression lock ↓
↓ Obsidian vault + News Hub
pack → bundle.nhsource.json
└───── manual/scripted transfer ─────→ harvester
```
The `.nhsource.json` bundle is the only interface between the two skills. No shared runtime state, no shared config, no shared browser profile.
## Shared scripts
Four CDP/Chromium infrastructure scripts (`ensure-chromium.js`, `cdp-client.js`, `fetcher-helper.js`, `block-check.js`) are duplicated in both skills as standalone copies. These are stable low-level CDP wrappers with low change frequency. When modifying one, sync the copy in the other skill.
---
name: news-harvester
description: Harvest and archive news articles from configured sources to Obsidian vault with Chinese summaries + original text. Sources are installed from .nhsource.json bundles produced by the news-source-analyzer skill. Use this skill whenever the user wants to fetch latest news, run a harvest job, archive articles, push to News Hub, install a source bundle, list/edit/enable/disable sources, or check archived content. Triggers on phrases like "抓取新闻", "harvest news", "fetch articles", "run harvest", "install bundle", "push to news hub", "采集新闻", "归档", "推送", "install source", "list sources", "news sources".
---
# News Harvester Skill
Fetch latest articles from configured news sources, archive them to Obsidian vault with Chinese summaries + original text. Sources are installed exclusively from `.nhsource.json` bundles (produced by the **news-source-analyzer** skill) — there is no source-authoring capability in this skill.
> **Skill root** = the directory containing this `SKILL.md`. Prefix every command with `cd "<skill-root>" &&`, or scripts fail with `MODULE_NOT_FOUND`.
## Quick Reference
| Task | Command |
|------|---------|
| Archive latest articles | `node scripts/harvest.js <source_id> <count>` |
| Preview latest (read-only) | `node scripts/harvest.js <source_id> <count> --preview` |
| Preview with full body | `node scripts/harvest.js <source_id> <count> --preview --full` |
| Finalize Chinese summaries | `node scripts/finalize.js "<dayDir>"` |
| Push a day directory to News Hub | `node scripts/push.js "<dayDir>"` |
| Check push sync status | `node scripts/push.js "<dayDir>" --status` |
| Merge a summary batch | `node scripts/summary-add.js "<dayDir>"` |
| Check summary coverage | `node scripts/summary-add.js "<dayDir>" --status` |
| Install a source from a bundle | `node scripts/source-manage.js install <pkg.nhsource.json> [--force]` |
| List sources | `node scripts/source-manage.js list` |
| Edit source | `node scripts/source-manage.js edit <id> --field value` |
| Enable/disable source | `node scripts/source-manage.js enable\|disable <id>` |
| Ensure Chromium (browser sources) | `node scripts/ensure-chromium.js` (or `--check`, `--list`, `--login`) |
> **All commands must be prefixed with `cd "<skill-root>" &&`**. Scripts live in `scripts/` — always reference them as `node scripts/harvest.js`, never `node harvest.js`.
Config file: `config.json` (auto-generated from `references/config-template.json` on first run).
---
## First-Run Setup
**Pre-flight gate** — run this at the start of a task. If it prints `OK`, skip setup:
```bash
cd "<skill-root>" && node -e "const c=require('./config.json'); console.log(c.vaultPath==='/path/to/your/obsidian/vault'?'UNCONFIGURED':'OK')" 2>/dev/null || echo "UNCONFIGURED (no config.json)"
```
### Onboarding (only if UNCONFIGURED)
**1. Set the Obsidian vault path.** Ask the user for the absolute path to their Obsidian vault root. Verify it exists and is writable:
```bash
mkdir -p "<vaultPath>" && test -w "<vaultPath>" && echo "writable"
```
Write the real path into `config.json` (replace the placeholder).
**2. Install the first source from a bundle.** Bundles are in `bundles/`. Pick one and install:
```bash
node scripts/source-manage.js install bundles/reuters-world.nhsource.json
```
For a `direct` source that doesn't need Chromium, install `the-conversation` if a bundle is available.
**3. Check prerequisites for the source's `method`.**
- `direct` → nothing extra.
- `browser``harvest.js` auto-launches Chromium. If the source needs login, run:
```bash
node scripts/ensure-chromium.js --login --url <homepage>
```
Log in, quit the browser (Cmd+Q), then set `chromium.userDataDir` in `config.json`.
**4. Smoke test (read-only).**
```bash
node scripts/harvest.js <source_id> 2 --preview
```
If this returns article data, setup is complete.
---
## Configuration
`config.json` is machine-specific (contains the local vault path). Auto-created from `references/config-template.json` on first run.
| Field | Required? | Description |
|-------|-----------|-------------|
| `vaultPath` | ✅ Yes | Absolute path to the Obsidian vault root. Must exist and be writable. |
| `registryWindowDays` | Optional | Dedup sliding window in days (default `7`). |
| `push.enabled` | Optional | Enable pushing finalized archives to News Hub (default `false`). |
| `push.endpoint` | Optional* | News Hub API base URL. *Required when `push.enabled` is `true`. |
| `push.appId` / `appKeyId` / `appSecret` | Optional* | News Hub App credentials. *Required when `push.enabled` is `true`. |
| `push.batchSize` | Optional | Articles per push batch (default `10`). |
| `push.autoPushAfterFinalize` | Optional | When `true` (default), `finalize.js` auto-chains into `push.js`. |
| `push.retry` | Optional | `{ maxAttempts, baseDelayMs }` for exponential backoff (defaults `5` / `1000`). |
| `chromium.cdpPort` | Optional | CDP debug port (default `9222`). |
| `chromium.headless` | Optional | Headless (`true`, default) or windowed (`false`). |
| `chromium.executablePath` | Optional | Full path to browser binary. `null` = auto-discover. |
| `chromium.userDataDir` | Optional | Browser profile dir for login state. |
#### Dedicated profile (login state)
For sources needing cookies/login, log into a fixed profile once:
```bash
node scripts/ensure-chromium.js --login --url <homepage>
```
Log in, **quit the browser fully** (Cmd+Q), then persist `chromium.userDataDir` in `config.json`. After that, `harvest.js` auto-launches Chromium headless with this profile — login state intact.
Articles are archived to `<vaultPath>/<vaultFolder>/YYYYMMDD/`. Within a day directory: 原文.md files + `registry.json` + `index.md` at the root, images in `assets/`, and Chinese summary docs in `summary/`.
---
## Installing sources from bundles
Sources are added by installing `.nhsource.json` bundles (produced by the **news-source-analyzer** skill):
```bash
node scripts/source-manage.js install bundles/<id>.nhsource.json [--force]
```
This writes the recipe + fixtures into `helpers/`, registers the source in `config.json` (enabled), and the source is immediately usable. See `references/source-management.md` for bundle format and install behavior.
---
## Workflow
Two flows: **archive** (fetch + save to Obsidian) or **preview** (read-only).
> **Stay lean.** If the user already named a source (e.g. "reuters"), use it directly — do **not** run `source-manage.js list` first. The archive flow is exactly **3 script calls**: `harvest.js` → write `.summaries.json` → `finalize.js`.
### How to work (read once)
- **Scripts do all deterministic I/O** (fetch, parse, download images, write 原文.md / registry / index). The agent does only the intelligent part — Chinese translation + 4-section summaries — and hands them back as a JSON file for `finalize.js` to stitch in. The full article body is written to disk by the script and **never enters the agent's context**.
- **`count` is the target number of NEW articles**, not raw links. If a run returns fewer than `count`, the stderr prints `⚠️ … stream may be exhausted` — that's a genuine source limit, not a bug. Don't re-run for the same source/day just to "get more".
- **Never `Read` the 原文.md files** to write summaries — the `bodyPreview` in the manifest is sufficient.
- **Extract `tags` from the summary/body** (≤3 semantic keywords), not from the category/source.
- **One `finalize.js` call** after writing `.summaries.json`.
### Flow A — Archive to Obsidian (3 steps)
#### Step 1. Run harvest
```bash
node scripts/harvest.js <source_id> <count>
```
Fetches links, deduplicates, fetches each article, downloads images, and writes to disk:
- `<英文标题> - <SourceName>原文.md`
- `registry.json` (dedup record, written incrementally)
- `index.md` (daily index, sentinel titles until finalize)
- `.manifest.json` (temp — consumed by finalize)
Prints a **compact manifest** to stdout (bodyPreview ~4000 chars per article). Read it.
#### Step 2. Generate Chinese summaries → write `.summaries.json`
Each summary entry:
```json
{
"url": "https://...",
"titleZh": "中文标题(≤30字)",
"category": "科技/AI",
"tags": ["关键词1", "关键词2", "关键词3"],
"summaryZh": "## 事件概述\n...\n\n## 背景\n...\n\n## 关键引语\n> \"...\"\n\n## 影响分析\n..."
}
```
- `titleZh`: concise Chinese title (≤30 chars)
- `category`: the article's category (see Category Detection below)
- `tags`: ≤3 keywords extracted from the summary/body (semantic, not the category/source name)
- `summaryZh`: the 4-section markdown body (事件概述 / 背景 / 关键引语 / 影响分析)
**Batching** (writing all summaries in one response hits the output-token ceiling at ~10+ articles):
- **N ≤ 10**: Write all entries to `<dayDir>/.summaries.json` in one Write → go to Step 3.
- **N > 10**: Split into batches of ~6. For each batch:
1. Write `<dayDir>/.summaries-batch.json` (JSON array, ≤6 entries).
2. `node scripts/summary-add.js "<dayDir>"` — merges batch into `.summaries.json`.
3. If output says `missing: 0` → go to Step 3. Otherwise write the next batch.
> **Interrupted run?** Do not re-run `harvest.js`. Run `node scripts/summary-add.js "<dayDir>" --status` to see which articles still need summaries, then resume batching.
#### Step 3. Run finalize
```bash
node scripts/finalize.js "<dayDir>"
```
Writes each `<中文标题>.md`, patches sentinel links in 原文.md files, updates `registry.json` + `index.md`, and removes `.manifest.json` + `.summaries.json`.
> **Auto-push (optional).** If `config.push.enabled` is `true`, finalize auto-chains into `push.js`. A push failure never aborts finalize — re-push with `node scripts/push.js "<dayDir>"` once the backend is reachable.
### Flow B — Preview (read-only, no archive)
```bash
node scripts/harvest.js <source_id> <count> --preview # titles + metadata + bodyPreview
node scripts/harvest.js <source_id> <count> --preview --full # ... + full body
```
No files written. stdout is a compact JSON list. Use `--full` only when the user explicitly wants the complete article text.
### Flow C — Push to News Hub
```bash
node scripts/push.js "<dayDir>" # push (resumes from failures)
node scripts/push.js "<dayDir>" --status # read-only sync status
node scripts/push.js "<dayDir>" --force # re-push all articles
```
`push.js` reads `registry.json`, parses each 原文.md / 摘要.md, uploads images to MinIO (SHA-256 deduped), and batch-pushes articles (≤10/batch, idempotent). Exit code `2` = partial failure. See `references/push-api-spec.md`.
---
## Human-intervention exits (CAPTCHA / login loss)
`harvest.js` detects two situations where it cannot proceed autonomously and exits immediately (exit code `70`):
| Situation | What to do |
|-----------|------------|
| **人机验证 / 反爬挑战** (CAPTCHA, Cloudflare) | Wait and retry; optionally `node scripts/ensure-chromium.js --login --url <homepage>` to pass the challenge in a windowed browser. |
| **登录状态失效** (login wall, expired cookies) | Run `node scripts/ensure-chromium.js --login --url <homepage>`, log in, quit browser (Cmd+Q), then re-run harvest. |
When `harvest.js` prints the `🚫 采集中止 — 需要人工介入` block, **do not retry** — relay the printed 建议操作 to the user and stop. Articles already archived before the block are kept.
---
## Category Detection
| Keywords | Category |
|----------|---------|
| asia-pacific, pakistan, afghanistan, 巴, 阿富汗 | 地缘政治 |
| ai, anthropic, openai, tech, 科技, AI | 科技/AI |
| china, 中国, 中美 | 中美关系 |
| business, finance, housing, economy, 经济, 财经 | 财经 |
| sustainability, feminist, society, 社会 | 社会 |
| ukraine, russia, middle-east, war | 国际冲突 |
| default | 国际 |
Category emoji: 地缘政治→🌏, 科技/AI→🤖, 中美关系→🇨🇳, 财经→💰, 社会→🧑‍🤝‍🧑, 国际冲突→⚔️, 国际→📰
---
## References
| File | When to read |
|------|--------------|
| `references/source-management.md` | Installing bundles, bundle format, install behavior, list/edit/enable/disable. |
| `references/document-formats.md` | Schema of 原文/摘要/index.md/registry.json (all script-generated; the agent only writes `.summaries.json`). |
| `references/push-api-spec.md` | News Hub Push API spec — request/response schemas, sync strategy, `.syncstate.json` ledger. |
{
"format": "news-harvester-source",
"version": 1,
"exportedAt": "2026-08-04T04:39:33.227Z",
"source": {
"id": "nikkei-markets",
"name": "日经亚洲市场",
"homepageUrl": "https://asia.nikkei.com/business/markets",
"method": "browser",
"vaultFolder": "日经亚洲/市场"
},
"files": {
"helpers/nikkei-markets.md": "---\nsource: nikkei-markets\nlistSelector: '[class*=\"StreamArticleCard\"], [class*=\"SpotlightArticleCard\"], [class*=\"SecondaryArticleCard\"]'\nlinkSelector: 'a[href]'\nurlPattern: 'asia\\.nikkei\\.com/(?:business|spotlight|economy|politics|editor-s-picks|location|opinion|features|life-arts|techasia)/[^/?#]+(?:/.+)?'\nexcludeUrlPattern: '/(?:podcasts|static|infographics|member)/'\nscrollSteps: 3\nscrollWaitMs: 2000\nwaitMs: 15000\nvalidateArticle: true\nbodyMinChars: 500\nbodySelectors:\n - '[class*=\"ArticleBody\"] p'\n - '[class*=\"article-body\"] p'\n - 'article p'\nbodyMinParagraphs: 3\ndateSelector: 'time[datetime]'\nauthorSelector: '[class*=\"author\"] a, [rel=\"author\"]'\nimageSelector: 'img'\n# 文章底部\"推荐/相关文章\"卡片缩略图与 hero 头图共用 images.ft.com CDN,\n# src 子串排除(excludeImage)无法区分,改用容器选择器整块排除。\n# 实测容器:RelatedArticle* / RelatedArticleFeature* / LatestOnTopicBottom* / Outbrain(.ob-rec-image)。\n# hero 头图在 NewsArticleHeaderImage 内,不在排除范围 → 保留。\nexcludeImageSelector: '[class*=\"RelatedArticle\"], [class*=\"RelatedArticleFeature\"], [class*=\"LatestOnTopicBottom\"], .ob-rec-image'\nimageMinWidth: 200\nexcludeImage:\n - logo\n - icon\n - avatar\n - profile\n - placeholder\n - outbrainimg.com\ntitleStripSuffix: ' - Nikkei Asia$'\nmaxBodyChars: 15000\n---\n# 日经亚洲市场 抓取备忘\n\n## 列表页链接收集(`/business/markets`)\n\n- **listSelector 用复合卡片选择器**:`[class*=\"StreamArticleCard\"], [class*=\"SpotlightArticleCard\"], [class*=\"SecondaryArticleCard\"]`。实测 `/business/markets` 页面文章卡片分散在这三类容器里(StreamArticleCard 24 篇 + SpotlightArticleCard 3 篇 + SecondaryArticleCard 3 篇 = 30 篇)。\n- 与 nikkei-tech 同属 Nikkei 模板,卡片容器类型一致,复用同一复合选择器。\n- **urlPattern** 覆盖所有可能的文章路径段:`business`、`spotlight`、`economy`、`politics`、`editor-s-picks`、`location`、`opinion`、`features`、`life-arts`、`techasia`。第三段 `(?:/.+)?` 可选——Nikkei 的 URL 结构不统一:`/business/markets/currencies/<slug>` 是 3 段,但 `/opinion/<slug>`、`/economy/<slug>`、`/politics/<slug>` 是 2 段。2 段 URL 会混入分类页(如 `/business/markets/commodities`),由 `validateArticle: true` 在 fetchPage 阶段剔除。\n- **excludeUrlPattern** 剔除 `/podcasts/`、`/static/`、`/infographics/`、`/member/` 等非文章路径。\n- **validateArticle: true** 在 fetchPage 阶段用 og:type + JSON-LD + 正文字符数剔除分类页(卡片 tag 链接如 `/business/markets/currencies`、`/business/markets/property` 会被判 isArticle=false 并跳过)。\n- `scrollSteps: 3` 触发懒加载;Nikkei 用 `?page=N` 分页,query strip 自动排除分页链接。\n\n## 详情页正文验证\n\n`validateArticle: true` 让 `fetchPage` 采集判别信号,返回 `isArticle` 布尔;harvest 对 `isArticle === false` 直接 skip:\n\n```\nisArticle = (og:type === 'article' || JSON-LD @type ∈ {NewsArticle, Article})\n && bodyChars >= 500\n && bodyParagraphs >= 3\n```\n\n## 其他\n\n- 正文优先 `[class*=\"ArticleBody\"]`(Nikkei CSS Modules 正文容器)。\n- 部分文章有付费墙,bodyMinChars=500 + bodyMinParagraphs=3 过滤被截断到极短的页面。\n"
}
}
\ No newline at end of file
{
"format": "news-harvester-source",
"version": 1,
"exportedAt": "2026-08-04T04:39:33.187Z",
"source": {
"id": "nikkei-tech",
"name": "日经亚洲科技",
"homepageUrl": "https://asia.nikkei.com/business/tech",
"method": "browser",
"vaultFolder": "日经亚洲/科技"
},
"files": {
"helpers/nikkei-tech.md": "---\nsource: nikkei-tech\nlistSelector: '[class*=\"StreamArticleCard\"], [class*=\"SpotlightArticleCard\"], [class*=\"SecondaryArticleCard\"]'\nlinkSelector: 'a[href]'\nurlPattern: 'asia\\.nikkei\\.com/(?:business|spotlight|economy|politics|editor-s-picks|location|opinion|features|life-arts|techasia)/[^/?#]+(?:/.+)?'\nexcludeUrlPattern: '/(?:podcasts|static|infographics|member)/'\nscrollSteps: 3\nscrollWaitMs: 2000\nwaitMs: 15000\n# 详情页正文验证(见 fetcher-helper.js buildExtractExpr):放宽 urlPattern 后,\n# 靠 og:type / JSON-LD @type / 正文字符数在 fetchPage 阶段剔除分类页,而不是\n# 靠 URL 末段连字符数猜测。实测文章 og:type=article + 正文>3000字符;分类页\n# og:type=website + 正文 0,判别力极强。\nvalidateArticle: true\nbodyMinChars: 500\nbodySelectors:\n - '[class*=\"ArticleBody\"] p'\n - '[class*=\"article-body\"] p'\n - 'article p'\nbodyMinParagraphs: 3\ndateSelector: 'time[datetime]'\nauthorSelector: '[class*=\"author\"] a, [rel=\"author\"]'\nimageSelector: 'img'\n# 文章底部\"推荐/相关文章\"卡片缩略图与 hero 头图共用 images.ft.com CDN,\n# src 子串排除(excludeImage)无法区分,改用容器选择器整块排除。\n# 实测容器:RelatedArticle* / RelatedArticleFeature* / LatestOnTopicBottom* / Outbrain(.ob-rec-image)。\n# hero 头图在 NewsArticleHeaderImage 内,不在排除范围 → 保留。\nexcludeImageSelector: '[class*=\"RelatedArticle\"], [class*=\"RelatedArticleFeature\"], [class*=\"LatestOnTopicBottom\"], .ob-rec-image'\nimageMinWidth: 200\nexcludeImage:\n - logo\n - icon\n - avatar\n - profile\n - placeholder\n - outbrainimg.com\ntitleStripSuffix: ' - Nikkei Asia$'\nmaxBodyChars: 15000\n---\n# 日经亚洲科技 抓取备忘\n\n## 列表页链接收集(`/business/tech`)\n\n- **listSelector 用复合卡片选择器**:`[class*=\"StreamArticleCard\"], [class*=\"SpotlightArticleCard\"], [class*=\"SecondaryArticleCard\"]`。实测文章卡片分散在这三类容器里(StreamArticleCard 27 篇 + SpotlightArticleCard 3 篇 + SecondaryArticleCard 3 篇 = 33 篇)。旧规则只锁定 `StreamArticleCard`,丢了 6 篇在 `SpotlightArticleCard`/`SecondaryArticleCard` 里的真实文章(含 SK Hynix、Philippine women、humanoid robots、Kumamoto quake、Panasonic、KOSPI 等)。\n- **为什么不直接 `listSelector: null`(全页)**:实测全页扫描会被导航栏的 `/location/<country>` 国家下拉链接(`/location/east-asia/china` 等 ~30 条)在 `maxLinks: 30` 上限内塞满,导致真实文章一条都收不到。卡片容器范围是必须的,只要把三类 ArticleCard 容器都纳入即可覆盖全部文章且避开 nav。\n- **urlPattern 扩展顶层段白名单**。旧 pattern 只覆盖 `/business/`、`/spotlight/`;实测 `/economy/`、`/politics/`、`/editor-s-picks/`、`/location/`、`/opinion/`、`/features/`、`/life-arts/`、`/techasia/` 下都出现文章。第三段 `(?:/.+)?` 可选——Nikkei 的 URL 结构不统一:`/business/tech/semiconductors/<slug>` 是 3 段,但 `/opinion/<slug>`、`/economy/<slug>`、`/politics/<slug>` 是 2 段。2 段 URL 会混入分类页(如 `/business/markets`),由 `validateArticle: true` 在 fetchPage 阶段剔除。\n- **excludeUrlPattern** 剔除 `/podcasts/`、`/static/`、`/infographics/`、`/member/` 等非文章路径。\n- **删除\"末段 ≥5 连字符\"硬规则**。旧 pattern 用 `(?:-[^/?#]*){5,}$` 猜测\"长 slug=文章\",但分类页 slug 也可能很长(如 `artificial-intelligence`、`wealth-management`),短标题文章又会被误伤。改由详情页验证区分。卡片里仍会混入 ~18 条子分类页链接(如 `/business/tech/semiconductors`、`/spotlight/trump-administration`,作为卡片的 tag 链接出现),由 `validateArticle` 在 fetchPage 阶段剔除。\n- `scrollSteps: 3` 触发懒加载;Nikkei 用 `?page=N` 分页(非无限滚动),query strip 自动排除分页链接。\n\n## 详情页正文验证(取代 URL 猜测)\n\n`validateArticle: true` 让 `fetchPage` 在提取正文时额外采集判别信号,返回 `isArticle` 布尔;harvest 对 `isArticle === false` 的候选直接 skip。判别逻辑:\n\n```\nisArticle = (og:type === 'article' || JSON-LD @type ∈ {NewsArticle, Article})\n && bodyChars >= 500\n && bodyParagraphs >= 3\n```\n\n实测信号(2026-07-29):\n| 页面类型 | og:type | JSON-LD @type | 正文段落 | 正文字符 |\n|----------|---------|---------------|----------|----------|\n| 文章(SK Hynix Q2) | article | NewsArticle | 11 | 3671 |\n| 文章(Philippine women) | article | NewsArticle | 30 | 5647 |\n| 分类页(/business/tech/semiconductors) | website | Thing | 0 | 0 |\n\n正文验证发生在候选链接被收集**之后**(fetchPage 阶段),所以放宽 urlPattern 混入的少量分类页会在这一步被剔除,不会进入 registry 或落盘。\n\n## 其他\n\n- 正文优先 `[class*=\"ArticleBody\"]`(Nikkei 的 CSS Modules 正文容器)。\n- 部分文章有付费墙,正文可能不完整——bodyMinChars=500 + bodyMinParagraphs=3 的下限会过滤掉被付费墙截断到极短的页面;preview 验证后若 charCount 过低需排除。\n- 回归测试:`node scripts/recipe-test.js nikkei-tech`(固定文章 URL 必须 isArticle,固定分类页 URL 必须非 isArticle,列表页链接数 ≥10 且不含 exclude 路径)。\n",
"helpers/nikkei-tech.fixtures.json": "{\n \"_comment\": \"Regression fixtures for the nikkei-tech recipe. Article URLs must fetch with isArticle=true (og:type=article + body ≥500 chars); section URLs must fetch with isArticle=false (og:type=website). URLs verified 2026-07-29 against https://asia.nikkei.com/business/tech. Update when Nikkei archives these — pick current articles from the same path depths (2-seg /economy/<slug>, 3-seg /business/tech/semiconductors/<slug>).\",\n \"articles\": [\n \"https://asia.nikkei.com/business/tech/semiconductors/sk-hynix-q2-profit-surges-but-misses-market-forecast-shares-slide\",\n \"https://asia.nikkei.com/business/tech/semiconductors/ai-chip-boom-shifts-bottleneck-to-advanced-packaging-says-at-s-ceo\",\n \"https://asia.nikkei.com/spotlight/society/for-philippine-women-in-taiwan-s-tech-factories-motherhood-is-impossible\",\n \"https://asia.nikkei.com/spotlight/trump-administration/us-bans-new-chinese-humanoid-robots-to-protect-ai-buildout\",\n \"https://asia.nikkei.com/economy/natural-disasters/major-japan-quake-traps-people-inside-kumamoto-shopping-mall-factory\",\n \"https://asia.nikkei.com/business/markets/south-korea-s-kospi-plunges-11-on-china-chip-competition-fears\",\n \"https://asia.nikkei.com/business/companies/panasonic-to-end-tv-production-in-malaysia-cutting-jobs\"\n ],\n \"sections\": [\n \"https://asia.nikkei.com/business/tech\",\n \"https://asia.nikkei.com/business/tech/semiconductors\",\n \"https://asia.nikkei.com/business/markets\",\n \"https://asia.nikkei.com/economy/natural-disasters\",\n \"https://asia.nikkei.com/spotlight/society\"\n ],\n \"minLinks\": 10\n}\n"
}
}
\ No newline at end of file
{
"format": "news-harvester-source",
"version": 1,
"exportedAt": "2026-08-04T04:39:33.259Z",
"source": {
"id": "nikkei-world",
"name": "日经亚洲世界",
"homepageUrl": "https://asia.nikkei.com/location",
"method": "browser",
"vaultFolder": "日经亚洲/世界"
},
"files": {
"helpers/nikkei-world.md": "---\nsource: nikkei-world\nlistSelector: '[class*=\"StreamArticleCard\"], [class*=\"SpotlightArticleCard\"], [class*=\"SecondaryArticleCard\"]'\nlinkSelector: 'a[href]'\nurlPattern: 'asia\\.nikkei\\.com/(?:business|spotlight|economy|politics|editor-s-picks|location|opinion|features|life-arts|techasia)/[^/?#]+(?:/.+)?'\nexcludeUrlPattern: '/(?:podcasts|static|infographics|member)/'\nscrollSteps: 3\nscrollWaitMs: 2000\nwaitMs: 15000\nvalidateArticle: true\nbodyMinChars: 500\nbodySelectors:\n - '[class*=\"ArticleBody\"] p'\n - '[class*=\"article-body\"] p'\n - 'article p'\nbodyMinParagraphs: 3\ndateSelector: 'time[datetime]'\nauthorSelector: '[class*=\"author\"] a, [rel=\"author\"]'\nimageSelector: 'img'\n# 文章底部\"推荐/相关文章\"卡片缩略图与 hero 头图共用 images.ft.com CDN,\n# src 子串排除(excludeImage)无法区分,改用容器选择器整块排除。\n# 实测容器:RelatedArticle* / RelatedArticleFeature* / LatestOnTopicBottom* / Outbrain(.ob-rec-image)。\n# hero 头图在 NewsArticleHeaderImage 内,不在排除范围 → 保留。\nexcludeImageSelector: '[class*=\"RelatedArticle\"], [class*=\"RelatedArticleFeature\"], [class*=\"LatestOnTopicBottom\"], .ob-rec-image'\nimageMinWidth: 200\nexcludeImage:\n - logo\n - icon\n - avatar\n - profile\n - placeholder\n - outbrainimg.com\ntitleStripSuffix: ' - Nikkei Asia$'\nmaxBodyChars: 15000\n---\n# 日经亚洲世界 抓取备忘\n\n## 列表页链接收集(`/location`)\n\n- **listSelector 用复合卡片选择器**:`[class*=\"StreamArticleCard\"], [class*=\"SpotlightArticleCard\"], [class*=\"SecondaryArticleCard\"]`。实测 `/location` 页面文章卡片分散在这三类容器里(StreamArticleCard 26 篇 + SpotlightArticleCard 3 篇 + SecondaryArticleCard 3 篇 = 32 篇)。\n- 与 nikkei-tech 同属 Nikkei 模板,卡片容器类型一致,复用同一复合选择器。\n- **urlPattern** 覆盖所有可能的文章路径段:`business`、`spotlight`、`economy`、`politics`、`editor-s-picks`、`location`、`opinion`、`features`、`life-arts`、`techasia`。第三段 `(?:/.+)?` 可选——Nikkei 的 URL 结构不统一:`/business/markets/currencies/<slug>` 是 3 段,但 `/opinion/<slug>`、`/economy/<slug>`、`/politics/<slug>` 是 2 段。2 段 URL 会混入分类页(如 `/business/markets`),由 `validateArticle: true` 在 fetchPage 阶段剔除。\n- **excludeUrlPattern** 剔除 `/podcasts/`、`/static/`、`/infographics/`、`/member/` 等非文章路径。\n- **validateArticle: true** 在 fetchPage 阶段用 og:type + JSON-LD + 正文字符数剔除分类页(卡片 tag 链接如 `/business/markets/commodities` 会被判 isArticle=false 并跳过)。\n- `scrollSteps: 3` 触发懒加载;Nikkei 用 `?page=N` 分页,query strip 自动排除分页链接。\n\n## 详情页正文验证\n\n`validateArticle: true` 让 `fetchPage` 采集判别信号,返回 `isArticle` 布尔;harvest 对 `isArticle === false` 直接 skip:\n\n```\nisArticle = (og:type === 'article' || JSON-LD @type ∈ {NewsArticle, Article})\n && bodyChars >= 500\n && bodyParagraphs >= 3\n```\n\n## 其他\n\n- 正文优先 `[class*=\"ArticleBody\"]`(Nikkei CSS Modules 正文容器)。\n- 部分文章有付费墙,bodyMinChars=500 + bodyMinParagraphs=3 过滤被截断到极短的页面。\n"
}
}
\ No newline at end of file
{
"format": "news-harvester-source",
"version": 1,
"exportedAt": "2026-08-05T06:40:26.452Z",
"source": {
"id": "reuters-business",
"name": "路透社商业",
"homepageUrl": "https://www.reuters.com/business/",
"method": "browser",
"vaultFolder": "路透社/商业"
},
"files": {
"helpers/reuters-business.md": "---\nsource: reuters-business\nlistSelector: 'div[data-testid=\"Title\"]'\nlinkSelector: 'a[href]'\nurlPattern: 'reuters\\.com/.+-\\d{4}-\\d{2}-\\d{2}/?$'\nexcludeUrlPattern: 'reuters\\.com/(podcasts|newsletter)/'\nscrollSteps: 3\nscrollWaitMs: 2000\nwaitMs: 15000\nvalidateArticle: true\nbodyMinChars: 500\nbodySelectors:\n - '[data-testid^=\"paragraph\"]'\n - '[class*=\"articleBodyContent\"] p'\n - '[class*=\"article-body\"] p'\n - 'article p'\nbodyMinParagraphs: 3\ndateSelector: 'time[datetime]'\nauthorSelector: '[rel=\"author\"], [class*=\"author\"] a'\nimageSelector: 'img'\nimageMinWidth: 200\nexcludeImage:\n - logo\n - icon\n - avatar\n - profile\ntitleStripSuffix: ' \\| Reuters$'\nmaxBodyChars: 15000\n---\n# Reuters Business 抓取备忘\n\n## 列表页链接收集(`/business/`)\n\n- **listSelector: `div[data-testid=\"Title\"]`**。实测全页 20 篇文章链接全部在此容器内(单一容器覆盖全部,无分散问题)。该 data-testid 比 CSS Modules 类名稳定(hash 不变)。\n- **urlPattern: `reuters\\.com/.+-\\d{4}-\\d{2}-\\d{2}/?$`**。Reuters 文章 URL 以 `-YYYY-MM-DD/` 结尾,日期后缀是极强的文章标识——分类页(`/business/`、`/business/finance/`)无日期后缀。放宽为全站匹配(不限 `/business/`),因为板块页会推荐其他分区的文章(如 `/world/asia-pacific/...`)。\n- **excludeUrlPattern** 排除 `/podcasts/`、`/newsletter/`(它们也带日期但非正文报道)。\n- `scrollSteps: 3` 触发懒加载拿全当天列表;首屏仅 ~10 篇。\n\n## 详情页正文验证\n\n`validateArticle: true` 让 fetchPage 用 `og:type` + JSON-LD `@type` + 正文长度剔除分类页。实测信号:\n- 文章:`og:type=article`、`@type=NewsArticle`、正文 4000+ 字符\n- 分类页 `/business/`:`og:type=website`、`@type=CollectionPage`、正文 0\n\nReuters 的日期后缀 URL 已能区分大部分文章/分类页,validateArticle 作为兜底防止非标准 URL 混入。\n\n## 其他\n\n- 反爬:headless 会被拦,需 `config.chromium.headless: false`。\n- 正文优先 `[data-testid^=\"paragraph\"]`,旧 `article p` 会被\"相关阅读\"\"订阅推广\"段落污染。\n"
}
}
\ No newline at end of file
{
"format": "news-harvester-source",
"version": 1,
"exportedAt": "2026-08-05T06:40:26.498Z",
"source": {
"id": "reuters-markets",
"name": "路透社市场",
"homepageUrl": "https://www.reuters.com/markets/",
"method": "browser",
"vaultFolder": "路透社/市场"
},
"files": {
"helpers/reuters-markets.md": "---\nsource: reuters-markets\nlistSelector: '[data-testid=\"MediaStoryCard\"], [data-testid=\"BasicCard\"], [data-testid=\"HeroCard\"], [data-testid=\"HubCard\"], [data-testid=\"AuthorCard\"], [data-testid=\"OurColumnists\"]'\nlinkSelector: 'a[href]'\nurlPattern: 'reuters\\.com/.+-\\d{4}-\\d{2}-\\d{2}/?$'\nexcludeUrlPattern: 'reuters\\.com/(podcasts|newsletter)/'\nscrollSteps: 3\nscrollWaitMs: 2000\nwaitMs: 15000\nvalidateArticle: true\nbodyMinChars: 500\nbodySelectors:\n - '[data-testid^=\"paragraph\"]'\n - '[class*=\"articleBodyContent\"] p'\n - '[class*=\"article-body\"] p'\n - 'article p'\nbodyMinParagraphs: 3\ndateSelector: 'time[datetime]'\nauthorSelector: '[rel=\"author\"], [class*=\"author\"] a'\nimageSelector: 'img'\nimageMinWidth: 200\nexcludeImage:\n - logo\n - icon\n - avatar\n - profile\ntitleStripSuffix: ' \\| Reuters$'\nmaxBodyChars: 15000\n---\n# Reuters Markets 抓取备忘\n\n## 列表页链接收集(`/markets/`)\n\n- **listSelector 用复合 `data-testid` 选择器**:`MediaStoryCard`、`BasicCard`、`HeroCard`、`HubCard`、`AuthorCard`、`OurColumnists` 六类卡片容器。实测全页 20 篇文章链接分散在这六类容器里(`MediaStoryCard` 18 + `BasicCard` 12 + `HeroCard`/`HubCard`/`AuthorCard`/`OurColumnists` 各 2-3,去重后 20)。\n- **为什么不用旧 `div.media-story-card-module__placement-container__1ZSB1`**:旧选择器依赖带 hash 的 CSS Modules 类名,hash 随构建变化 → 过期后只匹配 9 篇,漏掉 11 篇。`data-testid` 无 hash,稳定得多。Markets 页面布局与 Business/World 不同(`div[data-testid=\"Title\"]` 在此页 0 篇),必须用 Markets 专属的复合容器。\n- **urlPattern: `reuters\\.com/.+-\\d{4}-\\d{2}-\\d{2}/?$`**。Reuters 文章 URL 以 `-YYYY-MM-DD/` 结尾,日期后缀是极强的文章标识——分类页(`/markets/`、`/markets/currencies/`)无日期后缀。放宽为全站匹配,因为 Markets 板块会推荐其他分区文章(如 `/world/asia-pacific/...`、`/business/energy/...`、`/commentary/...`)。\n- **excludeUrlPattern** 排除 `/podcasts/`、`/newsletter/`(它们也带日期但非正文报道)。\n- `scrollSteps: 3` 触发懒加载拿全当天列表。\n\n## 详情页正文验证\n\n`validateArticle: true` 让 fetchPage 用 `og:type` + JSON-LD `@type` + 正文长度剔除分类页。实测信号:\n- 文章:`og:type=article`、`@type=NewsArticle`、正文 4000+ 字符\n- 分类页 `/markets/`:`og:type=website`、正文 0\n\nReuters 的日期后缀 URL 已能区分大部分文章/分类页,validateArticle 作为兜底防止非标准 URL 混入。\n\n## 其他\n\n- 反爬:headless 会被拦,需 `config.chromium.headless: false`。\n- 正文优先 `[data-testid^=\"paragraph\"]`,旧 `article p` 会被\"相关阅读\"\"订阅推广\"段落污染。\n"
}
}
\ No newline at end of file
{
"format": "news-harvester-source",
"version": 1,
"exportedAt": "2026-08-05T06:40:26.534Z",
"source": {
"id": "reuters-world",
"name": "路透社世界",
"homepageUrl": "https://www.reuters.com/world/",
"method": "browser",
"vaultFolder": "路透社/世界"
},
"files": {
"helpers/reuters-world.md": "---\nsource: reuters-world\nlistSelector: 'div[data-testid=\"Title\"]'\nlinkSelector: 'a[href]'\nurlPattern: 'reuters\\.com/.+-\\d{4}-\\d{2}-\\d{2}/?$'\nexcludeUrlPattern: 'reuters\\.com/(podcasts|newsletter)/'\nscrollSteps: 3\nscrollWaitMs: 2000\nwaitMs: 15000\nvalidateArticle: true\nbodyMinChars: 500\nbodySelectors:\n - '[data-testid^=\"paragraph\"]'\n - '[class*=\"articleBodyContent\"] p'\n - '[class*=\"article-body\"] p'\n - 'article p'\nbodyMinParagraphs: 3\ndateSelector: 'time[datetime]'\nauthorSelector: '[rel=\"author\"], [class*=\"author\"] a'\nimageSelector: 'img'\nimageMinWidth: 200\nexcludeImage:\n - logo\n - icon\n - avatar\n - profile\ntitleStripSuffix: ' \\| Reuters$'\nmaxBodyChars: 15000\n---\n# 路透社 World 抓取备忘\n\n## 列表页链接收集(`/world/`)\n\n- **listSelector: `div[data-testid=\"Title\"]`**。实测全页 19 篇文章链接全部在此容器内(单一容器覆盖全部,无分散问题)。该 data-testid 比 CSS Modules 类名稳定。\n- **urlPattern: `reuters\\.com/.+-\\d{4}-\\d{2}-\\d{2}/?$`**。Reuters 文章 URL 以 `-YYYY-MM-DD/` 结尾,日期后缀是极强的文章标识——分类页(`/world/`、`/world/asia-pacific/`)无日期后缀。放宽为全站匹配(不限 `/world/`),因为板块页会推荐其他分区的文章(如 `/business/environment/...`、`/legal/government/...`)。\n- **excludeUrlPattern** 排除 `/podcasts/`、`/newsletter/`(它们也带日期但非正文报道)。\n- `scrollSteps: 3` 触发懒加载拿全当天列表。\n\n## 详情页正文验证\n\n`validateArticle: true` 让 fetchPage 用 `og:type` + JSON-LD `@type` + 正文长度剔除分类页。实测信号:\n- 文章:`og:type=article`、`@type=NewsArticle`、正文 6000+ 字符\n- 分类页 `/world/`:`og:type=website`、正文 0\n\nReuters 的日期后缀 URL 已能区分大部分文章/分类页,validateArticle 作为兜底防止非标准 URL 混入。\n\n## 其他\n\n- 反爬:headless 会被拦,需 `config.chromium.headless: false`。\n- 正文优先 `[data-testid^=\"paragraph\"]`,旧 `article p` 会被\"相关阅读\"\"订阅推广\"段落污染。\n"
}
}
\ No newline at end of file
{
"name": "news-harvester",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "news-harvester",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"js-yaml": "^5.2.2",
"ws": "^8.19.0"
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/js-yaml": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.3.tgz",
"integrity": "sha512-n+mUVyUX5bVv7G/G2zyIHOhdxfuU1dY2NOFzTQUWiMUbFss8b57NFlgCCaggU78wSw5KVS9cllzeLyzyR+n5nw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.mjs"
}
},
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}
{
"name": "news-harvester",
"version": "1.0.0",
"description": "News harvester — install, harvest, archive, push",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"js-yaml": "^5.2.2",
"ws": "^8.19.0"
}
}
{
"vaultPath": "/path/to/your/obsidian/vault",
"registryWindowDays": 7,
"push": {
"enabled": false,
"endpoint": "http://localhost:8080/api/v1",
"appId": "",
"appKeyId": "",
"appSecret": "",
"batchSize": 10,
"autoPushAfterFinalize": true,
"retry": { "maxAttempts": 5, "baseDelayMs": 1000 }
},
"chromium": {
"cdpPort": 9222,
"headless": true,
"executablePath": null,
"userDataDir": null
},
"sources": []
}
# Document Formats (reference)
These docs are **generated by the scripts**, not hand-written by the agent —
shown here for debugging or when reading archived files. The agent only writes
`.summaries.json`; `finalize.js` produces the Chinese summary docs and patches
the originals.
## Original Doc (written by `harvest.js`)
Filename: `<英文标题> - <SourceName>原文.md`
Between Step 1 (harvest) and Step 3 (finalize), the Chinese-title links in this file are a **sentinel placeholder** `__SUMMARY_<slug>__` — in both the frontmatter `related` field and the footer wikilink. `finalize.js` replaces them with the real `titleZh`, and overwrites the `tags` block + `category` line with the agent-extracted values. Image captions: only rendered when `alt` text exists (empty alt → bare embed, no `**`).
```markdown
---
title: "<英文标题>"
date: <ISO 8601 published timestamp, e.g. 2026-07-30T21:47:28Z>
tags: # agent-extracted (≤3); placeholder [source] until finalize
- <tag1>
- <tag2>
category: <分类> # agent-supplied; overrides the script's keyword guess
source: "<url>"
publisher: <source>
authors: "<authors>"
type: 原文
related: "[[<中文标题>]]" # sentinel __SUMMARY_<slug>__ until finalize
saved: YYYY-MM-DD
---
# <英文标题>
> [!info] Source
> **Published:** <Shanghai time, e.g. 2026-07-31 05:47>
> **URL:** <url>
> **中文摘要:** [[<中文标题>]]
---
## 配图
![[assets/<slug>-1.jpg]]
*<alt text — omitted if empty>*
---
## 正文
<full article body>
---
[[<中文标题>]]
```
## Chinese Summary Doc (written by `finalize.js`)
File: `<dayDir>/summary/<中文标题>.md` (concise title, ≤30 chars). The `summary/` subdirectory is a sibling of `assets/`. Wikilinks from 原文.md and index.md use the bare title `[[<中文标题>]]` — Obsidian resolves them by basename.
```markdown
---
title: <中文标题>
date: <ISO 8601 published timestamp, e.g. 2026-07-30T21:47:28Z>
tags: # agent-extracted from the summary/body (≤3)
- <tag1>
- <tag2>
category: <分类> # agent-supplied; enables Obsidian property/Bases filtering
source: "<original URL>"
publisher: <source name>
authors: "<author names>"
type: 摘要
related: "[[<英文标题> - <SourceName>原文]]"
saved: YYYY-MM-DD
---
# <中文标题>
> [!info] 文章信息
> **发布:** <Shanghai time, e.g. 2026-07-31 05:47>
> **来源:** <source>
> **作者:** <authors>
> **原文:** [[<英文标题> - <SourceName>原文]]
---
## 事件概述
<2-3 sentences summary in Chinese>
## 背景
<context>
## 关键引语
> "<translated quote>"
## 影响分析
<analysis>
---
[[<英文标题> - <SourceName>原文]]
```
## index.md Format
File: `<vaultFolder>/YYYYMMDD/index.md` (written by `harvest.js`, rewritten by `finalize.js`)
Image asset count is a **real file count** of `assets/` (not "articles with images"), so it reflects downloaded files even before finalize.
```markdown
---
date: YYYY-MM-DD
type: daily-index
source: <source name>
total_articles: N
last_updated: "<ISO timestamp>"
---
# <SourceName> Daily Index · YYYY-MM-DD
> **最近更新:** YYYY-MM-DD HH:MM CST
> **当日归档:** N 篇 | **图片资产:** N 张
## 📊 分类统计
| 分类 | 数量 |
|------|------|
| ... | ... |
## 📋 文章列表
| # | 标题 | 原文标题 | 分类 | 标签 | 发布时间 | 状态 |
|---|------|----------|------|------|----------|------|
| 1 | [[中文标题]] | English Title | ... | tag1, tag2 | YYYY-MM-DD | ✅ |
> 发布时间列是紧凑 YYYY-MM-DD(本地时区),便于表格浏览;frontmatter `date:` 存完整 ISO 8601 时间戳(无损,Obsidian 可按任意时区解析)。
## 🗂️ 分类导航
### 分类1(N篇)
- [[文章1]]
```
## registry.json Format
File: `<vaultFolder>/YYYYMMDD/registry.json` (written incrementally by `harvest.js`, patched by `finalize.js`)
`titleZh` is the sentinel `__SUMMARY_<slug>__` until finalize supplies the real title.
```json
{
"date": "YYYYMMDD",
"source": "<source_id>",
"articles": [
{
"url": "https://...",
"titleZh": "中文标题",
"titleEn": "English Title",
"date": "2026-07-24T14:30:00.000Z",
"processedAt": "2026-07-24T15:00:00.000Z",
"category": "地缘政治",
"tags": ["tag1", "tag2"],
"slug": "theconversation-com-some-article-286430",
"originalFilename": "Some Article - The Conversation原文.md"
}
]
}
```
# News Harvester · Push API 开发文档
> 将本地已归档的新闻数据产物(Obsidian vault 中 `finalize.js` 产出的目录)推送到远程后端服务的 API 设计规范。
> 包含:数据产物分析、请求 Schema、远程同步策略、CLI 集成方案。
---
## 1. 数据产物分析(Push 的数据源)
`finalize.js` 执行完毕后,一个"已完成、可推送"的 day 目录 `<vaultFolder>/YYYYMMDD/` 结构如下:
```
路透社 World/20260731/
├── registry.json ← 当日文章结构化索引(权威元数据)
├── index.md ← 当日索引(可不强同步,后端可重建)
├── <英文标题> - <Source>原文.md ← 原文(每篇一个)
├── summary/
│ └── <中文标题>.md ← 中文摘要(每篇一个,四段式)
└── assets/
└── <slug>-N.jpg ← 下载的配图
```
**关键标志**:临时文件 `.manifest.json``.summaries.json``finalize.js` 删除 = 该 day 目录已最终化、可推送。若二者仍存在 → 未完成,不应推送。
### 1.1 registry.json(结构化元数据,权威来源)
```json
{
"date": "20260731",
"source": "reuters-business",
"articles": [
{
"url": "https://www.reuters.com/business/autos-transportation/teslas-china-operations-...",
"titleZh": "特斯拉中国业务:全球生产核心",
"titleEn": "Tesla's China operations, the EV maker's global production powerhouse",
"date": "2026-07-31T03:53:43.649Z",
"processedAt": "2026-07-31T04:15:38.683Z",
"category": "中美关系",
"tags": ["特斯拉", "上海工厂", "供应链"],
"slug": "autos-transportation-teslas-china-operations-ev-makers-globa",
"originalFilename": "Tesla's China operations... - Reuters Business原文.md"
}
]
}
```
### 1.2 原文.md(frontmatter + 正文全文)
frontmatter 字段:`title`(英文) / `date`(发布ISO) / `tags` / `category` / `source`(url) / `publisher` / `authors` / `type:原文` / `related`(摘要 wikilink) / `saved`(归档日期)。正文含 `## 配图`(图片 embed)+ `## 正文`(英文全文)。
### 1.3 summary/<中文标题>.md(四段式中文摘要)
frontmatter 同上但 `title`=中文、`type:摘要``related` 指向原文。正文为固定四段:`## 事件概述` / `## 背景` / `## 关键引语` / `## 影响分析`
### 1.4 推送单元
- **原子单元 = 单篇文章** = (元数据 + 原文 + 摘要 + 配图)。一篇 url 对应一篇文章,天然唯一。
- **批量单元 = 一个 day 目录** = 同 source 同一天的多篇文章。
- API 以「文章」为原子、支持「批量提交」对齐上述结构。
---
## 2. 总体架构与同步模型
```
本地 harvester (vault) 远程后端服务
┌──────────────────────────┐ ┌──────────────────────┐
│ finalize.js 产出 day 目录 │ │ POST /articles/batch │
│ │ │ ① 上传配图 │ POST /assets │
│ ▼ │ ──────────────▶ │ │ │
│ push.js (新增) │ ② 批量推送文章 │ ▼ │
│ ├ 读 registry.json │ ──────────────▶ │ upsert (sourceId,url)│
│ ├ 读 原文.md/摘要.md │ │ 去重 + 版本冲突处理 │
│ ├ 上传 assets │ ③ 返回 per-article 结果
│ └ 写 .syncstate.json │ ◀────────────── │ {created/updated/...}│
└──────────────────────────┘ └──────────────────────┘
```
**两阶段提交**(推荐):
1. **阶段一·配图上传**`POST /assets`(multipart)逐张上传,返回 `assetId`。按内容哈希去重,重推不重复上传。
2. **阶段二·文章批量推送**`POST /articles/batch`(JSON),文章内引用阶段一拿到的 `assetId`
> 两阶段分离的原因:图片是二进制、体积大、可独立去重;文章是结构化 JSON、需事务性 upsert。混在单请求里(base64 内联)会膨胀 payload 且无法独立去重图片。若后端不愿存图,可改为只传图片原始 URL(`originalSrc`),见 §6.2。
---
## 3. 鉴权
| 项 | 设计 |
|----|------|
| 方式 | `Authorization: Bearer <API_KEY>`(CLI 场景最简;亦可 `X-API-Key` 头) |
| 配置 | API key 与 endpoint 写入 `config.json` 新增字段 `push`(见 §7) |
| 错误 | 401 key 无效/过期 → CLI 报错退出,不重试 |
> **假设**:后端为受信内部服务,单一 API key 即可。若需多用户/多租户,改用 OAuth2 client_credentials,不影响本文其余 schema。
---
## 4. 端点总览
| 方法 | 路径 | 用途 |
|------|------|------|
| `POST` | `/api/v1/assets` | 上传单张配图,返回 `assetId`(按 sha256 去重) |
| `POST` | `/api/v1/articles/batch` | 批量 upsert 文章(一个 day 目录的多篇) |
| `GET` | `/api/v1/articles:lookup` | (可选)按 url 查询服务端版本,用于增量比对 |
---
## 5. 请求 Schema
### 5.1 `POST /api/v1/assets` — 配图上传
```
Content-Type: multipart/form-data
Authorization: Bearer <API_KEY>
X-Idempotency-Key: <uuid> # 可选;同 key 重试返回缓存结果
```
| part | 类型 | 说明 |
|------|------|------|
| `file` | binary | 图片二进制(`.jpg`) |
| `filename` | text | 原始文件名,如 `autos-transportation-teslas-...-1.jpg` |
| `slug` | text | 所属文章 slug(便于关联) |
| `alt` | text | 图注(可空) |
| `originalSrc` | text | 图片原始 URL(存档留档) |
**响应** `200 OK`
```json
{
"assetId": "ast_01HXYZ...",
"sha256": "9f2a...",
"url": "https://cdn.example.com/assets/ast_01HXYZ....jpg",
"deduplicated": false
}
```
> `deduplicated: true` 表示该哈希已存在,服务端复用既有 asset,未重复存储。
---
### 5.2 `POST /api/v1/articles/batch` — 文章批量推送
```
Content-Type: application/json
Authorization: Bearer <API_KEY>
Idempotency-Key: <uuid> # 整批重试幂等键,服务端缓存 24h
```
#### 5.2.1 请求体(Batch 包装)
```jsonc
{
"sourceId": "reuters-business",
"sourceName": "Reuters Business",
"harvestDate": "20260731", // day 目录名
"clientBatchId": "<uuid>", // 客户端本次推送批次 id
"articles": [ /* Article 对象数组,见 5.2.2,建议 ≤10/批 */ ]
}
```
#### 5.2.2 Article 对象(核心 Schema)
字段映射自 `registry.json` 条目 + 两个 markdown 文档。顶层结构化字段为索引权威,`markdown` 为忠实归档。
```jsonc
{
// ── 标识与去重(幂等键 = sourceId + url)──
"sourceId": "reuters-business",
"url": "https://www.reuters.com/business/autos-transportation/teslas-china-operations-...",
"slug": "autos-transportation-teslas-china-operations-ev-makers-globa",
// ── 元数据(来自 registry / frontmatter)──
"titleEn": "Tesla's China operations, the EV maker's global production powerhouse",
"titleZh": "特斯拉中国业务:全球生产核心",
"publishedAt": "2026-07-31T03:53:43.649Z", // registry.date / frontmatter.date(ISO 8601)
"processedAt": "2026-07-31T04:15:38.683Z", // registry.processedAt(采集处理时间)
"harvestDate": "20260731", // 归档日(frontmatter.saved / day 目录名)
"category": "中美关系",
"tags": ["特斯拉", "上海工厂", "供应链"],
"authors": "Sam Nussey",
"publisher": "Reuters Business",
// ── 原文文档 ──
"originalDoc": {
"filename": "Tesla's China operations... - Reuters Business原文.md",
"markdown": "<完整 原文.md 内容:frontmatter + 配图 + 正文全文>"
// 注:frontmatter 与顶层元数据重复(设计如此,保证归档忠实性)
},
// ── 中文摘要文档 ──
"summaryDoc": {
"filename": "特斯拉中国业务:全球生产核心.md",
"markdown": "<完整 摘要.md 内容:frontmatter + 四段式正文>",
"sections": { // 可选:解析后的四段,便于后端检索/分面
"overview": "特斯拉上海工厂是其全球产能核心……",
"background": "……",
"keyQuote": "……",
"impact": "……"
}
},
// ── 配图引用(assetId 来自阶段一上传)──
"assets": [
{
"filename": "autos-transportation-teslas-...-1.jpg",
"assetId": "ast_01HXYZ...",
"alt": "Illustration shows Sony logo",
"originalSrc": "https://www.reuters.com/graphics/..."
}
]
}
```
#### 5.2.3 字段规约
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `sourceId` | string | ✅ | 来源 id(config.json sources[].id),幂等键组成 |
| `url` | string | ✅ | 文章原始 URL,**主去重键**,全局唯一 |
| `slug` | string | ✅ | URL 派生 slug,辅助去重(与 harvester 本地 registry 一致) |
| `titleEn` | string | ✅ | 英文标题 |
| `titleZh` | string | ✅ | 中文标题(非 sentinel 占位符) |
| `publishedAt` | string(ISO) | ✅ | 文章发布时间 |
| `processedAt` | string(ISO) | ✅ | 采集处理时间,**冲突裁决依据** |
| `harvestDate` | string(YYYYMMDD) | ✅ | 归档日 |
| `category` | string | ✅ | 分类(如 中美关系/财经/科技-AI) |
| `tags` | string[] | ✅ | 标签(≤3) |
| `authors` | string | ❌ | 作者,可空 |
| `publisher` | string | ✅ | 来源名(= sourceName) |
| `originalDoc.markdown` | string | ✅ | 原文完整 markdown |
| `originalDoc.filename` | string | ❌ | 原文文件名(留档) |
| `summaryDoc.markdown` | string | ✅ | 摘要完整 markdown |
| `summaryDoc.filename` | string | ❌ | 摘要文件名 |
| `summaryDoc.sections` | object | ❌ | 解析后的四段(可选,便于检索) |
| `assets[]` | array | ❌ | 配图引用数组,可空 |
| `assets[].assetId` | string | ❌* | 阶段一返回的 assetId(*若 assets 非空则必填) |
> **markdown vs 纯正文**:默认推送完整 markdown(含 frontmatter)以保归档忠实。若后端希望精简,可只推 `## 正文` / 四段正文 body,frontmatter 信息已由顶层字段承载。本文默认推完整 markdown。
---
## 6. 响应 Schema
### 6.1 批量推送响应 `200 OK`
```jsonc
{
"batchId": "bk_01HXYZ...", // 服务端批次 id
"clientBatchId": "<uuid>", // 回显客户端批次 id
"summary": { "created": 3, "updated": 5, "unchanged": 0, "failed": 0, "total": 8 },
"results": [
{
"url": "https://www.reuters.com/business/autos-transportation/teslas-...",
"slug": "autos-transportation-teslas-...",
"status": "created", // created | updated | unchanged | error
"articleId": "art_01HXYZ...", // 服务端文章 id(error 时缺省)
"version": 1, // 服务端版本号(每次 update +1)
"error": null // error 时为 { code, message }
}
]
}
```
- `created`:新文章,服务端首次入库。
- `updated`:已存在(sourceId+url 命中),且 `processedAt` 更新或内容变化 → 覆盖。
- `unchanged`:已存在且 `processedAt` 与服务端一致、内容未变 → 跳过(节省写入)。
- `error`:单篇失败(如摘要缺失),**不中断整批**,其余继续。
### 6.2 资源策略选项(图片)
| 策略 | 说明 | 适用 |
|------|------|------|
| **A. 后端存图(推荐)** | 阶段一上传图片,文章引用 `assetId` | 后端有对象存储,希望脱离源站 |
| **B. 仅存 URL** | 跳过阶段一,`assets[].originalSrc` 直接存原始 URL,无 `assetId` | 后端不存图,展示时回源(源站失效则图丢) |
| **C. 混合** | 上传图但仍保留 `originalSrc` | 兼顾存档与溯源 |
本文 schema 同时支持 A/B/C:`assets[].assetId` 可空(B)、`originalSrc` 始终携带。
---
## 7. 远程同步策略
### 7.1 增量与可推送判定
- **可推送条件**:day 目录内 `registry.json` 存在,且 `.manifest.json` / `.summaries.json` **不存在**(= finalize 已完成),且任一文章 `titleZh``__SUMMARY_*__` sentinel。
- **已同步判定**:day 目录内存在 `.syncstate.json`(见 7.3),记录全部 url 的 `status: synced`
- **增量**`push.js --all` 扫描所有 source 的 day 目录,仅推送「可推送且未同步」的。
### 7.2 去重与幂等
| 层级 | 键 | 机制 |
|------|-----|------|
| 服务端文章去重 | `(sourceId, url)` | upsert:存在则 update,不存在则 create |
| 请求级幂等 | `Idempotency-Key` 头 | 同 key 24h 内重试返回首次结果,不重复副作用 |
| 批内重复 | 同 batch 内相同 url | 服务端取最后一条,返回 `unchanged``updated` |
| 图片去重 | `sha256` | 同哈希复用 asset,`deduplicated: true` |
| 本地 dedup | harvester 已有 7 天滑动窗口 registry | 保证本地不重复采集,推送层不再处理 |
### 7.3 本地同步账本 `.syncstate.json`
每个 day 目录一份,记录推送状态,支持断点续传与重推。
```jsonc
{
"harvestDate": "20260731",
"sourceId": "reuters-business",
"lastSyncedAt": "2026-07-31T12:30:00Z",
"lastSyncStatus": "partial", // success | partial | failed | pending
"serverBatchId": "bk_01HXYZ...",
"articles": [
{
"url": "https://www.reuters.com/...",
"slug": "autos-...",
"status": "synced", // synced | error | pending
"serverArticleId": "art_01HXYZ...",
"version": 1,
"syncedAt": "2026-07-31T12:30:00Z",
"error": null
}
]
}
```
- 仅当所有文章 `status: synced``lastSyncStatus: success`
- `push.js` 重跑时跳过已 `synced` 的 url,只重试 `error`/`pending`
- `--force` 忽略账本,全量重推(用于摘要修正后强制覆盖)。
### 7.4 冲突裁决
- 服务端按 `processedAt`**Last-Write-Wins**:客户端 `processedAt` > 服务端 → 覆盖;< → `unchanged`;= → 比较内容哈希,变则 update 否则 unchanged。
- 进阶可选:服务端返回 `version`,客户端下次推送带 `If-Match: <version>` 做乐观锁;不匹配返回 `409`。MVP 用 LWW 即可。
### 7.5 重试与容错
| 场景 | 策略 |
|------|------|
| 网络错误 / 5xx | 指数退避重试:1s → 2s → 4s → 8s → 16s,最多 5 次。`Idempotency-Key` 保证安全重试 |
| 401 / 403 | 不重试,报错退出(key 问题) |
| 400 / 422 | 不重试,记录到账本 `error`,继续下一篇 |
| 409(版本冲突) | 不重试,标记 `error`,人工介入或 `--force` |
| 413(payload 过大) | 缩小批量(articles/批 减半)后重试 |
| 单篇失败 | 不中断整批;失败项写入账本,下次重跑只重试这些 |
| 阶段一图片失败 | 该图标记失败,文章仍可推送(`assets` 中缺该项),不阻塞;可后补 |
### 7.6 批量切分
- 单批 `articles` 建议 ≤10 篇(控制 JSON 体积,原文全文可能数 KB~数十 KB/篇)。
- `push.js` 自动分片:一个 day 目录有 N 篇 → ⌈N/10⌉ 批,逐批发送,每批独立 `Idempotency-Key`
- 图片上传并发:阶段一可并发上传(如 3 并发),但需限速避免被源站/后端限流。
### 7.7 同步频率
- **事件驱动(推荐)**`finalize.js` 完成后立即 `push.js <dayDir>`。可在 finalize 末尾自动链式调用。
- **定时批量**:cron 每日固定时间 `push.js --all`,兜底漏推的 day 目录。
- 二者可共存:实时推 + 定时兜底。
---
## 8. 错误码
| HTTP | 含义 | 客户端动作 |
|------|------|-----------|
| 200 | 批量成功(含单篇 error) | 读 results 更新账本 |
| 400 | 请求体格式错误 | 不重试,修正后重发 |
| 401 | 未鉴权 / key 无效 | 不重试,检查 config |
| 413 | payload 过大 | 缩小批量重试 |
| 422 | 语义错误(如缺 titleZh) | 不重试,记录 error |
| 409 | 版本冲突(用乐观锁时) | 标记 error,人工介入 |
| 5xx | 服务端错误 | 指数退避重试 |
---
## 9. CLI 集成:`scripts/push.js`(待实现)
对齐现有 CLI 架构,新增推送脚本。
```
node scripts/push.js <dayDir> # 推送单个 day 目录
node scripts/push.js <dayDir> --force # 忽略账本,强制全量重推
node scripts/push.js --all # 扫描所有未同步的 day 目录并推送
node scripts/push.js <dayDir> --status # 查看同步状态
node scripts/push.js -h | --help
```
**工作流**
1. 校验 day 目录已最终化(无 `.manifest.json`/`.summaries.json`)。
2.`registry.json` → 文章列表;读 `.syncstate.json` → 过滤已 synced。
3. 对每篇文章:读 `原文.md` + `summary/<titleZh>.md` + `assets/*`
4. 阶段一:上传未同步的图片(按 sha256 去重)→ 收集 `assetId`
5. 阶段二:按 ≤10/批切分 → `POST /articles/batch`(带 `Idempotency-Key`)。
6.`results` 更新 `.syncstate.json`;失败项保留待重试。
7. 输出摘要到 stderr,stdout 输出 JSON 结果(便于 agent/脚本消费)。
### 9.1 config.json 扩展
```jsonc
{
"push": {
"enabled": true,
"endpoint": "https://api.example.com/api/v1",
"apiKey": "<KEY>", // 或从环境变量 NEWS_HARVESTER_API_KEY 读取
"batchSize": 10, // 每批文章数
"imageStrategy": "upload", // upload(A) | url-only(B) | hybrid(C)
"autoPushAfterFinalize": true, // finalize 后自动推送
"retry": { "maxAttempts": 5, "baseDelayMs": 1000 }
}
}
```
---
## 10. 完整请求示例
### 10.1 上传配图
```bash
curl -X POST https://api.example.com/api/v1/assets \
-H "Authorization: Bearer $KEY" \
-H "X-Idempotency-Key: $(uuidgen)" \
-F "file=@assets/autos-transportation-teslas-...-1.jpg" \
-F "filename=autos-transportation-teslas-...-1.jpg" \
-F "slug=autos-transportation-teslas-china-operations-ev-makers-globa" \
-F "alt=Illustration shows Sony logo" \
-F "originalSrc=https://www.reuters.com/graphics/..."
```
### 10.2 批量推送文章
```bash
curl -X POST https://api.example.com/api/v1/articles/batch \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d @payload.json
```
`payload.json`(单篇示例,实际为 articles 数组):
```json
{
"sourceId": "reuters-business",
"sourceName": "Reuters Business",
"harvestDate": "20260731",
"clientBatchId": "b1-20260731-0",
"articles": [{
"sourceId": "reuters-business",
"url": "https://www.reuters.com/business/autos-transportation/teslas-china-operations-...",
"slug": "autos-transportation-teslas-china-operations-ev-makers-globa",
"titleEn": "Tesla's China operations, the EV maker's global production powerhouse",
"titleZh": "特斯拉中国业务:全球生产核心",
"publishedAt": "2026-07-31T03:53:43.649Z",
"processedAt": "2026-07-31T04:15:38.683Z",
"harvestDate": "20260731",
"category": "中美关系",
"tags": ["特斯拉", "上海工厂", "供应链"],
"authors": "Sam Nussey",
"publisher": "Reuters Business",
"originalDoc": { "filename": "Tesla's China operations... - Reuters Business原文.md", "markdown": "---\ntitle: \"Tesla's China operations...\"\n..." },
"summaryDoc": { "filename": "特斯拉中国业务:全球生产核心.md", "markdown": "---\ntitle: \"特斯拉中国业务:全球生产核心\"\n...", "sections": { "overview": "...", "background": "...", "keyQuote": "...", "impact": "..." } },
"assets": [{ "filename": "autos-...-1.jpg", "assetId": "ast_01HXYZ", "alt": "...", "originalSrc": "https://..." }]
}]
}
```
---
## 11. 决策点(待确认)
| # | 决策 | 推荐 | 备选 |
|---|------|------|------|
| 1 | 鉴权方式 | Bearer API Key | OAuth2 client_credentials |
| 2 | 图片策略 | A:后端存图 | B:仅存 URL / C:混合 |
| 3 | markdown 范围 | 完整 markdown(含 frontmatter) | 仅正文 body(更精简) |
| 4 | 冲突裁决 | Last-Write-Wins by processedAt | 乐观锁 `If-Match` |
| 5 | 推送时机 | finalize 后自动 + cron 兜底 | 仅手动 / 仅 cron |
| 6 | 摘要四段解析 | 可选 `sections` 字段 | 仅存 markdown,后端自行解析 |
```
# Source Management
## List Sources
```bash
node scripts/source-manage.js list
```
```
ID NAME METHOD RECIPE STATUS
reuters-world 路透社世界 browser ✅ ✅ enabled
theconversation The Conversation direct — ❌ disabled
```
RECIPE ✅ = `helpers/<id>.md` exists (spec-driven fetcher); — = legacy fetcher.
## Edit Source
```bash
node scripts/source-manage.js edit <id> --name "New Name" --url https://... --method browser
```
Editable fields: `name`, `homepageUrl`, `method`, `articleUrlPattern`, `vaultFolder`, `enabled`.
## Enable / Disable Source
```bash
node scripts/source-manage.js enable <id>
node scripts/source-manage.js disable <id>
```
---
## Installing sources from bundles
Sources are added by installing `.nhsource.json` bundles produced by the **news-source-analyzer** skill. There is no `add` or `export` command in the harvester — use the analyzer to research and pack new sources.
```bash
node scripts/source-manage.js install <pkg.nhsource.json> [--force]
```
`install` writes the recipe + fixtures into `helpers/`, registers the source in `config.json` (enabled), and the source is immediately usable.
### Bundle format
A `.nhsource.json` is plain JSON:
| Field | Contents |
|-------|----------|
| `format` | `"news-harvester-source"` (install validates this) |
| `version` | `1` |
| `exportedAt` | ISO timestamp |
| `source` | `{ id, name, homepageUrl, method, vaultFolder }` — the config entry, minus `vaultPath`, `enabled`, and `articleUrlPattern` |
| `files` | Map of relative path → file content. Includes `helpers/<id>.md` (the recipe) and `helpers/<id>.fixtures.json` (if present). |
### Install behavior
- Writes every file in `files` into `helpers/` (path traversal guarded — only basenames under `helpers/` are written).
- Registers the source in `config.json` as enabled. If `<id>` already exists, the entry is overwritten in place; otherwise it's appended.
- **Conflict handling**: refuses if `<id>` is already in `config.json` or a helper file of the same name exists, unless `--force` is passed. `--force` overwrites the config entry and all helper files.
- **vaultPath caveat**: install succeeds even when `config.vaultPath` is still the placeholder (the source is registered, `--preview` works), but archiving requires a real vault path — install prints a warning in that case.
- After install, the source is immediately usable: `harvest.js <id> 3 --preview`.
Bundles carry no signature/checksum (personal-sharing scope). To vet a bundle before installing, open it in an editor and check the `source` block + the recipe frontmatter in `files["helpers/<id>.md"]`.
### Managing recipes
Recipes live in `helpers/` (installed from bundles, gitignored). Each installed source has a `helpers/<id>.md` file. `source-manage.js list` shows which sources have recipes (✅) vs legacy (—).
If you need to research or create a new recipe, use the **news-source-analyzer** skill — it has `inspect-source.js`, `preview.js`, `recipe-test.js`, and `pack.js` for the full recipe authoring workflow.
/**
* block-check.js — Detect human-verification (CAPTCHA / anti-bot challenge) and
* login-state-loss (auth redirect / paywall login wall) on fetched pages.
*
* When a block is detected, a HumanInterventionError is thrown so harvest.js can
* exit immediately with a friendly message instead of silently archiving empty
* or garbage articles. The check is intentionally conservative: it only fires on
* high-confidence signals (challenge iframes/titles, auth-host redirects, or a
* login-wall CTA paired with a near-empty body) to avoid false positives on
* legitimate articles.
*
* Two entry points:
* • Browser (CDP) sources — evaluateBrowserBlock(ws) runs an in-page probe via
* cdpEval and throws if blocked. Used by fetcher-helper.js / fetcher-browser.js.
* • Direct HTTP sources — checkDirectBlock(html, url) does a string scan of the
* raw HTML. Used by fetcher-direct.js (open sites rarely need login, but a
* Cloudflare-style challenge can still appear).
*/
// Sent to the page as a Runtime.evaluate expression. Serialized from a real
// function via .toString() so regex backslashes survive without double-escaping.
function _browserBlockProbe() {
var url = location.href, host = location.hostname;
var title = (document.title || '').trim();
var bodyText = (document.body && document.body.innerText) ? document.body.innerText.substring(0, 4000) : '';
var bodyLen = bodyText.length;
// 1. CAPTCHA / anti-bot challenge — iframes from known challenge providers.
var challengeIframe = Array.from(document.querySelectorAll('iframe[src]')).some(function (f) {
var s = f.src.toLowerCase();
return s.indexOf('recaptcha') !== -1 || s.indexOf('hcaptcha') !== -1
|| s.indexOf('challenges.cloudflare.com') !== -1
|| s.indexOf('turnstile') !== -1
|| s.indexOf('arkoselabs') !== -1 || s.indexOf('funcaptcha') !== -1;
});
// Challenge page titles (Cloudflare "Just a moment...", "Access Denied", etc.).
var challengeTitle = /^(just a moment|attention required|verify|checking your browser|are you human)/i.test(title)
|| /access denied|请输入验证码|安全验证/i.test(title);
// Challenge body copy.
var challengeText = /please verify (that )?you are human|verify you are human|unusual traffic from your computer|checking your browser before accessing|enable javascript and cookies to continue|our system has detected unusual traffic|请完成安全验证|进行人机验证/i.test(bodyText);
if (challengeIframe || challengeTitle || challengeText) {
return JSON.stringify({ blocked: true, kind: 'captcha', reason: '人机验证/反爬挑战页面', url: url, title: title });
}
// 2. Login-state loss — the article URL redirected to an auth/login host or
// path. A real article page is never served from accounts.* / login.* or a
// /signin path, so this is a high-confidence signal regardless of body length.
var authHost = /^(accounts\.|login\.|signin\.|auth\.|sso\.|chkpt\.)/i.test(host);
var authPath = /\/(signin|sign-in|login|auth|account\/login|subscribe|sso)\b/i.test(location.pathname + location.search);
if (authHost || authPath) {
return JSON.stringify({ blocked: true, kind: 'login', reason: '页面被重定向到登录/认证页面(登录状态可能已失效)', url: url, title: title });
}
// 3. Paywall / login wall — a login/subscribe CTA paired with a near-empty
// body (the page is essentially a gate, not an article). The CTA regex is
// specific ("... to continue/read") to avoid matching normal marketing copy.
var loginCta = /sign in to (continue|read)|log in to (continue|read)|subscribe to (continue|read|keep)|以继续阅读|登录后查看|订阅后查看|登录以继续|登录后可继续阅读/i.test(bodyText);
if (loginCta && bodyLen < 800) {
return JSON.stringify({ blocked: true, kind: 'login', reason: '检测到登录/订阅墙(正文极短且出现登录/订阅提示)', url: url, title: title });
}
return JSON.stringify({ blocked: false, kind: null, reason: '', url: url, title: title });
}
const BROWSER_CHECK_EXPR = '(' + _browserBlockProbe.toString() + ')()';
// Custom error — harvest.js checks `e.name === 'HumanInterventionError'` (or
// `instanceof`) to distinguish a block from an ordinary fetch failure.
class HumanInterventionError extends Error {
constructor(reason, { kind, url, title } = {}) {
super(reason);
this.name = 'HumanInterventionError';
this.kind = kind; // 'captcha' | 'login'
this.blockedUrl = url;
this.pageTitle = title;
}
}
// Parse the JSON string returned by the in-page probe and throw if blocked.
function evaluateBlockResult(raw) {
if (!raw) return;
let info;
try { info = JSON.parse(raw); } catch (e) { return; }
if (info && info.blocked) {
throw new HumanInterventionError(info.reason, { kind: info.kind, url: info.url, title: info.title });
}
}
// Browser (CDP) entry point. Runs the probe in the page via cdpEval; throws
// HumanInterventionError if the page is a challenge/login wall.
async function evaluateBrowserBlock(ws) {
const { cdpEval } = require('./cdp-client');
const raw = await cdpEval(ws, BROWSER_CHECK_EXPR);
evaluateBlockResult(raw);
}
// ── Direct (HTTP) entry point ───────────────────────────────────────────────
// String scan of raw HTML for challenge/login-wall signals. No DOM, so the
// signals are a subset of the browser probe — enough to catch a Cloudflare
// interstitial or a login redirect on an open site.
const DIRECT_CHALLENGE_RE = /please verify (that )?you are human|verify you are human|unusual traffic from your computer|checking your browser before accessing|enable javascript and cookies to continue|our system has detected unusual traffic|请完成安全验证|进行人机验证/i;
const DIRECT_CHALLENGE_TITLE_RE = /<title[^>]*>(just a moment|attention required|verify|checking your browser|are you human|access denied|请输入验证码|安全验证)/i;
const DIRECT_LOGIN_CTA_RE = /sign in to (continue|read)|log in to (continue|read)|subscribe to (continue|read|keep)|以继续阅读|登录后查看|订阅后查看|登录以继续|登录后可继续阅读/i;
function checkDirectBlock(html, url) {
if (!html) return;
const head = html.substring(0, 8000);
if (DIRECT_CHALLENGE_TITLE_RE.test(head) || DIRECT_CHALLENGE_RE.test(head)) {
throw new HumanInterventionError('人机验证/反爬挑战页面', { kind: 'captcha', url });
}
// Login wall: CTA present AND the extracted article body would be tiny. We
// approximate "tiny body" by counting <p> text length in the head slice.
if (DIRECT_LOGIN_CTA_RE.test(head)) {
const paras = head.match(/<p[^>]*>([\s\S]*?)<\/p>/gi) || [];
const textLen = paras.reduce((n, p) => n + p.replace(/<[^>]+>/g, '').trim().length, 0);
if (textLen < 400) {
throw new HumanInterventionError('检测到登录/订阅墙(正文极短且出现登录/订阅提示)', { kind: 'login', url });
}
}
}
module.exports = {
HumanInterventionError,
BROWSER_CHECK_EXPR,
evaluateBrowserBlock,
checkDirectBlock
};
/**
* cdp-client.js — Shared Chrome DevTools Protocol primitives.
*
* Previously every CDP caller (fetcher-browser.js) hand-rolled the same
* WebSocket boilerplate: open connection, send a command with an id, attach a
* message listener matching that id, parse the result. That pattern was
* duplicated 4× and the Page.enable call didn't even check the id (resolving
* on the first message of any kind). This module is the single source of truth
* for those operations so fetcher-helper.js (and a future fetcher-browser
* refactor) can build on top.
*
* Lifecycle (browser launch/discovery) stays in ensure-chromium.js — this
* module only handles per-tab CDP commands, assuming a browser is already up.
*/
const http = require('http');
const fs = require('fs');
const path = require('path');
const WebSocket = require('ws');
const SKILL_DIR = path.dirname(__dirname);
const CONFIG_PATH = path.join(SKILL_DIR, 'config.json');
const TEMPLATE_PATH = path.join(SKILL_DIR, 'references', 'config-template.json');
// ── Utilities ────────────────────────────────────────────────────────────────
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
// Monotonic id for CDP commands. Per-tab sessions are sequential, so a simple
// counter is fine and avoids the random-id collisions the old code risked.
let _msgId = 0;
function nextId() { return ++_msgId; }
// ── Port resolution ──────────────────────────────────────────────────────────
// Precedence: env override > config.chromium.cdpPort > default 9222.
// (Mirrors the old private resolveCdpPort in fetcher-browser.js.)
function resolveCdpPort() {
if (process.env.CDP_PORT_OVERRIDE) return parseInt(process.env.CDP_PORT_OVERRIDE, 10);
try {
const cfgPath = fs.existsSync(CONFIG_PATH) ? CONFIG_PATH : TEMPLATE_PATH;
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
if (cfg.chromium && cfg.chromium.cdpPort) return parseInt(cfg.chromium.cdpPort, 10);
} catch (e) { /* fall through to default */ }
return 9222;
}
// ── WebSocket URL discovery ───────────────────────────────────────────────────
// Fetch /json and pick the first `page` tab's webSocketDebuggerUrl. Retries a
// few times: right after ensure-chromium spawns the browser, the port may
// accept connections a beat before /json responds.
function getWsUrl() {
const port = resolveCdpPort();
const MAX_ATTEMPTS = 6;
const STEP_MS = 500;
return new Promise((resolve, reject) => {
let attempt = 0;
const tryOnce = () => {
attempt++;
http.get(`http://localhost:${port}/json`, (res) => {
let data = '';
res.on('data', d => data += d);
res.on('end', () => {
try {
const tabs = JSON.parse(data);
// Prefer a page tab to avoid grabbing an iframe/service_worker target.
const tab = tabs.find(t => t.type === 'page') || tabs[0];
if (!tab || !tab.webSocketDebuggerUrl) {
return reject(new Error(`CDP /json returned no page tab on :${port}`));
}
resolve(tab.webSocketDebuggerUrl);
} catch (e) {
if (attempt < MAX_ATTEMPTS) return setTimeout(tryOnce, STEP_MS);
reject(e);
}
});
}).on('error', (e) => {
if (attempt < MAX_ATTEMPTS) return setTimeout(tryOnce, STEP_MS);
reject(new Error(`Cannot reach CDP on :${port} after ${MAX_ATTEMPTS} tries (${e.message}). Run: node scripts/ensure-chromium.js`));
});
};
tryOnce();
});
}
// ── Session management ───────────────────────────────────────────────────────
// Open a WebSocket to the page target and resolve once connected.
// Returns { ws, close }. The caller owns the lifecycle (close when done).
function openSession() {
return getWsUrl().then(wsUrl => new Promise((resolve, reject) => {
const ws = new WebSocket(wsUrl);
ws.on('open', () => resolve({ ws, close: () => ws.close() }));
ws.on('error', reject);
}));
}
// ── Command sending ──────────────────────────────────────────────────────────
// Send a CDP command and await its response, matched by id. Rejects on CDP
// error. Concurrent calls are safe — each attaches its own id-matched listener.
function cdpSend(ws, method, params = {}) {
return new Promise((resolve, reject) => {
const id = nextId();
const handler = (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.id === id) {
ws.removeListener('message', handler);
if (msg.error) reject(new Error(`CDP ${method} error: ${JSON.stringify(msg.error)}`));
else resolve(msg.result);
}
};
ws.on('message', handler);
ws.send(JSON.stringify({ id, method, params }));
});
}
// Evaluate a JS expression in the page and return the value (returnByValue).
// Returns undefined if the expression yielded no value.
async function cdpEval(ws, expression) {
const result = await cdpSend(ws, 'Runtime.evaluate', {
expression,
returnByValue: true
});
return result && result.result && result.result.value;
}
// ── Navigation ───────────────────────────────────────────────────────────────
// Enable Page domain, navigate to url, wait for loadEventFired (racing a 25s
// timeout), then sleep waitMs for JS/React to finish rendering.
// Fixes the old Page.enable bug where ws.once('message') resolved on ANY
// message instead of the enable response — here cdpSend id-checks it.
async function navigateAndWait(ws, url, waitMs = 12000) {
await cdpSend(ws, 'Page.enable');
// Attach the load listener BEFORE sending navigate, since loadEventFired can
// fire before the navigate response arrives.
let loaded = false;
const loadPromise = new Promise(resolve => {
const handler = (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.method === 'Page.loadEventFired' && !loaded) {
loaded = true;
ws.removeListener('message', handler);
resolve();
}
};
ws.on('message', handler);
});
await cdpSend(ws, 'Page.navigate', { url });
await Promise.race([loadPromise, sleep(25000)]);
await sleep(waitMs);
}
// ── Scrolling + lazy-load collection ─────────────────────────────────────────
// Scroll to the bottom of the page `steps` times, waiting `waitMs` after each
// scroll, and run `collectExpr` (a JS expression returning a JSON string of an
// array) after the initial load and after each scroll. Results are merged,
// deduplicating by `dedupKey` (default: JSON.stringify of the element).
//
// Returns the merged array (of parsed objects). This is what lets SPA list
// pages that lazy-load article cards yield their full set instead of just the
// first screenful.
//
// Two loop modes (mutually exclusive):
// • Fixed steps (default): exactly `steps` scroll iterations, no early exit.
// Used by preview mode and any caller that just wants a fixed lazy-load
// pass. Backward compatible — no `targetCount` ⇒ this mode.
// • Target-count: when `targetCount` is set, scroll until `merged.length >=
// targetCount` OR `maxSteps` iterations OR `maxStaleSteps` consecutive
// scrolls add nothing (stream exhausted). `steps` is ignored in this mode.
// Used by archive harvest so the fetcher can keep loading a lazy stream
// until enough candidates exist to satisfy the requested count *after*
// dedup, instead of giving up at a fixed `scrollSteps` cap.
async function scrollAndCollect(ws, {
steps = 0, waitMs = 1500, collectExpr, dedupKey,
targetCount = null, maxSteps = 10, maxStaleSteps = 3
}) {
const keyFn = dedupKey || (item => JSON.stringify(item));
const seen = new Set();
const merged = [];
const absorb = (raw) => {
if (!raw) return;
let arr;
try { arr = JSON.parse(raw); } catch (e) { return; }
if (!Array.isArray(arr)) return;
for (const item of arr) {
const k = keyFn(item);
if (!seen.has(k)) { seen.add(k); merged.push(item); }
}
};
absorb(await cdpEval(ws, collectExpr));
// Target-count mode: bounded by maxSteps, exits early on enough items or
// on a stale streak (consecutive scrolls adding zero new items ⇒ the lazy
// stream has run dry, so more scrolling won't help).
if (targetCount && merged.length < targetCount) {
let stale = 0;
for (let i = 0; i < maxSteps && merged.length < targetCount; i++) {
const before = merged.length;
await cdpEval(ws, 'window.scrollTo(0, document.body.scrollHeight)');
await sleep(waitMs);
absorb(await cdpEval(ws, collectExpr));
stale = (merged.length === before) ? stale + 1 : 0;
if (stale >= maxStaleSteps) break;
}
return merged;
}
// Fixed-steps mode (default / preview): backward compatible.
for (let i = 0; i < steps; i++) {
await cdpEval(ws, 'window.scrollTo(0, document.body.scrollHeight)');
await sleep(waitMs);
absorb(await cdpEval(ws, collectExpr));
}
return merged;
}
module.exports = {
sleep,
resolveCdpPort,
getWsUrl,
openSession,
cdpSend,
cdpEval,
navigateAndWait,
scrollAndCollect
};
#!/usr/bin/env node
/**
* ensure-chromium.js - Robust cross-platform Chromium launcher for CDP
*
* Why this exists: the old documented command `chromium ... & sleep 5` assumed
* `chromium` was on PATH. On macOS, Chromium/Chrome ship as .app bundles whose
* real binary lives in /Applications/<Browser>.app/Contents/MacOS/<Binary>, so
* `chromium` is not found and the command silently fails (&>/dev/null swallows
* the error). `sleep 5` is also not a readiness check — a slow first launch or
* a crash leaves you waiting 5s for nothing.
*
* This script:
* 1. Health-checks the CDP port (pure node http, no python3 dependency).
* 2. If not up, discovers the browser binary per-platform (or uses
* config.chromium.executablePath).
* 3. Cleans stale SingletonLock files left by a previous crash.
* 4. Spawns the browser with CDP enabled (headless=new by default, configurable).
* 5. Polls /json/version until ready (up to 30s).
* 6. On timeout, dumps the browser's stderr log tail for diagnosis.
*
* CLI:
* node ensure-chromium.js # ensure running (no-op if already up)
* node ensure-chromium.js --check # only check, exit 0 if up / 1 if not
* node ensure-chromium.js --port 9333 # override CDP port for this run
*
* Used programmatically by harvest.js for browser-method sources.
*/
const fs = require('fs');
const path = require('path');
const http = require('http');
const os = require('os');
const { spawn, execFileSync } = require('child_process');
const SKILL_DIR = path.dirname(__dirname);
const CONFIG_PATH = path.join(SKILL_DIR, 'config.json');
const TEMPLATE_PATH = path.join(SKILL_DIR, 'references', 'config-template.json');
// ── Config ──────────────────────────────────────────────────────────────────
function loadConfig() {
if (!fs.existsSync(CONFIG_PATH)) {
const template = JSON.parse(fs.readFileSync(TEMPLATE_PATH, 'utf8'));
fs.writeFileSync(CONFIG_PATH, JSON.stringify(template, null, 2));
}
return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
}
function resolveOptions(config, portOverride) {
const chromium = (config && config.chromium) || {};
const cdpPort = parseInt(portOverride || process.env.CDP_PORT_OVERRIDE || chromium.cdpPort || '9222', 10);
const headless = chromium.headless !== false; // default true, set false to keep a window
const userDataDir = chromium.userDataDir || path.join(os.tmpdir(), 'news-harvester-chromium');
return { cdpPort, headless, userDataDir, executablePath: chromium.executablePath || null };
}
// ── CDP health check (no python3) ───────────────────────────────────────────
function cdpVersion(port, timeoutMs = 1500) {
return new Promise((resolve, reject) => {
const req = http.get(`http://localhost:${port}/json/version`, (res) => {
let data = '';
res.on('data', (d) => (data += d));
res.on('end', () => {
try { resolve(JSON.parse(data)); }
catch (e) { reject(new Error(`bad /json/version response: ${data.slice(0, 120)}`)); }
});
});
req.on('error', reject);
req.setTimeout(timeoutMs, () => { req.destroy(); reject(new Error('health-check timeout')); });
});
}
async function waitForCdp(port, totalMs = 30000, stepMs = 500) {
const deadline = Date.now() + totalMs;
let lastErr;
while (Date.now() < deadline) {
try {
const v = await cdpVersion(port, 1500);
return v;
} catch (e) {
lastErr = e;
await new Promise((r) => setTimeout(r, stepMs));
}
}
throw new Error(`CDP not ready after ${totalMs / 1000}s (last: ${lastErr && lastErr.message})`);
}
// ── Binary discovery ────────────────────────────────────────────────────────
// Each candidate is { name, path }. `name` is a friendly label for --list; the
// first existing `path` wins in discoverBinary() (backward compat with harvest.js).
function macCandidates() {
const apps = [
['Chromium', 'Chromium.app', 'Chromium'],
['Google Chrome', 'Google Chrome.app', 'Google Chrome'],
['Brave Browser', 'Brave Browser.app', 'Brave Browser'],
['Microsoft Edge', 'Microsoft Edge.app', 'Microsoft Edge'],
];
const out = [];
for (const [name, bundle, bin] of apps) {
out.push({ name, path: `/Applications/${bundle}/Contents/MacOS/${bin}` });
out.push({ name, path: path.join(os.homedir(), 'Applications', bundle, 'Contents', 'MacOS', bin) });
}
out.push({ name: 'Chromium (Homebrew)', path: '/opt/homebrew/bin/chromium' });
out.push({ name: 'Chromium (Homebrew)', path: '/usr/local/bin/chromium' });
return out;
}
function linuxCandidates() {
// `command -v` resolves PATH-based binaries; fall back to common fixed paths.
const names = ['chromium', 'chromium-browser', 'google-chrome', 'google-chrome-stable', 'brave-browser'];
const found = [];
for (const n of names) {
try {
const p = execFileSync('command', ['-v', n], { stdio: ['ignore', 'pipe', 'ignore'], shell: true })
.toString().trim();
if (p) found.push({ name: n, path: p });
} catch (e) { /* not on PATH */ }
}
return [...found,
{ name: 'chromium', path: '/usr/bin/chromium' },
{ name: 'chromium-browser', path: '/usr/bin/chromium-browser' },
{ name: 'chromium (snap)', path: '/snap/bin/chromium' },
];
}
function windowsCandidates() {
const pf = process.env['ProgramFiles'] || 'C:\\Program Files';
const pf86 = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)';
const local = process.env['LOCALAPPDATA'] || '';
return [
{ name: 'Google Chrome', path: path.join(pf, 'Google', 'Chrome', 'Application', 'chrome.exe') },
{ name: 'Google Chrome', path: path.join(pf86, 'Google', 'Chrome', 'Application', 'chrome.exe') },
{ name: 'Google Chrome', path: path.join(local, 'Google', 'Chrome', 'Application', 'chrome.exe') },
{ name: 'Chromium', path: path.join(pf, 'Chromium', 'Application', 'chrome.exe') },
];
}
function candidatesForPlatform() {
return process.platform === 'darwin' ? macCandidates()
: process.platform === 'win32' ? windowsCandidates()
: linuxCandidates();
}
// All browsers actually present on this machine (for --list / agent detection).
function discoverAll() {
const out = [];
for (const c of candidatesForPlatform()) {
try { if (fs.existsSync(c.path) && fs.statSync(c.path).isFile()) out.push(c); } catch (e) {}
}
return out;
}
// First existing binary (backward compat — used by harvest.js + ensureChromium).
function discoverBinary() {
const found = discoverAll();
if (found.length) return found[0].path;
const cands = candidatesForPlatform().map(c => c.path);
throw new Error(
'Could not find a Chromium/Chrome binary. Tried:\n ' + cands.join('\n ') +
'\nSet config.chromium.executablePath to the full path of your browser.'
);
}
// ── Stale-lock cleanup ──────────────────────────────────────────────────────
// A crashed Chromium leaves SingletonLock / SingletonCookie / SingletonSocket in
// the user-data-dir, which make a fresh process exit silently. Remove them.
function cleanStaleLocks(userDataDir) {
for (const name of ['SingletonLock', 'SingletonCookie', 'SingletonSocket']) {
const f = path.join(userDataDir, name);
try { if (fs.existsSync(f)) fs.unlinkSync(f); } catch (e) { /* best effort */ }
}
}
// ── Launch ──────────────────────────────────────────────────────────────────
function spawnBrowser(bin, opts) {
const { cdpPort, headless, userDataDir, url = 'about:blank', windowsHide = true } = opts;
fs.mkdirSync(userDataDir, { recursive: true });
cleanStaleLocks(userDataDir);
const args = [
`--remote-debugging-port=${cdpPort}`,
`--user-data-dir=${userDataDir}`,
'--no-first-run',
'--no-default-browser-check',
'--disable-extensions',
'--disable-gpu',
// Let Chromium auto-detect Wayland vs X11 on Linux. Without this it
// defaults to the X11 backend and crashes with "Missing X server or
// $DISPLAY" on Wayland-only sessions (common on Arch/GNOME/Sway). Harmless
// on macOS/Windows — they don't use the Ozone backend.
'--ozone-platform-hint=auto',
];
// --no-sandbox is only needed when running as root (typical in Linux/Docker
// containers, where Chromium refuses to start with the sandbox as root). On
// macOS/Windows/regular-user Linux it's unnecessary and Chromium shows an
// "unsupported command-line flag" warning banner — so omit it there.
if (typeof process.getuid === 'function' && process.getuid() === 0) {
args.push('--no-sandbox');
}
if (headless) args.push('--headless=new');
args.push(url);
const logPath = path.join(userDataDir, 'chromium-stderr.log');
const logFd = fs.openSync(logPath, 'a');
const child = spawn(bin, args, {
detached: true,
stdio: ['ignore', 'ignore', logFd],
windowsHide,
env: { ...process.env },
});
child.on('error', (e) => {
console.error(`Chromium spawn error: ${e.message}`);
});
// Detach so it survives this process exiting.
child.unref();
return { pid: child.pid, logPath };
}
function tailFile(filePath, lines = 25) {
try {
const content = fs.readFileSync(filePath, 'utf8');
return content.split('\n').filter(Boolean).slice(-lines).join('\n');
} catch (e) { return '(no log file)'; }
}
// Diagnose display/X11 failures — common on Wayland-only Linux sessions where
// $DISPLAY is unset and Chromium wasn't told (or couldn't) use the Wayland
// backend. Returns a multi-line hint string.
function displayDiagnostic() {
const d = process.env.DISPLAY ? `set (${process.env.DISPLAY})` : 'unset';
const w = process.env.WAYLAND_DISPLAY ? `set (${process.env.WAYLAND_DISPLAY})` : 'unset';
const lines = [
` $DISPLAY: ${d}`,
` $WAYLAND_DISPLAY: ${w}`,
];
if (!process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
lines.push(' ⚠️ Neither is set — this shell is not attached to a graphical session.');
lines.push(' Run --login from a terminal inside your desktop (GUI terminal), or');
lines.push(' export the variables from a graphical session, e.g.:');
lines.push(' export WAYLAND_DISPLAY=wayland-0 # Wayland');
lines.push(' export DISPLAY=:0 # X11 / XWayland');
} else if (!process.env.DISPLAY && process.env.WAYLAND_DISPLAY) {
lines.push(' (Wayland session. --ozone-platform-hint=auto is already passed; if it still');
lines.push(' fails, force Wayland by adding --ozone-platform=wayland to the browser args,');
lines.push(' or run in a GUI terminal that also exports $DISPLAY for XWayland.)');
}
return lines.join('\n');
}
function looksLikeDisplayFailure(log) {
return /Missing X server|\$DISPLAY|platform failed to initialize|ozone/i.test(log);
}
// ── Main ────────────────────────────────────────────────────────────────────
async function ensureChromium(config, portOverride) {
const opts = resolveOptions(config, portOverride);
// 1. Already up? (idempotent — safe to call every run)
try {
const v = await cdpVersion(opts.cdpPort, 1500);
return { port: opts.cdpPort, alreadyRunning: true, browser: v.Browser };
} catch (e) { /* not up yet, fall through to launch */ }
// 2. Discover binary
const bin = opts.executablePath || discoverBinary();
if (!opts.executablePath && !fs.existsSync(bin)) {
// discoverBinary already throws, but guard explicit paths too
throw new Error(`executablePath not found: ${bin}`);
}
// 3. Launch
const { pid, logPath } = spawnBrowser(bin, opts);
// 4. Wait for readiness
try {
const v = await waitForCdp(opts.cdpPort, 30000, 500);
return { port: opts.cdpPort, pid, browser: v.Browser, bin, logPath };
} catch (e) {
const logTail = tailFile(logPath);
const hint = looksLikeDisplayFailure(logTail) ? '\n' + displayDiagnostic() + '\n' : '';
throw new Error(
`Chromium did not become ready on port ${opts.cdpPort}.\n` +
` binary: ${bin}\n` +
` userData: ${opts.userDataDir}\n` +
` log: ${logPath}\n` +
` stderr tail:\n${logTail}${hint}`
);
}
}
// ── CLI ─────────────────────────────────────────────────────────────────────
const HELP = `ensure-chromium.js — robust cross-platform Chromium launcher for CDP
USAGE
node ensure-chromium.js Ensure Chromium is up on the CDP port
(launches it if not; no-op if already running)
node ensure-chromium.js --check Only health-check; exit 0 if up, 1 if not
node ensure-chromium.js --list Detect platform + all available browsers,
print current config; exit 0
node ensure-chromium.js --login [--url <url>] [--port <port>] [--profile <dir>]
Launch a WINDOWED browser on the CDP port for
interactive login. Persisted to a fixed profile
so headless harvests keep your cookies.
node ensure-chromium.js --port 9333 Override the CDP port for this run
node ensure-chromium.js --help Show this help
WHAT IT DOES
1. Health-checks http://localhost:<port>/json/version (pure node, no python3).
2. If not up, discovers the browser binary per platform:
macOS — /Applications/{Chromium,Google Chrome,Brave,Edge}.app/Contents/MacOS/*
+ /opt/homebrew/bin/chromium, /usr/local/bin/chromium
Linux — chromium, chromium-browser, google-chrome[-stable], brave-browser
(via command -v) + /usr/bin/*, /snap/bin/chromium
Win — Program Files\\Google\\Chrome\\Application\\chrome.exe (+x86, %LOCALAPPDATA%)
Override discovery with config.chromium.executablePath.
3. Cleans stale SingletonLock files left by a crashed browser.
4. Spawns the browser (headless=new by default; --no-sandbox --disable-gpu,
remote-debugging-port=<port>, user-data-dir=<dir>, about:blank).
5. Polls /json/version until ready (up to 30s, every 500ms).
6. On timeout, prints binary path / port / userDataDir / stderr-log tail.
--LIST
Prints the platform, every Chromium/Chrome binary found on this machine, and
the current chromium config (port / headless / executablePath / userDataDir).
Use this to report the environment to a new user before launching a login.
--LOGIN
Launches a WINDOWED browser (headless=false) with CDP enabled so the user can
log into a site interactively. The profile persists, so later headless harvests
reuse the same cookies/login state.
--url <url> Page to open (default about:blank; pass the source homepage)
--port <port> CDP port (default config.chromium.cdpPort / 9222)
--profile <dir> Profile dir (default config.chromium.userDataDir, else
~/.news-harvester/chromium-profile — a fixed path, NOT the
ephemeral tmp dir, so login state survives)
If the CDP port is already occupied (e.g. a headless harvest instance), --login
reports it and exits 0 WITHOUT launching (avoiding a SingletonLock clash).
If the profile used is the default path (config had no userDataDir), --login
prints a ready-to-paste config.json fragment so the agent/user can persist it.
CONFIG (config.json, all optional — see SKILL.md "Configuration")
chromium.cdpPort CDP port (default 9222; env CDP_PORT_OVERRIDE wins)
chromium.headless true (default) | false (keep a window)
chromium.executablePath full path to browser binary (null = auto-discover)
chromium.userDataDir browser profile dir (null = <tmp>/news-harvester-chromium).
Set this to a fixed profile you've logged into once
(via --login) so future auto-launches keep your
cookies/login state.
EXIT CODES
0 CDP is up (already running or just launched); --list ok; --login ok/already-running
1 CDP not up (--check), or launch failed
EXAMPLES
# Diagnose: is Chromium reachable?
node scripts/ensure-chromium.js --check
# Detect what browsers are installed (new-user onboarding step 1)
node scripts/ensure-chromium.js --list
# Launch (auto-discovers Chrome on a Mac)
node scripts/ensure-chromium.js
# Open a windowed browser on the source homepage for interactive login
node scripts/ensure-chromium.js --login --url https://www.reuters.com/
# Use a non-default port for both this launcher and harvest.js
CDP_PORT_OVERRIDE=9333 node scripts/ensure-chromium.js
CDP_PORT_OVERRIDE=9333 node scripts/harvest.js reuters 2 --preview`;
// ── --list: detect platform + available browsers + config ───────────────────
function platformLabel() {
return process.platform === 'darwin' ? 'macOS'
: process.platform === 'win32' ? 'Windows'
: 'Linux';
}
async function cmdList(config) {
const opts = resolveOptions(config, null);
console.log(`Platform: ${platformLabel()} (${process.platform})`);
const found = discoverAll();
if (found.length === 0) {
console.log('Browsers: none found in standard locations.');
console.log(' Set config.chromium.executablePath to your browser binary.');
} else {
console.log('Browsers found:');
for (const b of found) console.log(` • ${b.name}${b.path}`);
}
console.log('\nCurrent chromium config:');
console.log(` cdpPort: ${opts.cdpPort}`);
console.log(` headless: ${opts.headless}`);
console.log(` executablePath: ${opts.executablePath || '(auto-discover)'}`);
console.log(` userDataDir: ${opts.userDataDir || '(ephemeral tmp — set a fixed path for login persistence)'}`);
process.exit(0);
}
// ── --login: windowed browser for interactive login ─────────────────────────
const DEFAULT_PROFILE = path.join(os.homedir(), '.news-harvester', 'chromium-profile');
async function cmdLogin(config, flagUrl, flagPort, flagProfile) {
const chromium = (config && config.chromium) || {};
const port = parseInt(flagPort || chromium.cdpPort || '9222', 10);
// A persistent profile is MANDATORY for login; never fall back to the tmp dir.
const profile = flagProfile || chromium.userDataDir || DEFAULT_PROFILE;
const url = flagUrl || 'about:blank';
// 1. Port already occupied? Report and exit 0 (don't clash).
try {
const v = await cdpVersion(port, 1500);
console.log(`ℹ️ A browser is already running on CDP port :${port}${v.Browser}`);
console.log(' If that is a headless harvest instance, close it first (Cmd+Q / Ctrl+Q), then re-run --login.');
console.log(' (Not reusing it: it may be a different profile, and a second instance would hit a SingletonLock.)');
process.exit(0);
} catch (e) { /* port free — proceed to launch */ }
// 2. Discover binary.
const bin = chromium.executablePath || discoverBinary();
if (!fs.existsSync(bin)) {
console.error(`❌ Browser binary not found: ${bin}`);
console.error(' Set config.chromium.executablePath to the full path of your browser.');
process.exit(1);
}
// 3. Launch windowed (headless=false, window visible, open the target url).
const { pid, logPath } = spawnBrowser(bin, {
cdpPort: port, headless: false, userDataDir: profile, url, windowsHide: false,
});
// 4. Short readiness poll (15s) — catches silent launch failures.
try {
const v = await waitForCdp(port, 15000, 500);
console.log(`✅ Windowed browser launched (pid ${pid}) on :${port}${v.Browser}`);
console.log(` binary: ${bin}`);
console.log(` profile: ${profile}`);
console.log(` log: ${logPath}`);
} catch (e) {
const logTail = tailFile(logPath);
console.error(`❌ Browser did not become ready on :${port} within 15s.`);
console.error(` binary: ${bin}`);
console.error(` profile: ${profile}`);
console.error(` log: ${logPath}`);
console.error(` stderr tail:\n${logTail}`);
if (looksLikeDisplayFailure(logTail)) {
console.error(`\n ${displayDiagnostic()}`);
}
process.exit(1);
}
// 5. Guide the user through login.
const siteHint = url !== 'about:blank' ? ` at ${url}` : '';
console.log(`\n🔐 A browser window has opened${siteHint}.`);
console.log(' 1. Log into the site inside that window (it uses the profile above).');
console.log(' 2. When done, QUIT the browser fully: Cmd+Q (macOS) / Ctrl+Q (Linux/Windows).');
console.log(' 3. The login state (cookies) is persisted in the profile, so future headless');
console.log(' harvests auto-launch with this same profile and stay logged in.');
// 6. If the profile is the default path (config had no userDataDir), prompt to persist it.
const configHadProfile = !!(chromium.userDataDir);
if (!configHadProfile) {
console.log(`\n📝 To make this profile permanent, add userDataDir to config.json's "chromium" block:`);
console.log(` "chromium": { "userDataDir": "${profile}" }`);
console.log(' (This script does not edit config.json — write the snippet above in yourself.)');
}
process.exit(0);
}
// ── CLI ─────────────────────────────────────────────────────────────────────
function flagValue(argv, name) {
const i = argv.indexOf(name);
return i >= 0 ? argv[i + 1] : null;
}
if (require.main === module) {
const argv = process.argv.slice(2);
if (argv.includes('--help') || argv.includes('-h')) {
console.log(HELP);
process.exit(0);
}
if (argv.includes('--list')) {
cmdList(loadConfig());
return;
}
if (argv.includes('--login')) {
cmdLogin(
loadConfig(),
flagValue(argv, '--url'),
flagValue(argv, '--port'),
flagValue(argv, '--profile')
);
return;
}
const checkOnly = argv.includes('--check');
const portOverride = flagValue(argv, '--port');
(async () => {
const config = loadConfig();
const opts = resolveOptions(config, portOverride);
if (checkOnly) {
try {
const v = await cdpVersion(opts.cdpPort, 1500);
console.log(`✅ Chromium CDP up on :${opts.cdpPort}${v.Browser}`);
process.exit(0);
} catch (e) {
console.log(`❌ Chromium CDP not running on :${opts.cdpPort} (${e.message})`);
process.exit(1);
}
}
try {
const r = await ensureChromium(config, portOverride);
if (r.alreadyRunning) {
console.log(`✅ Chromium already running on :${r.port}${r.browser}`);
} else {
console.log(`✅ Chromium launched (pid ${r.pid}) on :${r.port}${r.browser}`);
console.log(` binary: ${r.bin}`);
console.log(` log: ${r.logPath}`);
}
} catch (e) {
console.error(`❌ ${e.message}`);
process.exit(1);
}
})();
}
module.exports = { ensureChromium, cdpVersion, resolveOptions, discoverBinary, discoverAll };
/**
* fetcher-browser.js
* Fetch article content using Chromium CDP (for JS-heavy sites like Reuters)
* Usage: node fetcher-browser.js <url> [waitMs]
*/
const http = require('http');
const fs = require('fs');
const path = require('path');
const WebSocket = require('ws');
const { evaluateBrowserBlock } = require('./block-check');
const SKILL_DIR = path.dirname(__dirname);
const CONFIG_PATH = path.join(SKILL_DIR, 'config.json');
const TEMPLATE_PATH = path.join(SKILL_DIR, 'references', 'config-template.json');
// Resolve the CDP port lazily so config.json changes are picked up per run.
// Precedence: env override > config.chromium.cdpPort > default 9222.
function resolveCdpPort() {
if (process.env.CDP_PORT_OVERRIDE) return parseInt(process.env.CDP_PORT_OVERRIDE, 10);
try {
const cfgPath = fs.existsSync(CONFIG_PATH) ? CONFIG_PATH : TEMPLATE_PATH;
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
if (cfg.chromium && cfg.chromium.cdpPort) return parseInt(cfg.chromium.cdpPort, 10);
} catch (e) { /* fall through to default */ }
return 9222;
}
// Retry the /json endpoint a few times: right after ensure-chromium spawns the
// browser, the port may accept connections a beat before /json responds.
function getWsUrl() {
const port = resolveCdpPort();
const MAX_ATTEMPTS = 6;
const STEP_MS = 500;
return new Promise((resolve, reject) => {
let attempt = 0;
const tryOnce = () => {
attempt++;
http.get(`http://localhost:${port}/json`, (res) => {
let data = '';
res.on('data', d => data += d);
res.on('end', () => {
try {
const tabs = JSON.parse(data);
// 优先找 page 类型的 tab,避免拿到 iframe/service_worker
const tab = tabs.find(t => t.type === 'page') || tabs[0];
if (!tab || !tab.webSocketDebuggerUrl) {
return reject(new Error(`CDP /json returned no page tab on :${port}`));
}
resolve(tab.webSocketDebuggerUrl);
} catch (e) {
if (attempt < MAX_ATTEMPTS) return setTimeout(tryOnce, STEP_MS);
reject(e);
}
});
}).on('error', (e) => {
if (attempt < MAX_ATTEMPTS) return setTimeout(tryOnce, STEP_MS);
reject(new Error(`Cannot reach CDP on :${port} after ${MAX_ATTEMPTS} tries (${e.message}). Run: node scripts/ensure-chromium.js`));
});
};
tryOnce();
});
}
async function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
async function fetchPage(url, waitMs = 12000) {
const wsUrl = await getWsUrl();
const ws = new WebSocket(wsUrl);
await new Promise((resolve, reject) => { ws.on('open', resolve); ws.on('error', reject); });
// Enable Page events
await new Promise(resolve => {
ws.send(JSON.stringify({ id: 1, method: 'Page.enable' }));
ws.once('message', () => resolve());
});
// Navigate + wait for load
let loaded = false;
const loadPromise = new Promise(resolve => {
const handler = (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.method === 'Page.loadEventFired' && !loaded) {
loaded = true;
ws.removeListener('message', handler);
resolve();
}
};
ws.on('message', handler);
});
ws.send(JSON.stringify({ id: 2, method: 'Page.navigate', params: { url } }));
await Promise.race([loadPromise, sleep(25000)]);
await sleep(waitMs); // wait for React/JS to fully render
// Human-verification / login-state-loss guard. Throws HumanInterventionError
// (propagates to harvest.js, which exits with a friendly message) before we
// try to extract content from what might be a challenge or login page.
await evaluateBrowserBlock(ws);
// Extract content
const result = await new Promise((resolve) => {
const id = Math.floor(Math.random() * 100000) + 90000;
const handler = (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.id === id) {
ws.removeListener('message', handler);
resolve(msg.result && msg.result.result && msg.result.result.value);
}
};
ws.on('message', handler);
ws.send(JSON.stringify({ id, method: 'Runtime.evaluate', params: {
expression: `(function(){
var title = document.title.replace(/ \\| Reuters| \\| The Conversation| - Bloomberg$/g,'').trim();
var dateEl = document.querySelector('time[datetime]');
var date = dateEl ? dateEl.getAttribute('datetime') : '';
var authors = Array.from(document.querySelectorAll('[rel="author"], [class*="author"] a'))
.map(a => a.innerText.trim()).filter(Boolean)
.filter((v,i,a) => a.indexOf(v)===i).join(', ');
var imgs = Array.from(document.querySelectorAll('img')).map(img => ({
src: img.src,
alt: img.alt || '',
width: img.naturalWidth || img.width || 0
})).filter(i => i.src && i.src.startsWith('http') && i.width > 200
&& !i.src.includes('logo') && !i.src.includes('icon') && !i.src.includes('avatar')
&& !i.src.includes('profile')).slice(0, 6);
var body = '';
var selectors = [
'[data-testid^="paragraph"]',
'[class*="articleBodyContent"] p',
'[class*="article-body"] p',
'[class*="ArticleBody"] p',
'[class*="story-body"] p',
'article p'
];
for (var sel of selectors) {
var els = document.querySelectorAll(sel);
if (els.length >= 3) {
body = Array.from(els).map(e => e.innerText.trim()).filter(t => t.length > 40).join('\\n\\n');
break;
}
}
if (!body) {
var noise = ['Reporting by','Editing by','Access unmatched','Browse an unrivalled',
'Screen for heightened','Sign up here','Reuters, the news','All quotes delayed','See here for'];
body = Array.from(document.querySelectorAll('p'))
.map(e => e.innerText.trim())
.filter(t => t.length > 60 && !noise.some(n => t.startsWith(n)))
.join('\\n\\n');
}
return JSON.stringify({ title, date, authors, imgs, body: body.substring(0, 15000) });
})()`
}}));
});
ws.close();
return result ? JSON.parse(result) : null;
}
// Extract article links from homepage
async function fetchHomeLinks(url, pattern, waitMs = 15000) {
const wsUrl = await getWsUrl();
const ws = new WebSocket(wsUrl);
await new Promise((resolve, reject) => { ws.on('open', resolve); ws.on('error', reject); });
await new Promise(resolve => {
ws.send(JSON.stringify({ id: 1, method: 'Page.enable' }));
ws.once('message', () => resolve());
});
let loaded = false;
const loadPromise = new Promise(resolve => {
const handler = (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.method === 'Page.loadEventFired' && !loaded) {
loaded = true;
ws.removeListener('message', handler);
resolve();
}
};
ws.on('message', handler);
});
ws.send(JSON.stringify({ id: 2, method: 'Page.navigate', params: { url } }));
await Promise.race([loadPromise, sleep(25000)]);
await sleep(waitMs);
// Human-verification / login-state-loss guard (see fetchPage above).
await evaluateBrowserBlock(ws);
const result = await new Promise((resolve) => {
const id = Math.floor(Math.random() * 100000) + 80000;
const patternStr = pattern.toString();
const handler = (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.id === id) {
ws.removeListener('message', handler);
resolve(msg.result && msg.result.result && msg.result.result.value);
}
};
ws.on('message', handler);
ws.send(JSON.stringify({ id, method: 'Runtime.evaluate', params: {
expression: `JSON.stringify(
Array.from(document.querySelectorAll('a[href]'))
.map(a => a.href)
.filter(h => h && new RegExp(${JSON.stringify(pattern)}).test(h))
.filter((h, i, arr) => arr.indexOf(h) === i)
.slice(0, 30)
)`
}}));
});
ws.close();
return result ? JSON.parse(result) : [];
}
module.exports = { fetchPage, fetchHomeLinks };
// CLI usage
if (require.main === module) {
const url = process.argv[2];
const waitMs = parseInt(process.argv[3] || '12000');
if (!url) { console.error('Usage: node fetcher-browser.js <url> [waitMs]'); process.exit(1); }
fetchPage(url, waitMs).then(data => {
process.stdout.write(JSON.stringify(data, null, 2));
}).catch(e => { console.error('Error:', e.message); process.exit(1); });
}
/**
* fetcher-direct.js
* Fetch article content via direct HTTP (for open sites like The Conversation)
* Usage: node fetcher-direct.js <url>
*/
const https = require('https');
const http = require('http');
const { checkDirectBlock } = require('./block-check');
function fetchHtml(url) {
return new Promise((resolve, reject) => {
const lib = url.startsWith('https') ? https : http;
const req = lib.get(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml',
'Accept-Language': 'en-US,en;q=0.9'
}
}, (res) => {
// Handle redirects
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
return resolve(fetchHtml(res.headers.location));
}
let data = '';
res.on('data', d => data += d);
res.on('end', () => resolve(data));
});
req.on('error', reject);
req.setTimeout(15000, () => { req.destroy(); reject(new Error('Timeout')); });
});
}
// Extract author names from JSON-LD, then <meta name="author"> (one tag per
// author, as on The Conversation), then rel="author" links. Returns '' if none.
function extractAuthors(html) {
// 1. JSON-LD author (Reuters/Guardian embed ld+json blocks)
const ldBlocks = [...html.matchAll(/<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi)];
for (const b of ldBlocks) {
try {
const data = JSON.parse(b[1]);
const candidates = [];
const collect = (obj) => {
if (!obj) return;
if (Array.isArray(obj)) { obj.forEach(collect); return; }
candidates.push(obj);
if (obj['@graph']) collect(obj['@graph']);
};
collect(data);
for (const obj of candidates) {
let author = obj.author || (obj.mainEntity && obj.mainEntity.author);
if (author) {
const names = (Array.isArray(author) ? author : [author])
.map(a => typeof a === 'string' ? a : (a && a.name) || '')
.filter(Boolean);
if (names.length) return [...new Set(names)].slice(0, 3).join(', ');
}
}
} catch (e) {}
}
// 2. <meta name="author" content="..."> — multiple tags for co-authors
const metaMatches = [...html.matchAll(/<meta[^>]+name=["']author["'][^>]+content=["']([^"']+)["']/gi)];
if (metaMatches.length) {
const names = [...new Set(metaMatches.map(m => m[1].trim()).filter(Boolean))];
if (names.length) return names.slice(0, 3).join(', ');
}
// 3. rel="author" link text
const relMatch = html.match(/<a[^>]+rel=["']author["'][^>]*>([^<]+)<\/a>/i);
if (relMatch && relMatch[1].trim()) return relMatch[1].trim();
return '';
}
function extractArticle(html, url) {
// Title
const titleMatch = html.match(/<title[^>]*>(.*?)<\/title>/is);
let title = titleMatch ? titleMatch[1].trim() : '';
title = title.replace(/ \| The Conversation| \| Reuters/g, '').trim();
// Date — capture the full ISO 8601 timestamp (incl. fractional seconds and
// timezone offsets like -05:00 / +09:00). The earlier [\d\-T:Z+] class missed
// '.' and the second '-' / '+' in offsets, truncating those values.
const dateMatch = html.match(/["']datePublished["']\s*:\s*["']([^"']+)["']/) ||
html.match(/<time[^>]+datetime=["']([^"']+)["']/i);
const date = dateMatch ? dateMatch[1] : '';
// Authors
const authors = extractAuthors(html);
// Images — skip logos/icons and tracking pixels (e.g. facebook.com/tr), dedupe by src
const imgMatches = [...html.matchAll(/<img[^>]+src=["'](https?:\/\/[^"']+)["'][^>]*>/gi)];
const seenSrc = new Set();
const imgs = imgMatches.map(m => {
const altMatch = m[0].match(/alt=["']([^"']*)["']/i);
return { src: m[1], alt: altMatch ? altMatch[1] : '', width: 500 };
}).filter(i => !i.src.includes('logo') && !i.src.includes('icon')
&& !i.src.includes('facebook.com/tr') && i.src.length > 50
&& !seenSrc.has(i.src) && seenSrc.add(i.src)).slice(0, 6);
// Article body - try structured extraction
let body = '';
// Try JSON-LD articleBody
const jsonLdMatch = html.match(/"articleBody"\s*:\s*"((?:[^"\\]|\\.)*)"/);
if (jsonLdMatch) {
body = jsonLdMatch[1].replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\//g, '/');
}
if (!body) {
// Extract all paragraphs, clean HTML
const paraMatches = [...html.matchAll(/<p[^>]*>(.*?)<\/p>/gis)];
const noise = ['Sign up', 'Newsletter', 'Subscribe', 'Follow us', 'Read more', 'Click here',
'opens new tab', 'Thomson Reuters', 'All quotes delayed', 'Access unmatched',
'Copy link', 'Bluesky', 'Write an article and join'];
body = paraMatches.map(m => {
let text = m[1].replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').trim();
return text;
}).filter(t => {
const words = t.replace(/\s+/g, ' ').trim().split(/\s+/);
// drop share-button blobs / fragments (much whitespace, few real words)
return words.length >= 8 && t.length > 60 && !noise.some(n => t.includes(n));
}).join('\n\n');
}
return { title, date, authors, imgs, body: body.substring(0, 15000) };
}
function extractLinks(html, baseUrl, pattern) {
// Special case: The Conversation uses Next.js __NEXT_DATA__ JSON
const nextDataMatch = html.match(/<script id="__NEXT_DATA__" type="application\/json">([\s\S]*?)<\/script>/);
if (nextDataMatch) {
try {
const nextData = JSON.parse(nextDataMatch[1]);
const blocks = nextData?.props?.pageProps?.blocks || [];
const urls = [];
for (const block of blocks) {
for (const article of (block.blocks || [])) {
const url = article.url || '';
if (url && url.includes('theconversation.com') && /-\d+$/.test(url)) {
if (!urls.includes(url)) urls.push(url);
}
}
}
if (urls.length > 0) return urls.slice(0, 30);
} catch (e) {}
}
// Generic: find links by regex pattern
const regex = new RegExp(`href=["']((?:https?://)?[^"']*${pattern}[^"']*)["']`, 'gi');
const matches = [...html.matchAll(regex)];
const links = matches.map(m => {
let href = m[1];
if (href.startsWith('/')) {
const base = new URL(baseUrl);
href = `${base.protocol}//${base.host}${href}`;
}
return href;
}).filter(h => h.startsWith('http'));
return [...new Set(links)].slice(0, 30);
}
async function fetchPage(url) {
const html = await fetchHtml(url);
checkDirectBlock(html, url);
return extractArticle(html, url);
}
async function fetchHomeLinks(url, pattern) {
const html = await fetchHtml(url);
checkDirectBlock(html, url);
return extractLinks(html, url, pattern);
}
module.exports = { fetchPage, fetchHomeLinks };
// CLI
if (require.main === module) {
const url = process.argv[2];
if (!url) { console.error('Usage: node fetcher-direct.js <url>'); process.exit(1); }
fetchPage(url).then(data => {
process.stdout.write(JSON.stringify(data, null, 2));
}).catch(e => { console.error('Error:', e.message); process.exit(1); });
}
/**
* fetcher-helper.js — Spec-driven fetcher for sources with a helper.md recipe.
*
* Each helper.md is a YAML-frontmattered markdown file whose frontmatter is a
* structured spec (selectors / scroll / filter). This module parses that spec
* and drives Chromium via the shared cdp-client primitives — no per-source JS.
*
* Two operations, mirroring the legacy fetcher contract:
* fetchHomeLinks(url, spec) → string[] (absolute article URLs, deduped)
* fetchPage(url, spec) → { title, date, authors, imgs, body }
*
* The 2-arg `(url, spec)` signature aligns with how harvest.js calls legacy
* fetchers `(url, ctx)`, so the dispatch branch in harvest.js stays minimal.
*
* Usage (CLI, for manual testing):
* node fetcher-helper.js <helperPath> links <url>
* node fetcher-helper.js <helperPath> page <url>
*/
const fs = require('fs');
const path = require('path');
const yaml = require('js-yaml');
const { openSession, navigateAndWait, scrollAndCollect, cdpEval } = require('./cdp-client');
const { evaluateBrowserBlock } = require('./block-check');
// ── Spec defaults ────────────────────────────────────────────────────────────
// Defaults reproduce the existing hard-coded behaviour of fetcher-browser.js,
// so a minimal helper.md (just urlPattern + listSelector) already improves on
// legacy without forcing the author to redeclare every field.
const DEFAULTS = {
// ── Discovery ──
listSelector: null, // CSS selector for the article-list container(s); null = whole page
linkSelector: 'a[href]', // candidate links inside each container
urlPattern: null, // REQUIRED — regex source; article URL validation
excludeUrlPattern: null, // regex source; URLs to drop even if they match urlPattern
scrollSteps: 0, // bottom-scroll iterations to trigger lazy-load (fixed-steps mode)
maxScrollSteps: 12, // scroll ceiling in target-count mode (archive harvest tops up
// candidates until dedup can still satisfy the requested count)
scrollWaitMs: 1500, // pause after each scroll
waitMs: 12000, // initial render wait after load
maxLinks: 30,
// ── Extraction ──
bodySelectors: [
'[data-testid^="paragraph"]',
'[class*="articleBodyContent"] p',
'[class*="article-body"] p',
'[class*="ArticleBody"] p',
'[class*="story-body"] p',
'article p'
],
bodyMinParagraphs: 3, // a selector must yield ≥ this many nodes to "win"
// Article validation: when validateArticle is true, fetchPage additionally
// probes og:type + JSON-LD @type + body length and returns `isArticle`. A
// recipe with a broad urlPattern (so candidate URLs aren't pre-filtered by
// fragile slug heuristics) sets this to reject section/category pages that
// slip through the URL filter once the page is actually fetched. Off by
// default → existing recipes keep their original behaviour.
validateArticle: false,
bodyMinChars: 500,
dateSelector: 'time[datetime]',
authorSelector: '[rel="author"], [class*="author"] a',
imageSelector: 'img',
imageMinWidth: 200,
excludeImage: ['logo', 'icon', 'avatar', 'profile'],
// CSS selector: any <img> whose closest ancestor (or itself) matches is
// dropped during extraction. Use this to exclude "recommended articles" /
// "related articles" thumbnails that share the same CDN host as the hero
// image (so src-substring excludeImage can't tell them apart). null = off.
excludeImageSelector: null,
noisePrefixes: [
'Reporting by', 'Editing by', 'Access unmatched', 'Browse an unrivalled',
'Screen for heightened', 'Sign up here', 'Reuters, the news',
'All quotes delayed', 'See here for'
],
titleStripSuffix: ' \\| Reuters| \\| The Conversation| - Bloomberg$', // regex source, applied with /g
maxBodyChars: 15000
};
// Parse a helper.md file into a fully-defaulted spec object.
// Throws if frontmatter is missing or urlPattern is absent.
function loadSpec(helperPath) {
const raw = fs.readFileSync(helperPath, 'utf8');
const fm = raw.match(/^---\s*\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n|$)/);
if (!fm) throw new Error(`No YAML frontmatter found in ${helperPath}`);
const parsed = yaml.load(fm[1]) || {};
const spec = { ...DEFAULTS, ...parsed };
if (!spec.urlPattern) {
throw new Error(`helper.md ${path.basename(helperPath)} missing required 'urlPattern'`);
}
return spec;
}
// ── Expression builders ──────────────────────────────────────────────────────
// Each builder returns a JS expression string to be evaluated in the page via
// Runtime.evaluate. Spec values are embedded via JSON.stringify so quoting and
// backslashes survive the round-trip (YAML → js-yaml → JS string → JSON → expr).
function buildDiscoveryExpr(spec) {
const listSel = spec.listSelector ? JSON.stringify(spec.listSelector) : 'null';
const linkSel = JSON.stringify(spec.linkSelector);
const urlPattern = JSON.stringify(spec.urlPattern);
const excludePattern = spec.excludeUrlPattern ? JSON.stringify(spec.excludeUrlPattern) : 'null';
// listSelector may match one big container OR many card containers; handle
// both by iterating every match and collecting links within each.
return `(function(){
var roots = ${listSel} ? Array.from(document.querySelectorAll(${listSel})) : [document];
var urlRe = new RegExp(${urlPattern});
var exRe = ${excludePattern} ? new RegExp(${excludePattern}) : null;
var out = [], seen = {};
for (var r = 0; r < roots.length; r++) {
var links = Array.from(roots[r].querySelectorAll(${linkSel}));
for (var i = 0; i < links.length; i++) {
var href = links[i].href;
if (!href) continue;
try { var u = new URL(href); u.search = ''; u.hash = ''; href = u.toString(); }
catch (e) { continue; }
if (!urlRe.test(href)) continue;
if (exRe && exRe.test(href)) continue;
if (seen[href]) continue;
seen[href] = true;
out.push({ url: href, text: (links[i].innerText || links[i].textContent || '').trim().substring(0, 200) });
}
}
return JSON.stringify(out);
})()`;
}
function buildExtractExpr(spec) {
const bodySelectors = JSON.stringify(spec.bodySelectors);
const bodyMinParagraphs = spec.bodyMinParagraphs;
const dateSelector = JSON.stringify(spec.dateSelector);
const authorSelector = JSON.stringify(spec.authorSelector);
const imageMinWidth = spec.imageMinWidth;
const excludeImage = JSON.stringify(spec.excludeImage);
// imageSelector scopes which <img> nodes are candidates for download. The
// default 'img' (whole document) is kept for backward compat, but recipes
// should narrow it to the article-body container so bottom-of-page
// "recommended articles" thumbnails aren't pulled into assets/. Supported
// as a single CSS selector or a comma-separated list (querySelectorAll
// handles both); null/empty falls back to 'img'.
const imageSelector = JSON.stringify(spec.imageSelector || 'img');
// excludeImageSelector: drop an <img> if it (or any ancestor) matches this
// CSS selector. Comma-separated lists are valid. null → no container-based
// exclusion (existing behaviour). Used to filter "recommended articles"
// thumbnails that share a CDN host with the hero image.
const excludeImageSelector = spec.excludeImageSelector
? JSON.stringify(spec.excludeImageSelector) : 'null';
const noisePrefixes = JSON.stringify(spec.noisePrefixes);
const titleStripRe = JSON.stringify(spec.titleStripSuffix);
const maxBodyChars = spec.maxBodyChars;
// Article-validation knobs (see DEFAULTS). When validateArticle is false the
// isArticle signal is always true and the og:type/JSON-LD probes are skipped,
// so non-recipe / non-validating sources keep their original behaviour.
const validateArticle = !!spec.validateArticle;
const bodyMinChars = spec.bodyMinChars || 500;
return `(function(){
var title = document.title.replace(new RegExp(${titleStripRe}, 'g'), '').trim();
// JSON-LD fallback: many sites (Nikkei, Reuters) embed datePublished/author
// only in <script type="application/ld+json">, not in <time> or visible
// author links. Parse it once up front so the CSS-selector results can
// fall back to it below.
var ld = null;
var ldEl = document.querySelector('script[type="application/ld+json"]');
if (ldEl) { try { ld = JSON.parse(ldEl.textContent); } catch (e) {} }
function ldGet(obj, key) {
if (!obj) return null;
if (Array.isArray(obj)) { for (var i=0;i<obj.length;i++){ var v=ldGet(obj[i],key); if(v) return v; } return null; }
if (obj[key]) return obj[key];
if (obj['@graph']) return ldGet(obj['@graph'], key);
return null;
}
var ldDate = ldGet(ld, 'datePublished');
var ldAuthor = ldGet(ld, 'author');
var ldAuthorNames = [];
if (Array.isArray(ldAuthor)) {
ldAuthorNames = ldAuthor.map(function(a){ return typeof a === 'string' ? a : (a && a.name) || ''; }).filter(Boolean);
} else if (ldAuthor) {
ldAuthorNames = [typeof ldAuthor === 'string' ? ldAuthor : (ldAuthor.name || '')].filter(Boolean);
}
var dateEl = document.querySelector(${dateSelector});
var date = dateEl ? dateEl.getAttribute('datetime') : '';
if (!date && ldDate) date = ldDate;
var authors = Array.from(document.querySelectorAll(${authorSelector}))
.map(function(a){ return a.innerText.trim(); }).filter(Boolean)
.filter(function(v,i,a){ return a.indexOf(v) === i; }).join(', ');
if (!authors && ldAuthorNames.length) authors = ldAuthorNames.join(', ');
var exclImgSel = ${excludeImageSelector};
var imgs = Array.from(document.querySelectorAll(${imageSelector}))
.filter(function(img){
// Container-based exclusion: drop if the img (or any ancestor) sits
// inside a "recommended/related articles" block. Skipped when no
// excludeImageSelector is configured.
return !exclImgSel || !img.closest(exclImgSel);
})
.map(function(img){
return { src: img.src, alt: img.alt || '', width: img.naturalWidth || img.width || 0 };
}).filter(function(i){
return i.src && i.src.indexOf('http') === 0 && i.width > ${imageMinWidth}
&& !${excludeImage}.some(function(x){ return i.src.toLowerCase().indexOf(x) !== -1; });
}).slice(0, 6);
var body = '';
var bodyParagraphs = 0;
var sels = ${bodySelectors};
for (var s = 0; s < sels.length; s++) {
var els = document.querySelectorAll(sels[s]);
if (els.length >= ${bodyMinParagraphs}) {
bodyParagraphs = els.length;
body = Array.from(els).map(function(e){ return e.innerText.trim(); })
.filter(function(t){ return t.length > 40; }).join('\\n\\n');
break;
}
}
if (!body) {
var fallback = Array.from(document.querySelectorAll('p'))
.map(function(e){ return e.innerText.trim(); })
.filter(function(t){ return t.length > 60
&& !${noisePrefixes}.some(function(n){ return t.indexOf(n) === 0; }); });
bodyParagraphs = fallback.length;
body = fallback.join('\\n\\n');
}
// Article-validation signal: og:type + JSON-LD @type + body length. A
// section/category page typically has og:type=website, @type=Thing, and
// zero body paragraphs, so this cleanly separates real articles from the
// candidate URLs a widened urlPattern lets through on list pages. When
// validateArticle is off the probe cost is avoided and isArticle is true.
var ogType = '', ldType = '', isArticle = true;
if (${validateArticle ? 'true' : 'false'}) {
var ogEl = document.querySelector('meta[property="og:type"]');
if (ogEl) ogType = ogEl.content || '';
if (ld) {
var t = ldGet(ld, '@type');
ldType = Array.isArray(t) ? t.join(',') : (t || '');
}
isArticle = (ogType === 'article' || /NewsArticle|Article/.test(ldType))
&& body.length >= ${bodyMinChars}
&& bodyParagraphs >= ${bodyMinParagraphs};
}
return JSON.stringify({
title: title, date: date, authors: authors, imgs: imgs,
body: body.substring(0, ${maxBodyChars}),
isArticle: isArticle, ogType: ogType, ldType: ldType,
bodyChars: body.length, bodyParagraphs: bodyParagraphs
});
})()`;
}
// ── Public API ───────────────────────────────────────────────────────────────
// Discover article links on a listing page. Returns absolute URL strings
// (deduped, query/hash stripped, capped at spec.maxLinks) — the same shape
// legacy fetchHomeLinks returns so harvest.js needs no changes downstream.
//
// Optional `desiredCount` (archive harvest): when > 0, switch scrollAndCollect
// into target-count mode so a lazy stream keeps loading until ~desiredCount
// candidates exist (or the stream goes stale / hits maxScrollSteps). This lets
// the harvest top up candidates enough that, after registry dedup, the
// requested count of *new* articles can still be satisfied — instead of being
// capped by the fixed `scrollSteps` lazy-load pass. The slice cap is widened
// to `max(maxLinks, desiredCount)` so the extra candidates aren't discarded.
async function fetchHomeLinks(url, spec, desiredCount) {
const { ws, close } = await openSession();
try {
await navigateAndWait(ws, url, spec.waitMs);
await evaluateBrowserBlock(ws);
const opts = {
waitMs: spec.scrollWaitMs,
collectExpr: buildDiscoveryExpr(spec),
dedupKey: item => item.url
};
if (desiredCount && desiredCount > 0) {
opts.targetCount = desiredCount;
opts.maxSteps = spec.maxScrollSteps;
} else {
opts.steps = spec.scrollSteps;
}
const items = await scrollAndCollect(ws, opts);
const cap = (desiredCount && desiredCount > 0)
? Math.max(spec.maxLinks || 30, desiredCount)
: (spec.maxLinks || 30);
return items.map(i => i.url).slice(0, cap);
} finally {
close();
}
}
// Extract a single article's content. Returns { title, date, authors, imgs, body }
// or null if evaluation yielded nothing.
async function fetchPage(url, spec) {
const { ws, close } = await openSession();
try {
await navigateAndWait(ws, url, spec.waitMs);
await evaluateBrowserBlock(ws);
const result = await cdpEval(ws, buildExtractExpr(spec));
return result ? JSON.parse(result) : null;
} finally {
close();
}
}
module.exports = { loadSpec, fetchHomeLinks, fetchPage };
// ── CLI (manual testing) ─────────────────────────────────────────────────────
// node fetcher-helper.js helpers/reuters.md links https://www.reuters.com/
// node fetcher-helper.js helpers/reuters.md page https://www.reuters.com/world/...
if (require.main === module) {
const [helperPath, mode, url] = process.argv.slice(2);
if (!helperPath || !mode || !url) {
console.error('Usage: node fetcher-helper.js <helperPath> links|page <url>');
process.exit(1);
}
const spec = loadSpec(helperPath);
console.error(`Loaded spec: ${path.basename(helperPath)} (urlPattern=${spec.urlPattern}, listSelector=${spec.listSelector || '(whole page)'})`);
const fn = mode === 'links' ? fetchHomeLinks : fetchPage;
fn(url, spec).then(data => {
process.stdout.write(JSON.stringify(data, null, 2));
}).catch(e => { console.error('Error:', e.message); process.exit(1); });
}
#!/usr/bin/env node
/**
* finalize.js - Patch Chinese summaries into an already-archived day directory.
* Usage: node finalize.js <dayDir>
*
* Reads:
* <dayDir>/.manifest.json (written by harvest.js — maps url → slug → original filename)
* <dayDir>/.summaries.json (written by the agent — Chinese title + 4-section summary)
*
* For each article with a summary:
* 1. Write <dayDir>/<中文标题>.md (the Chinese summary doc)
* 2. Patch the sentinel __SUMMARY_<slug>__ in the original 原文.md (related + footer wikilink)
* 3. Update the registry entry (titleZh / category / tags)
* Finally rewrites index.md with real titles and removes the two temp files.
*
* Missing summary for an article → warn and leave its sentinel in place (non-fatal).
* Missing .summaries.json → error and exit.
*/
const fs = require('fs');
const path = require('path');
const {
generateSummaryDoc,
updateIndex,
sentinelFor,
originalFilename
} = require('./harvest');
function loadJson(p, label) {
if (!fs.existsSync(p)) {
console.error(`✗ ${label} not found: ${p}`);
process.exit(1);
}
try {
return JSON.parse(fs.readFileSync(p, 'utf8'));
} catch (e) {
console.error(`✗ Failed to parse ${label}: ${e.message}`);
process.exit(1);
}
}
function printHelp() {
console.log(`finalize.js — Patch Chinese summaries into an archived day directory.
Usage:
node finalize.js <dayDir>
Arguments:
dayDir Path to the YYYYMMDD directory produced by harvest.js
(e.g. /path/to/vault/The Conversation/20260724)
Prerequisites:
- <dayDir>/.manifest.json (written by harvest.js)
- <dayDir>/.summaries.json (written by the agent — Chinese title + 4-section summary)
What it does:
1. Writes <dayDir>/<中文标题>.md for each article with a summary
2. Replaces sentinel placeholders in the 原文.md files with real titles
3. Updates registry.json and index.md with real titles/categories/tags
4. Removes .manifest.json and .summaries.json
Options:
-h, --help Show this help message
Example:
node finalize.js "/Users/me/obsidian/Harvester/The Conversation/20260724"`);
}
function main() {
const args = process.argv.slice(2);
if (args.includes('-h') || args.includes('--help')) {
printHelp();
process.exit(0);
}
const dayDir = args.find(a => !a.startsWith('-'));
if (!dayDir) {
console.error('Usage: node finalize.js <dayDir>');
console.error('Example: node finalize.js /path/to/vault/The Conversation/20260724');
console.error('Run "node finalize.js --help" for details.');
process.exit(1);
}
if (!fs.existsSync(dayDir) || !fs.statSync(dayDir).isDirectory()) {
console.error(`✗ Day directory does not exist: ${dayDir}`);
process.exit(1);
}
const manifestPath = path.join(dayDir, '.manifest.json');
const summariesPath = path.join(dayDir, '.summaries.json');
const manifest = loadJson(manifestPath, '.manifest.json');
const summaries = loadJson(summariesPath, '.summaries.json');
const sourceName = manifest.sourceName || 'Unknown';
// Index summaries by url for O(1) lookup
const summaryByUrl = new Map();
for (const s of (Array.isArray(summaries) ? summaries : [])) {
if (s && s.url) summaryByUrl.set(s.url, s);
}
console.error(`\n🖌️ Finalizing ${dayDir}`);
console.error(` Articles in manifest: ${manifest.articles.length}`);
console.error(` Summaries provided: ${summaryByUrl.size}`);
// Build the registry path (same convention as harvest.js)
const regPath = path.join(dayDir, 'registry.json');
let registry = { date: path.basename(dayDir), source: manifest.sourceId, articles: [] };
if (fs.existsSync(regPath)) {
try { registry = JSON.parse(fs.readFileSync(regPath, 'utf8')); } catch (e) {}
}
let patched = 0, skipped = 0;
for (const article of manifest.articles) {
const { url, slug, titleEn, originalFilename: origName } = article;
const summary = summaryByUrl.get(url);
if (!summary) {
console.error(` ⚠️ No summary for: ${titleEn} — leaving sentinel in place`);
skipped++;
continue;
}
const titleZh = summary.titleZh;
if (!titleZh || typeof titleZh !== 'string' || !titleZh.trim()) {
console.error(` ⚠️ Empty titleZh for: ${titleEn} — skipping`);
skipped++;
continue;
}
const sentinel = sentinelFor(slug);
// Summary docs live in a `summary/` subdirectory (sibling of `assets/`).
// Wikilinks stay bare-title ([[中文标题]]); Obsidian resolves them by basename.
const summaryDir = path.join(dayDir, 'summary');
fs.mkdirSync(summaryDir, { recursive: true });
const summaryFilePath = path.join(summaryDir, `${titleZh}.md`);
const origFilePath = path.join(dayDir, origName || originalFilename(titleEn, sourceName));
// 1. Write the Chinese summary doc
const summaryDoc = generateSummaryDoc({
titleZh,
titleEn,
date: article.date,
authors: article.authors,
source: url,
category: summary.category || article.category,
tags: summary.tags || article.tags,
summaryZh: summary.summaryZh
}, sourceName);
fs.writeFileSync(summaryFilePath, summaryDoc);
// 2. Patch the original doc: replace the title sentinel, then overwrite the
// frontmatter `tags:` block and `category:` line with the agent-extracted
// values so 原文.md carries the same tags/category as the summary doc.
if (fs.existsSync(origFilePath)) {
const orig = fs.readFileSync(origFilePath, 'utf8');
// Replace all occurrences of the sentinel with the real title.
let patchedOrig = orig.split(sentinel).join(titleZh);
// Overwrite the tags block (tags:\n - x\n - y\n) with agent-extracted tags.
const finalTags = (summary.tags && summary.tags.length) ? summary.tags : article.tags;
const newTagsYaml = (finalTags || []).map(t => ` - ${t}`).join('\n');
patchedOrig = patchedOrig.replace(/(^tags:\n)(?: - .*\n)+/m, `$1${newTagsYaml}\n`);
// Overwrite the category line if the agent supplied one.
if (summary.category) {
patchedOrig = patchedOrig.replace(/^category: .*/m, `category: ${summary.category}`);
}
fs.writeFileSync(origFilePath, patchedOrig);
} else {
console.error(` ⚠️ Original doc missing: ${origName} — summary written but link unpatched`);
}
// 3. Update the registry entry for this url
const regEntry = registry.articles.find(a => a.url === url);
if (regEntry) {
regEntry.titleZh = titleZh;
if (summary.category) regEntry.category = summary.category;
if (summary.tags) regEntry.tags = summary.tags;
}
patched++;
console.error(` ✓ ${titleEn}${titleZh}`);
}
// Persist the patched registry
fs.writeFileSync(regPath, JSON.stringify(registry, null, 2));
// 4. Rewrite index.md using real titles (from the now-updated registry).
// Index links resolve to summary docs where a titleZh exists, else sentinel.
const indexArticles = registry.articles.map(a => ({
titleZh: a.titleZh,
titleEn: a.titleEn || '',
date: a.date || '',
category: a.category || '国际',
tags: a.tags || []
}));
updateIndex(dayDir, indexArticles, sourceName);
// 5. Cleanup temp files
try { fs.unlinkSync(manifestPath); } catch (e) {}
try { fs.unlinkSync(summariesPath); } catch (e) {}
console.error(`\n✅ Done: ${patched} finalized, ${skipped} skipped`);
console.error(` Registry + index updated, temp files removed`);
// 6. Auto-push to News Hub if enabled and configured. Push failures do NOT
// abort finalize (the archive is already complete and re-pushable), so
// a backend hiccup never leaves the local archive in a bad state.
maybeAutoPush(dayDir);
}
// Chain into push.js when config.push.enabled && autoPushAfterFinalize.
// Runs push.js as a child process so a push crash can't take down finalize.
function maybeAutoPush(dayDir) {
const cfgPath = path.join(__dirname, '..', 'config.json');
let cfg;
try { cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); }
catch (e) { return; } // no config → skip silently
const p = cfg.push;
if (!p || !p.enabled || !p.autoPushAfterFinalize) return;
if (!p.endpoint || !p.appId || !p.appKeyId || !p.appSecret) {
console.error(`\n⚠️ Push enabled but config incomplete (endpoint/appId/appKeyId/appSecret) — skipping.`);
console.error(` Run manually once configured: node scripts/push.js "${dayDir}"`);
return;
}
console.error(`\n📤 Auto-pushing to News Hub (config.push.autoPushAfterFinalize = true)`);
const { spawnSync } = require('child_process');
const r = spawnSync(process.execPath, [path.join(__dirname, 'push.js'), dayDir], {
stdio: ['ignore', 'inherit', 'inherit'], // stdout/stderr → this process
});
if (r.status !== 0) {
console.error(`\n⚠️ Push exited with code ${r.status} — local archive is unaffected.`);
console.error(` Fix the issue and re-run: node scripts/push.js "${dayDir}"`);
}
}
if (require.main === module) {
main();
}
#!/usr/bin/env node
/**
* harvest.js - Main entry point for news harvesting
* Usage:
* node harvest.js <source_id> <count> # archive: fetch + write originals/registry/index
* node harvest.js <source_id> <count> --preview # read-only: print article list (no writes)
* node harvest.js <source_id> <count> --preview --full # read-only + full body
*
* Archive flow (writes everything deterministic to disk itself):
* fetch links → dedup → fetch each article → download images → write 原文.md +
* registry.json + index.md (using sentinel placeholder for the Chinese title) →
* print a COMPACT manifest (no full body) + write .manifest.json.
* The agent then reads the manifest, writes .summaries.json, and runs finalize.js
* to generate the Chinese summary docs and patch the sentinel links.
*/
const fs = require('fs');
const path = require('path');
const https = require('https');
const http = require('http');
const { HumanInterventionError } = require('./block-check');
const SKILL_DIR = path.dirname(__dirname);
const CONFIG_PATH = path.join(SKILL_DIR, 'config.json');
const TEMPLATE_PATH = path.join(SKILL_DIR, 'references', 'config-template.json');
// Body preview length sent to the agent (full body stays on disk, never in context)
const PREVIEW_LEN = 4000;
// Upper bound on candidate links to request from a fetcher when topping up.
// The harvest asks for `count + seen.size + buffer` candidates so registry
// dedup can still leave `count` *new* articles; this caps that figure so a
// large 7-day registry can't trigger hundreds of scroll iterations. The
// fetcher's stale-exit stops early when a lazy stream runs dry, so a high
// ask on a shallow stream is still cheap.
const MAX_CANDIDATE_LINKS = 100;
// ── Helpers ─────────────────────────────────────────────────────────────────
function loadConfig() {
if (!fs.existsSync(CONFIG_PATH)) {
const template = JSON.parse(fs.readFileSync(TEMPLATE_PATH, 'utf8'));
fs.writeFileSync(CONFIG_PATH, JSON.stringify(template, null, 2));
console.error('Created config.json from template');
}
return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
}
function saveConfig(config) {
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
}
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
function todayStr() {
const d = new Date();
return `${d.getFullYear()}${String(d.getMonth()+1).padStart(2,'0')}${String(d.getDate()).padStart(2,'0')}`;
}
// Return the full ISO 8601 timestamp for frontmatter `date:` fields. Storing
// the complete timestamp (not a truncated YYYY-MM-DD) is lossless and lets
// Obsidian render it in any timezone — truncating the raw UTC string caused
// off-by-one errors for articles published near UTC midnight.
function dateShort(dateStr) {
return (dateStr && dateStr.trim()) || todayStr().replace(/(\d{4})(\d{2})(\d{2})/, '$1-$2-$3');
}
// Compact YYYY-MM-DD for the index.md table cell (full ISO is too wide there).
// Falls back to the raw string if it isn't a valid date.
function dateCell(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr);
if (isNaN(d.getTime())) return dateStr.substring(0, 10);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
// Format a published timestamp for display in the Shanghai timezone (UTC+8),
// e.g. "2026-07-31 05:47". Used in the info callouts of 原文.md / 摘要.md.
function dateShanghai(dateStr) {
if (!dateStr) return '—';
const d = new Date(dateStr);
if (isNaN(d.getTime())) return dateStr;
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hour12: false
}).formatToParts(d);
const get = t => (parts.find(p => p.type === t) || {}).value || '';
return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')}`;
}
function getCategory(url, title) {
const u = url.toLowerCase(), t = title.toLowerCase();
if (/asia-pacific|pakistan|afghanistan/.test(u) || /巴基斯坦|阿富汗/.test(t)) return '地缘政治';
if (/\/ai\/|anthropic|openai|tech/.test(u) || /\bai\b|人工智能|科技/.test(t)) return '科技/AI';
if (/china|中美/.test(u) || /中国|中美/.test(t)) return '中美关系';
if (/business|finance|housing|economy/.test(u) || /经济|财经|住房/.test(t)) return '财经';
if (/sustainability|feminist|society/.test(u) || /社会|女权/.test(t)) return '社会';
if (/ukraine|russia|middle-east/.test(u) || /冲突|战争/.test(t)) return '国际冲突';
return '国际';
}
const CATEGORY_EMOJI = {
'地缘政治': '🌏', '科技/AI': '🤖', '中美关系': '🇨🇳',
'财经': '💰', '社会': '🧑‍🤝‍🧑', '国际冲突': '⚔️', '国际': '📰'
};
function slugFromUrl(url) {
return url.split('/').filter(Boolean).slice(-2).join('-')
.replace(/[^a-z0-9-]/gi, '-').replace(/-+/g, '-').substring(0, 60);
}
// Placeholder Chinese title used in 原文.md links, registry and index until the
// agent supplies a real translation. finalize.js replaces it with the real title.
function sentinelFor(slug) {
return `__SUMMARY_${slug}__`;
}
function originalFilename(titleEn, sourceName) {
const safeTitle = titleEn.replace(/[\\/:*?"<>|]/g, '-').replace(/-+/g, '-').trim();
return `${safeTitle} - ${sourceName}原文.md`;
}
// ── Registry ─────────────────────────────────────────────────────────────────
function normalizeUrl(u) {
try { const p = new URL(u); p.search = ''; return p.toString(); }
catch (e) { return u; }
}
function buildDedupSet(config, sourceId) {
const seen = new Set();
const windowDays = config.registryWindowDays || 7;
const vaultPath = config.vaultPath;
const source = config.sources.find(s => s.id === sourceId);
if (!source) return seen;
// vaultFolder 含 "/" 时(如 "日经亚洲/世界"),从主目录遍历所有二级子目录的 registry.json,
// 实现同主目录下不同栏目的跨栏目去重;否则只扫描自身目录。
const parts = source.vaultFolder.split('/');
let dayRoots;
if (parts.length > 1) {
const primary = path.join(vaultPath, parts[0]);
if (!fs.existsSync(primary)) return seen;
dayRoots = fs.readdirSync(primary)
.filter(sub => fs.statSync(path.join(primary, sub)).isDirectory())
.map(sub => path.join(primary, sub));
} else {
const folder = path.join(vaultPath, source.vaultFolder);
if (!fs.existsSync(folder)) return seen;
dayRoots = [folder];
}
const today = new Date();
for (let i = 0; i < windowDays; i++) {
const d = new Date(today); d.setDate(today.getDate() - i);
const dayStr = `${d.getFullYear()}${String(d.getMonth()+1).padStart(2,'0')}${String(d.getDate()).padStart(2,'0')}`;
for (const root of dayRoots) {
const regPath = path.join(root, dayStr, 'registry.json');
if (fs.existsSync(regPath)) {
try {
const reg = JSON.parse(fs.readFileSync(regPath, 'utf8'));
(reg.articles || []).forEach(a => seen.add(normalizeUrl(a.url)));
} catch (e) {
console.error(` ⚠️ Failed to parse registry ${regPath}: ${e.message}`);
}
}
}
}
return seen;
}
function appendRegistry(config, source, today, article) {
const regPath = path.join(config.vaultPath, source.vaultFolder, today, 'registry.json');
let reg = { date: today, source: source.id, articles: [] };
if (fs.existsSync(regPath)) {
try { reg = JSON.parse(fs.readFileSync(regPath, 'utf8')); } catch (e) {}
}
reg.articles.push(article);
fs.writeFileSync(regPath, JSON.stringify(reg, null, 2));
}
// ── Image Download ────────────────────────────────────────────────────────────
function downloadImage(url, dest) {
return new Promise((resolve) => {
const lib = url.startsWith('https') ? https : http;
const file = fs.createWriteStream(dest);
const req = lib.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
file.close();
return resolve(downloadImage(res.headers.location, dest));
}
res.pipe(file);
file.on('finish', () => { file.close(); resolve(true); });
});
req.on('error', () => { file.close(); resolve(false); });
req.setTimeout(15000, () => { req.destroy(); resolve(false); });
});
}
// ── Document Generation ───────────────────────────────────────────────────────
function generateSummaryDoc(data, sourceName) {
const { titleZh, titleEn, date, authors, source: url, category, tags } = data;
const tagsYaml = (tags || []).map(t => ` - ${t}`).join('\n');
const origName = originalFilename(titleEn, sourceName);
return `---
title: "${titleZh}"
date: ${dateShort(date)}
tags:
${tagsYaml}
category: ${category}
source: "${url}"
publisher: ${sourceName}
authors: "${authors || ''}"
type: 摘要
related: "[[${origName}]]"
saved: ${todayStr().replace(/(\d{4})(\d{2})(\d{2})/, '$1-$2-$3')}
---
# ${titleZh}
> [!info] 文章信息
> **发布:** ${dateShanghai(date)}
> **来源:** ${sourceName}
> **作者:** ${authors || '—'}
> **原文:** [[${origName}]]
---
${data.summaryZh || '(摘要生成中)'}
---
[[${origName}]]
`;
}
function generateOriginalDoc(data, sourceName, imgFiles) {
const { titleEn, titleZh, date, authors, url, body, category } = data;
const tags = (data.tags || []).map(t => ` - ${t}`).join('\n');
// Empty-alt guard: only render the caption line when alt text exists, so we
// never emit a bare `**` (empty bold) for avatar/tracking images.
const imgSection = imgFiles.length > 0
? imgFiles.map(f => {
const embed = `![[assets/${f.filename}]]`;
return f.alt ? `${embed}\n*${f.alt}*` : embed;
}).join('\n\n')
: '(无图片)';
return `---
title: "${titleEn}"
date: ${dateShort(date)}
tags:
${tags}
category: ${category}
source: "${url}"
publisher: ${sourceName}
authors: "${authors || ''}"
type: 原文
related: "[[${titleZh}]]"
saved: ${todayStr().replace(/(\d{4})(\d{2})(\d{2})/, '$1-$2-$3')}
---
# ${titleEn}
> [!info] Source
> **Published:** ${dateShanghai(date)}
> **URL:** ${url}
> **中文摘要:** [[${titleZh}]]
---
## 配图
${imgSection}
---
## 正文
${body || '(正文获取失败)'}
---
[[${titleZh}]]
`;
}
// ── index.md Update ───────────────────────────────────────────────────────────
function countAssets(dayDir) {
const assetsDir = path.join(dayDir, 'assets');
if (!fs.existsSync(assetsDir)) return 0;
try {
return fs.readdirSync(assetsDir).filter(n => fs.statSync(path.join(assetsDir, n)).isFile()).length;
} catch (e) { return 0; }
}
function updateIndex(dayDir, articles, sourceName) {
const indexPath = path.join(dayDir, 'index.md');
const today = path.basename(dayDir);
const dateFormatted = today.replace(/(\d{4})(\d{2})(\d{2})/, '$1-$2-$3');
const now = new Date().toISOString().replace('T', ' ').substring(0, 16) + ' CST';
const assetCount = countAssets(dayDir);
// Category stats
const catCount = {};
articles.forEach(a => { catCount[a.category] = (catCount[a.category] || 0) + 1; });
const catRows = Object.entries(catCount).map(([c, n]) => `| ${CATEGORY_EMOJI[c] || '📰'} ${c} | ${n} |`).join('\n');
const tableRows = articles.map((a, i) =>
`| ${i+1} | [[${a.titleZh}]] | ${a.titleEn || ''} | ${a.category} | ${(a.tags||[]).slice(0,3).join(', ')} | ${dateCell(a.date)} | ✅ |`
).join('\n');
const catNav = Object.entries(catCount).map(([cat, n]) => {
const items = articles.filter(a => a.category === cat).map(a => `- [[${a.titleZh}]]`).join('\n');
return `### ${CATEGORY_EMOJI[cat] || '📰'} ${cat}${n}篇)\n${items}`;
}).join('\n\n');
const content = `---
date: ${dateFormatted}
type: daily-index
source: ${sourceName}
total_articles: ${articles.length}
last_updated: "${new Date().toISOString()}"
---
# ${sourceName} Daily Index · ${dateFormatted}
> **最近更新:** ${now}
> **当日归档:** ${articles.length} 篇 | **图片资产:** ${assetCount}
> **涵盖分类:** ${Object.keys(catCount).join(' · ')}
---
## 📊 统计信息
| 分类 | 数量 |
|------|------|
${catRows}
| **合计** | **${articles.length}** |
---
## 📋 文章列表
| # | 标题 | 原文标题 | 分类 | 标签 | 发布时间 | 状态 |
|---|------|----------|------|------|----------|------|
${tableRows}
---
## 🗂️ 分类导航
${catNav}
`;
fs.writeFileSync(indexPath, content);
}
// Print a friendly human-intervention message and exit. Called when a fetcher
// detects a CAPTCHA / anti-bot challenge or a login-state-loss redirect — the
// harvest can't proceed autonomously, so we stop immediately rather than
// silently archiving empty/garbage articles. `context` says where it happened
// (homepage fetch vs article fetch) so the user knows the scope.
function exitForHumanIntervention(err, context) {
const kind = err.kind === 'captcha' ? '人机验证 / 反爬挑战' : '登录状态失效';
console.error('');
console.error('🚫 采集中止 — 需要人工介入');
console.error(` 原因:${kind}`);
console.error(` 详情:${err.message}`);
if (err.blockedUrl) console.error(` 触发页面:${err.blockedUrl}`);
if (err.pageTitle) console.error(` 页面标题:${err.pageTitle}`);
console.error(` 发生环节:${context}`);
console.error('');
console.error(' 建议操作:');
if (err.kind === 'captcha') {
console.error(' 1. 该源站点当前触发了人机验证/反爬挑战,自动采集无法绕过。');
console.error(' 2. 请稍后重试;若持续触发,可尝试降低采集频率或更换网络环境。');
console.error(' 3. 必要时运行 `node scripts/ensure-chromium.js --login --url <homepage>`');
console.error(' 手动打开浏览器完成验证后,再重新执行采集。');
} else {
console.error(' 1. 该源需要登录态,当前会话的登录状态可能已失效(cookie 过期/未登录)。');
console.error(' 2. 请运行 `node scripts/ensure-chromium.js --login --url <homepage>`,');
console.error(' 在弹出的浏览器中完成登录并完全退出浏览器(保存 cookie)。');
console.error(' 3. 确认 config.chromium.userDataDir 已指向该登录所用 profile,再重新采集。');
}
console.error('');
console.error(' 本次采集已终止。此前已成功归档的文章保留在 vault 中,');
console.error(' 可在处理完毕后重新运行 harvest(去重机制会跳过已归档的文章)。');
process.exit(70); // EX_SOFTWARE-ish: distinct from network/parse errors
}
// ── Main ──────────────────────────────────────────────────────────────────────
async function main() {
// Parse args: <source_id> [count] [--preview] [--full] [-h|--help]
const argv = process.argv.slice(2);
if (argv.includes('-h') || argv.includes('--help')) {
console.log(`harvest.js — Fetch and archive news articles from a configured source.
Usage:
node harvest.js <source_id> [count] [--preview] [--full]
Arguments:
source_id A source id from config.json (e.g. the-conversation, reuters)
count Max number of new articles to fetch (default 5)
Modes:
(default) Archive: fetch + write 原文.md / registry / index to vault,
print compact manifest (bodyPreview only, no full body)
--preview Read-only: print article list (titles + metadata + bodyPreview),
no files written
--preview --full Read-only + include full article body in output
Options:
-h, --help Show this help message
Examples:
node harvest.js the-conversation 3
node harvest.js the-conversation 3 --preview
node harvest.js the-conversation 3 --preview --full
node harvest.js reuters 5`);
process.exit(0);
}
const preview = argv.includes('--preview');
const full = argv.includes('--full');
const positional = argv.filter(a => !a.startsWith('-'));
const sourceId = positional[0];
const count = parseInt(positional[1] || '5');
if (!sourceId) {
console.error('Usage: node harvest.js <source_id> [count] [--preview] [--full]');
console.error('Example: node harvest.js reuters 5');
console.error(' node harvest.js the-conversation 3 --preview --full');
console.error('Run "node harvest.js --help" for details.');
process.exit(1);
}
const config = loadConfig();
const source = config.sources.find(s => s.id === sourceId);
if (!source) {
console.error(`Source "${sourceId}" not found. Available: ${config.sources.map(s=>s.id).join(', ')}`);
process.exit(1);
}
if (!source.enabled) {
console.error(`Source "${sourceId}" is disabled.`);
process.exit(1);
}
const mode = preview ? (full ? 'preview (full)' : 'preview') : 'archive';
console.error(`\n🗞️ Harvesting ${source.name} (max ${count} articles) — ${mode}`);
console.error(` Method: ${source.method}`);
// Strategy dispatch: if a helpers/<id>.md recipe exists for this source, use
// the spec-driven fetcher-helper; otherwise fall back to the legacy fetcher
// for its method. Existing sources keep working unchanged; a source opts in
// simply by gaining a helper.md file. See references/source-management.md
// (Helper recipes) for the schema.
const helperPath = path.join(SKILL_DIR, 'helpers', `${source.id}.md`);
let fetcher, spec = null;
if (fs.existsSync(helperPath)) {
const fetcherHelper = require('./fetcher-helper');
spec = fetcherHelper.loadSpec(helperPath);
fetcher = fetcherHelper;
console.error(` Recipe: helpers/${source.id}.md (listSelector=${spec.listSelector || 'whole page'}, scroll=${spec.scrollSteps})`);
} else {
fetcher = source.method === 'browser'
? require('./fetcher-browser')
: require('./fetcher-direct');
}
// CDP-based fetchers (browser method, or any source with a helper recipe)
// need a Chromium endpoint. ensure-chromium.js is a no-op if already running.
if (source.method === 'browser' || spec) {
const { ensureChromium } = require('./ensure-chromium');
console.error(' Ensuring Chromium (CDP)...');
try {
const r = await ensureChromium(config);
console.error(` CDP ready on :${r.port}${r.alreadyRunning ? ' (already running)' : ` (pid ${r.pid})`}`);
} catch (e) {
console.error(` Chromium startup failed:\n${e.message}`);
process.exit(1);
}
}
// Call-site adapters: legacy fetchers take (url, pattern) / (url[, waitMs]),
// while fetcher-helper takes (url, spec). The closures hide that difference
// so the main flow below is identical for both paths. NB: spec must NOT be
// passed to legacy fetchPage (it would be coerced to waitMs=NaN).
// Call-site adapters: legacy fetchers take (url, pattern) / (url[, waitMs]),
// while fetcher-helper takes (url, spec[, desiredCount]). The closures hide
// that difference so the main flow below is identical for both paths.
// NB: spec must NOT be passed to legacy fetchPage (it would be coerced to
// waitMs=NaN). desiredCount is only threaded into the spec path — legacy
// sources do a single-page fetch with no scroll/pagination, so they can't
// top up candidates (that's a separate, larger change).
const getLinks = (desiredCount) => spec
? fetcher.fetchHomeLinks(source.homepageUrl, spec, desiredCount)
: fetcher.fetchHomeLinks(source.homepageUrl, source.articleUrlPattern);
const getPage = (url) => spec
? fetcher.fetchPage(url, spec)
: fetcher.fetchPage(url);
// Build dedup set (skip in preview — preview shows latest regardless)
const seen = preview ? new Set() : buildDedupSet(config, sourceId);
console.error(` Registry: ${seen.size} URLs in last ${config.registryWindowDays || 7} days`);
// Ask the fetcher for enough candidate links that, after registry dedup
// eats the already-archived ones, `count` NEW articles can still remain.
// `count + seen.size` covers the worst case (every seen URL reappears in the
// candidates); `+ 10` is a buffer for candidates that fetch but are then
// dropped as low-content. Capped so a large registry can't trigger hundreds
// of scroll iterations — the fetcher's stale-exit stops a shallow stream
// early regardless. Preview stays null (fixed scrollSteps, speed-first).
const desiredCount = preview
? null
: Math.min(count + seen.size + 10, MAX_CANDIDATE_LINKS);
if (!preview && desiredCount > count) {
console.error(` Targeting ${desiredCount} candidates to net ${count} new after dedup`);
}
// Fetch homepage links
console.error(`\n📡 Fetching homepage: ${source.homepageUrl}`);
let links;
try {
links = await getLinks(desiredCount);
} catch (e) {
if (e instanceof HumanInterventionError) exitForHumanIntervention(e, '首页链接抓取');
console.error('Failed to fetch homepage:', e.message);
process.exit(1);
}
console.error(` Collected ${links.length} candidate links`);
// Normalize URLs (strip query params for dedup)
const normalizedLinks = [...new Set(links.map(u => normalizeUrl(u)))];
// Keep ALL new candidates rather than slicing to `count`: recipe sources with
// validateArticle reject section/category pages only at fetch time, so capping
// candidates here would shortchange the article target (section pages mixed
// into the first `count` candidates eat slots the buffer was meant to backfill).
// The fetcher already capped collection at desiredCount (count + seen + buffer
// ≤ MAX_CANDIDATE_LINKS), so this list is bounded; the fetch loops below stop
// once `count` articles are successfully archived.
const newLinks = normalizedLinks.filter(u => !seen.has(u));
const skipped = normalizedLinks.filter(u => seen.has(u)).length;
console.error(` New: ${newLinks.length} candidates | Already seen: ${skipped}`);
if (!preview && newLinks.length < count) {
console.error(` ⚠️ Only ${newLinks.length} new candidates of ${count} requested — stream may be exhausted (fewer fresh articles than asked). Re-running won't help; wait for new articles or broaden the source.`);
}
// ── Preview mode: fetch only, print compact list, write nothing ───────────
if (preview) {
const out = [];
for (let i = 0; i < newLinks.length; i++) {
if (out.length >= count) break; // cap on successful articles, not candidates
const url = newLinks[i];
console.error(`\n[${i+1}/${newLinks.length}] ${url}`);
let articleData;
try {
articleData = await getPage(url);
} catch (e) {
if (e instanceof HumanInterventionError) exitForHumanIntervention(e, `文章抓取(第 ${i+1} 篇)`);
console.error(` ✗ Fetch failed: ${e.message}`);
continue;
}
if (!articleData || !articleData.title) { console.error(' ✗ No title, skip'); continue; }
// Recipe article-validation: a broad urlPattern may let section/category
// pages through as candidate links; fetchPage marks them isArticle=false
// (via og:type / JSON-LD / body-length probes). Non-validating recipes
// always return isArticle=true, so this is a no-op for them.
if (articleData.isArticle === false) { console.error(' ✗ Not an article (og:type/body), skip'); continue; }
const category = getCategory(url, articleData.title);
const entry = {
title: articleData.title,
date: articleData.date,
authors: articleData.authors,
url,
category,
charCount: (articleData.body || '').length,
imgCount: (articleData.imgs || []).length,
imgs: (articleData.imgs || []).map(im => ({ alt: im.alt || '', src: im.src }))
};
if (full) {
entry.body = articleData.body || '';
} else {
entry.bodyPreview = (articleData.body || '').substring(0, PREVIEW_LEN);
}
out.push(entry);
console.error(` ✓ ${articleData.title} (${entry.charCount} chars, ${entry.imgCount} imgs)`);
}
process.stdout.write(JSON.stringify({
sourceId, sourceName: source.name, preview: true, full, count: out.length, articles: out
}, null, 2));
console.error(`\n✅ Preview: ${out.length} articles (no files written)`);
return;
}
// ── Archive mode: fetch + write originals/registry/index ─────────────────
if (newLinks.length === 0) {
console.error('No new articles to process.');
process.stdout.write(JSON.stringify({
sourceId, sourceName: source.name, today: todayStr(), dayDir: null,
summariesPath: null, articles: [], skipped, newCount: 0
}, null, 2));
return;
}
const today = todayStr();
const dayDir = path.join(config.vaultPath, source.vaultFolder, today);
const assetsDir = path.join(dayDir, 'assets');
fs.mkdirSync(assetsDir, { recursive: true });
const processed = [];
for (let i = 0; i < newLinks.length; i++) {
if (processed.length >= count) break; // cap on successful articles, not candidates
const url = newLinks[i];
console.error(`\n[${i+1}/${newLinks.length}] ${url}`);
let articleData;
try {
articleData = await getPage(url);
} catch (e) {
if (e instanceof HumanInterventionError) exitForHumanIntervention(e, `文章抓取(第 ${i+1} 篇)`);
console.error(` ✗ Fetch failed: ${e.message}`);
continue;
}
if (!articleData || !articleData.title) { console.error(' ✗ No title, skip'); continue; }
// Recipe article-validation: reject section/category pages that slipped
// through a broad urlPattern (fetchPage set isArticle=false via og:type /
// JSON-LD / body-length probes). No-op for non-validating recipes.
if (articleData.isArticle === false) { console.error(' ✗ Not an article (og:type/body), skip'); continue; }
if ((articleData.body || '').length < 100) {
console.error(` ✗ Insufficient content (body: ${(articleData?.body||'').length} chars)`);
continue;
}
const { title: titleEn, date, authors, imgs = [], body } = articleData;
const slug = slugFromUrl(url);
const category = getCategory(url, titleEn);
const tags = [source.name]; // placeholder; finalize overwrites with agent-extracted tags
const titleZh = sentinelFor(slug); // patched by finalize.js
const origName = originalFilename(titleEn, source.name);
// Download images
const imgFiles = [];
for (let j = 0; j < imgs.length; j++) {
const imgSrc = imgs[j].src;
const filename = `${slug}-${j+1}.jpg`;
const dest = path.join(assetsDir, filename);
console.error(` 📷 Downloading image ${j+1}...`);
const ok = await downloadImage(imgSrc, dest);
if (ok) imgFiles.push({ filename, alt: imgs[j].alt, src: imgSrc });
}
const articleInfo = {
url, titleEn, titleZh, date, authors, body,
imgs: imgFiles, category, tags, slug,
originalFilename: origName, sourceName: source.name
};
// Write original doc (full body on disk, never sent to the agent)
fs.writeFileSync(path.join(dayDir, origName), generateOriginalDoc(articleInfo, source.name, imgFiles));
// Append to registry (dedup record, written immediately so a crash keeps it)
appendRegistry(config, source, today, {
url, titleZh, titleEn, date, processedAt: new Date().toISOString(),
category, tags, slug, originalFilename: origName
});
// Refresh daily index from the running set (sentinel titles until finalize)
updateIndex(dayDir, [...processed, articleInfo], source.name);
processed.push(articleInfo);
console.error(` ${titleEn} (${imgFiles.length} imgs, ${body.length} chars) ${origName}`);
await sleep(1000);
}
if (!preview && processed.length < count) {
console.error(` ⚠️ Archived ${processed.length} of ${count} requested remaining candidates were non-article/low-content pages; stream ran out of real articles. Re-running won't help.`);
}
// Compact manifest for the agent: NO full body, only a preview slice.
const newArticles = processed.map(a => ({
url: a.url,
titleEn: a.titleEn,
date: a.date,
authors: a.authors,
category: a.category,
tags: a.tags,
slug: a.slug,
originalFilename: a.originalFilename,
charCount: (a.body || '').length,
imgs: a.imgs.map(im => ({ filename: im.filename, alt: im.alt || '', src: im.src })),
bodyPreview: (a.body || '').substring(0, PREVIEW_LEN)
}));
// Merge into any existing .manifest.json rather than overwriting it. A second
// harvest run in the same day dir (or recovery after a crash) must not lose
// the slug→filename mapping for articles already on disk but not yet finalized.
// finalize.js consumes the merged set so no sentinel is left dangling.
const manifestPath = path.join(dayDir, '.manifest.json');
let mergedArticles = newArticles;
if (fs.existsSync(manifestPath)) {
try {
const prev = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const prevArticles = Array.isArray(prev.articles) ? prev.articles : [];
const seenUrl = new Set(newArticles.map(a => a.url));
mergedArticles = [...newArticles, ...prevArticles.filter(a => !seenUrl.has(a.url))];
} catch (e) {
console.error(` ⚠️ Could not merge previous .manifest.json (${e.message}); overwriting`);
}
}
const manifest = {
sourceId,
sourceName: source.name,
today,
dayDir,
summariesPath: path.join(dayDir, '.summaries.json'),
skipped,
newCount: processed.length,
nextStep: `Read this manifest, write Chinese summaries to ${path.join(dayDir, '.summaries.json')}, then run: node scripts/finalize.js "${dayDir}"`,
summariesContract: {
path: path.join(dayDir, '.summaries.json'),
format: 'array of { url, titleZh, category, tags[], summaryZh } one per article url; summaryZh = the 4 sections markdown (事件概述/背景/关键引语/影响分析)',
batchHint: `If ${mergedArticles.length} articles is more than ~8, do NOT write all summaries in one response — ` +
`it will hit the output-token ceiling and get truncated. Work in batches of ≤4: write each batch to ` +
`${path.join(dayDir, '.summaries-batch.json')} and merge with "node scripts/summary-add.js \\"${dayDir}\\"" ` +
`(repeat per batch), then run finalize.js once. Check gaps with "node scripts/summary-add.js \\"${dayDir}\\" --status".`
},
articles: mergedArticles
};
// Persist the merged manifest so finalize.js can map every url → slug → original filename
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
// stdout shows only THIS run's new articles (the agent summarizes what it just fetched)
process.stdout.write(JSON.stringify({ ...manifest, articles: newArticles }, null, 2));
console.error(`\n✅ Done: ${processed.length} processed this run, ${skipped} skipped`);
console.error(` Day dir: ${dayDir}`);
console.error(` Next: write .summaries.json to ${path.join(dayDir, '.summaries.json')}, then run: node scripts/finalize.js "${dayDir}"`);
}
// Run only when invoked directly (not when required by finalize.js)
if (require.main === module) {
main().catch(e => { console.error('Fatal:', e); process.exit(1); });
}
// Exported for finalize.js
module.exports = {
generateSummaryDoc,
generateOriginalDoc,
updateIndex,
appendRegistry,
getCategory,
CATEGORY_EMOJI,
slugFromUrl,
sentinelFor,
originalFilename,
dateShort
};
#!/usr/bin/env node
/**
* push.js — Push a finalized day directory to the News Hub backend.
*
* Reads the finalized day directory produced by finalize.js (registry.json +
* 原文.md + summary/<中文标题>.md + assets/), uploads images to MinIO via the
* backend's /assets endpoint (SHA-256 deduped), then batch-pushes articles via
* /articles. Maintains a per-day .syncstate.json ledger for incremental resume.
*
* Usage:
* node scripts/push.js <dayDir> # push a single day directory
* node scripts/push.js <dayDir> --status # show sync status (read-only)
* node scripts/push.js <dayDir> --force # ignore ledger, re-push all
* node scripts/push.js -h | --help
*
* Prerequisites:
* - <dayDir>/registry.json exists (written by harvest.js, patched by finalize.js)
* - <dayDir>/.manifest.json and .summaries.json are ABSENT (= finalize done)
* - config.json has a `push` section (endpoint, appId, appKeyId, appSecret)
*
* Exit codes: 0 success (or all-pending-resolved), 1 usage/IO error,
* 2 partial failure (some articles still error after push).
*/
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { spawnSync } = require('child_process');
const yaml = require('js-yaml');
const CONFIG_PATH = path.join(__dirname, '..', 'config.json');
const SYNCSTATE = '.syncstate.json';
const MANIFEST = '.manifest.json';
const SUMMARIES = '.summaries.json';
const REGISTRY = 'registry.json';
// Summary section headers (finalize.js produces exactly these four, in order).
const SUMMARY_SECTIONS = ['事件概述', '背景', '关键引语', '影响分析'];
const SUMMARY_KEYS = ['overview', 'background', 'keyQuote', 'impact'];
// ── Helpers ───────────────────────────────────────────────────────────────
function loadConfig() {
if (!fs.existsSync(CONFIG_PATH)) {
console.error(`✗ config.json not found at ${CONFIG_PATH}`);
console.error(` Run a harvest script once to generate it, or copy references/config-template.json.`);
process.exit(1);
}
return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
}
function loadJson(p, label) {
if (!fs.existsSync(p)) {
console.error(`✗ ${label} not found: ${p}`);
process.exit(1);
}
try {
return JSON.parse(fs.readFileSync(p, 'utf8'));
} catch (e) {
console.error(`✗ Failed to parse ${label}: ${e.message}`);
process.exit(1);
}
}
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
function uuid() {
return crypto.randomUUID();
}
// Parse a `---\n...yaml...\n---` frontmatter block from markdown.
// Returns { fm: object|null, body: string } where body is the markdown AFTER
// the frontmatter (frontmatter stripped). Mirrors the pattern in
// fetcher-helper.js:77-82.
function parseFrontmatter(md) {
const m = md.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
if (!m) return { fm: null, body: md };
let fm = null;
try { fm = yaml.load(m[1]) || {}; } catch (e) { fm = null; }
return { fm, body: m[2] };
}
// Extract the four summary sections from the summary doc body.
// The body looks like:
// ## 事件概述\n<text>\n\n## 背景\n<text>\n\n## 关键引语\n> "..."\n\n## 影响分析\n<text>
// Returns { overview, background, keyQuote, impact } (missing sections = '').
function parseSummarySections(body) {
const out = { overview: '', background: '', keyQuote: '', impact: '' };
// Match `## <sectionName>` and capture everything until the next `## ` header,
// a horizontal rule (a `---` line surrounded by blank lines), or end of string.
// The `---` guard keeps the trailing footer (wikilink back-link) out of the
// last section, since the summary doc ends with `\n\n---\n\n[[原文]]`.
const names = SUMMARY_SECTIONS.join('|');
const re = new RegExp(
`## (${names})\\r?\\n([\\s\\S]*?)(?=\\r?\\n## (?:${names})|\\r?\\n\\r?\\n---\\r?\\n|$)`, 'g'
);
let m;
while ((m = re.exec(body)) !== null) {
const title = m[1];
const text = m[2].trim();
const idx = SUMMARY_SECTIONS.indexOf(title);
if (idx >= 0) out[SUMMARY_KEYS[idx]] = text;
}
return out;
}
// Parse image embeds from the 原文.md `## 配图` section.
// Format per image:
// ![[assets/<filename>]]
// *<alt text>* ← optional caption line; omitted when alt is empty
// Returns [{ filename, alt }].
function parseImages(originalMd) {
// Isolate the 配图 section: between `## 配图` and the next `## ` header.
const secMatch = originalMd.match(/## 配图\r?\n([\s\S]*?)(?=\r?\n## )/);
if (!secMatch) return [];
const section = secMatch[1];
const imgs = [];
const lines = section.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const embed = lines[i].match(/!\[\[assets\/([^\]]+)\]\]/);
if (!embed) continue;
const filename = embed[1].trim();
// Look at the next non-empty line for a caption `*...*`.
let alt = '';
for (let j = i + 1; j < lines.length; j++) {
const next = lines[j].trim();
if (next === '') continue;
const cap = next.match(/^\*(.+)\*$/);
if (cap) alt = cap[1].trim();
break;
}
imgs.push({ filename, alt });
}
return imgs;
}
// Compute SHA-256 of a file (for local dedup before upload).
function fileSha256(filePath) {
const buf = fs.readFileSync(filePath);
return crypto.createHash('sha256').update(buf).digest('hex');
}
// Locate the Chinese summary doc for an article. finalize.js (current) writes
// `summary/<titleZh>.md`; older layouts placed `<titleZh>.md` directly in the
// day dir. Try the subdir first, then fall back to the root. Returns '' if
// neither exists (the article is still pushed, just with an empty summary).
function readSummaryDoc(dayDir, titleZh) {
if (!titleZh) return '';
const sub = path.join(dayDir, 'summary', `${titleZh}.md`);
if (fs.existsSync(sub)) return fs.readFileSync(sub, 'utf8');
const root = path.join(dayDir, `${titleZh}.md`);
if (fs.existsSync(root)) return fs.readFileSync(root, 'utf8');
return '';
}
// Convert the 原文.md body (markdown AFTER frontmatter) into clean HTML for
// `originalDoc.body`. Strips Obsidian-specific syntax (callouts, wikilinks,
// `![[assets/...]]` embeds) and produces semantic HTML: <h2> for the English
// title, <figure>/<img> for images (src = `asset://<assetId>`), <p> for
// paragraphs, <blockquote> for quotes.
//
// `assetIdByFilename` maps `assets/<filename>` → assetId (from Phase 1 uploads).
// Images without a known assetId get `src=""` (the asset upload failed or the
// file was missing — the article still pushes, just without that image).
function mdToHtmlBody(md, titleEn, assetIdByFilename) {
const lines = md.split(/\r?\n/);
const out = [];
let i = 0;
let paraBuf = [];
const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const flush = () => {
if (paraBuf.length) {
const t = paraBuf.join(' ').trim();
if (t) out.push(`<p>${esc(t)}</p>`);
paraBuf = [];
}
};
while (i < lines.length) {
const trimmed = lines[i].trim();
// Skip callout block (> [!info] ... until next ---)
if (trimmed.match(/^>\s*\[!info\]/)) {
while (i < lines.length && lines[i].trim() !== '---') i++;
if (i < lines.length) i++;
continue;
}
// Skip bare wikilinks [[...]] (footer back-links)
if (trimmed.match(/^\[\[.+\]\]$/)) { flush(); i++; continue; }
// Skip horizontal rules
if (trimmed === '---') { flush(); i++; continue; }
// Skip section headers ## 配图 / ## 正文
if (trimmed.match(/^##\s+(配图|正文)$/)) { flush(); i++; continue; }
// # Title → <h2> (use frontmatter titleEn, more authoritative)
if (trimmed.match(/^#\s+(.+)$/)) {
flush();
out.push(`<h2>${esc(titleEn || RegExp.$1)}</h2>`);
i++; continue;
}
// Obsidian image embed ![[assets/<filename>]] + optional *alt* caption
const imgMatch = trimmed.match(/^!\[\[assets\/([^\]]+)\]\]$/);
if (imgMatch) {
flush();
const filename = imgMatch[1];
let alt = '';
if (i + 1 < lines.length) {
const cap = lines[i + 1].trim().match(/^\*(.+)\*$/);
if (cap) { alt = cap[1].trim(); i++; }
}
// Placeholder alt "alt" carries no real caption — drop the annotation.
if (alt === 'alt') alt = '';
const assetId = assetIdByFilename ? assetIdByFilename.get(filename) : null;
const src = assetId != null ? `asset://${assetId}` : '';
if (alt) {
out.push(`<figure><img src="${src}" alt="${esc(alt)}"><figcaption>${esc(alt)}</figcaption></figure>`);
} else {
out.push(`<img src="${src}" alt="">`);
}
i++; continue;
}
// Skip the "(无图片)" placeholder harvest.js writes when an article has
// no images — it should not appear in the pushed HTML body.
if (trimmed === '(无图片)') { i++; continue; }
// Blockquote > text
if (trimmed.match(/^>\s*(.*)$/)) {
flush();
const quotes = [];
while (i < lines.length && lines[i].trim().match(/^>\s*(.*)$/)) {
quotes.push(RegExp.$1); i++;
}
out.push(`<blockquote><p>${esc(quotes.join(' ').trim())}</p></blockquote>`);
continue;
}
// Blank line → flush paragraph
if (trimmed === '') { flush(); i++; continue; }
// Normal text line
paraBuf.push(trimmed);
i++;
}
flush();
return out.join('\n');
}
// ── HTTP client (native fetch, Node 18+/23) ──────────────────────────────
function authHeaders(cfg) {
return {
'X-App-Id': cfg.push.appId,
'X-App-Key-Id': cfg.push.appKeyId,
'X-App-Secret': cfg.push.appSecret,
};
}
async function retryingFetch(url, opts, retryCfg, label) {
const max = (retryCfg && retryCfg.maxAttempts) || 5;
const base = (retryCfg && retryCfg.baseDelayMs) || 1000;
let lastErr;
for (let attempt = 1; attempt <= max; attempt++) {
try {
const res = await fetch(url, opts);
// Retry on 5xx and 429; everything else is a definitive response.
if ((res.status >= 500 || res.status === 429) && attempt < max) {
const delay = base * Math.pow(2, attempt - 1);
console.error(` ↻ ${label}: HTTP ${res.status}, retry ${attempt}/${max} in ${delay}ms`);
await sleep(delay);
continue;
}
return res;
} catch (e) {
lastErr = e;
if (attempt < max) {
const delay = base * Math.pow(2, attempt - 1);
console.error(` ↻ ${label}: ${e.message}, retry ${attempt}/${max} in ${delay}ms`);
await sleep(delay);
continue;
}
}
}
// Exhausted retries — rethrow the last network error, or synthesize one.
throw lastErr || new Error(`${label}: exhausted retries`);
}
// Upload a single image. Returns { assetId, sha256, deduplicated } or null on failure.
async function uploadAsset(cfg, filePath, slug, alt) {
const endpoint = cfg.push.endpoint.replace(/\/$/, '');
const url = `${endpoint}/assets`;
const sha = fileSha256(filePath);
const fd = new FormData();
const buf = fs.readFileSync(filePath);
const filename = path.basename(filePath);
// Infer content type from extension (backend does the same, but set it anyway).
const ext = path.extname(filename).toLowerCase();
const ct = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
'.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml' }[ext]
|| 'application/octet-stream';
fd.append('file', new Blob([buf], { type: ct }), filename);
if (slug) fd.append('slug', slug);
if (alt) fd.append('alt', alt);
// originalSrc is unknown after finalize (manifest deleted); omit it.
const res = await retryingFetch(url, {
method: 'POST',
headers: { ...authHeaders(cfg), 'Idempotency-Key': uuid() },
body: fd,
}, cfg.push.retry, `asset ${filename}`);
const text = await res.text();
let body;
try { body = JSON.parse(text); } catch (e) { body = { raw: text }; }
if (!res.ok) {
const code = body && body.error && body.error.code;
const msg = body && body.error && body.error.message || text;
console.error(` ✗ asset ${filename}: HTTP ${res.status} ${code || ''} ${msg}`);
return null;
}
return {
assetId: body.assetId,
sha256: body.sha256 || sha,
deduplicated: !!body.deduplicated,
};
}
// Push a batch of articles (≤10). Returns the parsed response body.
async function pushArticleBatch(cfg, payload) {
const endpoint = cfg.push.endpoint.replace(/\/$/, '');
const url = `${endpoint}/articles`;
const res = await retryingFetch(url, {
method: 'POST',
headers: {
...authHeaders(cfg),
'Content-Type': 'application/json',
'Idempotency-Key': uuid(),
},
body: JSON.stringify(payload),
}, cfg.push.retry, `batch ${payload.clientBatchId}`);
const text = await res.text();
let body;
try { body = JSON.parse(text); } catch (e) { body = { raw: text }; }
if (!res.ok) {
const code = body && body.error && body.error.code;
const msg = body && body.error && body.error.message || text;
const err = new Error(`HTTP ${res.status} ${code || ''} ${msg}`);
err.status = res.status;
err.body = body;
throw err;
}
return body;
}
// ── Ledger (.syncstate.json) ─────────────────────────────────────────────
function loadSyncState(dayDir) {
const p = path.join(dayDir, SYNCSTATE);
if (!fs.existsSync(p)) return null;
try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch (e) { return null; }
}
function saveSyncState(dayDir, state) {
state.updatedAt = new Date().toISOString();
fs.writeFileSync(path.join(dayDir, SYNCSTATE), JSON.stringify(state, null, 2));
}
function newSyncState(registry) {
return {
source: registry.source,
harvestDate: registry.date,
lastSyncStatus: 'pending',
articles: registry.articles.map(a => ({
url: a.url,
slug: a.slug,
status: 'pending',
articleId: null,
version: null,
error: null,
})),
};
}
// Mark a url's result in the ledger.
function recordResult(state, url, result) {
const entry = state.articles.find(a => a.url === url);
if (!entry) return;
if (result.status === 'error') {
entry.status = 'error';
entry.error = result.error || null;
} else {
entry.status = 'synced';
entry.articleId = result.articleId != null ? String(result.articleId) : null;
entry.version = result.version != null ? result.version : null;
entry.error = null;
}
}
function summarizeState(state) {
let created = 0, updated = 0, unchanged = 0, failed = 0;
for (const a of state.articles) {
// We don't track created/updated/unchanged distinctly in the ledger across
// re-runs (a re-push of an already-synced url returns unchanged). For the
// final summary we report the latest batch's counts directly from the API.
if (a.status === 'error') failed++;
}
return { total: state.articles.length, failed };
}
// ── Core push flow ───────────────────────────────────────────────────────
async function pushDayDir(dayDir, cfg, opts) {
const force = !!opts.force;
// 1. Validate finalized state.
const regPath = path.join(dayDir, REGISTRY);
if (!fs.existsSync(regPath)) {
console.error(`✗ ${REGISTRY} not found in ${dayDir}`);
console.error(` Has harvest.js run for this day?`);
process.exit(1);
}
if (fs.existsSync(path.join(dayDir, MANIFEST)) || fs.existsSync(path.join(dayDir, SUMMARIES))) {
console.error(`✗ Day directory is not finalized yet (`.concat(MANIFEST, ' or ', SUMMARIES, ' still present).'));
console.error(` Run finalize.js first: node scripts/finalize.js "${dayDir}"`);
process.exit(1);
}
const registry = loadJson(regPath, REGISTRY);
const articles = Array.isArray(registry.articles) ? registry.articles : [];
if (articles.length === 0) {
console.error('✗ registry.json has no articles — nothing to push.');
process.exit(0);
}
const source = registry.source;
const harvestDate = registry.date;
console.error(`\n📤 Pushing ${dayDir}`);
console.error(` source: ${source} | harvestDate: ${harvestDate} | articles: ${articles.length}`);
// 2. Load / init ledger, decide what to push.
let state = loadSyncState(dayDir) || newSyncState(registry);
if (force) {
state = newSyncState(registry);
console.error(' --force: ledger reset, re-pushing all articles.');
}
const pending = articles.filter(a => {
const e = state.articles.find(x => x.url === a.url);
return !e || e.status !== 'synced';
});
if (pending.length === 0) {
console.error(' ✅ All articles already synced — nothing to do.');
console.error(' (use --force to re-push)');
state.lastSyncStatus = 'success';
saveSyncState(dayDir, state);
process.stdout.write(JSON.stringify({ dayDir, source, harvestDate, skipped: true, state }, null, 2));
return;
}
console.error(` Pending: ${pending.length} (already synced: ${articles.length - pending.length})`);
// 3. Phase 1 — upload images for pending articles (local sha256 dedup).
const shaCache = new Map(); // sha256 -> assetId (in-process dedup)
// Seed cache from existing synced entries? Not needed: asset ownership is
// per-App on the server and re-upload of identical content returns
// deduplicated:true with the same assetId. Local cache avoids re-reading.
const articleAssets = new Map(); // url -> [{ assetId, alt, originalSrc }]
for (const a of pending) {
const origPath = path.join(dayDir, a.originalFilename);
if (!fs.existsSync(origPath)) {
console.error(` ⚠️ Original doc missing for ${a.titleEn || a.url}: ${a.originalFilename}`);
articleAssets.set(a.url, []);
continue;
}
const origMd = fs.readFileSync(origPath, 'utf8');
const imgs = parseImages(origMd);
const uploaded = [];
for (const img of imgs) {
const imgPath = path.join(dayDir, 'assets', img.filename);
if (!fs.existsSync(imgPath)) {
console.error(` ⚠️ Image missing: assets/${img.filename} (${a.titleEn || a.url})`);
continue;
}
const sha = fileSha256(imgPath);
let assetId;
if (shaCache.has(sha)) {
assetId = shaCache.get(sha);
} else {
console.error(` 📷 Uploading assets/${img.filename} ...`);
const res = await uploadAsset(cfg, imgPath, a.slug, img.alt);
if (!res) {
// Image upload failed — don't block the article; omit this asset.
continue;
}
assetId = res.assetId;
shaCache.set(sha, assetId);
}
uploaded.push({ assetId, filename: img.filename, alt: img.alt || '', originalSrc: '' });
}
articleAssets.set(a.url, uploaded);
}
// 4. Phase 2 — batch push articles (≤ batchSize, default 10).
const batchSize = (cfg.push.batchSize && cfg.push.batchSize > 0) ? cfg.push.batchSize : 10;
const batches = [];
for (let i = 0; i < pending.length; i += batchSize) {
batches.push(pending.slice(i, i + batchSize));
}
const batchResults = [];
for (let bi = 0; bi < batches.length; bi++) {
const batch = batches[bi];
const clientBatchId = `nh-${harvestDate}-${bi}`;
const payload = {
source,
harvestDate,
clientBatchId,
articles: batch.map(a => {
const origPath = path.join(dayDir, a.originalFilename);
const origMd = fs.existsSync(origPath) ? fs.readFileSync(origPath, 'utf8') : '';
const { fm, body: origBody } = parseFrontmatter(origMd);
const summaryMd = readSummaryDoc(dayDir, a.titleZh);
const { body: summaryBody } = parseFrontmatter(summaryMd);
const sections = parseSummarySections(summaryBody);
// Build filename → assetId map for image src resolution in HTML body.
const assetIdByFilename = new Map();
for (const ast of (articleAssets.get(a.url) || [])) {
if (ast.filename) assetIdByFilename.set(ast.filename, ast.assetId);
}
const htmlBody = mdToHtmlBody(origBody, fm && fm.title, assetIdByFilename);
return {
url: a.url,
slug: a.slug,
titleEn: a.titleEn || '',
titleZh: a.titleZh,
publishedAt: a.date || (fm && fm.date) || '',
// --bump-processed-at: override processedAt to now() so the backend's
// LWW (strict greater-than) accepts the update. Use when re-pushing
// corrected content (e.g. summaries were empty on first push) — the
// original processedAt would be judged "unchanged" and skipped.
processedAt: opts.bumpProcessedAt ? new Date().toISOString() : a.processedAt,
category: a.category,
tags: a.tags || [],
authors: a.authors || (fm && fm.authors) || '',
publisher: (fm && fm.publisher) || '',
originalDoc: { markdown: origMd, body: htmlBody },
summaryDoc: { markdown: summaryMd, sections },
assets: articleAssets.get(a.url) || [],
};
}),
};
console.error(`\n ▶ Batch ${bi + 1}/${batches.length} (${batch.length} articles) — ${clientBatchId}`);
let resp;
try {
resp = await pushArticleBatch(cfg, payload);
} catch (e) {
// Whole batch failed (network/5xx exhausted, or 4xx at batch level).
console.error(` ✗ Batch ${clientBatchId} failed: ${e.message}`);
// Mark every article in this batch as error.
for (const a of batch) {
recordResult(state, a.url, { status: 'error', error: { code: 'BATCH_FAILED', message: e.message } });
}
batchResults.push({ clientBatchId, status: 'failed', error: e.message, results: [] });
saveSyncState(dayDir, state);
continue;
}
// Record per-article results from the response.
if (Array.isArray(resp.results)) {
for (const r of resp.results) {
recordResult(state, r.url, r);
}
}
batchResults.push({
clientBatchId,
batchId: resp.batchId,
status: resp.status,
summary: resp.summary,
results: resp.results,
});
console.error(` ✓ Batch ${clientBatchId}: ${resp.status}${JSON.stringify(resp.summary)}`);
saveSyncState(dayDir, state);
}
// 5. Finalize ledger status + output.
const failed = state.articles.filter(a => a.status === 'error').length;
state.lastSyncStatus = failed === 0 ? 'success' : (failed === state.articles.length ? 'failed' : 'partial');
saveSyncState(dayDir, state);
console.error(`\n${failed === 0 ? '✅' : (failed === state.articles.length ? '❌' : '⚠️')} Push ${state.lastSyncStatus}: ${state.articles.length - failed}/${state.articles.length} synced, ${failed} failed`);
if (failed > 0) {
console.error(` Re-run to retry failed articles: node scripts/push.js "${dayDir}"`);
}
const output = {
dayDir, source, harvestDate,
lastSyncStatus: state.lastSyncStatus,
summary: { total: state.articles.length, failed, synced: state.articles.length - failed },
batches: batchResults,
};
process.stdout.write(JSON.stringify(output, null, 2));
// Exit 2 if partial/failed, so callers can detect incomplete pushes.
if (state.lastSyncStatus !== 'success') process.exit(2);
}
// ── --status: read-only ledger report ────────────────────────────────────
function printStatus(dayDir) {
const state = loadSyncState(dayDir);
if (!state) {
console.error(`No ${SYNCSTATE} in ${dayDir} — not pushed yet.`);
process.exit(0);
}
const byStatus = {};
for (const a of state.articles) byStatus[a.status] = (byStatus[a.status] || 0) + 1;
console.error(`📊 Sync status for ${dayDir}`);
console.error(` source: ${state.source} | harvestDate: ${state.harvestDate}`);
console.error(` lastSyncStatus: ${state.lastSyncStatus} | updatedAt: ${state.updatedAt || '?'}`);
console.error(` ${JSON.stringify(byStatus)}`);
const errs = state.articles.filter(a => a.status === 'error');
if (errs.length) {
console.error(`\n ❌ Failed articles:`);
for (const a of errs) {
console.error(` - ${a.slug || a.url}`);
console.error(` ${(a.error && a.error.message) || a.error}`);
}
console.error(`\n Retry: node scripts/push.js "${dayDir}"`);
}
}
// ── CLI ───────────────────────────────────────────────────────────────────
function printHelp() {
console.log(`push.js — Push a finalized day directory to News Hub.
Usage:
node scripts/push.js <dayDir> Push a day directory (incremental)
node scripts/push.js <dayDir> --force Ignore ledger, re-push all articles
node scripts/push.js <dayDir> --bump-processed-at
Override processedAt to now() so the backend's LWW
accepts the update (use when re-pushing corrected
content that was previously pushed with empty/missing
summaries; the original timestamp would be "unchanged")
node scripts/push.js <dayDir> --status Show sync status (read-only)
node scripts/push.js -h | --help Show this help
Arguments:
dayDir Path to the YYYYMMDD directory produced by finalize.js
(e.g. /path/to/vault/Reuters Business/20260731)
Prerequisites:
- <dayDir>/registry.json exists (written by harvest.js, patched by finalize.js)
- <dayDir>/.manifest.json and .summaries.json are ABSENT (= finalize done)
- config.json has a \`push\` section with endpoint + appId/appKeyId/appSecret
What it does:
1. Phase 1: upload each article's images to MinIO (POST /assets, sha256 deduped)
2. Phase 2: batch-push articles (POST /articles, ≤10 per batch, idempotent)
3. Writes <dayDir>/.syncstate.json ledger — re-running resumes from failures
Exit codes:
0 success (all articles synced, or --status printed)
1 usage/IO error (missing files, not finalized, config missing)
2 partial/failed push (some articles still in error state)
Example:
node scripts/push.js "/Users/me/obsidian/Harvester/Reuters Business/20260731"`);
}
function main() {
const args = process.argv.slice(2);
if (args.includes('-h') || args.includes('--help')) {
printHelp();
process.exit(0);
}
const positional = args.filter(a => !a.startsWith('-'));
const flags = args.filter(a => a.startsWith('-'));
if (positional.length === 0) {
console.error('Usage: node scripts/push.js <dayDir> [--force] [--bump-processed-at] [--status]');
console.error('Run "node scripts/push.js --help" for details.');
process.exit(1);
}
const dayDir = positional[0];
if (!fs.existsSync(dayDir) || !fs.statSync(dayDir).isDirectory()) {
console.error(`✗ Day directory does not exist: ${dayDir}`);
process.exit(1);
}
// --status is a pure local ledger read — allow it even when push is disabled,
// so users can inspect sync state without enabling/configuring the backend.
if (flags.includes('--status')) {
printStatus(dayDir);
process.exit(0);
}
const cfg = loadConfig();
if (!cfg.push || !cfg.push.enabled) {
console.error('✗ Push is not enabled in config.json (push.enabled = false).');
console.error(' Configure the push section and set enabled: true to use this command.');
process.exit(1);
}
if (!cfg.push.endpoint || !cfg.push.appId || !cfg.push.appKeyId || !cfg.push.appSecret) {
console.error('✗ Push config incomplete: endpoint/appId/appKeyId/appSecret are all required.');
process.exit(1);
}
pushDayDir(dayDir, cfg, {
force: flags.includes('--force'),
bumpProcessedAt: flags.includes('--bump-processed-at'),
}).catch(e => {
console.error(`Fatal: ${e.message}`);
process.exit(1);
});
}
if (require.main === module) {
main();
}
// Exported for potential finalize.js chaining / testing.
module.exports = {
pushDayDir,
parseFrontmatter,
parseSummarySections,
parseImages,
fileSha256,
readSummaryDoc,
mdToHtmlBody,
};
#!/usr/bin/env node
/**
* source-manage.js - Manage installed news sources
*
* This is the harvester-side source manager. It does NOT add/export/helper
* — sources are added exclusively by installing .nhsource.json bundles
* produced by the news-source-analyzer skill. This keeps the harvester
* focused on the collection pipeline.
*
* Usage:
* node source-manage.js list
* node source-manage.js edit <id> --field value
* node source-manage.js enable <id>
* node source-manage.js disable <id>
* node source-manage.js install <pkg.nhsource.json> [--force] # install a shared bundle
*/
const fs = require('fs');
const path = require('path');
const SKILL_DIR = path.dirname(__dirname);
const CONFIG_PATH = path.join(SKILL_DIR, 'config.json');
const TEMPLATE_PATH = path.join(SKILL_DIR, 'references', 'config-template.json');
const HELPERS_DIR = path.join(SKILL_DIR, 'helpers');
function loadConfig() {
if (!fs.existsSync(CONFIG_PATH)) {
const template = JSON.parse(fs.readFileSync(TEMPLATE_PATH, 'utf8'));
fs.writeFileSync(CONFIG_PATH, JSON.stringify(template, null, 2));
}
return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
}
function saveConfig(config) {
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
}
function parseArgs(argv) {
const args = {};
for (let i = 0; i < argv.length; i++) {
if (argv[i].startsWith('--')) {
args[argv[i].slice(2)] = argv[i+1] || true;
i++;
} else {
args['_' + (args._count || 0)] = argv[i];
args._count = (args._count || 0) + 1;
}
}
return args;
}
const command = process.argv[2];
const args = parseArgs(process.argv.slice(3));
const config = loadConfig();
switch (command) {
case 'list': {
console.log('\n📋 Configured Sources\n');
console.log('ID'.padEnd(20) + 'NAME'.padEnd(20) + 'METHOD'.padEnd(10) + 'RECIPE'.padEnd(10) + 'STATUS');
console.log('─'.repeat(70));
for (const s of config.sources) {
const status = s.enabled ? '✅ enabled' : '❌ disabled';
const hasRecipe = fs.existsSync(path.join(HELPERS_DIR, `${s.id}.md`));
const recipe = hasRecipe ? '✅' : '—';
console.log(
s.id.padEnd(20) +
s.name.padEnd(20) +
s.method.padEnd(10) +
recipe.padEnd(10) +
status
);
}
console.log('\n RECIPE ✅ = helpers/<id>.md exists (spec-driven fetcher); — = legacy fetcher\n');
break;
}
case 'edit': {
const id = args._0;
const source = config.sources.find(s => s.id === id);
if (!source) { console.error(`Source "${id}" not found`); process.exit(1); }
const editableFields = ['name', 'homepageUrl', 'method', 'articleUrlPattern', 'vaultFolder', 'enabled'];
let changed = false;
for (const field of editableFields) {
if (args[field] !== undefined) {
const oldVal = source[field];
source[field] = args[field] === 'true' ? true : args[field] === 'false' ? false : args[field];
console.log(` ${field}: ${oldVal}${source[field]}`);
changed = true;
}
}
if (!changed) {
console.log(`\nCurrent config for "${id}":`);
console.log(JSON.stringify(source, null, 2));
console.log('\nUsage: node source-manage.js edit <id> --field value');
} else {
saveConfig(config);
console.log(`\n✅ Updated source "${id}"`);
}
break;
}
case 'enable':
case 'disable': {
const id = args._0;
const source = config.sources.find(s => s.id === id);
if (!source) { console.error(`Source "${id}" not found`); process.exit(1); }
source.enabled = command === 'enable';
saveConfig(config);
console.log(`✅ Source "${id}" ${command}d`);
break;
}
case 'install': {
// Unpack a .nhsource.json: write recipe/fixtures into helpers/, add the
// source to config.json. Refuses on id conflict unless --force (matching
// the "helper" command's refuse-with-force convention).
const pkgPath = args._0;
if (!pkgPath) { console.error('Usage: node source-manage.js install <pkg.nhsource.json> [--force]'); process.exit(1); }
const absPkg = path.resolve(pkgPath);
if (!fs.existsSync(absPkg)) { console.error(`Bundle not found: ${absPkg}`); process.exit(1); }
let bundle;
try { bundle = JSON.parse(fs.readFileSync(absPkg, 'utf8')); }
catch (e) { console.error(`Failed to parse bundle: ${e.message}`); process.exit(1); }
if (bundle.format !== 'news-harvester-source') {
console.error(`Not a news-harvester source bundle (format="${bundle.format}").`);
process.exit(1);
}
const src = bundle.source || {};
const id = src.id;
if (!id) { console.error('Bundle is missing source.id'); process.exit(1); }
const existing = config.sources.find(s => s.id === id);
if (existing && args.force === undefined) {
console.error(`Source "${id}" already exists in config.json. Use --force to overwrite (config entry + helper files).`);
process.exit(1);
}
// Write helper files (refuse to overwrite an existing file unless --force,
// same convention as the id-conflict check above).
if (!fs.existsSync(HELPERS_DIR)) fs.mkdirSync(HELPERS_DIR, { recursive: true });
const written = [];
const files = bundle.files || {};
for (const [relPath, content] of Object.entries(files)) {
// Guard against path traversal: only allow writing into helpers/.
const dest = path.join(HELPERS_DIR, path.basename(relPath));
if (dest.indexOf(HELPERS_DIR) !== 0) {
console.error(` ⚠️ Skipping unsafe path in bundle: ${relPath}`);
continue;
}
if (fs.existsSync(dest) && args.force === undefined && !existing) {
console.error(`helpers/${path.basename(dest)} already exists. Use --force to overwrite.`);
process.exit(1);
}
fs.writeFileSync(dest, content);
written.push(`helpers/${path.basename(dest)}`);
}
// Upsert the config entry. recipe sources don't carry articleUrlPattern
// (it's legacy-direct-only and ignored when a recipe exists).
const entry = {
id,
name: src.name || id,
homepageUrl: src.homepageUrl || '',
method: src.method || 'browser',
vaultFolder: src.vaultFolder || src.name || id,
enabled: true
};
if (existing) {
Object.assign(existing, entry);
} else {
config.sources.push(entry);
}
saveConfig(config);
console.log(`\n✅ Installed source "${entry.name}" (id: ${id})`);
console.log(` Method: ${entry.method} | Vault folder: ${entry.vaultFolder}`);
if (written.length) console.log(` Files: ${written.join(', ')}`);
// Warn if vaultPath is still the placeholder — preview works, archive won't.
if (config.vaultPath === '/path/to/your/obsidian/vault' || !config.vaultPath) {
console.error(`\n ⚠️ config.vaultPath is not set. Preview works, but archiving needs a real vault path —`);
console.error(` set it in config.json before running a full harvest.`);
}
console.log(`\n Next:`);
if (written.includes(`helpers/${id}.fixtures.json`)) {
console.log(` node scripts/recipe-test.js ${id} # verify the recipe still matches the live site`);
}
console.log(` node scripts/harvest.js ${id} 3 --preview # fetch latest articles (read-only)\n`);
break;
}
default:
console.log(`
Usage:
node source-manage.js list
node source-manage.js edit <id> --name "New Name" --url https://... --method browser
node source-manage.js enable <id>
node source-manage.js disable <id>
node source-manage.js install <pkg.nhsource.json> [--force] # install a shared bundle (recipe + config entry)
Sources are added by installing .nhsource.json bundles (produced by the
news-source-analyzer skill). There is no "add" or "export" command —
use the analyzer to research and pack new sources.
Helper recipes: each installed source has a helpers/<id>.md file (written
by install). "list" shows which sources have recipes (✅) vs legacy (—).
`);
}
#!/usr/bin/env node
/**
* summary-add.js - Incrementally merge a batch of Chinese summaries into .summaries.json.
*
* Problem this solves: when a harvest produces many articles (10+), asking the agent
* to emit the entire .summaries.json in ONE response hits the model's output-token
* ceiling — the response is truncated mid-stream and only some articles get summaries
* (the rest keep the __SUMMARY_<slug>__ sentinel forever). Splitting summary work into
* batches of ~6 that are each merged into .summaries.json here removes that ceiling:
* each batch is a small Write + one call to this script, so no single response can be
* truncated, and an interrupted run resumes from where it left off.
*
* Usage:
* node summary-add.js <dayDir> [batchFile] merge a batch (default <dayDir>/.summaries-batch.json)
* node summary-add.js <dayDir> --status diff .manifest.json vs .summaries.json (done / missing)
* node summary-add.js -h | --help
*
* Batch file format (JSON array, one entry per article):
* [{ "url": "...", "titleZh": "...", "category": "...", "tags": [...], "summaryZh": "..." }]
*
* Merge semantics:
* - Entries are keyed by `url`. A url already present in .summaries.json is OVERWRITTEN
* (so a corrected summary for an article replaces the old one, no duplicates).
* - On success the batch file is deleted (it has been absorbed into .summaries.json).
* - On parse error the batch file is LEFT IN PLACE so the agent can fix and retry.
*
* Exit codes: 0 success, 1 usage/IO error, 2 batch parse error (batch file kept).
*/
const fs = require('fs');
const path = require('path');
const DEFAULT_BATCH = '.summaries-batch.json';
const SUMMARIES = '.summaries.json';
const MANIFEST = '.manifest.json';
function printHelp() {
console.log(`summary-add.js — Incrementally merge Chinese summary batches into .summaries.json.
Solves the output-truncation failure where many articles (10+) exceed a single
model response: write summaries in batches of ~6, merging each batch
here, then run finalize.js once at the end.
Usage:
node summary-add.js <dayDir> [batchFile] Merge a batch into <dayDir>/.summaries.json
node summary-add.js <dayDir> --status Show done vs missing summaries (needs .manifest.json)
node summary-add.js -h | --help Show this help
Arguments:
dayDir Path to the YYYYMMDD directory produced by harvest.js
batchFile JSON array of { url, titleZh, category, tags[], summaryZh }
(default: <dayDir>/.summaries-batch.json)
Merge:
- Keyed by url; existing entry for a url is overwritten (no duplicates).
- Batch file is deleted after a successful merge.
- Batch file is KEPT on parse error (fix and retry).
Example:
# Agent writes 3 summaries to .summaries-batch.json, then:
node scripts/summary-add.js "/path/to/vault/Source/20260724"
# Repeat per batch, then run finalize.js once.`);
}
function loadJson(p, label) {
if (!fs.existsSync(p)) {
console.error(`✗ ${label} not found: ${p}`);
process.exit(1);
}
try {
return JSON.parse(fs.readFileSync(p, 'utf8'));
} catch (e) {
console.error(`✗ Failed to parse ${label}: ${e.message}`);
process.exit(1);
}
}
function isValidEntry(s) {
return s && typeof s === 'object' &&
typeof s.url === 'string' && s.url &&
typeof s.titleZh === 'string' && s.titleZh.trim();
}
// ── --status: diff .manifest.json urls against .summaries.json urls ──────────
function status(dayDir) {
const manifestPath = path.join(dayDir, MANIFEST);
const summariesPath = path.join(dayDir, SUMMARIES);
if (!fs.existsSync(manifestPath)) {
console.error(`✗ ${MANIFEST} not found in ${dayDir}`);
console.error(` (already finalized? manifest is removed by finalize.js)`);
process.exit(1);
}
const manifest = loadJson(manifestPath, MANIFEST);
const manifestArticles = Array.isArray(manifest.articles) ? manifest.articles : [];
let summaries = [];
if (fs.existsSync(summariesPath)) {
try { summaries = JSON.parse(fs.readFileSync(summariesPath, 'utf8')); }
catch (e) {
console.error(`✗ Failed to parse ${SUMMARIES}: ${e.message}`);
process.exit(1);
}
}
if (!Array.isArray(summaries)) summaries = [];
const doneUrls = new Set(summaries.filter(isValidEntry).map(s => s.url));
const done = manifestArticles.filter(a => doneUrls.has(a.url));
const missing = manifestArticles.filter(a => !doneUrls.has(a.url));
console.error(`📊 Summary status for ${dayDir}`);
console.error(` Manifest articles: ${manifestArticles.length}`);
console.error(` Summaries written: ${done.length}`);
console.error(` Missing: ${missing.length}`);
if (missing.length) {
console.error(`\n ❌ Missing summaries:`);
for (const a of missing) {
console.error(` - ${a.titleEn || a.url}`);
console.error(` ${a.url}`);
}
console.error(`\n Write the missing summaries (~6 per batch) and run:`);
console.error(` node scripts/summary-add.js "${dayDir}"`);
} else if (manifestArticles.length > 0) {
console.error(`\n ✅ All ${manifestArticles.length} articles have summaries — run finalize.js:`);
console.error(` node scripts/finalize.js "${dayDir}"`);
}
}
// ── merge: absorb a batch file into .summaries.json ──────────────────────────
function merge(dayDir, batchFile) {
const batchPath = path.isAbsolute(batchFile) ? batchFile : path.join(dayDir, batchFile);
const summariesPath = path.join(dayDir, SUMMARIES);
if (!fs.existsSync(batchPath)) {
console.error(`✗ Batch file not found: ${batchPath}`);
console.error(` Write a JSON array of summaries there first.`);
process.exit(1);
}
// Parse the batch FIRST; on failure keep it so the agent can fix and retry.
let batch;
try {
batch = JSON.parse(fs.readFileSync(batchPath, 'utf8'));
} catch (e) {
console.error(`✗ Failed to parse batch file (${batchPath}): ${e.message}`);
console.error(` Batch file KEPT — fix the JSON and re-run.`);
process.exit(2);
}
if (!Array.isArray(batch)) {
console.error(`✗ Batch file must be a JSON array, got ${typeof batch} (${batchPath})`);
console.error(` Batch file KEPT — fix and re-run.`);
process.exit(2);
}
// Validate entries; collect good ones, report bad ones (don't abort the whole batch).
const incoming = [];
let bad = 0;
for (const s of batch) {
if (!isValidEntry(s)) {
console.error(` ⚠️ Skipping invalid entry (needs url + non-empty titleZh): ${JSON.stringify(s).slice(0, 120)}`);
bad++;
continue;
}
incoming.push({
url: s.url,
titleZh: s.titleZh.trim(),
category: s.category || '',
tags: Array.isArray(s.tags) ? s.tags : [],
summaryZh: typeof s.summaryZh === 'string' ? s.summaryZh : ''
});
}
if (incoming.length === 0) {
console.error(`✗ No valid entries in batch file (${batchPath}).`);
console.error(` Batch file KEPT — fix and re-run.`);
process.exit(2);
}
// Load existing .summaries.json (if any) and merge by url (overwrite).
let existing = [];
if (fs.existsSync(summariesPath)) {
try {
const parsed = JSON.parse(fs.readFileSync(summariesPath, 'utf8'));
if (Array.isArray(parsed)) existing = parsed;
} catch (e) {
console.error(`✗ Existing ${SUMMARIES} is corrupt: ${e.message}`);
console.error(` Refusing to merge — inspect ${summariesPath} manually.`);
process.exit(1);
}
}
const byUrl = new Map();
for (const s of existing) if (isValidEntry(s)) byUrl.set(s.url, s);
for (const s of incoming) byUrl.set(s.url, s); // overwrite
const merged = [...byUrl.values()];
fs.writeFileSync(summariesPath, JSON.stringify(merged, null, 2));
fs.unlinkSync(batchPath); // batch absorbed — clean up
// Report against the manifest if present (so the agent knows what's left).
const manifestPath = path.join(dayDir, MANIFEST);
let manifestCount = null, missingCount = null;
if (fs.existsSync(manifestPath)) {
try {
const m = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const urls = Array.isArray(m.articles) ? m.articles.map(a => a.url) : [];
manifestCount = urls.length;
const done = new Set(merged.map(s => s.url));
missingCount = urls.filter(u => !done.has(u)).length;
} catch (e) { /* manifest is informational only */ }
}
console.error(`✓ Merged ${incoming.length} summar${incoming.length === 1 ? 'y' : 'ies'}${bad ? ` (skipped ${bad} invalid)` : ''}`);
console.error(` ${SUMMARIES}: ${merged.length} total (was ${existing.length})`);
if (manifestCount !== null) {
console.error(` Manifest: ${manifestCount} articles | missing: ${missingCount}`);
if (missingCount > 0) {
console.error(` Write the next batch (~6) and re-run, or check gaps:`);
console.error(` node scripts/summary-add.js "${dayDir}" --status`);
} else {
console.error(` ✅ All ${manifestCount} covered — run finalize.js:`);
console.error(` node scripts/finalize.js "${dayDir}"`);
}
}
}
function main() {
const args = process.argv.slice(2);
if (args.includes('-h') || args.includes('--help')) {
printHelp();
process.exit(0);
}
const positional = args.filter(a => !a.startsWith('-'));
const flags = args.filter(a => a.startsWith('-'));
if (positional.length === 0) {
console.error('Usage: node summary-add.js <dayDir> [batchFile|--status]');
console.error('Run "node summary-add.js --help" for details.');
process.exit(1);
}
const dayDir = positional[0];
if (!fs.existsSync(dayDir) || !fs.statSync(dayDir).isDirectory()) {
console.error(`✗ Day directory does not exist: ${dayDir}`);
process.exit(1);
}
if (flags.includes('--status')) {
status(dayDir);
} else {
const batchFile = positional[1] || DEFAULT_BATCH;
merge(dayDir, batchFile);
}
}
if (require.main === module) {
main();
}
---
name: news-source-analyzer
description: Analyze news websites and produce installable source bundles (.nhsource.json) for the news-harvester. Use this skill whenever the user wants to research a new news source, inspect a website's page structure, author or debug a fetch recipe, verify a recipe works, or pack a source bundle. Stateless — no vault dependency, no source registry. Triggers on phrases like "分析数据源", "研究新闻网站", "inspect source", "create recipe", "make bundle", "打包数据源", "添加数据源", "new source", "recipe", "selector", "listSelector", "urlPattern".
---
# News Source Analyzer Skill
Analyze news websites, author fetch recipes, and produce installable `.nhsource.json` bundles. This skill is **stateless** — it does not manage the harvester's vault, push config, or source registry. Its sole output is a bundle file that the harvester installs in one command.
> **Skill root** = the directory containing this `SKILL.md`. Prefix every command with `cd "<skill-root>" &&`, or scripts fail with `MODULE_NOT_FOUND`.
## Quick Reference
| Task | Command |
|------|---------|
| Inspect page structure | `node scripts/inspect-source.js <url> --scroll 3` |
| Inspect + write recipe | `node scripts/inspect-source.js <url> --scroll 3 --write <id> [--name "名称"]` |
| Preview articles (read-only) | `node scripts/preview.js --url <url> --recipe <path> [--count 3] [--full]` |
| Run recipe regression tests | `node scripts/recipe-test.js <id> --url <homepageUrl>` |
| Pack a bundle | `node scripts/pack.js --id <id> --name <name> --url <url> --method <browser\|direct> --folder <folder> --recipe <path> [--fixtures <path>] [--out <path>]` |
| Ensure Chromium | `node scripts/ensure-chromium.js` (or `--check`, `--list`, `--login`) |
> **All commands must be prefixed with `cd "<skill-root>" &&`**. Scripts live in `scripts/` — always reference them as `node scripts/inspect-source.js`, never `node inspect-source.js`.
---
## First-Run Setup
**Pre-flight gate** — run this at the start of a task. If it prints `OK`, skip setup:
```bash
cd "<skill-root>" && node -e "const c=require('./config.json'); console.log(c.chromium?'OK':'UNCONFIGURED')" 2>/dev/null || echo "UNCONFIGURED (no config.json)"
```
### Onboarding (only if UNCONFIGURED)
This skill only needs the `chromium` block in `config.json` (no vault, no push, no sources). The config is auto-created from `references/config-template.json` on first script run. To pre-configure:
1. Check what browsers are available:
```bash
node scripts/ensure-chromium.js --list
```
2. If a source needs login (subscription/paywall), launch a windowed browser to log in:
```bash
node scripts/ensure-chromium.js --login --url <homepage>
```
Log in inside the window, then **quit the browser fully** (Cmd+Q / Ctrl+Q) to persist cookies.
3. If `--login` used the default profile, persist it in `config.json`:
```json
{ "chromium": { "userDataDir": "/Users/<you>/.news-harvester/analyzer-profile" } }
```
4. Smoke test:
```bash
node scripts/inspect-source.js https://theconversation.com/us --scroll 1
```
If you see container groups with article links, setup is complete.
---
## Configuration
| Field | Required? | Description |
|-------|-----------|-------------|
| `chromium.cdpPort` | Optional | CDP debug port (default `9222`) |
| `chromium.headless` | Optional | Launch headless (`true`, default) or windowed (`false`) |
| `chromium.executablePath` | Optional | Full path to browser binary. `null` = auto-discover |
| `chromium.userDataDir` | Optional | Browser profile dir for login state. Set to a fixed profile you've logged into once |
---
## Workflow
The 4-step closed loop: **inspect → preview → test → pack**.
### Step 1. Inspect + write recipe
```bash
node scripts/inspect-source.js <homepageUrl> --scroll 3 --write <id> [--name "显示名称"]
```
- Navigates to the URL, scrolls to trigger lazy-loading, collects every `<a[href]>`, and groups them by container signature.
- The biggest non-nav group becomes `listSelector`; `urlPattern` is inferred from sample URLs.
- Writes `helpers/<id>.md` directly (refuses if it exists unless `--force`).
- Without `--write`: prints a paste-ready skeleton to stdout.
### Step 2. Preview (read-only verification)
```bash
node scripts/preview.js --url <homepageUrl> --recipe helpers/<id>.md --count 3
```
- Fetches candidate links using the recipe, then fetches each article page.
- Prints a compact JSON manifest (titles + metadata + bodyPreview) to stdout.
- No files written. Use `--full` to include complete article bodies.
**Convergence check**: preview passes when fetched links are real articles (not nav/section pages) and `charCount > 500`. Diagnose failures:
| Symptom | Fix |
|---------|-----|
| Too few candidates | `listSelector` too narrow. Use composite selectors (comma-separated). |
| Candidates are nav/section pages | Tighten `urlPattern`, or set `validateArticle: true`. |
| Empty body | Fix `bodySelectors`. |
| 0 candidates, all nav | `listSelector: null` is wrong — scope to card containers. |
Re-run preview after each recipe edit. Deep troubleshooting: `references/recipe-guide.md` → *Troubleshooting recipes*.
### Step 3. Lock with regression tests
Create `helpers/<id>.fixtures.json`:
```json
{
"articles": ["https://.../real-article-1", "https://.../real-article-2"],
"sections": ["https://.../section-page-1"],
"minLinks": 10
}
```
Then:
```bash
node scripts/recipe-test.js <id> --url <homepageUrl>
```
Asserts: every article URL fetches `isArticle=true` with `bodyChars ≥ bodyMinChars`; every section URL fetches `isArticle=false`; homepage yields ≥ `minLinks` and zero `excludeUrlPattern` matches.
### Step 4. Pack into a bundle
```bash
node scripts/pack.js --id <id> --name <name> --url <homepageUrl> \
--method browser --folder <vaultFolder> \
--recipe helpers/<id>.md [--fixtures helpers/<id>.fixtures.json] \
[--out bundles/<id>.nhsource.json]
```
Produces a self-contained `.nhsource.json` bundle. The harvester installs it with:
```bash
node scripts/source-manage.js install <bundle.nhsource.json>
```
---
## Category Detection
Preview output includes category classification based on URL path and title keywords:
| Keywords | Category |
|----------|---------|
| asia-pacific, pakistan, afghanistan, 巴, 阿富汗 | 地缘政治 |
| ai, anthropic, openai, tech, 科技, AI | 科技/AI |
| china, 中国, 中美 | 中美关系 |
| business, finance, housing, economy, 经济, 财经 | 财经 |
| sustainability, feminist, society, 社会 | 社会 |
| ukraine, russia, middle-east, war | 国际冲突 |
| default | 国际 |
Category emoji: 地缘政治→🌏, 科技/AI→🤖, 中美关系→🇨🇳, 财经→💰, 社会→🧑‍🤝‍🧑, 国际冲突→⚔️, 国际→📰
---
## Human-intervention exits (CAPTCHA / login loss)
`preview.js` detects CAPTCHA/anti-bot challenges and login-state loss (exit code `70`). When this happens:
- **Do not retry** the same command — the block is deterministic until the user intervenes.
- Relay the printed message to the user.
- Suggest: `node scripts/ensure-chromium.js --login --url <homepage>` to pass the challenge or re-login in a windowed browser.
---
## References
| File | When to read |
|------|--------------|
| `references/recipe-guide.md` | Recipe frontmatter schema, authoring workflow, troubleshooting (composite listSelector, `validateArticle`), fixtures format. |
| `references/helper-example.md` | Annotated recipe template with every field commented. Start here when authoring a recipe by hand. |
{
"name": "news-source-analyzer",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "news-source-analyzer",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"js-yaml": "^5.2.2",
"ws": "^8.19.0"
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/js-yaml": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.3.tgz",
"integrity": "sha512-n+mUVyUX5bVv7G/G2zyIHOhdxfuU1dY2NOFzTQUWiMUbFss8b57NFlgCCaggU78wSw5KVS9cllzeLyzyR+n5nw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.mjs"
}
},
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}
{
"name": "news-source-analyzer",
"version": "1.0.0",
"description": "Stateless news source analyzer — inspect, recipe, pack",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"js-yaml": "^5.2.2",
"ws": "^8.19.0"
}
}
{
"chromium": {
"cdpPort": 9222,
"headless": true,
"executablePath": null,
"userDataDir": null
}
}
---
# ── Source identity ──
# source 必须与 config.json 的 source.id 一致;harvest.js 据此加载本文件。
source: reuters
# ── 发现阶段:在列表页定位文章链接 ──
# listSelector: 主文章列表容器的 CSS 选择器。
# 关键字段——把链接采集范围从"整页"收窄到这个容器,排除导航/侧栏/页脚。
# null = 全页扫描(=legacy 行为,不推荐)。用 `node scripts/inspect-source.js <url>` 找到最大容器组。
listSelector: 'div[data-testid="Title"]'
# linkSelector: 容器内候选 <a> 元素。一般不用改。
linkSelector: 'a[href]'
# urlPattern: 【必填】文章 URL 校验正则(regex source,不是带斜杠的字面量)。
# 匹配此正则的 href 才算文章。有 helper 时此字段取代 config.articleUrlPattern(后者被忽略)。
# 注意:用单引号包裹(双引号会让 js-yaml 把 \. 当非法转义报错)。
urlPattern: 'reuters\.com/.+-\d{4}-\d{2}-\d{2}/?$'
# excludeUrlPattern: 排除正则——匹配 urlPattern 但不该算文章的 URL(如 podcasts/newsletter)。
excludeUrlPattern: 'reuters\.com/(podcasts|newsletter)/'
# scrollSteps: 向下滚动次数,触发 SPA 懒加载。0 = 不滚(只拿首屏)。
# Reuters 首屏仅 ~10 篇,滚 3 次能拿全当天列表。
scrollSteps: 3
scrollWaitMs: 2000 # 每次滚动后等待毫秒(等新内容渲染)
waitMs: 15000 # 页面初始加载后等待毫秒(等 JS/React 渲染完)
maxLinks: 30 # 链接上限
# ── 提取阶段:在文章页定位正文/元数据 ──
# bodySelectors: 正文容器选择器,按序尝试,首个满足 ≥ bodyMinParagraphs 的即用。
bodySelectors:
- '[data-testid^="paragraph"]'
- '[class*="articleBodyContent"] p'
- '[class*="article-body"] p'
- 'article p'
bodyMinParagraphs: 3
# dateSelector / authorSelector: CSS 选择器。找不到时自动回退到页面的
# <script type="application/ld+json"> 的 datePublished / author.name(JSON-LD 兜底)。
# 很多 SPA 站点(Nikkei)只在 JSON-LD 放元数据,所以通常不用自定义。
dateSelector: 'time[datetime]'
authorSelector: '[rel="author"], [class*="author"] a'
# imageSelector: 图片元素(一般不动)。imageMinWidth 过滤图标/头像/跟踪像素。
# excludeImage: 图片 URL 子串黑名单(小写匹配)。
imageSelector: 'img'
imageMinWidth: 200
excludeImage:
- logo
- icon
- avatar
- profile
# noisePrefixes: 正文段落过滤——以这些前缀开头的 <p> 会被丢弃(如 Reuters 的"Reporting by"署名块)。
noisePrefixes:
- 'Reporting by'
- 'Editing by'
- 'Access unmatched'
- 'Browse an unrivalled'
- 'Screen for heightened'
- 'Sign up here'
- 'Reuters, the news'
- 'All quotes delayed'
- 'See here for'
# titleStripSuffix: 从 document.title 剥离的后缀正则(清理 "| Reuters" 等)。
titleStripSuffix: ' \| Reuters$'
maxBodyChars: 15000
---
# Reuters 抓取备忘
这一段(frontmatter 下方)是给人看的备忘,脚本不解析。记录:
- 主文章流容器是 `div[data-testid="Title"]`(inspect 实测 59 篇)。
- 文章 URL 以日期 `-YYYY-MM-DD/` 结尾,是过滤导航/section 页的关键。
- 反爬:headless 会被拦,需 config.chromium.headless:false。
---
# 字段速查(所有字段除 urlPattern 外都可选,缺省值 = legacy 行为)
#
# 发现阶段: listSelector / linkSelector / urlPattern(必填) / excludeUrlPattern
# scrollSteps / scrollWaitMs / waitMs / maxLinks
# 提取阶段: bodySelectors / bodyMinParagraphs / dateSelector / authorSelector
# imageSelector / imageMinWidth / excludeImage / noisePrefixes
# titleStripSuffix / maxBodyChars
#
# 完整说明见 references/source-management.md 的 "Authoring workflow" 小节。
# 写新 recipe 流程(一步生成): node scripts/inspect-source.js <url> --scroll 3 --write <id>
# → 自动测 listSelector + 推断 urlPattern,直接写好 helpers/<id>.md
# → node scripts/harvest.js <id> 3 --preview 验证
# → node scripts/recipe-test.js <id> 锁定回归测试
# (source-manage.js helper <id> 仅在想要空白模板手填时用)
# Recipe Guide
## How recipes work
A recipe (`helpers/<id>.md`) is a YAML frontmatter file that declares the selectors, scroll behavior, and URL filters for a news source. When the harvester's `harvest.js` finds a recipe, it uses `fetcher-helper.js` (which reads the recipe's spec) instead of the legacy `fetcher-browser.js`/`fetcher-direct.js`.
> **Precedence**: when a recipe exists, its `urlPattern`/selectors are the source of truth and the config's `articleUrlPattern` is **ignored**. A recipe also forces Chromium use regardless of `method` (since `fetcher-helper.js` drives the browser).
## Authoring workflow
The canonical 4-step flow for analyzing a new source:
1. **Inspect + auto-write the recipe**`inspect --write` measures `listSelector` (biggest container group) and infers `urlPattern` from real URLs, then writes `helpers/<id>.md` directly:
```bash
node scripts/inspect-source.js <homepageUrl> --scroll 3 --write <id> [--name "显示名称"]
```
(Use `--force` to overwrite an existing recipe.)
2. **Verify** (read-only):
```bash
node scripts/preview.js --url <homepageUrl> --recipe helpers/<id>.md --count 3
```
Confirm the links are real articles (not nav/section pages) and the body char counts look right. Iterate on the recipe until clean — see *Troubleshooting recipes* below.
3. **Lock with regression tests** (needs `helpers/<id>.fixtures.json`):
```bash
node scripts/recipe-test.js <id> --url <homepageUrl>
```
4. **Pack into a bundle** for the harvester to install:
```bash
node scripts/pack.js --id <id> --name <name> --url <homepageUrl> \
--method browser --folder <vaultFolder> --recipe helpers/<id>.md \
[--fixtures helpers/<id>.fixtures.json] [--out <path.nhsource.json>]
```
## Frontmatter schema
All fields optional except `urlPattern`; defaults reproduce legacy behaviour.
**Discovery** (find the article-link list):
| Field | Default | Purpose |
|-------|---------|---------|
| `listSelector` | `null` (whole page) | CSS selector for the article-list container — scopes link collection away from nav/sidebar/footer |
| `linkSelector` | `a[href]` | Candidate links inside each container |
| `urlPattern` | **required** | Regex source; an href must match to count as an article |
| `excludeUrlPattern` | `null` | Regex source; drop matching hrefs (section pages, podcasts, etc.) |
| `scrollSteps` | `0` | Bottom-scroll iterations to trigger lazy-loaded lists (fixed-steps mode; used by `--preview` and when `maxScrollSteps` doesn't apply) |
| `maxScrollSteps` | `12` | Scroll ceiling in target-count mode (archive harvest). When the harvest needs more candidates than a fixed pass yields, it keeps scrolling up to this many iterations, exiting early once enough candidates are collected or the stream goes stale (3 consecutive scrolls add nothing) |
| `scrollWaitMs` | `1500` | Pause after each scroll |
| `waitMs` | `12000` | Initial render wait after load |
| `maxLinks` | `30` | Link cap |
**Extraction** (single article body + metadata):
| Field | Default | Purpose |
|-------|---------|---------|
| `bodySelectors` | (6 selectors) | Tried in order; first yielding ≥ `bodyMinParagraphs` wins |
| `bodyMinParagraphs` | `3` | Min paragraph count for a selector to qualify |
| `validateArticle` | `false` | When `true`, `fetchPage` probes `og:type` + JSON-LD `@type` + body length and returns `isArticle`; `harvest.js` skips candidates where `isArticle === false`. Use with a *broad* `urlPattern` (so candidate URLs aren't pre-filtered by fragile slug heuristics) to reject section/category pages that slip through once the page is actually fetched. Off by default → existing recipes unchanged. |
| `bodyMinChars` | `500` | Floor on body length for `isArticle` (only consulted when `validateArticle: true`) |
| `dateSelector` | `time[datetime]` | Falls back to JSON-LD `datePublished` |
| `authorSelector` | `[rel="author"], [class*="author"] a` | Falls back to JSON-LD `author.name` |
| `imageSelector` | `img` | |
| `imageMinWidth` | `200` | Drops icons/avatars/tracking pixels |
| `excludeImage` | `["logo","icon","avatar","profile"]` | Substring blocklist on image URL |
| `noisePrefixes` | (Reuters boilerplate) | Paragraphs starting with these are dropped from body |
| `titleStripSuffix` | ` \| Reuters\| ...` | Regex source, stripped from `document.title` |
| `maxBodyChars` | `15000` | Body truncation |
> **Regex quoting**: use single-quoted YAML scalars for regex fields (`'reuters\.com/.+'`).
> Double quotes make js-yaml reject `\.` as an unknown escape. This matches how
> you'd write the regex in code.
>
> **JSON-LD fallback**: if `dateSelector`/`authorSelector` find nothing,
> `fetcher-helper.js` automatically reads `<script type="application/ld+json">`
> (`datePublished` / `author.name`). Many SPA sites (Nikkei) only embed metadata
> there, so you usually don't need a custom selector.
## Troubleshooting recipes
### Convergence check
Preview passes when fetched links are real articles (not nav/section pages) and `charCount > 500`. Diagnose failures against the candidate count `preview.js` prints (`Collected N candidate links`):
| Symptom | Fix |
|---------|-----|
| Too few candidates | `listSelector` too narrow. Re-run `inspect` (without `--write`) to see container groups; if articles live in several card classes, use a **composite `listSelector`** (comma-separated, e.g. `[class*="StreamArticleCard"], [class*="SpotlightArticleCard"]`). |
| Candidates are nav/section pages | Tighten `urlPattern`, or (if article & section URLs share a path shape) set `validateArticle: true` so `fetchPage` rejects section pages by `og:type`/body. |
| Empty body | Fix `bodySelectors`. |
| 0 candidates, all `/location/`-style nav | `listSelector: null` (whole page) is wrong — nav floods the `maxLinks` cap. Scope to card containers. |
### Pitfall: articles spread across multiple card containers
`inspect-source.js` reports the *single largest* container group as `listSelector`, but on many sites (Nikkei, large news hubs) real articles are spread across several card-container classes of similar size, and no one container holds them all. Setting `listSelector` to just the biggest group silently drops the rest.
Symptom: `inspect` shows ~30 article-like links on the page but `preview` collects only ~12. Fix: write `listSelector` as a **comma-separated composite** of every article-card class prefix, e.g.
`[class*="StreamArticleCard"], [class*="SpotlightArticleCard"], [class*="SecondaryArticleCard"]`.
Re-run `inspect` (without `--write`) to enumerate which `*ArticleCard*` classes carry links if unsure.
Why not `listSelector: null` (whole page)? On sites with country/topic nav dropdowns (Nikkei's `/location/<country>` links, ~30 entries), a whole-page scan floods the `maxLinks` cap before real articles are reached. Scoping to card containers avoids this. `inspect` without `--write` shows the container groups so you can pick the right composite.
### When to use `validateArticle` (and when not to)
Set `validateArticle: true` when article and section/category URLs on the site **share a path shape** — i.e. you can't write a `urlPattern` that admits articles and rejects section pages purely from the URL. Nikkei is the canonical case: `/business/tech/semiconductors/<slug>` (article) and `/business/tech/semiconductors` (section) differ only by the trailing slug, and slug length is an unreliable signal. With `validateArticle`, `fetchPage` probes `og:type` + JSON-LD `@type` + body length, marks the section page `isArticle=false` (via `og:type=website` / `@type=Thing` / empty body), and `harvest.js` skips it. This lets you use a *broad* `urlPattern` (no fragile "slug must have N hyphens" guesswork) and still keep section pages out.
Don't set it when the URL alone cleanly separates articles from sections (e.g. Reuters' `/world/<slug>/<date>/` vs. `/world/`) — a tight `urlPattern` is cheaper than a per-candidate page fetch. `validateArticle` defaults to `false`, so existing recipes are unaffected.
## Fixtures format
`recipe-test.js` requires `helpers/<id>.fixtures.json`:
```json
{
"articles": ["https://.../real-article-1", "https://.../real-article-2"],
"sections": ["https://.../section-page-1", "https://.../section-page-2"],
"minLinks": 10
}
```
- `articles`: URLs that must fetch as `isArticle=true` with `bodyChars ≥ bodyMinChars`
- `sections`: URLs that must fetch as `isArticle=false` (only checked when `validateArticle: true`)
- `minLinks`: homepage must yield at least this many candidate links
Pick fixture URLs across the path depths the site uses (2-seg and 3-seg) so depth regressions surface. When the site archives a fixture article, swap in a current one from the same path shape.
/**
* block-check.js — Detect human-verification (CAPTCHA / anti-bot challenge) and
* login-state-loss (auth redirect / paywall login wall) on fetched pages.
*
* When a block is detected, a HumanInterventionError is thrown so harvest.js can
* exit immediately with a friendly message instead of silently archiving empty
* or garbage articles. The check is intentionally conservative: it only fires on
* high-confidence signals (challenge iframes/titles, auth-host redirects, or a
* login-wall CTA paired with a near-empty body) to avoid false positives on
* legitimate articles.
*
* Two entry points:
* • Browser (CDP) sources — evaluateBrowserBlock(ws) runs an in-page probe via
* cdpEval and throws if blocked. Used by fetcher-helper.js / fetcher-browser.js.
* • Direct HTTP sources — checkDirectBlock(html, url) does a string scan of the
* raw HTML. Used by fetcher-direct.js (open sites rarely need login, but a
* Cloudflare-style challenge can still appear).
*/
// Sent to the page as a Runtime.evaluate expression. Serialized from a real
// function via .toString() so regex backslashes survive without double-escaping.
function _browserBlockProbe() {
var url = location.href, host = location.hostname;
var title = (document.title || '').trim();
var bodyText = (document.body && document.body.innerText) ? document.body.innerText.substring(0, 4000) : '';
var bodyLen = bodyText.length;
// 1. CAPTCHA / anti-bot challenge — iframes from known challenge providers.
var challengeIframe = Array.from(document.querySelectorAll('iframe[src]')).some(function (f) {
var s = f.src.toLowerCase();
return s.indexOf('recaptcha') !== -1 || s.indexOf('hcaptcha') !== -1
|| s.indexOf('challenges.cloudflare.com') !== -1
|| s.indexOf('turnstile') !== -1
|| s.indexOf('arkoselabs') !== -1 || s.indexOf('funcaptcha') !== -1;
});
// Challenge page titles (Cloudflare "Just a moment...", "Access Denied", etc.).
var challengeTitle = /^(just a moment|attention required|verify|checking your browser|are you human)/i.test(title)
|| /access denied|请输入验证码|安全验证/i.test(title);
// Challenge body copy.
var challengeText = /please verify (that )?you are human|verify you are human|unusual traffic from your computer|checking your browser before accessing|enable javascript and cookies to continue|our system has detected unusual traffic|请完成安全验证|进行人机验证/i.test(bodyText);
if (challengeIframe || challengeTitle || challengeText) {
return JSON.stringify({ blocked: true, kind: 'captcha', reason: '人机验证/反爬挑战页面', url: url, title: title });
}
// 2. Login-state loss — the article URL redirected to an auth/login host or
// path. A real article page is never served from accounts.* / login.* or a
// /signin path, so this is a high-confidence signal regardless of body length.
var authHost = /^(accounts\.|login\.|signin\.|auth\.|sso\.|chkpt\.)/i.test(host);
var authPath = /\/(signin|sign-in|login|auth|account\/login|subscribe|sso)\b/i.test(location.pathname + location.search);
if (authHost || authPath) {
return JSON.stringify({ blocked: true, kind: 'login', reason: '页面被重定向到登录/认证页面(登录状态可能已失效)', url: url, title: title });
}
// 3. Paywall / login wall — a login/subscribe CTA paired with a near-empty
// body (the page is essentially a gate, not an article). The CTA regex is
// specific ("... to continue/read") to avoid matching normal marketing copy.
var loginCta = /sign in to (continue|read)|log in to (continue|read)|subscribe to (continue|read|keep)|以继续阅读|登录后查看|订阅后查看|登录以继续|登录后可继续阅读/i.test(bodyText);
if (loginCta && bodyLen < 800) {
return JSON.stringify({ blocked: true, kind: 'login', reason: '检测到登录/订阅墙(正文极短且出现登录/订阅提示)', url: url, title: title });
}
return JSON.stringify({ blocked: false, kind: null, reason: '', url: url, title: title });
}
const BROWSER_CHECK_EXPR = '(' + _browserBlockProbe.toString() + ')()';
// Custom error — harvest.js checks `e.name === 'HumanInterventionError'` (or
// `instanceof`) to distinguish a block from an ordinary fetch failure.
class HumanInterventionError extends Error {
constructor(reason, { kind, url, title } = {}) {
super(reason);
this.name = 'HumanInterventionError';
this.kind = kind; // 'captcha' | 'login'
this.blockedUrl = url;
this.pageTitle = title;
}
}
// Parse the JSON string returned by the in-page probe and throw if blocked.
function evaluateBlockResult(raw) {
if (!raw) return;
let info;
try { info = JSON.parse(raw); } catch (e) { return; }
if (info && info.blocked) {
throw new HumanInterventionError(info.reason, { kind: info.kind, url: info.url, title: info.title });
}
}
// Browser (CDP) entry point. Runs the probe in the page via cdpEval; throws
// HumanInterventionError if the page is a challenge/login wall.
async function evaluateBrowserBlock(ws) {
const { cdpEval } = require('./cdp-client');
const raw = await cdpEval(ws, BROWSER_CHECK_EXPR);
evaluateBlockResult(raw);
}
// ── Direct (HTTP) entry point ───────────────────────────────────────────────
// String scan of raw HTML for challenge/login-wall signals. No DOM, so the
// signals are a subset of the browser probe — enough to catch a Cloudflare
// interstitial or a login redirect on an open site.
const DIRECT_CHALLENGE_RE = /please verify (that )?you are human|verify you are human|unusual traffic from your computer|checking your browser before accessing|enable javascript and cookies to continue|our system has detected unusual traffic|请完成安全验证|进行人机验证/i;
const DIRECT_CHALLENGE_TITLE_RE = /<title[^>]*>(just a moment|attention required|verify|checking your browser|are you human|access denied|请输入验证码|安全验证)/i;
const DIRECT_LOGIN_CTA_RE = /sign in to (continue|read)|log in to (continue|read)|subscribe to (continue|read|keep)|以继续阅读|登录后查看|订阅后查看|登录以继续|登录后可继续阅读/i;
function checkDirectBlock(html, url) {
if (!html) return;
const head = html.substring(0, 8000);
if (DIRECT_CHALLENGE_TITLE_RE.test(head) || DIRECT_CHALLENGE_RE.test(head)) {
throw new HumanInterventionError('人机验证/反爬挑战页面', { kind: 'captcha', url });
}
// Login wall: CTA present AND the extracted article body would be tiny. We
// approximate "tiny body" by counting <p> text length in the head slice.
if (DIRECT_LOGIN_CTA_RE.test(head)) {
const paras = head.match(/<p[^>]*>([\s\S]*?)<\/p>/gi) || [];
const textLen = paras.reduce((n, p) => n + p.replace(/<[^>]+>/g, '').trim().length, 0);
if (textLen < 400) {
throw new HumanInterventionError('检测到登录/订阅墙(正文极短且出现登录/订阅提示)', { kind: 'login', url });
}
}
}
module.exports = {
HumanInterventionError,
BROWSER_CHECK_EXPR,
evaluateBrowserBlock,
checkDirectBlock
};
/**
* cdp-client.js — Shared Chrome DevTools Protocol primitives.
*
* Previously every CDP caller (fetcher-browser.js) hand-rolled the same
* WebSocket boilerplate: open connection, send a command with an id, attach a
* message listener matching that id, parse the result. That pattern was
* duplicated 4× and the Page.enable call didn't even check the id (resolving
* on the first message of any kind). This module is the single source of truth
* for those operations so fetcher-helper.js (and a future fetcher-browser
* refactor) can build on top.
*
* Lifecycle (browser launch/discovery) stays in ensure-chromium.js — this
* module only handles per-tab CDP commands, assuming a browser is already up.
*/
const http = require('http');
const fs = require('fs');
const path = require('path');
const WebSocket = require('ws');
const SKILL_DIR = path.dirname(__dirname);
const CONFIG_PATH = path.join(SKILL_DIR, 'config.json');
const TEMPLATE_PATH = path.join(SKILL_DIR, 'references', 'config-template.json');
// ── Utilities ────────────────────────────────────────────────────────────────
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
// Monotonic id for CDP commands. Per-tab sessions are sequential, so a simple
// counter is fine and avoids the random-id collisions the old code risked.
let _msgId = 0;
function nextId() { return ++_msgId; }
// ── Port resolution ──────────────────────────────────────────────────────────
// Precedence: env override > config.chromium.cdpPort > default 9222.
// (Mirrors the old private resolveCdpPort in fetcher-browser.js.)
function resolveCdpPort() {
if (process.env.CDP_PORT_OVERRIDE) return parseInt(process.env.CDP_PORT_OVERRIDE, 10);
try {
const cfgPath = fs.existsSync(CONFIG_PATH) ? CONFIG_PATH : TEMPLATE_PATH;
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
if (cfg.chromium && cfg.chromium.cdpPort) return parseInt(cfg.chromium.cdpPort, 10);
} catch (e) { /* fall through to default */ }
return 9222;
}
// ── WebSocket URL discovery ───────────────────────────────────────────────────
// Fetch /json and pick the first `page` tab's webSocketDebuggerUrl. Retries a
// few times: right after ensure-chromium spawns the browser, the port may
// accept connections a beat before /json responds.
function getWsUrl() {
const port = resolveCdpPort();
const MAX_ATTEMPTS = 6;
const STEP_MS = 500;
return new Promise((resolve, reject) => {
let attempt = 0;
const tryOnce = () => {
attempt++;
http.get(`http://localhost:${port}/json`, (res) => {
let data = '';
res.on('data', d => data += d);
res.on('end', () => {
try {
const tabs = JSON.parse(data);
// Prefer a page tab to avoid grabbing an iframe/service_worker target.
const tab = tabs.find(t => t.type === 'page') || tabs[0];
if (!tab || !tab.webSocketDebuggerUrl) {
return reject(new Error(`CDP /json returned no page tab on :${port}`));
}
resolve(tab.webSocketDebuggerUrl);
} catch (e) {
if (attempt < MAX_ATTEMPTS) return setTimeout(tryOnce, STEP_MS);
reject(e);
}
});
}).on('error', (e) => {
if (attempt < MAX_ATTEMPTS) return setTimeout(tryOnce, STEP_MS);
reject(new Error(`Cannot reach CDP on :${port} after ${MAX_ATTEMPTS} tries (${e.message}). Run: node scripts/ensure-chromium.js`));
});
};
tryOnce();
});
}
// ── Session management ───────────────────────────────────────────────────────
// Open a WebSocket to the page target and resolve once connected.
// Returns { ws, close }. The caller owns the lifecycle (close when done).
function openSession() {
return getWsUrl().then(wsUrl => new Promise((resolve, reject) => {
const ws = new WebSocket(wsUrl);
ws.on('open', () => resolve({ ws, close: () => ws.close() }));
ws.on('error', reject);
}));
}
// ── Command sending ──────────────────────────────────────────────────────────
// Send a CDP command and await its response, matched by id. Rejects on CDP
// error. Concurrent calls are safe — each attaches its own id-matched listener.
function cdpSend(ws, method, params = {}) {
return new Promise((resolve, reject) => {
const id = nextId();
const handler = (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.id === id) {
ws.removeListener('message', handler);
if (msg.error) reject(new Error(`CDP ${method} error: ${JSON.stringify(msg.error)}`));
else resolve(msg.result);
}
};
ws.on('message', handler);
ws.send(JSON.stringify({ id, method, params }));
});
}
// Evaluate a JS expression in the page and return the value (returnByValue).
// Returns undefined if the expression yielded no value.
async function cdpEval(ws, expression) {
const result = await cdpSend(ws, 'Runtime.evaluate', {
expression,
returnByValue: true
});
return result && result.result && result.result.value;
}
// ── Navigation ───────────────────────────────────────────────────────────────
// Enable Page domain, navigate to url, wait for loadEventFired (racing a 25s
// timeout), then sleep waitMs for JS/React to finish rendering.
// Fixes the old Page.enable bug where ws.once('message') resolved on ANY
// message instead of the enable response — here cdpSend id-checks it.
async function navigateAndWait(ws, url, waitMs = 12000) {
await cdpSend(ws, 'Page.enable');
// Attach the load listener BEFORE sending navigate, since loadEventFired can
// fire before the navigate response arrives.
let loaded = false;
const loadPromise = new Promise(resolve => {
const handler = (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.method === 'Page.loadEventFired' && !loaded) {
loaded = true;
ws.removeListener('message', handler);
resolve();
}
};
ws.on('message', handler);
});
await cdpSend(ws, 'Page.navigate', { url });
await Promise.race([loadPromise, sleep(25000)]);
await sleep(waitMs);
}
// ── Scrolling + lazy-load collection ─────────────────────────────────────────
// Scroll to the bottom of the page `steps` times, waiting `waitMs` after each
// scroll, and run `collectExpr` (a JS expression returning a JSON string of an
// array) after the initial load and after each scroll. Results are merged,
// deduplicating by `dedupKey` (default: JSON.stringify of the element).
//
// Returns the merged array (of parsed objects). This is what lets SPA list
// pages that lazy-load article cards yield their full set instead of just the
// first screenful.
//
// Two loop modes (mutually exclusive):
// • Fixed steps (default): exactly `steps` scroll iterations, no early exit.
// Used by preview mode and any caller that just wants a fixed lazy-load
// pass. Backward compatible — no `targetCount` ⇒ this mode.
// • Target-count: when `targetCount` is set, scroll until `merged.length >=
// targetCount` OR `maxSteps` iterations OR `maxStaleSteps` consecutive
// scrolls add nothing (stream exhausted). `steps` is ignored in this mode.
// Used by archive harvest so the fetcher can keep loading a lazy stream
// until enough candidates exist to satisfy the requested count *after*
// dedup, instead of giving up at a fixed `scrollSteps` cap.
async function scrollAndCollect(ws, {
steps = 0, waitMs = 1500, collectExpr, dedupKey,
targetCount = null, maxSteps = 10, maxStaleSteps = 3
}) {
const keyFn = dedupKey || (item => JSON.stringify(item));
const seen = new Set();
const merged = [];
const absorb = (raw) => {
if (!raw) return;
let arr;
try { arr = JSON.parse(raw); } catch (e) { return; }
if (!Array.isArray(arr)) return;
for (const item of arr) {
const k = keyFn(item);
if (!seen.has(k)) { seen.add(k); merged.push(item); }
}
};
absorb(await cdpEval(ws, collectExpr));
// Target-count mode: bounded by maxSteps, exits early on enough items or
// on a stale streak (consecutive scrolls adding zero new items ⇒ the lazy
// stream has run dry, so more scrolling won't help).
if (targetCount && merged.length < targetCount) {
let stale = 0;
for (let i = 0; i < maxSteps && merged.length < targetCount; i++) {
const before = merged.length;
await cdpEval(ws, 'window.scrollTo(0, document.body.scrollHeight)');
await sleep(waitMs);
absorb(await cdpEval(ws, collectExpr));
stale = (merged.length === before) ? stale + 1 : 0;
if (stale >= maxStaleSteps) break;
}
return merged;
}
// Fixed-steps mode (default / preview): backward compatible.
for (let i = 0; i < steps; i++) {
await cdpEval(ws, 'window.scrollTo(0, document.body.scrollHeight)');
await sleep(waitMs);
absorb(await cdpEval(ws, collectExpr));
}
return merged;
}
module.exports = {
sleep,
resolveCdpPort,
getWsUrl,
openSession,
cdpSend,
cdpEval,
navigateAndWait,
scrollAndCollect
};
#!/usr/bin/env node
/**
* ensure-chromium.js - Robust cross-platform Chromium launcher for CDP
*
* Why this exists: the old documented command `chromium ... & sleep 5` assumed
* `chromium` was on PATH. On macOS, Chromium/Chrome ship as .app bundles whose
* real binary lives in /Applications/<Browser>.app/Contents/MacOS/<Binary>, so
* `chromium` is not found and the command silently fails (&>/dev/null swallows
* the error). `sleep 5` is also not a readiness check — a slow first launch or
* a crash leaves you waiting 5s for nothing.
*
* This script:
* 1. Health-checks the CDP port (pure node http, no python3 dependency).
* 2. If not up, discovers the browser binary per-platform (or uses
* config.chromium.executablePath).
* 3. Cleans stale SingletonLock files left by a previous crash.
* 4. Spawns the browser with CDP enabled (headless=new by default, configurable).
* 5. Polls /json/version until ready (up to 30s).
* 6. On timeout, dumps the browser's stderr log tail for diagnosis.
*
* CLI:
* node ensure-chromium.js # ensure running (no-op if already up)
* node ensure-chromium.js --check # only check, exit 0 if up / 1 if not
* node ensure-chromium.js --port 9333 # override CDP port for this run
*
* Used programmatically by harvest.js for browser-method sources.
*/
const fs = require('fs');
const path = require('path');
const http = require('http');
const os = require('os');
const { spawn, execFileSync } = require('child_process');
const SKILL_DIR = path.dirname(__dirname);
const CONFIG_PATH = path.join(SKILL_DIR, 'config.json');
const TEMPLATE_PATH = path.join(SKILL_DIR, 'references', 'config-template.json');
// ── Config ──────────────────────────────────────────────────────────────────
function loadConfig() {
if (!fs.existsSync(CONFIG_PATH)) {
const template = JSON.parse(fs.readFileSync(TEMPLATE_PATH, 'utf8'));
fs.writeFileSync(CONFIG_PATH, JSON.stringify(template, null, 2));
}
return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
}
function resolveOptions(config, portOverride) {
const chromium = (config && config.chromium) || {};
const cdpPort = parseInt(portOverride || process.env.CDP_PORT_OVERRIDE || chromium.cdpPort || '9222', 10);
const headless = chromium.headless !== false; // default true, set false to keep a window
const userDataDir = chromium.userDataDir || path.join(os.tmpdir(), 'news-harvester-chromium');
return { cdpPort, headless, userDataDir, executablePath: chromium.executablePath || null };
}
// ── CDP health check (no python3) ───────────────────────────────────────────
function cdpVersion(port, timeoutMs = 1500) {
return new Promise((resolve, reject) => {
const req = http.get(`http://localhost:${port}/json/version`, (res) => {
let data = '';
res.on('data', (d) => (data += d));
res.on('end', () => {
try { resolve(JSON.parse(data)); }
catch (e) { reject(new Error(`bad /json/version response: ${data.slice(0, 120)}`)); }
});
});
req.on('error', reject);
req.setTimeout(timeoutMs, () => { req.destroy(); reject(new Error('health-check timeout')); });
});
}
async function waitForCdp(port, totalMs = 30000, stepMs = 500) {
const deadline = Date.now() + totalMs;
let lastErr;
while (Date.now() < deadline) {
try {
const v = await cdpVersion(port, 1500);
return v;
} catch (e) {
lastErr = e;
await new Promise((r) => setTimeout(r, stepMs));
}
}
throw new Error(`CDP not ready after ${totalMs / 1000}s (last: ${lastErr && lastErr.message})`);
}
// ── Binary discovery ────────────────────────────────────────────────────────
// Each candidate is { name, path }. `name` is a friendly label for --list; the
// first existing `path` wins in discoverBinary() (backward compat with harvest.js).
function macCandidates() {
const apps = [
['Chromium', 'Chromium.app', 'Chromium'],
['Google Chrome', 'Google Chrome.app', 'Google Chrome'],
['Brave Browser', 'Brave Browser.app', 'Brave Browser'],
['Microsoft Edge', 'Microsoft Edge.app', 'Microsoft Edge'],
];
const out = [];
for (const [name, bundle, bin] of apps) {
out.push({ name, path: `/Applications/${bundle}/Contents/MacOS/${bin}` });
out.push({ name, path: path.join(os.homedir(), 'Applications', bundle, 'Contents', 'MacOS', bin) });
}
out.push({ name: 'Chromium (Homebrew)', path: '/opt/homebrew/bin/chromium' });
out.push({ name: 'Chromium (Homebrew)', path: '/usr/local/bin/chromium' });
return out;
}
function linuxCandidates() {
// `command -v` resolves PATH-based binaries; fall back to common fixed paths.
const names = ['chromium', 'chromium-browser', 'google-chrome', 'google-chrome-stable', 'brave-browser'];
const found = [];
for (const n of names) {
try {
const p = execFileSync('command', ['-v', n], { stdio: ['ignore', 'pipe', 'ignore'], shell: true })
.toString().trim();
if (p) found.push({ name: n, path: p });
} catch (e) { /* not on PATH */ }
}
return [...found,
{ name: 'chromium', path: '/usr/bin/chromium' },
{ name: 'chromium-browser', path: '/usr/bin/chromium-browser' },
{ name: 'chromium (snap)', path: '/snap/bin/chromium' },
];
}
function windowsCandidates() {
const pf = process.env['ProgramFiles'] || 'C:\\Program Files';
const pf86 = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)';
const local = process.env['LOCALAPPDATA'] || '';
return [
{ name: 'Google Chrome', path: path.join(pf, 'Google', 'Chrome', 'Application', 'chrome.exe') },
{ name: 'Google Chrome', path: path.join(pf86, 'Google', 'Chrome', 'Application', 'chrome.exe') },
{ name: 'Google Chrome', path: path.join(local, 'Google', 'Chrome', 'Application', 'chrome.exe') },
{ name: 'Chromium', path: path.join(pf, 'Chromium', 'Application', 'chrome.exe') },
];
}
function candidatesForPlatform() {
return process.platform === 'darwin' ? macCandidates()
: process.platform === 'win32' ? windowsCandidates()
: linuxCandidates();
}
// All browsers actually present on this machine (for --list / agent detection).
function discoverAll() {
const out = [];
for (const c of candidatesForPlatform()) {
try { if (fs.existsSync(c.path) && fs.statSync(c.path).isFile()) out.push(c); } catch (e) {}
}
return out;
}
// First existing binary (backward compat — used by harvest.js + ensureChromium).
function discoverBinary() {
const found = discoverAll();
if (found.length) return found[0].path;
const cands = candidatesForPlatform().map(c => c.path);
throw new Error(
'Could not find a Chromium/Chrome binary. Tried:\n ' + cands.join('\n ') +
'\nSet config.chromium.executablePath to the full path of your browser.'
);
}
// ── Stale-lock cleanup ──────────────────────────────────────────────────────
// A crashed Chromium leaves SingletonLock / SingletonCookie / SingletonSocket in
// the user-data-dir, which make a fresh process exit silently. Remove them.
function cleanStaleLocks(userDataDir) {
for (const name of ['SingletonLock', 'SingletonCookie', 'SingletonSocket']) {
const f = path.join(userDataDir, name);
try { if (fs.existsSync(f)) fs.unlinkSync(f); } catch (e) { /* best effort */ }
}
}
// ── Launch ──────────────────────────────────────────────────────────────────
function spawnBrowser(bin, opts) {
const { cdpPort, headless, userDataDir, url = 'about:blank', windowsHide = true } = opts;
fs.mkdirSync(userDataDir, { recursive: true });
cleanStaleLocks(userDataDir);
const args = [
`--remote-debugging-port=${cdpPort}`,
`--user-data-dir=${userDataDir}`,
'--no-first-run',
'--no-default-browser-check',
'--disable-extensions',
'--disable-gpu',
// Let Chromium auto-detect Wayland vs X11 on Linux. Without this it
// defaults to the X11 backend and crashes with "Missing X server or
// $DISPLAY" on Wayland-only sessions (common on Arch/GNOME/Sway). Harmless
// on macOS/Windows — they don't use the Ozone backend.
'--ozone-platform-hint=auto',
];
// --no-sandbox is only needed when running as root (typical in Linux/Docker
// containers, where Chromium refuses to start with the sandbox as root). On
// macOS/Windows/regular-user Linux it's unnecessary and Chromium shows an
// "unsupported command-line flag" warning banner — so omit it there.
if (typeof process.getuid === 'function' && process.getuid() === 0) {
args.push('--no-sandbox');
}
if (headless) args.push('--headless=new');
args.push(url);
const logPath = path.join(userDataDir, 'chromium-stderr.log');
const logFd = fs.openSync(logPath, 'a');
const child = spawn(bin, args, {
detached: true,
stdio: ['ignore', 'ignore', logFd],
windowsHide,
env: { ...process.env },
});
child.on('error', (e) => {
console.error(`Chromium spawn error: ${e.message}`);
});
// Detach so it survives this process exiting.
child.unref();
return { pid: child.pid, logPath };
}
function tailFile(filePath, lines = 25) {
try {
const content = fs.readFileSync(filePath, 'utf8');
return content.split('\n').filter(Boolean).slice(-lines).join('\n');
} catch (e) { return '(no log file)'; }
}
// Diagnose display/X11 failures — common on Wayland-only Linux sessions where
// $DISPLAY is unset and Chromium wasn't told (or couldn't) use the Wayland
// backend. Returns a multi-line hint string.
function displayDiagnostic() {
const d = process.env.DISPLAY ? `set (${process.env.DISPLAY})` : 'unset';
const w = process.env.WAYLAND_DISPLAY ? `set (${process.env.WAYLAND_DISPLAY})` : 'unset';
const lines = [
` $DISPLAY: ${d}`,
` $WAYLAND_DISPLAY: ${w}`,
];
if (!process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
lines.push(' ⚠️ Neither is set — this shell is not attached to a graphical session.');
lines.push(' Run --login from a terminal inside your desktop (GUI terminal), or');
lines.push(' export the variables from a graphical session, e.g.:');
lines.push(' export WAYLAND_DISPLAY=wayland-0 # Wayland');
lines.push(' export DISPLAY=:0 # X11 / XWayland');
} else if (!process.env.DISPLAY && process.env.WAYLAND_DISPLAY) {
lines.push(' (Wayland session. --ozone-platform-hint=auto is already passed; if it still');
lines.push(' fails, force Wayland by adding --ozone-platform=wayland to the browser args,');
lines.push(' or run in a GUI terminal that also exports $DISPLAY for XWayland.)');
}
return lines.join('\n');
}
function looksLikeDisplayFailure(log) {
return /Missing X server|\$DISPLAY|platform failed to initialize|ozone/i.test(log);
}
// ── Main ────────────────────────────────────────────────────────────────────
async function ensureChromium(config, portOverride) {
const opts = resolveOptions(config, portOverride);
// 1. Already up? (idempotent — safe to call every run)
try {
const v = await cdpVersion(opts.cdpPort, 1500);
return { port: opts.cdpPort, alreadyRunning: true, browser: v.Browser };
} catch (e) { /* not up yet, fall through to launch */ }
// 2. Discover binary
const bin = opts.executablePath || discoverBinary();
if (!opts.executablePath && !fs.existsSync(bin)) {
// discoverBinary already throws, but guard explicit paths too
throw new Error(`executablePath not found: ${bin}`);
}
// 3. Launch
const { pid, logPath } = spawnBrowser(bin, opts);
// 4. Wait for readiness
try {
const v = await waitForCdp(opts.cdpPort, 30000, 500);
return { port: opts.cdpPort, pid, browser: v.Browser, bin, logPath };
} catch (e) {
const logTail = tailFile(logPath);
const hint = looksLikeDisplayFailure(logTail) ? '\n' + displayDiagnostic() + '\n' : '';
throw new Error(
`Chromium did not become ready on port ${opts.cdpPort}.\n` +
` binary: ${bin}\n` +
` userData: ${opts.userDataDir}\n` +
` log: ${logPath}\n` +
` stderr tail:\n${logTail}${hint}`
);
}
}
// ── CLI ─────────────────────────────────────────────────────────────────────
const HELP = `ensure-chromium.js — robust cross-platform Chromium launcher for CDP
USAGE
node ensure-chromium.js Ensure Chromium is up on the CDP port
(launches it if not; no-op if already running)
node ensure-chromium.js --check Only health-check; exit 0 if up, 1 if not
node ensure-chromium.js --list Detect platform + all available browsers,
print current config; exit 0
node ensure-chromium.js --login [--url <url>] [--port <port>] [--profile <dir>]
Launch a WINDOWED browser on the CDP port for
interactive login. Persisted to a fixed profile
so headless harvests keep your cookies.
node ensure-chromium.js --port 9333 Override the CDP port for this run
node ensure-chromium.js --help Show this help
WHAT IT DOES
1. Health-checks http://localhost:<port>/json/version (pure node, no python3).
2. If not up, discovers the browser binary per platform:
macOS — /Applications/{Chromium,Google Chrome,Brave,Edge}.app/Contents/MacOS/*
+ /opt/homebrew/bin/chromium, /usr/local/bin/chromium
Linux — chromium, chromium-browser, google-chrome[-stable], brave-browser
(via command -v) + /usr/bin/*, /snap/bin/chromium
Win — Program Files\\Google\\Chrome\\Application\\chrome.exe (+x86, %LOCALAPPDATA%)
Override discovery with config.chromium.executablePath.
3. Cleans stale SingletonLock files left by a crashed browser.
4. Spawns the browser (headless=new by default; --no-sandbox --disable-gpu,
remote-debugging-port=<port>, user-data-dir=<dir>, about:blank).
5. Polls /json/version until ready (up to 30s, every 500ms).
6. On timeout, prints binary path / port / userDataDir / stderr-log tail.
--LIST
Prints the platform, every Chromium/Chrome binary found on this machine, and
the current chromium config (port / headless / executablePath / userDataDir).
Use this to report the environment to a new user before launching a login.
--LOGIN
Launches a WINDOWED browser (headless=false) with CDP enabled so the user can
log into a site interactively. The profile persists, so later headless harvests
reuse the same cookies/login state.
--url <url> Page to open (default about:blank; pass the source homepage)
--port <port> CDP port (default config.chromium.cdpPort / 9222)
--profile <dir> Profile dir (default config.chromium.userDataDir, else
~/.news-harvester/chromium-profile — a fixed path, NOT the
ephemeral tmp dir, so login state survives)
If the CDP port is already occupied (e.g. a headless harvest instance), --login
reports it and exits 0 WITHOUT launching (avoiding a SingletonLock clash).
If the profile used is the default path (config had no userDataDir), --login
prints a ready-to-paste config.json fragment so the agent/user can persist it.
CONFIG (config.json, all optional — see SKILL.md "Configuration")
chromium.cdpPort CDP port (default 9222; env CDP_PORT_OVERRIDE wins)
chromium.headless true (default) | false (keep a window)
chromium.executablePath full path to browser binary (null = auto-discover)
chromium.userDataDir browser profile dir (null = <tmp>/news-harvester-chromium).
Set this to a fixed profile you've logged into once
(via --login) so future auto-launches keep your
cookies/login state.
EXIT CODES
0 CDP is up (already running or just launched); --list ok; --login ok/already-running
1 CDP not up (--check), or launch failed
EXAMPLES
# Diagnose: is Chromium reachable?
node scripts/ensure-chromium.js --check
# Detect what browsers are installed (new-user onboarding step 1)
node scripts/ensure-chromium.js --list
# Launch (auto-discovers Chrome on a Mac)
node scripts/ensure-chromium.js
# Open a windowed browser on the source homepage for interactive login
node scripts/ensure-chromium.js --login --url https://www.reuters.com/
# Use a non-default port for both this launcher and harvest.js
CDP_PORT_OVERRIDE=9333 node scripts/ensure-chromium.js
CDP_PORT_OVERRIDE=9333 node scripts/harvest.js reuters 2 --preview`;
// ── --list: detect platform + available browsers + config ───────────────────
function platformLabel() {
return process.platform === 'darwin' ? 'macOS'
: process.platform === 'win32' ? 'Windows'
: 'Linux';
}
async function cmdList(config) {
const opts = resolveOptions(config, null);
console.log(`Platform: ${platformLabel()} (${process.platform})`);
const found = discoverAll();
if (found.length === 0) {
console.log('Browsers: none found in standard locations.');
console.log(' Set config.chromium.executablePath to your browser binary.');
} else {
console.log('Browsers found:');
for (const b of found) console.log(` • ${b.name}${b.path}`);
}
console.log('\nCurrent chromium config:');
console.log(` cdpPort: ${opts.cdpPort}`);
console.log(` headless: ${opts.headless}`);
console.log(` executablePath: ${opts.executablePath || '(auto-discover)'}`);
console.log(` userDataDir: ${opts.userDataDir || '(ephemeral tmp — set a fixed path for login persistence)'}`);
process.exit(0);
}
// ── --login: windowed browser for interactive login ─────────────────────────
const DEFAULT_PROFILE = path.join(os.homedir(), '.news-harvester', 'chromium-profile');
async function cmdLogin(config, flagUrl, flagPort, flagProfile) {
const chromium = (config && config.chromium) || {};
const port = parseInt(flagPort || chromium.cdpPort || '9222', 10);
// A persistent profile is MANDATORY for login; never fall back to the tmp dir.
const profile = flagProfile || chromium.userDataDir || DEFAULT_PROFILE;
const url = flagUrl || 'about:blank';
// 1. Port already occupied? Report and exit 0 (don't clash).
try {
const v = await cdpVersion(port, 1500);
console.log(`ℹ️ A browser is already running on CDP port :${port}${v.Browser}`);
console.log(' If that is a headless harvest instance, close it first (Cmd+Q / Ctrl+Q), then re-run --login.');
console.log(' (Not reusing it: it may be a different profile, and a second instance would hit a SingletonLock.)');
process.exit(0);
} catch (e) { /* port free — proceed to launch */ }
// 2. Discover binary.
const bin = chromium.executablePath || discoverBinary();
if (!fs.existsSync(bin)) {
console.error(`❌ Browser binary not found: ${bin}`);
console.error(' Set config.chromium.executablePath to the full path of your browser.');
process.exit(1);
}
// 3. Launch windowed (headless=false, window visible, open the target url).
const { pid, logPath } = spawnBrowser(bin, {
cdpPort: port, headless: false, userDataDir: profile, url, windowsHide: false,
});
// 4. Short readiness poll (15s) — catches silent launch failures.
try {
const v = await waitForCdp(port, 15000, 500);
console.log(`✅ Windowed browser launched (pid ${pid}) on :${port}${v.Browser}`);
console.log(` binary: ${bin}`);
console.log(` profile: ${profile}`);
console.log(` log: ${logPath}`);
} catch (e) {
const logTail = tailFile(logPath);
console.error(`❌ Browser did not become ready on :${port} within 15s.`);
console.error(` binary: ${bin}`);
console.error(` profile: ${profile}`);
console.error(` log: ${logPath}`);
console.error(` stderr tail:\n${logTail}`);
if (looksLikeDisplayFailure(logTail)) {
console.error(`\n ${displayDiagnostic()}`);
}
process.exit(1);
}
// 5. Guide the user through login.
const siteHint = url !== 'about:blank' ? ` at ${url}` : '';
console.log(`\n🔐 A browser window has opened${siteHint}.`);
console.log(' 1. Log into the site inside that window (it uses the profile above).');
console.log(' 2. When done, QUIT the browser fully: Cmd+Q (macOS) / Ctrl+Q (Linux/Windows).');
console.log(' 3. The login state (cookies) is persisted in the profile, so future headless');
console.log(' harvests auto-launch with this same profile and stay logged in.');
// 6. If the profile is the default path (config had no userDataDir), prompt to persist it.
const configHadProfile = !!(chromium.userDataDir);
if (!configHadProfile) {
console.log(`\n📝 To make this profile permanent, add userDataDir to config.json's "chromium" block:`);
console.log(` "chromium": { "userDataDir": "${profile}" }`);
console.log(' (This script does not edit config.json — write the snippet above in yourself.)');
}
process.exit(0);
}
// ── CLI ─────────────────────────────────────────────────────────────────────
function flagValue(argv, name) {
const i = argv.indexOf(name);
return i >= 0 ? argv[i + 1] : null;
}
if (require.main === module) {
const argv = process.argv.slice(2);
if (argv.includes('--help') || argv.includes('-h')) {
console.log(HELP);
process.exit(0);
}
if (argv.includes('--list')) {
cmdList(loadConfig());
return;
}
if (argv.includes('--login')) {
cmdLogin(
loadConfig(),
flagValue(argv, '--url'),
flagValue(argv, '--port'),
flagValue(argv, '--profile')
);
return;
}
const checkOnly = argv.includes('--check');
const portOverride = flagValue(argv, '--port');
(async () => {
const config = loadConfig();
const opts = resolveOptions(config, portOverride);
if (checkOnly) {
try {
const v = await cdpVersion(opts.cdpPort, 1500);
console.log(`✅ Chromium CDP up on :${opts.cdpPort}${v.Browser}`);
process.exit(0);
} catch (e) {
console.log(`❌ Chromium CDP not running on :${opts.cdpPort} (${e.message})`);
process.exit(1);
}
}
try {
const r = await ensureChromium(config, portOverride);
if (r.alreadyRunning) {
console.log(`✅ Chromium already running on :${r.port}${r.browser}`);
} else {
console.log(`✅ Chromium launched (pid ${r.pid}) on :${r.port}${r.browser}`);
console.log(` binary: ${r.bin}`);
console.log(` log: ${r.logPath}`);
}
} catch (e) {
console.error(`❌ ${e.message}`);
process.exit(1);
}
})();
}
module.exports = { ensureChromium, cdpVersion, resolveOptions, discoverBinary, discoverAll };
/**
* fetcher-helper.js — Spec-driven fetcher for sources with a helper.md recipe.
*
* Each helper.md is a YAML-frontmattered markdown file whose frontmatter is a
* structured spec (selectors / scroll / filter). This module parses that spec
* and drives Chromium via the shared cdp-client primitives — no per-source JS.
*
* Two operations, mirroring the legacy fetcher contract:
* fetchHomeLinks(url, spec) → string[] (absolute article URLs, deduped)
* fetchPage(url, spec) → { title, date, authors, imgs, body }
*
* The 2-arg `(url, spec)` signature aligns with how harvest.js calls legacy
* fetchers `(url, ctx)`, so the dispatch branch in harvest.js stays minimal.
*
* Usage (CLI, for manual testing):
* node fetcher-helper.js <helperPath> links <url>
* node fetcher-helper.js <helperPath> page <url>
*/
const fs = require('fs');
const path = require('path');
const yaml = require('js-yaml');
const { openSession, navigateAndWait, scrollAndCollect, cdpEval } = require('./cdp-client');
const { evaluateBrowserBlock } = require('./block-check');
// ── Spec defaults ────────────────────────────────────────────────────────────
// Defaults reproduce the existing hard-coded behaviour of fetcher-browser.js,
// so a minimal helper.md (just urlPattern + listSelector) already improves on
// legacy without forcing the author to redeclare every field.
const DEFAULTS = {
// ── Discovery ──
listSelector: null, // CSS selector for the article-list container(s); null = whole page
linkSelector: 'a[href]', // candidate links inside each container
urlPattern: null, // REQUIRED — regex source; article URL validation
excludeUrlPattern: null, // regex source; URLs to drop even if they match urlPattern
scrollSteps: 0, // bottom-scroll iterations to trigger lazy-load (fixed-steps mode)
maxScrollSteps: 12, // scroll ceiling in target-count mode (archive harvest tops up
// candidates until dedup can still satisfy the requested count)
scrollWaitMs: 1500, // pause after each scroll
waitMs: 12000, // initial render wait after load
maxLinks: 30,
// ── Extraction ──
bodySelectors: [
'[data-testid^="paragraph"]',
'[class*="articleBodyContent"] p',
'[class*="article-body"] p',
'[class*="ArticleBody"] p',
'[class*="story-body"] p',
'article p'
],
bodyMinParagraphs: 3, // a selector must yield ≥ this many nodes to "win"
// Article validation: when validateArticle is true, fetchPage additionally
// probes og:type + JSON-LD @type + body length and returns `isArticle`. A
// recipe with a broad urlPattern (so candidate URLs aren't pre-filtered by
// fragile slug heuristics) sets this to reject section/category pages that
// slip through the URL filter once the page is actually fetched. Off by
// default → existing recipes keep their original behaviour.
validateArticle: false,
bodyMinChars: 500,
dateSelector: 'time[datetime]',
authorSelector: '[rel="author"], [class*="author"] a',
imageSelector: 'img',
imageMinWidth: 200,
excludeImage: ['logo', 'icon', 'avatar', 'profile'],
// CSS selector: any <img> whose closest ancestor (or itself) matches is
// dropped during extraction. Use this to exclude "recommended articles" /
// "related articles" thumbnails that share the same CDN host as the hero
// image (so src-substring excludeImage can't tell them apart). null = off.
excludeImageSelector: null,
noisePrefixes: [
'Reporting by', 'Editing by', 'Access unmatched', 'Browse an unrivalled',
'Screen for heightened', 'Sign up here', 'Reuters, the news',
'All quotes delayed', 'See here for'
],
titleStripSuffix: ' \\| Reuters| \\| The Conversation| - Bloomberg$', // regex source, applied with /g
maxBodyChars: 15000
};
// Parse a helper.md file into a fully-defaulted spec object.
// Throws if frontmatter is missing or urlPattern is absent.
function loadSpec(helperPath) {
const raw = fs.readFileSync(helperPath, 'utf8');
const fm = raw.match(/^---\s*\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n|$)/);
if (!fm) throw new Error(`No YAML frontmatter found in ${helperPath}`);
const parsed = yaml.load(fm[1]) || {};
const spec = { ...DEFAULTS, ...parsed };
if (!spec.urlPattern) {
throw new Error(`helper.md ${path.basename(helperPath)} missing required 'urlPattern'`);
}
return spec;
}
// ── Expression builders ──────────────────────────────────────────────────────
// Each builder returns a JS expression string to be evaluated in the page via
// Runtime.evaluate. Spec values are embedded via JSON.stringify so quoting and
// backslashes survive the round-trip (YAML → js-yaml → JS string → JSON → expr).
function buildDiscoveryExpr(spec) {
const listSel = spec.listSelector ? JSON.stringify(spec.listSelector) : 'null';
const linkSel = JSON.stringify(spec.linkSelector);
const urlPattern = JSON.stringify(spec.urlPattern);
const excludePattern = spec.excludeUrlPattern ? JSON.stringify(spec.excludeUrlPattern) : 'null';
// listSelector may match one big container OR many card containers; handle
// both by iterating every match and collecting links within each.
return `(function(){
var roots = ${listSel} ? Array.from(document.querySelectorAll(${listSel})) : [document];
var urlRe = new RegExp(${urlPattern});
var exRe = ${excludePattern} ? new RegExp(${excludePattern}) : null;
var out = [], seen = {};
for (var r = 0; r < roots.length; r++) {
var links = Array.from(roots[r].querySelectorAll(${linkSel}));
for (var i = 0; i < links.length; i++) {
var href = links[i].href;
if (!href) continue;
try { var u = new URL(href); u.search = ''; u.hash = ''; href = u.toString(); }
catch (e) { continue; }
if (!urlRe.test(href)) continue;
if (exRe && exRe.test(href)) continue;
if (seen[href]) continue;
seen[href] = true;
out.push({ url: href, text: (links[i].innerText || links[i].textContent || '').trim().substring(0, 200) });
}
}
return JSON.stringify(out);
})()`;
}
function buildExtractExpr(spec) {
const bodySelectors = JSON.stringify(spec.bodySelectors);
const bodyMinParagraphs = spec.bodyMinParagraphs;
const dateSelector = JSON.stringify(spec.dateSelector);
const authorSelector = JSON.stringify(spec.authorSelector);
const imageMinWidth = spec.imageMinWidth;
const excludeImage = JSON.stringify(spec.excludeImage);
// imageSelector scopes which <img> nodes are candidates for download. The
// default 'img' (whole document) is kept for backward compat, but recipes
// should narrow it to the article-body container so bottom-of-page
// "recommended articles" thumbnails aren't pulled into assets/. Supported
// as a single CSS selector or a comma-separated list (querySelectorAll
// handles both); null/empty falls back to 'img'.
const imageSelector = JSON.stringify(spec.imageSelector || 'img');
// excludeImageSelector: drop an <img> if it (or any ancestor) matches this
// CSS selector. Comma-separated lists are valid. null → no container-based
// exclusion (existing behaviour). Used to filter "recommended articles"
// thumbnails that share a CDN host with the hero image.
const excludeImageSelector = spec.excludeImageSelector
? JSON.stringify(spec.excludeImageSelector) : 'null';
const noisePrefixes = JSON.stringify(spec.noisePrefixes);
const titleStripRe = JSON.stringify(spec.titleStripSuffix);
const maxBodyChars = spec.maxBodyChars;
// Article-validation knobs (see DEFAULTS). When validateArticle is false the
// isArticle signal is always true and the og:type/JSON-LD probes are skipped,
// so non-recipe / non-validating sources keep their original behaviour.
const validateArticle = !!spec.validateArticle;
const bodyMinChars = spec.bodyMinChars || 500;
return `(function(){
var title = document.title.replace(new RegExp(${titleStripRe}, 'g'), '').trim();
// JSON-LD fallback: many sites (Nikkei, Reuters) embed datePublished/author
// only in <script type="application/ld+json">, not in <time> or visible
// author links. Parse it once up front so the CSS-selector results can
// fall back to it below.
var ld = null;
var ldEl = document.querySelector('script[type="application/ld+json"]');
if (ldEl) { try { ld = JSON.parse(ldEl.textContent); } catch (e) {} }
function ldGet(obj, key) {
if (!obj) return null;
if (Array.isArray(obj)) { for (var i=0;i<obj.length;i++){ var v=ldGet(obj[i],key); if(v) return v; } return null; }
if (obj[key]) return obj[key];
if (obj['@graph']) return ldGet(obj['@graph'], key);
return null;
}
var ldDate = ldGet(ld, 'datePublished');
var ldAuthor = ldGet(ld, 'author');
var ldAuthorNames = [];
if (Array.isArray(ldAuthor)) {
ldAuthorNames = ldAuthor.map(function(a){ return typeof a === 'string' ? a : (a && a.name) || ''; }).filter(Boolean);
} else if (ldAuthor) {
ldAuthorNames = [typeof ldAuthor === 'string' ? ldAuthor : (ldAuthor.name || '')].filter(Boolean);
}
var dateEl = document.querySelector(${dateSelector});
var date = dateEl ? dateEl.getAttribute('datetime') : '';
if (!date && ldDate) date = ldDate;
var authors = Array.from(document.querySelectorAll(${authorSelector}))
.map(function(a){ return a.innerText.trim(); }).filter(Boolean)
.filter(function(v,i,a){ return a.indexOf(v) === i; }).join(', ');
if (!authors && ldAuthorNames.length) authors = ldAuthorNames.join(', ');
var exclImgSel = ${excludeImageSelector};
var imgs = Array.from(document.querySelectorAll(${imageSelector}))
.filter(function(img){
// Container-based exclusion: drop if the img (or any ancestor) sits
// inside a "recommended/related articles" block. Skipped when no
// excludeImageSelector is configured.
return !exclImgSel || !img.closest(exclImgSel);
})
.map(function(img){
return { src: img.src, alt: img.alt || '', width: img.naturalWidth || img.width || 0 };
}).filter(function(i){
return i.src && i.src.indexOf('http') === 0 && i.width > ${imageMinWidth}
&& !${excludeImage}.some(function(x){ return i.src.toLowerCase().indexOf(x) !== -1; });
}).slice(0, 6);
var body = '';
var bodyParagraphs = 0;
var sels = ${bodySelectors};
for (var s = 0; s < sels.length; s++) {
var els = document.querySelectorAll(sels[s]);
if (els.length >= ${bodyMinParagraphs}) {
bodyParagraphs = els.length;
body = Array.from(els).map(function(e){ return e.innerText.trim(); })
.filter(function(t){ return t.length > 40; }).join('\\n\\n');
break;
}
}
if (!body) {
var fallback = Array.from(document.querySelectorAll('p'))
.map(function(e){ return e.innerText.trim(); })
.filter(function(t){ return t.length > 60
&& !${noisePrefixes}.some(function(n){ return t.indexOf(n) === 0; }); });
bodyParagraphs = fallback.length;
body = fallback.join('\\n\\n');
}
// Article-validation signal: og:type + JSON-LD @type + body length. A
// section/category page typically has og:type=website, @type=Thing, and
// zero body paragraphs, so this cleanly separates real articles from the
// candidate URLs a widened urlPattern lets through on list pages. When
// validateArticle is off the probe cost is avoided and isArticle is true.
var ogType = '', ldType = '', isArticle = true;
if (${validateArticle ? 'true' : 'false'}) {
var ogEl = document.querySelector('meta[property="og:type"]');
if (ogEl) ogType = ogEl.content || '';
if (ld) {
var t = ldGet(ld, '@type');
ldType = Array.isArray(t) ? t.join(',') : (t || '');
}
isArticle = (ogType === 'article' || /NewsArticle|Article/.test(ldType))
&& body.length >= ${bodyMinChars}
&& bodyParagraphs >= ${bodyMinParagraphs};
}
return JSON.stringify({
title: title, date: date, authors: authors, imgs: imgs,
body: body.substring(0, ${maxBodyChars}),
isArticle: isArticle, ogType: ogType, ldType: ldType,
bodyChars: body.length, bodyParagraphs: bodyParagraphs
});
})()`;
}
// ── Public API ───────────────────────────────────────────────────────────────
// Discover article links on a listing page. Returns absolute URL strings
// (deduped, query/hash stripped, capped at spec.maxLinks) — the same shape
// legacy fetchHomeLinks returns so harvest.js needs no changes downstream.
//
// Optional `desiredCount` (archive harvest): when > 0, switch scrollAndCollect
// into target-count mode so a lazy stream keeps loading until ~desiredCount
// candidates exist (or the stream goes stale / hits maxScrollSteps). This lets
// the harvest top up candidates enough that, after registry dedup, the
// requested count of *new* articles can still be satisfied — instead of being
// capped by the fixed `scrollSteps` lazy-load pass. The slice cap is widened
// to `max(maxLinks, desiredCount)` so the extra candidates aren't discarded.
async function fetchHomeLinks(url, spec, desiredCount) {
const { ws, close } = await openSession();
try {
await navigateAndWait(ws, url, spec.waitMs);
await evaluateBrowserBlock(ws);
const opts = {
waitMs: spec.scrollWaitMs,
collectExpr: buildDiscoveryExpr(spec),
dedupKey: item => item.url
};
if (desiredCount && desiredCount > 0) {
opts.targetCount = desiredCount;
opts.maxSteps = spec.maxScrollSteps;
} else {
opts.steps = spec.scrollSteps;
}
const items = await scrollAndCollect(ws, opts);
const cap = (desiredCount && desiredCount > 0)
? Math.max(spec.maxLinks || 30, desiredCount)
: (spec.maxLinks || 30);
return items.map(i => i.url).slice(0, cap);
} finally {
close();
}
}
// Extract a single article's content. Returns { title, date, authors, imgs, body }
// or null if evaluation yielded nothing.
async function fetchPage(url, spec) {
const { ws, close } = await openSession();
try {
await navigateAndWait(ws, url, spec.waitMs);
await evaluateBrowserBlock(ws);
const result = await cdpEval(ws, buildExtractExpr(spec));
return result ? JSON.parse(result) : null;
} finally {
close();
}
}
module.exports = { loadSpec, fetchHomeLinks, fetchPage };
// ── CLI (manual testing) ─────────────────────────────────────────────────────
// node fetcher-helper.js helpers/reuters.md links https://www.reuters.com/
// node fetcher-helper.js helpers/reuters.md page https://www.reuters.com/world/...
if (require.main === module) {
const [helperPath, mode, url] = process.argv.slice(2);
if (!helperPath || !mode || !url) {
console.error('Usage: node fetcher-helper.js <helperPath> links|page <url>');
process.exit(1);
}
const spec = loadSpec(helperPath);
console.error(`Loaded spec: ${path.basename(helperPath)} (urlPattern=${spec.urlPattern}, listSelector=${spec.listSelector || '(whole page)'})`);
const fn = mode === 'links' ? fetchHomeLinks : fetchPage;
fn(url, spec).then(data => {
process.stdout.write(JSON.stringify(data, null, 2));
}).catch(e => { console.error('Error:', e.message); process.exit(1); });
}
#!/usr/bin/env node
/**
* inspect-source.js — Diagnose a news source's page structure to author a
* helper.md recipe. This is the tool you reach for when adding a new browser
* source: it shows you WHERE article links live on the page so you can pick a
* good listSelector and urlPattern instead of guessing.
*
* Usage:
* node scripts/inspect-source.js <url> [--pattern REGEX] [--scroll N] [--wait MS] [--selector CSS]
*
* What it does:
* 1. Launches Chromium (via ensure-chromium) and navigates to <url>.
* 2. Optionally scrolls N times (default 0) to trigger lazy-loaded lists.
* 3. Collects every <a[href]> on the page, grouped by the "container
* signature" of its nearest meaningful ancestor — a short label derived
* from the ancestor's tag + data-testid/class/id. This reveals which page
* region holds the real article stream (usually one big group) vs.
* nav/footer/sidebars (many small groups).
* 4. Filters links by --pattern if given.
* 5. Prints each group: count + a sample of link texts and URLs, sorted by
* count descending (the biggest group is almost always the main list).
* 6. Prints a ready-to-paste helper.md skeleton using the largest matching
* group's container signature as listSelector.
*
* Examples:
* node scripts/inspect-source.js https://www.reuters.com/ --scroll 3
* node scripts/inspect-source.js https://asia.nikkei.com/business/tech --pattern 'nikkei\\.com/.+'
* node scripts/inspect-source.js https://www.reuters.com/ --selector 'main'
*
* Exit code 0 on success, 1 on error.
*/
const fs = require('fs');
const path = require('path');
const SKILL_DIR = path.dirname(__dirname);
const CONFIG_PATH = path.join(SKILL_DIR, 'config.json');
const TEMPLATE_PATH = path.join(SKILL_DIR, 'references', 'config-template.json');
function loadConfig() {
const cfgPath = fs.existsSync(CONFIG_PATH) ? CONFIG_PATH : TEMPLATE_PATH;
return JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
}
const { openSession, navigateAndWait, scrollAndCollect, cdpEval, sleep } = require('./cdp-client');
// ── Arg parsing ──────────────────────────────────────────────────────────────
function parseArgs(argv) {
const out = { scroll: 0, wait: 12000, pattern: null, selector: null, url: null, write: null, name: null, force: false };
const positional = [];
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--scroll') out.scroll = parseInt(argv[++i] || '0', 10);
else if (a === '--wait') out.wait = parseInt(argv[++i] || '12000', 10);
else if (a === '--pattern') out.pattern = argv[++i];
else if (a === '--selector') out.selector = argv[++i];
else if (a === '--write') out.write = argv[++i];
else if (a === '--name') out.name = argv[++i];
else if (a === '--force') out.force = true;
else if (a === '-h' || a === '--help') { out.help = true; }
else positional.push(a);
}
out.url = positional[0];
return out;
}
function printHelp() {
console.log(`inspect-source.js — Diagnose a news source's page structure for helper.md authoring.
Usage:
node scripts/inspect-source.js <url> [options]
Options:
--pattern REGEX Only show links whose URL matches this regex (try the site's article URL shape)
--scroll N Scroll to the bottom N times to trigger lazy-loaded lists (default 0)
--wait MS Initial render wait in ms (default 12000)
--selector CSS Restrict link collection to descendants of this container (default: whole page)
--write <id> Write a ready-to-use helpers/<id>.md using the measured listSelector + urlPattern
(refuses if helper exists unless --force)
--name <name> Display name for the recipe header (defaults to <id> if omitted)
--force With --write, overwrite an existing helpers/<id>.md
-h, --help Show this help
Output:
- Links grouped by container signature, biggest group first
- With --write: writes helpers/<id>.md and prints a one-line confirmation + next step
- Without --write: prints a ready-to-paste helper.md skeleton
Examples:
node scripts/inspect-source.js https://www.reuters.com/ --scroll 3
node scripts/inspect-source.js https://www.reuters.com/markets/ --scroll 3 --write reuters-markets --name "路透社市场"
node scripts/inspect-source.js https://asia.nikkei.com/business/tech --pattern 'nikkei\\\\.com/.+'`);
}
// ── Page-side collection expression ──────────────────────────────────────────
// Returns a JSON string of [{ url, text, container }]. `container` is a short
// signature of the nearest ancestor that has an identifying attribute, so links
// from the same article stream share a container label and cluster together.
function buildCollectExpr(rootSelector, patternStr) {
const rootExpr = rootSelector ? JSON.stringify(rootSelector) : 'null';
const patExpr = patternStr ? JSON.stringify(patternStr) : 'null';
return `(function(){
var roots = ${rootExpr} ? Array.from(document.querySelectorAll(${rootExpr})) : [document];
var pat = ${patExpr} ? new RegExp(${patExpr}) : null;
var out = [];
// Build a short signature for an element: tag + first useful attr.
function sig(el){
if (!el || el === document.body) return 'body';
var t = el.tagName.toLowerCase();
var dt = el.getAttribute('data-testid');
if (dt) return t + '[data-testid="' + dt + '"]';
var id = el.id;
if (id) return t + '#' + id;
var cls = (el.className && typeof el.className === 'string')
? el.className.trim().split(/\\s+/)[0] : '';
if (cls) return t + '.' + cls;
return sig(el.parentElement) || t;
}
for (var r = 0; r < roots.length; r++) {
var links = Array.from(roots[r].querySelectorAll('a[href]'));
for (var i = 0; i < links.length; i++) {
var href = links[i].href;
if (!href || href.indexOf('http') !== 0) continue;
if (pat && !pat.test(href)) continue;
var text = (links[i].innerText || links[i].textContent || '').trim().replace(/\\s+/g,' ').substring(0, 120);
// Walk up to the nearest ancestor with an identifying attribute (skip <a> itself).
var node = links[i].parentElement;
var c = '';
var hops = 0;
while (node && hops < 6) {
var s = sig(node);
if (s !== node.tagName.toLowerCase() && s !== 'body') { c = s; break; }
node = node.parentElement; hops++;
}
if (!c) c = sig(links[i].parentElement) || 'body';
out.push({ url: href, text: text, container: c });
}
}
return JSON.stringify(out);
})()`;
}
// ── Reporting ────────────────────────────────────────────────────────────────
function groupByContainer(items) {
const groups = {};
for (const it of items) {
(groups[it.container] = groups[it.container] || []).push(it);
}
return Object.entries(groups)
.map(([container, links]) => ({ container, links, count: links.length }))
.sort((a, b) => b.count - a.count);
}
function printGroups(groups, maxGroups, samplePerGroup) {
if (!groups.length) {
console.log('\n (no links found — try --scroll, a longer --wait, or a different --selector)');
return;
}
console.log(`\n📦 ${groups.length} container group(s), ${groups.reduce((n,g)=>n+g.count,0)} link(s) total\n`);
for (let i = 0; i < Math.min(groups.length, maxGroups); i++) {
const g = groups[i];
console.log(` ┌─ [${g.count}] ${g.container}`);
for (const l of g.links.slice(0, samplePerGroup)) {
const label = l.text ? `"${l.text}"` : '(no text)';
console.log(` │ ${label}`);
console.log(` │ → ${l.url}`);
}
if (g.count > samplePerGroup) console.log(` │ … +${g.count - samplePerGroup} more`);
console.log(` └─`);
}
}
// Build a complete helper.md content string from measured data.
// Uses the largest group's container as listSelector and infers a urlPattern
// from the sample URLs. The rest of the fields use the same defaults as
// `source-manage.js helper` so the file is immediately usable — the agent
// (or human) only needs to verify via preview and tune if needed.
function buildHelperContent(groups, patternStr, url, sourceId, sourceName) {
// Pick the best container: skip obvious nav/header/footer/menu groups, then
// take the largest remaining. Without this, a nav dropdown (50+ section
// links) would win over the real article stream (which may be split across
// several card-type containers of 6-10 links each).
const NAVISH = /nav|header|footer|dropdown|menu|breadcrumb|pagination|ticker|sidebar/i;
let best = groups.find(g => !NAVISH.test(g.container));
if (!best) best = groups[0]; // fall back to overall largest if everything looks navish
if (!best) return null;
// Infer a urlPattern: take the first matching link, strip trailing slug,
// keep domain + path to the last segment that looks like an article id.
const sampleUrl = best.links[0].url;
let inferredPattern = '';
try {
const u = new URL(sampleUrl);
const segs = u.pathname.split('/').filter(Boolean);
if (segs.length) {
// Heuristic: drop the last segment (likely the article slug) and anchor
// on the path prefix. Author should tighten this in the real helper.md.
const prefix = segs.slice(0, -1).join('/');
inferredPattern = prefix
? `${u.host.replace(/\./g, '\\.')}/${prefix}/.+`
: `${u.host.replace(/\./g, '\\.')}/.+`;
}
} catch (e) { /* leave empty */ }
const listSel = best.container;
const finalPattern = patternStr || inferredPattern;
const displayName = sourceName || sourceId;
const content = `---
source: ${sourceId}
listSelector: ${JSON.stringify(listSel)}
linkSelector: 'a[href]'
urlPattern: '${finalPattern}'
excludeUrlPattern: null
scrollSteps: ${patternStr ? 0 : 3}
scrollWaitMs: 2000
waitMs: 15000
bodySelectors:
- '[data-testid^="paragraph"]'
- '[class*="articleBodyContent"] p'
- '[class*="article-body"] p'
- 'article p'
bodyMinParagraphs: 3
dateSelector: 'time[datetime]'
authorSelector: '[rel="author"], [class*="author"] a'
# Scope to the article-body container (e.g. '[class*="ArticleBody"] img, article img')
# so bottom-of-page "recommended articles" thumbnails aren't downloaded into assets/.
imageSelector: 'img'
imageMinWidth: 200
excludeImage:
- logo
- icon
- avatar
- profile
# excludeImageSelector drops imgs whose ancestor matches — use for
# "recommended/related articles" thumbnails sharing the hero image's CDN host.
# excludeImageSelector: '[class*="RelatedArticle"], [class*="RecommendedArticle"]'
titleStripSuffix: ''
maxBodyChars: 15000
---
# ${displayName} 抓取备忘
- 主文章流容器 \`${listSel}\` 实测 ${best.count} 个候选链接(inspect 自动生成)。
- urlPattern \`${finalPattern}\` 由样本 URL 推断,如 preview 漏抓/误抓请手编收紧。
- 首屏可能不全,scrollSteps=${patternStr ? 0 : 3} 触发懒加载;反爬严重时调高 waitMs。
- 验证: node scripts/preview.js --url ${url} --recipe helpers/${sourceId}.md --count 3(链接为真实文章且 charCount>500 即通过)`;
return { content, best };
}
// ── Main ─────────────────────────────────────────────────────────────────────
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help || !args.url) { printHelp(); process.exit(args.help ? 0 : 1); }
const config = loadConfig();
// Ensure Chromium is up (same path as harvest.js).
const { ensureChromium } = require('./ensure-chromium');
console.error(`🔬 Inspecting: ${args.url}`);
console.error(` scroll=${args.scroll} wait=${args.wait} pattern=${args.pattern || '(none)'} selector=${args.selector || '(whole page)'}${args.write ? ` write=${args.write}` : ''}`);
console.error(' Ensuring Chromium (CDP)...');
try {
const r = await ensureChromium(config);
console.error(` CDP ready on :${r.port}${r.alreadyRunning ? ' (already running)' : ` (pid ${r.pid})`}`);
} catch (e) {
console.error(` Chromium startup failed:\n${e.message}`);
process.exit(1);
}
const { ws, close } = await openSession();
try {
console.error(` Navigating + rendering (${args.wait}ms)...`);
await navigateAndWait(ws, args.url, args.wait);
const items = await scrollAndCollect(ws, {
steps: args.scroll,
waitMs: 1500,
collectExpr: buildCollectExpr(args.selector, args.pattern),
dedupKey: it => it.url
});
const groups = groupByContainer(items);
printGroups(groups, 15, 4);
const built = buildHelperContent(groups, args.pattern, args.url, args.write || '<source-id>', args.name);
if (!built) return;
const { content, best } = built;
if (args.write) {
// Write a ready-to-use helper.md, refusing if it exists without --force.
const helperPath = path.join(SKILL_DIR, 'helpers', `${args.write}.md`);
if (fs.existsSync(helperPath) && !args.force) {
console.error(`\n✗ helpers/${args.write}.md already exists. Use --force to overwrite, or edit it manually.`);
process.exit(1);
}
if (!fs.existsSync(path.join(SKILL_DIR, 'helpers'))) fs.mkdirSync(path.join(SKILL_DIR, 'helpers'), { recursive: true });
fs.writeFileSync(helperPath, content);
console.error(`\n✅ Wrote helpers/${args.write}.md (listSelector=${best.container}, ${best.count} links measured)`);
console.error(` Next: node scripts/preview.js --url ${args.url} --recipe helpers/${args.write}.md --count 3 # verify links are real articles + charCount>500`);
} else {
console.log(`\n📝 Suggested helper.md skeleton (best group: ${best.count} links in ${best.container}):\n`);
console.log(content);
console.log(`\n Save to helpers/<source-id>.md (or re-run with --write <id>), then test: node scripts/preview.js --url <url> --recipe helpers/<source-id>.md --count 3`);
}
} finally {
close();
}
}
main().catch(e => { console.error('Fatal:', e.message); process.exit(1); });
#!/usr/bin/env node
/**
* pack.js — Pack a recipe + source metadata into a shareable .nhsource.json bundle.
*
* This is the analyzer-side replacement for the old `source-manage.js export`.
* It takes source metadata from CLI args (not config.json) and reads the recipe
* file from an explicit path, producing a self-contained bundle that the
* harvester's `source-manage.js install` can consume in one command.
*
* Usage:
* node scripts/pack.js --id <id> --name <name> --url <homepageUrl> \
* --method <browser|direct> --folder <vaultFolder> \
* --recipe <path> [--fixtures <path>] [--out <path.nhsource.json>]
*
* The bundle format is identical to the old export output:
* {
* "format": "news-harvester-source",
* "version": 1,
* "exportedAt": "<ISO timestamp>",
* "source": { "id", "name", "homepageUrl", "method", "vaultFolder" },
* "files": { "helpers/<id>.md": "<recipe content>", "helpers/<id>.fixtures.json": "..." }
* }
*
* Exit code 0 on success, 1 on error.
*/
const fs = require('fs');
const path = require('path');
function parseArgs(argv) {
const out = {};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--id') out.id = argv[++i];
else if (a === '--name') out.name = argv[++i];
else if (a === '--url') out.url = argv[++i];
else if (a === '--method') out.method = argv[++i];
else if (a === '--folder') out.folder = argv[++i];
else if (a === '--recipe') out.recipe = argv[++i];
else if (a === '--fixtures') out.fixtures = argv[++i];
else if (a === '--out') out.out = argv[++i];
else if (a === '-h' || a === '--help') out.help = true;
}
return out;
}
function printHelp() {
console.log(`pack.js — Pack a recipe + source metadata into a .nhsource.json bundle.
Usage:
node scripts/pack.js --id <id> --name <name> --url <homepageUrl> \\
--method <browser|direct> --folder <vaultFolder> \\
--recipe <path> [--fixtures <path>] [--out <path.nhsource.json>]
Required:
--id <id> Source identifier (lowercase, no spaces, e.g. "reuters-world")
--name <name> Display name (e.g. "路透社世界")
--url <url> Homepage URL (e.g. "https://www.reuters.com/world/")
--method <method> Fetch method: "browser" or "direct"
--folder <folder> Vault subfolder (e.g. "路透社/世界")
--recipe <path> Path to the recipe file (helpers/<id>.md)
Optional:
--fixtures <path> Path to fixtures file (helpers/<id>.fixtures.json)
--out <path> Output path (default: ./<id>.nhsource.json)
-h, --help Show this help
The produced bundle is installed on the harvester side with:
node scripts/source-manage.js install <bundle.nhsource.json>
Examples:
node scripts/pack.js --id reuters-world --name "路透社世界" \\
--url https://www.reuters.com/world/ --method browser --folder "路透社/世界" \\
--recipe helpers/reuters-world.md --fixtures helpers/reuters-world.fixtures.json \\
--out bundles/reuters-world.nhsource.json`);
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) { printHelp(); process.exit(0); }
// Validate required args
const required = ['id', 'name', 'url', 'method', 'folder', 'recipe'];
for (const field of required) {
if (!args[field]) {
console.error(`✗ --${field} is required`);
process.exit(1);
}
}
if (!['browser', 'direct'].includes(args.method)) {
console.error(`✗ --method must be "browser" or "direct" (got "${args.method}")`);
process.exit(1);
}
// Read recipe file
const recipePath = path.resolve(args.recipe);
if (!fs.existsSync(recipePath)) {
console.error(`✗ Recipe not found: ${recipePath}`);
process.exit(1);
}
const recipeContent = fs.readFileSync(recipePath, 'utf8');
// Build files map
const files = {};
files[`helpers/${args.id}.md`] = recipeContent;
const included = [`helpers/${args.id}.md`];
// Optionally read fixtures
if (args.fixtures) {
const fixturesPath = path.resolve(args.fixtures);
if (!fs.existsSync(fixturesPath)) {
console.error(`✗ Fixtures not found: ${fixturesPath}`);
process.exit(1);
}
files[`helpers/${args.id}.fixtures.json`] = fs.readFileSync(fixturesPath, 'utf8');
included.push(`helpers/${args.id}.fixtures.json`);
}
// Build bundle (same format as the old source-manage.js export)
const bundle = {
format: 'news-harvester-source',
version: 1,
exportedAt: new Date().toISOString(),
source: {
id: args.id,
name: args.name,
homepageUrl: args.url,
method: args.method,
vaultFolder: args.folder
},
files
};
// Write bundle
const outPath = path.resolve(args.out || path.join(process.cwd(), `${args.id}.nhsource.json`));
fs.writeFileSync(outPath, JSON.stringify(bundle, null, 2));
console.log(`\n✅ Packed source "${args.name}" (id: ${args.id})`);
console.log(` Bundle: ${outPath}`);
console.log(` Files: ${included.join(', ')}`);
console.log(`\n Install on the harvester with:`);
console.log(` node scripts/source-manage.js install ${path.basename(outPath)}\n`);
}
main().catch(e => { console.error('Fatal:', e.message); process.exit(1); });
#!/usr/bin/env node
/**
* preview.js — Read-only recipe verification for the news-source-analyzer.
*
* Fetches article links from a homepage using a recipe, then fetches each
* article page — all in read-only mode (no vault writes, no config.sources
* lookup, no registry). This is the analyzer-side equivalent of
* `harvest.js --preview`, decoupled from the harvester's config/vault.
*
* Usage:
* node scripts/preview.js --url <homepageUrl> --recipe <path> [--count N] [--full]
*
* Options:
* --url <url> Homepage URL to collect candidate links from (required)
* --recipe <path> Path to the helper.md recipe file (required)
* --count N Max number of articles to fetch (default 3)
* --full Include the full article body in the output (default: bodyPreview only)
*
* Output: compact JSON manifest to stdout (same shape as harvest.js --preview).
* No files are written.
*
* Exit code 0 on success, 1 on error, 70 on human-intervention (CAPTCHA/login).
*/
const fs = require('fs');
const path = require('path');
const SKILL_DIR = path.dirname(__dirname);
const CONFIG_PATH = path.join(SKILL_DIR, 'config.json');
const TEMPLATE_PATH = path.join(SKILL_DIR, 'references', 'config-template.json');
function loadConfig() {
const cfgPath = fs.existsSync(CONFIG_PATH) ? CONFIG_PATH : TEMPLATE_PATH;
return JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
}
const { loadSpec, fetchPage, fetchHomeLinks } = require('./fetcher-helper');
const { HumanInterventionError } = require('./block-check');
// Body preview length (matches harvest.js's PREVIEW_LEN)
const PREVIEW_LEN = 4000;
function getCategory(url, title) {
const u = url.toLowerCase(), t = title.toLowerCase();
if (/asia-pacific|pakistan|afghanistan/.test(u) || /巴基斯坦|阿富汗/.test(t)) return '地缘政治';
if (/\/ai\/|anthropic|openai|tech/.test(u) || /\bai\b|人工智能|科技/.test(t)) return '科技/AI';
if (/china|中美/.test(u) || /中国|中美/.test(t)) return '中美关系';
if (/business|finance|housing|economy/.test(u) || /经济|财经|住房/.test(t)) return '财经';
if (/sustainability|feminist|society/.test(u) || /社会|女权/.test(t)) return '社会';
if (/ukraine|russia|middle-east/.test(u) || /冲突|战争/.test(t)) return '国际冲突';
return '国际';
}
function normalizeUrl(u) {
try { const p = new URL(u); p.search = ''; return p.toString(); }
catch (e) { return u; }
}
function parseArgs(argv) {
const out = { url: null, recipe: null, count: 3, full: false, help: false };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--url') out.url = argv[++i];
else if (a === '--recipe') out.recipe = argv[++i];
else if (a === '--count') out.count = parseInt(argv[++i] || '3', 10);
else if (a === '--full') out.full = true;
else if (a === '-h' || a === '--help') out.help = true;
}
return out;
}
function printHelp() {
console.log(`preview.js — Read-only recipe verification (no vault writes).
Usage:
node scripts/preview.js --url <homepageUrl> --recipe <path> [--count N] [--full]
Options:
--url <url> Homepage URL to collect candidate links from (required)
--recipe <path> Path to the helper.md recipe file (required)
--count N Max number of articles to fetch (default 3)
--full Include full article body in output (default: bodyPreview only)
-h, --help Show this help
Output: compact JSON manifest to stdout (titles + metadata + bodyPreview).
No files are written.
Examples:
node scripts/preview.js --url https://www.reuters.com/world/ --recipe helpers/reuters-world.md --count 3
node scripts/preview.js --url https://www.reuters.com/world/ --recipe helpers/reuters-world.md --count 1 --full`);
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) { printHelp(); process.exit(0); }
if (!args.url) { console.error('✗ --url <homepageUrl> is required'); process.exit(1); }
if (!args.recipe) { console.error('✗ --recipe <path> is required'); process.exit(1); }
const recipePath = path.resolve(args.recipe);
if (!fs.existsSync(recipePath)) {
console.error(`✗ Recipe not found: ${recipePath}`);
process.exit(1);
}
const config = loadConfig();
const spec = loadSpec(recipePath);
console.error(`\n🔬 Preview: ${args.url}`);
console.error(` Recipe: ${recipePath} (listSelector=${spec.listSelector || 'whole page'}, scroll=${spec.scrollSteps})`);
console.error(` Count: ${args.count} | Full body: ${args.full}`);
// Ensure Chromium is up
const { ensureChromium } = require('./ensure-chromium');
console.error(' Ensuring Chromium (CDP)...');
try {
const r = await ensureChromium(config);
console.error(` CDP ready on :${r.port}${r.alreadyRunning ? ' (already running)' : ` (pid ${r.pid})`}`);
} catch (e) {
console.error(` Chromium startup failed:\n${e.message}`);
process.exit(1);
}
// Fetch homepage links
console.error(`\n📡 Fetching homepage: ${args.url}`);
let links;
try {
links = await fetchHomeLinks(args.url, spec);
} catch (e) {
if (e instanceof HumanInterventionError) {
console.error(`\n🚫 采集中止 — 需要人工介入: ${e.message}`);
process.exit(70);
}
console.error('Failed to fetch homepage:', e.message);
process.exit(1);
}
console.error(` Collected ${links.length} candidate links`);
const normalizedLinks = [...new Set(links.map(u => normalizeUrl(u)))];
console.error(` Unique: ${normalizedLinks.length} candidates`);
// Fetch each article (read-only)
const out = [];
for (let i = 0; i < normalizedLinks.length; i++) {
if (out.length >= args.count) break;
const url = normalizedLinks[i];
console.error(`\n[${i+1}/${normalizedLinks.length}] ${url}`);
let articleData;
try {
articleData = await fetchPage(url, spec);
} catch (e) {
if (e instanceof HumanInterventionError) {
console.error(`\n🚫 采集中止 — 需要人工介入: ${e.message}`);
process.exit(70);
}
console.error(` ✗ Fetch failed: ${e.message}`);
continue;
}
if (!articleData || !articleData.title) { console.error(' ✗ No title, skip'); continue; }
if (articleData.isArticle === false) { console.error(' ✗ Not an article (og:type/body), skip'); continue; }
const category = getCategory(url, articleData.title);
const entry = {
title: articleData.title,
date: articleData.date,
authors: articleData.authors,
url,
category,
charCount: (articleData.body || '').length,
imgCount: (articleData.imgs || []).length,
imgs: (articleData.imgs || []).map(im => ({ alt: im.alt || '', src: im.src }))
};
if (args.full) {
entry.body = articleData.body || '';
} else {
entry.bodyPreview = (articleData.body || '').substring(0, PREVIEW_LEN);
}
out.push(entry);
console.error(` ✓ ${articleData.title} (${entry.charCount} chars, ${entry.imgCount} imgs)`);
}
process.stdout.write(JSON.stringify({
url: args.url, recipe: recipePath, preview: true, full: args.full,
count: out.length, articles: out
}, null, 2));
console.error(`\n✅ Preview: ${out.length} articles (no files written)`);
}
main().catch(e => { console.error('Fatal:', e.message); process.exit(1); });
#!/usr/bin/env node
/**
* recipe-test.js — Regression tests for a source's helper.md recipe.
*
* Guards against two classes of breakage when a site redesigns:
* 1. Article URLs that used to fetch as real articles (og:type=article +
* real body) start getting rejected, or section pages start passing —
* i.e. the article/section boundary drifted.
* 2. The list page stops yielding enough candidate links, or lets through
* URLs the excludeUrlPattern is supposed to drop.
*
* A fixture set lives in helpers/<id>.fixtures.json (articles[], sections[],
* minLinks). Each article URL must fetch with isArticle=true and bodyChars ≥
* a floor; each section URL must fetch with isArticle=false; the homepage
* must yield ≥ minLinks and zero excludeUrlPattern matches.
*
* Usage:
* node scripts/recipe-test.js <helperId> --url <homepageUrl>
*
* Exit 0 if all assertions pass, 1 otherwise.
*/
const fs = require('fs');
const path = require('path');
const SKILL_DIR = path.dirname(__dirname);
const CONFIG_PATH = path.join(SKILL_DIR, 'config.json');
const TEMPLATE_PATH = path.join(SKILL_DIR, 'references', 'config-template.json');
function loadConfig() {
const cfgPath = fs.existsSync(CONFIG_PATH) ? CONFIG_PATH : TEMPLATE_PATH;
return JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
}
const { loadSpec, fetchPage, fetchHomeLinks } = require('./fetcher-helper');
// ── Assertions ───────────────────────────────────────────────────────────────
let passed = 0, failed = 0;
function assert(cond, label, detail) {
if (cond) {
passed++;
console.log(` ✓ ${label}`);
} else {
failed++;
console.log(` ✗ ${label}${detail ? ' — ' + detail : ''}`);
}
}
// ── Main ─────────────────────────────────────────────────────────────────────
async function main() {
const helperId = process.argv[2];
if (!helperId) {
console.error('Usage: node scripts/recipe-test.js <helperId> --url <homepageUrl>');
console.error('Example: node scripts/recipe-test.js nikkei-tech --url https://asia.nikkei.com/business/tech');
process.exit(1);
}
// Parse --url from remaining args
const argv = process.argv.slice(3);
let homepageUrl = null;
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--url') homepageUrl = argv[++i];
}
if (!homepageUrl) {
console.error('✗ --url <homepageUrl> is required (the source\'s homepage URL for link collection)');
console.error(' Example: node scripts/recipe-test.js nikkei-tech --url https://asia.nikkei.com/business/tech');
process.exit(1);
}
const helperPath = path.join(SKILL_DIR, 'helpers', `${helperId}.md`);
const fixturesPath = path.join(SKILL_DIR, 'helpers', `${helperId}.fixtures.json`);
if (!fs.existsSync(helperPath)) {
console.error(`✗ No recipe found: helpers/${helperId}.md`);
process.exit(1);
}
if (!fs.existsSync(fixturesPath)) {
console.error(`✗ No fixtures found: helpers/${helperId}.fixtures.json`);
console.error(` Create one with: { "articles": [...], "sections": [...], "minLinks": N }`);
process.exit(1);
}
const config = loadConfig();
const spec = loadSpec(helperPath);
const fixtures = JSON.parse(fs.readFileSync(fixturesPath, 'utf8'));
console.error(`🧪 recipe-test: ${helperId} (${homepageUrl})`);
console.error(` validateArticle=${spec.validateArticle}, urlPattern=${spec.urlPattern}`);
console.error(` Ensuring Chromium (CDP)...`);
const { ensureChromium } = require('./ensure-chromium');
try {
const r = await ensureChromium(config);
console.error(` CDP ready on :${r.port}${r.alreadyRunning ? ' (already running)' : ` (pid ${r.pid})`}`);
} catch (e) {
console.error(` Chromium startup failed:\n${e.message}`);
process.exit(1);
}
// ── 1. Article URLs must validate as articles ─────────────────────────────
console.error(`\n📄 Article fixtures (must be isArticle=true, body ≥ ${spec.bodyMinChars || 500} chars):`);
for (const url of (fixtures.articles || [])) {
try {
const data = await fetchPage(url, spec);
if (!data) { assert(false, `article: ${url}`, 'fetchPage returned null'); continue; }
assert(data.isArticle !== false, `article: ${data.title ? data.title.slice(0,60) : url}`,
`isArticle=${data.isArticle} og=${data.ogType} ld=${data.ldType} body=${data.bodyChars}c/${data.bodyParagraphs}p`);
} catch (e) {
assert(false, `article: ${url}`, e.message);
}
}
// ── 2. Section/category URLs must NOT validate as articles ────────────────
// Only meaningful when the recipe uses validateArticle; otherwise this group
// is skipped (no isArticle signal to assert against).
if (spec.validateArticle) {
console.error(`\n🗂️ Section fixtures (must be isArticle=false):`);
for (const url of (fixtures.sections || [])) {
try {
const data = await fetchPage(url, spec);
if (!data) { assert(false, `section: ${url}`, 'fetchPage returned null'); continue; }
assert(data.isArticle === false, `section: ${url}`,
`isArticle=${data.isArticle} og=${data.ogType} ld=${data.ldType} body=${data.bodyChars}c/${data.bodyParagraphs}p`);
} catch (e) {
assert(false, `section: ${url}`, e.message);
}
}
}
// ── 3. Homepage link collection: count + no excluded URLs ─────────────────
console.error(`\n🔗 Homepage links (≥ ${fixtures.minLinks || 10}, none matching excludeUrlPattern):`);
try {
const links = await fetchHomeLinks(homepageUrl, spec);
assert(links.length >= (fixtures.minLinks || 10),
`homepage yields ≥ ${fixtures.minLinks || 10} links`, `got ${links.length}`);
if (spec.excludeUrlPattern) {
const exRe = new RegExp(spec.excludeUrlPattern);
const leaked = links.filter(u => exRe.test(u));
assert(leaked.length === 0, `no links match excludeUrlPattern (${spec.excludeUrlPattern})`,
leaked.length ? leaked.slice(0,3).join(' ; ') : '');
}
// Also verify every link matches urlPattern (sanity: the recipe isn't
// letting unrelated hrefs through).
const urlRe = new RegExp(spec.urlPattern);
const offPattern = links.filter(u => !urlRe.test(u));
assert(offPattern.length === 0, 'all links match urlPattern',
offPattern.length ? offPattern.slice(0,3).join(' ; ') : '');
} catch (e) {
assert(false, 'homepage link collection', e.message);
}
// ── Summary ───────────────────────────────────────────────────────────────
console.error(`\n${failed === 0 ? '✅' : '❌'} ${passed} passed, ${failed} failed`);
process.exit(failed === 0 ? 0 : 1);
}
main().catch(e => { console.error('Fatal:', e.message); process.exit(1); });
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment