weflow-cli 1.17.0 → 1.19.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 (73) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +3 -0
  3. package/bin/weflow-cli.ts +167 -0
  4. package/dist/bin/weflow-cli.js +199 -0
  5. package/dist/bin/weflow-cli.js.map +1 -1
  6. package/dist/src/services/diagnosticReport.d.ts +6 -0
  7. package/dist/src/services/diagnosticReport.d.ts.map +1 -1
  8. package/dist/src/services/diagnosticReport.js +16 -0
  9. package/dist/src/services/diagnosticReport.js.map +1 -1
  10. package/dist/src/services/wechatCompat.d.ts +29 -0
  11. package/dist/src/services/wechatCompat.d.ts.map +1 -0
  12. package/dist/src/services/wechatCompat.js +65 -0
  13. package/dist/src/services/wechatCompat.js.map +1 -0
  14. package/docs/DATA_CONTRACT.md +25 -25
  15. package/docs/DECISIONS.md +26 -0
  16. package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +365 -365
  17. package/docs/HEALTH-CHECK.md +104 -104
  18. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -310
  19. package/docs/NEXT_ACTIONS.md +236 -236
  20. package/docs/NPM-PUBLISH.md +130 -130
  21. package/docs/NT_DATABASES.md +47 -0
  22. package/docs/POSITIONING.md +68 -68
  23. package/docs/PROJECT_STATE.md +3 -2
  24. package/docs/RELEASING.md +4 -0
  25. package/package.json +1 -1
  26. package/requirements-3x.txt +4 -4
  27. package/requirements-voice.txt +15 -15
  28. package/resources/js/graph3d/NOTICE.txt +35 -35
  29. package/resources/js/graph3d/d3-binarytree.min.js +2 -2
  30. package/resources/js/graph3d/d3-dispatch.min.js +2 -2
  31. package/resources/js/graph3d/d3-force-3d.min.js +2 -2
  32. package/resources/js/graph3d/d3-octree.min.js +2 -2
  33. package/resources/js/graph3d/d3-quadtree.min.js +2 -2
  34. package/resources/js/graph3d/d3-timer.min.js +2 -2
  35. package/resources/js/graph3d/three.min.js +6 -6
  36. package/resources/panel/quick-menu.cjs +197 -197
  37. package/scripts/_utils.py +1338 -1338
  38. package/scripts/annual_report.py +3 -1
  39. package/scripts/article_notes.py +423 -423
  40. package/scripts/contact_schema.py +339 -339
  41. package/scripts/create_reading_notes.py +11 -11
  42. package/scripts/daily_mail.py +51 -5
  43. package/scripts/daily_task.ps1 +216 -213
  44. package/scripts/extract_todos.py +3 -1
  45. package/scripts/fav_notes.py +321 -321
  46. package/scripts/health_check.py +473 -473
  47. package/scripts/imported_chats.py +68 -68
  48. package/scripts/install_tasks.ps1 +105 -105
  49. package/scripts/key_cache.py +232 -0
  50. package/scripts/leads.py +179 -179
  51. package/scripts/mcp_bridge.py +3 -1
  52. package/scripts/media_index.py +150 -0
  53. package/scripts/network.py +206 -206
  54. package/scripts/nt_common.py +32 -0
  55. package/scripts/nt_decrypt.py +2023 -1954
  56. package/scripts/opinion_timeline.py +138 -138
  57. package/scripts/replay.py +803 -803
  58. package/scripts/run_hidden.vbs +24 -24
  59. package/scripts/semantic_search.py +5 -1
  60. package/scripts/user_notes.py +315 -315
  61. package/scripts/voice_transcribe.py +139 -139
  62. package/scripts/watch_issues.py +212 -212
  63. package/scripts/watch_mail.py +131 -131
  64. package/scripts/wechat_avatar.py +185 -0
  65. package/scripts/wechat_ledger.py +190 -0
  66. package/src/services/cursorProbe.ts +222 -222
  67. package/src/services/diagnosticReport.ts +22 -0
  68. package/src/services/qchatImport.ts +327 -327
  69. package/src/services/setupVenv.ts +238 -238
  70. package/src/services/syncService.ts +363 -363
  71. package/src/services/syncState.ts +275 -275
  72. package/src/services/wechatCompat.ts +67 -0
  73. package/src/services/wechatMedia.ts +166 -166
@@ -480,21 +480,21 @@ def main():
480
480
  parser.add_argument('--date', help='日期 YYYY-MM-DD(--refresh-queries 时不需要)')
481
481
  parser.add_argument('--source', default=DEFAULT_SOURCE, help='文章目录')
482
482
  parser.add_argument('--vault', default=DEFAULT_VAULT, help='Vault 目录')
483
- parser.add_argument('--clean-summaries', action='store_true',
484
- help='只把已有笔记摘要段里的微信界面残留洗掉(本地,不调模型)')
483
+ parser.add_argument('--clean-summaries', action='store_true',
484
+ help='只把已有笔记摘要段里的微信界面残留洗掉(本地,不调模型)')
485
485
  parser.add_argument('--refresh-queries', action='store_true',
486
486
  help='只做这件事:把已有笔记里的 dataview 查询重写成当前模板(本地,不调模型)')
487
487
  args = parser.parse_args()
488
488
 
489
- if args.clean_summaries:
490
- # 本地清洗:动的是本地就能算出来的东西,不必把两万多篇重问一遍
491
- result = clean_summaries(args.vault)
492
- print('清洗摘要:%d 篇;本来就干净、没动的 %d 篇;读不出来的 %d 篇'
493
- % (len(result['cleaned']), len(result['untouched']), len(result['skipped'])))
494
- for name in result['skipped'][:5]:
495
- print(' 跳过:', name[:70])
496
- return
497
-
489
+ if args.clean_summaries:
490
+ # 本地清洗:动的是本地就能算出来的东西,不必把两万多篇重问一遍
491
+ result = clean_summaries(args.vault)
492
+ print('清洗摘要:%d 篇;本来就干净、没动的 %d 篇;读不出来的 %d 篇'
493
+ % (len(result['cleaned']), len(result['untouched']), len(result['skipped'])))
494
+ for name in result['skipped'][:5]:
495
+ print(' 跳过:', name[:70])
496
+ return
497
+
498
498
  if args.refresh_queries:
499
499
  result = refresh_dataview_blocks(args.vault)
500
500
  print('重写 %d 篇;没有查询块、没动的 %d 篇;读不出来的 %d 篇'
@@ -32,7 +32,16 @@ sys.path.insert(0, str(SCRIPT_DIR))
32
32
  from _utils import get_api_key, load_config # noqa: E402
33
33
 
34
34
  ROOT = SCRIPT_DIR.parent
35
- DEFAULT_TOPICS = ('AI', '学术')
35
+ #: **政治不进默认**:2026-10-26 实测,含政治题材的邮件被 QQ SMTP 直接拒收
36
+ #: (`550 The mail may contain inappropriate words or content.`),去掉后同一封信
37
+ #: 立刻发出。政治类仍然在本地阅读器里(`daily-server`),只是不走邮件。
38
+ DEFAULT_TOPICS = ('AI', '学术', '新闻', '文学', '投资')
39
+ #: 只有这两类生成 AI 综述 + 聚类。其余题材**照常发出**(标题 + 一句摘要),但不额外
40
+ #: 花钱:六个主题全做综述和聚类是 12 次调用,而新闻/文学/投资/政治通常只需扫一眼标题。
41
+ DIGEST_TOPICS = ('AI', '学术')
42
+ #: 非重点主题最多列几条。六个主题全量渲染是 10 万字符(2026-10-26 实测 356 篇),
43
+ #: 多数邮件客户端会截断;只留最前面这些,其余让读的人在本地阅读器里翻。
44
+ OTHER_TOPIC_LIMIT = 12
36
45
  SMTP_CONFIG = pathlib.Path(os.path.expanduser('~/.weflow-issue-watch.json'))
37
46
 
38
47
 
@@ -232,7 +241,16 @@ def render(date: str, groups, digests, bodies=None, clusters=None) -> tuple[str,
232
241
  total = sum(len(v) for v in groups.values())
233
242
  # 主题配色:AI 靛蓝、学术 青绿。用 table + inline style —— 邮件客户端会剥离
234
243
  # `<style>` 与 class,class 选择器在 QQ / Gmail / 企业微信里表现不一致。
235
- accents = {'AI': ('#4f46e5', '#eef2ff'), '学术': ('#0d9488', '#ecfdf5')}
244
+ # 六个主题各一套配色。同一封信里出现六种强调色不算花——它们是**分区标记**,
245
+ # 换主题时颜色跟着换,扫读时不用先找回标题才知道换段了。
246
+ accents = {
247
+ 'AI': ('#4f46e5', '#eef2ff'),
248
+ '学术': ('#0d9488', '#ecfdf5'),
249
+ '新闻': ('#2563eb', '#eff6ff'),
250
+ '文学': ('#db2777', '#fdf2f8'),
251
+ '投资': ('#ca8a04', '#fefce8'),
252
+ '政治': ('#dc2626', '#fef2f2'),
253
+ }
236
254
 
237
255
  text_lines = [f'公众号日报 {date}', f'共 {total} 篇', '']
238
256
  blocks: list[str] = []
@@ -298,7 +316,12 @@ def render(date: str, groups, digests, bodies=None, clusters=None) -> tuple[str,
298
316
  )
299
317
  text_lines.append(f'-- {cname}({len(citems)})--')
300
318
 
301
- for a in citems:
319
+ # **非重点主题走紧凑模式**:一行标题、不带摘要,且只列前 N 条。
320
+ # 六个主题全量渲染是 10 万字符(2026-10-26 实测 356 篇),多数邮件客户端
321
+ # 会截断,人也不会读完——而新闻/文学/投资/政治本来就是"扫一眼标题"的量。
322
+ compact = topic not in DIGEST_TOPICS
323
+ shown = citems[:OTHER_TOPIC_LIMIT] if compact else citems
324
+ for a in shown:
302
325
  i = items.index(a) + 1
303
326
  title = a.get('title') or ''
304
327
  source = a.get('source') or ''
@@ -311,6 +334,18 @@ def render(date: str, groups, digests, bodies=None, clusters=None) -> tuple[str,
311
334
  title_html = (f'<a href="{html.escape(url)}" style="color:#111827;text-decoration:none;">'
312
335
  f'{html.escape(title)}</a>' if url else html.escape(title))
313
336
 
337
+ if compact:
338
+ # 紧凑模式下不按相关度分档:它们整体就是"浏览用"的流水。
339
+ summary = ''
340
+ text_lines.append(f'{num}. {title} — {meta}')
341
+ blocks.append(
342
+ '<tr><td style="padding:6px 28px 0;">'
343
+ f'<span style="font-size:11px;color:#c0c7d0;">{num}</span> '
344
+ f'<span style="font-size:13px;color:#374151;">{title_html}</span>'
345
+ f'<span style="font-size:11px;color:#b3bac4;"> · {html.escape(source)}</span>'
346
+ '</td></tr>'
347
+ )
348
+ continue
314
349
  # **颜色必须自己说明自己。** 上一版把序号徽章染成主题色来表示"高相关",
315
350
  # 但没有任何图例——用户看到截图的第一反应是"这边为什么有的是绿的"(2026-10-20)。
316
351
  # 现在徽章统一成灰色,相关度改成 meta 行里一个**写着"高"的小色块**:
@@ -440,8 +475,14 @@ def main():
440
475
  # `--no-digest` 只关"每主题一段综述";聚类是版式结构,仍要做(两件事分开开关,
441
476
  # 否则想看新版式的人一加 `--no-digest` 就退回一根长肠子)。
442
477
  api_key = get_api_key(cfg)
443
- digests = {} if args.no_digest else {t: topic_digest(t, items, api_key) for t, items in groups.items() if items}
444
- clusters = {t: cluster_items(t, items, api_key, want=5) for t, items in groups.items() if len(items) >= 8}
478
+ digests = {} if args.no_digest else {
479
+ t: topic_digest(t, items, api_key)
480
+ for t, items in groups.items() if items and t in DIGEST_TOPICS}
481
+ # 聚类只给重点主题做。非重点主题分 4-6 组没有收益(新闻/文学本来就是几条标题的
482
+ # 流水),却要为每个主题多付一次调用。
483
+ clusters = {t: cluster_items(t, items, api_key, want=5)
484
+ for t, items in groups.items()
485
+ if len(items) >= 8 and t in DIGEST_TOPICS}
445
486
  for t, cs in clusters.items():
446
487
  print(f' {t} 分了 {len(cs)} 组: ' + ' / '.join(f'{n}({len(v)})' for n, v in cs))
447
488
 
@@ -450,7 +491,12 @@ def main():
450
491
  print(f'正文含 GitHub 链接: {with_repo} 篇')
451
492
 
452
493
  text_body, html_body, total = render(args.date, groups, digests, bodies, clusters)
494
+ # **`--label` 支持 ASCII 代号**,中文在这里映射。原因:调用方 `daily_task.ps1` 是
495
+ # 无 BOM 的 UTF-8,Windows PowerShell 5.1 会按 GBK 解码它,直接传中文会变成
496
+ # `鎴嚦涓崍` 那种乱码进到邮件主题(2026-10-26 实际发生)。
497
+ LABELS = {'noon': '截至中午'}
453
498
  label = (args.label or '').strip()
499
+ label = LABELS.get(label, label)
454
500
  suffix = f'({label})' if label else ''
455
501
  subject = f'公众号日报 {args.date}{suffix}:{" / ".join(t for t in topics if groups.get(t))} {total} 篇'
456
502
  if label:
@@ -1,213 +1,216 @@
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
- # Fallback for the HTML stage.
123
- #
124
- # `pipeline.py` fetches, summarises, writes the day, then renders `index.html`. That last step
125
- # has hung twice now (2026-10-18: 11 hours with no output; 2026-10-23: the run died after the
126
- # README and never produced the page). Losing the page also loses the mail, because mailing
127
- # happens only on success - so a single slow step silently costs the whole morning report.
128
- #
129
- # The fetch and summaries are already on disk at that point (`.articles.json`), so rendering is
130
- # retried on its own. `$proc` is replaced by the retry's exit code, which is what lets the mail
131
- # and index steps below run on a day where only the renderer failed.
132
- if ($proc.ExitCode -ne 0) {
133
- $dayDir = Join-Path $root ('output\biz-daily\' + $targetDay)
134
- $art = Join-Path $dayDir '.articles.json'
135
- $idx = Join-Path $dayDir 'index.html'
136
- $htmlScript = Join-Path $PSScriptRoot 'generate_html.py'
137
- # Only retry when the data is there and the page is missing or older than the data:
138
- # a page newer than `.articles.json` means rendering already happened this run.
139
- $needsRender = (Test-Path $art) -and (Test-Path $htmlScript)
140
- if ($needsRender -and (Test-Path $idx)) {
141
- $needsRender = (Get-Item $art).LastWriteTime -gt (Get-Item $idx).LastWriteTime
142
- }
143
- if ($needsRender) {
144
- Add-Content -Path (Join-Path $logDir 'daily-task.log') -Value `
145
- ('===== {0} [{1}] {2} pipeline exit {3}, retrying HTML only =====' -f `
146
- (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $mode, $targetDay, $proc.ExitCode) -Encoding UTF8
147
- $retryProc = Start-Process -FilePath $python `
148
- -ArgumentList @($htmlScript, '--date', $targetDay) `
149
- -NoNewWindow -Wait -PassThru `
150
- -RedirectStandardOutput (Join-Path $logDir ('html-retry-{0}{1}.log' -f $stamp, $suffix)) `
151
- -RedirectStandardError (Join-Path $logDir ('html-retry-{0}{1}.err.log' -f $stamp, $suffix))
152
- if ($retryProc.ExitCode -eq 0) {
153
- $proc = $retryProc
154
- }
155
- }
156
- }
157
-
158
- if ($proc.ExitCode -eq 0 -and $rest.Count -eq 0) {
159
- # Marker for the noon pass only; the day pass is judged by generated_at above.
160
- if ($mode -eq 'noon') {
161
- $dayDir = Join-Path $root ('output\biz-daily\' + $targetDay)
162
- if (Test-Path $dayDir) {
163
- Set-Content -Path (Join-Path $dayDir '.noon_done') -Value (Get-Date -Format 'yyyy-MM-dd HH:mm:ss') -Encoding UTF8
164
- }
165
- }
166
-
167
- # Retrieval indexes lag behind unless something refreshes them: on 2026-10-22 the
168
- # semantic index was still at 10-15 (7 days stale) and the phrase index at 10-17, so
169
- # `search` simply could not find anything newer. Both run here, after the day is on
170
- # disk, in their own logs -- a failure is recorded and never fails the day.
171
- $searchScript = Join-Path $PSScriptRoot 'semantic_search.py'
172
- if ($mode -eq 'day' -and (Test-Path $searchScript)) {
173
- $idxOut = Join-Path $logDir ('index-{0}.log' -f $stamp)
174
- $idxErr = Join-Path $logDir ('index-{0}.err.log' -f $stamp)
175
- # `update` only embeds what is not in the index yet (4,061 new records on the first
176
- # run after a week). `phrase-build` is local FTS, no API, so it rebuilds in full.
177
- $idxProc = Start-Process -FilePath $python `
178
- -ArgumentList @($searchScript, 'update', '--article-days', '30') `
179
- -NoNewWindow -Wait -PassThru -RedirectStandardOutput $idxOut -RedirectStandardError $idxErr
180
- $phraseProc = Start-Process -FilePath $python `
181
- -ArgumentList @($searchScript, 'phrase-build') `
182
- -NoNewWindow -Wait -PassThru `
183
- -RedirectStandardOutput (Join-Path $logDir ('phrase-{0}.log' -f $stamp)) `
184
- -RedirectStandardError (Join-Path $logDir ('phrase-{0}.err.log' -f $stamp))
185
- if ($idxProc.ExitCode -ne 0 -or $phraseProc.ExitCode -ne 0) {
186
- Add-Content -Path (Join-Path $logDir 'daily-task.log') -Value `
187
- ('===== {0} index update failed (update={1}, phrase={2}), see {3} =====' -f `
188
- (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $idxProc.ExitCode, $phraseProc.ExitCode, $idxErr) -Encoding UTF8
189
- }
190
- }
191
-
192
- # **Both passes mail now.** The noon pass used to be silent, which read as "it never
193
- # ran" - the only way to tell it had done anything was to open the log. The two emails
194
- # are clearly different: the noon one is labelled 截至中午 and says the complete version
195
- # follows next morning. Mail is still its own step with its own log pair, so a mail
196
- # failure never fails the day nor blocks the noon marker.
197
- $mailScript = Join-Path $PSScriptRoot 'daily_mail.py'
198
- $mailOut = Join-Path $logDir ('daily-mail-{0}{1}.log' -f $stamp, $suffix)
199
- $mailErr = Join-Path $logDir ('daily-mail-{0}{1}.err.log' -f $stamp, $suffix)
200
- if (Test-Path $mailScript) {
201
- $mailArgs = @($mailScript, '--date', $targetDay)
202
- if ($mode -eq 'noon') { $mailArgs += @('--label', '截至中午') }
203
- $mailProc = Start-Process -FilePath $python -ArgumentList $mailArgs `
204
- -NoNewWindow -Wait -PassThru -RedirectStandardOutput $mailOut -RedirectStandardError $mailErr
205
- if ($mailProc.ExitCode -ne 0) {
206
- Add-Content -Path (Join-Path $logDir 'daily-task.log') -Value `
207
- ('===== {0} [{1}] {2} mail failed (exit {3}), see {4} =====' -f `
208
- (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $mode, $targetDay, $mailProc.ExitCode, $mailErr) -Encoding UTF8
209
- }
210
- }
211
- }
212
-
213
- exit $proc.ExitCode
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
+ # Fallback for the HTML stage.
123
+ #
124
+ # `pipeline.py` fetches, summarises, writes the day, then renders `index.html`. That last step
125
+ # has hung twice now (2026-10-18: 11 hours with no output; 2026-10-23: the run died after the
126
+ # README and never produced the page). Losing the page also loses the mail, because mailing
127
+ # happens only on success - so a single slow step silently costs the whole morning report.
128
+ #
129
+ # The fetch and summaries are already on disk at that point (`.articles.json`), so rendering is
130
+ # retried on its own. `$proc` is replaced by the retry's exit code, which is what lets the mail
131
+ # and index steps below run on a day where only the renderer failed.
132
+ if ($proc.ExitCode -ne 0) {
133
+ $dayDir = Join-Path $root ('output\biz-daily\' + $targetDay)
134
+ $art = Join-Path $dayDir '.articles.json'
135
+ $idx = Join-Path $dayDir 'index.html'
136
+ $htmlScript = Join-Path $PSScriptRoot 'generate_html.py'
137
+ # Only retry when the data is there and the page is missing or older than the data:
138
+ # a page newer than `.articles.json` means rendering already happened this run.
139
+ $needsRender = (Test-Path $art) -and (Test-Path $htmlScript)
140
+ if ($needsRender -and (Test-Path $idx)) {
141
+ $needsRender = (Get-Item $art).LastWriteTime -gt (Get-Item $idx).LastWriteTime
142
+ }
143
+ if ($needsRender) {
144
+ Add-Content -Path (Join-Path $logDir 'daily-task.log') -Value `
145
+ ('===== {0} [{1}] {2} pipeline exit {3}, retrying HTML only =====' -f `
146
+ (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $mode, $targetDay, $proc.ExitCode) -Encoding UTF8
147
+ $retryProc = Start-Process -FilePath $python `
148
+ -ArgumentList @($htmlScript, '--date', $targetDay) `
149
+ -NoNewWindow -Wait -PassThru `
150
+ -RedirectStandardOutput (Join-Path $logDir ('html-retry-{0}{1}.log' -f $stamp, $suffix)) `
151
+ -RedirectStandardError (Join-Path $logDir ('html-retry-{0}{1}.err.log' -f $stamp, $suffix))
152
+ if ($retryProc.ExitCode -eq 0) {
153
+ $proc = $retryProc
154
+ }
155
+ }
156
+ }
157
+
158
+ if ($proc.ExitCode -eq 0 -and $rest.Count -eq 0) {
159
+ # Marker for the noon pass only; the day pass is judged by generated_at above.
160
+ if ($mode -eq 'noon') {
161
+ $dayDir = Join-Path $root ('output\biz-daily\' + $targetDay)
162
+ if (Test-Path $dayDir) {
163
+ Set-Content -Path (Join-Path $dayDir '.noon_done') -Value (Get-Date -Format 'yyyy-MM-dd HH:mm:ss') -Encoding UTF8
164
+ }
165
+ }
166
+
167
+ # Retrieval indexes lag behind unless something refreshes them: on 2026-10-22 the
168
+ # semantic index was still at 10-15 (7 days stale) and the phrase index at 10-17, so
169
+ # `search` simply could not find anything newer. Both run here, after the day is on
170
+ # disk, in their own logs -- a failure is recorded and never fails the day.
171
+ $searchScript = Join-Path $PSScriptRoot 'semantic_search.py'
172
+ if ($mode -eq 'day' -and (Test-Path $searchScript)) {
173
+ $idxOut = Join-Path $logDir ('index-{0}.log' -f $stamp)
174
+ $idxErr = Join-Path $logDir ('index-{0}.err.log' -f $stamp)
175
+ # `update` only embeds what is not in the index yet (4,061 new records on the first
176
+ # run after a week). `phrase-build` is local FTS, no API, so it rebuilds in full.
177
+ $idxProc = Start-Process -FilePath $python `
178
+ -ArgumentList @($searchScript, 'update', '--article-days', '30') `
179
+ -NoNewWindow -Wait -PassThru -RedirectStandardOutput $idxOut -RedirectStandardError $idxErr
180
+ $phraseProc = Start-Process -FilePath $python `
181
+ -ArgumentList @($searchScript, 'phrase-build') `
182
+ -NoNewWindow -Wait -PassThru `
183
+ -RedirectStandardOutput (Join-Path $logDir ('phrase-{0}.log' -f $stamp)) `
184
+ -RedirectStandardError (Join-Path $logDir ('phrase-{0}.err.log' -f $stamp))
185
+ if ($idxProc.ExitCode -ne 0 -or $phraseProc.ExitCode -ne 0) {
186
+ Add-Content -Path (Join-Path $logDir 'daily-task.log') -Value `
187
+ ('===== {0} index update failed (update={1}, phrase={2}), see {3} =====' -f `
188
+ (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $idxProc.ExitCode, $phraseProc.ExitCode, $idxErr) -Encoding UTF8
189
+ }
190
+ }
191
+
192
+ # **Both passes mail now.** The noon pass used to be silent, which read as "it never
193
+ # ran" - the only way to tell it had done anything was to open the log. The two emails
194
+ # are clearly different: the noon one is labelled 截至中午 and says the complete version
195
+ # follows next morning. Mail is still its own step with its own log pair, so a mail
196
+ # failure never fails the day nor blocks the noon marker.
197
+ $mailScript = Join-Path $PSScriptRoot 'daily_mail.py'
198
+ $mailOut = Join-Path $logDir ('daily-mail-{0}{1}.log' -f $stamp, $suffix)
199
+ $mailErr = Join-Path $logDir ('daily-mail-{0}{1}.err.log' -f $stamp, $suffix)
200
+ if (Test-Path $mailScript) {
201
+ $mailArgs = @($mailScript, '--date', $targetDay)
202
+ # **只传 ASCII**:这个脚本是无 BOM 的 UTF-8,而 Windows PowerShell 5.1 会把
203
+ # 无 BOM 文件按 ANSI(本机 GBK)解码——直接写中文标签会变成 `鎴嚦涓崍`
204
+ # 这种乱码进到邮件主题里(2026-10-26 实测)。中文由 Python 侧映射。
205
+ if ($mode -eq 'noon') { $mailArgs += @('--label', 'noon') }
206
+ $mailProc = Start-Process -FilePath $python -ArgumentList $mailArgs `
207
+ -NoNewWindow -Wait -PassThru -RedirectStandardOutput $mailOut -RedirectStandardError $mailErr
208
+ if ($mailProc.ExitCode -ne 0) {
209
+ Add-Content -Path (Join-Path $logDir 'daily-task.log') -Value `
210
+ ('===== {0} [{1}] {2} mail failed (exit {3}), see {4} =====' -f `
211
+ (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $mode, $targetDay, $mailProc.ExitCode, $mailErr) -Encoding UTF8
212
+ }
213
+ }
214
+ }
215
+
216
+ exit $proc.ExitCode
@@ -23,6 +23,7 @@ from pathlib import Path
23
23
 
24
24
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
25
25
  from _utils import load_config, decrypt_lock, call_deepseek, get_api_key
26
+ from nt_common import decode_content
26
27
 
27
28
  TODOS_FILE = os.path.join(os.path.expanduser('~'), '.weflow-cli', 'todos.json')
28
29
  TZ = timezone(timedelta(hours=8))
@@ -108,7 +109,8 @@ def collect_recent_messages(conn, start_ts, end_ts, name_map, limit_per_session=
108
109
  ''', (start_ts, end_ts, limit_per_session))
109
110
 
110
111
  for ts, sender_id, content in c.fetchall():
111
- if not content or not isinstance(content, str) or len(content) < 5:
112
+ content = decode_content(content) # 压缩正文别再整条丢掉(issue #12)
113
+ if len(content) < 5:
112
114
  continue
113
115
  # 关键词过滤
114
116
  content_lower = content.lower()