|
|
@@ -0,0 +1,338 @@
|
|
|
+"""供给域工具函数 —— API 响应解析、数据转换、搜索/账号/文章通用工具"""
|
|
|
+
|
|
|
+import logging
|
|
|
+import re
|
|
|
+from datetime import datetime
|
|
|
+from typing import Dict, List, Tuple
|
|
|
+from urllib.parse import parse_qs, urlparse
|
|
|
+
|
|
|
+from src.infra.spider.wechat.gzh import get_article_list_from_account
|
|
|
+
|
|
|
+logger = logging.getLogger(__name__)
|
|
|
+
|
|
|
+
|
|
|
+# ═══════════════════════════════════════════════════════════════
|
|
|
+# URL 参数提取(通用)
|
|
|
+# ═══════════════════════════════════════════════════════════════
|
|
|
+
|
|
|
+
|
|
|
+def _extract_query_param(content_url: str | None, param: str) -> str | None:
|
|
|
+ """从 URL query 中提取指定参数值"""
|
|
|
+ if not content_url:
|
|
|
+ return None
|
|
|
+ query = urlparse(content_url).query
|
|
|
+ return parse_qs(query).get(param, [None])[0]
|
|
|
+
|
|
|
+
|
|
|
+def extract_wx_sn(content_url: str | None) -> str | None:
|
|
|
+ """从文章 URL 中提取 sn 参数作为 wx_sn
|
|
|
+
|
|
|
+ 文章链接格式: http://mp.weixin.qq.com/s?__biz=...&sn=xxx...
|
|
|
+ sn 参数值即为微信文章链接 ID。
|
|
|
+ """
|
|
|
+ return _extract_query_param(content_url, "sn")
|
|
|
+
|
|
|
+
|
|
|
+def extract_biz(content_url: str | None) -> str | None:
|
|
|
+ """从文章 URL 中提取 __biz 参数,标识公众号主体
|
|
|
+
|
|
|
+ 文章链接格式: http://mp.weixin.qq.com/s?__biz=MzIxNDE5NDA2OQ==&sn=xxx...
|
|
|
+ """
|
|
|
+ return _extract_query_param(content_url, "__biz")
|
|
|
+
|
|
|
+
|
|
|
+# ═══════════════════════════════════════════════════════════════
|
|
|
+# 搜索词校验
|
|
|
+# ═══════════════════════════════════════════════════════════════
|
|
|
+
|
|
|
+_INVALID_KEYWORDS = frozenset({"-", "--", "---", "/", ".", "。。。"})
|
|
|
+
|
|
|
+
|
|
|
+def is_valid_keyword(keyword: str) -> bool:
|
|
|
+ """搜索词是否有效:非空、非纯占位符"""
|
|
|
+ kw = keyword.strip()
|
|
|
+ if not kw:
|
|
|
+ return False
|
|
|
+ if kw in _INVALID_KEYWORDS:
|
|
|
+ return False
|
|
|
+ return True
|
|
|
+
|
|
|
+
|
|
|
+# ═══════════════════════════════════════════════════════════════
|
|
|
+# 搜索结果解析(Phase 1)
|
|
|
+# ═══════════════════════════════════════════════════════════════
|
|
|
+
|
|
|
+
|
|
|
+def parse_search_result(
|
|
|
+ article: Dict,
|
|
|
+ keyword: str,
|
|
|
+ key_type: str,
|
|
|
+ experiment_id: str,
|
|
|
+ page: int,
|
|
|
+) -> Dict:
|
|
|
+ """微信搜索 API 返回的单篇文章 → relation 表 INSERT dict"""
|
|
|
+ ts = article.get("time")
|
|
|
+ publish_time = datetime.fromtimestamp(ts) if ts and ts > 0 else None
|
|
|
+ article_url = article.get("url", "")
|
|
|
+ return {
|
|
|
+ "search_key": keyword,
|
|
|
+ "key_type": key_type,
|
|
|
+ "experiment_id": experiment_id,
|
|
|
+ "search_cursor": page,
|
|
|
+ "url": article_url,
|
|
|
+ "wx_sn": extract_wx_sn(article_url) or "",
|
|
|
+ "biz": extract_biz(article_url) or "",
|
|
|
+ "title": article.get("title", ""),
|
|
|
+ "cover_url": article.get("cover_url", ""),
|
|
|
+ "publish_time": publish_time,
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+# ═══════════════════════════════════════════════════════════════
|
|
|
+# 详情解析(Phase 2)
|
|
|
+# ═══════════════════════════════════════════════════════════════
|
|
|
+
|
|
|
+
|
|
|
+def parse_detail(detail: Dict) -> Dict:
|
|
|
+ """微信详情 API 返回的 data.data → detail 表 INSERT dict"""
|
|
|
+ ts = detail.get("publish_timestamp")
|
|
|
+ publish_at = datetime.fromtimestamp(ts / 1000) if ts and ts > 0 else None
|
|
|
+ content_url = detail.get("content_link", "")
|
|
|
+ wx_sn = detail.get("wx_sn", "") or extract_wx_sn(content_url) or ""
|
|
|
+ return {
|
|
|
+ "channel_content_id": detail.get("channel_content_id", ""),
|
|
|
+ "wx_sn": wx_sn,
|
|
|
+ "url": content_url,
|
|
|
+ "title": detail.get("title", ""),
|
|
|
+ "body_text": detail.get("body_text", ""),
|
|
|
+ "content_type": detail.get("content_type", ""),
|
|
|
+ "channel_account_id": detail.get("channel_account_id", ""),
|
|
|
+ "publish_timestamp": ts or 0,
|
|
|
+ "publish_at": publish_at,
|
|
|
+ "is_original": 1 if detail.get("is_original") else 0,
|
|
|
+ "view_count": detail.get("view_count") or 0,
|
|
|
+ "like_count": detail.get("like_count") or 0,
|
|
|
+ "looking_count": detail.get("looking_count") or 0,
|
|
|
+ "comment_count": detail.get("comment_count") or 0,
|
|
|
+ "share_count": detail.get("share_count") or 0,
|
|
|
+ "image_url_list": detail.get("image_url_list") or [],
|
|
|
+ "video_url_list": detail.get("video_url_list") or [],
|
|
|
+ "is_cache": 1 if detail.get("is_cache") else 0,
|
|
|
+ "extra": {
|
|
|
+ "item_index": detail.get("item_index"),
|
|
|
+ "channel_account_name": detail.get("channel_account_name"),
|
|
|
+ },
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+# ═══════════════════════════════════════════════════════════════
|
|
|
+# 账号解析(Phase 3)
|
|
|
+# ═══════════════════════════════════════════════════════════════
|
|
|
+
|
|
|
+
|
|
|
+def parse_account_response(api_data: Dict, biz: str = "") -> Dict:
|
|
|
+ """get_account_from_url 返回的 data.data → accounts 表 INSERT dict"""
|
|
|
+ return {
|
|
|
+ "channel_account_id": api_data.get("channel_account_id", ""),
|
|
|
+ "gh_id": api_data.get("wx_gh", "") or "",
|
|
|
+ "account_name": api_data.get("account_name", "") or "",
|
|
|
+ "biz": biz or api_data.get("biz_info", "") or "",
|
|
|
+ "biz_info": api_data.get("biz_info", "") or "",
|
|
|
+ "description": api_data.get("description", "") or "",
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+# ═══════════════════════════════════════════════════════════════
|
|
|
+# ShowDesc 解析:阅读/赞/付费/赞赏 → 数字
|
|
|
+# ═══════════════════════════════════════════════════════════════
|
|
|
+
|
|
|
+
|
|
|
+def show_desc_to_sta(show_desc: str) -> Dict[str, int]:
|
|
|
+ """解析 ShowDesc 中的阅读量、点赞量等
|
|
|
+
|
|
|
+ ShowDesc 格式示例:
|
|
|
+ "阅读 80 赞 3 "
|
|
|
+ "阅读 1 "
|
|
|
+ "阅读 121 赞 5 "
|
|
|
+
|
|
|
+ 分隔符: \\u2006 分隔 key-value, \\u2004/\\u2005 分隔 group。
|
|
|
+ """
|
|
|
+
|
|
|
+ def _decode_value(raw: str) -> int:
|
|
|
+ if not raw:
|
|
|
+ return 0
|
|
|
+ raw = raw.strip().lower().replace(",", ".")
|
|
|
+ m = re.search(r"(\d+(?:\.\d+)?)([a-z一-龥]*)", raw)
|
|
|
+ if not m:
|
|
|
+ return 0
|
|
|
+ num = float(m.group(1))
|
|
|
+ unit = m.group(2)
|
|
|
+ if "亿" in unit:
|
|
|
+ num *= 1e8
|
|
|
+ elif "万" in unit:
|
|
|
+ num *= 1e4
|
|
|
+ elif "千" in unit:
|
|
|
+ num *= 1e3
|
|
|
+ elif unit.startswith("k"):
|
|
|
+ num *= 1e3
|
|
|
+ elif unit.startswith("m"):
|
|
|
+ num *= 1e6
|
|
|
+ elif unit.startswith("b"):
|
|
|
+ num *= 1e9
|
|
|
+ return int(num)
|
|
|
+
|
|
|
+ def _decode_key(raw: str) -> str:
|
|
|
+ if not raw:
|
|
|
+ return "show_unknown"
|
|
|
+ raw = raw.strip().lower()
|
|
|
+ mapping = {
|
|
|
+ "阅读": "show_view_count",
|
|
|
+ "看过": "show_view_count",
|
|
|
+ "观看": "show_view_count",
|
|
|
+ "reads": "show_view_count",
|
|
|
+ "views": "show_view_count",
|
|
|
+ "view": "show_view_count",
|
|
|
+ "赞": "show_like_count",
|
|
|
+ "点赞": "show_like_count",
|
|
|
+ "likes": "show_like_count",
|
|
|
+ "like": "show_like_count",
|
|
|
+ "付费": "show_pay_count",
|
|
|
+ "payments": "show_pay_count",
|
|
|
+ "paid": "show_pay_count",
|
|
|
+ "赞赏": "show_zs_count",
|
|
|
+ }
|
|
|
+ return mapping.get(raw, "show_unknown")
|
|
|
+
|
|
|
+ if not show_desc:
|
|
|
+ return {
|
|
|
+ "show_view_count": 0,
|
|
|
+ "show_like_count": 0,
|
|
|
+ "show_pay_count": 0,
|
|
|
+ "show_zs_count": 0,
|
|
|
+ }
|
|
|
+
|
|
|
+ show_desc = show_desc.replace("+", "")
|
|
|
+ sta: Dict[str, int] = {}
|
|
|
+ groups = re.split(r"[ ]+", show_desc)
|
|
|
+
|
|
|
+ for group in groups:
|
|
|
+ group = group.strip()
|
|
|
+ if not group:
|
|
|
+ continue
|
|
|
+ parts = group.split(" ")
|
|
|
+ if len(parts) != 2:
|
|
|
+ continue
|
|
|
+ a, b = parts
|
|
|
+ if re.search(r"\d", a):
|
|
|
+ v_raw, k_raw = a, b
|
|
|
+ else:
|
|
|
+ k_raw, v_raw = a, b
|
|
|
+ key = _decode_key(k_raw)
|
|
|
+ val = _decode_value(v_raw)
|
|
|
+ if key != "show_unknown":
|
|
|
+ sta[key] = val
|
|
|
+
|
|
|
+ return {
|
|
|
+ "show_view_count": sta.get("show_view_count", 0),
|
|
|
+ "show_like_count": sta.get("show_like_count", 0),
|
|
|
+ "show_pay_count": sta.get("show_pay_count", 0),
|
|
|
+ "show_zs_count": sta.get("show_zs_count", 0),
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+# ═══════════════════════════════════════════════════════════════
|
|
|
+# 文章列表响应解析
|
|
|
+# ═══════════════════════════════════════════════════════════════
|
|
|
+
|
|
|
+
|
|
|
+def parse_article_list_response(data_list: List[Dict], gh_id: str) -> List[Dict]:
|
|
|
+ """get_article_list_from_account 返回的 data.data[] → articles 表 INSERT dict 列表"""
|
|
|
+ rows = []
|
|
|
+ for group in data_list or []:
|
|
|
+ app_msg = group.get("AppMsg") or {}
|
|
|
+ base_info = app_msg.get("BaseInfo") or {}
|
|
|
+ detail_list = app_msg.get("DetailInfo") or []
|
|
|
+ top_base = group.get("BaseInfo") or {}
|
|
|
+
|
|
|
+ for detail in detail_list:
|
|
|
+ url = detail.get("ContentUrl", "") or ""
|
|
|
+ show_desc = detail.get("ShowDesc", "") or ""
|
|
|
+ counts = show_desc_to_sta(show_desc)
|
|
|
+
|
|
|
+ rows.append(
|
|
|
+ {
|
|
|
+ "gh_id": gh_id,
|
|
|
+ "app_msg_id": base_info.get("AppMsgId", 0),
|
|
|
+ "item_index": detail.get("ItemIndex", 0),
|
|
|
+ "title": detail.get("Title", "") or "",
|
|
|
+ "content_url": url,
|
|
|
+ "wx_sn": extract_wx_sn(url) or "",
|
|
|
+ "digest": detail.get("Digest", "") or "",
|
|
|
+ "is_original": 1 if detail.get("IsOriginal") else 0,
|
|
|
+ "item_show_type": detail.get("ItemShowType", 0) or 0,
|
|
|
+ "source_url": detail.get("SourceUrl", "") or "",
|
|
|
+ "cover_img_url": detail.get("CoverImgUrl", "") or "",
|
|
|
+ "show_desc": show_desc,
|
|
|
+ "show_view_count": counts["show_view_count"],
|
|
|
+ "show_like_count": counts["show_like_count"],
|
|
|
+ "show_pay_count": counts["show_pay_count"],
|
|
|
+ "show_zs_count": counts["show_zs_count"],
|
|
|
+ "type": base_info.get("Type", 0) or 0,
|
|
|
+ "send_time": detail.get("send_time", 0) or 0,
|
|
|
+ "create_time": base_info.get("CreateTime", 0) or 0,
|
|
|
+ "update_time": base_info.get("UpdateTime", 0) or 0,
|
|
|
+ "date_time": top_base.get("DateTime", 0) or 0,
|
|
|
+ "base_info": top_base,
|
|
|
+ }
|
|
|
+ )
|
|
|
+ return rows
|
|
|
+
|
|
|
+
|
|
|
+# ═══════════════════════════════════════════════════════════════
|
|
|
+# API 调用封装
|
|
|
+# ═══════════════════════════════════════════════════════════════
|
|
|
+
|
|
|
+
|
|
|
+async def fetch_article_page(
|
|
|
+ gh_id: str,
|
|
|
+ cursor: str | None,
|
|
|
+) -> Tuple[List[Dict], str | None, bool, int | None]:
|
|
|
+ """调用 get_article_list_from_account 获取一页文章
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ (articles, next_cursor, has_more, oldest_send_time)
|
|
|
+ """
|
|
|
+ resp = await get_article_list_from_account(gh_id, index=cursor)
|
|
|
+ if not resp or resp.get("code") != 0:
|
|
|
+ logger.warning(
|
|
|
+ "get_article_list_from_account 返回异常: gh_id=%s, cursor=%s, code=%s",
|
|
|
+ gh_id,
|
|
|
+ cursor,
|
|
|
+ resp.get("code") if resp else "None",
|
|
|
+ )
|
|
|
+ return [], None, False, None
|
|
|
+
|
|
|
+ data_wrapper = resp.get("data") or {}
|
|
|
+ data_list = data_wrapper.get("data") or []
|
|
|
+ has_more = data_wrapper.get("has_more", False)
|
|
|
+ next_cursor = data_wrapper.get("next_cursor")
|
|
|
+
|
|
|
+ if not data_list:
|
|
|
+ return [], None, False, None
|
|
|
+
|
|
|
+ articles = parse_article_list_response(data_list, gh_id)
|
|
|
+ oldest = min((a["send_time"] for a in articles if a["send_time"] > 0), default=None)
|
|
|
+ return articles, next_cursor, has_more, oldest
|
|
|
+
|
|
|
+
|
|
|
+__all__ = [
|
|
|
+ "extract_wx_sn",
|
|
|
+ "extract_biz",
|
|
|
+ "is_valid_keyword",
|
|
|
+ "parse_search_result",
|
|
|
+ "parse_detail",
|
|
|
+ "parse_account_response",
|
|
|
+ "parse_article_list_response",
|
|
|
+ "show_desc_to_sta",
|
|
|
+ "fetch_article_page",
|
|
|
+]
|