weflow-cli 1.8.0 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +159 -1
  2. package/OPERATIONS.md +42 -6
  3. package/README.en.md +3 -3
  4. package/README.md +3 -3
  5. package/bin/weflow-cli.ts +3 -1
  6. package/dist/bin/weflow-cli.js +3 -1
  7. package/dist/bin/weflow-cli.js.map +1 -1
  8. package/dist/src/panel/server.js +1 -1
  9. package/dist/src/panel/server.js.map +1 -1
  10. package/dist/src/services/assistantPrivacy.d.ts.map +1 -1
  11. package/dist/src/services/assistantPrivacy.js +7 -2
  12. package/dist/src/services/assistantPrivacy.js.map +1 -1
  13. package/dist/src/services/assistantService.d.ts.map +1 -1
  14. package/dist/src/services/assistantService.js +10 -1
  15. package/dist/src/services/assistantService.js.map +1 -1
  16. package/dist/src/services/assistantTools.d.ts +12 -0
  17. package/dist/src/services/assistantTools.d.ts.map +1 -1
  18. package/dist/src/services/assistantTools.js +29 -3
  19. package/dist/src/services/assistantTools.js.map +1 -1
  20. package/dist/src/services/wereadService.d.ts +33 -6
  21. package/dist/src/services/wereadService.d.ts.map +1 -1
  22. package/dist/src/services/wereadService.js +27 -2
  23. package/dist/src/services/wereadService.js.map +1 -1
  24. package/docs/BRANCHES.md +13 -4
  25. package/docs/PROJECT_STATE.md +299 -299
  26. package/package.json +1 -1
  27. package/resources/panel/main.cjs +43 -7
  28. package/resources/panel/mascot-thinking.png +0 -0
  29. package/resources/panel/panel.css +415 -407
  30. package/resources/panel/preload.cjs +79 -72
  31. package/resources/panel/renderer.js +470 -441
  32. package/scripts/_utils.py +23 -1
  33. package/scripts/backfill_articles.py +438 -438
  34. package/scripts/compile_wiki.py +18 -17
  35. package/scripts/wiki_lint.py +429 -397
  36. package/src/panel/server.ts +424 -424
  37. package/src/services/assistantPrivacy.ts +7 -2
  38. package/src/services/assistantService.ts +11 -1
  39. package/src/services/assistantTools.ts +33 -3
  40. package/src/services/wereadService.ts +43 -7
  41. package/resources/panel/mascot-focus.png +0 -0
@@ -1,438 +1,438 @@
1
- #!/usr/bin/env python3
2
- """历史文章回填 — 把过去几个月的公众号文章抓成正文,喂给知识库。
3
-
4
- 用法:
5
- python scripts/backfill_articles.py --since 2026-03-01 --until 2026-08-31 --dry-run --json
6
- python scripts/backfill_articles.py --since 2026-03-01 --until 2026-08-31 --workers 12 --yes
7
-
8
- ## 为什么不是直接跑 `pipeline run --date`
9
-
10
- 三条实测出来的理由,都不是推测:
11
-
12
- 1. **`pipeline run` 有硬编码的 10 分钟超时**(`bin/weflow-cli.ts:3906` 的 `timeout: 600_000`)。
13
- 那是给"当天日报"那条交互路径设的;一天 100+ 篇历史文章抓不完就被它掐死,产出为零。
14
- 2. **`biz_daily` 是逐篇串行抓的,而且每篇都下图片**。实测试点 25 分钟只处理了约 47 篇
15
- (≈30 秒/篇,大头在 13–21 张配图),29,667 篇要 150 小时以上。
16
- 3. **知识库用不到图片**。下游 `create_reading_notes` 只读 md 的 frontmatter(title/source/
17
- url/topic/date)与正文前 10 行;`compile_wiki` 只认 `## AI 摘要` 那一段。图片一张都不读。
18
-
19
- 所以本脚本只做三件与 `biz_daily` 不同的事:**并发抓**、**不下图片**、**不落 HTML 阅读器**。
20
- 取数、主题归一化、md 格式、`.articles.json` 的字段全部复用 `biz_daily` 里那几个函数,
21
- 不另写一份——两份写同一件事就会分叉,本仓库已经因为这类分叉吃过亏(见 `_normalize_topics`
22
- 的注释)。
23
-
24
- ## 一条必须说清的限制
25
-
26
- **图片型文章抓不到正文。** 实测 3/4、7/4 两天:27/40、44/60 有正文,其余是图文消息
27
- (内容全在图片里),清洗掉图片与链接后不足 100 字,与 `biz_daily` 一样被跳过。这不是
28
- 抓取失败,是那类文章本来就没有文字。所以"2 万篇"这个量级是有正文的部分,不是总量。
29
- """
30
- import argparse
31
- import hashlib
32
- import json
33
- import os
34
- import re
35
- import sys
36
- from concurrent.futures import ThreadPoolExecutor
37
- from datetime import datetime, timedelta, timezone
38
- from pathlib import Path
39
-
40
- sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
41
- from _utils import load_config # noqa: E402
42
- from biz_daily import (DEFAULT_RELEVANCE, DEFAULT_TOPIC, TOPICS, # noqa: E402
43
- _group_by_topic, _guess_topic, _serializable_article,
44
- _tags_for_write, extract_article_info, fetch_article,
45
- get_db_keys, sanitize_filename, summary_section)
46
- from _utils import strip_wx_ads_document, write_with_frontmatter # noqa: E402
47
-
48
- TZ = timezone(timedelta(hours=8))
49
- OUTPUT_ROOT = 'output/biz-daily'
50
- # Vault 里"原始素材"那一层的落点,与 `pipeline.py` 用的是同一个(但那边整份拷含图片)
51
- VAULT_SOURCES = 'output/wechat-vault/Sources/WeChat'
52
- # 与 biz_daily 的 `内容过短` 闸门同一个阈值。换成别的数会让两条路对"哪些文章算有正文"
53
- # 给出不同答案——那正是本仓库不许再出现的分叉。
54
- MIN_BODY_CHARS = 100
55
- # 支付/服务通知不是文章,biz_daily 在这张表上过滤过一次,这里沿用同一张表。
56
- SKIP_TITLE_PREFIXES = ('已支付', '已扣费', '支付成功', '扣费预通知', '你已关闭', '下单成功')
57
-
58
-
59
- def body_without_media(markdown: str) -> str:
60
- """去掉图片与链接后的正文——判"够不够长"用的是它,不是原文。
61
-
62
- 与 `biz_daily` 的清洗口径一致(去图、去链、去换行后再数字数)。
63
- """
64
- text = ''.join(str(markdown or '').split('\n'))
65
- text = re.sub(r'!\[.*?\]\(.*?\)', '', text)
66
- text = re.sub(r'\[.*?\]\(.*?\)', '', text)
67
- return text.strip()
68
-
69
-
70
- def collect_articles(cursor, users, name_map, day):
71
- """某一天库里所有文章(还没抓正文)。字段与 biz_daily 的 Phase 1 一致。"""
72
- start = int(datetime.strptime(day, '%Y-%m-%d').replace(tzinfo=TZ).timestamp())
73
- end = start + 86400
74
- out = []
75
- for user in users:
76
- table = 'Msg_' + hashlib.md5(user.encode()).hexdigest()
77
- try:
78
- rows = cursor.execute(
79
- 'SELECT create_time, message_content FROM "%s" '
80
- 'WHERE create_time >= ? AND create_time < ? ORDER BY create_time' % table,
81
- (start, end)).fetchall()
82
- except Exception:
83
- continue
84
- for create_time, content in rows:
85
- if not content:
86
- continue
87
- info = extract_article_info(content)
88
- if not info['title']:
89
- continue
90
- if any(info['title'].startswith(p) for p in SKIP_TITLE_PREFIXES):
91
- continue
92
- entry = {
93
- 'account': user,
94
- 'account_name': name_map.get(user, user),
95
- 'title': info['title'],
96
- 'digest': info['digest'],
97
- 'url': info['url'],
98
- 'cover': info['cover'],
99
- 'local_text': info['local_text'],
100
- 'time': datetime.fromtimestamp(create_time, tz=TZ).strftime('%H:%M'),
101
- 'timestamp': create_time,
102
- }
103
- out.append(entry)
104
- out.sort(key=lambda a: a['timestamp'])
105
- return apply_topics(out)
106
-
107
-
108
- def apply_topics(articles):
109
- """给每篇定主题(关键词),**就地**改并返回。
110
-
111
- 主题必须在这里定,**不能留给下游兜底**:`_group_by_topic` 对没有 topic 的文章一律
112
- 折成 `DEFAULT_TOPIC`,于是整批会静默落进同一个目录。这条踩过——第一次跑完 3/5,
113
- 106 篇 100% 落在 `学术/`,而当时的输出里只写着一句"主题兜底 113 篇"。
114
-
115
- 用 `_guess_topic`(关键词),这是 `biz_daily` 在 AI 分类失败时走的同一条退路;
116
- 本脚本不调模型,所以它是这里唯一可用的那条。
117
- """
118
- for article in articles:
119
- if article.get('topic') not in TOPICS:
120
- article['topic'] = _guess_topic(article)
121
- return articles
122
-
123
-
124
- def fetch_bodies(articles, workers: int, log=None):
125
- """并发把这批文章的正文抓回来,写进 `fetched_md`。
126
-
127
- **只抓正文,不碰图片**——`fetch_article` 返回的 markdown 里图片是链接,本脚本
128
- 就此打住,不再像 `biz_daily` 那样逐张下到本地(那一步占了实测 30 秒/篇里的绝大部分)。
129
- """
130
- todo = [a for a in articles
131
- if len(body_without_media(a.get('local_text'))) < MIN_BODY_CHARS and a.get('url')]
132
- if not todo:
133
- return {'attempted': 0, 'ok': 0}
134
-
135
- def one(article):
136
- try:
137
- body = fetch_article(article['url'])
138
- except Exception:
139
- return False
140
- if body:
141
- article['fetched_md'] = body
142
- return True
143
- return False
144
-
145
- ok = 0
146
- with ThreadPoolExecutor(max_workers=max(workers, 1)) as pool:
147
- for result in pool.map(one, todo):
148
- ok += 1 if result else 0
149
- if log:
150
- log(' 抓正文: 尝试 %d,成功 %d,并发 %d' % (len(todo), ok, workers))
151
- return {'attempted': len(todo), 'ok': ok}
152
-
153
-
154
- def write_day(articles, day, out_root):
155
- """落盘:`<日期>/<主题>/<来源>-<标题>.md` + `.articles.json` + README。
156
-
157
- 格式与 `biz_daily` 一致——`create_reading_notes` 与 `compile_wiki` 都按那个形状读。
158
- 正文那段写 `## AI 摘要`(内容用文章自带的 digest):**本脚本不调模型**,所以这里
159
- 放的只能是原文已有的东西,不能假装是生成的摘要。
160
- """
161
- groups, fallbacks = _group_by_topic(articles)
162
- out_dir = Path(out_root) / day
163
- written, skipped = [], []
164
- for topic in TOPICS:
165
- for article in groups[topic]:
166
- markdown = article.get('fetched_md') or article.get('local_text') or ''
167
- if len(body_without_media(markdown)) < MIN_BODY_CHARS:
168
- skipped.append({'title': article['title'][:30], 'reason': '正文不足 %d 字' % MIN_BODY_CHARS})
169
- continue
170
- safe = sanitize_filename('%s-%s' % (article['account_name'], article['title']))
171
- target = out_dir / topic / (safe + '.md')
172
- target.parent.mkdir(parents=True, exist_ok=True)
173
- digest = article.get('digest') or ''
174
- frontmatter = {
175
- 'title': '"%s"' % article['title'],
176
- 'source': '"%s"' % article['account_name'],
177
- 'date': day,
178
- 'topic': topic,
179
- 'relevance': article.get('relevance', DEFAULT_RELEVANCE),
180
- 'tags': _tags_for_write(article, topic),
181
- 'created': day,
182
- # 来源标记:正文是回填脚本抓的,不是当天日报那条路
183
- 'backfilled': 'true',
184
- }
185
- if article.get('url'):
186
- frontmatter['url'] = '"%s"' % article['url']
187
- body = [
188
- '# %s\n' % article['title'],
189
- '> 来源:%s ' % article['account_name'],
190
- '> 时间:%s %s ' % (day, article.get('time', '')),
191
- '> 原文:[阅读原文](%s)\n' % article.get('url', ''),
192
- '\n---\n',
193
- summary_section(digest).rstrip('\n'),
194
- '\n---\n',
195
- markdown,
196
- ]
197
- # 序列化**用 _utils 里那一份**,不另写:frontmatter 的引号规则(含 `:`/`#`/`"`
198
- # 才加引号、列表写成 `[a, b]`)在两边各写一遍就会分叉,而 `parse_frontmatter`
199
- # 只认其中一种。
200
- write_with_frontmatter(str(target), frontmatter, '\n'.join(body))
201
- written.append(article)
202
- out_dir.mkdir(parents=True, exist_ok=True)
203
- serializable = [_serializable_article(a, day) for a in written]
204
- (out_dir / '.articles.json').write_text(
205
- json.dumps({'date': day, 'generated_at': datetime.now(TZ).strftime('%Y-%m-%d %H:%M:%S'),
206
- 'backfilled': True, 'articles': serializable},
207
- ensure_ascii=False, indent=2), encoding='utf-8')
208
- (out_dir / 'README.md').write_text(
209
- '# 公众号文章 — %s(历史回填)\n\n共 %d 篇。正文由 `backfill_articles.py` 抓取,'
210
- '**未下载图片、未调用模型**。\n' % (day, len(written)), encoding='utf-8')
211
- return {'written': len(written), 'skipped': len(skipped),
212
- 'fallbackTopics': fallbacks, 'skipSample': skipped[:3]}
213
-
214
-
215
- def copy_to_vault(day: str, out_root: str, vault_root: str) -> dict:
216
- """把这一天的文章 md 拷进 Vault 的 `Sources/WeChat/<日期>/`。**只拷 md,不下图片。**
217
-
218
- `pipeline.py` 那条路是 `copytree` 整份拷(含图片):一天约 200 MB,165 天约 33 GB。
219
- 知识库那条线**一张图都不读**(下游只读 md 的 frontmatter 与正文),所以这里只拷
220
- `.md`,约 14 KB/篇、165 天约 0.4 GB。
221
-
222
- **为什么非要补这一步**:`Sources/` 是"原始素材"那一层(见 `user_notes.py` 的说明),
223
- 而 `002_Literature/` 是读后笔记。只写后者的话,Vault 里"读过什么"是全的、"原文在哪"
224
- 却是残缺的——2026-09-27 用户就是这么发现的:165 天的 `Sources` 根本没有,
225
- 因为回填时我只在对话里说了一句"跳过了",没在库里留下任何痕迹。
226
-
227
- 逐 md 拷而不是整目录替换:`copytree` 的语义是"删掉再放",而这里要的是**补缺**——
228
- Vault 是用户的东西,不能因为要补一天就把那天底下别的东西清掉。
229
-
230
- 拷过去的是**洗过的正文**(`strip_wx_ads_document`:删微信界面短语,frontmatter 与代码块
231
- 不动)。用户 2026-09-28 定的就是"原文与笔记都清"——`Sources/` 是给人读的那一层,
232
- "继续滑动看下一个"这种按钮文字搜出来只会添乱。清洗落在这里的理由与代价写在循环那段。
233
- """
234
- source = Path(out_root) / day
235
- target = Path(vault_root) / day
236
- if not source.is_dir():
237
- return {'copied': 0, 'reason': '没有这天的产出'}
238
- copied = 0
239
- for path in source.rglob('*.md'):
240
- rel = path.relative_to(source)
241
- dest = target / rel
242
- dest.parent.mkdir(parents=True, exist_ok=True)
243
- try:
244
- with open(path, encoding='utf-8', newline='') as handle:
245
- text = handle.read()
246
- except (OSError, UnicodeDecodeError):
247
- # 读不成文本就照原样拷:宁可库里有几篇没洗的,也不要因为一篇读不了就整天不补。
248
- dest.write_bytes(path.read_bytes())
249
- copied += 1
250
- continue
251
- # **在这里洗,而不是在 fetch 那一步**:`biz-daily` 是**原始存档**(洗了就再也拿不回
252
- # 原文),`Sources/` 才是给人读的那一层。代价是两边不再逐字节相同 —— 所以这一步必须
253
- # 落在拷贝函数里,否则任何一次补拷都会把洗过的又覆盖回去(2026-09-28 就这么发生过一次)。
254
- with open(dest, 'w', encoding='utf-8', newline='') as handle:
255
- handle.write(strip_wx_ads_document(text))
256
- copied += 1
257
- return {'copied': copied, 'reason': ''}
258
-
259
-
260
- def days_with_articles(out_root) -> list:
261
- """`biz-daily` 下**真的有文章**的那些天(升序)。
262
-
263
- 判据是"那天底下有 md",**不是** `.articles.json` 里的 `backfilled` 标记。这两个判据
264
- 实测不等价:2026-09-28 量过,199 天里有 12 天有文章却没有那个标记(`.articles.json`
265
- 是 `daily` 那条路写的,不写这个键),其中 08-24/08-25 两天在 Vault 里**根本没有**对应
266
- 目录 —— 拿标记当判据,这两天既不会被补也不会被报出来。
267
-
268
- 只在**扫到的目录里**判断,不去查库、不联网:补拷要能在离线时跑。
269
- """
270
- root = Path(out_root)
271
- days = []
272
- for directory in sorted(root.iterdir()) if root.exists() else []:
273
- if not directory.is_dir():
274
- continue
275
- if any(p.name != 'README.md' for p in directory.rglob('*.md')):
276
- days.append(directory.name)
277
- return days
278
-
279
-
280
- def day_done(out_root, day) -> bool:
281
- """这一天已经回填过了吗(增量:免得重跑把同样的文章再抓一遍)。"""
282
- path = Path(out_root) / day / '.articles.json'
283
- if not path.exists():
284
- return False
285
- try:
286
- return bool(json.loads(path.read_text(encoding='utf-8')).get('articles'))
287
- except Exception:
288
- return False
289
-
290
-
291
- def main():
292
- sys.stdout.reconfigure(encoding='utf-8', errors='replace')
293
- parser = argparse.ArgumentParser(description='历史文章回填(只抓正文,不下图片、不调模型)')
294
- parser.add_argument('--since', required=True, help='起始日期 YYYY-MM-DD(含)')
295
- parser.add_argument('--until', required=True, help='结束日期 YYYY-MM-DD(含)')
296
- parser.add_argument('--out', default=OUTPUT_ROOT, help='输出根目录')
297
- parser.add_argument('--vault-root', default=VAULT_SOURCES, help='Vault 的 Sources/WeChat 目录')
298
- parser.add_argument('--vault-copy', action='store_true',
299
- help='回填时顺带把文章 md 拷进 Vault 的 Sources(只拷 md,不下图片)')
300
- parser.add_argument('--vault-sync', action='store_true',
301
- help='只做这件事:把已回填的天补拷进 Vault 的 Sources(本地,不抓取、不调模型)')
302
- parser.add_argument('--workers', type=int, default=8, help='抓正文的并发数(默认 8)')
303
- parser.add_argument('--limit-per-day', type=int, default=0, help='每天最多抓几篇(0=不限)')
304
- parser.add_argument('--refresh', action='store_true', help='已回填过的天也重做(默认跳过)')
305
- parser.add_argument('--max-days', type=int, default=0, help='最多处理几天(0=不限,用于试跑)')
306
- parser.add_argument('--dry-run', action='store_true', help='只报到要抓多少,不抓不写')
307
- parser.add_argument('--yes', action='store_true', help='确认抓取并写入文件')
308
- parser.add_argument('--json', action='store_true')
309
- args = parser.parse_args()
310
-
311
- try:
312
- start = datetime.strptime(args.since, '%Y-%m-%d').date()
313
- end = datetime.strptime(args.until, '%Y-%m-%d').date()
314
- except ValueError as error:
315
- print(json.dumps({'success': False, 'error': '日期格式要 YYYY-MM-DD:%s' % error})
316
- if args.json else '日期格式要 YYYY-MM-DD:%s' % error)
317
- return 1
318
- if end < start:
319
- print('--until 不能早于 --since'); return 1
320
- if args.workers < 1:
321
- print('--workers 要 ≥ 1'); return 1
322
-
323
- if args.vault_sync:
324
- # **补拷模式**:不抓取、不调模型,只把已经躺在 biz-daily 的那几天补进 Vault。
325
- # 为什么要有它:回填那天可以先不拷(省体积),后来想补就得能单独补——2026-09-27
326
- # 就是这么补的,165 天早就在 biz-daily 里,只是 Vault 的 Sources 里没有。
327
- #
328
- # 重复跑是**安全**的:`copy_to_vault` 逐 md 覆盖、不删别的东西,而它现在还会洗残留,
329
- # 所以"再补一次"同时也是"把库里那一层重新洗一遍"(2026-09-28 就是这么把 1,755 篇
330
- # 漏网的补上的 —— 那天被 `backfilled` 标记挡在门外的 12 天)。
331
- days = days_with_articles(args.out)
332
- if args.max_days:
333
- days = days[:args.max_days]
334
- copied_days = total = 0
335
- for day in days:
336
- result = copy_to_vault(day, args.out, args.vault_root)
337
- if result['copied']:
338
- copied_days += 1
339
- total += result['copied']
340
- payload = {'success': True, 'action': 'backfill-articles.vault-sync',
341
- 'days': len(days), 'daysCopied': copied_days, 'files': total,
342
- 'target': args.vault_root, 'invokesAI': False, 'downloadsImages': False}
343
- print(json.dumps(payload, ensure_ascii=False, indent=2) if args.json
344
- else '补拷 %d 天、共 %d 个 md → %s' % (copied_days, total, args.vault_root))
345
- return 0
346
-
347
- days = []
348
- cursor_day = start
349
- while cursor_day <= end:
350
- days.append(cursor_day.isoformat())
351
- cursor_day += timedelta(days=1)
352
- if args.max_days:
353
- days = days[:args.max_days]
354
-
355
- keys = get_db_keys(load_config())
356
- import sqlcipher3.dbapi2 as sqlcipher
357
- conn = sqlcipher.connect(keys['biz_db'])
358
- conn.execute('PRAGMA key = "x\'%s%s\'";' % (keys['biz_key'], keys['biz_salt']))
359
- cursor = conn.cursor()
360
- users = [row[0] for row in cursor.execute(
361
- "SELECT user_name FROM Name2Id WHERE user_name LIKE 'gh_%'").fetchall()]
362
- name_map = {}
363
- if keys.get('contact_key') and os.path.exists(keys.get('contact_db', '')):
364
- try:
365
- contact = sqlcipher.connect(keys['contact_db'])
366
- contact.execute('PRAGMA key = "x\'%s%s\'";'
367
- % (keys['contact_key'], keys['contact_salt']))
368
- name_map = dict(contact.execute(
369
- "SELECT username, COALESCE(NULLIF(remark,''), NULLIF(nick_name,''), username) "
370
- "FROM contact WHERE username LIKE 'gh_%'").fetchall())
371
- contact.close()
372
- except Exception:
373
- pass
374
-
375
- todo = []
376
- for day in days:
377
- if not args.refresh and day_done(args.out, day):
378
- continue
379
- articles = collect_articles(cursor, users, name_map, day)
380
- if args.limit_per_day:
381
- articles = articles[:args.limit_per_day]
382
- if articles:
383
- todo.append((day, articles))
384
-
385
- if args.dry_run:
386
- total = sum(len(items) for _, items in todo)
387
- need = sum(1 for _, items in todo for a in items
388
- if len(body_without_media(a.get('local_text'))) < MIN_BODY_CHARS and a.get('url'))
389
- payload = {
390
- 'success': True, 'dryRun': True, 'action': 'backfill-articles',
391
- 'days': len(todo), 'articles': total, 'needFetch': need,
392
- 'workers': args.workers, 'invokesAI': False, 'downloadsImages': False,
393
- 'readsLocalData': True,
394
- 'note': '只抓正文,不下图片、不调用模型;抓完还要跑 vault notes 与 article-notes',
395
- }
396
- print(json.dumps(payload, ensure_ascii=False, indent=2) if args.json
397
- else '%d 天、%d 篇(其中 %d 篇要联网抓正文),并发 %d'
398
- % (len(todo), total, need, args.workers))
399
- conn.close()
400
- return 0
401
-
402
- if not todo:
403
- print('没有要处理的(都已回填;要重做加 --refresh)')
404
- conn.close()
405
- return 0
406
- if not args.yes:
407
- print('会抓 %d 天、%d 篇的正文(不调模型)。加 --yes 确认。'
408
- % (len(todo), sum(len(i) for _, i in todo)))
409
- conn.close()
410
- return 1
411
-
412
- report = []
413
- for index, (day, articles) in enumerate(todo, 1):
414
- print('\n[%d/%d] %s — 库里 %d 篇' % (index, len(todo), day, len(articles)))
415
- fetch = fetch_bodies(articles, args.workers, log=print)
416
- result = write_day(articles, day, args.out)
417
- print(' 写入 %d 篇,跳过 %d 篇(正文太短),主题兜底 %d 篇'
418
- % (result['written'], result['skipped'], result['fallbackTopics']))
419
- if args.vault_copy:
420
- sync = copy_to_vault(day, args.out, args.vault_root)
421
- print(' 入 Vault 的 Sources: %d 个 md' % sync['copied'])
422
- report.append({'date': day, 'inDb': len(articles), 'fetched': fetch['ok'],
423
- 'written': result['written'], 'skippedThin': result['skipped']})
424
- conn.close()
425
-
426
- total_written = sum(r['written'] for r in report)
427
- if args.json:
428
- print(json.dumps({'success': True, 'action': 'backfill-articles',
429
- 'days': report, 'written': total_written},
430
- ensure_ascii=False, indent=2))
431
- else:
432
- print('\n共写入 %d 篇,覆盖 %d 天' % (total_written, len(report)))
433
- print('接着跑: vault notes --date <日期> 然后 article-notes')
434
- return 0 if total_written else 1
435
-
436
-
437
- if __name__ == '__main__':
438
- sys.exit(main())
1
+ #!/usr/bin/env python3
2
+ """历史文章回填 — 把过去几个月的公众号文章抓成正文,喂给知识库。
3
+
4
+ 用法:
5
+ python scripts/backfill_articles.py --since 2026-03-01 --until 2026-08-31 --dry-run --json
6
+ python scripts/backfill_articles.py --since 2026-03-01 --until 2026-08-31 --workers 12 --yes
7
+
8
+ ## 为什么不是直接跑 `pipeline run --date`
9
+
10
+ 三条实测出来的理由,都不是推测:
11
+
12
+ 1. **`pipeline run` 有硬编码的 10 分钟超时**(`bin/weflow-cli.ts:3906` 的 `timeout: 600_000`)。
13
+ 那是给"当天日报"那条交互路径设的;一天 100+ 篇历史文章抓不完就被它掐死,产出为零。
14
+ 2. **`biz_daily` 是逐篇串行抓的,而且每篇都下图片**。实测试点 25 分钟只处理了约 47 篇
15
+ (≈30 秒/篇,大头在 13–21 张配图),29,667 篇要 150 小时以上。
16
+ 3. **知识库用不到图片**。下游 `create_reading_notes` 只读 md 的 frontmatter(title/source/
17
+ url/topic/date)与正文前 10 行;`compile_wiki` 只认 `## AI 摘要` 那一段。图片一张都不读。
18
+
19
+ 所以本脚本只做三件与 `biz_daily` 不同的事:**并发抓**、**不下图片**、**不落 HTML 阅读器**。
20
+ 取数、主题归一化、md 格式、`.articles.json` 的字段全部复用 `biz_daily` 里那几个函数,
21
+ 不另写一份——两份写同一件事就会分叉,本仓库已经因为这类分叉吃过亏(见 `_normalize_topics`
22
+ 的注释)。
23
+
24
+ ## 一条必须说清的限制
25
+
26
+ **图片型文章抓不到正文。** 实测 3/4、7/4 两天:27/40、44/60 有正文,其余是图文消息
27
+ (内容全在图片里),清洗掉图片与链接后不足 100 字,与 `biz_daily` 一样被跳过。这不是
28
+ 抓取失败,是那类文章本来就没有文字。所以"2 万篇"这个量级是有正文的部分,不是总量。
29
+ """
30
+ import argparse
31
+ import hashlib
32
+ import json
33
+ import os
34
+ import re
35
+ import sys
36
+ from concurrent.futures import ThreadPoolExecutor
37
+ from datetime import datetime, timedelta, timezone
38
+ from pathlib import Path
39
+
40
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
41
+ from _utils import load_config # noqa: E402
42
+ from biz_daily import (DEFAULT_RELEVANCE, DEFAULT_TOPIC, TOPICS, # noqa: E402
43
+ _group_by_topic, _guess_topic, _serializable_article,
44
+ _tags_for_write, extract_article_info, fetch_article,
45
+ get_db_keys, sanitize_filename, summary_section)
46
+ from _utils import strip_wx_ads_document, write_with_frontmatter # noqa: E402
47
+
48
+ TZ = timezone(timedelta(hours=8))
49
+ OUTPUT_ROOT = 'output/biz-daily'
50
+ # Vault 里"原始素材"那一层的落点,与 `pipeline.py` 用的是同一个(但那边整份拷含图片)
51
+ VAULT_SOURCES = 'output/wechat-vault/Sources/WeChat'
52
+ # 与 biz_daily 的 `内容过短` 闸门同一个阈值。换成别的数会让两条路对"哪些文章算有正文"
53
+ # 给出不同答案——那正是本仓库不许再出现的分叉。
54
+ MIN_BODY_CHARS = 100
55
+ # 支付/服务通知不是文章,biz_daily 在这张表上过滤过一次,这里沿用同一张表。
56
+ SKIP_TITLE_PREFIXES = ('已支付', '已扣费', '支付成功', '扣费预通知', '你已关闭', '下单成功')
57
+
58
+
59
+ def body_without_media(markdown: str) -> str:
60
+ """去掉图片与链接后的正文——判"够不够长"用的是它,不是原文。
61
+
62
+ 与 `biz_daily` 的清洗口径一致(去图、去链、去换行后再数字数)。
63
+ """
64
+ text = ''.join(str(markdown or '').split('\n'))
65
+ text = re.sub(r'!\[.*?\]\(.*?\)', '', text)
66
+ text = re.sub(r'\[.*?\]\(.*?\)', '', text)
67
+ return text.strip()
68
+
69
+
70
+ def collect_articles(cursor, users, name_map, day):
71
+ """某一天库里所有文章(还没抓正文)。字段与 biz_daily 的 Phase 1 一致。"""
72
+ start = int(datetime.strptime(day, '%Y-%m-%d').replace(tzinfo=TZ).timestamp())
73
+ end = start + 86400
74
+ out = []
75
+ for user in users:
76
+ table = 'Msg_' + hashlib.md5(user.encode()).hexdigest()
77
+ try:
78
+ rows = cursor.execute(
79
+ 'SELECT create_time, message_content FROM "%s" '
80
+ 'WHERE create_time >= ? AND create_time < ? ORDER BY create_time' % table,
81
+ (start, end)).fetchall()
82
+ except Exception:
83
+ continue
84
+ for create_time, content in rows:
85
+ if not content:
86
+ continue
87
+ info = extract_article_info(content)
88
+ if not info['title']:
89
+ continue
90
+ if any(info['title'].startswith(p) for p in SKIP_TITLE_PREFIXES):
91
+ continue
92
+ entry = {
93
+ 'account': user,
94
+ 'account_name': name_map.get(user, user),
95
+ 'title': info['title'],
96
+ 'digest': info['digest'],
97
+ 'url': info['url'],
98
+ 'cover': info['cover'],
99
+ 'local_text': info['local_text'],
100
+ 'time': datetime.fromtimestamp(create_time, tz=TZ).strftime('%H:%M'),
101
+ 'timestamp': create_time,
102
+ }
103
+ out.append(entry)
104
+ out.sort(key=lambda a: a['timestamp'])
105
+ return apply_topics(out)
106
+
107
+
108
+ def apply_topics(articles):
109
+ """给每篇定主题(关键词),**就地**改并返回。
110
+
111
+ 主题必须在这里定,**不能留给下游兜底**:`_group_by_topic` 对没有 topic 的文章一律
112
+ 折成 `DEFAULT_TOPIC`,于是整批会静默落进同一个目录。这条踩过——第一次跑完 3/5,
113
+ 106 篇 100% 落在 `学术/`,而当时的输出里只写着一句"主题兜底 113 篇"。
114
+
115
+ 用 `_guess_topic`(关键词),这是 `biz_daily` 在 AI 分类失败时走的同一条退路;
116
+ 本脚本不调模型,所以它是这里唯一可用的那条。
117
+ """
118
+ for article in articles:
119
+ if article.get('topic') not in TOPICS:
120
+ article['topic'] = _guess_topic(article)
121
+ return articles
122
+
123
+
124
+ def fetch_bodies(articles, workers: int, log=None):
125
+ """并发把这批文章的正文抓回来,写进 `fetched_md`。
126
+
127
+ **只抓正文,不碰图片**——`fetch_article` 返回的 markdown 里图片是链接,本脚本
128
+ 就此打住,不再像 `biz_daily` 那样逐张下到本地(那一步占了实测 30 秒/篇里的绝大部分)。
129
+ """
130
+ todo = [a for a in articles
131
+ if len(body_without_media(a.get('local_text'))) < MIN_BODY_CHARS and a.get('url')]
132
+ if not todo:
133
+ return {'attempted': 0, 'ok': 0}
134
+
135
+ def one(article):
136
+ try:
137
+ body = fetch_article(article['url'])
138
+ except Exception:
139
+ return False
140
+ if body:
141
+ article['fetched_md'] = body
142
+ return True
143
+ return False
144
+
145
+ ok = 0
146
+ with ThreadPoolExecutor(max_workers=max(workers, 1)) as pool:
147
+ for result in pool.map(one, todo):
148
+ ok += 1 if result else 0
149
+ if log:
150
+ log(' 抓正文: 尝试 %d,成功 %d,并发 %d' % (len(todo), ok, workers))
151
+ return {'attempted': len(todo), 'ok': ok}
152
+
153
+
154
+ def write_day(articles, day, out_root):
155
+ """落盘:`<日期>/<主题>/<来源>-<标题>.md` + `.articles.json` + README。
156
+
157
+ 格式与 `biz_daily` 一致——`create_reading_notes` 与 `compile_wiki` 都按那个形状读。
158
+ 正文那段写 `## AI 摘要`(内容用文章自带的 digest):**本脚本不调模型**,所以这里
159
+ 放的只能是原文已有的东西,不能假装是生成的摘要。
160
+ """
161
+ groups, fallbacks = _group_by_topic(articles)
162
+ out_dir = Path(out_root) / day
163
+ written, skipped = [], []
164
+ for topic in TOPICS:
165
+ for article in groups[topic]:
166
+ markdown = article.get('fetched_md') or article.get('local_text') or ''
167
+ if len(body_without_media(markdown)) < MIN_BODY_CHARS:
168
+ skipped.append({'title': article['title'][:30], 'reason': '正文不足 %d 字' % MIN_BODY_CHARS})
169
+ continue
170
+ safe = sanitize_filename('%s-%s' % (article['account_name'], article['title']))
171
+ target = out_dir / topic / (safe + '.md')
172
+ target.parent.mkdir(parents=True, exist_ok=True)
173
+ digest = article.get('digest') or ''
174
+ frontmatter = {
175
+ 'title': '"%s"' % article['title'],
176
+ 'source': '"%s"' % article['account_name'],
177
+ 'date': day,
178
+ 'topic': topic,
179
+ 'relevance': article.get('relevance', DEFAULT_RELEVANCE),
180
+ 'tags': _tags_for_write(article, topic),
181
+ 'created': day,
182
+ # 来源标记:正文是回填脚本抓的,不是当天日报那条路
183
+ 'backfilled': 'true',
184
+ }
185
+ if article.get('url'):
186
+ frontmatter['url'] = '"%s"' % article['url']
187
+ body = [
188
+ '# %s\n' % article['title'],
189
+ '> 来源:%s ' % article['account_name'],
190
+ '> 时间:%s %s ' % (day, article.get('time', '')),
191
+ '> 原文:[阅读原文](%s)\n' % article.get('url', ''),
192
+ '\n---\n',
193
+ summary_section(digest).rstrip('\n'),
194
+ '\n---\n',
195
+ markdown,
196
+ ]
197
+ # 序列化**用 _utils 里那一份**,不另写:frontmatter 的引号规则(含 `:`/`#`/`"`
198
+ # 才加引号、列表写成 `[a, b]`)在两边各写一遍就会分叉,而 `parse_frontmatter`
199
+ # 只认其中一种。
200
+ write_with_frontmatter(str(target), frontmatter, '\n'.join(body))
201
+ written.append(article)
202
+ out_dir.mkdir(parents=True, exist_ok=True)
203
+ serializable = [_serializable_article(a, day) for a in written]
204
+ (out_dir / '.articles.json').write_text(
205
+ json.dumps({'date': day, 'generated_at': datetime.now(TZ).strftime('%Y-%m-%d %H:%M:%S'),
206
+ 'backfilled': True, 'articles': serializable},
207
+ ensure_ascii=False, indent=2), encoding='utf-8')
208
+ (out_dir / 'README.md').write_text(
209
+ '# 公众号文章 — %s(历史回填)\n\n共 %d 篇。正文由 `backfill_articles.py` 抓取,'
210
+ '**未下载图片、未调用模型**。\n' % (day, len(written)), encoding='utf-8')
211
+ return {'written': len(written), 'skipped': len(skipped),
212
+ 'fallbackTopics': fallbacks, 'skipSample': skipped[:3]}
213
+
214
+
215
+ def copy_to_vault(day: str, out_root: str, vault_root: str) -> dict:
216
+ """把这一天的文章 md 拷进 Vault 的 `Sources/WeChat/<日期>/`。**只拷 md,不下图片。**
217
+
218
+ `pipeline.py` 那条路是 `copytree` 整份拷(含图片):一天约 200 MB,165 天约 33 GB。
219
+ 知识库那条线**一张图都不读**(下游只读 md 的 frontmatter 与正文),所以这里只拷
220
+ `.md`,约 14 KB/篇、165 天约 0.4 GB。
221
+
222
+ **为什么非要补这一步**:`Sources/` 是"原始素材"那一层(见 `user_notes.py` 的说明),
223
+ 而 `002_Literature/` 是读后笔记。只写后者的话,Vault 里"读过什么"是全的、"原文在哪"
224
+ 却是残缺的——2026-09-27 用户就是这么发现的:165 天的 `Sources` 根本没有,
225
+ 因为回填时我只在对话里说了一句"跳过了",没在库里留下任何痕迹。
226
+
227
+ 逐 md 拷而不是整目录替换:`copytree` 的语义是"删掉再放",而这里要的是**补缺**——
228
+ Vault 是用户的东西,不能因为要补一天就把那天底下别的东西清掉。
229
+
230
+ 拷过去的是**洗过的正文**(`strip_wx_ads_document`:删微信界面短语,frontmatter 与代码块
231
+ 不动)。用户 2026-09-28 定的就是"原文与笔记都清"——`Sources/` 是给人读的那一层,
232
+ "继续滑动看下一个"这种按钮文字搜出来只会添乱。清洗落在这里的理由与代价写在循环那段。
233
+ """
234
+ source = Path(out_root) / day
235
+ target = Path(vault_root) / day
236
+ if not source.is_dir():
237
+ return {'copied': 0, 'reason': '没有这天的产出'}
238
+ copied = 0
239
+ for path in source.rglob('*.md'):
240
+ rel = path.relative_to(source)
241
+ dest = target / rel
242
+ dest.parent.mkdir(parents=True, exist_ok=True)
243
+ try:
244
+ with open(path, encoding='utf-8', newline='') as handle:
245
+ text = handle.read()
246
+ except (OSError, UnicodeDecodeError):
247
+ # 读不成文本就照原样拷:宁可库里有几篇没洗的,也不要因为一篇读不了就整天不补。
248
+ dest.write_bytes(path.read_bytes())
249
+ copied += 1
250
+ continue
251
+ # **在这里洗,而不是在 fetch 那一步**:`biz-daily` 是**原始存档**(洗了就再也拿不回
252
+ # 原文),`Sources/` 才是给人读的那一层。代价是两边不再逐字节相同 —— 所以这一步必须
253
+ # 落在拷贝函数里,否则任何一次补拷都会把洗过的又覆盖回去(2026-09-28 就这么发生过一次)。
254
+ with open(dest, 'w', encoding='utf-8', newline='') as handle:
255
+ handle.write(strip_wx_ads_document(text))
256
+ copied += 1
257
+ return {'copied': copied, 'reason': ''}
258
+
259
+
260
+ def days_with_articles(out_root) -> list:
261
+ """`biz-daily` 下**真的有文章**的那些天(升序)。
262
+
263
+ 判据是"那天底下有 md",**不是** `.articles.json` 里的 `backfilled` 标记。这两个判据
264
+ 实测不等价:2026-09-28 量过,199 天里有 12 天有文章却没有那个标记(`.articles.json`
265
+ 是 `daily` 那条路写的,不写这个键),其中 08-24/08-25 两天在 Vault 里**根本没有**对应
266
+ 目录 —— 拿标记当判据,这两天既不会被补也不会被报出来。
267
+
268
+ 只在**扫到的目录里**判断,不去查库、不联网:补拷要能在离线时跑。
269
+ """
270
+ root = Path(out_root)
271
+ days = []
272
+ for directory in sorted(root.iterdir()) if root.exists() else []:
273
+ if not directory.is_dir():
274
+ continue
275
+ if any(p.name != 'README.md' for p in directory.rglob('*.md')):
276
+ days.append(directory.name)
277
+ return days
278
+
279
+
280
+ def day_done(out_root, day) -> bool:
281
+ """这一天已经回填过了吗(增量:免得重跑把同样的文章再抓一遍)。"""
282
+ path = Path(out_root) / day / '.articles.json'
283
+ if not path.exists():
284
+ return False
285
+ try:
286
+ return bool(json.loads(path.read_text(encoding='utf-8')).get('articles'))
287
+ except Exception:
288
+ return False
289
+
290
+
291
+ def main():
292
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
293
+ parser = argparse.ArgumentParser(description='历史文章回填(只抓正文,不下图片、不调模型)')
294
+ parser.add_argument('--since', required=True, help='起始日期 YYYY-MM-DD(含)')
295
+ parser.add_argument('--until', required=True, help='结束日期 YYYY-MM-DD(含)')
296
+ parser.add_argument('--out', default=OUTPUT_ROOT, help='输出根目录')
297
+ parser.add_argument('--vault-root', default=VAULT_SOURCES, help='Vault 的 Sources/WeChat 目录')
298
+ parser.add_argument('--vault-copy', action='store_true',
299
+ help='回填时顺带把文章 md 拷进 Vault 的 Sources(只拷 md,不下图片)')
300
+ parser.add_argument('--vault-sync', action='store_true',
301
+ help='只做这件事:把已回填的天补拷进 Vault 的 Sources(本地,不抓取、不调模型)')
302
+ parser.add_argument('--workers', type=int, default=8, help='抓正文的并发数(默认 8)')
303
+ parser.add_argument('--limit-per-day', type=int, default=0, help='每天最多抓几篇(0=不限)')
304
+ parser.add_argument('--refresh', action='store_true', help='已回填过的天也重做(默认跳过)')
305
+ parser.add_argument('--max-days', type=int, default=0, help='最多处理几天(0=不限,用于试跑)')
306
+ parser.add_argument('--dry-run', action='store_true', help='只报到要抓多少,不抓不写')
307
+ parser.add_argument('--yes', action='store_true', help='确认抓取并写入文件')
308
+ parser.add_argument('--json', action='store_true')
309
+ args = parser.parse_args()
310
+
311
+ try:
312
+ start = datetime.strptime(args.since, '%Y-%m-%d').date()
313
+ end = datetime.strptime(args.until, '%Y-%m-%d').date()
314
+ except ValueError as error:
315
+ print(json.dumps({'success': False, 'error': '日期格式要 YYYY-MM-DD:%s' % error})
316
+ if args.json else '日期格式要 YYYY-MM-DD:%s' % error)
317
+ return 1
318
+ if end < start:
319
+ print('--until 不能早于 --since'); return 1
320
+ if args.workers < 1:
321
+ print('--workers 要 ≥ 1'); return 1
322
+
323
+ if args.vault_sync:
324
+ # **补拷模式**:不抓取、不调模型,只把已经躺在 biz-daily 的那几天补进 Vault。
325
+ # 为什么要有它:回填那天可以先不拷(省体积),后来想补就得能单独补——2026-09-27
326
+ # 就是这么补的,165 天早就在 biz-daily 里,只是 Vault 的 Sources 里没有。
327
+ #
328
+ # 重复跑是**安全**的:`copy_to_vault` 逐 md 覆盖、不删别的东西,而它现在还会洗残留,
329
+ # 所以"再补一次"同时也是"把库里那一层重新洗一遍"(2026-09-28 就是这么把 1,755 篇
330
+ # 漏网的补上的 —— 那天被 `backfilled` 标记挡在门外的 12 天)。
331
+ days = days_with_articles(args.out)
332
+ if args.max_days:
333
+ days = days[:args.max_days]
334
+ copied_days = total = 0
335
+ for day in days:
336
+ result = copy_to_vault(day, args.out, args.vault_root)
337
+ if result['copied']:
338
+ copied_days += 1
339
+ total += result['copied']
340
+ payload = {'success': True, 'action': 'backfill-articles.vault-sync',
341
+ 'days': len(days), 'daysCopied': copied_days, 'files': total,
342
+ 'target': args.vault_root, 'invokesAI': False, 'downloadsImages': False}
343
+ print(json.dumps(payload, ensure_ascii=False, indent=2) if args.json
344
+ else '补拷 %d 天、共 %d 个 md → %s' % (copied_days, total, args.vault_root))
345
+ return 0
346
+
347
+ days = []
348
+ cursor_day = start
349
+ while cursor_day <= end:
350
+ days.append(cursor_day.isoformat())
351
+ cursor_day += timedelta(days=1)
352
+ if args.max_days:
353
+ days = days[:args.max_days]
354
+
355
+ keys = get_db_keys(load_config())
356
+ import sqlcipher3.dbapi2 as sqlcipher
357
+ conn = sqlcipher.connect(keys['biz_db'])
358
+ conn.execute('PRAGMA key = "x\'%s%s\'";' % (keys['biz_key'], keys['biz_salt']))
359
+ cursor = conn.cursor()
360
+ users = [row[0] for row in cursor.execute(
361
+ "SELECT user_name FROM Name2Id WHERE user_name LIKE 'gh_%'").fetchall()]
362
+ name_map = {}
363
+ if keys.get('contact_key') and os.path.exists(keys.get('contact_db', '')):
364
+ try:
365
+ contact = sqlcipher.connect(keys['contact_db'])
366
+ contact.execute('PRAGMA key = "x\'%s%s\'";'
367
+ % (keys['contact_key'], keys['contact_salt']))
368
+ name_map = dict(contact.execute(
369
+ "SELECT username, COALESCE(NULLIF(remark,''), NULLIF(nick_name,''), username) "
370
+ "FROM contact WHERE username LIKE 'gh_%'").fetchall())
371
+ contact.close()
372
+ except Exception:
373
+ pass
374
+
375
+ todo = []
376
+ for day in days:
377
+ if not args.refresh and day_done(args.out, day):
378
+ continue
379
+ articles = collect_articles(cursor, users, name_map, day)
380
+ if args.limit_per_day:
381
+ articles = articles[:args.limit_per_day]
382
+ if articles:
383
+ todo.append((day, articles))
384
+
385
+ if args.dry_run:
386
+ total = sum(len(items) for _, items in todo)
387
+ need = sum(1 for _, items in todo for a in items
388
+ if len(body_without_media(a.get('local_text'))) < MIN_BODY_CHARS and a.get('url'))
389
+ payload = {
390
+ 'success': True, 'dryRun': True, 'action': 'backfill-articles',
391
+ 'days': len(todo), 'articles': total, 'needFetch': need,
392
+ 'workers': args.workers, 'invokesAI': False, 'downloadsImages': False,
393
+ 'readsLocalData': True,
394
+ 'note': '只抓正文,不下图片、不调用模型;抓完还要跑 vault notes 与 article-notes',
395
+ }
396
+ print(json.dumps(payload, ensure_ascii=False, indent=2) if args.json
397
+ else '%d 天、%d 篇(其中 %d 篇要联网抓正文),并发 %d'
398
+ % (len(todo), total, need, args.workers))
399
+ conn.close()
400
+ return 0
401
+
402
+ if not todo:
403
+ print('没有要处理的(都已回填;要重做加 --refresh)')
404
+ conn.close()
405
+ return 0
406
+ if not args.yes:
407
+ print('会抓 %d 天、%d 篇的正文(不调模型)。加 --yes 确认。'
408
+ % (len(todo), sum(len(i) for _, i in todo)))
409
+ conn.close()
410
+ return 1
411
+
412
+ report = []
413
+ for index, (day, articles) in enumerate(todo, 1):
414
+ print('\n[%d/%d] %s — 库里 %d 篇' % (index, len(todo), day, len(articles)))
415
+ fetch = fetch_bodies(articles, args.workers, log=print)
416
+ result = write_day(articles, day, args.out)
417
+ print(' 写入 %d 篇,跳过 %d 篇(正文太短),主题兜底 %d 篇'
418
+ % (result['written'], result['skipped'], result['fallbackTopics']))
419
+ if args.vault_copy:
420
+ sync = copy_to_vault(day, args.out, args.vault_root)
421
+ print(' 入 Vault 的 Sources: %d 个 md' % sync['copied'])
422
+ report.append({'date': day, 'inDb': len(articles), 'fetched': fetch['ok'],
423
+ 'written': result['written'], 'skippedThin': result['skipped']})
424
+ conn.close()
425
+
426
+ total_written = sum(r['written'] for r in report)
427
+ if args.json:
428
+ print(json.dumps({'success': True, 'action': 'backfill-articles',
429
+ 'days': report, 'written': total_written},
430
+ ensure_ascii=False, indent=2))
431
+ else:
432
+ print('\n共写入 %d 篇,覆盖 %d 天' % (total_written, len(report)))
433
+ print('接着跑: vault notes --date <日期> 然后 article-notes')
434
+ return 0 if total_written else 1
435
+
436
+
437
+ if __name__ == '__main__':
438
+ sys.exit(main())