tencent_client.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462
  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. "begin_date",
  23. "end_date",
  24. "time_series",
  25. ]
  26. class PostWriteVerificationError(RuntimeError):
  27. """Tencent accepted a write, but the read-back did not converge in time."""
  28. def __init__(
  29. self,
  30. *,
  31. account_id: int,
  32. adgroup_id: int,
  33. expected: dict[str, Any],
  34. actual: dict[str, Any],
  35. ) -> None:
  36. self.account_id = account_id
  37. self.adgroup_id = adgroup_id
  38. self.expected = expected
  39. self.actual = actual
  40. super().__init__(
  41. "Post-write verification failed: "
  42. f"account={account_id} adgroup={adgroup_id} "
  43. f"expected={expected} actual={actual}"
  44. )
  45. class TencentWriteNotSentError(RuntimeError):
  46. """The request failed before Tencent received the write request."""
  47. class TencentWriteRejectedError(RuntimeError):
  48. """Tencent explicitly rejected the write request."""
  49. class TencentWriteOutcomeUnknownError(RuntimeError):
  50. """The request may have reached Tencent, but no definitive result exists."""
  51. class TencentClient:
  52. def __init__(self) -> None:
  53. self.base_url = os.getenv(
  54. "TENCENT_AD_BASE_URL", "https://api.e.qq.com/v3.0"
  55. ).rstrip("/")
  56. self.access_token_api = os.getenv(
  57. "TENCENT_AD_TOKEN_API",
  58. "https://api.piaoquantv.com/ad/put/tencent/getAccessToken",
  59. )
  60. self.user_token_api = os.getenv(
  61. "TENCENT_AD_USER_TOKEN_API",
  62. "https://api.piaoquantv.com/ad/put/tencent/getUserToken",
  63. )
  64. self.timeout = int(os.getenv("TENCENT_AD_TIMEOUT_SECONDS", "30"))
  65. self.verify_attempts = int(os.getenv("RTC_VERIFY_ATTEMPTS", "3"))
  66. self.verify_delay_seconds = float(
  67. os.getenv("RTC_VERIFY_DELAY_SECONDS", "1")
  68. )
  69. if self.verify_attempts < 1:
  70. raise ValueError("RTC_VERIFY_ATTEMPTS must be at least 1")
  71. if self.verify_delay_seconds < 0:
  72. raise ValueError("RTC_VERIFY_DELAY_SECONDS must not be negative")
  73. self._access_tokens: dict[int, str] = {}
  74. self._user_tokens: dict[int, str] = {}
  75. self.session = requests.Session()
  76. def _access_token(self, account_id: int) -> str:
  77. if account_id not in self._access_tokens:
  78. response = self.session.get(
  79. self.access_token_api,
  80. params={"accountId": account_id},
  81. timeout=15,
  82. )
  83. response.raise_for_status()
  84. token = response.text.strip()
  85. if len(token) <= 10:
  86. raise RuntimeError(f"Invalid access token for account={account_id}")
  87. self._access_tokens[account_id] = token
  88. return self._access_tokens[account_id]
  89. def _user_token(self, account_id: int) -> str:
  90. if account_id in self._user_tokens:
  91. return self._user_tokens[account_id]
  92. token = ""
  93. try:
  94. response = self.session.get(
  95. self.user_token_api,
  96. params={"accountId": account_id},
  97. timeout=15,
  98. )
  99. response.raise_for_status()
  100. candidate = response.text.strip()
  101. if len(candidate) > 10:
  102. token = candidate
  103. except Exception:
  104. pass
  105. if not token:
  106. connection = connect()
  107. try:
  108. with connection.cursor() as cursor:
  109. cursor.execute(
  110. "SELECT user_token FROM account_whitelist WHERE account_id=%s",
  111. (account_id,),
  112. )
  113. row = cursor.fetchone()
  114. token = str((row or {}).get("user_token") or "")
  115. finally:
  116. connection.close()
  117. if not token:
  118. token = os.getenv("TENCENT_AD_USER_TOKEN", "").strip()
  119. if not token:
  120. raise RuntimeError(f"No user_token available for account={account_id}")
  121. self._user_tokens[account_id] = token
  122. return token
  123. def _common_params(self, account_id: int) -> dict[str, Any]:
  124. return {
  125. "access_token": self._access_token(account_id),
  126. "timestamp": int(time.time()),
  127. "nonce": uuid.uuid4().hex,
  128. }
  129. @staticmethod
  130. def _check(payload: dict[str, Any], operation: str) -> dict[str, Any]:
  131. if payload.get("code") != 0:
  132. message = payload.get("message_cn") or payload.get("message") or "unknown"
  133. raise RuntimeError(
  134. f"{operation} failed: code={payload.get('code')} message={message}"
  135. )
  136. return payload.get("data") or {}
  137. def get_ads(self, account_id: int) -> list[dict[str, Any]]:
  138. ads: list[dict[str, Any]] = []
  139. page = 1
  140. while True:
  141. params = {
  142. **self._common_params(account_id),
  143. "account_id": account_id,
  144. "fields": json.dumps(AD_FIELDS, ensure_ascii=False),
  145. "page": page,
  146. "page_size": 100,
  147. }
  148. response = self.session.get(
  149. f"{self.base_url}/adgroups/get",
  150. params=params,
  151. timeout=self.timeout,
  152. )
  153. response.raise_for_status()
  154. data = self._check(response.json(), "get_ads")
  155. rows = data.get("list") or []
  156. ads.extend(rows)
  157. page_info = data.get("page_info") or {}
  158. if page >= int(page_info.get("total_page") or 1):
  159. break
  160. page += 1
  161. return ads
  162. def get_ad(self, account_id: int, adgroup_id: int) -> dict[str, Any]:
  163. params = {
  164. **self._common_params(account_id),
  165. "account_id": account_id,
  166. "fields": json.dumps(AD_FIELDS, ensure_ascii=False),
  167. "filtering": json.dumps(
  168. [
  169. {
  170. "field": "adgroup_id",
  171. "operator": "IN",
  172. "values": [str(adgroup_id)],
  173. }
  174. ]
  175. ),
  176. "page": 1,
  177. "page_size": 10,
  178. }
  179. response = self.session.get(
  180. f"{self.base_url}/adgroups/get",
  181. params=params,
  182. timeout=self.timeout,
  183. )
  184. response.raise_for_status()
  185. data = self._check(response.json(), "get_ad")
  186. rows = data.get("list") or []
  187. for row in rows:
  188. if int(row.get("adgroup_id") or 0) == adgroup_id:
  189. return row
  190. raise RuntimeError(
  191. f"Ad not found after update: account={account_id} adgroup={adgroup_id}"
  192. )
  193. def update_ad(
  194. self,
  195. account_id: int,
  196. adgroup_id: int,
  197. *,
  198. bid_field: str | None = None,
  199. target_bid_fen: int | None = None,
  200. target_status: str | None = None,
  201. ) -> dict[str, Any]:
  202. body: dict[str, Any] = {
  203. "account_id": account_id,
  204. "adgroup_id": adgroup_id,
  205. }
  206. if bid_field and target_bid_fen is not None:
  207. body[bid_field] = target_bid_fen
  208. if target_status:
  209. body["configured_status"] = target_status
  210. try:
  211. params = {
  212. **self._common_params(account_id),
  213. "user_token": self._user_token(account_id),
  214. }
  215. except Exception as exc:
  216. raise TencentWriteNotSentError(str(exc)) from exc
  217. try:
  218. response = self.session.post(
  219. f"{self.base_url}/adgroups/update",
  220. params=params,
  221. json=body,
  222. timeout=self.timeout,
  223. )
  224. except requests.RequestException as exc:
  225. raise TencentWriteOutcomeUnknownError(str(exc)) from exc
  226. if response.status_code == 408 or response.status_code >= 500:
  227. raise TencentWriteOutcomeUnknownError(
  228. f"Tencent HTTP {response.status_code}: {response.text[:500]}"
  229. )
  230. try:
  231. response.raise_for_status()
  232. except requests.HTTPError as exc:
  233. raise TencentWriteRejectedError(str(exc)) from exc
  234. try:
  235. payload = response.json()
  236. except Exception as exc:
  237. raise TencentWriteOutcomeUnknownError(
  238. f"Tencent returned non-JSON success response: {response.text[:500]}"
  239. ) from exc
  240. try:
  241. self._check(payload, "update_ad")
  242. except Exception as exc:
  243. raise TencentWriteRejectedError(str(exc)) from exc
  244. expected: dict[str, Any] = {}
  245. if bid_field and target_bid_fen is not None:
  246. expected[bid_field] = target_bid_fen
  247. if target_status:
  248. expected["configured_status"] = target_status
  249. last_actual: dict[str, Any] = {}
  250. for attempt in range(1, self.verify_attempts + 1):
  251. try:
  252. ad = self.get_ad(account_id, adgroup_id)
  253. last_actual = {field: ad.get(field) for field in expected}
  254. matches = True
  255. for field, target in expected.items():
  256. actual = ad.get(field)
  257. if field in {"bid_amount", "custom_cost_cap"}:
  258. try:
  259. actual = int(actual)
  260. except (TypeError, ValueError):
  261. matches = False
  262. break
  263. if actual != target:
  264. matches = False
  265. break
  266. if matches:
  267. return ad
  268. except Exception as exc:
  269. last_actual = {"verification_error": str(exc)}
  270. if attempt < self.verify_attempts:
  271. time.sleep(self.verify_delay_seconds)
  272. raise PostWriteVerificationError(
  273. account_id=account_id,
  274. adgroup_id=adgroup_id,
  275. expected=expected,
  276. actual=last_actual,
  277. )
  278. def update_ad_begin_dates(
  279. self,
  280. account_id: int,
  281. adgroup_ids: list[int],
  282. begin_date: str,
  283. ) -> list[dict[str, Any]]:
  284. if not adgroup_ids:
  285. return []
  286. if len(adgroup_ids) > 100:
  287. raise ValueError("Tencent update_datetime supports at most 100 ads")
  288. specs = [
  289. {"adgroup_id": adgroup_id, "begin_date": begin_date}
  290. for adgroup_id in adgroup_ids
  291. ]
  292. params = {
  293. **self._common_params(account_id),
  294. "user_token": self._user_token(account_id),
  295. }
  296. response = self.session.post(
  297. f"{self.base_url}/adgroups/update_datetime",
  298. params=params,
  299. json={
  300. "account_id": account_id,
  301. "update_datetime_spec": specs,
  302. },
  303. timeout=self.timeout,
  304. )
  305. response.raise_for_status()
  306. data = self._check(response.json(), "update_ad_begin_dates")
  307. item_failures = [
  308. item
  309. for item in data.get("list") or []
  310. if int(item.get("code") or 0) != 0
  311. ]
  312. failed_ids = [int(value) for value in data.get("fail_id_list") or []]
  313. if item_failures or failed_ids:
  314. raise RuntimeError(
  315. "update_ad_begin_dates partially failed: "
  316. f"items={item_failures} fail_id_list={failed_ids}"
  317. )
  318. target_ids = set(adgroup_ids)
  319. last_actual: dict[int, str] = {}
  320. for attempt in range(1, self.verify_attempts + 1):
  321. ads = {
  322. int(ad.get("adgroup_id") or 0): ad
  323. for ad in self.get_ads(account_id)
  324. if int(ad.get("adgroup_id") or 0) in target_ids
  325. }
  326. last_actual = {
  327. adgroup_id: str(
  328. (ads.get(adgroup_id) or {}).get("begin_date") or ""
  329. )
  330. for adgroup_id in adgroup_ids
  331. }
  332. if all(value == begin_date for value in last_actual.values()):
  333. return [ads[adgroup_id] for adgroup_id in adgroup_ids]
  334. if attempt < self.verify_attempts:
  335. time.sleep(self.verify_delay_seconds)
  336. raise RuntimeError(
  337. "Tencent begin_date verification failed: "
  338. f"account={account_id} expected={begin_date} actual={last_actual}"
  339. )
  340. def get_today_ad_metrics(
  341. self,
  342. account_id: int,
  343. adgroup_ids: list[int],
  344. data_date: date,
  345. ) -> dict[int, dict[str, int]]:
  346. if not adgroup_ids:
  347. return {}
  348. metrics: dict[int, dict[str, int]] = {}
  349. page = 1
  350. while True:
  351. params = {
  352. **self._common_params(account_id),
  353. "account_id": account_id,
  354. "level": "REPORT_LEVEL_ADGROUP",
  355. "date_range": json.dumps(
  356. {
  357. "start_date": data_date.isoformat(),
  358. "end_date": data_date.isoformat(),
  359. }
  360. ),
  361. "group_by": json.dumps(["adgroup_id", "date"]),
  362. "fields": json.dumps(
  363. [
  364. "account_id",
  365. "adgroup_id",
  366. "date",
  367. "view_count",
  368. "valid_click_count",
  369. "cost",
  370. "conversions_count",
  371. ]
  372. ),
  373. "filtering": json.dumps(
  374. [
  375. {
  376. "field": "adgroup_id",
  377. "operator": "IN",
  378. "values": [str(value) for value in adgroup_ids],
  379. }
  380. ]
  381. ),
  382. "page": page,
  383. "page_size": 100,
  384. "time_line": "REQUEST_TIME",
  385. }
  386. response = self.session.get(
  387. f"{self.base_url}/daily_reports/get",
  388. params=params,
  389. timeout=self.timeout,
  390. )
  391. response.raise_for_status()
  392. data = self._check(response.json(), "get_today_ad_metrics")
  393. for row in data.get("list") or []:
  394. adgroup_id = int(row.get("adgroup_id") or 0)
  395. if adgroup_id <= 0:
  396. continue
  397. metrics[adgroup_id] = {
  398. "cost_fen": int(row.get("cost") or 0),
  399. "impressions": int(row.get("view_count") or 0),
  400. "clicks": int(row.get("valid_click_count") or 0),
  401. "conversions": int(row.get("conversions_count") or 0),
  402. }
  403. page_info = data.get("page_info") or {}
  404. if page >= int(page_info.get("total_page") or 1):
  405. break
  406. page += 1
  407. return metrics
  408. def resolve_bid_field(ad: dict[str, Any], configured_bid_scene: str | None) -> str:
  409. smart_bid_type = str(ad.get("smart_bid_type") or "").upper()
  410. try:
  411. has_custom_cost_cap = int(ad.get("custom_cost_cap") or 0) > 0
  412. except (TypeError, ValueError):
  413. has_custom_cost_cap = False
  414. if (
  415. has_custom_cost_cap
  416. or smart_bid_type == "SMART_BID_TYPE_SYSTEMATIC"
  417. or configured_bid_scene == "max_conversion"
  418. ):
  419. return "custom_cost_cap"
  420. return "bid_amount"
  421. def current_bid_fen(ad: dict[str, Any], bid_field: str) -> int | None:
  422. try:
  423. return int(ad.get(bid_field))
  424. except (TypeError, ValueError):
  425. return None