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.
@@ -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
- '{{SKILL_DIR}}/scripts/url-index.db'
189
+ '{{VAULT_PATH}}/url-index.db'
190
190
  )
191
191
  conn = sqlite3.connect(db_path)
192
- conn.execute('UPDATE url_index SET issues=? WHERE source_url=?',
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. validate_and_repair: 完整校验 + 自动修复流程
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
- # 7. sanitize_filename: 文件名清理
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'![](Image/{img["filename"]})')
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'![](Image/{img["filename"]})')
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}")