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.
- package/LICENSE +21 -0
- package/README.md +149 -0
- package/bin/weflow-cli-electron.cjs +59 -0
- package/bin/weflow-cli.ts +2311 -0
- package/cli.cjs +3 -0
- package/dist/bin/weflow-cli.d.ts +3 -0
- package/dist/bin/weflow-cli.d.ts.map +1 -0
- package/dist/bin/weflow-cli.js +2270 -0
- package/dist/bin/weflow-cli.js.map +1 -0
- package/dist/mcp-server/index.d.ts +3 -0
- package/dist/mcp-server/index.d.ts.map +1 -0
- package/dist/mcp-server/index.js +690 -0
- package/dist/mcp-server/index.js.map +1 -0
- package/dist/src/core/dbPathService.d.ts +40 -0
- package/dist/src/core/dbPathService.d.ts.map +1 -0
- package/dist/src/core/dbPathService.js +324 -0
- package/dist/src/core/dbPathService.js.map +1 -0
- package/dist/src/core/keyService.d.ts +59 -0
- package/dist/src/core/keyService.d.ts.map +1 -0
- package/dist/src/core/keyService.js +368 -0
- package/dist/src/core/keyService.js.map +1 -0
- package/dist/src/core/ntCore.d.ts +62 -0
- package/dist/src/core/ntCore.d.ts.map +1 -0
- package/dist/src/core/ntCore.js +191 -0
- package/dist/src/core/ntCore.js.map +1 -0
- package/dist/src/core/sqlcipherCore.d.ts +119 -0
- package/dist/src/core/sqlcipherCore.d.ts.map +1 -0
- package/dist/src/core/sqlcipherCore.js +693 -0
- package/dist/src/core/sqlcipherCore.js.map +1 -0
- package/dist/src/core/wcdbCore.d.ts +738 -0
- package/dist/src/core/wcdbCore.d.ts.map +1 -0
- package/dist/src/core/wcdbCore.js +4545 -0
- package/dist/src/core/wcdbCore.js.map +1 -0
- package/dist/src/core/wechatClient.d.ts +23 -0
- package/dist/src/core/wechatClient.d.ts.map +1 -0
- package/dist/src/core/wechatClient.js +166 -0
- package/dist/src/core/wechatClient.js.map +1 -0
- package/dist/src/services/chatService.d.ts +25 -0
- package/dist/src/services/chatService.d.ts.map +1 -0
- package/dist/src/services/chatService.js +291 -0
- package/dist/src/services/chatService.js.map +1 -0
- package/dist/src/services/configService.d.ts +43 -0
- package/dist/src/services/configService.d.ts.map +1 -0
- package/dist/src/services/configService.js +156 -0
- package/dist/src/services/configService.js.map +1 -0
- package/dist/src/services/exportService.d.ts +30 -0
- package/dist/src/services/exportService.d.ts.map +1 -0
- package/dist/src/services/exportService.js +273 -0
- package/dist/src/services/exportService.js.map +1 -0
- package/dist/src/services/wechat-formatter.d.ts +69 -0
- package/dist/src/services/wechat-formatter.d.ts.map +1 -0
- package/dist/src/services/wechat-formatter.js +248 -0
- package/dist/src/services/wechat-formatter.js.map +1 -0
- package/dist/src/services/wechatMessageService.d.ts +28 -0
- package/dist/src/services/wechatMessageService.d.ts.map +1 -0
- package/dist/src/services/wechatMessageService.js +341 -0
- package/dist/src/services/wechatMessageService.js.map +1 -0
- package/dist/src/services/wereadService.d.ts +206 -0
- package/dist/src/services/wereadService.d.ts.map +1 -0
- package/dist/src/services/wereadService.js +145 -0
- package/dist/src/services/wereadService.js.map +1 -0
- package/dist/src/services/whitelistService.d.ts +20 -0
- package/dist/src/services/whitelistService.d.ts.map +1 -0
- package/dist/src/services/whitelistService.js +60 -0
- package/dist/src/services/whitelistService.js.map +1 -0
- package/dist/src/types.d.ts +140 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +2 -0
- package/dist/src/types.js.map +1 -0
- package/dist/src/utils/errors.d.ts +25 -0
- package/dist/src/utils/errors.d.ts.map +1 -0
- package/dist/src/utils/errors.js +43 -0
- package/dist/src/utils/errors.js.map +1 -0
- package/dist/src/utils/pathUtils.d.ts +5 -0
- package/dist/src/utils/pathUtils.d.ts.map +1 -0
- package/dist/src/utils/pathUtils.js +16 -0
- package/dist/src/utils/pathUtils.js.map +1 -0
- package/dist/src/utils/pythonRunner.d.ts +27 -0
- package/dist/src/utils/pythonRunner.d.ts.map +1 -0
- package/dist/src/utils/pythonRunner.js +62 -0
- package/dist/src/utils/pythonRunner.js.map +1 -0
- package/dist/src/utils/talkerUtils.d.ts +14 -0
- package/dist/src/utils/talkerUtils.d.ts.map +1 -0
- package/dist/src/utils/talkerUtils.js +73 -0
- package/dist/src/utils/talkerUtils.js.map +1 -0
- package/mcp-server/index.ts +757 -0
- package/package.json +72 -0
- package/resources/key/win32/x64/wx_key.dll +0 -0
- package/resources/wcdb/win32/x64/SDL2.dll +0 -0
- package/resources/wcdb/win32/x64/WCDB.dll +0 -0
- package/resources/wcdb/win32/x64/msvcp140.dll +0 -0
- package/resources/wcdb/win32/x64/msvcp140_1.dll +0 -0
- package/resources/wcdb/win32/x64/vcruntime140.dll +0 -0
- package/resources/wcdb/win32/x64/vcruntime140_1.dll +0 -0
- package/resources/wcdb/win32/x64/wcdb_api.dll +0 -0
- package/scripts/_batch_link_sources.py +73 -0
- package/scripts/_utils.py +531 -0
- package/scripts/annual_report.py +796 -0
- package/scripts/auto_tag.py +128 -0
- package/scripts/biz_daily.py +896 -0
- package/scripts/chat_report.py +397 -0
- package/scripts/chat_stats.py +702 -0
- package/scripts/classify_daily.py +411 -0
- package/scripts/compile_wiki.py +258 -0
- package/scripts/create_reading_notes.py +370 -0
- package/scripts/enrich_backlinks.py +163 -0
- package/scripts/export_chat_html.py +766 -0
- package/scripts/extract_3x_key.py +86 -0
- package/scripts/extract_todos.py +404 -0
- package/scripts/fav_server.py +392 -0
- package/scripts/fix_topics.py +111 -0
- package/scripts/generate_ai_report.py +367 -0
- package/scripts/generate_html.py +1372 -0
- package/scripts/generate_review.py +170 -0
- package/scripts/mcp_bridge.py +398 -0
- package/scripts/nt_decrypt.py +562 -0
- package/scripts/pipeline.py +145 -0
- package/scripts/promote_all.py +310 -0
- package/scripts/promote_ideas.py +297 -0
- package/scripts/rag_chat.py +191 -0
- package/scripts/scan_decrypt_4x.py +329 -0
- package/scripts/semantic_search.py +431 -0
- package/scripts/sync_fav.py +146 -0
- package/scripts/sync_weread.py +192 -0
- package/scripts/vault_rag.py +139 -0
- package/scripts/vault_search.py +141 -0
- package/src/core/dbPathService.ts +348 -0
- package/src/core/keyService.ts +409 -0
- package/src/core/ntCore.ts +224 -0
- package/src/core/sqlcipherCore.ts +764 -0
- package/src/core/wcdbCore.ts +4568 -0
- package/src/core/wechatClient.ts +204 -0
- package/src/lz4.d.ts +10 -0
- package/src/services/chatService.ts +296 -0
- package/src/services/configService.ts +197 -0
- package/src/services/exportService.ts +305 -0
- package/src/services/wechat-formatter.ts +348 -0
- package/src/services/wechatMessageService.ts +383 -0
- package/src/services/wereadService.ts +308 -0
- package/src/services/whitelistService.ts +72 -0
- package/src/types.ts +141 -0
- package/src/utils/errors.ts +42 -0
- package/src/utils/pathUtils.ts +16 -0
- package/src/utils/pythonRunner.ts +81 -0
- package/src/utils/talkerUtils.ts +84 -0
|
@@ -0,0 +1,896 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
公众号日报 — 自动抓取今日推送文章,DeepSeek 摘要,输出到日期文件夹。
|
|
4
|
+
|
|
5
|
+
用法:
|
|
6
|
+
python scripts/biz_daily.py # 今天
|
|
7
|
+
python scripts/biz_daily.py --date 2026-05-12 # 指定日期
|
|
8
|
+
python scripts/biz_daily.py --dry-run # 预览不抓取
|
|
9
|
+
|
|
10
|
+
输出:
|
|
11
|
+
output/biz-daily/YYYY-MM-DD/
|
|
12
|
+
README.md # 总索引
|
|
13
|
+
<公众号>-<标题>.md # 每篇文章
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import sys
|
|
17
|
+
import os
|
|
18
|
+
import json
|
|
19
|
+
import hashlib
|
|
20
|
+
import random
|
|
21
|
+
import re
|
|
22
|
+
import time
|
|
23
|
+
import urllib.request
|
|
24
|
+
import base64
|
|
25
|
+
from datetime import datetime, timezone, timedelta
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
# 公共工具
|
|
29
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
30
|
+
from _utils import call_deepseek, load_config, decrypt_lock, write_with_frontmatter, format_wikilinks
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
from sqlcipher3 import dbapi2 as sqlcipher
|
|
34
|
+
except ImportError:
|
|
35
|
+
print("请安装: pip install sqlcipher3")
|
|
36
|
+
sys.exit(1)
|
|
37
|
+
|
|
38
|
+
# scrapling 按需导入(仅在 fetch_article fallback 时需要)
|
|
39
|
+
# 完整安装: pip install scrapling html2text playwright
|
|
40
|
+
_FETCHER_AVAILABLE = False
|
|
41
|
+
try:
|
|
42
|
+
from scrapling.fetchers import Fetcher as _Fetcher
|
|
43
|
+
_FETCHER_AVAILABLE = True
|
|
44
|
+
except ImportError:
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
# ====== Config ======
|
|
48
|
+
|
|
49
|
+
SCRIPT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
50
|
+
CONFIG_PATH = os.path.join(os.path.expanduser('~'), '.weflow-cli', 'config.json')
|
|
51
|
+
DB_PATH = None # auto-detect from config
|
|
52
|
+
OUTPUT_ROOT = os.path.join(SCRIPT_DIR, 'output', 'biz-daily')
|
|
53
|
+
MAX_ARTICLES = 50 # 最多抓取篇数
|
|
54
|
+
FETCH_TIMEOUT = 15
|
|
55
|
+
DEEPSEEK_TIMEOUT = 60
|
|
56
|
+
FETCH_DELAY_MIN = 8 # 最小抓取间隔 (秒)
|
|
57
|
+
FETCH_DELAY_MAX = 12 # 最大抓取间隔 (秒)
|
|
58
|
+
|
|
59
|
+
TOPICS = ['AI', '学术', '新闻', '文学', '投资']
|
|
60
|
+
def _guess_topic(article: dict) -> str:
|
|
61
|
+
"""Keyword-based topic guess when AI classification fails."""
|
|
62
|
+
title = article.get('title', '')
|
|
63
|
+
account = article.get('account_name', '')
|
|
64
|
+
text = (title + ' ' + account).lower()
|
|
65
|
+
|
|
66
|
+
# Strong AI signals
|
|
67
|
+
ai_keywords = ['ai', 'agent', 'llm', 'gpt', 'claude', 'codex', 'cursor',
|
|
68
|
+
'大模型', '编程', '开源', 'skill', 'prompt', 'deepseek',
|
|
69
|
+
'copilot', 'vibe coding', 'rag', 'embedding', 'token']
|
|
70
|
+
if any(kw in text for kw in ai_keywords):
|
|
71
|
+
return 'AI'
|
|
72
|
+
|
|
73
|
+
# Investment signals
|
|
74
|
+
invest_keywords = ['股票', '基金', '融资', 'ipo', '上市', '财报', 'a股', '港股']
|
|
75
|
+
if any(kw in text for kw in invest_keywords):
|
|
76
|
+
return '投资'
|
|
77
|
+
|
|
78
|
+
# Academic signals
|
|
79
|
+
academic_keywords = ['nature', 'science', 'cell', 'est', '论文', '研究', '实验室',
|
|
80
|
+
'doi', 'et al', 'abstract', 'method', 'result', 'conclusion']
|
|
81
|
+
if any(kw in text for kw in academic_keywords):
|
|
82
|
+
return '学术'
|
|
83
|
+
|
|
84
|
+
# Literature signals
|
|
85
|
+
lit_keywords = ['小说', '散文', '诗词', '美食', '旅游', '随笔', '历史', '读书']
|
|
86
|
+
if any(kw in text for kw in lit_keywords):
|
|
87
|
+
return '文学'
|
|
88
|
+
|
|
89
|
+
# Default: news
|
|
90
|
+
return '新闻'
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
TOPIC_PROMPT = f"""对文章分类、摘要、打标签,并评估与读者的相关度。
|
|
94
|
+
|
|
95
|
+
【读者定位】环境科学研究生,研究方向是计算机与环境的交叉领域(环境模型、大气污染模拟、遥感反演、环境大数据分析、LCA等),关注AI工具如何提升科研效率。
|
|
96
|
+
|
|
97
|
+
【主题】必须且只能是:{' / '.join(TOPICS)} 中的一个词,不要写其他任何文字。
|
|
98
|
+
|
|
99
|
+
判断规则:
|
|
100
|
+
- AI:涉及AI大模型/Agent/编程/开源/科技产品/工具教程 → 归AI
|
|
101
|
+
- 投资:涉及股票基金/融资/经济分析/商业市场 → 归投资
|
|
102
|
+
- 新闻:时事政策/社会热点/娱乐圈/招聘促销/会议通知 → 归新闻
|
|
103
|
+
- 文学:散文小说/美食旅游/生活随笔/历史文化 → 归文学
|
|
104
|
+
- 学术:严格科研论文/学术期刊/实验室研究/学位论文 → 归学术(公众号文章极少属此类)
|
|
105
|
+
|
|
106
|
+
【相关度】判断这篇文章对上述读者的实用价值:
|
|
107
|
+
- 高:可直接用于科研(新工具/新方法/数据源/代码库)
|
|
108
|
+
- 中:有启发性,需转化后使用(思路/趋势/跨领域技术)
|
|
109
|
+
- 低:信息性阅读,无直接行动价值(纯新闻/娱乐/文学)
|
|
110
|
+
|
|
111
|
+
**关键**:
|
|
112
|
+
- 【主题】这一行只写一个词:AI 或 学术 或 新闻 或 文学 或 投资
|
|
113
|
+
- 【相关度】只写:高 / 中 / 低
|
|
114
|
+
- 科技报道、开发者工具、AI产品即使提到Nature/Science也归AI或新闻,不归学术
|
|
115
|
+
- 不确定时选最可能的非学术分类
|
|
116
|
+
|
|
117
|
+
返回格式(严格):
|
|
118
|
+
【主题】AI
|
|
119
|
+
【相关度】高
|
|
120
|
+
【标签】tag1, tag2, tag3
|
|
121
|
+
【摘要】2-4句中文总结
|
|
122
|
+
【概念】概念名|说明, 概念名|说明"""
|
|
123
|
+
|
|
124
|
+
# ====== Helpers ======
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def get_db_keys(config):
|
|
128
|
+
"""Extract all needed DB paths and keys from config."""
|
|
129
|
+
nt_db = config.get('ntDbPath', '')
|
|
130
|
+
msg_key_enc = config.get('ntKey', '')
|
|
131
|
+
msg_salt = config.get('ntSalt', '')
|
|
132
|
+
|
|
133
|
+
msg_key = decrypt_lock(msg_key_enc)
|
|
134
|
+
|
|
135
|
+
# contact.db: derive path from ntDbPath
|
|
136
|
+
msg_dir = os.path.dirname(nt_db.replace('\\', '/'))
|
|
137
|
+
wxid_dir = os.path.dirname(os.path.dirname(msg_dir)) # up to xwechat_files/<wxid>
|
|
138
|
+
contact_db = os.path.join(wxid_dir, 'db_storage', 'contact', 'contact.db')
|
|
139
|
+
|
|
140
|
+
contact_key_enc = config.get('contactKey', '')
|
|
141
|
+
contact_salt = config.get('contactSalt', '')
|
|
142
|
+
contact_key = decrypt_lock(contact_key_enc) if contact_key_enc else ''
|
|
143
|
+
|
|
144
|
+
# biz_message_0.db (separate key from message/contact db)
|
|
145
|
+
biz_db = os.path.join(msg_dir, 'biz_message_0.db')
|
|
146
|
+
# Try config first, then fallback to known key from scan
|
|
147
|
+
biz_key_enc = config.get('bizKey', '')
|
|
148
|
+
biz_salt = config.get('bizSalt', '')
|
|
149
|
+
if biz_key_enc and biz_salt:
|
|
150
|
+
biz_key = decrypt_lock(biz_key_enc)
|
|
151
|
+
else:
|
|
152
|
+
print('[ERROR] 缺少 biz_message_0.db 密钥,请运行: python scripts/nt_decrypt.py scan --json')
|
|
153
|
+
sys.exit(1)
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
'biz_db': biz_db, 'biz_key': biz_key, 'biz_salt': biz_salt,
|
|
157
|
+
'contact_db': contact_db, 'contact_key': contact_key, 'contact_salt': contact_salt,
|
|
158
|
+
'msg_db': nt_db, 'msg_key': msg_key, 'msg_salt': msg_salt,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
WECHAT_UA = (
|
|
163
|
+
'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) '
|
|
164
|
+
'AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 '
|
|
165
|
+
'MicroMessenger/8.0.38(0x18002633) NetType/WIFI Language/zh_CN'
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def fetch_article(url: str, max_retries: int = 3) -> str | None:
|
|
169
|
+
"""Fetch WeChat article with WeChat browser UA to bypass WAF.
|
|
170
|
+
Retries up to max_retries times if content is too short."""
|
|
171
|
+
last_result = None
|
|
172
|
+
for attempt in range(max_retries):
|
|
173
|
+
try:
|
|
174
|
+
# L1: Try direct fetch with WeChat UA headers
|
|
175
|
+
req = urllib.request.Request(url, headers={
|
|
176
|
+
'User-Agent': WECHAT_UA,
|
|
177
|
+
'Referer': 'https://mp.weixin.qq.com/',
|
|
178
|
+
'Origin': 'https://mp.weixin.qq.com',
|
|
179
|
+
'Accept': 'text/html,application/xhtml+xml',
|
|
180
|
+
'Accept-Language': 'zh-CN,zh;q=0.9',
|
|
181
|
+
})
|
|
182
|
+
with urllib.request.urlopen(req, timeout=FETCH_TIMEOUT) as resp:
|
|
183
|
+
html = resp.read().decode('utf-8', errors='ignore')
|
|
184
|
+
|
|
185
|
+
# Validate: article content must include js_content div
|
|
186
|
+
if 'js_content' not in html and 'rich_media_content' not in html:
|
|
187
|
+
print(f' [WARN] 内容验证失败 (无 js_content)')
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
# 微信文章用 data-src 懒加载图片,先提取再转 markdown
|
|
191
|
+
img_count_before = len(re.findall(r'<img\s', html, re.I))
|
|
192
|
+
html = re.sub(r'data-src="(https?://[^"]+)"', r'src="\1"', html)
|
|
193
|
+
img_count_after = len(re.findall(r'<img\s[^>]*src="https?://[^"]*mmbiz', html, re.I))
|
|
194
|
+
|
|
195
|
+
# Convert to markdown
|
|
196
|
+
import html2text
|
|
197
|
+
h = html2text.HTML2Text()
|
|
198
|
+
h.ignore_links = False
|
|
199
|
+
h.ignore_images = False
|
|
200
|
+
h.body_width = 0
|
|
201
|
+
h.protect_links = True
|
|
202
|
+
h.wrap_links = False
|
|
203
|
+
result = h.handle(html)
|
|
204
|
+
|
|
205
|
+
# 检查内容是否充足(去掉图片链接和空白后至少有 100 字)
|
|
206
|
+
body_check = re.sub(r'!\[.*?\]\(.*?\)', '', result)
|
|
207
|
+
body_check = re.sub(r'\[.*?\]\(.*?\)', '', body_check)
|
|
208
|
+
body_check = ''.join(c for c in body_check if c not in ' \n\r\t#*->|')
|
|
209
|
+
if len(body_check) < 100:
|
|
210
|
+
if attempt < max_retries - 1:
|
|
211
|
+
retry_delay = (attempt + 1) * 3
|
|
212
|
+
print(f' 内容过短({len(body_check)}字), 第{attempt+1}次重试 (等{retry_delay}s)...')
|
|
213
|
+
time.sleep(retry_delay)
|
|
214
|
+
continue
|
|
215
|
+
else:
|
|
216
|
+
print(f' 内容过短({len(body_check)}字), 已达最大重试次数')
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
# 统计结果中的图片
|
|
220
|
+
md_imgs = re.findall(r'!\[.*?\]\(https?://', result)
|
|
221
|
+
retry_suffix = f' (重试{attempt}次)' if attempt > 0 else ''
|
|
222
|
+
print(f' HTML图片: {img_count_before}个, mmbiz图片: {img_count_after}个, MD图片: {len(md_imgs)}个{retry_suffix}')
|
|
223
|
+
return result
|
|
224
|
+
except Exception as e:
|
|
225
|
+
last_result = e
|
|
226
|
+
if attempt < max_retries - 1:
|
|
227
|
+
retry_delay = (attempt + 1) * 3
|
|
228
|
+
print(f' 抓取异常, 第{attempt+1}次重试 (等{retry_delay}s): {e}')
|
|
229
|
+
time.sleep(retry_delay)
|
|
230
|
+
continue
|
|
231
|
+
|
|
232
|
+
# L2: Fallback to Scrapling with custom headers
|
|
233
|
+
if _FETCHER_AVAILABLE:
|
|
234
|
+
try:
|
|
235
|
+
page = _Fetcher.get(url)
|
|
236
|
+
import html2text
|
|
237
|
+
h = html2text.HTML2Text()
|
|
238
|
+
h.ignore_links = False
|
|
239
|
+
h.body_width = 0
|
|
240
|
+
result = h.handle(page.html_content)
|
|
241
|
+
body_check = re.sub(r'!\[.*?\]\(.*?\)', '', result)
|
|
242
|
+
body_check = ''.join(c for c in body_check if c not in ' \n\r\t#*->|')
|
|
243
|
+
if len(body_check) >= 100:
|
|
244
|
+
return result
|
|
245
|
+
except Exception:
|
|
246
|
+
pass
|
|
247
|
+
print(f' [WARN] 抓取失败: {last_result} (scrapling 未安装,无 fallback)')
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def download_images_to_local(markdown: str, images_dir: Path) -> tuple[str, dict]:
|
|
252
|
+
"""Download mmbiz images to local directory. Returns (markdown, url_mapping)."""
|
|
253
|
+
url_mapping = {} # remote_url -> local_rel_path
|
|
254
|
+
|
|
255
|
+
if not markdown:
|
|
256
|
+
return markdown, url_mapping
|
|
257
|
+
|
|
258
|
+
# Create images directory
|
|
259
|
+
images_dir.mkdir(parents=True, exist_ok=True)
|
|
260
|
+
|
|
261
|
+
# Find all image URLs in markdown
|
|
262
|
+
img_pattern = re.compile(r'!\[(.*?)\]\((https?://[^)]+)\)')
|
|
263
|
+
img_matches = img_pattern.findall(markdown)
|
|
264
|
+
|
|
265
|
+
if not img_matches:
|
|
266
|
+
return markdown, url_mapping
|
|
267
|
+
|
|
268
|
+
downloaded_count = 0
|
|
269
|
+
for alt, url in img_matches:
|
|
270
|
+
# Process any qpic.cn (mmbiz/mmecoa) images
|
|
271
|
+
if '.qpic.cn' not in url:
|
|
272
|
+
continue
|
|
273
|
+
|
|
274
|
+
# Generate filename from URL hash
|
|
275
|
+
url_hash = hashlib.md5(url.encode()).hexdigest()[:12]
|
|
276
|
+
ext = '.jpg' # default
|
|
277
|
+
if '.png' in url or 'wx_fmt=png' in url:
|
|
278
|
+
ext = '.png'
|
|
279
|
+
elif '.gif' in url or 'wx_fmt=gif' in url:
|
|
280
|
+
ext = '.gif'
|
|
281
|
+
elif '.webp' in url or 'wx_fmt=webp' in url:
|
|
282
|
+
ext = '.webp'
|
|
283
|
+
|
|
284
|
+
local_filename = f'{url_hash}{ext}'
|
|
285
|
+
local_path = images_dir / local_filename
|
|
286
|
+
rel_path = f'images/{local_filename}'
|
|
287
|
+
|
|
288
|
+
# Store mapping
|
|
289
|
+
url_mapping[url] = rel_path
|
|
290
|
+
|
|
291
|
+
# Skip if already downloaded
|
|
292
|
+
if local_path.exists() and local_path.stat().st_size > 0:
|
|
293
|
+
downloaded_count += 1
|
|
294
|
+
continue
|
|
295
|
+
|
|
296
|
+
# Download image
|
|
297
|
+
try:
|
|
298
|
+
req = urllib.request.Request(url, headers={
|
|
299
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
|
300
|
+
'Referer': 'https://mp.weixin.qq.com/',
|
|
301
|
+
})
|
|
302
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
303
|
+
data = resp.read(10 * 1024 * 1024) # max 10MB
|
|
304
|
+
if len(data) > 0:
|
|
305
|
+
local_path.write_bytes(data)
|
|
306
|
+
downloaded_count += 1
|
|
307
|
+
except Exception as e:
|
|
308
|
+
# Keep original URL if download fails
|
|
309
|
+
pass
|
|
310
|
+
|
|
311
|
+
if downloaded_count > 0:
|
|
312
|
+
print(f' 图片下载: {downloaded_count}张')
|
|
313
|
+
|
|
314
|
+
return markdown, url_mapping
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def extract_article_info(content_bytes: bytes) -> dict:
|
|
318
|
+
"""Extract title, digest, URL, full text from protobuf message_content."""
|
|
319
|
+
import zstandard as zstd
|
|
320
|
+
dctx = zstd.ZstdDecompressor()
|
|
321
|
+
try:
|
|
322
|
+
text = dctx.decompress(content_bytes).decode('utf-8', errors='ignore')
|
|
323
|
+
except:
|
|
324
|
+
text = content_bytes.decode('utf-8', errors='ignore')
|
|
325
|
+
|
|
326
|
+
info = {
|
|
327
|
+
'title': '',
|
|
328
|
+
'digest': '',
|
|
329
|
+
'url': '',
|
|
330
|
+
'cover': '',
|
|
331
|
+
'local_text': '', # locally cached body text
|
|
332
|
+
}
|
|
333
|
+
# Title
|
|
334
|
+
titles = re.findall(r'<title[^>]*><!\[CDATA\[(.*?)\]\]></title>', text)
|
|
335
|
+
if titles:
|
|
336
|
+
info['title'] = titles[0]
|
|
337
|
+
elif not titles:
|
|
338
|
+
titles_plain = re.findall(r'<title[^>]*>(.*?)</title>', text)
|
|
339
|
+
if titles_plain:
|
|
340
|
+
info['title'] = titles_plain[0]
|
|
341
|
+
|
|
342
|
+
# Full text from <des> field (some articles put full text here)
|
|
343
|
+
descs = re.findall(r'<des[^>]*><!\[CDATA\[(.*?)\]\]></des>', text, re.DOTALL)
|
|
344
|
+
if descs:
|
|
345
|
+
info['local_text'] = descs[0].strip()
|
|
346
|
+
|
|
347
|
+
# Digest (summary)
|
|
348
|
+
digests = re.findall(r'<digest[^>]*><!\[CDATA\[(.*?)\]\]></digest>', text, re.DOTALL)
|
|
349
|
+
if digests and digests[0].strip():
|
|
350
|
+
d = digests[0].strip()
|
|
351
|
+
if len(d) > len(info['local_text']):
|
|
352
|
+
info['local_text'] = d
|
|
353
|
+
elif not info['local_text']:
|
|
354
|
+
info['local_text'] = d
|
|
355
|
+
info['digest'] = d[:300]
|
|
356
|
+
|
|
357
|
+
# Summary (some articles use this instead of digest)
|
|
358
|
+
if not info['local_text']:
|
|
359
|
+
summaries = re.findall(r'<summary[^>]*><!\[CDATA\[(.*?)\]\]></summary>', text, re.DOTALL)
|
|
360
|
+
if summaries:
|
|
361
|
+
info['local_text'] = summaries[0].strip()
|
|
362
|
+
info['digest'] = summaries[0][:300]
|
|
363
|
+
|
|
364
|
+
# Content desc
|
|
365
|
+
if not info['local_text']:
|
|
366
|
+
content_descs = re.findall(r'<contentDesc>(.*?)</contentDesc>', text)
|
|
367
|
+
if content_descs:
|
|
368
|
+
info['local_text'] = content_descs[0].strip()
|
|
369
|
+
|
|
370
|
+
# URL
|
|
371
|
+
urls = re.findall(r'<url[^>]*><!\[CDATA\[(https?://[^<\]]*)\]\]></url>', text)
|
|
372
|
+
if urls:
|
|
373
|
+
info['url'] = urls[0]
|
|
374
|
+
# Cover
|
|
375
|
+
covers = re.findall(r'<cover[^>]*><!\[CDATA\[(https?://[^<\]]*)\]\]></cover>', text)
|
|
376
|
+
if covers:
|
|
377
|
+
info['cover'] = covers[0]
|
|
378
|
+
|
|
379
|
+
return info
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def sanitize_filename(name: str) -> str:
|
|
383
|
+
"""Remove characters unsafe for filenames."""
|
|
384
|
+
return re.sub(r'[\\/:*?"<>|]', '_', name)[:80]
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
# ====== Main ======
|
|
388
|
+
|
|
389
|
+
def main():
|
|
390
|
+
# Fix Windows console encoding
|
|
391
|
+
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
|
392
|
+
import argparse
|
|
393
|
+
parser = argparse.ArgumentParser(description='公众号日报')
|
|
394
|
+
parser.add_argument('--date', help='日期 YYYY-MM-DD, 默认今天')
|
|
395
|
+
parser.add_argument('--dry-run', action='store_true', help='仅预览,不抓取')
|
|
396
|
+
parser.add_argument('--limit', type=int, default=0, help='最多处理篇数 (0=不限制)')
|
|
397
|
+
parser.add_argument('--api-key', help='DeepSeek API key (或设环境变量 DEEPSEEK_API_KEY)')
|
|
398
|
+
args = parser.parse_args()
|
|
399
|
+
|
|
400
|
+
# Date
|
|
401
|
+
tz = timezone(timedelta(hours=8))
|
|
402
|
+
if args.date:
|
|
403
|
+
target_date = datetime.strptime(args.date, '%Y-%m-%d').replace(tzinfo=tz)
|
|
404
|
+
else:
|
|
405
|
+
target_date = datetime.now(tz).replace(hour=0, minute=0, second=0, microsecond=0)
|
|
406
|
+
day_start = int(target_date.timestamp())
|
|
407
|
+
day_end = day_start + 86400
|
|
408
|
+
date_str = target_date.strftime('%Y-%m-%d')
|
|
409
|
+
|
|
410
|
+
# Load config & DB keys
|
|
411
|
+
config = load_config()
|
|
412
|
+
keys = get_db_keys(config)
|
|
413
|
+
|
|
414
|
+
# API key
|
|
415
|
+
api_key = args.api_key or os.environ.get('DEEPSEEK_API_KEY', '') or config.get('deepseekApiKey', '')
|
|
416
|
+
if not args.dry_run and not api_key:
|
|
417
|
+
print('[ERROR] 缺少 DeepSeek API key。请通过 --api-key、环境变量 DEEPSEEK_API_KEY 或 ~/.weflow-cli/config.json 中的 deepseekApiKey 提供')
|
|
418
|
+
sys.exit(1)
|
|
419
|
+
|
|
420
|
+
print(f'=== 公众号日报 {date_str} ===')
|
|
421
|
+
print(f'Biz DB: {keys["biz_db"]}')
|
|
422
|
+
|
|
423
|
+
# Connect biz db
|
|
424
|
+
raw_key = f"x'{keys['biz_key']}{keys['biz_salt']}'"
|
|
425
|
+
conn = sqlcipher.connect(keys['biz_db'])
|
|
426
|
+
c = conn.cursor()
|
|
427
|
+
c.execute(f'PRAGMA key = "{raw_key}";')
|
|
428
|
+
|
|
429
|
+
# Load contact names
|
|
430
|
+
name_map = {}
|
|
431
|
+
if keys['contact_key'] and os.path.exists(keys['contact_db']):
|
|
432
|
+
contact_raw = f"x'{keys['contact_key']}{keys['contact_salt']}'"
|
|
433
|
+
try:
|
|
434
|
+
conn2 = sqlcipher.connect(keys['contact_db'])
|
|
435
|
+
c2 = conn2.cursor()
|
|
436
|
+
c2.execute(f'PRAGMA key = "{contact_raw}";')
|
|
437
|
+
c2.execute("SELECT username, COALESCE(NULLIF(remark,''), NULLIF(nick_name,''), username) FROM contact")
|
|
438
|
+
name_map = dict(c2.fetchall())
|
|
439
|
+
conn2.close()
|
|
440
|
+
except:
|
|
441
|
+
pass
|
|
442
|
+
|
|
443
|
+
# Find today's articles
|
|
444
|
+
c.execute("SELECT user_name FROM Name2Id WHERE user_name LIKE 'gh_%'")
|
|
445
|
+
biz_users = [row[0] for row in c.fetchall()]
|
|
446
|
+
|
|
447
|
+
articles = []
|
|
448
|
+
for user in biz_users:
|
|
449
|
+
tbl = 'Msg_' + hashlib.md5(user.encode()).hexdigest()
|
|
450
|
+
try:
|
|
451
|
+
c.execute(f'SELECT create_time, message_content FROM "{tbl}" WHERE create_time >= ? AND create_time < ? ORDER BY create_time',
|
|
452
|
+
(day_start, day_end))
|
|
453
|
+
for create_time, content in c.fetchall():
|
|
454
|
+
if not content:
|
|
455
|
+
continue
|
|
456
|
+
info = extract_article_info(content)
|
|
457
|
+
if not info['title']:
|
|
458
|
+
continue
|
|
459
|
+
# 过滤支付/服务通知(非真实文章)
|
|
460
|
+
t = info['title']
|
|
461
|
+
if any(t.startswith(p) for p in ('已支付', '已扣费', '支付成功', '扣费预通知', '你已关闭', '下单成功')):
|
|
462
|
+
continue
|
|
463
|
+
if '自动续费' in t and '微信支付' in name_map.get(user, user):
|
|
464
|
+
continue
|
|
465
|
+
articles.append({
|
|
466
|
+
'account': user,
|
|
467
|
+
'account_name': name_map.get(user, user),
|
|
468
|
+
'title': info['title'],
|
|
469
|
+
'digest': info['digest'],
|
|
470
|
+
'url': info['url'],
|
|
471
|
+
'cover': info['cover'],
|
|
472
|
+
'local_text': info['local_text'],
|
|
473
|
+
'time': datetime.fromtimestamp(create_time, tz=tz).strftime('%H:%M'),
|
|
474
|
+
'timestamp': create_time,
|
|
475
|
+
})
|
|
476
|
+
except:
|
|
477
|
+
pass
|
|
478
|
+
|
|
479
|
+
# Sort by time
|
|
480
|
+
articles.sort(key=lambda a: a['timestamp'])
|
|
481
|
+
conn.close()
|
|
482
|
+
|
|
483
|
+
# === Incremental dedup: load previous run state ===
|
|
484
|
+
out_dir = Path(OUTPUT_ROOT) / date_str
|
|
485
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
486
|
+
state_file = out_dir / '.run_state.json'
|
|
487
|
+
processed = {}
|
|
488
|
+
if state_file.exists():
|
|
489
|
+
with open(state_file, 'r', encoding='utf-8') as f:
|
|
490
|
+
processed = json.load(f)
|
|
491
|
+
|
|
492
|
+
new_articles = []
|
|
493
|
+
skipped = 0
|
|
494
|
+
for a in articles:
|
|
495
|
+
fp = hashlib.sha256(a.get('url', '').encode()).hexdigest()[:16]
|
|
496
|
+
if a.get('url') and fp in processed:
|
|
497
|
+
skipped += 1
|
|
498
|
+
print(f' [SKIP] {a["title"][:40]}')
|
|
499
|
+
else:
|
|
500
|
+
new_articles.append(a)
|
|
501
|
+
articles = new_articles
|
|
502
|
+
if skipped:
|
|
503
|
+
print(f' (跳过 {skipped} 篇已处理)')
|
|
504
|
+
|
|
505
|
+
# Apply limit after dedup
|
|
506
|
+
if args.limit and args.limit > 0:
|
|
507
|
+
articles = articles[:args.limit]
|
|
508
|
+
|
|
509
|
+
print(f'找到 {len(articles)} 篇文章\n')
|
|
510
|
+
|
|
511
|
+
if args.dry_run:
|
|
512
|
+
for i, a in enumerate(articles):
|
|
513
|
+
t, n, ti = a['time'], a['account_name'], a['title']
|
|
514
|
+
print(f'{i+1}. [{t}] {n} - {ti}')
|
|
515
|
+
print(f'\n共 {len(articles)} 篇 (预览模式)')
|
|
516
|
+
return
|
|
517
|
+
|
|
518
|
+
# Output folder already created above (with dedup state)
|
|
519
|
+
print(f'输出目录: {out_dir}\n')
|
|
520
|
+
|
|
521
|
+
# ====== Phase 1: Fetch all articles ======
|
|
522
|
+
print(f'=== Phase 1: 抓取 {len(articles)} 篇文章 ===\n')
|
|
523
|
+
for i, a in enumerate(articles):
|
|
524
|
+
t, n, ti = a['time'], a['account_name'], a['title']
|
|
525
|
+
print(f'[{i+1}/{len(articles)}] [{t}] {n} - {ti[:50]}')
|
|
526
|
+
|
|
527
|
+
if a['url']:
|
|
528
|
+
delay = FETCH_DELAY_MIN + random.random() * (FETCH_DELAY_MAX - FETCH_DELAY_MIN)
|
|
529
|
+
md = fetch_article(a['url'])
|
|
530
|
+
if md:
|
|
531
|
+
a['fetched_md'] = md
|
|
532
|
+
print(f' OK ({len(md)}字, {delay:.1f}s)')
|
|
533
|
+
else:
|
|
534
|
+
print(f' FAIL, 回退本地缓存')
|
|
535
|
+
time.sleep(delay)
|
|
536
|
+
elif a.get('local_text'):
|
|
537
|
+
print(f' 无URL, 使用本地缓存')
|
|
538
|
+
|
|
539
|
+
# ====== Phase 2: DeepSeek summary + topic classification ======
|
|
540
|
+
if api_key:
|
|
541
|
+
print(f'\n=== Phase 2: AI 摘要 + 主题分类 ===\n')
|
|
542
|
+
for i, a in enumerate(articles):
|
|
543
|
+
t, n, ti = a['time'], a['account_name'], a['title']
|
|
544
|
+
content = a.get('fetched_md') or a.get('local_text', '')
|
|
545
|
+
if content and len(content.strip()) > 50:
|
|
546
|
+
try:
|
|
547
|
+
prompt = TOPIC_PROMPT + f'\n\n标题:{a["title"]}\n来源:{a["account_name"]}\n\n内容:\n{content[:4000]}'
|
|
548
|
+
response = call_deepseek(prompt, api_key, max_tokens=600)
|
|
549
|
+
|
|
550
|
+
# Parse response: 【主题】xxx 【相关度】xxx 【标签】xxx 【摘要】xxx 【概念】xxx
|
|
551
|
+
topic_match = re.search(r'【主题】\s*(.+)', response)
|
|
552
|
+
relevance_match = re.search(r'【相关度】\s*(.+)', response)
|
|
553
|
+
tags_match = re.search(r'【标签】\s*(.+)', response)
|
|
554
|
+
# Stop summary at next 【tag or end
|
|
555
|
+
summary_match = re.search(r'【摘要】\s*(.+?)(?=\n【|$)', response, re.DOTALL)
|
|
556
|
+
concepts_match = re.search(r'【概念】\s*(.+)', response, re.DOTALL)
|
|
557
|
+
|
|
558
|
+
if topic_match:
|
|
559
|
+
raw_topic = topic_match.group(1).strip()
|
|
560
|
+
if raw_topic in TOPICS:
|
|
561
|
+
a['topic'] = raw_topic
|
|
562
|
+
else:
|
|
563
|
+
# Fuzzy match: try each known topic
|
|
564
|
+
matched = False
|
|
565
|
+
for t in TOPICS:
|
|
566
|
+
if t in raw_topic:
|
|
567
|
+
a['topic'] = t
|
|
568
|
+
matched = True
|
|
569
|
+
break
|
|
570
|
+
if not matched:
|
|
571
|
+
a['topic'] = _guess_topic(a)
|
|
572
|
+
else:
|
|
573
|
+
a['topic'] = _guess_topic(a)
|
|
574
|
+
|
|
575
|
+
# Parse relevance: 高/中/低
|
|
576
|
+
if relevance_match:
|
|
577
|
+
raw_rel = relevance_match.group(1).strip()
|
|
578
|
+
if raw_rel in ['高', '中', '低']:
|
|
579
|
+
a['relevance'] = raw_rel
|
|
580
|
+
elif '高' in raw_rel:
|
|
581
|
+
a['relevance'] = '高'
|
|
582
|
+
elif '中' in raw_rel:
|
|
583
|
+
a['relevance'] = '中'
|
|
584
|
+
elif '低' in raw_rel:
|
|
585
|
+
a['relevance'] = '低'
|
|
586
|
+
else:
|
|
587
|
+
a['relevance'] = '中'
|
|
588
|
+
else:
|
|
589
|
+
a['relevance'] = '中'
|
|
590
|
+
|
|
591
|
+
# Parse tags: comma-separated, clean up
|
|
592
|
+
if tags_match:
|
|
593
|
+
a['tags'] = [t.strip() for t in tags_match.group(1).split(',') if t.strip()]
|
|
594
|
+
else:
|
|
595
|
+
a['tags'] = [a['topic']]
|
|
596
|
+
|
|
597
|
+
# Parse concepts: name|desc, name|desc
|
|
598
|
+
if concepts_match:
|
|
599
|
+
concepts_raw = concepts_match.group(1).strip()
|
|
600
|
+
a['concepts'] = []
|
|
601
|
+
for pair in concepts_raw.split(','):
|
|
602
|
+
pair = pair.strip()
|
|
603
|
+
if '|' in pair:
|
|
604
|
+
name, desc = pair.split('|', 1)
|
|
605
|
+
a['concepts'].append((name.strip(), desc.strip()))
|
|
606
|
+
elif pair:
|
|
607
|
+
a['concepts'].append((pair.strip(), ''))
|
|
608
|
+
else:
|
|
609
|
+
a['concepts'] = []
|
|
610
|
+
|
|
611
|
+
if summary_match:
|
|
612
|
+
a['summary'] = summary_match.group(1).strip()
|
|
613
|
+
else:
|
|
614
|
+
a['summary'] = response[:300]
|
|
615
|
+
|
|
616
|
+
print(f'[{i+1}/{len(articles)}] [{t}] {n} - [{a.get("topic","?")}] tags={a.get("tags",[])} ({len(a.get("summary",""))}字)')
|
|
617
|
+
time.sleep(0.3)
|
|
618
|
+
except Exception as e:
|
|
619
|
+
a['summary'] = a.get('digest', '') or content[:300]
|
|
620
|
+
a['topic'] = '学术'
|
|
621
|
+
a['tags'] = ['学术']
|
|
622
|
+
a['concepts'] = []
|
|
623
|
+
print(f'[{i+1}/{len(articles)}] [{t}] {n} - ERR: {e}')
|
|
624
|
+
elif content:
|
|
625
|
+
a['summary'] = content[:400]
|
|
626
|
+
a['topic'] = '学术'
|
|
627
|
+
else:
|
|
628
|
+
a['summary'] = a.get('digest', '(无内容)')
|
|
629
|
+
a['topic'] = '学术'
|
|
630
|
+
|
|
631
|
+
# Print topic distribution
|
|
632
|
+
from collections import Counter
|
|
633
|
+
topic_counts = Counter(a.get('topic', '学术') for a in articles)
|
|
634
|
+
print(f'\n 主题分布: {dict(topic_counts)}')
|
|
635
|
+
|
|
636
|
+
# ====== Phase 3: Write files (by topic folders) ======
|
|
637
|
+
print(f'\n=== Phase 3: 写入文件 (按主题) ===\n')
|
|
638
|
+
|
|
639
|
+
# Create topic subdirs
|
|
640
|
+
for topic in TOPICS:
|
|
641
|
+
(out_dir / topic).mkdir(parents=True, exist_ok=True)
|
|
642
|
+
|
|
643
|
+
# Group articles by topic
|
|
644
|
+
topic_groups = {t: [] for t in TOPICS}
|
|
645
|
+
for a in articles:
|
|
646
|
+
t = a.get('topic', '学术')
|
|
647
|
+
if t not in topic_groups:
|
|
648
|
+
t = '学术'
|
|
649
|
+
topic_groups[t].append(a)
|
|
650
|
+
|
|
651
|
+
# --- 写入结构化 JSON:一次提取,多次复用(供 AI 报告等下游使用) ---
|
|
652
|
+
serializable = []
|
|
653
|
+
for a in articles:
|
|
654
|
+
entry = {
|
|
655
|
+
'title': a.get('title', ''),
|
|
656
|
+
'source': a.get('account_name', ''),
|
|
657
|
+
'date': date_str,
|
|
658
|
+
'time': a.get('time', ''),
|
|
659
|
+
'topic': a.get('topic', ''),
|
|
660
|
+
'relevance': a.get('relevance', '中'),
|
|
661
|
+
'tags': a.get('tags', []),
|
|
662
|
+
'summary': a.get('summary', a.get('digest', '')),
|
|
663
|
+
'url': a.get('url', ''),
|
|
664
|
+
}
|
|
665
|
+
serializable.append(entry)
|
|
666
|
+
|
|
667
|
+
json_path = out_dir / '.articles.json'
|
|
668
|
+
try:
|
|
669
|
+
with open(json_path, 'w', encoding='utf-8') as f:
|
|
670
|
+
json.dump({
|
|
671
|
+
'generated_at': datetime.now(timezone(timedelta(hours=8))).strftime('%Y-%m-%d %H:%M:%S'),
|
|
672
|
+
'date': date_str,
|
|
673
|
+
'articles': serializable,
|
|
674
|
+
}, f, ensure_ascii=False, indent=2)
|
|
675
|
+
print(f' ✓ 结构化数据: {json_path} ({len(serializable)} 篇)')
|
|
676
|
+
except Exception as e:
|
|
677
|
+
print(f' [WARN] JSON 写入失败: {e}')
|
|
678
|
+
|
|
679
|
+
# Write new article files
|
|
680
|
+
for topic in TOPICS:
|
|
681
|
+
group = topic_groups[topic]
|
|
682
|
+
if not group:
|
|
683
|
+
continue
|
|
684
|
+
for a in group:
|
|
685
|
+
safe_name = sanitize_filename(f'{a["account_name"]}-{a["title"]}')
|
|
686
|
+
file_name = f'{safe_name}.md'
|
|
687
|
+
file_path = out_dir / topic / file_name
|
|
688
|
+
|
|
689
|
+
markdown = a.get('fetched_md') or a.get('local_text', '')
|
|
690
|
+
# Skip articles with empty body content
|
|
691
|
+
if markdown:
|
|
692
|
+
body_text = markdown.strip()
|
|
693
|
+
body_text = ''.join(body_text.split('\n')) # remove newlines
|
|
694
|
+
body_text = re.sub(r'!\[.*?\]\(.*?\)', '', body_text) # remove images
|
|
695
|
+
body_text = re.sub(r'\[.*?\]\(.*?\)', '', body_text) # remove links
|
|
696
|
+
body_text = body_text.strip()
|
|
697
|
+
if len(body_text) < 100:
|
|
698
|
+
print(f' [SKIP] 内容过短 ({len(body_text)}字): {a["title"]}')
|
|
699
|
+
continue
|
|
700
|
+
summary = a.get('summary', a.get('digest', ''))
|
|
701
|
+
tags = a.get('tags', [topic])
|
|
702
|
+
concepts = a.get('concepts', [])
|
|
703
|
+
|
|
704
|
+
fm = {
|
|
705
|
+
'title': f'"{a["title"]}"',
|
|
706
|
+
'source': f'"{a["account_name"]}"',
|
|
707
|
+
'date': date_str,
|
|
708
|
+
'topic': topic,
|
|
709
|
+
'relevance': a.get('relevance', '中'),
|
|
710
|
+
'tags': tags,
|
|
711
|
+
'created': date_str,
|
|
712
|
+
}
|
|
713
|
+
if a['url']:
|
|
714
|
+
fm['url'] = f'"{a["url"]}"'
|
|
715
|
+
|
|
716
|
+
body_parts = [f'# {a["title"]}\n']
|
|
717
|
+
body_parts.append(f'> 来源:{a["account_name"]} \n')
|
|
718
|
+
body_parts.append(f'> 时间:{date_str} {a["time"]} \n')
|
|
719
|
+
if a['url']:
|
|
720
|
+
body_parts.append(f'> 原文:[阅读原文]({a["url"]})\n')
|
|
721
|
+
body_parts.append('\n---\n\n')
|
|
722
|
+
body_parts.append(f'## AI 摘要\n\n{summary}\n\n')
|
|
723
|
+
|
|
724
|
+
if concepts:
|
|
725
|
+
body_parts.append(format_wikilinks(concepts))
|
|
726
|
+
body_parts.append('\n')
|
|
727
|
+
|
|
728
|
+
if markdown:
|
|
729
|
+
# Download images to local directory
|
|
730
|
+
images_dir = out_dir / 'images'
|
|
731
|
+
markdown, url_mapping = download_images_to_local(markdown, images_dir)
|
|
732
|
+
|
|
733
|
+
# Save image mapping for HTML fallback
|
|
734
|
+
if url_mapping:
|
|
735
|
+
map_file = out_dir / '.image_map.json'
|
|
736
|
+
existing_map = {}
|
|
737
|
+
if map_file.exists():
|
|
738
|
+
try:
|
|
739
|
+
existing_map = json.loads(map_file.read_text(encoding='utf-8'))
|
|
740
|
+
except:
|
|
741
|
+
pass
|
|
742
|
+
existing_map.update(url_mapping)
|
|
743
|
+
map_file.write_text(json.dumps(existing_map, ensure_ascii=False, indent=2), encoding='utf-8')
|
|
744
|
+
|
|
745
|
+
body_parts.append('---\n\n')
|
|
746
|
+
body_parts.append('## 正文\n\n')
|
|
747
|
+
body_parts.append(markdown)
|
|
748
|
+
|
|
749
|
+
write_with_frontmatter(str(file_path), fm, ''.join(body_parts))
|
|
750
|
+
|
|
751
|
+
# Rebuild README from ALL existing md files (not just this batch)
|
|
752
|
+
index_path = out_dir / 'README.md'
|
|
753
|
+
all_articles = []
|
|
754
|
+
for topic in TOPICS:
|
|
755
|
+
topic_dir = out_dir / topic
|
|
756
|
+
if not topic_dir.is_dir():
|
|
757
|
+
continue
|
|
758
|
+
for md_file in sorted(topic_dir.glob('*.md')):
|
|
759
|
+
try:
|
|
760
|
+
content = md_file.read_text(encoding='utf-8')
|
|
761
|
+
except:
|
|
762
|
+
continue
|
|
763
|
+
# Extract title and time from frontmatter
|
|
764
|
+
title = md_file.stem
|
|
765
|
+
source = ''
|
|
766
|
+
article_time = ''
|
|
767
|
+
for line in content.split('\n'):
|
|
768
|
+
line = line.strip()
|
|
769
|
+
if line.startswith('title:'):
|
|
770
|
+
title = line.split(':', 1)[1].strip().strip('"').strip("'")
|
|
771
|
+
elif line.startswith('source:'):
|
|
772
|
+
source = line.split(':', 1)[1].strip().strip('"').strip("'")
|
|
773
|
+
elif line.startswith('date:') and source:
|
|
774
|
+
# Find time after frontmatter
|
|
775
|
+
break
|
|
776
|
+
# Extract time from markdown body
|
|
777
|
+
m = re.search(r'时间:\d{4}-\d{2}-\d{2} (\d{2}:\d{2})', content)
|
|
778
|
+
if m:
|
|
779
|
+
article_time = m.group(1)
|
|
780
|
+
all_articles.append({
|
|
781
|
+
'title': title,
|
|
782
|
+
'source': source,
|
|
783
|
+
'time': article_time,
|
|
784
|
+
'topic': topic,
|
|
785
|
+
'file': md_file.name,
|
|
786
|
+
})
|
|
787
|
+
|
|
788
|
+
# Generate AI briefing from all article files on disk
|
|
789
|
+
briefing = ''
|
|
790
|
+
if api_key and all_articles:
|
|
791
|
+
topic_counts = {}
|
|
792
|
+
for a in all_articles:
|
|
793
|
+
t = a['topic']
|
|
794
|
+
topic_counts[t] = topic_counts.get(t, 0) + 1
|
|
795
|
+
topic_summary = '、'.join(f'{t}{c}篇' for t, c in sorted(topic_counts.items()))
|
|
796
|
+
|
|
797
|
+
# Read summaries from existing md files
|
|
798
|
+
highlights = []
|
|
799
|
+
for topic in TOPICS:
|
|
800
|
+
topic_dir = out_dir / topic
|
|
801
|
+
if not topic_dir.is_dir():
|
|
802
|
+
continue
|
|
803
|
+
for md_file in sorted(topic_dir.glob('*.md'), key=lambda f: f.stat().st_mtime, reverse=True)[:15]:
|
|
804
|
+
try:
|
|
805
|
+
content = md_file.read_text(encoding='utf-8')
|
|
806
|
+
# Extract title
|
|
807
|
+
title = md_file.stem
|
|
808
|
+
summary = ''
|
|
809
|
+
in_summary = False
|
|
810
|
+
for line in content.split('\n'):
|
|
811
|
+
if line.startswith('title:'):
|
|
812
|
+
title = line.split(':', 1)[1].strip().strip('"').strip("'")
|
|
813
|
+
if '## AI 摘要' in line or '## 深度解析' in line:
|
|
814
|
+
in_summary = True
|
|
815
|
+
continue
|
|
816
|
+
if in_summary and line.startswith('##'):
|
|
817
|
+
break
|
|
818
|
+
if in_summary and line.strip():
|
|
819
|
+
summary += line.strip()[:100]
|
|
820
|
+
if len(summary) > 80:
|
|
821
|
+
break
|
|
822
|
+
if summary:
|
|
823
|
+
highlights.append(f"[{topic}] {title} | {summary}")
|
|
824
|
+
except:
|
|
825
|
+
pass
|
|
826
|
+
if len(highlights) >= 25:
|
|
827
|
+
break
|
|
828
|
+
|
|
829
|
+
highlight_text = '\n'.join(highlights[:25])
|
|
830
|
+
if highlight_text:
|
|
831
|
+
try:
|
|
832
|
+
briefing_prompt = f"""你是公众号日报助手。基于今天 {len(all_articles)} 篇文章({topic_summary}),生成一段200字以内的今日简报。
|
|
833
|
+
|
|
834
|
+
要求:
|
|
835
|
+
1. 按主题分组,每个主题1-2句话概括重点
|
|
836
|
+
2. 突出3-5篇最值得关注的文章及其核心观点
|
|
837
|
+
3. 语气简洁专业
|
|
838
|
+
|
|
839
|
+
文章概览:
|
|
840
|
+
{highlight_text}
|
|
841
|
+
|
|
842
|
+
请直接输出简报内容(不要标题)。"""
|
|
843
|
+
briefing = call_deepseek(briefing_prompt, api_key, max_tokens=500, timeout=90).strip()
|
|
844
|
+
if briefing:
|
|
845
|
+
print(f'\n ✓ 简报生成完成')
|
|
846
|
+
else:
|
|
847
|
+
print(f'\n [WARN] 简报 API 返回空,跳过')
|
|
848
|
+
except Exception as e:
|
|
849
|
+
print(f'\n [WARN] 简报生成失败: {e}')
|
|
850
|
+
|
|
851
|
+
index_lines = [
|
|
852
|
+
f'# 公众号日报 — {date_str}',
|
|
853
|
+
'',
|
|
854
|
+
]
|
|
855
|
+
if briefing:
|
|
856
|
+
index_lines.append(f'> 📋 **今日简报**\n> \n> {briefing}\n')
|
|
857
|
+
index_lines.append(f'共 {len(all_articles)} 篇推送,按主题分类')
|
|
858
|
+
index_lines.append('')
|
|
859
|
+
for topic in TOPICS:
|
|
860
|
+
group = [a for a in all_articles if a['topic'] == topic]
|
|
861
|
+
if not group:
|
|
862
|
+
continue
|
|
863
|
+
index_lines.append(f'## {topic} ({len(group)}篇)')
|
|
864
|
+
index_lines.append('')
|
|
865
|
+
index_lines.append('| # | 时间 | 公众号 | 标题 |')
|
|
866
|
+
index_lines.append('|---|------|--------|------|')
|
|
867
|
+
for j, a in enumerate(group):
|
|
868
|
+
index_lines.append(
|
|
869
|
+
f'| {j+1} | {a["time"]} | {a["source"]} | [{a["title"]}](./{topic}/{a["file"]}) |'
|
|
870
|
+
)
|
|
871
|
+
index_lines.append('')
|
|
872
|
+
|
|
873
|
+
with open(index_path, 'w', encoding='utf-8') as f:
|
|
874
|
+
f.write('\n'.join(index_lines))
|
|
875
|
+
f.write('\n---\n\n*由 weflow-cli 公众号日报自动生成*\n')
|
|
876
|
+
|
|
877
|
+
print(f'\n✓ 完成!输出到 {out_dir}')
|
|
878
|
+
print(f' 总索引: {index_path}')
|
|
879
|
+
print(f' 文章数: {len(all_articles)}')
|
|
880
|
+
for t in TOPICS:
|
|
881
|
+
count = len([a for a in all_articles if a['topic'] == t])
|
|
882
|
+
if count:
|
|
883
|
+
print(f' {t}/: {count} 篇')
|
|
884
|
+
|
|
885
|
+
# Save run state for incremental dedup
|
|
886
|
+
for a in topic_groups.values():
|
|
887
|
+
for art in a:
|
|
888
|
+
fp = hashlib.sha256(art.get('url', '').encode()).hexdigest()[:16]
|
|
889
|
+
if art.get('url'):
|
|
890
|
+
processed[fp] = art['title'][:50]
|
|
891
|
+
with open(state_file, 'w', encoding='utf-8') as f:
|
|
892
|
+
json.dump(processed, f, ensure_ascii=False, indent=2)
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
if __name__ == '__main__':
|
|
896
|
+
main()
|