skills-vault/skills/clip-web-article/scripts/clip_web_article.py

672 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Clip one visible web article into a lightweight Markdown folder."""
from __future__ import annotations
import argparse
import html
import json
import os
import re
import shutil
import subprocess
import sys
import time
import urllib.parse
import urllib.request
from datetime import date, datetime
from pathlib import Path
from typing import Any, NamedTuple
DEFAULT_OUTPUT_ROOT = Path(
r"C:\Users\wangq\Documents\Codex\knowledge-vault\sources\clipped-articles\文章参考"
)
DEFAULT_WORK_ROOT = Path(r"C:\tmp\clip-web-article")
INVALID_WINDOWS_CHARS_RE = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
IMAGE_RE = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)")
METADATA_RE = re.compile(r"^>\s*(作者|公众号|发布时间|原文链接|author|published|publish time|source url)\s*[:]", re.I)
DATE_RE = re.compile(r"(\d{4})[-/年.](\d{1,2})[-/月.](\d{1,2})")
class ClipError(RuntimeError):
pass
class ClipResult(NamedTuple):
title: str
output_dir: Path
markdown_path: Path
image_count: int
language: str
class ClipBatchItem(NamedTuple):
url: str
result: ClipResult | None
error: str
class OpenCliPlan(NamedTuple):
kind: str
command: list[str]
output_dir: Path
def opencli_binary() -> str:
return "opencli.cmd" if os.name == "nt" else "opencli"
def release_tab_args() -> list[str]:
return ["--keep-tab", "false"]
def sanitize_windows_name(text: str, max_len: int = 80) -> str:
value = html.unescape(text or "").replace("\ufeff", "")
value = INVALID_WINDOWS_CHARS_RE.sub("", value)
value = re.sub(r"\s+", " ", value).strip().rstrip(". ")
if len(value) > max_len:
value = value[:max_len].rstrip(". ")
return value or "untitled"
def compact_title(text: str, max_len: int = 24) -> str:
safe = sanitize_windows_name(text, max_len=max_len)
safe = re.sub(r"\s+", "", safe)
return safe[:max_len] or "untitled"
def short_file_stem(text: str, max_len: int = 32) -> str:
return sanitize_windows_name(text, max_len=max_len)
def short_folder_name(title: str, article_date: date, max_len: int = 30) -> str:
title_part = compact_title(title, max_len=12)
folder = f"{article_date:%Y%m%d}-{title_part}"
return sanitize_windows_name(folder, max_len=max_len)
def unique_dir(parent: Path, name: str) -> Path:
candidate = parent / name
if not candidate.exists():
return candidate
index = 2
while True:
candidate = parent / f"{name}-{index}"
if not candidate.exists():
return candidate
index += 1
def parse_date_from_text(text: str) -> date | None:
match = DATE_RE.search(text or "")
if not match:
return None
year, month, day = (int(part) for part in match.groups())
try:
return date(year, month, day)
except ValueError:
return None
def extract_title(markdown: str, fallback: str = "untitled") -> str:
for line in markdown.splitlines():
stripped = line.strip()
if stripped.startswith("# "):
return stripped[2:].strip() or fallback
return fallback
def extract_header_date(markdown: str) -> date | None:
for line in markdown.splitlines()[:20]:
if line.lstrip().startswith(">"):
parsed = parse_date_from_text(line)
if parsed:
return parsed
return None
def tidy_markdown(markdown: str) -> str:
text = markdown.replace("\u200b", "").replace("\ufeff", "")
lines = [line.rstrip() for line in text.splitlines()]
text = "\n".join(lines).strip()
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip() + ("\n" if text.strip() else "")
def strip_zhihu_pin_page_chrome(title: str, body: str) -> str:
lines = body.splitlines()
title_key = re.split(r"[|]", title, maxsplit=1)[0].strip()
start = 0
if title_key:
for index, line in enumerate(lines):
stripped = line.strip()
if stripped and title_key in stripped and not stripped.startswith(("[", "!", ">")):
start = index + 1
break
kept: list[str] = []
for line in lines[start:]:
stripped = line.strip()
if "编辑于" in stripped and "zhihu.com/pin/" in stripped:
break
if "IP 属地" in stripped and "编辑于" in stripped:
break
if re.fullmatch(r"\+\d+", stripped):
continue
if stripped in {"默认", "最新", "理性发言,友善互动"}:
continue
kept.append(line)
return tidy_markdown("\n".join(kept))
def clean_opencli_markdown(markdown: str, source_kind: str = "generic") -> str:
lines = markdown.replace("\r\n", "\n").replace("\r", "\n").split("\n")
if not lines:
return ""
title_line = ""
body_start = 0
for index, line in enumerate(lines):
if line.strip().startswith("# "):
title_line = line.strip()
body_start = index + 1
break
if not title_line:
title_line = "# untitled"
while body_start < len(lines):
stripped = lines[body_start].strip()
if not stripped or stripped == "---" or METADATA_RE.match(stripped):
body_start += 1
continue
break
body = "\n".join(lines[body_start:]).strip()
title = title_line[2:].strip() if title_line.startswith("# ") else title_line.lstrip("# ").strip()
if source_kind == "zhihu-pin":
body = strip_zhihu_pin_page_chrome(title, body).strip()
else:
body = tidy_markdown(body).strip()
return f"{title_line}\n\n{body}\n" if body else f"{title_line}\n"
def image_extension_from_url(url: str, default: str = ".jpg") -> str:
parsed = urllib.parse.urlparse(url)
suffix = Path(parsed.path).suffix.lower()
if suffix in {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".avif"}:
return suffix
query = urllib.parse.parse_qs(parsed.query)
fmt = (query.get("wx_fmt") or query.get("format") or [""])[0].lower()
if fmt in {"jpg", "jpeg", "png", "gif", "webp", "bmp", "avif"}:
return f".{fmt}"
return default
def download_remote_image(url: str, target: Path, referer: str = "") -> bool:
headers = {"User-Agent": "Mozilla/5.0"}
if referer:
headers["Referer"] = referer
try:
request = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(request, timeout=20) as response:
target.write_bytes(response.read())
return target.exists() and target.stat().st_size > 0
except Exception:
return False
def localize_markdown_images(markdown: str, source_base: Path, output_dir: Path, referer: str = "") -> tuple[str, int]:
replacements: dict[str, str] = {}
image_count = 0
for _alt, raw_url in IMAGE_RE.findall(markdown):
if raw_url in replacements:
continue
if raw_url.startswith("data:"):
continue
parsed = urllib.parse.urlparse(raw_url)
local_source: Path | None = None
ext = ".jpg"
if parsed.scheme in {"http", "https"}:
ext = image_extension_from_url(raw_url)
elif parsed.scheme == "file":
local_source = Path(urllib.request.url2pathname(parsed.path))
ext = local_source.suffix or ".jpg"
else:
local_source = (source_base / urllib.parse.unquote(raw_url)).resolve()
ext = local_source.suffix or ".jpg"
target_name = f"img_{image_count + 1}{ext}"
target = output_dir / target_name
copied = False
if local_source is not None:
try:
if local_source.exists() and local_source.is_file():
shutil.copy2(local_source, target)
copied = True
except Exception:
copied = False
else:
copied = download_remote_image(raw_url, target, referer=referer)
if copied:
image_count += 1
replacements[raw_url] = target_name
def replace(match: re.Match[str]) -> str:
alt = match.group(1)
raw = match.group(2)
return f"![{alt}]({replacements[raw]})" if raw in replacements else match.group(0)
return IMAGE_RE.sub(replace, markdown), image_count
def find_primary_markdown(opencli_dir: Path) -> Path:
candidates = [path for path in opencli_dir.rglob("*.md") if path.is_file()]
if not candidates:
raise ClipError(f"No Markdown file found under OpenCLI output: {opencli_dir}")
return max(candidates, key=lambda p: p.stat().st_size)
def normalize_opencli_article(
opencli_dir: Path,
output_root: Path,
capture_date: date | None = None,
source_kind: str = "generic",
) -> ClipResult:
capture_date = capture_date or date.today()
markdown_path = find_primary_markdown(Path(opencli_dir))
raw_markdown = markdown_path.read_text(encoding="utf-8")
title = extract_title(raw_markdown, fallback=markdown_path.stem)
article_date = extract_header_date(raw_markdown)
if article_date is None and source_kind == "zhihu-pin":
article_date = parse_date_from_text(raw_markdown)
article_date = article_date or capture_date
folder_name = short_folder_name(title, article_date)
output_dir = unique_dir(Path(output_root), folder_name)
output_dir.mkdir(parents=True, exist_ok=False)
cleaned = clean_opencli_markdown(raw_markdown, source_kind=source_kind)
localized, image_count = localize_markdown_images(cleaned, markdown_path.parent, output_dir)
localized = tidy_markdown(localized)
output_md = output_dir / f"{short_file_stem(title)}.md"
output_md.write_text(localized, encoding="utf-8")
return ClipResult(title, output_dir, output_md, image_count, detect_language(localized))
def normalize_media_urls(value: Any) -> list[str]:
if not value:
return []
if isinstance(value, list):
return [str(item) for item in value if str(item).strip()]
if isinstance(value, str):
parts = re.split(r"[\n,]+", value)
return [part.strip() for part in parts if part.strip().startswith(("http://", "https://"))]
return []
def detect_language(text: str) -> str:
clean = re.sub(r"!\[[^\]]*\]\([^)]+\)|https?://\S+|[#*>`~_\-|]", "", text)
cjk = len(re.findall(r"[\u4e00-\u9fff]", clean))
latin = len(re.findall(r"[A-Za-z]", clean))
if cjk and latin:
return "mixed"
if cjk:
return "chinese"
if latin:
return "english"
return "unknown"
def title_from_text(text: str, fallback: str = "tweet") -> str:
normalized = re.sub(r"\s+", " ", text or "").strip()
return normalized[:40].rstrip(".,;:!? ") or fallback
def extract_zhihu_answer_id(url: str) -> str:
match = re.search(r"/answer/(\d+)", url)
return match.group(1) if match else ""
def extract_twitter_status_id(url: str) -> str:
match = re.search(r"/status/(\d+)", url)
if match:
return match.group(1)
return url if re.fullmatch(r"\d+", url) else ""
def parse_iso_date(value: str) -> date | None:
if not value:
return None
try:
return datetime.fromisoformat(value.replace("Z", "+00:00")).date()
except ValueError:
pass
try:
return datetime.strptime(value, "%a %b %d %H:%M:%S %z %Y").date()
except ValueError:
return parse_date_from_text(value)
def save_zhihu_answer_rows(rows: list[dict[str, Any]], output_root: Path, capture_date: date | None = None) -> ClipResult:
capture_date = capture_date or date.today()
if not rows:
raise ClipError("Zhihu did not return the requested answer")
row = rows[0]
content = tidy_markdown(str(row.get("content", "")).strip())
if not content:
raise ClipError("Zhihu answer-detail returned no readable content")
title = str(row.get("question_title") or "zhihu-answer").strip()
article_date = parse_iso_date(str(row.get("created_at", ""))) or capture_date
output_dir = unique_dir(Path(output_root), short_folder_name(title, article_date))
output_dir.mkdir(parents=True, exist_ok=False)
markdown = tidy_markdown(f"# {title}\n\n{content}")
output_md = output_dir / f"{short_file_stem(title)}.md"
output_md.write_text(markdown, encoding="utf-8")
return ClipResult(title, output_dir, output_md, 0, detect_language(markdown))
def save_twitter_rows(rows: list[dict[str, Any]], output_root: Path, url: str, capture_date: date | None = None) -> ClipResult:
capture_date = capture_date or date.today()
target_id = extract_twitter_status_id(url)
useful_rows = [row for row in rows if str(row.get("text", "")).strip()]
if target_id:
useful_rows = [row for row in useful_rows if str(row.get("id", "")) == target_id]
elif useful_rows:
useful_rows = [useful_rows[0]]
if not useful_rows:
raise ClipError("Twitter/X did not return readable tweet text")
title = title_from_text(str(useful_rows[0].get("text", "")), fallback="tweet")
article_date = parse_iso_date(str(useful_rows[0].get("created_at", ""))) or capture_date
output_dir = unique_dir(Path(output_root), short_folder_name(title, article_date))
output_dir.mkdir(parents=True, exist_ok=False)
image_count = 0
parts = [f"# {title}", ""]
for row in useful_rows:
text = str(row.get("text", "")).strip()
parts.append(text)
for media_url in normalize_media_urls(row.get("media_urls")):
ext = image_extension_from_url(media_url)
target_name = f"img_{image_count + 1}{ext}"
if download_remote_image(media_url, output_dir / target_name, referer=url):
image_count += 1
parts.append("")
parts.append(f"![]({target_name})")
else:
parts.append("")
parts.append(f"![]({media_url})")
parts.append("")
markdown = tidy_markdown("\n".join(parts))
output_md = output_dir / f"{short_file_stem(title)}.md"
output_md.write_text(markdown, encoding="utf-8")
return ClipResult(title, output_dir, output_md, image_count, detect_language(markdown))
def plan_opencli_invocation(url: str, work_dir: Path) -> OpenCliPlan:
parsed = urllib.parse.urlparse(url)
host = parsed.hostname.lower() if parsed.hostname else ""
output_dir = Path(work_dir) / "opencli-output"
base = opencli_binary()
if host in {"www.zhihu.com", "zhihu.com"} or host.endswith(".zhihu.com"):
answer_id = extract_zhihu_answer_id(url)
if answer_id:
return OpenCliPlan(
"opencli-zhihu-answer-detail",
[base, "zhihu", "answer-detail", answer_id, *release_tab_args(), "-f", "json"],
output_dir,
)
if parsed.path.startswith("/pin/"):
return OpenCliPlan(
"opencli-zhihu-pin-web-read",
[
base,
"web",
"read",
"--url",
url,
"--output",
str(output_dir),
"--download-images",
"true",
"--wait",
"5",
*release_tab_args(),
"-f",
"json",
],
output_dir,
)
if host == "mp.weixin.qq.com":
return OpenCliPlan(
"opencli-download",
[
base,
"weixin",
"download",
"--url",
url,
"--output",
str(output_dir),
"--download-images",
"true",
*release_tab_args(),
"-f",
"json",
],
output_dir,
)
if host in {"x.com", "twitter.com"} or host.endswith(".x.com") or host.endswith(".twitter.com"):
return OpenCliPlan(
"opencli-twitter-thread",
[base, "twitter", "thread", url, "--limit", "50", *release_tab_args(), "-f", "json"],
output_dir,
)
if host == "zhuanlan.zhihu.com":
return OpenCliPlan(
"opencli-download",
[
base,
"zhihu",
"download",
"--url",
url,
"--output",
str(output_dir),
"--download-images",
"true",
*release_tab_args(),
"-f",
"json",
],
output_dir,
)
return OpenCliPlan(
"opencli-web-read",
[
base,
"web",
"read",
"--url",
url,
"--output",
str(output_dir),
"--download-images",
"true",
"--wait",
"5",
*release_tab_args(),
"-f",
"json",
],
output_dir,
)
def parse_opencli_json(stdout: str) -> Any:
text = stdout.strip()
if not text:
return None
try:
return json.loads(text)
except json.JSONDecodeError:
pass
starts = [pos for pos in (text.find("["), text.find("{")) if pos >= 0]
if not starts:
return None
start = min(starts)
end = max(text.rfind("]"), text.rfind("}"))
if end <= start:
return None
return json.loads(text[start : end + 1])
def rows_from_json(value: Any) -> list[dict[str, Any]]:
if value is None:
return []
if isinstance(value, list):
return [row for row in value if isinstance(row, dict)]
if isinstance(value, dict):
for key in ("rows", "data", "result", "items"):
nested = value.get(key)
if isinstance(nested, list):
return [row for row in nested if isinstance(row, dict)]
return []
def format_opencli_failure(returncode: int, stderr: str, stdout: str) -> str:
combined = "\n".join(part for part in [stderr, stdout] if part).strip()
lowered = combined.lower()
if "exitcode: 69" in lowered or "browser bridge" in lowered:
return "OpenCLI Browser Bridge extension not connected. Run opencli doctor, connect the extension, then retry."
if "exitcode: 77" in lowered or "auth required" in lowered or "not logged" in lowered:
return "OpenCLI needs a logged-in browser session for this page. Open the page in the connected browser, then retry."
if "verification" in lowered or "captcha" in lowered:
return "the page requires verification. Complete it in the connected browser, then retry."
lines = combined.splitlines()
return lines[-1].strip() if lines else f"exit {returncode}"
def run_opencli(plan: OpenCliPlan, timeout: int = 240) -> Any:
completed = subprocess.run(
plan.command,
text=True,
encoding="utf-8",
errors="replace",
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=timeout,
)
if completed.returncode != 0:
raise ClipError(f"OpenCLI failed: {format_opencli_failure(completed.returncode, completed.stderr, completed.stdout)}")
return parse_opencli_json(completed.stdout)
def clip_url(url: str, output_root: Path, work_dir: Path, capture_date: date | None = None) -> ClipResult:
plan = plan_opencli_invocation(url, work_dir)
work_dir.mkdir(parents=True, exist_ok=True)
data = run_opencli(plan)
if plan.kind == "opencli-zhihu-answer-detail":
rows = rows_from_json(data)
return save_zhihu_answer_rows(rows, output_root, capture_date=capture_date)
if plan.kind == "opencli-twitter-thread":
rows = rows_from_json(data)
return save_twitter_rows(rows, output_root, url, capture_date=capture_date)
source_kind = "zhihu-pin" if plan.kind == "opencli-zhihu-pin-web-read" else "generic"
return normalize_opencli_article(plan.output_dir, output_root, capture_date=capture_date, source_kind=source_kind)
def batch_work_dir(work_root: Path, index: int, total: int) -> Path:
return Path(work_root) if total == 1 else Path(work_root) / f"{index:03d}"
def clip_urls(
urls: list[str],
output_root: Path,
work_root: Path,
keep_work: bool = False,
capture_date: date | None = None,
) -> list[ClipBatchItem]:
items: list[ClipBatchItem] = []
total = len(urls)
for index, url in enumerate(urls, start=1):
work_dir = batch_work_dir(work_root, index, total)
try:
result = clip_url(url, output_root, work_dir, capture_date=capture_date)
items.append(ClipBatchItem(url, result, ""))
except ClipError as exc:
items.append(ClipBatchItem(url, None, str(exc)))
finally:
if not keep_work and work_dir.exists():
shutil.rmtree(work_dir, ignore_errors=True)
if not keep_work and total > 1 and Path(work_root).exists():
try:
Path(work_root).rmdir()
except OSError:
pass
return items
def make_work_dir(root: Path) -> Path:
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
return root / f"{stamp}-{os.getpid()}"
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Clip visible web article URL(s) to Markdown plus local images.")
parser.add_argument("urls", nargs="+", help="One or more article, Zhihu, WeChat, X/Twitter, or ordinary web URLs")
parser.add_argument("--output-root", default=str(DEFAULT_OUTPUT_ROOT), help="Final clipped article output root")
parser.add_argument("--work-dir", default="", help="Temporary OpenCLI output directory")
parser.add_argument("--keep-work", action="store_true", help="Keep temporary OpenCLI output for debugging")
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
output_root = Path(args.output_root)
work_root = Path(args.work_dir) if args.work_dir else make_work_dir(DEFAULT_WORK_ROOT)
items = clip_urls(args.urls, output_root, work_root, keep_work=args.keep_work)
failures = 0
for index, item in enumerate(items, start=1):
if len(items) > 1:
print(f"[{index}/{len(items)}] {item.url}")
if item.error or item.result is None:
failures += 1
print(f"未能抓取正文。原因: {item.error}")
print("建议: 在已登录浏览器中打开该页面后重试,或手动提供正文。")
print()
continue
result = item.result
print("已保存到:")
print(result.output_dir)
print()
print(f"Markdown: {result.markdown_path.name}")
print(f"Images: {result.image_count}")
if result.language == "english":
print("Language: English (按 Skill 规则保留英文原文,并生成同目录中文译文)")
elif result.language != "unknown":
print(f"Language: {result.language}")
print()
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())