weflow-cli 1.11.1 → 1.13.0

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 (68) hide show
  1. package/CHANGELOG.md +378 -0
  2. package/OPERATIONS.md +359 -11
  3. package/README.md +4 -0
  4. package/bin/weflow-cli.ts +289 -37
  5. package/dist/bin/weflow-cli.js +300 -35
  6. package/dist/bin/weflow-cli.js.map +1 -1
  7. package/dist/src/core/dbPathService.d.ts.map +1 -1
  8. package/dist/src/core/dbPathService.js +5 -0
  9. package/dist/src/core/dbPathService.js.map +1 -1
  10. package/dist/src/core/ntCore.d.ts +12 -1
  11. package/dist/src/core/ntCore.d.ts.map +1 -1
  12. package/dist/src/core/ntCore.js +23 -3
  13. package/dist/src/core/ntCore.js.map +1 -1
  14. package/dist/src/core/sqlcipherCore.d.ts +8 -0
  15. package/dist/src/core/sqlcipherCore.d.ts.map +1 -1
  16. package/dist/src/core/sqlcipherCore.js +46 -11
  17. package/dist/src/core/sqlcipherCore.js.map +1 -1
  18. package/dist/src/services/assistantEval.d.ts.map +1 -1
  19. package/dist/src/services/assistantEval.js +3 -1
  20. package/dist/src/services/assistantEval.js.map +1 -1
  21. package/dist/src/services/assistantTools.d.ts.map +1 -1
  22. package/dist/src/services/assistantTools.js +25 -7
  23. package/dist/src/services/assistantTools.js.map +1 -1
  24. package/dist/src/services/configService.d.ts +3 -0
  25. package/dist/src/services/configService.d.ts.map +1 -1
  26. package/dist/src/services/configService.js +6 -3
  27. package/dist/src/services/configService.js.map +1 -1
  28. package/dist/src/services/diagnosticReport.d.ts +80 -0
  29. package/dist/src/services/diagnosticReport.d.ts.map +1 -0
  30. package/dist/src/services/diagnosticReport.js +191 -0
  31. package/dist/src/services/diagnosticReport.js.map +1 -0
  32. package/dist/src/services/exportService.d.ts +7 -0
  33. package/dist/src/services/exportService.d.ts.map +1 -1
  34. package/dist/src/services/exportService.js +1 -1
  35. package/dist/src/services/exportService.js.map +1 -1
  36. package/dist/src/services/wechatClient.d.ts +9 -3
  37. package/dist/src/services/wechatClient.d.ts.map +1 -1
  38. package/dist/src/services/wechatClient.js +55 -22
  39. package/dist/src/services/wechatClient.js.map +1 -1
  40. package/docs/DECISIONS.md +662 -1
  41. package/docs/MCP.md +1 -1
  42. package/docs/NEXT_ACTIONS.md +236 -0
  43. package/docs/POSITIONING.md +68 -0
  44. package/docs/PROJECT_STATE.md +119 -17
  45. package/docs/RELEASING.md +14 -0
  46. package/package.json +88 -86
  47. package/scripts/_utils.py +120 -4
  48. package/scripts/backfill_articles.py +64 -14
  49. package/scripts/biz_daily.py +118 -11
  50. package/scripts/daily_task.ps1 +132 -0
  51. package/scripts/export_chat_html.py +79 -31
  52. package/scripts/install_tasks.cmd +32 -0
  53. package/scripts/install_tasks.ps1 +91 -0
  54. package/scripts/jev_client.py +335 -3
  55. package/scripts/nt_decrypt.py +79 -14
  56. package/scripts/pipeline.py +21 -0
  57. package/scripts/recall_eval.py +247 -0
  58. package/scripts/repro_customer_init.cmd +86 -0
  59. package/scripts/semantic_search.py +926 -51
  60. package/src/core/dbPathService.ts +5 -0
  61. package/src/core/ntCore.ts +34 -3
  62. package/src/core/sqlcipherCore.ts +47 -11
  63. package/src/services/assistantEval.ts +3 -1
  64. package/src/services/assistantTools.ts +2471 -2452
  65. package/src/services/configService.ts +12 -3
  66. package/src/services/diagnosticReport.ts +237 -0
  67. package/src/services/exportService.ts +14 -2
  68. package/src/services/wechatClient.ts +418 -380
package/scripts/_utils.py CHANGED
@@ -648,6 +648,30 @@ def load_config():
648
648
  return json.load(f)
649
649
 
650
650
 
651
+ # ---- 「不说话地失败」的最小护栏 ---------------------------------------------
652
+ # 这个文件里有一批函数**故意不抛**(跨机器拷配置时,解不开的密文该表现为"没配",
653
+ # 而不是让整条命令崩)。但"不抛"不等于"不说":`except: return ''` 会让调用方
654
+ # 与用户都以为"本来就没有",而真实原因是**解密失败/配置坏了**。
655
+ # 所以这里只做一件事:同一条原因**说一次**,然后照旧返回空值。
656
+ # 只说一次是必须的——这些函数每次请求/每次重排都会被调用,刷屏等于没警告。
657
+ _WARNED_ONCE = set()
658
+
659
+
660
+ def warn_once(tag, message):
661
+ """同一条原因只往 stderr 说一次(tag 相同即视为同一条)。"""
662
+ if tag in _WARNED_ONCE:
663
+ return
664
+ _WARNED_ONCE.add(tag)
665
+ import sys as _sys
666
+ print('[WARN] ' + message, file=_sys.stderr)
667
+ _sys.stderr.flush()
668
+
669
+
670
+ def reset_warn_once():
671
+ """测试用:清空去重表。"""
672
+ _WARNED_ONCE.clear()
673
+
674
+
651
675
  # 配置里"不想在日报里看到的主题"的键名。值形如 `新闻,投资,学术`。
652
676
  EXCLUDED_TOPICS_KEY = 'dailyExcludeTopics'
653
677
 
@@ -876,11 +900,15 @@ def get_dashscope_key(config=None) -> str:
876
900
  if config is None:
877
901
  try:
878
902
  config = load_config()
879
- except Exception:
903
+ except Exception as exc:
904
+ warn_once('load_config', '读不到配置文件 %s(%s: %s)—— 本次当作空配置,需要配置的功能会退化成默认行为'
905
+ % (CONFIG_PATH, type(exc).__name__, exc))
880
906
  return ''
881
907
  try:
882
908
  return decrypt_lock(config.get('dashscopeApiKey', ''))
883
- except Exception:
909
+ except Exception as exc:
910
+ warn_once('dashscopeApiKey', '配置项 dashscopeApiKey 解不开(%s: %s)—— 当作"没配",语义检索会退成关键词'
911
+ % (type(exc).__name__, exc))
884
912
  return ''
885
913
 
886
914
 
@@ -895,14 +923,102 @@ def get_typesafe_key(config=None) -> str:
895
923
  if config is None:
896
924
  try:
897
925
  config = load_config()
898
- except Exception:
926
+ except Exception as exc:
927
+ warn_once('load_config', '读不到配置文件 %s(%s: %s)—— 本次当作空配置,需要配置的功能会退化成默认行为'
928
+ % (CONFIG_PATH, type(exc).__name__, exc))
899
929
  return ''
900
930
  try:
901
931
  return decrypt_lock(config.get('typesafeApiKey', ''))
902
- except Exception:
932
+ except Exception as exc:
933
+ warn_once('typesafeApiKey', '配置项 typesafeApiKey 解不开(%s: %s)—— 当作"没配",决策模型退回 LLM 解析'
934
+ % (type(exc).__name__, exc))
903
935
  return ''
904
936
 
905
937
 
938
+ def get_openrouter_key(config=None) -> str:
939
+ """读取 OpenRouter 的 key(决策模型的第二条通路,D-120),自动解密 lock: 前缀。
940
+
941
+ 与 `get_typesafe_key` 逐字同一条规矩:**跨机器拷配置时表现为"没配",不是崩**。
942
+ 解密失败回空串之后,`create_client()` 会返回 None,调用方按老路退回 ——
943
+ 这是"这条路没配好"的**安全**失效方向。
944
+ """
945
+ if config is None:
946
+ try:
947
+ config = load_config()
948
+ except Exception as exc:
949
+ warn_once('load_config', '读不到配置文件 %s(%s: %s)—— 本次当作空配置,需要配置的功能会退化成默认行为'
950
+ % (CONFIG_PATH, type(exc).__name__, exc))
951
+ return ''
952
+ try:
953
+ return decrypt_lock(config.get('openrouterApiKey', ''))
954
+ except Exception as exc:
955
+ warn_once('openrouterApiKey', '配置项 openrouterApiKey 解不开(%s: %s)—— 当作"没配",OpenRouter 那条路会停用'
956
+ % (type(exc).__name__, exc))
957
+ return ''
958
+
959
+
960
+ def get_judge_provider(config=None) -> str:
961
+ """决策模型走哪条通路:`native`(默认)、`chat`(国内对话模型)或 `openrouter`。
962
+
963
+ **默认必须是 native**:这个开关存在的意义是"本机原生被地区封锁时换条路",
964
+ 而不是替所有用户改行为。取值不认识时也回 native —— 打错的配置项不该把判断整层关掉。
965
+ """
966
+ if config is None:
967
+ try:
968
+ config = load_config()
969
+ except Exception:
970
+ return 'native'
971
+ value = str((config or {}).get('judgeProvider') or '').strip().lower()
972
+ return value if value in ('native', 'chat', 'openrouter') else 'native'
973
+
974
+
975
+ def get_chat_endpoint(config=None):
976
+ """`judgeProvider=chat` 时该往哪个 (base_url, model) 发 —— 与"中转站"同一条口径。
977
+
978
+ 故意只做一件事:**委托 `_custom_endpoint()`**。那个函数是 `aiBaseUrl`/`aiModel` 的权威读法
979
+ (base 与 model 成对出现),判断器另写一份就会在"只配了 base 没配 model"这类组合上分叉,
980
+ 而分叉的表现是"发去了错的端点"——不报错、只是判断质量变了。没配 `aiBaseUrl` 时返回 (None, None)。
981
+ """
982
+ try:
983
+ return _custom_endpoint()
984
+ except Exception:
985
+ return None, None
986
+
987
+
988
+ def get_ai_engine(config=None) -> str:
989
+ """配置里选的对话引擎:`local` / `deepseek`(默认)/ `claude` / `ollama`。
990
+
991
+ **决策模型的 `chat` 通路必须读它**:`judgeProvider=chat` 的语义是"用**配置里的**对话模型",
992
+ 而"配置里的"到底指谁只有 `aiEngine` 说得清。2026-10-18 之前那条路无条件用 `deepseekApiKey`,
993
+ 于是 `aiEngine=local` 的用户一开 `judgeProvider=chat` 就会把文章正文发去 DeepSeek ——
994
+ 这不是"多花点钱",是**用户明确选了不出境之后又被送出境**。
995
+ """
996
+ if config is None:
997
+ try:
998
+ config = load_config()
999
+ except Exception as exc:
1000
+ warn_once('load_config', '读不到配置文件 %s(%s: %s)—— 本次当作空配置,需要配置的功能会退化成默认行为'
1001
+ % (CONFIG_PATH, type(exc).__name__, exc))
1002
+ return 'deepseek'
1003
+ return str((config or {}).get('aiEngine') or 'deepseek').strip().lower()
1004
+
1005
+
1006
+ def get_openrouter_model(config=None) -> str:
1007
+ """OpenRouter 上用的模型 id(默认 `typesafe/jev-router`,即 TypeSafe 发布的决策模型)。
1008
+
1009
+ ⚠️ **地区限制是逐模型的**(实测 2026-10-17):`typesafe/jev-router` 与 `:free` 模型能通,
1010
+ 付费通用模型(`openai/gpt-4o-mini`)在本地区返回 403。换模型必须重测,见 D-120。
1011
+ """
1012
+ if config is None:
1013
+ try:
1014
+ config = load_config()
1015
+ except Exception as exc:
1016
+ warn_once('load_config', '读不到配置文件 %s(%s: %s)—— 本次当作空配置,需要配置的功能会退化成默认行为'
1017
+ % (CONFIG_PATH, type(exc).__name__, exc))
1018
+ return ''
1019
+ return str((config or {}).get('openrouterJudgeModel') or '').strip()
1020
+
1021
+
906
1022
  # ======================================================================
907
1023
  # Markdown / Frontmatter
908
1024
  # ======================================================================
@@ -54,7 +54,8 @@ from _utils import load_config # noqa: E402
54
54
  from biz_daily import (DEFAULT_RELEVANCE, DEFAULT_TOPIC, TOPICS, # noqa: E402
55
55
  _group_by_topic, _guess_topic, _serializable_article,
56
56
  _tags_for_write, extract_article_info, fetch_article,
57
- get_db_keys, sanitize_filename, summary_section)
57
+ fetch_article_cached, get_db_keys, sanitize_filename,
58
+ summary_section)
58
59
  from _utils import strip_wx_ads_document, write_with_frontmatter # noqa: E402
59
60
 
60
61
  TZ = timezone(timedelta(hours=8))
@@ -159,20 +160,32 @@ def select_topics(articles, topics):
159
160
  return [a for a in articles if a.get('topic') in topics]
160
161
 
161
162
 
162
- def fetch_bodies(articles, workers: int, log=None):
163
+ def fetch_bodies(articles, workers: int, log=None, use_cache: bool = True):
163
164
  """并发把这批文章的正文抓回来,写进 `fetched_md`。
164
165
 
165
166
  **只抓正文,不碰图片**——`fetch_article` 返回的 markdown 里图片是链接,本脚本
166
167
  就此打住,不再像 `biz_daily` 那样逐张下到本地(那一步占了实测 30 秒/篇里的绝大部分)。
168
+
169
+ **走 `fetch_article_cached`(日报那条同一个缓存)**,理由是把"补历史"和"正日报"
170
+ 接起来:补完的正文落在 `output/.cache/fetch/<md5(url)>.md`,随后逐天
171
+ `daily --date X` 全是缓存命中——而日报的 8–12 秒节流在它的**调用循环**里
172
+ (`biz_daily` 抓取那一段),命中缓存既不联网也不睡,所以正式路径能快速产出
173
+ 与平常完全一样的完整日报(含摘要与配图)。反过来也一样:日报跑过的天,这里重抓
174
+ 时直接命中,不会再去打扰上游。
175
+
176
+ `use_cache=False`(`--no-fetch-cache`)**只跳过"读"**:仍然照常联网,成功结果照常
177
+ 写回缓存。这样"强制重抓"既是"这次不看旧缓存",也是"把旧缓存刷新成新的"。
178
+ **失败不写缓存**(由 `fetch_article_cached` 保证),所以重抓失败不会把已有的有效
179
+ 缓存清掉或弄脏。
167
180
  """
168
181
  todo = [a for a in articles
169
182
  if len(body_without_media(a.get('local_text'))) < MIN_BODY_CHARS and a.get('url')]
170
183
  if not todo:
171
- return {'attempted': 0, 'ok': 0}
184
+ return {'attempted': 0, 'ok': 0, 'failed': 0}
172
185
 
173
186
  def one(article):
174
187
  try:
175
- body = fetch_article(article['url'])
188
+ body, _hit = fetch_article_cached(article['url'], use_cache=use_cache)
176
189
  except Exception:
177
190
  return False
178
191
  if body:
@@ -184,9 +197,11 @@ def fetch_bodies(articles, workers: int, log=None):
184
197
  with ThreadPoolExecutor(max_workers=max(workers, 1)) as pool:
185
198
  for result in pool.map(one, todo):
186
199
  ok += 1 if result else 0
200
+ failed = len(todo) - ok
187
201
  if log:
188
- log(' 抓正文: 尝试 %d,成功 %d,并发 %d' % (len(todo), ok, workers))
189
- return {'attempted': len(todo), 'ok': ok}
202
+ log(' 抓正文: 尝试 %d,成功 %d%s,并发 %d'
203
+ % (len(todo), ok, (',失败 %d' % failed) if failed else '', workers))
204
+ return {'attempted': len(todo), 'ok': ok, 'failed': failed}
190
205
 
191
206
 
192
207
  def _read_index(day_dir) -> dict:
@@ -226,7 +241,7 @@ def _merge_articles(kept, fresh):
226
241
  return sorted(merged.values(), key=lambda e: (str(e.get('time', '')), str(e.get('title', ''))))
227
242
 
228
243
 
229
- def write_day(articles, day, out_root, topic_filter=None, truncated=False):
244
+ def write_day(articles, day, out_root, topic_filter=None, truncated=False, fetch_failed=0):
230
245
  """落盘:`<日期>/<主题>/<来源>-<标题>.md` + `.articles.json` + README。
231
246
 
232
247
  格式与 `biz_daily` 一致——`create_reading_notes` 与 `compile_wiki` 都按那个形状读。
@@ -324,6 +339,17 @@ def write_day(articles, day, out_root, topic_filter=None, truncated=False):
324
339
  if truncated or prior.get('truncated'):
325
340
  # `truncated` 一旦记上就不撤销:这一次没截,不代表上一次截掉的那些补回来了。
326
341
  payload['truncated'] = True
342
+ # **抓取失败要与"正文本来就短"分开记**,否则一句"跳过 N 篇(正文太短)"把两件事混成
343
+ # 一件:前者这一轮可重试、后者是文章自己的属性。这个标记让 `day_done` 把这一天判成
344
+ # **没做完**,于是下次重跑会再试一遍——正文命中缓存的那批不花钱,只重试失败的。
345
+ #
346
+ # 它**不是 sticky 的**(与 `truncated` 不同):全量跑完一轮、一篇都没失败,就说明
347
+ # 上一次失败的那些也补齐了,标记该消失。但**局部**跑(`--topic`)只覆盖一部分文章,
348
+ # 它没重试到的那些(别的主题)还在,所以那一档只保证"至少还有这么多",取两轮的较大者。
349
+ carried_failed = int(prior.get('fetchFailed') or 0)
350
+ failed_total = fetch_failed if topic_filter is None else max(fetch_failed, carried_failed)
351
+ if failed_total:
352
+ payload['fetchFailed'] = failed_total
327
353
  (out_dir / '.articles.json').write_text(
328
354
  json.dumps(payload, ensure_ascii=False, indent=2), encoding='utf-8')
329
355
  if scope_filter:
@@ -333,11 +359,19 @@ def write_day(articles, day, out_root, topic_filter=None, truncated=False):
333
359
  scope = ''
334
360
  # README 说的是**这一天**有几篇(合并后的),不是这一轮写了几篇 —— 分两次回填的那天,
335
361
  # 前者才是读它的人想要的数。
362
+ failed_note = ''
363
+ if failed_total:
364
+ # 让人**看得见**这天没做完:README 是这个目录里唯一给人看的文件,
365
+ # 只写"共 N 篇"会让"少了 N 篇"读起来像"这天就这些文章"。
366
+ failed_note = ('\n其中 **%d 篇正文没抓到**(网络/来源问题),这一天不算完成;'
367
+ '重跑会只重试这些篇(其余走缓存)。\n' % failed_total)
336
368
  (out_dir / 'README.md').write_text(
337
369
  '# 公众号文章 — %s(历史回填)\n\n共 %d 篇。正文由 `backfill_articles.py` 抓取,'
338
- '**未下载图片、未调用模型**。\n%s' % (day, len(payload['articles']), scope), encoding='utf-8')
370
+ '**未下载图片、未调用模型**。\n%s%s' % (day, len(payload['articles']), scope, failed_note),
371
+ encoding='utf-8')
339
372
  return {'written': len(written), 'skipped': len(skipped),
340
- 'fallbackTopics': fallbacks, 'skipSample': skipped[:3]}
373
+ 'fallbackTopics': fallbacks, 'skipSample': skipped[:3],
374
+ 'fetchFailed': failed_total}
341
375
 
342
376
 
343
377
  def copy_to_vault(day: str, out_root: str, vault_root: str) -> dict:
@@ -429,6 +463,11 @@ def day_done(out_root, day, topics=None) -> bool:
429
463
  return False
430
464
  if payload.get('truncated'):
431
465
  return False
466
+ if payload.get('fetchFailed'):
467
+ # 上一次这天有文章**抓取失败**(不是"正文本来就短")。不算做完 ⇒ 下次重跑会再试。
468
+ # 不计这一条的话,一次网络抖动就会被"这天已回填"永久埋掉:文件在、索引在、md 也有,
469
+ # 少的那几篇谁都看不出来。仓库里量过同类事故:84 次失败里 28 次重试就能拿到。
470
+ return False
432
471
  stored = payload.get('topicFilter')
433
472
  if stored is None:
434
473
  return True # 完整的一天,所有主题都在
@@ -460,7 +499,13 @@ def main():
460
499
  help='只回填这些主题(可重复或逗号分隔,合法值 %s),不写=全部。'
461
500
  '只回填过一部分主题的一天,之后跑全量时不会被跳过。'
462
501
  % '/'.join(TOPICS))
463
- parser.add_argument('--refresh', action='store_true', help='已回填过的天也重做(默认跳过)')
502
+ parser.add_argument('--no-fetch-cache', action='store_true',
503
+ help='忽略正文抓取缓存,强制重新抓取(默认命中缓存,用于续跑与重跑)。'
504
+ '与日报的同名开关行为一致;**与 --refresh 不是一回事**,'
505
+ '后者重做"哪几天",它重抓"哪几篇的正文"')
506
+ parser.add_argument('--refresh', action='store_true',
507
+ help='已回填过的天也重做(默认跳过)。它决定**处理哪些天**,'
508
+ '不改变正文的抓取方式——要绕过缓存重抓正文用 --no-fetch-cache')
464
509
  parser.add_argument('--max-days', type=int, default=0, help='最多处理几天(0=不限,用于试跑)')
465
510
  parser.add_argument('--dry-run', action='store_true', help='只报到要抓多少,不抓不写')
466
511
  parser.add_argument('--yes', action='store_true', help='确认抓取并写入文件')
@@ -592,16 +637,21 @@ def main():
592
637
  report = []
593
638
  for index, (day, articles, truncated) in enumerate(todo, 1):
594
639
  print('\n[%d/%d] %s — 选中 %d 篇' % (index, len(todo), day, len(articles)))
595
- fetch = fetch_bodies(articles, args.workers, log=print)
596
- result = write_day(articles, day, args.out, topic_filter=topics, truncated=truncated)
640
+ fetch = fetch_bodies(articles, args.workers, log=print,
641
+ use_cache=not args.no_fetch_cache)
642
+ result = write_day(articles, day, args.out, topic_filter=topics, truncated=truncated,
643
+ fetch_failed=fetch['failed'])
597
644
  print(' 写入 %d 篇,跳过 %d 篇(正文太短),主题兜底 %d 篇'
598
645
  % (result['written'], result['skipped'], result['fallbackTopics']))
646
+ if fetch['failed']:
647
+ print(' ⚠ 有 %d 篇正文没抓到:这天不算完成,重跑会只重试这些篇'
648
+ % fetch['failed'])
599
649
  if args.vault_copy:
600
650
  sync = copy_to_vault(day, args.out, args.vault_root)
601
651
  print(' 入 Vault 的 Sources: %d 个 md' % sync['copied'])
602
652
  report.append({'date': day, 'inDb': len(articles), 'fetched': fetch['ok'],
603
- 'written': result['written'], 'skippedThin': result['skipped'],
604
- 'truncated': truncated})
653
+ 'fetchFailed': fetch['failed'], 'written': result['written'],
654
+ 'skippedThin': result['skipped'], 'truncated': truncated})
605
655
  conn.close()
606
656
 
607
657
  total_written = sum(r['written'] for r in report)
@@ -64,6 +64,7 @@ FETCH_TIMEOUT = 15
64
64
  DEEPSEEK_TIMEOUT = 60
65
65
  FETCH_DELAY_MIN = 8 # 最小抓取间隔 (秒)
66
66
  FETCH_DELAY_MAX = 12 # 最大抓取间隔 (秒)
67
+ PREFETCH_WORKERS = 8 # `--prefetch-bodies` 不带数字时的并发数(预热正文缓存,见 prefetch_bodies)
67
68
 
68
69
  # TOPICS 现在从 _utils 导入(见文件头的 import)——分类法只有一份定义,
69
70
  # 因为"散文写的枚举"已经漂过一次:这里曾硬编码五类,而 TOPICS 是六类。
@@ -141,6 +142,58 @@ def _tags_for_write(article, topic):
141
142
  return article.get('tags', [topic])
142
143
 
143
144
 
145
+ def _article_key(entry):
146
+ """同一篇文章的合并键:url 优先(微信文章 url 唯一),没有 url 时退回标题+时间。"""
147
+ url = str(entry.get('url') or '').strip()
148
+ if url:
149
+ return url
150
+ title = str(entry.get('title') or '').strip()
151
+ if not title:
152
+ return ''
153
+ return 'no-url:%s|%s' % (title, entry.get('time') or '')
154
+
155
+
156
+ def merge_articles_json(json_path, date_str, serializable, decision_model=None):
157
+ """把这一批**并进** `.articles.json`,返回落盘的全部条目(按时间排序)。
158
+
159
+ 为什么必须合并:同一天可能跑两次(午报 13 点 + 日终 24 点)。第二次进来时
160
+ `.run_state.json` 已经把上午处理过的文章跳过了,所以这批只有下午新到达的那些;
161
+ 若覆盖写,上午那批的标题/摘要/主题/relevance 就从这个文件里消失。而 README 与
162
+ `index.html` 是**从磁盘上的 md 重建**的 —— 于是"页面是全天、结构化数据只有半天",
163
+ 下游(AI 报告、daily-stats、知识库统计)静默少一半,没有任何地方会报错。
164
+ `.image_map.json` 早就是合并写的(`existing_map.update(...)`),这里只是把它缺的那半补上。
165
+ """
166
+ merged = {}
167
+ previous_model = None
168
+ if json_path.exists():
169
+ try:
170
+ old = json.loads(json_path.read_text(encoding='utf-8'))
171
+ previous_model = old.get('decisionModel')
172
+ for entry in old.get('articles') or []:
173
+ key = _article_key(entry)
174
+ if key:
175
+ merged[key] = entry
176
+ except Exception:
177
+ # 旧文件坏了不该让今天失败:从空开始(与 `.image_map.json` 同一态度)
178
+ print(' [WARN] 旧的 .articles.json 读不了,这次从空开始(旧条目不参与合并)')
179
+ for entry in serializable:
180
+ key = _article_key(entry)
181
+ if key:
182
+ merged[key] = entry
183
+ articles = sorted(merged.values(), key=lambda e: (e.get('time') or '', e.get('title') or ''))
184
+ payload = {
185
+ 'generated_at': datetime.now(timezone(timedelta(hours=8))).strftime('%Y-%m-%d %H:%M:%S'),
186
+ 'date': date_str,
187
+ # 本次没判断(`--no-ai`)时不要用 None 盖掉上一次记下的模型名:那会让
188
+ # "这天是谁判的"在重跑后凭空消失。
189
+ 'decisionModel': decision_model if decision_model is not None else previous_model,
190
+ 'articles': articles,
191
+ }
192
+ json_path.parent.mkdir(parents=True, exist_ok=True)
193
+ json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding='utf-8')
194
+ return articles
195
+
196
+
144
197
  def _serializable_article(article, date_str):
145
198
  """一篇文章 -> `.articles.json` 里的那条记录。
146
199
 
@@ -666,6 +719,43 @@ def fetch_article_cached(url: str, use_cache: bool = True) -> tuple[str | None,
666
719
  return body, False
667
720
 
668
721
 
722
+ def prefetch_bodies(articles, workers=PREFETCH_WORKERS, log=None):
723
+ """**并发**把这一批文章的正文抓进缓存(`output/.cache/fetch/`),只抓不写盘。
724
+
725
+ 为什么值得有:Phase 1 是**串行**的,而且未命中缓存时每篇要 `sleep` 8~12 秒(节流是为了
726
+ 少打扰上游)——450 篇就是约 75 分钟。而**命中缓存既不联网也不 sleep**(见
727
+ `fetch_article_cached`),所以先把正文并发抓下来,Phase 1 就变成秒级。
728
+ 对上游的**请求总次数完全相同**,区别只是把同样多的请求压进更短的时间窗 ——
729
+ 这是一个显式取舍,所以默认**关**,由 `--prefetch-bodies` 打开。
730
+
731
+ 失败**不抛也不报错退出**:Phase 1 还会再试一次(那里才是"这篇到底有没有正文"的判据)。
732
+ 返回 `{'requested', 'ok'}`。
733
+ """
734
+ urls = []
735
+ for a in articles:
736
+ url = a.get('url')
737
+ if url and url not in urls:
738
+ urls.append(url)
739
+ if not urls:
740
+ return {'requested': 0, 'ok': 0}
741
+
742
+ def _one(url):
743
+ try:
744
+ body, _cached = fetch_article_cached(url)
745
+ return bool(body and body.strip())
746
+ except Exception: # noqa: BLE001 单篇炸了不能拖掉整批
747
+ return False
748
+
749
+ done = ok = 0
750
+ with ThreadPoolExecutor(max_workers=max(1, int(workers))) as pool:
751
+ for hit in pool.map(_one, urls):
752
+ done += 1
753
+ ok += 1 if hit else 0
754
+ if log and (done % 50 == 0 or done == len(urls)):
755
+ log(' 预热 %d/%d(成功 %d)' % (done, len(urls), ok))
756
+ return {'requested': len(urls), 'ok': ok}
757
+
758
+
669
759
  def _download_one_image(url: str, local_path: Path) -> bool:
670
760
  """取一张图。成功返回 True;失败**不抛**——一张图挂了不该影响这一篇的其它图。"""
671
761
  try:
@@ -846,6 +936,12 @@ def main():
846
936
  parser.add_argument('--no-ai', action='store_true', help='关闭摘要、分类和日报简报的 AI 调用')
847
937
  parser.add_argument('--no-fetch-cache', action='store_true',
848
938
  help='忽略正文抓取缓存,强制重新抓取(默认命中缓存,用于续跑与重跑)')
939
+ parser.add_argument('--prefetch-bodies', nargs='?', type=int, const=PREFETCH_WORKERS, default=0,
940
+ metavar='N',
941
+ help='Phase 1 之前先**并发**把这一天的正文抓进缓存(N=并发数,默认 %d)——'
942
+ 'Phase 1 于是全部命中缓存、不再逐篇 sleep(450 篇约 75 分钟 → 秒级)。'
943
+ '对上游的请求次数不变,只是压进更短的时间窗,所以默认关(见 prefetch_bodies)'
944
+ % PREFETCH_WORKERS)
849
945
  parser.add_argument('--no-summary', action='store_true',
850
946
  help='只要判断、不要生成:完全跳过 LLM 调用(摘要/标签/概念/简报),'
851
947
  '主题与相关度仍由 Jev 判断;因此**不需要 DeepSeek key**')
@@ -1003,6 +1099,17 @@ def main():
1003
1099
  # Output folder already created above (with dedup state)
1004
1100
  print(f'输出目录: {out_dir}\n')
1005
1101
 
1102
+ # ====== Phase 0: 并发预热正文缓存(可选,`--prefetch-bodies`)======
1103
+ if args.prefetch_bodies and not args.no_fetch_cache:
1104
+ print(f'=== Phase 0: 并发预热正文缓存({args.prefetch_bodies} 并发)===\n')
1105
+ prefetch = prefetch_bodies(articles, args.prefetch_bodies, log=print)
1106
+ print(f'预热完成:{prefetch["ok"]}/{prefetch["requested"]} 篇进了缓存'
1107
+ f'(没进的 Phase 1 会再试一次)\n')
1108
+ elif args.prefetch_bodies:
1109
+ # 说清楚为什么没做:`--no-fetch-cache` 让 Phase 1 绕过缓存,预热等于白抓一遍。
1110
+ print('[WARN] 同时给了 --no-fetch-cache 与 --prefetch-bodies:'
1111
+ '前者让 Phase 1 绕过缓存,预热没有意义,已跳过\n')
1112
+
1006
1113
  # ====== Phase 1: Fetch all articles ======
1007
1114
  print(f'=== Phase 1: 抓取 {len(articles)} 篇文章 ===\n')
1008
1115
  cache_hits = 0
@@ -1218,19 +1325,14 @@ def main():
1218
1325
  f'已归入兜底类「{DEFAULT_TOPIC}」(不是判断结果)')
1219
1326
 
1220
1327
  # --- 写入结构化 JSON:一次提取,多次复用(供 AI 报告等下游使用) ---
1328
+ # **合并写**:同一天跑两次(午报 + 日终)时,这一批只有新到达的文章,
1329
+ # 覆盖写会把上一批从文件里抹掉(详解见 `merge_articles_json` 的 docstring)。
1221
1330
  serializable = [_serializable_article(a, date_str) for a in articles]
1222
1331
 
1223
1332
  json_path = out_dir / '.articles.json'
1224
1333
  try:
1225
- with open(json_path, 'w', encoding='utf-8') as f:
1226
- json.dump({
1227
- 'generated_at': datetime.now(timezone(timedelta(hours=8))).strftime('%Y-%m-%d %H:%M:%S'),
1228
- 'date': date_str,
1229
- # 只增字段:老读者不认识它会忽略;但"这次是谁判的"从此有据可查。
1230
- 'decisionModel': decision_model,
1231
- 'articles': serializable,
1232
- }, f, ensure_ascii=False, indent=2)
1233
- print(f' ✓ 结构化数据: {json_path} ({len(serializable)} 篇)')
1334
+ merged_articles = merge_articles_json(json_path, date_str, serializable, decision_model)
1335
+ print(f' ✓ 结构化数据: {json_path} ({len(merged_articles)} 篇,本次新增 {len(serializable)} 篇)')
1234
1336
  except Exception as e:
1235
1337
  print(f' [WARN] JSON 写入失败: {e}')
1236
1338
 
@@ -1303,8 +1405,13 @@ def main():
1303
1405
  if map_file.exists():
1304
1406
  try:
1305
1407
  existing_map = json.loads(map_file.read_text(encoding='utf-8'))
1306
- except:
1307
- pass
1408
+ except Exception as exc:
1409
+ # 读不出来的旧映射表会让**之前几天的图片**从 HTML 里消失,
1410
+ # 而这一篇看起来还是成功的。宁可丢这一批映射,也要说清是什么原因。
1411
+ print(' [WARN] 旧的 .image_map.json 读不了(%s: %s)—— 这次从空表开始,'
1412
+ '旧的图片映射会丢(HTML 里那些图可能显示不出来)'
1413
+ % (type(exc).__name__, exc), file=sys.stderr)
1414
+ existing_map = {}
1308
1415
  existing_map.update(url_mapping)
1309
1416
  map_file.write_text(json.dumps(existing_map, ensure_ascii=False, indent=2), encoding='utf-8')
1310
1417
 
@@ -0,0 +1,132 @@
1
+ # Runs a daily report pass, with everything written to logs/daily-<date>.log.
2
+ #
3
+ # Two modes, because a WeChat official-account day only fills up gradually:
4
+ #
5
+ # daily_task.ps1 noon -> today so far (run at 13:00; half a day of push messages)
6
+ # daily_task.ps1 -> yesterday, complete (run at 00:05; same as before)
7
+ #
8
+ # The second pass is incremental by design: `.run_state.json` makes biz_daily skip the
9
+ # articles the noon pass already handled, so 00:05 only fetches/summarises what arrived
10
+ # in the afternoon, while README/index.html are rebuilt from every md on disk.
11
+ #
12
+ # Why this wrapper exists instead of putting the command straight into the scheduled task:
13
+ #
14
+ # 1. The task must not depend on a build artifact. It calls the python layer
15
+ # (scripts/pipeline.py) directly, which is the same source the repo builds from.
16
+ # 2. A task whose output goes nowhere answers "it failed" and nothing else. One log
17
+ # file per run, so "why was yesterday short?" has an answer.
18
+ # 3. Redirecting stdout to a FILE changes python's stdout encoding (it stops being the
19
+ # console and becomes the locale codepage). A title containing a character outside
20
+ # that codepage would then raise UnicodeEncodeError *after* the day was already
21
+ # written, i.e. the run looks like a failure while the artifacts are fine. Hence
22
+ # PYTHONIOENCODING/PYTHONUTF8 below.
23
+ #
24
+ # Extra arguments are appended, so `daily_task.ps1 --help` is a safe smoke test (it
25
+ # prints the pipeline's help into that day's log and exits without touching anything).
26
+ $ErrorActionPreference = 'Stop'
27
+
28
+ $root = Split-Path -Parent $PSScriptRoot
29
+ $logDir = Join-Path $root 'logs'
30
+ if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir | Out-Null }
31
+
32
+ # Mode is the first positional word; anything else is forwarded to pipeline.py.
33
+ $mode = 'day'
34
+ $rest = @($args)
35
+ if ($args.Count -gt 0 -and ($args[0] -eq 'noon' -or $args[0] -eq 'day')) {
36
+ $mode = $args[0]
37
+ $rest = if ($args.Count -gt 1) { @($args[1..($args.Count - 1)]) } else { @() }
38
+ }
39
+
40
+ $today = (Get-Date).ToString('yyyy-MM-dd')
41
+ $yesterday = (Get-Date).AddDays(-1).ToString('yyyy-MM-dd')
42
+ $targetDay = if ($mode -eq 'noon') { $today } else { $yesterday }
43
+
44
+ $stamp = Get-Date -Format 'yyyy-MM-dd'
45
+ $suffix = if ($mode -eq 'noon') { '-noon' } else { '' }
46
+ $out = Join-Path $logDir ('daily-{0}{1}.log' -f $stamp, $suffix)
47
+ $err = Join-Path $logDir ('daily-{0}{1}.err.log' -f $stamp, $suffix)
48
+
49
+ $env:PYTHONIOENCODING = 'utf-8'
50
+ $env:PYTHONUTF8 = '1'
51
+
52
+ $python = (Get-Command python.exe).Source
53
+ $script = Join-Path $PSScriptRoot 'pipeline.py'
54
+ $modeArgs = if ($mode -eq 'noon') { @('--date', $today) } else { @('--yesterday') }
55
+ $allArgs = @($script) + $modeArgs + @('--prefetch-bodies') + $rest
56
+
57
+ # Idempotent "make sure the target day is as complete as it can be right now", not
58
+ # "run at 08:20".
59
+ #
60
+ # Why: this machine slept from 2026-10-17 02:09 to 2026-10-18 15:26 and BOTH 08:20
61
+ # triggers were counted as missed while StartWhenAvailable=On did not start the task
62
+ # on wake (NumberOfMissedRuns=2, no run). A schedule that only fires if the machine
63
+ # happens to be awake at one instant is not a schedule. With the guard below the task
64
+ # can be triggered cheaply and often (schedule + at logon): if the target is already
65
+ # done this exits in milliseconds, otherwise it produces/extends the day.
66
+ #
67
+ # The day-mode guard deliberately does NOT trust index.html alone: the noon pass also
68
+ # writes one, so "index.html exists" would make the 00:05 pass skip the afternoon and
69
+ # leave the day half-done behind a complete-looking page. `.articles.json`'s
70
+ # generated_at tells us when the last pass actually ran; anything during that day
71
+ # (before 23:00) is a mid-day artifact and must be extended.
72
+ #
73
+ # Note the day pass runs at 00:05 **the next day**, so a finished day is one whose
74
+ # generated_at is later than the day itself - not one stamped at 23:xx. Testing
75
+ # `hour -ge 23` alone would have made every 00:05 run redo the whole day (observed
76
+ # while verifying this guard: generated_at 00:20 was judged incomplete).
77
+ function Test-DayComplete([string]$dir, [string]$targetDay) {
78
+ $idx = Join-Path $dir 'index.html'
79
+ if (-not (Test-Path $idx)) { return $false }
80
+ $art = Join-Path $dir '.articles.json'
81
+ if (-not (Test-Path $art)) { return $true } # old artifact without json: trust it
82
+ try {
83
+ $j = Get-Content $art -Raw -Encoding UTF8 | ConvertFrom-Json
84
+ $gen = [datetime]::ParseExact([string]$j.generated_at, 'yyyy-MM-dd HH:mm:ss', $null)
85
+ $genDay = $gen.ToString('yyyy-MM-dd')
86
+ # finished = generated on a later day (the usual 00:05 pass), or late the same night
87
+ return (($genDay -gt $targetDay) -or (($genDay -eq $targetDay) -and ($gen.Hour -ge 23)))
88
+ } catch { return $true }
89
+ }
90
+
91
+ # Skipped when arguments are passed, so `daily_task.ps1 --help` still works as a
92
+ # smoke test with no side effects.
93
+ if ($rest.Count -eq 0) {
94
+ $dayDir = Join-Path $root ('output\biz-daily\' + $targetDay)
95
+ $done = if ($mode -eq 'noon') { Test-Path (Join-Path $dayDir '.noon_done') } else { Test-DayComplete $dayDir $targetDay }
96
+ if ($done) {
97
+ $note = '===== {0} [{1}] {2} already complete - skip =====' -f `
98
+ (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $mode, $targetDay
99
+ Add-Content -Path (Join-Path $logDir 'daily-task.log') -Value $note -Encoding UTF8
100
+ exit 0
101
+ }
102
+ }
103
+
104
+ # Start-Process OVERWRITES its redirect targets, so a same-day rerun would erase the log
105
+ # of the run that just failed - exactly the one worth keeping. Rotate any existing pair to
106
+ # daily-<date>.run<N>[.err].log first, so every attempt stays on disk. N is the first free
107
+ # index, which also works when several reruns happen in one day.
108
+ if (Test-Path $out) {
109
+ $n = 1
110
+ while (Test-Path (Join-Path $logDir ('daily-{0}{1}.run{2}.log' -f $stamp, $suffix, $n))) { $n++ }
111
+ Move-Item -Path $out -Destination (Join-Path $logDir ('daily-{0}{1}.run{2}.log' -f $stamp, $suffix, $n))
112
+ if (Test-Path $err) {
113
+ Move-Item -Path $err -Destination (Join-Path $logDir ('daily-{0}{1}.run{2}.err.log' -f $stamp, $suffix, $n))
114
+ }
115
+ }
116
+
117
+ # Start-Process writes the raw bytes through: no PowerShell error-record wrapping, and
118
+ # no shell quoting to get wrong.
119
+ $proc = Start-Process -FilePath $python -ArgumentList $allArgs -NoNewWindow -Wait -PassThru `
120
+ -RedirectStandardOutput $out -RedirectStandardError $err
121
+
122
+ if ($proc.ExitCode -eq 0 -and $rest.Count -eq 0) {
123
+ # Marker for the noon pass only; the day pass is judged by generated_at above.
124
+ if ($mode -eq 'noon') {
125
+ $dayDir = Join-Path $root ('output\biz-daily\' + $targetDay)
126
+ if (Test-Path $dayDir) {
127
+ Set-Content -Path (Join-Path $dayDir '.noon_done') -Value (Get-Date -Format 'yyyy-MM-dd HH:mm:ss') -Encoding UTF8
128
+ }
129
+ }
130
+ }
131
+
132
+ exit $proc.ExitCode