wechat-article-parser 0.0.11__tar.gz → 0.0.13__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.11 → wechat_article_parser-0.0.13}/.gitignore +2 -1
- {wechat_article_parser-0.0.11 → wechat_article_parser-0.0.13}/PKG-INFO +8 -1
- {wechat_article_parser-0.0.11 → wechat_article_parser-0.0.13}/README.md +7 -0
- {wechat_article_parser-0.0.11 → wechat_article_parser-0.0.13}/pyproject.toml +1 -1
- {wechat_article_parser-0.0.11 → wechat_article_parser-0.0.13}/src/wechat_article_parser/parser.py +50 -2
- {wechat_article_parser-0.0.11 → wechat_article_parser-0.0.13}/tests/test_markdown.py +48 -1
- wechat_article_parser-0.0.13/tests/test_miniprogram_content.py +108 -0
- {wechat_article_parser-0.0.11 → wechat_article_parser-0.0.13}/tests/test_parser.py +4 -0
- {wechat_article_parser-0.0.11 → wechat_article_parser-0.0.13}/LICENSE +0 -0
- {wechat_article_parser-0.0.11 → wechat_article_parser-0.0.13}/src/wechat_article_parser/__init__.py +0 -0
- {wechat_article_parser-0.0.11 → wechat_article_parser-0.0.13}/src/wechat_article_parser/models.py +0 -0
- {wechat_article_parser-0.0.11 → wechat_article_parser-0.0.13}/tests/conftest.py +0 -0
- {wechat_article_parser-0.0.11 → wechat_article_parser-0.0.13}/tests/test_models.py +0 -0
- {wechat_article_parser-0.0.11 → wechat_article_parser-0.0.13}/tests/test_paid_content.py +0 -0
- {wechat_article_parser-0.0.11 → wechat_article_parser-0.0.13}/tests/test_product_content.py +0 -0
- {wechat_article_parser-0.0.11 → wechat_article_parser-0.0.13}/tests/test_profile_content.py +0 -0
- {wechat_article_parser-0.0.11 → wechat_article_parser-0.0.13}/tests/test_repost_content.py +0 -0
- {wechat_article_parser-0.0.11 → wechat_article_parser-0.0.13}/tests/test_svg_content.py +0 -0
- {wechat_article_parser-0.0.11 → wechat_article_parser-0.0.13}/tests/test_swiper_content.py +0 -0
- {wechat_article_parser-0.0.11 → wechat_article_parser-0.0.13}/tests/test_type_routing.py +0 -0
- {wechat_article_parser-0.0.11 → wechat_article_parser-0.0.13}/tests/test_user_name.py +0 -0
- {wechat_article_parser-0.0.11 → wechat_article_parser-0.0.13}/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.13
|
|
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,6 +31,9 @@ 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
|
并保留公开元数据;已有的可见预览正文仍正常提取。
|
|
@@ -38,6 +41,10 @@ Description-Content-Type: text/markdown
|
|
|
38
41
|
名称或简介缺失时,仅输出已有字段。
|
|
39
42
|
图文正文仅包含商品广告卡片(`mp-common-cpsad`)时,Markdown 返回“这是一篇商品推荐内容。”;
|
|
40
43
|
已有的文字和图片仍正常提取。关键元数据完整时占位结果也有效,不表示已获取商品详情。
|
|
44
|
+
小程序卡片(`mp-common-miniprogram`)按原文顺序转换为加粗标题、`小程序:名称`及
|
|
45
|
+
“请在微信中打开小程序查看内容。”,标题或名称缺失时省略对应段落。
|
|
46
|
+
小程序卡片的封面和头像不输出到 Markdown,也不加入 `images`;不生成小程序内部路径链接。
|
|
47
|
+
关键元数据完整时卡片结果也有效,不表示已获取小程序内的文档或其他内容。
|
|
41
48
|
|
|
42
49
|
## 安装
|
|
43
50
|
|
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
`8` 对应图片轮播、`10` 对应文字消息,再通过 HTML 或脚本数据区分具体模板。
|
|
17
17
|
类型字段缺失、值未知或对应分支未提取到正文时,会按原有 HTML 特征匹配顺序兜底。
|
|
18
18
|
|
|
19
|
+
Markdown 转换前会展开无格式作用的 `span`,合并嵌套的重复加粗、斜体和删除线标签,
|
|
20
|
+
避免编辑器产生的数百层冗余行内标签导致递归超限,并保留文字、链接、图片和段落顺序。
|
|
21
|
+
|
|
19
22
|
图文正文仅包含视频号视频卡片或微信视频 iframe 时,Markdown 返回“这是一篇纯视频内容。”。
|
|
20
23
|
正文只有付费预览占位标签时,Markdown 返回“这是一篇付费内容,正文需付费后查看。”,
|
|
21
24
|
并保留公开元数据;已有的可见预览正文仍正常提取。
|
|
@@ -23,6 +26,10 @@
|
|
|
23
26
|
名称或简介缺失时,仅输出已有字段。
|
|
24
27
|
图文正文仅包含商品广告卡片(`mp-common-cpsad`)时,Markdown 返回“这是一篇商品推荐内容。”;
|
|
25
28
|
已有的文字和图片仍正常提取。关键元数据完整时占位结果也有效,不表示已获取商品详情。
|
|
29
|
+
小程序卡片(`mp-common-miniprogram`)按原文顺序转换为加粗标题、`小程序:名称`及
|
|
30
|
+
“请在微信中打开小程序查看内容。”,标题或名称缺失时省略对应段落。
|
|
31
|
+
小程序卡片的封面和头像不输出到 Markdown,也不加入 `images`;不生成小程序内部路径链接。
|
|
32
|
+
关键元数据完整时卡片结果也有效,不表示已获取小程序内的文档或其他内容。
|
|
26
33
|
|
|
27
34
|
## 安装
|
|
28
35
|
|
|
@@ -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.13"
|
|
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.11 → wechat_article_parser-0.0.13}/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
|
# ---------------------------------------------------------------------------
|
|
@@ -462,6 +490,26 @@ def _extract_rich_media_content(content_tag: Tag, result: ArticleResult) -> None
|
|
|
462
490
|
paragraph.append(signature)
|
|
463
491
|
profile.replace_with(paragraph)
|
|
464
492
|
|
|
493
|
+
# 小程序卡片只保留公开文字和查看提示,不收集封面、头像或内部路径。
|
|
494
|
+
for card in soup.find_all("mp-common-miniprogram"):
|
|
495
|
+
title = card.get("data-miniprogram-title", "").strip()
|
|
496
|
+
nickname = card.get("data-miniprogram-nickname", "").strip()
|
|
497
|
+
paragraphs = []
|
|
498
|
+
if title:
|
|
499
|
+
paragraph = soup.new_tag("p")
|
|
500
|
+
heading = soup.new_tag("strong")
|
|
501
|
+
heading.string = title
|
|
502
|
+
paragraph.append(heading)
|
|
503
|
+
paragraphs.append(paragraph)
|
|
504
|
+
if nickname:
|
|
505
|
+
paragraph = soup.new_tag("p")
|
|
506
|
+
paragraph.string = f"小程序:{nickname}"
|
|
507
|
+
paragraphs.append(paragraph)
|
|
508
|
+
notice = soup.new_tag("p")
|
|
509
|
+
notice.string = "请在微信中打开小程序查看内容。"
|
|
510
|
+
paragraphs.append(notice)
|
|
511
|
+
card.replace_with(*paragraphs)
|
|
512
|
+
|
|
465
513
|
# 处理 <img> 标签
|
|
466
514
|
to_remove = []
|
|
467
515
|
for img in soup.find_all("img"):
|
|
@@ -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']
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""小程序卡片文字提取、图片排除及混合正文顺序的回归测试。"""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from wechat_article_parser import ArticleDisplayType
|
|
6
|
+
from wechat_article_parser.parser import _parse_html
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
_URL = "https://mp.weixin.qq.com/s/qs2YxgdwWDplsmb6H8OS-w"
|
|
10
|
+
_NOTICE = "请在微信中打开小程序查看内容。"
|
|
11
|
+
_METADATA = """
|
|
12
|
+
var item_show_type = "0";
|
|
13
|
+
var biz = "MzIxMjg3NDA0Nw==";
|
|
14
|
+
var mid = "2247487342";
|
|
15
|
+
var idx = "1";
|
|
16
|
+
var sn = "6cda9d4c31fd025a8a9a319f96173d4a";
|
|
17
|
+
window.__allowLoadResFromMp = true;
|
|
18
|
+
var hd_head_img = "https://example.com/publisher-avatar";
|
|
19
|
+
var nickname = htmlDecode("苏瑞膜");
|
|
20
|
+
var oriCreateTime = '1775961027';
|
|
21
|
+
"""
|
|
22
|
+
_CARD = (
|
|
23
|
+
'<mp-common-miniprogram data-miniprogram-title="苏瑞膜反渗透与纳滤产品手册(2026版)" '
|
|
24
|
+
'data-miniprogram-nickname="快文档" '
|
|
25
|
+
'data-miniprogram-imageurl="https://example.com/cover.jpg" '
|
|
26
|
+
'data-miniprogram-avatar="https://example.com/avatar.jpg" '
|
|
27
|
+
'data-miniprogram-path="/pages/download/download?fid=13695" '
|
|
28
|
+
'data-miniprogram-appid="wxc24ac90045c531a4">'
|
|
29
|
+
'</mp-common-miniprogram>'
|
|
30
|
+
)
|
|
31
|
+
_EXPECTED = f"**苏瑞膜反渗透与纳滤产品手册(2026版)**\n\n小程序:快文档\n\n{_NOTICE}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _html(body: str, metadata: str = _METADATA) -> str:
|
|
35
|
+
return (
|
|
36
|
+
'<meta property="og:title" content="苏瑞膜SURO®反渗透与纳滤膜产品手册(2026)">'
|
|
37
|
+
f'<div class="rich_media_content">{body}</div>'
|
|
38
|
+
f'<script type="text/javascript">{metadata}</script>'
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.mark.parametrize("metadata", [_METADATA, ""])
|
|
43
|
+
def test_miniprogram_only_article_extracts_text_and_requires_metadata(metadata: str) -> None:
|
|
44
|
+
result = _parse_html(_URL, _html(f'<section>{_CARD}</section><p><br></p>', metadata))
|
|
45
|
+
|
|
46
|
+
assert result.article_markdown == _EXPECTED
|
|
47
|
+
assert result.images == []
|
|
48
|
+
assert result.is_valid is bool(metadata)
|
|
49
|
+
if metadata:
|
|
50
|
+
assert result.article_display_type is ArticleDisplayType.ARTICLE
|
|
51
|
+
assert result.article_item_show_type == 0
|
|
52
|
+
assert result.mp_name == "苏瑞膜"
|
|
53
|
+
assert result.article_msg_id == 2247487342
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_mixed_cards_preserve_order_and_exclude_nested_card_images() -> None:
|
|
57
|
+
rendered_card = _CARD.replace(
|
|
58
|
+
'</mp-common-miniprogram>',
|
|
59
|
+
'<img src="https://example.com/cover.jpg"><p>重复卡片文字</p>'
|
|
60
|
+
'</mp-common-miniprogram>',
|
|
61
|
+
)
|
|
62
|
+
result = _parse_html(_URL, _html(
|
|
63
|
+
'<p>正文开头。</p>' + rendered_card
|
|
64
|
+
+ '<p><img data-src="https://example.com/body.jpg"></p>'
|
|
65
|
+
+ '<mp-common-miniprogram data-miniprogram-title="第二份文档" '
|
|
66
|
+
'data-miniprogram-nickname="文档助手"></mp-common-miniprogram>'
|
|
67
|
+
'<p>正文结尾。</p>'
|
|
68
|
+
))
|
|
69
|
+
|
|
70
|
+
assert result.article_markdown == (
|
|
71
|
+
f'正文开头。\n\n{_EXPECTED}\n\n\n\n'
|
|
72
|
+
f'**第二份文档**\n\n小程序:文档助手\n\n{_NOTICE}\n\n正文结尾。'
|
|
73
|
+
)
|
|
74
|
+
assert result.images == ["https://example.com/body.jpg"]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@pytest.mark.parametrize(("attributes", "expected"), [
|
|
78
|
+
('data-miniprogram-title=" 手册 "', f'**手册**\n\n{_NOTICE}'),
|
|
79
|
+
('data-miniprogram-nickname=" 快文档 "', f'小程序:快文档\n\n{_NOTICE}'),
|
|
80
|
+
('data-miniprogram-title=" " data-miniprogram-nickname=" "', _NOTICE),
|
|
81
|
+
('', _NOTICE),
|
|
82
|
+
])
|
|
83
|
+
def test_missing_card_fields_omit_empty_paragraphs(attributes: str, expected: str) -> None:
|
|
84
|
+
result = _parse_html(_URL, _html(
|
|
85
|
+
f'<mp-common-miniprogram {attributes}></mp-common-miniprogram>'
|
|
86
|
+
))
|
|
87
|
+
|
|
88
|
+
assert result.article_markdown == expected
|
|
89
|
+
assert result.images == []
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_card_attributes_preserve_entities_and_escape_markdown() -> None:
|
|
93
|
+
result = _parse_html(_URL, _html(
|
|
94
|
+
'<mp-common-miniprogram data-miniprogram-title="A & B *手册* <tag>" '
|
|
95
|
+
'data-miniprogram-nickname="文档&助手"></mp-common-miniprogram>'
|
|
96
|
+
))
|
|
97
|
+
|
|
98
|
+
assert result.article_markdown == (
|
|
99
|
+
r'**A & B \*手册\* <tag>**' f'\n\n小程序:文档&助手\n\n{_NOTICE}'
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_card_outside_article_body_is_not_extracted() -> None:
|
|
104
|
+
result = _parse_html(_URL, _html("") + _CARD)
|
|
105
|
+
|
|
106
|
+
assert result.article_markdown == ""
|
|
107
|
+
assert result.images == []
|
|
108
|
+
assert not result.is_valid
|
|
@@ -32,6 +32,10 @@ TEST_URLS = [
|
|
|
32
32
|
"https://mp.weixin.qq.com/s/y4QxlYs7u0X9TdvZatCfIA",
|
|
33
33
|
# 纯商品广告卡片正文。
|
|
34
34
|
"https://mp.weixin.qq.com/s/CBmoIFPJwV4BkidYGp-l1Q",
|
|
35
|
+
# 纯小程序卡片正文。
|
|
36
|
+
"https://mp.weixin.qq.com/s/qs2YxgdwWDplsmb6H8OS-w",
|
|
37
|
+
# 编辑器产生数百层 span/strong 嵌套的富文本正文。
|
|
38
|
+
"https://mp.weixin.qq.com/s/JfYFarShiNhiUt0cTuI4kA",
|
|
35
39
|
]
|
|
36
40
|
|
|
37
41
|
|
|
File without changes
|
{wechat_article_parser-0.0.11 → wechat_article_parser-0.0.13}/src/wechat_article_parser/__init__.py
RENAMED
|
File without changes
|
{wechat_article_parser-0.0.11 → wechat_article_parser-0.0.13}/src/wechat_article_parser/models.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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|