ad_api.py 32 KB

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