weixin.py 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. """Weixin formal acquisition adapter."""
  2. from __future__ import annotations
  3. import hashlib
  4. from urllib.parse import parse_qs, urlparse
  5. from typing import Any, Iterable
  6. from acquisition.content_mode import ARTICLE
  7. from acquisition.platforms.base import PlatformCandidate, PlatformItem, PlatformSearchPage
  8. from acquisition.search import fetch_weixin_detail, search_weixin_page
  9. from core.config import Settings
  10. def _hash(value: str, n: int = 16) -> str:
  11. return hashlib.md5(value.encode("utf-8")).hexdigest()[:n]
  12. def _dedupe_key(url: str) -> str:
  13. parsed = urlparse(url)
  14. return parsed._replace(query="", fragment="").geturl() or url
  15. def _is_decorative_weixin_image(url: str) -> bool:
  16. parsed = urlparse(url)
  17. query = parse_qs(parsed.query)
  18. wx_fmt = (query.get("wx_fmt") or [""])[0].lower()
  19. path = parsed.path.lower()
  20. if wx_fmt == "gif" or path.endswith(".gif"):
  21. return True
  22. if "mmbiz_gif" in path:
  23. return True
  24. return False
  25. def filter_weixin_article_images(image_urls: list[str]) -> list[str]:
  26. """Keep only article images that should become decode cards.
  27. Weixin detail returns all article image resources, including duplicated
  28. images and common account UI/animation GIFs such as "在看" prompts. Those
  29. should stay in the original article text, but they should not become
  30. knowledge trace cards.
  31. """
  32. kept: list[str] = []
  33. seen: set[str] = set()
  34. for url in image_urls:
  35. if not url:
  36. continue
  37. key = _dedupe_key(url)
  38. if key in seen:
  39. continue
  40. seen.add(key)
  41. if _is_decorative_weixin_image(url):
  42. continue
  43. kept.append(url)
  44. return kept
  45. class WeixinAdapter:
  46. platform = "weixin"
  47. def search(
  48. self,
  49. query: str,
  50. *,
  51. settings: Settings,
  52. limit: int,
  53. rate_limiter: Any,
  54. ) -> list[PlatformCandidate]:
  55. pages = self.search_pages(
  56. query,
  57. settings=settings,
  58. limit=limit,
  59. rate_limiter=rate_limiter,
  60. max_pages=1,
  61. )
  62. return [candidate for page in pages for candidate in page.candidates]
  63. def search_pages(
  64. self,
  65. query: str,
  66. *,
  67. settings: Settings,
  68. limit: int,
  69. rate_limiter: Any,
  70. max_pages: int = 2,
  71. ) -> Iterable[PlatformSearchPage]:
  72. cursor = "0"
  73. rank = 1
  74. seen: set[str] = set()
  75. for page_index in range(1, max_pages + 1):
  76. page = search_weixin_page(
  77. query,
  78. cursor=cursor,
  79. limit=limit,
  80. settings=settings,
  81. rate_limiter=rate_limiter,
  82. )
  83. candidates: list[PlatformCandidate] = []
  84. for page_rank, row in enumerate(page.rows, start=1):
  85. url = row.get("url") or ""
  86. source_id = _hash(url)
  87. if not url or source_id in seen:
  88. continue
  89. seen.add(source_id)
  90. raw = {
  91. **row,
  92. "page_index": page_index,
  93. "page_rank": page_rank,
  94. "source_cursor": page.cursor,
  95. "request_payload": page.request_payload,
  96. }
  97. candidates.append(
  98. PlatformCandidate(
  99. rank=rank,
  100. platform=self.platform,
  101. source_id=source_id,
  102. url=url,
  103. title=row.get("title") or "",
  104. author=row.get("nick_name") or "",
  105. cover_url=row.get("cover_url") or "",
  106. raw=raw,
  107. )
  108. )
  109. rank += 1
  110. yield PlatformSearchPage(
  111. page_index=page_index,
  112. candidates=candidates,
  113. raw_count=page.raw_count,
  114. cursor=page.cursor,
  115. next_cursor=page.next_cursor,
  116. has_more=page.has_more,
  117. provider=page.provider,
  118. request_payload=page.request_payload,
  119. )
  120. if not page.has_more or not page.next_cursor or page.next_cursor == cursor:
  121. break
  122. cursor = page.next_cursor
  123. def fetch_detail(
  124. self,
  125. candidate: PlatformCandidate,
  126. *,
  127. settings: Settings,
  128. rate_limiter: Any,
  129. ) -> PlatformItem:
  130. body_text, images = fetch_weixin_detail(
  131. candidate.url,
  132. settings=settings,
  133. rate_limiter=rate_limiter,
  134. )
  135. return PlatformItem(
  136. platform=self.platform,
  137. source_id=candidate.source_id or _hash(candidate.url),
  138. url=candidate.url,
  139. content_type="图文",
  140. content_mode=ARTICLE,
  141. title=candidate.title,
  142. author=candidate.author,
  143. body_text=body_text,
  144. image_urls=filter_weixin_article_images(images) or [candidate.cover_url],
  145. raw=candidate.raw,
  146. )