#!/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]", "![Wuling Morocco Market Cooperation Evaluation](assets/wuling-channel-margin-comparison.png)") 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//") 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()