weflow-cli 1.8.1 → 1.9.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/CHANGELOG.md +359 -0
- package/OPERATIONS.md +79 -0
- package/README.en.md +9 -2
- package/README.md +12 -2
- package/bin/weflow-cli-electron.cjs +2 -0
- package/bin/weflow-cli.ts +648 -79
- package/dist/bin/weflow-cli.js +675 -78
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/mcp-server/http.d.ts +14 -0
- package/dist/mcp-server/http.d.ts.map +1 -0
- package/dist/mcp-server/http.js +84 -0
- package/dist/mcp-server/http.js.map +1 -0
- package/dist/mcp-server/httpConfig.d.ts +50 -0
- package/dist/mcp-server/httpConfig.d.ts.map +1 -0
- package/dist/mcp-server/httpConfig.js +104 -0
- package/dist/mcp-server/httpConfig.js.map +1 -0
- package/dist/mcp-server/index.d.ts +13 -1
- package/dist/mcp-server/index.d.ts.map +1 -1
- package/dist/mcp-server/index.js +39 -373
- package/dist/mcp-server/index.js.map +1 -1
- package/dist/src/panel/server.d.ts.map +1 -1
- package/dist/src/panel/server.js +26 -1
- package/dist/src/panel/server.js.map +1 -1
- package/dist/src/services/assistantEval.d.ts +69 -0
- package/dist/src/services/assistantEval.d.ts.map +1 -1
- package/dist/src/services/assistantEval.js +270 -8
- package/dist/src/services/assistantEval.js.map +1 -1
- package/dist/src/services/assistantScenes.d.ts +81 -0
- package/dist/src/services/assistantScenes.d.ts.map +1 -0
- package/dist/src/services/assistantScenes.js +322 -0
- package/dist/src/services/assistantScenes.js.map +1 -0
- package/dist/src/services/assistantService.d.ts +14 -4
- package/dist/src/services/assistantService.d.ts.map +1 -1
- package/dist/src/services/assistantService.js +82 -6
- package/dist/src/services/assistantService.js.map +1 -1
- package/dist/src/services/assistantSkills.d.ts +94 -0
- package/dist/src/services/assistantSkills.d.ts.map +1 -0
- package/dist/src/services/assistantSkills.js +330 -0
- package/dist/src/services/assistantSkills.js.map +1 -0
- package/dist/src/services/assistantTools.d.ts +7 -0
- package/dist/src/services/assistantTools.d.ts.map +1 -1
- package/dist/src/services/assistantTools.js +770 -61
- package/dist/src/services/assistantTools.js.map +1 -1
- package/dist/src/services/assistantTrace.d.ts +4 -0
- package/dist/src/services/assistantTrace.d.ts.map +1 -1
- package/dist/src/services/assistantTrace.js +9 -0
- package/dist/src/services/assistantTrace.js.map +1 -1
- package/dist/src/services/configService.d.ts +7 -0
- package/dist/src/services/configService.d.ts.map +1 -1
- package/dist/src/services/configService.js +4 -2
- package/dist/src/services/configService.js.map +1 -1
- package/dist/src/services/outboundSend.d.ts +53 -0
- package/dist/src/services/outboundSend.d.ts.map +1 -0
- package/dist/src/services/outboundSend.js +90 -0
- package/dist/src/services/outboundSend.js.map +1 -0
- package/dist/src/services/pythonBridge.d.ts.map +1 -1
- package/dist/src/services/pythonBridge.js +1 -0
- package/dist/src/services/pythonBridge.js.map +1 -1
- package/dist/src/utils/python.js +2 -2
- package/dist/src/utils/python.js.map +1 -1
- package/docs/BRANCHES.md +37 -0
- package/docs/DECISIONS.md +350 -0
- package/docs/EXTENDING.md +29 -3
- package/docs/MCP.md +56 -2
- package/docs/PROJECT_STATE.md +121 -9
- package/mcp-server/http.ts +108 -0
- package/mcp-server/httpConfig.ts +126 -0
- package/mcp-server/index.ts +344 -681
- package/package.json +1 -1
- package/resources/panel/ball-position.cjs +48 -2
- package/resources/panel/index.html +6 -4
- package/resources/panel/main.cjs +137 -5
- package/resources/panel/mascot-base.png +0 -0
- package/resources/panel/mascot-iris.png +0 -0
- package/resources/panel/mascot-peek.png +0 -0
- package/resources/panel/panel.css +107 -63
- package/resources/panel/preload.cjs +26 -0
- package/resources/panel/renderer.js +167 -10
- package/scripts/_utils.py +47 -1
- package/scripts/backfill_articles.py +202 -21
- package/scripts/biz_daily.py +8 -31
- package/scripts/bonds.py +331 -0
- package/scripts/chat_notes.py +551 -470
- package/scripts/chat_stats.py +8 -7
- package/scripts/compile_wiki.py +24 -2
- package/scripts/export_chat_html.py +3 -1
- package/scripts/mcp_bridge.py +6 -7
- package/scripts/wechat_voice.py +9 -0
- package/scripts/wiki_lint.py +30 -7
- package/src/panel/server.ts +25 -1
- package/src/services/assistantEval.ts +323 -12
- package/src/services/assistantScenes.ts +342 -0
- package/src/services/assistantService.ts +81 -6
- package/src/services/assistantSkills.ts +364 -0
- package/src/services/assistantTools.ts +738 -59
- package/src/services/assistantTrace.ts +9 -0
- package/src/services/configService.ts +412 -403
- package/src/services/outboundSend.ts +126 -0
- package/src/services/pythonBridge.ts +1 -0
- package/src/utils/python.ts +2 -2
|
@@ -84,6 +84,40 @@ function addTurn(who, text) {
|
|
|
84
84
|
return div
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/**
|
|
88
|
+
* 把这一轮的**轨迹**折叠着挂在回答下面(服务端 `/api/ask` 一并给回来)。
|
|
89
|
+
*
|
|
90
|
+
* **默认折叠**是有意的:轨迹多数时候是"我查了什么",摊开会把每次回答都变成一屏日志,
|
|
91
|
+
* 而不想看的人还得滚过它。想核对"它到底查没查、查的是什么"的人点一下展开。
|
|
92
|
+
* 内容与微信里发「轨迹」看到的是同一份(同一个格式函数),所以两处不会各说各话。
|
|
93
|
+
*
|
|
94
|
+
* 全用 `textContent`(不用 innerHTML):轨迹里有工具参数,是**用户数据**。
|
|
95
|
+
*/
|
|
96
|
+
function addThinking(turn, trace) {
|
|
97
|
+
const lines = trace && Array.isArray(trace.lines)
|
|
98
|
+
? trace.lines.filter((line) => typeof line === 'string' && line.trim()) : []
|
|
99
|
+
const reasoning = trace && typeof trace.reasoning === 'string' ? trace.reasoning.trim() : ''
|
|
100
|
+
if (!lines.length && !reasoning) return // 没有轨迹就什么都不挂(不是显示一个空壳)
|
|
101
|
+
const details = document.createElement('details')
|
|
102
|
+
details.className = 'thinking'
|
|
103
|
+
const summary = document.createElement('summary')
|
|
104
|
+
summary.textContent = '思考过程'
|
|
105
|
+
details.appendChild(summary)
|
|
106
|
+
for (const line of lines) {
|
|
107
|
+
const row = document.createElement('div')
|
|
108
|
+
row.className = 'thinking-line'
|
|
109
|
+
row.textContent = line
|
|
110
|
+
details.appendChild(row)
|
|
111
|
+
}
|
|
112
|
+
if (reasoning) {
|
|
113
|
+
const row = document.createElement('div')
|
|
114
|
+
row.className = 'thinking-line thinking-reasoning'
|
|
115
|
+
row.textContent = reasoning
|
|
116
|
+
details.appendChild(row)
|
|
117
|
+
}
|
|
118
|
+
turn.appendChild(details)
|
|
119
|
+
}
|
|
120
|
+
|
|
87
121
|
/**
|
|
88
122
|
* 还没聊过时的开头。**空白是这里最糟的状态**:第一次打开的人看到一整片黑,
|
|
89
123
|
* 既不知道它能干什么,也看不出它是活的(下面没有正在输入之类的动静)。
|
|
@@ -127,10 +161,11 @@ function renderEmptyState() {
|
|
|
127
161
|
}
|
|
128
162
|
|
|
129
163
|
/**
|
|
130
|
-
*
|
|
164
|
+
* 球的**脸**跟着状态走(CSS 里 `body.busy/.offline/.quota #ball .face`)——
|
|
165
|
+
* 2026-09-29 之前还有一层会变色/呼吸的光晕,按用户要求整层去掉了(见 panel.css 那段注释)。
|
|
131
166
|
*
|
|
132
167
|
* 集中在一个函数里改:之前这些类名散在 ask()/refreshStatus() 各处的话,
|
|
133
|
-
* 迟早有一条分支忘了摘掉 `busy
|
|
168
|
+
* 迟早有一条分支忘了摘掉 `busy`,球就一直挂着那张脸——那种"看起来在忙其实没在忙"比不显示更糟。
|
|
134
169
|
* 传进来的每一项都**整体替换**,不做增量。
|
|
135
170
|
*/
|
|
136
171
|
function setBallState(state) {
|
|
@@ -243,7 +278,8 @@ async function ask(text, display = null) {
|
|
|
243
278
|
pending.remove()
|
|
244
279
|
|
|
245
280
|
if (res.ok && body.ok) {
|
|
246
|
-
addTurn('it', body.reply)
|
|
281
|
+
const turn = addTurn('it', body.reply)
|
|
282
|
+
addThinking(turn, body.trace)
|
|
247
283
|
// 配额用尽时服务端回的是一句**回答**(不是错误),所以上面照常显示;
|
|
248
284
|
// 这里只是把状态条上的数字刷新一下
|
|
249
285
|
void refreshStatus()
|
|
@@ -307,9 +343,101 @@ function prefersReducedMotion() {
|
|
|
307
343
|
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
308
344
|
}
|
|
309
345
|
|
|
346
|
+
// ------------------------------------------------------- 眼珠跟着鼠标(2026-09-30)
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* 鼠标位置来自**主进程**(`onCursor`),不是页面的 `mousemove`:球只有 96×96,鼠标绝大多数
|
|
350
|
+
* 时间在窗口**外面**,渲染进程一个事件都收不到。
|
|
351
|
+
*
|
|
352
|
+
* 位移上限是**从美术上量出来的**,不是拍的(量法见提交信息里的脚本):
|
|
353
|
+
* - 虹膜到左边那道深色描边只剩约 2.6px(球上),再往左就裂开一条白缝;
|
|
354
|
+
* - **向上几乎没有余量**(上面就是眼皮),向下宽一些;
|
|
355
|
+
* - 所以夹的是一个**椭圆**,不是一个圆 —— 横向 2.6、向上 0.8、向下 2.6。
|
|
356
|
+
*
|
|
357
|
+
* 常量按**球的 96px** 写;球尺寸将来变了,下面按实测宽度等比缩放,不用回来改这些数。
|
|
358
|
+
*/
|
|
359
|
+
const IRIS_MAX_X = 2.6
|
|
360
|
+
const IRIS_MAX_UP = 0.8
|
|
361
|
+
const IRIS_MAX_DOWN = 2.6
|
|
362
|
+
const IRIS_EASE = 0.18 // 每帧逼近目标的比例(一阶低通)
|
|
363
|
+
const IRIS_FAR = 260 // 鼠标远过这个距离就当"看向最边上",不再加分
|
|
364
|
+
const IRIS_EPS = 0.02 // 到位判定(球上像素)
|
|
365
|
+
|
|
366
|
+
let cursorTarget = null // 鼠标的屏幕坐标;null = 还不知道它在哪
|
|
367
|
+
let irisX = 0
|
|
368
|
+
let irisY = 0
|
|
369
|
+
let irisRaf = null
|
|
370
|
+
|
|
371
|
+
/** 球心在屏幕上的位置。载荷是 DIP、`getBoundingClientRect` 也是 DIP,所以可以直接相减。 */
|
|
372
|
+
function ballCenterOnScreen() {
|
|
373
|
+
const rect = ball.getBoundingClientRect()
|
|
374
|
+
return {
|
|
375
|
+
x: window.screenX + rect.left + rect.width / 2,
|
|
376
|
+
y: window.screenY + rect.top + rect.height / 2,
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** 往目标挪一帧。返回"还在动吗"——不动了外层就把 rAF 停掉。 */
|
|
381
|
+
function stepIris() {
|
|
382
|
+
const size = ball.offsetWidth || 96
|
|
383
|
+
const k = size / 96
|
|
384
|
+
let tx = 0
|
|
385
|
+
let ty = 0
|
|
386
|
+
if (cursorTarget && !prefersReducedMotion()) {
|
|
387
|
+
const center = ballCenterOnScreen()
|
|
388
|
+
const dx = cursorTarget.x - center.x
|
|
389
|
+
const dy = cursorTarget.y - center.y
|
|
390
|
+
const dist = Math.hypot(dx, dy) || 1
|
|
391
|
+
const strength = Math.min(1, dist / (IRIS_FAR * k)) // 远处饱和:屏幕另一头也是"看那边"
|
|
392
|
+
const ux = (dx / dist) * strength
|
|
393
|
+
const uy = (dy / dist) * strength
|
|
394
|
+
tx = ux * IRIS_MAX_X * k
|
|
395
|
+
ty = uy * (uy < 0 ? IRIS_MAX_UP : IRIS_MAX_DOWN) * k
|
|
396
|
+
}
|
|
397
|
+
irisX += (tx - irisX) * IRIS_EASE
|
|
398
|
+
irisY += (ty - irisY) * IRIS_EASE
|
|
399
|
+
const done = Math.abs(irisX - tx) < IRIS_EPS && Math.abs(irisY - ty) < IRIS_EPS
|
|
400
|
+
if (done) {
|
|
401
|
+
irisX = tx
|
|
402
|
+
irisY = ty
|
|
403
|
+
}
|
|
404
|
+
const iris = ball.querySelector('.iris')
|
|
405
|
+
if (iris) iris.style.transform = `translate(${irisX.toFixed(2)}px, ${irisY.toFixed(2)}px)`
|
|
406
|
+
return !done
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* **静止就停 rAF**。一直跑 60fps 写 transform 会让合成器一直忙——那是为装饰付的电,
|
|
411
|
+
* 不值得(这个窗口是常驻的)。所以:鼠标动了才起循环,到位就停,下一次推送再起。
|
|
412
|
+
*/
|
|
413
|
+
function pumpIris() {
|
|
414
|
+
if (irisRaf !== null) return
|
|
415
|
+
if (typeof requestAnimationFrame !== 'function') return
|
|
416
|
+
irisRaf = requestAnimationFrame(() => {
|
|
417
|
+
irisRaf = null
|
|
418
|
+
if (stepIris()) pumpIris()
|
|
419
|
+
})
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (hasShell && window.weflowPanel && typeof window.weflowPanel.onCursor === 'function') {
|
|
423
|
+
window.weflowPanel.onCursor((point) => {
|
|
424
|
+
cursorTarget = point
|
|
425
|
+
pumpIris()
|
|
426
|
+
})
|
|
427
|
+
}
|
|
428
|
+
|
|
310
429
|
/** 气泡方位相关的类,每次切形态先全摘掉再按主进程说的挂上 */
|
|
311
430
|
const LAYOUT_CLASSES = ['bubble-left', 'bubble-right', 'anchor-top']
|
|
312
431
|
|
|
432
|
+
/**
|
|
433
|
+
* 半隐相关的类:`ball-peek` 换成探头那张脸(并去掉球的圆角,好让那条直切边落在屏幕边上),
|
|
434
|
+
* `ball-peek-right` 再把图水平镜像一次给右边用。
|
|
435
|
+
*
|
|
436
|
+
* 贴哪条边只有主进程知道(它才管窗口位置),所以这一组**和形态一样是主进程说了算**,
|
|
437
|
+
* 页面只照着挂。同样先整体摘掉再按说的挂——散着写迟早有一条分支忘了摘。
|
|
438
|
+
*/
|
|
439
|
+
const PEEK_CLASSES = ['ball-peek', 'ball-peek-right']
|
|
440
|
+
|
|
313
441
|
/**
|
|
314
442
|
* 按主进程说的形态切 class。**这里是唯一改形态的地方**。
|
|
315
443
|
*
|
|
@@ -322,15 +450,28 @@ const LAYOUT_CLASSES = ['bubble-left', 'bubble-right', 'anchor-top']
|
|
|
322
450
|
function applyMode(payload) {
|
|
323
451
|
const mode = payload && payload.mode === 'ball' ? 'ball' : 'chat'
|
|
324
452
|
const side = payload && payload.side === 'right' ? 'right' : 'left'
|
|
325
|
-
|
|
326
|
-
|
|
453
|
+
// **只摆锚、不切形态**(`anchorOnly`):展开分两步走,见 `main.cjs` 里"先摆锚、等页面
|
|
454
|
+
// 画完、再改窗口尺寸"那段。切形态会连带把气泡显出来,而那会儿窗口还是 96px ——
|
|
455
|
+
// 气泡恰好盖住球。所以第一步只动 `LAYOUT_CLASSES`。
|
|
456
|
+
const anchorOnly = !!(payload && payload.anchorOnly)
|
|
457
|
+
document.body.classList.remove(
|
|
458
|
+
...(anchorOnly ? LAYOUT_CLASSES : ['mode-ball', 'mode-chat', 'closing', ...LAYOUT_CLASSES]))
|
|
459
|
+
// 半隐与"锚"无关,所以 `anchorOnly` 那一步(展开的两个阶段)不许把它摘掉
|
|
460
|
+
if (!anchorOnly) {
|
|
461
|
+
document.body.classList.remove(...PEEK_CLASSES)
|
|
462
|
+
if (mode === 'ball' && payload && payload.peek) {
|
|
463
|
+
document.body.classList.add('ball-peek')
|
|
464
|
+
if (payload.peek === 'right') document.body.classList.add('ball-peek-right')
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
if (!anchorOnly) document.body.classList.add(mode === 'chat' ? 'mode-chat' : 'mode-ball')
|
|
327
468
|
if (mode === 'chat') {
|
|
328
469
|
document.body.classList.add(side === 'right' ? 'bubble-right' : 'bubble-left')
|
|
329
470
|
if (payload && payload.anchorY === 'top') document.body.classList.add('anchor-top')
|
|
330
471
|
const height = Number(payload && payload.bubbleHeight)
|
|
331
472
|
document.body.style.setProperty('--bubble-height',
|
|
332
473
|
`${Number.isFinite(height) && height > 0 ? Math.round(height) : 560}px`)
|
|
333
|
-
input.focus()
|
|
474
|
+
if (!anchorOnly) input.focus()
|
|
334
475
|
}
|
|
335
476
|
}
|
|
336
477
|
|
|
@@ -354,6 +495,21 @@ function toggleMode() {
|
|
|
354
495
|
if (hasShell) {
|
|
355
496
|
// `shell` 这个类决定球在不在场(见 panel.css):浏览器降级那条路永远不该看见球
|
|
356
497
|
document.body.classList.add('shell', 'mode-ball')
|
|
498
|
+
// **四张脸先取回来。**(2026-09-29 加的,用户报"点一下会闪一下")
|
|
499
|
+
//
|
|
500
|
+
// 这些脸是 CSS 背景图,**什么时候用什么时候才去取**;而实测从守护进程取一张要
|
|
501
|
+
// **~15 毫秒**(60Hz 一帧是 16.7ms,正好一帧)。所以按下球的那一刻才开始取,第一帧
|
|
502
|
+
// 就是空的——看起来就是"闪一下"。仓库里那句老注释写过同一个症状
|
|
503
|
+
// ("球在按下的一瞬间会闪成一张空图"),当时的结论是静态白名单一张都不能漏;
|
|
504
|
+
// 现在白名单是对的,但**第一次用到仍然要等那一趟网络**。预取把它挪到页面刚加载时,
|
|
505
|
+
// 那时没人盯着球看。
|
|
506
|
+
//
|
|
507
|
+
// 放在 `hasShell` 里面:浏览器降级那条路根本不显示球,没必要替它取。
|
|
508
|
+
for (const face of ['mascot.png', 'mascot-happy.png', 'mascot-thinking.png',
|
|
509
|
+
'mascot-sorry.png', 'mascot-tired.png']) {
|
|
510
|
+
const img = new Image()
|
|
511
|
+
img.src = '/panel/' + face
|
|
512
|
+
}
|
|
357
513
|
ball.hidden = false
|
|
358
514
|
collapse.hidden = false
|
|
359
515
|
|
|
@@ -431,10 +587,11 @@ if (hasShell) {
|
|
|
431
587
|
}
|
|
432
588
|
})()
|
|
433
589
|
})
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
590
|
+
// 这里原来挂着两条 `closeQuickMenu()`(点了别处关菜单、Esc 关菜单)——**那是页内菜单时代的
|
|
591
|
+
// 旧址**:菜单现在是**原生**的(`openQuickMenu` 由主进程弹,画在窗口外面),没有"页内菜单"
|
|
592
|
+
// 可关,而 `closeQuickMenu` 这个函数**在文件里根本没有定义**。
|
|
593
|
+
// 后果是:点面板里任何地方、或按 Esc,都会抛一次未捕获的 ReferenceError(不致命,
|
|
594
|
+
// 但测试里 jsdom 会把它报出来,控制台也一直脏)。按"没有页内菜单"这个事实删掉。
|
|
438
595
|
|
|
439
596
|
collapse.addEventListener('click', () => { requestMode('ball') })
|
|
440
597
|
|
package/scripts/_utils.py
CHANGED
|
@@ -601,6 +601,38 @@ def get_db_config(config=None):
|
|
|
601
601
|
}
|
|
602
602
|
|
|
603
603
|
|
|
604
|
+
def biz_message_db(config=None):
|
|
605
|
+
"""订阅号(公众号)消息库 `biz_message_0.db` 的路径与钥匙,返回 `(db, key_hex, salt_hex)`。
|
|
606
|
+
|
|
607
|
+
**微信 4.x:钥匙由全库 passphrase 派生**(PBKDF2-HMAC-SHA512,256000 轮,32 字节),
|
|
608
|
+
盐取**库文件头 16 字节**。`favPassphrase` 优先,退而用 `decryptKey`;
|
|
609
|
+
更早的 `bizKey`/`bizSalt`(配了就直用)只作兼容保留——那两个键在本机早就不存在了。
|
|
610
|
+
|
|
611
|
+
抽成一个函数,是因为这把钥匙原来有**三份实现**:`biz_daily` 那份是 passphrase 派生(可用),
|
|
612
|
+
`chat_stats` 与 `mcp_bridge` 那两份只认 `bizKey`(在本机必然报『缺少密钥』,而照它们的提示去
|
|
613
|
+
`config set bizKey` 还会撞上『该键不在 CLI 可写白名单里』——越修越远)。
|
|
614
|
+
"""
|
|
615
|
+
import hashlib
|
|
616
|
+
if config is None:
|
|
617
|
+
config = load_config()
|
|
618
|
+
nt_db = str(config.get('ntDbPath', ''))
|
|
619
|
+
msg_dir = os.path.dirname(nt_db.replace('\\', '/'))
|
|
620
|
+
db = os.path.join(msg_dir, 'biz_message_0.db')
|
|
621
|
+
key_enc = config.get('bizKey', '')
|
|
622
|
+
salt = config.get('bizSalt', '')
|
|
623
|
+
if key_enc and salt:
|
|
624
|
+
return db, decrypt_lock(key_enc), salt
|
|
625
|
+
pass_enc = config.get('favPassphrase', '') or config.get('decryptKey', '')
|
|
626
|
+
if not pass_enc or not os.path.exists(db):
|
|
627
|
+
raise RuntimeError(
|
|
628
|
+
'缺少订阅号数据库密钥: 请运行 weflow-cli fav set-key --passphrase <64位hex> '
|
|
629
|
+
'配置全库 passphrase (自动派生各库密钥)')
|
|
630
|
+
with open(db, 'rb') as fh:
|
|
631
|
+
salt = fh.read(16).hex()
|
|
632
|
+
key = hashlib.pbkdf2_hmac('sha512', bytes.fromhex(decrypt_lock(pass_enc)),
|
|
633
|
+
bytes.fromhex(salt), 256000, dklen=32).hex()
|
|
634
|
+
return db, key, salt
|
|
635
|
+
|
|
604
636
|
def get_api_key(config=None) -> str:
|
|
605
637
|
"""读取 AI API key (自动解密 lock: 前缀)。"""
|
|
606
638
|
if config is None:
|
|
@@ -676,6 +708,20 @@ def write_with_frontmatter(filepath: str, frontmatter: dict, body: str):
|
|
|
676
708
|
raise
|
|
677
709
|
|
|
678
710
|
|
|
711
|
+
def _unquote(item: str) -> str:
|
|
712
|
+
"""拆掉**成对的**外层引号;不成对就一个字都不动。
|
|
713
|
+
|
|
714
|
+
原来这里写的是 `item.strip().strip('"\\'')` —— `.strip()` 的语义是"剥掉首尾**所有**这些
|
|
715
|
+
字符",于是项**内部**末尾的引号也被吃掉:`AI 长出"手脚"` 读回来变成 `AI 长出"手脚`。
|
|
716
|
+
一个字符之差、不报错,下游只表现为"这个名字对不上"(2026-10-01:一张概念页的别名因此
|
|
717
|
+
永远匹配不上卡片里的链接,那页被报成孤儿)。成对判断把它修好,同时保住"外层引号是语法"。
|
|
718
|
+
"""
|
|
719
|
+
item = item.strip()
|
|
720
|
+
if len(item) >= 2 and item[0] == item[-1] and item[0] in '"\'':
|
|
721
|
+
return item[1:-1]
|
|
722
|
+
return item
|
|
723
|
+
|
|
724
|
+
|
|
679
725
|
def parse_frontmatter(content: str) -> tuple[dict, str]:
|
|
680
726
|
"""解析 Markdown 文件中的 YAML frontmatter。"""
|
|
681
727
|
if not content.startswith('---'):
|
|
@@ -695,7 +741,7 @@ def parse_frontmatter(content: str) -> tuple[dict, str]:
|
|
|
695
741
|
val = val.strip()
|
|
696
742
|
if val.startswith('[') and val.endswith(']'):
|
|
697
743
|
inner = val[1:-1]
|
|
698
|
-
items = [v
|
|
744
|
+
items = [_unquote(v) for v in inner.split(',')] if inner.strip() else []
|
|
699
745
|
result[key] = items
|
|
700
746
|
elif val.startswith('"') and val.endswith('"'):
|
|
701
747
|
result[key] = val[1:-1]
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
用法:
|
|
5
5
|
python scripts/backfill_articles.py --since 2026-03-01 --until 2026-08-31 --dry-run --json
|
|
6
6
|
python scripts/backfill_articles.py --since 2026-03-01 --until 2026-08-31 --workers 12 --yes
|
|
7
|
+
python scripts/backfill_articles.py --since 2025-09-05 --until 2026-03-02 --topic AI --yes
|
|
7
8
|
|
|
8
9
|
## 为什么不是直接跑 `pipeline run --date`
|
|
9
10
|
|
|
@@ -26,6 +27,17 @@
|
|
|
26
27
|
**图片型文章抓不到正文。** 实测 3/4、7/4 两天:27/40、44/60 有正文,其余是图文消息
|
|
27
28
|
(内容全在图片里),清洗掉图片与链接后不足 100 字,与 `biz_daily` 一样被跳过。这不是
|
|
28
29
|
抓取失败,是那类文章本来就没有文字。所以"2 万篇"这个量级是有正文的部分,不是总量。
|
|
30
|
+
|
|
31
|
+
## `--topic`:只回填一部分主题(先筛后抓)
|
|
32
|
+
|
|
33
|
+
`--topic AI` 用的就是本脚本自己判主题的那套 `_guess_topic`(只看标题与号名、**不看正文**),
|
|
34
|
+
所以它筛出来的 AI 集合,与全量跑完之后落在 `AI/` 目录里的那批**逐个相同** —— 不是另立一套
|
|
35
|
+
口径,只是省掉了其余主题的抓取(实测 2025-09-05~2026-03-02 那段:全量 10,880 篇里 AI 占
|
|
36
|
+
1,759 篇,抓取量约 1/6)。
|
|
37
|
+
|
|
38
|
+
**代价必须写出来:那一天就不再是完整的一天。** 所以 `.articles.json` 里会多一个 `topicFilter`
|
|
39
|
+
(README 里也写明),`day_done` 据此在之后跑**全量**时**不会**跳过这一天。少了这一步,那天
|
|
40
|
+
其余五个主题的文章会被永久漏掉、而且不报错 —— 文件在、json 在、"已回填"看起来处处成立。
|
|
29
41
|
"""
|
|
30
42
|
import argparse
|
|
31
43
|
import hashlib
|
|
@@ -121,6 +133,32 @@ def apply_topics(articles):
|
|
|
121
133
|
return articles
|
|
122
134
|
|
|
123
135
|
|
|
136
|
+
def parse_topics(values):
|
|
137
|
+
"""把 `--topic` 的取值展开成一个列表:**可重复,也可逗号分隔**。
|
|
138
|
+
|
|
139
|
+
两种都收的理由是仓库里另一条命令(`article_notes.py`)的 `--topic` 就是 `action='append'`
|
|
140
|
+
且写明"可重复或逗号分隔"。只认一种的话,照另一边习惯写就会**静默只做一个主题**。
|
|
141
|
+
|
|
142
|
+
`values` 传字符串也当单个处理:`for chunk in 'AI'` 会**逐字符**迭代,而每个字符都不在分类法里
|
|
143
|
+
—— 于是"整批被拒"看起来跟"参数拼错"一模一样,很难查。这里把它挡住。
|
|
144
|
+
"""
|
|
145
|
+
if isinstance(values, str) or values is None:
|
|
146
|
+
values = [values or '']
|
|
147
|
+
return [t.strip() for chunk in values for t in str(chunk).split(',') if t.strip()]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def select_topics(articles, topics):
|
|
151
|
+
"""只留下选中主题的文章;`topics` 为空/None 表示全要(原样返回)。
|
|
152
|
+
|
|
153
|
+
必须在 `apply_topics` **之后**调:主题是那个函数的输出。顺序对"选中哪些"没有影响
|
|
154
|
+
(`_guess_topic` 逐篇独立、只看标题与号名),所以筛出来的集合与全量跑完落在对应主题
|
|
155
|
+
目录里的那批逐个相同。
|
|
156
|
+
"""
|
|
157
|
+
if not topics:
|
|
158
|
+
return articles
|
|
159
|
+
return [a for a in articles if a.get('topic') in topics]
|
|
160
|
+
|
|
161
|
+
|
|
124
162
|
def fetch_bodies(articles, workers: int, log=None):
|
|
125
163
|
"""并发把这批文章的正文抓回来,写进 `fetched_md`。
|
|
126
164
|
|
|
@@ -151,13 +189,66 @@ def fetch_bodies(articles, workers: int, log=None):
|
|
|
151
189
|
return {'attempted': len(todo), 'ok': ok}
|
|
152
190
|
|
|
153
191
|
|
|
154
|
-
def
|
|
192
|
+
def _read_index(day_dir) -> dict:
|
|
193
|
+
"""读这一天已有的 `.articles.json`(没有就空表)。
|
|
194
|
+
|
|
195
|
+
**读不了的时候不是安静地返回空表**:那等于把已有条目从索引里抹掉,而这件事不会报错
|
|
196
|
+
—— 正是这一整段要防的事。所以把原文件改名留成 `.json.bad` 再继续,至少还能人工救。
|
|
197
|
+
"""
|
|
198
|
+
path = Path(day_dir) / '.articles.json'
|
|
199
|
+
if not path.exists():
|
|
200
|
+
return {}
|
|
201
|
+
try:
|
|
202
|
+
payload = json.loads(path.read_text(encoding='utf-8'))
|
|
203
|
+
return payload if isinstance(payload, dict) else {}
|
|
204
|
+
except Exception as exc:
|
|
205
|
+
try:
|
|
206
|
+
path.rename(path.with_name('.articles.json.bad'))
|
|
207
|
+
except OSError:
|
|
208
|
+
pass
|
|
209
|
+
print(' 警告:%s 读不了(%s),已留成 .articles.json.bad,旧索引未参与合并'
|
|
210
|
+
% (path.name, exc))
|
|
211
|
+
return {}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _merge_articles(kept, fresh):
|
|
215
|
+
"""合并两组 `.articles.json` 条目,按「来源-标题」去重(那是它在盘上的文件名)。
|
|
216
|
+
|
|
217
|
+
顺序按 `time` 再 `title` 排,是为了让同一天的索引**稳定**:分两次回填的那天,
|
|
218
|
+
第二次不该把先前的条目挤到别处去,否则每次跑都产生一份看着像"改了"的索引。
|
|
219
|
+
没有 `time` 的老条目(理论上不会有)排在前面,不参与排序。
|
|
220
|
+
"""
|
|
221
|
+
merged = {}
|
|
222
|
+
for entry in list(kept) + list(fresh):
|
|
223
|
+
if not isinstance(entry, dict):
|
|
224
|
+
continue
|
|
225
|
+
merged[(str(entry.get('source', '')), str(entry.get('title', '')))] = entry
|
|
226
|
+
return sorted(merged.values(), key=lambda e: (str(e.get('time', '')), str(e.get('title', ''))))
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def write_day(articles, day, out_root, topic_filter=None, truncated=False):
|
|
155
230
|
"""落盘:`<日期>/<主题>/<来源>-<标题>.md` + `.articles.json` + README。
|
|
156
231
|
|
|
157
232
|
格式与 `biz_daily` 一致——`create_reading_notes` 与 `compile_wiki` 都按那个形状读。
|
|
158
233
|
正文那段写 `## AI 摘要`(内容用文章自带的 digest):**本脚本不调模型**,所以这里
|
|
159
234
|
放的只能是原文已有的东西,不能假装是生成的摘要。
|
|
235
|
+
|
|
236
|
+
传了 `topic_filter`(`--topic`)时,这里会**再筛一次**并在元数据里留下 `topicFilter`:
|
|
237
|
+
|
|
238
|
+
- 筛在**这里**做,是为了让标记不可能撒谎。如果筛只发生在调用方(`main`),那么有人
|
|
239
|
+
漏调那一步时,`write_day` 会把六个主题全写出去、同时盖上"只回填了 AI"的章 ——
|
|
240
|
+
一个自称只回填了一半、实际写满了的目录,比不写标记更坏。
|
|
241
|
+
- README 里也写明这一天是哪几个主题。**这两处不是装饰**:一天只回填了一半却看起来像
|
|
242
|
+
完整的一天,是这份产出唯一会"静默变少"的方式(见 `day_done`)。
|
|
243
|
+
- 传了 `truncated`(`--limit-per-day` 截过)时同理,多一个 `truncated` 标记。
|
|
244
|
+
- **索引是与这一天已有的内容合并的,不是覆盖**:先 `--topic AI` 再来 `--topic 学术`,
|
|
245
|
+
先前那个主题的条目必须留着。覆盖会让**索引比盘上少几条**而 md 还在 —— 不报错,
|
|
246
|
+
而"索引与盘不一致"正是这一支反复吃的那个形状(见 `_merge_articles`、`_read_index`)。
|
|
247
|
+
`topicFilter` 取并集;只要这次是全量、或这天本来就是完整的,标记就**消失**。
|
|
248
|
+
|
|
249
|
+
`truncated` 只影响元数据(它记录的是"调用方截过",不宜在这里重算——截在调用方发生)。
|
|
160
250
|
"""
|
|
251
|
+
articles = select_topics(articles, topic_filter)
|
|
161
252
|
groups, fallbacks = _group_by_topic(articles)
|
|
162
253
|
out_dir = Path(out_root) / day
|
|
163
254
|
written, skipped = [], []
|
|
@@ -201,13 +292,50 @@ def write_day(articles, day, out_root):
|
|
|
201
292
|
written.append(article)
|
|
202
293
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
203
294
|
serializable = [_serializable_article(a, day) for a in written]
|
|
295
|
+
# **索引描述的是这一天盘上全部的文章,不是这一轮写的那批。** 分两次、用不同主题回填同一天
|
|
296
|
+
# 时(`--topic AI` 之后再来 `--topic 学术`),只写本轮的话,先前那个主题的条目会从索引里
|
|
297
|
+
# 消失而 md 还留在盘上——索引少一条、盘上多一篇,不报错。所以先把不属于本轮的旧条目接过来。
|
|
298
|
+
prior = _read_index(out_dir)
|
|
299
|
+
kept = [e for e in prior.get('articles', [])
|
|
300
|
+
if e.get('topic') not in set(topic_filter or ())]
|
|
301
|
+
stored_filter = prior.get('topicFilter')
|
|
302
|
+
# 「**没有**旧索引」与「旧索引是**完整**的一天」是两件事,第一版把它们混成了一个 `None`:
|
|
303
|
+
# 于是第一次做过滤回填时标记根本没写,那天看起来像完整的一天 —— 正是要防的那件事。
|
|
304
|
+
# 区分它们的是"旧索引里有没有东西",不是"有没有那个键"。
|
|
305
|
+
if topic_filter is None:
|
|
306
|
+
scope_filter = None # 全量跑过 → 这天完整
|
|
307
|
+
elif not prior.get('articles'):
|
|
308
|
+
scope_filter = sorted(topic_filter) # 第一次写这天,且只写了一部分
|
|
309
|
+
elif stored_filter is None:
|
|
310
|
+
scope_filter = None # 本来就完整,这次只是重做其中一部分
|
|
311
|
+
else:
|
|
312
|
+
scope_filter = sorted(set(stored_filter) | set(topic_filter))
|
|
313
|
+
if scope_filter and set(scope_filter) >= set(TOPICS):
|
|
314
|
+
# 六个主题都做过一遍了 —— 这一天"能做的都做了",与全量跑过没有区别。
|
|
315
|
+
# 不撤标记的话它**永远**被当成没做完:以后随便跑一次全量都会把整个窗口重抓一遍。
|
|
316
|
+
scope_filter = None
|
|
317
|
+
payload = {'date': day, 'generated_at': datetime.now(TZ).strftime('%Y-%m-%d %H:%M:%S'),
|
|
318
|
+
'backfilled': True, 'articles': _merge_articles(kept, serializable)}
|
|
319
|
+
# 只在**确实是一部分**的时候才加这两个键:全量回填写出来的文件与以前逐字节相同,
|
|
320
|
+
# 而 `day_done` 把"没有 topicFilter"读作"完整的一天"。缺了它就等于把半天的产出
|
|
321
|
+
# 冒充成整天——这正是这条路径唯一能静默丢东西的地方。
|
|
322
|
+
if scope_filter:
|
|
323
|
+
payload['topicFilter'] = scope_filter
|
|
324
|
+
if truncated or prior.get('truncated'):
|
|
325
|
+
# `truncated` 一旦记上就不撤销:这一次没截,不代表上一次截掉的那些补回来了。
|
|
326
|
+
payload['truncated'] = True
|
|
204
327
|
(out_dir / '.articles.json').write_text(
|
|
205
|
-
json.dumps(
|
|
206
|
-
|
|
207
|
-
|
|
328
|
+
json.dumps(payload, ensure_ascii=False, indent=2), encoding='utf-8')
|
|
329
|
+
if scope_filter:
|
|
330
|
+
scope = ('**只回填了 %s 主题** —— 这一天并非全部文章,其余主题还没拉。\n'
|
|
331
|
+
% '、'.join(scope_filter))
|
|
332
|
+
else:
|
|
333
|
+
scope = ''
|
|
334
|
+
# README 说的是**这一天**有几篇(合并后的),不是这一轮写了几篇 —— 分两次回填的那天,
|
|
335
|
+
# 前者才是读它的人想要的数。
|
|
208
336
|
(out_dir / 'README.md').write_text(
|
|
209
337
|
'# 公众号文章 — %s(历史回填)\n\n共 %d 篇。正文由 `backfill_articles.py` 抓取,'
|
|
210
|
-
'**未下载图片、未调用模型**。\n' % (day, len(
|
|
338
|
+
'**未下载图片、未调用模型**。\n%s' % (day, len(payload['articles']), scope), encoding='utf-8')
|
|
211
339
|
return {'written': len(written), 'skipped': len(skipped),
|
|
212
340
|
'fallbackTopics': fallbacks, 'skipSample': skipped[:3]}
|
|
213
341
|
|
|
@@ -277,15 +405,40 @@ def days_with_articles(out_root) -> list:
|
|
|
277
405
|
return days
|
|
278
406
|
|
|
279
407
|
|
|
280
|
-
def day_done(out_root, day) -> bool:
|
|
281
|
-
"""
|
|
408
|
+
def day_done(out_root, day, topics=None) -> bool:
|
|
409
|
+
"""这一天**我们要的那些主题**已经回填过了吗(增量:免得重跑把同样的文章再抓一遍)。
|
|
410
|
+
|
|
411
|
+
`topics=None` 表示"全量"。**判据不只是"写过"**,还要看写的是不是完整的一天:
|
|
412
|
+
|
|
413
|
+
- 只回填过 AI 的一天,`.articles.json` 里带着 `topicFilter`。此后跑**全量**时必须
|
|
414
|
+
**不能**被跳过 —— 否则那天其余五个主题的文章会被永久漏掉,而且不报错:文件在、
|
|
415
|
+
`.articles.json` 在、"已回填"看起来成立。这就是 `days_with_articles` 那条注释里
|
|
416
|
+
记的同一类事故(拿一个不完整的判据当"做完了")。
|
|
417
|
+
- 反向:`--topic AI` 之后又跑 `--topic AI,学术`,存过的是子集,也要重做;
|
|
418
|
+
而存过的**覆盖**了这次要的(或那天本来就完整),才算做完。
|
|
419
|
+
- 被 `--limit-per-day` 截过的一天**从来不算做完**:截掉的那些既没抓、也看不出来。
|
|
420
|
+
"""
|
|
282
421
|
path = Path(out_root) / day / '.articles.json'
|
|
283
422
|
if not path.exists():
|
|
284
423
|
return False
|
|
285
424
|
try:
|
|
286
|
-
|
|
425
|
+
payload = json.loads(path.read_text(encoding='utf-8'))
|
|
287
426
|
except Exception:
|
|
288
427
|
return False
|
|
428
|
+
if not payload.get('articles'):
|
|
429
|
+
return False
|
|
430
|
+
if payload.get('truncated'):
|
|
431
|
+
return False
|
|
432
|
+
stored = payload.get('topicFilter')
|
|
433
|
+
if stored is None:
|
|
434
|
+
return True # 完整的一天,所有主题都在
|
|
435
|
+
if set(stored) >= set(TOPICS):
|
|
436
|
+
# 六个主题在标记里都在 = 这天完整(`write_day` 现在会直接把标记撤掉,但**旧代码写的**
|
|
437
|
+
# 天数带着这个形态留在盘上 —— 读的判据也认它,免得那几天永远"没做完")。
|
|
438
|
+
return True
|
|
439
|
+
if topics is None:
|
|
440
|
+
return False # 只要过一部分,全量的活还没干
|
|
441
|
+
return set(stored) >= set(topics)
|
|
289
442
|
|
|
290
443
|
|
|
291
444
|
def main():
|
|
@@ -300,7 +453,13 @@ def main():
|
|
|
300
453
|
parser.add_argument('--vault-sync', action='store_true',
|
|
301
454
|
help='只做这件事:把已回填的天补拷进 Vault 的 Sources(本地,不抓取、不调模型)')
|
|
302
455
|
parser.add_argument('--workers', type=int, default=8, help='抓正文的并发数(默认 8)')
|
|
303
|
-
parser.add_argument('--limit-per-day', type=int, default=0,
|
|
456
|
+
parser.add_argument('--limit-per-day', type=int, default=0,
|
|
457
|
+
help='每天最多抓几篇(0=不限)。设了就不是完整的一天:'
|
|
458
|
+
'截掉的那些既没抓也看不出来,所以增量判断会重做这天')
|
|
459
|
+
parser.add_argument('--topic', action='append', default=[], metavar='T',
|
|
460
|
+
help='只回填这些主题(可重复或逗号分隔,合法值 %s),不写=全部。'
|
|
461
|
+
'只回填过一部分主题的一天,之后跑全量时不会被跳过。'
|
|
462
|
+
% '/'.join(TOPICS))
|
|
304
463
|
parser.add_argument('--refresh', action='store_true', help='已回填过的天也重做(默认跳过)')
|
|
305
464
|
parser.add_argument('--max-days', type=int, default=0, help='最多处理几天(0=不限,用于试跑)')
|
|
306
465
|
parser.add_argument('--dry-run', action='store_true', help='只报到要抓多少,不抓不写')
|
|
@@ -319,6 +478,17 @@ def main():
|
|
|
319
478
|
print('--until 不能早于 --since'); return 1
|
|
320
479
|
if args.workers < 1:
|
|
321
480
|
print('--workers 要 ≥ 1'); return 1
|
|
481
|
+
picked = parse_topics(args.topic)
|
|
482
|
+
unknown = [t for t in picked if t not in TOPICS]
|
|
483
|
+
if unknown:
|
|
484
|
+
# 拼错一个词(比如 `--topic ai` 小写)会静默筛出 0 篇、然后报一句"没有要处理的"——
|
|
485
|
+
# 看着像"这天本来就没文章"。所以这里当场报错并列出合法值,放在碰库之前。
|
|
486
|
+
message = ('--topic 里有不在分类法里的主题:%s\n合法值:%s'
|
|
487
|
+
% ('、'.join(unknown), '、'.join(TOPICS)))
|
|
488
|
+
print(json.dumps({'success': False, 'error': message}, ensure_ascii=False)
|
|
489
|
+
if args.json else message)
|
|
490
|
+
return 1
|
|
491
|
+
topics = sorted(set(picked)) or None
|
|
322
492
|
|
|
323
493
|
if args.vault_sync:
|
|
324
494
|
# **补拷模式**:不抓取、不调模型,只把已经躺在 biz-daily 的那几天补进 Vault。
|
|
@@ -328,6 +498,8 @@ def main():
|
|
|
328
498
|
# 重复跑是**安全**的:`copy_to_vault` 逐 md 覆盖、不删别的东西,而它现在还会洗残留,
|
|
329
499
|
# 所以"再补一次"同时也是"把库里那一层重新洗一遍"(2026-09-28 就是这么把 1,755 篇
|
|
330
500
|
# 漏网的补上的 —— 那天被 `backfilled` 标记挡在门外的 12 天)。
|
|
501
|
+
if topics:
|
|
502
|
+
print('提示:--vault-sync 只把**已回填的** md 补拷进 Vault,--topic 对这一步不起作用。')
|
|
331
503
|
days = days_with_articles(args.out)
|
|
332
504
|
if args.max_days:
|
|
333
505
|
days = days[:args.max_days]
|
|
@@ -374,28 +546,35 @@ def main():
|
|
|
374
546
|
|
|
375
547
|
todo = []
|
|
376
548
|
for day in days:
|
|
377
|
-
if not args.refresh and day_done(args.out, day):
|
|
549
|
+
if not args.refresh and day_done(args.out, day, topics):
|
|
378
550
|
continue
|
|
379
551
|
articles = collect_articles(cursor, users, name_map, day)
|
|
552
|
+
# 筛选在 `collect_articles` **之后**:主题是它内部 `apply_topics` 的输出,
|
|
553
|
+
# 先筛就无从筛起(理由与等价性写在 `select_topics` 上)。
|
|
554
|
+
articles = select_topics(articles, topics)
|
|
555
|
+
# 截断要在**筛完之后**判断:`--limit-per-day 5 --topic AI` 该解作"每天 5 篇 AI"。
|
|
556
|
+
truncated = bool(args.limit_per_day) and len(articles) > args.limit_per_day
|
|
380
557
|
if args.limit_per_day:
|
|
381
558
|
articles = articles[:args.limit_per_day]
|
|
382
559
|
if articles:
|
|
383
|
-
todo.append((day, articles))
|
|
560
|
+
todo.append((day, articles, truncated))
|
|
384
561
|
|
|
385
562
|
if args.dry_run:
|
|
386
|
-
total = sum(len(items) for _, items in todo)
|
|
387
|
-
need = sum(1 for _, items in todo for a in items
|
|
563
|
+
total = sum(len(items) for _, items, _ in todo)
|
|
564
|
+
need = sum(1 for _, items, _ in todo for a in items
|
|
388
565
|
if len(body_without_media(a.get('local_text'))) < MIN_BODY_CHARS and a.get('url'))
|
|
389
566
|
payload = {
|
|
390
567
|
'success': True, 'dryRun': True, 'action': 'backfill-articles',
|
|
568
|
+
'topics': topics or '全部',
|
|
391
569
|
'days': len(todo), 'articles': total, 'needFetch': need,
|
|
392
570
|
'workers': args.workers, 'invokesAI': False, 'downloadsImages': False,
|
|
393
571
|
'readsLocalData': True,
|
|
394
572
|
'note': '只抓正文,不下图片、不调用模型;抓完还要跑 vault notes 与 article-notes',
|
|
395
573
|
}
|
|
396
574
|
print(json.dumps(payload, ensure_ascii=False, indent=2) if args.json
|
|
397
|
-
else '%d 天、%d 篇(其中 %d 篇要联网抓正文),并发 %d'
|
|
398
|
-
% (len(todo), total, need, args.workers
|
|
575
|
+
else '%d 天、%d 篇(其中 %d 篇要联网抓正文),并发 %d,主题 %s'
|
|
576
|
+
% (len(todo), total, need, args.workers,
|
|
577
|
+
'全部' if not topics else '、'.join(topics)))
|
|
399
578
|
conn.close()
|
|
400
579
|
return 0
|
|
401
580
|
|
|
@@ -404,23 +583,25 @@ def main():
|
|
|
404
583
|
conn.close()
|
|
405
584
|
return 0
|
|
406
585
|
if not args.yes:
|
|
407
|
-
print('会抓 %d 天、%d
|
|
408
|
-
% (len(todo), sum(len(i) for _, i in todo)
|
|
586
|
+
print('会抓 %d 天、%d 篇的正文(不调模型,主题 %s)。加 --yes 确认。'
|
|
587
|
+
% (len(todo), sum(len(i) for _, i, _ in todo),
|
|
588
|
+
'全部' if not topics else '、'.join(topics)))
|
|
409
589
|
conn.close()
|
|
410
590
|
return 1
|
|
411
591
|
|
|
412
592
|
report = []
|
|
413
|
-
for index, (day, articles) in enumerate(todo, 1):
|
|
414
|
-
print('\n[%d/%d] %s —
|
|
593
|
+
for index, (day, articles, truncated) in enumerate(todo, 1):
|
|
594
|
+
print('\n[%d/%d] %s — 选中 %d 篇' % (index, len(todo), day, len(articles)))
|
|
415
595
|
fetch = fetch_bodies(articles, args.workers, log=print)
|
|
416
|
-
result = write_day(articles, day, args.out)
|
|
596
|
+
result = write_day(articles, day, args.out, topic_filter=topics, truncated=truncated)
|
|
417
597
|
print(' 写入 %d 篇,跳过 %d 篇(正文太短),主题兜底 %d 篇'
|
|
418
598
|
% (result['written'], result['skipped'], result['fallbackTopics']))
|
|
419
599
|
if args.vault_copy:
|
|
420
600
|
sync = copy_to_vault(day, args.out, args.vault_root)
|
|
421
601
|
print(' 入 Vault 的 Sources: %d 个 md' % sync['copied'])
|
|
422
602
|
report.append({'date': day, 'inDb': len(articles), 'fetched': fetch['ok'],
|
|
423
|
-
'written': result['written'], 'skippedThin': result['skipped']
|
|
603
|
+
'written': result['written'], 'skippedThin': result['skipped'],
|
|
604
|
+
'truncated': truncated})
|
|
424
605
|
conn.close()
|
|
425
606
|
|
|
426
607
|
total_written = sum(r['written'] for r in report)
|