weflow-cli 1.13.1 → 1.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/bin/weflow-cli.ts +25 -2
- package/dist/bin/weflow-cli.js +28 -2
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/src/services/configService.d.ts +3 -0
- package/dist/src/services/configService.d.ts.map +1 -1
- package/dist/src/services/configService.js +3 -2
- package/dist/src/services/configService.js.map +1 -1
- package/docs/DECISIONS.md +30 -0
- package/package.json +1 -1
- package/scripts/_utils.py +21 -2
- package/scripts/daily_mail.py +472 -0
- package/scripts/daily_task.ps1 +45 -0
- package/scripts/jev_client.py +136 -2
- package/scripts/semantic_search.py +9 -1
- package/src/services/configService.ts +8 -3
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
"""把当天日报按主题整理成一封邮件(AI / 文学 / 学术),SMTP 发出。
|
|
2
|
+
|
|
3
|
+
为什么不发 HTML 阅读器:`output/biz-daily/<日期>/index.html` 的图片是**本地相对路径**,
|
|
4
|
+
发到邮箱里会全部裂掉;内联 base64 又会让邮件变成好几 MB。所以正文走「文本 + 原文链接」,
|
|
5
|
+
要带图的完整版仍在电脑上开阅读器。
|
|
6
|
+
|
|
7
|
+
"每篇都由 AI 读过"不是这里做的:逐篇摘要(`【核心观点】/【关键细节】`)是 `biz_daily.py`
|
|
8
|
+
在抓取阶段用决策模型生成的,本脚本只负责**按主题聚合 + 写每个主题的综述**(每个主题一次调用)。
|
|
9
|
+
|
|
10
|
+
用法:
|
|
11
|
+
python scripts/daily_mail.py --date 2026-10-18
|
|
12
|
+
python scripts/daily_mail.py --date 2026-10-18 --to someone@example.com --dry-run
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import html
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import pathlib
|
|
21
|
+
import re
|
|
22
|
+
import smtplib
|
|
23
|
+
import sys
|
|
24
|
+
from email.header import Header
|
|
25
|
+
from email.mime.multipart import MIMEMultipart
|
|
26
|
+
from email.mime.text import MIMEText
|
|
27
|
+
from email.utils import formataddr, formatdate
|
|
28
|
+
|
|
29
|
+
SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
|
|
30
|
+
sys.path.insert(0, str(SCRIPT_DIR))
|
|
31
|
+
|
|
32
|
+
from _utils import get_api_key, load_config # noqa: E402
|
|
33
|
+
|
|
34
|
+
ROOT = SCRIPT_DIR.parent
|
|
35
|
+
DEFAULT_TOPICS = ('AI', '学术')
|
|
36
|
+
SMTP_CONFIG = pathlib.Path(os.path.expanduser('~/.weflow-issue-watch.json'))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def articles_path(date: str) -> pathlib.Path:
|
|
40
|
+
return ROOT / 'output' / 'biz-daily' / date / '.articles.json'
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load_articles(date: str, topics):
|
|
44
|
+
path = articles_path(date)
|
|
45
|
+
if not path.is_file():
|
|
46
|
+
raise SystemExit(f'没有这一天的数据:{path}')
|
|
47
|
+
data = json.loads(path.read_text(encoding='utf-8'))
|
|
48
|
+
arts = [a for a in (data.get('articles') or []) if a.get('topic') in topics and a.get('title')]
|
|
49
|
+
return data, arts
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def group_by_topic(articles, topics):
|
|
53
|
+
groups = {t: [] for t in topics}
|
|
54
|
+
for a in articles:
|
|
55
|
+
groups.setdefault(a.get('topic'), []).append(a)
|
|
56
|
+
for t in groups:
|
|
57
|
+
groups[t].sort(key=lambda a: (a.get('time') or '', a.get('relevanceScore') or 0), reverse=True)
|
|
58
|
+
return groups
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
GITHUB_RE = re.compile(r'https?://github\.com/[\w\-./]+', re.I)
|
|
62
|
+
#: 主题内分类的小圆点配色。同一条新闻流里给不同聚类换色,读起来不容易串行。
|
|
63
|
+
PALETTE = ('#6366f1', '#8b5cf6', '#0ea5e9', '#f59e0b', '#ec4899', '#14b8a6')
|
|
64
|
+
_TAG_RE = re.compile(r'【[^】]{1,12}】')
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def clean_summary(summary: str, limit=170) -> str:
|
|
68
|
+
"""把 `【核心观点】…【关键细节】1. …;2. …` 压成**一句人话**。
|
|
69
|
+
|
|
70
|
+
邮件里那串标签是给机器分段的,对读者只是噪音:谁在手机上读日报,都不会去分辨
|
|
71
|
+
「核心观点」和「关键细节」的区别。所以只取【核心观点】那一段,标签一律去掉;
|
|
72
|
+
没有标签的老格式就整段压平。
|
|
73
|
+
"""
|
|
74
|
+
text = summary or ''
|
|
75
|
+
m = re.search(r'【核心观点】(.+?)(?=【|$)', text, re.S)
|
|
76
|
+
if m:
|
|
77
|
+
text = m.group(1)
|
|
78
|
+
text = _TAG_RE.sub(' ', text)
|
|
79
|
+
text = ' '.join(text.split())
|
|
80
|
+
if len(text) <= limit:
|
|
81
|
+
return text
|
|
82
|
+
return text[:limit].rstrip(',;、,. ') + '…'
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def body_index(date: str) -> dict:
|
|
86
|
+
"""url -> 那篇文章的 md 全文(用来从正文里找 GitHub 链接)。
|
|
87
|
+
|
|
88
|
+
按 frontmatter 的 `url` 建索引,而不是按文件名:文件名是 `sanitize_filename` 拼的,
|
|
89
|
+
标题里有 `/`、`:` 或超长名字时会被改写,按名字匹配会静默丢文章。
|
|
90
|
+
"""
|
|
91
|
+
index = {}
|
|
92
|
+
for md in (ROOT / 'output' / 'biz-daily' / date).glob('*/*.md'):
|
|
93
|
+
try:
|
|
94
|
+
text = md.read_text(encoding='utf-8', errors='replace')
|
|
95
|
+
except OSError:
|
|
96
|
+
continue
|
|
97
|
+
m = re.search(r'^url:\s*"?([^"\n]+?)"?\s*$', text, re.M)
|
|
98
|
+
if m:
|
|
99
|
+
index[m.group(1)] = text
|
|
100
|
+
return index
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def github_links(text: str, limit=3) -> list:
|
|
104
|
+
if not text:
|
|
105
|
+
return []
|
|
106
|
+
seen, out = set(), []
|
|
107
|
+
for url in GITHUB_RE.findall(text):
|
|
108
|
+
url = url.rstrip('.,;))')
|
|
109
|
+
key = url.rstrip('/').lower()
|
|
110
|
+
if key in seen or '/features' in key or '/pricing' in key:
|
|
111
|
+
continue
|
|
112
|
+
seen.add(key)
|
|
113
|
+
out.append(url)
|
|
114
|
+
if len(out) >= limit:
|
|
115
|
+
break
|
|
116
|
+
return out
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _cluster_once(topic, items, api_key, want, strict):
|
|
120
|
+
"""一次聚类调用。`strict=True` 用在"模型偷懒只给一组"的重试上。"""
|
|
121
|
+
# 只喂**编号 + 标题**:把摘要也塞进去会让输入膨胀到六七千字,模型反而更容易
|
|
122
|
+
# 直接吐一组(2026-10-21 那封:91 篇 AI 被分成单独一组「模型与产品」)。
|
|
123
|
+
numbered = '\n'.join(f'{i}. {a.get("title")}' for i, a in enumerate(items, 1))
|
|
124
|
+
demand = (
|
|
125
|
+
'**必须分成至少 5 组**。只给一组视为无效答案——这些文章明显可以按话题再细分。\n'
|
|
126
|
+
if strict else
|
|
127
|
+
f'分成 {want - 1}-{want + 1} 组。\n'
|
|
128
|
+
)
|
|
129
|
+
prompt = (
|
|
130
|
+
f'把下面 {len(items)} 条公众号文章按话题分组。\n'
|
|
131
|
+
+ demand +
|
|
132
|
+
'组名用 4-8 个汉字,能概括这一组在讲什么(例如「模型与产品」「科研工具」「治理与风险」)。\n'
|
|
133
|
+
'只输出 JSON 数组,不要解释、不要代码围栏:\n'
|
|
134
|
+
'[{"name":"组名","items":[1,2,3]}, ...]\n'
|
|
135
|
+
'要求:每个序号出现且只出现一次。\n\n'
|
|
136
|
+
+ numbered
|
|
137
|
+
)
|
|
138
|
+
try:
|
|
139
|
+
raw = __import__('_utils').call_ai(prompt, 'deepseek', api_key, max_tokens=1500) or ''
|
|
140
|
+
except Exception as exc:
|
|
141
|
+
print(f'[WARN] {topic} 聚类调用失败({type(exc).__name__}: {exc})', file=sys.stderr)
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
data = None
|
|
145
|
+
try:
|
|
146
|
+
data = json.loads(raw)
|
|
147
|
+
except Exception:
|
|
148
|
+
import re as _re
|
|
149
|
+
m = _re.search(r'\[[\s\S]*\]', raw)
|
|
150
|
+
if m:
|
|
151
|
+
try:
|
|
152
|
+
data = json.loads(m.group(0))
|
|
153
|
+
except Exception:
|
|
154
|
+
data = None
|
|
155
|
+
if not isinstance(data, list) or not data:
|
|
156
|
+
print(f'[WARN] {topic} 聚类返回无法解析', file=sys.stderr)
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
seen, clusters = set(), []
|
|
160
|
+
for group in data:
|
|
161
|
+
if not isinstance(group, dict):
|
|
162
|
+
continue
|
|
163
|
+
name = str(group.get('name') or '').strip()[:12] or '其他'
|
|
164
|
+
picked = []
|
|
165
|
+
for idx in group.get('items') or []:
|
|
166
|
+
try:
|
|
167
|
+
i = int(idx)
|
|
168
|
+
except Exception:
|
|
169
|
+
continue
|
|
170
|
+
if 1 <= i <= len(items) and i not in seen:
|
|
171
|
+
seen.add(i)
|
|
172
|
+
picked.append(items[i - 1])
|
|
173
|
+
if picked:
|
|
174
|
+
clusters.append((name, picked))
|
|
175
|
+
leftover = [a for i, a in enumerate(items, 1) if i not in seen]
|
|
176
|
+
if leftover:
|
|
177
|
+
if clusters and len(leftover) <= max(3, len(items) // 4):
|
|
178
|
+
clusters[-1][1].extend(leftover)
|
|
179
|
+
else:
|
|
180
|
+
clusters.append(('其他', leftover))
|
|
181
|
+
return clusters or None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def cluster_items(topic: str, items, api_key: str, want=5):
|
|
185
|
+
"""把同一主题下的文章再分一次类(让几十篇的段落不再是一根长肠子)。
|
|
186
|
+
|
|
187
|
+
为什么不用现成的 `tags`:实测 68 篇 AI 文章里有 **382 个不同标签**,频次最高的才 6 次——
|
|
188
|
+
按标签聚类会碎成一地。所以交给模型分一次。
|
|
189
|
+
|
|
190
|
+
**退化要重试**:2026-10-21 那天模型把 91 篇 AI 全塞进一组「模型与产品」,
|
|
191
|
+
版式当场退回上线前的样子(一根长肠子),而且完全不报错。现在"只给一组但篇数很多"
|
|
192
|
+
会被当成失败、用更硬的指令再要一次。
|
|
193
|
+
|
|
194
|
+
返回 `[(类名, [文章...])]`;任何一步失败都退回"一组=全部",**绝不因此不发信**。
|
|
195
|
+
"""
|
|
196
|
+
if len(items) < 8 or not api_key:
|
|
197
|
+
return [(topic, list(items))]
|
|
198
|
+
|
|
199
|
+
result = None
|
|
200
|
+
for strict in (False, True):
|
|
201
|
+
result = _cluster_once(topic, items, api_key, want, strict)
|
|
202
|
+
degenerate = result and len(result) == 1 and len(items) > 20
|
|
203
|
+
if result and not degenerate:
|
|
204
|
+
return result
|
|
205
|
+
if degenerate:
|
|
206
|
+
print(f'[WARN] {topic} 聚类退化成 1 组({len(items)} 篇),{"再要求一次" if not strict else "仍失败"}',
|
|
207
|
+
file=sys.stderr)
|
|
208
|
+
return result or [(topic, list(items))]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def topic_digest(topic: str, items, api_key: str) -> str:
|
|
212
|
+
"""每个主题一次调用:让它读完全部标题+摘要,写一段综述。失败就返回空串(不影响发信)。"""
|
|
213
|
+
if not api_key:
|
|
214
|
+
return ''
|
|
215
|
+
lines = [f'- {a.get("title")}|{a.get("source")}|{clean_summary(a.get("summary"), 90)}' for a in items]
|
|
216
|
+
prompt = (
|
|
217
|
+
f'下面是今天「{topic}」主题下 {len(items)} 篇公众号文章的标题与要点。\n'
|
|
218
|
+
'请用中文写一段 150-250 字的综述:今天这个主题主要在讨论什么、有哪些值得注意的进展或分歧、'
|
|
219
|
+
'其中哪一两篇值得优先精读(给出标题)。不要罗列全部篇目,不要空话,不要用列表。\n\n'
|
|
220
|
+
+ '\n'.join(lines)
|
|
221
|
+
)
|
|
222
|
+
try:
|
|
223
|
+
return api_key and __import__('_utils').call_ai(prompt, 'deepseek', api_key, max_tokens=600) or ''
|
|
224
|
+
except Exception as exc:
|
|
225
|
+
print(f'[WARN] {topic} 综述生成失败({type(exc).__name__}: {exc}),邮件里跳过这一段', file=sys.stderr)
|
|
226
|
+
return ''
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def render(date: str, groups, digests, bodies=None, clusters=None) -> tuple[str, str, int]:
|
|
230
|
+
bodies = bodies or {}
|
|
231
|
+
clusters = clusters or {}
|
|
232
|
+
total = sum(len(v) for v in groups.values())
|
|
233
|
+
# 主题配色:AI 靛蓝、学术 青绿。用 table + inline style —— 邮件客户端会剥离
|
|
234
|
+
# `<style>` 与 class,class 选择器在 QQ / Gmail / 企业微信里表现不一致。
|
|
235
|
+
accents = {'AI': ('#4f46e5', '#eef2ff'), '学术': ('#0d9488', '#ecfdf5')}
|
|
236
|
+
|
|
237
|
+
text_lines = [f'公众号日报 {date}', f'共 {total} 篇', '']
|
|
238
|
+
blocks: list[str] = []
|
|
239
|
+
|
|
240
|
+
for topic, items in groups.items():
|
|
241
|
+
if not items:
|
|
242
|
+
continue
|
|
243
|
+
accent, tint = accents.get(topic, ('#334155', '#f1f5f9'))
|
|
244
|
+
text_lines.append(f'=== {topic}({len(items)} 篇)===')
|
|
245
|
+
digest = (digests or {}).get(topic) or ''
|
|
246
|
+
if digest:
|
|
247
|
+
text_lines.append(digest)
|
|
248
|
+
text_lines.append('')
|
|
249
|
+
|
|
250
|
+
# 主题分区:一条通栏色条 + 主题名,比一个小圆角标签更能"切开"版面。
|
|
251
|
+
# 档位名取自 `_utils.RELEVANCE_NAMES`:写死 `('高','中','低')` 会与判据层漂移,
|
|
252
|
+
# 而 `topic_taxonomy_test` 就是专门拦这件事的(它当场红过一次)。
|
|
253
|
+
from _utils import RELEVANCE_NAMES as _REL
|
|
254
|
+
hi, mid, lo = (sum(1 for a in items if a.get('relevance') == k) for k in (_REL[2], _REL[1], _REL[0]))
|
|
255
|
+
def pct(n):
|
|
256
|
+
return max(1, round(n * 100 / max(1, len(items))))
|
|
257
|
+
blocks.append(
|
|
258
|
+
'<tr><td style="padding:30px 28px 0;">'
|
|
259
|
+
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr>'
|
|
260
|
+
f'<td width="4" style="background:{accent};border-radius:2px;"></td>'
|
|
261
|
+
'<td style="padding-left:10px;">'
|
|
262
|
+
f'<span style="font-size:18px;font-weight:700;color:#111827;">{html.escape(topic)}</span>'
|
|
263
|
+
f'<span style="font-size:12px;color:#9aa3af;margin-left:8px;">{len(items)} 篇</span>'
|
|
264
|
+
'</td></tr></table>'
|
|
265
|
+
# 相关度比例条:不用看数字就知道今天"值得读的占多少"
|
|
266
|
+
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-top:10px;">'
|
|
267
|
+
'<tr>'
|
|
268
|
+
f'<td width="{pct(hi)}%" height="7" style="background:{accent};border-radius:4px 0 0 4px;"></td>'
|
|
269
|
+
f'<td width="{pct(mid)}%" height="7" style="background:{tint};"></td>'
|
|
270
|
+
f'<td width="{pct(lo)}%" height="7" style="background:#eef1f4;border-radius:0 4px 4px 0;"></td>'
|
|
271
|
+
'</tr></table>'
|
|
272
|
+
f'<div style="font-size:11.5px;color:#9aa3af;margin-top:6px;">'
|
|
273
|
+
f'<span style="color:{accent};font-weight:600;">高 {hi}</span> · 中 {mid} · 低 {lo}</div>'
|
|
274
|
+
'</td></tr>'
|
|
275
|
+
)
|
|
276
|
+
if digest:
|
|
277
|
+
blocks.append(
|
|
278
|
+
'<tr><td style="padding:14px 28px 0;">'
|
|
279
|
+
'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" '
|
|
280
|
+
f'style="background:#f8fafc;border-left:3px solid {accent};border-radius:8px;">'
|
|
281
|
+
'<tr><td style="padding:14px 16px;font-size:14px;line-height:1.8;color:#3f4652;">'
|
|
282
|
+
f'{html.escape(digest)}</td></tr></table></td></tr>'
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
# 主题内再分一次类:68 篇挤成一排没人看,分成 4-6 组后每一段都有名字。
|
|
286
|
+
section_clusters = (clusters or {}).get(topic) or [(topic, list(items))]
|
|
287
|
+
for ci, (cname, citems) in enumerate(section_clusters):
|
|
288
|
+
dot = PALETTE[ci % len(PALETTE)]
|
|
289
|
+
if len(section_clusters) > 1:
|
|
290
|
+
blocks.append(
|
|
291
|
+
'<tr><td style="padding:22px 28px 0;">'
|
|
292
|
+
f'<span style="display:inline-block;width:7px;height:7px;border-radius:50%;'
|
|
293
|
+
f'background:{dot};vertical-align:middle;"></span>'
|
|
294
|
+
f'<span style="font-size:13.5px;font-weight:700;color:#374151;margin-left:7px;">'
|
|
295
|
+
f'{html.escape(cname)}</span>'
|
|
296
|
+
f'<span style="font-size:11.5px;color:#b3bac4;margin-left:6px;">{len(citems)}</span>'
|
|
297
|
+
'</td></tr>'
|
|
298
|
+
)
|
|
299
|
+
text_lines.append(f'-- {cname}({len(citems)})--')
|
|
300
|
+
|
|
301
|
+
for a in citems:
|
|
302
|
+
i = items.index(a) + 1
|
|
303
|
+
title = a.get('title') or ''
|
|
304
|
+
source = a.get('source') or ''
|
|
305
|
+
when = (a.get('time') or '')[:5]
|
|
306
|
+
rel = a.get('relevance') or ''
|
|
307
|
+
url = a.get('url') or ''
|
|
308
|
+
repos = github_links(bodies.get(url, ''))
|
|
309
|
+
meta = ' · '.join(x for x in (source, when, rel) if x)
|
|
310
|
+
num = f'{i:02d}'
|
|
311
|
+
title_html = (f'<a href="{html.escape(url)}" style="color:#111827;text-decoration:none;">'
|
|
312
|
+
f'{html.escape(title)}</a>' if url else html.escape(title))
|
|
313
|
+
|
|
314
|
+
# **颜色必须自己说明自己。** 上一版把序号徽章染成主题色来表示"高相关",
|
|
315
|
+
# 但没有任何图例——用户看到截图的第一反应是"这边为什么有的是绿的"(2026-10-20)。
|
|
316
|
+
# 现在徽章统一成灰色,相关度改成 meta 行里一个**写着"高"的小色块**:
|
|
317
|
+
# 颜色解释自己,不需要图例。
|
|
318
|
+
if rel == _REL[2]:
|
|
319
|
+
summary = clean_summary(a.get('summary'), 190)
|
|
320
|
+
text_lines.append(f'{num}. ★ {title} — {meta}')
|
|
321
|
+
elif rel == _REL[1]:
|
|
322
|
+
summary = clean_summary(a.get('summary'), 120)
|
|
323
|
+
text_lines.append(f'{num}. {title} — {meta}')
|
|
324
|
+
else:
|
|
325
|
+
summary = ''
|
|
326
|
+
text_lines.append(f'{num}. {title} — {meta}')
|
|
327
|
+
if rel == _REL[2]:
|
|
328
|
+
rel_html = (f'<span style="display:inline-block;background:{accent};color:#ffffff;'
|
|
329
|
+
f'font-size:10.5px;font-weight:600;padding:1px 6px;border-radius:4px;">'
|
|
330
|
+
f'{html.escape(rel)}</span>')
|
|
331
|
+
else:
|
|
332
|
+
rel_html = f'<span style="color:#b3bac4;">{html.escape(rel)}</span>' if rel else ''
|
|
333
|
+
meta_html = ' · '.join(x for x in (
|
|
334
|
+
html.escape(source), html.escape(when), rel_html) if x)
|
|
335
|
+
if summary:
|
|
336
|
+
text_lines.append(f' {summary}')
|
|
337
|
+
for repo in repos:
|
|
338
|
+
text_lines.append(f' GitHub: {repo}')
|
|
339
|
+
if url:
|
|
340
|
+
text_lines.append(f' {url}')
|
|
341
|
+
blocks.append(
|
|
342
|
+
'<tr><td style="padding:10px 28px 0;">'
|
|
343
|
+
'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" '
|
|
344
|
+
'style="background:#ffffff;border:1px solid #eef0f3;border-radius:12px;">'
|
|
345
|
+
'<tr><td style="padding:14px 16px;">'
|
|
346
|
+
'<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr>'
|
|
347
|
+
f'<td width="28" valign="top"><span style="display:inline-block;width:24px;height:24px;'
|
|
348
|
+
'line-height:24px;text-align:center;border-radius:7px;background:#f1f5f9;'
|
|
349
|
+
f'color:#94a3b8;font-size:11.5px;font-weight:700;">{num}</span></td>'
|
|
350
|
+
'<td valign="top">'
|
|
351
|
+
f'<div style="font-size:14.5px;font-weight:600;color:#111827;line-height:1.55;">{title_html}</div>'
|
|
352
|
+
f'<div style="font-size:11.5px;color:#9aa3af;margin-top:4px;">{meta_html}</div>'
|
|
353
|
+
+ (f'<div style="font-size:13.5px;line-height:1.75;color:#4b5563;margin-top:8px;">'
|
|
354
|
+
f'{html.escape(summary)}</div>' if summary else '')
|
|
355
|
+
+ ''.join(
|
|
356
|
+
f'<div style="margin-top:8px;font-size:12.5px;">'
|
|
357
|
+
f'<a href="{html.escape(repo)}" style="color:{accent};text-decoration:none;">'
|
|
358
|
+
f'● {html.escape(repo.replace("https://github.com/", "").rstrip("/"))}</a></div>'
|
|
359
|
+
for repo in repos
|
|
360
|
+
)
|
|
361
|
+
+ '</td></tr></table></td></tr></table></td></tr>'
|
|
362
|
+
)
|
|
363
|
+
text_lines.append('')
|
|
364
|
+
|
|
365
|
+
text_lines.append('完整版(含配图)在电脑上打开:')
|
|
366
|
+
text_lines.append(f' weflow-cli daily-server --date {date} --open')
|
|
367
|
+
|
|
368
|
+
stats = ' · '.join(f'{t} {len(v)} 篇' for t, v in groups.items() if v)
|
|
369
|
+
page = (
|
|
370
|
+
'<!doctype html><html><head><meta charset="utf-8">'
|
|
371
|
+
'<meta name="viewport" content="width=device-width,initial-scale=1">'
|
|
372
|
+
'<title>' + html.escape(f'公众号日报 {date}') + '</title></head>'
|
|
373
|
+
'<body style="margin:0;padding:0;background:#f4f5f7;">'
|
|
374
|
+
'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" '
|
|
375
|
+
'style="background:#f4f5f7;"><tr><td align="center" style="padding:24px 12px;">'
|
|
376
|
+
'<table role="presentation" width="640" cellpadding="0" cellspacing="0" '
|
|
377
|
+
'style="width:100%;max-width:640px;background:#ffffff;border-radius:14px;'
|
|
378
|
+
'overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.06);">'
|
|
379
|
+
'<tr><td style="background:#111827;padding:26px 28px;">'
|
|
380
|
+
'<div style="font-size:12px;letter-spacing:.12em;color:#9ca3af;font-weight:600;">'
|
|
381
|
+
'WEFLOW · 公众号日报</div>'
|
|
382
|
+
f'<div style="font-size:26px;font-weight:700;color:#ffffff;margin-top:8px;">{html.escape(date)}</div>'
|
|
383
|
+
f'<div style="font-size:13px;color:#c7cbd1;margin-top:8px;">{html.escape(stats)}</div>'
|
|
384
|
+
'</td></tr>'
|
|
385
|
+
+ ''.join(blocks)
|
|
386
|
+
+ '<tr><td style="padding:22px 28px 28px;">'
|
|
387
|
+
'<div style="font-size:12px;color:#9aa3af;line-height:1.8;">'
|
|
388
|
+
'完整版(含配图)在电脑上打开:<br>'
|
|
389
|
+
'<span style="font-family:ui-monospace,Consolas,Menlo,monospace;color:#6b7280;">'
|
|
390
|
+
f'weflow-cli daily-server --date {html.escape(date)} --open</span>'
|
|
391
|
+
'</div></td></tr>'
|
|
392
|
+
'</table></td></tr></table></body></html>'
|
|
393
|
+
)
|
|
394
|
+
return '\n'.join(text_lines), page, total
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def smtp_settings():
|
|
398
|
+
if not SMTP_CONFIG.is_file():
|
|
399
|
+
raise SystemExit(f'找不到发信凭据 {SMTP_CONFIG}(需要 smtp_user 与 smtp_auth)')
|
|
400
|
+
cfg = json.loads(SMTP_CONFIG.read_text(encoding='utf-8'))
|
|
401
|
+
if not cfg.get('smtp_user') or not cfg.get('smtp_auth'):
|
|
402
|
+
raise SystemExit(f'{SMTP_CONFIG} 里缺 smtp_user / smtp_auth')
|
|
403
|
+
return cfg['smtp_user'], cfg['smtp_auth']
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def send(user, auth, to, subject, text_body, html_body):
|
|
407
|
+
msg = MIMEMultipart('alternative')
|
|
408
|
+
msg['From'] = formataddr((str(Header('WeFlow CLI 日报', 'utf-8')), user))
|
|
409
|
+
msg['To'] = to
|
|
410
|
+
msg['Subject'] = Header(subject, 'utf-8')
|
|
411
|
+
msg['Date'] = formatdate(localtime=True)
|
|
412
|
+
msg.attach(MIMEText(text_body, 'plain', 'utf-8'))
|
|
413
|
+
msg.attach(MIMEText(html_body, 'html', 'utf-8'))
|
|
414
|
+
with smtplib.SMTP_SSL('smtp.qq.com', 465, timeout=45) as smtp:
|
|
415
|
+
smtp.login(user, auth)
|
|
416
|
+
refused = smtp.sendmail(user, [to], msg.as_string())
|
|
417
|
+
if refused:
|
|
418
|
+
raise SystemExit(f'被拒收:{refused}')
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def main():
|
|
422
|
+
ap = argparse.ArgumentParser(description='把某天的日报按主题整理成邮件并发送')
|
|
423
|
+
ap.add_argument('--date', required=True, help='YYYY-MM-DD')
|
|
424
|
+
ap.add_argument('--to', default='', help='收件人;缺省用配置 dailyMailTo,再缺省发给自己')
|
|
425
|
+
ap.add_argument('--topics', default=','.join(DEFAULT_TOPICS), help='逗号分隔的主题')
|
|
426
|
+
ap.add_argument('--label', default='', help='标题后缀,如「截至中午」——区分半日速览与次日完整版')
|
|
427
|
+
ap.add_argument('--no-digest', action='store_true', help='不生成每主题的 AI 综述(省钱、更快)')
|
|
428
|
+
ap.add_argument('--dry-run', action='store_true', help='只渲染,不发送')
|
|
429
|
+
args = ap.parse_args()
|
|
430
|
+
|
|
431
|
+
topics = [t.strip() for t in args.topics.split(',') if t.strip()]
|
|
432
|
+
data, articles = load_articles(args.date, set(topics))
|
|
433
|
+
groups = group_by_topic(articles, topics)
|
|
434
|
+
total = sum(len(v) for v in groups.values())
|
|
435
|
+
print(f'{args.date}: 命中 {total} 篇({", ".join(f"{t}={len(v)}" for t, v in groups.items())})')
|
|
436
|
+
if not total:
|
|
437
|
+
raise SystemExit('没有可发送的文章')
|
|
438
|
+
|
|
439
|
+
cfg = load_config()
|
|
440
|
+
# `--no-digest` 只关"每主题一段综述";聚类是版式结构,仍要做(两件事分开开关,
|
|
441
|
+
# 否则想看新版式的人一加 `--no-digest` 就退回一根长肠子)。
|
|
442
|
+
api_key = get_api_key(cfg)
|
|
443
|
+
digests = {} if args.no_digest else {t: topic_digest(t, items, api_key) for t, items in groups.items() if items}
|
|
444
|
+
clusters = {t: cluster_items(t, items, api_key, want=5) for t, items in groups.items() if len(items) >= 8}
|
|
445
|
+
for t, cs in clusters.items():
|
|
446
|
+
print(f' {t} 分了 {len(cs)} 组: ' + ' / '.join(f'{n}({len(v)})' for n, v in cs))
|
|
447
|
+
|
|
448
|
+
bodies = body_index(args.date)
|
|
449
|
+
with_repo = sum(1 for a in articles if github_links(bodies.get(a.get('url'), '')))
|
|
450
|
+
print(f'正文含 GitHub 链接: {with_repo} 篇')
|
|
451
|
+
|
|
452
|
+
text_body, html_body, total = render(args.date, groups, digests, bodies, clusters)
|
|
453
|
+
label = (args.label or '').strip()
|
|
454
|
+
suffix = f'({label})' if label else ''
|
|
455
|
+
subject = f'公众号日报 {args.date}{suffix}:{" / ".join(t for t in topics if groups.get(t))} {total} 篇'
|
|
456
|
+
if label:
|
|
457
|
+
text_body = f'【{label}】以下是到此刻为止收到的 {total} 篇。完整版会在次日凌晨按整天重发。\n\n' + text_body
|
|
458
|
+
|
|
459
|
+
user, auth = smtp_settings()
|
|
460
|
+
to = args.to or str(cfg.get('dailyMailTo') or '') or user
|
|
461
|
+
print(f'收件人: {to} 主题: {subject} 正文字符: {len(text_body)}')
|
|
462
|
+
if args.dry_run:
|
|
463
|
+
out = ROOT / 'output' / f'daily-mail-{args.date}.html'
|
|
464
|
+
out.write_text(html_body, encoding='utf-8')
|
|
465
|
+
print(f'[dry-run] 已渲染到 {out}')
|
|
466
|
+
return
|
|
467
|
+
send(user, auth, to, subject, text_body, html_body)
|
|
468
|
+
print(f'已发送到 {to}')
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
if __name__ == '__main__':
|
|
472
|
+
main()
|
package/scripts/daily_task.ps1
CHANGED
|
@@ -127,6 +127,51 @@ if ($proc.ExitCode -eq 0 -and $rest.Count -eq 0) {
|
|
|
127
127
|
Set-Content -Path (Join-Path $dayDir '.noon_done') -Value (Get-Date -Format 'yyyy-MM-dd HH:mm:ss') -Encoding UTF8
|
|
128
128
|
}
|
|
129
129
|
}
|
|
130
|
+
|
|
131
|
+
# Retrieval indexes lag behind unless something refreshes them: on 2026-10-22 the
|
|
132
|
+
# semantic index was still at 10-15 (7 days stale) and the phrase index at 10-17, so
|
|
133
|
+
# `search` simply could not find anything newer. Both run here, after the day is on
|
|
134
|
+
# disk, in their own logs -- a failure is recorded and never fails the day.
|
|
135
|
+
$searchScript = Join-Path $PSScriptRoot 'semantic_search.py'
|
|
136
|
+
if ($mode -eq 'day' -and (Test-Path $searchScript)) {
|
|
137
|
+
$idxOut = Join-Path $logDir ('index-{0}.log' -f $stamp)
|
|
138
|
+
$idxErr = Join-Path $logDir ('index-{0}.err.log' -f $stamp)
|
|
139
|
+
# `update` only embeds what is not in the index yet (4,061 new records on the first
|
|
140
|
+
# run after a week). `phrase-build` is local FTS, no API, so it rebuilds in full.
|
|
141
|
+
$idxProc = Start-Process -FilePath $python `
|
|
142
|
+
-ArgumentList @($searchScript, 'update', '--article-days', '30') `
|
|
143
|
+
-NoNewWindow -Wait -PassThru -RedirectStandardOutput $idxOut -RedirectStandardError $idxErr
|
|
144
|
+
$phraseProc = Start-Process -FilePath $python `
|
|
145
|
+
-ArgumentList @($searchScript, 'phrase-build') `
|
|
146
|
+
-NoNewWindow -Wait -PassThru `
|
|
147
|
+
-RedirectStandardOutput (Join-Path $logDir ('phrase-{0}.log' -f $stamp)) `
|
|
148
|
+
-RedirectStandardError (Join-Path $logDir ('phrase-{0}.err.log' -f $stamp))
|
|
149
|
+
if ($idxProc.ExitCode -ne 0 -or $phraseProc.ExitCode -ne 0) {
|
|
150
|
+
Add-Content -Path (Join-Path $logDir 'daily-task.log') -Value `
|
|
151
|
+
('===== {0} index update failed (update={1}, phrase={2}), see {3} =====' -f `
|
|
152
|
+
(Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $idxProc.ExitCode, $phraseProc.ExitCode, $idxErr) -Encoding UTF8
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
# **Both passes mail now.** The noon pass used to be silent, which read as "it never
|
|
157
|
+
# ran" - the only way to tell it had done anything was to open the log. The two emails
|
|
158
|
+
# are clearly different: the noon one is labelled 截至中午 and says the complete version
|
|
159
|
+
# follows next morning. Mail is still its own step with its own log pair, so a mail
|
|
160
|
+
# failure never fails the day nor blocks the noon marker.
|
|
161
|
+
$mailScript = Join-Path $PSScriptRoot 'daily_mail.py'
|
|
162
|
+
$mailOut = Join-Path $logDir ('daily-mail-{0}{1}.log' -f $stamp, $suffix)
|
|
163
|
+
$mailErr = Join-Path $logDir ('daily-mail-{0}{1}.err.log' -f $stamp, $suffix)
|
|
164
|
+
if (Test-Path $mailScript) {
|
|
165
|
+
$mailArgs = @($mailScript, '--date', $targetDay)
|
|
166
|
+
if ($mode -eq 'noon') { $mailArgs += @('--label', '截至中午') }
|
|
167
|
+
$mailProc = Start-Process -FilePath $python -ArgumentList $mailArgs `
|
|
168
|
+
-NoNewWindow -Wait -PassThru -RedirectStandardOutput $mailOut -RedirectStandardError $mailErr
|
|
169
|
+
if ($mailProc.ExitCode -ne 0) {
|
|
170
|
+
Add-Content -Path (Join-Path $logDir 'daily-task.log') -Value `
|
|
171
|
+
('===== {0} [{1}] {2} mail failed (exit {3}), see {4} =====' -f `
|
|
172
|
+
(Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $mode, $targetDay, $mailProc.ExitCode, $mailErr) -Encoding UTF8
|
|
173
|
+
}
|
|
174
|
+
}
|
|
130
175
|
}
|
|
131
176
|
|
|
132
177
|
exit $proc.ExitCode
|