tencent_client.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  1. """Minimal Tencent Ads read/write client for real-time control."""
  2. from __future__ import annotations
  3. import json
  4. import os
  5. import time
  6. import uuid
  7. from datetime import date
  8. from typing import Any
  9. import requests
  10. from storage import connect
  11. ACTIVE_STATUS = "AD_STATUS_NORMAL"
  12. SUSPEND_STATUS = "AD_STATUS_SUSPEND"
  13. AD_FIELDS = [
  14. "adgroup_id",
  15. "adgroup_name",
  16. "configured_status",
  17. "system_status",
  18. "bid_amount",
  19. "custom_cost_cap",
  20. "smart_bid_type",
  21. "cost_constraint_scene",
  22. ]
  23. class TencentClient:
  24. def __init__(self) -> None:
  25. self.base_url = os.getenv(
  26. "TENCENT_AD_BASE_URL", "https://api.e.qq.com/v3.0"
  27. ).rstrip("/")
  28. self.access_token_api = os.getenv(
  29. "TENCENT_AD_TOKEN_API",
  30. "https://api.piaoquantv.com/ad/put/tencent/getAccessToken",
  31. )
  32. self.user_token_api = os.getenv(
  33. "TENCENT_AD_USER_TOKEN_API",
  34. "https://api.piaoquantv.com/ad/put/tencent/getUserToken",
  35. )
  36. self.timeout = int(os.getenv("TENCENT_AD_TIMEOUT_SECONDS", "30"))
  37. self.verify_attempts = int(os.getenv("RTC_VERIFY_ATTEMPTS", "3"))
  38. self.verify_delay_seconds = float(
  39. os.getenv("RTC_VERIFY_DELAY_SECONDS", "1")
  40. )
  41. if self.verify_attempts < 1:
  42. raise ValueError("RTC_VERIFY_ATTEMPTS must be at least 1")
  43. if self.verify_delay_seconds < 0:
  44. raise ValueError("RTC_VERIFY_DELAY_SECONDS must not be negative")
  45. self._access_tokens: dict[int, str] = {}
  46. self._user_tokens: dict[int, str] = {}
  47. self.session = requests.Session()
  48. def _access_token(self, account_id: int) -> str:
  49. if account_id not in self._access_tokens:
  50. response = self.session.get(
  51. self.access_token_api,
  52. params={"accountId": account_id},
  53. timeout=15,
  54. )
  55. response.raise_for_status()
  56. token = response.text.strip()
  57. if len(token) <= 10:
  58. raise RuntimeError(f"Invalid access token for account={account_id}")
  59. self._access_tokens[account_id] = token
  60. return self._access_tokens[account_id]
  61. def _user_token(self, account_id: int) -> str:
  62. if account_id in self._user_tokens:
  63. return self._user_tokens[account_id]
  64. token = ""
  65. try:
  66. response = self.session.get(
  67. self.user_token_api,
  68. params={"accountId": account_id},
  69. timeout=15,
  70. )
  71. response.raise_for_status()
  72. candidate = response.text.strip()
  73. if len(candidate) > 10:
  74. token = candidate
  75. except Exception:
  76. pass
  77. if not token:
  78. connection = connect()
  79. try:
  80. with connection.cursor() as cursor:
  81. cursor.execute(
  82. "SELECT user_token FROM account_whitelist WHERE account_id=%s",
  83. (account_id,),
  84. )
  85. row = cursor.fetchone()
  86. token = str((row or {}).get("user_token") or "")
  87. finally:
  88. connection.close()
  89. if not token:
  90. token = os.getenv("TENCENT_AD_USER_TOKEN", "").strip()
  91. if not token:
  92. raise RuntimeError(f"No user_token available for account={account_id}")
  93. self._user_tokens[account_id] = token
  94. return token
  95. def _common_params(self, account_id: int) -> dict[str, Any]:
  96. return {
  97. "access_token": self._access_token(account_id),
  98. "timestamp": int(time.time()),
  99. "nonce": uuid.uuid4().hex,
  100. }
  101. @staticmethod
  102. def _check(payload: dict[str, Any], operation: str) -> dict[str, Any]:
  103. if payload.get("code") != 0:
  104. message = payload.get("message_cn") or payload.get("message") or "unknown"
  105. raise RuntimeError(
  106. f"{operation} failed: code={payload.get('code')} message={message}"
  107. )
  108. return payload.get("data") or {}
  109. def get_ads(self, account_id: int) -> list[dict[str, Any]]:
  110. ads: list[dict[str, Any]] = []
  111. page = 1
  112. while True:
  113. params = {
  114. **self._common_params(account_id),
  115. "account_id": account_id,
  116. "fields": json.dumps(AD_FIELDS, ensure_ascii=False),
  117. "page": page,
  118. "page_size": 100,
  119. }
  120. response = self.session.get(
  121. f"{self.base_url}/adgroups/get",
  122. params=params,
  123. timeout=self.timeout,
  124. )
  125. response.raise_for_status()
  126. data = self._check(response.json(), "get_ads")
  127. rows = data.get("list") or []
  128. ads.extend(rows)
  129. page_info = data.get("page_info") or {}
  130. if page >= int(page_info.get("total_page") or 1):
  131. break
  132. page += 1
  133. return ads
  134. def get_ad(self, account_id: int, adgroup_id: int) -> dict[str, Any]:
  135. params = {
  136. **self._common_params(account_id),
  137. "account_id": account_id,
  138. "fields": json.dumps(AD_FIELDS, ensure_ascii=False),
  139. "filtering": json.dumps(
  140. [
  141. {
  142. "field": "adgroup_id",
  143. "operator": "IN",
  144. "values": [str(adgroup_id)],
  145. }
  146. ]
  147. ),
  148. "page": 1,
  149. "page_size": 10,
  150. }
  151. response = self.session.get(
  152. f"{self.base_url}/adgroups/get",
  153. params=params,
  154. timeout=self.timeout,
  155. )
  156. response.raise_for_status()
  157. data = self._check(response.json(), "get_ad")
  158. rows = data.get("list") or []
  159. for row in rows:
  160. if int(row.get("adgroup_id") or 0) == adgroup_id:
  161. return row
  162. raise RuntimeError(
  163. f"Ad not found after update: account={account_id} adgroup={adgroup_id}"
  164. )
  165. def update_ad(
  166. self,
  167. account_id: int,
  168. adgroup_id: int,
  169. *,
  170. bid_field: str | None = None,
  171. target_bid_fen: int | None = None,
  172. target_status: str | None = None,
  173. ) -> dict[str, Any]:
  174. body: dict[str, Any] = {
  175. "account_id": account_id,
  176. "adgroup_id": adgroup_id,
  177. }
  178. if bid_field and target_bid_fen is not None:
  179. body[bid_field] = target_bid_fen
  180. if target_status:
  181. body["configured_status"] = target_status
  182. params = {
  183. **self._common_params(account_id),
  184. "user_token": self._user_token(account_id),
  185. }
  186. response = self.session.post(
  187. f"{self.base_url}/adgroups/update",
  188. params=params,
  189. json=body,
  190. timeout=self.timeout,
  191. )
  192. response.raise_for_status()
  193. self._check(response.json(), "update_ad")
  194. expected: dict[str, Any] = {}
  195. if bid_field and target_bid_fen is not None:
  196. expected[bid_field] = target_bid_fen
  197. if target_status:
  198. expected["configured_status"] = target_status
  199. last_actual: dict[str, Any] = {}
  200. for attempt in range(1, self.verify_attempts + 1):
  201. ad = self.get_ad(account_id, adgroup_id)
  202. last_actual = {field: ad.get(field) for field in expected}
  203. matches = True
  204. for field, target in expected.items():
  205. actual = ad.get(field)
  206. if field in {"bid_amount", "custom_cost_cap"}:
  207. try:
  208. actual = int(actual)
  209. except (TypeError, ValueError):
  210. matches = False
  211. break
  212. if actual != target:
  213. matches = False
  214. break
  215. if matches:
  216. return ad
  217. if attempt < self.verify_attempts:
  218. time.sleep(self.verify_delay_seconds)
  219. raise RuntimeError(
  220. "Post-write verification failed: "
  221. f"account={account_id} adgroup={adgroup_id} "
  222. f"expected={expected} actual={last_actual}"
  223. )
  224. def get_today_ad_metrics(
  225. self,
  226. account_id: int,
  227. adgroup_ids: list[int],
  228. data_date: date,
  229. ) -> dict[int, dict[str, int]]:
  230. if not adgroup_ids:
  231. return {}
  232. metrics: dict[int, dict[str, int]] = {}
  233. page = 1
  234. while True:
  235. params = {
  236. **self._common_params(account_id),
  237. "account_id": account_id,
  238. "level": "REPORT_LEVEL_ADGROUP",
  239. "date_range": json.dumps(
  240. {
  241. "start_date": data_date.isoformat(),
  242. "end_date": data_date.isoformat(),
  243. }
  244. ),
  245. "group_by": json.dumps(["adgroup_id", "date"]),
  246. "fields": json.dumps(
  247. [
  248. "account_id",
  249. "adgroup_id",
  250. "date",
  251. "view_count",
  252. "valid_click_count",
  253. "cost",
  254. "conversions_count",
  255. ]
  256. ),
  257. "filtering": json.dumps(
  258. [
  259. {
  260. "field": "adgroup_id",
  261. "operator": "IN",
  262. "values": [str(value) for value in adgroup_ids],
  263. }
  264. ]
  265. ),
  266. "page": page,
  267. "page_size": 100,
  268. "time_line": "REQUEST_TIME",
  269. }
  270. response = self.session.get(
  271. f"{self.base_url}/daily_reports/get",
  272. params=params,
  273. timeout=self.timeout,
  274. )
  275. response.raise_for_status()
  276. data = self._check(response.json(), "get_today_ad_metrics")
  277. for row in data.get("list") or []:
  278. adgroup_id = int(row.get("adgroup_id") or 0)
  279. if adgroup_id <= 0:
  280. continue
  281. metrics[adgroup_id] = {
  282. "cost_fen": int(row.get("cost") or 0),
  283. "impressions": int(row.get("view_count") or 0),
  284. "clicks": int(row.get("valid_click_count") or 0),
  285. "conversions": int(row.get("conversions_count") or 0),
  286. }
  287. page_info = data.get("page_info") or {}
  288. if page >= int(page_info.get("total_page") or 1):
  289. break
  290. page += 1
  291. return metrics
  292. def resolve_bid_field(ad: dict[str, Any], configured_bid_scene: str | None) -> str:
  293. smart_bid_type = str(ad.get("smart_bid_type") or "").upper()
  294. try:
  295. has_custom_cost_cap = int(ad.get("custom_cost_cap") or 0) > 0
  296. except (TypeError, ValueError):
  297. has_custom_cost_cap = False
  298. if (
  299. has_custom_cost_cap
  300. or smart_bid_type == "SMART_BID_TYPE_SYSTEMATIC"
  301. or configured_bid_scene == "max_conversion"
  302. ):
  303. return "custom_cost_cap"
  304. return "bid_amount"
  305. def current_bid_fen(ad: dict[str, Any], bid_field: str) -> int | None:
  306. try:
  307. return int(ad.get(bid_field))
  308. except (TypeError, ValueError):
  309. return None