delivery_config.py 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. """Account-level bidding and placement config parsed from Feishu/DB."""
  2. from __future__ import annotations
  3. from dataclasses import dataclass
  4. from functools import lru_cache
  5. import json
  6. from pathlib import Path
  7. BID_MODE_AVERAGE_COST = "average_cost"
  8. BID_MODE_MAX_CONVERSION = "max_conversion"
  9. @dataclass(frozen=True)
  10. class PlacementConfig:
  11. automatic_site_enabled: bool
  12. site_set: list[str]
  13. @dataclass(frozen=True)
  14. class GeoLocationConfig:
  15. location_types: list[str]
  16. region_ids: list[int]
  17. _BID_SCENE_VALUES = {
  18. "": BID_MODE_AVERAGE_COST,
  19. "平均成本": BID_MODE_AVERAGE_COST,
  20. "稳定成本": BID_MODE_AVERAGE_COST,
  21. "最大转化量": BID_MODE_MAX_CONVERSION,
  22. "最大转化量出价": BID_MODE_MAX_CONVERSION,
  23. "average_cost": BID_MODE_AVERAGE_COST,
  24. "max_conversion": BID_MODE_MAX_CONVERSION,
  25. # 兼容已经同步过的旧值。
  26. "BID_SCENE_NORMAL_AVERAGE": BID_MODE_AVERAGE_COST,
  27. "BID_SCENE_NORMAL_MAX": BID_MODE_MAX_CONVERSION,
  28. }
  29. _PLACEMENT_GROUPS = {
  30. "微信朋友圈": ["SITE_SET_MOMENTS"],
  31. "朋友圈": ["SITE_SET_MOMENTS"],
  32. "微信公众号与小程序": ["SITE_SET_WECHAT", "SITE_SET_WECHAT_PLUGIN"],
  33. "微信公众号": ["SITE_SET_WECHAT"],
  34. "微信插件": ["SITE_SET_WECHAT_PLUGIN"],
  35. "搜索场景": ["SITE_SET_SEARCH_SCENE"],
  36. "腾讯平台与内容媒体": [
  37. "SITE_SET_TENCENT_NEWS",
  38. "SITE_SET_TENCENT_VIDEO",
  39. "SITE_SET_KANDIAN",
  40. "SITE_SET_QQ_MUSIC_GAME",
  41. ],
  42. "腾讯营销联盟": ["SITE_SET_MOBILE_UNION"],
  43. }
  44. REGION_DICTIONARY_PATH = (
  45. Path(__file__).resolve().parents[1]
  46. / "data"
  47. / "tencent_constants"
  48. / "regions_all.json"
  49. )
  50. def validate_region_dictionary() -> Path:
  51. """Fail fast when the versioned Tencent region asset is missing or malformed."""
  52. path = REGION_DICTIONARY_PATH
  53. if not path.is_file():
  54. raise RuntimeError(f"腾讯地域字典缺失: {path}")
  55. try:
  56. regions = json.loads(path.read_text(encoding="utf-8"))
  57. except (OSError, json.JSONDecodeError) as exc:
  58. raise RuntimeError(f"腾讯地域字典不可读取: {path}: {exc}") from exc
  59. if not isinstance(regions, list) or not regions:
  60. raise RuntimeError(f"腾讯地域字典必须是非空数组: {path}")
  61. for index, region in enumerate(regions):
  62. if not isinstance(region, dict) or "id" not in region or "name" not in region:
  63. raise RuntimeError(f"腾讯地域字典第 {index} 项缺少 id/name: {path}")
  64. return path
  65. def parse_bid_scene(raw: object) -> str:
  66. text = str(raw or "").strip()
  67. if text in _BID_SCENE_VALUES:
  68. return _BID_SCENE_VALUES[text]
  69. raise ValueError(f"未知出价方式:{raw}")
  70. def parse_placement_config(raw: object) -> PlacementConfig | None:
  71. text = str(raw or "").strip()
  72. if not text:
  73. return None
  74. if text.upper() == "AIM+":
  75. return PlacementConfig(automatic_site_enabled=True, site_set=[])
  76. site_set: list[str] = []
  77. for part in text.replace(",", ",").split(","):
  78. name = part.strip()
  79. if not name:
  80. continue
  81. values = _PLACEMENT_GROUPS.get(name)
  82. if values is None:
  83. raise ValueError(f"未知投放版位:{name}")
  84. for value in values:
  85. if value not in site_set:
  86. site_set.append(value)
  87. if not site_set:
  88. raise ValueError(f"投放版位为空:{raw}")
  89. return PlacementConfig(automatic_site_enabled=False, site_set=site_set)
  90. @lru_cache(maxsize=1)
  91. def _region_name_mapping() -> dict[str, int]:
  92. path = validate_region_dictionary()
  93. regions = json.loads(path.read_text(encoding="utf-8"))
  94. mapping: dict[str, int] = {}
  95. for region in regions:
  96. name = str(region.get("name") or "").strip()
  97. if not name:
  98. continue
  99. region_id = int(region["id"])
  100. mapping.setdefault(name, region_id)
  101. for suffix in ("特别行政区", "壮族自治区", "回族自治区", "维吾尔自治区", "自治区", "省", "市"):
  102. if name.endswith(suffix):
  103. mapping.setdefault(name[:-len(suffix)], region_id)
  104. break
  105. return mapping
  106. def parse_geo_location_config(raw: object) -> GeoLocationConfig | None:
  107. """Parse Feishu geography: blank keeps the delivery template; 不限 omits geo targeting."""
  108. text = str(raw or "").strip()
  109. if not text or text in {"排地域", "默认地域"}:
  110. return None
  111. if text.lower() in {"不限", "不限地域", "全国", "all"}:
  112. return GeoLocationConfig(location_types=[], region_ids=[])
  113. names = [part.strip() for part in text.replace(",", ",").split(",") if part.strip()]
  114. if not names:
  115. raise ValueError(f"地域为空:{raw}")
  116. name_mapping = _region_name_mapping()
  117. region_ids: list[int] = []
  118. for name in names:
  119. try:
  120. region_id = int(name)
  121. except ValueError:
  122. region_id = name_mapping.get(name, 0)
  123. if not region_id:
  124. raise ValueError(f"未知地域:{name}")
  125. if region_id not in region_ids:
  126. region_ids.append(region_id)
  127. return GeoLocationConfig(location_types=["LIVE_IN"], region_ids=region_ids)