wechat-article-parser 0.0.13__tar.gz → 0.0.14__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 (22) hide show
  1. {wechat_article_parser-0.0.13 → wechat_article_parser-0.0.14}/PKG-INFO +15 -1
  2. {wechat_article_parser-0.0.13 → wechat_article_parser-0.0.14}/README.md +14 -0
  3. {wechat_article_parser-0.0.13 → wechat_article_parser-0.0.14}/pyproject.toml +1 -1
  4. {wechat_article_parser-0.0.13 → wechat_article_parser-0.0.14}/src/wechat_article_parser/models.py +3 -0
  5. {wechat_article_parser-0.0.13 → wechat_article_parser-0.0.14}/src/wechat_article_parser/parser.py +68 -1
  6. {wechat_article_parser-0.0.13 → wechat_article_parser-0.0.14}/tests/test_models.py +7 -0
  7. {wechat_article_parser-0.0.13 → wechat_article_parser-0.0.14}/tests/test_paid_content.py +84 -0
  8. {wechat_article_parser-0.0.13 → wechat_article_parser-0.0.14}/tests/test_parser.py +2 -0
  9. {wechat_article_parser-0.0.13 → wechat_article_parser-0.0.14}/.gitignore +0 -0
  10. {wechat_article_parser-0.0.13 → wechat_article_parser-0.0.14}/LICENSE +0 -0
  11. {wechat_article_parser-0.0.13 → wechat_article_parser-0.0.14}/src/wechat_article_parser/__init__.py +0 -0
  12. {wechat_article_parser-0.0.13 → wechat_article_parser-0.0.14}/tests/conftest.py +0 -0
  13. {wechat_article_parser-0.0.13 → wechat_article_parser-0.0.14}/tests/test_markdown.py +0 -0
  14. {wechat_article_parser-0.0.13 → wechat_article_parser-0.0.14}/tests/test_miniprogram_content.py +0 -0
  15. {wechat_article_parser-0.0.13 → wechat_article_parser-0.0.14}/tests/test_product_content.py +0 -0
  16. {wechat_article_parser-0.0.13 → wechat_article_parser-0.0.14}/tests/test_profile_content.py +0 -0
  17. {wechat_article_parser-0.0.13 → wechat_article_parser-0.0.14}/tests/test_repost_content.py +0 -0
  18. {wechat_article_parser-0.0.13 → wechat_article_parser-0.0.14}/tests/test_svg_content.py +0 -0
  19. {wechat_article_parser-0.0.13 → wechat_article_parser-0.0.14}/tests/test_swiper_content.py +0 -0
  20. {wechat_article_parser-0.0.13 → wechat_article_parser-0.0.14}/tests/test_type_routing.py +0 -0
  21. {wechat_article_parser-0.0.13 → wechat_article_parser-0.0.14}/tests/test_user_name.py +0 -0
  22. {wechat_article_parser-0.0.13 → wechat_article_parser-0.0.14}/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.14
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 返回“这是一篇商品推荐内容。”;
@@ -131,6 +132,7 @@ result = parse(
131
132
  | `article_description` | `str` | 文章摘要 |
132
133
  | `article_markdown` | `str` | 文章正文的 Markdown 内容;视频或音频分享没有文字附言时可为空 |
133
134
  | `article_publish_time` | `int` | 发布时间(Unix 时间戳) |
135
+ | `article_is_paid` | `bool` | 是否检测到当前文章的付费标记,默认 `False`;不表示是否已获取付费全文 |
134
136
  | `article_display_type` | `ArticleDisplayType` | 文章展示类型:`article`、`video`、`audio`、`image`、`text` 或 `unknown` |
135
137
  | `article_item_show_type` | `int \| None` | 微信页面中的原始展示类型:`0` 图文、`5` 视频、`7` 音频、`8` 图片轮播、`10` 文字;其他已提取的数值原样保留,缺失或无法解析时为 `None` |
136
138
  | `raw_html` | `str` | 原始 HTML 网页源代码(仅在 `include_raw_html=True` 时填充,否则为空字符串) |
@@ -141,6 +143,18 @@ result = parse(
141
143
 
142
144
  付费受限页面返回提示且关键元数据完整时,`is_valid` 也为 `True`,这不表示已获取付费正文。
143
145
 
146
+ ### 判断是否为付费文章
147
+
148
+ ```python
149
+ result = parse("https://mp.weixin.qq.com/s/xxxxx")
150
+ print(result.article_is_paid) # True:识别为付费文章
151
+ ```
152
+
153
+ `article_is_paid` 根据页面全局或 `window.cgiDataNew` 直接属性中的 `is_pay_subscribe`,
154
+ 以及正文中的 `mp-pay-preview-filter` 标签识别;未检测到付费标记时返回 `False`。
155
+ 该字段包含在 `dataclasses.asdict(result)` 中,JSON 序列化为布尔值。
156
+ 有可见预览或可访问正文的付费文章仍返回 `True`,正文按原有逻辑提取。
157
+
144
158
  ### 判断文章展示类型
145
159
 
146
160
  `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 返回“这是一篇商品推荐内容。”;
@@ -116,6 +117,7 @@ result = parse(
116
117
  | `article_description` | `str` | 文章摘要 |
117
118
  | `article_markdown` | `str` | 文章正文的 Markdown 内容;视频或音频分享没有文字附言时可为空 |
118
119
  | `article_publish_time` | `int` | 发布时间(Unix 时间戳) |
120
+ | `article_is_paid` | `bool` | 是否检测到当前文章的付费标记,默认 `False`;不表示是否已获取付费全文 |
119
121
  | `article_display_type` | `ArticleDisplayType` | 文章展示类型:`article`、`video`、`audio`、`image`、`text` 或 `unknown` |
120
122
  | `article_item_show_type` | `int \| None` | 微信页面中的原始展示类型:`0` 图文、`5` 视频、`7` 音频、`8` 图片轮播、`10` 文字;其他已提取的数值原样保留,缺失或无法解析时为 `None` |
121
123
  | `raw_html` | `str` | 原始 HTML 网页源代码(仅在 `include_raw_html=True` 时填充,否则为空字符串) |
@@ -126,6 +128,18 @@ result = parse(
126
128
 
127
129
  付费受限页面返回提示且关键元数据完整时,`is_valid` 也为 `True`,这不表示已获取付费正文。
128
130
 
131
+ ### 判断是否为付费文章
132
+
133
+ ```python
134
+ result = parse("https://mp.weixin.qq.com/s/xxxxx")
135
+ print(result.article_is_paid) # True:识别为付费文章
136
+ ```
137
+
138
+ `article_is_paid` 根据页面全局或 `window.cgiDataNew` 直接属性中的 `is_pay_subscribe`,
139
+ 以及正文中的 `mp-pay-preview-filter` 标签识别;未检测到付费标记时返回 `False`。
140
+ 该字段包含在 `dataclasses.asdict(result)` 中,JSON 序列化为布尔值。
141
+ 有可见预览或可访问正文的付费文章仍返回 `True`,正文按原有逻辑提取。
142
+
129
143
  ### 判断文章展示类型
130
144
 
131
145
  `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.14"
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"
@@ -76,6 +76,9 @@ class ArticleResult:
76
76
  # 当前公众号的页面全局 user_name,未提取到时为空字符串。
77
77
  mp_user_name: str = ""
78
78
 
79
+ # 页面是否包含当前文章的付费标记;不表示是否已获取付费全文。
80
+ article_is_paid: bool = False
81
+
79
82
  @property
80
83
  def is_valid(self) -> bool:
81
84
  """检查关键字段是否解析成功;视频和音频分享允许正文为空。"""
@@ -272,6 +272,69 @@ def _extract_item_show_type(scripts: list[Tag]) -> int | None:
272
272
  return None
273
273
 
274
274
 
275
+ def _extract_is_paid(soup: BeautifulSoup, scripts: list[Tag]) -> bool:
276
+ """识别当前文章的付费标记,排除正文外占位符和嵌套推荐文章的数据。"""
277
+ if soup.select_one(
278
+ "div.rich_media_content mp-pay-preview-filter, "
279
+ "div.original_page mp-pay-preview-filter"
280
+ ) is not None:
281
+ return True
282
+
283
+ for script in scripts:
284
+ if "is_pay_subscribe" not in script.text:
285
+ continue
286
+ tokens = [
287
+ match.group() for match in _JS_DATA_TOKEN_RE.finditer(script.text)
288
+ if match.lastgroup != "comment"
289
+ ]
290
+ stack: list[str] = []
291
+ cgi_depth: int | None = None
292
+ for i, token in enumerate(tokens):
293
+ if token == "{":
294
+ if (
295
+ tokens[max(0, i - 4):i] == ["window", ".", "cgiDataNew", "="]
296
+ and (i < 5 or tokens[i - 5] != ".")
297
+ ):
298
+ cgi_depth = len(stack) + 1
299
+ stack.append(token)
300
+ elif token in ("[", "("):
301
+ stack.append(token)
302
+ elif token in ("}", "]", ")"):
303
+ if stack and stack[-1] == {"}": "{", "]": "[", ")": "("}[token]:
304
+ if len(stack) == cgi_depth:
305
+ cgi_depth = None
306
+ stack.pop()
307
+ if token.strip("\"'") != "is_pay_subscribe":
308
+ continue
309
+ is_global = token == "is_pay_subscribe" and (
310
+ (not stack and i > 0 and tokens[i - 1] in ("var", "let", "const"))
311
+ or (
312
+ tokens[max(0, i - 2):i] == ["window", "."]
313
+ and (i < 3 or tokens[i - 3] != ".")
314
+ )
315
+ )
316
+ is_cgi_field = (
317
+ cgi_depth is not None and len(stack) == cgi_depth
318
+ and i > 0 and tokens[i - 1] in ("{", ",")
319
+ )
320
+ separator = tokens[i + 1:i + 2]
321
+ if not (
322
+ (is_global and separator == ["="])
323
+ or (is_cgi_field and separator == [":"])
324
+ ):
325
+ continue
326
+ # 仅识别页面使用的字面量及乘 1 转数值写法,不执行 JS 表达式。
327
+ value = tokens[i + 2:i + 3]
328
+ if value not in (["1"], ["'1'"], ['"1"'], ["true"]):
329
+ continue
330
+ end = i + 3
331
+ if value != ["true"] and tokens[end:end + 2] == ["*", "1"]:
332
+ end += 2
333
+ if end == len(tokens) or tokens[end] in (",", ";", "}"):
334
+ return True
335
+ return False
336
+
337
+
275
338
  def _extract_user_name(scripts: list[Tag]) -> str:
276
339
  """读取全局 user_name,缺失时读取 window.cgiDataNew 的直接属性。
277
340
 
@@ -469,6 +532,8 @@ def _extract_rich_media_content(content_tag: Tag, result: ArticleResult) -> None
469
532
  """将 rich_media_content 区块转换为 Markdown。"""
470
533
  # prettify 会在行内标签之间插入空白,改变代码和引用链接的内容。
471
534
  soup = BeautifulSoup(str(content_tag), "html.parser")
535
+ if soup.find("mp-pay-preview-filter") is not None:
536
+ result.article_is_paid = True
472
537
  has_video = soup.select_one(
473
538
  'mp-common-videosnap[data-type="video"], iframe.video_iframe, iframe.wx_video_iframe'
474
539
  ) is not None
@@ -779,7 +844,9 @@ def _parse_html(url: str, html: str) -> ArticleResult:
779
844
 
780
845
  scripts = soup.find_all("script", attrs={"type": "text/javascript"})
781
846
 
782
- result.mp_user_name = _extract_user_name(soup.find_all("script"))
847
+ all_scripts = soup.find_all("script")
848
+ result.mp_user_name = _extract_user_name(all_scripts)
849
+ result.article_is_paid = _extract_is_paid(soup, all_scripts)
783
850
  item_show_type = _extract_item_show_type(scripts)
784
851
  result.article_item_show_type = item_show_type
785
852
  result.article_display_type = _ARTICLE_DISPLAY_TYPES.get(
@@ -91,3 +91,10 @@ 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
@@ -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 == "预览"
@@ -86,6 +86,7 @@ def _assert_result(result: ArticleResult, url: str) -> None:
86
86
  print(f"文章标题: {result.article_title}")
87
87
  print(f"展示类型: {result.article_display_type}")
88
88
  print(f"原始展示类型: {result.article_item_show_type}")
89
+ print(f"是否付费: {result.article_is_paid}")
89
90
  print(
90
91
  f"封面图: {result.article_cover_image[:80]}..."
91
92
  if result.article_cover_image
@@ -139,6 +140,7 @@ def test_fetch_all(url: str, proxy: str | None) -> None:
139
140
  print(f"文章标题: {result.article_title}")
140
141
  print(f"展示类型: {result.article_display_type}")
141
142
  print(f"原始展示类型: {result.article_item_show_type}")
143
+ print(f"是否付费: {result.article_is_paid}")
142
144
  print(f"封面图: {result.article_cover_image}")
143
145
  print(f"文章摘要: {result.article_description}")
144
146
  print(f"发布时间: {result.article_publish_time}")