Files
2026-10-31 14:33:07 +08:00

213 lines
9.8 KiB
Python
Raw Permalink 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
"""将探数新闻正文保存为与微信下载器相同的 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'<svg' in data[:1024].lower() and mime.split(';')[0] == 'image/svg+xml':
return '.svg'
raise ArticleDownloadError(f'返回内容不是支持的图片(Content-Type: {mime or "未知"})')
def _image_source(img) -> 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('<article>' + raw_content + '</article>', '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)