wechat-article-parser 0.0.14__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.
- {wechat_article_parser-0.0.14 → wechat_article_parser-0.0.15}/PKG-INFO +17 -3
- {wechat_article_parser-0.0.14 → wechat_article_parser-0.0.15}/README.md +16 -2
- {wechat_article_parser-0.0.14 → wechat_article_parser-0.0.15}/pyproject.toml +1 -1
- {wechat_article_parser-0.0.14 → wechat_article_parser-0.0.15}/src/wechat_article_parser/__init__.py +2 -1
- {wechat_article_parser-0.0.14 → wechat_article_parser-0.0.15}/src/wechat_article_parser/models.py +18 -1
- {wechat_article_parser-0.0.14 → wechat_article_parser-0.0.15}/src/wechat_article_parser/parser.py +29 -5
- wechat_article_parser-0.0.15/tests/test_article_urls.py +148 -0
- {wechat_article_parser-0.0.14 → wechat_article_parser-0.0.15}/tests/test_models.py +44 -1
- {wechat_article_parser-0.0.14 → wechat_article_parser-0.0.15}/tests/test_parser.py +2 -0
- {wechat_article_parser-0.0.14 → wechat_article_parser-0.0.15}/.gitignore +0 -0
- {wechat_article_parser-0.0.14 → wechat_article_parser-0.0.15}/LICENSE +0 -0
- {wechat_article_parser-0.0.14 → wechat_article_parser-0.0.15}/tests/conftest.py +0 -0
- {wechat_article_parser-0.0.14 → wechat_article_parser-0.0.15}/tests/test_markdown.py +0 -0
- {wechat_article_parser-0.0.14 → wechat_article_parser-0.0.15}/tests/test_miniprogram_content.py +0 -0
- {wechat_article_parser-0.0.14 → wechat_article_parser-0.0.15}/tests/test_paid_content.py +0 -0
- {wechat_article_parser-0.0.14 → wechat_article_parser-0.0.15}/tests/test_product_content.py +0 -0
- {wechat_article_parser-0.0.14 → wechat_article_parser-0.0.15}/tests/test_profile_content.py +0 -0
- {wechat_article_parser-0.0.14 → wechat_article_parser-0.0.15}/tests/test_repost_content.py +0 -0
- {wechat_article_parser-0.0.14 → wechat_article_parser-0.0.15}/tests/test_svg_content.py +0 -0
- {wechat_article_parser-0.0.14 → wechat_article_parser-0.0.15}/tests/test_swiper_content.py +0 -0
- {wechat_article_parser-0.0.14 → wechat_article_parser-0.0.15}/tests/test_type_routing.py +0 -0
- {wechat_article_parser-0.0.14 → wechat_article_parser-0.0.15}/tests/test_user_name.py +0 -0
- {wechat_article_parser-0.0.14 → 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.
|
|
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
|
|
@@ -109,6 +109,17 @@ result = parse(
|
|
|
109
109
|
)
|
|
110
110
|
```
|
|
111
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
|
+
|
|
112
123
|
## 返回结构
|
|
113
124
|
|
|
114
125
|
`parse` 和 `parse_async` 均返回 `ArticleResult` 数据类,包含以下字段:
|
|
@@ -123,7 +134,8 @@ result = parse(
|
|
|
123
134
|
| `mp_image` | `str` | 公众号头像链接 |
|
|
124
135
|
| `mp_description` | `str` | 公众号简介 |
|
|
125
136
|
| `mp_account_type` | `AccountType` | 账号类型:`AccountType.SUBSCRIPTION`(订阅号)/ `AccountType.SERVICE`(服务号)/ `AccountType.UNKNOWN`(未识别) |
|
|
126
|
-
| `article_id` | `str` |
|
|
137
|
+
| `article_id` | `str` | 短链接中的文章 ID;长链接为空字符串 |
|
|
138
|
+
| `article_url_type` | `ArticleURLType` | 输入链接类型:`short`、`long` 或 `unknown`(默认);可从包顶层导入枚举,支持字符串比较和 JSON 序列化 |
|
|
127
139
|
| `article_msg_id` | `int` | 文章所在的群发消息 ID |
|
|
128
140
|
| `article_idx` | `int` | 群发图文中的位置(从 1 开始) |
|
|
129
141
|
| `article_sn` | `str` | 文章签名(防伪校验) |
|
|
@@ -139,7 +151,9 @@ result = parse(
|
|
|
139
151
|
| `images` | `list[str]` | 文章中提取的所有图片链接 |
|
|
140
152
|
| `is_valid` | `bool` | 关键字段是否全部解析成功(属性) |
|
|
141
153
|
|
|
142
|
-
`is_valid` 为 `True` 的条件:`mp_id`、`mp_name`、`
|
|
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
|
+
视频和音频分享允许没有文字正文,仍须满足其他关键字段要求;有效性不表示已获取视频或音频文件。
|
|
143
157
|
|
|
144
158
|
付费受限页面返回提示且关键元数据完整时,`is_valid` 也为 `True`,这不表示已获取付费正文。
|
|
145
159
|
|
|
@@ -94,6 +94,17 @@ result = parse(
|
|
|
94
94
|
)
|
|
95
95
|
```
|
|
96
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
|
+
|
|
97
108
|
## 返回结构
|
|
98
109
|
|
|
99
110
|
`parse` 和 `parse_async` 均返回 `ArticleResult` 数据类,包含以下字段:
|
|
@@ -108,7 +119,8 @@ result = parse(
|
|
|
108
119
|
| `mp_image` | `str` | 公众号头像链接 |
|
|
109
120
|
| `mp_description` | `str` | 公众号简介 |
|
|
110
121
|
| `mp_account_type` | `AccountType` | 账号类型:`AccountType.SUBSCRIPTION`(订阅号)/ `AccountType.SERVICE`(服务号)/ `AccountType.UNKNOWN`(未识别) |
|
|
111
|
-
| `article_id` | `str` |
|
|
122
|
+
| `article_id` | `str` | 短链接中的文章 ID;长链接为空字符串 |
|
|
123
|
+
| `article_url_type` | `ArticleURLType` | 输入链接类型:`short`、`long` 或 `unknown`(默认);可从包顶层导入枚举,支持字符串比较和 JSON 序列化 |
|
|
112
124
|
| `article_msg_id` | `int` | 文章所在的群发消息 ID |
|
|
113
125
|
| `article_idx` | `int` | 群发图文中的位置(从 1 开始) |
|
|
114
126
|
| `article_sn` | `str` | 文章签名(防伪校验) |
|
|
@@ -124,7 +136,9 @@ result = parse(
|
|
|
124
136
|
| `images` | `list[str]` | 文章中提取的所有图片链接 |
|
|
125
137
|
| `is_valid` | `bool` | 关键字段是否全部解析成功(属性) |
|
|
126
138
|
|
|
127
|
-
`is_valid` 为 `True` 的条件:`mp_id`、`mp_name`、`
|
|
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
|
+
视频和音频分享允许没有文字正文,仍须满足其他关键字段要求;有效性不表示已获取视频或音频文件。
|
|
128
142
|
|
|
129
143
|
付费受限页面返回提示且关键元数据完整时,`is_valid` 也为 `True`,这不表示已获取付费正文。
|
|
130
144
|
|
|
@@ -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.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"
|
{wechat_article_parser-0.0.14 → wechat_article_parser-0.0.15}/src/wechat_article_parser/__init__.py
RENAMED
|
@@ -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
|
]
|
{wechat_article_parser-0.0.14 → wechat_article_parser-0.0.15}/src/wechat_article_parser/models.py
RENAMED
|
@@ -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
|
|
|
@@ -79,13 +93,16 @@ class ArticleResult:
|
|
|
79
93
|
# 页面是否包含当前文章的付费标记;不表示是否已获取付费全文。
|
|
80
94
|
article_is_paid: bool = False
|
|
81
95
|
|
|
96
|
+
# 根据原始输入 URL 识别,不根据 article_id 是否为空反推。
|
|
97
|
+
article_url_type: ArticleURLType = ArticleURLType.UNKNOWN
|
|
98
|
+
|
|
82
99
|
@property
|
|
83
100
|
def is_valid(self) -> bool:
|
|
84
101
|
"""检查关键字段是否解析成功;视频和音频分享允许正文为空。"""
|
|
85
102
|
return bool(
|
|
86
103
|
self.mp_id
|
|
87
104
|
and self.mp_name
|
|
88
|
-
and self.article_id
|
|
105
|
+
and (self.article_id or self.article_url_type == ArticleURLType.LONG)
|
|
89
106
|
and self.article_msg_id
|
|
90
107
|
and self.article_idx
|
|
91
108
|
and self.article_sn
|
{wechat_article_parser-0.0.14 → wechat_article_parser-0.0.15}/src/wechat_article_parser/parser.py
RENAMED
|
@@ -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
|
-
|
|
221
|
-
|
|
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
|
|
|
@@ -839,6 +862,7 @@ def _parse_html(url: str, html: str) -> ArticleResult:
|
|
|
839
862
|
result = ArticleResult()
|
|
840
863
|
soup = BeautifulSoup(html, "html.parser")
|
|
841
864
|
|
|
865
|
+
result.article_url_type = _extract_article_url_type(url)
|
|
842
866
|
result.article_id = _extract_article_id(url)
|
|
843
867
|
_extract_meta(soup, result)
|
|
844
868
|
|
|
@@ -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(
|
|
@@ -98,3 +98,46 @@ def test_paid_field_serializes_as_boolean(is_paid: bool) -> None:
|
|
|
98
98
|
result = ArticleResult(article_is_paid=is_paid)
|
|
99
99
|
assert json.loads(json.dumps(asdict(result)))["article_is_paid"] is is_paid
|
|
100
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
|
|
@@ -80,6 +80,7 @@ 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}")
|
|
@@ -134,6 +135,7 @@ def test_fetch_all(url: str, proxy: str | None) -> None:
|
|
|
134
135
|
print(f"公众号图片: {result.mp_image}")
|
|
135
136
|
print(f"公众号简介: {result.mp_description}")
|
|
136
137
|
print(f"文章ID: {result.article_id}")
|
|
138
|
+
print(f"链接类型: {result.article_url_type}")
|
|
137
139
|
print(f"群发消息ID: {result.article_msg_id}")
|
|
138
140
|
print(f"文章idx: {result.article_idx}")
|
|
139
141
|
print(f"文章签名: {result.article_sn}")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{wechat_article_parser-0.0.14 → wechat_article_parser-0.0.15}/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
|
|
File without changes
|