Files
2026-04-29 17:52:50 +08:00

886 lines
34 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.
"""
Async WX Article Fetcher - 完全异步版本
"""
import random
import time
import base64
import re
import os
import asyncio
from datetime import datetime
from typing import Dict
from bs4 import BeautifulSoup
from .playwright_driver import PlaywrightController
from core.print import print_error, print_info, print_success, print_warning
from core.config import cfg
class WXArticleFetcher:
"""
异步微信公众号文章获取器
完全基于 async/await,与 FastAPI 完美兼容
"""
def __init__(self, wait_timeout: int = 10000):
"""初始化文章获取器"""
self.wait_timeout = wait_timeout
self.controller = PlaywrightController()
self.browser_proxy_url = ""
if cfg.get("proxy.enabled", False):
self.browser_proxy_url = cfg.get("proxy.http_url", "")
async def Close(self):
"""关闭浏览器(异步)"""
if self.controller:
await self.controller.Close()
async def get_article_content(self, url: str) -> Dict:
"""
获取文章内容(异步)
Args:
url: 文章URL
Returns:
文章信息字典,包含:
- id: 文章ID
- title: 标题
- author: 作者
- description: 描述
- topic_image: 题图
- publish_time: 发布时间戳
- content: 正文HTML
- mp_info: 公众号信息 {mp_name, logo, biz}
- mp_id: 公众号ID
- article_type: 文章类型 (0=图文, 5=视频, 7=音频, 10=贴图)
- fetch_error: 错误信息
"""
info = {
"id": self.extract_id_from_url(url),
"title": "",
"author": "",
"description": "",
"topic_image": "",
"publish_time": 0,
"content": "",
"mp_info": {
"mp_name": "",
"logo": "",
"biz": ""
},
"mp_id": "",
"article_type": 0, # 默认为图文类型
"fetch_error": ""
}
try:
# 使用异步上下文管理器
async with PlaywrightController(
proxy_url=self.browser_proxy_url,
mobile_mode=True
) as controller:
# 打开URL
success = await controller.open_url(url, timeout=self.wait_timeout)
if not success:
raise Exception("页面加载失败")
page = controller.page
# 等待页面加载
await asyncio.sleep(2)
# 获取页面内容
body = await page.content()
body_text = await page.locator("body").text_content()
# 检查各种异常情况
if "当前环境异常,完成验证后即可继续访问" in body_text:
info["content"] = ""
info["fetch_error"] = "当前环境异常,完成验证后即可继续访问"
return info
if "该内容已被发布者删除" in body_text or "The content has been deleted by the author." in body_text:
info["content"] = "DELETED"
info["fetch_error"] = "该内容已被发布者删除"
return info
if "内容审核中" in body_text:
info["content"] = "DELETED"
info["fetch_error"] = "内容审核中"
return info
if "该内容暂时无法查看" in body_text:
info["content"] = "DELETED"
info["fetch_error"] = "该内容暂时无法查看"
return info
if "违规无法查看" in body_text or "Unable to view this content because it violates regulation" in body_text:
info["content"] = "DELETED"
info["fetch_error"] = "违规无法查看"
return info
if "发送失败无法查看" in body_text:
info["content"] = "DELETED"
info["fetch_error"] = "发送失败无法查看"
return info
# 获取文章基本信息
title = await page.locator('meta[property="og:title"]').get_attribute("content")
author = await page.locator('meta[property="og:article:author"]').get_attribute("content")
description = await page.locator('meta[property="og:description"]').get_attribute("content")
topic_image = await page.locator('meta[property="twitter:image"]').get_attribute("content")
if not title:
title = await page.evaluate('() => document.title')
# 获取发布时间
publish_time = await self._extract_publish_time(page)
# 检测文章类型
article_type = await self._detect_article_type(page)
info["article_type"] = article_type
# 获取内容
content = await page.locator('#js_content').inner_html()
if not content:
content = await page.locator('#js_article').inner_html()
# 模拟滚动页面到底部,触发懒加载图片
try:
await self._scroll_to_bottom_and_load_images(page)
except Exception as e:
print_warning(f"滚动加载图片失败: {e}")
# 重新获取内容(滚动后可能有更多图片加载)
content = await page.locator('#js_content').inner_html()
if not content:
content = await page.locator('#js_article').inner_html()
content=Web.clean_article_content(str(content))
# 更新基本信息
info["title"] = title or ""
info["author"] = author or ""
info["description"] = description or ""
info["topic_image"] = topic_image or ""
info["publish_time"] = publish_time
info["content"] = content or ""
# 获取公众号信息
try:
# 获取公众号头像
logo_src = None
selectors = [
'#js_like_profile_bar .wx_follow_avatar img',
'#js_like_profile_bar img.wx_follow_avatar_pic',
'.wx_follow_avatar img'
]
for selector in selectors:
try:
ele_logo = page.locator(selector)
logo_src = await ele_logo.get_attribute('src', timeout=5000)
if logo_src:
print_success(f"使用选择器 {selector} 成功获取公众号头像")
break
except Exception:
continue
if not logo_src:
try:
logo_src = await page.locator('meta[property="og:image"]').get_attribute("content", timeout=3000)
except Exception:
pass
# 获取公众号名称
mp_name = None
try:
mp_name = await page.evaluate('() => { const el = document.getElementById("js_wx_follow_nickname"); return el ? el.textContent : null; }')
except Exception:
pass
if not mp_name:
try:
mp_name = await page.locator('meta[property="og:article:author"]').get_attribute("content", timeout=3000)
except Exception:
pass
# 获取biz
biz = None
try:
biz = await page.evaluate('() => window.biz')
except Exception:
pass
if not biz:
biz = self._extract_biz(url, content or "")
info["mp_info"] = {
"mp_name": mp_name or "未知公众号",
"logo": logo_src or "",
"biz": biz or ""
}
# 生成 mp_id
if biz:
try:
info["mp_id"] = "MP_WXS_" + base64.b64decode(biz).decode("utf-8")
except Exception:
info["mp_id"] = ""
except Exception as e:
print_error(f"获取公众号信息失败: {str(e)}")
info["mp_info"] = {
"mp_name": "未知公众号",
"logo": "",
"biz": ""
}
info["mp_id"] = ""
return info
except Exception as e:
info["fetch_error"] = str(e)
print_error(f"获取文章内容失败: {str(e)}")
return info
async def _scroll_to_bottom_and_load_images(self, page, scroll_step: int = 500, max_scrolls: int = 50, wait_time: int = 300):
"""
模拟滚动页面到底部并等待图片加载完成
Args:
page: Playwright page 对象
scroll_step: 每次滚动的像素
max_scrolls: 最大滚动次数
wait_time: 每次滚动后的等待时间(毫秒)
"""
try:
# 获取页面总高度
total_height = await page.evaluate('() => document.body.scrollHeight')
current_position = 0
print_info(f"开始滚动加载图片,页面总高度: {total_height}px")
scroll_count = 0
while current_position < total_height and scroll_count < max_scrolls:
# 滚动一段距离
current_position += scroll_step
await page.evaluate(f'() => window.scrollTo(0, {current_position})')
# 等待图片加载
await asyncio.sleep(wait_time / 1000)
# 更新页面总高度(可能因为懒加载而变化)
total_height = await page.evaluate('() => document.body.scrollHeight')
scroll_count += 1
# 每隔几次滚动输出进度
if scroll_count % 5 == 0:
print_info(f"滚动进度: {current_position}/{total_height}px ({scroll_count}次)")
# 滚动到顶部,然后再到底部确保所有图片加载
await page.evaluate('() => window.scrollTo(0, 0)')
await asyncio.sleep(0.5)
# 最后一次性滚动到底部
await page.evaluate('() => window.scrollTo(0, document.body.scrollHeight)')
await asyncio.sleep(1)
# 等待所有图片加载完成
await self._wait_for_images_to_load(page)
print_success(f"滚动完成,共滚动 {scroll_count} 次")
except Exception as e:
print_warning(f"滚动加载图片时出错: {e}")
async def _wait_for_images_to_load(self, page, timeout: int = 10000):
"""
等待页面中所有图片加载完成
Args:
page: Playwright page 对象
timeout: 超时时间(毫秒)
"""
try:
# 使用 JavaScript 检查所有图片是否加载完成
await page.evaluate('''
() => {
return new Promise((resolve) => {
const images = document.querySelectorAll('img');
let loaded = 0;
const total = images.length;
if (total === 0) {
resolve();
return;
}
const checkLoaded = () => {
loaded++;
if (loaded >= total) {
resolve();
}
};
images.forEach(img => {
if (img.complete) {
checkLoaded();
} else {
img.onload = checkLoaded;
img.onerror = checkLoaded;
}
});
// 超时保护
setTimeout(resolve, %d);
});
}
''' % timeout)
print_info(f"图片加载等待完成")
except Exception as e:
print_warning(f"等待图片加载时出错: {e}")
async def _extract_publish_time(self, page) -> int:
"""
提取发布时间(异步)
"""
try:
# 尝试从 meta 标签获取
publish_time_str = await page.locator('#publish_time').text_content()
if publish_time_str:
return self._convert_publish_time_to_timestamp(publish_time_str)
# 尝试从页面内容获取
content = await page.content()
# 常见的时间格式
patterns = [
r'publish_time\s*=\s*["\']([^"\']+)["\']',
r'var\s+publish_time\s*=\s*["\']([^"\']+)["\']',
r'create_time\s*=\s*["\']([^"\']+)["\']',
]
for pattern in patterns:
match = re.search(pattern, content)
if match:
return self._convert_publish_time_to_timestamp(match.group(1))
# 返回当前时间
return int(datetime.now().timestamp())
except Exception as e:
print_warning(f"提取发布时间失败: {str(e)}")
return int(datetime.now().timestamp())
async def _detect_article_type(self, page) -> int:
"""
检测文章类型(异步)
Returns:
文章类型: 0=图文, 5=视频, 7=音频, 10=贴图
"""
try:
# 尝试从页面内容中提取
content = await page.content()
# 根据页面元素判断类型
# 检查是否有视频播放器
has_video = await page.evaluate('() => !!document.querySelector(".video_iframe, #js_video_page_title, [data-vid], mp-common-videosnap")')
if has_video:
return 5
# 检查是否有音频播放器
has_audio = await page.evaluate('() => !!document.querySelector("#js_audio_title, .audio_area, mpvoice")')
if has_audio:
return 7
# 检查是否为贴图类型(多图展示)
has_images = await page.evaluate('() => !!document.querySelector("#js_text_title")')
if has_images:
return 10
# 尝试从页面JavaScript变量获取itemShowType
item_show_type = await page.evaluate('() => window.item_show_type')
if item_show_type is not None:
article_type = int(item_show_type)
# 验证是否为有效类型
if article_type in [0, 5, 7, 10]:
return article_type
# 查找 itemShowType 的定义
patterns = [
r"var\s+itemShowType\s*=\s*window\.a_value_which_never_exists\s*\|\|\s*['\"](\d+)['\"]",
r"itemShowType\s*=\s*['\"](\d+)['\"]",
r"item_show_type\s*=\s*['\"](\d+)['\"]",
]
for pattern in patterns:
match = re.search(pattern, content)
if match:
article_type = int(match.group(1))
if article_type in [0, 5, 7, 10]:
return article_type
# 默认为图文类型
return 0
except Exception as e:
print_warning(f"检测文章类型失败: {str(e)}")
return 0
def _convert_publish_time_to_timestamp(self, publish_time_str: str) -> int:
"""
将发布时间字符串转换为时间戳
支持 "2026年4月9日 22:44" 等中文日期格式(月份/日期可以是单位数)
"""
try:
# 预处理:补零对齐中文日期中的单位数月份和日期
# 例如 "2026年4月9日 22:44" -> "2026年04月09日 22:44"
normalized_str = re.sub(
r'(\d{4})年(\d{1,2})月(\d{1,2})日',
lambda m: f"{m.group(1)}{m.group(2).zfill(2)}{m.group(3).zfill(2)}日",
publish_time_str
)
formats = [
"%Y-%m-%d %H:%M:%S",
"%Y年%m月%d日 %H:%M",
"%Y-%m-%d %H:%M",
"%Y-%m-%d",
"%Y年%m月%d日",
"%m月%d日",
]
for fmt in formats:
try:
if fmt == "%m月%d日":
current_date = datetime.now()
current_year = current_date.year
full_time_str = f"{current_year}{normalized_str}"
dt = datetime.strptime(full_time_str, "%Y年%m月%d日")
if dt > current_date:
dt = dt.replace(year=current_year - 1)
else:
dt = datetime.strptime(normalized_str, fmt)
return int(dt.timestamp())
except ValueError:
continue
return int(datetime.now().timestamp())
except Exception as e:
print_error(f"时间转换失败: {e}")
return int(datetime.now().timestamp())
def _extract_biz(self, url: str, content: str) -> str:
"""
提取 biz 参数
"""
# 从 URL 提取
match = re.search(r'[?&]__biz=([^&]+)', url)
if match:
return match.group(1)
# 从内容提取
match = re.search(r'var\s+biz\s*=\s*["\']([^"\']+)["\']', content)
if match:
return match.group(1)
return ""
def extract_id_from_url(self, url: str) -> str:
"""从微信文章URL中提取ID
Args:
url: 文章URL
Returns:
文章ID字符串,如果提取失败返回空字符串
"""
try:
# 从URL中提取ID部分
match = re.search(r'/s/([A-Za-z0-9_-]+)', url)
if not match:
return ""
id_str = match.group(1)
# 添加必要的填充
padding = 4 - len(id_str) % 4
if padding != 4:
id_str += '=' * padding
# 尝试解码base64
try:
id_number = base64.b64decode(id_str).decode("utf-8")
return id_number
except Exception:
# 如果base64解码失败,返回原始ID字符串
return match.group(1)
except Exception as e:
print_error(f"提取文章ID失败: {e}")
return ""
@staticmethod
def get_description(content: str, length: int = 200) -> str:
"""
从文章内容中提取描述文本
Args:
content: HTML 内容
length: 描述文本的最大长度
Returns:
描述文本
"""
if not content:
return ""
try:
soup = BeautifulSoup(content, 'html.parser')
js_content_div = soup
if js_content_div is None:
return ""
text = js_content_div.get_text().strip().strip("\n").replace("\n", " ").replace("\r", " ")
return text[:length] + "..." if len(text) > length else text
except Exception:
return ""
# Web 工具类(兼容旧代码)
class Web:
"""Web 工具类,提供文章内容清理等功能"""
@staticmethod
def fix_images(content:str)->str:
try:
soup = BeautifulSoup(content, 'html.parser')
# 找到内容
js_content_div = soup
# 移除style属性中的visibility: hidden;
if js_content_div is None:
return ""
js_content_div.attrs.pop('style', None)
# 1. 处理img标签,只保留src和style属性
img_tags = js_content_div.find_all('img')
for img_tag in img_tags:
# 保存需要保留的属性
src_value = img_tag.get('src') or img_tag.get('data-src', '')
if "data:image" in src_value and src_value!=img_tag.get('data-src','') :
src_value = img_tag.get('data-src', '')
style_value = img_tag.get('style', '')
# 清除所有属性
img_tag.attrs = {}
# 只设置src和style属性
if src_value:
img_tag['src'] = src_value
if style_value:
img_tag['style'] = style_value
# 2. 处理section、p、span标签,只保留style属性
for tag_name in ['section', 'p', 'span']:
tags = js_content_div.find_all(tag_name)
for tag in tags:
style_value = tag.get('style', '')
# 清除所有属性
tag.attrs = {}
# 只保留style属性
if style_value:
tag['style'] = style_value
# 3. 处理背景类资源 (background-image, background等)
# 查找所有带有style属性的元素
all_elements = js_content_div.find_all(attrs={'style': True})
for element in all_elements:
style = element.get('style', '')
if not style:
continue
original_style = style
# 处理 background-image: url("data-src") 或 background-image: url(data-src)
# 微信文章中常见格式: background-image: url("https://mmbiz.qpic.cn/...")
# 或使用 data-src 属性值作为占位符
def process_bg_url(style_str):
"""处理背景URL,将data-src属性引用转为实际URL"""
# 匹配 url() 中的内容
url_pattern = r'url\s*\(\s*["\']?([^"\')\s]+)["\']?\s*\)'
def replace_url(match):
url_value = match.group(1)
# 如果URL是data-src属性引用(如 "data-src" 字符串),尝试从元素获取实际值
# 注意:微信文章通常直接使用URL,这里处理边缘情况
return match.group(0)
return re.sub(url_pattern, replace_url, style_str, flags=re.IGNORECASE)
# 检查元素是否有data-src属性,如果有则将其用于背景
data_src_value = element.get('data-src', '')
if data_src_value and ('background' in style.lower() or 'background-image' in style.lower()):
# 如果style中有background但URL为空或无效,用data-src替换
# 匹配空的url()或需要替换的情况
if 'url()' in style or 'url("")' in style or "url('')" in style:
style = re.sub(
r'url\s*\(\s*["\']?\s*["\']?\s*\)',
f'url("{data_src_value}")',
style,
flags=re.IGNORECASE
)
# 更新style属性
if style != original_style:
element['style'] = style
# 3. 处理data-src属性在其他元素上的情况
# 某些元素可能直接使用data-src作为属性(如section、div等)
elements_with_data_src = js_content_div.find_all(attrs={'data-src': True})
for element in elements_with_data_src:
if element.name != 'img': # img标签已经处理过了
data_src = element.get('data-src', '')
if data_src:
# 检查style是否有background相关属性
style = element.get('style', '')
if style and 'background' in style.lower():
# 如果有background但没有有效的URL,添加data-src
if 'url(' not in style.lower():
# 在style末尾添加background-image
if style.endswith(';'):
element['style'] = f"{style}background-image: url(\"{data_src}\")"
else:
element['style'] = f"{style};background-image: url(\"{data_src}\")"
section_tags = js_content_div.find_all('section')
for section in section_tags:
section['powered-by'] = 'werss'
return js_content_div.prettify()
except Exception as e:
print_error(f"修复图片失败: {str(e)}")
return content
def clean_article_content(html_content: str,mp_id:str=""):
from tools.htmltools import htmltools
html_content=Web.fix_images(html_content)
# 应用过滤规则
try:
from apis.filter_rule import apply_filter_rules
print(f"[DB] 准备应用过滤规则: mp_id={mp_id}, content_html存在={html_content is not None}")
if html_content:
html_content = apply_filter_rules(html_content, mp_id)
except Exception as e:
print_warning(f"应用过滤规则失败: {e}")
if not cfg.get("gather.clean_html",False):
return html_content
return htmltools.clean_html(str(html_content).strip(),
remove_ids=
['content_bottom_interaction',
'activity-name',
'meta_content',
"js_article_bottom_bar",
"js_pc_weapp_code",
"js_novel_card",
"js_pc_qr_code"
],
remove_selectors=[
"link",
"head",
"script"
],
remove_attributes=[
{"name":"style","value":"display: none;"},
{"name":"style","value":"display:none;"},
{"name":"aria-hidden","value":"true"},
],
remove_normal_tag=True
)
@staticmethod
def get_article_content(url: str) -> Dict:
"""
获取文章内容(同步包装器,兼容旧代码)
注意: 这是一个同步包装器,会阻塞调用线程
建议使用 WXArticleFetcher().get_article_content() 的 async 版本
Args:
url: 文章URL
Returns:
文章信息字典
"""
import asyncio
try:
# 创建新的事件循环
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
# 创建 fetcher 实例
fetcher = WXArticleFetcher()
# 运行异步方法
result = loop.run_until_complete(fetcher.get_article_content(url))
return result
finally:
# 清理事件循环
loop.close()
except Exception as e:
print_error(f"获取文章内容失败: {e}")
return {}
@staticmethod
def get_description(content:str,length:int=200)->str:
# 防御性检查:确保 content 不是 None
if not content:
return ""
try:
soup = BeautifulSoup(content, 'html.parser')
# 找到内容
js_content_div = soup
if js_content_div is None:
return ""
text = js_content_div.get_text().strip().strip("\n").replace("\n"," ").replace("\r"," ")
return text[:length]+"..." if len(text)>length else text
except Exception:
return ""
@staticmethod
def get_image_url(content: str) -> str:
"""
从内容中提取图片URL
Args:
content: HTML 内容或直接的图片URL
Returns:
图片URL
"""
if not content:
return ""
# 如果传入的已经是URL,直接返回
if content.startswith(('http://', 'https://')):
return content
try:
# 使用 BeautifulSoup 解析
soup = BeautifulSoup(content, 'html.parser')
# 查找第一个图片标签
img = soup.find('img')
if img:
# 获取 src 属性
src = img.get('src') or img.get('data-src')
if src:
return src
return ""
except Exception as e:
print_error(f"提取图片URL失败: {e}")
return content
@staticmethod
def proxy_images(content: str,isProxy:bool=True) -> str:
"""
处理文章内容中的图片链接,将微信图片转换为代理URL
微信公众号文章图片特点:
1. 使用 data-src 属性(懒加载)
2. URL 来自 mmbiz.qpic.cn 等微信 CDN
3. 可能需要正确的 referer 才能访问
4. 背景图片使用 CSS background-image 或 background 属性
Args:
content: HTML 内容
Returns:
处理后的 HTML 内容
"""
if not content:
return content
try:
soup = BeautifulSoup(content, 'html.parser')
from urllib.parse import quote
# 处理所有图片标签
for img in soup.find_all('img'):
# 获取图片URL(优先 data-src,其次 src
img_url = img.get('data-src') or img.get('src')
if img_url and img_url.startswith(('http://', 'https://')):
# 将图片URL转换为代理URL
encoded_url = quote(img_url, safe='')
proxy_url = f"/static/res/logo/{encoded_url}"
# 设置 src 属性(确保图片能显示)
img['src'] = proxy_url if isProxy else img_url
# 如果有 data-src,也更新它
if img.has_attr('data-src'):
img['data-src'] = proxy_url
# 移除可能阻止图片加载的属性
for attr in ['data-type', 'data-ratio', 'data-w']:
if img.has_attr(attr):
del img[attr]
# 处理所有带有 style 属性的元素(背景图片)
for element in soup.find_all(attrs={'style': True}):
style = element.get('style', '')
if not style:
continue
# 匹配 background-image: url(...) 或 background: url(...)
# 支持单引号、双引号和无引号的 URL
bg_pattern = r'(background-image\s*:\s*url\(|background\s*:[^;]*url\()([\'"]?)(https?://[^\'")\s]+)([\'"]?)\)'
def replace_bg_url(match):
prefix = match.group(1) # background-image: url( 或 background: ... url(
quote1 = match.group(2) # 开始引号
img_url = match.group(3) # 图片 URL
quote2 = match.group(4) # 结束引号
# 转换为代理 URL
encoded_url = quote(img_url, safe='')
proxy_url = f"/static/res/logo/{encoded_url}" if isProxy else img_url
return f"{prefix}{quote1}{proxy_url}{quote2})"
new_style = re.sub(bg_pattern, replace_bg_url, style)
if new_style != style:
element['style'] = new_style
return str(soup)
except Exception as e:
print_error(f"处理图片失败: {e}")
return content
# 导入 asyncio(用于文件顶部)
import asyncio