delivery_config.py 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126
  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. def parse_bid_scene(raw: object) -> str:
  45. text = str(raw or "").strip()
  46. if text in _BID_SCENE_VALUES:
  47. return _BID_SCENE_VALUES[text]
  48. raise ValueError(f"未知出价方式:{raw}")
  49. def parse_placement_config(raw: object) -> PlacementConfig | None:
  50. text = str(raw or "").strip()
  51. if not text:
  52. return None
  53. if text.upper() == "AIM+":
  54. return PlacementConfig(automatic_site_enabled=True, site_set=[])
  55. site_set: list[str] = []
  56. for part in text.replace(",", ",").split(","):
  57. name = part.strip()
  58. if not name:
  59. continue
  60. values = _PLACEMENT_GROUPS.get(name)
  61. if values is None:
  62. raise ValueError(f"未知投放版位:{name}")
  63. for value in values:
  64. if value not in site_set:
  65. site_set.append(value)
  66. if not site_set:
  67. raise ValueError(f"投放版位为空:{raw}")
  68. return PlacementConfig(automatic_site_enabled=False, site_set=site_set)
  69. @lru_cache(maxsize=1)
  70. def _region_name_mapping() -> dict[str, int]:
  71. path = Path(__file__).resolve().parents[1] / "data" / "tencent_constants" / "regions_all.json"
  72. regions = json.loads(path.read_text(encoding="utf-8"))
  73. mapping: dict[str, int] = {}
  74. for region in regions:
  75. name = str(region.get("name") or "").strip()
  76. if not name:
  77. continue
  78. region_id = int(region["id"])
  79. mapping.setdefault(name, region_id)
  80. for suffix in ("特别行政区", "壮族自治区", "回族自治区", "维吾尔自治区", "自治区", "省", "市"):
  81. if name.endswith(suffix):
  82. mapping.setdefault(name[:-len(suffix)], region_id)
  83. break
  84. return mapping
  85. def parse_geo_location_config(raw: object) -> GeoLocationConfig | None:
  86. """Parse Feishu geography: blank keeps the delivery template; 不限 omits geo targeting."""
  87. text = str(raw or "").strip()
  88. if not text:
  89. return None
  90. if text.lower() in {"不限", "不限地域", "全国", "all"}:
  91. return GeoLocationConfig(location_types=[], region_ids=[])
  92. names = [part.strip() for part in text.replace(",", ",").split(",") if part.strip()]
  93. if not names:
  94. raise ValueError(f"地域为空:{raw}")
  95. name_mapping = _region_name_mapping()
  96. region_ids: list[int] = []
  97. for name in names:
  98. try:
  99. region_id = int(name)
  100. except ValueError:
  101. region_id = name_mapping.get(name, 0)
  102. if not region_id:
  103. raise ValueError(f"未知地域:{name}")
  104. if region_id not in region_ids:
  105. region_ids.append(region_id)
  106. return GeoLocationConfig(location_types=["LIVE_IN"], region_ids=region_ids)