#!/usr/bin/env python3 """将探数新闻正文保存为与微信下载器相同的 Markdown 目录格式。""" from __future__ import annotations import base64 from dataclasses import asdict, dataclass from pathlib import Path import re import shutil import tempfile from urllib.parse import unquote, urljoin, urlsplit import requests from bs4 import BeautifulSoup from markdownify import markdownify from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry class ArticleDownloadError(RuntimeError): """页面不是文章、图片下载失败等可向调用方报告的错误。""" @dataclass class ArticleResult: title: str source_url: str directory: str markdown: str images: list[str] warnings: list[str] def to_dict(self) -> dict: return asdict(self) def safe_name(title: str) -> str: """跨平台目录/文件名;目录名与其中 Markdown 的文件名保持一致。""" name = re.sub(r'[<>:"/\\|?*\x00-\x1f\x7f]', '_', title) name = re.sub(r'\s+', ' ', name).strip(' .') # 同时限制 UTF-8 字节数,给 .md 和重名后缀保留空间。 name = name.encode('utf-8')[:180].decode('utf-8', errors='ignore').rstrip(' .') if not name: name = '未命名文章' if re.fullmatch(r'(?i:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\..*)?', name): name = '_' + name return name def _session() -> requests.Session: session = requests.Session() session.headers.update({ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ' 'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36', 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', }) retry = Retry(total=3, backoff_factor=0.6, status_forcelist=[429, 500, 502, 503, 504], allowed_methods=['GET']) session.mount('https://', HTTPAdapter(max_retries=retry)) session.mount('http://', HTTPAdapter(max_retries=retry)) return session def _get(session: requests.Session, url: str, timeout: float, limit: int, referer: str | None = None) -> tuple[bytes, str, str]: if urlsplit(url).scheme not in ('http', 'https'): raise ArticleDownloadError(f'不支持的下载地址:{url[:200]}') headers = {'Referer': referer} if referer else {} with session.get(url, timeout=(min(timeout, 15), timeout), headers=headers, stream=True) as response: response.raise_for_status() chunks, size = [], 0 for chunk in response.iter_content(65536): size += len(chunk) if size > limit: raise ArticleDownloadError(f'文件超过大小限制({limit // 1024 // 1024} MB):{url[:200]}') chunks.append(chunk) return b''.join(chunks), response.headers.get('Content-Type', ''), response.url def _image_ext(data: bytes, mime: str) -> str: if data.startswith(b'\xff\xd8\xff'): return '.jpg' if data.startswith(b'\x89PNG\r\n\x1a\n'): return '.png' if data.startswith((b'GIF87a', b'GIF89a')): return '.gif' if data[:4] == b'RIFF' and data[8:12] == b'WEBP': return '.webp' if len(data) >= 12 and data[4:8] == b'ftyp' and data[8:12] in (b'avif', b'avis'): return '.avif' if b' str: for attr in ('data-src', 'data-original', 'data-actualsrc', 'data-lazy-src', 'src'): value = img.get(attr) if value and str(value).strip(): return str(value).strip() srcset = img.get('data-srcset') or img.get('srcset') if srcset: return str(srcset).split(',')[-1].strip().split()[0] return '' def save_article(article: dict, output_root: str | Path = '.', *, timeout: float = 30, allow_missing_images: bool = False) -> ArticleResult: """从 API 正文保存文章;图片失败时清理半成品,不覆盖已有同名目录。""" url = str(article.get('weburl') or article.get('url') or '') title = str(article.get('title') or '').strip() raw_content = str(article.get('content') or '').strip() if not title or not raw_content: raise ArticleDownloadError('新闻标题或正文为空') if timeout <= 0: raise ValueError('timeout 必须大于 0') with _session() as session: final_url = url soup = BeautifulSoup('
' + raw_content + '
', 'html.parser') content = soup.article for tag in content.select('script, style, noscript, iframe, form, button'): tag.decompose() # 部分微信排版把插图放在行内 CSS 背景中,也转为普通图片参与下载。 for tag in list(content.find_all(style=True)): for match in re.finditer(r'url\(\s*[\'\"]?(.*?)[\'\"]?\s*\)', str(tag['style'])): source = match.group(1).strip() if source and not source.startswith('#'): background = soup.new_tag('img', src=source, alt='') tag.insert(0, background) if not content.get_text(strip=True) and not content.find('img'): raise ArticleDownloadError('文章正文为空,可能需要浏览器执行 JavaScript') for link in content.find_all('a', href=True): href = str(link['href']).strip() if href and not href.startswith('#'): absolute = urljoin(final_url, href) if urlsplit(absolute).scheme in ('http', 'https', 'mailto', 'tel'): link['href'] = absolute else: del link['href'] root = Path(output_root).expanduser().resolve() root.mkdir(parents=True, exist_ok=True) stage = Path(tempfile.mkdtemp(prefix='.article-', dir=root)) images, warnings, downloaded = [], [], {} try: for img in list(content.find_all('img')): source = _image_source(img) if not source: img.replace_with(soup.new_string('[图片缺少下载地址]')) if not allow_missing_images: raise ArticleDownloadError('正文中存在没有下载地址的图片') warnings.append('正文中存在没有下载地址的图片') continue image_url = urljoin(final_url, source) try: if image_url not in downloaded: if source.startswith('data:image/'): header, payload = source.split(',', 1) data = base64.b64decode(payload, validate=True) if ';base64' in header else unquote(payload).encode() mime = header[5:].split(';')[0] if len(data) > 50 * 1024 * 1024: raise ArticleDownloadError('内嵌图片超过 50 MB') else: data, mime, _ = _get(session, image_url, timeout, 50 * 1024 * 1024, final_url) ext = _image_ext(data, mime) filename = f'image{len(images) + 1:03d}{ext}' (stage / filename).write_bytes(data) images.append(filename) downloaded[image_url] = filename # 清除懒加载/响应式属性,使转换器只引用本地文件。 alt = str(img.get('alt') or '') img.attrs = {'src': downloaded[image_url], 'alt': alt} except (requests.RequestException, ArticleDownloadError, ValueError) as exc: if not allow_missing_images: raise ArticleDownloadError(f'图片下载失败:{image_url[:200]};{exc}') from exc warnings.append(f'图片下载失败:{image_url[:200]};{exc}') img.replace_with(soup.new_string(f'[图片下载失败:{img.get("alt") or "未命名图片"}]')) # 新闻图片后常带空的图注标题,避免生成单独的 ## 和多余空行。 for heading in content.select('h1, h2, h3, h4, h5, h6'): if not heading.get_text(strip=True) and not heading.find('img'): heading.decompose() body = markdownify(str(content), heading_style='ATX', bullets='-', strip=['picture', 'source']).strip() # 微信富文本中各段常没有标准 p 标签;markdownify 仍会保留文字和图片顺序。 author = str(article.get('src') or '未知作者').strip() or '未知作者' text = f'# {title}\n\n' text += body + f'\n\n来源:{author},如有侵权,请联系删除。\n' basename = safe_name(title) # 原子认领新目录,支持多个进程并发保存同名文章。 index = 1 while True: name = basename if index == 1 else f'{basename} ({index})' destination = root / name try: destination.mkdir() break except FileExistsError: index += 1 try: (stage / f'{name}.md').write_text(text, encoding='utf-8') for item in stage.iterdir(): shutil.move(str(item), destination / item.name) except BaseException: shutil.rmtree(destination, ignore_errors=True) raise return ArticleResult(title, url, str(destination), str(destination / f'{name}.md'), [str(destination / x) for x in images], warnings) finally: shutil.rmtree(stage, ignore_errors=True)