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.
- package/bin/cli.js +116 -44
- package/lib/bundles.js +97 -22
- package/lib/installer.js +126 -5
- package/lib/targets.js +1 -0
- package/lib/vars.js +34 -0
- package/package.json +6 -3
- package/skills/harness/diataxis-docs/SKILL.md +128 -0
- package/skills/harness/diataxis-docs/references/custom-categories.md +32 -0
- package/skills/harness/diataxis-docs/references/index-template.md +29 -0
- package/skills/web-fetch/article-fetcher/SKILL.md +625 -0
- package/skills/web-fetch/article-fetcher/references/article_utils.py +225 -0
- package/skills/web-fetch/article-fetcher/references/file-format.md +73 -0
- package/skills/web-fetch/article-fetcher/scripts/import_reading.py +75 -0
- package/skills/web-fetch/article-fetcher/scripts/init_db.py +33 -0
- package/skills/web-fetch/article-fetcher/scripts/url-index.db +0 -0
- package/skills/web-fetch/article-fetcher/scripts/url-index.db.bak +0 -0
- package/skills/web-fetch/article-fetcher/vars.json +12 -0
- package/skills/writing/mermaid-diagram/SKILL.md +220 -0
- package/skills/writing/mermaid-diagram/references/color-templates.md +75 -0
- package/skills/writing/mermaid-diagram/references/flowchart.md +88 -0
- package/skills/writing/mermaid-diagram/references/gantt-timeline.md +62 -0
- package/skills/writing/mermaid-diagram/references/other-charts.md +55 -0
- package/skills/writing/mermaid-diagram/references/sequence.md +143 -0
- package/skills/writing/mermaid-diagram/references/statediagram.md +54 -0
- package/skills/writing/mermaid-diagram/scripts/check_mermaid.py +84 -0
- package/tools/p-launch/p-launch.sh +275 -0
- package/tools/p-launch/tests/e2e.bats +164 -0
- package/tools/p-launch/tests/unit.bats +151 -0
- package/tools/p-launch/vars.json +9 -0
- package/tools/p-launch/zshrc.snippet +5 -0
- 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
|
+
- 图片标记  原样保留
|
|
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'')
|
|
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'')
|
|
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)
|