prepare_facebook_outreach.py 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """Generate English Facebook outreach previews from the customer workbook."""
  4. from __future__ import annotations
  5. import argparse
  6. import json
  7. import random
  8. import re
  9. import sys
  10. from datetime import datetime
  11. from pathlib import Path
  12. from typing import Any, Dict, List, Optional, Sequence, Tuple
  13. from openpyxl import load_workbook
  14. SCRIPT_DIR = Path(__file__).resolve().parent
  15. SKILL_ROOT = SCRIPT_DIR.parents[1]
  16. sys.path.insert(0, str(SKILL_ROOT / "scripts" / "common"))
  17. try:
  18. from workbook_resolver import resolve_workbook_path
  19. except Exception: # pragma: no cover
  20. resolve_workbook_path = None
  21. DEFAULT_SHEET = "Facebook"
  22. DEFAULT_STATUS = "未联系"
  23. HEADER_ALIASES = {
  24. "index": ["序号", "Index", "No."],
  25. "name": ["客户姓名/公司", "公司名称", "客户名称", "Name", "Company"],
  26. "country": ["国家", "Country"],
  27. "city": ["城市", "City"],
  28. "type": ["客户类型", "类型", "Type"],
  29. "link": ["主页/链接", "Facebook链接", "主页", "链接", "Link", "URL"],
  30. "website": ["公司官网", "官网", "Website", "Official Website"],
  31. "contact": ["联系人", "姓名", "Contact"],
  32. "position": ["职位", "职务", "Position", "Title"],
  33. "phone": ["电话/WhatsApp", "电话", "WhatsApp", "Phone"],
  34. "email": ["邮箱", "Email"],
  35. "business": ["主营业务", "公司主营业务", "业务", "Business"],
  36. "status": ["建联状态", "建联情况", "状态", "Status"],
  37. "next_followup": ["下次跟进", "Next Follow-up"],
  38. "note": ["备注", "说明", "Notes"],
  39. }
  40. CONTACTED_MARKERS = [
  41. "已发送邮件", "已发邮件", "邮件已发送", "邮件发送成功", "已加好友", "已发送好友请求",
  42. "已发私信", "已发送私信", "已联系", "email sent", "sent", "success",
  43. ]
  44. OEM_BRANDS = [
  45. "byd", "bmw", "jac", "mercedes", "toyota", "renault", "dacia", "kia", "hyundai",
  46. "volkswagen", "peugeot", "citroen", "citroën", "ford", "chery", "geely", "audi",
  47. "honda", "nissan", "suzuki", "mazda", "fiat", "opel", "skoda", "seat", "mg",
  48. "changan", "foton", "sitrak", "dfsk", "great wall", "haval", "dongfeng", "jetour", "baic",
  49. "gac", "maxus", "wuling",
  50. ]
  51. OEM_BRANCH_HINTS = [
  52. "official", "officiel", "page officielle", "maroc", "morocco", "branch", "subsidiary",
  53. "官方", "分公司", "当地分公司", "国家页",
  54. ]
  55. SCENARIOS = {
  56. "used_car_dealer": {
  57. "label_cn": "二手车商/occasion渠道",
  58. "judgment_cn": "该客户有二手车或 occasion 客户基础。值得建联的原因是其客户通常对总购车成本敏感,可能愿意评估低成本新车作为二手车库存补充;批量潜力取决于库存周转和本地客源规模。",
  59. "angle_cn": "从二手车客户升级到低成本新车的利润机会切入,强调先看小批量和价格区间,不压库存。",
  60. "signal_label": "used-car/showroom activity",
  61. "customer_base": "used-car buyers",
  62. "fit_context": "buyers who want a new vehicle but still care most about total cost",
  63. },
  64. "multibrand_dealer": {
  65. "label_cn": "多品牌经销商/展厅",
  66. "judgment_cn": "该客户像多品牌经销商或 showroom,已有汽车销售场景和客户流量,可能具备消化一批高性价比车型的能力。",
  67. "angle_cn": "从补充现有品牌和库存缺口切入,突出低成本新车线和小批量测试带来的走量可能。",
  68. "signal_label": "multi-brand/showroom activity",
  69. "customer_base": "showroom buyers",
  70. "fit_context": "buyers comparing practical new vehicles across brands",
  71. },
  72. "commercial_vehicle_channel": {
  73. "label_cn": "商用车/车队/实用车型渠道",
  74. "judgment_cn": "该客户涉及商用车、车队、配送或实用车型。五菱的经济实用定位适合小企业、配送和家商两用需求,有机会形成批量采购或渠道分销。",
  75. "angle_cn": "围绕小企业、配送、工具车和家商两用需求,测试实用低成本车型的批量消化能力。",
  76. "signal_label": "commercial/practical vehicle activity",
  77. "customer_base": "SME and practical-vehicle buyers",
  78. "fit_context": "customers watching purchase cost, uptime, and practical daily use",
  79. },
  80. "local_showroom": {
  81. "label_cn": "本地展厅/区域车商",
  82. "judgment_cn": "该客户像本地展厅或区域车商,直接接触本地终端客户,适合用小批量方式测试价格敏感市场的接受度。",
  83. "angle_cn": "从本地客户对价格和周转敏感切入,强调低压力首批试单。",
  84. "signal_label": "local auto sales activity",
  85. "customer_base": "local showroom buyers",
  86. "fit_context": "local buyers who compare total cost before choosing a vehicle",
  87. },
  88. "importer_group": {
  89. "label_cn": "进口商/集团/分销渠道",
  90. "judgment_cn": "该客户可能具备进口、集团、分销或区域渠道能力,可能不只消化零售订单,也可能评估持续批量供货和区域分销。",
  91. "angle_cn": "从进口/分销能力切入,先判断是否愿意评估首批试单和后续批量潜力。",
  92. "signal_label": "import/distribution activity",
  93. "customer_base": "regional dealer or importer networks",
  94. "fit_context": "channels that can evaluate a first batch and possible later volume",
  95. },
  96. "rental_fleet": {
  97. "label_cn": "租赁/车队客户",
  98. "judgment_cn": "该客户经营租赁或车队。车队客户对购置成本、维护成本和车辆周转敏感,可能通过小批量先验证五菱车型适配度。",
  99. "angle_cn": "围绕车队更新成本和车辆使用成本切入,先验证小批量车辆是否适合租赁/车队场景。",
  100. "signal_label": "rental/fleet activity",
  101. "customer_base": "rental or fleet buyers",
  102. "fit_context": "fleet operators trying to lower renewal and operating cost",
  103. },
  104. "unknown_auto_channel": {
  105. "label_cn": "信息不足的汽车相关渠道",
  106. "judgment_cn": "该客户看起来与汽车业务相关,但职责和渠道能力不明确。可以轻量建联,但重点是先确认其是否涉及采购、销售、进口或分销,不应直接强推。",
  107. "angle_cn": "信息不足时降低推销强度,先确认对方是否负责车辆采购、销售、进口或分销。",
  108. "signal_label": "auto-sector activity",
  109. "customer_base": "local auto-sector contacts",
  110. "fit_context": "channels that may handle vehicle sourcing, sales, import, or distribution",
  111. },
  112. }
  113. SIGNAL_PATTERNS = [
  114. ("used_car", ["used", "occasion", "second hand", "pre-owned", "reprise", "二手", "置换"], "二手车/occasion", "used-car or occasion activity"),
  115. ("showroom", ["showroom", "concessionnaire", "dealer", "multimarque", "multi-brand", "展厅", "经销", "多品牌"], "showroom/经销", "showroom or dealer activity"),
  116. ("stock", ["stock", "inventory", "parc auto", "annonce", "库存", "车源", "车辆较多"], "库存/车源", "visible stock or vehicle listings"),
  117. ("rental_fleet", ["rental", "rentcar", "location", "flotte", "fleet", "租赁", "租车", "车队"], "location/fleet", "rental or fleet activity"),
  118. ("import_distribution", ["import", "importation", "importateur", "distributeur", "distribution", "group", "groupe", "进口", "分销", "集团"], "importation/distribution", "import or distribution activity"),
  119. ("commercial", ["commercial", "utilitaire", "truck", "camion", "van", "delivery", "mpv", "商用", "货车", "卡车", "配送", "微型车"], "商用/实用车型", "commercial or practical-vehicle activity"),
  120. ("website", ["官网", "website", "http", ".ma", ".com", ".net"], "官网/正式页面", "official website or public business page"),
  121. ("contactable", ["whatsapp", "phone", "email", "电话", "邮箱", "公开联系方式", "+212"], "公开联系方式", "public WhatsApp, phone, or email"),
  122. ("active_page", ["recent", "post", "active", "近期", "发帖", "活跃", "followers", "粉丝"], "主页活跃信号", "recent page activity"),
  123. ]
  124. def clean(value: Any) -> str:
  125. if value is None:
  126. return ""
  127. return re.sub(r"\s+", " ", str(value).strip())
  128. def header_map(headers: Sequence[Any]) -> Dict[str, int]:
  129. raw = {clean(header): idx for idx, header in enumerate(headers) if clean(header)}
  130. mapped: Dict[str, int] = {}
  131. for key, aliases in HEADER_ALIASES.items():
  132. for alias in aliases:
  133. if alias in raw:
  134. mapped[key] = raw[alias]
  135. break
  136. return mapped
  137. def read_records(excel_path: Path, sheet_name: str) -> List[Dict[str, str]]:
  138. wb = load_workbook(excel_path, read_only=True, data_only=True)
  139. if sheet_name not in wb.sheetnames:
  140. raise KeyError(f"Sheet not found: {sheet_name}")
  141. ws = wb[sheet_name]
  142. rows = list(ws.iter_rows(values_only=True))
  143. if not rows:
  144. return []
  145. columns = header_map(rows[0])
  146. records: List[Dict[str, str]] = []
  147. for row_number, raw in enumerate(rows[1:], start=2):
  148. record = {key: clean(raw[idx]) if idx < len(raw) else "" for key, idx in columns.items()}
  149. record["_row_number"] = str(row_number)
  150. if any(record.get(key) for key in ["name", "link", "phone", "email", "website", "business", "note"]):
  151. records.append(record)
  152. return records
  153. def is_contacted(status: str) -> bool:
  154. lowered = clean(status).casefold()
  155. return bool(lowered) and any(marker.casefold() in lowered for marker in CONTACTED_MARKERS)
  156. def is_oem_branch(record: Dict[str, str]) -> bool:
  157. name = record.get("name", "").casefold()
  158. text = " ".join(record.get(key, "") for key in ["name", "type", "business", "note", "link"]).casefold()
  159. has_brand = any(brand in name or brand in text for brand in OEM_BRANDS)
  160. has_branch_hint = any(hint in name or hint in text for hint in OEM_BRANCH_HINTS)
  161. return has_brand and has_branch_hint
  162. def classify_channel(record: Dict[str, str]) -> str:
  163. combined = " ".join(record.get(key, "") for key in ["type", "business", "note", "name"]).casefold()
  164. if any(k in combined for k in ["rental", "rentcar", "location", "flotte", "fleet", "租赁", "租车", "车队"]):
  165. return "rental_fleet"
  166. if any(k in combined for k in ["import", "importateur", "distributeur", "distribution", "group", "groupe", "集团", "进口", "分销"]):
  167. return "importer_group"
  168. if any(k in combined for k in ["commercial", "utilitaire", "truck", "camion", "delivery", "fleet", "商用", "货车", "卡车", "配送"]):
  169. return "commercial_vehicle_channel"
  170. if any(k in combined for k in ["used", "occasion", "second hand", "pre-owned", "二手"]):
  171. return "used_car_dealer"
  172. if any(k in combined for k in ["multi-brand", "multibrand", "multimarque", "concessionnaire", "dealer", "showroom", "多品牌", "展厅", "经销"]):
  173. return "multibrand_dealer"
  174. if any(k in combined for k in ["local", "showroom", "本地", "区域"]):
  175. return "local_showroom"
  176. return "unknown_auto_channel"
  177. def apply_filters(record: Dict[str, str], filters: Sequence[Tuple[str, str]]) -> bool:
  178. for key, expected in filters:
  179. value = record.get(key, record.get(key.lower(), ""))
  180. if clean(value) != expected:
  181. return False
  182. return True
  183. COUNTRY_NAME_MAP = {
  184. "摩洛哥": "Morocco",
  185. "埃及": "Egypt",
  186. "阿联酋": "the UAE",
  187. "沙特": "Saudi Arabia",
  188. "沙特阿拉伯": "Saudi Arabia",
  189. "智利": "Chile",
  190. "秘鲁": "Peru",
  191. "墨西哥": "Mexico",
  192. "哥伦比亚": "Colombia",
  193. "阿尔及利亚": "Algeria",
  194. "突尼斯": "Tunisia",
  195. "南非": "South Africa",
  196. }
  197. GENERIC_CITY_MARKERS = {
  198. "", "多城市", "多个城市", "全国", "全国范围", "全境", "多地区", "多个地区", "各地",
  199. "morocco", "maroc", "national", "nationwide", "multiple cities", "multi-city", "all cities",
  200. }
  201. def market_name(country: str) -> str:
  202. value = clean(country)
  203. if not value:
  204. return "the target market"
  205. return COUNTRY_NAME_MAP.get(value, value)
  206. def is_generic_city(city: str) -> bool:
  207. value = clean(city)
  208. lowered = value.casefold()
  209. if lowered in GENERIC_CITY_MARKERS or value in GENERIC_CITY_MARKERS:
  210. return True
  211. return any(ord(ch) > 127 for ch in value)
  212. def city_phrase(city: str, country: str) -> str:
  213. market = market_name(country)
  214. if is_generic_city(city):
  215. return f"in {market}"
  216. return f"in {clean(city)}"
  217. def sentence_join(parts: Sequence[str]) -> str:
  218. return "; ".join(dict.fromkeys(clean(part) for part in parts if clean(part)))
  219. def english_signal_phrase(parts: Sequence[str], fallback: str) -> str:
  220. unique = list(dict.fromkeys(clean(part) for part in parts if clean(part)))
  221. if not unique:
  222. return fallback
  223. selected = unique[:2]
  224. if len(selected) == 1:
  225. return selected[0]
  226. return f"{selected[0]} and {selected[1]}"
  227. def has_signal_text(record: Dict[str, str]) -> str:
  228. return " ".join(record.get(key, "") for key in ["name", "type", "business", "note", "website", "phone", "email", "link"]).casefold()
  229. def extract_customer_signals(record: Dict[str, str], scenario_key: str) -> Dict[str, Any]:
  230. text = has_signal_text(record)
  231. hits_cn: List[str] = []
  232. hits_en: List[str] = []
  233. hit_keys: List[str] = []
  234. for key, keywords, label_cn, label_en in SIGNAL_PATTERNS:
  235. if any(keyword.casefold() in text for keyword in keywords):
  236. hits_cn.append(label_cn)
  237. hits_en.append(label_en)
  238. hit_keys.append(key)
  239. scenario = SCENARIOS[scenario_key]
  240. market = market_name(record.get("country", ""))
  241. location = city_phrase(record.get("city", ""), record.get("country", ""))
  242. observed_signal_cn = sentence_join(hits_cn[:4]) or scenario["signal_label"]
  243. observed_signal_en = sentence_join(hits_en[:3]) or scenario["signal_label"]
  244. customer_signal_en = english_signal_phrase(hits_en, scenario["signal_label"])
  245. strong_signal = len(hit_keys) >= 1 and scenario_key != "unknown_auto_channel"
  246. signal_quality = "strong" if len(hit_keys) >= 2 else "medium" if strong_signal else "weak"
  247. if scenario_key == "unknown_auto_channel" or signal_quality == "weak":
  248. business_hypothesis = "对方与汽车行业相关,但采购、销售、进口或分销职责不清,首轮应先确认角色。"
  249. profit_angle = "低压力确认是否负责车辆采购/分销,不直接强推。"
  250. light_offer = "a short model and price-range overview"
  251. reply_question = "Does your team handle vehicle purchasing or distribution?"
  252. elif scenario_key == "rental_fleet":
  253. business_hypothesis = "租赁/车队业务对购置成本、维护成本和周转敏感,可能关注低成本车队更新方案。"
  254. profit_angle = "用低采购成本和小批量 fleet fit check 切入。"
  255. light_offer = "a small fleet-fit and price-range overview"
  256. reply_question = "Should I send a short fleet-fit and price-range overview?"
  257. elif scenario_key == "importer_group":
  258. business_hypothesis = "对方可能具备进口、集团或区域分销能力,适合验证首批试单和后续批量潜力。"
  259. profit_angle = "用 import/distribution 能力和可能的 volume potential 切入。"
  260. light_offer = "a short first-batch fit check"
  261. reply_question = "Would a short first-batch fit check be useful for your team?"
  262. elif scenario_key == "commercial_vehicle_channel":
  263. business_hypothesis = "对方客户可能重视实用车型、配送、小企业和家商两用需求。"
  264. profit_angle = "用低成本实用新车补充商用/工具车需求。"
  265. light_offer = "a practical-vehicle model and price-range overview"
  266. reply_question = "Should I send a short practical-vehicle overview for your team to judge fit?"
  267. else:
  268. business_hypothesis = "对方已有汽车销售或 showroom 客户基础,可能接触价格敏感买家。"
  269. profit_angle = "用 affordable new-vehicle line 补充现有库存,先小批量判断周转潜力。"
  270. light_offer = "a short model and price-range overview"
  271. reply_question = "Should I send a short model and price-range overview?"
  272. return {
  273. "observed_signal": observed_signal_en,
  274. "observed_signal_cn": observed_signal_cn,
  275. "customer_signal": customer_signal_en,
  276. "observed_signal_keys": hit_keys,
  277. "signal_quality": signal_quality,
  278. "business_hypothesis": business_hypothesis,
  279. "profit_angle": profit_angle,
  280. "light_offer": light_offer,
  281. "reply_question": reply_question,
  282. "market_name": market,
  283. "location_phrase": location,
  284. "risk_reason": "信息不足,话术已降级为低压确认型。" if signal_quality == "weak" else "需人工确认页面真实性和客户是否负责采购/分销。",
  285. }
  286. def compact_english(text: str) -> str:
  287. text = re.sub(r"\s+([.,;:!?])", r"\1", text)
  288. text = re.sub(r"[ \t]+", " ", text)
  289. text = re.sub(r"\n ", "\n", text)
  290. return text.strip()
  291. def build_connect_variants(record: Dict[str, str], scenario: Dict[str, str], signals: Dict[str, Any]) -> Dict[str, str]:
  292. contact = record.get("contact") or "there"
  293. name = record.get("name") or "your company"
  294. location = signals["location_phrase"]
  295. signal = signals["customer_signal"]
  296. if signals["signal_quality"] == "weak":
  297. return {
  298. "direct_profit_hook": compact_english(f"Hi {contact}, I am mapping auto channels {location} that may handle vehicle sourcing or distribution. {name} seemed relevant. Thought it would be useful to connect."),
  299. "stock_gap_hook": compact_english(f"Hi {contact}, your page looks connected to the auto sector {location}. I am checking who reviews affordable new-vehicle options for local buyers. Useful to connect?"),
  300. "soft_research_hook": compact_english(f"Hi {contact}, I am learning which auto channels {location} handle sourcing or distribution. {name} came up as relevant, so I thought I’d connect."),
  301. }
  302. return {
  303. "direct_profit_hook": compact_english(f"Hi {contact}, your page shows {signal}, so {name} may reach price-sensitive buyers. I am checking whether a low-cost new-vehicle line is worth a small first-batch review."),
  304. "stock_gap_hook": compact_english(f"Hi {contact}, {name} looks close to {scenario['customer_base']} {location}. A low-cost new-vehicle option may complement current stock. Thought it would be useful to connect."),
  305. "soft_research_hook": compact_english(f"Hi {contact}, I am looking at auto channels {location} serving practical, cost-sensitive buyers. {name} stood out from its {signal}. Thought it would be useful to connect."),
  306. }
  307. def build_dm_variants(record: Dict[str, str], scenario_key: str, scenario: Dict[str, str], signals: Dict[str, Any]) -> Dict[str, str]:
  308. market = signals["market_name"]
  309. signal = signals["customer_signal"]
  310. question = signals["reply_question"]
  311. light_offer = signals["light_offer"]
  312. fit_context = scenario["fit_context"]
  313. if signals["signal_quality"] == "weak" or scenario_key == "unknown_auto_channel":
  314. return {
  315. "direct_profit_hook": compact_english("Your page appears connected to the auto sector, but I am not sure whether your team handles sourcing, sales, import, or distribution. We support Wuling export and are checking if affordable new-vehicle options are relevant locally.\n\nDoes your team handle vehicle purchasing or distribution?"),
  316. "stock_gap_hook": compact_english("I am mapping auto businesses that may review affordable new-vehicle lines for price-sensitive buyers. I don’t want to assume your role from limited public info.\n\nAre you the right team to review vehicle sourcing or distribution opportunities?"),
  317. "soft_research_hook": compact_english(f"I am looking at auto channels in {market} and your page seemed relevant, though the public info is limited. The aim is simply to see whether a small Wuling first-batch review fits the right local channels.\n\nDoes your team usually evaluate vehicle sourcing opportunities?"),
  318. }
  319. return {
  320. "direct_profit_hook": compact_english(f"Your page shows {signal}, which looks close to {fit_context}. A low-cost new-vehicle line could be worth testing with limited stock pressure; Huatu Overseas can support Wuling export for a small first-batch fit check.\n\n{question}"),
  321. "stock_gap_hook": compact_english(f"If some of your buyers want newer vehicles but still decide mainly on total cost, there may be a gap between used stock and higher-priced brands. Wuling could be reviewed as an affordable line to complement your current offer.\n\nWould {light_offer} help your team judge fit?"),
  322. "soft_research_hook": compact_english(f"I am looking at auto channels in {market} where affordable practical vehicles could match local demand. Your {signal} stood out, so I am checking whether Wuling is worth a low-pressure first-batch review.\n\nIs this something your team would normally evaluate?"),
  323. }
  324. def recommended_variant_key(scenario_key: str, signals: Dict[str, Any]) -> str:
  325. if signals["signal_quality"] == "weak" or scenario_key == "unknown_auto_channel":
  326. return "soft_research_hook"
  327. if scenario_key in {"used_car_dealer", "rental_fleet", "commercial_vehicle_channel"}:
  328. return "direct_profit_hook"
  329. return "stock_gap_hook"
  330. def build_message_variants(record: Dict[str, str], scenario_key: str) -> Dict[str, Any]:
  331. scenario = SCENARIOS[scenario_key]
  332. signals = extract_customer_signals(record, scenario_key)
  333. connects = build_connect_variants(record, scenario, signals)
  334. dms = build_dm_variants(record, scenario_key, scenario, signals)
  335. alternatives = {
  336. key: {
  337. "label_cn": {
  338. "direct_profit_hook": "直接利润机会",
  339. "stock_gap_hook": "库存/车型补充机会",
  340. "soft_research_hook": "低压行业交流",
  341. }[key],
  342. "english_connect": connects[key],
  343. "english_first_dm": dms[key],
  344. }
  345. for key in ["direct_profit_hook", "stock_gap_hook", "soft_research_hook"]
  346. }
  347. recommended_key = recommended_variant_key(scenario_key, signals)
  348. return {
  349. "recommended_key": recommended_key,
  350. "recommended_message": alternatives[recommended_key]["english_first_dm"],
  351. "recommended_connect": alternatives[recommended_key]["english_connect"],
  352. "alternatives": alternatives,
  353. "signals": signals,
  354. }
  355. def first_sentence(text: str) -> str:
  356. value = clean(text)
  357. match = re.search(r"^(.+?[.!?])(?:\s|$)", value)
  358. return clean(match.group(1) if match else value[:120]).casefold()
  359. def apply_variant_to_item(item: Dict[str, Any], variant_key: str) -> None:
  360. variant = item.get("alternatives", {}).get(variant_key, {})
  361. if not variant:
  362. return
  363. connect = variant.get("english_connect", "")
  364. dm = variant.get("english_first_dm", "")
  365. item["recommended_variant"] = variant_key
  366. item["recommended_message"] = dm
  367. item["english_connect"] = connect
  368. item["english_first_dm"] = dm
  369. item["messages"]["connect"]["en"] = connect
  370. item["messages"]["dm"]["en"] = dm
  371. item["formatted_preview"]["英文加好友话术"] = connect
  372. item["formatted_preview"]["英文首轮私信"] = dm
  373. def ensure_unique_message_openers(items: List[Dict[str, Any]]) -> None:
  374. seen: set[str] = set()
  375. variant_order = ["direct_profit_hook", "stock_gap_hook", "soft_research_hook"]
  376. for item in items:
  377. current = item.get("recommended_variant") or ""
  378. candidates = [current] + [key for key in variant_order if key != current]
  379. selected = current
  380. for key in candidates:
  381. message = item.get("alternatives", {}).get(key, {}).get("english_first_dm", "")
  382. opener = first_sentence(message)
  383. if opener and opener not in seen:
  384. selected = key
  385. break
  386. apply_variant_to_item(item, selected)
  387. opener = first_sentence(item.get("recommended_message", ""))
  388. if opener:
  389. seen.add(opener)
  390. def suggested_action(status: str) -> str:
  391. if not clean(status) or clean(status) == DEFAULT_STATUS:
  392. return "follow_and_first_dm"
  393. if is_contacted(status):
  394. return "skip_or_follow_up"
  395. return "first_dm"
  396. def parse_filters(raw_filters: Sequence[str]) -> List[Tuple[str, str]]:
  397. filters: List[Tuple[str, str]] = []
  398. for item in raw_filters:
  399. if "=" not in item:
  400. raise ValueError(f"Invalid filter, expected field=value: {item}")
  401. key, value = item.split("=", 1)
  402. filters.append((key.strip(), value.strip()))
  403. return filters
  404. def resolve_excel(excel: str) -> Path:
  405. if excel:
  406. path = Path(excel).expanduser()
  407. return path if path.is_absolute() else (Path.cwd() / path).resolve()
  408. if resolve_workbook_path:
  409. resolved = resolve_workbook_path("", create_from_template=False)
  410. if resolved.get("path"):
  411. return Path(resolved["path"])
  412. raise FileNotFoundError("No workbook found. Pass --excel.")
  413. def build_preview(records: Sequence[Dict[str, str]], filters: Sequence[Tuple[str, str]], include_sent: bool) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
  414. items: List[Dict[str, Any]] = []
  415. skipped: List[Dict[str, Any]] = []
  416. for ordinal, record in enumerate(records, start=1):
  417. name = record.get("name", "")
  418. link = record.get("link", "")
  419. status = record.get("status", "")
  420. if not name or not link:
  421. skipped.append({"row_number": record.get("_row_number"), "dealer_name": name or "(blank)", "reason": "missing name or Facebook page link"})
  422. continue
  423. if filters and not apply_filters(record, filters):
  424. continue
  425. if is_contacted(status) and not include_sent:
  426. skipped.append({"row_number": record.get("_row_number"), "dealer_name": name, "reason": f"already contacted: {status}"})
  427. continue
  428. if is_oem_branch(record):
  429. skipped.append({"row_number": record.get("_row_number"), "dealer_name": name, "reason": "建议跳过:疑似官方品牌当地页,不生成发送话术"})
  430. continue
  431. scenario_key = classify_channel(record)
  432. scenario = SCENARIOS[scenario_key]
  433. variant_pack = build_message_variants(record, scenario_key)
  434. signals = variant_pack["signals"]
  435. connect_en = variant_pack["recommended_connect"]
  436. dm_en = variant_pack["recommended_message"]
  437. signal_line = f"真实信号:{signals['observed_signal_cn']};商业假设:{signals['business_hypothesis']}"
  438. risk_note = f"{signals['risk_reason']} 预览用途。真实执行前必须先在聊天框展示整批预览,并由用户一次性确认。"
  439. item = {
  440. "index": record.get("index") or str(ordinal),
  441. "row_number": record.get("_row_number"),
  442. "dealer_name": name,
  443. "city": record.get("city", ""),
  444. "dealer_type": record.get("type", ""),
  445. "main_business": record.get("business", ""),
  446. "page_url": link,
  447. "company_website": record.get("website", ""),
  448. "status": status or DEFAULT_STATUS,
  449. "channel": scenario_key,
  450. "customer_judgment_cn": f"{scenario['judgment_cn']} {signal_line}",
  451. "recommended_angle_cn": f"{scenario['angle_cn']} 推荐使用“{variant_pack['alternatives'][variant_pack['recommended_key']]['label_cn']}”版本。",
  452. "observed_signal": signals["observed_signal"],
  453. "observed_signal_cn": signals["observed_signal_cn"],
  454. "signal_quality": signals["signal_quality"],
  455. "business_hypothesis": signals["business_hypothesis"],
  456. "profit_angle": signals["profit_angle"],
  457. "light_offer": signals["light_offer"],
  458. "reply_question": signals["reply_question"],
  459. "suggested_action": suggested_action(status),
  460. "recommended_variant": variant_pack["recommended_key"],
  461. "recommended_message": dm_en,
  462. "english_connect": connect_en,
  463. "english_first_dm": dm_en,
  464. "alternatives": variant_pack["alternatives"],
  465. "message_variants": variant_pack["alternatives"],
  466. "messages": {
  467. "connect": {"客户判断": f"{scenario['judgment_cn']} {signal_line}", "推荐切入点": scenario["angle_cn"], "en": connect_en},
  468. "dm": {"客户判断": f"{scenario['judgment_cn']} {signal_line}", "推荐切入点": scenario["angle_cn"], "en": dm_en},
  469. },
  470. "formatted_preview": {
  471. "客户判断": f"{scenario['judgment_cn']} {signal_line}",
  472. "推荐切入点": f"{scenario['angle_cn']} 推荐使用“{variant_pack['alternatives'][variant_pack['recommended_key']]['label_cn']}”版本。",
  473. "英文加好友话术": connect_en,
  474. "英文首轮私信": dm_en,
  475. "备选话术": variant_pack["alternatives"],
  476. "风险提示": risk_note,
  477. },
  478. "risk_note": risk_note,
  479. "risk_note_cn": risk_note,
  480. "record": record,
  481. }
  482. items.append(item)
  483. return items, skipped
  484. def render_human_preview(result: Dict[str, Any], max_chars: int = 16000) -> str:
  485. """Render a chat-friendly preview for one batch before any send action."""
  486. summary = result.get("summary", {})
  487. lines: List[str] = []
  488. lines.append("# Facebook 建联话术预览")
  489. lines.append(f"准备发送:{summary.get('ready_to_send', 0)} 条;跳过:{summary.get('skipped', 0)} 条")
  490. lines.append("确认后将按本批预览批量执行 Follow + Messenger DM,不再逐条确认。")
  491. lines.append("")
  492. for idx, item in enumerate(result.get("items", []), start=1):
  493. fp = item.get("formatted_preview", {}) or {}
  494. lines.append(f"## {idx}. {item.get('dealer_name', '')}")
  495. lines.append(f"主页:{item.get('page_url', '')}")
  496. lines.append(f"客户判断:{fp.get('客户判断') or item.get('customer_judgment_cn', '')}")
  497. lines.append(f"推荐切入点:{fp.get('推荐切入点') or item.get('outreach_angle_cn', '')}")
  498. lines.append("英文首轮私信:")
  499. lines.append(item.get("recommended_message") or item.get("english_first_dm", ""))
  500. lines.append(f"风险提示:{fp.get('风险提示') or item.get('risk_note', '')}")
  501. lines.append("")
  502. rendered = "\n".join(lines).strip()
  503. if len(rendered) > max_chars:
  504. rendered = rendered[:max_chars] + "\n\n[预览过长,已截断;完整 JSON 见输出文件]"
  505. return rendered
  506. def main(argv: Optional[Sequence[str]] = None) -> int:
  507. parser = argparse.ArgumentParser(description="Generate English Facebook outreach preview JSON.")
  508. parser.add_argument("--excel", default="", help="Customer outreach workbook path. If omitted, use workbook resolver.")
  509. parser.add_argument("--sheet", default=DEFAULT_SHEET, help="Source sheet name.")
  510. parser.add_argument("--filter", action="append", default=[], help="Filter condition, field=value. Can repeat.")
  511. parser.add_argument("--sample", type=int, default=0, help="Randomly sample N matched records. 0 means all.")
  512. parser.add_argument("--include-sent", action="store_true", help="Include already-contacted records for follow-up preview.")
  513. parser.add_argument("--seed", type=int, default=None, help="Random seed for sampling.")
  514. parser.add_argument("--output", required=True, help="Output JSON preview path.")
  515. args = parser.parse_args(argv)
  516. excel_path = resolve_excel(args.excel)
  517. if not excel_path.exists():
  518. raise FileNotFoundError(f"Workbook not found: {excel_path}")
  519. filters = parse_filters(args.filter)
  520. records = read_records(excel_path, args.sheet)
  521. items, skipped = build_preview(records, filters, args.include_sent)
  522. if args.sample and args.sample < len(items):
  523. if args.seed is not None:
  524. random.seed(args.seed)
  525. items = random.sample(items, args.sample)
  526. ensure_unique_message_openers(items)
  527. result = {
  528. "generated_at": datetime.now().isoformat(timespec="seconds"),
  529. "language_policy": {"customer_facing": "English", "internal_review": "Chinese", "facebook_default_uses_french": False},
  530. "message_strategy": {
  531. "framework": "specific signal -> commercial hypothesis -> light offer -> one question",
  532. "variants": ["direct_profit_hook", "stock_gap_hook", "soft_research_hook"],
  533. "recommended_field": "recommended_message",
  534. },
  535. "source": {"excel": str(excel_path), "sheet": args.sheet, "filters": args.filter, "sample": args.sample, "seed": args.seed, "include_sent": args.include_sent},
  536. "summary": {"total_records": len(records), "ready_to_send": len(items), "matched_records": len(items), "skipped": len(skipped)},
  537. "items": items,
  538. "skipped": skipped,
  539. }
  540. result["human_preview"] = render_human_preview(result)
  541. output_path = Path(args.output).expanduser()
  542. if not output_path.is_absolute():
  543. output_path = Path.cwd() / output_path
  544. output_path.parent.mkdir(parents=True, exist_ok=True)
  545. output_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
  546. print(json.dumps(result["summary"], ensure_ascii=False))
  547. print("\n" + result["human_preview"] + "\n")
  548. return 0
  549. if __name__ == "__main__":
  550. raise SystemExit(main())