tencent_client.py 20 KB

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