landing_plan.py 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
  1. """落地页计划注册接口(piaoquantv `xcx/save`)适配层。
  2. 业务含义(2026-06-08 用户确认):
  3. 每条腾讯创意挂上去前,先到 piaoquantv 服务侧"注册一个落地计划",
  4. 服务侧返回:
  5. - pageUrl:小程序跳转路径,**直接作为 creative_components.jump_info.mini_program_path**
  6. - pageUrl 里嵌入了服务侧生成的 rootSourceId,**直接作为 dynamic_creative_name**(归因锚点)
  7. 接口契约(严格按用户给的 curl,不增不减字段):
  8. POST https://tp-open.piaoquantv.com/contentPlatform/plan/xcx/save
  9. header: token: <fixed>
  10. body: {
  11. "audiencePackage": str, # 账户级 crowd_package
  12. "videoList": [ # 单条逐次调,不批量
  13. {"videoId": int, "title": str, "cover": str, "video": str, "experimentId": str}
  14. ]
  15. }
  16. 重复调行为(用户确认):**新建** — 同 video 重复调返回不同 plan_id + 不同 rootSourceId + 不同 shareId。
  17. 客户端无需缓存 plan_id;每条腾讯创意对应一次 xcx/save 调用。
  18. """
  19. import logging
  20. import os
  21. from dataclasses import dataclass, field
  22. from typing import Optional
  23. from urllib.parse import parse_qs, unquote
  24. import httpx
  25. from tools.video_recall import LandingVideo, PIAOQUANTV_TOKEN
  26. logger = logging.getLogger(__name__)
  27. XCX_SAVE_URL = os.getenv(
  28. "PIAOQUANTV_XCX_SAVE_URL",
  29. "https://tp-open.piaoquantv.com/contentPlatform/plan/xcx/save",
  30. )
  31. @dataclass
  32. class LandingPlanResult:
  33. """xcx/save 返回的落地计划。
  34. 业务核心字段:
  35. page_url — 直接传给腾讯创意的 mini_program_path
  36. root_source_id — 直接用作 dynamic_creative_name(归因锚点)
  37. share_id — pageUrl 里的 rootShareId/shareId(实测两者相同)
  38. """
  39. plan_id: int # piaoquantv 平台计划 ID(自增,8059)
  40. page_url: str # `pages/category?jumpPage=...`
  41. root_source_id: str # 从 pageUrl 解析 — 也用作 creative_name
  42. share_id: str # 从 pageUrl 解析
  43. video_id: int # 入参回显,便于追溯
  44. audience_package: str
  45. raw: dict = field(default_factory=dict, repr=False)
  46. def _parse_inner_query(page_url: str) -> dict:
  47. """解析 pageUrl 内层 query。
  48. pageUrl 是双层 URL:
  49. 外层:`pages/category?jumpPage=<URL-encoded inner>`
  50. 内层:`pages/user-videos?fromGzh=1&rootShareId=...&id=...&shareId=...&rootSourceId=...`
  51. 返回:`{key: value}`(取 parse_qs 的第一个值)
  52. """
  53. q_idx = page_url.find("?")
  54. if q_idx < 0:
  55. raise ValueError(f"pageUrl 无 query 段: {page_url}")
  56. outer = page_url[q_idx + 1:]
  57. if not outer.startswith("jumpPage="):
  58. raise ValueError(f"pageUrl 外层 query 不是 jumpPage=: {outer[:80]}")
  59. inner_encoded = outer[len("jumpPage="):]
  60. inner_url = unquote(inner_encoded)
  61. inner_q_idx = inner_url.find("?")
  62. if inner_q_idx < 0:
  63. raise ValueError(f"jumpPage 解码后无 query: {inner_url}")
  64. inner_query = inner_url[inner_q_idx + 1:]
  65. parsed = parse_qs(inner_query, keep_blank_values=True)
  66. return {k: v[0] for k, v in parsed.items() if v}
  67. def create_landing_plan(
  68. crowd_package: str,
  69. video: LandingVideo,
  70. timeout: int = 30,
  71. ) -> LandingPlanResult:
  72. """调 xcx/save 注册一个落地计划,返回可直接挂创意的 pageUrl + creative_name。
  73. Args:
  74. crowd_package: 账户级 crowd_package(来自 account_whitelist)
  75. video: LandingVideo(必须含 video_id/title/cover_url/video_url/experiment_id)
  76. """
  77. if not video.experiment_id:
  78. raise ValueError(
  79. f"video_id={video.video_id} 缺 experiment_id,xcx/save 入参不完整"
  80. )
  81. body = {
  82. "audiencePackage": crowd_package,
  83. "videoList": [{
  84. "videoId": video.video_id,
  85. "title": video.title,
  86. "cover": video.cover_url,
  87. "video": video.video_url,
  88. "experimentId": video.experiment_id,
  89. }],
  90. }
  91. headers = {
  92. "Content-Type": "application/json",
  93. "token": PIAOQUANTV_TOKEN,
  94. "Accept": "application/json, text/plain, */*",
  95. }
  96. logger.info(
  97. "[landing_plan] POST xcx/save crowd=%r videoId=%d experimentId=%s",
  98. crowd_package, video.video_id, video.experiment_id,
  99. )
  100. resp = httpx.post(XCX_SAVE_URL, json=body, headers=headers, timeout=timeout)
  101. resp.raise_for_status()
  102. data = resp.json()
  103. if data.get("code") != 0 or not data.get("success"):
  104. raise RuntimeError(
  105. f"xcx/save 失败 videoId={video.video_id} "
  106. f"code={data.get('code')} msg={data.get('msg')}"
  107. )
  108. items = data.get("data") or []
  109. if not items:
  110. raise RuntimeError(
  111. f"xcx/save 返回 data 为空 videoId={video.video_id}"
  112. )
  113. item = items[0]
  114. page_url = item.get("pageUrl") or ""
  115. if not page_url:
  116. raise RuntimeError(
  117. f"xcx/save 返回 pageUrl 为空 videoId={video.video_id} item={item}"
  118. )
  119. inner = _parse_inner_query(page_url)
  120. root_source_id = inner.get("rootSourceId") or ""
  121. share_id = inner.get("shareId") or ""
  122. if not root_source_id:
  123. raise RuntimeError(
  124. f"pageUrl 内层未解析出 rootSourceId: {page_url}"
  125. )
  126. logger.info(
  127. "[landing_plan] OK plan_id=%s rootSourceId=%s",
  128. item.get("id"), root_source_id,
  129. )
  130. return LandingPlanResult(
  131. plan_id=int(item.get("id") or 0),
  132. page_url=page_url,
  133. root_source_id=root_source_id,
  134. share_id=share_id,
  135. video_id=video.video_id,
  136. audience_package=crowd_package,
  137. raw=item,
  138. )