weflow-cli 1.7.0 → 1.8.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.
- package/ARCHITECTURE.md +3 -2
- package/CHANGELOG.md +1949 -1
- package/OPERATIONS.md +774 -1
- package/README.en.md +93 -7
- package/README.md +31 -6
- package/bin/weflow-cli.ts +934 -27
- package/dist/bin/weflow-cli.js +952 -27
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/mcp-server/index.js +39 -13
- package/dist/mcp-server/index.js.map +1 -1
- package/dist/src/core/appMsgFormat.d.ts +37 -0
- package/dist/src/core/appMsgFormat.d.ts.map +1 -0
- package/dist/src/core/appMsgFormat.js +106 -0
- package/dist/src/core/appMsgFormat.js.map +1 -0
- package/dist/src/core/ntCore.d.ts +19 -2
- package/dist/src/core/ntCore.d.ts.map +1 -1
- package/dist/src/core/ntCore.js +11 -1
- package/dist/src/core/ntCore.js.map +1 -1
- package/dist/src/core/sqlcipherCore.d.ts +0 -12
- package/dist/src/core/sqlcipherCore.d.ts.map +1 -1
- package/dist/src/core/sqlcipherCore.js +5 -64
- package/dist/src/core/sqlcipherCore.js.map +1 -1
- package/dist/src/core/wcdbCore.d.ts +1 -1
- package/dist/src/core/wcdbCore.d.ts.map +1 -1
- package/dist/src/core/wcdbCore.js +5 -1
- package/dist/src/core/wcdbCore.js.map +1 -1
- package/dist/src/panel/client.d.ts +25 -0
- package/dist/src/panel/client.d.ts.map +1 -0
- package/dist/src/panel/client.js +34 -0
- package/dist/src/panel/client.js.map +1 -0
- package/dist/src/panel/endpoint.d.ts +45 -0
- package/dist/src/panel/endpoint.d.ts.map +1 -0
- package/dist/src/panel/endpoint.js +132 -0
- package/dist/src/panel/endpoint.js.map +1 -0
- package/dist/src/panel/launch.d.ts +50 -0
- package/dist/src/panel/launch.d.ts.map +1 -0
- package/dist/src/panel/launch.js +74 -0
- package/dist/src/panel/launch.js.map +1 -0
- package/dist/src/panel/server.d.ts +23 -0
- package/dist/src/panel/server.d.ts.map +1 -0
- package/dist/src/panel/server.js +413 -0
- package/dist/src/panel/server.js.map +1 -0
- package/dist/src/panel/userId.d.ts +37 -0
- package/dist/src/panel/userId.d.ts.map +1 -0
- package/dist/src/panel/userId.js +56 -0
- package/dist/src/panel/userId.js.map +1 -0
- package/dist/src/services/assistantDaemon.d.ts +20 -3
- package/dist/src/services/assistantDaemon.d.ts.map +1 -1
- package/dist/src/services/assistantDaemon.js +46 -9
- package/dist/src/services/assistantDaemon.js.map +1 -1
- package/dist/src/services/assistantEval.d.ts +141 -0
- package/dist/src/services/assistantEval.d.ts.map +1 -0
- package/dist/src/services/assistantEval.js +497 -0
- package/dist/src/services/assistantEval.js.map +1 -0
- package/dist/src/services/assistantMemory.d.ts +106 -4
- package/dist/src/services/assistantMemory.d.ts.map +1 -1
- package/dist/src/services/assistantMemory.js +396 -61
- package/dist/src/services/assistantMemory.js.map +1 -1
- package/dist/src/services/assistantPrivacy.d.ts +15 -2
- package/dist/src/services/assistantPrivacy.d.ts.map +1 -1
- package/dist/src/services/assistantPrivacy.js +18 -4
- package/dist/src/services/assistantPrivacy.js.map +1 -1
- package/dist/src/services/assistantRouter.d.ts +41 -0
- package/dist/src/services/assistantRouter.d.ts.map +1 -0
- package/dist/src/services/assistantRouter.js +151 -0
- package/dist/src/services/assistantRouter.js.map +1 -0
- package/dist/src/services/assistantService.d.ts +209 -3
- package/dist/src/services/assistantService.d.ts.map +1 -1
- package/dist/src/services/assistantService.js +590 -75
- package/dist/src/services/assistantService.js.map +1 -1
- package/dist/src/services/assistantTools.d.ts +205 -1
- package/dist/src/services/assistantTools.d.ts.map +1 -1
- package/dist/src/services/assistantTools.js +1089 -58
- package/dist/src/services/assistantTools.js.map +1 -1
- package/dist/src/services/assistantTrace.d.ts +47 -0
- package/dist/src/services/assistantTrace.d.ts.map +1 -0
- package/dist/src/services/assistantTrace.js +150 -0
- package/dist/src/services/assistantTrace.js.map +1 -0
- package/dist/src/services/chatService.d.ts +1 -1
- package/dist/src/services/chatService.d.ts.map +1 -1
- package/dist/src/services/chatService.js +5 -2
- package/dist/src/services/chatService.js.map +1 -1
- package/dist/src/services/configService.d.ts +9 -0
- package/dist/src/services/configService.d.ts.map +1 -1
- package/dist/src/services/configService.js +15 -8
- package/dist/src/services/configService.js.map +1 -1
- package/dist/src/services/pythonBridge.d.ts +41 -0
- package/dist/src/services/pythonBridge.d.ts.map +1 -0
- package/dist/src/services/pythonBridge.js +119 -0
- package/dist/src/services/pythonBridge.js.map +1 -0
- package/dist/src/services/syncService.d.ts +8 -2
- package/dist/src/services/syncService.d.ts.map +1 -1
- package/dist/src/services/syncService.js +26 -7
- package/dist/src/services/syncService.js.map +1 -1
- package/dist/src/services/wechatMessageService.d.ts +25 -0
- package/dist/src/services/wechatMessageService.d.ts.map +1 -1
- package/dist/src/services/wechatMessageService.js +69 -7
- package/dist/src/services/wechatMessageService.js.map +1 -1
- package/dist/src/utils/atomicWrite.d.ts +4 -0
- package/dist/src/utils/atomicWrite.d.ts.map +1 -0
- package/dist/src/utils/atomicWrite.js +60 -0
- package/dist/src/utils/atomicWrite.js.map +1 -0
- package/dist/src/utils/dateRange.d.ts +15 -0
- package/dist/src/utils/dateRange.d.ts.map +1 -1
- package/dist/src/utils/dateRange.js +47 -0
- package/dist/src/utils/dateRange.js.map +1 -1
- package/dist/src/utils/text.d.ts +18 -0
- package/dist/src/utils/text.d.ts.map +1 -0
- package/dist/src/utils/text.js +20 -0
- package/dist/src/utils/text.js.map +1 -0
- package/docs/AI_INTERFACE.md +2 -0
- package/docs/DECISIONS.md +1424 -0
- package/docs/EXTENDING.md +187 -0
- package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -0
- package/docs/MCP.md +23 -4
- package/docs/PROJECT_STATE.md +299 -98
- package/docs/SETUP.md +22 -0
- package/docs/SYNC_CONTRACT.md +31 -3
- package/mcp-server/index.ts +681 -657
- package/package.json +84 -83
- package/resources/panel/ball-position.cjs +153 -0
- package/resources/panel/index.html +46 -0
- package/resources/panel/main.cjs +447 -0
- package/resources/panel/mascot-focus.png +0 -0
- package/resources/panel/mascot-happy.png +0 -0
- package/resources/panel/mascot-sorry.png +0 -0
- package/resources/panel/mascot-tired.png +0 -0
- package/resources/panel/mascot.png +0 -0
- package/resources/panel/package.json +7 -0
- package/resources/panel/panel.css +407 -0
- package/resources/panel/preload.cjs +72 -0
- package/resources/panel/quick-menu.cjs +197 -0
- package/resources/panel/renderer.js +441 -0
- package/resources/panel/tray-menu.cjs +40 -0
- package/resources/panel/tray.png +0 -0
- package/scripts/_utils.py +403 -54
- package/scripts/annual_report.py +4 -2
- package/scripts/article_notes.py +423 -0
- package/scripts/backfill_articles.py +438 -0
- package/scripts/biz_daily.py +590 -105
- package/scripts/chat_notes.py +470 -0
- package/scripts/classify_daily.py +10 -18
- package/scripts/compile_wiki.py +1059 -46
- package/scripts/create_reading_notes.py +199 -14
- package/scripts/decide.py +253 -0
- package/scripts/draft_reply.py +671 -0
- package/scripts/export_chat_html.py +35 -33
- package/scripts/extract_todos.py +31 -5
- package/scripts/fav_notes.py +318 -0
- package/scripts/fix_topics.py +2 -1
- package/scripts/generate_ai_report.py +214 -9
- package/scripts/generate_html.py +18 -3
- package/scripts/generate_review.py +2 -2
- package/scripts/jev_client.py +294 -0
- package/scripts/jev_probe.py +440 -0
- package/scripts/nt_common.py +82 -0
- package/scripts/nt_decrypt.py +1755 -1460
- package/scripts/pipeline.py +27 -2
- package/scripts/promote_ideas.py +19 -4
- package/scripts/quality_eval.py +707 -0
- package/scripts/rag_chat.py +4 -4
- package/scripts/read_image.py +187 -0
- package/scripts/reply_debt.py +488 -0
- package/scripts/route_cards.py +453 -0
- package/scripts/semantic_search.py +208 -49
- package/scripts/user_notes.py +315 -0
- package/scripts/vault_rag.py +13 -7
- package/scripts/vault_search.py +21 -4
- package/scripts/wiki_lint.py +397 -0
- package/src/core/appMsgFormat.ts +117 -0
- package/src/core/ntCore.ts +27 -2
- package/src/core/sqlcipherCore.ts +5 -61
- package/src/core/wcdbCore.ts +6 -2
- package/src/panel/client.ts +49 -0
- package/src/panel/endpoint.ts +144 -0
- package/src/panel/launch.ts +101 -0
- package/src/panel/server.ts +424 -0
- package/src/panel/userId.ts +72 -0
- package/src/services/assistantDaemon.ts +57 -9
- package/src/services/assistantEval.ts +611 -0
- package/src/services/assistantMemory.ts +413 -57
- package/src/services/assistantPrivacy.ts +18 -4
- package/src/services/assistantRouter.ts +191 -0
- package/src/services/assistantService.ts +671 -74
- package/src/services/assistantTools.ts +1118 -57
- package/src/services/assistantTrace.ts +175 -0
- package/src/services/chatService.ts +6 -2
- package/src/services/configService.ts +403 -385
- package/src/services/pythonBridge.ts +139 -0
- package/src/services/syncService.ts +37 -9
- package/src/services/wechatMessageService.ts +73 -7
- package/src/utils/atomicWrite.ts +56 -0
- package/src/utils/dateRange.ts +46 -0
- package/src/utils/text.ts +20 -0
package/scripts/biz_daily.py
CHANGED
|
@@ -22,12 +22,18 @@ import re
|
|
|
22
22
|
import time
|
|
23
23
|
import urllib.request
|
|
24
24
|
import base64
|
|
25
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
25
26
|
from datetime import datetime, timezone, timedelta
|
|
26
27
|
from pathlib import Path
|
|
27
28
|
|
|
28
29
|
# 公共工具
|
|
29
30
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
30
|
-
from _utils import call_deepseek, load_config, decrypt_lock, get_api_key,
|
|
31
|
+
from _utils import (call_deepseek, load_config, decrypt_lock, get_api_key,
|
|
32
|
+
write_with_frontmatter, format_wikilinks,
|
|
33
|
+
TOPICS, TOPIC_CRITERIA, DEFAULT_TOPIC,
|
|
34
|
+
RELEVANCE_NAMES, DEFAULT_RELEVANCE,
|
|
35
|
+
excluded_topics, record_source_topics, load_source_topics,
|
|
36
|
+
source_prior_candidates, source_topics_path)
|
|
31
37
|
|
|
32
38
|
try:
|
|
33
39
|
from sqlcipher3 import dbapi2 as sqlcipher
|
|
@@ -47,16 +53,20 @@ except ImportError:
|
|
|
47
53
|
# ====== Config ======
|
|
48
54
|
|
|
49
55
|
SCRIPT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
50
|
-
|
|
56
|
+
# 配置一律走 _utils.load_config()(它自己解析路径)。这里原先还有一份 CONFIG_PATH,
|
|
57
|
+
# 定义了但没人用过。
|
|
51
58
|
DB_PATH = None # auto-detect from config
|
|
52
59
|
OUTPUT_ROOT = os.path.join(SCRIPT_DIR, 'output', 'biz-daily')
|
|
53
|
-
MAX_ARTICLES = 50
|
|
60
|
+
# 这里曾有一个 MAX_ARTICLES = 50,注释写着"最多抓取篇数"——**它从来没有被读过**。
|
|
61
|
+
# 真正生效的是 --limit(默认 0 = 不限),所以一天 150–270 篇是常态。一个写着上限
|
|
62
|
+
# 却不生效的常量,比没有它更误导人。
|
|
54
63
|
FETCH_TIMEOUT = 15
|
|
55
64
|
DEEPSEEK_TIMEOUT = 60
|
|
56
65
|
FETCH_DELAY_MIN = 8 # 最小抓取间隔 (秒)
|
|
57
66
|
FETCH_DELAY_MAX = 12 # 最大抓取间隔 (秒)
|
|
58
67
|
|
|
59
|
-
TOPICS
|
|
68
|
+
# TOPICS 现在从 _utils 导入(见文件头的 import)——分类法只有一份定义,
|
|
69
|
+
# 因为"散文写的枚举"已经漂过一次:这里曾硬编码五类,而 TOPICS 是六类。
|
|
60
70
|
|
|
61
71
|
|
|
62
72
|
def load_source_categories(config: dict) -> dict[str, str]:
|
|
@@ -114,18 +124,289 @@ def _guess_topic(article: dict) -> str:
|
|
|
114
124
|
return '新闻'
|
|
115
125
|
|
|
116
126
|
|
|
127
|
+
def _tags_for_write(article, topic):
|
|
128
|
+
"""两个写入者共用的 tags 取值。
|
|
129
|
+
|
|
130
|
+
md 与 json 原本各写各的默认:md 是 `a.get('tags', [topic])`,json 是
|
|
131
|
+
`article.get('tags', [])`。**键不存在时**(`--no-ai`、或没配 API key,
|
|
132
|
+
Phase 2 整段不跑,就不会有任何分类路径去设 `tags`)同一篇文章于是变成
|
|
133
|
+
md `tags: [学术]`、json `tags: []`。2026-09-04 的 177 篇就是这个样子。
|
|
134
|
+
|
|
135
|
+
回落成 `[主题]` 而不是 `[]`,是沿用本仓库既有的约定:分类路径自己就是
|
|
136
|
+
`a['tags'] = [a['topic']]`(三处)。这里只是让 json 那个写入者跟上。
|
|
137
|
+
|
|
138
|
+
注意用的是 `.get(k, 默认)` 而不是 `... or [topic]`:键**存在但为空表**
|
|
139
|
+
(模型一个标签都没抽出来)是另一种情况,两条路都保留空表,不要在这里合流。
|
|
140
|
+
"""
|
|
141
|
+
return article.get('tags', [topic])
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _serializable_article(article, date_str):
|
|
145
|
+
"""一篇文章 -> `.articles.json` 里的那条记录。
|
|
146
|
+
|
|
147
|
+
**判断的原始概率必须一起写进去。** 报告优先读这个文件,只把概率写进 md 的
|
|
148
|
+
frontmatter 的话,它们在报告那条主路径上等于不存在——真的发生过:日报末尾的
|
|
149
|
+
"我拿不准的"永远只输出一句"没有概率字段",而 frontmatter 里明明有。
|
|
150
|
+
"""
|
|
151
|
+
entry = {
|
|
152
|
+
'title': article.get('title', ''),
|
|
153
|
+
'source': article.get('account_name', ''),
|
|
154
|
+
'date': date_str,
|
|
155
|
+
'time': article.get('time', ''),
|
|
156
|
+
# 默认值与分组/md 那条路**必须是同一个**。这里曾经是 `''`,而分组那边是
|
|
157
|
+
# `'学术'`,于是同一次运行里 md 写 `topic: 学术`、json 写 `topic: ""`。
|
|
158
|
+
'topic': article.get('topic') or DEFAULT_TOPIC,
|
|
159
|
+
'relevance': article.get('relevance', DEFAULT_RELEVANCE),
|
|
160
|
+
'tags': _tags_for_write(article, article.get('topic') or DEFAULT_TOPIC),
|
|
161
|
+
'summary': article.get('summary', article.get('digest', '')),
|
|
162
|
+
'url': article.get('url', ''),
|
|
163
|
+
}
|
|
164
|
+
for key in ('relevanceScore', 'topicConfidence', 'includeScore'):
|
|
165
|
+
if article.get(key) is not None:
|
|
166
|
+
entry[key] = round(float(article[key]), 3)
|
|
167
|
+
return entry
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _normalize_topics(articles):
|
|
171
|
+
"""把每篇的主题定稿到 `TOPICS` 之内,**就地**改,返回兜底的篇数。
|
|
172
|
+
|
|
173
|
+
在写入之前只跑这一次,分组、md frontmatter、json 三条路都读它的结果。
|
|
174
|
+
|
|
175
|
+
以前没有这一步,主题在写入路径上有三个来源,而且都不报错:
|
|
176
|
+
|
|
177
|
+
* 分组键:`a.get('topic','学术')`,越界再折成 `'学术'` —— md 写的是它
|
|
178
|
+
* json:`a.get('topic','')` —— 空字符串
|
|
179
|
+
* 原始字段:可能是空的,也可能不在分类法里
|
|
180
|
+
|
|
181
|
+
2026-09-04 的产出就是这么分叉的:178 篇全落在 `学术/`、frontmatter 写
|
|
182
|
+
`topic: 学术`(其中两篇是「OpenAI 深夜发布 GPT-6」「专为高管准备的 AI 助手」),
|
|
183
|
+
而同一批的 json 全是 `topic: ""` —— 报告读 json,整批被静默排除。
|
|
184
|
+
|
|
185
|
+
返回值不为 0 就是"这一批有文章没拿到主题"的信号,调用方要把它说出来:
|
|
186
|
+
整批兜底(`--no-ai` 就是)不该看起来像一次正常分类。
|
|
187
|
+
"""
|
|
188
|
+
fallbacks = 0
|
|
189
|
+
for a in articles:
|
|
190
|
+
if a.get('topic') not in TOPICS:
|
|
191
|
+
a['topic'] = DEFAULT_TOPIC
|
|
192
|
+
fallbacks += 1
|
|
193
|
+
return fallbacks
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _group_by_topic(articles):
|
|
197
|
+
"""归一化主题并按主题分桶,返回 `(分组, 兜底篇数)`。
|
|
198
|
+
|
|
199
|
+
**分组键就是 md 写进 frontmatter 的那个主题**(见 Phase 3 的 `fm['topic']`),
|
|
200
|
+
所以这张表和 `.articles.json` 里的 `topic` 必须指的是同一个值——md 与 json
|
|
201
|
+
对同一篇文章给出不同主题,就是从这里分叉出去的。
|
|
202
|
+
|
|
203
|
+
归一化放在函数内部而不是让调用方分两步做:两步就能被写颠倒(先分组再归一化,
|
|
204
|
+
键就落在归一化之前的旧值上),而这类颠倒不会报错。合成一个入口之后,
|
|
205
|
+
"分组用的主题"和"落盘用的主题"在结构上不可能不是同一个。
|
|
206
|
+
"""
|
|
207
|
+
fallbacks = _normalize_topics(articles)
|
|
208
|
+
groups = {t: [] for t in TOPICS}
|
|
209
|
+
for a in articles:
|
|
210
|
+
# 直取不兜底:归一化刚保证过成员资格,这里再兜一次就等于又有第二个默认值。
|
|
211
|
+
groups[a['topic']].append(a)
|
|
212
|
+
return groups, fallbacks
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _classify_with_jev(client, title, body, topics):
|
|
216
|
+
"""让 Jev 判断这一篇;**问不出来就返回 None**,由调用方逐篇退回老路。
|
|
217
|
+
|
|
218
|
+
这里刻意吞掉异常(包括 `JevError` 之外的意外),因为"分类这一篇失败"
|
|
219
|
+
不该让整天的日报挂掉。但异常类型会打出来——一个真的写错了的行号不该
|
|
220
|
+
在日志里长得跟"网络抖了一下"一样。
|
|
221
|
+
"""
|
|
222
|
+
if client is None:
|
|
223
|
+
return None
|
|
224
|
+
try:
|
|
225
|
+
return client.decide_article(title, body, topics)
|
|
226
|
+
except Exception as error:
|
|
227
|
+
print(f' [WARN] Jev 分类失败({type(error).__name__}),本篇退回 LLM 解析:{error}')
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# 分类是纯网络等待(实测 0.97s/篇),而且对摘要没有任何依赖,所以没有理由
|
|
232
|
+
# 把它排在那条串行的 LLM 循环里一篇一篇等。并发数克制一些:服务 2026-09-15 才上线,
|
|
233
|
+
# 打满了会返回 529(已实测遇到过),并发拉高只会换来一堆重试。
|
|
234
|
+
JEV_WORKERS = 6
|
|
235
|
+
# 图片并发。取的是 qpic.cn(微信 CDN),浏览器本来就并发取图;串行只是实现选择。
|
|
236
|
+
# 实测每张 0.37s、135 KB、一篇 14–31 张,串行下来 190 篇约 18 分钟。
|
|
237
|
+
IMAGE_WORKERS = 6
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def classifier_plan(no_ai, classifier, deepseek_key, engine, needs_llm=True):
|
|
241
|
+
"""这次日报用哪条判断路径。返回 `'skip' | 'jev' | 'llm'`。
|
|
242
|
+
|
|
243
|
+
这是 D-031 那句"`--classifier` 是一条命令回滚"的**全部实现**——三条分支此前
|
|
244
|
+
没有任何测试,而回滚承诺就压在它们上面:
|
|
245
|
+
|
|
246
|
+
* `'llm'` 必须**不碰 Jev**(一键回退,也是"同一天两条路各跑一遍"的做法);
|
|
247
|
+
* `'jev'` 只是**意图**,不是结果——没有 key、或 key 解不开时,建客户端会失败,
|
|
248
|
+
由调用方按 `classifier == 'jev'` 决定要不要告警,然后安静退回老路(`auto` 的语义);
|
|
249
|
+
* `'skip'` 让**整块 Phase 2 都不执行**:`--no-ai`,或 `dailyAiEnabled=false`
|
|
250
|
+
(那个配置项在更早处被折进 `no_ai`)。
|
|
251
|
+
|
|
252
|
+
`needs_llm=False`(`--no-summary`:只要判断、不要生成)时,**不再要求 LLM key**——
|
|
253
|
+
那一层的 key 校验是为了调 LLM 生成摘要,而这条路上根本不生成。少了这一句,
|
|
254
|
+
"没有 DeepSeek key 也能只用 Jev 分类"这个诉求会静默变成"整段不跑"。
|
|
255
|
+
|
|
256
|
+
单独成函数是为了能**离线枚举**这几种组合——它们决定"有没有数据出境",
|
|
257
|
+
不该只靠读代码确认。
|
|
258
|
+
"""
|
|
259
|
+
if no_ai:
|
|
260
|
+
return 'skip'
|
|
261
|
+
if needs_llm and not (deepseek_key or engine != 'deepseek'):
|
|
262
|
+
return 'skip'
|
|
263
|
+
return 'jev' if classifier in ('auto', 'jev') else 'llm'
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _classify_articles_parallel(articles, client, topics, workers=JEV_WORKERS):
|
|
267
|
+
"""把能分类的篇目一次性并发问完,返回 {下标: decision}。
|
|
268
|
+
|
|
269
|
+
分类对摘要没有任何依赖,两者串在一起只是历史顺序。单独跑这一步之后,
|
|
270
|
+
串行的摘要循环里就只剩它真正必须串行的那部分。
|
|
271
|
+
|
|
272
|
+
eligibility 与主循环保持一致(正文存在且够长)——不一致的话,某些文章会在这
|
|
273
|
+
一步被跳过、然后在循环里又被问一次,等于白花一次调用。
|
|
274
|
+
"""
|
|
275
|
+
if client is None:
|
|
276
|
+
return {}
|
|
277
|
+
todo = []
|
|
278
|
+
for index, article in enumerate(articles):
|
|
279
|
+
content = article.get('fetched_md') or article.get('local_text', '')
|
|
280
|
+
if content and len(content.strip()) > 50:
|
|
281
|
+
todo.append((index, article.get('title', ''), content))
|
|
282
|
+
if not todo:
|
|
283
|
+
return {}
|
|
284
|
+
|
|
285
|
+
started = time.time()
|
|
286
|
+
decisions = {}
|
|
287
|
+
with ThreadPoolExecutor(max_workers=max(1, workers)) as pool:
|
|
288
|
+
futures = {
|
|
289
|
+
pool.submit(_classify_with_jev, client, title, body, topics): index
|
|
290
|
+
for index, title, body in todo
|
|
291
|
+
}
|
|
292
|
+
for future in as_completed(futures):
|
|
293
|
+
decisions[futures[future]] = future.result()
|
|
294
|
+
|
|
295
|
+
ok = sum(1 for value in decisions.values() if value)
|
|
296
|
+
# 先报个数:并发之后逐篇日志没有意义了,但这三件事必须看得见。
|
|
297
|
+
print(f' 分类完成 {ok}/{len(todo)} 篇,耗时 {time.time() - started:.1f}s'
|
|
298
|
+
f'({workers} 并发;串行约需 {len(todo) * 0.97:.0f}s)')
|
|
299
|
+
return decisions
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _apply_decision(article, decision, set_topic=True):
|
|
303
|
+
"""把 Jev 的判断写进 article。`set_topic=False` 用于主题已被人工配置钉住的场景。"""
|
|
304
|
+
if not decision:
|
|
305
|
+
return False
|
|
306
|
+
if set_topic:
|
|
307
|
+
article['topic'] = decision['topic']
|
|
308
|
+
article['relevance'] = decision['relevance']
|
|
309
|
+
# 原始分与置信度一起落盘:相关度那三档的切点是暂定的,留着原始值,
|
|
310
|
+
# 将来校准不用重跑整天的日报。
|
|
311
|
+
article['relevanceScore'] = decision.get('relevanceScore')
|
|
312
|
+
if decision.get('topicConfidence') is not None:
|
|
313
|
+
article['topicConfidence'] = decision['topicConfidence']
|
|
314
|
+
# 收录判断与 relatedness 不是一回事,单独存一个原始概率给日报那道门用。
|
|
315
|
+
if decision.get('includeScore') is not None:
|
|
316
|
+
article['includeScore'] = decision['includeScore']
|
|
317
|
+
return True
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
SUMMARY_WORKERS = 6
|
|
321
|
+
# 单篇摘要的实测耗时(3 篇串行 2.27/2.92/2.45s,含网络)。只用来报「串行约需多少秒」。
|
|
322
|
+
SUMMARY_SECONDS_PER_ARTICLE = 2.5
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def summary_prompt_for(article, category_hint, judged=False):
|
|
326
|
+
"""这一篇要发的提示词与 `max_tokens`。
|
|
327
|
+
|
|
328
|
+
三条分支:
|
|
329
|
+
* 配了类别的来源 → 短提示词(只要摘要,`max_tokens=1000`);
|
|
330
|
+
* `judged=True`(Jev 已经判过这一篇)→ `SUMMARY_ONLY_PROMPT`,不再要它写主题/相关度;
|
|
331
|
+
* 其余(`--classifier llm`,或单篇 Jev 失败退回来的)→ 完整的 `TOPIC_PROMPT`,
|
|
332
|
+
**与旧行为逐字节一致**,回退路径不能换标准。
|
|
333
|
+
|
|
334
|
+
抽出来是为了让"取摘要"能并发:搬动的是**调用位置**,不是内容。
|
|
335
|
+
"""
|
|
336
|
+
content = article.get('fetched_md') or article.get('local_text', '')
|
|
337
|
+
if category_hint:
|
|
338
|
+
return (f'''请只为下面这篇公众号文章生成一段 50-300 字的中文摘要。
|
|
339
|
+
来源类别已经确定为「{category_hint}」,不要重新判断或改写文章分类,不要输出主题、标签、相关度或概念字段。
|
|
340
|
+
|
|
341
|
+
标题:{article["title"]}
|
|
342
|
+
来源:{article["account_name"]}
|
|
343
|
+
|
|
344
|
+
正文:
|
|
345
|
+
{content[:4000]}''', 1000)
|
|
346
|
+
base = SUMMARY_ONLY_PROMPT if judged else TOPIC_PROMPT
|
|
347
|
+
prompt = base + f'\n\n标题:{article["title"]}\n来源:{article["account_name"]}'
|
|
348
|
+
prompt += f'\n\n内容:\n{content[:4000]}'
|
|
349
|
+
return prompt, 2000
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _summarise_articles_parallel(articles, engine, api_key, workers=SUMMARY_WORKERS,
|
|
353
|
+
decisions=None):
|
|
354
|
+
"""把每篇的 LLM 调用先并发跑完,返回与 `articles` 等长的 `(response, error)`。
|
|
355
|
+
|
|
356
|
+
**只有网络等待是并发的**:调用方仍按原顺序串行地解析与落字段,所以每篇的写入
|
|
357
|
+
顺序、以及失败时走哪条兜底分支,都与串行版本一致。这一层不碰 article dict。
|
|
358
|
+
|
|
359
|
+
资格判断必须与调用方**逐字一致**(`content and len(content.strip()) > 50`),
|
|
360
|
+
否则会出现"并发跑了、主循环却不认为该跑"的错位——那正是 `_classify_articles_parallel`
|
|
361
|
+
里用下标对齐要防的东西,这里同样按**原下标**回填。
|
|
362
|
+
|
|
363
|
+
实测(12 篇):串行约 12s → 6 路约 3s。串行时每篇后睡 0.3s 的节奏挪到了
|
|
364
|
+
worker 里(每个请求照样睡一次),所以对上游的请求速率没有变密。
|
|
365
|
+
"""
|
|
366
|
+
results = [(None, None)] * len(articles)
|
|
367
|
+
jobs = [i for i, a in enumerate(articles)
|
|
368
|
+
if (a.get('fetched_md') or a.get('local_text', ''))
|
|
369
|
+
and len((a.get('fetched_md') or a.get('local_text', '')).strip()) > 50]
|
|
370
|
+
if not jobs:
|
|
371
|
+
return results
|
|
372
|
+
|
|
373
|
+
from _utils import call_ai
|
|
374
|
+
|
|
375
|
+
def one(index):
|
|
376
|
+
article = articles[index]
|
|
377
|
+
# 逐篇判断,不是整批一刀切:Jev 判过的那篇用精简提示词,判失败的仍用完整提示词
|
|
378
|
+
# (它要靠 LLM 的【主题】/【相关度】兜底)。
|
|
379
|
+
judged = bool((decisions or {}).get(index))
|
|
380
|
+
prompt, max_tokens = summary_prompt_for(article, article.get('source_category', ''),
|
|
381
|
+
judged=judged)
|
|
382
|
+
try:
|
|
383
|
+
return index, call_ai(prompt, engine, api_key, max_tokens=max_tokens), None
|
|
384
|
+
except Exception as exc: # 交给调用方那条原有的 except 分支
|
|
385
|
+
return index, None, exc
|
|
386
|
+
finally:
|
|
387
|
+
time.sleep(0.3)
|
|
388
|
+
|
|
389
|
+
started = time.time()
|
|
390
|
+
with ThreadPoolExecutor(max_workers=max(1, workers)) as pool:
|
|
391
|
+
for index, response, error in pool.map(one, jobs):
|
|
392
|
+
results[index] = (response, error)
|
|
393
|
+
|
|
394
|
+
ok = sum(1 for response, _error in results if response is not None)
|
|
395
|
+
# 与分类那段同一个形状:并发之后逐篇日志不再说明什么,但三件事必须看得见——
|
|
396
|
+
# 成了几篇、实际耗时、串行本来要多久。
|
|
397
|
+
print(f' 摘要完成 {ok}/{len(jobs)} 篇,耗时 {time.time() - started:.1f}s'
|
|
398
|
+
f'({workers} 并发;串行约需 {len(jobs) * SUMMARY_SECONDS_PER_ARTICLE:.0f}s)')
|
|
399
|
+
return results
|
|
400
|
+
|
|
401
|
+
|
|
117
402
|
TOPIC_PROMPT = f"""对文章分类、深度摘要、打标签,并评估与读者的相关度。
|
|
118
403
|
|
|
119
404
|
【读者定位】环境科学研究生,研究方向是计算机与环境的交叉领域(环境模型、大气污染模拟、遥感反演、环境大数据分析、LCA等),关注AI工具如何提升科研效率。
|
|
120
405
|
|
|
121
406
|
【主题】必须且只能是:{' / '.join(TOPICS)} 中的一个词。
|
|
122
407
|
|
|
123
|
-
|
|
124
|
-
-
|
|
125
|
-
- 投资:股票基金/融资/经济分析/商业市场
|
|
126
|
-
- 新闻:时事政策/社会热点/娱乐/招聘促销/会议通知
|
|
127
|
-
- 文学:散文小说/美食旅游/生活随笔/历史文化
|
|
128
|
-
- 学术:科研论文/期刊文章/实验室成果/学术会议/高校研究(环境/气候/地理/海洋/生态/化学/材料/生物医学等)
|
|
408
|
+
判断规则(每一类都必须落到其中一条):
|
|
409
|
+
{chr(10).join('- %s:%s' % (name, desc) for name, desc in TOPIC_CRITERIA.items())}
|
|
129
410
|
|
|
130
411
|
【相关度】对上述读者的实用价值:
|
|
131
412
|
- 高:可直接用于科研(新工具/新方法/数据源/代码库)
|
|
@@ -133,7 +414,7 @@ TOPIC_PROMPT = f"""对文章分类、深度摘要、打标签,并评估与读
|
|
|
133
414
|
- 低:信息性阅读(纯新闻/娱乐/文学)
|
|
134
415
|
|
|
135
416
|
**分类关键**:
|
|
136
|
-
- 【主题】只写一个词(
|
|
417
|
+
- 【主题】只写一个词({'/'.join(TOPICS)}),不要写其他文字
|
|
137
418
|
- 【相关度】只写一个词(高/中/低)
|
|
138
419
|
- 科研论文、期刊文章优先归学术;AI技术/工具/产品归AI
|
|
139
420
|
|
|
@@ -149,6 +430,29 @@ TOPIC_PROMPT = f"""对文章分类、深度摘要、打标签,并评估与读
|
|
|
149
430
|
【摘要】【核心观点】一句话。【关键细节】1. 要点一;2. 要点二;3. 要点三
|
|
150
431
|
【概念】概念名|一句话说明, 概念名|一句话说明"""
|
|
151
432
|
|
|
433
|
+
# Jev 已经判过主题与相关度时,给 LLM 的提示词改成这一份:**不再要它输出那两个字段**。
|
|
434
|
+
#
|
|
435
|
+
# 原来无论谁判,提示词都把【主题】【相关度】连"六类判据 + 三档定义"一起塞进去,
|
|
436
|
+
# 而 Jev 那条路上这两个答案是白写的——每篇约 20 个输出 token,加上那几段判据约
|
|
437
|
+
# 300 个输入 token,一天 400 篇就是十来万 token 的纯浪费。
|
|
438
|
+
#
|
|
439
|
+
# **摘要要求那一段与 `TOPIC_PROMPT` 逐字相同**(有测试钉住)——只动分类那几段,
|
|
440
|
+
# 生成的摘要/标签/概念不受影响。`--classifier llm`、以及单篇 Jev 失败退回来的那些,
|
|
441
|
+
# 仍然用完整的 `TOPIC_PROMPT`,回退路径与旧行为逐字节一致(D-031 的约束)。
|
|
442
|
+
SUMMARY_ONLY_PROMPT = f"""为文章写深度摘要、打标签,并抽取概念。
|
|
443
|
+
|
|
444
|
+
【读者定位】环境科学研究生,研究方向是计算机与环境的交叉领域(环境模型、大气污染模拟、遥感反演、环境大数据分析、LCA等),关注AI工具如何提升科研效率。
|
|
445
|
+
|
|
446
|
+
**摘要要求**:
|
|
447
|
+
- 【摘要】写一段完整的深度摘要(150-300字),不要只写一两句
|
|
448
|
+
- 格式:【核心观点】一句话概括中心思想。【关键细节】列出3-5个具体要点(工具/方法/数据/结论/人物/事件等),每个要点一句话
|
|
449
|
+
- 摘要不需要包含分类/相关度/标签信息,那些由上面的字段处理
|
|
450
|
+
|
|
451
|
+
返回格式(严格):
|
|
452
|
+
【标签】tag1, tag2, tag3
|
|
453
|
+
【摘要】【核心观点】一句话。【关键细节】1. 要点一;2. 要点二;3. 要点三
|
|
454
|
+
【概念】概念名|一句话说明, 概念名|一句话说明"""
|
|
455
|
+
|
|
152
456
|
# ====== Helpers ======
|
|
153
457
|
|
|
154
458
|
|
|
@@ -214,6 +518,37 @@ WECHAT_UA = (
|
|
|
214
518
|
'MicroMessenger/8.0.38(0x18002633) NetType/WIFI Language/zh_CN'
|
|
215
519
|
)
|
|
216
520
|
|
|
521
|
+
def _decode_body(raw: bytes, content_encoding: str) -> str:
|
|
522
|
+
"""按 `Content-Encoding` 解压响应体。认不出来就原样解码。
|
|
523
|
+
|
|
524
|
+
**只认我们主动要求的两种**(`gzip`/`deflate`)——请求里声明的就是这两种,
|
|
525
|
+
服务器不该回别的;真回了不认识的,退回原样解码而不是抛错:那就会走
|
|
526
|
+
`fetch_article` 的重试与回退,把一次"少传了字节"的优化变成"抓不到文章"。
|
|
527
|
+
"""
|
|
528
|
+
enc = (content_encoding or '').strip().lower()
|
|
529
|
+
if enc == 'gzip':
|
|
530
|
+
import gzip
|
|
531
|
+
raw = gzip.decompress(raw)
|
|
532
|
+
elif enc == 'deflate':
|
|
533
|
+
import zlib
|
|
534
|
+
# 裸 deflate(无 zlib 头)是这里的常见形态,先按裸的试,再退回带头的。
|
|
535
|
+
try:
|
|
536
|
+
raw = zlib.decompress(raw, -zlib.MAX_WBITS)
|
|
537
|
+
except zlib.error:
|
|
538
|
+
raw = zlib.decompress(raw)
|
|
539
|
+
return raw.decode('utf-8', errors='ignore')
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def summary_section(summary):
|
|
543
|
+
"""md 正文里的摘要段。**空就不写这一段**(`--no-summary` 就是这种情况)。
|
|
544
|
+
|
|
545
|
+
为什么要单独一条规则:没有摘要时,把标题写上去会看起来像"摘要生成失败",而用
|
|
546
|
+
本地 digest 顶上又会看起来像 AI 写的摘要——两者都是把不存在的东西说成存在。
|
|
547
|
+
判据用"去空白后非空",顺带挡住空串与纯空白。
|
|
548
|
+
"""
|
|
549
|
+
return f'## AI 摘要\n\n{summary}\n\n' if str(summary or '').strip() else ''
|
|
550
|
+
|
|
551
|
+
|
|
217
552
|
def fetch_article(url: str, max_retries: int = 3) -> str | None:
|
|
218
553
|
"""Fetch WeChat article with WeChat browser UA to bypass WAF.
|
|
219
554
|
Retries up to max_retries times if content is too short."""
|
|
@@ -227,9 +562,16 @@ def fetch_article(url: str, max_retries: int = 3) -> str | None:
|
|
|
227
562
|
'Origin': 'https://mp.weixin.qq.com',
|
|
228
563
|
'Accept': 'text/html,application/xhtml+xml',
|
|
229
564
|
'Accept-Language': 'zh-CN,zh;q=0.9',
|
|
565
|
+
# **`urllib` 默认不发这个头**,于是服务器只好把整页未压缩地传过来。
|
|
566
|
+
# 微信文章页是 3–4 MB(大量内联 JS/CSS),实测同一篇:不发头 33–40s,
|
|
567
|
+
# 发了 gzip 后 0.7–0.8 MB / 6–10s——正文一模一样(解压后同尺寸,
|
|
568
|
+
# `js_content` 与 `rich_media_content` 都在)。传的字节更少,
|
|
569
|
+
# 上游压力是**变小**的,与"按篇节流"同一个方向。
|
|
570
|
+
'Accept-Encoding': 'gzip, deflate',
|
|
230
571
|
})
|
|
231
572
|
with urllib.request.urlopen(req, timeout=FETCH_TIMEOUT) as resp:
|
|
232
|
-
|
|
573
|
+
raw = resp.read()
|
|
574
|
+
html = _decode_body(raw, resp.headers.get('Content-Encoding', ''))
|
|
233
575
|
|
|
234
576
|
# Validate: article content must include js_content div
|
|
235
577
|
if 'js_content' not in html and 'rich_media_content' not in html:
|
|
@@ -297,31 +639,85 @@ def fetch_article(url: str, max_retries: int = 3) -> str | None:
|
|
|
297
639
|
return None
|
|
298
640
|
|
|
299
641
|
|
|
300
|
-
|
|
301
|
-
|
|
642
|
+
FETCH_CACHE_DIR = os.path.join(SCRIPT_DIR, 'output', '.cache', 'fetch')
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def fetch_article_cached(url: str, use_cache: bool = True) -> tuple[str | None, bool]:
|
|
646
|
+
"""抓正文,带本地缓存。返回 `(markdown 或 None, 是否命中缓存)`。
|
|
647
|
+
|
|
648
|
+
**为什么需要它**:日报是**全有全无**的——要把当天的文章**全部抓完**才进 Phase 3
|
|
649
|
+
写盘,抓取结果只存在内存里。一天 400 篇光是抓取(10s/篇 + 8–12s 节流)就一个多
|
|
650
|
+
小时,任何中断(被收走、网络断、手滑)都会把前面的抓取全部作废。2026-09-22 那天
|
|
651
|
+
实测停在 105/403,什么都没写出来。
|
|
652
|
+
按 URL 缓存正文之后:**重跑只抓缺的那些**,也顺带让"改代码 / 换分类器 /
|
|
653
|
+
`--no-summary` 与正常模式之间切换"重跑同一天几乎免费。
|
|
654
|
+
|
|
655
|
+
**只缓存成功**:失败的下次照旧重试(`None` 不落盘)。
|
|
656
|
+
"""
|
|
657
|
+
path = os.path.join(FETCH_CACHE_DIR, hashlib.md5(url.encode()).hexdigest() + '.md')
|
|
658
|
+
if use_cache and os.path.isfile(path) and os.path.getsize(path) > 0:
|
|
659
|
+
try:
|
|
660
|
+
with open(path, encoding='utf-8') as fh:
|
|
661
|
+
body = fh.read()
|
|
662
|
+
if body.strip():
|
|
663
|
+
return body, True
|
|
664
|
+
except OSError:
|
|
665
|
+
pass # 读不了就当没缓存,走网络
|
|
666
|
+
body = fetch_article(url)
|
|
667
|
+
if body and body.strip():
|
|
668
|
+
try:
|
|
669
|
+
os.makedirs(FETCH_CACHE_DIR, exist_ok=True)
|
|
670
|
+
with open(path, 'w', encoding='utf-8') as fh:
|
|
671
|
+
fh.write(body)
|
|
672
|
+
except OSError:
|
|
673
|
+
pass # 缓存写不进去不该让这一天的日报失败
|
|
674
|
+
return body, False
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def _download_one_image(url: str, local_path: Path) -> bool:
|
|
678
|
+
"""取一张图。成功返回 True;失败**不抛**——一张图挂了不该影响这一篇的其它图。"""
|
|
679
|
+
try:
|
|
680
|
+
req = urllib.request.Request(url, headers={
|
|
681
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
|
682
|
+
'Referer': 'https://mp.weixin.qq.com/',
|
|
683
|
+
})
|
|
684
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
685
|
+
data = resp.read(10 * 1024 * 1024) # max 10MB
|
|
686
|
+
if data:
|
|
687
|
+
local_path.write_bytes(data)
|
|
688
|
+
return True
|
|
689
|
+
except Exception:
|
|
690
|
+
pass
|
|
691
|
+
return False
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def download_images_to_local(markdown: str, images_dir: Path,
|
|
695
|
+
workers: int = IMAGE_WORKERS) -> tuple[str, dict]:
|
|
696
|
+
"""Download mmbiz images to local directory. Returns (markdown, url_mapping).
|
|
697
|
+
|
|
698
|
+
**并发取图**(`IMAGE_WORKERS`):串行只是实现选择,实测 190 篇约 18 分钟。
|
|
699
|
+
|
|
700
|
+
只登记**真的在本地存在**的文件。原先每张图在下载前就写进映射,于是下载失败的
|
|
701
|
+
也留一条——而映射会被注入阅读器(`window._IMG_MAP`,见 `generate_html.py`),
|
|
702
|
+
让页面去找一个不存在的文件。保留远程链接才是对的:阅读器按远程取照样能看。
|
|
703
|
+
"""
|
|
302
704
|
url_mapping = {} # remote_url -> local_rel_path
|
|
303
705
|
|
|
304
706
|
if not markdown:
|
|
305
707
|
return markdown, url_mapping
|
|
306
708
|
|
|
307
|
-
# Create images directory
|
|
308
709
|
images_dir.mkdir(parents=True, exist_ok=True)
|
|
309
710
|
|
|
310
|
-
|
|
311
|
-
img_pattern = re.compile(r'!\[(.*?)\]\((https?://[^)]+)\)')
|
|
312
|
-
img_matches = img_pattern.findall(markdown)
|
|
313
|
-
|
|
711
|
+
img_matches = re.compile(r'!\[(.*?)\]\((https?://[^)]+)\)').findall(markdown)
|
|
314
712
|
if not img_matches:
|
|
315
713
|
return markdown, url_mapping
|
|
316
714
|
|
|
317
|
-
|
|
318
|
-
for
|
|
715
|
+
targets, seen = [], set()
|
|
716
|
+
for _alt, url in img_matches:
|
|
319
717
|
# Process any qpic.cn (mmbiz/mmecoa) images
|
|
320
|
-
if '.qpic.cn' not in url:
|
|
718
|
+
if '.qpic.cn' not in url or url in seen:
|
|
321
719
|
continue
|
|
322
|
-
|
|
323
|
-
# Generate filename from URL hash
|
|
324
|
-
url_hash = hashlib.md5(url.encode()).hexdigest()[:12]
|
|
720
|
+
seen.add(url) # 同一张图在页面上可能出现多次,只取一次
|
|
325
721
|
ext = '.jpg' # default
|
|
326
722
|
if '.png' in url or 'wx_fmt=png' in url:
|
|
327
723
|
ext = '.png'
|
|
@@ -329,33 +725,28 @@ def download_images_to_local(markdown: str, images_dir: Path) -> tuple[str, dict
|
|
|
329
725
|
ext = '.gif'
|
|
330
726
|
elif '.webp' in url or 'wx_fmt=webp' in url:
|
|
331
727
|
ext = '.webp'
|
|
728
|
+
local_filename = f'{hashlib.md5(url.encode()).hexdigest()[:12]}{ext}'
|
|
729
|
+
targets.append((url, images_dir / local_filename, f'images/{local_filename}'))
|
|
332
730
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
# Store mapping
|
|
338
|
-
url_mapping[url] = rel_path
|
|
339
|
-
|
|
340
|
-
# Skip if already downloaded
|
|
731
|
+
downloaded_count = 0
|
|
732
|
+
todo = []
|
|
733
|
+
for url, local_path, rel_path in targets:
|
|
734
|
+
# Skip if already downloaded——重跑同一天时这一步就把它们全跳过了
|
|
341
735
|
if local_path.exists() and local_path.stat().st_size > 0:
|
|
736
|
+
url_mapping[url] = rel_path
|
|
342
737
|
downloaded_count += 1
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
local_path.write_bytes(data)
|
|
738
|
+
else:
|
|
739
|
+
todo.append((url, local_path, rel_path))
|
|
740
|
+
|
|
741
|
+
if todo:
|
|
742
|
+
with ThreadPoolExecutor(max_workers=max(1, workers)) as pool:
|
|
743
|
+
futures = {pool.submit(_download_one_image, url, path): (url, rel_path)
|
|
744
|
+
for url, path, rel_path in todo}
|
|
745
|
+
for future in as_completed(futures):
|
|
746
|
+
url, rel_path = futures[future]
|
|
747
|
+
if future.result():
|
|
748
|
+
url_mapping[url] = rel_path
|
|
355
749
|
downloaded_count += 1
|
|
356
|
-
except Exception as e:
|
|
357
|
-
# Keep original URL if download fails
|
|
358
|
-
pass
|
|
359
750
|
|
|
360
751
|
if downloaded_count > 0:
|
|
361
752
|
print(f' 图片下载: {downloaded_count}张')
|
|
@@ -461,6 +852,14 @@ def main():
|
|
|
461
852
|
parser.add_argument('--api-key', help='AI API key (或设环境变量 DEEPSEEK_API_KEY)')
|
|
462
853
|
parser.add_argument('--engine', default='deepseek', help='AI 引擎: local/deepseek/claude/ollama')
|
|
463
854
|
parser.add_argument('--no-ai', action='store_true', help='关闭摘要、分类和日报简报的 AI 调用')
|
|
855
|
+
parser.add_argument('--no-fetch-cache', action='store_true',
|
|
856
|
+
help='忽略正文抓取缓存,强制重新抓取(默认命中缓存,用于续跑与重跑)')
|
|
857
|
+
parser.add_argument('--no-summary', action='store_true',
|
|
858
|
+
help='只要判断、不要生成:完全跳过 LLM 调用(摘要/标签/概念/简报),'
|
|
859
|
+
'主题与相关度仍由 Jev 判断;因此**不需要 DeepSeek key**')
|
|
860
|
+
parser.add_argument('--classifier', choices=['auto', 'llm', 'jev'], default='auto',
|
|
861
|
+
help='主题/相关度由谁判断:auto=配了 TypeSafe key 就用 Jev(默认),'
|
|
862
|
+
'llm=沿用 LLM 解析路径,jev=强制 Jev')
|
|
464
863
|
parser.add_argument('--source', action='append', default=[], metavar='NAME',
|
|
465
864
|
help='仅处理指定公众号,可重复或用逗号分隔;默认读取配置 dailySources')
|
|
466
865
|
args = parser.parse_args()
|
|
@@ -486,7 +885,9 @@ def main():
|
|
|
486
885
|
# API key — only required for cloud engines
|
|
487
886
|
engine = args.engine or 'deepseek'
|
|
488
887
|
api_key = args.api_key or os.environ.get('DEEPSEEK_API_KEY', '') or get_api_key(config)
|
|
489
|
-
|
|
888
|
+
# `--no-summary` 下不生成任何文字,也就不需要 LLM key——这条校验是"要调 LLM"的前提,
|
|
889
|
+
# 不是"要跑 Phase 2"的前提。不改这一句,"没有 DeepSeek 也能只用 Jev 分类"会被它挡死。
|
|
890
|
+
if not args.dry_run and not args.no_summary and engine in ('deepseek', 'claude') and not api_key:
|
|
490
891
|
print(f'[ERROR] --engine {engine} 需要 API key。请通过 --api-key、环境变量或配置文件提供')
|
|
491
892
|
sys.exit(1)
|
|
492
893
|
# Auto-detect local engine if no api_key and engine is deepseek
|
|
@@ -612,53 +1013,106 @@ def main():
|
|
|
612
1013
|
|
|
613
1014
|
# ====== Phase 1: Fetch all articles ======
|
|
614
1015
|
print(f'=== Phase 1: 抓取 {len(articles)} 篇文章 ===\n')
|
|
1016
|
+
cache_hits = 0
|
|
615
1017
|
for i, a in enumerate(articles):
|
|
616
1018
|
t, n, ti = a['time'], a['account_name'], a['title']
|
|
617
1019
|
print(f'[{i+1}/{len(articles)}] [{t}] {n} - {ti[:50]}')
|
|
618
1020
|
|
|
619
1021
|
if a['url']:
|
|
620
1022
|
delay = FETCH_DELAY_MIN + random.random() * (FETCH_DELAY_MAX - FETCH_DELAY_MIN)
|
|
621
|
-
md =
|
|
1023
|
+
md, cached = fetch_article_cached(a['url'], use_cache=not args.no_fetch_cache)
|
|
622
1024
|
if md:
|
|
623
1025
|
a['fetched_md'] = md
|
|
624
|
-
|
|
1026
|
+
if cached:
|
|
1027
|
+
cache_hits += 1
|
|
1028
|
+
print(f' OK ({len(md)}字, 缓存)')
|
|
1029
|
+
else:
|
|
1030
|
+
print(f' OK ({len(md)}字, {delay:.1f}s)')
|
|
625
1031
|
else:
|
|
626
1032
|
print(f' FAIL, 回退本地缓存')
|
|
627
|
-
|
|
1033
|
+
# **命中缓存就不睡**:节流是为了少打扰上游,而缓存命中根本没有请求。
|
|
1034
|
+
# 这也正是"重跑快"的来源——续跑那部分几乎是瞬时的,且与节流同向。
|
|
1035
|
+
if not cached:
|
|
1036
|
+
time.sleep(delay)
|
|
628
1037
|
elif a.get('local_text'):
|
|
629
1038
|
print(f' 无URL, 使用本地缓存')
|
|
630
1039
|
|
|
1040
|
+
if cache_hits:
|
|
1041
|
+
print(f' 抓取完成:{len(articles) - cache_hits} 篇走网络,'
|
|
1042
|
+
f'{cache_hits} 篇命中本地缓存(少发 {cache_hits} 次请求)')
|
|
1043
|
+
|
|
631
1044
|
# ====== Phase 2: AI summary + topic classification ======
|
|
632
|
-
|
|
633
|
-
|
|
1045
|
+
# **实际服务我们的判断模型**(如 `jev-1.13.0`),不是请求的别名(`jev-latest`)。
|
|
1046
|
+
# 别名会漂:今天跑的和测过的可能不是同一个东西,而结果看起来一切正常。落盘它,
|
|
1047
|
+
# 是为了让这件事**可见**。(没走 Jev 时保持 None,如实表示"没用判断模型"。)
|
|
1048
|
+
decision_model = None
|
|
1049
|
+
plan = classifier_plan(args.no_ai, args.classifier, api_key, engine,
|
|
1050
|
+
needs_llm=not args.no_summary)
|
|
1051
|
+
if plan != 'skip':
|
|
1052
|
+
stage = '只分类(--no-summary)' if args.no_summary else 'AI 摘要 + 主题分类'
|
|
1053
|
+
print(f'\n=== Phase 2: {stage} (engine={engine}) ===\n')
|
|
634
1054
|
from _utils import call_ai
|
|
635
|
-
|
|
1055
|
+
from jev_client import create_client
|
|
1056
|
+
# 判断交给 Jev,生成留给 LLM。没配 key 就整条走老路(auto 的语义)。
|
|
1057
|
+
jev_client = create_client(config=config) if plan == 'jev' else None
|
|
1058
|
+
if jev_client is not None:
|
|
1059
|
+
tail = '本次不生成摘要' if args.no_summary else f'生成摘要仍用 {engine}'
|
|
1060
|
+
print(f' 分类:Jev(model={jev_client.model},{tail})')
|
|
1061
|
+
elif args.classifier == 'jev':
|
|
1062
|
+
print(' [WARN] --classifier jev 但没找到 TypeSafe key,本次退回 LLM 解析路径')
|
|
1063
|
+
# 先把分类并发跑完,再进串行的摘要循环。分类对摘要没有任何依赖,
|
|
1064
|
+
# 串行做就是把 N 次网络等待排在 N 次 LLM 调用后面。
|
|
1065
|
+
decisions = _classify_articles_parallel(articles, jev_client, TOPICS)
|
|
1066
|
+
if jev_client is not None:
|
|
1067
|
+
decision_model = jev_client.last_model
|
|
1068
|
+
if args.no_summary:
|
|
1069
|
+
# **只要判断、不要生成**:不调 LLM,也不留摘要。`prefetched = None` 让下面
|
|
1070
|
+
# 那个串行循环整段跳过——它是围绕 LLM 响应写的,没有响应就没有它的事。
|
|
1071
|
+
judged = 0
|
|
1072
|
+
for i, a in enumerate(articles):
|
|
1073
|
+
if _apply_decision(a, decisions.get(i)):
|
|
1074
|
+
judged += 1
|
|
1075
|
+
# **明确写空**而不是留着不写:md 那条路的缺省是本地 digest、json 那条路
|
|
1076
|
+
# 另有缺省,写空才能让两边都如实表示"没有摘要",而不是拿平台摘要冒充。
|
|
1077
|
+
a['summary'] = ''
|
|
1078
|
+
print(f' 判出 {judged}/{len(articles)} 篇;未生成:摘要、标签、概念')
|
|
1079
|
+
if jev_client is None:
|
|
1080
|
+
print(' [WARN] 没有 TypeSafe key,本次**没有任何判断**——只抓取与归档,'
|
|
1081
|
+
'主题与相关度保持默认值')
|
|
1082
|
+
prefetched = None
|
|
1083
|
+
else:
|
|
1084
|
+
# 先把每篇的 LLM 调用并发跑完,再进下面这个串行循环做解析与落字段。
|
|
1085
|
+
# 解析是纯本地操作,并发的价值全在网络等待上;这样循环体本身不用重写,
|
|
1086
|
+
# 每条兜底分支的行为也就与串行版本一致。
|
|
1087
|
+
prefetched = _summarise_articles_parallel(articles, engine, api_key,
|
|
1088
|
+
decisions=decisions)
|
|
1089
|
+
# `--no-summary` 时 prefetched 是 None,这个循环整段不跑(循环体是围绕 LLM
|
|
1090
|
+
# 响应写的)。用变量而不是把循环体缩进进 if,是为了让 diff 只碰这一行。
|
|
1091
|
+
pending = list(enumerate(articles)) if prefetched is not None else []
|
|
1092
|
+
for i, a in pending:
|
|
636
1093
|
t, n, ti = a['time'], a['account_name'], a['title']
|
|
637
1094
|
content = a.get('fetched_md') or a.get('local_text', '')
|
|
638
1095
|
if content and len(content.strip()) > 50:
|
|
639
1096
|
try:
|
|
640
1097
|
category_hint = a.get('source_category', '')
|
|
1098
|
+
response, call_error = prefetched[i]
|
|
1099
|
+
if call_error is not None:
|
|
1100
|
+
# 调用是并发阶段做的,异常在那里被捕获了。这里重新抛出,
|
|
1101
|
+
# 让下面原有的 `except` 接管——兜底行为与串行版本一致。
|
|
1102
|
+
raise call_error
|
|
641
1103
|
if category_hint:
|
|
642
|
-
summary_prompt = f'''请只为下面这篇公众号文章生成一段 50-300 字的中文摘要。
|
|
643
|
-
来源类别已经确定为「{category_hint}」,不要重新判断或改写文章分类,不要输出主题、标签、相关度或概念字段。
|
|
644
|
-
|
|
645
|
-
标题:{a["title"]}
|
|
646
|
-
来源:{a["account_name"]}
|
|
647
|
-
|
|
648
|
-
正文:
|
|
649
|
-
{content[:4000]}'''
|
|
650
|
-
response = call_ai(summary_prompt, engine, api_key, max_tokens=1000)
|
|
651
1104
|
a['topic'] = category_hint
|
|
652
1105
|
a['tags'] = [category_hint]
|
|
653
1106
|
a['summary'] = response.strip()[:1000]
|
|
654
|
-
|
|
1107
|
+
# 主题是人工配的强约束,不动;但相关度以前是硬编码的「中」,
|
|
1108
|
+
# 而那正是"全库 relevance 都是中"的成因之一,交给 Jev。
|
|
1109
|
+
_apply_decision(a, decisions.get(i), set_topic=False)
|
|
655
1110
|
a['concepts'] = []
|
|
656
1111
|
print(f'[{i+1}/{len(articles)}] [{t}] {n} - [{category_hint}] 固定来源类别,仅生成摘要')
|
|
657
|
-
time.sleep(0.3)
|
|
658
1112
|
continue
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
1113
|
+
# (提示词与调用已移到 `_summarise_articles_parallel`,响应在上面取。)
|
|
1114
|
+
# 判断来自开头那轮并发分类;没拿到才回落到下面的解析。
|
|
1115
|
+
decision = decisions.get(i)
|
|
662
1116
|
|
|
663
1117
|
# Parse response: 【主题】xxx 【相关度】xxx 【标签】xxx 【摘要】xxx 【概念】xxx
|
|
664
1118
|
topic_match = re.search(r'【主题】\s*(.+)', response)
|
|
@@ -668,8 +1122,8 @@ def main():
|
|
|
668
1122
|
summary_match = re.search(r'【摘要】\s*(.+?)(?=\n【(?:主题|相关度|标签|概念)】|$)', response, re.DOTALL)
|
|
669
1123
|
concepts_match = re.search(r'【概念】\s*(.+)', response, re.DOTALL)
|
|
670
1124
|
|
|
671
|
-
if
|
|
672
|
-
a
|
|
1125
|
+
if decision:
|
|
1126
|
+
_apply_decision(a, decision)
|
|
673
1127
|
elif topic_match:
|
|
674
1128
|
raw_topic = topic_match.group(1).strip()
|
|
675
1129
|
if raw_topic in TOPICS:
|
|
@@ -688,9 +1142,11 @@ def main():
|
|
|
688
1142
|
a['topic'] = _guess_topic(a)
|
|
689
1143
|
|
|
690
1144
|
# Parse relevance: 高/中/低
|
|
691
|
-
if
|
|
1145
|
+
if decision:
|
|
1146
|
+
pass # 已由 _apply_decision 写入
|
|
1147
|
+
elif relevance_match:
|
|
692
1148
|
raw_rel = relevance_match.group(1).strip()
|
|
693
|
-
if raw_rel in
|
|
1149
|
+
if raw_rel in RELEVANCE_NAMES:
|
|
694
1150
|
a['relevance'] = raw_rel
|
|
695
1151
|
elif '高' in raw_rel:
|
|
696
1152
|
a['relevance'] = '高'
|
|
@@ -699,9 +1155,9 @@ def main():
|
|
|
699
1155
|
elif '低' in raw_rel:
|
|
700
1156
|
a['relevance'] = '低'
|
|
701
1157
|
else:
|
|
702
|
-
a['relevance'] =
|
|
1158
|
+
a['relevance'] = DEFAULT_RELEVANCE
|
|
703
1159
|
else:
|
|
704
|
-
a['relevance'] =
|
|
1160
|
+
a['relevance'] = DEFAULT_RELEVANCE
|
|
705
1161
|
|
|
706
1162
|
# Parse tags: comma-separated, clean up
|
|
707
1163
|
if category_hint:
|
|
@@ -731,23 +1187,27 @@ def main():
|
|
|
731
1187
|
a['summary'] = response[:300]
|
|
732
1188
|
|
|
733
1189
|
print(f'[{i+1}/{len(articles)}] [{t}] {n} - [{a.get("topic","?")}] tags={a.get("tags",[])} ({len(a.get("summary",""))}字)')
|
|
734
|
-
time.sleep(0.3)
|
|
735
1190
|
except Exception as e:
|
|
736
1191
|
a['summary'] = a.get('digest', '') or content[:300]
|
|
737
|
-
a['topic'] = a.get('source_category') or
|
|
1192
|
+
a['topic'] = a.get('source_category') or DEFAULT_TOPIC
|
|
738
1193
|
a['tags'] = [a['topic']]
|
|
739
1194
|
a['concepts'] = []
|
|
1195
|
+
# 这三条兜底路径以前完全不设 relevance,靠落盘时的默认值兜成「中」。
|
|
1196
|
+
# 显式写出来,并保留 Jev 已经给出的那一份(setdefault 而不是赋值)。
|
|
1197
|
+
a.setdefault('relevance', DEFAULT_RELEVANCE)
|
|
740
1198
|
print(f'[{i+1}/{len(articles)}] [{t}] {n} - ERR: {e}')
|
|
741
1199
|
elif content:
|
|
742
1200
|
a['summary'] = content[:400]
|
|
743
|
-
a['topic'] = a.get('source_category') or
|
|
1201
|
+
a['topic'] = a.get('source_category') or DEFAULT_TOPIC
|
|
1202
|
+
a.setdefault('relevance', DEFAULT_RELEVANCE)
|
|
744
1203
|
else:
|
|
745
1204
|
a['summary'] = a.get('digest', '(无内容)')
|
|
746
|
-
a['topic'] = a.get('source_category') or
|
|
1205
|
+
a['topic'] = a.get('source_category') or DEFAULT_TOPIC
|
|
1206
|
+
a.setdefault('relevance', DEFAULT_RELEVANCE)
|
|
747
1207
|
|
|
748
1208
|
# Print topic distribution
|
|
749
1209
|
from collections import Counter
|
|
750
|
-
topic_counts = Counter(a.get('topic'
|
|
1210
|
+
topic_counts = Counter(a.get('topic') or DEFAULT_TOPIC for a in articles)
|
|
751
1211
|
print(f'\n 主题分布: {dict(topic_counts)}')
|
|
752
1212
|
|
|
753
1213
|
# ====== Phase 3: Write files (by topic folders) ======
|
|
@@ -757,29 +1217,16 @@ def main():
|
|
|
757
1217
|
for topic in TOPICS:
|
|
758
1218
|
(out_dir / topic).mkdir(parents=True, exist_ok=True)
|
|
759
1219
|
|
|
760
|
-
#
|
|
761
|
-
topic_groups =
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
1220
|
+
# --- 主题在这里定稿:**分组、md frontmatter、json 三条路都只读这一次的结果** ---
|
|
1221
|
+
topic_groups, fallback_count = _group_by_topic(articles)
|
|
1222
|
+
if fallback_count:
|
|
1223
|
+
# 说出来,别让它看起来像一次正常分类:`--no-ai`、没配 key、或者来源配置里
|
|
1224
|
+
# 写了个不在分类法里的词,都会整批落到兜底类。
|
|
1225
|
+
print(f' [WARN] {fallback_count}/{len(articles)} 篇没有可用主题,'
|
|
1226
|
+
f'已归入兜底类「{DEFAULT_TOPIC}」(不是判断结果)')
|
|
767
1227
|
|
|
768
1228
|
# --- 写入结构化 JSON:一次提取,多次复用(供 AI 报告等下游使用) ---
|
|
769
|
-
serializable = []
|
|
770
|
-
for a in articles:
|
|
771
|
-
entry = {
|
|
772
|
-
'title': a.get('title', ''),
|
|
773
|
-
'source': a.get('account_name', ''),
|
|
774
|
-
'date': date_str,
|
|
775
|
-
'time': a.get('time', ''),
|
|
776
|
-
'topic': a.get('topic', ''),
|
|
777
|
-
'relevance': a.get('relevance', '中'),
|
|
778
|
-
'tags': a.get('tags', []),
|
|
779
|
-
'summary': a.get('summary', a.get('digest', '')),
|
|
780
|
-
'url': a.get('url', ''),
|
|
781
|
-
}
|
|
782
|
-
serializable.append(entry)
|
|
1229
|
+
serializable = [_serializable_article(a, date_str) for a in articles]
|
|
783
1230
|
|
|
784
1231
|
json_path = out_dir / '.articles.json'
|
|
785
1232
|
try:
|
|
@@ -787,6 +1234,8 @@ def main():
|
|
|
787
1234
|
json.dump({
|
|
788
1235
|
'generated_at': datetime.now(timezone(timedelta(hours=8))).strftime('%Y-%m-%d %H:%M:%S'),
|
|
789
1236
|
'date': date_str,
|
|
1237
|
+
# 只增字段:老读者不认识它会忽略;但"这次是谁判的"从此有据可查。
|
|
1238
|
+
'decisionModel': decision_model,
|
|
790
1239
|
'articles': serializable,
|
|
791
1240
|
}, f, ensure_ascii=False, indent=2)
|
|
792
1241
|
print(f' ✓ 结构化数据: {json_path} ({len(serializable)} 篇)')
|
|
@@ -815,7 +1264,7 @@ def main():
|
|
|
815
1264
|
print(f' [SKIP] 内容过短 ({len(body_text)}字): {a["title"]}')
|
|
816
1265
|
continue
|
|
817
1266
|
summary = a.get('summary', a.get('digest', ''))
|
|
818
|
-
tags = a
|
|
1267
|
+
tags = _tags_for_write(a, topic)
|
|
819
1268
|
concepts = a.get('concepts', [])
|
|
820
1269
|
|
|
821
1270
|
fm = {
|
|
@@ -823,10 +1272,18 @@ def main():
|
|
|
823
1272
|
'source': f'"{a["account_name"]}"',
|
|
824
1273
|
'date': date_str,
|
|
825
1274
|
'topic': topic,
|
|
826
|
-
'relevance': a.get('relevance',
|
|
1275
|
+
'relevance': a.get('relevance', DEFAULT_RELEVANCE),
|
|
827
1276
|
'tags': tags,
|
|
828
1277
|
'created': date_str,
|
|
829
1278
|
}
|
|
1279
|
+
# 只增字段:下游只做 fm.get('relevance') 的字面量比较,不认识的键会被忽略。
|
|
1280
|
+
# 不留这两个值,Jev 带来的概率信息就在落盘这一步丢掉了。
|
|
1281
|
+
if a.get('relevanceScore') is not None:
|
|
1282
|
+
fm['relevanceScore'] = round(float(a['relevanceScore']), 3)
|
|
1283
|
+
if a.get('topicConfidence') is not None:
|
|
1284
|
+
fm['topicConfidence'] = round(float(a['topicConfidence']), 3)
|
|
1285
|
+
if a.get('includeScore') is not None:
|
|
1286
|
+
fm['includeScore'] = round(float(a['includeScore']), 3)
|
|
830
1287
|
if a['url']:
|
|
831
1288
|
fm['url'] = f'"{a["url"]}"'
|
|
832
1289
|
|
|
@@ -836,7 +1293,7 @@ def main():
|
|
|
836
1293
|
if a['url']:
|
|
837
1294
|
body_parts.append(f'> 原文:[阅读原文]({a["url"]})\n')
|
|
838
1295
|
body_parts.append('\n---\n\n')
|
|
839
|
-
body_parts.append(
|
|
1296
|
+
body_parts.append(summary_section(summary))
|
|
840
1297
|
|
|
841
1298
|
if concepts:
|
|
842
1299
|
body_parts.append(format_wikilinks(concepts))
|
|
@@ -944,7 +1401,9 @@ def main():
|
|
|
944
1401
|
break
|
|
945
1402
|
|
|
946
1403
|
highlight_text = '\n'.join(highlights[:25])
|
|
947
|
-
|
|
1404
|
+
# `--no-summary` 同样跳过简报的 LLM 调用(它是"生成"):落到下面那条由标题拼的
|
|
1405
|
+
# 确定性兜底上。兜底路径本来就在(AI 失败时走它),所以这里不新增一种产物形状。
|
|
1406
|
+
if highlight_text and not args.no_ai and not args.no_summary:
|
|
948
1407
|
try:
|
|
949
1408
|
briefing_prompt = f"""你是公众号日报助手。基于今天 {len(all_articles)} 篇文章({topic_summary}),生成一段200字以内的今日简报。
|
|
950
1409
|
|
|
@@ -1050,6 +1509,32 @@ def main():
|
|
|
1050
1509
|
with open(state_file, 'w', encoding='utf-8') as f:
|
|
1051
1510
|
json.dump(processed, f, ensure_ascii=False, indent=2)
|
|
1052
1511
|
|
|
1512
|
+
# === 来源级先验:把今天**真正归档了的**判断累加进本地表 ===
|
|
1513
|
+
#
|
|
1514
|
+
# 记"盘上真有的"(按 written_urls 对回来)而不是"抓过的":这样这张表和 output/
|
|
1515
|
+
# 里的语料一一对应,哪天想重算也重算得出来。
|
|
1516
|
+
#
|
|
1517
|
+
# 它**只记账,不跳过任何东西**。跳过是不可逆的,而这张表还在长;等某个来源
|
|
1518
|
+
# 攒够了样本(见 _utils.SOURCE_PRIOR_MIN_SAMPLES),再决定要不要拿它做拉取前的筛。
|
|
1519
|
+
pairs = [(a.get('account_name', ''), a.get('topic', '')) for a in articles
|
|
1520
|
+
if a.get('url') and a['url'] in written_urls]
|
|
1521
|
+
if pairs:
|
|
1522
|
+
summary = record_source_topics(pairs)
|
|
1523
|
+
if summary['error']:
|
|
1524
|
+
# 说清楚"没写进去":静默失败会让人以为表在长,实际一直没动。
|
|
1525
|
+
print(f' [WARN] 来源先验没写进去({summary["error"]})—— 不影响今天的日报')
|
|
1526
|
+
else:
|
|
1527
|
+
print(f' 来源先验: 今天记了 {summary["added"]} 篇,表里共 {summary["sources"]} 个来源'
|
|
1528
|
+
f'({source_topics_path()})')
|
|
1529
|
+
exclude = excluded_topics(config)
|
|
1530
|
+
rows = source_prior_candidates(load_source_topics(), exclude)
|
|
1531
|
+
if rows:
|
|
1532
|
+
print(' 来源先验: 这些来源已经稳到能判,且正落在你配的排除主题里'
|
|
1533
|
+
'(只报数,不跳过):')
|
|
1534
|
+
for source, topic, ratio, total in rows[:5]:
|
|
1535
|
+
print(f' {source} → {topic}({total} 篇里 {round(ratio * total)} 篇'
|
|
1536
|
+
f' = {ratio:.0%})')
|
|
1537
|
+
|
|
1053
1538
|
|
|
1054
1539
|
if __name__ == '__main__':
|
|
1055
1540
|
main()
|