ad_api.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702
  1. """
  2. 腾讯广告 Marketing API v3.0 封装工具
  3. 层级结构(3.0,仅2层):
  4. 广告(Ad) → 创意(Dynamic Creative)
  5. ⚠️ 重要:
  6. - 业务概念是"广告",但 API 端点技术上仍叫 adgroups
  7. - POST 请求:公共参数(access_token/timestamp/nonce)在 URL query,业务参数在 JSON body
  8. - GET 请求:所有参数(含公共参数)在 URL query,复杂对象需 JSON 序列化后 URL 编码
  9. 环境变量:
  10. TENCENT_AD_ACCESS_TOKEN OAuth2 access token
  11. TENCENT_AD_ACCOUNT_ID 默认广告账户 ID(可被参数覆盖)
  12. TENCENT_AD_BASE_URL API base,默认 https://api.e.qq.com/v3.0
  13. """
  14. import json
  15. import logging
  16. import os
  17. import time
  18. import uuid
  19. from typing import Any, Dict, List, Optional
  20. from urllib.parse import urlencode
  21. import httpx
  22. from agent.tools import tool
  23. from agent.tools.models import ToolResult
  24. logger = logging.getLogger(__name__)
  25. # ===== 基础配置 =====
  26. BASE_URL = os.getenv("TENCENT_AD_BASE_URL", "https://api.e.qq.com/v3.0")
  27. DEFAULT_ACCOUNT_ID = int(os.getenv("TENCENT_AD_ACCOUNT_ID", "0") or 0)
  28. TIMEOUT = 30 # 秒
  29. def _get_access_token() -> str:
  30. token = os.getenv("TENCENT_AD_ACCESS_TOKEN", "")
  31. if not token:
  32. raise ValueError("未配置 TENCENT_AD_ACCESS_TOKEN 环境变量")
  33. return token
  34. def _common_params() -> Dict[str, str]:
  35. """公共查询参数:access_token / timestamp / nonce"""
  36. return {
  37. "access_token": _get_access_token(),
  38. "timestamp": str(int(time.time())),
  39. "nonce": uuid.uuid4().hex,
  40. }
  41. def _get(path: str, params: Dict[str, Any]) -> Dict[str, Any]:
  42. """
  43. 发送 GET 请求。
  44. 复杂对象(list/dict)自动 JSON 序列化后作为 query string 参数传递。
  45. """
  46. query = dict(_common_params())
  47. for k, v in params.items():
  48. if v is None:
  49. continue
  50. if isinstance(v, (dict, list)):
  51. query[k] = json.dumps(v, ensure_ascii=False)
  52. else:
  53. query[k] = str(v)
  54. url = f"{BASE_URL}{path}?{urlencode(query)}"
  55. logger.debug("[TencentAPI] GET %s", url)
  56. resp = httpx.get(url, timeout=TIMEOUT)
  57. resp.raise_for_status()
  58. return resp.json()
  59. def _post(path: str, body: Dict[str, Any]) -> Dict[str, Any]:
  60. """
  61. 发送 POST 请求。
  62. 公共参数在 URL query,业务参数在 JSON body。
  63. """
  64. query = urlencode(_common_params())
  65. url = f"{BASE_URL}{path}?{query}"
  66. logger.debug("[TencentAPI] POST %s body=%s", url, json.dumps(body, ensure_ascii=False)[:200])
  67. resp = httpx.post(url, json=body, timeout=TIMEOUT)
  68. resp.raise_for_status()
  69. return resp.json()
  70. def _check(resp: Dict[str, Any], op: str) -> Dict[str, Any]:
  71. """统一检查 API 响应,code != 0 时抛异常"""
  72. code = resp.get("code", -1)
  73. if code != 0:
  74. msg = resp.get("message_cn") or resp.get("message", "未知错误")
  75. raise RuntimeError(f"[{op}] 腾讯广告 API 错误 code={code}: {msg}")
  76. return resp.get("data") or {}
  77. # ===== 广告(Ad)— 3.0 顶层单位 =====
  78. @tool(description="创建广告(腾讯广告3.0顶层单位,含营销目标/定向/出价/预算,对应API: /adgroups/add)")
  79. async def ad_create(
  80. adgroup_name: str,
  81. marketing_goal: str = "MARKETING_GOAL_USER_GROWTH",
  82. marketing_carrier_type: str = "MARKETING_CARRIER_TYPE_MINI_PROGRAM_WECHAT",
  83. marketing_carrier_id: str = "",
  84. begin_date: str = "",
  85. end_date: str = "",
  86. time_series: str = "1" * 336,
  87. bid_mode: str = "BID_MODE_OCPM",
  88. optimization_goal: str = "OPTIMIZATIONGOAL_PAGE_VIEW",
  89. bid_amount: int = 0,
  90. daily_budget: int = 0,
  91. automatic_site_enabled: bool = True,
  92. targeting: Optional[Dict[str, Any]] = None,
  93. configured_status: str = "AD_STATUS_NORMAL",
  94. account_id: int = 0,
  95. ) -> ToolResult:
  96. """创建广告(3.0 顶层单位,API 端点: /v3.0/adgroups/add)
  97. 本业务固定参数:
  98. - marketing_goal: MARKETING_GOAL_USER_GROWTH(用户增长)
  99. - bid_mode: BID_MODE_OCPM(oCPM 出价,固定)
  100. - optimization_goal: OPTIMIZATIONGOAL_PAGE_VIEW 或 OPTIMIZATIONGOAL_CLICK
  101. targeting 结构示例:
  102. {
  103. "age": [{"min": 25, "max": 35}],
  104. "custom_audience": [人群包ID列表],
  105. "excluded_custom_audience": [排除人群包ID列表],
  106. "geo_location": {"regions": [省市区县ID列表]},
  107. "gender": "MALE", // 可选,不传则不限性别
  108. "user_os": ["IOS", "ANDROID"] // 可选
  109. }
  110. Args:
  111. adgroup_name: 广告名称(1-60个等宽字符)
  112. marketing_goal: 营销目的,固定 MARKETING_GOAL_USER_GROWTH
  113. marketing_carrier_type: 推广载体,MARKETING_CARRIER_TYPE_MINI_PROGRAM_WECHAT 或 MARKETING_CARRIER_TYPE_WECHAT_OFFICIAL_ACCOUNT
  114. marketing_carrier_id: 载体ID(小程序AppID或公众号ID)
  115. begin_date: 投放开始日期,格式 YYYY-MM-DD
  116. end_date: 投放结束日期,格式 YYYY-MM-DD
  117. time_series: 投放时段,336位字符串(48段×7天),"1"=投放,"0"=不投,全1表示全时段
  118. bid_mode: 出价方式,固定 BID_MODE_OCPM
  119. optimization_goal: 优化目标,OPTIMIZATIONGOAL_PAGE_VIEW 或 OPTIMIZATIONGOAL_CLICK
  120. bid_amount: 出价(单位:分),如 5000 = 50元
  121. daily_budget: 日预算(单位:分),0=不限
  122. automatic_site_enabled: 是否开启智能版位(建议 True)
  123. targeting: 定向设置(见上方说明)
  124. configured_status: AD_STATUS_NORMAL(投放中)或 AD_STATUS_SUSPEND(暂停)
  125. account_id: 广告主账号ID,0则使用环境变量
  126. """
  127. acct = account_id or DEFAULT_ACCOUNT_ID
  128. if not acct:
  129. return ToolResult(title="ad_create 失败", output="account_id 未指定且未配置 TENCENT_AD_ACCOUNT_ID")
  130. body: Dict[str, Any] = {
  131. "account_id": acct,
  132. "adgroup_name": adgroup_name,
  133. "marketing_goal": marketing_goal,
  134. "marketing_carrier_type": marketing_carrier_type,
  135. "bid_mode": bid_mode,
  136. "optimization_goal": optimization_goal,
  137. "configured_status": configured_status,
  138. "automatic_site_enabled": automatic_site_enabled,
  139. }
  140. if marketing_carrier_id:
  141. body["marketing_carrier_detail"] = {"marketing_carrier_id": marketing_carrier_id}
  142. if begin_date:
  143. body["begin_date"] = begin_date
  144. if end_date:
  145. body["end_date"] = end_date
  146. if time_series:
  147. body["time_series"] = time_series
  148. if bid_amount:
  149. body["bid_amount"] = bid_amount
  150. if daily_budget:
  151. body["daily_budget"] = daily_budget
  152. if targeting:
  153. body["targeting"] = targeting
  154. try:
  155. resp = _post("/adgroups/add", body)
  156. data = _check(resp, "ad_create")
  157. adgroup_id = data.get("adgroup_id")
  158. return ToolResult(
  159. title=f"广告创建成功",
  160. output=f"广告已创建,adgroup_id={adgroup_id},名称:{adgroup_name}",
  161. metadata={"adgroup_id": adgroup_id, "adgroup_name": adgroup_name},
  162. )
  163. except Exception as e:
  164. logger.error("ad_create 失败: %s", e)
  165. return ToolResult(title="ad_create 失败", output=str(e))
  166. @tool(description="更新广告设置(出价/预算/定向/状态/名称),对应API: /adgroups/update")
  167. async def ad_update(
  168. adgroup_id: int,
  169. adgroup_name: Optional[str] = None,
  170. bid_amount: Optional[int] = None,
  171. daily_budget: Optional[int] = None,
  172. targeting: Optional[Dict[str, Any]] = None,
  173. configured_status: Optional[str] = None,
  174. account_id: int = 0,
  175. ) -> ToolResult:
  176. """更新广告设置。只传需要修改的字段,未传字段保持不变。
  177. Args:
  178. adgroup_id: 广告ID(API字段名,实际是3.0的广告ID)
  179. adgroup_name: 新名称(可选)
  180. bid_amount: 新出价,单位分(可选)
  181. daily_budget: 新日预算,单位分,0=不限(可选)
  182. targeting: 新定向设置(可选)
  183. configured_status: 新状态 AD_STATUS_NORMAL / AD_STATUS_SUSPEND(可选)
  184. account_id: 广告主账号ID
  185. """
  186. acct = account_id or DEFAULT_ACCOUNT_ID
  187. body: Dict[str, Any] = {"account_id": acct, "adgroup_id": adgroup_id}
  188. if adgroup_name is not None:
  189. body["adgroup_name"] = adgroup_name
  190. if bid_amount is not None:
  191. body["bid_amount"] = bid_amount
  192. if daily_budget is not None:
  193. body["daily_budget"] = daily_budget
  194. if targeting is not None:
  195. body["targeting"] = targeting
  196. if configured_status is not None:
  197. body["configured_status"] = configured_status
  198. try:
  199. resp = _post("/adgroups/update", body)
  200. _check(resp, "ad_update")
  201. changes = [k for k in ["adgroup_name", "bid_amount", "daily_budget", "targeting", "configured_status"] if k in body]
  202. return ToolResult(
  203. title="广告更新成功",
  204. output=f"广告 {adgroup_id} 已更新字段:{', '.join(changes)}",
  205. )
  206. except Exception as e:
  207. return ToolResult(title="ad_update 失败", output=str(e))
  208. @tool(description="批量修改广告状态(开启/暂停),一次最多50个广告")
  209. async def ad_batch_update_status(
  210. adgroup_ids: List[int],
  211. configured_status: str,
  212. account_id: int = 0,
  213. ) -> ToolResult:
  214. """批量开启或暂停广告,单次最多50个。
  215. Args:
  216. adgroup_ids: 广告ID列表,最多50个
  217. configured_status: AD_STATUS_NORMAL(开启)或 AD_STATUS_SUSPEND(暂停)
  218. account_id: 广告主账号ID
  219. """
  220. acct = account_id or DEFAULT_ACCOUNT_ID
  221. if len(adgroup_ids) > 50:
  222. return ToolResult(title="ad_batch_update_status 失败", output="单次最多操作50个广告(API限制)")
  223. results = []
  224. errors = []
  225. for adgroup_id in adgroup_ids:
  226. try:
  227. body = {"account_id": acct, "adgroup_id": adgroup_id, "configured_status": configured_status}
  228. resp = _post("/adgroups/update", body)
  229. _check(resp, "ad_batch_update_status")
  230. results.append(adgroup_id)
  231. except Exception as e:
  232. errors.append(f"{adgroup_id}: {e}")
  233. status_label = "开启" if configured_status == "AD_STATUS_NORMAL" else "暂停"
  234. summary = f"成功{status_label} {len(results)} 个广告"
  235. if errors:
  236. summary += f",失败 {len(errors)} 个:{'; '.join(errors)}"
  237. return ToolResult(title=f"批量{status_label}广告", output=summary)
  238. @tool(description="查询广告列表,支持按ID/状态/营销目标过滤")
  239. async def ad_get_list(
  240. adgroup_ids: Optional[List[int]] = None,
  241. configured_status: Optional[List[str]] = None,
  242. marketing_goal: Optional[str] = None,
  243. page: int = 1,
  244. page_size: int = 20,
  245. account_id: int = 0,
  246. ) -> ToolResult:
  247. """查询广告列表。
  248. Args:
  249. adgroup_ids: 按广告ID过滤(可选)
  250. configured_status: 按状态过滤,如 ["AD_STATUS_NORMAL", "AD_STATUS_SUSPEND"]
  251. marketing_goal: 按营销目标过滤(可选)
  252. page: 页码,从1开始
  253. page_size: 每页数量,最大100
  254. account_id: 广告主账号ID
  255. """
  256. acct = account_id or DEFAULT_ACCOUNT_ID
  257. params: Dict[str, Any] = {"account_id": acct, "page": page, "page_size": page_size}
  258. filtering: Dict[str, Any] = {}
  259. if adgroup_ids:
  260. filtering["adgroup_id_list"] = adgroup_ids
  261. if configured_status:
  262. filtering["configured_status_list"] = configured_status
  263. if marketing_goal:
  264. filtering["marketing_goal"] = marketing_goal
  265. if filtering:
  266. params["filtering"] = filtering
  267. try:
  268. resp = _get("/adgroups/get", params)
  269. data = _check(resp, "ad_get_list")
  270. items = data.get("list", [])
  271. page_info = data.get("page_info", {})
  272. summary_lines = []
  273. for item in items:
  274. summary_lines.append(
  275. f"- [{item.get('adgroup_id')}] {item.get('adgroup_name')} "
  276. f"| 状态:{item.get('configured_status')} "
  277. f"| 出价:{item.get('bid_amount', 0)/100:.2f}元 "
  278. f"| 日预算:{item.get('daily_budget', 0)/100:.0f}元"
  279. )
  280. output = f"共 {page_info.get('total_number', len(items))} 个广告,当前第{page}页:\n" + "\n".join(summary_lines)
  281. return ToolResult(title=f"查询广告列表({len(items)}条)", output=output, metadata={"list": items, "page_info": page_info})
  282. except Exception as e:
  283. return ToolResult(title="ad_get_list 失败", output=str(e))
  284. # ===== 创意(Dynamic Creative)=====
  285. @tool(description="创建动态创意(绑定素材组件到广告),对应API: /dynamic_creatives/add")
  286. async def creative_create(
  287. adgroup_id: int,
  288. creative_name: str,
  289. page_id: Optional[int] = None,
  290. title_list: Optional[List[str]] = None,
  291. description_list: Optional[List[str]] = None,
  292. image_id_list: Optional[List[str]] = None,
  293. video_id: Optional[str] = None,
  294. call_to_action: Optional[str] = None,
  295. configured_status: str = "AD_STATUS_NORMAL",
  296. account_id: int = 0,
  297. ) -> ToolResult:
  298. """创建动态创意,系统自动组合素材组件并优化投放。
  299. Args:
  300. adgroup_id: 广告ID(绑定到哪个广告)
  301. creative_name: 创意名称
  302. page_id: 落地页ID(小程序页面或H5)
  303. title_list: 标题列表,系统从中优选(≤30字/条)
  304. description_list: 描述列表(≤60字/条)
  305. image_id_list: 图片素材ID列表(从素材库获取)
  306. video_id: 视频素材ID
  307. call_to_action: 行动号召按钮文案,如"立即体验"
  308. configured_status: AD_STATUS_NORMAL 或 AD_STATUS_SUSPEND
  309. account_id: 广告主账号ID
  310. """
  311. acct = account_id or DEFAULT_ACCOUNT_ID
  312. body: Dict[str, Any] = {
  313. "account_id": acct,
  314. "adgroup_id": adgroup_id,
  315. "dynamic_creative_name": creative_name,
  316. "configured_status": configured_status,
  317. }
  318. if page_id:
  319. body["page_id"] = page_id
  320. if title_list:
  321. body["title_list"] = title_list
  322. if description_list:
  323. body["description_list"] = description_list
  324. if image_id_list:
  325. body["image_id_list"] = image_id_list
  326. if video_id:
  327. body["video_id"] = video_id
  328. if call_to_action:
  329. body["call_to_action"] = call_to_action
  330. try:
  331. resp = _post("/dynamic_creatives/add", body)
  332. data = _check(resp, "creative_create")
  333. creative_id = data.get("dynamic_creative_id")
  334. return ToolResult(
  335. title="创意创建成功",
  336. output=f"创意已创建,dynamic_creative_id={creative_id},绑定广告 {adgroup_id}",
  337. metadata={"dynamic_creative_id": creative_id},
  338. )
  339. except Exception as e:
  340. return ToolResult(title="creative_create 失败", output=str(e))
  341. @tool(description="查询创意列表,支持按广告ID或状态过滤")
  342. async def creative_get_list(
  343. adgroup_id: Optional[int] = None,
  344. creative_ids: Optional[List[int]] = None,
  345. configured_status: Optional[List[str]] = None,
  346. page: int = 1,
  347. page_size: int = 20,
  348. account_id: int = 0,
  349. ) -> ToolResult:
  350. """查询动态创意列表。
  351. Args:
  352. adgroup_id: 按广告ID过滤
  353. creative_ids: 按创意ID过滤
  354. configured_status: 按状态过滤
  355. page: 页码
  356. page_size: 每页数量
  357. account_id: 广告主账号ID
  358. """
  359. acct = account_id or DEFAULT_ACCOUNT_ID
  360. params: Dict[str, Any] = {"account_id": acct, "page": page, "page_size": page_size}
  361. filtering: Dict[str, Any] = {}
  362. if adgroup_id:
  363. filtering["adgroup_id"] = adgroup_id
  364. if creative_ids:
  365. filtering["dynamic_creative_id_list"] = creative_ids
  366. if configured_status:
  367. filtering["configured_status_list"] = configured_status
  368. if filtering:
  369. params["filtering"] = filtering
  370. try:
  371. resp = _get("/dynamic_creatives/get", params)
  372. data = _check(resp, "creative_get_list")
  373. items = data.get("list", [])
  374. page_info = data.get("page_info", {})
  375. output = f"共 {page_info.get('total_number', len(items))} 个创意"
  376. return ToolResult(title=f"查询创意列表({len(items)}条)", output=output, metadata={"list": items})
  377. except Exception as e:
  378. return ToolResult(title="creative_get_list 失败", output=str(e))
  379. @tool(description="更新创意状态或素材(对应API: /dynamic_creatives/update)")
  380. async def creative_update(
  381. creative_id: int,
  382. creative_name: Optional[str] = None,
  383. configured_status: Optional[str] = None,
  384. title_list: Optional[List[str]] = None,
  385. image_id_list: Optional[List[str]] = None,
  386. account_id: int = 0,
  387. ) -> ToolResult:
  388. """更新动态创意。只传需要修改的字段。"""
  389. acct = account_id or DEFAULT_ACCOUNT_ID
  390. body: Dict[str, Any] = {"account_id": acct, "dynamic_creative_id": creative_id}
  391. if creative_name is not None:
  392. body["dynamic_creative_name"] = creative_name
  393. if configured_status is not None:
  394. body["configured_status"] = configured_status
  395. if title_list is not None:
  396. body["title_list"] = title_list
  397. if image_id_list is not None:
  398. body["image_id_list"] = image_id_list
  399. try:
  400. resp = _post("/dynamic_creatives/update", body)
  401. _check(resp, "creative_update")
  402. return ToolResult(title="创意更新成功", output=f"创意 {creative_id} 已更新")
  403. except Exception as e:
  404. return ToolResult(title="creative_update 失败", output=str(e))
  405. # ===== 数据报表 =====
  406. @tool(description="获取广告数据报表(消耗/点击/转化/CTR等),支持广告和创意两个维度")
  407. async def ad_get_report(
  408. date_range: Dict[str, str],
  409. level: str = "adgroup",
  410. fields: Optional[List[str]] = None,
  411. adgroup_ids: Optional[List[int]] = None,
  412. group_by: Optional[List[str]] = None,
  413. page: int = 1,
  414. page_size: int = 100,
  415. account_id: int = 0,
  416. ) -> ToolResult:
  417. """查询广告数据报表。
  418. Args:
  419. date_range: {"start_date": "2026-04-01", "end_date": "2026-04-07"}
  420. level: 报表维度,"adgroup"(广告级)或 "dynamic_creative"(创意级)
  421. fields: 指标字段列表,默认 ["cost", "impression", "click", "ctr", "cpc", "cpm", "conversion", "cvr", "cpa"]
  422. adgroup_ids: 按广告ID过滤(可选)
  423. group_by: 额外分组维度,如 ["date", "adgroup_id"]
  424. page: 页码
  425. page_size: 每页数量
  426. account_id: 广告主账号ID
  427. """
  428. acct = account_id or DEFAULT_ACCOUNT_ID
  429. default_fields = ["cost", "impression", "click", "ctr", "cpc", "cpm", "conversion", "cvr", "cpa"]
  430. report_fields = fields or default_fields
  431. params: Dict[str, Any] = {
  432. "account_id": acct,
  433. "level": level.upper() if level == "adgroup" else "DYNAMIC_CREATIVE",
  434. "date_range": date_range,
  435. "fields": report_fields,
  436. "page": page,
  437. "page_size": page_size,
  438. }
  439. if group_by:
  440. params["group_by"] = group_by
  441. filtering: Dict[str, Any] = {}
  442. if adgroup_ids:
  443. filtering["adgroup_id_list"] = adgroup_ids
  444. if filtering:
  445. params["filtering"] = filtering
  446. # 报表 API 路径根据 level 不同
  447. path_map = {"adgroup": "/daily_reports/adgroups/get", "dynamic_creative": "/daily_reports/dynamic_creatives/get"}
  448. path = path_map.get(level, "/daily_reports/adgroups/get")
  449. try:
  450. resp = _get(path, params)
  451. data = _check(resp, "ad_get_report")
  452. items = data.get("list", [])
  453. if not items:
  454. return ToolResult(title="广告报表(无数据)", output="该时间段内无数据")
  455. # 格式化输出
  456. lines = [f"报表维度: {level},时间: {date_range['start_date']} ~ {date_range['end_date']}"]
  457. for item in items[:10]: # 最多显示10条
  458. cost = item.get("cost", 0)
  459. lines.append(
  460. f"- 广告{item.get('adgroup_id', '')}: "
  461. f"消耗{cost/100:.2f}元 "
  462. f"| 展示{item.get('impression', 0):,} "
  463. f"| 点击{item.get('click', 0):,} "
  464. f"| CTR{item.get('ctr', 0):.2%} "
  465. f"| 转化{item.get('conversion', 0)} "
  466. f"| CPA{item.get('cpa', 0)/100:.2f}元"
  467. )
  468. if len(items) > 10:
  469. lines.append(f"...共 {len(items)} 条,仅显示前10条")
  470. return ToolResult(title=f"广告报表({len(items)}条)", output="\n".join(lines), metadata={"list": items})
  471. except Exception as e:
  472. return ToolResult(title="ad_get_report 失败", output=str(e))
  473. @tool(description="获取单个创意的效果报表(CTR/CVR/消耗/转化),按日汇总")
  474. async def creative_get_report(
  475. adcreative_id: int,
  476. date_range: Dict[str, str],
  477. fields: Optional[List[str]] = None,
  478. account_id: int = 0,
  479. ) -> ToolResult:
  480. """获取创意效果报告,用于素材衰退检测和优化决策。
  481. Args:
  482. adcreative_id: 创意ID(dynamic_creative_id)
  483. date_range: {"start_date": "2026-04-01", "end_date": "2026-04-07"}
  484. fields: 指标字段列表,默认 ["cost", "impression", "click", "ctr", "conversion", "cvr", "cpa"]
  485. account_id: 广告主账号ID
  486. """
  487. acct = account_id or DEFAULT_ACCOUNT_ID
  488. default_fields = ["cost", "impression", "click", "ctr", "conversion", "cvr", "cpa"]
  489. report_fields = fields or default_fields
  490. params: Dict[str, Any] = {
  491. "account_id": acct,
  492. "level": "DYNAMIC_CREATIVE",
  493. "date_range": date_range,
  494. "fields": report_fields,
  495. "filtering": {"dynamic_creative_id_list": [adcreative_id]},
  496. "group_by": ["date"],
  497. }
  498. try:
  499. resp = _get("/daily_reports/dynamic_creatives/get", params)
  500. data = _check(resp, "creative_get_report")
  501. items = data.get("list", [])
  502. if not items:
  503. return ToolResult(title="创意报表(无数据)", output=f"创意 {adcreative_id} 在该时间段无数据")
  504. lines = [f"创意 {adcreative_id} 报表:{date_range['start_date']} ~ {date_range['end_date']}"]
  505. total_cost = sum(r.get("cost", 0) for r in items)
  506. total_click = sum(r.get("click", 0) for r in items)
  507. total_conv = sum(r.get("conversion", 0) for r in items)
  508. avg_ctr = (total_click / max(sum(r.get("impression", 0) for r in items), 1))
  509. lines.append(
  510. f"汇总: 消耗{total_cost/100:.2f}元 | 点击{total_click:,} | 转化{total_conv} "
  511. f"| 均CTR{avg_ctr:.2%} | 均CPA{(total_cost/max(total_conv,1))/100:.2f}元"
  512. )
  513. for item in items:
  514. lines.append(
  515. f" {item.get('date', '-')}: 消耗{item.get('cost', 0)/100:.2f}元"
  516. f" | CTR{item.get('ctr', 0):.2%}"
  517. f" | 转化{item.get('conversion', 0)}"
  518. )
  519. return ToolResult(
  520. title=f"创意报表({len(items)}天)",
  521. output="\n".join(lines),
  522. metadata={"list": items, "adcreative_id": adcreative_id},
  523. )
  524. except Exception as e:
  525. return ToolResult(title="creative_get_report 失败", output=str(e))
  526. # ===== 素材库 =====
  527. @tool(description="查询账户素材库列表(图片/视频)")
  528. async def asset_get_list(
  529. material_type: Optional[str] = None,
  530. page: int = 1,
  531. page_size: int = 20,
  532. account_id: int = 0,
  533. ) -> ToolResult:
  534. """查询账户下的素材库。
  535. Args:
  536. material_type: "IMAGE" 或 "VIDEO",不传则查全部
  537. page: 页码
  538. page_size: 每页数量
  539. account_id: 广告主账号ID
  540. """
  541. acct = account_id or DEFAULT_ACCOUNT_ID
  542. params: Dict[str, Any] = {"account_id": acct, "page": page, "page_size": page_size}
  543. if material_type:
  544. params["material_type"] = material_type
  545. try:
  546. resp = _get("/material_infos/get", params)
  547. data = _check(resp, "asset_get_list")
  548. items = data.get("list", [])
  549. output = f"素材库共 {len(items)} 条:\n" + "\n".join(
  550. f"- [{m.get('material_id')}] {m.get('material_type')} {m.get('material_name', '')}"
  551. for m in items
  552. )
  553. return ToolResult(title=f"素材库({len(items)}条)", output=output, metadata={"list": items})
  554. except Exception as e:
  555. return ToolResult(title="asset_get_list 失败", output=str(e))
  556. # ===== 人群包 =====
  557. @tool(description="查询账户下可用的自定义人群包列表")
  558. async def audience_get_list(
  559. page: int = 1,
  560. page_size: int = 50,
  561. account_id: int = 0,
  562. ) -> ToolResult:
  563. """查询账户下的自定义人群包(用于 targeting.custom_audience 字段)。
  564. Args:
  565. page: 页码
  566. page_size: 每页数量
  567. account_id: 广告主账号ID
  568. """
  569. acct = account_id or DEFAULT_ACCOUNT_ID
  570. params: Dict[str, Any] = {"account_id": acct, "page": page, "page_size": page_size}
  571. try:
  572. resp = _get("/custom_audiences/get", params)
  573. data = _check(resp, "audience_get_list")
  574. items = data.get("list", [])
  575. output = f"共 {len(items)} 个人群包:\n" + "\n".join(
  576. f"- [{a.get('audience_id')}] {a.get('name')} "
  577. f"| 状态:{a.get('status')} "
  578. f"| 人数:{a.get('user_count', 0):,}"
  579. for a in items
  580. )
  581. return ToolResult(title=f"人群包列表({len(items)}个)", output=output, metadata={"list": items})
  582. except Exception as e:
  583. return ToolResult(title="audience_get_list 失败", output=str(e))
  584. # ===== 账户信息 =====
  585. @tool(description="获取广告账户基本信息(余额、日限额、账户状态等)")
  586. async def account_get_info(account_id: int = 0) -> ToolResult:
  587. """获取广告账户基本信息。
  588. Args:
  589. account_id: 广告主账号ID,0则使用环境变量
  590. """
  591. acct = account_id or DEFAULT_ACCOUNT_ID
  592. params: Dict[str, Any] = {
  593. "account_id": acct,
  594. "fields": ["balance", "daily_budget", "configured_status"],
  595. }
  596. try:
  597. resp = _get("/accounts/get", params)
  598. data = _check(resp, "account_get_info")
  599. items = data.get("list", [data])
  600. info = items[0] if items else {}
  601. balance = info.get("balance", 0)
  602. output = (
  603. f"账户 {acct} 信息:\n"
  604. f"- 余额:{balance/100:.2f} 元\n"
  605. f"- 日限额:{info.get('daily_budget', 0)/100:.0f} 元\n"
  606. f"- 状态:{info.get('configured_status', '未知')}"
  607. )
  608. return ToolResult(title="账户信息", output=output, metadata=info)
  609. except Exception as e:
  610. return ToolResult(title="account_get_info 失败", output=str(e))