Files
nanocode/personal-blog/backend/routers/wechat_crawl.py
T
Ubuntu 32b43f85bd a
2026-07-03 16:58:28 +08:00

475 lines
15 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.
"""
微信公众号文章采集路由
使用 Playwright + Stealth 绕过反爬虫
支持 Markdown 转换、图片下载、预览编辑、确认入库
"""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
from typing import Optional, List
import httpx
import re
import os
import uuid
import asyncio
from urllib.parse import urlparse
from datetime import datetime
from bs4 import BeautifulSoup, NavigableString
from database import get_db
from models import Post, Tag, Category
from schemas import PostResponse
from routers.auth import get_current_admin
try:
from playwright.async_api import async_playwright
PLAYWRIGHT_AVAILABLE = True
except ImportError:
PLAYWRIGHT_AVAILABLE = False
print("[WeChat] Playwright not available")
try:
from playwright_stealth import stealth_async
STEALTH_AVAILABLE = True
except ImportError:
STEALTH_AVAILABLE = False
print("[WeChat] Stealth not available")
router = APIRouter(prefix="/api/wechat-crawl", tags=["wechat-crawl"])
UPLOADS_DIR = os.path.join(os.path.dirname(__file__), "..", "uploads")
os.makedirs(UPLOADS_DIR, exist_ok=True)
os.makedirs(os.path.join(UPLOADS_DIR, "thumbnails"), exist_ok=True)
class WechatCrawlRequest(BaseModel):
url: str
class WechatSaveRequest(BaseModel):
title: str
content: str
summary: Optional[str] = ""
cover_image: Optional[str] = ""
category_id: Optional[int] = None
tag_names: Optional[List[str]] = []
source_url: Optional[str] = ""
author: Optional[str] = ""
is_draft: Optional[bool] = False
# ============================================================
# 辅助函数
# ============================================================
def is_wechat_url(url: str) -> bool:
return "mp.weixin.qq.com" in url.lower()
def generate_slug(title: str, suffix: str = "") -> str:
slug = re.sub(r'[^\w\u4e00-\u9fa5]+', '-', title.strip())
slug = slug.strip('-').lower()
if len(slug) > 80:
slug = slug[:80]
if not slug:
slug = "wechat-article"
if suffix:
slug = f"{slug}-{suffix}"
return slug
def remove_empty_paragraphs(soup):
"""移除空的 p 标签"""
for p in soup.find_all('p'):
txt = p.get_text(strip=True)
if not txt:
imgs = p.find_all('img')
if not imgs:
p.decompose()
return soup
def fix_wechat_code_blocks(soup):
"""
修复微信代码块:
<pre class="code-snippet__js"> 里的 <code> 下直接是文本
或多个 <span class="code-snippet__line">...
按行拆成多个 code block 以便 markdownify 正确处理
"""
for pre in soup.find_all('pre', class_=re.compile('code-snippet')):
code_tag = pre.find('code')
if not code_tag:
continue
# 收集纯文本
lines = []
for span in code_tag.find_all('span', class_='code-snippet__line'):
line_text = span.get_text(separator='', strip=False)
if line_text:
lines.append(line_text)
if lines:
code_tag.clear()
for line in lines:
code_tag.append(NavigableString(line + '\n'))
return soup
def fix_wechat_images(soup):
"""
修复微信图片:
微信文章使用 data-src 存放真实图片URL,src 往往是 svg 占位图。
把 data-src 复制到 src,让 markdownify 正确识别图片。
"""
for img in soup.find_all('img'):
data_src = img.get('data-src', '')
src = img.get('src', '')
# data-src 是真实微信图片URL,且 src 不是有效的 http URL
if data_src and data_src.startswith('http') and (
not src or src.startswith('data:image') or not src.startswith('http')
):
img['src'] = data_src
# 清理无意义的占位属性
if 'data-src' in img.attrs:
del img['data-src']
return soup
def convert_html_to_markdown(html: str) -> str:
"""使用 markdownify 将 HTML 转为 Markdown"""
try:
import markdownify
except ImportError:
# 降级:用 BeautifulSoup 提取纯文本
soup = BeautifulSoup(html, 'html.parser')
return soup.get_text(separator='\n\n', strip=True)
soup = BeautifulSoup(html, 'html.parser')
soup = fix_wechat_images(soup)
soup = fix_wechat_code_blocks(soup)
soup = remove_empty_paragraphs(soup)
# markdownify 配置
md = markdownify.markdownify(
str(soup),
heading_style="ATX",
bullets="-",
strip=['script', 'style', 'iframe', 'noscript']
)
# 清理多余空行
md = re.sub(r'\n{3,}', '\n\n', md)
return md.strip()
def generate_filename(original_url: str, article_id: str = "") -> str:
"""生成唯一文件名"""
ext = os.path.splitext(urlparse(original_url).path)[1].lower()
if ext not in ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp']:
ext = '.jpg'
uid = str(uuid.uuid4())[:8]
return f"wechat_{article_id}_{uid}{ext}"
async def download_image(url: str, filepath: str, headers: dict) -> bool:
"""下载单张图片"""
try:
async with httpx.AsyncClient(timeout=30) as client:
r = await client.get(url, headers=headers, follow_redirects=True)
if r.status_code == 200:
with open(filepath, 'wb') as f:
f.write(r.content)
return True
except Exception as e:
print(f"[WeChat] 下载图片失败 {url}: {e}")
return False
async def download_all_images(image_tasks: list, headers: dict):
"""并发下载所有图片"""
async def task_wrapper(task):
url, filepath = task
success = await download_image(url, filepath, headers)
return url, filepath, success
return await asyncio.gather(*(task_wrapper(t) for t in image_tasks))
# ============================================================
# 核心采集函数
# ============================================================
async def crawl_wechat_article(url: str) -> dict:
"""采集微信公众号文章"""
if not PLAYWRIGHT_AVAILABLE:
raise Exception("Playwright 未安装,无法采集微信文章")
async with async_playwright() as p:
browser = await p.chromium.launch(
headless=True,
args=[
'--disable-blink-features=AutomationControlled',
'--disable-web-security',
'--disable-features=IsolateOrigins,site-per-process',
'--disable-dev-shm-usage',
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-accelerated-2d-canvas',
'--disable-gpu',
'--window-size=1920,1080',
]
)
context = await browser.new_context(
viewport={'width': 1920, 'height': 1080},
user_agent='Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
locale='zh-CN',
timezone_id='Asia/Shanghai',
java_script_enabled=True,
)
await context.add_init_script("""
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
Object.defineProperty(navigator, 'plugins', { get: () => [1,2,3,4,5] });
window.chrome = { runtime: {} };
""")
page = await context.new_page()
if STEALTH_AVAILABLE:
await stealth_async(page)
try:
print(f"[WeChat] 访问: {url}")
await page.goto(url, wait_until="networkidle", timeout=60000)
await page.wait_for_timeout(3000)
# 获取标题
title = ""
try:
title = await page.title()
if title in ["微信公众平台", ""]:
h2 = await page.query_selector("#activity_name")
if h2:
title = await h2.inner_text()
except:
pass
title = title.strip() or "未命名文章"
# 获取公众号名称
author = ""
try:
name_el = await page.query_selector("#js_name")
if name_el:
author = await name_el.inner_text()
author = author.strip()
except:
pass
# 获取正文 HTML
content_html = ""
try:
content_el = await page.query_selector("#js_content")
if content_el:
content_html = await content_el.inner_html()
except:
pass
# 获取封面图
cover_image = ""
try:
meta = await page.query_selector('meta[property="og:image"]')
if meta:
cover_image = await meta.get_attribute("content")
except:
pass
# 获取正文中的图片
images = []
if content_html:
soup = BeautifulSoup(content_html, 'html.parser')
for img in soup.find_all('img'):
src = img.get('data-src') or img.get('src', '')
if src and ('mmbiz.qpic.cn' in src or 'mmbiz.qlogo.cn' in src):
images.append({
'original_url': src,
'description': img.get('alt', '')
})
print(f"[WeChat] 标题: {title[:50]}")
print(f"[WeChat] 作者: {author}")
print(f"[WeChat] 内容长度: {len(content_html)}")
print(f"[WeChat] 图片数: {len(images)}")
return {
"title": title,
"content_html": content_html,
"author": author,
"cover_image": cover_image,
"images": images,
}
finally:
await browser.close()
# ============================================================
# 图片下载处理
# ============================================================
async def process_images(images: list, article_id: str) -> dict:
"""下载图片并返回 URL 映射"""
if not images:
return {}
headers = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
"Referer": "https://mp.weixin.qq.com/",
}
tasks = []
url_map = {}
for img in images:
original_url = img['original_url']
filename = generate_filename(original_url, article_id)
filepath = os.path.join(UPLOADS_DIR, filename)
tasks.append((original_url, filepath))
url_map[original_url] = f"/uploads/{filename}"
results = await download_all_images(tasks, headers)
# 过滤失败的
for original_url, filepath, success in results:
if not success and os.path.exists(filepath):
os.remove(filepath)
if not success:
# 保留原 URL
url_map[original_url] = original_url
return url_map
# ============================================================
# API 端点
# ============================================================
@router.post("/preview")
async def preview_wechat(request: WechatCrawlRequest):
"""第1步:采集并返回预览数据"""
url = request.url.strip()
if not url:
raise HTTPException(status_code=400, detail="URL 不能为空")
if not is_wechat_url(url):
raise HTTPException(status_code=400, detail="不是微信公众号文章链接")
try:
result = await crawl_wechat_article(url)
except Exception as e:
raise HTTPException(status_code=500, detail=f"采集失败: {str(e)}")
if not result['content_html']:
raise HTTPException(status_code=500, detail="未能获取到文章内容,可能需要等待页面加载或文章已被删除")
# 转换为 Markdown(预览阶段不下载图片,保留原始 URL)
md_content = convert_html_to_markdown(result['content_html'])
# 提取摘要(前200字符)
plain = BeautifulSoup(result['content_html'], 'html.parser').get_text(strip=True)
summary = plain[:200] if len(plain) > 200 else plain
return {
"title": result['title'],
"content_md": md_content,
"summary": summary,
"author": result['author'],
"cover_image": result['cover_image'],
"images": result['images'],
"source_url": url,
}
@router.post("/save")
async def save_wechat(request: WechatSaveRequest, db: Session = Depends(get_db), current_admin = Depends(get_current_admin)):
"""第2步:确认入库(下载图片、替换URL、保存文章)"""
# 生成唯一 article_id 用于图片命名
article_id = str(uuid.uuid4())[:8]
# 从 content 中提取所有微信图片 URL
image_urls = re.findall(
r'https?://mmbiz\.(?:qpic|qlogo)\.cn/[^\s\)"\'>\]]+',
request.content
)
# 去重
unique_urls = list(set(image_urls))
images_info = [{'original_url': u, 'description': ''} for u in unique_urls]
# 下载图片
url_map = await process_images(images_info, article_id)
# 替换 content 中的图片 URL
final_content = request.content
for original_url, local_url in url_map.items():
final_content = final_content.replace(original_url, local_url)
# 替换封面图
final_cover = request.cover_image
if final_cover and 'mmbiz' in final_cover:
# 封面图也下载
cover_filename = generate_filename(final_cover, article_id)
cover_filepath = os.path.join(UPLOADS_DIR, cover_filename)
headers = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
"Referer": "https://mp.weixin.qq.com/",
}
success = await download_image(final_cover, cover_filepath, headers)
if success:
final_cover = f"/uploads/{cover_filename}"
# 生成 slug
base_slug = generate_slug(request.title, article_id)
slug = base_slug
counter = 1
while db.query(Post).filter(Post.slug == slug).first():
slug = f"{base_slug}-{counter}"
counter += 1
# 创建文章
is_draft = request.is_draft if request.is_draft is not None else False
post = Post(
title=request.title,
slug=slug,
content=final_content,
summary=request.summary or "",
cover_image=final_cover or "",
is_published=not is_draft,
is_draft=is_draft,
)
# 分类
if request.category_id:
category = db.query(Category).filter(Category.id == request.category_id).first()
if category:
post.category = category
# 标签
if request.tag_names:
for tag_name in request.tag_names:
tag_name = tag_name.strip()
if not tag_name:
continue
tag = db.query(Tag).filter(Tag.name == tag_name).first()
if not tag:
tag = Tag(name=tag_name)
db.add(tag)
db.flush()
post.tags.append(tag)
db.add(post)
db.commit()
db.refresh(post)
return {
"success": True,
"message": "文章已保存",
"post": {
"id": post.id,
"title": post.title,
"slug": post.slug,
}
}