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.
- package/CHANGELOG.md +6 -0
- package/package.json +1 -1
- package/skills/research/extract-url/SKILL.md +1 -1
- package/skills/research/extract-url/references/subagent1-fetch-prompt.md +9 -0
- package/skills/research/extract-url/scripts/playwright_web_wechat.py +256 -0
- package/skills/research/extract-url/scripts/playwright_xcom.py +118 -0
- package/skills/research/extract-url/tests/__pycache__/conftest.cpython-314-pytest-9.0.2.pyc +0 -0
- package/skills/research/extract-url/tests/__pycache__/test_article_utils_meta.cpython-314-pytest-9.0.2.pyc +0 -0
- package/skills/research/extract-url/tests/__pycache__/test_config.cpython-314-pytest-9.0.2.pyc +0 -0
- package/skills/research/extract-url/tests/__pycache__/test_dedup_check.cpython-314-pytest-9.0.2.pyc +0 -0
- package/skills/research/extract-url/tests/__pycache__/test_migrate_to_folder_structure.cpython-314-pytest-9.0.2.pyc +0 -0
- package/skills/research/extract-url/tests/__pycache__/test_playwright_web.cpython-314-pytest-9.0.2.pyc +0 -0
- package/skills/research/extract-url/tests/__pycache__/test_playwright_web_arxiv.cpython-314-pytest-9.0.2.pyc +0 -0
- package/skills/research/extract-url/tests/__pycache__/test_playwright_web_wechat.cpython-314-pytest-9.0.2.pyc +0 -0
- package/skills/research/extract-url/tests/__pycache__/test_playwright_xcom.cpython-314-pytest-9.0.2.pyc +0 -0
- package/skills/research/extract-url/tests/__pycache__/test_subagent1_prompt.cpython-314-pytest-9.0.2.pyc +0 -0
- package/skills/research/extract-url/tests/__pycache__/test_subagent2_prompt.cpython-314-pytest-9.0.2.pyc +0 -0
- package/skills/research/extract-url/tests/__pycache__/test_validate_article.cpython-314-pytest-9.0.2.pyc +0 -0
- package/skills/research/extract-url/tests/test_playwright_web_wechat.py +191 -0
- package/skills/research/extract-url/tests/test_playwright_xcom.py +103 -1
- 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: extract-url
|
|
3
|
-
version: "2.
|
|
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'')
|
|
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});
|
|
Binary file
|
|
Binary file
|
package/skills/research/extract-url/tests/__pycache__/test_config.cpython-314-pytest-9.0.2.pyc
CHANGED
|
Binary file
|
package/skills/research/extract-url/tests/__pycache__/test_dedup_check.cpython-314-pytest-9.0.2.pyc
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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’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."""
|