prepare_outreach_emails.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  1. #!/usr/bin/env python3
  2. import argparse
  3. import json
  4. import re
  5. import sys
  6. import zipfile
  7. import xml.etree.ElementTree as ET
  8. from datetime import datetime
  9. from pathlib import Path
  10. try:
  11. from common.artifact_manager import resolve_artifact_path
  12. except ImportError: # pragma: no cover - supports direct CLI execution
  13. sys.path.append(str(Path(__file__).resolve().parents[1]))
  14. from common.artifact_manager import resolve_artifact_path
  15. NS = {
  16. "main": "http://schemas.openxmlformats.org/spreadsheetml/2006/main",
  17. "rel": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
  18. }
  19. ALIASES = {
  20. "dealer_name": "客户姓名/公司",
  21. "country": "国家",
  22. "city": "城市",
  23. "dealer_type": "客户类型",
  24. "page_url": "主页/链接",
  25. "contact_name": "联系人",
  26. "position": "职位",
  27. "phone": "电话/WhatsApp",
  28. "email": "邮箱",
  29. "main_business": "主营业务",
  30. "status": "建联状态",
  31. "next_follow_up": "下次跟进",
  32. "notes": "备注",
  33. }
  34. SENT_STATUS_MARKERS = [
  35. "已发送邮件",
  36. "已发邮件",
  37. "邮件已发送",
  38. "邮件发送成功",
  39. "email sent",
  40. "sent email",
  41. "sent",
  42. "success",
  43. ]
  44. def already_sent(status):
  45. normalized = str(status or "").strip().casefold()
  46. if not normalized:
  47. return False
  48. explicit_email_markers = [
  49. "已发送邮件",
  50. "已发邮件",
  51. "邮件已发送",
  52. "邮件发送成功",
  53. "email sent",
  54. "sent email",
  55. ]
  56. if any(marker.casefold() in normalized for marker in explicit_email_markers):
  57. return True
  58. return normalized in {"已发送", "sent", "success"}
  59. EMAIL_PATTERN = re.compile(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}")
  60. def extract_email_addresses(*values):
  61. emails = []
  62. for value in values:
  63. for email in EMAIL_PATTERN.findall(str(value or "")):
  64. normalized = email.strip().strip(".,;,;")
  65. if normalized and normalized.casefold() not in {item.casefold() for item in emails}:
  66. emails.append(normalized)
  67. return emails
  68. def col_to_index(ref):
  69. letters = "".join(ch for ch in ref if ch.isalpha())
  70. total = 0
  71. for ch in letters:
  72. total = total * 26 + (ord(ch.upper()) - ord("A") + 1)
  73. return total - 1
  74. def read_shared_strings(zip_file):
  75. if "xl/sharedStrings.xml" not in zip_file.namelist():
  76. return []
  77. root = ET.fromstring(zip_file.read("xl/sharedStrings.xml"))
  78. strings = []
  79. for item in root.findall("main:si", NS):
  80. strings.append("".join((t.text or "") for t in item.iter(f"{{{NS['main']}}}t")))
  81. return strings
  82. def cell_value(cell, shared_strings):
  83. cell_type = cell.attrib.get("t")
  84. if cell_type == "inlineStr":
  85. return "".join((t.text or "") for t in cell.iter(f"{{{NS['main']}}}t"))
  86. value = cell.find("main:v", NS)
  87. if value is None:
  88. return ""
  89. raw = value.text or ""
  90. if cell_type == "s":
  91. return shared_strings[int(raw)]
  92. return raw
  93. def read_sheet_rows(xlsx_path, sheet_name):
  94. with zipfile.ZipFile(xlsx_path) as archive:
  95. shared_strings = read_shared_strings(archive)
  96. workbook = ET.fromstring(archive.read("xl/workbook.xml"))
  97. sheets = workbook.find("main:sheets", NS).findall("main:sheet", NS)
  98. sheet = next((s for s in sheets if s.attrib.get("name") == sheet_name), None)
  99. if sheet is None:
  100. available = ", ".join(s.attrib.get("name", "") for s in sheets)
  101. raise SystemExit(f"找不到工作表:{sheet_name}。可用工作表:{available}")
  102. rels = ET.fromstring(archive.read("xl/_rels/workbook.xml.rels"))
  103. rel_map = {rel.attrib["Id"]: rel.attrib["Target"] for rel in rels}
  104. rel_id = sheet.attrib[f"{{{NS['rel']}}}id"]
  105. target = rel_map[rel_id]
  106. target = target.lstrip("/")
  107. sheet_path = target if target.startswith("xl/") else "xl/" + target
  108. root = ET.fromstring(archive.read(sheet_path))
  109. parsed_rows = []
  110. for row in root.findall(".//main:sheetData/main:row", NS):
  111. values = {}
  112. for cell in row.findall("main:c", NS):
  113. values[col_to_index(cell.attrib.get("r", ""))] = cell_value(cell, shared_strings)
  114. if values:
  115. max_index = max(values)
  116. parsed_rows.append([values.get(i, "") for i in range(max_index + 1)])
  117. if not parsed_rows:
  118. return []
  119. headers = [str(value).strip() for value in parsed_rows[0]]
  120. records = []
  121. for row in parsed_rows[1:]:
  122. record = {}
  123. for index, header in enumerate(headers):
  124. if header:
  125. record[header] = str(row[index]).strip() if index < len(row) else ""
  126. if any(record.values()):
  127. records.append(record)
  128. return records
  129. def parse_filters(filter_args):
  130. filters = []
  131. for item in filter_args:
  132. if "=" not in item:
  133. raise SystemExit(f"筛选条件格式错误,应为 字段=值:{item}")
  134. key, value = item.split("=", 1)
  135. filters.append((key.strip(), value.strip()))
  136. return filters
  137. def matches(record, filters):
  138. for key, expected in filters:
  139. actual = record.get(key, "")
  140. if str(actual).strip() != expected:
  141. return False
  142. return True
  143. def choose_capability_phrase(record):
  144. text = " ".join(str(record.get(key, "")) for key in (
  145. "客户类型",
  146. "主营业务",
  147. "公司主营业务",
  148. "备注",
  149. "客户属性",
  150. "官网链接",
  151. "主页/链接",
  152. )).casefold()
  153. if any(token in text for token in (
  154. "location", "rental", "rent", "leasing", "fleet", "flotte", "车队", "租赁", "政府", "corporate", "enterprise"
  155. )):
  156. return "corporate customer resources"
  157. if any(token in text for token in (
  158. "network", "réseau", "reseau", "dealer network", "downstream", "全国分销", "区域分销", "分销", "代理", "group", "groupe"
  159. )):
  160. return "a dealer network"
  161. if any(token in text for token in (
  162. "import", "importation", "importateur", "importer", "认证", "certification", "进口"
  163. )):
  164. return "vehicle import capabilities"
  165. if any(token in text for token in (
  166. "multi-brand", "multibrand", "multi brand", "多品牌", "showroom", "concessionnaire", "dealer", "汽车经销", "经销", "renault", "dacia", "audi", "volkswagen", "skoda", "škoda", "fiat", "jeep", "porsche", "kia"
  167. )):
  168. return "multi-brand distribution experience"
  169. return "local vehicle sales and customer resources"
  170. def choose_platform_resource_phrase(record):
  171. text = " ".join(str(record.get(key, "")) for key in (
  172. "客户类型",
  173. "主营业务",
  174. "公司主营业务",
  175. "备注",
  176. "客户属性",
  177. "官网链接",
  178. "主页/链接",
  179. )).casefold()
  180. if any(token in text for token in (
  181. "交易平台", "marketplace", "platform", "annuaire", "portal", "site", "网站", "流量", "traffic", "leads", "线索"
  182. )):
  183. return "automotive industry traffic"
  184. if any(token in text for token in (
  185. "协会", "商会", "联盟", "federation", "association", "chamber", "alliance", "member", "会员", "network", "réseau", "reseau"
  186. )):
  187. return "dealer members and industry resources"
  188. if any(token in text for token in (
  189. "fleet", "flotte", "corporate", "enterprise", "政府", "institution", "机构", "企业客户", "车队", "采购"
  190. )):
  191. return "corporate customers and procurement resources"
  192. if any(token in text for token in (
  193. "dealer", "concessionnaire", "distributor", "importer", "importateur", "经销商", "进口商", "分销"
  194. )):
  195. return "dealer and importer resources"
  196. return "automotive industry resources"
  197. def render_template(template, record, name_field="客户姓名/公司", sender_name=""):
  198. values = dict(record)
  199. for alias, header in ALIASES.items():
  200. values.setdefault(alias, record.get(header, ""))
  201. display_name = record.get(name_field, "").strip() or record.get("客户姓名/公司", "").strip()
  202. if not display_name:
  203. raise ValueError("[Name]/{{customer_name}} cannot be replaced because the customer name is empty.")
  204. values["dealer_name"] = display_name
  205. values["customer_name"] = display_name
  206. values["sender_name"] = sender_name.strip()
  207. values.setdefault("capability_phrase", choose_capability_phrase(record))
  208. values.setdefault("platform_resource_phrase", choose_platform_resource_phrase(record))
  209. rendered = template.replace("[Name]", display_name).replace("[name]", display_name)
  210. rendered = rendered.replace("[PICTURE]", "![Wuling Morocco Market Cooperation Evaluation](assets/wuling-channel-margin-comparison.png)")
  211. def replace(match):
  212. key = match.group(1).strip()
  213. return str(values.get(key, match.group(0)))
  214. rendered = re.sub(r"\{\{\s*([^}]+?)\s*\}\}", replace, rendered)
  215. unresolved_required = re.findall(r"\[(?:Name|name)\]|\{\{\s*(?:customer_name|dealer_name|sender_name)\s*\}\}", rendered)
  216. if unresolved_required:
  217. raise ValueError(f"Unresolved required placeholder(s): {', '.join(sorted(set(unresolved_required)))}")
  218. return rendered
  219. def main():
  220. parser = argparse.ArgumentParser(description="从 Excel 生成经销商建联邮件预览,并替换 Dear [Name] 与模板占位符。")
  221. parser.add_argument("--excel", required=True, help="Excel .xlsx 文件路径")
  222. parser.add_argument("--sheet", required=True, help="工作表名称")
  223. parser.add_argument("--filter", action="append", default=[], help="筛选条件,格式为 字段=值,可重复传入")
  224. parser.add_argument("--template", required=True, help="邮件 .md 或 .txt 模板路径")
  225. parser.add_argument("--subject", required=True, help="邮件主题")
  226. parser.add_argument("--output", required=True, help="输出 JSON 预览文件")
  227. parser.add_argument("--run-id", default="", help="Run ID; bare output filenames are stored under runs/YYYYMMDD/<run_id>/")
  228. parser.add_argument("--email-field", default="邮箱", help="主收件邮箱字段名")
  229. parser.add_argument("--extra-email-field", action="append", default=[], help="补充收件邮箱字段名,可重复传入,例如 个人邮箱")
  230. parser.add_argument("--name-field", default="客户姓名/公司", help="客户名称/公司字段名")
  231. parser.add_argument("--sender-name", default="Chris Chen", help="Email sender display/body fallback. Email templates use fixed Chris Chen; social outreach uses per-account sender names separately.")
  232. parser.add_argument("--status-field", default="建联状态", help="建联状态字段名")
  233. parser.add_argument("--include-sent", action="store_true", help="包含已发送邮件的记录")
  234. args = parser.parse_args()
  235. excel_path = Path(args.excel)
  236. template_path = Path(args.template)
  237. if not excel_path.exists():
  238. raise SystemExit(f"Excel 文件不存在:{excel_path}")
  239. if not template_path.exists():
  240. raise SystemExit(f"邮件模板不存在:{template_path}")
  241. filters = parse_filters(args.filter)
  242. template = template_path.read_text(encoding="utf-8-sig")
  243. if re.search(r"\{\{\s*sender_name\s*\}\}", template) and not args.sender_name.strip():
  244. raise SystemExit("Missing --sender-name for a legacy email template. Current official email templates must use fixed Chris Chen; social outreach uses per-account sender names separately.")
  245. records = read_sheet_rows(excel_path, args.sheet)
  246. matched = [record for record in records if matches(record, filters)]
  247. emails = []
  248. skipped = []
  249. for index, record in enumerate(matched, 1):
  250. status_value = record.get(args.status_field, "").strip()
  251. email_fields = [args.email_field] + list(args.extra_email_field)
  252. recipients = extract_email_addresses(*(record.get(field, "") for field in email_fields))
  253. display_name = record.get(args.name_field, "").strip()
  254. if already_sent(status_value) and not args.include_sent:
  255. skipped.append({"index": index, "dealer_name": display_name, "reason": f"已发送过邮件:{status_value}", "record": record})
  256. continue
  257. if not display_name:
  258. skipped.append({"index": index, "dealer_name": "", "reason": f"缺少{args.name_field}", "record": record})
  259. continue
  260. if not recipients:
  261. skipped.append({"index": index, "dealer_name": display_name, "reason": f"缺少收件邮箱字段:{', '.join(email_fields)}", "record": record})
  262. continue
  263. for recipient in recipients:
  264. emails.append({
  265. "to": recipient,
  266. "dealer_name": display_name,
  267. "subject": render_template(args.subject, record, args.name_field, args.sender_name),
  268. "body": render_template(template, record, args.name_field, args.sender_name),
  269. "record": record,
  270. })
  271. result = {
  272. "generated_at": datetime.now().isoformat(timespec="seconds"),
  273. "source": {
  274. "excel": str(excel_path),
  275. "sheet": args.sheet,
  276. "filters": args.filter,
  277. "template": str(template_path),
  278. "email_field": args.email_field,
  279. "extra_email_field": args.extra_email_field,
  280. "name_field": args.name_field,
  281. "status_field": args.status_field,
  282. "include_sent": args.include_sent,
  283. },
  284. "summary": {
  285. "matched_records": len(matched),
  286. "ready_to_send": len(emails),
  287. "skipped": len(skipped),
  288. },
  289. "emails": emails,
  290. "skipped": skipped,
  291. }
  292. output_path = resolve_artifact_path(
  293. args.output,
  294. kind="email_preview",
  295. default_name="outreach_preview.json",
  296. run_id=args.run_id or None,
  297. )
  298. output_path.parent.mkdir(parents=True, exist_ok=True)
  299. output_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
  300. json.dump(result["summary"], sys.stdout, ensure_ascii=False)
  301. print()
  302. if __name__ == "__main__":
  303. main()