videos.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445
  1. """
  2. 从 multi_demand_pool_di 收集视频,增量写入 multi_demand_video_detail,
  3. 并从 ODPS 昨天分区拉取最终选题 JSON 与标题。
  4. """
  5. from __future__ import annotations
  6. import json
  7. import logging
  8. from datetime import datetime, timedelta
  9. from typing import Any
  10. from supply_infra.db.repositories.multi_demand_pool_di_repo import MultiDemandPoolDiRepository
  11. from supply_infra.db.repositories.multi_demand_video_detail_repo import (
  12. MultiDemandVideoDetailRepository,
  13. )
  14. from supply_infra.db.repositories.multi_demand_video_point_repo import (
  15. MultiDemandVideoPointRepository,
  16. )
  17. from supply_infra.db.session import get_session
  18. from supply_infra.odps.client import ODPSClient, get_odps_client
  19. from supply_infra.video_points import (
  20. extract_points_json_from_decode,
  21. points_from_decode_payload,
  22. )
  23. logger = logging.getLogger(__name__)
  24. _FINAL_TOPIC_KEY = "最终选题"
  25. _TARGET_POST_KEY = "target_post"
  26. _TITLE_KEY = "title"
  27. VIDEO_SYNC_BATCH_SIZE = 100
  28. def _collect_vids_from_video_lists(video_lists: list[str]) -> set[str]:
  29. """解析 video_list JSON 文本,收集去重 vid。"""
  30. vids: set[str] = set()
  31. for text in video_lists:
  32. try:
  33. parsed = json.loads(text)
  34. except json.JSONDecodeError:
  35. logger.warning("Skip invalid video_list JSON: %s", text[:120])
  36. continue
  37. if not isinstance(parsed, list):
  38. logger.warning("Skip non-list video_list: %s", text[:120])
  39. continue
  40. for item in parsed:
  41. if item is None:
  42. continue
  43. vid = str(item).strip()
  44. if vid:
  45. vids.add(vid)
  46. return vids
  47. def _parse_decode_result(decode_result: Any) -> dict[str, Any] | None:
  48. """将 decode_result 解析为 dict。"""
  49. if decode_result is None:
  50. return None
  51. if isinstance(decode_result, dict):
  52. return decode_result
  53. if isinstance(decode_result, str):
  54. text = decode_result.strip()
  55. if not text:
  56. return None
  57. try:
  58. payload = json.loads(text)
  59. except json.JSONDecodeError:
  60. logger.warning("Skip invalid decode_result JSON: %s", text[:120])
  61. return None
  62. return payload if isinstance(payload, dict) else None
  63. return None
  64. def _extract_final_topic_json(payload: dict[str, Any]) -> str | None:
  65. """从 decode_result 取出「最终选题」并序列化为 JSON 文本。"""
  66. final_topic = payload.get(_FINAL_TOPIC_KEY)
  67. if final_topic is None:
  68. return None
  69. return json.dumps(final_topic, ensure_ascii=False)
  70. def _extract_all_points(payload: dict[str, Any]) -> dict[str, str | None]:
  71. """从 decode_result 提取灵感点/目的点/关键点三个 JSON 列。"""
  72. return extract_points_json_from_decode(payload)
  73. def _extract_title(payload: dict[str, Any]) -> str | None:
  74. """从 decode_result.target_post.title 取标题。"""
  75. target_post = payload.get(_TARGET_POST_KEY)
  76. if not isinstance(target_post, dict):
  77. return None
  78. title = target_post.get(_TITLE_KEY)
  79. if title is None:
  80. return None
  81. text = str(title).strip()
  82. return text[:512] if text else None
  83. def _extract_url(value: Any, max_len: int = 1024) -> str | None:
  84. """从 ODPS 行字段提取 URL 文本(与 vid 同级)。"""
  85. if value is None:
  86. return None
  87. text = str(value).strip()
  88. return text[:max_len] if text else None
  89. def _sync_one_batch(
  90. odps: ODPSClient,
  91. decode_dt: str,
  92. batch_vids: list[str],
  93. batch_idx: int,
  94. batch_total: int,
  95. ) -> dict[str, int]:
  96. """查询一批 vid,立刻写入 MySQL。"""
  97. logger.info(
  98. "Batch %d/%d: query %d vids, decode_dt=%s",
  99. batch_idx,
  100. batch_total,
  101. len(batch_vids),
  102. decode_dt,
  103. )
  104. odps_rows = odps.fetch_topic_decode_results(
  105. decode_dt, batch_vids, batch_size=len(batch_vids)
  106. )
  107. insert_rows: list[dict[str, Any]] = []
  108. point_rows_by_vid: dict[str, list[dict[str, Any]]] = {}
  109. skipped_no_topic = 0
  110. seen_vids: set[str] = set()
  111. for row in odps_rows:
  112. raw_vid = row.get("vid")
  113. if raw_vid is None:
  114. continue
  115. vid = str(raw_vid).strip()
  116. if not vid or vid in seen_vids:
  117. continue
  118. payload = _parse_decode_result(row.get("decode_result"))
  119. if payload is None:
  120. skipped_no_topic += 1
  121. continue
  122. topic_json = _extract_final_topic_json(payload)
  123. if topic_json is None:
  124. skipped_no_topic += 1
  125. continue
  126. seen_vids.add(vid)
  127. insert_rows.append(
  128. {
  129. "vid": vid,
  130. "url1": _extract_url(row.get("url1")),
  131. "url2": _extract_url(row.get("url2")),
  132. "title": _extract_title(payload),
  133. "final_topic_json": topic_json,
  134. **_extract_all_points(payload),
  135. }
  136. )
  137. point_rows = points_from_decode_payload(vid, payload)
  138. if point_rows:
  139. point_rows_by_vid[vid] = point_rows
  140. with get_session() as session:
  141. detail_repo = MultiDemandVideoDetailRepository(session)
  142. inserted = detail_repo.bulk_insert_ignore(insert_rows)
  143. if point_rows_by_vid:
  144. MultiDemandVideoPointRepository(session).replace_for_video_ids(
  145. point_rows_by_vid
  146. )
  147. odps_vids = {
  148. str(r.get("vid")).strip()
  149. for r in odps_rows
  150. if r.get("vid") is not None and str(r.get("vid")).strip()
  151. }
  152. missing_in_odps = len(set(batch_vids) - odps_vids)
  153. stats = {
  154. "odps_rows": len(odps_rows),
  155. "inserted": inserted,
  156. "skipped_no_topic": skipped_no_topic,
  157. "missing_in_odps": missing_in_odps,
  158. }
  159. logger.info("Batch %d/%d done: %s", batch_idx, batch_total, stats)
  160. return stats
  161. def sync_multi_demand_videos(
  162. limit: int | None = None,
  163. offset: int = 0,
  164. batch_size: int = VIDEO_SYNC_BATCH_SIZE,
  165. *,
  166. decode_dt: str | None = None,
  167. ) -> dict[str, Any]:
  168. """
  169. 增量同步需求池视频与最终选题。
  170. - 视频来源:multi_demand_pool_di 全表 video_list
  171. - 已有 vid 跳过
  172. - ODPS decode 分区由批次冻结参数传入;兼容调用未传时使用「今天的昨天」
  173. - 按 batch_size 分批:每批查询后立刻写入
  174. - limit: 本轮最多处理的 pending 数;None 表示从 offset 起全部
  175. - offset: pending 列表起始偏移(手动分批用)
  176. """
  177. resolved_decode_dt = decode_dt or (
  178. datetime.now() - timedelta(days=1)
  179. ).strftime("%Y%m%d")
  180. start = max(0, int(offset))
  181. chunk = max(1, int(batch_size))
  182. logger.info(
  183. "Starting multi demand video sync: decode_dt=%s limit=%s offset=%d batch_size=%d",
  184. resolved_decode_dt,
  185. limit,
  186. start,
  187. chunk,
  188. )
  189. with get_session() as session:
  190. video_lists = MultiDemandPoolDiRepository(session).list_all_video_lists()
  191. all_vids = _collect_vids_from_video_lists(video_lists)
  192. existing = (
  193. MultiDemandVideoDetailRepository(session).get_existing_vids(all_vids)
  194. if all_vids
  195. else set()
  196. )
  197. pending_all = sorted(all_vids - existing)
  198. pending = pending_all[start:]
  199. if limit is not None:
  200. pending = pending[: max(0, int(limit))]
  201. logger.info(
  202. "Vids: pool_lists=%d unique=%d existing=%d pending_total=%d to_process=%d",
  203. len(video_lists),
  204. len(all_vids),
  205. len(existing),
  206. len(pending_all),
  207. len(pending),
  208. )
  209. empty = {
  210. "decode_dt": resolved_decode_dt,
  211. "pool_video_lists": len(video_lists),
  212. "unique_vids": len(all_vids),
  213. "pending_total": len(pending_all),
  214. "offset": start,
  215. "batch_size": chunk,
  216. "batches": 0,
  217. "processed": 0,
  218. "odps_rows": 0,
  219. "inserted": 0,
  220. "skipped_no_topic": 0,
  221. "missing_in_odps": 0,
  222. "next_offset": start,
  223. "remaining": max(0, len(pending_all) - start),
  224. }
  225. if not pending:
  226. logger.info("No pending vids: %s", empty)
  227. return empty
  228. odps = get_odps_client()
  229. batches = [pending[i : i + chunk] for i in range(0, len(pending), chunk)]
  230. total_odps_rows = 0
  231. total_inserted = 0
  232. total_skipped = 0
  233. total_missing = 0
  234. for idx, batch_vids in enumerate(batches, start=1):
  235. stats = _sync_one_batch(odps, resolved_decode_dt, batch_vids, idx, len(batches))
  236. total_odps_rows += stats["odps_rows"]
  237. total_inserted += stats["inserted"]
  238. total_skipped += stats["skipped_no_topic"]
  239. total_missing += stats["missing_in_odps"]
  240. next_offset = start + len(pending)
  241. result = {
  242. "decode_dt": resolved_decode_dt,
  243. "pool_video_lists": len(video_lists),
  244. "unique_vids": len(all_vids),
  245. "pending_total": len(pending_all),
  246. "offset": start,
  247. "batch_size": chunk,
  248. "batches": len(batches),
  249. "processed": len(pending),
  250. "odps_rows": total_odps_rows,
  251. "inserted": total_inserted,
  252. "skipped_no_topic": total_skipped,
  253. "missing_in_odps": total_missing,
  254. "next_offset": next_offset,
  255. "remaining": max(0, len(pending_all) - next_offset),
  256. }
  257. logger.info("Multi demand video sync completed: %s", result)
  258. return result
  259. def _backfill_urls_one_batch(
  260. odps: ODPSClient,
  261. decode_dt: str,
  262. batch_vids: list[str],
  263. batch_idx: int,
  264. batch_total: int,
  265. ) -> dict[str, int]:
  266. """查询一批 vid 的 url1/url2,立刻更新 MySQL。"""
  267. logger.info(
  268. "Backfill batch %d/%d: query %d vids, decode_dt=%s",
  269. batch_idx,
  270. batch_total,
  271. len(batch_vids),
  272. decode_dt,
  273. )
  274. odps_rows = odps.fetch_topic_decode_results(
  275. decode_dt, batch_vids, batch_size=len(batch_vids)
  276. )
  277. urls_by_vid: dict[str, dict[str, str | None]] = {}
  278. seen_vids: set[str] = set()
  279. for row in odps_rows:
  280. raw_vid = row.get("vid")
  281. if raw_vid is None:
  282. continue
  283. vid = str(raw_vid).strip()
  284. if not vid or vid in seen_vids:
  285. continue
  286. url1 = _extract_url(row.get("url1"))
  287. url2 = _extract_url(row.get("url2"))
  288. if url1 is None and url2 is None:
  289. continue
  290. seen_vids.add(vid)
  291. urls_by_vid[vid] = {"url1": url1, "url2": url2}
  292. with get_session() as session:
  293. updated = MultiDemandVideoDetailRepository(session).update_urls(urls_by_vid)
  294. odps_vids = {
  295. str(r.get("vid")).strip()
  296. for r in odps_rows
  297. if r.get("vid") is not None and str(r.get("vid")).strip()
  298. }
  299. stats = {
  300. "odps_rows": len(odps_rows),
  301. "matched": len(urls_by_vid),
  302. "updated": updated,
  303. "missing_in_odps": len(set(batch_vids) - odps_vids),
  304. "no_urls_in_odps": len(odps_rows) - len(urls_by_vid),
  305. }
  306. logger.info("Backfill batch %d/%d done: %s", batch_idx, batch_total, stats)
  307. return stats
  308. def backfill_multi_demand_video_urls(
  309. limit: int | None = None,
  310. offset: int = 0,
  311. batch_size: int = VIDEO_SYNC_BATCH_SIZE,
  312. *,
  313. decode_dt: str | None = None,
  314. ) -> dict[str, Any]:
  315. """
  316. 回填已有 multi_demand_video_detail 行的 url1/url2。
  317. - 目标:url1 或 url2 为空的 vid
  318. - 数据来源:ODPS dwd_topic_decode_result_di 表字段(与 vid 同级)
  319. - decode_dt 默认「今天的昨天」
  320. """
  321. resolved_decode_dt = decode_dt or (
  322. datetime.now() - timedelta(days=1)
  323. ).strftime("%Y%m%d")
  324. start = max(0, int(offset))
  325. chunk = max(1, int(batch_size))
  326. logger.info(
  327. "Starting multi demand video url backfill: decode_dt=%s limit=%s offset=%d batch_size=%d",
  328. resolved_decode_dt,
  329. limit,
  330. start,
  331. chunk,
  332. )
  333. with get_session() as session:
  334. pending_all = sorted(
  335. MultiDemandVideoDetailRepository(session).list_vids_missing_urls()
  336. )
  337. pending = pending_all[start:]
  338. if limit is not None:
  339. pending = pending[: max(0, int(limit))]
  340. logger.info(
  341. "Url backfill vids: pending_total=%d to_process=%d",
  342. len(pending_all),
  343. len(pending),
  344. )
  345. empty = {
  346. "decode_dt": resolved_decode_dt,
  347. "pending_total": len(pending_all),
  348. "offset": start,
  349. "batch_size": chunk,
  350. "batches": 0,
  351. "processed": 0,
  352. "odps_rows": 0,
  353. "matched": 0,
  354. "updated": 0,
  355. "missing_in_odps": 0,
  356. "no_urls_in_odps": 0,
  357. "next_offset": start,
  358. "remaining": max(0, len(pending_all) - start),
  359. }
  360. if not pending:
  361. logger.info("No pending vids for url backfill: %s", empty)
  362. return empty
  363. odps = get_odps_client()
  364. batches = [pending[i : i + chunk] for i in range(0, len(pending), chunk)]
  365. total_odps_rows = 0
  366. total_matched = 0
  367. total_updated = 0
  368. total_missing = 0
  369. total_no_urls = 0
  370. for idx, batch_vids in enumerate(batches, start=1):
  371. stats = _backfill_urls_one_batch(
  372. odps, resolved_decode_dt, batch_vids, idx, len(batches)
  373. )
  374. total_odps_rows += stats["odps_rows"]
  375. total_matched += stats["matched"]
  376. total_updated += stats["updated"]
  377. total_missing += stats["missing_in_odps"]
  378. total_no_urls += stats["no_urls_in_odps"]
  379. next_offset = start + len(pending)
  380. result = {
  381. "decode_dt": resolved_decode_dt,
  382. "pending_total": len(pending_all),
  383. "offset": start,
  384. "batch_size": chunk,
  385. "batches": len(batches),
  386. "processed": len(pending),
  387. "odps_rows": total_odps_rows,
  388. "matched": total_matched,
  389. "updated": total_updated,
  390. "missing_in_odps": total_missing,
  391. "no_urls_in_odps": total_no_urls,
  392. "next_offset": next_offset,
  393. "remaining": max(0, len(pending_all) - next_offset),
  394. }
  395. logger.info("Multi demand video url backfill completed: %s", result)
  396. return result