| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348 |
- #!/usr/bin/env python3
- import argparse
- import json
- import re
- import sys
- import zipfile
- import xml.etree.ElementTree as ET
- from datetime import datetime
- from pathlib import Path
- try:
- from common.artifact_manager import resolve_artifact_path
- except ImportError: # pragma: no cover - supports direct CLI execution
- sys.path.append(str(Path(__file__).resolve().parents[1]))
- from common.artifact_manager import resolve_artifact_path
- NS = {
- "main": "http://schemas.openxmlformats.org/spreadsheetml/2006/main",
- "rel": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
- }
- ALIASES = {
- "dealer_name": "客户姓名/公司",
- "country": "国家",
- "city": "城市",
- "dealer_type": "客户类型",
- "page_url": "主页/链接",
- "contact_name": "联系人",
- "position": "职位",
- "phone": "电话/WhatsApp",
- "email": "邮箱",
- "main_business": "主营业务",
- "status": "建联状态",
- "next_follow_up": "下次跟进",
- "notes": "备注",
- }
- SENT_STATUS_MARKERS = [
- "已发送邮件",
- "已发邮件",
- "邮件已发送",
- "邮件发送成功",
- "email sent",
- "sent email",
- "sent",
- "success",
- ]
- def already_sent(status):
- normalized = str(status or "").strip().casefold()
- if not normalized:
- return False
- explicit_email_markers = [
- "已发送邮件",
- "已发邮件",
- "邮件已发送",
- "邮件发送成功",
- "email sent",
- "sent email",
- ]
- if any(marker.casefold() in normalized for marker in explicit_email_markers):
- return True
- return normalized in {"已发送", "sent", "success"}
- EMAIL_PATTERN = re.compile(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}")
- def extract_email_addresses(*values):
- emails = []
- for value in values:
- for email in EMAIL_PATTERN.findall(str(value or "")):
- normalized = email.strip().strip(".,;,;")
- if normalized and normalized.casefold() not in {item.casefold() for item in emails}:
- emails.append(normalized)
- return emails
- def col_to_index(ref):
- letters = "".join(ch for ch in ref if ch.isalpha())
- total = 0
- for ch in letters:
- total = total * 26 + (ord(ch.upper()) - ord("A") + 1)
- return total - 1
- def read_shared_strings(zip_file):
- if "xl/sharedStrings.xml" not in zip_file.namelist():
- return []
- root = ET.fromstring(zip_file.read("xl/sharedStrings.xml"))
- strings = []
- for item in root.findall("main:si", NS):
- strings.append("".join((t.text or "") for t in item.iter(f"{{{NS['main']}}}t")))
- return strings
- def cell_value(cell, shared_strings):
- cell_type = cell.attrib.get("t")
- if cell_type == "inlineStr":
- return "".join((t.text or "") for t in cell.iter(f"{{{NS['main']}}}t"))
- value = cell.find("main:v", NS)
- if value is None:
- return ""
- raw = value.text or ""
- if cell_type == "s":
- return shared_strings[int(raw)]
- return raw
- def read_sheet_rows(xlsx_path, sheet_name):
- with zipfile.ZipFile(xlsx_path) as archive:
- shared_strings = read_shared_strings(archive)
- workbook = ET.fromstring(archive.read("xl/workbook.xml"))
- sheets = workbook.find("main:sheets", NS).findall("main:sheet", NS)
- sheet = next((s for s in sheets if s.attrib.get("name") == sheet_name), None)
- if sheet is None:
- available = ", ".join(s.attrib.get("name", "") for s in sheets)
- raise SystemExit(f"找不到工作表:{sheet_name}。可用工作表:{available}")
- rels = ET.fromstring(archive.read("xl/_rels/workbook.xml.rels"))
- rel_map = {rel.attrib["Id"]: rel.attrib["Target"] for rel in rels}
- rel_id = sheet.attrib[f"{{{NS['rel']}}}id"]
- target = rel_map[rel_id]
- target = target.lstrip("/")
- sheet_path = target if target.startswith("xl/") else "xl/" + target
- root = ET.fromstring(archive.read(sheet_path))
- parsed_rows = []
- for row in root.findall(".//main:sheetData/main:row", NS):
- values = {}
- for cell in row.findall("main:c", NS):
- values[col_to_index(cell.attrib.get("r", ""))] = cell_value(cell, shared_strings)
- if values:
- max_index = max(values)
- parsed_rows.append([values.get(i, "") for i in range(max_index + 1)])
- if not parsed_rows:
- return []
- headers = [str(value).strip() for value in parsed_rows[0]]
- records = []
- for row in parsed_rows[1:]:
- record = {}
- for index, header in enumerate(headers):
- if header:
- record[header] = str(row[index]).strip() if index < len(row) else ""
- if any(record.values()):
- records.append(record)
- return records
- def parse_filters(filter_args):
- filters = []
- for item in filter_args:
- if "=" not in item:
- raise SystemExit(f"筛选条件格式错误,应为 字段=值:{item}")
- key, value = item.split("=", 1)
- filters.append((key.strip(), value.strip()))
- return filters
- def matches(record, filters):
- for key, expected in filters:
- actual = record.get(key, "")
- if str(actual).strip() != expected:
- return False
- return True
- def choose_capability_phrase(record):
- text = " ".join(str(record.get(key, "")) for key in (
- "客户类型",
- "主营业务",
- "公司主营业务",
- "备注",
- "客户属性",
- "官网链接",
- "主页/链接",
- )).casefold()
- if any(token in text for token in (
- "location", "rental", "rent", "leasing", "fleet", "flotte", "车队", "租赁", "政府", "corporate", "enterprise"
- )):
- return "corporate customer resources"
- if any(token in text for token in (
- "network", "réseau", "reseau", "dealer network", "downstream", "全国分销", "区域分销", "分销", "代理", "group", "groupe"
- )):
- return "a dealer network"
- if any(token in text for token in (
- "import", "importation", "importateur", "importer", "认证", "certification", "进口"
- )):
- return "vehicle import capabilities"
- if any(token in text for token in (
- "multi-brand", "multibrand", "multi brand", "多品牌", "showroom", "concessionnaire", "dealer", "汽车经销", "经销", "renault", "dacia", "audi", "volkswagen", "skoda", "škoda", "fiat", "jeep", "porsche", "kia"
- )):
- return "multi-brand distribution experience"
- return "local vehicle sales and customer resources"
- def choose_platform_resource_phrase(record):
- text = " ".join(str(record.get(key, "")) for key in (
- "客户类型",
- "主营业务",
- "公司主营业务",
- "备注",
- "客户属性",
- "官网链接",
- "主页/链接",
- )).casefold()
- if any(token in text for token in (
- "交易平台", "marketplace", "platform", "annuaire", "portal", "site", "网站", "流量", "traffic", "leads", "线索"
- )):
- return "automotive industry traffic"
- if any(token in text for token in (
- "协会", "商会", "联盟", "federation", "association", "chamber", "alliance", "member", "会员", "network", "réseau", "reseau"
- )):
- return "dealer members and industry resources"
- if any(token in text for token in (
- "fleet", "flotte", "corporate", "enterprise", "政府", "institution", "机构", "企业客户", "车队", "采购"
- )):
- return "corporate customers and procurement resources"
- if any(token in text for token in (
- "dealer", "concessionnaire", "distributor", "importer", "importateur", "经销商", "进口商", "分销"
- )):
- return "dealer and importer resources"
- return "automotive industry resources"
- def render_template(template, record, name_field="客户姓名/公司", sender_name=""):
- values = dict(record)
- for alias, header in ALIASES.items():
- values.setdefault(alias, record.get(header, ""))
- display_name = record.get(name_field, "").strip() or record.get("客户姓名/公司", "").strip()
- if not display_name:
- raise ValueError("[Name]/{{customer_name}} cannot be replaced because the customer name is empty.")
- values["dealer_name"] = display_name
- values["customer_name"] = display_name
- values["sender_name"] = sender_name.strip()
- values.setdefault("capability_phrase", choose_capability_phrase(record))
- values.setdefault("platform_resource_phrase", choose_platform_resource_phrase(record))
- rendered = template.replace("[Name]", display_name).replace("[name]", display_name)
- rendered = rendered.replace("[PICTURE]", "")
- def replace(match):
- key = match.group(1).strip()
- return str(values.get(key, match.group(0)))
- rendered = re.sub(r"\{\{\s*([^}]+?)\s*\}\}", replace, rendered)
- unresolved_required = re.findall(r"\[(?:Name|name)\]|\{\{\s*(?:customer_name|dealer_name|sender_name)\s*\}\}", rendered)
- if unresolved_required:
- raise ValueError(f"Unresolved required placeholder(s): {', '.join(sorted(set(unresolved_required)))}")
- return rendered
- def main():
- parser = argparse.ArgumentParser(description="从 Excel 生成经销商建联邮件预览,并替换 Dear [Name] 与模板占位符。")
- parser.add_argument("--excel", required=True, help="Excel .xlsx 文件路径")
- parser.add_argument("--sheet", required=True, help="工作表名称")
- parser.add_argument("--filter", action="append", default=[], help="筛选条件,格式为 字段=值,可重复传入")
- parser.add_argument("--template", required=True, help="邮件 .md 或 .txt 模板路径")
- parser.add_argument("--subject", required=True, help="邮件主题")
- parser.add_argument("--output", required=True, help="输出 JSON 预览文件")
- parser.add_argument("--run-id", default="", help="Run ID; bare output filenames are stored under runs/YYYYMMDD/<run_id>/")
- parser.add_argument("--email-field", default="邮箱", help="主收件邮箱字段名")
- parser.add_argument("--extra-email-field", action="append", default=[], help="补充收件邮箱字段名,可重复传入,例如 个人邮箱")
- parser.add_argument("--name-field", default="客户姓名/公司", help="客户名称/公司字段名")
- 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.")
- parser.add_argument("--status-field", default="建联状态", help="建联状态字段名")
- parser.add_argument("--include-sent", action="store_true", help="包含已发送邮件的记录")
- args = parser.parse_args()
- excel_path = Path(args.excel)
- template_path = Path(args.template)
- if not excel_path.exists():
- raise SystemExit(f"Excel 文件不存在:{excel_path}")
- if not template_path.exists():
- raise SystemExit(f"邮件模板不存在:{template_path}")
- filters = parse_filters(args.filter)
- template = template_path.read_text(encoding="utf-8-sig")
- if re.search(r"\{\{\s*sender_name\s*\}\}", template) and not args.sender_name.strip():
- 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.")
- records = read_sheet_rows(excel_path, args.sheet)
- matched = [record for record in records if matches(record, filters)]
- emails = []
- skipped = []
- for index, record in enumerate(matched, 1):
- status_value = record.get(args.status_field, "").strip()
- email_fields = [args.email_field] + list(args.extra_email_field)
- recipients = extract_email_addresses(*(record.get(field, "") for field in email_fields))
- display_name = record.get(args.name_field, "").strip()
- if already_sent(status_value) and not args.include_sent:
- skipped.append({"index": index, "dealer_name": display_name, "reason": f"已发送过邮件:{status_value}", "record": record})
- continue
- if not display_name:
- skipped.append({"index": index, "dealer_name": "", "reason": f"缺少{args.name_field}", "record": record})
- continue
- if not recipients:
- skipped.append({"index": index, "dealer_name": display_name, "reason": f"缺少收件邮箱字段:{', '.join(email_fields)}", "record": record})
- continue
- for recipient in recipients:
- emails.append({
- "to": recipient,
- "dealer_name": display_name,
- "subject": render_template(args.subject, record, args.name_field, args.sender_name),
- "body": render_template(template, record, args.name_field, args.sender_name),
- "record": record,
- })
- result = {
- "generated_at": datetime.now().isoformat(timespec="seconds"),
- "source": {
- "excel": str(excel_path),
- "sheet": args.sheet,
- "filters": args.filter,
- "template": str(template_path),
- "email_field": args.email_field,
- "extra_email_field": args.extra_email_field,
- "name_field": args.name_field,
- "status_field": args.status_field,
- "include_sent": args.include_sent,
- },
- "summary": {
- "matched_records": len(matched),
- "ready_to_send": len(emails),
- "skipped": len(skipped),
- },
- "emails": emails,
- "skipped": skipped,
- }
- output_path = resolve_artifact_path(
- args.output,
- kind="email_preview",
- default_name="outreach_preview.json",
- run_id=args.run_id or None,
- )
- output_path.parent.mkdir(parents=True, exist_ok=True)
- output_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
- json.dump(result["summary"], sys.stdout, ensure_ascii=False)
- print()
- if __name__ == "__main__":
- main()
|