diff --git a/.gitignore b/.gitignore index 17235ad..f9be8a4 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,6 @@ logs/ # RSS database data/ + +# SaaS 版本(独立仓库管理) +saas/ diff --git a/README.md b/README.md index 01b885f..35d2a3e 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,9 @@ ## 功能特性 -- **RSS 订阅** — 订阅任意公众号,自动定时拉取新文章,生成标准 RSS 2.0 源,接入 FreshRSS / Feedly 等阅读器即可使用 +- **RSS 订阅** — 订阅任意公众号,自动定时拉取新文章(**包含完整文章内容和图片**),生成标准 RSS 2.0 源,接入 FreshRSS / Feedly 等阅读器即可使用 - **文章内容获取** — 通过 URL 获取文章完整内容(标题、作者、正文 HTML / 纯文本、图片列表) -- **反风控体系** — Chrome TLS 指纹模拟 + IP 代理池轮转 + 三层自动限频,有效对抗微信封控 +- **反风控体系** — Chrome TLS 指纹模拟 + SOCKS5 代理池轮转 + 三层自动限频,有效对抗微信封控 - **文章列表 & 搜索** — 获取任意公众号历史文章列表,支持分页和关键词搜索 - **公众号搜索** — 按名称搜索公众号,获取 FakeID - **扫码登录** — 微信公众平台扫码登录,凭证自动保存,4 天有效期 @@ -39,11 +39,19 @@ --- -## SaaS 托管版(即将推出) +## SaaS 托管版 — 已上线 🚀 -不想自己部署?我们正在筹备 **RSS 订阅托管服务**——无需服务器、无需配置,输入公众号名称即可获得 RSS 订阅地址,直接接入你喜欢的 RSS 阅读器。同时也在评估开放文章内容获取 API 的托管方案。 +**不想折腾部署?30 秒注册即可使用** 👉 **[wechatrss.waytomaster.com](https://wechatrss.waytomaster.com)** -感兴趣的话欢迎扫码添加微信,提前锁定体验名额 👇 [联系方式](#联系方式) +搜索公众号名称,拿到 RSS 链接,丢进你的阅读器——Feedly、Inoreader、NetNewsWire 全部兼容。 + +| 套餐 | 公众号数量 | 价格 | +|------|-----------|------| +| 免费版 | 2 个 | ¥0 | +| 基础版 | 20 个 | ¥9.9/月 | +| 专业版 | 50 个 | ¥19.9/月 | + +> 免费版够用就一直免费,不够了再升级,没有套路。 --- @@ -57,11 +65,33 @@ 登录后即可通过 API 获取**任意公众号**的公开文章(不限于自己的公众号)。 +> **本地电脑可以直接使用!** 不需要公网服务器——在本地启动服务后通过 `localhost` 访问即可完成扫码登录和全部功能。只有当你需要从其他设备(如手机 RSS 阅读器)远程访问时,才需要公网服务器或内网穿透。 + --- ## 快速开始 -### 方式一:一键启动(推荐) +### 方式一:Docker 部署(推荐,适合 NAS) + +**最简单的部署方式,适用于群晖 NAS、威联通 NAS、服务器等环境。** + +```bash +# 克隆项目 +git clone https://github.com/tmwgsicp/wechat-download-api.git +cd wechat-download-api + +# 配置环境变量(可选) +cp env.example .env + +# 启动服务 +docker-compose up -d +``` + +服务启动后访问 http://your-ip:5000 即可使用。 + +> 详细的 Docker 部署指南(包括群晖 NAS 图形界面操作)请查看 **[DOCKER.md](DOCKER.md)** + +### 方式二:一键启动脚本 **Windows:** ```bash @@ -78,7 +108,7 @@ chmod +x start.sh > Linux 生产环境可使用 `sudo bash start.sh` 自动配置 systemd 服务和开机自启。 -### 方式二:手动安装 +### 方式三:手动安装 ```bash # 创建虚拟环境 @@ -104,6 +134,64 @@ python app.py --- +## 服务器部署 + +### Docker 部署(推荐) + +适用于各类服务器、NAS 等环境,零依赖、易维护。详见 **[DOCKER.md](DOCKER.md)** + +### Linux 生产环境(systemd) + +`start.sh` 脚本在 Linux 上以 `sudo` 运行时,会自动注册 systemd 服务并启用开机自启: + +```bash +sudo bash start.sh +``` + +之后可通过以下命令管理服务: + +```bash +# 查看运行状态 +bash status.sh + +# 停止服务 +bash stop.sh + +# 手动操作 +sudo systemctl restart wechat-download-api +sudo systemctl status wechat-download-api +``` + +### 配置反向代理(可选) + +如需通过域名或 HTTPS 访问,配置 Nginx 反向代理到 `localhost:5000`: + +```nginx +server { + listen 80; + server_name your-domain.com; + + location / { + proxy_pass http://127.0.0.1:5000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } +} +``` + +### 环境变量 + +复制 `env.example` 为 `.env` 并按需修改: + +```bash +cp env.example .env +``` + +主要配置项参见 `env.example` 中的注释说明。 + +--- + ## API 接口 ### 获取文章内容 @@ -208,6 +296,12 @@ curl "http://localhost:5000/api/rss/MzA1MjM1ODk2MA==" 也可以通过管理面板的 **RSS 订阅** 页面可视化管理,搜索公众号一键订阅并复制 RSS 地址。 +> **关于 RSS 内容**: RSS 源包含**完整文章内容**(图文混排),您可以直接在 RSS 阅读器中阅读全文。 +> +> 系统使用 **SOCKS5 代理池 + Chrome TLS 指纹模拟**技术获取文章内容,有效规避微信风控。 +> +> 扫码登录后,系统会**自动**将微信凭证用于内容获取,无需手动配置。如需禁用完整内容获取(仅保留标题和摘要),可在 `.env` 中设置 `RSS_FETCH_FULL_CONTENT=false`。 + #### RSS 订阅管理接口 | 方法 | 路径 | 说明 | @@ -255,14 +349,26 @@ cp env.example .env | `RATE_LIMIT_PER_IP` | 单 IP 每分钟请求上限 | 5 | | `RATE_LIMIT_ARTICLE_INTERVAL` | 文章请求最小间隔(秒) | 3 | | `RSS_POLL_INTERVAL` | RSS 轮询间隔(秒) | 3600 | -| `PROXY_URLS` | 代理池地址(多个逗号分隔,留空直连) | 空 | +| `RSS_FETCH_FULL_CONTENT` | RSS 是否获取完整内容(true/false) | true | +| `PROXY_URLS` | **SOCKS5 代理池地址(强烈建议配置,避免账号风控)** | 空 | +| `SITE_URL` | **网站访问地址(用于RSS图片代理,必须配置)** | http://localhost:5000 | | `PORT` | 服务端口 | 5000 | | `HOST` | 监听地址 | 0.0.0.0 | | `DEBUG` | 调试模式(开启热重载) | false | -### 代理池配置(可选) +> **⚠️ 重要**: `SITE_URL` 必须配置为实际访问地址(IP或域名),否则RSS图片无法正常显示。例如: +> - 本地开发: `http://localhost:5000` +> - 局域网部署: `http://192.168.1.100:5000` +> - 公网域名: `https://你的域名.com` -文章内容获取接口(`POST /api/article`)会访问微信文章页面,频繁请求可能触发微信验证码保护。配置代理池可以将请求分散到不同 IP,降低风控风险。 +### SOCKS5 代理池配置(⚠️ 强烈建议) + +**重要提示**: +- ⚠️ **启用完整内容获取时,强烈建议配置代理池,避免账号被微信风控** +- ⚠️ **不配置代理直连微信可能导致:频繁验证、账号限制、IP封禁** +- ✅ **配置2-3个代理IP可有效分散请求,降低风控风险** + +**用途**:获取文章完整内容时分散请求 IP,配合 Chrome TLS 指纹模拟,有效规避微信风控。 > 本项目使用 `curl_cffi` 模拟 Chrome TLS 指纹,请求特征与真实浏览器一致,配合代理池效果更佳。 @@ -370,6 +476,9 @@ PROXY_URLS=socks5://myuser:mypass@vps1-ip:1080,socks5://myuser:mypass@vps2-ip:10 │ ├── rate_limiter.py # 限频器 │ ├── rss_store.py # RSS 数据存储(SQLite) │ ├── rss_poller.py # RSS 后台轮询器 +│ ├── content_processor.py # 内容处理与图片代理 +│ ├── image_proxy.py # 图片URL代理工具 +│ ├── article_fetcher.py # 批量并发获取文章 │ └── webhook.py # Webhook 通知 └── static/ # 前端页面(含 RSS 管理) ``` @@ -476,6 +585,8 @@ Cookie 登录有效期约 4 天,过期后需重新扫码登录。配置 `WEBHO - **GitHub Issues**: [提交问题](https://github.com/tmwgsicp/wechat-download-api/issues) +- **邮箱**: creator@waytomaster.com +- **SaaS 托管版**: [wechatrss.waytomaster.com](https://wechatrss.waytomaster.com) --- diff --git a/assets/qrcode/group.jpg b/assets/qrcode/group.jpg new file mode 100644 index 0000000..2db714c Binary files /dev/null and b/assets/qrcode/group.jpg differ diff --git a/env.example b/env.example index a08d0a2..ec317c1 100644 --- a/env.example +++ b/env.example @@ -21,13 +21,22 @@ WEBHOOK_NOTIFICATION_INTERVAL=300 # RSS 订阅配置 # 轮询间隔(秒),默认 3600(1 小时) RSS_POLL_INTERVAL=3600 +# RSS 轮询时是否获取完整文章内容(true/false),默认 true +# ⚠️ 启用时强烈建议配置下方的 PROXY_URLS,避免账号被微信风控 +RSS_FETCH_FULL_CONTENT=true -# 代理池 (留空则直连,多个用逗号分隔) -# 支持 HTTP / SOCKS5 代理,用于分散请求 IP 降低风控风险 -# 示例: socks5://ip1:1080,http://ip2:8080,socks5://user:pass@ip3:1080 +# SOCKS5 代理池(⚠️ 启用RSS完整内容时强烈建议配置,避免账号风控) +# 用途:分散请求 IP,配合 Chrome TLS 指纹模拟,有效规避微信封控 +# 不配置代理直连微信可能导致:频繁验证、账号限制、IP 封禁 +# 支持 SOCKS5 代理,多个用逗号分隔,建议 2-3 个即可 +# 示例: socks5://ip1:1080,socks5://ip2:1080,socks5://user:pass@ip3:1080 +# 留空则直连(仅适用于少量订阅或禁用 RSS_FETCH_FULL_CONTENT 的情况) PROXY_URLS= # 服务配置 +# 网站URL(用于RSS图片代理,必须配置为实际访问地址) +# 例如: http://你的IP:5000 或 https://你的域名.com +SITE_URL=http://localhost:5000 PORT=5000 HOST=0.0.0.0 DEBUG=false diff --git a/routes/article.py b/routes/article.py index 20051c5..d8291d4 100644 --- a/routes/article.py +++ b/routes/article.py @@ -69,6 +69,7 @@ async def get_article(article_request: ArticleRequest, request: Request): html = await fetch_page( article_request.url, extra_headers={"Referer": "https://mp.weixin.qq.com/"}, + timeout=120 # WeChat 大文章可能超时,延长至 120 秒 ) if "js_content" not in html: diff --git a/routes/rss.py b/routes/rss.py index 916f02d..4dd52fa 100644 --- a/routes/rss.py +++ b/routes/rss.py @@ -13,8 +13,6 @@ import time import logging from datetime import datetime, timezone from html import escape as html_escape -from urllib.parse import quote -from xml.etree.ElementTree import Element, SubElement, tostring from typing import Optional from fastapi import APIRouter, HTTPException, Query, Request @@ -23,6 +21,7 @@ from pydantic import BaseModel, Field from utils import rss_store from utils.rss_poller import rss_poller, POLL_INTERVAL +from utils.image_proxy import proxy_image_url logger = logging.getLogger(__name__) @@ -120,8 +119,11 @@ async def get_subscriptions(request: Request): items = [] for s in subs: + # 将头像 URL 转换为代理链接 + head_img = proxy_image_url(s.get("head_img", ""), base_url) items.append({ **s, + "head_img": head_img, "rss_url": f"{base_url}/api/rss/{s['fakeid']}", }) @@ -173,13 +175,6 @@ async def poller_status(): # ── RSS XML 输出 ────────────────────────────────────────── -def _proxy_cover(url: str, base_url: str) -> str: - """将微信 CDN 封面图地址替换为本服务的图片代理地址""" - if url and "mmbiz.qpic.cn" in url: - return base_url + "/api/image?url=" + quote(url, safe="") - return url - - def _rfc822(ts: int) -> str: """Unix 时间戳 → RFC 822 日期字符串""" if not ts: @@ -190,81 +185,137 @@ def _rfc822(ts: int) -> str: def _build_rss_xml(fakeid: str, sub: dict, articles: list, base_url: str) -> str: - rss = Element("rss", version="2.0") - rss.set("xmlns:atom", "http://www.w3.org/2005/Atom") - - channel = SubElement(rss, "channel") - SubElement(channel, "title").text = sub.get("nickname") or fakeid - SubElement(channel, "link").text = "https://mp.weixin.qq.com" - SubElement(channel, "description").text = ( - f'{sub.get("nickname", "")} 的微信公众号文章 RSS 订阅' - ) - SubElement(channel, "language").text = "zh-CN" - SubElement(channel, "lastBuildDate").text = _rfc822(int(time.time())) - SubElement(channel, "generator").text = "WeChat Download API" - - atom_link = SubElement(channel, "atom:link") - atom_link.set("href", f"{base_url}/api/rss/{fakeid}") - atom_link.set("rel", "self") - atom_link.set("type", "application/rss+xml") - + """ + 构建 RSS XML,使用 CDATA 包裹 HTML 内容 + """ + from xml.dom import minidom + + # 创建 XML 文档 + doc = minidom.Document() + + # 创建根元素 + rss = doc.createElement("rss") + rss.setAttribute("version", "2.0") + rss.setAttribute("xmlns:atom", "http://www.w3.org/2005/Atom") + doc.appendChild(rss) + + # 创建 channel + channel = doc.createElement("channel") + rss.appendChild(channel) + + # Channel 基本信息 + def add_text_element(parent, tag, text): + elem = doc.createElement(tag) + elem.appendChild(doc.createTextNode(str(text))) + parent.appendChild(elem) + return elem + + add_text_element(channel, "title", sub.get("nickname") or fakeid) + add_text_element(channel, "link", "https://mp.weixin.qq.com") + add_text_element(channel, "description", + f'{sub.get("nickname", "")} 的微信公众号文章 RSS 订阅') + add_text_element(channel, "language", "zh-CN") + add_text_element(channel, "lastBuildDate", _rfc822(int(time.time()))) + add_text_element(channel, "generator", "WeChat Download API") + + # atom:link + atom_link = doc.createElement("atom:link") + atom_link.setAttribute("href", f"{base_url}/api/rss/{fakeid}") + atom_link.setAttribute("rel", "self") + atom_link.setAttribute("type", "application/rss+xml") + channel.appendChild(atom_link) + + # Channel 图片 if sub.get("head_img"): - image = SubElement(channel, "image") - SubElement(image, "url").text = sub["head_img"] - SubElement(image, "title").text = sub.get("nickname", "") - SubElement(image, "link").text = "https://mp.weixin.qq.com" - + image = doc.createElement("image") + head_img_proxied = proxy_image_url(sub["head_img"], base_url) + add_text_element(image, "url", head_img_proxied) + add_text_element(image, "title", sub.get("nickname", "")) + add_text_element(image, "link", "https://mp.weixin.qq.com") + channel.appendChild(image) + + # 文章列表 for a in articles: - item = SubElement(channel, "item") - SubElement(item, "title").text = a.get("title", "") - + item = doc.createElement("item") + + add_text_element(item, "title", a.get("title", "")) + link = a.get("link", "") - SubElement(item, "link").text = link - - guid = SubElement(item, "guid") - guid.text = link - guid.set("isPermaLink", "true") - + add_text_element(item, "link", link) + + guid = doc.createElement("guid") + guid.setAttribute("isPermaLink", "true") + guid.appendChild(doc.createTextNode(link)) + item.appendChild(guid) + if a.get("publish_time"): - SubElement(item, "pubDate").text = _rfc822(a["publish_time"]) - + add_text_element(item, "pubDate", _rfc822(a["publish_time"])) + if a.get("author"): - SubElement(item, "author").text = a["author"] - - cover = _proxy_cover(a.get("cover", ""), base_url) + add_text_element(item, "author", a["author"]) + + # 构建 description HTML + cover = proxy_image_url(a.get("cover", ""), base_url) digest = html_escape(a.get("digest", "")) if a.get("digest") else "" author = html_escape(a.get("author", "")) if a.get("author") else "" title_escaped = html_escape(a.get("title", "")) - + + content_html = a.get("content", "") html_parts = [] - if cover: + + if content_html: + # 统一策略:入库时已代理(见utils/rss_poller.py:236),RSS输出时直接使用 html_parts.append( - f'
作者: {author}
' + ) + else: + if cover: + html_parts.append( + f'{digest}
' + ) + if author: + html_parts.append( + f'' + f'作者: {author}
' + ) html_parts.append( - f'{digest}
' + f'' ) - if author: - html_parts.append( - f'' - f'作者: {author}
' - ) - html_parts.append( - f'' - ) - - SubElement(item, "description").text = "\n".join(html_parts) - - xml_bytes = tostring(rss, encoding="unicode", xml_declaration=False) - return '\n' + xml_bytes + + # 使用 CDATA 包裹 HTML 内容 + description = doc.createElement("description") + cdata = doc.createCDATASection("\n".join(html_parts)) + description.appendChild(cdata) + item.appendChild(description) + + channel.appendChild(item) + + # 生成 XML 字符串 + xml_str = doc.toprettyxml(indent=" ", encoding=None) + + # 移除多余的空行和 XML 声明(我们自己添加) + lines = [line for line in xml_str.split('\n') if line.strip()] + xml_str = '\n'.join(lines[1:]) # 跳过默认的 XML 声明 + + return '\n' + xml_str @router.get("/rss/{fakeid}", summary="获取 RSS 订阅源", diff --git a/routes/search.py b/routes/search.py index fa0d4c0..ae85920 100644 --- a/routes/search.py +++ b/routes/search.py @@ -8,12 +8,13 @@ 搜索路由 - FastAPI版本 """ -from fastapi import APIRouter, Query +from fastapi import APIRouter, Query, Request from pydantic import BaseModel from typing import Optional, List import time import httpx from utils.auth_manager import auth_manager +from utils.image_proxy import proxy_image_url router = APIRouter() @@ -30,7 +31,7 @@ class SearchResponse(BaseModel): error: Optional[str] = None @router.get("/searchbiz", response_model=SearchResponse, summary="搜索公众号") -async def search_accounts(query: str = Query(..., description="公众号名称或关键词", alias="query")): +async def search_accounts(query: str = Query(..., description="公众号名称或关键词", alias="query"), request: Request = None): """ 按关键词搜索微信公众号,获取 FakeID。 @@ -78,14 +79,19 @@ async def search_accounts(query: str = Query(..., description="公众号名称 if result.get("base_resp", {}).get("ret") == 0: accounts = result.get("list", []) + # 获取 base_url 用于图片代理 + base_url = str(request.base_url).rstrip("/") if request else "" + # 格式化返回数据 formatted_accounts = [] for acc in accounts: + # 将头像 URL 转换为代理链接 + round_head_img = proxy_image_url(acc.get("round_head_img", ""), base_url) formatted_accounts.append({ "fakeid": acc.get("fakeid", ""), "nickname": acc.get("nickname", ""), "alias": acc.get("alias", ""), - "round_head_img": acc.get("round_head_img", ""), + "round_head_img": round_head_img, "service_type": acc.get("service_type", 0) }) diff --git a/utils/article_fetcher.py b/utils/article_fetcher.py new file mode 100644 index 0000000..06f8b41 --- /dev/null +++ b/utils/article_fetcher.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +文章内容获取器 - SOCKS5 代理方案 +使用 curl_cffi 模拟真实浏览器 TLS 指纹,支持代理池轮转 +""" + +import asyncio +import logging +import os +from typing import Optional + +logger = logging.getLogger(__name__) + + +async def fetch_article_content( + article_url: str, + timeout: int = 60, + wechat_token: Optional[str] = None, + wechat_cookie: Optional[str] = None +) -> Optional[str]: + """ + 获取文章内容 + + 请求策略: + 1. SOCKS5 代理池轮转 + 2. 直连兜底 + + Args: + article_url: 文章 URL + timeout: 超时时间(秒) + wechat_token: 微信 token(用于鉴权) + wechat_cookie: 微信 Cookie(用于鉴权) + + Returns: + 文章 HTML 内容,失败返回 None + """ + # 使用代理池获取文章 + html = await _fetch_via_proxy(article_url, timeout, wechat_cookie, wechat_token) + return html + + +async def _fetch_via_proxy( + article_url: str, + timeout: int, + wechat_cookie: Optional[str] = None, + wechat_token: Optional[str] = None +) -> Optional[str]: + """通过 SOCKS5 代理或直连获取文章""" + try: + # 使用现有的 http_client(支持代理池轮转 + 直连兜底) + from utils.http_client import fetch_page + + logger.info("[Proxy] %s", article_url[:80]) + + # 构建完整 URL(带 token) + full_url = article_url + if wechat_token: + separator = '&' if '?' in article_url else '?' + full_url = f"{article_url}{separator}token={wechat_token}" + + # 准备请求头 + extra_headers = {"Referer": "https://mp.weixin.qq.com/"} + if wechat_cookie: + extra_headers["Cookie"] = wechat_cookie + + html = await fetch_page( + full_url, + extra_headers=extra_headers, + timeout=timeout + ) + + # 验证内容有效性 + if "js_content" in html and len(html) > 500000: + logger.info("[Proxy] ✅ len=%d", len(html)) + return html + else: + logger.warning("[Proxy] ❌ 内容无效 (len=%d, has_js_content=%s)", + len(html), "js_content" in html) + return None + + except Exception as e: + logger.error("[Proxy] ❌ %s", str(e)[:100]) + return None + + +async def fetch_articles_batch( + article_urls: list, + max_concurrency: int = 5, + timeout: int = 60, + wechat_token: Optional[str] = None, + wechat_cookie: Optional[str] = None +) -> dict: + """ + 批量获取文章内容(并发版) + + Args: + article_urls: 文章 URL 列表 + max_concurrency: 最大并发数 + timeout: 单个请求超时时间 + wechat_token: 微信 token(用于鉴权) + wechat_cookie: 微信 Cookie(用于鉴权) + + Returns: + {url: html} 字典,失败的 URL 对应 None + """ + semaphore = asyncio.Semaphore(max_concurrency) + results = {} + + async def fetch_one(url): + async with semaphore: + html = await fetch_article_content(url, timeout, wechat_token, wechat_cookie) + results[url] = html + + # 避免请求过快 + await asyncio.sleep(0.5) + + logger.info("[Batch] 开始批量获取 %d 篇文章", len(article_urls)) + + await asyncio.gather( + *[fetch_one(url) for url in article_urls], + return_exceptions=True + ) + + success_count = sum(1 for html in results.values() if html) + fail_count = len(results) - success_count + + logger.info("[Batch] 完成: 成功=%d, 失败=%d", success_count, fail_count) + + return results diff --git a/utils/content_processor.py b/utils/content_processor.py new file mode 100644 index 0000000..0f7aa48 --- /dev/null +++ b/utils/content_processor.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +图文内容处理器 - 完美还原微信文章的图文混合内容 +""" + +import re +import logging +from typing import Dict, List +from urllib.parse import quote + +logger = logging.getLogger(__name__) + + +def process_article_content(html: str, proxy_base_url: str = None) -> Dict: + """ + 处理文章内容,保持图文顺序并代理图片 + + Args: + html: 原始 HTML + proxy_base_url: 图片代理基础 URL(例如:https://你的域名.com) + + Returns: + { + 'content': '处理后的 HTML(图片已代理)', + 'plain_content': '纯文本', + 'images': ['图片URL列表'], + 'has_images': True/False + } + """ + + # 1. 提取正文内容(保持原始 HTML 结构) + content = extract_content(html) + + if not content: + return { + 'content': '', + 'plain_content': '', + 'images': [], + 'has_images': False + } + + # 2. 提取所有图片 URL(按顺序) + images = extract_images_in_order(content) + + # 3. 代理图片 URL(保持 HTML 中的图片顺序) + if proxy_base_url: + content = proxy_all_images(content, proxy_base_url) + + # 4. 清理和优化 HTML + content = clean_html(content) + + # 5. 生成纯文本 + plain_content = html_to_text(content) + + return { + 'content': content, + 'plain_content': plain_content, + 'images': images, + 'has_images': len(images) > 0 + } + + +def extract_content(html: str) -> str: + """ + 提取文章正文(保持原始 HTML 结构) + + 微信文章的正文在 id="js_content" 的 div 中, + 这个 div 内的 HTML 已经按正确顺序排列了文本和图片。 + """ + + # 方法 1: 匹配 id="js_content" (改进版,更灵活) + match = re.search( + r']*>\s*
', '', content, flags=re.IGNORECASE) + + # 移除多余空白 + content = re.sub(r'\n\s*\n', '\n', content) + + return content.strip() + + +def html_to_text(html: str) -> str: + """将 HTML 转为纯文本(移除图片,只保留文字)""" + import html as html_module + + # 移除图片标签 + text = re.sub(r'这是第一段文字
+
这是第二段文字
+
这是第三段文字
+