wechat-article-parser 0.0.13__tar.gz → 0.0.15__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. {wechat_article_parser-0.0.13 → wechat_article_parser-0.0.15}/PKG-INFO +31 -3
  2. {wechat_article_parser-0.0.13 → wechat_article_parser-0.0.15}/README.md +30 -2
  3. {wechat_article_parser-0.0.13 → wechat_article_parser-0.0.15}/pyproject.toml +1 -1
  4. {wechat_article_parser-0.0.13 → wechat_article_parser-0.0.15}/src/wechat_article_parser/__init__.py +2 -1
  5. {wechat_article_parser-0.0.13 → wechat_article_parser-0.0.15}/src/wechat_article_parser/models.py +21 -1
  6. {wechat_article_parser-0.0.13 → wechat_article_parser-0.0.15}/src/wechat_article_parser/parser.py +97 -6
  7. wechat_article_parser-0.0.15/tests/test_article_urls.py +148 -0
  8. {wechat_article_parser-0.0.13 → wechat_article_parser-0.0.15}/tests/test_models.py +51 -1
  9. {wechat_article_parser-0.0.13 → wechat_article_parser-0.0.15}/tests/test_paid_content.py +84 -0
  10. {wechat_article_parser-0.0.13 → wechat_article_parser-0.0.15}/tests/test_parser.py +4 -0
  11. {wechat_article_parser-0.0.13 → wechat_article_parser-0.0.15}/.gitignore +0 -0
  12. {wechat_article_parser-0.0.13 → wechat_article_parser-0.0.15}/LICENSE +0 -0
  13. {wechat_article_parser-0.0.13 → wechat_article_parser-0.0.15}/tests/conftest.py +0 -0
  14. {wechat_article_parser-0.0.13 → wechat_article_parser-0.0.15}/tests/test_markdown.py +0 -0
  15. {wechat_article_parser-0.0.13 → wechat_article_parser-0.0.15}/tests/test_miniprogram_content.py +0 -0
  16. {wechat_article_parser-0.0.13 → wechat_article_parser-0.0.15}/tests/test_product_content.py +0 -0
  17. {wechat_article_parser-0.0.13 → wechat_article_parser-0.0.15}/tests/test_profile_content.py +0 -0
  18. {wechat_article_parser-0.0.13 → wechat_article_parser-0.0.15}/tests/test_repost_content.py +0 -0
  19. {wechat_article_parser-0.0.13 → wechat_article_parser-0.0.15}/tests/test_svg_content.py +0 -0
  20. {wechat_article_parser-0.0.13 → wechat_article_parser-0.0.15}/tests/test_swiper_content.py +0 -0
  21. {wechat_article_parser-0.0.13 → wechat_article_parser-0.0.15}/tests/test_type_routing.py +0 -0
  22. {wechat_article_parser-0.0.13 → wechat_article_parser-0.0.15}/tests/test_user_name.py +0 -0
  23. {wechat_article_parser-0.0.13 → wechat_article_parser-0.0.15}/tests/test_video_content.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: wechat-article-parser
3
- Version: 0.0.13
3
+ Version: 0.0.15
4
4
  Summary: WeChat MP article parser - extract metadata and content from WeChat public account articles
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -37,6 +37,7 @@ Markdown 转换前会展开无格式作用的 `span`,合并嵌套的重复加
37
37
  图文正文仅包含视频号视频卡片或微信视频 iframe 时,Markdown 返回“这是一篇纯视频内容。”。
38
38
  正文只有付费预览占位标签时,Markdown 返回“这是一篇付费内容,正文需付费后查看。”,
39
39
  并保留公开元数据;已有的可见预览正文仍正常提取。
40
+ 返回字段 `article_is_paid` 独立标记文章是否付费,有可见预览正文时也会识别。
40
41
  图文中的公众号推荐卡片按原文顺序转换为 `**公众号名称**:简介`,每张卡片一段;
41
42
  名称或简介缺失时,仅输出已有字段。
42
43
  图文正文仅包含商品广告卡片(`mp-common-cpsad`)时,Markdown 返回“这是一篇商品推荐内容。”;
@@ -108,6 +109,17 @@ result = parse(
108
109
  )
109
110
  ```
110
111
 
112
+ ### 长链接
113
+
114
+ 同步和异步接口均支持短链接 `/s/<22位文章ID>` 和长链接 `/s?__biz=...&mid=...&idx=...&sn=...&chksm=...`。
115
+ 根据原始输入 URL 识别链接类型:协议须为 HTTP/HTTPS,域名须为 `mp.weixin.qq.com`。
116
+ 长链接路径须为 `/s`,且 `__biz`、`mid`、`idx`、`sn`、`chksm` 五个查询参数必须都存在且有值,
117
+ 空字符串或纯空白不算有效值;重复参数中的每个值也须非空白。额外查询参数和片段不影响识别。
118
+ 未满足识别条件时,`article_url_type` 为 `unknown`,仍尝试解析页面,不额外抛出链接校验异常。
119
+
120
+ 长链接返回的 `article_id` 为空,其他字段继续从页面提取。
121
+ 只有识别为 `long` 时,`is_valid` 才允许缺少 `article_id`。
122
+
111
123
  ## 返回结构
112
124
 
113
125
  `parse` 和 `parse_async` 均返回 `ArticleResult` 数据类,包含以下字段:
@@ -122,7 +134,8 @@ result = parse(
122
134
  | `mp_image` | `str` | 公众号头像链接 |
123
135
  | `mp_description` | `str` | 公众号简介 |
124
136
  | `mp_account_type` | `AccountType` | 账号类型:`AccountType.SUBSCRIPTION`(订阅号)/ `AccountType.SERVICE`(服务号)/ `AccountType.UNKNOWN`(未识别) |
125
- | `article_id` | `str` | 文章 ID |
137
+ | `article_id` | `str` | 短链接中的文章 ID;长链接为空字符串 |
138
+ | `article_url_type` | `ArticleURLType` | 输入链接类型:`short`、`long` 或 `unknown`(默认);可从包顶层导入枚举,支持字符串比较和 JSON 序列化 |
126
139
  | `article_msg_id` | `int` | 文章所在的群发消息 ID |
127
140
  | `article_idx` | `int` | 群发图文中的位置(从 1 开始) |
128
141
  | `article_sn` | `str` | 文章签名(防伪校验) |
@@ -131,16 +144,31 @@ result = parse(
131
144
  | `article_description` | `str` | 文章摘要 |
132
145
  | `article_markdown` | `str` | 文章正文的 Markdown 内容;视频或音频分享没有文字附言时可为空 |
133
146
  | `article_publish_time` | `int` | 发布时间(Unix 时间戳) |
147
+ | `article_is_paid` | `bool` | 是否检测到当前文章的付费标记,默认 `False`;不表示是否已获取付费全文 |
134
148
  | `article_display_type` | `ArticleDisplayType` | 文章展示类型:`article`、`video`、`audio`、`image`、`text` 或 `unknown` |
135
149
  | `article_item_show_type` | `int \| None` | 微信页面中的原始展示类型:`0` 图文、`5` 视频、`7` 音频、`8` 图片轮播、`10` 文字;其他已提取的数值原样保留,缺失或无法解析时为 `None` |
136
150
  | `raw_html` | `str` | 原始 HTML 网页源代码(仅在 `include_raw_html=True` 时填充,否则为空字符串) |
137
151
  | `images` | `list[str]` | 文章中提取的所有图片链接 |
138
152
  | `is_valid` | `bool` | 关键字段是否全部解析成功(属性) |
139
153
 
140
- `is_valid` 为 `True` 的条件:`mp_id`、`mp_name`、`article_id`、`article_msg_id`、`article_idx`、`article_sn`、`article_title`、`article_publish_time` 均不为空/零,且 `article_markdown` 非空或 `article_display_type in ("video", "audio")`。视频和音频分享允许没有文字正文,仍须满足其他关键字段要求;有效性不表示已获取视频或音频文件。
154
+ `is_valid` 为 `True` 的条件:`mp_id`、`mp_name`、`article_msg_id`、`article_idx`、`article_sn`、`article_title`、`article_publish_time` 均不为空/零,且 `article_markdown` 非空或 `article_display_type in ("video", "audio")`。
155
+ 此外,`article_url_type == "long"` 时允许 `article_id` 为空;短链接和未识别链接仍要求 `article_id` 非空。
156
+ 视频和音频分享允许没有文字正文,仍须满足其他关键字段要求;有效性不表示已获取视频或音频文件。
141
157
 
142
158
  付费受限页面返回提示且关键元数据完整时,`is_valid` 也为 `True`,这不表示已获取付费正文。
143
159
 
160
+ ### 判断是否为付费文章
161
+
162
+ ```python
163
+ result = parse("https://mp.weixin.qq.com/s/xxxxx")
164
+ print(result.article_is_paid) # True:识别为付费文章
165
+ ```
166
+
167
+ `article_is_paid` 根据页面全局或 `window.cgiDataNew` 直接属性中的 `is_pay_subscribe`,
168
+ 以及正文中的 `mp-pay-preview-filter` 标签识别;未检测到付费标记时返回 `False`。
169
+ 该字段包含在 `dataclasses.asdict(result)` 中,JSON 序列化为布尔值。
170
+ 有可见预览或可访问正文的付费文章仍返回 `True`,正文按原有逻辑提取。
171
+
144
172
  ### 判断文章展示类型
145
173
 
146
174
  `ArticleDisplayType` 是字符串枚举,既支持枚举比较,也支持语义字符串比较:
@@ -22,6 +22,7 @@ Markdown 转换前会展开无格式作用的 `span`,合并嵌套的重复加
22
22
  图文正文仅包含视频号视频卡片或微信视频 iframe 时,Markdown 返回“这是一篇纯视频内容。”。
23
23
  正文只有付费预览占位标签时,Markdown 返回“这是一篇付费内容,正文需付费后查看。”,
24
24
  并保留公开元数据;已有的可见预览正文仍正常提取。
25
+ 返回字段 `article_is_paid` 独立标记文章是否付费,有可见预览正文时也会识别。
25
26
  图文中的公众号推荐卡片按原文顺序转换为 `**公众号名称**:简介`,每张卡片一段;
26
27
  名称或简介缺失时,仅输出已有字段。
27
28
  图文正文仅包含商品广告卡片(`mp-common-cpsad`)时,Markdown 返回“这是一篇商品推荐内容。”;
@@ -93,6 +94,17 @@ result = parse(
93
94
  )
94
95
  ```
95
96
 
97
+ ### 长链接
98
+
99
+ 同步和异步接口均支持短链接 `/s/<22位文章ID>` 和长链接 `/s?__biz=...&mid=...&idx=...&sn=...&chksm=...`。
100
+ 根据原始输入 URL 识别链接类型:协议须为 HTTP/HTTPS,域名须为 `mp.weixin.qq.com`。
101
+ 长链接路径须为 `/s`,且 `__biz`、`mid`、`idx`、`sn`、`chksm` 五个查询参数必须都存在且有值,
102
+ 空字符串或纯空白不算有效值;重复参数中的每个值也须非空白。额外查询参数和片段不影响识别。
103
+ 未满足识别条件时,`article_url_type` 为 `unknown`,仍尝试解析页面,不额外抛出链接校验异常。
104
+
105
+ 长链接返回的 `article_id` 为空,其他字段继续从页面提取。
106
+ 只有识别为 `long` 时,`is_valid` 才允许缺少 `article_id`。
107
+
96
108
  ## 返回结构
97
109
 
98
110
  `parse` 和 `parse_async` 均返回 `ArticleResult` 数据类,包含以下字段:
@@ -107,7 +119,8 @@ result = parse(
107
119
  | `mp_image` | `str` | 公众号头像链接 |
108
120
  | `mp_description` | `str` | 公众号简介 |
109
121
  | `mp_account_type` | `AccountType` | 账号类型:`AccountType.SUBSCRIPTION`(订阅号)/ `AccountType.SERVICE`(服务号)/ `AccountType.UNKNOWN`(未识别) |
110
- | `article_id` | `str` | 文章 ID |
122
+ | `article_id` | `str` | 短链接中的文章 ID;长链接为空字符串 |
123
+ | `article_url_type` | `ArticleURLType` | 输入链接类型:`short`、`long` 或 `unknown`(默认);可从包顶层导入枚举,支持字符串比较和 JSON 序列化 |
111
124
  | `article_msg_id` | `int` | 文章所在的群发消息 ID |
112
125
  | `article_idx` | `int` | 群发图文中的位置(从 1 开始) |
113
126
  | `article_sn` | `str` | 文章签名(防伪校验) |
@@ -116,16 +129,31 @@ result = parse(
116
129
  | `article_description` | `str` | 文章摘要 |
117
130
  | `article_markdown` | `str` | 文章正文的 Markdown 内容;视频或音频分享没有文字附言时可为空 |
118
131
  | `article_publish_time` | `int` | 发布时间(Unix 时间戳) |
132
+ | `article_is_paid` | `bool` | 是否检测到当前文章的付费标记,默认 `False`;不表示是否已获取付费全文 |
119
133
  | `article_display_type` | `ArticleDisplayType` | 文章展示类型:`article`、`video`、`audio`、`image`、`text` 或 `unknown` |
120
134
  | `article_item_show_type` | `int \| None` | 微信页面中的原始展示类型:`0` 图文、`5` 视频、`7` 音频、`8` 图片轮播、`10` 文字;其他已提取的数值原样保留,缺失或无法解析时为 `None` |
121
135
  | `raw_html` | `str` | 原始 HTML 网页源代码(仅在 `include_raw_html=True` 时填充,否则为空字符串) |
122
136
  | `images` | `list[str]` | 文章中提取的所有图片链接 |
123
137
  | `is_valid` | `bool` | 关键字段是否全部解析成功(属性) |
124
138
 
125
- `is_valid` 为 `True` 的条件:`mp_id`、`mp_name`、`article_id`、`article_msg_id`、`article_idx`、`article_sn`、`article_title`、`article_publish_time` 均不为空/零,且 `article_markdown` 非空或 `article_display_type in ("video", "audio")`。视频和音频分享允许没有文字正文,仍须满足其他关键字段要求;有效性不表示已获取视频或音频文件。
139
+ `is_valid` 为 `True` 的条件:`mp_id`、`mp_name`、`article_msg_id`、`article_idx`、`article_sn`、`article_title`、`article_publish_time` 均不为空/零,且 `article_markdown` 非空或 `article_display_type in ("video", "audio")`。
140
+ 此外,`article_url_type == "long"` 时允许 `article_id` 为空;短链接和未识别链接仍要求 `article_id` 非空。
141
+ 视频和音频分享允许没有文字正文,仍须满足其他关键字段要求;有效性不表示已获取视频或音频文件。
126
142
 
127
143
  付费受限页面返回提示且关键元数据完整时,`is_valid` 也为 `True`,这不表示已获取付费正文。
128
144
 
145
+ ### 判断是否为付费文章
146
+
147
+ ```python
148
+ result = parse("https://mp.weixin.qq.com/s/xxxxx")
149
+ print(result.article_is_paid) # True:识别为付费文章
150
+ ```
151
+
152
+ `article_is_paid` 根据页面全局或 `window.cgiDataNew` 直接属性中的 `is_pay_subscribe`,
153
+ 以及正文中的 `mp-pay-preview-filter` 标签识别;未检测到付费标记时返回 `False`。
154
+ 该字段包含在 `dataclasses.asdict(result)` 中,JSON 序列化为布尔值。
155
+ 有可见预览或可访问正文的付费文章仍返回 `True`,正文按原有逻辑提取。
156
+
129
157
  ### 判断文章展示类型
130
158
 
131
159
  `ArticleDisplayType` 是字符串枚举,既支持枚举比较,也支持语义字符串比较:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "wechat-article-parser"
7
- version = "0.0.13"
7
+ version = "0.0.15"
8
8
  description = "WeChat MP article parser - extract metadata and content from WeChat public account articles"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,4 +1,4 @@
1
- from .models import AccountType, ArticleDisplayType, ArticleResult, WeChatVerifyError
1
+ from .models import AccountType, ArticleDisplayType, ArticleResult, ArticleURLType, WeChatVerifyError
2
2
  from .parser import parse, parse_async
3
3
 
4
4
  __all__ = [
@@ -7,5 +7,6 @@ __all__ = [
7
7
  "ArticleResult",
8
8
  "AccountType",
9
9
  "ArticleDisplayType",
10
+ "ArticleURLType",
10
11
  "WeChatVerifyError",
11
12
  ]
@@ -22,6 +22,20 @@ class AccountType(str, Enum):
22
22
  return format(self.value, format_spec)
23
23
 
24
24
 
25
+ class ArticleURLType(str, Enum):
26
+ """输入链接类型;仅通过长链接校验时才允许缺少文章 ID。"""
27
+
28
+ UNKNOWN = "unknown"
29
+ SHORT = "short"
30
+ LONG = "long"
31
+
32
+ def __str__(self) -> str:
33
+ return self.value
34
+
35
+ def __format__(self, format_spec: str) -> str:
36
+ return format(self.value, format_spec)
37
+
38
+
25
39
  class ArticleDisplayType(str, Enum):
26
40
  """文章展示类型,支持枚举和语义字符串比较。"""
27
41
 
@@ -76,13 +90,19 @@ class ArticleResult:
76
90
  # 当前公众号的页面全局 user_name,未提取到时为空字符串。
77
91
  mp_user_name: str = ""
78
92
 
93
+ # 页面是否包含当前文章的付费标记;不表示是否已获取付费全文。
94
+ article_is_paid: bool = False
95
+
96
+ # 根据原始输入 URL 识别,不根据 article_id 是否为空反推。
97
+ article_url_type: ArticleURLType = ArticleURLType.UNKNOWN
98
+
79
99
  @property
80
100
  def is_valid(self) -> bool:
81
101
  """检查关键字段是否解析成功;视频和音频分享允许正文为空。"""
82
102
  return bool(
83
103
  self.mp_id
84
104
  and self.mp_name
85
- and self.article_id
105
+ and (self.article_id or self.article_url_type == ArticleURLType.LONG)
86
106
  and self.article_msg_id
87
107
  and self.article_idx
88
108
  and self.article_sn
@@ -7,13 +7,13 @@ import html as html_module
7
7
  import re
8
8
  from collections import deque
9
9
  from dataclasses import replace
10
- from urllib.parse import unquote
10
+ from urllib.parse import parse_qs, unquote, urlsplit
11
11
 
12
12
  import httpx
13
13
  from bs4 import BeautifulSoup, Tag
14
14
  from markdownify import MarkdownConverter
15
15
 
16
- from .models import AccountType, ArticleDisplayType, ArticleResult, WeChatVerifyError
16
+ from .models import AccountType, ArticleDisplayType, ArticleResult, ArticleURLType, WeChatVerifyError
17
17
 
18
18
  _USER_AGENT = (
19
19
  "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
@@ -216,10 +216,33 @@ def _extract_picture_cdn_urls(script_text: str) -> list[str]:
216
216
  # 内部提取函数
217
217
  # ---------------------------------------------------------------------------
218
218
 
219
+ def _extract_article_url_type(url: str) -> ArticleURLType:
220
+ """识别输入链接;长链接的五个必需参数均须存在且非空白。"""
221
+ try:
222
+ parts = urlsplit(url)
223
+ except ValueError:
224
+ return ArticleURLType.UNKNOWN
225
+ if (
226
+ parts.scheme not in ("http", "https")
227
+ or parts.hostname != "mp.weixin.qq.com"
228
+ or parts.username is not None
229
+ or parts.password is not None
230
+ ):
231
+ return ArticleURLType.UNKNOWN
232
+ if re.fullmatch(r"/s/[A-Za-z0-9_-]{22}", parts.path):
233
+ return ArticleURLType.SHORT
234
+ if parts.path == "/s":
235
+ params = parse_qs(parts.query, keep_blank_values=True)
236
+ required = ("__biz", "mid", "idx", "sn", "chksm")
237
+ # 保留空值,重复参数中出现空值也不能放行。
238
+ if all(key in params and all(value.strip() for value in params[key]) for key in required):
239
+ return ArticleURLType.LONG
240
+ return ArticleURLType.UNKNOWN
241
+
242
+
219
243
  def _extract_article_id(url: str) -> str:
220
- parts = url.split("/")
221
- if len(parts) == 5 and len(parts[4]) == 22:
222
- return parts[4]
244
+ if _extract_article_url_type(url) == ArticleURLType.SHORT:
245
+ return urlsplit(url).path.rsplit("/", 1)[1]
223
246
  return ""
224
247
 
225
248
 
@@ -272,6 +295,69 @@ def _extract_item_show_type(scripts: list[Tag]) -> int | None:
272
295
  return None
273
296
 
274
297
 
298
+ def _extract_is_paid(soup: BeautifulSoup, scripts: list[Tag]) -> bool:
299
+ """识别当前文章的付费标记,排除正文外占位符和嵌套推荐文章的数据。"""
300
+ if soup.select_one(
301
+ "div.rich_media_content mp-pay-preview-filter, "
302
+ "div.original_page mp-pay-preview-filter"
303
+ ) is not None:
304
+ return True
305
+
306
+ for script in scripts:
307
+ if "is_pay_subscribe" not in script.text:
308
+ continue
309
+ tokens = [
310
+ match.group() for match in _JS_DATA_TOKEN_RE.finditer(script.text)
311
+ if match.lastgroup != "comment"
312
+ ]
313
+ stack: list[str] = []
314
+ cgi_depth: int | None = None
315
+ for i, token in enumerate(tokens):
316
+ if token == "{":
317
+ if (
318
+ tokens[max(0, i - 4):i] == ["window", ".", "cgiDataNew", "="]
319
+ and (i < 5 or tokens[i - 5] != ".")
320
+ ):
321
+ cgi_depth = len(stack) + 1
322
+ stack.append(token)
323
+ elif token in ("[", "("):
324
+ stack.append(token)
325
+ elif token in ("}", "]", ")"):
326
+ if stack and stack[-1] == {"}": "{", "]": "[", ")": "("}[token]:
327
+ if len(stack) == cgi_depth:
328
+ cgi_depth = None
329
+ stack.pop()
330
+ if token.strip("\"'") != "is_pay_subscribe":
331
+ continue
332
+ is_global = token == "is_pay_subscribe" and (
333
+ (not stack and i > 0 and tokens[i - 1] in ("var", "let", "const"))
334
+ or (
335
+ tokens[max(0, i - 2):i] == ["window", "."]
336
+ and (i < 3 or tokens[i - 3] != ".")
337
+ )
338
+ )
339
+ is_cgi_field = (
340
+ cgi_depth is not None and len(stack) == cgi_depth
341
+ and i > 0 and tokens[i - 1] in ("{", ",")
342
+ )
343
+ separator = tokens[i + 1:i + 2]
344
+ if not (
345
+ (is_global and separator == ["="])
346
+ or (is_cgi_field and separator == [":"])
347
+ ):
348
+ continue
349
+ # 仅识别页面使用的字面量及乘 1 转数值写法,不执行 JS 表达式。
350
+ value = tokens[i + 2:i + 3]
351
+ if value not in (["1"], ["'1'"], ['"1"'], ["true"]):
352
+ continue
353
+ end = i + 3
354
+ if value != ["true"] and tokens[end:end + 2] == ["*", "1"]:
355
+ end += 2
356
+ if end == len(tokens) or tokens[end] in (",", ";", "}"):
357
+ return True
358
+ return False
359
+
360
+
275
361
  def _extract_user_name(scripts: list[Tag]) -> str:
276
362
  """读取全局 user_name,缺失时读取 window.cgiDataNew 的直接属性。
277
363
 
@@ -469,6 +555,8 @@ def _extract_rich_media_content(content_tag: Tag, result: ArticleResult) -> None
469
555
  """将 rich_media_content 区块转换为 Markdown。"""
470
556
  # prettify 会在行内标签之间插入空白,改变代码和引用链接的内容。
471
557
  soup = BeautifulSoup(str(content_tag), "html.parser")
558
+ if soup.find("mp-pay-preview-filter") is not None:
559
+ result.article_is_paid = True
472
560
  has_video = soup.select_one(
473
561
  'mp-common-videosnap[data-type="video"], iframe.video_iframe, iframe.wx_video_iframe'
474
562
  ) is not None
@@ -774,12 +862,15 @@ def _parse_html(url: str, html: str) -> ArticleResult:
774
862
  result = ArticleResult()
775
863
  soup = BeautifulSoup(html, "html.parser")
776
864
 
865
+ result.article_url_type = _extract_article_url_type(url)
777
866
  result.article_id = _extract_article_id(url)
778
867
  _extract_meta(soup, result)
779
868
 
780
869
  scripts = soup.find_all("script", attrs={"type": "text/javascript"})
781
870
 
782
- result.mp_user_name = _extract_user_name(soup.find_all("script"))
871
+ all_scripts = soup.find_all("script")
872
+ result.mp_user_name = _extract_user_name(all_scripts)
873
+ result.article_is_paid = _extract_is_paid(soup, all_scripts)
783
874
  item_show_type = _extract_item_show_type(scripts)
784
875
  result.article_item_show_type = item_show_type
785
876
  result.article_display_type = _ARTICLE_DISPLAY_TYPES.get(
@@ -0,0 +1,148 @@
1
+ """长短链接识别及有效性判断的离线回归测试。"""
2
+
3
+ from dataclasses import replace
4
+ from urllib.parse import urlencode
5
+
6
+ import httpx
7
+ import pytest
8
+
9
+ from wechat_article_parser import ArticleURLType, parser
10
+
11
+
12
+ _SHORT_URL = "https://mp.weixin.qq.com/s/ncPoZnxKdvDRLM_s4DBgmA"
13
+ _PARAMS = {
14
+ "__biz": "MjY2NzgwMjU0MA==",
15
+ "mid": "2650331950",
16
+ "idx": "3",
17
+ "sn": "59630a9449c484f555c71978ee521cd8",
18
+ "chksm": "ae4b5d1c0343c5bf3af413da074abb54a3d56268937ba1825fa518c6ba4b1c96c34675d15d9e",
19
+ }
20
+ _LONG_URL = "https://mp.weixin.qq.com/s?" + urlencode(_PARAMS)
21
+ _HTML = """
22
+ <meta property="og:title" content="长链接测试文章">
23
+ <div class="rich_media_content"><p>正文</p></div>
24
+ <script type="text/javascript">
25
+ var item_show_type = "0";
26
+ var hd_head_img = "https://example.com/avatar";
27
+ var nickname = htmlDecode("测试公众号");
28
+ var oriCreateTime = '1791884914';
29
+ window.__allowLoadResFromMp = true;
30
+ var biz = "MjY2NzgwMjU0MA==";
31
+ var mid = "2650331950";
32
+ var idx = "3";
33
+ var sn = "59630a9449c484f555c71978ee521cd8";
34
+ </script>
35
+ """
36
+
37
+
38
+ @pytest.mark.parametrize("scheme", ["http", "https"])
39
+ @pytest.mark.parametrize("suffix", ["", "&scene=21#wechat_redirect"])
40
+ def test_long_url_parses_without_article_id(scheme: str, suffix: str) -> None:
41
+ result = parser._parse_html(_LONG_URL.replace("https:", scheme + ":") + suffix, _HTML)
42
+
43
+ assert result.article_url_type is ArticleURLType.LONG
44
+ assert result.article_id == ""
45
+ assert result.mp_id == 2667802540
46
+ assert result.article_msg_id == 2650331950
47
+ assert result.article_idx == 3
48
+ assert result.article_sn == _PARAMS["sn"]
49
+ assert result.article_markdown == "正文"
50
+ assert result.is_valid
51
+
52
+
53
+ @pytest.mark.parametrize("key", _PARAMS)
54
+ @pytest.mark.parametrize("value", [None, "", " \t\n"])
55
+ def test_long_url_requires_each_parameter_with_value(key: str, value: str | None) -> None:
56
+ params = _PARAMS.copy()
57
+ if value is None:
58
+ del params[key]
59
+ else:
60
+ params[key] = value
61
+ result = parser._parse_html("https://mp.weixin.qq.com/s?" + urlencode(params), _HTML)
62
+
63
+ assert result.article_url_type is ArticleURLType.UNKNOWN
64
+ assert result.article_id == ""
65
+ assert not result.is_valid
66
+
67
+
68
+ @pytest.mark.parametrize("key", _PARAMS)
69
+ def test_duplicate_blank_parameter_does_not_pass_validation(key: str) -> None:
70
+ result = parser._parse_html(_LONG_URL + "&" + key + "=", _HTML)
71
+ assert result.article_url_type is ArticleURLType.UNKNOWN
72
+ assert not result.is_valid
73
+
74
+
75
+ @pytest.mark.parametrize(
76
+ "base",
77
+ [
78
+ "https://example.com/s",
79
+ "https://mp.weixin.qq.com.example.com/s",
80
+ "https://mp.weixin.qq.com@evil.example/s",
81
+ "https://user@mp.weixin.qq.com/s",
82
+ "ftp://mp.weixin.qq.com/s",
83
+ "//mp.weixin.qq.com/s",
84
+ "https://mp.weixin.qq.com/s/",
85
+ "https://mp.weixin.qq.com/other",
86
+ "https://[invalid/s",
87
+ ],
88
+ )
89
+ def test_unrecognized_url_cannot_skip_article_id(base: str) -> None:
90
+ result = parser._parse_html(base + "?" + urlencode(_PARAMS), _HTML)
91
+ assert result.article_url_type is ArticleURLType.UNKNOWN
92
+ assert not result.is_valid
93
+
94
+
95
+ def test_required_parameters_in_fragment_do_not_count() -> None:
96
+ result = parser._parse_html("https://mp.weixin.qq.com/s#" + urlencode(_PARAMS), _HTML)
97
+ assert result.article_url_type is ArticleURLType.UNKNOWN
98
+ assert not result.is_valid
99
+
100
+
101
+ @pytest.mark.parametrize("suffix", ["", "?scene=21#wechat_redirect", "?" + urlencode(_PARAMS)])
102
+ def test_short_url_still_requires_article_id(suffix: str) -> None:
103
+ result = parser._parse_html(_SHORT_URL + suffix, _HTML)
104
+ assert result.article_url_type is ArticleURLType.SHORT
105
+ assert result.article_id == "ncPoZnxKdvDRLM_s4DBgmA"
106
+ assert result.is_valid
107
+ assert not replace(result, article_id="").is_valid
108
+
109
+
110
+ def test_query_parameters_do_not_replace_missing_page_metadata() -> None:
111
+ result = parser._parse_html(_LONG_URL, '<div class="rich_media_content">正文</div>')
112
+ assert result.article_url_type is ArticleURLType.LONG
113
+ assert not result.is_valid
114
+
115
+
116
+ @pytest.fixture
117
+ def mock_article_http(monkeypatch):
118
+ # 重定向到短链接后仍根据原始输入链接识别类型。
119
+ def respond(request):
120
+ if request.url.path == "/s":
121
+ return httpx.Response(302, headers={"Location": _SHORT_URL})
122
+ return httpx.Response(200, text=_HTML)
123
+
124
+ transport = httpx.MockTransport(respond)
125
+ client, async_client = httpx.Client, httpx.AsyncClient
126
+ monkeypatch.setattr(
127
+ parser.httpx, "Client", lambda **kwargs: client(transport=transport, **kwargs),
128
+ )
129
+ monkeypatch.setattr(
130
+ parser.httpx, "AsyncClient", lambda **kwargs: async_client(transport=transport, **kwargs),
131
+ )
132
+
133
+
134
+ def test_sync_public_entry_accepts_long_url(mock_article_http) -> None:
135
+ result = parser.parse(_LONG_URL, include_raw_html=True)
136
+ assert result.article_url_type is ArticleURLType.LONG
137
+ assert result.article_id == ""
138
+ assert result.is_valid
139
+ assert result.raw_html == _HTML
140
+
141
+
142
+ @pytest.mark.asyncio
143
+ async def test_async_public_entry_accepts_long_url(mock_article_http) -> None:
144
+ result = await parser.parse_async(_LONG_URL, include_raw_html=True)
145
+ assert result.article_url_type is ArticleURLType.LONG
146
+ assert result.article_id == ""
147
+ assert result.is_valid
148
+ assert result.raw_html == _HTML
@@ -5,7 +5,7 @@ import json
5
5
 
6
6
  import pytest
7
7
 
8
- from wechat_article_parser import ArticleDisplayType, ArticleResult
8
+ from wechat_article_parser import ArticleDisplayType, ArticleResult, ArticleURLType
9
9
 
10
10
 
11
11
  _COMPLETE = ArticleResult(
@@ -91,3 +91,53 @@ def test_default_display_type_is_unknown() -> None:
91
91
  assert result.article_display_type is ArticleDisplayType.UNKNOWN
92
92
  assert result.article_item_show_type is None
93
93
  assert not result.is_valid
94
+
95
+
96
+ @pytest.mark.parametrize("is_paid", [False, True])
97
+ def test_paid_field_serializes_as_boolean(is_paid: bool) -> None:
98
+ result = ArticleResult(article_is_paid=is_paid)
99
+ assert json.loads(json.dumps(asdict(result)))["article_is_paid"] is is_paid
100
+ assert ArticleResult().article_is_paid is False
101
+
102
+
103
+ @pytest.mark.parametrize("url_type", list(ArticleURLType))
104
+ @pytest.mark.parametrize("display_type", list(ArticleDisplayType))
105
+ @pytest.mark.parametrize("markdown", ["", "正文"])
106
+ def test_only_long_url_can_omit_article_id(
107
+ url_type: ArticleURLType, display_type: ArticleDisplayType, markdown: str,
108
+ ) -> None:
109
+ result = replace(
110
+ _COMPLETE, article_id="", article_url_type=url_type,
111
+ article_display_type=display_type, article_markdown=markdown,
112
+ )
113
+ assert result.is_valid is (
114
+ url_type == ArticleURLType.LONG
115
+ and (bool(markdown) or display_type in (ArticleDisplayType.VIDEO, ArticleDisplayType.AUDIO))
116
+ )
117
+
118
+
119
+ @pytest.mark.parametrize(
120
+ ("field_name", "empty_value"),
121
+ [
122
+ ("mp_id", 0), ("mp_name", ""), ("article_msg_id", 0),
123
+ ("article_idx", 0), ("article_sn", ""), ("article_title", ""),
124
+ ("article_publish_time", 0),
125
+ ],
126
+ )
127
+ def test_long_url_still_requires_other_key_metadata(
128
+ field_name: str, empty_value: str | int,
129
+ ) -> None:
130
+ result = replace(
131
+ _COMPLETE, article_id="", article_url_type=ArticleURLType.LONG,
132
+ article_markdown="正文", **{field_name: empty_value},
133
+ )
134
+ assert not result.is_valid
135
+
136
+
137
+ @pytest.mark.parametrize("url_type", list(ArticleURLType))
138
+ def test_url_type_serializes_as_semantic_string(url_type: ArticleURLType) -> None:
139
+ result = ArticleResult(article_url_type=url_type)
140
+ assert str(url_type) == url_type.value
141
+ assert f"{url_type:>10}" == f"{url_type.value:>10}"
142
+ assert json.loads(json.dumps(asdict(result)))["article_url_type"] == url_type.value
143
+ assert ArticleResult().article_url_type is ArticleURLType.UNKNOWN
@@ -51,6 +51,7 @@ def test_paid_placeholder_keeps_metadata_and_does_not_use_unrelated_images(
51
51
  result = _parse_html(_URL, _html(_FILTER, metadata, data))
52
52
 
53
53
  assert result.article_markdown == _NOTICE
54
+ assert result.article_is_paid is True
54
55
  assert result.images == []
55
56
  assert result.article_display_type is ArticleDisplayType.ARTICLE
56
57
  assert result.article_item_show_type == 0
@@ -73,6 +74,7 @@ def test_paid_filter_does_not_replace_visible_preview(body: str) -> None:
73
74
  result = _parse_html(_URL, _html(body + _FILTER))
74
75
 
75
76
  assert result.is_valid
77
+ assert result.article_is_paid is True
76
78
  assert _NOTICE not in result.article_markdown
77
79
  if "免费预览" in body:
78
80
  assert result.article_markdown == "可见的免费预览正文。"
@@ -86,6 +88,7 @@ def test_paid_filter_takes_priority_over_empty_video_embed() -> None:
86
88
  result = _parse_html(_URL, _html(body))
87
89
 
88
90
  assert result.is_valid
91
+ assert result.article_is_paid is True
89
92
  assert result.article_markdown == _NOTICE
90
93
 
91
94
 
@@ -93,6 +96,7 @@ def test_paid_marker_outside_article_does_not_fill_empty_body() -> None:
93
96
  result = _parse_html(_URL, _html("") + _FILTER)
94
97
 
95
98
  assert result.article_markdown == ""
99
+ assert result.article_is_paid is False
96
100
  assert not result.is_valid
97
101
 
98
102
 
@@ -103,4 +107,84 @@ def test_paid_flag_without_filter_keeps_accessible_body() -> None:
103
107
  )
104
108
 
105
109
  assert result.is_valid
110
+ assert result.article_is_paid is True
106
111
  assert result.article_markdown == "已提供的完整正文。"
112
+
113
+
114
+ @pytest.mark.parametrize("value", ["1", "'1'", '"1"', "'1' * 1", '"1" * 1', "true"])
115
+ @pytest.mark.parametrize("template", [
116
+ "var is_pay_subscribe = VALUE;",
117
+ "window.is_pay_subscribe = VALUE;",
118
+ "window.cgiDataNew = {is_pay_subscribe: VALUE};",
119
+ 'window.cgiDataNew = {"is_pay_subscribe": VALUE, other: 0};',
120
+ ])
121
+ def test_paid_flag_forms(value: str, template: str) -> None:
122
+ result = _parse_html(
123
+ _URL, _html("<p>可见正文</p>", data=template.replace("VALUE", value)),
124
+ )
125
+
126
+ assert result.article_is_paid is True
127
+ assert result.article_markdown == "可见正文"
128
+ assert result.is_valid
129
+
130
+
131
+ @pytest.mark.parametrize("data", [
132
+ "",
133
+ "var is_pay_subscribe = 0;",
134
+ "window.cgiDataNew = {is_pay_subscribe: '0' * 1};",
135
+ "window.cgiDataNew = {is_pay_subscribe: false};",
136
+ "window.cgiDataNew = {is_pay_subscribe: '10'};",
137
+ "var is_pay_subscribe = '1' * 0;",
138
+ "var is_pay_subscribe = 1 === 0;",
139
+ "// var is_pay_subscribe = 1;",
140
+ "/* window.cgiDataNew = {is_pay_subscribe: 1}; */",
141
+ 'var text = "var is_pay_subscribe = 1;";',
142
+ "var recommendation = {is_pay_subscribe: 1};",
143
+ "window.cgiDataNew = {card: {is_pay_subscribe: 1}, is_pay_subscribe: 0};",
144
+ "window.cgiDataNew = {cards: [{is_pay_subscribe: 1}]};",
145
+ "function unrelated() { var is_pay_subscribe = 1; }",
146
+ "other.window.cgiDataNew = {is_pay_subscribe: 1};",
147
+ "other.window.is_pay_subscribe = 1;",
148
+ ])
149
+ def test_missing_free_or_unrelated_paid_flags(data: str) -> None:
150
+ result = _parse_html(_URL, _html("<p>正文提到付费,并不代表本文收费。</p>", data=data))
151
+
152
+ assert result.article_is_paid is False
153
+ assert result.is_valid
154
+
155
+
156
+ def test_paid_flag_survives_content_fallback() -> None:
157
+ html = _html("", data="window.cgiDataNew = {is_pay_subscribe: 1};")
158
+ html += '<div class="original_page"><p id="js_share_notice">转载正文</p></div>'
159
+ result = _parse_html(_URL, html)
160
+
161
+ assert result.article_is_paid is True
162
+ assert result.article_markdown == "转载正文"
163
+
164
+
165
+ def test_paid_flag_without_content_or_script_type() -> None:
166
+ result = _parse_html(_URL, "<script>var is_pay_subscribe = 1;</script>")
167
+
168
+ assert result.article_is_paid is True
169
+ assert not result.is_valid
170
+
171
+
172
+ def test_paid_preview_marker_in_repost() -> None:
173
+ result = _parse_html(
174
+ _URL, f'<div class="original_page"><p>转载预览</p>{_FILTER}</div>',
175
+ )
176
+
177
+ assert result.article_is_paid is True
178
+
179
+
180
+ def test_paid_preview_marker_in_script_repost_content() -> None:
181
+ html = (
182
+ '<div class="original_page"></div>'
183
+ '<script type="text/javascript">window.cgiDataNew = {'
184
+ """content: '<p>预览</p><mp-pay-preview-filter></mp-pay-preview-filter>'"""
185
+ '};</script>'
186
+ )
187
+ result = _parse_html(_URL, html)
188
+
189
+ assert result.article_is_paid is True
190
+ assert result.article_markdown == "预览"
@@ -80,12 +80,14 @@ def _assert_result(result: ArticleResult, url: str) -> None:
80
80
  else "公众号简介: (empty)"
81
81
  )
82
82
  print(f"文章ID: {result.article_id}")
83
+ print(f"链接类型: {result.article_url_type}")
83
84
  print(f"群发消息ID: {result.article_msg_id}")
84
85
  print(f"文章idx: {result.article_idx}")
85
86
  print(f"文章签名: {result.article_sn}")
86
87
  print(f"文章标题: {result.article_title}")
87
88
  print(f"展示类型: {result.article_display_type}")
88
89
  print(f"原始展示类型: {result.article_item_show_type}")
90
+ print(f"是否付费: {result.article_is_paid}")
89
91
  print(
90
92
  f"封面图: {result.article_cover_image[:80]}..."
91
93
  if result.article_cover_image
@@ -133,12 +135,14 @@ def test_fetch_all(url: str, proxy: str | None) -> None:
133
135
  print(f"公众号图片: {result.mp_image}")
134
136
  print(f"公众号简介: {result.mp_description}")
135
137
  print(f"文章ID: {result.article_id}")
138
+ print(f"链接类型: {result.article_url_type}")
136
139
  print(f"群发消息ID: {result.article_msg_id}")
137
140
  print(f"文章idx: {result.article_idx}")
138
141
  print(f"文章签名: {result.article_sn}")
139
142
  print(f"文章标题: {result.article_title}")
140
143
  print(f"展示类型: {result.article_display_type}")
141
144
  print(f"原始展示类型: {result.article_item_show_type}")
145
+ print(f"是否付费: {result.article_is_paid}")
142
146
  print(f"封面图: {result.article_cover_image}")
143
147
  print(f"文章摘要: {result.article_description}")
144
148
  print(f"发布时间: {result.article_publish_time}")