wechat-article-parser 0.0.12__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.
- {wechat_article_parser-0.0.12 → wechat_article_parser-0.0.14}/.gitignore +2 -1
- {wechat_article_parser-0.0.12 → wechat_article_parser-0.0.14}/PKG-INFO +18 -1
- {wechat_article_parser-0.0.12 → wechat_article_parser-0.0.14}/README.md +17 -0
- {wechat_article_parser-0.0.12 → wechat_article_parser-0.0.14}/pyproject.toml +1 -1
- {wechat_article_parser-0.0.12 → wechat_article_parser-0.0.14}/src/wechat_article_parser/models.py +3 -0
- {wechat_article_parser-0.0.12 → wechat_article_parser-0.0.14}/src/wechat_article_parser/parser.py +98 -3
- {wechat_article_parser-0.0.12 → wechat_article_parser-0.0.14}/tests/test_markdown.py +48 -1
- {wechat_article_parser-0.0.12 → wechat_article_parser-0.0.14}/tests/test_models.py +7 -0
- {wechat_article_parser-0.0.12 → wechat_article_parser-0.0.14}/tests/test_paid_content.py +84 -0
- {wechat_article_parser-0.0.12 → wechat_article_parser-0.0.14}/tests/test_parser.py +4 -0
- {wechat_article_parser-0.0.12 → wechat_article_parser-0.0.14}/LICENSE +0 -0
- {wechat_article_parser-0.0.12 → wechat_article_parser-0.0.14}/src/wechat_article_parser/__init__.py +0 -0
- {wechat_article_parser-0.0.12 → wechat_article_parser-0.0.14}/tests/conftest.py +0 -0
- {wechat_article_parser-0.0.12 → wechat_article_parser-0.0.14}/tests/test_miniprogram_content.py +0 -0
- {wechat_article_parser-0.0.12 → wechat_article_parser-0.0.14}/tests/test_product_content.py +0 -0
- {wechat_article_parser-0.0.12 → wechat_article_parser-0.0.14}/tests/test_profile_content.py +0 -0
- {wechat_article_parser-0.0.12 → wechat_article_parser-0.0.14}/tests/test_repost_content.py +0 -0
- {wechat_article_parser-0.0.12 → wechat_article_parser-0.0.14}/tests/test_svg_content.py +0 -0
- {wechat_article_parser-0.0.12 → wechat_article_parser-0.0.14}/tests/test_swiper_content.py +0 -0
- {wechat_article_parser-0.0.12 → wechat_article_parser-0.0.14}/tests/test_type_routing.py +0 -0
- {wechat_article_parser-0.0.12 → wechat_article_parser-0.0.14}/tests/test_user_name.py +0 -0
- {wechat_article_parser-0.0.12 → 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.
|
|
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
|
|
@@ -31,9 +31,13 @@ Description-Content-Type: text/markdown
|
|
|
31
31
|
`8` 对应图片轮播、`10` 对应文字消息,再通过 HTML 或脚本数据区分具体模板。
|
|
32
32
|
类型字段缺失、值未知或对应分支未提取到正文时,会按原有 HTML 特征匹配顺序兜底。
|
|
33
33
|
|
|
34
|
+
Markdown 转换前会展开无格式作用的 `span`,合并嵌套的重复加粗、斜体和删除线标签,
|
|
35
|
+
避免编辑器产生的数百层冗余行内标签导致递归超限,并保留文字、链接、图片和段落顺序。
|
|
36
|
+
|
|
34
37
|
图文正文仅包含视频号视频卡片或微信视频 iframe 时,Markdown 返回“这是一篇纯视频内容。”。
|
|
35
38
|
正文只有付费预览占位标签时,Markdown 返回“这是一篇付费内容,正文需付费后查看。”,
|
|
36
39
|
并保留公开元数据;已有的可见预览正文仍正常提取。
|
|
40
|
+
返回字段 `article_is_paid` 独立标记文章是否付费,有可见预览正文时也会识别。
|
|
37
41
|
图文中的公众号推荐卡片按原文顺序转换为 `**公众号名称**:简介`,每张卡片一段;
|
|
38
42
|
名称或简介缺失时,仅输出已有字段。
|
|
39
43
|
图文正文仅包含商品广告卡片(`mp-common-cpsad`)时,Markdown 返回“这是一篇商品推荐内容。”;
|
|
@@ -128,6 +132,7 @@ result = parse(
|
|
|
128
132
|
| `article_description` | `str` | 文章摘要 |
|
|
129
133
|
| `article_markdown` | `str` | 文章正文的 Markdown 内容;视频或音频分享没有文字附言时可为空 |
|
|
130
134
|
| `article_publish_time` | `int` | 发布时间(Unix 时间戳) |
|
|
135
|
+
| `article_is_paid` | `bool` | 是否检测到当前文章的付费标记,默认 `False`;不表示是否已获取付费全文 |
|
|
131
136
|
| `article_display_type` | `ArticleDisplayType` | 文章展示类型:`article`、`video`、`audio`、`image`、`text` 或 `unknown` |
|
|
132
137
|
| `article_item_show_type` | `int \| None` | 微信页面中的原始展示类型:`0` 图文、`5` 视频、`7` 音频、`8` 图片轮播、`10` 文字;其他已提取的数值原样保留,缺失或无法解析时为 `None` |
|
|
133
138
|
| `raw_html` | `str` | 原始 HTML 网页源代码(仅在 `include_raw_html=True` 时填充,否则为空字符串) |
|
|
@@ -138,6 +143,18 @@ result = parse(
|
|
|
138
143
|
|
|
139
144
|
付费受限页面返回提示且关键元数据完整时,`is_valid` 也为 `True`,这不表示已获取付费正文。
|
|
140
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
|
+
|
|
141
158
|
### 判断文章展示类型
|
|
142
159
|
|
|
143
160
|
`ArticleDisplayType` 是字符串枚举,既支持枚举比较,也支持语义字符串比较:
|
|
@@ -16,9 +16,13 @@
|
|
|
16
16
|
`8` 对应图片轮播、`10` 对应文字消息,再通过 HTML 或脚本数据区分具体模板。
|
|
17
17
|
类型字段缺失、值未知或对应分支未提取到正文时,会按原有 HTML 特征匹配顺序兜底。
|
|
18
18
|
|
|
19
|
+
Markdown 转换前会展开无格式作用的 `span`,合并嵌套的重复加粗、斜体和删除线标签,
|
|
20
|
+
避免编辑器产生的数百层冗余行内标签导致递归超限,并保留文字、链接、图片和段落顺序。
|
|
21
|
+
|
|
19
22
|
图文正文仅包含视频号视频卡片或微信视频 iframe 时,Markdown 返回“这是一篇纯视频内容。”。
|
|
20
23
|
正文只有付费预览占位标签时,Markdown 返回“这是一篇付费内容,正文需付费后查看。”,
|
|
21
24
|
并保留公开元数据;已有的可见预览正文仍正常提取。
|
|
25
|
+
返回字段 `article_is_paid` 独立标记文章是否付费,有可见预览正文时也会识别。
|
|
22
26
|
图文中的公众号推荐卡片按原文顺序转换为 `**公众号名称**:简介`,每张卡片一段;
|
|
23
27
|
名称或简介缺失时,仅输出已有字段。
|
|
24
28
|
图文正文仅包含商品广告卡片(`mp-common-cpsad`)时,Markdown 返回“这是一篇商品推荐内容。”;
|
|
@@ -113,6 +117,7 @@ result = parse(
|
|
|
113
117
|
| `article_description` | `str` | 文章摘要 |
|
|
114
118
|
| `article_markdown` | `str` | 文章正文的 Markdown 内容;视频或音频分享没有文字附言时可为空 |
|
|
115
119
|
| `article_publish_time` | `int` | 发布时间(Unix 时间戳) |
|
|
120
|
+
| `article_is_paid` | `bool` | 是否检测到当前文章的付费标记,默认 `False`;不表示是否已获取付费全文 |
|
|
116
121
|
| `article_display_type` | `ArticleDisplayType` | 文章展示类型:`article`、`video`、`audio`、`image`、`text` 或 `unknown` |
|
|
117
122
|
| `article_item_show_type` | `int \| None` | 微信页面中的原始展示类型:`0` 图文、`5` 视频、`7` 音频、`8` 图片轮播、`10` 文字;其他已提取的数值原样保留,缺失或无法解析时为 `None` |
|
|
118
123
|
| `raw_html` | `str` | 原始 HTML 网页源代码(仅在 `include_raw_html=True` 时填充,否则为空字符串) |
|
|
@@ -123,6 +128,18 @@ result = parse(
|
|
|
123
128
|
|
|
124
129
|
付费受限页面返回提示且关键元数据完整时,`is_valid` 也为 `True`,这不表示已获取付费正文。
|
|
125
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
|
+
|
|
126
143
|
### 判断文章展示类型
|
|
127
144
|
|
|
128
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.
|
|
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"
|
{wechat_article_parser-0.0.12 → wechat_article_parser-0.0.14}/src/wechat_article_parser/parser.py
RENAMED
|
@@ -53,8 +53,36 @@ class _MarkdownConverter(MarkdownConverter):
|
|
|
53
53
|
return super().convert_th(el, text.replace("|", r"\|"), parent_tags)
|
|
54
54
|
|
|
55
55
|
|
|
56
|
+
def _prepare_markdown_soup(html: str) -> BeautifulSoup:
|
|
57
|
+
"""展开编辑器产生的冗余行内标签,避免 Markdown 转换时递归超限。"""
|
|
58
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
59
|
+
styles = {
|
|
60
|
+
"strong": "bold", "b": "bold",
|
|
61
|
+
"em": "italic", "i": "italic",
|
|
62
|
+
"s": "strike", "del": "strike",
|
|
63
|
+
}
|
|
64
|
+
# 显式栈遍历,清理过程本身也不依赖 Python 的递归深度。
|
|
65
|
+
stack = [(soup, frozenset())]
|
|
66
|
+
to_unwrap = []
|
|
67
|
+
while stack:
|
|
68
|
+
node, active_styles = stack.pop()
|
|
69
|
+
style = styles.get(node.name)
|
|
70
|
+
if node.name == "span" or (style and style in active_styles):
|
|
71
|
+
to_unwrap.append(node)
|
|
72
|
+
if style:
|
|
73
|
+
active_styles = active_styles | {style}
|
|
74
|
+
stack.extend(
|
|
75
|
+
(child, active_styles) for child in node.children if isinstance(child, Tag)
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# 先处理内层,保留标签中的文字、链接、图片和它们的原始顺序。
|
|
79
|
+
for node in reversed(to_unwrap):
|
|
80
|
+
node.unwrap()
|
|
81
|
+
return soup
|
|
82
|
+
|
|
83
|
+
|
|
56
84
|
def _to_markdown(html: str) -> str:
|
|
57
|
-
return _MarkdownConverter().
|
|
85
|
+
return _MarkdownConverter().convert_soup(_prepare_markdown_soup(html))
|
|
58
86
|
|
|
59
87
|
|
|
60
88
|
def _to_markdown_plain(html: str) -> str:
|
|
@@ -62,7 +90,7 @@ def _to_markdown_plain(html: str) -> str:
|
|
|
62
90
|
|
|
63
91
|
这些内容由 <br> 承载换行,使用基础转换器保留换行。
|
|
64
92
|
"""
|
|
65
|
-
return MarkdownConverter().
|
|
93
|
+
return MarkdownConverter().convert_soup(_prepare_markdown_soup(html))
|
|
66
94
|
|
|
67
95
|
|
|
68
96
|
# ---------------------------------------------------------------------------
|
|
@@ -244,6 +272,69 @@ def _extract_item_show_type(scripts: list[Tag]) -> int | None:
|
|
|
244
272
|
return None
|
|
245
273
|
|
|
246
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
|
+
|
|
247
338
|
def _extract_user_name(scripts: list[Tag]) -> str:
|
|
248
339
|
"""读取全局 user_name,缺失时读取 window.cgiDataNew 的直接属性。
|
|
249
340
|
|
|
@@ -441,6 +532,8 @@ def _extract_rich_media_content(content_tag: Tag, result: ArticleResult) -> None
|
|
|
441
532
|
"""将 rich_media_content 区块转换为 Markdown。"""
|
|
442
533
|
# prettify 会在行内标签之间插入空白,改变代码和引用链接的内容。
|
|
443
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
|
|
444
537
|
has_video = soup.select_one(
|
|
445
538
|
'mp-common-videosnap[data-type="video"], iframe.video_iframe, iframe.wx_video_iframe'
|
|
446
539
|
) is not None
|
|
@@ -751,7 +844,9 @@ def _parse_html(url: str, html: str) -> ArticleResult:
|
|
|
751
844
|
|
|
752
845
|
scripts = soup.find_all("script", attrs={"type": "text/javascript"})
|
|
753
846
|
|
|
754
|
-
|
|
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)
|
|
755
850
|
item_show_type = _extract_item_show_type(scripts)
|
|
756
851
|
result.article_item_show_type = item_show_type
|
|
757
852
|
result.article_display_type = _ARTICLE_DISPLAY_TYPES.get(
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
"""富文本 Markdown 转换的离线回归测试。"""
|
|
2
2
|
|
|
3
|
+
import sys
|
|
4
|
+
|
|
3
5
|
import pytest
|
|
4
6
|
|
|
5
|
-
from wechat_article_parser.parser import _parse_html
|
|
7
|
+
from wechat_article_parser.parser import _parse_html, _to_markdown_plain
|
|
6
8
|
|
|
7
9
|
|
|
8
10
|
def _convert(body: str) -> str:
|
|
@@ -83,3 +85,48 @@ def test_literal_asterisks_in_source_stay_literal_and_inline() -> None:
|
|
|
83
85
|
'<sup><span>[1]</span></sup><span>**:Nous Research 的自主 AI Agent</span>'
|
|
84
86
|
'</section></li></ul>'
|
|
85
87
|
) == r'* \*\*Hermes[1]\*\*:Nous Research 的自主 AI Agent'
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@pytest.mark.parametrize("convert", [_convert, _to_markdown_plain])
|
|
91
|
+
def test_deep_editor_wrappers_preserve_content_and_formatting(convert) -> None:
|
|
92
|
+
recursion_limit = sys.getrecursionlimit()
|
|
93
|
+
# 模拟真实文章反复粘贴产生的 span/strong 嵌套,超过默认递归上限。
|
|
94
|
+
body = (
|
|
95
|
+
'<p>前文</p><p>' + '<span><strong>' * 600
|
|
96
|
+
+ '加粗 <em>斜体</em> <a href="https://example.com">链接</a>'
|
|
97
|
+
+ '<br><img src="https://example.com/image.png" alt="图片">'
|
|
98
|
+
+ '</strong></span>' * 600 + '</p><p>后文</p>'
|
|
99
|
+
)
|
|
100
|
+
expected = convert(
|
|
101
|
+
'<p>前文</p><p><strong>加粗 <em>斜体</em> '
|
|
102
|
+
'<a href="https://example.com">链接</a><br>'
|
|
103
|
+
'<img src="https://example.com/image.png" alt="图片">'
|
|
104
|
+
'</strong></p><p>后文</p>'
|
|
105
|
+
)
|
|
106
|
+
assert convert(body) == expected
|
|
107
|
+
assert sys.getrecursionlimit() == recursion_limit
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_redundant_styles_do_not_leak_into_siblings() -> None:
|
|
111
|
+
assert _convert(
|
|
112
|
+
'<p><strong>粗<span><b>体</b></span></strong> 普通 '
|
|
113
|
+
'<em>斜<span><i>体</i></span></em> '
|
|
114
|
+
'<s>删<span><del>除</del></span></s></p>'
|
|
115
|
+
'<p><b>独立加粗</b></p>'
|
|
116
|
+
) == '**粗体** 普通 *斜体* ~~删除~~\n\n**独立加粗**'
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_deep_spans_preserve_code_whitespace_and_images() -> None:
|
|
120
|
+
body = (
|
|
121
|
+
'<pre><code>' + '<span>' * 1200 + ' a < b<br> next\n\n end'
|
|
122
|
+
+ '</span>' * 1200 + '</code></pre>'
|
|
123
|
+
'<p><span><img data-src="https://example.com/image.png"></span>尾文</p>'
|
|
124
|
+
)
|
|
125
|
+
result = _parse_html(
|
|
126
|
+
'https://mp.weixin.qq.com/s/test',
|
|
127
|
+
f'<div class="rich_media_content">{body}</div>',
|
|
128
|
+
)
|
|
129
|
+
assert result.article_markdown == (
|
|
130
|
+
'```\n a < b\n next\n\n end\n```\n\n尾文'
|
|
131
|
+
)
|
|
132
|
+
assert result.images == ['https://example.com/image.png']
|
|
@@ -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 == "预览"
|
|
@@ -34,6 +34,8 @@ TEST_URLS = [
|
|
|
34
34
|
"https://mp.weixin.qq.com/s/CBmoIFPJwV4BkidYGp-l1Q",
|
|
35
35
|
# 纯小程序卡片正文。
|
|
36
36
|
"https://mp.weixin.qq.com/s/qs2YxgdwWDplsmb6H8OS-w",
|
|
37
|
+
# 编辑器产生数百层 span/strong 嵌套的富文本正文。
|
|
38
|
+
"https://mp.weixin.qq.com/s/JfYFarShiNhiUt0cTuI4kA",
|
|
37
39
|
]
|
|
38
40
|
|
|
39
41
|
|
|
@@ -84,6 +86,7 @@ def _assert_result(result: ArticleResult, url: str) -> None:
|
|
|
84
86
|
print(f"文章标题: {result.article_title}")
|
|
85
87
|
print(f"展示类型: {result.article_display_type}")
|
|
86
88
|
print(f"原始展示类型: {result.article_item_show_type}")
|
|
89
|
+
print(f"是否付费: {result.article_is_paid}")
|
|
87
90
|
print(
|
|
88
91
|
f"封面图: {result.article_cover_image[:80]}..."
|
|
89
92
|
if result.article_cover_image
|
|
@@ -137,6 +140,7 @@ def test_fetch_all(url: str, proxy: str | None) -> None:
|
|
|
137
140
|
print(f"文章标题: {result.article_title}")
|
|
138
141
|
print(f"展示类型: {result.article_display_type}")
|
|
139
142
|
print(f"原始展示类型: {result.article_item_show_type}")
|
|
143
|
+
print(f"是否付费: {result.article_is_paid}")
|
|
140
144
|
print(f"封面图: {result.article_cover_image}")
|
|
141
145
|
print(f"文章摘要: {result.article_description}")
|
|
142
146
|
print(f"发布时间: {result.article_publish_time}")
|
|
File without changes
|
{wechat_article_parser-0.0.12 → wechat_article_parser-0.0.14}/src/wechat_article_parser/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{wechat_article_parser-0.0.12 → wechat_article_parser-0.0.14}/tests/test_miniprogram_content.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|