ad_api.py 36 KB

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