client.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360
  1. from __future__ import annotations
  2. import logging
  3. from functools import lru_cache
  4. from typing import Any
  5. from supply_infra.config import get_infra_settings
  6. logger = logging.getLogger(__name__)
  7. class ODPSClient:
  8. """ODPS / MaxCompute 查询客户端封装。"""
  9. def __init__(
  10. self,
  11. access_id: str,
  12. access_key: str,
  13. project: str,
  14. endpoint: str,
  15. ) -> None:
  16. self.access_id = access_id
  17. self.access_key = access_key
  18. self.project = project
  19. self.endpoint = endpoint
  20. self._client: Any = None
  21. def _get_client(self) -> Any:
  22. if self._client is None:
  23. try:
  24. from odps import ODPS
  25. except ImportError as e:
  26. raise ImportError("pyodps 未安装,请执行: pip install pyodps") from e
  27. self._client = ODPS(
  28. self.access_id,
  29. self.access_key,
  30. project=self.project,
  31. endpoint=self.endpoint,
  32. )
  33. return self._client
  34. def execute_sql(self, sql: str) -> list[dict[str, Any]]:
  35. """执行 SQL 并返回字典列表。"""
  36. client = self._get_client()
  37. logger.info("ODPS executing SQL: %s", sql[:200])
  38. instance = client.execute_sql(sql)
  39. with instance.open_reader() as reader:
  40. columns = [col.name for col in reader._schema.columns] # type: ignore[attr-defined]
  41. return [dict(zip(columns, row.values)) for row in reader]
  42. return []
  43. def fetch_pattern_mining_elements(self, bizdate: str) -> list[dict[str, Any]]:
  44. """拉取 pattern_mining_element 元素(name, category_id)。"""
  45. sql = f"""
  46. SELECT t1.name
  47. ,t1.category_id
  48. FROM loghubods.pattern_mining_element t1
  49. LEFT JOIN loghubods.post t2
  50. ON t1.post_id = t2.post_id
  51. AND t2.dt = '{bizdate}'
  52. WHERE t1.dt = '{bizdate}'
  53. AND t1.execution_id = 401
  54. AND t1.source_table = 'post_decode_topic_point_element'
  55. AND t1.name is NOT NULL
  56. AND t1.category_id IS NOT NULL
  57. AND t1.element_type = '实质'
  58. AND t2.platform = 'piaoquan'
  59. GROUP BY t1.name
  60. ,t1.category_id
  61. """
  62. return self.execute_sql(sql)
  63. def fetch_pattern_mining_categories(self, bizdate: str) -> list[dict[str, Any]]:
  64. """拉取 public_pattern_mining_category 分类。"""
  65. sql = f"""
  66. SELECT id,name,description,level,parent_id
  67. FROM loghubods.public_pattern_mining_category
  68. WHERE dt = '{bizdate}'
  69. AND execution_id = 401
  70. AND source_type = '实质'
  71. """
  72. return self.execute_sql(sql)
  73. def fetch_multi_demand_pool(self, bizdate: str) -> list[dict[str, Any]]:
  74. """拉取 dwd_multi_demand_pool_di 策略需求天级数据(video_list 仅取前 10 个)。"""
  75. sql = f"""
  76. SELECT strategy
  77. ,demand_id
  78. ,demand_name
  79. ,weight
  80. ,`type`
  81. ,video_count
  82. ,SLICE(video_list, 1, 10) AS video_list
  83. ,extend
  84. FROM loghubods.dwd_multi_demand_pool_di
  85. WHERE dt = '{bizdate}'
  86. """
  87. return self.execute_sql(sql)
  88. def count_multi_demand_pool(self, bizdate: str) -> int:
  89. """统计 dwd_multi_demand_pool_di 分区去重后行数(strategy + demand_id)。"""
  90. sql = f"""
  91. SELECT COUNT(1) AS cnt
  92. FROM (
  93. SELECT strategy
  94. ,demand_id
  95. FROM loghubods.dwd_multi_demand_pool_di
  96. WHERE dt = '{bizdate}'
  97. GROUP BY strategy
  98. ,demand_id
  99. ) t
  100. """
  101. rows = self.execute_sql(sql)
  102. if not rows:
  103. return 0
  104. return int(rows[0].get("cnt") or 0)
  105. def fetch_topic_decode_results(
  106. self,
  107. dt: str,
  108. vids: list[str],
  109. batch_size: int = 100,
  110. ) -> list[dict[str, Any]]:
  111. """按 vid 批量拉取 dwd_topic_decode_result_di 的 vid、url1、url2、decode_result。"""
  112. # 保序去重
  113. unique_vids = list(
  114. dict.fromkeys(str(v).strip() for v in vids if v is not None and str(v).strip())
  115. )
  116. if not unique_vids:
  117. return []
  118. results: list[dict[str, Any]] = []
  119. for i in range(0, len(unique_vids), batch_size):
  120. batch = unique_vids[i : i + batch_size]
  121. in_list = ",".join("'" + v.replace("'", "''") + "'" for v in batch)
  122. sql = f"""
  123. SELECT vid
  124. ,decode_result
  125. ,url1
  126. ,url2
  127. FROM loghubods.dwd_topic_decode_result_di
  128. WHERE dt = '{dt}'
  129. AND vid IN ({in_list})
  130. """
  131. results.extend(self.execute_sql(sql))
  132. return results
  133. def fetch_real_rov_vov_7d(
  134. self,
  135. dt_left: str,
  136. dt_right: str,
  137. limit: int = 1000,
  138. ) -> list[dict[str, Any]]:
  139. """拉取近 N 日人工/自动 AGC 的 rov_diff / vov_diff(相对全局基线)。"""
  140. sql = f"""
  141. WITH base AS (
  142. SELECT
  143. 特征维度,
  144. 特征值,
  145. 特征值_寻找词,
  146. 供给类型,
  147. 当日分发曝光pv,
  148. 当日分发回流uv,
  149. 当日分发拉回曝光pv
  150. FROM loghubods.dwd_video_produce_plan_stat_hour
  151. WHERE dt BETWEEN '{dt_left}' AND '{dt_right}'
  152. AND 供给类型 IN ('人工AGC', '自动AGC')
  153. AND 特征值 NOT IN ('-', '', 'null')
  154. ),
  155. grouped AS (
  156. SELECT
  157. 1 AS row_order,
  158. 特征维度,
  159. 特征值,
  160. 特征值_寻找词,
  161. 供给类型,
  162. SUM(当日分发曝光pv) AS exp,
  163. SUM(当日分发回流uv) AS return_uv,
  164. SUM(当日分发拉回曝光pv) AS new_exp
  165. FROM base
  166. GROUP BY
  167. 特征维度,
  168. 特征值,
  169. 特征值_寻找词,
  170. 供给类型
  171. ),
  172. result_rows AS (
  173. SELECT
  174. 0 AS row_order,
  175. '全局SUM' AS 特征维度,
  176. '全局SUM' AS 特征值,
  177. '全局SUM' AS 特征值_寻找词,
  178. '全局SUM' AS 供给类型,
  179. SUM(exp) AS exp,
  180. SUM(return_uv) AS return_uv,
  181. SUM(new_exp) AS new_exp,
  182. COALESCE(ROUND(SUM(return_uv) / NULLIF(SUM(exp), 0), 4), 0) AS rov,
  183. COALESCE(ROUND(SUM(new_exp) / NULLIF(SUM(exp), 0), 4), 0) AS vov,
  184. 0 AS rov_diff,
  185. 0 AS vov_diff
  186. FROM grouped
  187. UNION ALL
  188. SELECT
  189. row_order,
  190. 特征维度,
  191. 特征值,
  192. 特征值_寻找词,
  193. 供给类型,
  194. exp,
  195. return_uv,
  196. new_exp,
  197. COALESCE(ROUND(return_uv / NULLIF(exp, 0), 4), 0) AS rov,
  198. COALESCE(ROUND(new_exp / NULLIF(exp, 0), 4), 0) AS vov,
  199. COALESCE(
  200. ROUND(
  201. (return_uv / NULLIF(exp, 0))
  202. / NULLIF(SUM(return_uv) OVER () / NULLIF(SUM(exp) OVER (), 0), 0)
  203. - 1,
  204. 4
  205. ),
  206. 0
  207. ) AS rov_diff,
  208. COALESCE(
  209. ROUND(
  210. (new_exp / NULLIF(exp, 0))
  211. / NULLIF(SUM(new_exp) OVER () / NULLIF(SUM(exp) OVER (), 0), 0)
  212. - 1,
  213. 4
  214. ),
  215. 0
  216. ) AS vov_diff
  217. FROM grouped
  218. )
  219. SELECT
  220. 特征维度,
  221. 特征值,
  222. 特征值_寻找词,
  223. 供给类型,
  224. exp,
  225. return_uv AS `return`,
  226. new_exp,
  227. rov,
  228. vov,
  229. rov_diff,
  230. vov_diff
  231. FROM result_rows
  232. ORDER BY
  233. row_order,
  234. exp DESC
  235. LIMIT {int(limit)}
  236. """
  237. return self.execute_sql(sql)
  238. def fetch_global_categories_v2(self, bizdate: str) -> list[dict[str, Any]]:
  239. """Fetch the active substantive global-category snapshot."""
  240. sql = f"""
  241. SELECT stable_id
  242. ,name
  243. ,description
  244. ,source_type
  245. ,level
  246. ,parent_stable_id
  247. FROM loghubods.global_category
  248. WHERE dt = '{bizdate}'
  249. AND retired_at_execution_id IS NULL
  250. AND source_type = '实质'
  251. ORDER BY level
  252. """
  253. return self.execute_sql(sql)
  254. def fetch_global_elements_v2(self, bizdate: str) -> list[dict[str, Any]]:
  255. """Fetch the active substantive global-element snapshot."""
  256. sql = f"""
  257. SELECT id AS element_id
  258. ,name
  259. ,description
  260. ,belong_category_stable_id
  261. ,source_type
  262. ,element_sub_type
  263. FROM loghubods.global_element
  264. WHERE dt = '{bizdate}'
  265. AND retired_at_execution_id IS NULL
  266. AND source_type = '实质'
  267. """
  268. return self.execute_sql(sql)
  269. def fetch_global_source_element_data(self, bizdate: str) -> list[dict[str, Any]]:
  270. """Fetch source-element mappings and their intent weights."""
  271. sql = f"""
  272. SELECT t1.global_element_id
  273. ,t1.global_category_stable_id
  274. ,t1.element_type
  275. ,t1.source_element_id
  276. ,t1.post_id
  277. ,t1.element_name
  278. ,t2.contribution
  279. ,t2.consumption_intent
  280. ,t2.click_intent
  281. ,t2.share_intent
  282. FROM loghubods.element_classification_mapping t1
  283. INNER JOIN loghubods.post t3
  284. ON t1.post_id = t3.post_id
  285. AND t3.dt = '{bizdate}'
  286. AND t3.platform = 'gongzhonghao'
  287. INNER JOIN loghubods.public_post_element_weight t2
  288. ON t1.post_id = t2.post_id
  289. AND t1.source_element_id = t2.element_id
  290. AND t2.dt = '{bizdate}'
  291. WHERE t1.dt = '{bizdate}'
  292. AND t1.element_type = '实质'
  293. AND t1.source_table = 'post_decode_topic_point_element'
  294. """
  295. return self.execute_sql(sql)
  296. def fetch_channel_content_data(self, bizdate: str) -> list[dict[str, Any]]:
  297. """Fetch one daily WeChat article read-rate partition."""
  298. sql = f"""
  299. SELECT account_uid
  300. ,channel_content_id
  301. ,read_cnt
  302. ,like_cnt
  303. ,avg_read_cnt_30d
  304. ,cal_fans_num
  305. ,CASE
  306. WHEN read_cnt IS NULL
  307. OR avg_read_cnt_30d IS NULL
  308. OR avg_read_cnt_30d = 0
  309. THEN 0.0
  310. ELSE CAST(read_cnt AS DOUBLE) / avg_read_cnt_30d
  311. END AS avg_read_rate
  312. ,CASE
  313. WHEN like_cnt IS NULL
  314. OR read_cnt IS NULL
  315. OR read_cnt = 0
  316. THEN 0.0
  317. ELSE CAST(like_cnt AS DOUBLE) / CAST(read_cnt AS DOUBLE)
  318. END AS like_rate
  319. ,CASE
  320. WHEN read_cnt IS NULL
  321. OR cal_fans_num IS NULL
  322. OR cal_fans_num = 0
  323. THEN 0.0
  324. ELSE CAST(read_cnt AS DOUBLE) / cal_fans_num
  325. END AS read_rate
  326. ,dt
  327. FROM loghubods.wechat_article_read_rate_analysis
  328. WHERE dt = '{bizdate}'
  329. """
  330. return self.execute_sql(sql)
  331. @lru_cache
  332. def get_odps_client() -> ODPSClient:
  333. settings = get_infra_settings()
  334. return ODPSClient(
  335. access_id=settings.odps_access_id,
  336. access_key=settings.odps_access_key,
  337. project=settings.odps_project,
  338. endpoint=settings.odps_endpoint,
  339. )