harveyz-skill 0.6.2 → 0.7.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 +89 -0
- package/README.md +85 -39
- package/bin/cli.js +444 -51
- package/hooks/check-similar-branch/check-similar-branch.sh +75 -0
- package/lib/bundles.js +74 -7
- package/lib/installer.js +256 -30
- package/lib/targets.js +22 -13
- package/package.json +8 -6
- package/skills/analysis/git-cleanup/SKILL.md +189 -0
- package/skills/design/sync-design-html/SKILL.md +354 -0
- package/skills/design/sync-design-html/references/stack-electron.md +119 -0
- package/skills/design/sync-design-html/references/stack-nextjs.md +101 -0
- package/skills/design/sync-design-html/references/stack-react.md +117 -0
- package/skills/design/sync-design-html/references/stack-swiftui.md +115 -0
- package/skills/design/sync-design-html/references/stack-vue.md +142 -0
- package/skills/design/sync-design-html/references/stacks.md +45 -0
- package/skills/web-fetch/article-fetcher/SKILL.md +95 -509
- package/skills/web-fetch/article-fetcher/references/__pycache__/article_utils.cpython-314.pyc +0 -0
- package/skills/web-fetch/article-fetcher/references/article_utils.py +32 -5
- package/skills/web-fetch/article-fetcher/scripts/playwright_web.py +172 -0
- package/skills/web-fetch/article-fetcher/scripts/playwright_xcom.py +221 -0
- package/skills/web-fetch/article-fetcher/scripts/validate_article.py +36 -0
- package/skills/web-fetch/article-fetcher/tests/test_security.py +257 -0
- package/skills-index.json +18 -4
|
Binary file
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""
|
|
2
2
|
共享工具函数:格式化 block、构建文章、修复 frontmatter
|
|
3
3
|
"""
|
|
4
|
-
import re, os, json, sqlite3
|
|
4
|
+
import re, os, json, sqlite3, yaml
|
|
5
5
|
from datetime import datetime, timezone, timedelta
|
|
6
6
|
|
|
7
7
|
# ------------------------------------------------------------
|
|
@@ -186,16 +186,43 @@ def record_issues(url, issues_text, db_path=None):
|
|
|
186
186
|
"""将 issues 写入 SQLite"""
|
|
187
187
|
if db_path is None:
|
|
188
188
|
db_path = os.path.expanduser(
|
|
189
|
-
'{{
|
|
189
|
+
'{{VAULT_PATH}}/url-index.db'
|
|
190
190
|
)
|
|
191
191
|
conn = sqlite3.connect(db_path)
|
|
192
|
-
conn.execute('UPDATE url_index SET issues=? WHERE
|
|
192
|
+
conn.execute('UPDATE url_index SET issues=? WHERE url=?',
|
|
193
193
|
(issues_text, url))
|
|
194
194
|
conn.commit()
|
|
195
195
|
|
|
196
196
|
|
|
197
197
|
# ------------------------------------------------------------
|
|
198
|
-
# 6.
|
|
198
|
+
# 6. write_url_index: 写入 SQLite url_index 表
|
|
199
|
+
# ------------------------------------------------------------
|
|
200
|
+
def write_url_index(url, origin_path, article_path, db_path, category=''):
|
|
201
|
+
"""Insert or replace a URL index entry after successful fetch+translate."""
|
|
202
|
+
fetch_date = datetime.now(timezone(timedelta(hours=8))).strftime('%Y-%m-%d')
|
|
203
|
+
fm = {}
|
|
204
|
+
try:
|
|
205
|
+
with open(article_path, encoding='utf-8') as f:
|
|
206
|
+
content = f.read()
|
|
207
|
+
if content.startswith('---'):
|
|
208
|
+
parts = content.split('---', 2)
|
|
209
|
+
if len(parts) >= 3:
|
|
210
|
+
fm = yaml.safe_load(parts[1]) or {}
|
|
211
|
+
except Exception:
|
|
212
|
+
pass
|
|
213
|
+
cat = category or (fm.get('category') or '')
|
|
214
|
+
conn = sqlite3.connect(db_path)
|
|
215
|
+
conn.execute(
|
|
216
|
+
"INSERT OR REPLACE INTO url_index "
|
|
217
|
+
"(url, title, fetched_at, issues, category, origin_path, article_path) "
|
|
218
|
+
"VALUES (?,?,?,?,?,?,?)",
|
|
219
|
+
(url, os.path.basename(article_path), fetch_date, '', cat, origin_path, article_path)
|
|
220
|
+
)
|
|
221
|
+
conn.commit()
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# ------------------------------------------------------------
|
|
225
|
+
# 8. validate_and_repair: 完整校验 + 自动修复流程
|
|
199
226
|
# ------------------------------------------------------------
|
|
200
227
|
def validate_and_repair(origin_path, article_path, url, defaults=None):
|
|
201
228
|
"""
|
|
@@ -216,7 +243,7 @@ def validate_and_repair(origin_path, article_path, url, defaults=None):
|
|
|
216
243
|
|
|
217
244
|
|
|
218
245
|
# ------------------------------------------------------------
|
|
219
|
-
#
|
|
246
|
+
# 9. sanitize_filename: 文件名清理
|
|
220
247
|
# ------------------------------------------------------------
|
|
221
248
|
def sanitize_filename(name):
|
|
222
249
|
"""移除文件名中的特殊字符"""
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Playwright scraper for general websites.
|
|
4
|
+
Usage: python playwright_web.py <url> <html_path> <vault_path> <skill_dir>
|
|
5
|
+
html_path: path to pre-fetched HTML file (e.g. /tmp/fetched_page.html)
|
|
6
|
+
Stdout: "ORIGIN_PATH: <path>" on success
|
|
7
|
+
"""
|
|
8
|
+
import sys, os, ipaddress
|
|
9
|
+
from urllib.parse import urlparse
|
|
10
|
+
|
|
11
|
+
# --- Security: validate URL scheme FIRST, before any heavy imports ---
|
|
12
|
+
url = sys.argv[1]
|
|
13
|
+
html_path = sys.argv[2]
|
|
14
|
+
vault_path = sys.argv[3]
|
|
15
|
+
skill_dir = sys.argv[4]
|
|
16
|
+
|
|
17
|
+
_parsed = urlparse(url)
|
|
18
|
+
if _parsed.scheme not in ('http', 'https') or not _parsed.netloc:
|
|
19
|
+
print(f"ERROR: Rejected URL with scheme '{_parsed.scheme}' — only http/https allowed", file=sys.stderr)
|
|
20
|
+
sys.exit(1)
|
|
21
|
+
|
|
22
|
+
import urllib.request, hashlib
|
|
23
|
+
from datetime import datetime, timezone, timedelta
|
|
24
|
+
from playwright.sync_api import sync_playwright
|
|
25
|
+
|
|
26
|
+
sys.path.insert(0, os.path.join(skill_dir, 'references'))
|
|
27
|
+
from article_utils import infer_ext, format_block, sanitize_filename, repair_frontmatter, record_issues
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _is_safe_image_url(src):
|
|
31
|
+
"""Block file://, non-HTTP schemes, and private/loopback IPs (SSRF prevention)."""
|
|
32
|
+
p = urlparse(src)
|
|
33
|
+
if p.scheme not in ('http', 'https'):
|
|
34
|
+
return False
|
|
35
|
+
try:
|
|
36
|
+
ip = ipaddress.ip_address(p.hostname)
|
|
37
|
+
if ip.is_private or ip.is_loopback or ip.is_link_local:
|
|
38
|
+
return False
|
|
39
|
+
except (ValueError, TypeError):
|
|
40
|
+
pass
|
|
41
|
+
return True
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
url_hash = hashlib.md5(url.encode()).hexdigest()[:8]
|
|
45
|
+
image_dir = os.path.join(vault_path, 'Image')
|
|
46
|
+
origin_dir = os.path.join(vault_path, 'Origin')
|
|
47
|
+
db_path = os.path.join(vault_path, 'url-index.db')
|
|
48
|
+
|
|
49
|
+
os.makedirs(image_dir, exist_ok=True)
|
|
50
|
+
os.makedirs(origin_dir, exist_ok=True)
|
|
51
|
+
|
|
52
|
+
# --- Load HTML and extract content ---
|
|
53
|
+
with open(html_path, encoding='utf-8', errors='replace') as f:
|
|
54
|
+
html = f.read()
|
|
55
|
+
|
|
56
|
+
with sync_playwright() as p:
|
|
57
|
+
browser = p.chromium.launch(headless=True)
|
|
58
|
+
page = browser.new_page()
|
|
59
|
+
page.set_content(html, wait_until='domcontentloaded')
|
|
60
|
+
|
|
61
|
+
result = page.evaluate(r"""() => {
|
|
62
|
+
const skipTags = new Set(['SCRIPT','STYLE','NAV','FOOTER','HEADER','ASIDE','BUTTON','FORM']);
|
|
63
|
+
const contentUnits = [];
|
|
64
|
+
const imageBlocks = [];
|
|
65
|
+
|
|
66
|
+
const titleEl = document.querySelector('h1') || document.querySelector('title');
|
|
67
|
+
const title = titleEl ? titleEl.innerText.replace(/\s+/g, ' ').trim() : 'Untitled';
|
|
68
|
+
|
|
69
|
+
const dateMeta = document.querySelector('meta[property="article:published_time"]')
|
|
70
|
+
|| document.querySelector('meta[name="date"]')
|
|
71
|
+
|| document.querySelector('time');
|
|
72
|
+
const publishDate = dateMeta
|
|
73
|
+
? (dateMeta.getAttribute('content') || dateMeta.getAttribute('datetime') || '')
|
|
74
|
+
: '';
|
|
75
|
+
|
|
76
|
+
const authorMeta = document.querySelector('meta[name="author"]')
|
|
77
|
+
|| document.querySelector('[rel="author"]');
|
|
78
|
+
const author = authorMeta
|
|
79
|
+
? (authorMeta.getAttribute('content') || authorMeta.innerText || '').trim()
|
|
80
|
+
: '';
|
|
81
|
+
|
|
82
|
+
const main = document.querySelector('main') || document.querySelector('article') || document.body;
|
|
83
|
+
const walker = document.createTreeWalker(main, NodeFilter.SHOW_ELEMENT);
|
|
84
|
+
let node;
|
|
85
|
+
while (node = walker.nextNode()) {
|
|
86
|
+
if (skipTags.has(node.tagName.toUpperCase())) continue;
|
|
87
|
+
const tag = node.tagName.toUpperCase();
|
|
88
|
+
|
|
89
|
+
if (tag === 'IMG') {
|
|
90
|
+
const src = node.src || node.getAttribute('data-src') || '';
|
|
91
|
+
if (src && !src.startsWith('data:') && src.startsWith('http')) {
|
|
92
|
+
imageBlocks.push({src, alt: node.alt || '', afterBlock: contentUnits.length - 1});
|
|
93
|
+
}
|
|
94
|
+
} else if (['H1','H2','H3','P','LI','BLOCKQUOTE','PRE','CODE'].includes(tag)) {
|
|
95
|
+
const t = node.innerText.replace(/\s+/g, ' ').trim();
|
|
96
|
+
if (t && t.length > 10) {
|
|
97
|
+
contentUnits.push({tag: tag.toLowerCase(), content: t});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {title, author, publishDate, blocks: contentUnits, imageBlocks};
|
|
103
|
+
}""")
|
|
104
|
+
browser.close()
|
|
105
|
+
|
|
106
|
+
# --- Download images ---
|
|
107
|
+
downloaded = []
|
|
108
|
+
for i, img in enumerate(result.get('imageBlocks', [])):
|
|
109
|
+
if not _is_safe_image_url(img['src']):
|
|
110
|
+
print(f" [{i+1}] Skipped unsafe image URL: {img['src'][:80]}")
|
|
111
|
+
continue
|
|
112
|
+
ext = infer_ext(img['src'])
|
|
113
|
+
fname = f"{url_hash}_img_{i+1}{ext}"
|
|
114
|
+
fpath = os.path.join(image_dir, fname)
|
|
115
|
+
try:
|
|
116
|
+
req = urllib.request.Request(img['src'], headers={'User-Agent': 'Mozilla/5.0'})
|
|
117
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
118
|
+
data = resp.read()
|
|
119
|
+
with open(fpath, 'wb') as f:
|
|
120
|
+
f.write(data)
|
|
121
|
+
print(f" [{i+1}] Downloaded {fname} ({len(data)} bytes)")
|
|
122
|
+
downloaded.append({**img, 'filename': fname})
|
|
123
|
+
except Exception as e:
|
|
124
|
+
print(f" [{i+1}] Failed: {e}")
|
|
125
|
+
downloaded.append({**img, 'filename': fname})
|
|
126
|
+
|
|
127
|
+
# --- Build origin file ---
|
|
128
|
+
blocks = result['blocks']
|
|
129
|
+
title = result.get('title', 'Untitled')
|
|
130
|
+
author = result.get('author', '')
|
|
131
|
+
publish_date = (result.get('publishDate') or '')[:10]
|
|
132
|
+
fetch_date = datetime.now(timezone(timedelta(hours=8))).strftime('%Y-%m-%d')
|
|
133
|
+
|
|
134
|
+
origin_filename = sanitize_filename(title) + '.md'
|
|
135
|
+
origin_path = os.path.join(origin_dir, origin_filename)
|
|
136
|
+
|
|
137
|
+
body_units = []
|
|
138
|
+
for i, block in enumerate(blocks):
|
|
139
|
+
parts = [format_block(block)]
|
|
140
|
+
for img in downloaded:
|
|
141
|
+
if img.get('afterBlock') == i:
|
|
142
|
+
parts.append(f'')
|
|
143
|
+
body_units.append('\n'.join(parts))
|
|
144
|
+
|
|
145
|
+
body = '\n\n'.join(body_units)
|
|
146
|
+
|
|
147
|
+
origin_content = f"""---
|
|
148
|
+
publish_date: {publish_date}
|
|
149
|
+
fetch_date: {fetch_date}
|
|
150
|
+
author: {author}
|
|
151
|
+
source_url: {url}
|
|
152
|
+
origin_title: "{title}"
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
# {title}
|
|
156
|
+
|
|
157
|
+
{body}
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
with open(origin_path, 'w', encoding='utf-8') as f:
|
|
161
|
+
f.write(origin_content)
|
|
162
|
+
|
|
163
|
+
# --- Validate ---
|
|
164
|
+
fm, fixed, remaining = repair_frontmatter(origin_path, url, {'fetch_date': fetch_date})
|
|
165
|
+
if remaining:
|
|
166
|
+
record_issues(url, '; '.join(remaining), db_path)
|
|
167
|
+
print(f"警告:校验问题 {remaining}", file=sys.stderr)
|
|
168
|
+
else:
|
|
169
|
+
record_issues(url, '', db_path)
|
|
170
|
+
|
|
171
|
+
print(f"ORIGIN_PATH: {origin_path}")
|
|
172
|
+
print(f"抓取完成:{title} ({len(blocks)} blocks, {len(downloaded)} images)")
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Playwright scraper for X.com (Twitter) articles.
|
|
4
|
+
Usage: python playwright_xcom.py <url> <vault_path> <skill_dir>
|
|
5
|
+
Stdout: "ORIGIN_PATH: <path>" on success
|
|
6
|
+
"""
|
|
7
|
+
import sys, os, ipaddress
|
|
8
|
+
from urllib.parse import urlparse
|
|
9
|
+
|
|
10
|
+
# --- Security: validate URL scheme FIRST, before any heavy imports ---
|
|
11
|
+
url = sys.argv[1]
|
|
12
|
+
vault_path = sys.argv[2]
|
|
13
|
+
skill_dir = sys.argv[3]
|
|
14
|
+
|
|
15
|
+
_parsed = urlparse(url)
|
|
16
|
+
if _parsed.scheme not in ('http', 'https') or not _parsed.netloc:
|
|
17
|
+
print(f"ERROR: Rejected URL with scheme '{_parsed.scheme}' — only http/https allowed", file=sys.stderr)
|
|
18
|
+
sys.exit(1)
|
|
19
|
+
|
|
20
|
+
import json, urllib.request, hashlib
|
|
21
|
+
from datetime import datetime, timezone, timedelta
|
|
22
|
+
from playwright.sync_api import sync_playwright
|
|
23
|
+
|
|
24
|
+
sys.path.insert(0, os.path.join(skill_dir, 'references'))
|
|
25
|
+
from article_utils import infer_ext, format_block, sanitize_filename, repair_frontmatter, record_issues
|
|
26
|
+
|
|
27
|
+
chrome_profile = os.path.expanduser('~/Library/Application Support/Google/Chrome/Profile 2')
|
|
28
|
+
url_hash = hashlib.md5(url.encode()).hexdigest()[:8]
|
|
29
|
+
image_dir = os.path.join(vault_path, 'Image')
|
|
30
|
+
origin_dir = os.path.join(vault_path, 'Origin')
|
|
31
|
+
db_path = os.path.join(vault_path, 'url-index.db')
|
|
32
|
+
|
|
33
|
+
os.makedirs(image_dir, exist_ok=True)
|
|
34
|
+
os.makedirs(origin_dir, exist_ok=True)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _is_safe_image_url(src):
|
|
38
|
+
"""Block file://, non-HTTP schemes, and private/loopback IPs (SSRF prevention)."""
|
|
39
|
+
p = urlparse(src)
|
|
40
|
+
if p.scheme not in ('http', 'https'):
|
|
41
|
+
return False
|
|
42
|
+
try:
|
|
43
|
+
ip = ipaddress.ip_address(p.hostname)
|
|
44
|
+
if ip.is_private or ip.is_loopback or ip.is_link_local:
|
|
45
|
+
return False
|
|
46
|
+
except (ValueError, TypeError):
|
|
47
|
+
pass # hostname, not a bare IP — allow
|
|
48
|
+
return True
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# --- Scrape ---
|
|
52
|
+
with sync_playwright() as p:
|
|
53
|
+
ctx = p.chromium.launch_persistent_context(user_data_dir=chrome_profile, headless=False)
|
|
54
|
+
page = ctx.pages[0] if ctx.pages else ctx.new_page()
|
|
55
|
+
page.goto(url, timeout=30000)
|
|
56
|
+
page.wait_for_timeout(8000)
|
|
57
|
+
|
|
58
|
+
result = page.evaluate(r"""() => {
|
|
59
|
+
const article = document.querySelector('article[data-testid="tweet"]');
|
|
60
|
+
if (!article) return {error: 'No article found'};
|
|
61
|
+
|
|
62
|
+
const titleEl = article.querySelector('[data-testid="twitter-article-title"]');
|
|
63
|
+
const title = titleEl ? titleEl.innerText.replace(/\s+/g, ' ').trim() : 'Untitled';
|
|
64
|
+
|
|
65
|
+
const timeEl = article.querySelector('article time');
|
|
66
|
+
const publishDate = timeEl ? timeEl.getAttribute('datetime') : '';
|
|
67
|
+
|
|
68
|
+
const authorEl = article.querySelector('[data-testid="User-Name"]');
|
|
69
|
+
let author = '';
|
|
70
|
+
if (authorEl) {
|
|
71
|
+
const authorText = authorEl.innerText.replace(/\s+/g, ' ').trim();
|
|
72
|
+
author = authorText.split('@')[0].trim();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const skipTags = new Set(['SCRIPT','STYLE','NAV','FOOTER','HEADER','ASIDE']);
|
|
76
|
+
const contentUnits = [];
|
|
77
|
+
let lastText = '';
|
|
78
|
+
|
|
79
|
+
const walker = document.createTreeWalker(article, NodeFilter.SHOW_ELEMENT);
|
|
80
|
+
let node;
|
|
81
|
+
while (node = walker.nextNode()) {
|
|
82
|
+
if (skipTags.has(node.tagName.toUpperCase())) continue;
|
|
83
|
+
const tag = node.tagName.toUpperCase();
|
|
84
|
+
const tid = node.getAttribute('data-testid') || '';
|
|
85
|
+
|
|
86
|
+
if (tag === 'DIV' && tid === 'tweetPhoto') {
|
|
87
|
+
const img = node.querySelector('img');
|
|
88
|
+
if (img && img.src && !img.src.includes('data:') && !img.src.includes('/profile_images/')) {
|
|
89
|
+
contentUnits.push({type: 'image', src: img.src, alt: img.alt || ''});
|
|
90
|
+
}
|
|
91
|
+
} else if (tag === 'SPAN' && tid === '') {
|
|
92
|
+
let directText = '';
|
|
93
|
+
for (const cn of node.childNodes) {
|
|
94
|
+
if (cn.nodeType === Node.TEXT_NODE) {
|
|
95
|
+
directText += (cn.textContent || '').replace(/\s+/g, ' ').trim() + ' ';
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
directText = directText.trim();
|
|
99
|
+
let hasLiAncestor = false;
|
|
100
|
+
let ancestor = node.parentElement;
|
|
101
|
+
while (ancestor && ancestor.tagName) {
|
|
102
|
+
if (['LI','OL','UL'].includes(ancestor.tagName.toUpperCase())) {
|
|
103
|
+
hasLiAncestor = true;
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
ancestor = ancestor.parentElement;
|
|
107
|
+
}
|
|
108
|
+
if (hasLiAncestor) continue;
|
|
109
|
+
const isNoise = (
|
|
110
|
+
directText.length < 30 ||
|
|
111
|
+
/^[@#]?[\d.]+[KMB]?$/i.test(directText) ||
|
|
112
|
+
directText.startsWith('@')
|
|
113
|
+
);
|
|
114
|
+
const isSubset = lastText.length > 10 && (lastText.includes(directText) || directText.includes(lastText));
|
|
115
|
+
if (!isNoise && !isSubset && directText.length >= 30) {
|
|
116
|
+
contentUnits.push({type: 'text', tag: 'span', content: directText});
|
|
117
|
+
lastText = directText;
|
|
118
|
+
}
|
|
119
|
+
} else if (['H2','H3','P','LI','BLOCKQUOTE','PRE'].includes(tag)) {
|
|
120
|
+
const t = node.innerText.replace(/\s+/g, ' ').trim();
|
|
121
|
+
if (t && t.length > 5) {
|
|
122
|
+
contentUnits.push({type: 'text', tag: tag.toLowerCase(), content: t});
|
|
123
|
+
lastText = t;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const blocks = [];
|
|
129
|
+
const imageBlocks = [];
|
|
130
|
+
for (let i = 0; i < contentUnits.length; i++) {
|
|
131
|
+
const unit = contentUnits[i];
|
|
132
|
+
if (unit.type === 'text') {
|
|
133
|
+
blocks.push({
|
|
134
|
+
type: ['H2','H3'].includes(unit.tag.toUpperCase()) ? 'heading' : 'block',
|
|
135
|
+
tag: unit.tag,
|
|
136
|
+
content: unit.content,
|
|
137
|
+
blockIndex: blocks.length
|
|
138
|
+
});
|
|
139
|
+
} else if (unit.type === 'image') {
|
|
140
|
+
imageBlocks.push({src: unit.src, alt: unit.alt, afterBlock: blocks.length - 1});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return {title, author, publishDate, blocks, imageBlocks,
|
|
145
|
+
totalTextBlocks: blocks.length, totalImages: imageBlocks.length};
|
|
146
|
+
}""")
|
|
147
|
+
|
|
148
|
+
result['publishDate'] = result.get('publishDate', '')
|
|
149
|
+
ctx.close()
|
|
150
|
+
|
|
151
|
+
if result.get('error'):
|
|
152
|
+
print(f"ERROR: {result['error']}", file=sys.stderr)
|
|
153
|
+
sys.exit(1)
|
|
154
|
+
|
|
155
|
+
# --- Download images ---
|
|
156
|
+
downloaded = []
|
|
157
|
+
for i, img in enumerate(result.get('imageBlocks', [])):
|
|
158
|
+
if not _is_safe_image_url(img['src']):
|
|
159
|
+
print(f" [{i+1}] Skipped unsafe image URL: {img['src'][:80]}")
|
|
160
|
+
continue
|
|
161
|
+
ext = infer_ext(img['src'])
|
|
162
|
+
fname = f"{url_hash}_img_{i+1}{ext}"
|
|
163
|
+
fpath = os.path.join(image_dir, fname)
|
|
164
|
+
try:
|
|
165
|
+
req = urllib.request.Request(img['src'], headers={'User-Agent': 'Mozilla/5.0', 'Accept': 'image/*'})
|
|
166
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
167
|
+
data = resp.read()
|
|
168
|
+
with open(fpath, 'wb') as f:
|
|
169
|
+
f.write(data)
|
|
170
|
+
print(f" [{i+1}] Downloaded {fname} ({len(data)} bytes)")
|
|
171
|
+
downloaded.append({**img, 'filename': fname})
|
|
172
|
+
except Exception as e:
|
|
173
|
+
print(f" [{i+1}] Failed {fname}: {e}")
|
|
174
|
+
downloaded.append({**img, 'filename': fname})
|
|
175
|
+
|
|
176
|
+
# --- Build origin file ---
|
|
177
|
+
blocks = result['blocks']
|
|
178
|
+
title = result.get('title', 'Untitled')
|
|
179
|
+
author = result.get('author', '')
|
|
180
|
+
publish_date = result.get('publishDate', '')[:10]
|
|
181
|
+
fetch_date = datetime.now(timezone(timedelta(hours=8))).strftime('%Y-%m-%d')
|
|
182
|
+
|
|
183
|
+
origin_filename = sanitize_filename(title) + '.md'
|
|
184
|
+
origin_path = os.path.join(origin_dir, origin_filename)
|
|
185
|
+
|
|
186
|
+
body_units = []
|
|
187
|
+
for i, block in enumerate(blocks):
|
|
188
|
+
parts = [format_block(block)]
|
|
189
|
+
for img in downloaded:
|
|
190
|
+
if img.get('afterBlock') == i:
|
|
191
|
+
parts.append(f'')
|
|
192
|
+
body_units.append('\n'.join(parts))
|
|
193
|
+
|
|
194
|
+
body = '\n\n'.join(body_units)
|
|
195
|
+
|
|
196
|
+
origin_content = f"""---
|
|
197
|
+
publish_date: {publish_date}
|
|
198
|
+
fetch_date: {fetch_date}
|
|
199
|
+
author: {author}
|
|
200
|
+
source_url: {url}
|
|
201
|
+
origin_title: "{title}"
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
# {title}
|
|
205
|
+
|
|
206
|
+
{body}
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
with open(origin_path, 'w', encoding='utf-8') as f:
|
|
210
|
+
f.write(origin_content)
|
|
211
|
+
|
|
212
|
+
# --- Validate ---
|
|
213
|
+
fm, fixed, remaining = repair_frontmatter(origin_path, url, {'fetch_date': fetch_date})
|
|
214
|
+
if remaining:
|
|
215
|
+
record_issues(url, '; '.join(remaining), db_path)
|
|
216
|
+
print(f"警告:校验问题 {remaining}", file=sys.stderr)
|
|
217
|
+
else:
|
|
218
|
+
record_issues(url, '', db_path)
|
|
219
|
+
|
|
220
|
+
print(f"ORIGIN_PATH: {origin_path}")
|
|
221
|
+
print(f"抓取完成:{title} ({len(blocks)} blocks, {len(downloaded)} images)")
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Post-translate validation + SQLite index write for Subagent 2.
|
|
4
|
+
Parameters passed via environment variables to avoid shell injection:
|
|
5
|
+
ARTICLE_URL - source URL
|
|
6
|
+
ARTICLE_ORIGIN - path to origin .md file
|
|
7
|
+
ARTICLE_PATH - path to translated article .md file
|
|
8
|
+
ARTICLE_DB - path to url-index.db
|
|
9
|
+
ARTICLE_SKILL_DIR - skill installation directory
|
|
10
|
+
ARTICLE_CATEGORY - (optional) category tag
|
|
11
|
+
"""
|
|
12
|
+
import sys, os
|
|
13
|
+
|
|
14
|
+
url = os.environ['ARTICLE_URL']
|
|
15
|
+
origin_path = os.environ['ARTICLE_ORIGIN']
|
|
16
|
+
article_path = os.environ['ARTICLE_PATH']
|
|
17
|
+
db_path = os.environ['ARTICLE_DB']
|
|
18
|
+
skill_dir = os.environ['ARTICLE_SKILL_DIR']
|
|
19
|
+
category = os.environ.get('ARTICLE_CATEGORY', '')
|
|
20
|
+
|
|
21
|
+
sys.path.insert(0, os.path.join(skill_dir, 'references'))
|
|
22
|
+
from article_utils import repair_frontmatter, record_issues, write_url_index
|
|
23
|
+
|
|
24
|
+
if not os.path.exists(article_path):
|
|
25
|
+
print(f"ERROR: article file not found: {article_path}", file=sys.stderr)
|
|
26
|
+
sys.exit(1)
|
|
27
|
+
|
|
28
|
+
fm, fixed, remaining = repair_frontmatter(article_path, url)
|
|
29
|
+
if remaining:
|
|
30
|
+
record_issues(url, '; '.join(remaining), db_path)
|
|
31
|
+
print(f"ERROR: 校验未通过:{remaining}", file=sys.stderr)
|
|
32
|
+
sys.exit(1)
|
|
33
|
+
|
|
34
|
+
record_issues(url, '', db_path)
|
|
35
|
+
write_url_index(url, origin_path, article_path, db_path, category=category)
|
|
36
|
+
print(f"翻译完成:{article_path}")
|