weflow-cli 1.5.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 (145) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -0
  3. package/bin/weflow-cli-electron.cjs +59 -0
  4. package/bin/weflow-cli.ts +2311 -0
  5. package/cli.cjs +3 -0
  6. package/dist/bin/weflow-cli.d.ts +3 -0
  7. package/dist/bin/weflow-cli.d.ts.map +1 -0
  8. package/dist/bin/weflow-cli.js +2270 -0
  9. package/dist/bin/weflow-cli.js.map +1 -0
  10. package/dist/mcp-server/index.d.ts +3 -0
  11. package/dist/mcp-server/index.d.ts.map +1 -0
  12. package/dist/mcp-server/index.js +690 -0
  13. package/dist/mcp-server/index.js.map +1 -0
  14. package/dist/src/core/dbPathService.d.ts +40 -0
  15. package/dist/src/core/dbPathService.d.ts.map +1 -0
  16. package/dist/src/core/dbPathService.js +324 -0
  17. package/dist/src/core/dbPathService.js.map +1 -0
  18. package/dist/src/core/keyService.d.ts +59 -0
  19. package/dist/src/core/keyService.d.ts.map +1 -0
  20. package/dist/src/core/keyService.js +368 -0
  21. package/dist/src/core/keyService.js.map +1 -0
  22. package/dist/src/core/ntCore.d.ts +62 -0
  23. package/dist/src/core/ntCore.d.ts.map +1 -0
  24. package/dist/src/core/ntCore.js +191 -0
  25. package/dist/src/core/ntCore.js.map +1 -0
  26. package/dist/src/core/sqlcipherCore.d.ts +119 -0
  27. package/dist/src/core/sqlcipherCore.d.ts.map +1 -0
  28. package/dist/src/core/sqlcipherCore.js +693 -0
  29. package/dist/src/core/sqlcipherCore.js.map +1 -0
  30. package/dist/src/core/wcdbCore.d.ts +738 -0
  31. package/dist/src/core/wcdbCore.d.ts.map +1 -0
  32. package/dist/src/core/wcdbCore.js +4545 -0
  33. package/dist/src/core/wcdbCore.js.map +1 -0
  34. package/dist/src/core/wechatClient.d.ts +23 -0
  35. package/dist/src/core/wechatClient.d.ts.map +1 -0
  36. package/dist/src/core/wechatClient.js +166 -0
  37. package/dist/src/core/wechatClient.js.map +1 -0
  38. package/dist/src/services/chatService.d.ts +25 -0
  39. package/dist/src/services/chatService.d.ts.map +1 -0
  40. package/dist/src/services/chatService.js +291 -0
  41. package/dist/src/services/chatService.js.map +1 -0
  42. package/dist/src/services/configService.d.ts +43 -0
  43. package/dist/src/services/configService.d.ts.map +1 -0
  44. package/dist/src/services/configService.js +156 -0
  45. package/dist/src/services/configService.js.map +1 -0
  46. package/dist/src/services/exportService.d.ts +30 -0
  47. package/dist/src/services/exportService.d.ts.map +1 -0
  48. package/dist/src/services/exportService.js +273 -0
  49. package/dist/src/services/exportService.js.map +1 -0
  50. package/dist/src/services/wechat-formatter.d.ts +69 -0
  51. package/dist/src/services/wechat-formatter.d.ts.map +1 -0
  52. package/dist/src/services/wechat-formatter.js +248 -0
  53. package/dist/src/services/wechat-formatter.js.map +1 -0
  54. package/dist/src/services/wechatMessageService.d.ts +28 -0
  55. package/dist/src/services/wechatMessageService.d.ts.map +1 -0
  56. package/dist/src/services/wechatMessageService.js +341 -0
  57. package/dist/src/services/wechatMessageService.js.map +1 -0
  58. package/dist/src/services/wereadService.d.ts +206 -0
  59. package/dist/src/services/wereadService.d.ts.map +1 -0
  60. package/dist/src/services/wereadService.js +145 -0
  61. package/dist/src/services/wereadService.js.map +1 -0
  62. package/dist/src/services/whitelistService.d.ts +20 -0
  63. package/dist/src/services/whitelistService.d.ts.map +1 -0
  64. package/dist/src/services/whitelistService.js +60 -0
  65. package/dist/src/services/whitelistService.js.map +1 -0
  66. package/dist/src/types.d.ts +140 -0
  67. package/dist/src/types.d.ts.map +1 -0
  68. package/dist/src/types.js +2 -0
  69. package/dist/src/types.js.map +1 -0
  70. package/dist/src/utils/errors.d.ts +25 -0
  71. package/dist/src/utils/errors.d.ts.map +1 -0
  72. package/dist/src/utils/errors.js +43 -0
  73. package/dist/src/utils/errors.js.map +1 -0
  74. package/dist/src/utils/pathUtils.d.ts +5 -0
  75. package/dist/src/utils/pathUtils.d.ts.map +1 -0
  76. package/dist/src/utils/pathUtils.js +16 -0
  77. package/dist/src/utils/pathUtils.js.map +1 -0
  78. package/dist/src/utils/pythonRunner.d.ts +27 -0
  79. package/dist/src/utils/pythonRunner.d.ts.map +1 -0
  80. package/dist/src/utils/pythonRunner.js +62 -0
  81. package/dist/src/utils/pythonRunner.js.map +1 -0
  82. package/dist/src/utils/talkerUtils.d.ts +14 -0
  83. package/dist/src/utils/talkerUtils.d.ts.map +1 -0
  84. package/dist/src/utils/talkerUtils.js +73 -0
  85. package/dist/src/utils/talkerUtils.js.map +1 -0
  86. package/mcp-server/index.ts +757 -0
  87. package/package.json +72 -0
  88. package/resources/key/win32/x64/wx_key.dll +0 -0
  89. package/resources/wcdb/win32/x64/SDL2.dll +0 -0
  90. package/resources/wcdb/win32/x64/WCDB.dll +0 -0
  91. package/resources/wcdb/win32/x64/msvcp140.dll +0 -0
  92. package/resources/wcdb/win32/x64/msvcp140_1.dll +0 -0
  93. package/resources/wcdb/win32/x64/vcruntime140.dll +0 -0
  94. package/resources/wcdb/win32/x64/vcruntime140_1.dll +0 -0
  95. package/resources/wcdb/win32/x64/wcdb_api.dll +0 -0
  96. package/scripts/_batch_link_sources.py +73 -0
  97. package/scripts/_utils.py +531 -0
  98. package/scripts/annual_report.py +796 -0
  99. package/scripts/auto_tag.py +128 -0
  100. package/scripts/biz_daily.py +896 -0
  101. package/scripts/chat_report.py +397 -0
  102. package/scripts/chat_stats.py +702 -0
  103. package/scripts/classify_daily.py +411 -0
  104. package/scripts/compile_wiki.py +258 -0
  105. package/scripts/create_reading_notes.py +370 -0
  106. package/scripts/enrich_backlinks.py +163 -0
  107. package/scripts/export_chat_html.py +766 -0
  108. package/scripts/extract_3x_key.py +86 -0
  109. package/scripts/extract_todos.py +404 -0
  110. package/scripts/fav_server.py +392 -0
  111. package/scripts/fix_topics.py +111 -0
  112. package/scripts/generate_ai_report.py +367 -0
  113. package/scripts/generate_html.py +1372 -0
  114. package/scripts/generate_review.py +170 -0
  115. package/scripts/mcp_bridge.py +398 -0
  116. package/scripts/nt_decrypt.py +562 -0
  117. package/scripts/pipeline.py +145 -0
  118. package/scripts/promote_all.py +310 -0
  119. package/scripts/promote_ideas.py +297 -0
  120. package/scripts/rag_chat.py +191 -0
  121. package/scripts/scan_decrypt_4x.py +329 -0
  122. package/scripts/semantic_search.py +431 -0
  123. package/scripts/sync_fav.py +146 -0
  124. package/scripts/sync_weread.py +192 -0
  125. package/scripts/vault_rag.py +139 -0
  126. package/scripts/vault_search.py +141 -0
  127. package/src/core/dbPathService.ts +348 -0
  128. package/src/core/keyService.ts +409 -0
  129. package/src/core/ntCore.ts +224 -0
  130. package/src/core/sqlcipherCore.ts +764 -0
  131. package/src/core/wcdbCore.ts +4568 -0
  132. package/src/core/wechatClient.ts +204 -0
  133. package/src/lz4.d.ts +10 -0
  134. package/src/services/chatService.ts +296 -0
  135. package/src/services/configService.ts +197 -0
  136. package/src/services/exportService.ts +305 -0
  137. package/src/services/wechat-formatter.ts +348 -0
  138. package/src/services/wechatMessageService.ts +383 -0
  139. package/src/services/wereadService.ts +308 -0
  140. package/src/services/whitelistService.ts +72 -0
  141. package/src/types.ts +141 -0
  142. package/src/utils/errors.ts +42 -0
  143. package/src/utils/pathUtils.ts +16 -0
  144. package/src/utils/pythonRunner.ts +81 -0
  145. package/src/utils/talkerUtils.ts +84 -0
@@ -0,0 +1,367 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ AI 阅读日报 — 信息密集但精炼版本。
4
+
5
+ 核心目标:没时间看公众号时,用这份报告快速扫一眼即可。
6
+ - AI 一次调用搞定:总评 + 趋势 + 逐篇摘要 + 行动指南
7
+ - 按主题分组,AI 主题最详细
8
+ - 默认优先走本地推理(Ollama/LM Studio/Claude Code 本地服务)
9
+
10
+ 用法:
11
+ python scripts/generate_ai_report.py # 今天(默认走本地推理)
12
+ python scripts/generate_ai_report.py --date 2026-06-09
13
+ python scripts/generate_ai_report.py --range 7 # 最近 7 天
14
+ python scripts/generate_ai_report.py --engine local|deepseek|claude|ollama
15
+ python scripts/generate_ai_report.py --dry-run # 仅文章清单,不调用 AI
16
+
17
+ 输出:
18
+ output/ai-reports/ai-report-YYYY-MM-DD.md
19
+ """
20
+ import sys, os, json, re, time, argparse
21
+ from datetime import datetime, timezone, timedelta, date
22
+ from pathlib import Path
23
+ from collections import Counter
24
+
25
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
26
+ from _utils import create_engine, parse_frontmatter
27
+
28
+ SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
29
+ ROOT_DIR = os.path.dirname(SCRIPTS_DIR)
30
+ SOURCE_ROOT = os.path.join(ROOT_DIR, 'output', 'biz-daily')
31
+ OUTPUT_DIR = os.path.join(ROOT_DIR, 'output', 'ai-reports')
32
+
33
+ TZ = timezone(timedelta(hours=8))
34
+ TOPICS = ['AI', '学术', '新闻', '文学', '投资']
35
+ FOCUS_TOPIC = 'AI'
36
+
37
+
38
+ # ======================================================================
39
+ # PROMPT(信息密集精炼版,一次调用搞定)
40
+ # ======================================================================
41
+
42
+ REPORT_PROMPT = """你是一位为忙碌研究生写作的「深度阅读策展人」。你的任务是用**信息密集但精炼**的方式,把今天的公众号文章整理成一份有深度、有观点的日报。读者读完能清晰了解「今天发生了什么、核心观点是什么、对我有什么用」。
43
+
44
+ 【读者】环境科学研究生,关注 AI 前沿 + 环境科学交叉方向(大气模拟、遥感反演、环境大数据等)。
45
+
46
+ 【文章列表】
47
+ {articles_block}
48
+
49
+ ---
50
+
51
+ ## 写作要求(核心)
52
+
53
+ **精炼不是简短,是信息密度高。** 每句话都要有信息量:
54
+ - **覆盖要全面**:尽量覆盖所有文章,至少 20 篇以上要被提及或概括
55
+ - **重点要突出**:对最值得关注的 10-15 篇文章展开讲清楚「是什么、为什么重要、怎么用」
56
+ - **其余也要有存在感**:剩下的文章用主题分类 + 一句话概括,让读者知道今天还有什么
57
+ - 用**段落叙述**串联信息,而不是 bullet list 罗列标题
58
+ - 每个观点回答**「所以呢?」**——对读者有什么启发
59
+
60
+ ## 结构(Markdown,遵守标题层级)
61
+
62
+ ### 今日焦点(3-5 段叙述)
63
+ 用叙事段落讲清最重要的事。每段聚焦一个主题,把 2-4 篇相关文章串成故事线。用 **《标题》**(*来源*) 格式。深入解释核心观点、数据和意义。
64
+
65
+ ### AI 工具与趋势(2-3 段)
66
+ 今天 AI 领域的新动态。对有价值的工具/项目,讲清楚:它是什么、能做什么、和环境科学研究有什么关系、怎么入手。
67
+
68
+ ### 学术前沿(2-3 段)
69
+ 与环境科学相关的研究进展。每篇讲清:研究了什么问题、用什么方法、得出什么结论、有什么启发。
70
+
71
+ ### 其他值得关注
72
+ 用表格或分组列出今天其他值得知道的文章,每条至少写清楚核心观点。不要只写标题。
73
+
74
+ ### 值得做的事(具体、可执行)
75
+ 5-8 个具体行动。不要泛泛而谈,要说「打开 XX 试 YY」或「搜索 XX 论文看第 N 节」。
76
+
77
+ ## 写作规则
78
+ 1. 中文输出,语气专业但不僵硬
79
+ 2. 不引入文章列表中未出现的信息
80
+ 3. 段落为主,可适当用表格对比
81
+ 4. Markdown,**粗体**强调关键,*斜体*标注来源
82
+ 5. 全文 3000-5000 字,确保信息量和深度
83
+ 6. 每个章节都要有实质内容,不要用「今天内容较少,跳过」敷衍
84
+
85
+ 只返回 Markdown 正文。"""
86
+
87
+
88
+ # ======================================================================
89
+ # DATA LOADING
90
+ # ======================================================================
91
+
92
+ def load_articles_from_date(date_str: str) -> list[dict]:
93
+ json_path = Path(SOURCE_ROOT) / date_str / '.articles.json'
94
+ if json_path.exists():
95
+ try:
96
+ with open(json_path, 'r', encoding='utf-8') as f:
97
+ data = json.load(f)
98
+ arts = data.get('articles', data) if isinstance(data, dict) else data
99
+ for a in arts:
100
+ a.setdefault('date', date_str)
101
+ return arts
102
+ except Exception:
103
+ pass
104
+ # 回退:扫描 MD 文件
105
+ date_dir = Path(SOURCE_ROOT) / date_str
106
+ if not date_dir.exists():
107
+ return []
108
+ articles = []
109
+ for topic in TOPICS:
110
+ topic_dir = date_dir / topic
111
+ if not topic_dir.exists():
112
+ continue
113
+ for md_file in sorted(topic_dir.glob('*.md')):
114
+ try:
115
+ content = md_file.read_text(encoding='utf-8')
116
+ except Exception:
117
+ continue
118
+ fm, body = parse_frontmatter(content)
119
+ if not fm:
120
+ continue
121
+ relevance = fm.get('relevance', '中')
122
+ # 非 AI 主题只收录相关度"高"的文章
123
+ if topic != FOCUS_TOPIC and relevance != '高':
124
+ continue
125
+ # AI 主题读取完整正文(最多 5000 字),其他主题读取摘要
126
+ if topic == FOCUS_TOPIC:
127
+ # 提取正文部分供 AI 精读
128
+ body_sections = body.split('## 正文')
129
+ if len(body_sections) > 1:
130
+ full_text = body_sections[1].strip() # 不限制字数,完整正文
131
+ else:
132
+ m = re.search(r'## (?:AI 摘要|深度解析)\s*\n\s*(.+?)(?=\n##|\Z)', body, re.DOTALL)
133
+ full_text = m.group(1).strip()[:2000] if m else body[:2000].strip()
134
+ summary = full_text
135
+ else:
136
+ m = re.search(r'## (?:AI 摘要|深度解析)\s*\n\s*(.+?)(?=\n##|\Z)', body, re.DOTALL)
137
+ summary = m.group(1).strip()[:800] if m else body[:500].strip()
138
+ articles.append({
139
+ 'date': date_str,
140
+ 'title': fm.get('title', md_file.stem).strip('"\''),
141
+ 'source': fm.get('source', '').strip('"\''),
142
+ 'topic': topic,
143
+ 'relevance': relevance,
144
+ 'tags': fm.get('tags', []),
145
+ 'url': fm.get('url', '').strip('"\''),
146
+ 'summary': summary,
147
+ })
148
+ return articles
149
+
150
+
151
+ def load_articles_range(from_date: date, to_date: date) -> list[dict]:
152
+ all_articles = []
153
+ cur = from_date
154
+ while cur <= to_date:
155
+ all_articles.extend(load_articles_from_date(cur.strftime('%Y-%m-%d')))
156
+ cur += timedelta(days=1)
157
+ return all_articles
158
+
159
+
160
+ # ======================================================================
161
+ # PROMPT 准备
162
+ # ======================================================================
163
+
164
+ def build_articles_block(articles: list[dict]) -> str:
165
+ lines = []
166
+ ordered = [FOCUS_TOPIC] + [t for t in TOPICS if t != FOCUS_TOPIC]
167
+ remaining = list(articles)
168
+ for topic in ordered:
169
+ group = [a for a in remaining if a.get('topic') == topic]
170
+ if not group:
171
+ continue
172
+ lines.append(f'【主题:{topic}】共 {len(group)} 篇')
173
+ for i, a in enumerate(group):
174
+ tags = a.get('tags') or []
175
+ tags_str = '、'.join(tags[:5]) if isinstance(tags, list) else str(tags)[:50]
176
+ summary = (a.get('summary') or '(无摘要)').strip()
177
+ if topic == FOCUS_TOPIC:
178
+ # AI 文章:给完整的正文内容,不截断
179
+ lines.append(
180
+ f' [{i+1}] 《{a.get("title","")}》'
181
+ f'({a.get("source","")},相关度:{a.get("relevance","中")})'
182
+ )
183
+ if tags_str:
184
+ lines.append(f' 标签:{tags_str}')
185
+ lines.append(f' 正文内容:\n{summary}')
186
+ lines.append('')
187
+ else:
188
+ # 非 AI 文章:摘要截断到 300 字
189
+ summary = re.sub(r'\n+', ' / ', summary)[:300]
190
+ lines.append(
191
+ f' [{i+1}] 《{a.get("title","")}》'
192
+ f'({a.get("source","")},相关度:{a.get("relevance","中")})'
193
+ )
194
+ if tags_str:
195
+ lines.append(f' 标签:{tags_str}')
196
+ lines.append(f' 摘要:{summary}')
197
+ lines.append('')
198
+ extra = [a for a in remaining if a.get('topic') not in ordered]
199
+ if extra:
200
+ lines.append(f'【主题:其他】共 {len(extra)} 篇')
201
+ for a in extra:
202
+ summary = re.sub(r'\n+', ' / ', a.get('summary') or '')[:120]
203
+ lines.append(f' • 《{a.get("title","")}》({a.get("source","")})')
204
+ lines.append(f' 摘要:{summary}')
205
+ return '\n'.join(lines)
206
+
207
+
208
+ # ======================================================================
209
+ # REPORT 生成
210
+ # ======================================================================
211
+
212
+ def generate_report(articles: list[dict], engine, date_label: str,
213
+ dry_run: bool = False) -> str:
214
+ topic_counts = Counter(a.get('topic', '其他') for a in articles)
215
+ source_counts = Counter(a.get('source', '') for a in articles)
216
+ top_sources = '、'.join(f'{s}({c})' for s, c in source_counts.most_common(5))
217
+ ai_count = sum(1 for a in articles if a.get('topic') == FOCUS_TOPIC)
218
+ other_count = len(articles) - ai_count
219
+
220
+ header_lines = [
221
+ f'# 🤖 AI 阅读日报 — {date_label}',
222
+ '',
223
+ f'> 📖 共 **{len(articles)}** 篇 · AI **{ai_count}** · 其他 **{other_count}** ',
224
+ f'> 📰 来源:{top_sources} ',
225
+ f'> 🎯 主题:{" / ".join(f"{t}{c}" for t, c in topic_counts.most_common())}',
226
+ '',
227
+ '---',
228
+ '',
229
+ ]
230
+
231
+ if not articles:
232
+ return '\n'.join(header_lines) + '> 暂无文章数据。请先运行 `biz_daily.py` 抓取公众号内容。\n'
233
+
234
+ # dry-run:直接输出统计骨架
235
+ if dry_run or engine is None:
236
+ body_lines = ['## 📋 文章清单(dry-run,未调用 AI 提炼)', '']
237
+ ordered = [FOCUS_TOPIC] + [t for t in TOPICS if t != FOCUS_TOPIC]
238
+ for topic in ordered:
239
+ group = [a for a in articles if a.get('topic') == topic]
240
+ if not group:
241
+ continue
242
+ body_lines.append(f'### {topic} ({len(group)} 篇)')
243
+ body_lines.append('')
244
+ for a in group:
245
+ s = re.sub(r'\n+', ' / ', a.get('summary') or '(无摘要)')[:160]
246
+ body_lines.append(
247
+ f'- **《{a.get("title", "")}》** — *{a.get("source", "")}* '
248
+ f'({a.get("date", "")})'
249
+ )
250
+ body_lines.append(f' - {s}')
251
+ body_lines.append('')
252
+ ai_body = '\n'.join(body_lines)
253
+ else:
254
+ articles_block = build_articles_block(articles)
255
+ print(f' 🤖 AI 提炼中({len(articles)} 篇文章,本地/云端推理中)...')
256
+ try:
257
+ ai_body = engine.chat(
258
+ REPORT_PROMPT.format(articles_block=articles_block),
259
+ max_tokens=8192,
260
+ )
261
+ except Exception as e:
262
+ ai_body = f'> ⚠️ AI 调用失败:{e}\n\n已输出文章骨架,请检查模型连接或使用 --dry-run。\n'
263
+
264
+ footer_lines = [
265
+ '',
266
+ '---',
267
+ '',
268
+ f'*由 weflow-cli · generate_ai_report 自动生成 · '
269
+ f'{datetime.now(TZ).strftime("%Y-%m-%d %H:%M")}*',
270
+ '',
271
+ ]
272
+
273
+ return '\n'.join(header_lines) + ai_body.strip() + '\n' + '\n'.join(footer_lines)
274
+
275
+
276
+ # ======================================================================
277
+ # MAIN
278
+ # ======================================================================
279
+
280
+ def main():
281
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
282
+
283
+ parser = argparse.ArgumentParser(description='AI 阅读日报(信息密集精炼版)')
284
+ parser.add_argument('--api-key', help='API key(local/ollama 不需要)')
285
+ parser.add_argument('--engine', default='local',
286
+ help='AI 引擎: local(自动检测·默认) / deepseek / claude / ollama')
287
+ parser.add_argument('--date', help='单日期 YYYY-MM-DD,默认今天')
288
+ parser.add_argument('--range', type=int, help='最近 N 天合并报告,如 --range 7')
289
+ parser.add_argument('--from', dest='from_date', help='起始日期 YYYY-MM-DD')
290
+ parser.add_argument('--to', dest='to_date', help='结束日期 YYYY-MM-DD,默认今天')
291
+ parser.add_argument('--output', help='输出文件路径(默认 output/ai-reports/)')
292
+ parser.add_argument('--dry-run', action='store_true', help='不调用 AI,仅输出统计骨架')
293
+ args = parser.parse_args()
294
+
295
+ # 日期解析
296
+ today = datetime.now(TZ).date()
297
+ if args.date:
298
+ d = datetime.strptime(args.date, '%Y-%m-%d').date()
299
+ from_date, to_date = d, d
300
+ elif args.range:
301
+ to_date = today
302
+ from_date = to_date - timedelta(days=args.range - 1)
303
+ elif args.from_date:
304
+ from_date = datetime.strptime(args.from_date, '%Y-%m-%d').date()
305
+ to_date = datetime.strptime(args.to_date, '%Y-%m-%d').date() if args.to_date else today
306
+ else:
307
+ from_date = to_date = today
308
+
309
+ date_label = from_date.strftime('%Y-%m-%d') if from_date == to_date else \
310
+ f'{from_date.strftime("%Y-%m-%d")} ~ {to_date.strftime("%Y-%m-%d")}'
311
+
312
+ print(f'=== AI 阅读日报 ===')
313
+ print(f'📅 范围: {date_label}')
314
+ print(f'🔧 引擎: {args.engine}' + (' (dry-run)' if args.dry_run else ''))
315
+
316
+ # 加载文章
317
+ articles = load_articles_range(from_date, to_date)
318
+ if not articles:
319
+ print(f'\n❌ 未找到 {date_label} 的文章。请先运行:')
320
+ print(f' python scripts/biz_daily.py')
321
+ sys.exit(1)
322
+
323
+ ai_count = sum(1 for a in articles if a.get('topic') == FOCUS_TOPIC)
324
+ topic_counts = Counter(a.get('topic', '其他') for a in articles)
325
+ print(f'📖 文章: {len(articles)} 篇(AI {ai_count} · 其他 {len(articles) - ai_count})')
326
+ print(f'🎯 主题: {dict(topic_counts)}')
327
+ print(f'📰 来源: {len(set(a.get("source","") for a in articles))} 个公众号')
328
+
329
+ # 输出路径
330
+ Path(OUTPUT_DIR).mkdir(parents=True, exist_ok=True)
331
+ if args.output:
332
+ out_path = Path(args.output)
333
+ if not out_path.is_absolute():
334
+ out_path = Path(ROOT_DIR) / out_path
335
+ else:
336
+ out_name = f'ai-report-{from_date.strftime("%Y-%m-%d")}.md' if from_date == to_date else \
337
+ f'ai-report-{from_date.strftime("%Y%m%d")}-{to_date.strftime("%Y%m%d")}.md'
338
+ out_path = Path(OUTPUT_DIR) / out_name
339
+
340
+ # AI 引擎初始化
341
+ engine = None
342
+ if not args.dry_run:
343
+ api_key = args.api_key or ''
344
+ if args.engine not in ('local', 'ollama') and not api_key:
345
+ api_key = os.environ.get('DEEPSEEK_API_KEY', '')
346
+ try:
347
+ engine = create_engine(args.engine, api_key)
348
+ except (ValueError, RuntimeError) as e:
349
+ print(f'\n[ERROR] {e}')
350
+ print(' 或者使用 --dry-run 直接输出文章清单。')
351
+ sys.exit(1)
352
+
353
+ # 生成报告
354
+ t0 = time.time()
355
+ report = generate_report(articles, engine, date_label, dry_run=args.dry_run)
356
+ elapsed = time.time() - t0
357
+
358
+ with open(out_path, 'w', encoding='utf-8') as f:
359
+ f.write(report)
360
+ size_kb = len(report.encode('utf-8')) / 1024
361
+
362
+ print(f'\n✅ 报告已生成: {out_path}')
363
+ print(f' 大小: {size_kb:.0f} KB · 耗时: {elapsed:.0f}s')
364
+
365
+
366
+ if __name__ == '__main__':
367
+ main()