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

Refactor: scripts own file I/O, agent only produces summaries

Root cause: harvest.js fetched content but wrote NO files — it dumped a
giant manifest (with full body) to stdout and left all document I/O to
the agent. This caused silent save failures (agent hand-wrote fragile
Node scripts) and token waste (full body in context → 13 python re-parses).

Architecture change — scripts do all deterministic I/O, full body never
enters the agent context:

  Archive:  harvest.js <src> <n>  → writes 原文.md + registry + index
                                     (sentinel placeholder for 中文标题)
                                     + compact manifest (bodyPreview only)
            agent writes .summaries.json (中文标题 + 四段摘要 + 分类)
            finalize.js <dayDir>    → writes 摘要.md, patches sentinels,
                                     updates registry/index, cleans up

  Preview:  harvest.js <src> <n> --preview [--full]  → read-only, no writes

Changes:
- harvest.js: wire up generateOriginalDoc/updateIndex/appendRegistry
  (were defined but never called); compact manifest (no full body);
  sentinel placeholder links; --preview/--full read-only mode; real
  asset count (was "articles with images"); empty-alt guard (no bare **);
  manifest merge across runs (fixes dangling-sentinel data loss);
  --help; module exports for finalize.js
- finalize.js (new): consumes .manifest.json + .summaries.json, writes
  summary docs, patches sentinel links, updates registry/index, cleans
  up temp files; missing summary → warn + skip (non-fatal); --help
- fetcher-direct.js: author extraction via JSON-LD → <meta name=author>
  → rel=author (was matching arbitrary JSON-LD names → empty authors);
  body noise filter (share buttons, boilerplate); image tracking-pixel
  + duplicate filtering
- fetcher-browser.js: CDP port 9223 → 9222 (match docs/chromium launch)
- SKILL.md: 3-step archive workflow, --preview flow, First-Run Setup
  onboarding, guardrails, .summaries.json contract, sentinel docs
- README.md: architecture diagram, division of labor, task flows, limits
parent a2d4a8c4
......@@ -21,21 +21,30 @@
## 工作原理
```
config.json ──▶ harvest.js ──▶ fetcher-*.js ──▶ 文章数据(JSON)
config.json ──▶ harvest.js ──▶ fetcher-*.js ──▶ 文章数据
(数据源) (主调度器) (direct/browser) │
▲ │ ▼
│ dedup 去重 Agent(LLM)
│ (registry) 翻译/摘要/分类
Obsidian vault
│ dedup 去重 写盘: 原文.md + registry + index
│ (registry) (中文标题用占位哨兵, 全文已落盘)
│ │
│ stdout: 紧凑 manifest (无全文, 仅预览)
│ │
│ ▼
│ Agent(LLM)
│ 读 manifest → 写 .summaries.json
│ (中文标题 + 四段摘要 + 分类)
│ │
└────────────────────────────────────────────┘
finalize.js
写摘要.md / 修补原文哨兵链接 / 更新 registry+index
```
**脚本与 Agent 分工**
- **脚本**`scripts/`):确定性工作 —— HTTP 请求、HTML 解析、图片下载、去重、文件写入
- **Agent**(LLM):智能工作 —— 中英翻译、生成中文摘要、文章分类
- **脚本**`scripts/`):确定性工作 —— HTTP 请求、HTML 解析、图片下载、去重、**写原文.md / registry / index**(中文标题先用哨兵占位,全文直接落盘)
- **Agent**(LLM):智能工作 —— 读紧凑 manifest(无全文,仅 `bodyPreview` ~4000 字),生成中文标题 + 四段摘要 + 分类,写入 `.summaries.json`
- **finalize.js**:读 `.summaries.json`,写中文摘要文档,把原文里的哨兵替换成真实标题,更新 registry/index,清理临时文件
`harvest.js` 完成采集后输出 JSON manifest,提示 Agent 接管翻译与文档生成
> 全文 body 由脚本直接写盘,**永不进入 Agent 上下文**——这是降低 token 消耗的关键
---
......@@ -52,7 +61,8 @@ news-harvester/
│ ├── config-template.json # config.json 的模板(首次运行自动复制)
│ └── source-management.md # 数据源管理详细说明
├── scripts/
│ ├── harvest.js # 主入口:node harvest.js <source_id> <count>
│ ├── harvest.js # 主入口:node harvest.js <source_id> <count> [--preview] [--full]
│ ├── finalize.js # 补全中文摘要:node finalize.js <dayDir>
│ ├── source-manage.js # 数据源增删改查
│ ├── fetcher-direct.js # 纯 HTTP 抓取(开放站点)
│ └── fetcher-browser.js # Chromium CDP 抓取(JS 渲染站点)
......@@ -129,26 +139,38 @@ node scripts/source-manage.js add \
或让 Agent 自然语言完成:「添加 The Guardian 作为新闻源」。
#### 任务 2:采集最新文章,返回 JSON(不归档)
#### 任务 2:采集最新文章,返回数据(不归档)
适合只取数据、不落盘的场景。直接调用 fetcher
适合只取数据、不落盘的场景。`--preview` 一次调用取齐文章清单(标题/日期/作者/url/字数/图片),不写任何文件
```bash
# 抓取首页文章链接
node -e "require('./scripts/fetcher-direct').fetchHomeLinks('https://theconversation.com/us','theconversation\\\\.com/[\\\\w-]+-\\\\d+$').then(l=>console.log(JSON.stringify(l,null,2)))"
# 预览最新 3 篇(含正文预览 ~4000 字,无全文)
node scripts/harvest.js the-conversation 3 --preview
# 抓取单篇文章全文
node scripts/fetcher-direct.js https://theconversation.com/some-article-12345
# 预览并附带完整正文(用户明确要全文时)
node scripts/harvest.js the-conversation 3 --preview --full
```
#### 任务 3:完整采集并归档到 Obsidian
#### 任务 3:完整采集并归档到 Obsidian(三步)
```bash
# Step 1: 采集 + 写原文/registry/index(脚本包揽确定性 I/O)
node scripts/harvest.js the-conversation 5
```
`harvest.js` 会:抓取链接 → 去重 → 逐篇抓取全文 → 下载图片 → 输出 JSON manifest。
然后 Agent 接管:生成中文摘要 → 写入 Obsidian 文档 → 更新 index.md / registry.json。
脚本输出**紧凑 manifest**(无全文,仅 `bodyPreview`)。Agent 读 manifest 后:
```bash
# Step 2: Agent 生成中文摘要, 写入 <dayDir>/.summaries.json
# [{"url":"...","titleZh":"中文标题","category":"...","tags":[...],"summaryZh":"## 事件概述\n..."}]
```
```bash
# Step 3: 补全中文摘要文档 + 修补原文链接 + 更新 registry/index
node scripts/finalize.js "<dayDir>"
```
> Agent **不要手写文件 I/O**——原文、registry、index 由 `harvest.js` 写;摘要文档由 `finalize.js` 写。Agent 只产出 `.summaries.json`。
### 无头模式调用示例
......@@ -214,13 +236,14 @@ YYYYMMDD/
## 限制与注意
- **正文提取依赖正则**:网站改版可能导致提取失败(正文 < 100 字会被跳过)
- **中文标题需 Agent 翻译**`harvest.js` 只生成占位标题 `[需翻译] ...`,真正翻译由 Agent 完成
- **中文标题/摘要由 Agent 生成**`harvest.js` 在原文/registry/index 中用哨兵 `__SUMMARY_<slug>__` 占位,`finalize.js` 读取 Agent 写的 `.summaries.json` 后替换为真实标题并生成摘要文档
- **browser 方法依赖 Chromium CDP**(端口 9222),需预先启动:
```bash
chromium --no-sandbox --remote-debugging-port=9222 \
--user-data-dir=/tmp/chromium-harvest --disable-gpu "about:blank" &>/dev/null &
```
- **图片下载无重试**:失败仅跳过
- **Token 卫生**:归档任务为「harvest → 写 .summaries.json → finalize」三步;不要为同一源/日重跑 harvest 仅为"多取几篇"(registry 已去重,二次跑只取真正的新文章并安全合并进待处理 manifest);不要用 Read 反复读原文大文件——manifest 里的 `bodyPreview`(~4000 字)足够写摘要
---
......
......@@ -11,21 +11,77 @@ Fetch latest articles from configured news sources, archive them to Obsidian vau
| Task | Command |
|------|---------|
| Run harvest | `node harvest.js <source_id> <count>` |
| Archive latest articles | `node harvest.js <source_id> <count>` |
| Preview latest (read-only) | `node harvest.js <source_id> <count> --preview` |
| Preview with full body | `node harvest.js <source_id> <count> --preview --full` |
| Finalize Chinese summaries | `node finalize.js <dayDir>` |
| Add source | `node source-manage.js add` |
| List sources | `node source-manage.js list` |
| Edit source | `node source-manage.js edit <id>` |
> Scripts are invoked from the skill root directory.
> Scripts are invoked from the skill root directory (`scripts/` is relative to this skill's root).
Scripts live in: `scripts/` (relative to this skill's root directory)
Config file: `config.json` (auto-generated from `references/config-template.json` on first run — see Configuration below)
**Division of labor:** 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**.
Config file: `config.json` (auto-generated from `references/config-template.json` on first run — see First-Run Setup / Configuration below)
---
## First-Run Setup
The agent MUST proactively check configuration state at the start of **any** news-harvester task. If unconfigured, run this onboarding **before** doing anything else — do not wait for the user to ask. This is the single biggest cause of silent failures (articles written into a non-existent `/path/to/...` dir).
**Detect unconfigured state** — either condition triggers onboarding:
- `config.json` does not exist, OR
- `vaultPath` still equals the template placeholder `/path/to/your/obsidian/vault`
```bash
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 steps
**1. Set the Obsidian vault path.** Ask the user for the absolute path to their Obsidian vault root (e.g. `~/obsidian/MyVault`). Verify it exists and is writable; create it if they intend a fresh folder:
```bash
mkdir -p "<vaultPath>" && test -w "<vaultPath>" && echo "writable"
```
Then write the real path into `config.json` (replace the placeholder). Never leave the placeholder in place.
**2. Confirm the first test source.** The template ships `the-conversation` (method `direct`, no Chromium needed) — the ideal first test source. `node source-manage.js list` should show it enabled. If absent, add it:
```bash
node source-manage.js add --id the-conversation --name "The Conversation" \
--url https://theconversation.com/us --method direct --folder "The Conversation"
```
If the user wants a different first source (e.g. `reuters` with `browser` method), add that instead and note the Chromium prerequisite below applies.
**3. Check prerequisites for the source's `method`.**
- `direct` → nothing extra.
- `browser` → Chromium must be running on port 9222:
```bash
curl -s http://localhost:9222/json/version 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print('OK:', d.get('Browser',''))"
```
If not running:
```bash
chromium --no-sandbox --remote-debugging-port=9222 --user-data-dir=/tmp/chromium-harvest --disable-gpu "about:blank" &>/dev/null & sleep 5
```
**4. Smoke test (read-only, no vault writes).** Confirm the fetch pipeline works end-to-end before archiving anything:
```bash
node harvest.js the-conversation 2 --preview
```
If this returns article data, setup is complete. If it errors (empty list, parse failure, network), fix the source config before proceeding — do not move on to a full archive.
**5. (Optional) full test archive.** Only if the user wants to verify vault writes — run Flow A once with `count 1-2` and confirm files land in `<vaultPath>/<vaultFolder>/YYYYMMDD/`. Skip otherwise; the `--preview` smoke test is enough to consider setup done.
### After onboarding
Once `vaultPath` is real and the smoke test passes, **skip this section** on future runs and go straight to Workflow. The check is idempotent and cheap — re-running `node source-manage.js list` is always safe.
---
## Configuration
`config.json` is **not shipped** with this skill — it is machine-specific (contains the local vault path). On first run, any script auto-creates `config.json` by copying `references/config-template.json`.
`config.json` is **not shipped** with this skill — it is machine-specific (contains the local vault path). On first run, any script auto-creates `config.json` by copying `references/config-template.json`. For the guided first-time setup flow, see **First-Run Setup** above.
Before running a harvest, the agent MUST verify/edit these fields:
......@@ -40,49 +96,117 @@ To point a source at a different vault subfolder, set `sources[].vaultFolder`. A
## Workflow
When asked to harvest news, follow these steps:
Two flows depending on intent: **archive** (fetch + save to Obsidian) or **preview** (read-only, just return article data).
### 1. Load config
Read `config.json`. If it doesn't exist, create it from the default template in `references/config-template.json`.
### Flow A — Archive to Obsidian (3 steps)
### 2. Check Chromium (for browser sources)
#### Step 1. Run harvest (script writes originals + registry + index)
```bash
curl -s http://localhost:9222/json/version 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print('OK:', d.get('Browser',''))"
node harvest.js <source_id> <count>
```
If not running, start it:
```bash
chromium --no-sandbox --remote-debugging-port=9222 --user-data-dir=/tmp/chromium-harvest --disable-gpu "about:blank" &>/dev/null &
sleep 5
The script fetches links, deduplicates, fetches each article, downloads images, and **writes to disk itself**:
- `<英文标题> - <SourceName>原文.md` (full body + images, with a sentinel placeholder for the Chinese title link)
- `registry.json` (dedup record, written incrementally)
- `index.md` (daily index, using sentinel titles until finalize)
- `.manifest.json` (temp — consumed by finalize)
It prints a **compact manifest** to stdout (no full body — only a `bodyPreview` ~4000 chars per article). Read it.
> **Prerequisite** for `browser` sources: Chromium must be running on port 9222:
> ```bash
> curl -s http://localhost:9222/json/version 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print('OK:', d.get('Browser',''))"
> ```
> If not running: `chromium --no-sandbox --remote-debugging-port=9222 --user-data-dir=/tmp/chromium-harvest --disable-gpu "about:blank" &>/dev/null & sleep 5`
#### Step 2. Generate Chinese summaries → write `.summaries.json`
From the compact manifest, for each article write one entry to `<dayDir>/.summaries.json`:
```json
[
{
"url": "https://theconversation.com/...-286430",
"titleZh": "特朗普新规或拖慢联邦科研",
"category": "科技/AI",
"tags": ["科技/AI", "The Conversation"],
"summaryZh": "## 事件概述\n...\n\n## 背景\n...\n\n## 关键引语\n> \"...\"\n\n## 影响分析\n..."
}
]
```
### 3. Build dedup set
Scan the last `registryWindowDays` (default 7) days of registry files:
- `titleZh`: concise Chinese title (≤30 chars)
- `category` / `tags`: the agent's classification (overrides the script's keyword guess)
- `summaryZh`: the 4-section markdown body (事件概述 / 背景 / 关键引语 / 影响分析)
- The `bodyPreview` in the manifest is enough to write the summary. **Do not re-read the full 原文.md** — if you need more, read a targeted slice.
#### Step 3. Run finalize (script stitches summaries in)
```bash
node finalize.js "<dayDir>"
```
<vaultPath>/<source.vaultFolder>/YYYYMMDD/registry.json
This writes each `<中文标题>.md`, patches the sentinel links in the 原文.md files, updates `registry.json` + `index.md` with real titles, and removes `.manifest.json` + `.summaries.json`.
### Flow B — Preview (read-only, no archive)
For "返回最新文章数据" / "看看最新文章" requests where the user does **not** ask to save:
```bash
node harvest.js <source_id> <count> --preview # titles + metadata + bodyPreview
node harvest.js <source_id> <count> --preview --full # ... + full body
```
Collect all URLs into a Set.
### 4. Fetch homepage links
- **browser method**: use `scripts/fetcher-browser.js`
- **direct method**: use `scripts/fetcher-direct.js`
No files are written. stdout is a compact JSON list (one call, no re-fetching / re-parsing). Use `--full` only when the user explicitly wants the complete article text.
### 5. Filter + slice
Remove URLs already in dedup set. Take first `count` new ones.
### Guardrails (token hygiene)
### 6. Process each article
For each URL (in series, not parallel):
1. Fetch full article (title, date, authors, body, images)
2. Download images → `<vaultPath>/<vaultFolder>/YYYYMMDD/assets/<slug>-<n>.jpg`
3. Generate Chinese summary doc → `<vaultPath>/<vaultFolder>/YYYYMMDD/<中文标题>.md`
4. Generate original doc → `<vaultPath>/<vaultFolder>/YYYYMMDD/<英文标题> - <SourceName>原文.md`
5. Update `YYYYMMDD/index.md`
6. Append to `YYYYMMDD/registry.json`
- **Don't re-run `harvest.js`** for the same source/day just to "get more" — it deduplicates via the registry, so a second run only fetches genuinely new articles (and merges them into the pending `.manifest.json`, which is safe). If you need more, increase `count` on the first run.
- **Never `Read` the 原文.md files** to write summaries — the `bodyPreview` in the manifest is sufficient. Targeted slice reads are OK if truly needed.
- **One `finalize.js` call** after writing `.summaries.json`. Do not hand-write file I/O — the scripts handle all original/registry/index/summary writes.
---
## Document Formats
### Chinese Summary Doc
> These docs are **generated by the scripts**, not hand-written by the agent. Shown here for reference (e.g. when debugging or 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`. Image captions: only rendered when `alt` text exists (empty alt → bare embed, no `**`).
```markdown
---
title: "<英文标题>"
date: YYYY-MM-DD
tags: [...]
source: "<url>"
publisher: <source>
authors: "<authors>"
type: 原文
related: "[[<中文标题>]]" # sentinel __SUMMARY_<slug>__ until finalize
saved: YYYY-MM-DD
---
# <英文标题>
> [!info] Source
> **Published:** YYYY-MM-DD | **URL:** <url>
> **中文摘要:** [[<中文标题>]]
---
## 配图
![[assets/<slug>-1.jpg]]
*<alt text — omitted if empty>*
---
## 正文
<full article body>
---
[[<中文标题>]]
```
### Chinese Summary Doc (written by `finalize.js`)
Filename: `<中文标题>.md` (concise, ≤30 chars)
```markdown
......@@ -125,48 +249,13 @@ saved: YYYY-MM-DD
[[<英文标题> - <SourceName>原文]]
```
### Original Doc
Filename: `<英文标题> - <SourceName>原文.md`
```markdown
---
title: "<英文标题>"
date: YYYY-MM-DD
tags: [...]
source: "<url>"
publisher: <source>
authors: "<authors>"
type: 原文
related: "[[<中文标题>]]"
saved: YYYY-MM-DD
---
# <英文标题>
> [!info] Source
> **Published:** YYYY-MM-DD | **URL:** <url>
> **中文摘要:** [[<中文标题>]]
---
## 配图
![[assets/<slug>-1.jpg]]
*<alt text>*
---
## 正文
<full article body>
---
[[<中文标题>]]
```
---
## index.md Format
File: `<vaultFolder>/YYYYMMDD/index.md`
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
---
......@@ -201,7 +290,9 @@ last_updated: "<ISO timestamp>"
## registry.json Format
File: `<vaultFolder>/YYYYMMDD/registry.json`
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
{
......@@ -212,9 +303,11 @@ File: `<vaultFolder>/YYYYMMDD/registry.json`
"url": "https://...",
"titleZh": "中文标题",
"titleEn": "English Title",
"processedAt": "2026-02-28T14:30:00+08:00",
"processedAt": "2026-07-24T14:30:00.000Z",
"category": "地缘政治",
"tags": ["tag1", "tag2"]
"tags": ["tag1", "tag2"],
"slug": "theconversation-com-some-article-286430",
"originalFilename": "Some Article - The Conversation原文.md"
}
]
}
......
......@@ -7,7 +7,9 @@
const http = require('http');
const WebSocket = require('ws');
const CDP_PORT = parseInt(process.env.CDP_PORT_OVERRIDE || '9223');
// Chromium CDP debug port — must match the --remote-debugging-port used to start
// Chromium (9222 per SKILL.md / README). Override via env if you run elsewhere.
const CDP_PORT = parseInt(process.env.CDP_PORT_OVERRIDE || '9222');
function getWsUrl() {
return new Promise((resolve, reject) => {
......
......@@ -30,6 +30,45 @@ function fetchHtml(url) {
});
}
// 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);
......@@ -42,16 +81,17 @@ function extractArticle(html, url) {
const date = dateMatch ? dateMatch[1] : '';
// Authors
const authorMatches = html.match(/["']name["']\s*:\s*["']([^"']{3,60})["']/g) || [];
const authors = [...new Set(authorMatches.map(m => m.match(/["']name["']\s*:\s*["']([^"']+)["']/)[1])
.filter(a => a.length < 60 && !a.includes('{')))].slice(0, 3).join(', ');
const authors = extractAuthors(html);
// Images
// 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.length > 50).slice(0, 6);
}).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 = '';
......@@ -66,12 +106,17 @@ function extractArticle(html, url) {
// 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'];
'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 => t.length > 60 && !noise.some(n => t.includes(n))).join('\n\n');
}).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) };
......
#!/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);
const summaryFilePath = path.join(dayDir, `${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 sentinel in the original doc (frontmatter related + footer wikilink)
if (fs.existsSync(origFilePath)) {
const orig = fs.readFileSync(origFilePath, 'utf8');
// Replace all occurrences of the sentinel with the real title.
const patchedOrig = orig.split(sentinel).join(titleZh);
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,
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`);
}
if (require.main === module) {
main();
}
#!/usr/bin/env node
/**
* harvest.js - Main entry point for news harvesting
* Usage: node harvest.js <source_id> <count>
* Example: node harvest.js reuters 5
* 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');
......@@ -14,6 +23,9 @@ 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;
// ── Helpers ─────────────────────────────────────────────────────────────────
function loadConfig() {
......@@ -36,6 +48,10 @@ function todayStr() {
return `${d.getFullYear()}${String(d.getMonth()+1).padStart(2,'0')}${String(d.getDate()).padStart(2,'0')}`;
}
function dateShort(dateStr) {
return (dateStr || '').substring(0, 10) || todayStr().replace(/(\d{4})(\d{2})(\d{2})/, '$1-$2-$3');
}
function getCategory(url, title) {
const u = url.toLowerCase(), t = title.toLowerCase();
if (/asia-pacific|pakistan|afghanistan/.test(u) || /巴基斯坦|阿富汗/.test(t)) return '地缘政治';
......@@ -57,6 +73,16 @@ function slugFromUrl(url) {
.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) {
return `${titleEn} - ${sourceName}原文.md`;
}
// ── Registry ─────────────────────────────────────────────────────────────────
function buildDedupSet(config, sourceId) {
......@@ -117,15 +143,14 @@ function downloadImage(url, dest) {
// ── Document Generation ───────────────────────────────────────────────────────
function generateSummaryDoc(data, originPath, sourceName) {
function generateSummaryDoc(data, sourceName) {
const { titleZh, titleEn, date, authors, source: url, category, tags } = data;
const dateShort = (date || '').substring(0, 10) || todayStr().replace(/(\d{4})(\d{2})(\d{2})/, '$1-$2-$3');
const tagsYaml = (tags || []).map(t => ` - ${t}`).join('\n');
const origName = `${titleEn} - ${sourceName}原文`;
const origName = originalFilename(titleEn, sourceName);
return `---
title: "${titleZh}"
date: ${dateShort}
date: ${dateShort(date)}
tags:
${tagsYaml}
source: "${url}"
......@@ -139,7 +164,7 @@ saved: ${todayStr().replace(/(\d{4})(\d{2})(\d{2})/, '$1-$2-$3')}
# ${titleZh}
> [!info] 文章信息
> **发布:** ${dateShort} | **来源:** ${sourceName}
> **发布:** ${dateShort(date)} | **来源:** ${sourceName}
> **作者:** ${authors || '—'}
> **原文:** [[${origName}]]
......@@ -155,15 +180,19 @@ ${data.summaryZh || '(摘要生成中)'}
function generateOriginalDoc(data, sourceName, imgFiles) {
const { titleEn, titleZh, date, authors, source: url, body } = data;
const dateShort = (date || '').substring(0, 10) || todayStr().replace(/(\d{4})(\d{2})(\d{2})/, '$1-$2-$3');
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, i) => `![[assets/${f.filename}]]\n*${f.alt || ''}*`).join('\n\n')
? 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: ${dateShort(date)}
tags:
${tags}
source: "${url}"
......@@ -177,7 +206,7 @@ saved: ${todayStr().replace(/(\d{4})(\d{2})(\d{2})/, '$1-$2-$3')}
# ${titleEn}
> [!info] Source
> **Published:** ${dateShort} | **URL:** ${url}
> **Published:** ${dateShort(date)} | **URL:** ${url}
> **中文摘要:** [[${titleZh}]]
---
......@@ -200,17 +229,27 @@ ${body || '(正文获取失败)'}
// ── index.md Update ───────────────────────────────────────────────────────────
function updateIndex(config, source, today, articles) {
const indexPath = path.join(config.vaultPath, source.vaultFolder, today, 'index.md');
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.category} | ${(a.tags||[]).slice(0,3).join(', ')} | ${(a.date||'').substring(0,10)} | ✅ |`
`| ${i+1} | [[${a.titleZh}]] | ${a.category} | ${(a.tags||[]).slice(0,3).join(', ')} | ${dateShort(a.date)} | ✅ |`
).join('\n');
const catNav = Object.entries(catCount).map(([cat, n]) => {
......@@ -221,15 +260,15 @@ function updateIndex(config, source, today, articles) {
const content = `---
date: ${dateFormatted}
type: daily-index
source: ${source.name}
source: ${sourceName}
total_articles: ${articles.length}
last_updated: "${new Date().toISOString()}"
---
# ${source.name} Daily Index · ${dateFormatted}
# ${sourceName} Daily Index · ${dateFormatted}
> **最近更新:** ${now}
> **当日归档:** ${articles.length} 篇 | **图片资产:** ${articles.filter(a => a.hasImg).length}
> **当日归档:** ${articles.length} 篇 | **图片资产:** ${assetCount}
> **涵盖分类:** ${Object.keys(catCount).join(' · ')}
---
......@@ -261,12 +300,46 @@ ${catNav}
// ── Main ──────────────────────────────────────────────────────────────────────
async function main() {
const sourceId = process.argv[2];
const count = parseInt(process.argv[3] || '5');
// 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>');
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);
}
......@@ -283,17 +356,17 @@ async function main() {
process.exit(1);
}
console.error(`\n🗞️ Harvesting ${source.name} (max ${count} articles)`);
const mode = preview ? (full ? 'preview (full)' : 'preview') : 'archive';
console.error(`\n🗞️ Harvesting ${source.name} (max ${count} articles) — ${mode}`);
console.error(` Method: ${source.method}`);
// Load fetcher
const fetcher = source.method === 'browser'
? require('./fetcher-browser')
: require('./fetcher-direct');
// Build dedup set
const seen = buildDedupSet(config, sourceId);
console.error(` Registry: ${seen.size} URLs in last ${config.registryWindowDays} days`);
// 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`);
// Fetch homepage links
console.error(`\n📡 Fetching homepage: ${source.homepageUrl}`);
......@@ -306,27 +379,68 @@ async function main() {
}
console.error(` Found ${links.length} article links`);
// Normalize URLs (strip query params for dedup, e.g. Bloomberg ?srnd=)
// Normalize URLs (strip query params for dedup)
const normalizeUrl = (u) => { try { const p = new URL(u); p.search = ''; return p.toString(); } catch(e) { return u; } };
const normalizedLinks = links.map(u => normalizeUrl(u));
const newLinks = normalizedLinks.filter(u => !seen.has(u)).slice(0, count);
const skipped = normalizedLinks.filter(u => seen.has(u)).length;
console.error(` New: ${newLinks.length} | Already seen: ${skipped}`);
// ── Preview mode: fetch only, print compact list, write nothing ───────────
if (preview) {
const out = [];
for (let i = 0; i < newLinks.length; i++) {
const url = newLinks[i];
console.error(`\n[${i+1}/${newLinks.length}] ${url}`);
let articleData;
try {
articleData = await fetcher.fetchPage(url);
} catch (e) {
console.error(` ✗ Fetch failed: ${e.message}`);
continue;
}
if (!articleData || !articleData.title) { console.error(' ✗ No title, 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.');
const manifest = { sourceId, sourceName: source.name, today: todayStr(), processed: [], skipped, newCount: 0 };
process.stdout.write(JSON.stringify(manifest, null, 2));
process.stdout.write(JSON.stringify({
sourceId, sourceName: source.name, today: todayStr(), dayDir: null,
summariesPath: null, articles: [], skipped, newCount: 0
}, null, 2));
return;
}
// Setup dirs
const today = todayStr();
const dayDir = path.join(config.vaultPath, source.vaultFolder, today);
const assetsDir = path.join(dayDir, 'assets');
fs.mkdirSync(assetsDir, { recursive: true });
// Process articles
const processed = [];
for (let i = 0; i < newLinks.length; i++) {
......@@ -341,7 +455,7 @@ async function main() {
continue;
}
if (!articleData || !articleData.title || articleData.body.length < 100) {
if (!articleData || !articleData.title || (articleData.body || '').length < 100) {
console.error(` ✗ Insufficient content (body: ${(articleData?.body||'').length} chars)`);
continue;
}
......@@ -349,7 +463,9 @@ async function main() {
const { title: titleEn, date, authors, imgs = [], body } = articleData;
const slug = slugFromUrl(url);
const category = getCategory(url, titleEn);
const tags = [category, source.name, ...(date || '').substring(0,4) ? [] : []].filter(Boolean);
const tags = [category, source.name];
const titleZh = sentinelFor(slug); // patched by finalize.js
const origName = originalFilename(titleEn, source.name);
// Download images
const imgFiles = [];
......@@ -362,45 +478,102 @@ async function main() {
if (ok) imgFiles.push({ filename, alt: imgs[j].alt, src: imgSrc });
}
// Placeholder for Chinese title (agent will generate proper translation)
// For now use a truncated cleaned version that the agent will improve
const titleZh = `[需翻译] ${titleEn.substring(0, 40)}`;
const articleInfo = {
url,
titleEn,
titleZh,
date,
authors,
body,
imgs: imgFiles,
category,
tags,
hasImg: imgFiles.length > 0,
slug,
dayDir,
sourceName: source.name
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, 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)`);
console.error(` ${titleEn} (${imgFiles.length} imgs, ${body.length} chars) ${origName}`);
await sleep(1000);
}
// Output manifest for agent to use
// 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,
processed,
summariesPath: path.join(dayDir, '.summaries.json'),
skipped,
newCount: processed.length
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 (事件概述/背景/关键引语/影响分析)'
},
articles: mergedArticles
};
process.stdout.write(JSON.stringify(manifest, null, 2));
console.error(`\n Done: ${processed.length} processed, ${skipped} skipped`);
console.error(' Agent should now: generate Chinese summaries, create docs, update registry');
// 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(` Manifest (${mergedArticles.length} total pending): ${manifestPath}`);
console.error(` Next: generate .summaries.json, then run finalize.js`);
}
main().catch(e => { console.error('Fatal:', e); process.exit(1); });
// 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
};
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