harveyz-skill 0.1.2 → 0.2.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 (31) hide show
  1. package/bin/cli.js +116 -44
  2. package/lib/bundles.js +97 -22
  3. package/lib/installer.js +126 -5
  4. package/lib/targets.js +1 -0
  5. package/lib/vars.js +34 -0
  6. package/package.json +6 -3
  7. package/skills/harness/diataxis-docs/SKILL.md +128 -0
  8. package/skills/harness/diataxis-docs/references/custom-categories.md +32 -0
  9. package/skills/harness/diataxis-docs/references/index-template.md +29 -0
  10. package/skills/web-fetch/article-fetcher/SKILL.md +625 -0
  11. package/skills/web-fetch/article-fetcher/references/article_utils.py +225 -0
  12. package/skills/web-fetch/article-fetcher/references/file-format.md +73 -0
  13. package/skills/web-fetch/article-fetcher/scripts/import_reading.py +75 -0
  14. package/skills/web-fetch/article-fetcher/scripts/init_db.py +33 -0
  15. package/skills/web-fetch/article-fetcher/scripts/url-index.db +0 -0
  16. package/skills/web-fetch/article-fetcher/scripts/url-index.db.bak +0 -0
  17. package/skills/web-fetch/article-fetcher/vars.json +12 -0
  18. package/skills/writing/mermaid-diagram/SKILL.md +220 -0
  19. package/skills/writing/mermaid-diagram/references/color-templates.md +75 -0
  20. package/skills/writing/mermaid-diagram/references/flowchart.md +88 -0
  21. package/skills/writing/mermaid-diagram/references/gantt-timeline.md +62 -0
  22. package/skills/writing/mermaid-diagram/references/other-charts.md +55 -0
  23. package/skills/writing/mermaid-diagram/references/sequence.md +143 -0
  24. package/skills/writing/mermaid-diagram/references/statediagram.md +54 -0
  25. package/skills/writing/mermaid-diagram/scripts/check_mermaid.py +84 -0
  26. package/tools/p-launch/p-launch.sh +275 -0
  27. package/tools/p-launch/tests/e2e.bats +164 -0
  28. package/tools/p-launch/tests/unit.bats +151 -0
  29. package/tools/p-launch/vars.json +9 -0
  30. package/tools/p-launch/zshrc.snippet +5 -0
  31. package/bundles.json +0 -24
@@ -0,0 +1,625 @@
1
+ ---
2
+ name: article-fetcher
3
+ description: "Use for the fetch-URL → translate → save-to-Obsidian workflow. Trigger whenever a user has a web URL (article, blog post, tweet, newsletter) and wants it fetched, translated into Chinese, and saved locally — even if they say it obliquely (e.g. \"存到 obsidian\", \"抓取\", \"save this\", \"translate and archive\"). Handles single URLs and batch lists, and X.com / Twitter (Playwright + Chrome Profile). Skip only if: no URL is present (user pasting raw text to translate), user wants a summary without archiving, or user is asking about a site's tech stack without wanting to save anything."
4
+ ---
5
+
6
+ # Article Fetcher
7
+
8
+ ## 核心设计:两步分离
9
+
10
+ **第一步(Subagent 1)**:抓取文章 + 下载图片 → 保存原文到 Origin
11
+ **第二步(Subagent 2)**:读取 Origin → 翻译 → 保存译文到 Article
12
+
13
+ 两步由主 session 串联:Subagent 1 完成后,再派发 Subagent 2。
14
+
15
+ > 分离原因:翻译是 LLM 密集型任务,容易超时;抓取是 I/O 密集型任务,速度稳定。分开后各自超时独立,互不影响。
16
+
17
+ ---
18
+
19
+ ## 路径变量
20
+
21
+ ```
22
+ Base: {{VAULT_PATH}}
23
+ Origin: {{VAULT_PATH}}/Origin
24
+ Article: {{VAULT_PATH}}
25
+ Image: {{VAULT_PATH}}/Image
26
+ SkillDir: {{SKILL_DIR}}
27
+ ```
28
+
29
+ ---
30
+
31
+ ## URL 去重索引(SQLite)
32
+
33
+ **数据库路径:** `{{SKILL_DIR}}/scripts/url-index.db`
34
+
35
+ **表结构:**
36
+
37
+ ```sql
38
+ CREATE TABLE url_index (
39
+ url TEXT PRIMARY KEY,
40
+ title TEXT,
41
+ fetched_at TEXT,
42
+ issues TEXT,
43
+ category TEXT,
44
+ origin_path TEXT,
45
+ article_path TEXT
46
+ );
47
+ ```
48
+
49
+ ---
50
+
51
+ ## 单篇抓取流程(主 session 执行)
52
+
53
+ 当收到 1 个 URL 抓取任务时,主 session 执行以下流程:
54
+
55
+ ### 步骤 1:派发 Subagent 1(抓取 + 保存原文)
56
+
57
+ ```bash
58
+ sessions_spawn \
59
+ --task "【Subagent 1 - 抓取】抓取文章并保存原文。
60
+
61
+ URL: <URL>
62
+
63
+ 执行步骤:
64
+ 1. 查 SQLite 去重({{SKILL_DIR}}/scripts/url-index.db),如果 URL 已存在则报告「已抓取,跳过」。
65
+ 2. 判断 URL 类型:
66
+ - X.com / Twitter:用 Playwright(Chrome Profile 2)抓取 + 下载图片到 Image/ 目录
67
+ - 其他网站:用 web_fetch + Playwright 抓取
68
+ 3. 将原文保存到 Origin/ 目录(含完整 frontmatter:publish_date、fetch_date、author、source_url、origin_title)
69
+ 4. 校验 frontmatter(repair_frontmatter)
70
+ 5. 完成后报告:文章标题、block 数、图片数、origin_path
71
+
72
+ 【Playwright 脚本】:(见下方完整代码块)
73
+
74
+ 【校验代码】:(见下方校验代码块)
75
+
76
+ 完成后报告格式(用换行分隔,避免标题含 `|` 时解析出错):
77
+ ```
78
+ ORIGIN_PATH: {origin_path}
79
+ 抓取完成:{标题} ({block数} blocks, {图片数} images)
80
+ ```
81
+ " \
82
+ --runtime "subagent" \
83
+ --mode "run"
84
+ ```
85
+
86
+ ### 步骤 2:等待 Subagent 1 完成
87
+
88
+ 收到 Subagent 1 的完成通知后,从报告中提取 `ORIGIN_PATH:` 开头的那行,取其值作为 origin_path。检查文件是否存在。
89
+
90
+ ### 步骤 3:派发 Subagent 2(翻译)
91
+
92
+ ```bash
93
+ sessions_spawn \
94
+ --task "【Subagent 2 - 翻译】读取原文并翻译为简体中文。
95
+
96
+ URL: <URL>
97
+ origin_path: <上一步获取的 origin_path>
98
+ category: <category 可选,来源列表页抓取的分类标签>
99
+ fetch_type: <fetch_type 可选,默认 manual;传入时用传入值(cron/ manual),未传入时默认 manual 并写入 frontmatter>
100
+
101
+ 执行步骤:
102
+ 1. 读取 origin_path 文件
103
+ 2. 调用 LLM 将正文翻译为简体中文(图片标记和代码块原样保留)
104
+ 3. 保存译文到 Article/ 目录
105
+ - 文件名与 Origin 文件名相同(不含路径前缀)
106
+ - frontmatter 包含:publish_date、fetch_date、author、source_url、origin_title、category(如有)、fetch_type、tags、description
107
+ - 正文上方插入双向链接 [[Origin/<文件名>]]
108
+ 4. 写 SQLite 索引(url_index 表),含 category
109
+ 5. 校验(repair_frontmatter + record_issues)
110
+ 6. 完成后报告:文章标题、article_path
111
+
112
+ 【翻译要求】:
113
+ - 保留原义和逻辑,专有名词保留英文
114
+ - 图片标记 ![](Image/...) 原样保留
115
+ - 代码块内容原样保留
116
+ - tags 建议:AI, Agent, Engineering 等(根据内容调整)
117
+ - category:若传入则写入 frontmatter
118
+ - fetch_type:若传入则写入 frontmatter;若未传入则默认 "manual" 并写入 frontmatter
119
+ - description:一句话摘要
120
+
121
+ 【校验代码】:
122
+ ```python
123
+ import sys, os, sqlite3, re
124
+ sys.path.insert(0, '{{SKILL_DIR}}/references')
125
+ from article_utils import repair_frontmatter, record_issues
126
+ from datetime import datetime, timezone, timedelta
127
+
128
+ origin_path = '<origin_path>'
129
+ article_path = '{{VAULT_PATH}}/' + os.path.basename(origin_path)
130
+ url = '<URL>'
131
+
132
+ fetch_date = datetime.now(timezone(timedelta(hours=8))).strftime('%Y-%m-%d')
133
+ fm_art, fixed_art, rem_art = repair_frontmatter(article_path, url, {'fetch_date': fetch_date})
134
+
135
+ if rem_art:
136
+ record_issues(url, "; ".join(rem_art))
137
+ raise Exception(f"校验未通过:{rem_art}")
138
+ else:
139
+ record_issues(url, "")
140
+
141
+ # 写 SQLite(用 yaml.safe_load 解析 frontmatter,避免含冒号的值被截断)
142
+ import yaml
143
+ db_path = os.path.expanduser("{{SKILL_DIR}}/scripts/url-index.db")
144
+ conn = sqlite3.connect(db_path)
145
+ with open(article_path, encoding='utf-8') as f:
146
+ content = f.read()
147
+ fm = {}
148
+ if content.startswith('---'):
149
+ parts = content.split('---', 2)
150
+ if len(parts) >= 3:
151
+ try:
152
+ fm = yaml.safe_load(parts[1]) or {}
153
+ except Exception:
154
+ pass
155
+ conn.execute("""INSERT OR REPLACE INTO url_index (url, title, fetched_at, issues, category, origin_path, article_path) VALUES (?,?,?,?,?,?,?)""",
156
+ (url, os.path.basename(article_path), fetch_date, '', fm.get('category',''), origin_path, article_path))
157
+ conn.commit()
158
+ print(f"翻译完成:{article_path}")
159
+ ```
160
+
161
+ 完成后报告格式:
162
+ 翻译完成:{标题} | {article_path}
163
+ " \
164
+ --runtime "subagent" \
165
+ --runTimeoutSeconds 1200 \
166
+ --mode "run"
167
+ ```
168
+
169
+ ### 步骤 4:向 Harvey 报告最终结果
170
+
171
+ ---
172
+
173
+ ## 批量抓取流程(2 篇或以上)
174
+
175
+ ### 核心原则
176
+
177
+ 1. **随机间隔**:每次只启动 1 个抓取 Subagent 1,等待完成后随机等 60~180 秒再派发下一个
178
+ 2. **同时活跃不超过 5 个**(抓取 + 翻译各算一个)
179
+ 3. **任务清单先确认**
180
+
181
+ ### 执行流程
182
+
183
+ **步骤 1**:批量查 SQLite,整理任务清单(已抓取的标记跳过)
184
+
185
+ **步骤 2**:逐一派发 Subagent 1(抓取),每完成一个立即派发对应的 Subagent 2(翻译)
186
+
187
+ ```
188
+ Subagent 1 (抓取) → Subagent 2 (翻译) → [等待] → Subagent 1 (抓取) → Subagent 2 (翻译) → ...
189
+ ```
190
+
191
+ 每篇 Subagent 2 完成后,**在主 session 中用以下代码随机等待**再发下一篇:
192
+
193
+ ```python
194
+ import time, random
195
+ wait = random.randint(60, 180)
196
+ print(f"等待 {wait} 秒后继续下一篇...")
197
+ time.sleep(wait)
198
+ ```
199
+
200
+ ---
201
+
202
+ ## Playwright 抓取代码(X.com)
203
+
204
+ ```python
205
+ from playwright.sync_api import sync_playwright
206
+ import os, json, urllib.request, hashlib
207
+ from datetime import datetime, timezone, timedelta
208
+
209
+ chrome_profile = os.path.expanduser('~/Library/Application Support/Google/Chrome/Profile 2')
210
+ url = "<URL>"
211
+ url_hash = hashlib.md5(url.encode()).hexdigest()[:8]
212
+
213
+ import sys
214
+ sys.path.insert(0, '{{SKILL_DIR}}/references')
215
+ from article_utils import infer_ext
216
+
217
+ save_dir = "{{VAULT_PATH}}/Image"
218
+
219
+ with sync_playwright() as p:
220
+ ctx = p.chromium.launch_persistent_context(user_data_dir=chrome_profile, headless=False)
221
+ page = ctx.pages[0] if ctx.pages else ctx.new_page()
222
+ page.goto(url, timeout=30000)
223
+ page.wait_for_timeout(8000)
224
+
225
+ result = page.evaluate(r"""() => {
226
+ const article = document.querySelector('article[data-testid="tweet"]');
227
+ if (!article) return {error: 'No article found'};
228
+
229
+ const titleEl = article.querySelector('[data-testid="twitter-article-title"]');
230
+ const title = titleEl ? titleEl.innerText.replace(/\s+/g, ' ').trim() : 'Untitled';
231
+
232
+ const timeEl = article.querySelector('article time');
233
+ const publishDate = timeEl ? timeEl.getAttribute('datetime') : '';
234
+
235
+ const authorEl = article.querySelector('[data-testid="User-Name"]');
236
+ let author = '';
237
+ if (authorEl) {
238
+ const authorText = authorEl.innerText.replace(/\s+/g, ' ').trim();
239
+ author = authorText.split('@')[0].trim();
240
+ }
241
+
242
+ const skipTags = new Set(['SCRIPT','STYLE','NAV','FOOTER','HEADER','ASIDE']);
243
+ const contentUnits = [];
244
+ let lastText = '';
245
+
246
+ const walker = document.createTreeWalker(article, NodeFilter.SHOW_ELEMENT);
247
+ let node;
248
+ while (node = walker.nextNode()) {
249
+ if (skipTags.has(node.tagName.toUpperCase())) continue;
250
+ const tag = node.tagName.toUpperCase();
251
+ const tid = node.getAttribute('data-testid') || '';
252
+
253
+ if (tag === 'DIV' && tid === 'tweetPhoto') {
254
+ const img = node.querySelector('img');
255
+ if (img && img.src && !img.src.includes('data:') && !img.src.includes('/profile_images/')) {
256
+ contentUnits.push({type: 'image', src: img.src, alt: img.alt || ''});
257
+ }
258
+ } else if (tag === 'SPAN' && tid === '') {
259
+ let directText = '';
260
+ for (const cn of node.childNodes) {
261
+ if (cn.nodeType === Node.TEXT_NODE) {
262
+ directText += (cn.textContent || '').replace(/\s+/g, ' ').trim() + ' ';
263
+ }
264
+ }
265
+ directText = directText.trim();
266
+ let hasLiAncestor = false;
267
+ let ancestor = node.parentElement;
268
+ while (ancestor && ancestor.tagName) {
269
+ if (['LI','OL','UL'].includes(ancestor.tagName.toUpperCase())) {
270
+ hasLiAncestor = true;
271
+ break;
272
+ }
273
+ ancestor = ancestor.parentElement;
274
+ }
275
+ if (hasLiAncestor) continue;
276
+ const isNoise = (
277
+ directText.length < 30 ||
278
+ /^[@#]?[\d.]+[KMB]?$/i.test(directText) ||
279
+ directText.startsWith('@')
280
+ );
281
+ const isSubset = lastText.length > 10 && (lastText.includes(directText) || directText.includes(lastText));
282
+ if (!isNoise && !isSubset && directText.length >= 30) {
283
+ contentUnits.push({type: 'text', tag: 'span', content: directText});
284
+ lastText = directText;
285
+ }
286
+ } else if (['H2','H3','P','LI','BLOCKQUOTE','PRE'].includes(tag)) {
287
+ const t = node.innerText.replace(/\s+/g, ' ').trim();
288
+ if (t && t.length > 5) {
289
+ contentUnits.push({type: 'text', tag: tag.toLowerCase(), content: t});
290
+ lastText = t;
291
+ }
292
+ }
293
+ }
294
+
295
+ const blocks = [];
296
+ const imageBlocks = [];
297
+ for (let i = 0; i < contentUnits.length; i++) {
298
+ const unit = contentUnits[i];
299
+ if (unit.type === 'text') {
300
+ blocks.push({
301
+ type: ['H2','H3'].includes(unit.tag.toUpperCase()) ? 'heading' : 'block',
302
+ tag: unit.tag,
303
+ content: unit.content,
304
+ blockIndex: blocks.length
305
+ });
306
+ } else if (unit.type === 'image') {
307
+ imageBlocks.push({
308
+ src: unit.src,
309
+ alt: unit.alt,
310
+ afterBlock: blocks.length - 1
311
+ });
312
+ }
313
+ }
314
+
315
+ return {title, author, publishDate, blocks, imageBlocks, totalTextBlocks: blocks.length, totalImages: imageBlocks.length};
316
+ }""")
317
+
318
+ result['publishDate'] = result.get('publishDate', '')
319
+
320
+ with open('/tmp/x_article_combined.json', 'w', encoding='utf-8') as f:
321
+ json.dump(result, f, ensure_ascii=False, indent=2)
322
+
323
+ downloaded = []
324
+ for i, img in enumerate(result.get('imageBlocks', [])):
325
+ ext = infer_ext(img['src'])
326
+ fname = f"{url_hash}_img_{i+1}{ext}"
327
+ fpath = os.path.join(save_dir, fname)
328
+ try:
329
+ req = urllib.request.Request(img['src'], headers={'User-Agent': 'Mozilla/5.0', 'Accept': 'image/*'})
330
+ with urllib.request.urlopen(req, timeout=15) as resp:
331
+ data = resp.read()
332
+ with open(fpath, 'wb') as f:
333
+ f.write(data)
334
+ print(f" [{i+1}] Downloaded {fname} ({len(data)} bytes)")
335
+ downloaded.append({**img, 'filename': fname})
336
+ except Exception as e:
337
+ print(f" [{i+1}] Failed {fname}: {e}")
338
+ downloaded.append({**img, 'filename': fname})
339
+
340
+ with open('/tmp/x_article_combined.json', 'r', encoding='utf-8') as f:
341
+ data = json.load(f)
342
+ data['images'] = downloaded
343
+ with open('/tmp/x_article_combined.json', 'w', encoding='utf-8') as f:
344
+ json.dump(data, f, ensure_ascii=False, indent=2)
345
+
346
+ ctx.close()
347
+
348
+ print(f"Title: {result['title']}")
349
+ print(f"Text blocks: {result['totalTextBlocks']}, Images: {result['totalImages']}")
350
+ ```
351
+
352
+ ### 从 JSON 构建原文文件
353
+
354
+ ```python
355
+ import json, re, os
356
+ from datetime import datetime, timezone, timedelta
357
+
358
+ HEADING_PREFIX = {'h1': '# ', 'h2': '## ', 'h3': '### ', 'h4': '#### '}
359
+
360
+ def format_block(block):
361
+ tag = block.get('tag', 'p')
362
+ content = block.get('content', '')
363
+ if tag in HEADING_PREFIX:
364
+ return HEADING_PREFIX[tag] + content
365
+ if tag == 'pre':
366
+ return f'```\n{content}\n```'
367
+ if tag == 'li':
368
+ return f'- {content}'
369
+ return content
370
+
371
+ with open('/tmp/x_article_combined.json', encoding='utf-8') as f:
372
+ data = json.load(f)
373
+
374
+ blocks = data['blocks']
375
+ images = data.get('images') or []
376
+ title = data.get('title', 'Untitled')
377
+ author = data.get('author', '')
378
+ publish_date = data.get('publishDate', '')[:10]
379
+ source_url = "<URL>"
380
+
381
+ # 构建文件名
382
+ origin_filename = re.sub(r'[\\/:]', '', title) + '.md'
383
+ if origin_filename.startswith('.'):
384
+ origin_filename = origin_filename[1:]
385
+ origin_path = f'{{VAULT_PATH}}/Origin/{origin_filename}'
386
+
387
+ body_units = []
388
+ for i, block in enumerate(blocks):
389
+ unit_parts = [format_block(block)]
390
+ for img in images:
391
+ if img.get('afterBlock') == i:
392
+ unit_parts.append(f'![](Image/{img["filename"]})')
393
+ body_units.append('\n'.join(unit_parts))
394
+
395
+ body = '\n\n'.join(body_units)
396
+ fetch_date = datetime.now(timezone(timedelta(hours=8))).strftime('%Y-%m-%d')
397
+
398
+ origin_content = f"""---
399
+ publish_date: {publish_date}
400
+ fetch_date: {fetch_date}
401
+ author: {author}
402
+ source_url: {source_url}
403
+ origin_title: "{title}"
404
+ ---
405
+
406
+ # {title}
407
+
408
+ {body}
409
+ """
410
+
411
+ with open(origin_path, 'w', encoding='utf-8') as f:
412
+ f.write(origin_content)
413
+
414
+ print(f"Origin saved: {origin_path}")
415
+ print(f"Blocks: {len(blocks)}, Images: {len(images)}")
416
+ ```
417
+
418
+ ---
419
+
420
+ ## 其他网站抓取流程
421
+
422
+ ### Step 1:web_fetch 提取原始 HTML
423
+
424
+ ```python
425
+ # Subagent 1 内执行
426
+ import subprocess, os
427
+ result = subprocess.run(
428
+ ['python3', '-c',
429
+ f"import urllib.request; req=urllib.request.Request('{url}', headers={{'User-Agent':'Mozilla/5.0'}}); resp=urllib.request.urlopen(req,timeout=30); open('/tmp/fetched_page.html','wb').write(resp.read())"],
430
+ capture_output=True, text=True
431
+ )
432
+ # 若 web_fetch 工具可用,直接用工具调用更佳
433
+ ```
434
+
435
+ 也可直接调用 `web_fetch` 工具获取 HTML,保存到 `/tmp/fetched_page.html`。
436
+
437
+ ### Step 2:Playwright DOM 提取 + 图片下载
438
+
439
+ ```python
440
+ from playwright.sync_api import sync_playwright
441
+ import os, json, urllib.request, hashlib, re
442
+ from datetime import datetime, timezone, timedelta
443
+
444
+ import sys
445
+ sys.path.insert(0, '{{SKILL_DIR}}/references')
446
+ from article_utils import infer_ext
447
+
448
+ url = "<URL>"
449
+ url_hash = hashlib.md5(url.encode()).hexdigest()[:8]
450
+ save_dir = "{{VAULT_PATH}}/Image"
451
+ html_path = "/tmp/fetched_page.html"
452
+
453
+ with open(html_path, encoding='utf-8', errors='replace') as f:
454
+ html = f.read()
455
+
456
+ with sync_playwright() as p:
457
+ browser = p.chromium.launch(headless=True)
458
+ page = browser.new_page()
459
+ page.set_content(html, wait_until="domcontentloaded")
460
+
461
+ result = page.evaluate(r"""() => {
462
+ const skipTags = new Set(['SCRIPT','STYLE','NAV','FOOTER','HEADER','ASIDE','BUTTON','FORM']);
463
+ const contentUnits = [];
464
+ const imageBlocks = [];
465
+
466
+ // Extract title
467
+ const titleEl = document.querySelector('h1') || document.querySelector('title');
468
+ const title = titleEl ? titleEl.innerText.replace(/\s+/g, ' ').trim() : 'Untitled';
469
+
470
+ // Extract publish date from common meta tags
471
+ const dateMeta = document.querySelector('meta[property="article:published_time"]')
472
+ || document.querySelector('meta[name="date"]')
473
+ || document.querySelector('time');
474
+ const publishDate = dateMeta
475
+ ? (dateMeta.getAttribute('content') || dateMeta.getAttribute('datetime') || '')
476
+ : '';
477
+
478
+ // Extract author
479
+ const authorMeta = document.querySelector('meta[name="author"]')
480
+ || document.querySelector('[rel="author"]');
481
+ const author = authorMeta
482
+ ? (authorMeta.getAttribute('content') || authorMeta.innerText || '').trim()
483
+ : '';
484
+
485
+ // Walk main content area
486
+ const main = document.querySelector('main') || document.querySelector('article') || document.body;
487
+ const walker = document.createTreeWalker(main, NodeFilter.SHOW_ELEMENT);
488
+ let node;
489
+ while (node = walker.nextNode()) {
490
+ if (skipTags.has(node.tagName.toUpperCase())) continue;
491
+ const tag = node.tagName.toUpperCase();
492
+
493
+ if (tag === 'IMG') {
494
+ const src = node.src || node.getAttribute('data-src') || '';
495
+ if (src && !src.startsWith('data:') && src.startsWith('http')) {
496
+ imageBlocks.push({ src, alt: node.alt || '', afterBlock: contentUnits.length - 1 });
497
+ }
498
+ } else if (['H1','H2','H3','P','LI','BLOCKQUOTE','PRE','CODE'].includes(tag)) {
499
+ const t = node.innerText.replace(/\s+/g, ' ').trim();
500
+ if (t && t.length > 10) {
501
+ contentUnits.push({ tag: tag.toLowerCase(), content: t });
502
+ }
503
+ }
504
+ }
505
+
506
+ return { title, author, publishDate, blocks: contentUnits, imageBlocks };
507
+ }""")
508
+ browser.close()
509
+
510
+ # Download images
511
+ downloaded = []
512
+ for i, img in enumerate(result.get('imageBlocks', [])):
513
+ ext = infer_ext(img['src'])
514
+ fname = f"{url_hash}_img_{i+1}{ext}"
515
+ fpath = os.path.join(save_dir, fname)
516
+ try:
517
+ req = urllib.request.Request(img['src'], headers={'User-Agent': 'Mozilla/5.0'})
518
+ with urllib.request.urlopen(req, timeout=15) as resp:
519
+ data = resp.read()
520
+ with open(fpath, 'wb') as f:
521
+ f.write(data)
522
+ downloaded.append({**img, 'filename': fname})
523
+ except Exception as e:
524
+ print(f" Image {i+1} failed: {e}")
525
+ downloaded.append({**img, 'filename': fname})
526
+
527
+ # Build origin file
528
+ HEADING_PREFIX = {'h1': '# ', 'h2': '## ', 'h3': '### '}
529
+
530
+ def fmt(block):
531
+ tag, content = block['tag'], block['content']
532
+ if tag in HEADING_PREFIX:
533
+ return HEADING_PREFIX[tag] + content
534
+ if tag == 'pre':
535
+ return f'```\n{content}\n```'
536
+ if tag == 'li':
537
+ return f'- {content}'
538
+ return content
539
+
540
+ blocks = result['blocks']
541
+ title = result.get('title', 'Untitled')
542
+ author = result.get('author', '')
543
+ publish_date = (result.get('publishDate') or '')[:10]
544
+ fetch_date = datetime.now(timezone(timedelta(hours=8))).strftime('%Y-%m-%d')
545
+
546
+ # 文件名:移除特殊字符
547
+ origin_filename = re.sub(r'[\\/:*?<>|".]', '', title) + '.md'
548
+ origin_path = f'{{VAULT_PATH}}/Origin/{origin_filename}'
549
+
550
+ body_units = []
551
+ for i, block in enumerate(blocks):
552
+ parts = [fmt(block)]
553
+ for img in downloaded:
554
+ if img.get('afterBlock') == i:
555
+ parts.append(f'![](Image/{img["filename"]})')
556
+ body_units.append('\n'.join(parts))
557
+
558
+ body = '\n\n'.join(body_units)
559
+
560
+ origin_content = f"""---
561
+ publish_date: {publish_date}
562
+ fetch_date: {fetch_date}
563
+ author: {author}
564
+ source_url: {url}
565
+ origin_title: "{title}"
566
+ ---
567
+
568
+ # {title}
569
+
570
+ {body}
571
+ """
572
+
573
+ with open(origin_path, 'w', encoding='utf-8') as f:
574
+ f.write(origin_content)
575
+
576
+ print(f"Origin saved: {origin_path}")
577
+ print(f"Blocks: {len(blocks)}, Images: {len(downloaded)}")
578
+ ```
579
+
580
+ ---
581
+
582
+ ## 保存后校验代码(Subagent 1 用)
583
+
584
+ ```python
585
+ import sys, os, sqlite3, re
586
+ sys.path.insert(0, '{{SKILL_DIR}}/references')
587
+ from article_utils import repair_frontmatter, record_issues
588
+ from datetime import datetime, timezone, timedelta
589
+
590
+ origin_path = '<origin_path>'
591
+ url = '<URL>'
592
+
593
+ if not os.path.exists(origin_path):
594
+ raise Exception(f"原文文件未生成:{origin_path}")
595
+
596
+ fetch_date = datetime.now(timezone(timedelta(hours=8))).strftime('%Y-%m-%d')
597
+ fm_orig, fixed_orig, rem_orig = repair_frontmatter(origin_path, url, {'fetch_date': fetch_date})
598
+
599
+ if rem_orig:
600
+ record_issues(url, "; ".join(rem_orig))
601
+ raise Exception(f"校验未通过:{rem_orig}")
602
+ else:
603
+ record_issues(url, "")
604
+
605
+ print(f"校验通过:{origin_path}")
606
+ ```
607
+
608
+ ---
609
+
610
+ ## 文件命名规则
611
+
612
+ ```python
613
+ import re
614
+ origin_filename = re.sub(r'[\\/:*?<>|".]', '', title) + '.md'
615
+ ```
616
+
617
+ 移除字符:`\ / : * ? < > | " .`(与 file-format.md 保持一致)。
618
+
619
+ ---
620
+
621
+ ## 附录
622
+
623
+ ### 文件格式模板
624
+
625
+ 详见 [references/file-format.md](references/file-format.md)