weflow-cli 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -0
  3. package/bin/weflow-cli-electron.cjs +59 -0
  4. package/bin/weflow-cli.ts +2311 -0
  5. package/cli.cjs +3 -0
  6. package/dist/bin/weflow-cli.d.ts +3 -0
  7. package/dist/bin/weflow-cli.d.ts.map +1 -0
  8. package/dist/bin/weflow-cli.js +2270 -0
  9. package/dist/bin/weflow-cli.js.map +1 -0
  10. package/dist/mcp-server/index.d.ts +3 -0
  11. package/dist/mcp-server/index.d.ts.map +1 -0
  12. package/dist/mcp-server/index.js +690 -0
  13. package/dist/mcp-server/index.js.map +1 -0
  14. package/dist/src/core/dbPathService.d.ts +40 -0
  15. package/dist/src/core/dbPathService.d.ts.map +1 -0
  16. package/dist/src/core/dbPathService.js +324 -0
  17. package/dist/src/core/dbPathService.js.map +1 -0
  18. package/dist/src/core/keyService.d.ts +59 -0
  19. package/dist/src/core/keyService.d.ts.map +1 -0
  20. package/dist/src/core/keyService.js +368 -0
  21. package/dist/src/core/keyService.js.map +1 -0
  22. package/dist/src/core/ntCore.d.ts +62 -0
  23. package/dist/src/core/ntCore.d.ts.map +1 -0
  24. package/dist/src/core/ntCore.js +191 -0
  25. package/dist/src/core/ntCore.js.map +1 -0
  26. package/dist/src/core/sqlcipherCore.d.ts +119 -0
  27. package/dist/src/core/sqlcipherCore.d.ts.map +1 -0
  28. package/dist/src/core/sqlcipherCore.js +693 -0
  29. package/dist/src/core/sqlcipherCore.js.map +1 -0
  30. package/dist/src/core/wcdbCore.d.ts +738 -0
  31. package/dist/src/core/wcdbCore.d.ts.map +1 -0
  32. package/dist/src/core/wcdbCore.js +4545 -0
  33. package/dist/src/core/wcdbCore.js.map +1 -0
  34. package/dist/src/core/wechatClient.d.ts +23 -0
  35. package/dist/src/core/wechatClient.d.ts.map +1 -0
  36. package/dist/src/core/wechatClient.js +166 -0
  37. package/dist/src/core/wechatClient.js.map +1 -0
  38. package/dist/src/services/chatService.d.ts +25 -0
  39. package/dist/src/services/chatService.d.ts.map +1 -0
  40. package/dist/src/services/chatService.js +291 -0
  41. package/dist/src/services/chatService.js.map +1 -0
  42. package/dist/src/services/configService.d.ts +43 -0
  43. package/dist/src/services/configService.d.ts.map +1 -0
  44. package/dist/src/services/configService.js +156 -0
  45. package/dist/src/services/configService.js.map +1 -0
  46. package/dist/src/services/exportService.d.ts +30 -0
  47. package/dist/src/services/exportService.d.ts.map +1 -0
  48. package/dist/src/services/exportService.js +273 -0
  49. package/dist/src/services/exportService.js.map +1 -0
  50. package/dist/src/services/wechat-formatter.d.ts +69 -0
  51. package/dist/src/services/wechat-formatter.d.ts.map +1 -0
  52. package/dist/src/services/wechat-formatter.js +248 -0
  53. package/dist/src/services/wechat-formatter.js.map +1 -0
  54. package/dist/src/services/wechatMessageService.d.ts +28 -0
  55. package/dist/src/services/wechatMessageService.d.ts.map +1 -0
  56. package/dist/src/services/wechatMessageService.js +341 -0
  57. package/dist/src/services/wechatMessageService.js.map +1 -0
  58. package/dist/src/services/wereadService.d.ts +206 -0
  59. package/dist/src/services/wereadService.d.ts.map +1 -0
  60. package/dist/src/services/wereadService.js +145 -0
  61. package/dist/src/services/wereadService.js.map +1 -0
  62. package/dist/src/services/whitelistService.d.ts +20 -0
  63. package/dist/src/services/whitelistService.d.ts.map +1 -0
  64. package/dist/src/services/whitelistService.js +60 -0
  65. package/dist/src/services/whitelistService.js.map +1 -0
  66. package/dist/src/types.d.ts +140 -0
  67. package/dist/src/types.d.ts.map +1 -0
  68. package/dist/src/types.js +2 -0
  69. package/dist/src/types.js.map +1 -0
  70. package/dist/src/utils/errors.d.ts +25 -0
  71. package/dist/src/utils/errors.d.ts.map +1 -0
  72. package/dist/src/utils/errors.js +43 -0
  73. package/dist/src/utils/errors.js.map +1 -0
  74. package/dist/src/utils/pathUtils.d.ts +5 -0
  75. package/dist/src/utils/pathUtils.d.ts.map +1 -0
  76. package/dist/src/utils/pathUtils.js +16 -0
  77. package/dist/src/utils/pathUtils.js.map +1 -0
  78. package/dist/src/utils/pythonRunner.d.ts +27 -0
  79. package/dist/src/utils/pythonRunner.d.ts.map +1 -0
  80. package/dist/src/utils/pythonRunner.js +62 -0
  81. package/dist/src/utils/pythonRunner.js.map +1 -0
  82. package/dist/src/utils/talkerUtils.d.ts +14 -0
  83. package/dist/src/utils/talkerUtils.d.ts.map +1 -0
  84. package/dist/src/utils/talkerUtils.js +73 -0
  85. package/dist/src/utils/talkerUtils.js.map +1 -0
  86. package/mcp-server/index.ts +757 -0
  87. package/package.json +72 -0
  88. package/resources/key/win32/x64/wx_key.dll +0 -0
  89. package/resources/wcdb/win32/x64/SDL2.dll +0 -0
  90. package/resources/wcdb/win32/x64/WCDB.dll +0 -0
  91. package/resources/wcdb/win32/x64/msvcp140.dll +0 -0
  92. package/resources/wcdb/win32/x64/msvcp140_1.dll +0 -0
  93. package/resources/wcdb/win32/x64/vcruntime140.dll +0 -0
  94. package/resources/wcdb/win32/x64/vcruntime140_1.dll +0 -0
  95. package/resources/wcdb/win32/x64/wcdb_api.dll +0 -0
  96. package/scripts/_batch_link_sources.py +73 -0
  97. package/scripts/_utils.py +531 -0
  98. package/scripts/annual_report.py +796 -0
  99. package/scripts/auto_tag.py +128 -0
  100. package/scripts/biz_daily.py +896 -0
  101. package/scripts/chat_report.py +397 -0
  102. package/scripts/chat_stats.py +702 -0
  103. package/scripts/classify_daily.py +411 -0
  104. package/scripts/compile_wiki.py +258 -0
  105. package/scripts/create_reading_notes.py +370 -0
  106. package/scripts/enrich_backlinks.py +163 -0
  107. package/scripts/export_chat_html.py +766 -0
  108. package/scripts/extract_3x_key.py +86 -0
  109. package/scripts/extract_todos.py +404 -0
  110. package/scripts/fav_server.py +392 -0
  111. package/scripts/fix_topics.py +111 -0
  112. package/scripts/generate_ai_report.py +367 -0
  113. package/scripts/generate_html.py +1372 -0
  114. package/scripts/generate_review.py +170 -0
  115. package/scripts/mcp_bridge.py +398 -0
  116. package/scripts/nt_decrypt.py +562 -0
  117. package/scripts/pipeline.py +145 -0
  118. package/scripts/promote_all.py +310 -0
  119. package/scripts/promote_ideas.py +297 -0
  120. package/scripts/rag_chat.py +191 -0
  121. package/scripts/scan_decrypt_4x.py +329 -0
  122. package/scripts/semantic_search.py +431 -0
  123. package/scripts/sync_fav.py +146 -0
  124. package/scripts/sync_weread.py +192 -0
  125. package/scripts/vault_rag.py +139 -0
  126. package/scripts/vault_search.py +141 -0
  127. package/src/core/dbPathService.ts +348 -0
  128. package/src/core/keyService.ts +409 -0
  129. package/src/core/ntCore.ts +224 -0
  130. package/src/core/sqlcipherCore.ts +764 -0
  131. package/src/core/wcdbCore.ts +4568 -0
  132. package/src/core/wechatClient.ts +204 -0
  133. package/src/lz4.d.ts +10 -0
  134. package/src/services/chatService.ts +296 -0
  135. package/src/services/configService.ts +197 -0
  136. package/src/services/exportService.ts +305 -0
  137. package/src/services/wechat-formatter.ts +348 -0
  138. package/src/services/wechatMessageService.ts +383 -0
  139. package/src/services/wereadService.ts +308 -0
  140. package/src/services/whitelistService.ts +72 -0
  141. package/src/types.ts +141 -0
  142. package/src/utils/errors.ts +42 -0
  143. package/src/utils/pathUtils.ts +16 -0
  144. package/src/utils/pythonRunner.ts +81 -0
  145. package/src/utils/talkerUtils.ts +84 -0
@@ -0,0 +1,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()