hotspot_profile.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663
  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.tools.decision_support import normalize_age_portrait_pair
  14. from supply_agent.tools import tool
  15. logger = logging.getLogger(__name__)
  16. BATCH_MAX_ITEMS = 8
  17. ACCOUNT_FANS_PORTRAIT_API = (
  18. "http://crawapi.piaoquantv.com/crawler/dou_yin/re_dian_bao/account_fans_portrait"
  19. )
  20. CONTENT_FANS_PORTRAIT_API = (
  21. "http://crawapi.piaoquantv.com/crawler/dou_yin/re_dian_bao/video_like_portrait"
  22. )
  23. DEFAULT_TIMEOUT = 60.0
  24. def _top_k(items: dict[str, Any], k: int) -> list[tuple[str, Any]]:
  25. def percent_value(entry: tuple[str, Any]) -> float:
  26. metrics = entry[1] if isinstance(entry[1], dict) else {}
  27. return metrics.get("percentage") or 0.0
  28. return sorted(items.items(), key=percent_value, reverse=True)[:k]
  29. def _format_portrait_summary(
  30. header_line: str,
  31. link_line: str,
  32. portrait: dict[str, Any],
  33. ) -> str:
  34. summary_lines = [header_line, link_line, ""]
  35. for key, value in portrait.items():
  36. if not isinstance(value, dict):
  37. continue
  38. if key in ("省份", "城市"):
  39. summary_lines.append(f"【{key} TOP5】分布")
  40. items = _top_k(value, 5)
  41. else:
  42. summary_lines.append(f"【{key}】分布")
  43. items = value.items()
  44. for name, metrics in items:
  45. ratio = metrics.get("percentage")
  46. tgi = metrics.get("preference")
  47. summary_lines.append(f" {name}: {ratio} (偏好度: {tgi})")
  48. summary_lines.append("")
  49. return "\n".join(summary_lines)
  50. def _validate_account_id(account_id: str) -> Optional[str]:
  51. if not account_id or not isinstance(account_id, str):
  52. return "account_id 参数无效:必须是非空字符串"
  53. if not account_id.startswith("MS4wLjABAAAA"):
  54. return (
  55. f"account_id 格式错误:必须以 MS4wLjABAAAA 开头,"
  56. f"当前值: {account_id[:min(20, len(account_id))]}..."
  57. )
  58. return None
  59. def _validate_content_id(content_id: str) -> Optional[str]:
  60. if not content_id or not isinstance(content_id, str):
  61. return "content_id 参数无效:必须是非空字符串"
  62. if not content_id.isdigit():
  63. return f"content_id 格式错误:aweme_id 应该是纯数字,当前值: {content_id[:20]}..."
  64. if len(content_id) < 15 or len(content_id) > 25:
  65. return f"content_id 长度异常:期望 15-25 位数字,实际 {len(content_id)} 位"
  66. return None
  67. def _dimension_flags(
  68. need_province: bool,
  69. need_city: bool,
  70. need_city_level: bool,
  71. need_gender: bool,
  72. need_age: bool,
  73. need_phone_brand: bool,
  74. need_phone_price: bool,
  75. ) -> dict[str, bool]:
  76. return {
  77. "need_province": need_province,
  78. "need_city": need_city,
  79. "need_city_level": need_city_level,
  80. "need_gender": need_gender,
  81. "need_age": need_age,
  82. "need_phone_brand": need_phone_brand,
  83. "need_phone_price": need_phone_price,
  84. }
  85. def _parse_portrait_response(
  86. data: dict[str, Any],
  87. *,
  88. header: str,
  89. link: str,
  90. ) -> dict[str, Any]:
  91. data_block = data.get("data", {}) if isinstance(data.get("data"), dict) else {}
  92. portrait = data_block.get("data", {}) if isinstance(data_block.get("data"), dict) else {}
  93. output = _format_portrait_summary(header, link, portrait)
  94. has_portrait = bool(portrait and any(isinstance(v, dict) and v for v in portrait.values()))
  95. return {
  96. "output": output,
  97. "has_portrait": has_portrait,
  98. "portrait_data": portrait,
  99. "raw_data": data,
  100. }
  101. async def _fetch_account_portrait(
  102. client: httpx.AsyncClient,
  103. account_id: str,
  104. flags: dict[str, bool],
  105. ) -> tuple[Optional[str], Optional[dict[str, Any]]]:
  106. err = _validate_account_id(account_id)
  107. if err:
  108. return err, None
  109. response = await client.post(
  110. ACCOUNT_FANS_PORTRAIT_API,
  111. json={"account_id": account_id, **flags},
  112. headers={"Content-Type": "application/json"},
  113. )
  114. response.raise_for_status()
  115. data = response.json()
  116. header = f"账号 {account_id} 的粉丝画像"
  117. link = (
  118. f"画像链接:https://douhot.douyin.com/creator/detail?"
  119. f"active_tab=creator_fans_portrait&creator_id={account_id}"
  120. )
  121. return None, _parse_portrait_response(data, header=header, link=link)
  122. async def _fetch_content_portrait(
  123. client: httpx.AsyncClient,
  124. content_id: str,
  125. flags: dict[str, bool],
  126. ) -> tuple[Optional[str], Optional[dict[str, Any]]]:
  127. err = _validate_content_id(content_id)
  128. if err:
  129. return err, None
  130. response = await client.post(
  131. CONTENT_FANS_PORTRAIT_API,
  132. json={"content_id": content_id, **flags},
  133. headers={"Content-Type": "application/json"},
  134. )
  135. response.raise_for_status()
  136. data = response.json()
  137. header = f"内容 {content_id} 的点赞用户画像"
  138. link = (
  139. f"画像链接:https://douhot.douyin.com/video/detail?"
  140. f"active_tab=video_fans&video_id={content_id}"
  141. )
  142. return None, _parse_portrait_response(data, header=header, link=link)
  143. def _success_result(payload: dict[str, Any]) -> str:
  144. return json.dumps(payload, ensure_ascii=False)
  145. def _error_result(
  146. error: str,
  147. *,
  148. title: str = "画像获取失败",
  149. input_error: bool = False,
  150. ) -> str:
  151. return json.dumps(
  152. {"error": error, "title": title, "input_error": input_error},
  153. ensure_ascii=False,
  154. )
  155. @tool
  156. async def get_account_fans_portrait(
  157. account_id: str,
  158. need_province: bool = False,
  159. need_city: bool = False,
  160. need_city_level: bool = False,
  161. need_gender: bool = False,
  162. need_age: bool = True,
  163. need_phone_brand: bool = False,
  164. need_phone_price: bool = False,
  165. timeout: Optional[float] = None,
  166. ) -> str:
  167. """
  168. 获取抖音账号粉丝画像(热点宝数据)
  169. 获取指定账号的粉丝画像数据,包括年龄、性别、地域等多个维度。
  170. Args:
  171. account_id: 抖音账号ID(使用 author.sec_uid)
  172. need_province: 是否获取省份分布,默认 False
  173. need_city: 是否获取城市分布,默认 False
  174. need_city_level: 是否获取城市等级分布(一线/新一线/二线等),默认 False
  175. need_gender: 是否获取性别分布,默认 False
  176. need_age: 是否获取年龄分布,默认 True
  177. need_phone_brand: 是否获取手机品牌分布,默认 False
  178. need_phone_price: 是否获取手机价格分布,默认 False
  179. timeout: 超时时间(秒),默认 60
  180. Returns:
  181. JSON 字符串,包含 output(文本摘要)、has_portrait、portrait_data、raw_data。
  182. account_id 使用 author.sec_uid;省份数据只显示 TOP5。
  183. """
  184. start_time = time.time()
  185. request_timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
  186. flags = _dimension_flags(
  187. need_province,
  188. need_city,
  189. need_city_level,
  190. need_gender,
  191. need_age,
  192. need_phone_brand,
  193. need_phone_price,
  194. )
  195. try:
  196. async with httpx.AsyncClient(timeout=request_timeout) as client:
  197. err, ok = await _fetch_account_portrait(client, account_id, flags)
  198. duration_ms = int((time.time() - start_time) * 1000)
  199. if err:
  200. logger.error("get_account_fans_portrait failed: account_id=%s error=%s", account_id, err)
  201. return _error_result(err, title="账号粉丝画像获取失败")
  202. assert ok is not None
  203. logger.info(
  204. "get_account_fans_portrait completed: account_id=%s has_portrait=%s duration_ms=%d",
  205. account_id,
  206. ok["has_portrait"],
  207. duration_ms,
  208. )
  209. return _success_result(
  210. {
  211. "title": f"账号粉丝画像: {account_id}",
  212. "output": ok["output"],
  213. "has_portrait": ok["has_portrait"],
  214. "portrait_data": ok["portrait_data"],
  215. "raw_data": ok["raw_data"],
  216. "duration_ms": duration_ms,
  217. }
  218. )
  219. except httpx.HTTPStatusError as e:
  220. logger.error(
  221. "get_account_fans_portrait HTTP error: account_id=%s status=%d",
  222. account_id,
  223. e.response.status_code,
  224. )
  225. return _error_result(f"HTTP {e.response.status_code}: {e.response.text}", title="账号粉丝画像获取失败")
  226. except httpx.TimeoutException:
  227. logger.error("get_account_fans_portrait timeout: account_id=%s timeout=%s", account_id, request_timeout)
  228. return _error_result(f"请求超时({request_timeout}秒)", title="账号粉丝画像获取失败")
  229. except httpx.RequestError as e:
  230. logger.error("get_account_fans_portrait network error: account_id=%s error=%s", account_id, e)
  231. return _error_result(f"网络错误: {e}", title="账号粉丝画像获取失败")
  232. except Exception as e:
  233. logger.error(
  234. "get_account_fans_portrait unexpected error: account_id=%s error=%s",
  235. account_id,
  236. e,
  237. exc_info=True,
  238. )
  239. return _error_result(f"未知错误: {e}", title="账号粉丝画像获取失败")
  240. @tool
  241. async def get_content_fans_portrait(
  242. content_id: str,
  243. need_province: bool = False,
  244. need_city: bool = False,
  245. need_city_level: bool = False,
  246. need_gender: bool = False,
  247. need_age: bool = True,
  248. need_phone_brand: bool = False,
  249. need_phone_price: bool = False,
  250. timeout: Optional[float] = None,
  251. ) -> str:
  252. """
  253. 获取抖音内容点赞用户画像(热点宝数据)
  254. 获取指定视频内容的点赞用户画像数据,包括年龄、性别、地域等多个维度。
  255. Args:
  256. content_id: 抖音内容ID(使用 aweme_id)
  257. need_province: 是否获取省份分布,默认 False
  258. need_city: 是否获取城市分布,默认 False
  259. need_city_level: 是否获取城市等级分布(一线/新一线/二线等),默认 False
  260. need_gender: 是否获取性别分布,默认 False
  261. need_age: 是否获取年龄分布,默认 True
  262. need_phone_brand: 是否获取手机品牌分布,默认 False
  263. need_phone_price: 是否获取手机价格分布,默认 False
  264. timeout: 超时时间(秒),默认 60
  265. Returns:
  266. JSON 字符串,包含 output(文本摘要)、has_portrait、portrait_data、raw_data。
  267. 若 has_portrait 为 False,可用 get_account_fans_portrait 作为兜底。
  268. """
  269. start_time = time.time()
  270. request_timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
  271. flags = _dimension_flags(
  272. need_province,
  273. need_city,
  274. need_city_level,
  275. need_gender,
  276. need_age,
  277. need_phone_brand,
  278. need_phone_price,
  279. )
  280. try:
  281. async with httpx.AsyncClient(timeout=request_timeout) as client:
  282. err, ok = await _fetch_content_portrait(client, content_id, flags)
  283. duration_ms = int((time.time() - start_time) * 1000)
  284. if err:
  285. logger.error("get_content_fans_portrait failed: content_id=%s error=%s", content_id, err)
  286. return _error_result(err, title="内容点赞用户画像获取失败")
  287. assert ok is not None
  288. logger.info(
  289. "get_content_fans_portrait completed: content_id=%s has_portrait=%s duration_ms=%d",
  290. content_id,
  291. ok["has_portrait"],
  292. duration_ms,
  293. )
  294. return _success_result(
  295. {
  296. "title": f"内容点赞用户画像: {content_id}",
  297. "output": ok["output"],
  298. "has_portrait": ok["has_portrait"],
  299. "portrait_data": ok["portrait_data"],
  300. "raw_data": ok["raw_data"],
  301. "duration_ms": duration_ms,
  302. }
  303. )
  304. except httpx.HTTPStatusError as e:
  305. logger.error(
  306. "get_content_fans_portrait HTTP error: content_id=%s status=%d",
  307. content_id,
  308. e.response.status_code,
  309. )
  310. return _error_result(f"HTTP {e.response.status_code}: {e.response.text}", title="内容点赞用户画像获取失败")
  311. except httpx.TimeoutException:
  312. logger.error("get_content_fans_portrait timeout: content_id=%s timeout=%s", content_id, request_timeout)
  313. return _error_result(f"请求超时({request_timeout}秒)", title="内容点赞用户画像获取失败")
  314. except httpx.RequestError as e:
  315. logger.error("get_content_fans_portrait network error: content_id=%s error=%s", content_id, e)
  316. return _error_result(f"网络错误: {e}", title="内容点赞用户画像获取失败")
  317. except Exception as e:
  318. logger.error(
  319. "get_content_fans_portrait unexpected error: content_id=%s error=%s",
  320. content_id,
  321. e,
  322. exc_info=True,
  323. )
  324. return _error_result(f"未知错误: {e}", title="内容点赞用户画像获取失败")
  325. @tool
  326. async def batch_fetch_portraits(
  327. candidates_json: str,
  328. fetch_account_portrait: bool = False,
  329. need_province: bool = False,
  330. need_city: bool = False,
  331. need_city_level: bool = False,
  332. need_gender: bool = False,
  333. need_age: bool = True,
  334. need_phone_brand: bool = False,
  335. need_phone_price: bool = False,
  336. timeout: Optional[float] = None,
  337. ) -> str:
  338. """
  339. 批量获取多条候选视频的画像
  340. 依次请求内容点赞画像。fetch_account_portrait=true 时同时请求作者粉丝画像;
  341. 否则仅在内容画像缺失且允许兜底时请求作者画像。
  342. 一次调用返回所有条目,便于比较同一候选的两侧年龄证据。
  343. Args:
  344. candidates_json: JSON 数组字符串。每项为对象,字段:
  345. - aweme_id (必填): 视频 id
  346. - author_sec_uid (可选): 作者 sec_uid,作者画像或兜底时需要
  347. - try_account_fallback (可选,默认 true): 为 false 时不请求账号画像
  348. fetch_account_portrait: 是否为每个候选同时获取作者粉丝画像,默认 False。
  349. 老年受众判断建议设为 True;缺少 author_sec_uid 的条目会跳过作者画像。
  350. need_* / timeout: 与各单条画像工具一致
  351. Returns:
  352. JSON 字符串,包含 output(人类可读摘要)和 results(结构化列表)。
  353. results 与 candidates 顺序一致,每项含 content / account 子对象。
  354. """
  355. start_time = time.time()
  356. request_timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
  357. raw = (candidates_json or "").strip()
  358. if not raw:
  359. return _error_result(
  360. "candidates_json 为空",
  361. title="批量画像失败",
  362. input_error=True,
  363. )
  364. try:
  365. parsed = json.loads(raw)
  366. except json.JSONDecodeError as e:
  367. return _error_result(
  368. f"candidates_json 不是合法 JSON: {e}",
  369. title="批量画像失败",
  370. input_error=True,
  371. )
  372. if not isinstance(parsed, list):
  373. return _error_result(
  374. "candidates_json 必须是 JSON 数组",
  375. title="批量画像失败",
  376. input_error=True,
  377. )
  378. if len(parsed) > BATCH_MAX_ITEMS:
  379. return _error_result(
  380. f"条目数超过上限 {BATCH_MAX_ITEMS},请分批调用",
  381. title="批量画像失败",
  382. input_error=True,
  383. )
  384. flags = _dimension_flags(
  385. need_province,
  386. need_city,
  387. need_city_level,
  388. need_gender,
  389. need_age,
  390. need_phone_brand,
  391. need_phone_price,
  392. )
  393. results: list[dict[str, Any]] = []
  394. output_chunks: list[str] = []
  395. try:
  396. async with httpx.AsyncClient(timeout=request_timeout) as client:
  397. for idx, entry in enumerate(parsed):
  398. if not isinstance(entry, dict):
  399. results.append(
  400. {
  401. "aweme_id": None,
  402. "error": "条目不是对象",
  403. "content": None,
  404. "account": None,
  405. }
  406. )
  407. output_chunks.append(f"[{idx}] 跳过:条目不是 JSON 对象")
  408. continue
  409. aweme_id = entry.get("aweme_id") or entry.get("content_id")
  410. author_sec = entry.get("author_sec_uid") or entry.get("account_id")
  411. try_fallback = entry.get("try_account_fallback", True)
  412. if isinstance(try_fallback, str):
  413. try_fallback = try_fallback.strip().lower() in ("1", "true", "yes")
  414. if not aweme_id or not isinstance(aweme_id, str):
  415. results.append(
  416. {
  417. "aweme_id": aweme_id,
  418. "error": "缺少 aweme_id",
  419. "content": None,
  420. "account": None,
  421. }
  422. )
  423. output_chunks.append(f"[{idx}] 跳过:缺少 aweme_id")
  424. continue
  425. item_result: dict[str, Any] = {
  426. "aweme_id": aweme_id,
  427. "author_sec_uid": author_sec if isinstance(author_sec, str) else None,
  428. "try_account_fallback": bool(try_fallback),
  429. "fetch_account_portrait": fetch_account_portrait,
  430. "content": None,
  431. "account": None,
  432. "error": None,
  433. }
  434. try:
  435. cerr, cok = await _fetch_content_portrait(client, aweme_id, flags)
  436. except httpx.HTTPError as e:
  437. cerr, cok = str(e), None
  438. if cerr:
  439. item_result["content"] = {
  440. "ok": False,
  441. "error": cerr,
  442. "has_portrait": False,
  443. "portrait_data": {},
  444. }
  445. else:
  446. assert cok is not None
  447. item_result["content"] = {
  448. "ok": True,
  449. "error": None,
  450. "has_portrait": cok["has_portrait"],
  451. "portrait_data": cok["portrait_data"],
  452. "output": cok["output"],
  453. }
  454. c_block = item_result["content"]
  455. content_has = bool(c_block and c_block.get("has_portrait"))
  456. need_account = fetch_account_portrait or (
  457. bool(try_fallback) and not content_has
  458. )
  459. if need_account:
  460. if not author_sec or not isinstance(author_sec, str):
  461. item_result["account"] = {
  462. "attempted": False,
  463. "skipped_reason": "缺少 author_sec_uid,无法获取作者画像",
  464. "has_portrait": False,
  465. "portrait_data": {},
  466. }
  467. else:
  468. try:
  469. aerr, aok = await _fetch_account_portrait(client, author_sec, flags)
  470. except httpx.HTTPError as e:
  471. aerr, aok = str(e), None
  472. if aerr:
  473. item_result["account"] = {
  474. "attempted": True,
  475. "error": aerr,
  476. "has_portrait": False,
  477. "portrait_data": {},
  478. }
  479. else:
  480. assert aok is not None
  481. item_result["account"] = {
  482. "attempted": True,
  483. "error": None,
  484. "has_portrait": aok["has_portrait"],
  485. "portrait_data": aok["portrait_data"],
  486. "output": aok["output"],
  487. }
  488. else:
  489. skip_reason = (
  490. "try_account_fallback 为 false"
  491. if not try_fallback
  492. else "内容侧已有有效画像,且未要求同时获取作者画像"
  493. )
  494. item_result["account"] = {
  495. "attempted": False,
  496. "skipped_reason": skip_reason,
  497. "has_portrait": False,
  498. "portrait_data": {},
  499. }
  500. content_block = item_result["content"] or {}
  501. account_block = item_result["account"] or {}
  502. item_result["age_normalization"] = normalize_age_portrait_pair(
  503. content_block.get("portrait_data"),
  504. account_block.get("portrait_data"),
  505. )
  506. item_result["age_portraits_normalized"] = True
  507. results.append(item_result)
  508. c_part = item_result["content"] or {}
  509. a_part = item_result["account"] or {}
  510. output_chunks.append(
  511. f"[{idx}] aweme_id={aweme_id} "
  512. f"content_has_portrait={c_part.get('has_portrait')} "
  513. f"account_attempted={a_part.get('attempted')} "
  514. f"account_has_portrait={a_part.get('has_portrait')}"
  515. )
  516. duration_ms = int((time.time() - start_time) * 1000)
  517. logger.info(
  518. "batch_fetch_portraits completed: count=%d candidates=%d duration_ms=%d",
  519. len(results),
  520. len(parsed),
  521. duration_ms,
  522. )
  523. return _success_result(
  524. {
  525. "title": f"批量画像完成 ({len(results)} 条)",
  526. "output": "\n".join(output_chunks),
  527. "results": results,
  528. "count": len(results),
  529. "duration_ms": duration_ms,
  530. }
  531. )
  532. except Exception as e:
  533. logger.error("batch_fetch_portraits unexpected error: error=%s", e, exc_info=True)
  534. return _error_result(f"未知错误: {e}", title="批量画像失败")
  535. async def main() -> None:
  536. content_id = os.getenv("TEST_CONTENT_ID", "7641118685977614586")
  537. account_id = os.getenv("TEST_ACCOUNT_SEC_UID", "MS4wLjABAAAAcA9a--HmibvcoJ_0YCQYZ1qqbn2uCj5e4CVdc0c6y6s")
  538. print("=== 测试 get_content_fans_portrait ===")
  539. content_result = json.loads(await get_content_fans_portrait(content_id=content_id))
  540. if "error" in content_result:
  541. print(f"获取失败: {content_result['error']}")
  542. else:
  543. print(content_result["output"])
  544. print(
  545. f"\nhas_portrait={content_result.get('has_portrait')} "
  546. f"duration_ms={content_result.get('duration_ms')}"
  547. )
  548. if account_id:
  549. print("\n=== 测试 get_account_fans_portrait ===")
  550. account_result = json.loads(
  551. await get_account_fans_portrait(account_id=account_id)
  552. )
  553. if "error" in account_result:
  554. print(f"获取失败: {account_result['error']}")
  555. else:
  556. print(account_result["output"])
  557. print(
  558. f"\nhas_portrait={account_result.get('has_portrait')} "
  559. f"duration_ms={account_result.get('duration_ms')}"
  560. )
  561. else:
  562. print("\n跳过账号画像测试(设置环境变量 TEST_ACCOUNT_SEC_UID 可启用)")
  563. print("\n=== 测试 batch_fetch_portraits ===")
  564. candidates = [
  565. {
  566. "aweme_id": content_id,
  567. "author_sec_uid": account_id or None,
  568. "try_account_fallback": bool(account_id),
  569. }
  570. ]
  571. batch_result = json.loads(
  572. await batch_fetch_portraits(candidates_json=json.dumps(candidates, ensure_ascii=False))
  573. )
  574. if "error" in batch_result:
  575. print(f"批量获取失败: {batch_result['error']}")
  576. else:
  577. print(batch_result["output"])
  578. print(f"\ncount={batch_result.get('count')} duration_ms={batch_result.get('duration_ms')}")
  579. if __name__ == "__main__":
  580. asyncio.run(main())