portrait.py 24 KB

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