creation_search.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353
  1. """430 query 真实采集:搜索前 10、详情限速、媒体保存/OSS、创作知识判断。"""
  2. from __future__ import annotations
  3. import hashlib
  4. import json
  5. import time
  6. from dataclasses import dataclass
  7. from pathlib import Path
  8. from typing import Any, Callable, Iterable, Optional
  9. from acquisition import store
  10. from acquisition.classify import classify_imgtext, classify_video
  11. from acquisition.crawler import RateLimiter, fetch_post_detail
  12. from acquisition.oss import upload_stream
  13. from acquisition.search import search_keyword, search_weixin, search_xiaohongshu, fetch_weixin_detail
  14. from core.config import Settings
  15. from core.models import Post
  16. from creation_knowledge.integrations.video_extract import _default_download
  17. ROOT = Path(__file__).resolve().parent.parent
  18. DEFAULT_QUERY_FILE = ROOT / "data" / "queries" / "creation_demo.json"
  19. PLATFORMS = ("xiaohongshu", "weixin", "douyin")
  20. Downloader = Callable[[str, str], bytes]
  21. class PlatformRateLimiter:
  22. """把同一平台的 keyword/detail 调用合并到同一个 10-12s bucket。"""
  23. def __init__(self, platform: str, *, min_seconds: float = 10.0, max_seconds: float = 12.0,
  24. delegate: Optional[RateLimiter] = None) -> None:
  25. self.platform = platform
  26. self.delegate = delegate or RateLimiter(
  27. min_interval_seconds=min_seconds,
  28. max_interval_seconds=max_seconds,
  29. )
  30. def wait(self, bucket: str) -> None:
  31. self.delegate.wait(self.platform)
  32. @dataclass
  33. class Candidate:
  34. rank: int
  35. source_id: str = ""
  36. url: str = ""
  37. title: str = ""
  38. author: str = ""
  39. cover_url: str = ""
  40. raw: dict | None = None
  41. def load_creation_queries(path: Path | str = DEFAULT_QUERY_FILE) -> list[str]:
  42. """从 creation_demo.json 读取所有 keep=true 的唯一 query,保持首次出现顺序。"""
  43. data = json.loads(Path(path).read_text("utf-8"))
  44. seen: set[str] = set()
  45. out: list[str] = []
  46. for fam in data.get("families") or []:
  47. for item in fam.get("items") or []:
  48. q = (item.get("query") or "").strip()
  49. if q and item.get("keep", True) and q not in seen:
  50. seen.add(q)
  51. out.append(q)
  52. return out
  53. def prompt_version(*names: str) -> str:
  54. h = hashlib.sha256()
  55. for name in names:
  56. p = ROOT / "prompts" / f"{name}.txt"
  57. h.update(name.encode("utf-8"))
  58. if p.exists():
  59. h.update(p.read_bytes())
  60. return h.hexdigest()[:16]
  61. def _hash(value: str, n: int = 16) -> str:
  62. return hashlib.md5(value.encode("utf-8")).hexdigest()[:n]
  63. def _ext_from_bytes(data: bytes) -> str:
  64. if data[:4] == b"\x89PNG":
  65. return ".png"
  66. if data[:3] == b"GIF":
  67. return ".gif"
  68. if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
  69. return ".webp"
  70. return ".jpg"
  71. def _dedup(values: Iterable[str]) -> list[str]:
  72. seen: set[str] = set()
  73. out: list[str] = []
  74. for v in values:
  75. if v and v not in seen:
  76. seen.add(v)
  77. out.append(v)
  78. return out
  79. def _data_root(settings: Settings) -> Path:
  80. p = Path(settings.data_dir or "data")
  81. return p if p.is_absolute() else ROOT / p
  82. def _save_images(urls: list[str], *, platform: str, base_dir: Path, public_base: str,
  83. downloader: Downloader = _default_download, max_images: int = 12) -> list[str]:
  84. out: list[str] = []
  85. for i, url in enumerate(_dedup(urls)[:max_images], start=1):
  86. if not url.startswith("http"):
  87. continue
  88. try:
  89. data = downloader(url, platform)
  90. except Exception:
  91. continue
  92. if not data:
  93. continue
  94. base_dir.mkdir(parents=True, exist_ok=True)
  95. ext = _ext_from_bytes(data)
  96. name = f"image_{i}{ext}"
  97. (base_dir / name).write_bytes(data)
  98. out.append(f"{public_base}/{name}")
  99. return out
  100. def _classify_and_store(conn, item_id: int, platform: str, *, title: str, body_text: str,
  101. images: list[str], video_url: str, settings: Settings,
  102. ts: Optional[int] = None) -> None:
  103. if platform == "douyin":
  104. version = prompt_version("classify_video")
  105. is_creation, reason, knowledge, _points = classify_video(
  106. {"platform": platform, "title": title, "body_text": body_text, "video": video_url},
  107. settings,
  108. )
  109. else:
  110. version = prompt_version("classify_imgtext")
  111. is_creation, reason, knowledge, _points = classify_imgtext(
  112. {"platform": platform, "title": title, "body_text": body_text, "images": images},
  113. settings,
  114. )
  115. store.upsert_creation_classification(
  116. conn,
  117. item_id,
  118. is_creation,
  119. reason=reason,
  120. knowledge=knowledge,
  121. prompt_version=version,
  122. error=reason if is_creation is None else "",
  123. ts=ts,
  124. )
  125. def search_candidates(platform: str, query: str, *, settings: Settings, limit: int,
  126. rate_limiter: PlatformRateLimiter) -> list[Candidate]:
  127. if platform == "xiaohongshu":
  128. rows = search_xiaohongshu(
  129. query, content_type="图文", limit=limit, settings=settings, rate_limiter=rate_limiter
  130. )
  131. return [
  132. Candidate(
  133. rank=i, source_id=r.get("id") or "", url=r.get("url") or "",
  134. title=r.get("title") or "", author=r.get("nick_name") or "",
  135. cover_url=r.get("cover_url") or "", raw=r,
  136. )
  137. for i, r in enumerate(rows, start=1)
  138. ]
  139. if platform == "weixin":
  140. rows = search_weixin(query, limit=limit, settings=settings, rate_limiter=rate_limiter)
  141. return [
  142. Candidate(
  143. rank=i, url=r.get("url") or "", title=r.get("title") or "",
  144. author=r.get("nick_name") or "", cover_url=r.get("cover_url") or "", raw=r,
  145. )
  146. for i, r in enumerate(rows, start=1)
  147. ]
  148. if platform == "douyin":
  149. ids = search_keyword(
  150. query, platform="douyin", content_type="视频", limit=limit,
  151. settings=settings, rate_limiter=rate_limiter,
  152. )
  153. return [Candidate(rank=i, source_id=cid, raw={"id": cid}) for i, cid in enumerate(ids, start=1)]
  154. raise ValueError(f"unsupported platform: {platform}")
  155. def _process_xhs(candidate: Candidate, *, query_hash: str, settings: Settings,
  156. rate_limiter: PlatformRateLimiter, downloader: Downloader) -> dict:
  157. post = fetch_post_detail(candidate.source_id or candidate.url, settings=settings, rate_limiter=rate_limiter)
  158. base = _data_root(settings) / "media" / "xiaohongshu" / query_hash / (post.content_id or candidate.source_id)
  159. pub = f"/data/media/xiaohongshu/{query_hash}/{post.content_id or candidate.source_id}"
  160. images = _save_images(post.image_urls or [candidate.cover_url], platform="xiaohongshu",
  161. base_dir=base, public_base=pub, downloader=downloader)
  162. if not images:
  163. raise RuntimeError("图片下载失败")
  164. return {
  165. "source_id": post.content_id or candidate.source_id,
  166. "url": post.url or candidate.url,
  167. "title": post.title or candidate.title,
  168. "author": post.author_name or candidate.author,
  169. "body_text": post.body_text or "",
  170. "cover_url": images[0],
  171. "image_urls": images,
  172. "video_url": "",
  173. "raw": post.raw if isinstance(post.raw, dict) else {},
  174. }
  175. def _process_weixin(candidate: Candidate, *, query_hash: str, settings: Settings,
  176. rate_limiter: PlatformRateLimiter, downloader: Downloader) -> dict:
  177. body_text, detail_images = fetch_weixin_detail(
  178. candidate.url, settings=settings, rate_limiter=rate_limiter
  179. )
  180. h = _hash(candidate.url)
  181. base = _data_root(settings) / "media" / "weixin" / query_hash / h
  182. pub = f"/data/media/weixin/{query_hash}/{h}"
  183. images = _save_images(detail_images or [candidate.cover_url], platform="weixin",
  184. base_dir=base, public_base=pub, downloader=downloader)
  185. return {
  186. "source_id": h,
  187. "url": candidate.url,
  188. "title": candidate.title,
  189. "author": candidate.author,
  190. "body_text": body_text,
  191. "cover_url": images[0] if images else candidate.cover_url,
  192. "image_urls": images,
  193. "video_url": "",
  194. "raw": candidate.raw or {},
  195. }
  196. def _process_douyin(candidate: Candidate, *, settings: Settings,
  197. rate_limiter: PlatformRateLimiter) -> dict:
  198. post: Post = fetch_post_detail(candidate.source_id, settings=settings, rate_limiter=rate_limiter)
  199. if not post.video_urls:
  200. raise RuntimeError("详情无视频")
  201. cdn_url = upload_stream(post.video_urls[0], src_type="video", settings=settings)
  202. return {
  203. "source_id": post.content_id or candidate.source_id,
  204. "url": post.url,
  205. "title": post.title,
  206. "author": post.author_name or "",
  207. "body_text": post.body_text or "",
  208. "cover_url": (post.image_urls or [""])[0],
  209. "image_urls": post.image_urls or [],
  210. "video_url": cdn_url,
  211. "raw": post.raw if isinstance(post.raw, dict) else {},
  212. }
  213. def process_candidate(platform: str, candidate: Candidate, *, query_hash: str,
  214. settings: Settings, rate_limiter: PlatformRateLimiter,
  215. downloader: Downloader = _default_download) -> dict:
  216. if platform == "xiaohongshu":
  217. return _process_xhs(candidate, query_hash=query_hash, settings=settings,
  218. rate_limiter=rate_limiter, downloader=downloader)
  219. if platform == "weixin":
  220. return _process_weixin(candidate, query_hash=query_hash, settings=settings,
  221. rate_limiter=rate_limiter, downloader=downloader)
  222. if platform == "douyin":
  223. return _process_douyin(candidate, settings=settings, rate_limiter=rate_limiter)
  224. raise ValueError(f"unsupported platform: {platform}")
  225. def run_platform_query(conn, *, run_id: str, query: str, platform: str, settings: Settings,
  226. search_limit: int = 10, display_limit: int = 5,
  227. rate_limiter: Optional[PlatformRateLimiter] = None,
  228. downloader: Downloader = _default_download,
  229. sleep_fn: Callable[[float], None] = time.sleep,
  230. classify: bool = True, ts: Optional[int] = None) -> dict:
  231. """执行一个 query×platform job。任何 item 失败都会落库并继续。"""
  232. gate = rate_limiter or PlatformRateLimiter(platform)
  233. now = int(time.time()) if ts is None else ts
  234. store.update_creation_job(conn, run_id, query, platform, status="running", ts=now)
  235. candidates: list[Candidate] = []
  236. last_error = ""
  237. attempts = 0
  238. for attempts in range(1, 4):
  239. try:
  240. candidates = search_candidates(
  241. platform, query, settings=settings, limit=search_limit, rate_limiter=gate
  242. )
  243. last_error = ""
  244. break
  245. except Exception as exc:
  246. last_error = f"搜索失败: {str(exc)[:120]}"
  247. store.update_creation_job(
  248. conn, run_id, query, platform, status="running", attempts=attempts,
  249. error=last_error, ts=int(time.time()),
  250. )
  251. if attempts < 3:
  252. sleep_fn(30 if attempts == 1 else 90)
  253. if last_error:
  254. store.update_creation_job(
  255. conn, run_id, query, platform, status="failed", attempts=attempts,
  256. searched_count=0, display_count=0, error=last_error, ts=int(time.time()),
  257. )
  258. return {"platform": platform, "status": "failed", "display_count": 0, "error": last_error}
  259. qh = _hash(query)
  260. display_count = 0
  261. errors: list[str] = []
  262. for cand in candidates[:search_limit]:
  263. if display_count >= display_limit:
  264. break
  265. try:
  266. processed = process_candidate(
  267. platform, cand, query_hash=qh, settings=settings, rate_limiter=gate,
  268. downloader=downloader,
  269. )
  270. item_id = store.upsert_creation_item(
  271. conn, run_id=run_id, query=query, platform=platform, rank=cand.rank,
  272. source_id=processed.get("source_id", ""), url=processed.get("url", ""),
  273. title=processed.get("title", ""), author=processed.get("author", ""),
  274. body_text=processed.get("body_text", ""), cover_url=processed.get("cover_url", ""),
  275. image_urls=processed.get("image_urls", []), video_url=processed.get("video_url", ""),
  276. media={"source": cand.raw or {}, "candidate_rank": cand.rank},
  277. raw=processed.get("raw", {}), is_displayable=True, ts=int(time.time()),
  278. )
  279. if classify:
  280. _classify_and_store(
  281. conn, item_id, platform, title=processed.get("title", ""),
  282. body_text=processed.get("body_text", ""),
  283. images=processed.get("image_urls", []),
  284. video_url=processed.get("video_url", ""), settings=settings,
  285. ts=int(time.time()),
  286. )
  287. display_count += 1
  288. except Exception as exc:
  289. msg = f"rank {cand.rank}: {str(exc)[:120]}"
  290. errors.append(msg)
  291. store.upsert_creation_item(
  292. conn, run_id=run_id, query=query, platform=platform, rank=cand.rank,
  293. source_id=cand.source_id, url=cand.url, title=cand.title,
  294. author=cand.author, cover_url=cand.cover_url, raw=cand.raw or {},
  295. is_displayable=False, error=msg, ts=int(time.time()),
  296. )
  297. if display_count >= display_limit:
  298. status = "done"
  299. error = ""
  300. elif display_count > 0:
  301. status = "partial"
  302. error = "; ".join(errors[-3:])
  303. else:
  304. status = "failed"
  305. error = "; ".join(errors[-3:]) or "未获得可展示内容"
  306. store.update_creation_job(
  307. conn, run_id, query, platform, status=status, attempts=attempts,
  308. searched_count=len(candidates), display_count=display_count,
  309. error=error, ts=int(time.time()),
  310. )
  311. return {"platform": platform, "status": status, "display_count": display_count, "error": error}