portrait.py 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828
  1. """
  2. 热点宝画像数据工具
  3. 调用内部爬虫服务获取账号/内容的粉丝画像。
  4. """
  5. from __future__ import annotations
  6. import asyncio
  7. import json
  8. import logging
  9. import os
  10. import time
  11. from typing import Any, Optional
  12. import httpx
  13. from agents.find_agent.support.age_portrait import normalize_age_portrait_pair
  14. from supply_infra.services.video_discovery_service import (
  15. RunNotFoundError,
  16. build_evidence_values,
  17. get_video_discovery_service,
  18. )
  19. logger = logging.getLogger(__name__)
  20. BATCH_MAX_ITEMS = 8
  21. ACCOUNT_FANS_PORTRAIT_API = (
  22. "http://crawapi.piaoquantv.com/crawler/dou_yin/re_dian_bao/account_fans_portrait"
  23. )
  24. CONTENT_FANS_PORTRAIT_API = (
  25. "http://crawapi.piaoquantv.com/crawler/dou_yin/re_dian_bao/video_like_portrait"
  26. )
  27. DEFAULT_TIMEOUT = 60.0
  28. def _top_k(items: dict[str, Any], k: int) -> list[tuple[str, Any]]:
  29. def percent_value(entry: tuple[str, Any]) -> float:
  30. metrics = entry[1] if isinstance(entry[1], dict) else {}
  31. return metrics.get("percentage") or 0.0
  32. return sorted(items.items(), key=percent_value, reverse=True)[:k]
  33. def _format_portrait_summary(
  34. header_line: str,
  35. link_line: str,
  36. portrait: dict[str, Any],
  37. ) -> str:
  38. summary_lines = [header_line, link_line, ""]
  39. for key, value in portrait.items():
  40. if not isinstance(value, dict):
  41. continue
  42. if key in ("省份", "城市"):
  43. summary_lines.append(f"【{key} TOP5】分布")
  44. items = _top_k(value, 5)
  45. else:
  46. summary_lines.append(f"【{key}】分布")
  47. items = value.items()
  48. for name, metrics in items:
  49. ratio = metrics.get("percentage")
  50. tgi = metrics.get("preference")
  51. summary_lines.append(f" {name}: {ratio} (偏好度: {tgi})")
  52. summary_lines.append("")
  53. return "\n".join(summary_lines)
  54. def _validate_account_id(account_id: str) -> Optional[str]:
  55. if not account_id or not isinstance(account_id, str):
  56. return "account_id 参数无效:必须是非空字符串"
  57. if not account_id.startswith("MS4wLjABAAAA"):
  58. return (
  59. f"account_id 格式错误:必须以 MS4wLjABAAAA 开头,"
  60. f"当前值: {account_id[:min(20, len(account_id))]}..."
  61. )
  62. return None
  63. def _validate_content_id(content_id: str) -> Optional[str]:
  64. if not content_id or not isinstance(content_id, str):
  65. return "content_id 参数无效:必须是非空字符串"
  66. if not content_id.isdigit():
  67. return f"content_id 格式错误:aweme_id 应该是纯数字,当前值: {content_id[:20]}..."
  68. if len(content_id) < 15 or len(content_id) > 25:
  69. return f"content_id 长度异常:期望 15-25 位数字,实际 {len(content_id)} 位"
  70. return None
  71. def _dimension_flags(
  72. need_province: bool,
  73. need_city: bool,
  74. need_city_level: bool,
  75. need_gender: bool,
  76. need_age: bool,
  77. need_phone_brand: bool,
  78. need_phone_price: bool,
  79. ) -> dict[str, bool]:
  80. return {
  81. "need_province": need_province,
  82. "need_city": need_city,
  83. "need_city_level": need_city_level,
  84. "need_gender": need_gender,
  85. "need_age": need_age,
  86. "need_phone_brand": need_phone_brand,
  87. "need_phone_price": need_phone_price,
  88. }
  89. def _parse_portrait_response(
  90. data: dict[str, Any],
  91. *,
  92. header: str,
  93. link: str,
  94. ) -> dict[str, Any]:
  95. data_block = data.get("data", {}) if isinstance(data.get("data"), dict) else {}
  96. portrait = data_block.get("data", {}) if isinstance(data_block.get("data"), dict) else {}
  97. output = _format_portrait_summary(header, link, portrait)
  98. has_portrait = bool(portrait and any(isinstance(v, dict) and v for v in portrait.values()))
  99. return {
  100. "output": output,
  101. "has_portrait": has_portrait,
  102. "portrait_data": portrait,
  103. "raw_data": data,
  104. }
  105. async def _fetch_account_portrait(
  106. client: httpx.AsyncClient,
  107. account_id: str,
  108. flags: dict[str, bool],
  109. ) -> tuple[Optional[str], Optional[dict[str, Any]]]:
  110. err = _validate_account_id(account_id)
  111. if err:
  112. return err, None
  113. response = await client.post(
  114. ACCOUNT_FANS_PORTRAIT_API,
  115. json={"account_id": account_id, **flags},
  116. headers={"Content-Type": "application/json"},
  117. )
  118. response.raise_for_status()
  119. data = response.json()
  120. header = f"账号 {account_id} 的粉丝画像"
  121. link = (
  122. f"画像链接:https://douhot.douyin.com/creator/detail?"
  123. f"active_tab=creator_fans_portrait&creator_id={account_id}"
  124. )
  125. return None, _parse_portrait_response(data, header=header, link=link)
  126. async def _fetch_content_portrait(
  127. client: httpx.AsyncClient,
  128. content_id: str,
  129. flags: dict[str, bool],
  130. ) -> tuple[Optional[str], Optional[dict[str, Any]]]:
  131. err = _validate_content_id(content_id)
  132. if err:
  133. return err, None
  134. response = await client.post(
  135. CONTENT_FANS_PORTRAIT_API,
  136. json={"content_id": content_id, **flags},
  137. headers={"Content-Type": "application/json"},
  138. )
  139. response.raise_for_status()
  140. data = response.json()
  141. header = f"内容 {content_id} 的点赞用户画像"
  142. link = (
  143. f"画像链接:https://douhot.douyin.com/video/detail?"
  144. f"active_tab=video_fans&video_id={content_id}"
  145. )
  146. return None, _parse_portrait_response(data, header=header, link=link)
  147. def _success_result(payload: dict[str, Any]) -> str:
  148. return json.dumps(payload, ensure_ascii=False)
  149. def _error_result(
  150. error: str,
  151. *,
  152. title: str = "画像获取失败",
  153. input_error: bool = False,
  154. ) -> str:
  155. return json.dumps(
  156. {"error": error, "title": title, "input_error": input_error},
  157. ensure_ascii=False,
  158. )
  159. def _portrait_candidate_values(
  160. *,
  161. content_portrait: dict[str, Any],
  162. account_portrait: dict[str, Any],
  163. age_normalization: dict[str, Any],
  164. ) -> dict[str, Any]:
  165. content = age_normalization.get("content") or {}
  166. account = age_normalization.get("account") or {}
  167. return {
  168. "content_age_evidence_json": json.dumps(
  169. content_portrait, ensure_ascii=False
  170. ),
  171. "account_age_evidence_json": json.dumps(
  172. account_portrait, ensure_ascii=False
  173. ),
  174. "age_normalization_json": json.dumps(
  175. age_normalization, ensure_ascii=False
  176. ),
  177. "content_50_plus_ratio": (
  178. content.get("older_ratio") if content.get("has_age_portrait") else None
  179. ),
  180. "content_50_plus_tgi": (
  181. content.get("older_tgi") if content.get("has_age_portrait") else None
  182. ),
  183. "account_50_plus_ratio": (
  184. account.get("older_ratio") if account.get("has_age_portrait") else None
  185. ),
  186. "account_50_plus_tgi": (
  187. account.get("older_tgi") if account.get("has_age_portrait") else None
  188. ),
  189. }
  190. async def get_account_fans_portrait(
  191. account_id: str,
  192. need_province: bool = False,
  193. need_city: bool = False,
  194. need_city_level: bool = False,
  195. need_gender: bool = False,
  196. need_age: bool = True,
  197. need_phone_brand: bool = False,
  198. need_phone_price: bool = False,
  199. timeout: Optional[float] = None,
  200. ) -> str:
  201. """
  202. 获取抖音账号粉丝画像(热点宝数据)
  203. 获取指定账号的粉丝画像数据,包括年龄、性别、地域等多个维度。
  204. Args:
  205. account_id: 抖音账号ID(使用 author.sec_uid)
  206. need_province: 是否获取省份分布,默认 False
  207. need_city: 是否获取城市分布,默认 False
  208. need_city_level: 是否获取城市等级分布(一线/新一线/二线等),默认 False
  209. need_gender: 是否获取性别分布,默认 False
  210. need_age: 是否获取年龄分布,默认 True
  211. need_phone_brand: 是否获取手机品牌分布,默认 False
  212. need_phone_price: 是否获取手机价格分布,默认 False
  213. timeout: 超时时间(秒),默认 60
  214. Returns:
  215. JSON 字符串,包含 output(文本摘要)、has_portrait、portrait_data、raw_data。
  216. account_id 使用 author.sec_uid;省份数据只显示 TOP5。
  217. """
  218. start_time = time.time()
  219. request_timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
  220. flags = _dimension_flags(
  221. need_province,
  222. need_city,
  223. need_city_level,
  224. need_gender,
  225. need_age,
  226. need_phone_brand,
  227. need_phone_price,
  228. )
  229. try:
  230. async with httpx.AsyncClient(timeout=request_timeout) as client:
  231. err, ok = await _fetch_account_portrait(client, account_id, flags)
  232. duration_ms = int((time.time() - start_time) * 1000)
  233. if err:
  234. logger.error("get_account_fans_portrait failed: account_id=%s error=%s", account_id, err)
  235. return _error_result(err, title="账号粉丝画像获取失败")
  236. assert ok is not None
  237. logger.info(
  238. "get_account_fans_portrait completed: account_id=%s has_portrait=%s duration_ms=%d",
  239. account_id,
  240. ok["has_portrait"],
  241. duration_ms,
  242. )
  243. return _success_result(
  244. {
  245. "title": f"账号粉丝画像: {account_id}",
  246. "output": ok["output"],
  247. "has_portrait": ok["has_portrait"],
  248. "portrait_data": ok["portrait_data"],
  249. "raw_data": ok["raw_data"],
  250. "duration_ms": duration_ms,
  251. }
  252. )
  253. except httpx.HTTPStatusError as e:
  254. logger.error(
  255. "get_account_fans_portrait HTTP error: account_id=%s status=%d",
  256. account_id,
  257. e.response.status_code,
  258. )
  259. return _error_result(f"HTTP {e.response.status_code}: {e.response.text}", title="账号粉丝画像获取失败")
  260. except httpx.TimeoutException:
  261. logger.error("get_account_fans_portrait timeout: account_id=%s timeout=%s", account_id, request_timeout)
  262. return _error_result(f"请求超时({request_timeout}秒)", title="账号粉丝画像获取失败")
  263. except httpx.RequestError as e:
  264. logger.error("get_account_fans_portrait network error: account_id=%s error=%s", account_id, e)
  265. return _error_result(f"网络错误: {e}", title="账号粉丝画像获取失败")
  266. except Exception as e:
  267. logger.error(
  268. "get_account_fans_portrait unexpected error: account_id=%s error=%s",
  269. account_id,
  270. e,
  271. exc_info=True,
  272. )
  273. return _error_result(f"未知错误: {e}", title="账号粉丝画像获取失败")
  274. async def get_content_fans_portrait(
  275. content_id: str,
  276. need_province: bool = False,
  277. need_city: bool = False,
  278. need_city_level: bool = False,
  279. need_gender: bool = False,
  280. need_age: bool = True,
  281. need_phone_brand: bool = False,
  282. need_phone_price: bool = False,
  283. timeout: Optional[float] = None,
  284. ) -> str:
  285. """
  286. 获取抖音内容点赞用户画像(热点宝数据)
  287. 获取指定视频内容的点赞用户画像数据,包括年龄、性别、地域等多个维度。
  288. Args:
  289. content_id: 抖音内容ID(使用 aweme_id)
  290. need_province: 是否获取省份分布,默认 False
  291. need_city: 是否获取城市分布,默认 False
  292. need_city_level: 是否获取城市等级分布(一线/新一线/二线等),默认 False
  293. need_gender: 是否获取性别分布,默认 False
  294. need_age: 是否获取年龄分布,默认 True
  295. need_phone_brand: 是否获取手机品牌分布,默认 False
  296. need_phone_price: 是否获取手机价格分布,默认 False
  297. timeout: 超时时间(秒),默认 60
  298. Returns:
  299. JSON 字符串,包含 output(文本摘要)、has_portrait、portrait_data、raw_data。
  300. 若 has_portrait 为 False,可用 get_account_fans_portrait 作为兜底。
  301. """
  302. start_time = time.time()
  303. request_timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
  304. flags = _dimension_flags(
  305. need_province,
  306. need_city,
  307. need_city_level,
  308. need_gender,
  309. need_age,
  310. need_phone_brand,
  311. need_phone_price,
  312. )
  313. try:
  314. async with httpx.AsyncClient(timeout=request_timeout) as client:
  315. err, ok = await _fetch_content_portrait(client, content_id, flags)
  316. duration_ms = int((time.time() - start_time) * 1000)
  317. if err:
  318. logger.error("get_content_fans_portrait failed: content_id=%s error=%s", content_id, err)
  319. return _error_result(err, title="内容点赞用户画像获取失败")
  320. assert ok is not None
  321. logger.info(
  322. "get_content_fans_portrait completed: content_id=%s has_portrait=%s duration_ms=%d",
  323. content_id,
  324. ok["has_portrait"],
  325. duration_ms,
  326. )
  327. return _success_result(
  328. {
  329. "title": f"内容点赞用户画像: {content_id}",
  330. "output": ok["output"],
  331. "has_portrait": ok["has_portrait"],
  332. "portrait_data": ok["portrait_data"],
  333. "raw_data": ok["raw_data"],
  334. "duration_ms": duration_ms,
  335. }
  336. )
  337. except httpx.HTTPStatusError as e:
  338. logger.error(
  339. "get_content_fans_portrait HTTP error: content_id=%s status=%d",
  340. content_id,
  341. e.response.status_code,
  342. )
  343. return _error_result(f"HTTP {e.response.status_code}: {e.response.text}", title="内容点赞用户画像获取失败")
  344. except httpx.TimeoutException:
  345. logger.error("get_content_fans_portrait timeout: content_id=%s timeout=%s", content_id, request_timeout)
  346. return _error_result(f"请求超时({request_timeout}秒)", title="内容点赞用户画像获取失败")
  347. except httpx.RequestError as e:
  348. logger.error("get_content_fans_portrait network error: content_id=%s error=%s", content_id, e)
  349. return _error_result(f"网络错误: {e}", title="内容点赞用户画像获取失败")
  350. except Exception as e:
  351. logger.error(
  352. "get_content_fans_portrait unexpected error: content_id=%s error=%s",
  353. content_id,
  354. e,
  355. exc_info=True,
  356. )
  357. return _error_result(f"未知错误: {e}", title="内容点赞用户画像获取失败")
  358. async def batch_fetch_portraits(
  359. candidates_json: str = "",
  360. fetch_account_portrait: bool = False,
  361. need_province: bool = False,
  362. need_city: bool = False,
  363. need_city_level: bool = False,
  364. need_gender: bool = False,
  365. need_age: bool = True,
  366. need_phone_brand: bool = False,
  367. need_phone_price: bool = False,
  368. timeout: Optional[float] = None,
  369. run_id: str | None = None,
  370. candidate_ids: list[int] | None = None,
  371. ) -> str:
  372. """
  373. 批量获取多条候选视频的画像
  374. 依次请求内容点赞画像。fetch_account_portrait=true 时同时请求作者粉丝画像;
  375. 否则仅在内容画像缺失且允许兜底时请求作者画像。
  376. 一次调用返回所有条目,便于比较同一候选的两侧年龄证据。
  377. Args:
  378. candidates_json: 兼容旧调用的 JSON 数组字符串。每项为对象,字段:
  379. - aweme_id (必填): 视频 id
  380. - author_sec_uid (可选): 作者 sec_uid,作者画像或兜底时需要
  381. - try_account_fallback (可选,默认 true): 为 false 时不请求账号画像
  382. fetch_account_portrait: 是否为每个候选同时获取作者粉丝画像,默认 False。
  383. 老年受众判断建议设为 True;缺少 author_sec_uid 的条目会跳过作者画像。
  384. need_* / timeout: 与各单条画像工具一致
  385. run_id / candidate_ids: 新流程使用数据库候选 id;工具自动读取视频和作者 id、
  386. 保存原始响应及标准化画像并重算门禁。
  387. Returns:
  388. JSON 字符串,包含 output(人类可读摘要)和 results(结构化列表)。
  389. results 与 candidates 顺序一致,每项含 content / account 子对象。
  390. """
  391. start_time = time.time()
  392. request_timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
  393. candidate_id_by_aweme: dict[str, int] = {}
  394. if candidate_ids:
  395. if not run_id:
  396. return _error_result(
  397. "candidate_ids 模式必须提供 run_id",
  398. title="批量画像失败",
  399. input_error=True,
  400. )
  401. try:
  402. db_candidates = await asyncio.to_thread(
  403. get_video_discovery_service().get_candidates_by_ids,
  404. str(run_id),
  405. [int(value) for value in candidate_ids],
  406. )
  407. except (RunNotFoundError, ValueError) as exc:
  408. return _error_result(str(exc), title="批量画像失败", input_error=True)
  409. parsed_from_db: list[dict[str, Any]] = []
  410. for candidate in db_candidates:
  411. aweme_id = str(candidate.get("aweme_id") or "")
  412. if not aweme_id or aweme_id in candidate_id_by_aweme:
  413. continue
  414. candidate_id_by_aweme[aweme_id] = int(candidate["candidate_id"])
  415. parsed_from_db.append(
  416. {
  417. "aweme_id": aweme_id,
  418. "author_sec_uid": candidate.get("author_sec_uid"),
  419. "try_account_fallback": True,
  420. }
  421. )
  422. raw = json.dumps(parsed_from_db, ensure_ascii=False)
  423. else:
  424. raw = (candidates_json or "").strip()
  425. if not raw:
  426. return _error_result(
  427. "candidates_json 为空",
  428. title="批量画像失败",
  429. input_error=True,
  430. )
  431. try:
  432. parsed = json.loads(raw)
  433. except json.JSONDecodeError as e:
  434. return _error_result(
  435. f"candidates_json 不是合法 JSON: {e}",
  436. title="批量画像失败",
  437. input_error=True,
  438. )
  439. if not isinstance(parsed, list):
  440. return _error_result(
  441. "candidates_json 必须是 JSON 数组",
  442. title="批量画像失败",
  443. input_error=True,
  444. )
  445. if len(parsed) > BATCH_MAX_ITEMS:
  446. return _error_result(
  447. f"条目数超过上限 {BATCH_MAX_ITEMS},请分批调用",
  448. title="批量画像失败",
  449. input_error=True,
  450. )
  451. flags = _dimension_flags(
  452. need_province,
  453. need_city,
  454. need_city_level,
  455. need_gender,
  456. need_age,
  457. need_phone_brand,
  458. need_phone_price,
  459. )
  460. results: list[dict[str, Any]] = []
  461. output_chunks: list[str] = []
  462. try:
  463. async with httpx.AsyncClient(timeout=request_timeout) as client:
  464. for idx, entry in enumerate(parsed):
  465. if not isinstance(entry, dict):
  466. results.append(
  467. {
  468. "aweme_id": None,
  469. "error": "条目不是对象",
  470. "content": None,
  471. "account": None,
  472. }
  473. )
  474. output_chunks.append(f"[{idx}] 跳过:条目不是 JSON 对象")
  475. continue
  476. aweme_id = entry.get("aweme_id") or entry.get("content_id")
  477. author_sec = entry.get("author_sec_uid") or entry.get("account_id")
  478. try_fallback = entry.get("try_account_fallback", True)
  479. if isinstance(try_fallback, str):
  480. try_fallback = try_fallback.strip().lower() in ("1", "true", "yes")
  481. if not aweme_id or not isinstance(aweme_id, str):
  482. results.append(
  483. {
  484. "aweme_id": aweme_id,
  485. "error": "缺少 aweme_id",
  486. "content": None,
  487. "account": None,
  488. }
  489. )
  490. output_chunks.append(f"[{idx}] 跳过:缺少 aweme_id")
  491. continue
  492. item_result: dict[str, Any] = {
  493. "aweme_id": aweme_id,
  494. "author_sec_uid": author_sec if isinstance(author_sec, str) else None,
  495. "try_account_fallback": bool(try_fallback),
  496. "fetch_account_portrait": fetch_account_portrait,
  497. "content": None,
  498. "account": None,
  499. "error": None,
  500. }
  501. try:
  502. cerr, cok = await _fetch_content_portrait(client, aweme_id, flags)
  503. except httpx.HTTPError as e:
  504. cerr, cok = str(e), None
  505. if cerr:
  506. item_result["content"] = {
  507. "ok": False,
  508. "error": cerr,
  509. "has_portrait": False,
  510. "portrait_data": {},
  511. }
  512. else:
  513. assert cok is not None
  514. item_result["content"] = {
  515. "ok": True,
  516. "error": None,
  517. "has_portrait": cok["has_portrait"],
  518. "portrait_data": cok["portrait_data"],
  519. "output": cok["output"],
  520. }
  521. c_block = item_result["content"]
  522. content_has = bool(c_block and c_block.get("has_portrait"))
  523. need_account = fetch_account_portrait or (
  524. bool(try_fallback) and not content_has
  525. )
  526. aok: dict[str, Any] | None = None
  527. if need_account:
  528. if not author_sec or not isinstance(author_sec, str):
  529. item_result["account"] = {
  530. "attempted": False,
  531. "skipped_reason": "缺少 author_sec_uid,无法获取作者画像",
  532. "has_portrait": False,
  533. "portrait_data": {},
  534. }
  535. else:
  536. try:
  537. aerr, aok = await _fetch_account_portrait(client, author_sec, flags)
  538. except httpx.HTTPError as e:
  539. aerr, aok = str(e), None
  540. if aerr:
  541. item_result["account"] = {
  542. "attempted": True,
  543. "error": aerr,
  544. "has_portrait": False,
  545. "portrait_data": {},
  546. }
  547. else:
  548. assert aok is not None
  549. item_result["account"] = {
  550. "attempted": True,
  551. "error": None,
  552. "has_portrait": aok["has_portrait"],
  553. "portrait_data": aok["portrait_data"],
  554. "output": aok["output"],
  555. }
  556. else:
  557. skip_reason = (
  558. "try_account_fallback 为 false"
  559. if not try_fallback
  560. else "内容侧已有有效画像,且未要求同时获取作者画像"
  561. )
  562. item_result["account"] = {
  563. "attempted": False,
  564. "skipped_reason": skip_reason,
  565. "has_portrait": False,
  566. "portrait_data": {},
  567. }
  568. content_block = item_result["content"] or {}
  569. account_block = item_result["account"] or {}
  570. item_result["age_normalization"] = normalize_age_portrait_pair(
  571. content_block.get("portrait_data"),
  572. account_block.get("portrait_data"),
  573. )
  574. if run_id and aweme_id in candidate_id_by_aweme:
  575. candidate_id = candidate_id_by_aweme[aweme_id]
  576. normalized = _portrait_candidate_values(
  577. content_portrait=content_block.get("portrait_data") or {},
  578. account_portrait=account_block.get("portrait_data") or {},
  579. age_normalization=item_result["age_normalization"],
  580. )
  581. content_status = (
  582. "failed"
  583. if content_block.get("error")
  584. else (
  585. "success"
  586. if content_block.get("has_portrait")
  587. else "unavailable"
  588. )
  589. )
  590. content_evidence = build_evidence_values(
  591. run_id=str(run_id),
  592. evidence_type="content_portrait",
  593. provider="douhot",
  594. subject_key=aweme_id,
  595. request={"content_id": aweme_id, **flags},
  596. raw_response=(
  597. cok.get("raw_data")
  598. if cok is not None
  599. else {"error": content_block.get("error")}
  600. ),
  601. normalized=normalized,
  602. fetch_status=content_status,
  603. trigger_candidate_id=candidate_id,
  604. aweme_id=aweme_id,
  605. error_message=content_block.get("error"),
  606. )
  607. await asyncio.to_thread(
  608. get_video_discovery_service().record_candidate_evidence,
  609. run_id=str(run_id),
  610. evidence_values=content_evidence,
  611. aweme_id=aweme_id,
  612. normalized=normalized,
  613. )
  614. if account_block.get("attempted"):
  615. account_status = (
  616. "failed"
  617. if account_block.get("error")
  618. else (
  619. "success"
  620. if account_block.get("has_portrait")
  621. else "unavailable"
  622. )
  623. )
  624. account_evidence = build_evidence_values(
  625. run_id=str(run_id),
  626. evidence_type="account_portrait",
  627. provider="douhot",
  628. subject_key=str(author_sec or aweme_id),
  629. request={"account_id": author_sec, **flags},
  630. raw_response=(
  631. aok.get("raw_data")
  632. if aok is not None
  633. else {"error": account_block.get("error")}
  634. ),
  635. normalized=normalized,
  636. fetch_status=account_status,
  637. trigger_candidate_id=candidate_id,
  638. aweme_id=aweme_id,
  639. author_sec_uid=(
  640. str(author_sec) if author_sec is not None else None
  641. ),
  642. error_message=account_block.get("error"),
  643. )
  644. await asyncio.to_thread(
  645. get_video_discovery_service().record_candidate_evidence,
  646. run_id=str(run_id),
  647. evidence_values=account_evidence,
  648. aweme_id=aweme_id,
  649. normalized=normalized,
  650. )
  651. results.append(item_result)
  652. c_part = item_result["content"] or {}
  653. a_part = item_result["account"] or {}
  654. output_chunks.append(
  655. f"[{idx}] aweme_id={aweme_id} "
  656. f"content_has_portrait={c_part.get('has_portrait')} "
  657. f"account_attempted={a_part.get('attempted')} "
  658. f"account_has_portrait={a_part.get('has_portrait')}"
  659. )
  660. duration_ms = int((time.time() - start_time) * 1000)
  661. logger.info(
  662. "batch_fetch_portraits completed: count=%d candidates=%d duration_ms=%d",
  663. len(results),
  664. len(parsed),
  665. duration_ms,
  666. )
  667. payload = {
  668. "title": f"批量画像完成 ({len(results)} 条)",
  669. "output": "\n".join(output_chunks),
  670. "results": results,
  671. "count": len(results),
  672. "duration_ms": duration_ms,
  673. }
  674. if run_id and candidate_ids:
  675. refreshed = await asyncio.to_thread(
  676. get_video_discovery_service().get_candidates_by_ids,
  677. str(run_id),
  678. [int(value) for value in candidate_ids],
  679. )
  680. payload["candidates"] = [
  681. {
  682. "candidate_id": item["candidate_id"],
  683. "aweme_id": item["aweme_id"],
  684. "gate_status": item["gate_status"],
  685. "failed_reason_codes": (
  686. item.get("gate_results", {}).get("failed_reason_codes") or []
  687. ),
  688. "evidence_version": item.get("evidence_version"),
  689. }
  690. for item in refreshed
  691. ]
  692. payload.pop("results", None)
  693. return _success_result(payload)
  694. except Exception as e:
  695. logger.error("batch_fetch_portraits unexpected error: error=%s", e, exc_info=True)
  696. return _error_result(f"未知错误: {e}", title="批量画像失败")
  697. async def main() -> None:
  698. content_id = os.getenv("TEST_CONTENT_ID", "7641118685977614586")
  699. account_id = os.getenv("TEST_ACCOUNT_SEC_UID", "MS4wLjABAAAAcA9a--HmibvcoJ_0YCQYZ1qqbn2uCj5e4CVdc0c6y6s")
  700. print("=== 测试 get_content_fans_portrait ===")
  701. content_result = json.loads(await get_content_fans_portrait(content_id=content_id))
  702. if "error" in content_result:
  703. print(f"获取失败: {content_result['error']}")
  704. else:
  705. print(content_result["output"])
  706. print(
  707. f"\nhas_portrait={content_result.get('has_portrait')} "
  708. f"duration_ms={content_result.get('duration_ms')}"
  709. )
  710. if account_id:
  711. print("\n=== 测试 get_account_fans_portrait ===")
  712. account_result = json.loads(
  713. await get_account_fans_portrait(account_id=account_id)
  714. )
  715. if "error" in account_result:
  716. print(f"获取失败: {account_result['error']}")
  717. else:
  718. print(account_result["output"])
  719. print(
  720. f"\nhas_portrait={account_result.get('has_portrait')} "
  721. f"duration_ms={account_result.get('duration_ms')}"
  722. )
  723. else:
  724. print("\n跳过账号画像测试(设置环境变量 TEST_ACCOUNT_SEC_UID 可启用)")
  725. print("\n=== 测试 batch_fetch_portraits ===")
  726. candidates = [
  727. {
  728. "aweme_id": content_id,
  729. "author_sec_uid": account_id or None,
  730. "try_account_fallback": bool(account_id),
  731. }
  732. ]
  733. batch_result = json.loads(
  734. await batch_fetch_portraits(candidates_json=json.dumps(candidates, ensure_ascii=False))
  735. )
  736. if "error" in batch_result:
  737. print(f"批量获取失败: {batch_result['error']}")
  738. else:
  739. print(batch_result["output"])
  740. print(f"\ncount={batch_result.get('count')} duration_ms={batch_result.get('duration_ms')}")
  741. if __name__ == "__main__":
  742. asyncio.run(main())