wechat-article-parser 0.0.1__tar.gz → 0.0.3__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.1 → wechat_article_parser-0.0.3}/.gitignore +1 -0
- {wechat_article_parser-0.0.1 → wechat_article_parser-0.0.3}/PKG-INFO +1 -1
- {wechat_article_parser-0.0.1 → wechat_article_parser-0.0.3}/pyproject.toml +1 -1
- wechat_article_parser-0.0.3/src/wechat_article_parser/__init__.py +4 -0
- {wechat_article_parser-0.0.1 → wechat_article_parser-0.0.3}/src/wechat_article_parser/models.py +16 -0
- {wechat_article_parser-0.0.1 → wechat_article_parser-0.0.3}/src/wechat_article_parser/parser.py +53 -10
- {wechat_article_parser-0.0.1 → wechat_article_parser-0.0.3}/tests/test_parser.py +5 -0
- wechat_article_parser-0.0.1/src/wechat_article_parser/__init__.py +0 -4
- {wechat_article_parser-0.0.1 → wechat_article_parser-0.0.3}/LICENSE +0 -0
- {wechat_article_parser-0.0.1 → wechat_article_parser-0.0.3}/README.md +0 -0
- {wechat_article_parser-0.0.1 → wechat_article_parser-0.0.3}/tests/conftest.py +0 -0
|
@@ -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.3"
|
|
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.1 → wechat_article_parser-0.0.3}/src/wechat_article_parser/models.py
RENAMED
|
@@ -1,12 +1,27 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from dataclasses import dataclass, field
|
|
4
|
+
from enum import Enum
|
|
4
5
|
|
|
5
6
|
|
|
6
7
|
class WeChatVerifyError(Exception):
|
|
7
8
|
"""微信返回了验证码/人机验证页面,而非文章内容时抛出此异常。"""
|
|
8
9
|
|
|
9
10
|
|
|
11
|
+
class AccountType(str, Enum):
|
|
12
|
+
"""公众号账号类型。继承自 str,既可按枚举比较也可按字符串比较。"""
|
|
13
|
+
|
|
14
|
+
UNKNOWN = ""
|
|
15
|
+
SUBSCRIPTION = "订阅号"
|
|
16
|
+
SERVICE = "服务号"
|
|
17
|
+
|
|
18
|
+
def __str__(self) -> str:
|
|
19
|
+
return self.value
|
|
20
|
+
|
|
21
|
+
def __format__(self, format_spec: str) -> str:
|
|
22
|
+
return format(self.value, format_spec)
|
|
23
|
+
|
|
24
|
+
|
|
10
25
|
@dataclass
|
|
11
26
|
class ArticleResult:
|
|
12
27
|
"""微信公众号文章的解析结果。"""
|
|
@@ -18,6 +33,7 @@ class ArticleResult:
|
|
|
18
33
|
mp_alias: str = ""
|
|
19
34
|
mp_image: str = ""
|
|
20
35
|
mp_description: str = ""
|
|
36
|
+
mp_account_type: AccountType = AccountType.UNKNOWN
|
|
21
37
|
|
|
22
38
|
# 文章信息
|
|
23
39
|
article_id: str = ""
|
{wechat_article_parser-0.0.1 → wechat_article_parser-0.0.3}/src/wechat_article_parser/parser.py
RENAMED
|
@@ -11,7 +11,7 @@ import httpx
|
|
|
11
11
|
from bs4 import BeautifulSoup, Tag
|
|
12
12
|
from markdownify import MarkdownConverter
|
|
13
13
|
|
|
14
|
-
from .models import ArticleResult, WeChatVerifyError
|
|
14
|
+
from .models import AccountType, ArticleResult, WeChatVerifyError
|
|
15
15
|
|
|
16
16
|
_USER_AGENT = (
|
|
17
17
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
@@ -76,6 +76,15 @@ def _normalize_image_url(url: str) -> str:
|
|
|
76
76
|
return url
|
|
77
77
|
|
|
78
78
|
|
|
79
|
+
def _service_type_to_account_type(value: str) -> AccountType:
|
|
80
|
+
"""映射 WeChat new_service_type 到账号类型:0/1 → 订阅号,2 → 服务号。"""
|
|
81
|
+
if value in ("0", "1"):
|
|
82
|
+
return AccountType.SUBSCRIPTION
|
|
83
|
+
if value == "2":
|
|
84
|
+
return AccountType.SERVICE
|
|
85
|
+
return AccountType.UNKNOWN
|
|
86
|
+
|
|
87
|
+
|
|
79
88
|
def _extract_picture_cdn_urls(script_text: str) -> list[str]:
|
|
80
89
|
"""从 picture_page_info_list 中只提取正文图片的 cdn_url,排除 watermark_info 和 share_cover 中的。"""
|
|
81
90
|
urls: list[str] = []
|
|
@@ -120,8 +129,21 @@ def _extract_meta(soup: BeautifulSoup, result: ArticleResult) -> None:
|
|
|
120
129
|
setattr(result, attr, value)
|
|
121
130
|
|
|
122
131
|
|
|
132
|
+
def _extract_account_type(script_text: str, result: ArticleResult) -> None:
|
|
133
|
+
"""从 new_service_type 中提取账号类型:0/1 → 订阅号,2 → 服务号。"""
|
|
134
|
+
if result.mp_account_type:
|
|
135
|
+
return
|
|
136
|
+
m = re.search(r"new_service_type:\s*'(\d+)'", script_text)
|
|
137
|
+
if m:
|
|
138
|
+
account_type = _service_type_to_account_type(m.group(1))
|
|
139
|
+
if account_type != AccountType.UNKNOWN:
|
|
140
|
+
result.mp_account_type = account_type
|
|
141
|
+
|
|
142
|
+
|
|
123
143
|
def _extract_rich_text_meta(script_text: str, result: ArticleResult) -> None:
|
|
124
144
|
"""从富文本文章的 script 标签中提取元数据。"""
|
|
145
|
+
_extract_account_type(script_text, result)
|
|
146
|
+
|
|
125
147
|
if "var hd_head_img" in script_text:
|
|
126
148
|
m = re.search(r'var hd_head_img = "([^"]+)"', script_text)
|
|
127
149
|
if m:
|
|
@@ -164,6 +186,8 @@ def _extract_rich_text_meta(script_text: str, result: ArticleResult) -> None:
|
|
|
164
186
|
|
|
165
187
|
def _extract_swiper_meta(script_text: str, result: ArticleResult) -> None:
|
|
166
188
|
"""从图片轮播 / 纯文本 / 视频分享页面的 script 标签中提取元数据。"""
|
|
189
|
+
_extract_account_type(script_text, result)
|
|
190
|
+
|
|
167
191
|
if "window.__initCgiDataConfig =" in script_text:
|
|
168
192
|
m = re.search(r"d\.hd_head_img.*?:\s*'([^']+)'", script_text)
|
|
169
193
|
if m:
|
|
@@ -236,18 +260,37 @@ def _extract_rich_media_content(content_tag: Tag, result: ArticleResult) -> None
|
|
|
236
260
|
tag.decompose()
|
|
237
261
|
|
|
238
262
|
# 处理含有 background-image 的 <svg> 标签
|
|
239
|
-
#
|
|
263
|
+
# 部分文章用嵌套 SVG(外层 SVG > foreignobject > 内层 SVG)承载正文图片,
|
|
264
|
+
# 需从外向内收集所有图片 URL,一次性替换为多个 <img> 标签
|
|
240
265
|
to_remove = []
|
|
241
|
-
for svg in soup.find_all("svg"):
|
|
242
|
-
|
|
243
|
-
if
|
|
266
|
+
for svg in soup.find_all("svg", recursive=True):
|
|
267
|
+
# 跳过已被外层 SVG 处理过的嵌套 SVG(已脱离文档树)
|
|
268
|
+
if not svg.parent:
|
|
269
|
+
continue
|
|
270
|
+
# 跳过嵌套在其他 SVG 内的 SVG,由外层统一处理
|
|
271
|
+
if svg.find_parent("svg"):
|
|
272
|
+
continue
|
|
273
|
+
|
|
274
|
+
# 收集本 SVG 及所有后代 SVG 中的 background-image 图片
|
|
275
|
+
all_svgs = [svg] + svg.find_all("svg")
|
|
276
|
+
img_tags = []
|
|
277
|
+
for s in all_svgs:
|
|
278
|
+
style = s.get("style", "")
|
|
279
|
+
if "background-image" not in style:
|
|
280
|
+
continue
|
|
244
281
|
m = re.search(r'url\("([^"]+)"\)', style)
|
|
245
|
-
if m:
|
|
246
|
-
normalized = _normalize_image_url(m.group(1))
|
|
247
|
-
new_img = soup.new_tag("img", src=normalized)
|
|
248
|
-
svg.replace_with(new_img)
|
|
282
|
+
if not m:
|
|
249
283
|
continue
|
|
250
|
-
|
|
284
|
+
normalized = _normalize_image_url(m.group(1))
|
|
285
|
+
img_tags.append(soup.new_tag("img", src=normalized))
|
|
286
|
+
if normalized not in seen:
|
|
287
|
+
seen.add(normalized)
|
|
288
|
+
result.images.append(normalized)
|
|
289
|
+
|
|
290
|
+
if img_tags:
|
|
291
|
+
svg.replace_with(*img_tags)
|
|
292
|
+
else:
|
|
293
|
+
to_remove.append(svg)
|
|
251
294
|
for tag in to_remove:
|
|
252
295
|
tag.decompose()
|
|
253
296
|
|
|
@@ -11,6 +11,9 @@ TEST_URLS = [
|
|
|
11
11
|
"https://mp.weixin.qq.com/s/0Wz3JeMbtWBL5iWJgYPS_Q",
|
|
12
12
|
"https://mp.weixin.qq.com/s/DmZXjgIzq5gBo3H-YtcjVw",
|
|
13
13
|
"https://mp.weixin.qq.com/s/80bysSCadvy9VbaovXBv2g",
|
|
14
|
+
"https://mp.weixin.qq.com/s/sfwGsafriO9sm6PfbQVXhw",
|
|
15
|
+
"https://mp.weixin.qq.com/s/h8E6riExCaH2Znmnprj-WQ",
|
|
16
|
+
"https://mp.weixin.qq.com/s/ySQdtsRlRmAl_skdc5HQ-A",
|
|
14
17
|
]
|
|
15
18
|
|
|
16
19
|
|
|
@@ -42,6 +45,7 @@ def _assert_result(result: ArticleResult, url: str) -> None:
|
|
|
42
45
|
print(f"公众号ID: {result.mp_id}")
|
|
43
46
|
print(f"公众号名称: {result.mp_name}")
|
|
44
47
|
print(f"公众号别名: {result.mp_alias}")
|
|
48
|
+
print(f"账号类型: {result.mp_account_type}")
|
|
45
49
|
print(
|
|
46
50
|
f"公众号图片: {result.mp_image[:80]}..."
|
|
47
51
|
if result.mp_image
|
|
@@ -97,6 +101,7 @@ def test_fetch_all(url: str) -> None:
|
|
|
97
101
|
print(f"公众号ID: {result.mp_id}")
|
|
98
102
|
print(f"公众号名称: {result.mp_name}")
|
|
99
103
|
print(f"公众号别名: {result.mp_alias}")
|
|
104
|
+
print(f"账号类型: {result.mp_account_type}")
|
|
100
105
|
print(f"公众号图片: {result.mp_image}")
|
|
101
106
|
print(f"公众号简介: {result.mp_description}")
|
|
102
107
|
print(f"文章ID: {result.article_id}")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|