feat: support audio share articles (item_show_type=7) and improve content detection
Major Updates: - Add support for item_show_type=7 audio/video share pages with basic metadata extraction - Enhance is_audio_message() with strict regex to avoid false positives - Improve get_unavailable_reason() to correctly distinguish verification pages - Add friendly placeholder text for pure-image articles - Add comprehensive CONTENT_TYPES.md documentation Technical Improvements: - Fix pure-image articles being misidentified as audio articles - Fix WeChat verification pages being marked as permanently unavailable - Fix empty Vue app (privacy page) detection logic - Optimize article type detection priority based on item_show_type Documentation: - Add CONTENT_TYPES.md with detailed explanations of all content types - Update README.md to reference new documentation - Document known limitations for audio articles Note: Audio articles (type=7) use dynamic Vue apps, so only basic metadata (title, author, cover image) can be extracted. Full audio URL extraction would require browser environment. Made-with: Cursor
This commit is contained in:
parent
0fb8ba2484
commit
752f555f0c
|
|
@ -0,0 +1,382 @@
|
|||
# 微信公众号文章内容类型与识别策略
|
||||
|
||||
本文档说明微信公众号文章的各种内容类型、不可用状态,以及对应的识别和处理策略。
|
||||
|
||||
---
|
||||
|
||||
## 一、文章内容类型
|
||||
|
||||
微信公众号使用 `item_show_type` 参数来区分不同的内容类型。这个参数通常在HTML的JavaScript代码中定义。
|
||||
|
||||
### item_show_type 值说明
|
||||
|
||||
| 值 | 类型 | 说明 |
|
||||
|----|------|------|
|
||||
| `0` | 标准富文本 | 最常见的图文文章 |
|
||||
| `7` | 音频/视频分享 | 动态Vue应用,内容通过JS加载 |
|
||||
| `8` | 图文消息 | 类似小红书的多图+短文风格 |
|
||||
| `10` | 短内容 | 纯文字或转发消息,无 `js_content` 容器 |
|
||||
| 其他 | 未知 | 其他特殊内容,待补充 |
|
||||
|
||||
---
|
||||
|
||||
### 1. 标准富文本文章
|
||||
|
||||
**item_show_type**: `0`(或未定义)
|
||||
|
||||
**特征**:
|
||||
- 包含 `<div id="js_content">` 或 `<div class="rich_media_content">`
|
||||
- 文字 + 图片混合
|
||||
- HTML大小:通常 > 100KB
|
||||
|
||||
**提取策略**:
|
||||
- 提取 `js_content` 区域的完整HTML
|
||||
- 按顺序提取所有图片URL(`data-src` 或 `src` 属性)
|
||||
- 生成纯文本(`plain_content`)供RSS阅读器使用
|
||||
- 图片URL通过代理服务转发(避免防盗链)
|
||||
|
||||
---
|
||||
|
||||
### 1.5. 音频分享文章(Audio Share)
|
||||
|
||||
**item_show_type**: `7`
|
||||
|
||||
**特征**:
|
||||
- 动态Vue应用(使用 `common_share_audio` 模块)
|
||||
- **无传统的 `js_content` 容器**
|
||||
- `og:image` 和 `og:description` 通常为空
|
||||
- HTML中包含 `window.item_show_type = '7'`
|
||||
- 内容通过JavaScript动态加载,静态HTML中看不到实际音频内容
|
||||
|
||||
**典型公众号**:
|
||||
- 播客节目(如"马刺进步报告")
|
||||
- 音频节目分享
|
||||
- 视频号音频内容
|
||||
|
||||
**提取策略**:
|
||||
```python
|
||||
# 检测逻辑
|
||||
if get_item_show_type(html) == '7':
|
||||
# 这是音频分享页面
|
||||
return _extract_audio_share_content(html)
|
||||
```
|
||||
|
||||
**可提取内容**:
|
||||
- ✅ 标题(从 `og:title` 或 `window.msg_title`)
|
||||
- ✅ 作者(从 `og:article:author` 或 `var nickname`)
|
||||
- ✅ 封面图(从 `og:image`,如果有)
|
||||
- ❌ 音频URL(需要JavaScript执行才能获取)
|
||||
- ❌ 播放时长
|
||||
- ❌ 音频播放器
|
||||
|
||||
**RSS展示效果**:
|
||||
```html
|
||||
<div style="background:#f6f6f6;padding:20px;border-radius:8px">
|
||||
<p>🎵 音频内容 / Audio Content</p>
|
||||
<p>这是微信音频分享文章,内容通过JavaScript动态加载,无法直接提取。</p>
|
||||
<p>请在微信中查看完整内容</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
**已知限制**:
|
||||
- 无法提取真实音频URL(需要浏览器环境执行JS)
|
||||
- 只能提供标题、作者和封面图的基本信息
|
||||
- RSS阅读器中显示占位符,引导用户到微信查看原文
|
||||
|
||||
**未来改进方向**:
|
||||
- 使用无头浏览器(Playwright/Puppeteer)执行JavaScript
|
||||
- 逆向分析微信音频API
|
||||
- 提供更丰富的元数据展示
|
||||
|
||||
---
|
||||
|
||||
### 2. 纯图片文章
|
||||
|
||||
**item_show_type**: `0`
|
||||
|
||||
**特征**:
|
||||
- 有 `<div id="js_content">` 容器
|
||||
- 内容区域只有 `<img>` 标签,**没有任何文字**
|
||||
- HTML大小:2-3MB(正常大小)
|
||||
|
||||
**处理策略**:
|
||||
- 正常提取HTML和图片列表
|
||||
- `plain_content` 生成占位文本:`[纯图片文章,共 X 张图片]`
|
||||
|
||||
**注意**:
|
||||
- 必须使用严格的音频检测逻辑,避免误判为音频文章
|
||||
|
||||
---
|
||||
|
||||
### 3. 图文消息
|
||||
|
||||
**item_show_type**: `8`
|
||||
|
||||
**特征**:
|
||||
- 类似"小红书"的多图+短文风格
|
||||
- 包含特殊的图文混排结构
|
||||
- 通常是手机端创作的内容
|
||||
|
||||
**识别**:`is_image_text_message(html)` → `get_item_show_type(html) == '8'`
|
||||
|
||||
**提取**:`_extract_image_text_content(html)`
|
||||
|
||||
---
|
||||
|
||||
### 4. 短内容消息
|
||||
|
||||
**item_show_type**: `10`
|
||||
|
||||
**特征**:
|
||||
- 纯文字,无 `js_content` div
|
||||
- 类似"朋友圈"的短文本或转发内容
|
||||
- HTML结构简单,内容在特殊的容器中
|
||||
|
||||
**识别**:`is_short_content_message(html)` → `get_item_show_type(html) == '10'`
|
||||
|
||||
**提取**:`_extract_short_content(html)`
|
||||
|
||||
---
|
||||
|
||||
### 5. 音频文章(待完善)
|
||||
|
||||
**item_show_type**: `0`
|
||||
|
||||
**特征**:
|
||||
- 包含音频播放器组件
|
||||
- 可能包含 `<mpvoice>` 标签或 `<mp-common-mpaudio>` 标签
|
||||
- 可能同时包含配图(图+音频混合)
|
||||
|
||||
**识别**:`is_audio_message(html)`
|
||||
- 匹配真实的 `<mpvoice>` 标签
|
||||
- 匹配 `<mp-common-mpaudio>` 标签
|
||||
- 匹配 `<div id="js_editor_audio_xxx">` 容器
|
||||
|
||||
**重要**:
|
||||
- 必须使用严格的正则匹配HTML标签
|
||||
- 不要匹配JS代码中的 `voice_encode_fileid` 等字符串(会误判纯图片文章)
|
||||
|
||||
**当前状态**:
|
||||
- 基础识别逻辑已实现
|
||||
- 内容提取待完善(图+音频混合场景)
|
||||
|
||||
---
|
||||
|
||||
## 二、文章不可用状态
|
||||
|
||||
### 1. 验证页面(可重试)⚠️
|
||||
|
||||
**特征**:
|
||||
- HTML大小:1.5-2MB(很大)
|
||||
- 包含完整的验证组件代码
|
||||
- 关键标记:`"环境异常"` + `"完成验证后即可继续访问"` + `"去验证"`
|
||||
|
||||
**原因**:
|
||||
- 代理IP被微信风控
|
||||
- 或服务器IP请求过于频繁
|
||||
|
||||
**处理**:
|
||||
- ❌ **不应**标记为永久失效
|
||||
- ✅ **应该**标记为可重试(`failed`)
|
||||
- ✅ 切换代理或等待冷却后重试
|
||||
|
||||
---
|
||||
|
||||
### 2. 暂时无法查看(永久失效)❌
|
||||
|
||||
**特征**:
|
||||
- HTML极小:< 1KB
|
||||
- `<title>该内容暂时无法查看</title>`
|
||||
- 页面只有一句提示
|
||||
|
||||
**处理**:
|
||||
- ✅ 标记为永久失效(`permanent_fail`)
|
||||
- 原因:`"暂时无法查看"`
|
||||
|
||||
---
|
||||
|
||||
### 3. 根据作者隐私设置不可查看(永久失效)❌
|
||||
|
||||
**特征**:
|
||||
- HTML大小:10-20KB
|
||||
- 空的Vue应用:`<div id="app"></div>`
|
||||
- 空的 `<title></title>`
|
||||
- 无任何文章内容容器
|
||||
- 页面显示:"根据作者隐私设置,无法查看该内容"(通过JS动态加载)
|
||||
|
||||
**原因**:
|
||||
- 作者设置了文章隐私权限
|
||||
- 通常是会员专属内容
|
||||
|
||||
**处理**:
|
||||
- ✅ 标记为永久失效(`permanent_fail`)
|
||||
- 原因:`"根据作者隐私设置不可查看"`
|
||||
|
||||
**注意**:
|
||||
- 这种页面的错误提示不在静态HTML中
|
||||
- 需要检查空Vue应用 + 无内容容器 + 空title的组合特征
|
||||
|
||||
---
|
||||
|
||||
### 4. 已被发布者删除(永久失效)❌
|
||||
|
||||
**标记**:
|
||||
- `"该内容已被发布者删除"`
|
||||
- `"内容已删除"`
|
||||
|
||||
**处理**:
|
||||
- ✅ 标记为永久失效
|
||||
- 原因:`"已被发布者删除"`
|
||||
|
||||
---
|
||||
|
||||
### 5. 违规内容(永久失效)❌
|
||||
|
||||
**标记**:
|
||||
- `"此内容因违规无法查看"`
|
||||
- `"涉嫌违反相关法律法规和政策"`
|
||||
- `"此内容发送失败无法查看"`
|
||||
- `"接相关投诉,此内容违反"`
|
||||
|
||||
**处理**:
|
||||
- ✅ 标记为永久失效
|
||||
- 原因:`"因违规无法查看"` 或 `"涉嫌违规被限制"`
|
||||
|
||||
---
|
||||
|
||||
### 6. 第三方辟谣(永久失效)❌
|
||||
|
||||
**标记**:
|
||||
- `"该文章已被第三方辟谣"`
|
||||
|
||||
**处理**:
|
||||
- ✅ 标记为永久失效
|
||||
- 原因:`"已被第三方辟谣"`
|
||||
|
||||
---
|
||||
|
||||
## 三、提取流程
|
||||
|
||||
```
|
||||
获取HTML
|
||||
↓
|
||||
检查是否不可用 (is_article_unavailable)
|
||||
├─ 是 → 标记 permanent_fail + 原因
|
||||
└─ 否 → 继续
|
||||
↓
|
||||
检查是否有内容容器 (has_article_content)
|
||||
├─ 否 → 标记 failed(可重试)
|
||||
└─ 是 → 继续
|
||||
↓
|
||||
按类型提取内容
|
||||
├─ 图文消息 (type=8) → _extract_image_text_content()
|
||||
├─ 短内容 (type=10) → _extract_short_content()
|
||||
├─ 音频文章 → _extract_audio_content()
|
||||
└─ 标准文章 → extract_content()
|
||||
↓
|
||||
提取图片 (extract_images_in_order)
|
||||
↓
|
||||
生成纯文本 (html_to_text)
|
||||
↓
|
||||
检查是否纯图片文章
|
||||
├─ 是 → plain_content = "[纯图片文章,共 X 张图片]"
|
||||
└─ 否 → 保持原有纯文本
|
||||
↓
|
||||
返回结果
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、关键函数
|
||||
|
||||
### 1. `get_unavailable_reason(html) -> str | None`
|
||||
|
||||
检测文章是否永久不可用。
|
||||
|
||||
**返回值**:
|
||||
- `None` - 文章正常或可重试
|
||||
- `str` - 不可用原因
|
||||
|
||||
**检测顺序**:
|
||||
1. 优先排除:验证页面
|
||||
2. 静态标记:删除、违规、辟谣等
|
||||
3. 特殊页面:"暂时无法查看"、隐私设置页面
|
||||
|
||||
---
|
||||
|
||||
### 2. `is_audio_message(html) -> bool`
|
||||
|
||||
检测是否为音频文章。
|
||||
|
||||
**要点**:
|
||||
- ✅ 匹配真实的 `<mpvoice>` 标签
|
||||
- ✅ 用正则匹配 `<mp-common-mpaudio>` 标签
|
||||
- ✅ 用正则匹配 `<div id="js_editor_audio_xxx">` 容器
|
||||
- ❌ 不要用简单的 `in` 检查(会误判JS代码)
|
||||
|
||||
---
|
||||
|
||||
### 3. `has_article_content(html) -> bool`
|
||||
|
||||
快速检查HTML是否包含文章内容容器。
|
||||
|
||||
**容器标记**:
|
||||
- `id="js_content"`
|
||||
- `class="rich_media_content"`
|
||||
- `id="page-content"`(政府/机构账号)
|
||||
- 或特殊类型标记
|
||||
|
||||
---
|
||||
|
||||
## 五、代理和反爬策略
|
||||
|
||||
1. **代理池轮转**:
|
||||
- 使用 SOCKS5 代理
|
||||
- 失败后冷却120秒
|
||||
- 所有代理失败后使用直连
|
||||
|
||||
2. **TLS指纹伪装**:
|
||||
- 使用 `curl_cffi` 库
|
||||
- 模拟 Chrome 120 浏览器:`impersonate="chrome120"`
|
||||
|
||||
3. **请求头**:
|
||||
- `Referer: https://mp.weixin.qq.com/`
|
||||
- 必要时添加 `Cookie`(微信token)
|
||||
|
||||
---
|
||||
|
||||
## 六、数据库字段
|
||||
|
||||
### `articles` 表关键字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `status` | `VARCHAR` | 文章状态:`pending`(等待)/ `fetched`(已获取)/ `failed`(失败,可重试)/ `permanent_fail`(永久失效) |
|
||||
| `fetch_retry_count` | `INTEGER` | 重试次数(最多3次) |
|
||||
| `content` | `TEXT` | HTML内容 |
|
||||
| `plain_content` | `TEXT` | 纯文本内容(供RSS使用) |
|
||||
| `unavailable_reason` | `VARCHAR` | 不可用原因(仅 `permanent_fail` 时有值) |
|
||||
|
||||
---
|
||||
|
||||
## 七、贡献指南
|
||||
|
||||
如果你发现新的文章类型或错误页面,欢迎提交Issue或PR:
|
||||
|
||||
1. **提供详细信息**:
|
||||
- 文章URL(至少3个样本)
|
||||
- 完整的HTML源码
|
||||
- 期望的提取结果
|
||||
|
||||
2. **遵循代码规范**:
|
||||
- 使用严格的正则匹配(避免误判)
|
||||
- 添加详细的注释说明
|
||||
|
||||
3. **测试充分**:
|
||||
- 测试正常文章不受影响
|
||||
- 测试新类型能正确识别
|
||||
|
||||
---
|
||||
|
||||
**最后更新**:2026-03-24
|
||||
**维护者**:WeChat RSS API 项目组
|
||||
15
README.md
15
README.md
|
|
@ -502,6 +502,21 @@ PROXY_URLS=socks5://myuser:mypass@vps1-ip:1080,socks5://myuser:mypass@vps2-ip:10
|
|||
|
||||
---
|
||||
|
||||
## 内容类型与获取策略
|
||||
|
||||
本项目支持多种微信公众号内容类型,包括标准富文本、纯图片文章、图文消息、短内容、音频文章等。
|
||||
|
||||
详细说明请查看:**[CONTENT_TYPES.md](CONTENT_TYPES.md)**
|
||||
|
||||
**文档内容**:
|
||||
- 所有支持的内容类型及 `item_show_type` 值
|
||||
- 不可用状态识别(删除、违规、隐私、验证页面等)
|
||||
- 反爬策略与代理配置
|
||||
- 关键函数说明
|
||||
- 开发贡献指南
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
<details>
|
||||
|
|
|
|||
|
|
@ -53,6 +53,11 @@ def process_article_content(html: str, proxy_base_url: str = None) -> Dict:
|
|||
# 5. 生成纯文本
|
||||
plain_content = html_to_text(content)
|
||||
|
||||
# 6. 纯图片文章处理:如果没有文字但有图片,生成图片描述
|
||||
if not plain_content.strip() and images:
|
||||
plain_content = f"[纯图片文章,共 {len(images)} 张图片]"
|
||||
logger.info(f"检测到纯图片文章: {len(images)} 张图片,无文字内容")
|
||||
|
||||
return {
|
||||
'content': content,
|
||||
'plain_content': plain_content,
|
||||
|
|
|
|||
169
utils/helpers.py
169
utils/helpers.py
|
|
@ -82,11 +82,26 @@ def is_audio_message(html: str) -> bool:
|
|||
"""
|
||||
Detect audio articles (voice messages embedded via mpvoice / mp-common-mpaudio).
|
||||
检测是否为音频文章(包含 mpvoice 标签或音频播放器组件)。
|
||||
|
||||
Important: Must check for ACTUAL audio tags, not just JS code that mentions audio.
|
||||
"""
|
||||
return ('voice_encode_fileid' in html or
|
||||
'<mpvoice' in html or
|
||||
'mp-common-mpaudio' in html or
|
||||
'js_editor_audio' in html)
|
||||
# 方法1: 检查是否有真实的 <mpvoice> 标签(注意:mpvoice 是自定义标签)
|
||||
if '<mpvoice' in html:
|
||||
return True
|
||||
|
||||
# 方法2: 检查是否有音频播放器组件的 **HTML标签**(不是JS代码)
|
||||
# 使用更严格的正则,确保匹配的是标签而不是JS变量
|
||||
import re
|
||||
|
||||
# 匹配实际的音频标签:<mp-common-mpaudio ...>
|
||||
if re.search(r'<mp-common-mpaudio[^>]*>', html, re.IGNORECASE):
|
||||
return True
|
||||
|
||||
# 匹配实际的音频容器:<div id="js_editor_audio_...">
|
||||
if re.search(r'<div[^>]+id=["\']js_editor_audio[^"\']*["\']', html, re.IGNORECASE):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _extract_image_text_content(html: str) -> Dict:
|
||||
|
|
@ -372,6 +387,88 @@ def _extract_audio_content(html: str) -> Dict:
|
|||
}
|
||||
|
||||
|
||||
def _extract_audio_share_content(html: str) -> Dict:
|
||||
"""
|
||||
Extract content from item_show_type=7 audio/video share pages.
|
||||
|
||||
These pages use dynamic Vue applications (common_share_audio module),
|
||||
so most content is loaded via JavaScript. We can only extract basic
|
||||
metadata from the static HTML.
|
||||
|
||||
Example: Podcast episodes, audio shows (e.g., 马刺进步报告)
|
||||
"""
|
||||
import html as html_module
|
||||
|
||||
# 提取标题
|
||||
title = ''
|
||||
title_match = (
|
||||
re.search(r'<meta\s+property="og:title"\s+content="([^"]+)"', html) or
|
||||
re.search(r"window\.msg_title\s*=\s*window\.title\s*=\s*'([^']*)'", html)
|
||||
)
|
||||
if title_match:
|
||||
title = html_module.unescape(title_match.group(1))
|
||||
|
||||
# 提取作者
|
||||
author = ''
|
||||
author_match = (
|
||||
re.search(r'<meta\s+property="og:article:author"\s+content="([^"]+)"', html) or
|
||||
re.search(r'var\s+nickname\s*=\s*"([^"]+)"', html)
|
||||
)
|
||||
if author_match:
|
||||
author = html_module.unescape(author_match.group(1))
|
||||
|
||||
# 提取封面图(如果有)
|
||||
images = []
|
||||
og_image_match = re.search(r'<meta\s+property="og:image"\s+content="([^"]+)"', html)
|
||||
if og_image_match:
|
||||
img_url = og_image_match.group(1)
|
||||
if img_url and ('mmbiz' in img_url or img_url.startswith('http')):
|
||||
images.append(img_url)
|
||||
|
||||
# 生成内容
|
||||
content_parts = []
|
||||
|
||||
# 封面图
|
||||
if images:
|
||||
for img_url in images:
|
||||
content_parts.append(
|
||||
f'<div style="text-align:center;margin:16px 0">'
|
||||
f'<img src="{img_url}" data-src="{img_url}" '
|
||||
f'style="max-width:100%;height:auto;border-radius:8px" />'
|
||||
f'</div>'
|
||||
)
|
||||
|
||||
# 音频占位符(使用中英双语,适配RSS阅读器)
|
||||
content_parts.append(
|
||||
'<div style="background:#f6f6f6;padding:20px;border-radius:8px;'
|
||||
'text-align:center;margin:20px 0;border:2px dashed #d9d9d9">'
|
||||
'<p style="margin:0;font-size:18px;color:#333">🎵 音频内容 / Audio Content</p>'
|
||||
'<p style="margin:12px 0;font-size:14px;color:#666;line-height:1.6">'
|
||||
'这是微信音频分享文章,内容通过JavaScript动态加载,无法直接提取。<br>'
|
||||
'This is a WeChat audio share article. Content is loaded dynamically via JavaScript.</p>'
|
||||
'<p style="margin:8px 0;font-size:13px;color:#999">'
|
||||
'请在微信中查看完整内容 / Please view in WeChat app</p>'
|
||||
'</div>'
|
||||
)
|
||||
|
||||
content = '\n'.join(content_parts)
|
||||
|
||||
# 纯文本
|
||||
plain_content = f"[音频分享文章 / Audio Share Article]\n\n"
|
||||
if title:
|
||||
plain_content += f"标题 / Title: {title}\n"
|
||||
if author:
|
||||
plain_content += f"作者 / Author: {author}\n"
|
||||
plain_content += "\n(此音频内容无法直接提取,请在微信中查看)"
|
||||
plain_content += "\n(Audio content cannot be extracted directly, please view in WeChat)"
|
||||
|
||||
return {
|
||||
'content': content,
|
||||
'plain_content': plain_content,
|
||||
'images': images,
|
||||
}
|
||||
|
||||
|
||||
def extract_article_info(html: str, params: Optional[Dict] = None) -> Dict:
|
||||
"""
|
||||
从HTML中提取文章信息
|
||||
|
|
@ -427,18 +524,29 @@ def extract_article_info(html: str, params: Optional[Dict] = None) -> Dict:
|
|||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# 检测特殊内容类型
|
||||
if is_image_text_message(html):
|
||||
# 优先处理特殊类型(按 item_show_type 判断)
|
||||
item_type = get_item_show_type(html)
|
||||
|
||||
if item_type == '7':
|
||||
# item_show_type=7: 音频/视频分享页面(动态Vue应用)
|
||||
audio_share_data = _extract_audio_share_content(html)
|
||||
content = audio_share_data['content']
|
||||
images = audio_share_data['images']
|
||||
plain_content = audio_share_data['plain_content']
|
||||
elif item_type == '8' or is_image_text_message(html):
|
||||
# item_show_type=8: 图文消息
|
||||
img_text_data = _extract_image_text_content(html)
|
||||
content = img_text_data['content']
|
||||
images = img_text_data['images']
|
||||
plain_content = img_text_data['plain_content']
|
||||
elif is_short_content_message(html):
|
||||
elif item_type == '10' or is_short_content_message(html):
|
||||
# item_show_type=10: 短内容/转发消息
|
||||
short_data = _extract_short_content(html)
|
||||
content = short_data['content']
|
||||
images = short_data['images']
|
||||
plain_content = short_data['plain_content']
|
||||
elif is_audio_message(html):
|
||||
# 音频文章(mpvoice / mp-common-mpaudio)
|
||||
audio_data = _extract_audio_content(html)
|
||||
content = audio_data['content']
|
||||
images = audio_data['images']
|
||||
|
|
@ -520,6 +628,12 @@ def has_article_content(html: str) -> bool:
|
|||
return True
|
||||
if is_image_text_message(html) or is_short_content_message(html) or is_audio_message(html):
|
||||
return True
|
||||
|
||||
# item_show_type=7: Audio/video share pages (dynamic Vue app)
|
||||
# These pages have no traditional content container, but are valid articles
|
||||
if get_item_show_type(html) == '7':
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
|
|
@ -554,14 +668,26 @@ def get_unavailable_reason(html: str) -> Optional[str]:
|
|||
"""
|
||||
Return human-readable reason if article is permanently unavailable, else None.
|
||||
返回文章不可用的原因,如果文章正常则返回 None。
|
||||
|
||||
Important: Must distinguish between:
|
||||
1. Verification pages (environment error) - NOT unavailable, should retry
|
||||
2. "暂时无法查看" standalone page - IS unavailable (HTML < 1KB, minimal structure)
|
||||
3. Privacy/payment pages (empty Vue app) - IS unavailable
|
||||
4. Truly unavailable articles (deleted/censored) - permanently unavailable
|
||||
"""
|
||||
# 优先排除:微信验证页面(这不是文章不可用,而是IP风控)
|
||||
# 特征:包含"环境异常"+"完成验证"+"去验证",且HTML较大(>1.5MB)
|
||||
verification_markers = ["环境异常", "完成验证后即可继续访问", "去验证"]
|
||||
if all(marker in html for marker in verification_markers):
|
||||
return None
|
||||
|
||||
# 真正的不可用标记(静态HTML中的明确文字)
|
||||
markers = [
|
||||
("该内容已被发布者删除", "已被发布者删除"),
|
||||
("内容已删除", "已被发布者删除"),
|
||||
("此内容因违规无法查看", "因违规无法查看"),
|
||||
("涉嫌违反相关法律法规和政策", "涉嫌违规被限制"),
|
||||
("此内容发送失败无法查看", "发送失败无法查看"),
|
||||
("该内容暂时无法查看", "暂时无法查看"),
|
||||
("根据作者隐私设置,无法查看该内容", "作者隐私设置不可见"),
|
||||
("接相关投诉,此内容违反", "因投诉违规被限制"),
|
||||
("该文章已被第三方辟谣", "已被第三方辟谣"),
|
||||
|
|
@ -569,6 +695,33 @@ def get_unavailable_reason(html: str) -> Optional[str]:
|
|||
for keyword, reason in markers:
|
||||
if keyword in html:
|
||||
return reason
|
||||
|
||||
# 特殊处理:"该内容暂时无法查看"独立页面
|
||||
# 特征:HTML很小(<2KB)+ <title>标签包含此文字 = 独立错误页面
|
||||
# 必须同时满足两个条件,避免误判正常文章中包含这句话的情况
|
||||
if "该内容暂时无法查看" in html and len(html) < 2000:
|
||||
import re
|
||||
title_match = re.search(r'<title>(.*?)</title>', html, re.IGNORECASE)
|
||||
if title_match and "该内容暂时无法查看" in title_match.group(1):
|
||||
return "暂时无法查看"
|
||||
|
||||
# 特殊处理:空Vue应用(隐私设置的动态错误页面)
|
||||
# 特征:<div id="app"></div> 是空的 + 无文章内容容器 + HTML不超大(<200KB)
|
||||
# 这种页面的错误提示通过JS动态加载,静态HTML中看不到
|
||||
# 实际显示:"根据作者隐私设置,无法查看该内容"
|
||||
if '<div id="app">' in html and len(html) < 200000:
|
||||
import re
|
||||
# 检查是否有实际的文章内容容器
|
||||
has_content_container = (
|
||||
'id="js_content"' in html or
|
||||
'class="rich_media_content' in html or
|
||||
'class="rich_media_area_primary_inner' in html
|
||||
)
|
||||
# 如果没有内容容器,且title为空,是隐私限制页面
|
||||
title_match = re.search(r'<title>(.*?)</title>', html, re.IGNORECASE)
|
||||
if not has_content_container and title_match and not title_match.group(1).strip():
|
||||
return "根据作者隐私设置不可查看"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue