harveyz-skill 0.25.0 → 0.25.1

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 (21) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/package.json +1 -1
  3. package/skills/research/extract-url/SKILL.md +1 -1
  4. package/skills/research/extract-url/references/subagent1-fetch-prompt.md +9 -0
  5. package/skills/research/extract-url/scripts/playwright_web_wechat.py +256 -0
  6. package/skills/research/extract-url/scripts/playwright_xcom.py +118 -0
  7. package/skills/research/extract-url/tests/__pycache__/conftest.cpython-314-pytest-9.0.2.pyc +0 -0
  8. package/skills/research/extract-url/tests/__pycache__/test_article_utils_meta.cpython-314-pytest-9.0.2.pyc +0 -0
  9. package/skills/research/extract-url/tests/__pycache__/test_config.cpython-314-pytest-9.0.2.pyc +0 -0
  10. package/skills/research/extract-url/tests/__pycache__/test_dedup_check.cpython-314-pytest-9.0.2.pyc +0 -0
  11. package/skills/research/extract-url/tests/__pycache__/test_migrate_to_folder_structure.cpython-314-pytest-9.0.2.pyc +0 -0
  12. package/skills/research/extract-url/tests/__pycache__/test_playwright_web.cpython-314-pytest-9.0.2.pyc +0 -0
  13. package/skills/research/extract-url/tests/__pycache__/test_playwright_web_arxiv.cpython-314-pytest-9.0.2.pyc +0 -0
  14. package/skills/research/extract-url/tests/__pycache__/test_playwright_web_wechat.cpython-314-pytest-9.0.2.pyc +0 -0
  15. package/skills/research/extract-url/tests/__pycache__/test_playwright_xcom.cpython-314-pytest-9.0.2.pyc +0 -0
  16. package/skills/research/extract-url/tests/__pycache__/test_subagent1_prompt.cpython-314-pytest-9.0.2.pyc +0 -0
  17. package/skills/research/extract-url/tests/__pycache__/test_subagent2_prompt.cpython-314-pytest-9.0.2.pyc +0 -0
  18. package/skills/research/extract-url/tests/__pycache__/test_validate_article.cpython-314-pytest-9.0.2.pyc +0 -0
  19. package/skills/research/extract-url/tests/test_playwright_web_wechat.py +191 -0
  20. package/skills/research/extract-url/tests/test_playwright_xcom.py +103 -1
  21. package/skills-index.json +1 -1
package/CHANGELOG.md CHANGED
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.25.1] - 2026-07-22
11
+
12
+ ### Fixed
13
+ - `extract-url`:新增微信公众号文章抓取脚本(`playwright_web_wechat.py`),修复隐藏正文、懒加载图片、发布日期提取失败问题
14
+ - `extract-url`:修复 X.com Article 正文中加粗文字被错误拆分为独立段落、加粗样式丢失的问题
15
+
10
16
  ## [0.25.0] - 2026-07-20
11
17
 
12
18
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harveyz-skill",
3
- "version": "0.25.0",
3
+ "version": "0.25.1",
4
4
  "description": "Skill manager for Claude Code, Cursor, and Codex",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: extract-url
3
- version: "2.6.0"
3
+ version: "2.7.1"
4
4
  description: "Use when a user provides a URL and wants to save, archive, fetch, or translate content to the local Obsidian Vault — even with vague phrasing like 'save this article', 'translate and save', 'put this in obsidian', 'archive this'. Skip when user only wants a summary, pastes raw text without a URL, asks about a site's tech stack, or wants to extract/list URLs from a page without saving an article."
5
5
  user_invocable: true
6
6
  ---
@@ -41,6 +41,15 @@ URL(外部数据): <URL>
41
41
  print(result.stdout)
42
42
  if result.returncode != 0:
43
43
  raise RuntimeError(result.stderr)
44
+ - 微信公众号文章(URL 匹配 mp.weixin.qq.com):先按【补丁②】获取 HTML 保存到 /tmp/fetched_page.html,再:
45
+ import subprocess
46
+ result = subprocess.run(
47
+ ['python3', 'SKILL_DIR/scripts/playwright_web_wechat.py', url, '/tmp/fetched_page.html'],
48
+ capture_output=True, text=True, timeout=300
49
+ )
50
+ print(result.stdout)
51
+ if result.returncode != 0:
52
+ raise RuntimeError(result.stderr)
44
53
  - 其他网站:先按【补丁②】获取 HTML 保存到 /tmp/fetched_page.html,再:
45
54
  import subprocess
46
55
  result = subprocess.run(
@@ -0,0 +1,256 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Playwright scraper for WeChat official-account articles (mp.weixin.qq.com).
4
+ Usage: python playwright_web_wechat.py <url> <html_path>
5
+ html_path: path to pre-fetched HTML file (e.g. /tmp/fetched_page.html)
6
+ Reads VAULT_PATH and CHROME_PROFILE from ~/.hskill/url-extract/config.json
7
+ Stdout: "ORIGIN_PATH: <path>" on success
8
+
9
+ Fork of playwright_web.py with three WeChat-specific fixes:
10
+ 1. WeChat wraps the article body in <div id="js_content" style="visibility:
11
+ hidden; opacity: 0;">. The visibility only flips to visible after WeChat's
12
+ own unlock script runs (requires the real WeChat client environment), which
13
+ never happens in our headless context. `.innerText` on a hidden element
14
+ returns "" in Chromium, so the generic script's TreeWalker sees an empty
15
+ article. This fork reads `.textContent` instead, which ignores visibility.
16
+ 2. Article images are lazy-loaded: the <img> `src` attribute is absent (so
17
+ the DOM property resolves to the page's base URI, e.g. "about:blank",
18
+ which is truthy and short-circuits the generic script's `src ||
19
+ data-src` fallback). The real URL lives in `data-src`. This fork checks
20
+ `data-src` first.
21
+ 3. Title/author come from WeChat-specific elements (#activity-name,
22
+ #js_name) instead of generic <h1>/meta selectors. Publish date isn't in
23
+ the DOM at all — WeChat sets it client-side from a `var ct = "<unix ts>"`
24
+ script variable — so this fork extracts `ct` from the raw HTML with regex.
25
+
26
+ If the pre-fetched HTML yields thin content (<20 blocks or <3000 chars), the
27
+ script automatically retries by navigating directly with Chrome cookies injected
28
+ (same mechanism as playwright_xcom.py). Useful for paywalled / login-gated sites.
29
+ """
30
+ import re
31
+ import sys, os, ipaddress
32
+ from urllib.parse import urlparse
33
+ from pathlib import Path
34
+
35
+ # --- Security: validate URL scheme FIRST, before any heavy imports ---
36
+ url = sys.argv[1]
37
+ html_path = sys.argv[2]
38
+
39
+ _parsed = urlparse(url)
40
+ if _parsed.scheme not in ('http', 'https') or not _parsed.netloc:
41
+ print(f"ERROR: Rejected URL with scheme '{_parsed.scheme}' — only http/https allowed", file=sys.stderr)
42
+ sys.exit(1)
43
+
44
+ # --- Config (after security check) ---
45
+ sys.path.insert(0, str(Path(__file__).parent))
46
+ from config import get_vault_path, get_chrome_profile, get_article_paths
47
+ vault_path = get_vault_path()
48
+ skill_dir = str(Path(__file__).parent.parent)
49
+
50
+ import urllib.request, shutil, tempfile
51
+ from datetime import datetime, timezone, timedelta
52
+ from playwright.sync_api import sync_playwright
53
+
54
+ sys.path.insert(0, os.path.join(skill_dir, 'references'))
55
+ from article_utils import infer_ext, format_block, repair_frontmatter, record_fetch_issues
56
+
57
+
58
+ def _is_safe_image_url(src):
59
+ """Block file://, non-HTTP schemes, and private/loopback IPs (SSRF prevention)."""
60
+ p = urlparse(src)
61
+ if p.scheme not in ('http', 'https'):
62
+ return False
63
+ try:
64
+ ip = ipaddress.ip_address(p.hostname)
65
+ if ip.is_private or ip.is_loopback or ip.is_link_local:
66
+ return False
67
+ except (ValueError, TypeError):
68
+ pass
69
+ return True
70
+
71
+
72
+ _EXTRACT_JS = r"""() => {
73
+ const skipTags = new Set(['SCRIPT','STYLE','NAV','FOOTER','HEADER','ASIDE','BUTTON','FORM']);
74
+ const contentUnits = [];
75
+ const imageBlocks = [];
76
+
77
+ const titleEl = document.querySelector('#activity-name')
78
+ || document.querySelector('h1')
79
+ || document.querySelector('title');
80
+ const title = titleEl ? titleEl.innerText.replace(/\s+/g, ' ').trim() : 'Untitled';
81
+
82
+ const authorEl = document.querySelector('#js_name');
83
+ const author = authorEl ? authorEl.innerText.replace(/\s+/g, ' ').trim() : '';
84
+
85
+ // #js_content is server-rendered but sits behind visibility:hidden until
86
+ // WeChat's client-side unlock script runs (never happens here), so we
87
+ // read via textContent below instead of innerText.
88
+ const main = document.querySelector('#js_content')
89
+ || document.querySelector('main')
90
+ || document.querySelector('article')
91
+ || document.body;
92
+
93
+ const walker = document.createTreeWalker(main, NodeFilter.SHOW_ELEMENT);
94
+ let node;
95
+ while (node = walker.nextNode()) {
96
+ if (skipTags.has(node.tagName.toUpperCase())) continue;
97
+ const tag = node.tagName.toUpperCase();
98
+
99
+ if (tag === 'IMG') {
100
+ // Real URL lives in data-src (lazy-load); src attribute is
101
+ // usually absent, so the src *property* would otherwise resolve
102
+ // to the page's base URI instead of falling through.
103
+ const src = node.getAttribute('data-src') || node.src || '';
104
+ if (src && !src.startsWith('data:') && src.startsWith('http')) {
105
+ imageBlocks.push({src, alt: node.alt || '', afterBlock: contentUnits.length - 1});
106
+ }
107
+ } else if (['H1','H2','H3','P','LI','BLOCKQUOTE','PRE','CODE'].includes(tag)) {
108
+ const t = node.textContent.replace(/\s+/g, ' ').trim();
109
+ if (t && t.length > 10) {
110
+ contentUnits.push({tag: tag.toLowerCase(), content: t});
111
+ }
112
+ }
113
+ }
114
+
115
+ return {title, author, blocks: contentUnits, imageBlocks};
116
+ }"""
117
+
118
+
119
+ def _is_thin(result):
120
+ blocks = result.get('blocks', [])
121
+ total_chars = sum(len(b['content']) for b in blocks)
122
+ return len(blocks) < 20 or total_chars < 3000
123
+
124
+
125
+ def _fetch_with_cookies(url):
126
+ """Navigate directly to URL with Chrome profile cookies. Returns extracted result or None."""
127
+ try:
128
+ import pycookiecheat
129
+ chrome_profile = get_chrome_profile()
130
+ cookie_origin = f"{_parsed.scheme}://{_parsed.netloc}"
131
+ tmp = tempfile.mktemp(suffix='.db')
132
+ shutil.copy2(str(Path(chrome_profile) / 'Cookies'), tmp)
133
+ cookies_dict = pycookiecheat.chrome_cookies(cookie_origin, cookie_file=tmp)
134
+ Path(tmp).unlink(missing_ok=True)
135
+ if not cookies_dict:
136
+ return None
137
+ pw_cookies = [
138
+ {'name': k, 'value': v, 'domain': _parsed.netloc, 'path': '/'}
139
+ for k, v in cookies_dict.items()
140
+ ]
141
+ with sync_playwright() as p:
142
+ browser = p.chromium.launch(headless=True)
143
+ ctx = browser.new_context(
144
+ user_agent='Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) '
145
+ 'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36'
146
+ )
147
+ ctx.add_cookies(pw_cookies)
148
+ page = ctx.new_page()
149
+ page.goto(url, wait_until='domcontentloaded', timeout=60000)
150
+ page.wait_for_timeout(3000)
151
+ result = page.evaluate(_EXTRACT_JS)
152
+ browser.close()
153
+ return result
154
+ except Exception as e:
155
+ print(f"Chrome cookie 重试失败(忽略): {e}", file=sys.stderr)
156
+ return None
157
+
158
+
159
+ # --- Load HTML and extract content ---
160
+ with open(html_path, encoding='utf-8', errors='replace') as f:
161
+ html = f.read()
162
+
163
+ # WeChat sets the publish date client-side from `var ct = "<unix ts>"`;
164
+ # it's never in the DOM (not even hidden), so pull it straight from the
165
+ # raw HTML instead of via page.evaluate.
166
+ publish_date = ''
167
+ _ct_match = re.search(r'var\s+ct\s*=\s*["\'](\d+)["\']', html)
168
+ if _ct_match:
169
+ publish_date = datetime.fromtimestamp(
170
+ int(_ct_match.group(1)), tz=timezone(timedelta(hours=8))
171
+ ).strftime('%Y-%m-%d')
172
+
173
+ with sync_playwright() as p:
174
+ browser = p.chromium.launch(headless=True)
175
+ page = browser.new_page()
176
+ page.set_content(html, wait_until='domcontentloaded')
177
+ result = page.evaluate(_EXTRACT_JS)
178
+ browser.close()
179
+
180
+ if _is_thin(result):
181
+ print(f"内容偏少({len(result.get('blocks',[]))} blocks),尝试用 Chrome cookies 重抓…", file=sys.stderr)
182
+ retried = _fetch_with_cookies(url)
183
+ if retried and len(retried.get('blocks', [])) > len(result.get('blocks', [])):
184
+ result = retried
185
+ print(f"Cookie 重试成功,获得 {len(result['blocks'])} blocks", file=sys.stderr)
186
+
187
+ title = result.get('title', 'Untitled')
188
+ paths = get_article_paths(url, title)
189
+ image_dir = paths['image_dir']
190
+ origin_dir = paths['origin_dir']
191
+ origin_path = paths['origin_path']
192
+ os.makedirs(image_dir, exist_ok=True)
193
+ os.makedirs(origin_dir, exist_ok=True)
194
+
195
+ # --- Download images ---
196
+ downloaded = []
197
+ for i, img in enumerate(result.get('imageBlocks', [])):
198
+ if not _is_safe_image_url(img['src']):
199
+ print(f" [{i+1}] Skipped unsafe image URL: {img['src'][:80]}")
200
+ continue
201
+ ext = infer_ext(img['src'])
202
+ fname = f"img_{i+1}{ext}"
203
+ fpath = os.path.join(image_dir, fname)
204
+ try:
205
+ req = urllib.request.Request(img['src'], headers={'User-Agent': 'Mozilla/5.0'})
206
+ with urllib.request.urlopen(req, timeout=15) as resp:
207
+ data = resp.read()
208
+ with open(fpath, 'wb') as f:
209
+ f.write(data)
210
+ print(f" [{i+1}] Downloaded {fname} ({len(data)} bytes)")
211
+ downloaded.append({**img, 'filename': fname})
212
+ except Exception as e:
213
+ print(f" [{i+1}] Failed: {e}")
214
+ downloaded.append({**img, 'filename': fname})
215
+
216
+ # --- Build origin file ---
217
+ blocks = result['blocks']
218
+ author = result.get('author', '')
219
+ fetch_date = datetime.now(timezone(timedelta(hours=8))).strftime('%Y-%m-%d')
220
+
221
+ body_units = []
222
+ for i, block in enumerate(blocks):
223
+ parts = [format_block(block)]
224
+ for img in downloaded:
225
+ if img.get('afterBlock') == i:
226
+ parts.append(f'![](../Image/{img["filename"]})')
227
+ body_units.append('\n'.join(parts))
228
+
229
+ body = '\n\n'.join(body_units)
230
+
231
+ origin_content = f"""---
232
+ publish_date: {publish_date}
233
+ fetch_date: {fetch_date}
234
+ author: {author}
235
+ source_url: {url}
236
+ origin_title: "{title}"
237
+ ---
238
+
239
+ # {title}
240
+
241
+ {body}
242
+ """
243
+
244
+ with open(origin_path, 'w', encoding='utf-8') as f:
245
+ f.write(origin_content)
246
+
247
+ # --- Validate ---
248
+ fm, fixed, remaining = repair_frontmatter(origin_path, url, {'fetch_date': fetch_date}, skip_remaining_fields={'description'})
249
+ if remaining:
250
+ record_fetch_issues('; '.join(remaining), paths['article_dir'])
251
+ print(f"警告:校验问题 {remaining}", file=sys.stderr)
252
+ else:
253
+ record_fetch_issues('', paths['article_dir'])
254
+
255
+ print(f"ORIGIN_PATH: {origin_path}")
256
+ print(f"抓取完成:{title} ({len(blocks)} blocks, {len(downloaded)} images)")
@@ -100,8 +100,52 @@ _EXTRACT_JS_HEADED = r"""() => {
100
100
  return false;
101
101
  }
102
102
 
103
+ // X Articles/Notes render each paragraph as a Draft.js block div whose
104
+ // children are per-style-run spans (a new sibling span starts wherever
105
+ // bold toggles on/off). Without merging these runs, every bold word
106
+ // becomes its own top-level paragraph and the bold styling is lost.
107
+ function isDraftParagraphBlock(node) {
108
+ return node.tagName === 'DIV' && node.classList
109
+ && node.classList.contains('public-DraftStyleDefault-block');
110
+ }
111
+
112
+ function isBoldRun(span) {
113
+ // Inline style only (X marks bold runs with style="font-weight: bold"
114
+ // directly on the run) — NOT computed style, which would also pick up
115
+ // ambient bold from a heading ancestor and false-positive every run.
116
+ const w = span.style && span.style.fontWeight;
117
+ return w === 'bold' || parseInt(w) >= 600;
118
+ }
119
+
120
+ function paragraphToInlineMarkdown(blockDiv) {
121
+ let out = '';
122
+ for (const run of blockDiv.children) {
123
+ const text = (run.textContent || '').replace(/\s+/g, ' ');
124
+ if (!text) continue;
125
+ const trimmed = text.trim();
126
+ if (isBoldRun(run) && trimmed) {
127
+ const lead = text.slice(0, text.indexOf(trimmed));
128
+ const trail = text.slice(text.indexOf(trimmed) + trimmed.length);
129
+ out += lead + '**' + trimmed + '**' + trail;
130
+ } else {
131
+ out += text;
132
+ }
133
+ }
134
+ return out.trim();
135
+ }
136
+
137
+ function isInsideProcessedParagraph(node, processed) {
138
+ let el = node.parentElement;
139
+ while (el && el !== contentRoot) {
140
+ if (processed.has(el)) return true;
141
+ el = el.parentElement;
142
+ }
143
+ return false;
144
+ }
145
+
103
146
  const skipTags = new Set(['SCRIPT','STYLE','NAV','FOOTER','HEADER','ASIDE']);
104
147
  const contentUnits = [];
148
+ const processedParagraphs = new Set();
105
149
  let lastText = '';
106
150
 
107
151
  const walker = document.createTreeWalker(contentRoot, NodeFilter.SHOW_ELEMENT);
@@ -109,9 +153,20 @@ _EXTRACT_JS_HEADED = r"""() => {
109
153
  while (node = walker.nextNode()) {
110
154
  if (skipTags.has(node.tagName.toUpperCase())) continue;
111
155
  if (insideNestedTweet(node)) continue;
156
+ if (isInsideProcessedParagraph(node, processedParagraphs)) continue;
112
157
  const tag = node.tagName.toUpperCase();
113
158
  const tid = node.getAttribute('data-testid') || '';
114
159
 
160
+ if (isDraftParagraphBlock(node)) {
161
+ const md = paragraphToInlineMarkdown(node);
162
+ if (md && md.length > 5) {
163
+ contentUnits.push({type: 'text', tag: 'p', content: md});
164
+ lastText = md;
165
+ }
166
+ processedParagraphs.add(node);
167
+ continue;
168
+ }
169
+
115
170
  if (tag === 'DIV' && tid === 'tweetPhoto') {
116
171
  const img = node.querySelector('img');
117
172
  if (img && img.src && !img.src.includes('data:') && !img.src.includes('/profile_images/')) {
@@ -172,6 +227,10 @@ _EXTRACT_JS_HEADED = r"""() => {
172
227
  lastText = t.trim();
173
228
  }
174
229
  } else if (['H2','H3','P','LI','BLOCKQUOTE'].includes(tag)) {
230
+ // Mark processed so a nested Draft.js paragraph div (headings/
231
+ // blockquotes wrap one internally) isn't ALSO captured below,
232
+ // which would duplicate this element's text as an extra block.
233
+ processedParagraphs.add(node);
175
234
  const t = node.innerText.replace(/\s+/g, ' ').trim();
176
235
  if (t && t.length > 5) {
177
236
  contentUnits.push({type: 'text', tag: tag.toLowerCase(), content: t});
@@ -253,8 +312,52 @@ _EXTRACT_JS_HEADLESS = r"""() => {
253
312
  return false;
254
313
  }
255
314
 
315
+ // X Articles/Notes render each paragraph as a Draft.js block div whose
316
+ // children are per-style-run spans (a new sibling span starts wherever
317
+ // bold toggles on/off). Without merging these runs, every bold word
318
+ // becomes its own top-level paragraph and the bold styling is lost.
319
+ function isDraftParagraphBlock(node) {
320
+ return node.tagName === 'DIV' && node.classList
321
+ && node.classList.contains('public-DraftStyleDefault-block');
322
+ }
323
+
324
+ function isBoldRun(span) {
325
+ // Inline style only (X marks bold runs with style="font-weight: bold"
326
+ // directly on the run) — NOT computed style, which would also pick up
327
+ // ambient bold from a heading ancestor and false-positive every run.
328
+ const w = span.style && span.style.fontWeight;
329
+ return w === 'bold' || parseInt(w) >= 600;
330
+ }
331
+
332
+ function paragraphToInlineMarkdown(blockDiv) {
333
+ let out = '';
334
+ for (const run of blockDiv.children) {
335
+ const text = (run.textContent || '').replace(/\s+/g, ' ');
336
+ if (!text) continue;
337
+ const trimmed = text.trim();
338
+ if (isBoldRun(run) && trimmed) {
339
+ const lead = text.slice(0, text.indexOf(trimmed));
340
+ const trail = text.slice(text.indexOf(trimmed) + trimmed.length);
341
+ out += lead + '**' + trimmed + '**' + trail;
342
+ } else {
343
+ out += text;
344
+ }
345
+ }
346
+ return out.trim();
347
+ }
348
+
349
+ function isInsideProcessedParagraph(node, processed) {
350
+ let el = node.parentElement;
351
+ while (el && el !== contentRoot) {
352
+ if (processed.has(el)) return true;
353
+ el = el.parentElement;
354
+ }
355
+ return false;
356
+ }
357
+
256
358
  const skipTags = new Set(['SCRIPT','STYLE','NAV','FOOTER','HEADER','ASIDE']);
257
359
  const contentUnits = [];
360
+ const processedParagraphs = new Set();
258
361
  let lastText = '';
259
362
 
260
363
  const walker = document.createTreeWalker(contentRoot, NodeFilter.SHOW_ELEMENT);
@@ -262,9 +365,20 @@ _EXTRACT_JS_HEADLESS = r"""() => {
262
365
  while (node = walker.nextNode()) {
263
366
  if (skipTags.has(node.tagName.toUpperCase())) continue;
264
367
  if (insideNestedTweet(node)) continue;
368
+ if (isInsideProcessedParagraph(node, processedParagraphs)) continue;
265
369
  const tag = node.tagName.toUpperCase();
266
370
  const tid = node.getAttribute('data-testid') || '';
267
371
 
372
+ if (isDraftParagraphBlock(node)) {
373
+ const md = paragraphToInlineMarkdown(node);
374
+ if (md && md.length > 5) {
375
+ contentUnits.push({type: 'text', tag: 'p', content: md});
376
+ lastText = md;
377
+ }
378
+ processedParagraphs.add(node);
379
+ continue;
380
+ }
381
+
268
382
  if (tag === 'DIV' && tid === 'tweetPhoto') {
269
383
  const img = node.querySelector('img');
270
384
  if (img && img.src && !img.src.includes('data:') && !img.src.includes('/profile_images/')) {
@@ -305,6 +419,10 @@ _EXTRACT_JS_HEADLESS = r"""() => {
305
419
  lastText = directText;
306
420
  }
307
421
  } else if (['H2','H3','P','LI','BLOCKQUOTE','PRE'].includes(tag)) {
422
+ // Mark processed so a nested Draft.js paragraph div (headings/
423
+ // blockquotes wrap one internally) isn't ALSO captured below,
424
+ // which would duplicate this element's text as an extra block.
425
+ processedParagraphs.add(node);
308
426
  const t = node.innerText.replace(/\s+/g, ' ').trim();
309
427
  if (t && t.length > 5) {
310
428
  contentUnits.push({type: 'text', tag: tag.toLowerCase(), content: t});
@@ -0,0 +1,191 @@
1
+ import subprocess, os, pytest
2
+ from pathlib import Path
3
+
4
+ SCRIPTS_DIR = Path(__file__).parent.parent / 'scripts'
5
+
6
+ try:
7
+ from playwright.sync_api import sync_playwright as _sp # noqa: F401
8
+ PLAYWRIGHT_AVAILABLE = True
9
+ except ImportError:
10
+ PLAYWRIGHT_AVAILABLE = False
11
+
12
+ requires_playwright = pytest.mark.skipif(
13
+ not PLAYWRIGHT_AVAILABLE,
14
+ reason="playwright not installed — run: pip install playwright && playwright install chromium"
15
+ )
16
+
17
+ # Mirrors real mp.weixin.qq.com markup: #js_content starts visibility:hidden
18
+ # (WeChat's own unlock script never runs in our headless context), images
19
+ # are lazy-loaded via data-src with no usable src attribute, and the publish
20
+ # date lives only in a `var ct = "<unix ts>"` script variable.
21
+ _TEST_HTML = """\
22
+ <!DOCTYPE html>
23
+ <html>
24
+ <head>
25
+ <title>WeChat Test Article</title>
26
+ <script>var ct = "1719763200";</script>
27
+ </head>
28
+ <body>
29
+ <h1 class="rich_media_title" id="activity-name">
30
+ <span class="js_title_inner">WeChat Test Article</span>
31
+ </h1>
32
+ <a id="js_name">Test Official Account</a>
33
+ <div id="js_content" style="visibility: hidden; opacity: 0;">
34
+ <p>First paragraph with sufficient content to be captured by the playwright_web_wechat scraper logic.</p>
35
+ <p>Second paragraph providing additional body text for the content extraction verification test.</p>
36
+ <section><img data-src="https://mmbiz.qpic.cn/test/640?wx_fmt=png" class="rich_pages wxw-img"></section>
37
+ </div>
38
+ </body>
39
+ </html>
40
+ """
41
+
42
+ _TEST_HTML_NO_AUTHOR = """\
43
+ <!DOCTYPE html>
44
+ <html>
45
+ <head>
46
+ <title>WeChat No Author Article</title>
47
+ </head>
48
+ <body>
49
+ <h1 class="rich_media_title" id="activity-name">
50
+ <span class="js_title_inner">WeChat No Author Article</span>
51
+ </h1>
52
+ <div id="js_content" style="visibility: hidden; opacity: 0;">
53
+ <p>First paragraph with sufficient content to be captured by the playwright_web_wechat scraper logic.</p>
54
+ <p>Second paragraph providing additional body text for the content extraction verification test.</p>
55
+ </div>
56
+ </body>
57
+ </html>
58
+ """
59
+
60
+
61
+ def test_playwright_web_wechat_invalid_url_scheme(skill_config, tmp_path):
62
+ """Security check rejects non-http/https URLs before reading config."""
63
+ html = tmp_path / 'test.html'
64
+ html.write_text('<html><body><h1>X</h1></body></html>')
65
+ result = subprocess.run(
66
+ ['python3', str(SCRIPTS_DIR / 'playwright_web_wechat.py'),
67
+ 'file:///etc/passwd', str(html)],
68
+ env=skill_config['env'],
69
+ capture_output=True, text=True
70
+ )
71
+ assert result.returncode != 0
72
+ assert 'Rejected URL' in result.stderr
73
+
74
+
75
+ def test_playwright_web_wechat_missing_config(tmp_path):
76
+ """Clear error when config.json does not exist."""
77
+ html = tmp_path / 'test.html'
78
+ html.write_text('<html><body><h1>X</h1></body></html>')
79
+ env = {
80
+ **os.environ,
81
+ 'HSKILL_EXTRACT_URL_CONFIG': str(tmp_path / 'nonexistent.json'),
82
+ 'PATH': os.environ.get('PATH', ''),
83
+ }
84
+ result = subprocess.run(
85
+ ['python3', str(SCRIPTS_DIR / 'playwright_web_wechat.py'),
86
+ 'https://mp.weixin.qq.com/s/testid', str(html)],
87
+ env=env, capture_output=True, text=True
88
+ )
89
+ assert result.returncode != 0
90
+
91
+
92
+ def test_playwright_web_wechat_too_few_args(skill_config):
93
+ """Script exits non-zero when html_path argument is missing."""
94
+ result = subprocess.run(
95
+ ['python3', str(SCRIPTS_DIR / 'playwright_web_wechat.py'),
96
+ 'https://mp.weixin.qq.com/s/testid'],
97
+ env=skill_config['env'],
98
+ capture_output=True, text=True
99
+ )
100
+ assert result.returncode != 0
101
+
102
+
103
+ @requires_playwright
104
+ def test_playwright_web_wechat_e2e(skill_config, tmp_path):
105
+ """Full e2e: hidden #js_content + data-src images + ct timestamp all extracted correctly."""
106
+ html = tmp_path / 'article.html'
107
+ html.write_text(_TEST_HTML, encoding='utf-8')
108
+
109
+ result = subprocess.run(
110
+ ['python3', str(SCRIPTS_DIR / 'playwright_web_wechat.py'),
111
+ 'https://mp.weixin.qq.com/s/testid001', str(html)],
112
+ env=skill_config['env'],
113
+ capture_output=True, text=True,
114
+ timeout=60
115
+ )
116
+ assert result.returncode == 0, result.stderr
117
+ assert 'ORIGIN_PATH:' in result.stdout
118
+
119
+ origin_path = next(
120
+ line.split('ORIGIN_PATH:', 1)[1].strip()
121
+ for line in result.stdout.splitlines()
122
+ if line.startswith('ORIGIN_PATH:')
123
+ )
124
+ origin_file = Path(origin_path)
125
+ assert origin_file.exists(), f'Origin file not found at {origin_path}'
126
+
127
+ content = origin_file.read_text(encoding='utf-8')
128
+ assert 'WeChat Test Article' in content
129
+ assert 'source_url: https://mp.weixin.qq.com/s/testid001' in content
130
+ assert 'author: Test Official Account' in content
131
+ # ct = 1719763200 -> 2024-07-01 in UTC+8
132
+ assert 'publish_date: 2024-07-01' in content
133
+ # Content behind visibility:hidden must still be captured via textContent.
134
+ assert 'First paragraph with sufficient content' in content
135
+ assert 'Second paragraph providing additional body text' in content
136
+
137
+ import hashlib
138
+ expected_hash = hashlib.md5('https://mp.weixin.qq.com/s/testid001'.encode()).hexdigest()[:8]
139
+ assert origin_file.parent.name == 'Origin'
140
+ assert origin_file.parent.parent.name == expected_hash
141
+ assert origin_file.parent.parent.parent == skill_config['vault']
142
+
143
+ assert not (origin_file.parent.parent / '.fetch_issues.tmp').exists()
144
+
145
+
146
+ @requires_playwright
147
+ def test_playwright_web_wechat_finds_data_src_image(skill_config, tmp_path):
148
+ """Lazy-loaded images (data-src, no usable src attribute) are recognized as
149
+ real image URLs instead of being skipped (network fetch itself isn't under test)."""
150
+ html = tmp_path / 'article.html'
151
+ html.write_text(_TEST_HTML, encoding='utf-8')
152
+
153
+ result = subprocess.run(
154
+ ['python3', str(SCRIPTS_DIR / 'playwright_web_wechat.py'),
155
+ 'https://mp.weixin.qq.com/s/testid002', str(html)],
156
+ env=skill_config['env'],
157
+ capture_output=True, text=True,
158
+ timeout=60
159
+ )
160
+ assert result.returncode == 0, result.stderr
161
+ assert 'Skipped unsafe image URL' not in result.stdout
162
+ assert '1 images' in result.stdout
163
+
164
+
165
+ @requires_playwright
166
+ def test_playwright_web_wechat_e2e_writes_fetch_issues_tmp_when_incomplete(skill_config, tmp_path):
167
+ """When origin frontmatter has real gaps (missing author/date), a temp issues file is written."""
168
+ html = tmp_path / 'no-author.html'
169
+ html.write_text(_TEST_HTML_NO_AUTHOR, encoding='utf-8')
170
+
171
+ result = subprocess.run(
172
+ ['python3', str(SCRIPTS_DIR / 'playwright_web_wechat.py'),
173
+ 'https://mp.weixin.qq.com/s/testid003', str(html)],
174
+ env=skill_config['env'],
175
+ capture_output=True, text=True,
176
+ timeout=60
177
+ )
178
+ assert result.returncode == 0, result.stderr
179
+
180
+ origin_path = next(
181
+ line.split('ORIGIN_PATH:', 1)[1].strip()
182
+ for line in result.stdout.splitlines()
183
+ if line.startswith('ORIGIN_PATH:')
184
+ )
185
+ article_dir = Path(origin_path).parent.parent
186
+ tmp_issues = article_dir / '.fetch_issues.tmp'
187
+ assert tmp_issues.exists()
188
+ text = tmp_issues.read_text(encoding='utf-8')
189
+ assert 'author空' in text
190
+ assert 'publish_date空' in text
191
+ assert 'description空' not in text
@@ -1,8 +1,110 @@
1
- import subprocess, os
1
+ import re, subprocess, os
2
2
  from pathlib import Path
3
3
 
4
+ import pytest
5
+
4
6
  SCRIPTS_DIR = Path(__file__).parent.parent / 'scripts'
5
7
 
8
+ try:
9
+ from playwright.sync_api import sync_playwright as _sp # noqa: F401
10
+ PLAYWRIGHT_AVAILABLE = True
11
+ except ImportError:
12
+ PLAYWRIGHT_AVAILABLE = False
13
+
14
+ requires_playwright = pytest.mark.skipif(
15
+ not PLAYWRIGHT_AVAILABLE,
16
+ reason="playwright not installed — run: pip install playwright && playwright install chromium"
17
+ )
18
+
19
+
20
+ def _extract_js(variant):
21
+ """Pull _EXTRACT_JS_HEADED or _EXTRACT_JS_HEADLESS source out of the script
22
+ so it can be evaluated directly against a synthetic page — playwright_xcom.py
23
+ always navigates to a real x.com URL with real cookies, so this is the only
24
+ way to unit-test the DOM extraction logic in isolation."""
25
+ src = (SCRIPTS_DIR / 'playwright_xcom.py').read_text(encoding='utf-8')
26
+ m = re.search(rf'{variant} = r"""(.*?)"""', src, re.S)
27
+ return m.group(1)
28
+
29
+
30
+ # Mirrors the real DOM structure of an X Article (Notes) body: Draft.js renders
31
+ # each paragraph as a public-DraftStyleDefault-block div whose children are
32
+ # per-style-run spans — a run gets style="font-weight: bold" only where bold
33
+ # is toggled on. Headings/blockquotes wrap the same block div internally.
34
+ _XCOM_ARTICLE_HTML = """\
35
+ <article data-testid="tweet">
36
+ <time datetime="2026-07-20T11:23:31.000Z"></time>
37
+ <div data-testid="User-Name">Codez <span>@0xCodez</span></div>
38
+ <div data-testid="twitterArticleRichTextView">
39
+ <h1>Test Article Title</h1>
40
+ <div class="longform-unstyled" data-block="true" data-offset-key="a-0-0">
41
+ <div data-offset-key="a-0-0" class="public-DraftStyleDefault-block public-DraftStyleDefault-ltr">
42
+ <span data-offset-key="a-0-0"><span data-text="true">They don&#8217;t </span></span>
43
+ <span data-offset-key="a-0-1" style="font-weight: bold;"><span data-text="true">route.</span></span>
44
+ <span data-offset-key="a-0-2"><span data-text="true"> They just queue.</span></span>
45
+ </div>
46
+ </div>
47
+ <blockquote class="longform-blockquote" data-block="true" data-offset-key="b-0-0">
48
+ <div data-offset-key="b-0-0" class="public-DraftStyleDefault-block public-DraftStyleDefault-ltr">
49
+ <span data-offset-key="b-0-0"><span data-text="true">A quoted line worth keeping.</span></span>
50
+ </div>
51
+ </blockquote>
52
+ <h2 class="longform-header-two" data-block="true" data-offset-key="c-0-0">
53
+ <div data-offset-key="c-0-0" class="public-DraftStyleDefault-block public-DraftStyleDefault-ltr">
54
+ <span data-offset-key="c-0-0"><span data-text="true">A Heading</span></span>
55
+ </div>
56
+ </h2>
57
+ </div>
58
+ </article>
59
+ """
60
+
61
+
62
+ @requires_playwright
63
+ @pytest.mark.parametrize('variant', ['_EXTRACT_JS_HEADED', '_EXTRACT_JS_HEADLESS'])
64
+ def test_playwright_xcom_merges_bold_runs_into_one_paragraph(variant):
65
+ """Regression: sibling style-run spans within one Draft.js paragraph block
66
+ must merge into a single block with inline **bold** markdown, not fragment
67
+ into separate paragraphs with the bold styling silently dropped."""
68
+ from playwright.sync_api import sync_playwright
69
+
70
+ js = _extract_js(variant)
71
+ with sync_playwright() as p:
72
+ browser = p.chromium.launch()
73
+ page = browser.new_page()
74
+ page.set_content(_XCOM_ARTICLE_HTML)
75
+ result = page.evaluate(js)
76
+ browser.close()
77
+
78
+ blocks = result['blocks']
79
+ para = next(b for b in blocks if 'route' in b['content'])
80
+ assert para['content'] == "They don’t **route.** They just queue."
81
+ assert para['tag'] == 'p'
82
+
83
+
84
+ @requires_playwright
85
+ @pytest.mark.parametrize('variant', ['_EXTRACT_JS_HEADED', '_EXTRACT_JS_HEADLESS'])
86
+ def test_playwright_xcom_no_duplicate_blocks_for_heading_and_blockquote(variant):
87
+ """Regression: headings/blockquotes wrap a nested Draft.js paragraph div
88
+ internally — it must not be captured a second time as an extra block."""
89
+ from playwright.sync_api import sync_playwright
90
+
91
+ js = _extract_js(variant)
92
+ with sync_playwright() as p:
93
+ browser = p.chromium.launch()
94
+ page = browser.new_page()
95
+ page.set_content(_XCOM_ARTICLE_HTML)
96
+ result = page.evaluate(js)
97
+ browser.close()
98
+
99
+ blocks = result['blocks']
100
+ quote_blocks = [b for b in blocks if 'quoted line' in b['content']]
101
+ assert len(quote_blocks) == 1, f'blockquote text duplicated: {quote_blocks}'
102
+ assert quote_blocks[0]['tag'] == 'blockquote'
103
+
104
+ heading_blocks = [b for b in blocks if 'Heading' in b['content']]
105
+ assert len(heading_blocks) == 1, f'heading text duplicated: {heading_blocks}'
106
+ assert heading_blocks[0]['tag'] == 'h2'
107
+
6
108
 
7
109
  def test_playwright_xcom_invalid_scheme(skill_config):
8
110
  """Security check rejects non-http/https URLs before reading config."""
package/skills-index.json CHANGED
@@ -38,7 +38,7 @@
38
38
  "bundle": "research",
39
39
  "installScope": "global",
40
40
  "contentHash": "4b72bb5e655884d5",
41
- "contentVersion": "2.6.0"
41
+ "contentVersion": "2.7.1"
42
42
  },
43
43
  {
44
44
  "path": "research/extract-vision",