Преглед изворни кода

sync: update skill to latest live version

- fix build_dashboard read_only crash (read_only=False)
- add customer status/contactability judgment logic
- update google maps scraper
- improve facebook conversation collect/write
- add workflow pipeline, direct_summary, workflow config/templates
梁朝伟 пре 2 недеља
родитељ
комит
c0c6af40c7
31 измењених фајлова са 2552 додато и 2250 уклоњено
  1. 57 297
      SKILL.md
  2. BIN
      assets/blank_customer_outreach_workbook.xlsx
  3. 12 12
      assets/dashboard_template.html
  4. 405 138
      assets/social_outreach_library.md
  5. 494 0
      assets/social_private_message_template.md
  6. 10 22
      assets/social_templates.md
  7. 33 0
      assets/workflow_config.example.json
  8. 26 100
      references/dashboard.md
  9. 27 124
      references/facebook-conversation-sync.md
  10. 22 66
      references/feishu-sync.md
  11. 50 229
      references/field-schema.md
  12. 55 200
      references/search-strategy.md
  13. 73 0
      references/workflow-automation.md
  14. 120 542
      scripts/common/build_customer_summary.py
  15. 314 0
      scripts/common/direct_summary.py
  16. 88 96
      scripts/common/excel_io.py
  17. 94 108
      scripts/dashboard/build_dashboard.py
  18. 1 1
      scripts/scraper/scrape_single_page.py
  19. 1 1
      scripts/scraper/search_active_dealers.py
  20. 8 55
      scripts/scraper/search_auto_websites.py
  21. 1 1
      scripts/scraper/search_facebook.py
  22. 4 2
      scripts/scraper/search_google_maps.py
  23. 1 1
      scripts/scraper/search_linkedin.py
  24. 18 87
      scripts/social/README.md
  25. 47 3
      scripts/social/collect_facebook_conversations.py
  26. 116 44
      scripts/social/prepare_facebook_outreach.py
  27. 3 2
      scripts/social/run_facebook_follow_dm.py
  28. 38 19
      scripts/social/send_facebook_outreach.py
  29. 114 100
      scripts/social/write_facebook_conversations.py
  30. 1 0
      scripts/workflows/__init__.py
  31. 319 0
      scripts/workflows/run_dealer_pipeline.py

+ 57 - 297
SKILL.md

@@ -1,337 +1,97 @@
 ---
 name: wuling-overseas-dealer-expansion
-description: 为五菱汽车海外经销商拓展提供端到端自动化能力:使用 AdsPower/Playwright 搜索海外经销商线索,去重和评分,回写及汇总建联表,生成邮件和社交建联预览,执行经确认的建联,读取匹配的 Facebook Messenger 对话、翻译成中文、分析合作意向和跟进动作。Use when Codex needs to find overseas Wuling dealer leads, assess dealer fit, prepare/send confirmed outreach, synchronize Facebook replies, translate customer conversations, assess cooperation intent, or update the outreach workbook and dashboard.
+description: 为五菱汽车海外经销商拓展提供端到端自动化能力:使用 AdsPower/Playwright 搜索海外经销商线索,去重、评分并直接写入客户信息汇总表,生成邮件和社交建联预览,执行经确认的建联,读取匹配的 Facebook Messenger 对话、翻译成中文、分析合作意向、更新建联表、生成客户中台看板,并在配置后同步飞书云表格。Use when Codex needs to find overseas Wuling dealer leads, assess dealer fit, prepare/send confirmed outreach, synchronize Facebook replies, translate customer conversations, record unified Facebook reply status, update the outreach workbook, sync Lark/Feishu sheets, or build the customer dashboard.
 ---
 
 # 五菱汽车海外经销商拓展
 
 ## 文件边界
 
-这个目录是独立 skill 包,只放可复用文件:
+这个目录是独立 skill 包,只放可复用文件:`SKILL.md`、`references/`、`assets/`、`scripts/`。不要把真实客户表、采集结果、发送日志、预览 HTML/JSON、截图、缓存、SMTP 授权码或账号凭证放进 skill 包。唯一允许放进 `assets/` 的 Excel 是空白兜底模板 `assets/blank_customer_outreach_workbook.xlsx`。
 
-- `SKILL.md`:入口说明和任务路由。
-- `references/`:搜索策略、字段规范、品牌/OEM 规则、AdsPower、飞书同步和建联风控说明。
-- `assets/`:邮件模板、邮件主题、邮件图片、社交话术库、全新环境兜底用空白建联表模板、飞书同步配置示例。
-- `scripts/`:采集、去重、Excel、邮件预览/发送、社交预览/执行脚本。
-- `references/artifact-policy.md`: run artifact, backup, preview, send-log, and cleanup retention policy.
-- `references/encoding-and-excel-writeback.md`: mandatory text encoding and Excel write-back rules to prevent repeated-question-mark text corruption.
-- `references/feishu-sync.md`: WorkBuddy/Lark Sheets post-write sync rules for keeping the Feishu online spreadsheet aligned with the local workbook.
-- `references/dashboard.md`: customer dashboard generation rules, KPI definitions, dashboard artifact locations, and optional Feishu metric-sync boundary.
-- `references/facebook-conversation-sync.md`: matched Messenger collection, Chinese translation, five-level intent analysis, workbook write-back, and reply-dashboard rules.
+## 核心工作流
 
-不要把真实客户表、JSON 预览、采集结果、发送日志、截图、缓存、`__pycache__`、临时文件或任何账号/SMTP 授权码放进 skill 包。唯一允许放进 `assets/` 的 Excel 是空白模板 `assets/blank_customer_outreach_workbook.xlsx`。
+1. 先生成预览,再写表、发邮件或发社交私信。
+2. 搜索和浏览器操作必须使用 AdsPower + Playwright;不得用截图坐标或系统级鼠标键盘模拟。脚本不得关闭 AdsPower 指纹浏览器。
+3. 客户资料入表默认直接写入 `客户信息汇总表`,不再按 Facebook、LinkedIn、Google Maps、当地汽车网站等渠道分 Sheet 新增客户。
+4. 旧渠道 Sheet 只作为历史数据和兼容输入读取;新采集结果写表时,渠道名只写入对应链接列或 `备注` 的来源证据。
+5. 写表后如配置 `feishu_sync_config.json` 且 `enabled=true`,必须自动调用 WorkBuddy 飞书表格插件同步;未配置时说明“本地写表完成,飞书同步待配置”。
+6. 对话流水仍单独写入 `Facebook对话记录`,它是消息事件表,不是客户资料渠道分表。
 
-## 任务路由
+## 客户信息汇总表
 
-| 任务 | 使用文件 | 状态 |
-|---|---|---|
-| Facebook 经销商搜索 | `scripts/scraper/search_active_dealers.py` | 已实现,默认预览 |
-| Facebook 轻量搜索 | `scripts/scraper/search_facebook.py` | 已实现 |
-| Facebook 单页采集 | `scripts/scraper/scrape_single_page.py` | 已实现 |
-| LinkedIn 公司页搜索 | `scripts/scraper/search_linkedin.py` | 已实现,默认预览 |
-| Google Maps 搜索和公开邮箱提取 | `scripts/scraper/search_google_maps.py` | 已实现,邮箱需 `--deep-scrape` |
-| 当地汽车网站搜索 | `scripts/scraper/search_auto_websites.py` | 已实现,覆盖 OtoMoto、Wandaloo、Kerix、Kompass、Maroc Annuaire、Telecontact |
-| 客户信息汇总大表 | `scripts/common/build_customer_summary.py` | 已实现,完成各平台查找后统一汇总/覆盖 `客户信息汇总表` |
-| 客户中台看板 | `scripts/dashboard/build_dashboard.py` | 已实现,读取 `客户信息汇总表` 生成 HTML 看板和指标 JSON,不写 Excel |
-| 飞书云表格同步 | `references/feishu-sync.md` + WorkBuddy `lark-sheets` plugin | 写表后强制检查项目配置并同步 |
-| TikTok 搜索 | `scripts/scraper/search_tiktok.py` | 占位,暂缓开发 |
-| 邮件预览 | `scripts/email_outreach/prepare_outreach_emails.py` | 已实现 |
-| 邮件发送 | `scripts/email_outreach/send_outreach_emails.py` | 已实现,必须人工确认 |
-| 社交话术生成 | `assets/social_outreach_library.md` | 已实现,Facebook 主语言为英文 |
-| Facebook 社交建联预览 | `scripts/social/prepare_facebook_outreach.py` | 已实现,输出中文判断 + 英文话术 JSON 预览 |
-| Facebook Follow + Messenger 建联总控 | `scripts/social/run_facebook_follow_dm.py` | 已实现,输出整批聊天框预览;确认后批量执行;不断开 Playwright 连接,浏览器保持打开 |
-| Facebook 社交建联执行 | `scripts/social/send_facebook_outreach.py` | 已实现,默认 dry-run;支持 `--action follow_dm`;真实执行必须 `--confirm --batch-confirmed`,不逐条确认 |
-| Facebook 对话采集 | `scripts/social/collect_facebook_conversations.py` | 已实现,只读;首次全量、后续增量;始终保持浏览器打开 |
-| Facebook 对话翻译/意向写回 | `scripts/social/write_facebook_conversations.py` + `references/facebook-conversation-sync.md` | 已实现,先聊天框预览,再显式写表 |
-
-## 必读规则
-
-- 先生成预览,再写表或发送。
-- 查找客户默认面向用户指定国家全国范围,不局限单一城市;当前摩洛哥只是本项目默认市场。
-- 表里已有同名、同主页、同电话或同邮箱的客户,直接跳过或补充资料,不重复新建。
-- 跳过 OEM 官方品牌国家页或当地分公司,例如 `BYD Maroc`、`BMW Maroc`、`JAC Morocco`、`Changan Maroc`、`FOTON Maroc`。
-- Customer segmentation uses `客户属性` as the broad class and `客户类型` as the subtype. Classification must follow the five-attribute taxonomy. Main focus is `汽车渠道合作伙伴` and `平台与行业渠道`; `批量采购与运营客户`, `二手车转型候选`, and `生态支持资源` remain supported for full segmentation. `主营业务` is only the business-description field.
-- 邮箱只使用公开页面、`mailto:` 或官网 Contact/About 页面中能追溯的邮箱,不猜测、不生成。
-- 不保存 SMTP 授权码,不把授权码写入日志、JSON、skill 文件或脚本默认值。
-- 所有涉及浏览器的操作必须使用 Playwright 执行,包括 AdsPower 连接、Facebook/LinkedIn/Google Maps/当地汽车网站采集、官网深搜、页面点击、输入、滚动、截图验证和 Messenger 私信。不得使用截图坐标、系统级鼠标键盘模拟、浏览器扩展脚本或人工猜测 DOM 位置作为默认实现;除非用户明确要求人工操作,否则浏览器自动化入口必须通过 Playwright 的 DOM/locator/CDP 能力完成。所有脚本必须保持 AdsPower 指纹浏览器打开,禁止调用 AdsPower stop 或关闭用户浏览器窗口。
-
-## Facebook 英文建联流程
-
-Facebook 建联必须按半自动、低频、可审计的合规流程执行。优化目标是降低账号操作风险和骚扰风险,不允许写成规避官方风控、绕过验证、换指纹规避或模拟异常人类行为的方案。
-
-1. 从 `Facebook` Sheet 筛选 `未联系`、非 OEM 官方页、有主页链接的客户。
-2. 运行 `scripts/social/prepare_facebook_outreach.py` 生成预览,必须在聊天框展示整批 Markdown 预览,包含中文客户判断、英文私信话术、主页链接和风险提示。
-3. 人工一次性复核整批客户名单和英文话术;不再逐条确认。
-4. 推荐运行 `scripts/social/run_facebook_follow_dm.py` 做总控流程:先生成并打印整批预览,再调用 AdsPower/Playwright dry-run 或确认执行。
-5. 用户在聊天框明确确认整批预览后,才允许加 `--confirm-send` 真实执行;总控脚本会传入 `--confirm --batch-confirmed`。执行前必须展示本轮预计节奏、当天 Follow/DM 已用额度、剩余额度和停止规则。
-6. 默认不写表;只有用户明确传 `--write-workbook`,才写回 `Facebook` Sheet 的建联状态和备注。
-7. 一轮结束后,再用 `scripts/common/build_customer_summary.py --write-summary` 刷新 `客户信息汇总表`。
-
-禁止跳过预览直接真实发送。禁止把法语作为 Facebook 默认发送语言;中文只用于内部判断和复核,不发送给客户。
-
-示例预览命令:
-
-```bash
-python scripts/social/prepare_facebook_outreach.py --excel "<建联表路径>" --sheet Facebook --sample 5 --output facebook_social_preview.json
-```
-
-示例总控预览命令:
-
-```bash
-python scripts/social/run_facebook_follow_dm.py --excel "<建联表路径>" --sheet Facebook --profile-id "<AdsPower配置ID>" --max-contacts 3 --review-only
-```
-
-示例总控节奏预览命令:
-
-```bash
-python scripts/social/run_facebook_follow_dm.py --excel "<建联表路径>" --sheet Facebook --profile-id "<AdsPower配置ID>" --max-contacts 3 --dry-run-schedule
-```
-
-示例总控 dry-run 命令:
-
-```bash
-python scripts/social/run_facebook_follow_dm.py --excel "<建联表路径>" --sheet Facebook --profile-id "<AdsPower配置ID>" --max-contacts 3 --use-open-page --risk-profile very_conservative
-```
-
-示例 dry-run 命令:
-
-```bash
-python scripts/social/send_facebook_outreach.py --preview facebook_social_preview.json --profile-id "<AdsPower配置ID>" --action follow_dm --keep-browser-open --no-write-workbook
-```
-
-示例真实执行命令:
-
-```bash
-python scripts/social/send_facebook_outreach.py --preview facebook_social_preview.json --profile-id "<AdsPower配置ID>" --action follow_dm --risk-profile very_conservative --daily-follow-limit 20 --daily-dm-limit 20 --session-max 3 --keep-browser-open --no-write-workbook --confirm --batch-confirmed
-```
+所有新增、补充、状态更新和深搜补充,都以 `客户信息汇总表` 为主数据表。标准表头固定为:
 
-## 社交话术规则
+`公司姓名、国家、城市、客户类型、官网链接、联系人、职位、个人邮箱、联系人电话、Facebook主页链接、linkined主页链接、google map链接、公共电话/WhatsApp、公共邮箱、客户属性、线索等级、需人工确认、建联状态、下次跟进、备注`
 
-社交话术用于 Facebook 和 LinkedIn。Facebook 主语言为英文,输出结构为:中文内部判断 + 英文加好友话术 + 英文私信话术。话术目标是判断客户是否具备评估、采购、消化或分销一批五菱车型的能力。
+字段规则:
 
-首轮话术优先使用“真实客户信号 + 利润机会 + 首批小批量试单”切入:先引用 showroom、used-car stock、rental、fleet、importation、官网、WhatsApp、近期帖子等真实信号,再提出商业假设和低压力价值物。`prepare_facebook_outreach.py` 必须输出 `recommended_message`,并同时给出 `direct_profit_hook`、`stock_gap_hook`、`soft_research_hook` 三个备选版本,方便人工挑选。不要写成泛泛合作介绍。
+- `客户属性`:五大客户属性之一。
+- `客户类型`:必须是对应客户属性下的细分客户类型。
+- `线索等级`:`A/B/C`,用于判断线索质量。
+- `需人工确认`:无风险写 `否`;有价值但缺少关键确认点时写具体原因,例如 `主体归属待确认;新车业务待确认`。
+- `备注`:写中文结构化证据、来源、风险和批量采购潜力;专业词如 showroom、importation、fleet、location、vehicules neufs 可保留原文。
+- 来源平台不新增独立列;来源通过 Facebook/LinkedIn/Google Maps 链接列和 `备注` 中的 `客户来源:...` 保存。
 
-禁止编造具体价格、MOQ、车型清单、利润率、库存数量、政府关系或未经确认的合作条件。
+重复客户合并规则:优先按邮箱、电话、Facebook/LinkedIn/Google Maps 链接、官网链接匹配;其次按标准化公司名匹配。重复资料不新建行,只补充空字段,并将多个电话、邮箱、联系人、链接和备注用 `;` 去重合并。
 
-## Facebook 回复同步流程
+## 客户分类
 
-当用户要求查看 Facebook 回复、同步 Messenger 对话、翻译客户消息或判断下一步合作意向时,必须读取 `references/facebook-conversation-sync.md`。
+五大客户属性和细分客户类型必须严格使用下面的值:
 
-1. 使用 `collect_facebook_conversations.py` 只读取建联表中可可靠匹配的客户线程;首次用 `--initial-full`,后续用 `--incremental`。
-2. 保留双方原文并把非中文内容翻译成中文;仅对有效客户回复进行五级合作意向判断。
-3. 在聊天框展示回复摘要、意向分布和高意向客户,不生成英文回复、不自动发送。
-4. 用户明确要求写表后,使用 `write_facebook_conversations.py --write-workbook --refresh-summary --refresh-dashboard`。
-5. 对话逐条写入 `Facebook对话记录`;Facebook 客户行只更新最新中文摘要、合作意向、状态和下次跟进。
-6. 写表后校验乱码、刷新总表/看板,并按飞书配置同步。
-
-
-## Customer Attribute Taxonomy
-
-All workbook rows and scraper outputs must use exactly one customer attribute and one matching customer type from the table below. Do not write combined attributes, multi-line categories, custom labels, or fallback values such as pending/unknown into the official workbook. Keep business descriptions in `主营业务`; do not mix them into classification fields.
-
-| Customer attribute | Allowed customer types |
+| 客户属性 | 细分客户类型 |
 |---|---|
-| `汽车渠道合作伙伴` | `汽车进口商` ; `全国代理商` ; `全国分销商` ; `区域分销商` ; `多品牌经销商` ; `商用车渠道商` ; `中国品牌经销商` ; `新能源或小型车渠道商` |
-| `批量采购与运营客户` | `汽车租赁公司` ; `长期租赁公司` ; `企业车队` ; `物流配送企业` ; `政府或机构采购方` |
-| `二手车转型候选` | `连锁二手车企业` ; `实体二手车企业` ; `进口二手车企业` ; `新车与二手车综合企业` |
-| `平台与行业渠道` | `汽车交易平台` ; `汽车协会` ; `商会` ; `车商联盟` ; `经销商资源引荐机构` |
-| `生态支持资源` | `售后服务网络` ; `备件供应与仓储企业` ; `进口认证与上牌机构` ; `金融保险机构` ; `车辆物流企业` |
-
-Rules:
-
-- `客户属性` is the broad class; `客户类型` is the subtype. The pair must match the same row in the taxonomy table.
-- Main search focus: `汽车渠道合作伙伴` first, then `平台与行业渠道`. The other three classes are still kept for complete segmentation and later operations.
-- If evidence is insufficient, continue public-source deep search before writing. Preview files may explain uncertainty, but the official summary sheet must not contain fake or unsupported classes.
-
-## 人工确认标记边界
-
-以下内容是人工确认/风险标记,不是客户分类。禁止写入 `客户属性` 或 `客户类型`,只能写入候选预览 JSON 的 `risk_flags`、`备注` 或人工复核预览中:
-
-- `主体归属待确认`:汽车渠道线索中,无法判断是独立公司,还是品牌官方主体、进口商直营网点或普通分店。
-- `新车业务待确认`:汽车渠道线索中,无法判断主营新整车,还是二手车、维修、配件、轮胎等业务。
-- `平台与行业渠道主体待确认`:平台与行业渠道线索中,无法确认是否为真实机构主体,还是普通个人页、内容号或非正式资源页。
-- `平台与行业渠道作用待确认`:平台与行业渠道线索中,无法确认是否具备汽车行业资源、渠道引荐、媒体传播或行业活动组织作用。
-- `仅电话/WhatsApp待人工确认`:线索已有目标价值信号,但只有电话或 WhatsApp,缺少官网、Facebook、LinkedIn、Google Maps 等可复核资料,需要人工联系确认主体和实际业务。
-- `详细信息待确认`:当 AI 因登录限制、页面屏蔽、地区限制或网站无法访问,不能读取 Facebook、LinkedIn、官网等内容时填写。
-
-客户分类仍必须严格保持五大客户属性和表内细分客户类型;不要用上述风险标记作为兜底分类值。
-
-人工复核不是低质线索兜底池。只有已经显示出渠道价值、但缺少一个关键确认点的客户,才标记为需人工复核。
-
-输出人工复核名单时,不要输出通用复核规则;只列出那些已经值得推进、但需要人工确认一个关键点的客户。低价值或无关客户应写为跳过/低优先级,不要包装成“建议人工复核”。
-
-### 两段式人工复核标准
-
-人工复核必须先看客户是否有目标价值,再看是否缺少需人工确认的关键点。只有两部分同时成立,才能列为需人工复核。
-
-第一部分:客户属性信号至少满足其一。
-
-- 汽车渠道合作伙伴信号:新车销售、汽车进口、分销、授权经销、汽车展厅、showroom、库存、dealer network 等。
-- 平台与行业渠道信号:汽车协会、商会、汽车行业平台、汽车媒体、行业活动组织、车商联盟、经销商资源平台或引荐机构等。
-
-第二部分:人工复核条件至少满足其一。
-
-- 汽车渠道类:主体归属待确认,或新车业务待确认。
-- 平台与行业渠道类:真实机构主体待确认,或汽车行业资源/渠道作用待确认。
-- 只有电话或 WhatsApp:线索已有目标价值信号,但缺少官网、Facebook、LinkedIn、Google Maps 等可复核资料,需要人工联系确认其主体和实际业务。
+| `汽车渠道合作伙伴` | `汽车进口商`、`全国代理商`、`全国分销商`、`区域分销商`、`多品牌经销商`、`商用车渠道商`、`中国品牌经销商`、`新能源或小型车渠道商` |
+| `批量采购与运营客户` | `汽车租赁公司`、`长期租赁公司`、`企业车队`、`物流配送企业`、`政府或机构采购方` |
+| `二手车转型候选` | `连锁二手车企业`、`实体二手车企业`、`进口二手车企业`、`新车与二手车综合企业` |
+| `平台与行业渠道` | `汽车交易平台`、`汽车协会`、`商会`、`车商联盟`、`经销商资源引荐机构` |
+| `生态支持资源` | `售后服务网络`、`备件供应与仓储企业`、`进口认证与上牌机构`、`金融保险机构`、`车辆物流企业` |
 
-不要把“只有电话/WhatsApp”单独作为人工复核理由。如果客户没有汽车渠道价值或平台行业价值,应跳过或标记低优先级,不列入人工复核名单
+重点搜索 `汽车渠道合作伙伴` 和 `平台与行业渠道`。其他三类保留用于完整分层和后续触达。
 
+## 汽车渠道筛选和评级
 
-- 可进入人工复核的前提:至少有一个明确正向价值信号,例如汽车渠道、进口/分销、多品牌、showroom/展厅、库存/stock、中国品牌经验、商用车/车队场景、平台/协会/商会资源或可建联入口。
-- 人工复核的典型原因:看起来有渠道价值,但独立主体待确认、新整车业务待确认、或因页面/登录/地区限制导致详细信息无法读取。
-- 不进入人工复核的情况:纯维修/配件/轮胎/保险/洗车、个人卖家、OEM 官方主页/当地分公司、无汽车渠道价值信号且无可建联入口的记录。这些应该跳过或标记低优先级,而不是写成“需人工复核”。
+汽车渠道合作伙伴必须满足:有实际新整车销售或分销业务;具有独立经营主体;存在新增品牌可能性;至少有一个可建联入口。纯维修、配件、轮胎、个人卖家不纳入;纯租车公司转入 `批量采购与运营客户 / 汽车租赁公司`;品牌当地分公司、官方主页、进口商直营网点及普通分店跳过。
 
-## 汽车渠道合作伙伴筛选与评分规则
+评级标准:
 
-- 汽车渠道合作伙伴必须有实际新整车销售或分销业务证据:展厅、新车销售、进口、经销、分销、库存或 dealer network。纯维修、配件、轮胎、个人卖家不纳入汽车渠道合作伙伴。纯租车公司转入 `批量采购与运营客户 / 汽车租赁公司`。
-- 必须能识别独立经营主体:优先独立公司名称、注册主体、集团、经销商或进口商;排除品牌当地分公司、官方主页、进口商直营网点及普通分店,例如 `BYD Maroc`、`BMW Maroc` 等品牌官方主体。
-- 必须存在新增品牌的可能性:进口商、分销商、多品牌经销商优先;独立单品牌新车经销商可以保留,但备注和风险标记必须写 `排他协议及新增品牌权限待确认`。
-- 必须具有可建联入口:至少有一个有效电话、WhatsApp、邮箱、Facebook、LinkedIn 或负责人入口。页面活跃度、粉丝互动、近期帖子只作为辅助参考,不作为必须条件。
+- `A`:明确拥有进口、代理、分销、多品牌渠道、全国网络或平台/行业资源能力。
+- `B`:明确是独立目标客户,但规模、覆盖或新增品牌权限证据较弱,例如独立单品牌新车经销商、区域 showroom、较小经销主体。
+- `C`:客户有一定价值信号,但主体、新车业务、渠道作用或资料完整度仍无法确认。
 
-评分准则写入候选预览的 `score_reasons` 和 `risk_flags`:
+人工确认不是低质量兜底。只有客户已经表现出目标价值,同时缺少关键确认点时,才在 `需人工确认` 写具体原因。低价值或无关客户应跳过或标低优先级,不要包装成“需人工确认”。
 
-- `+3` 实际新整车销售、showroom、库存、concessionnaire 或 véhicules neufs 信号。
-- `+3` 进口、分销、代理、集团、réseau、dealer network 等渠道能力信号。
-- `+2` 多品牌经营、multimarque、multi-brand 或多品牌 showroom 信号。
-- `+2` 有可建联入口,例如电话、WhatsApp、邮箱、Facebook、LinkedIn、官网 Contact。
-- `+1` 中国品牌、商用车、车队或摩洛哥本地相关信号;仅作为辅助,不单独构成汽车渠道合作伙伴。
-- `-10` 品牌当地分公司、官方主页、进口商直营网点或普通品牌分店,推荐动作设为 `skip_brand_branch`。
-- `-6` 纯维修、配件、轮胎、洗车、保险、诊断等非整车销售服务,推荐动作设为 `skip_non_channel`。
-- `-5` 个人卖家或个人资料页,推荐动作设为 `skip_non_channel`。
-- 纯租赁/车队线索不作为汽车渠道合作伙伴加分,需转入 `批量采购与运营客户 / 汽车租赁公司`。
-- 没有新整车销售、进口、分销或 showroom 证据时扣分,并要求继续深搜或人工复核。
+## 脚本入口
 
-## 客户信息汇总流程
-
-各平台客户资料必须先写入各自的渠道 Sheet,例如 `Facebook`、`LinkedIn`、`Google Maps`、`本地汽车网站`、`汽车网站精选线索`。采集、浏览、深度补充和去重完成前,不要把新客户直接写入 `客户信息汇总表`。
-
-完成一轮平台查找后,再运行 `scripts/common/build_customer_summary.py` 统一生成或覆盖 `客户信息汇总表`。总表是可重建结果,不是原始采集表;表头必须对齐重点客户参考表:`公司姓名、国家、城市、客户类型、官网链接、联系人、职位、个人邮箱、联系人电话、Facebook主页链接、linkined主页链接、google map链接、公共电话/WhatsApp、公共邮箱、客户属性、建联状态、下次跟进、备注`。重复客户在总表中合并为一行,电话、邮箱、联系人、平台链接、官网链接和备注作为补充资料去重追加;合并来源数量、客户来源和来源 Sheet 写入 `备注`,不再单独增加 `重复来源数` 列,避免列错位或把序号当次数。
-
-默认只预览汇总统计,不保存工作簿:
-
-```bash
-python scripts/common/build_customer_summary.py --excel "<建联表路径>"
-```
-
-用户明确要求更新总表时,才写入:
-
-```bash
-python scripts/common/build_customer_summary.py --excel "<建联表路径>" --write-summary
-```
-
-## 客户中台看板
-
-当用户提到中台、看台、看板、客户统计、建联率、客户数量、渠道分布或邮件发送统计时,读取 `references/dashboard.md`,并使用 `scripts/dashboard/build_dashboard.py` 生成本地 HTML 看板和指标 JSON。
-
-看板以 `客户信息汇总表` 为客户主数据,并在存在 `Facebook对话记录` 时读取它计算唯一客户回复率、五级意向分布、高意向客户和最近 30 天回复趋势。消息行不得计作客户行。看板不直接写入 Excel,不触发飞书同步,也不保存真实客户数据到 skill 包。
-
-正式视觉模板固定为 `assets/dashboard_template.html`。每次生成中台都必须通过 `build_dashboard.py` 将运行时 JSON 注入 `{{dashboard_json}}` 占位符;不得把已生成 HTML 中的真实客户 JSON 复制回模板或 Skill 包。
-
-如果本轮刚完成渠道采集、邮件状态写回、社交状态写回或其他 Excel 写入,必须先运行 `scripts/common/build_customer_summary.py --write-summary` 刷新 `客户信息汇总表`,再生成看板。预览、dry-run、搜索未写表时,不强制刷新看板。
-
-示例命令:
-
-```bash
-python scripts/dashboard/build_dashboard.py --excel "<建联表路径>" --run-id "<run_id>" --latest-dir "dashboards/latest"
-```
-
-## 飞书云表格同步
-
-飞书同步是所有写表流程写表后的强制后置步骤。每次本地 Excel 成功执行 `--write-excel`、`--write-summary`、`--write-workbook`、邮件状态写回或社交状态写回后,agent 必须读取 `references/feishu-sync.md` 并检查项目根目录 `feishu_sync_config.json`。
+| 任务 | 脚本 | 入表规则 |
+|---|---|---|
+| Facebook 搜索/深搜 | `scripts/scraper/search_active_dealers.py`、`scripts/scraper/search_facebook.py`、`scripts/scraper/scrape_single_page.py` | `--write-excel` 时直接写 `客户信息汇总表` |
+| LinkedIn 搜索 | `scripts/scraper/search_linkedin.py` | 默认写 `客户信息汇总表`,LinkedIn 链接进入 `linkined主页链接` |
+| Google Maps 搜索 | `scripts/scraper/search_google_maps.py` | 默认写 `客户信息汇总表`,Maps 链接进入 `google map链接` |
+| 当地汽车网站 | `scripts/scraper/search_auto_websites.py` | 默认写 `客户信息汇总表`,来源网站进入 `备注` |
+| 总表修复/兼容导入 | `scripts/common/build_customer_summary.py` | 读取现有总表和历史渠道 Sheet,合并后重建总表 |
+| 邮件预览/发送 | `scripts/email_outreach/prepare_outreach_emails.py`、`scripts/email_outreach/send_outreach_emails.py` | 按总表筛选、预览、确认后发送并更新总表 |
+| Facebook 建联 | `scripts/social/prepare_facebook_outreach.py`、`scripts/social/run_facebook_follow_dm.py`、`scripts/social/send_facebook_outreach.py` | 从总表读取 Facebook 链接,确认后更新总表状态 |
+| Facebook 回复同步 | `scripts/social/collect_facebook_conversations.py`、`scripts/social/write_facebook_conversations.py` | 对话进 `Facebook对话记录`,客户状态/摘要回写总表 |
+| 客户中台看板 | `scripts/dashboard/build_dashboard.py` | 读取总表生成 HTML/JSON,不写 Excel |
 
-- 如果配置存在且 `enabled=true`,必须调用 WorkBuddy 的 `lark-sheets` 插件同步飞书电子表格,不再询问是否同步。
-- 默认同步 `客户信息汇总表`;渠道 Sheet 只有在用户明确要求整本工作簿同步,或配置 `sync_scope=all_configured_sheets` 时同步。
-- 如果配置缺失、插件不可用或未授权,必须在最终结果中说明飞书同步未执行/失败原因,但不能回滚本地 Excel 写入。
-- 飞书表格只作为在线镜像;本地建联表仍是主数据源。禁止把真实飞书链接、token、cookie、账号密码或授权信息写进 skill 包。
-- 示例配置见 `assets/feishu_sync_config.example.json`;真实配置应放在项目根目录 `feishu_sync_config.json`。
+旧命令里的 `--sheet Facebook`、`--sheet LinkedIn`、`--sheet Google Maps` 仍可兼容,但只作为来源标签;公共写表层会路由到 `客户信息汇总表`。
 
-## 工作簿优先级
+## Facebook 建联规则
 
-写表脚本必须按同一顺序解析建联表:
+Facebook 对外话术只用英文,中文只用于内部判断和预览。必须先在聊天框展示整批预览,用户确认一次后才允许 `--confirm --batch-confirmed` 批量执行,不再逐条确认。真实执行默认每轮 3 个客户,每日 Follow/DM 以账号账本控制。大层级操作等待 90-200 秒,小层级页面操作等待 30-90 秒,技术等待 0.5-8 秒。遇到验证、限流、异常活动或 Messenger 定位不匹配,立即停止,不关闭浏览器。
 
-1. 用户显式传入 `--excel` 时,优先使用该路径。
-2. 未传 `--excel` 时,先在当前运行目录和上级目录查找项目建联表,例如 `摩洛哥客户建联表-按渠道分类.xlsx` 或带空格版本。
-3. 查找时排除 `~$`、`_backup_`、`backup_before`、`_with_`、`sent_`、`preview`、`candidate`、`filtered` 等临时、备份、预览或过滤文件。
-4. 只有用户明确使用写表模式且项目内找不到建联表时,才从 `assets/blank_customer_outreach_workbook.xlsx` 复制一份到运行目录后再写入。该模板由当前正式表 `摩洛哥客户建联表-按渠道分类.xlsx` 生成,保留 Sheet、表头、列宽和基础格式,不包含真实客户数据。
-5. 预览模式不强制创建建联表。禁止直接写入 skill 内的空白模板文件。
+私信必须定位 Facebook Page 顶部 `Follow/追蹤` 和 `Message/發送訊息` 按钮,再定位匹配客户名称的 Messenger 小窗或 Messenger 页面输入框。禁止把帖子评论框、页面底部输入框或推荐区评论框当作私信目标。
 
 ## 邮件规则
 
-当前默认邮件模板面向 `客户属性=汽车渠道合作伙伴`,正式发送正文使用 `assets/email_template.md`,邮件主题使用 `assets/email_subject.txt`。同一模板也保存在 `assets/email_templates/汽车渠道合作伙伴.md`。`客户属性=平台与行业渠道` 使用 `assets/email_templates/平台与行业渠道.md`,邮件主题使用 `assets/email_subjects/平台与行业渠道.txt`,图片资源使用 `assets/wuling-platform-industry-channel.png`。后续按客户属性增加新模板时,应继续放入 `assets/email_templates/`,并在发送前明确选择对应客户属性模板。
-
-发送前必须生成并展示最终预览。除非用户明确要求修改模板,否则不要改模板正文。邮件模板中的图片文件必须放在 `assets/` 内。当前汽车渠道合作伙伴模板使用 `assets/wuling-channel-margin-comparison.png`,发送脚本会把 Markdown 图片转成 HTML 内嵌图片,并同时保留纯文本备用正文。
-
-平台与行业渠道模板可使用 `{{platform_resource_phrase}}` 变量,根据客户主营业务和备注自动选择 `automotive industry traffic`、`dealer members and industry resources`、`corporate customers and procurement resources`、`dealer and importer resources` 或 `automotive industry resources`,禁止编造具体会员数量、流量规模或已确认项目资源。
-
-邮件发送脚本必须把 Markdown `**加粗**`、列表和 `mailto:` / Website 链接转换成真实 HTML;纯文本备用正文必须去掉 Markdown 符号。邮件图片必须同时设置 `width="300"` 和内联 `style="width:300px;max-width:300px;height:auto"`,避免邮箱客户端把图片放大。
-
-## 浏览器自动化硬规则
-
-凡是需要打开、连接、控制或读取浏览器页面的任务,必须使用 Playwright:
-
-- AdsPower 只负责提供指纹浏览器环境和 CDP websocket;实际页面访问、搜索、点击、滚动、输入、读取 DOM、截图校验和下载均由 Playwright 完成。
-- Facebook、LinkedIn、Google Maps、当地汽车网站、企业官网深搜、Facebook Follow + Messenger 私信都属于浏览器操作,必须走 Playwright 脚本。
-- 官网识别必须通过 Playwright 读取 `a[href]`、链接可见文本、aria、按钮链接和页面正文;不得靠截图圈选、坐标点击或人工猜测链接位置作为默认采集逻辑。
-- 私信发送必须使用 Playwright 定位 Messenger 小窗或 Messenger 页面输入框,并通过 Playwright 键盘事件发送;禁止把帖子评论框、页面底部任意输入框或截图位置当作发送目标。
-- 如果 Playwright 无法定位目标元素,脚本应停止并输出人工处理原因,不允许降级为不可靠的全局输入框或坐标操作。
-
-## Facebook 合规降风险规则
-
-- 默认使用 `--risk-profile very_conservative`,真实发送每轮最多 3 个客户;可用 `--session-max` 进一步降低本轮数量。
-- 每个 AdsPower `profile_id` 每日建联客户默认上限为 20;Follow 和 Messenger DM 均按 20 封顶,可通过 `--daily-follow-limit` 和 `--daily-dm-limit` 调低。单轮默认仍为 3 个客户,建议分多轮完成每日 20 个客户。
-- 执行脚本按 `logs/facebook_account_ledger_<profile_id>_<YYYYMMDD>.json` 记录当天 Follow、DM、失败、风险提示、`last_run_at` 和 `cooldown_until`;超过额度或出现风险事件后,当天停止该账号继续建联。
-- 分层级节流:大层级操作间隔从 90-200 秒随机取值,小层级/页面内操作间隔从 30-90 秒随机取值,技术等待 technical 保持 0.5-8 秒短等待;轮次冷却仍作为额外冷却建议。等待只用于节流和降低误操作风险,不用于规避平台检测。
-- 大层级操作包括:切换到下一个客户、打开新的客户主页/公司页/Google Maps 商家页/官网首页、提交搜索关键词后等待结果、Follow 成功后、DM 发送成功后、从 Facebook 主页跳到官网深搜前。
-- 小层级操作包括:打开 About/联系资料/Services/Contact 等二级页面、页面滚动、展开更多、切换 tab、打开 Messenger 小窗后等待、输入话术后发送前等待、官网 Contact/About 页面之间切换。
-- 技术等待 technical 只用于 Playwright locator、DOM 加载、弹窗出现、输入框可用等元素检测,不作为对外行为节奏,不强制拉长。
-- `--dry-run-schedule` 只输出本轮预计节奏、客户列表和额度,不打开 AdsPower、不点击、不发送、不写表。
-- 同一批预览会尽量避免完全相同的英文私信首句;信息不足客户自动使用低压确认型话术。
-
-## 运行原则
-
-- 默认只输出 JSON/Markdown 预览。
-- 只有用户明确说“写入表”时,才使用写表选项。
-- 只有用户明确提供发件邮箱、SMTP 授权码并确认发送时,才发送邮件。
-- 只有用户完成 Facebook 整批聊天框预览并明确确认后,脚本显式使用 `--confirm --batch-confirmed`,才执行 Facebook 关注/私信;不再逐条确认,私信必须命中 Messenger 小窗或 Messenger 页面,禁止帖子留言。
-- 写表前确认 Excel 没有打开;如果保存失败,先输出结果文件,等用户关闭 Excel 后再写回。
-- 采集和发送结果应保存到项目运行目录或用户指定目录,不要保存到 skill 包目录。
-- 任何本地建联表写入成功后,都必须按 `references/feishu-sync.md` 检查项目根目录 `feishu_sync_config.json`。如果 `enabled=true` 且 WorkBuddy 已安装/授权 `lark-sheets` 插件,agent 必须自动同步飞书电子表格,不需要用户再次强调;预览、dry-run、review-only 和 schedule-only 不触发飞书同步。
-
-## Facebook 采集与官网深搜规则
-
-Facebook 客户采集在 `--deep-scrape` 阶段必须执行“Facebook 主页联络资料 -> About -> 公司官网 -> 官网公开页面”的补充流程:
-
-- `主页/链接` 继续保存 Facebook 主页,用于去重、私信和状态追踪。
-- 打开 Facebook 主页后,必须先读取主页顶部/侧栏/联络资料区域,提取官网、电话、邮箱、WhatsApp、地址、简介和主页按钮链接;随后再进入 About。主页已确认的官网、电话、邮箱不被 About 或官网深搜覆盖,只能补空字段或追加证据。不要把 Instagram、WhatsApp、YouTube、Google Maps 或其他平台链接当作公司官网。
-- 发现 `公司官网` 后,进入官网首页、Contact、About、Nous contacter、A propos、Services、Vehicules、Occasion、Location 等公开页面,提取公开邮箱、电话和业务证据。
-- `主营业务` 必须综合 Facebook 简介、About、近期帖子和官网正文判断;没有证据时写“汽车渠道线索,需人工确认”,不编造业务、价格、MOQ、库存或合作条件。
-- `备注` 使用中文结构化摘要,格式围绕:来源线索、Facebook证据、官网证据、主营业务判断、批量采购能力判断、联系方式证据、风险/待确认项。专业词如 showroom、importation、fleet、location、vehicules neufs 可保留原文。
-
-
-
-
-
-
-
-
-## Run Artifact Management
+邮件模板与社交话术分开。正式邮件固定使用 `Chris Chen`,社交私信才根据当前 Facebook/LinkedIn 账号询问 `sender_name`。发送邮件前必须生成 HTML 预览;模板图片应按邮件端尺寸控制,不能把 Markdown `**` 原样发给客户。SMTP 授权码只在本次发送中使用,不落盘。
 
-- Store previews, scraper outputs, retry manifests, send logs, and reports under `runs/YYYYMMDD/<run_id>/` by default; do not scatter runtime files in the project root.
-- Create Excel backups only before a write-enabled operation changes the official workbook. Store backups under `backups/YYYYMMDD/` and reuse one backup for the same run/purpose.
-- Keep HTML previews lightweight by referencing image files by path; only embed/base64 assets when the user explicitly asks for standalone offline HTML.
-- Store durable ledgers such as Facebook daily account limits under `logs/`. Do not write SMTP authorization codes to any artifact.
-- Clear `.tmp` after tasks. Run cleanup in dry-run mode first and keep the latest 10 workbook backups plus 30 days of JSON/HTML/log artifacts by default.
+## Facebook 对话同步
 
+读取 Messenger 对话时只采集能可靠匹配到总表客户的线程。保存双方原文,翻译成中文,只分析客户回复。合作意向采用五级:`明确有意向`、`潜在意向`、`需澄清`、`暂不考虑`、`明确拒绝`。先展示同步摘要和高意向客户,再由用户确认是否写入。不得自动回复客户。
 
+## 运行产物和模板
 
-## Sender Identity And Name Replacement
+预览、搜索结果、发送日志和报告默认进入 `runs/YYYYMMDD/<run_id>/`;Excel 备份进入 `backups/YYYYMMDD/`;Facebook 额度账本进入 `logs/`。预览和 dry-run 不备份 Excel。空白模板仅在全新环境找不到建联表且用户明确写表时复制出来,禁止直接写入 skill 内模板。
 
-- Email and social outreach identities are separate.
-- Official email templates and email sending use the fixed sender name `Chris Chen`. Do not ask the user for a per-account sender name when preparing or sending email.
-- Facebook and LinkedIn social outreach use the runtime variable `{{sender_name}}` only when the message needs the current social account/persona name. Different AdsPower/Facebook/LinkedIn accounts may use different sender names.
-- If the user changes social account/profile_id, ask for the sender role/account/persona name again unless it was explicitly provided in the same run request.
-- Template updates should be made only inside the project workspace and the official skill directories. Do not modify WeChat cache copies or external received-file directories unless the user explicitly asks for that exact file to be edited.
-- Customer name is `{{customer_name}}`. It must be filled from the workbook contact/customer/company name. If no contact person exists, use the customer/company name.
-- Final previews and sent messages must never contain `[Name]`, `[name]`, `{{customer_name}}`, or `{{sender_name}}`.
-- Email templates must not contain `{{sender_name}}`; legacy email templates containing it should be updated to `Chris Chen` before use.

BIN
assets/blank_customer_outreach_workbook.xlsx


+ 12 - 12
assets/dashboard_template.html

@@ -158,15 +158,15 @@
       <div class="card panel"><div class="panel-head"><h2>城市分布 Top</h2><span class="hint">区域资源密度</span></div><div class="bar-list" id="cityBars"></div></div>
     </section>
     <section class="grid charts">
-      <div class="card panel"><div class="panel-head"><h2>Facebook 合作意向</h2><span class="hint">按最新有效回复的唯一客户统计</span></div><div class="bar-list" id="intentBars"></div></div>
-      <div class="card panel"><div class="panel-head"><h2>最近 30 天有效回复</h2><span class="hint">按客户回复日期</span></div><div class="columns" id="replyTrendColumns"></div></div>
+      <div class="card panel"><div class="panel-head"><h2>Facebook &#22238;&#22797;&#32479;&#35745;</h2><span class="hint">&#32479;&#19968;&#25353;&#26159;&#21542;&#26377;&#23458;&#25143;&#30495;&#23454;&#22238;&#22797;&#32479;&#35745;</span></div><div class="bar-list" id="replyStatusBars"></div></div>
+      <div class="card panel"><div class="panel-head"><h2>&#26368;&#36817; 30 &#22825; Facebook &#22238;&#22797;</h2><span class="hint">&#25353;&#23458;&#25143;&#22238;&#22797;&#26085;&#26399;</span></div><div class="columns" id="replyTrendColumns"></div></div>
     </section>
     <section class="card panel wide" style="margin-bottom:14px">
-      <div class="panel-head"><h2>高意向客户</h2><span class="hint" id="highIntentCount"></span></div>
+      <div class="panel-head"><h2>Facebook &#22238;&#22797;&#23458;&#25143;</h2><span class="hint" id="replyCustomerCount"></span></div>
       <div class="table-wrap">
         <table>
-          <thead><tr><th>客户</th><th>合作意向</th><th>最新中文回复</th><th>判断依据</th><th>下一步建议</th><th>回复时间</th></tr></thead>
-          <tbody id="highIntentRows"></tbody>
+          <thead><tr><th>&#23458;&#25143;</th><th>&#29366;&#24577;</th><th>&#26368;&#26032;&#20013;&#25991;&#22238;&#22797;</th><th>&#32479;&#35745;&#21475;&#24452;</th><th>&#19979;&#19968;&#27493;&#24314;&#35758;</th><th>&#22238;&#22797;&#26102;&#38388;</th></tr></thead>
+          <tbody id="replyCustomerRows"></tbody>
         </table>
       </div>
     </section>
@@ -212,8 +212,8 @@
         ['邮箱覆盖率', pct(metrics.email_coverage_rate), `有邮箱 ${metrics.email_customers}`, metrics.email_coverage_rate, '#1d5fd1'],
         ['退信率', pct(metrics.bounce_rate), `退信/拒收 ${metrics.bounced_customers}`, metrics.bounce_rate, '#c9443d'],
         ['待人工复核', metrics.needs_review_customers, `${pct(metrics.review_rate)} 的有效客户`, metrics.review_rate, '#b7791f'],
-        ['Facebook 回复率', pct(metrics.facebook_reply_rate), `有效回复 ${metrics.facebook_reply_customers} / 已私信 ${metrics.facebook_dm_customers}`, metrics.facebook_reply_rate, '#13899b'],
-        ['Facebook 高意向', Number(metrics.facebook_clear_intent_customers || 0) + Number(metrics.facebook_potential_intent_customers || 0), `明确 ${metrics.facebook_clear_intent_customers || 0},潜在 ${metrics.facebook_potential_intent_customers || 0}`, metrics.facebook_reply_customers ? (Number(metrics.facebook_clear_intent_customers || 0) + Number(metrics.facebook_potential_intent_customers || 0)) * 100 / metrics.facebook_reply_customers : 0, '#16885a']
+        ['Facebook \u56de\u590d\u7387', pct(metrics.facebook_reply_rate), `\u5df2\u56de\u590d ${metrics.facebook_reply_customers} / \u5df2\u79c1\u4fe1 ${metrics.facebook_dm_customers}`, metrics.facebook_reply_rate, '#13899b'],
+        ['\u5f85\u67e5\u770b\u56de\u590d', metrics.facebook_pending_review_customers || 0, '\u5ba2\u6237\u5df2\u56de\u590d\uff0c\u9700\u4eba\u5de5\u67e5\u770b', metrics.facebook_reply_customers ? (Number(metrics.facebook_pending_review_customers || 0) * 100 / metrics.facebook_reply_customers) : 0, '#b7791f']
       ];
       document.getElementById('kpis').innerHTML = cards.map(([label, value, note, width, tone]) => `<article class="card kpi"><div class="label">${safe(label)}</div><div class="value">${safe(value)}</div><div class="note">${safe(note)}</div><div class="meter"><span style="--w:${clampPct(width)}%;--tone:${tone}"></span></div></article>`).join('');
     }
@@ -270,15 +270,15 @@
         return `<tr><td>${safe(row.row)}</td><td><strong>${safe(row.company)}</strong><div class="muted">${safe(row.city || '')}</div></td><td><span class="badge">${safe(row.attribute || '未填写')}</span></td><td>${safe(row.type || '未填写')}</td><td>${safe(row.source || '未标明')}</td><td>${safe(row.contact || '无')}</td><td><span class="badge ${statusClass}">${safe(row.status || '未填写')}</span></td><td class="note">${safe(row.note || '')}</td></tr>`;
       }).join('') || '<tr><td colspan="8" class="muted">没有匹配客户</td></tr>';
     }
-    function renderHighIntent() {
-      const items = data.facebook_high_intent_customers || [];
-      document.getElementById('highIntentCount').textContent = `共 ${items.length} 个`;
-      document.getElementById('highIntentRows').innerHTML = items.map(item => `<tr><td><strong>${safe(item.company)}</strong></td><td><span class="badge green">${safe(item.intent)}</span></td><td class="note">${safe(item.latest_reply)}</td><td class="note">${safe(item.reason)}</td><td class="note">${safe(item.next_action)}</td><td>${safe(item.message_time)}</td></tr>`).join('') || '<tr><td colspan="6" class="muted">暂无明确或潜在意向客户</td></tr>';
+    function renderFacebookReplies() {
+      const items = data.facebook_reply_customers_detail || [];
+      document.getElementById('replyCustomerCount').textContent = `? ${items.length} ?`;
+      document.getElementById('replyCustomerRows').innerHTML = items.map(item => `<tr><td><strong>${safe(item.company)}</strong></td><td><span class="badge green">\u5df2\u56de\u590d</span></td><td class="note">${safe(item.latest_reply)}</td><td class="note">\u7edf\u4e00\u8ba1\u4e3a Facebook \u56de\u590d</td><td class="note">${safe(item.next_action)}</td><td>${safe(item.message_time)}</td></tr>`).join('') || '<tr><td colspan="6" class="muted">\u6682\u65e0 Facebook \u56de\u590d\u5ba2\u6237</td></tr>';
     }
     function renderDefinitions() {
       document.getElementById('definitions').innerHTML = Object.entries(data.definitions || {}).map(([key, value]) => `<div class="def"><strong>${safe(key)}</strong><span>${safe(value)}</span></div>`).join('');
     }
-    renderKpis(); renderFunnel(); renderBars('attributeBars', charts.attribute || [], '#16885a'); renderBars('typeBars', charts.customer_type || [], '#1d5fd1'); renderBars('cityBars', charts.city || [], '#13899b'); renderBars('intentBars', charts.facebook_intent || [], '#16885a'); renderColumns('sourceColumns', charts.source || []); renderColumns('replyTrendColumns', charts.facebook_reply_trend || []); renderDonut(); renderHighIntent(); renderDefinitions(); initFilters(); renderRows();
+    renderKpis(); renderFunnel(); renderBars('attributeBars', charts.attribute || [], '#16885a'); renderBars('typeBars', charts.customer_type || [], '#1d5fd1'); renderBars('cityBars', charts.city || [], '#13899b'); renderBars('replyStatusBars', charts.facebook_reply_status || [], '#16885a'); renderColumns('sourceColumns', charts.source || []); renderColumns('replyTrendColumns', charts.facebook_reply_trend || []); renderDonut(); renderFacebookReplies(); renderDefinitions(); initFilters(); renderRows();
   </script>
 </body>
 </html>

+ 405 - 138
assets/social_outreach_library.md

@@ -1,227 +1,494 @@
-# Social Outreach Library
+# Skill 话术模板说明
 
-This library is the canonical prompt and template source for Facebook and LinkedIn social outreach in target-market Wuling dealer expansion.
+本文整理当前 `wuling-overseas-dealer-expansion` skill 中的社交建联话术逻辑,供后续优化、评审和迁移使用。
 
-## Business Context
+话术应优先服务两类核心客户:
 
-- Sender company: Huatu Overseas.
-- Business: Wuling overseas export and batch vehicle sales support.
-- Target market: use the workbook country or the user-selected market. Morocco is only the current project default.
-- Target customers: independent auto dealers, used-car dealers, multi-brand showrooms, importers, rental/fleet companies, commercial-vehicle channels, and formal local auto websites.
-- First-touch goal: get a reply and qualify whether the customer can evaluate, purchase, digest, or distribute a first batch of Wuling vehicles.
-- The first message is not a generic relationship opener and must not push authorization-right topics. It should create curiosity around a practical business opportunity.
+1. 汽车渠道合作伙伴
+2. 平台与行业渠道
 
-## Hard Rules
+其中,“汽车渠道合作伙伴”包括全国多品牌汽车分销集团、汽车进口商、全国或区域分销商以及多品牌经销商。二手车企业、租赁/车队公司和信息不足客户属于补充场景,不应成为示例和说明的主轴。
 
-- Customer-facing language: English.
-- Chinese is only for internal judgment and preview review.
-- Do not use French as the default Facebook message language.
-- Do not mention brand territory rights, authorization rights, regional rights, sole-right arrangements, exclusive/non-exclusive agency, or similar channel-right topics.
-- Do not invent specific prices, MOQ, model lists, inventory quantity, delivery time, margin, confirmed terms, or existing partnerships.
-- Do not ask for a meeting in the first touch.
-- Ask only one light question in the first DM.
-- Use only 1-2 selling points per message. Do not stack every advantage.
-- Every sendable message should cite at least one real customer signal. If the signal is weak, downgrade to a low-pressure role-confirmation message.
-- If the lead looks like an official brand-country page, mark it as `建议跳过` and do not generate sendable copy.
+## 1. 业务背景
 
-Official brand-country examples to skip:
+我们对外统一使用“五菱汽车海外事业部 / Wuling Overseas Business Department”身份,负责海外合作伙伴开发、产品导入、出口业务协调及长期本地市场合作。
 
-- `BYD Morocco`, `BYD Maroc`
-- `BMW Morocco`, `BMW Maroc`
-- `JAC Morocco`, `JAC Motors Maroc`
-- `Changan Morocco`, `Changan Maroc`
-- `FOTON Morocco`, `FOTON Maroc`
-- Similar OEM local branches or official country pages
+当前重点市场是摩洛哥,后续可以扩展到其他目标国家。
 
+首轮社交建联的目标不是泛泛建立联系,也不是立即推销代理权,而是:
 
-### Sender and Customer Name Variables
+- 判断客户是否愿意评估五菱在当地市场的车型和商业机会;
+- 识别客户是否具备进口认证、渠道分销、订单组织、库存消化、售后服务或渠道转化能力;
+- 获取一次进一步发送资料、邮件沟通或进入合作评估的机会。
 
-- `{{sender_name}}` is social-only: the current Facebook or LinkedIn sender role/account/persona name. Do not use it for email templates.
-- Before generating or sending Facebook/LinkedIn private messages, ask the user for the current role/account/persona name if it was not provided in this run.
-- Email templates are separate from social copy and use the fixed sender name `Chris Chen`.
-- `{{customer_name}}` is the customer contact/company name. It must be replaced from the workbook customer name/contact field before preview or sending.
-- Final customer-facing copy must never contain `[Name]`, `[name]`, `{{customer_name}}`, or `{{sender_name}}`.
+两类核心客户的合作逻辑不同:
 
-## Attractiveness Framework
+- 汽车渠道合作伙伴:重点评估车型、价格竞争力、产品线补充、进口分销、持续采购和售后能力。
+- 平台与行业渠道:重点评估会员车商激活、采购需求组织、B2B线索、订单集采、合作伙伴引荐和项目组织能力。
 
-Use a signal-driven structure instead of a fixed template:
+优先建联对象:
+
+- 全国多品牌汽车分销集团:具有进口、代理、分销、多城市网络、下游经销商或集团化运营信号。
+- 汽车进口商、全国分销商和区域分销商:具有进口认证、渠道覆盖或持续采购能力。
+- 多品牌经销商:具有 showroom、新车销售、多品牌经营、正式展厅、销售团队、官网或公开联系方式。
+- 平台与行业渠道:汽车交易平台、协会、商会、车商联盟或汽车行业资源引荐机构。
+
+次级对象:
+
+- 二手车转型候选:连锁二手车企业、实体二手车企业、进口二手车企业、新车与二手车综合企业。
+- 批量采购与运营客户:汽车租赁公司、企业车队和车辆运营企业。
+
+## 2. 硬性规则
+
+### 2.1 对外身份
+
+- 对外统一使用 `Wuling Overseas Business Department`。
+- 不在首轮话术中将华途作为主要对外身份。
+- 中文用于内部判断和复核,不直接发送给客户。
+
+### 2.2 语言规则
+
+- 英文是默认对外语言。
+- 如果客户官网、社交账号或主要沟通语言明显为法语,摩洛哥客户可以优先使用法语。
+- 不混合多种语言发送同一条首轮私信。
+
+### 2.3 代理与授权规则
+
+- 首轮社交建联不承诺独家代理、国家代理、区域授权或已经确认的合作资格。
+- 首轮不使用 `exclusive agency`、`authorized dealer`、`official distributor` 等可能被理解为已经授权的表达。
+- 以上规则仅适用于首轮建联,不代表永久不授予代理资格。
+- 客户回复并提交能力资料后,可以进入进口、渠道、采购、售后及市场覆盖能力评估。
+- 实力足够且通过内部审核的企业,可以进一步进入国家级代理或独家代理合作评估。
+
+### 2.4 商业信息规则
+
+- 不编造价格、MOQ、车型清单、库存数量、交期、利润率或已确认合作条件。
+- 首轮社交私信不主动披露具体A级价格、销售底价、利润数字或保密报价。
+- 正式邮件或后续资料可以使用经过批准的车型、价格、参考毛利和图片材料。
+- 所有利润或价差必须表述为参考机会,不得写成保证收益。
+
+### 2.5 首轮沟通规则
+
+- 不以 `We want to cooperate` 作为开头。
+- 不一上来要求签约或直接安排会议。
+- 不连续提出多个问题。
+- 首轮只问一个轻量问题,目标是获得回复或允许发送简短资料。
+- 话术必须使用至少一个真实客户信号,不写无法证明的赞美或能力。
+
+### 2.6 官方品牌页规则
+
+- 如果客户只是 BYD Maroc、BMW Maroc、JAC Morocco、Changan Maroc、FOTON Maroc 等品牌官方当地主页或直营网点,原则上建议跳过。
+- 如果页面背后是独立运营的汽车集团、进口商或多品牌分销商,则应评估其独立经营主体和产品线补充可能,不应仅因代理其他品牌而跳过。
+
+## 3. 吸引力框架
+
+话术不应机械套用固定模板,而应使用信号驱动结构:
+
+`客户具体实力信号 -> 对应业务空档或商业假设 -> 五菱可提供的轻量机会 -> 一个问题`
+
+可用客户信号包括:
+
+- national distribution network
+- multi-brand distribution
+- importer / distributor
+- dealer network
+- showroom
+- new vehicle sales
+- commercial vehicle channel
+- corporate customer resources
+- fleet / rental / leasing
+- official website
+- multiple cities / branches
+- after-sales network
+- spare-parts capability
+- automotive marketplace / association
+- dealer members
+- WhatsApp / business email / manager contact
+
+优先商业假设:
+
+- 全国多品牌分销集团:可能具备新产品线评估、进口分销、区域覆盖、批量消化及售后服务能力。
+- 汽车进口商或区域分销商:可能具备认证、进口、渠道供货和持续采购能力。
+- 多品牌经销商:可能需要补充高性价比新车产品,覆盖价格敏感客户并提高现有展厅利用率。
+- 平台与行业渠道:可能需要新的车型和供应资源,以激活会员、组织分散采购需求并形成B2B项目。
+
+轻量价值物应根据客户属性选择:
+
+### 汽车渠道合作伙伴
+
+- a short model and price-range overview
+- a product-line fit overview
+- a first-batch market validation outline
+- an export, spare-parts, and after-sales support outline
+
+### 平台与行业渠道
+
+- a short B2B vehicle opportunity overview
+- a dealer and importer partner profile
+- a consolidated purchasing project outline
+- a channel-introduction and member-activation overview
+
+## 4. 客户类型与优先级
+
+| 优先级 | 核心类别 | 客户子类型 | 推荐切入点 |
+| --- | --- | --- | --- |
+| P0 | 汽车渠道合作伙伴 | 全国多品牌汽车分销集团 | national coverage、multi-brand operation、import/distribution capability、dealer network、after-sales system |
+| P0 | 汽车渠道合作伙伴 | 汽车进口商/全国或区域分销商 | import and certification、channel coverage、regional distribution、continuous purchasing |
+| P0 | 汽车渠道合作伙伴 | 多品牌经销商 | complement current brands、affordable vehicle line、showroom utilization、small-batch validation |
+| P0 | 平台与行业渠道 | 汽车平台、协会、商会、车商联盟 | dealer resources、member activation、B2B leads、consolidated orders、qualified partner introductions |
+| P2 | 补充场景 | 二手车转型候选 | used-car replacement、affordable new vehicles、trade-in capability |
+| P2 | 补充场景 | 租赁/车队公司 | fleet renewal、operating cost、batch procurement |
+
+## 5. 输出结构
+
+每个客户建议输出以下内容:
+
+### 5.1 客户判断
+
+用中文说明:
+
+- 客户属于哪一类;
+- 为什么值得或不值得建联;
+- 是否具有批量采购、进口分销、售后服务、订单组织或渠道转化潜力。
+
+### 5.2 使用的客户信号
+
+列出支撑判断的真实信息,例如:
+
+- 覆盖城市数量;
+- 代理或经营品牌;
+- 展厅、分支机构或经销商网络;
+- 进口、车队、金融、保险、售后或备件能力;
+- 平台会员、行业资源或企业客户。
+
+不得使用无法从公开资料确认的信号。
+
+### 5.3 本轮合作角色
+
+从以下角色中选择一个最符合客户实际能力的角色:
+
+- 国家级渠道候选;
+- 汽车进口与分销合作伙伴;
+- 区域经销与销售合作伙伴;
+- 多品牌展厅合作伙伴;
+- 平台流量与会员转化合作伙伴;
+- 经销商引荐与订单集采合作伙伴;
+- 补充场景客户。
+
+### 5.4 推荐切入点
+
+用中文说明:
+
+- 客户当前可能存在的产品、渠道或项目空档;
+- 五菱为何适合补充该空档;
+- 首轮应使用哪一个商业抓点。
+
+每次只选择一个主切入点,不将所有优势同时塞入首轮话术。
+
+### 5.5 加好友话术
+
+- 默认输出英文;客户主要使用法语时输出法语。
+- 英文控制在约 180-260 个字符。
+- 简短、自然、有真实商业抓点,不写成群发广告。
+- 结尾只问一个轻量问题。
+
+### 5.6 首轮私信
+
+- 用于 WhatsApp 或 Facebook 接受好友后的首次正式沟通。
+- 建议控制在 55-85 个英文单词、3-4 个短段落以内。
+- 第一段说明真实身份:`Chris Chen + Wuling Overseas Business Department`。
+- 第二段引用客户的具体实力信号,说明为什么主动联系该客户。
+- 第三段用一条简短的五菱实力数据建立可信度,并说明与该客户属性匹配的商业机会。
+- 最后一段只问一个轻量问题,例如是否愿意接收一页车型与合作机会资料,或确认谁负责相关业务。
+- 不应为了套模板同时堆砌全部品牌数据、产品优势和合作方式;每条私信只保留一个主要吸引点。
+
+### 5.7 风险提示
+
+说明:
+
+- 是否建议跳过;
+- 是否疑似品牌官方页或普通分店;
+- 信息是否不足;
+- 是否需要人工确认独立经营主体;
+- 是否涉及未经批准的价格、利润或代理承诺。
+
+## 6. 汽车渠道合作伙伴:全国多品牌汽车分销集团
+
+适用信号:
+
+- group、distribution、importer、dealer network、multi-brand、national coverage、multiple cities、regional branches、corporate sales、fleet solutions、after-sales network。
+
+中文判断示例:
+
+该客户属于全国多品牌汽车分销集团,具备多品牌运营、进口分销、渠道覆盖和售后服务能力。相比普通单店经销商,这类客户更适合评估五菱的产品线补充、批量导入和持续采购潜力,并可在提交能力资料后进入国家级合作资格评估。
+
+推荐切入点:
+
+不应一开始要求对方成为五菱代理商,而应说明五菱是否能够补充其现有品牌组合中的大众价格带,并从一个车型、一个城市或一个小批量项目开始验证。
+
+英文加好友话术:
 
 ```text
-specific signal -> commercial hypothesis -> light offer -> one question
+Hi, your multi-brand distribution network in Morocco stood out. Wuling is evaluating strong automotive groups for practical, competitively positioned vehicle opportunities. Open to connect?
 ```
 
-- `specific signal`: a real clue from the page or workbook, such as `showroom`, `used-car stock`, `rental`, `fleet`, `importation`, `multi-city`, website, WhatsApp, recent posts, or public contact data.
-- `commercial hypothesis`: why this customer may care, such as total-cost pressure, inventory gap, fleet renewal cost, or regional distribution potential.
-- `light offer`: a low-commitment value item, such as `a short model and price-range overview`, `a small first-batch fit check`, or `an export and spare-parts support outline`.
-- `one question`: a single easy reply path. Do not combine meeting requests, qualification, and contact handoff in one message.
+英文首轮私信 A:commercial_value_hook
+
+```text
+Hi [Name], I’m Chris Chen from Wuling Overseas Business Department.
+
+Your multi-brand distribution network and market coverage caught my attention as we look for strong automotive partners in Morocco.
+
+Wuling has sold over 30 million vehicles and operates in 60+ countries. Our competitively priced practical models could complement your current brands and offer clear dealer margin potential without requiring a large initial stock.
+
+May I send you a one-page model, partner-pricing, and margin overview?
+```
 
-Avoid flat openings such as:
+英文首轮私信 B:portfolio_gap_hook
 
 ```text
-We are Huatu Overseas and want to cooperate.
-I noticed your auto business. Open to connect?
+Hi [Name], I’m Chris Chen from Wuling Overseas Business Department.
+
+Your established brand portfolio and distribution structure stood out as we reviewed leading automotive groups in Morocco.
+
+Wuling has sold over 30 million vehicles and operates in 60+ countries. Our practical vehicles could complement your existing brands in a more accessible price segment, helping you reach additional family, business, and fleet customers.
+
+Would you be open to reviewing a one-page portfolio-fit overview?
 ```
 
-Prefer customer-centered openings such as:
+英文首轮私信 C:soft_research_hook
 
 ```text
-Your used-car audience may be a good fit for buyers who want a new vehicle but still care most about total cost.
-If some buyers want newer vehicles but still decide mainly on total cost, there may be a gap between used stock and higher-priced brands.
-For a fleet business, purchase cost and maintenance directly affect profitability.
+Hi [Name], I’m Chris Chen from Wuling Overseas Business Department.
+
+Your import, distribution, and after-sales network appears relevant as we assess strong automotive partners in Morocco.
+
+Wuling has sold over 30 million vehicles and operates in 60+ countries. We are evaluating where our competitively positioned passenger and commercial vehicles could fit within established local channels.
+
+Is new brand or product-line evaluation handled by your team?
 ```
 
-## Output Variants
+## 7. 汽车渠道合作伙伴:进口商与多品牌经销商
 
-Each customer preview should include one recommended version and three selectable English variants:
+适用信号:
 
-| Variant | Best for | Style |
-|---|---|---|
-| `direct_profit_hook` | Facebook pages, used-car dealers, rental/fleet, commercial channels | More direct profit and cost angle. |
-| `stock_gap_hook` | Multi-brand dealers, showrooms, dealers with visible stock | Inventory gap and complementary low-cost line. |
-| `soft_research_hook` | LinkedIn, senior contacts, weak-signal customers | Lower-pressure industry relevance and fit check. |
+- importer、regional distributor、showroom、multi-brand dealer、new vehicle sales、concessionnaire、vehicles neufs、sales team、stock、official website、WhatsApp。
 
-The JSON preview must keep backward-compatible fields:
+中文判断示例:
 
-- `english_connect`: recommended connection/follow note.
-- `english_first_dm`: recommended first DM.
-- `recommended_message`: same as the recommended first DM.
-- `recommended_variant`: selected style key.
-- `alternatives` / `message_variants`: all three style versions.
-- `observed_signal`, `signal_quality`, `business_hypothesis`, `profit_angle`, `light_offer`, `reply_question`.
+该客户属于汽车进口商、区域分销商或多品牌经销商,具备车辆采购、销售或本地客户触达能力。切入点应放在补充现有产品线、覆盖大众价格带、提高展厅和销售网络利用率,以及通过市场验证降低新品牌库存风险。
 
-## Required Output Format
+英文加好友话术:
 
-Every generated preview must use this structure:
+```text
+Hi, your showroom and multi-brand vehicle activity in Morocco stood out. Wuling is exploring practical, competitively positioned vehicle opportunities with capable local dealers. Open to connect?
+```
+
+英文首轮私信 A:commercial_value_hook
 
 ```text
-客户判断:
-中文说明客户属于哪类客户,引用了什么真实信号,为什么值得或不值得建联,是否有批量采购/分销潜力。
+Hi [Name], I’m Chris Chen from Wuling Overseas Business Department.
 
-推荐切入点:
-中文说明应该用什么抓点切入,例如二手车库存补充、低成本新车、小批量测试、车队更新成本、商用车需求等,并说明推荐哪一个话术版本。
+Your showroom and multi-brand sales activity caught my attention as we look for capable vehicle dealers in Morocco.
 
-英文加好友话术:
-Short, natural, attractive, and not like a mass ad.
+Wuling has sold over 30 million vehicles and operates in 60+ countries. Our competitively priced practical models could complement your current stock, reach more family and business customers, and offer a clear dealer margin opportunity.
 
-英文首轮私信:
-No more than 2 short paragraphs. First paragraph explains the business opportunity and fit. Second paragraph asks one light question.
+May I send your sales team a one-page model, partner-pricing, and margin overview?
+```
+
+英文首轮私信 B:portfolio_gap_hook
+
+```text
+Hi [Name], I’m Chris Chen from Wuling Overseas Business Department.
 
-备选话术:
-输出 direct_profit_hook、stock_gap_hook、soft_research_hook 三种英文版本,方便人工挑选。
+Your multi-brand business appears well positioned to serve customers between used vehicles and higher-priced new models.
 
-风险提示:
-中文说明是否建议跳过、是否疑似官方品牌页、是否信息不足、是否需要人工确认。
+Wuling has sold over 30 million vehicles and operates in 60+ countries. Our practical models could fill this price gap with accessible new vehicles for family, commuting, and business use, without replacing your existing brands.
+
+Would your sales team be open to a one-page portfolio-fit overview?
 ```
 
-## Selling Points
+英文首轮私信 C:market_validation_hook
 
-Select only 1-2 per first touch:
+```text
+Hi [Name], I’m Chris Chen from Wuling Overseas Business Department.
 
-- `low-cost new vehicle line`
-- `small first-batch test`
-- `low stock pressure`
-- `price-sensitive local customers`
-- `complement current stock or brands`
-- `model and price-range overview`
-- `export support`
-- `spare-parts support`
-- `possible volume potential`
+Your showroom and local sales activity stood out as we identify Moroccan dealers capable of testing demand for practical new vehicles.
 
-Preferred first-touch direction: profit opportunity + small first-batch test. Keep Wuling out of the first sentence when a stronger customer-centered hook is available; introduce Wuling after the business hypothesis.
+Wuling has sold over 30 million vehicles and operates in 60+ countries. Cooperation could begin with a controlled market test and small initial order, reducing inventory pressure before any larger rollout.
 
-## Scenario Selection
+Would your team be open to reviewing this market-validation approach?
+```
 
-| Customer signal | Scenario | Core angle |
-|---|---|---|
-| used cars, occasion, second-hand stock | `used_car_dealer` | Low-cost new vehicles can complement used-car inventory for price-sensitive buyers. |
-| multi-brand, showroom, concessionnaire, dealer | `multibrand_dealer` | Add a low-cost complementary line and test volume potential. |
-| commercial vehicles, utilitaire, fleet, truck, delivery | `commercial_vehicle_channel` | Fit SMEs, delivery, practical vehicles, and family/business mixed use. |
-| local showroom, small city dealer, local auto sales | `local_showroom` | Test affordable new vehicles with low stock pressure. |
-| importer, distributor, group, network | `importer_group` | Evaluate first batch and possible later volume potential. |
-| rental, location, fleet | `rental_fleet` | Lower fleet renewal cost and test practical usage fit. |
-| auto-related but unclear | `unknown_auto_channel` | Confirm whether they handle sourcing, sales, import, or distribution. |
-| official brand branch or brand-country page | `reject_oem_branch` | Suggest skip and generate no sending text. |
+## 8. 平台与行业渠道
 
-## Variant Examples
+适用信号:
 
-### used_car_dealer
+- automotive platform、marketplace、association、chamber、dealer network、industry channel、dealer members、resource introduction、B2B leads。
 
-`direct_profit_hook`:
+中文判断示例:
 
-```text
-Your used-car audience may be a good fit for buyers who want a new vehicle but still care most about total cost. Huatu Overseas supports Wuling export and can start with a small first-batch fit check.
+该客户属于平台与行业渠道,本身不一定直接进口或持有车辆库存,但可能拥有车商会员、企业客户、行业流量或项目组织能力。其合作价值在于激活会员车商、汇总分散采购需求、形成B2B线索、订单集采及合格合作伙伴引荐。
+
+推荐切入点:
+
+不应要求平台直接采购车辆,而应说明五菱的车型、价格竞争力和广泛使用场景,如何帮助其组织新的车商合作项目,并将平台流量或会员资源转化为真实采购机会。
+
+英文加好友话术:
 
-Should I send a short model and price-range overview?
+```text
+Hi, your automotive platform and dealer network in Morocco stood out. Wuling is exploring B2B vehicle projects that could create new opportunities for dealer members. Open to connect?
 ```
 
-`stock_gap_hook`:
+英文首轮私信 A:member_activation_hook
 
 ```text
-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.
+Hi [Name], I’m Chris Chen from Wuling Overseas Business Department.
+
+Your access to automotive dealers and industry businesses caught my attention as we review strong channels in Morocco.
 
-Would a short model and price-range overview help your team judge fit?
+Wuling has sold over 30 million vehicles and operates in 60+ countries. Our practical, competitively priced models could give your members a new supply opportunity while helping your platform generate qualified B2B interest.
+
+Would a one-page vehicle and member-opportunity overview be relevant?
 ```
 
-`soft_research_hook`:
+英文首轮私信 B:consolidated_order_hook
 
 ```text
-I’m looking at auto channels serving price-sensitive buyers in the target market. Your used-car/showroom activity stood out, so I’m checking whether Wuling is worth a low-pressure first-batch review.
+Hi [Name], I’m Chris Chen from Wuling Overseas Business Department.
+
+Your platform’s dealer network appears well positioned to organize purchasing demand that may be too fragmented at the individual dealer level.
 
-Is this something your team would normally evaluate?
+Wuling has sold over 30 million vehicles and operates in 60+ countries. We see potential to identify qualified importers, collect member interest, and develop consolidated B2B vehicle projects through your network.
+
+Would a one-page consolidated-purchasing outline be useful?
 ```
 
-### rental_fleet
+英文首轮私信 C:partner_introduction_hook
 
 ```text
-For a fleet business, purchase cost and maintenance directly affect profitability. Wuling can be reviewed as a practical low-cost line for fleet renewal or mixed-use demand.
+Hi [Name], I’m Chris Chen from Wuling Overseas Business Department.
+
+Your automotive network caught my attention as we identify qualified importers and dealers for Wuling’s development in Morocco.
 
-Should I send a short fleet-fit and price-range overview?
+Wuling has sold over 30 million vehicles and operates in 60+ countries. We believe your industry reach could support qualified partner introductions and practical B2B vehicle projects.
+
+Is this type of automotive partnership handled by your team?
 ```
 
-### unknown_auto_channel
+## 9. 二手车转型候选
+
+二手车企业不是当前示例重点,只在客户确实以二手车销售、置换、`occasion` 或 `reprise` 为主要业务时使用。
+
+推荐切入点:
+
+不要求客户改变现有二手车业务,而是评估其是否可以利用展厅、客户和旧车置换能力,增加价格更容易接受的五菱新车选择。
+
+英文首轮私信:
 
 ```text
-Your page appears connected to the auto sector, but I’m 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.
+Hi [Name], I’m Chris Chen from Wuling Overseas Business Department.
 
-Does your team handle vehicle purchasing or distribution?
+Your used-vehicle and trade-in activity caught my attention as we reviewed automotive businesses in Morocco.
+
+Wuling has sold over 30 million vehicles and operates in 60+ countries. Our accessible new vehicles could give your customers an upgrade option while allowing your company to retain its existing used-car and trade-in strengths.
+
+Would a one-page new-vehicle and trade-in opportunity overview be useful?
 ```
 
-## Platform Tone
+## 10. 租赁与车队客户
 
-- Facebook Page DM can be more direct: stock, price band, buyer demand, fleet cost, profit opportunity, and low-pressure first batch.
-- LinkedIn connection note should be lighter and less sales-heavy: connect first, then send the business hypothesis after acceptance.
-- For weak-signal customers, do not push Wuling hard. First verify whether the person or page handles vehicle purchasing, sourcing, sales, import, or distribution.
+仅在客户明确具有 rental、leasing、fleet、logistics 或企业车辆运营信号时使用。
 
-## Follow-Up Rules
+推荐切入点:
 
-- Follow up only after 3-5 days.
-- Do not follow up more than twice without a reply.
-- Keep follow-up focused on whether they want a model and price-range overview.
-- Stop immediately if the customer says no or shows no relevance.
+重点关注车辆采购成本、使用场景、运营成本、批量采购及售后支持,不使用经销商利润或代理资格作为首轮吸引点。
 
-Short follow-up:
+英文首轮私信:
 
 ```text
-Hi {{contact_name}}, just a short follow-up. I can send a brief model and price-range overview so you can judge whether a small first-batch Wuling test is worth reviewing. Should I send it here, by email, or WhatsApp?
+Hi [Name], I’m Chris Chen from Wuling Overseas Business Department.
+
+Your [rental / leasing / logistics / fleet] operations stood out as we reviewed Moroccan companies with recurring vehicle requirements.
+
+Wuling has sold over 30 million vehicles and operates in 60+ countries. Our competitively priced passenger and light-commercial vehicles could support fleet renewal, staff mobility, delivery, or service operations across several use cases.
+
+Would a one-page fleet-model and application overview help your team assess fit?
 ```
 
-Ask for the right person:
+## 11. 信息不足客户
+
+如果客户资料不足,不应强推业务,应先确认对方是否负责采购、进口、分销、车型评估或平台合作。
+
+英文首轮私信:
 
 ```text
-Could you please let me know who handles vehicle sourcing, import, or distribution decisions in your company? I would like to send the Wuling first-batch and export-support information to the right person.
+Hi, I’m Chris Chen from Wuling Overseas Business Department.
+
+We found your company while reviewing automotive businesses in Morocco, but the public information does not clearly identify who manages vehicle sourcing, import, distribution, or partnership decisions.
+
+Wuling has sold over 30 million vehicles and operates in 60+ countries. We are evaluating suitable local channels for practical passenger and commercial vehicle opportunities.
+
+Are you the right person to review this type of opportunity?
 ```
 
-## Safety
+## 12. 跳过规则
+
+以下客户原则上不生成发送话术:
+
+- 单一品牌官方当地主页或直营网点,且没有独立经营主体证据。
+- 纯维修、配件、轮胎、保险、洗车或个人卖家。
+- 无法确认与汽车销售、进口、分销、车队或平台渠道有关。
+- 没有独立经营主体证据,且无法判断是否为普通分店。
+
+以下情况需要人工确认,不应直接跳过:
+
+- 同时运营多个汽车品牌的集团;
+- 品牌当地业务背后存在独立进口商或分销商;
+- 除品牌零售外,还具备车队、金融、保险、售后、备件或多城市渠道能力。
+
+## 13. 质量检查
+
+生成话术前应检查:
+
+- 是否正确识别为汽车渠道合作伙伴、平台与行业渠道或补充场景。
+- 是否引用至少一个真实客户信号。
+- 是否明确本轮合作角色。
+- 是否解释客户为什么值得建联。
+- 是否使用与客户属性匹配的切入点。
+- 是否避免以 `We want to cooperate` 开头。
+- 是否没有在首轮承诺独家、代理权或授权。
+- 是否没有编造具体价格、MOQ、车型、库存、交期或利润率。
+- 是否只问一个轻量问题。
+- 是否足够短、明确,并给客户一个回复理由。
+- 如果使用法语,是否确认客户主要沟通语言确实为法语。
+
+## 14. 分阶段合作逻辑
+
+### 第一阶段:社交建联
+
+目标是获得回复、确认负责人或得到发送简短资料的许可。
+
+不披露保密价格,不承诺代理资格,不要求签约。
+
+### 第二阶段:正式邮件与资料
+
+根据客户属性发送对应邮件模板和已批准图片,展示车型、价格竞争力、应用场景及参考商业机会。
+
+要求客户提供公司介绍、渠道覆盖、进口能力、销售网络、项目经验或会员资源。
+
+### 第三阶段:合作资格评估
+
+评估客户的进口认证、渠道覆盖、采购能力、售后备件、团队和持续运营能力。
 
-- Preview first. Dry-run second. Real action only after explicit user confirmation and per-customer confirmation.
-- Do not send messages to suspected official brand-country pages.
-- Do not store account passwords, cookies, SMTP codes, or sensitive information in previews, logs, or skill files.
+实力足够且通过内部审核的企业,可以进一步进入国家级代理或独家代理合作评估。
 
-## Sender Identity And Name Replacement
+## 15. 邮件模板位置
 
+邮件模板不属于社交建联话术,但与客户属性直接相关:
 
-- `{{sender_name}}` is per-run and social-only. Different AdsPower/Facebook/LinkedIn accounts may use different sender names, so never persist one social sender name as a global default in the skill, workbook, script, or config.
-- If the user changes social account/profile_id, ask for the sender role/account/persona name again unless the user has explicitly provided it in the same run request.
-- Email templates remain separate and use fixed `Chris Chen`.
-- Template updates should be made only inside the project workspace and the official skill directories. Do not modify WeChat cache copies or external received-file directories unless the user explicitly asks for that exact file to be edited.
+- 汽车渠道合作伙伴邮件模板:`assets/email_templates/汽车渠道合作伙伴.md`
+- 平台与行业渠道邮件模板:`assets/email_templates/平台与行业渠道.md`
 
+邮件发送必须以这些正式模板为准,不应使用根目录里的旧模板或临时预览内容。
+	

+ 494 - 0
assets/social_private_message_template.md

@@ -0,0 +1,494 @@
+# Skill 话术模板说明
+
+本文整理当前 `wuling-overseas-dealer-expansion` skill 中的社交建联话术逻辑,供后续优化、评审和迁移使用。
+
+话术应优先服务两类核心客户:
+
+1. 汽车渠道合作伙伴
+2. 平台与行业渠道
+
+其中,“汽车渠道合作伙伴”包括全国多品牌汽车分销集团、汽车进口商、全国或区域分销商以及多品牌经销商。二手车企业、租赁/车队公司和信息不足客户属于补充场景,不应成为示例和说明的主轴。
+
+## 1. 业务背景
+
+我们对外统一使用“五菱汽车海外事业部 / Wuling Overseas Business Department”身份,负责海外合作伙伴开发、产品导入、出口业务协调及长期本地市场合作。
+
+当前重点市场是摩洛哥,后续可以扩展到其他目标国家。
+
+首轮社交建联的目标不是泛泛建立联系,也不是立即推销代理权,而是:
+
+- 判断客户是否愿意评估五菱在当地市场的车型和商业机会;
+- 识别客户是否具备进口认证、渠道分销、订单组织、库存消化、售后服务或渠道转化能力;
+- 获取一次进一步发送资料、邮件沟通或进入合作评估的机会。
+
+两类核心客户的合作逻辑不同:
+
+- 汽车渠道合作伙伴:重点评估车型、价格竞争力、产品线补充、进口分销、持续采购和售后能力。
+- 平台与行业渠道:重点评估会员车商激活、采购需求组织、B2B线索、订单集采、合作伙伴引荐和项目组织能力。
+
+优先建联对象:
+
+- 全国多品牌汽车分销集团:具有进口、代理、分销、多城市网络、下游经销商或集团化运营信号。
+- 汽车进口商、全国分销商和区域分销商:具有进口认证、渠道覆盖或持续采购能力。
+- 多品牌经销商:具有 showroom、新车销售、多品牌经营、正式展厅、销售团队、官网或公开联系方式。
+- 平台与行业渠道:汽车交易平台、协会、商会、车商联盟或汽车行业资源引荐机构。
+
+次级对象:
+
+- 二手车转型候选:连锁二手车企业、实体二手车企业、进口二手车企业、新车与二手车综合企业。
+- 批量采购与运营客户:汽车租赁公司、企业车队和车辆运营企业。
+
+## 2. 硬性规则
+
+### 2.1 对外身份
+
+- 对外统一使用 `Wuling Overseas Business Department`。
+- 不在首轮话术中将华途作为主要对外身份。
+- 中文用于内部判断和复核,不直接发送给客户。
+
+### 2.2 语言规则
+
+- 英文是默认对外语言。
+- 如果客户官网、社交账号或主要沟通语言明显为法语,摩洛哥客户可以优先使用法语。
+- 不混合多种语言发送同一条首轮私信。
+
+### 2.3 代理与授权规则
+
+- 首轮社交建联不承诺独家代理、国家代理、区域授权或已经确认的合作资格。
+- 首轮不使用 `exclusive agency`、`authorized dealer`、`official distributor` 等可能被理解为已经授权的表达。
+- 以上规则仅适用于首轮建联,不代表永久不授予代理资格。
+- 客户回复并提交能力资料后,可以进入进口、渠道、采购、售后及市场覆盖能力评估。
+- 实力足够且通过内部审核的企业,可以进一步进入国家级代理或独家代理合作评估。
+
+### 2.4 商业信息规则
+
+- 不编造价格、MOQ、车型清单、库存数量、交期、利润率或已确认合作条件。
+- 首轮社交私信不主动披露具体A级价格、销售底价、利润数字或保密报价。
+- 正式邮件或后续资料可以使用经过批准的车型、价格、参考毛利和图片材料。
+- 所有利润或价差必须表述为参考机会,不得写成保证收益。
+
+### 2.5 首轮沟通规则
+
+- 不以 `We want to cooperate` 作为开头。
+- 不一上来要求签约或直接安排会议。
+- 不连续提出多个问题。
+- 首轮只问一个轻量问题,目标是获得回复或允许发送简短资料。
+- 话术必须使用至少一个真实客户信号,不写无法证明的赞美或能力。
+
+### 2.6 官方品牌页规则
+
+- 如果客户只是 BYD Maroc、BMW Maroc、JAC Morocco、Changan Maroc、FOTON Maroc 等品牌官方当地主页或直营网点,原则上建议跳过。
+- 如果页面背后是独立运营的汽车集团、进口商或多品牌分销商,则应评估其独立经营主体和产品线补充可能,不应仅因代理其他品牌而跳过。
+
+## 3. 吸引力框架
+
+话术不应机械套用固定模板,而应使用信号驱动结构:
+
+`客户具体实力信号 -> 对应业务空档或商业假设 -> 五菱可提供的轻量机会 -> 一个问题`
+
+可用客户信号包括:
+
+- national distribution network
+- multi-brand distribution
+- importer / distributor
+- dealer network
+- showroom
+- new vehicle sales
+- commercial vehicle channel
+- corporate customer resources
+- fleet / rental / leasing
+- official website
+- multiple cities / branches
+- after-sales network
+- spare-parts capability
+- automotive marketplace / association
+- dealer members
+- WhatsApp / business email / manager contact
+
+优先商业假设:
+
+- 全国多品牌分销集团:可能具备新产品线评估、进口分销、区域覆盖、批量消化及售后服务能力。
+- 汽车进口商或区域分销商:可能具备认证、进口、渠道供货和持续采购能力。
+- 多品牌经销商:可能需要补充高性价比新车产品,覆盖价格敏感客户并提高现有展厅利用率。
+- 平台与行业渠道:可能需要新的车型和供应资源,以激活会员、组织分散采购需求并形成B2B项目。
+
+轻量价值物应根据客户属性选择:
+
+### 汽车渠道合作伙伴
+
+- a short model and price-range overview
+- a product-line fit overview
+- a first-batch market validation outline
+- an export, spare-parts, and after-sales support outline
+
+### 平台与行业渠道
+
+- a short B2B vehicle opportunity overview
+- a dealer and importer partner profile
+- a consolidated purchasing project outline
+- a channel-introduction and member-activation overview
+
+## 4. 客户类型与优先级
+
+| 优先级 | 核心类别 | 客户子类型 | 推荐切入点 |
+| --- | --- | --- | --- |
+| P0 | 汽车渠道合作伙伴 | 全国多品牌汽车分销集团 | national coverage、multi-brand operation、import/distribution capability、dealer network、after-sales system |
+| P0 | 汽车渠道合作伙伴 | 汽车进口商/全国或区域分销商 | import and certification、channel coverage、regional distribution、continuous purchasing |
+| P0 | 汽车渠道合作伙伴 | 多品牌经销商 | complement current brands、affordable vehicle line、showroom utilization、small-batch validation |
+| P0 | 平台与行业渠道 | 汽车平台、协会、商会、车商联盟 | dealer resources、member activation、B2B leads、consolidated orders、qualified partner introductions |
+| P2 | 补充场景 | 二手车转型候选 | used-car replacement、affordable new vehicles、trade-in capability |
+| P2 | 补充场景 | 租赁/车队公司 | fleet renewal、operating cost、batch procurement |
+
+## 5. 输出结构
+
+每个客户建议输出以下内容:
+
+### 5.1 客户判断
+
+用中文说明:
+
+- 客户属于哪一类;
+- 为什么值得或不值得建联;
+- 是否具有批量采购、进口分销、售后服务、订单组织或渠道转化潜力。
+
+### 5.2 使用的客户信号
+
+列出支撑判断的真实信息,例如:
+
+- 覆盖城市数量;
+- 代理或经营品牌;
+- 展厅、分支机构或经销商网络;
+- 进口、车队、金融、保险、售后或备件能力;
+- 平台会员、行业资源或企业客户。
+
+不得使用无法从公开资料确认的信号。
+
+### 5.3 本轮合作角色
+
+从以下角色中选择一个最符合客户实际能力的角色:
+
+- 国家级渠道候选;
+- 汽车进口与分销合作伙伴;
+- 区域经销与销售合作伙伴;
+- 多品牌展厅合作伙伴;
+- 平台流量与会员转化合作伙伴;
+- 经销商引荐与订单集采合作伙伴;
+- 补充场景客户。
+
+### 5.4 推荐切入点
+
+用中文说明:
+
+- 客户当前可能存在的产品、渠道或项目空档;
+- 五菱为何适合补充该空档;
+- 首轮应使用哪一个商业抓点。
+
+每次只选择一个主切入点,不将所有优势同时塞入首轮话术。
+
+### 5.5 加好友话术
+
+- 默认输出英文;客户主要使用法语时输出法语。
+- 英文控制在约 180-260 个字符。
+- 简短、自然、有真实商业抓点,不写成群发广告。
+- 结尾只问一个轻量问题。
+
+### 5.6 首轮私信
+
+- 用于 WhatsApp 或 Facebook 接受好友后的首次正式沟通。
+- 建议控制在 55-85 个英文单词、3-4 个短段落以内。
+- 第一段说明真实身份:`Chris Chen + Wuling Overseas Business Department`。
+- 第二段引用客户的具体实力信号,说明为什么主动联系该客户。
+- 第三段用一条简短的五菱实力数据建立可信度,并说明与该客户属性匹配的商业机会。
+- 最后一段只问一个轻量问题,例如是否愿意接收一页车型与合作机会资料,或确认谁负责相关业务。
+- 不应为了套模板同时堆砌全部品牌数据、产品优势和合作方式;每条私信只保留一个主要吸引点。
+
+### 5.7 风险提示
+
+说明:
+
+- 是否建议跳过;
+- 是否疑似品牌官方页或普通分店;
+- 信息是否不足;
+- 是否需要人工确认独立经营主体;
+- 是否涉及未经批准的价格、利润或代理承诺。
+
+## 6. 汽车渠道合作伙伴:全国多品牌汽车分销集团
+
+适用信号:
+
+- group、distribution、importer、dealer network、multi-brand、national coverage、multiple cities、regional branches、corporate sales、fleet solutions、after-sales network。
+
+中文判断示例:
+
+该客户属于全国多品牌汽车分销集团,具备多品牌运营、进口分销、渠道覆盖和售后服务能力。相比普通单店经销商,这类客户更适合评估五菱的产品线补充、批量导入和持续采购潜力,并可在提交能力资料后进入国家级合作资格评估。
+
+推荐切入点:
+
+不应一开始要求对方成为五菱代理商,而应说明五菱是否能够补充其现有品牌组合中的大众价格带,并从一个车型、一个城市或一个小批量项目开始验证。
+
+英文加好友话术:
+
+```text
+Hi, your multi-brand distribution network in Morocco stood out. Wuling is evaluating strong automotive groups for practical, competitively positioned vehicle opportunities. Open to connect?
+```
+
+英文首轮私信 A:commercial_value_hook
+
+```text
+Hi [Name], I’m Chris Chen from Wuling Overseas Business Department.
+
+Your multi-brand distribution network and market coverage caught my attention as we look for strong automotive partners in Morocco.
+
+Wuling has sold over 30 million vehicles and operates in 60+ countries. Our competitively priced practical models could complement your current brands and offer clear dealer margin potential without requiring a large initial stock.
+
+May I send you a one-page model, partner-pricing, and margin overview?
+```
+
+英文首轮私信 B:portfolio_gap_hook
+
+```text
+Hi [Name], I’m Chris Chen from Wuling Overseas Business Department.
+
+Your established brand portfolio and distribution structure stood out as we reviewed leading automotive groups in Morocco.
+
+Wuling has sold over 30 million vehicles and operates in 60+ countries. Our practical vehicles could complement your existing brands in a more accessible price segment, helping you reach additional family, business, and fleet customers.
+
+Would you be open to reviewing a one-page portfolio-fit overview?
+```
+
+英文首轮私信 C:soft_research_hook
+
+```text
+Hi [Name], I’m Chris Chen from Wuling Overseas Business Department.
+
+Your import, distribution, and after-sales network appears relevant as we assess strong automotive partners in Morocco.
+
+Wuling has sold over 30 million vehicles and operates in 60+ countries. We are evaluating where our competitively positioned passenger and commercial vehicles could fit within established local channels.
+
+Is new brand or product-line evaluation handled by your team?
+```
+
+## 7. 汽车渠道合作伙伴:进口商与多品牌经销商
+
+适用信号:
+
+- importer、regional distributor、showroom、multi-brand dealer、new vehicle sales、concessionnaire、vehicles neufs、sales team、stock、official website、WhatsApp。
+
+中文判断示例:
+
+该客户属于汽车进口商、区域分销商或多品牌经销商,具备车辆采购、销售或本地客户触达能力。切入点应放在补充现有产品线、覆盖大众价格带、提高展厅和销售网络利用率,以及通过市场验证降低新品牌库存风险。
+
+英文加好友话术:
+
+```text
+Hi, your showroom and multi-brand vehicle activity in Morocco stood out. Wuling is exploring practical, competitively positioned vehicle opportunities with capable local dealers. Open to connect?
+```
+
+英文首轮私信 A:commercial_value_hook
+
+```text
+Hi [Name], I’m Chris Chen from Wuling Overseas Business Department.
+
+Your showroom and multi-brand sales activity caught my attention as we look for capable vehicle dealers in Morocco.
+
+Wuling has sold over 30 million vehicles and operates in 60+ countries. Our competitively priced practical models could complement your current stock, reach more family and business customers, and offer a clear dealer margin opportunity.
+
+May I send your sales team a one-page model, partner-pricing, and margin overview?
+```
+
+英文首轮私信 B:portfolio_gap_hook
+
+```text
+Hi [Name], I’m Chris Chen from Wuling Overseas Business Department.
+
+Your multi-brand business appears well positioned to serve customers between used vehicles and higher-priced new models.
+
+Wuling has sold over 30 million vehicles and operates in 60+ countries. Our practical models could fill this price gap with accessible new vehicles for family, commuting, and business use, without replacing your existing brands.
+
+Would your sales team be open to a one-page portfolio-fit overview?
+```
+
+英文首轮私信 C:market_validation_hook
+
+```text
+Hi [Name], I’m Chris Chen from Wuling Overseas Business Department.
+
+Your showroom and local sales activity stood out as we identify Moroccan dealers capable of testing demand for practical new vehicles.
+
+Wuling has sold over 30 million vehicles and operates in 60+ countries. Cooperation could begin with a controlled market test and small initial order, reducing inventory pressure before any larger rollout.
+
+Would your team be open to reviewing this market-validation approach?
+```
+
+## 8. 平台与行业渠道
+
+适用信号:
+
+- automotive platform、marketplace、association、chamber、dealer network、industry channel、dealer members、resource introduction、B2B leads。
+
+中文判断示例:
+
+该客户属于平台与行业渠道,本身不一定直接进口或持有车辆库存,但可能拥有车商会员、企业客户、行业流量或项目组织能力。其合作价值在于激活会员车商、汇总分散采购需求、形成B2B线索、订单集采及合格合作伙伴引荐。
+
+推荐切入点:
+
+不应要求平台直接采购车辆,而应说明五菱的车型、价格竞争力和广泛使用场景,如何帮助其组织新的车商合作项目,并将平台流量或会员资源转化为真实采购机会。
+
+英文加好友话术:
+
+```text
+Hi, your automotive platform and dealer network in Morocco stood out. Wuling is exploring B2B vehicle projects that could create new opportunities for dealer members. Open to connect?
+```
+
+英文首轮私信 A:member_activation_hook
+
+```text
+Hi [Name], I’m Chris Chen from Wuling Overseas Business Department.
+
+Your access to automotive dealers and industry businesses caught my attention as we review strong channels in Morocco.
+
+Wuling has sold over 30 million vehicles and operates in 60+ countries. Our practical, competitively priced models could give your members a new supply opportunity while helping your platform generate qualified B2B interest.
+
+Would a one-page vehicle and member-opportunity overview be relevant?
+```
+
+英文首轮私信 B:consolidated_order_hook
+
+```text
+Hi [Name], I’m Chris Chen from Wuling Overseas Business Department.
+
+Your platform’s dealer network appears well positioned to organize purchasing demand that may be too fragmented at the individual dealer level.
+
+Wuling has sold over 30 million vehicles and operates in 60+ countries. We see potential to identify qualified importers, collect member interest, and develop consolidated B2B vehicle projects through your network.
+
+Would a one-page consolidated-purchasing outline be useful?
+```
+
+英文首轮私信 C:partner_introduction_hook
+
+```text
+Hi [Name], I’m Chris Chen from Wuling Overseas Business Department.
+
+Your automotive network caught my attention as we identify qualified importers and dealers for Wuling’s development in Morocco.
+
+Wuling has sold over 30 million vehicles and operates in 60+ countries. We believe your industry reach could support qualified partner introductions and practical B2B vehicle projects.
+
+Is this type of automotive partnership handled by your team?
+```
+
+## 9. 二手车转型候选
+
+二手车企业不是当前示例重点,只在客户确实以二手车销售、置换、`occasion` 或 `reprise` 为主要业务时使用。
+
+推荐切入点:
+
+不要求客户改变现有二手车业务,而是评估其是否可以利用展厅、客户和旧车置换能力,增加价格更容易接受的五菱新车选择。
+
+英文首轮私信:
+
+```text
+Hi [Name], I’m Chris Chen from Wuling Overseas Business Department.
+
+Your used-vehicle and trade-in activity caught my attention as we reviewed automotive businesses in Morocco.
+
+Wuling has sold over 30 million vehicles and operates in 60+ countries. Our accessible new vehicles could give your customers an upgrade option while allowing your company to retain its existing used-car and trade-in strengths.
+
+Would a one-page new-vehicle and trade-in opportunity overview be useful?
+```
+
+## 10. 租赁与车队客户
+
+仅在客户明确具有 rental、leasing、fleet、logistics 或企业车辆运营信号时使用。
+
+推荐切入点:
+
+重点关注车辆采购成本、使用场景、运营成本、批量采购及售后支持,不使用经销商利润或代理资格作为首轮吸引点。
+
+英文首轮私信:
+
+```text
+Hi [Name], I’m Chris Chen from Wuling Overseas Business Department.
+
+Your [rental / leasing / logistics / fleet] operations stood out as we reviewed Moroccan companies with recurring vehicle requirements.
+
+Wuling has sold over 30 million vehicles and operates in 60+ countries. Our competitively priced passenger and light-commercial vehicles could support fleet renewal, staff mobility, delivery, or service operations across several use cases.
+
+Would a one-page fleet-model and application overview help your team assess fit?
+```
+
+## 11. 信息不足客户
+
+如果客户资料不足,不应强推业务,应先确认对方是否负责采购、进口、分销、车型评估或平台合作。
+
+英文首轮私信:
+
+```text
+Hi, I’m Chris Chen from Wuling Overseas Business Department.
+
+We found your company while reviewing automotive businesses in Morocco, but the public information does not clearly identify who manages vehicle sourcing, import, distribution, or partnership decisions.
+
+Wuling has sold over 30 million vehicles and operates in 60+ countries. We are evaluating suitable local channels for practical passenger and commercial vehicle opportunities.
+
+Are you the right person to review this type of opportunity?
+```
+
+## 12. 跳过规则
+
+以下客户原则上不生成发送话术:
+
+- 单一品牌官方当地主页或直营网点,且没有独立经营主体证据。
+- 纯维修、配件、轮胎、保险、洗车或个人卖家。
+- 无法确认与汽车销售、进口、分销、车队或平台渠道有关。
+- 没有独立经营主体证据,且无法判断是否为普通分店。
+
+以下情况需要人工确认,不应直接跳过:
+
+- 同时运营多个汽车品牌的集团;
+- 品牌当地业务背后存在独立进口商或分销商;
+- 除品牌零售外,还具备车队、金融、保险、售后、备件或多城市渠道能力。
+
+## 13. 质量检查
+
+生成话术前应检查:
+
+- 是否正确识别为汽车渠道合作伙伴、平台与行业渠道或补充场景。
+- 是否引用至少一个真实客户信号。
+- 是否明确本轮合作角色。
+- 是否解释客户为什么值得建联。
+- 是否使用与客户属性匹配的切入点。
+- 是否避免以 `We want to cooperate` 开头。
+- 是否没有在首轮承诺独家、代理权或授权。
+- 是否没有编造具体价格、MOQ、车型、库存、交期或利润率。
+- 是否只问一个轻量问题。
+- 是否足够短、明确,并给客户一个回复理由。
+- 如果使用法语,是否确认客户主要沟通语言确实为法语。
+
+## 14. 分阶段合作逻辑
+
+### 第一阶段:社交建联
+
+目标是获得回复、确认负责人或得到发送简短资料的许可。
+
+不披露保密价格,不承诺代理资格,不要求签约。
+
+### 第二阶段:正式邮件与资料
+
+根据客户属性发送对应邮件模板和已批准图片,展示车型、价格竞争力、应用场景及参考商业机会。
+
+要求客户提供公司介绍、渠道覆盖、进口能力、销售网络、项目经验或会员资源。
+
+### 第三阶段:合作资格评估
+
+评估客户的进口认证、渠道覆盖、采购能力、售后备件、团队和持续运营能力。
+
+实力足够且通过内部审核的企业,可以进一步进入国家级代理或独家代理合作评估。
+
+## 15. 邮件模板位置
+
+邮件模板不属于社交建联话术,但与客户属性直接相关:
+
+- 汽车渠道合作伙伴邮件模板:`assets/email_templates/汽车渠道合作伙伴.md`
+- 平台与行业渠道邮件模板:`assets/email_templates/平台与行业渠道.md`
+
+邮件发送必须以这些正式模板为准,不应使用根目录里的旧模板或临时预览内容。
+	

+ 10 - 22
assets/social_templates.md

@@ -2,28 +2,16 @@
 
 This file is kept only for backward compatibility with older references.
 
-For current Facebook and LinkedIn outreach, use `assets/social_outreach_library.md` and `scripts/social/prepare_facebook_outreach.py`.
+Current canonical private-message template:
 
-Current rule:
+- `assets/social_private_message_template.md`
+- Mirror/reference copy: `assets/social_outreach_library.md`
 
-- Customer-facing language: English only.
-- Chinese is used only for internal judgment and preview review.
-- Do not use French as the default Facebook message language.
-- Do not use old long-form cooperation introductions.
-- Do not discuss brand territory rights, authorization rights, regional rights, sole-right arrangements, or similar channel-right topics.
-- Use the profit-opportunity angle: low-cost new vehicle line, small first-batch test, low stock pressure, price-sensitive local customers, and possible volume potential if the model and price range fit.
-- First touch asks only one light question and never invents prices, MOQ, model lists, delivery time, margin, stock quantity, or confirmed terms.
+Rules:
 
-Canonical English connect example:
-
-```text
-Hi {{contact_name}}, I noticed {{dealer_name}} has auto sales activity in Morocco. Wuling could be tested as a low-cost new vehicle line with low stock pressure. Open to connect?
-```
-
-Canonical English DM example:
-
-```text
-Your channel looks suitable for an affordable new vehicle line for price-sensitive local customers. Wuling could complement current stock and be checked first through a small first-batch test.
-
-If the model and price range fit your market, would you be open to reviewing the volume potential?
-```
+- Facebook and LinkedIn customer private-message copy must follow `assets/social_private_message_template.md`.
+- The two priority customer attributes are `汽车渠道合作伙伴` and `平台与行业渠道`.
+- For `汽车渠道合作伙伴`, examples and defaults should focus on national multi-brand automotive distribution groups, importers/distributors, and multi-brand dealers. Used-car, rental/fleet, and low-information customers are supplementary scenarios only.
+- Customer-facing social copy uses English by default; if the customer website/social account clearly uses French, Moroccan customers may use French. Do not mix languages in one first-touch message.
+- Social first DM uses `Chris Chen + Wuling Overseas Business Department` unless the user provides a newer approved sender rule. Email templates remain separate.
+- Do not mention exclusive agency, authorization rights, territory rights, or confirmed partnership status in first-touch social messages.

+ 33 - 0
assets/workflow_config.example.json

@@ -0,0 +1,33 @@
+{
+  "enabled": true,
+  "country": "Morocco",
+  "profile_id": "",
+  "excel": "",
+  "summary_sheet_name": "客户信息汇总表",
+  "daily_outreach_target": 20,
+  "default_stages": [
+    "summary",
+    "dashboard",
+    "feishu_check"
+  ],
+  "refresh_summary": true,
+  "refresh_dashboard": true,
+  "dashboard_latest_dir": "dashboards/latest",
+  "sync_feishu": true,
+  "prepare_email_preview": false,
+  "prepare_facebook_outreach": false,
+  "collect_facebook_replies": false,
+  "send_email": false,
+  "send_facebook_dm": false,
+  "human_confirmation_required": [
+    "send_email",
+    "send_facebook_dm",
+    "linkedin_outreach",
+    "write_facebook_conversations"
+  ],
+  "notes": [
+    "Copy this file to project root as workflow_config.json before use.",
+    "Do not store SMTP codes, cookies, AdsPower credentials, Feishu tokens, or customer data in the skill package.",
+    "Outbound email or social messaging must still use preview + explicit user confirmation."
+  ]
+}

+ 26 - 100
references/dashboard.md

@@ -1,113 +1,39 @@
-# Customer Dashboard
+# Dashboard
 
-Use this reference when the user asks for a 中台、看台、看板、客户统计、建联率、客户数量、渠道分布、邮件发送统计, or wants a more visual view of the outreach workbook.
+The customer dashboard reads `客户信息汇总表` as the single customer source of truth. It does not read legacy channel sheets unless they have first been imported into the summary table with `scripts/common/build_customer_summary.py`.
 
-## Scope
+## Required Metrics
 
-The dashboard belongs in the skill as a reusable generator, not as customer data.
+- Total unique customers.
+- Contactable customers.
+- Outreach status distribution.
+- Customer attribute distribution.
+- Customer type distribution.
+- Lead grade distribution from `线索等级`.
+- Manual review count from `需人工确认` values other than blank or `否`.
+- Email sent count and bounced/failed count.
+- Facebook sent count, unified reply count, reply rate, latest reply customers, and pending manual-review reply count when `Facebook对话记录` exists.
+- Daily target progress. Default daily outreach target is 20 customers.
 
-- Skill package contains only reusable files:
-  - `scripts/dashboard/build_dashboard.py`
-  - `assets/dashboard_template.html`
-  - `assets/dashboard_config.example.json`
+## Facebook Reply Metrics
 
-`assets/dashboard_template.html` is the official reusable visual template. It must retain the `{{dashboard_json}}` placeholder and must never contain customer rows copied from a generated dashboard. When adopting the appearance of a generated dashboard, remove its embedded runtime JSON before updating this template.
-- Project/runtime outputs must be written outside the skill:
-  - `runs/YYYYMMDD/<run_id>/customer_dashboard.html`
-  - `runs/YYYYMMDD/<run_id>/dashboard_data.json`
-  - Optional stable copy: `dashboards/latest/customer_dashboard.html`
+Facebook reply metrics use the unified reply definition. Do not segment Facebook replies by quality, cooperation intent, or deal probability.
 
-Do not put generated dashboards, customer rows, Feishu URLs, tokens, screenshots, or run JSON files into the skill package.
+- `Facebook sent customers`: unique customers with our Facebook DM records.
+- `Facebook replied customers`: unique customers with at least one customer-side real reply.
+- `Facebook reply rate`: `Facebook replied customers / Facebook sent customers`.
+- `Pending reply review customers`: customers with a Facebook reply that should be manually reviewed.
 
-## Data Source
+System messages, read receipts, our messages, automated prompts, and standalone reactions are not replies.
 
-The default customer source is `客户信息汇总表`. If `Facebook对话记录` exists, read it as an optional event source for Facebook reply metrics; never treat message rows as customer rows.
+## Source Fields
 
-Before generating a dashboard after new scraping or write-back work, refresh the summary sheet first:
+Use these summary-table fields:
 
-```bash
-python scripts/common/build_customer_summary.py --excel "<建联表路径>" --write-summary --run-id "<run_id>"
-```
+`公司姓名`, `国家`, `城市`, `客户类型`, `官网链接`, `联系人`, `职位`, `个人邮箱`, `联系人电话`, `Facebook主页链接`, `linkined主页链接`, `google map链接`, `公共电话/WhatsApp`, `公共邮箱`, `客户属性`, `线索等级`, `需人工确认`, `建联状态`, `下次跟进`, `备注`
 
-Then generate the dashboard:
+## Output
 
-```bash
-python scripts/dashboard/build_dashboard.py --excel "<建联表路径>" --run-id "<run_id>" --latest-dir "dashboards/latest"
-```
+Generate dashboard artifacts under `runs/YYYYMMDD/<run_id>/` and optionally update `dashboards/latest/`. Do not copy real customer JSON back into `assets/dashboard_template.html`; the template must keep the `{{dashboard_json}}` placeholder.
 
-Preview, dry-run, or search-only tasks do not need to refresh the dashboard unless the user asks.
-
-## Metric Definitions
-
-- `有效客户`: rows in `客户信息汇总表` that are not marked as `已剔除`, `跳过`, `低优先级`, or `skip_*` in status/notes.
-- `可建联客户`: valid customers with at least one email, phone/WhatsApp, website, Facebook, LinkedIn, or Google Maps entry.
-- `已建联客户`: valid customers whose status or notes include `已发送邮件`, `已发邮件`, `邮件已发送`, `邮件发送成功`, `已发私信`, `已关注并私信`, `已建联`, `等待回复`, `已回复`, or `有意向`, excluding bounced/rejected emails.
-- `建联率`: `已建联客户数 / 可建联客户数`.
-- `邮箱覆盖率`: valid customers with `个人邮箱` or `公共邮箱` divided by all valid customers.
-- `退信率`: customers marked with `邮件退回`, `退回`, `拒收`, `无法送达`, `域名不存在`, `邮箱不存在`, or `发送失败` divided by customers with email.
-- `待人工复核`: valid customers whose notes, type, or attribute contain `待确认`, `人工复核`, `需人工`, `需确认`, or `信息不足`.
-- `Facebook回复率`: unique customers with at least one effective customer reply divided by unique customers with at least one outgoing Facebook message.
-- `Facebook高意向`: unique customers whose latest effective reply is `明确有意向` or `潜在意向`.
-- System messages, auto replies, reactions, likes, and read receipts do not count as effective replies.
-
-## Dashboard Views
-
-The default HTML dashboard should show:
-
-- KPI cards:
-  - 有效客户
-  - 可建联客户
-  - 建联率
-  - 邮箱覆盖率
-  - 退信率
-  - 待人工复核
-- Distribution sections:
-  - 客户属性分布
-  - 建联状态分布
-  - 客户来源分布
-  - 细分客户类型 Top
-- Attention table:
-  - contactable customers that have not yet been contacted and are not bounced.
-- Facebook follow-up sections:
-  - five-level cooperation-intent distribution;
-  - effective-reply trend for the latest 30 days;
-  - high-intent customer table with Chinese reply summary and next action.
-
-The dashboard is a management view. Do not use it as the source of truth for customer edits; the local Excel workbook remains the source of truth.
-
-## Feishu Integration
-
-If `feishu_sync_config.json` is present and enabled, normal workbook write-back still follows `references/feishu-sync.md`.
-
-Dashboard generation itself does not automatically push to Feishu unless the project has an explicit `dashboard_config.json` with:
-
-```json
-{
-  "sync_dashboard_metrics_to_feishu": true
-}
-```
-
-When enabled, the agent should use WorkBuddy `lark-sheets` to sync dashboard metric tables after the local workbook has been saved and the local dashboard data has been generated. Do not store real Feishu tokens, cookies, or URLs inside the skill.
-
-## Encoding Rules
-
-Dashboard JSON and HTML must be written with `encoding="utf-8"` and `ensure_ascii=False`.
-
-After generating dashboard files, scan them for repeated-question-mark corruption. If found, stop and repair the source text generation before sharing the dashboard.
-
-
-## Visual Design Requirements
-
-Dashboard HTML should look like a practical sales-operations middle platform, not a plain report. It must include visual charts and not rely on external CDN assets.
-
-Required visual modules:
-
-- KPI cards with compact progress meters.
-- Outreach funnel from total customers to valid customers, contactable customers, contacted customers, and manual-review customers.
-- Customer attribute distribution using horizontal bar charts.
-- Customer source distribution using vertical column/bar charts.
-- Outreach status structure using a donut-style CSS chart with legend.
-- Customer type and city distribution using horizontal bar charts.
-- A filterable customer table with search, customer-attribute filter, status filter, and email availability filter.
-
-Keep the style close to CRM / B2B SaaS dashboards: light background, restrained business colors, high information density, clear visual hierarchy, and no marketing hero layout.
+Dashboard generation does not modify Excel and does not trigger Feishu sync by itself.

+ 27 - 124
references/facebook-conversation-sync.md

@@ -1,145 +1,48 @@
 # Facebook Conversation Sync
 
-Use this reference whenever the user asks to read Facebook replies, synchronize Messenger conversations, translate customer messages, assess cooperation intent, or update follow-up status from Facebook conversations.
+Use this reference when the user asks to read Facebook/Messenger replies, translate customer messages into Chinese, or write Facebook reply records back to the outreach workbook.
 
-## Mandatory Workflow
+## Source Of Customers
 
-1. Read Facebook rows that contain a valid Facebook Page URL.
-2. Run scripts/social/collect_facebook_conversations.py through AdsPower + Playwright.
-3. Match each thread by Page URL identity/slug and visible Messenger title. Skip mismatches.
-4. Preserve both directions of the conversation. Keep the original text unchanged and translate every non-Chinese message into Chinese.
-5. Analyze cooperation intent only for effective customer replies.
-6. Show the customer-reply summary, intent distribution, and high-intent customers in chat before workbook write-back.
-7. Build an analysis JSON that follows the schema below.
-8. Run scripts/social/write_facebook_conversations.py with --write-workbook --refresh-summary --refresh-dashboard.
-9. After a successful local write, follow references/feishu-sync.md.
+Read matched customers from `客户信息汇总表`, not from a separate Facebook channel sheet. Match by `Facebook主页链接`, Messenger thread ID when known, Page slug, Page ID, and company name. If a thread cannot be reliably matched to a summary-table customer, skip it and report the reason.
 
-Never send a reply as part of this workflow. Only provide a Chinese next-step recommendation.
+## Unified Reply Definition
 
-## Collection Modes
+Unified Facebook reply definition: the Skill no longer distinguishes reply quality. Any real customer-side Messenger reply is counted as `Facebook回复`. Reply rate, dashboard metrics, and workbook status are based only on whether the customer replied, not on reply quality, cooperation intent level, or deal probability.
 
-Initial full history:
+Count as Facebook reply:
 
-    python scripts/social/collect_facebook_conversations.py --excel "<workbook>" --profile-id "<profile_id>" --initial-full
+- Customer-side real text message.
+- Customer-side voice, image, file, attachment, or other message that clearly comes from the customer.
 
-Incremental sync:
+Do not count as Facebook reply:
 
-    python scripts/social/collect_facebook_conversations.py --excel "<workbook>" --profile-id "<profile_id>" --incremental
-
-Rules:
-
-- First use --initial-full; later runs use --incremental.
-- The collector reads record IDs already stored in Facebook对话记录 and removes them from incremental output.
-- Default safety limit is 2000 messages per thread. If the limit is reached, write history_truncated and report that older history may be incomplete.
-- Do not collect unrelated inbox conversations. Only open threads derived from Facebook Page URLs already present in the workbook.
-- Stop the batch on Facebook verification, rate limiting, suspicious activity, or temporary block prompts.
-- Always leave AdsPower open. Detach Playwright only.
+- Our sent messages.
+- Read receipts.
+- Facebook/Messenger system notices.
+- Automated page prompts.
+- Pure likes/reactions or standalone emoji without business content.
 
 ## Conversation Sheet
 
-Use Sheet name Facebook对话记录 with exactly these columns:
-
-记录ID、客户序号、客户姓名/公司、Facebook主页链接、Messenger线程ID、消息时间、消息方向、发件人、原文语言、对话原文、中文翻译、消息类型、是否有效客户回复、合作意向、意向判断依据、下一步建议、同步时间、来源账号/Profile ID、风险标记
-
-- 消息方向: only 我方发送 or 客户回复.
-- 记录ID: prefer Facebook message ID; otherwise use the collector stable hash.
-- 对话原文: immutable evidence. Never overwrite it with a translation.
-- 中文翻译: preserve company names, model names, prices, quantities, URLs, email addresses, and professional terms accurately.
-- 消息类型: 文本, 图片, 文件, 视频, 语音, 系统消息, 自动回复, or 已读提示.
-- Images/files/audio are recorded as types and descriptions; do not download attachments by default.
-- System messages, automatic replies, reactions, likes, and read receipts are not effective customer replies.
-
-## Agent Analysis JSON
-
-The agent must create UTF-8 JSON. Each analyzed thread must match at least two of customer_index, thread_id, and facebook_link.
-
-    {
-      "schema_version": "4.26",
-      "threads": [
-        {
-          "customer_index": "1",
-          "facebook_link": "https://www.facebook.com/example",
-          "thread_id": "example",
-          "messages": [
-            {
-              "record_id": "fbh:...",
-              "message_time": "2026-08-06T10:30:00+08:00",
-              "original_language": "fr",
-              "chinese_translation": "客户希望先查看车型和价格区间。",
-              "is_effective_customer_reply": true,
-              "intent": "明确有意向",
-              "intent_reason": "客户主动询问车型和价格资料。",
-              "next_action": "整理适合当地市场的车型和价格区间资料,优先回复客户。",
-              "risk_flags": []
-            }
-          ],
-          "latest_analysis": {
-            "latest_reply_record_id": "fbh:...",
-            "latest_reply_at": "2026-08-06T10:30:00+08:00",
-            "chinese_summary": "客户愿意评估五菱车型,并要求先查看车型和价格区间。",
-            "intent": "明确有意向",
-            "intent_reason": "客户提出了具体资料需求。",
-            "next_action": "在一个工作日内整理车型和价格区间资料。",
-            "next_followup": ""
-          }
-        }
-      ]
-    }
-
-Every message with non-Chinese text requires chinese_translation. Outgoing messages are translated for context but must use is_effective_customer_reply=false and no cooperation intent.
-
-## Five-Level Cooperation Intent
-
-| Intent | Evidence | Status | Default follow-up |
-|---|---|---|---|
-| 明确有意向 | asks for models, price, quotation, import terms, trial order, volume purchase, or supplies purchase details | 已回复,有合作意向 | 1 business day |
-| 潜在意向 | agrees to review information or continue discussion without a concrete purchase step | 已回复,待跟进 | 3 business days |
-| 需澄清 | ambiguous reply, asks who the sender is, or requires decision-maker/business-fit confirmation | 已回复,待澄清 | 2 business days |
-| 暂不考虑 | no current plan but leaves a future opening or asks to reconnect later | 已回复,暂不考虑 | explicit customer date, otherwise 30 days |
-| 明确拒绝 | explicitly not interested, requests no more contact, or confirms it is not a target business | 已回复,明确拒绝 | none |
-
-Do not classify greetings, thanks, emojis, thumbs-up, read receipts, or automatic replies as positive intent. Base the decision on the full thread, customer type, business evidence, and the latest effective customer reply.
-
-## Workbook Write-Back
-
-Default is preview-only. Write only when the user explicitly asks to update the workbook.
+Write message records to the separate event-log Sheet `Facebook对话记录`:
 
-    python scripts/social/write_facebook_conversations.py --excel "<workbook>" --transcript "<raw.json>" --analysis "<analysis.json>" --write-workbook --refresh-summary --refresh-dashboard
+`记录ID`, `客户序号`, `客户姓名/公司`, `Facebook主页链接`, `Messenger线程ID`, `消息时间`, `消息方向`, `发件人`, `原文语言`, `对话原文`, `中文翻译`, `消息类型`, `是否有效客户回复`, `合作意向`, `意向判断依据`, `下一步建议`, `同步时间`, `来源账号/Profile ID`, `风险标记`
 
-The writer must:
+This Sheet stores message history only. It is not a customer source sheet and must never be counted as customer rows in `客户信息汇总表`.
 
-- deduplicate by 记录ID;
-- update existing records only to complete translation/analysis fields;
-- preserve original text and customer identity fields;
-- update the matched Facebook row by both customer index and normalized Facebook URL;
-- preserve email, WhatsApp, and previous outreach evidence in 建联状态;
-- replace, not append repeatedly, the structured note block between 【Facebook回复分析】 and 【/Facebook回复分析】;
-- calculate the default follow-up date when the analysis does not provide one;
-- create one backup per run;
-- rebuild 客户信息汇总表 without adding conversation columns;
-- regenerate the dashboard when requested;
-- reopen and scan the workbook after save.
+For backward compatibility, keep `合作意向` and `意向判断依据` columns in the event-log Sheet, but do not require them and do not use them for reply-rate, dashboard, or customer status. `是否有效客户回复=?` means "customer real reply" under the unified reply definition, not reply quality.
 
-A completed write must report question_mark_cells=0 and question_mark_note_rows=0.
-
-## Dashboard Metrics
-
-Dashboard reply metrics are unique-customer metrics:
-
-- Facebook DM customers: unique customers with at least one 我方发送 record.
-- Effective reply customers: unique customers with at least one effective 客户回复.
-- Facebook reply rate: effective reply customers / Facebook DM customers.
-- Intent distribution: each customer latest effective reply only.
-- High-intent customers: 明确有意向 plus 潜在意向.
-- Reply trend: effective replies in the latest 30 days.
+## Workbook Write-Back
 
-Never use message count as customer count.
+Preserve both sides' original text. Translate non-Chinese customer messages to Chinese. Write the latest customer reply summary back to the matched row in `客户信息汇总表`.
 
-## Safety And Privacy
+- `建联状态`: set or merge `Facebook已回复`.
+- `备注`: replace the structured `Facebook回复记录` block with latest reply time, Chinese summary, and next manual-review suggestion.
+- `下次跟进`: only write a date if the analysis explicitly provides one; otherwise keep the existing value.
+- Do not write cooperation intent levels as the main customer status. If old analysis files contain intent fields, keep them out of summary-table status metrics.
+- Do not erase existing email status or email evidence.
 
-- Use only business conversations matched to workbook customers.
-- Do not save passwords, cookies, access tokens, or AdsPower credentials.
-- Keep raw/analysis JSON under runs/YYYYMMDD/<run_id>/, never inside the skill package.
-- Major browser steps use 90-200 seconds, page-level steps use 30-90 seconds, and technical waits use 0.5-8 seconds.
-- Do not reply, react, send attachments, or change the browser account state.
+## Safety
 
+Read only. Do not send replies, reactions, attachments, or follow-up messages. Use AdsPower + Playwright for browser access and keep the browser open.

+ 22 - 66
references/feishu-sync.md

@@ -1,89 +1,45 @@
-# Feishu Sheet Sync
+# Feishu / Lark Sheets Sync
 
-Use this reference whenever a workflow updates the local customer outreach workbook and the WorkBuddy/Lark Sheets plugin is available.
+Use this reference after any write-enabled workbook operation.
 
-## Trigger Rule
+## Trigger
 
-Feishu sync is a mandatory post-write step when all conditions are true:
+After the local workbook is successfully saved by `--write-excel`, `--write-summary`, `--write-workbook`, email status write-back, social status write-back, or Facebook conversation write-back, check project-root `feishu_sync_config.json`.
 
-1. A local workbook write has completed successfully, such as `--write-excel`, `--write-summary`, `--write-workbook`, email status write-back, or social outreach status write-back.
-2. The run is not preview-only, dry-run, review-only, or schedule-only.
-3. The project root contains `feishu_sync_config.json` with `enabled: true`.
-4. The agent has access to the `lark-sheets` plugin in WorkBuddy.
+- If the config exists and `enabled=true`, automatically call the WorkBuddy `lark-sheets` plugin to sync.
+- If the config is missing, report that local write succeeded and Feishu sync is pending configuration.
+- If the plugin is unavailable or unauthorized, report the blocker but do not roll back local Excel.
 
-If the config is missing, report once: `本地建联表已更新;未发现 feishu_sync_config.json,飞书同步未执行。` Do not silently skip. If the plugin is unavailable or unauthenticated, report: `本地建联表已更新;飞书同步待授权/待插件可用。` Do not roll back the local workbook.
+## Default Scope
+
+Default sync target is `客户信息汇总表`. Channel sheets are legacy and should not be synced unless the user explicitly asks to mirror the full workbook.
+
+If `sync_conversation_sheet=true`, also sync `Facebook对话记录` after Facebook reply synchronization.
+
+Do not sync backups, preview HTML, preview JSON, send logs, run reports, screenshots, or SMTP credentials.
 
 ## Config
 
-Real Feishu URLs or tokens must live in the project root config file, not inside the skill package.
+The real config belongs in the project root, not inside the skill package. The skill only provides `assets/feishu_sync_config.example.json`.
 
-Recommended project config file: `feishu_sync_config.json`.
+Suggested fields:
 
 ```json
 {
   "enabled": true,
-  "spreadsheet_url": "https://example.feishu.cn/sheets/xxxxxx",
+  "spreadsheet_url": "",
   "spreadsheet_token": "",
-  "sync_scope": "summary_first",
   "summary_sheet_name": "客户信息汇总表",
   "conversation_sheet_name": "Facebook对话记录",
   "sync_conversation_sheet": true,
-  "channel_sheets": [
-    "Facebook",
-    "LinkedIn",
-    "Google Maps",
-    "汽车网站精选线索",
-    "协会商会"
-  ]
+  "sync_scope": "summary_first"
 }
 ```
 
-Rules:
-
-- Use either `spreadsheet_url` or `spreadsheet_token`; prefer `spreadsheet_url` when available.
-- Keep `enabled=false` to disable automatic sync without deleting config.
-- Do not store cookies, user tokens, app secrets, passwords, SMTP authorization codes, or exported Feishu access tokens in this config.
-- The skill package may only contain `assets/feishu_sync_config.example.json`, never a real project config.
-
-## Default Sync Scope
-
-Default `sync_scope` is `summary_first`.
-
-- Sync `客户信息汇总表` by default after it is rebuilt successfully.
-- A Facebook conversation write-back is a special event-log workflow: when `sync_conversation_sheet=true`, sync both `Facebook对话记录` and the rebuilt `客户信息汇总表`.
-- Overwrite the remote conversation Sheet from the local Sheet so repeated syncs do not append duplicate message IDs.
-- If a channel sheet was written but the summary sheet was not rebuilt in the same run, report that the local channel sheet was updated and recommend refreshing the summary before Feishu sync.
-- Sync all configured channel sheets only when the user explicitly asks for full workbook sync or `sync_scope` is set to `all_configured_sheets`.
-- Never sync previews, JSON candidates, HTML previews, sent logs, backups, temporary files, or SMTP data to Feishu.
-
-## Lark Plugin Routing
-
-When sync is required, the agent must use the WorkBuddy `lark-sheets` plugin. If it needs to locate a spreadsheet by name or folder first, use `lark-drive` search only for discovery, then return to `lark-sheets` for table operations.
-
-Before writing with `lark-sheets`, follow that skill's required setup, including reading `lark-shared` for authentication and permissions. Do not use `lark-doc` for customer table sync unless the user explicitly asks for a narrative document.
-
-## Sync Flow
-
-1. Confirm the local Excel write has succeeded.
-2. Read the local workbook target sheet, normally `客户信息汇总表`, preserving header order and all effective rows.
-3. Call `lark-sheets +workbook-info` to confirm the remote spreadsheet exists and list sheet names.
-4. If the target sheet is absent, create a sheet with the same name.
-5. Clear or overwrite the remote target sheet range so repeated syncs do not append duplicates.
-6. Write the local sheet data to the remote sheet with `+table-put` when typed data matters, otherwise `+csv-put` is acceptable for plain text customer tables.
-7. Read back with `+csv-get` and verify remote header, effective row count, first data row, and last data row.
-8. Save a sync report under `runs/YYYYMMDD/<run_id>/feishu-sync-report.json` when a run directory exists; otherwise print the report in chat.
-
-## Failure Handling
-
-- Local Excel is the source of truth. Never roll back local writes because Feishu sync failed.
-- Report the failure reason clearly: missing config, disabled config, plugin unavailable, unauthenticated account, permission denied, remote spreadsheet missing, sheet creation failed, write failed, or read-back mismatch.
-- If only Feishu sync fails, final response must separate local workbook success from Feishu sync failure.
-- If the remote table contains manual edits, default behavior is still to overwrite the target sheet from the local source of truth. Ask only if the user explicitly says remote edits must be preserved.
+## Write Strategy
 
-## Acceptance Criteria
+Use the local workbook as the source of truth. Overwrite the remote Sheet with the local table instead of appending rows, so repeated syncs do not create duplicates. After writing, read back the remote header and row count to verify the sync.
 
-A completed write-enabled workflow should end with one of these statuses:
+## Required Report
 
-- `本地建联表已更新,飞书客户信息汇总表已同步并回读校验通过。`
-- `本地建联表已更新;feishu_sync_config.json 未配置,飞书同步未执行。`
-- `本地建联表已更新;飞书同步失败:<原因>。本地数据已保留。`
+Save a run report under `runs/YYYYMMDD/<run_id>/feishu-sync-report.json` with status, target spreadsheet, synced sheets, local row counts, remote row counts, and failure reason when any.

+ 50 - 229
references/field-schema.md

@@ -1,257 +1,78 @@
 # Field Schema
 
-## Encoding Safety for Text Fields
+## Workbook Priority
 
-All text fields that may contain non-ASCII content must follow `references/encoding-and-excel-writeback.md`. This applies especially to notes, outreach status, follow-up status, customer attribute, customer type, business summary, and generated HTML preview labels.
+Use the workbook passed by `--excel` first. If missing, resolve the project workbook by the workbook resolver. In a brand-new environment, copy `assets/blank_customer_outreach_workbook.xlsx` to the project directory and write to the copied workbook. Never write business data into the skill asset template.
 
-- Do not construct Chinese note/status strings inside PowerShell inline Python commands. Use UTF-8 files, Unicode escapes, or shared UTF-8 scripts.
-- After any workbook write-back, reopen the file and scan for repeated question marks.
-- A write-back is not complete unless the verification report shows `question_mark_cells = 0`.
-- If corrupted text is found, reconstruct from source JSON, send logs, or scrape records. Do not keep or manually edit around corrupted text.
+## Single Customer Master Table
 
-## 工作簿优先级与空白模板
+All new customer records must be written directly to `客户信息汇总表`. Do not create new customer rows in channel sheets such as `Facebook`, `LinkedIn`, `Google Maps`, `汽车网站精选线索`, or `本地汽车网站`.
 
-- 正常运行优先使用用户传入的 `--excel`。
-- 未传 `--excel` 时,在当前项目目录和上级目录查找已有建联表,优先匹配 `摩洛哥客户建联表-按渠道分类.xlsx` 或同名带空格版本。
-- 查找时必须排除 `~$`、`_backup_`、`backup_before`、`_with_`、`sent_`、`preview`、`candidate`、`filtered`、预览和临时工作簿。
-- Skill 内置空白建联表模板:`assets/blank_customer_outreach_workbook.xlsx`。
-- 模板由正式建联表 `摩洛哥客户建联表-按渠道分类.xlsx` 生成,保留当前 Sheet、表头、列宽、基础格式、`填写说明` 和 `附件`,清空所有客户数据行。
-- 模板只在全新环境兜底使用:当项目目录没有建联表且用户明确写表时,先复制到运行目录,再写复制品。
-- 禁止把客户数据、发送状态或采集结果写入 skill 内的模板文件。
-- 预览模式不需要创建建联表;只有写表模式才允许自动复制空白模板。
+Legacy channel sheets may remain in old workbooks. They are read-only compatibility inputs for `scripts/common/build_customer_summary.py`; they are not the default write targets.
 
-## 字段口径
+`客户信息汇总表` headers must be exactly:
 
-- `客户属性`:客户大类,只填写分类结果,例如 `汽车渠道合作伙伴`、`批量采购与运营客户`、`二手车转型候选`、`平台与行业渠道`、`生态支持资源`。
-- `客户类型`:细分客户类型,例如 `汽车进口商`、`全国代理商`、`区域分销商`、`多品牌经销商`、`汽车交易平台`、`汽车租赁公司`、`实体二手车企业` 等。
-- `主营业务`:客户实际经营内容和业务摘要,保持业务描述用途,不改名、不删除、不用于存放大类。
-- `备注`:结构化证据、来源判断、批量采购潜力、风险和待确认项。
+`公司姓名`, `国家`, `城市`, `客户类型`, `官网链接`, `联系人`, `职位`, `个人邮箱`, `联系人电话`, `Facebook主页链接`, `linkined主页链接`, `google map链接`, `公共电话/WhatsApp`, `公共邮箱`, `客户属性`, `线索等级`, `需人工确认`, `建联状态`, `下次跟进`, `备注`
 
-## 人工确认标记边界
+## Field Meaning
 
-以下内容是人工确认/风险标记,不是客户分类。禁止写入 `客户属性` 或 `客户类型`,只能写入候选预览 JSON 的 `risk_flags`、`备注` 或人工复核预览中:
+- `公司姓名`: company/customer name.
+- `客户属性`: one of the five broad customer attributes.
+- `客户类型`: one matching subtype under the selected customer attribute.
+- `官网链接`: real external company website only; do not store Facebook, Instagram, WhatsApp, YouTube, TikTok, LinkedIn, or Google Maps here.
+- `Facebook主页链接`: Facebook Page URL used for follow/DM/status tracking.
+- `linkined主页链接`: LinkedIn company/person URL. Keep the existing misspelling for workbook compatibility.
+- `google map链接`: Google Maps business/detail link. Keep query/cid/place identifiers when available.
+- `个人邮箱`: named person email when known.
+- `公共邮箱`: company/public email from public pages, `mailto:`, directories, or website Contact/About pages. Do not guess.
+- `线索等级`: `A`, `B`, or `C`.
+- `需人工确认`: use `否` when no manual review is needed; otherwise write concrete reasons separated by `;`.
+- `备注`: Chinese structured evidence, source, risk, batch-sales potential, and update history.
 
-- `主体归属待确认`:汽车渠道线索中,无法判断是独立公司,还是品牌官方主体、进口商直营网点或普通分店。
-- `新车业务待确认`:汽车渠道线索中,无法判断主营新整车,还是二手车、维修、配件、轮胎等业务。
-- `平台与行业渠道主体待确认`:平台与行业渠道线索中,无法确认是否为真实机构主体,还是普通个人页、内容号或非正式资源页。
-- `平台与行业渠道作用待确认`:平台与行业渠道线索中,无法确认是否具备汽车行业资源、渠道引荐、媒体传播或行业活动组织作用。
-- `仅电话/WhatsApp待人工确认`:线索已有目标价值信号,但只有电话或 WhatsApp,缺少官网、Facebook、LinkedIn、Google Maps 等可复核资料,需要人工联系确认主体和实际业务。
-- `详细信息待确认`:当 AI 因登录限制、页面屏蔽、地区限制或网站无法访问,不能读取 Facebook、LinkedIn、官网等内容时填写。
+## Customer Taxonomy
 
-客户分类仍必须严格保持五大客户属性和表内细分客户类型;不要用上述风险标记作为兜底分类值。
-
-人工复核不是低质线索兜底池。只有已经显示出渠道价值、但缺少一个关键确认点的客户,才标记为需人工复核。
-
-输出人工复核名单时,不要输出通用复核规则;只列出那些已经值得推进、但需要人工确认一个关键点的客户。低价值或无关客户应写为跳过/低优先级,不要包装成“建议人工复核”。
-
-### 两段式人工复核标准
-
-人工复核必须先看客户是否有目标价值,再看是否缺少需人工确认的关键点。只有两部分同时成立,才能列为需人工复核。
-
-第一部分:客户属性信号至少满足其一。
-
-- 汽车渠道合作伙伴信号:新车销售、汽车进口、分销、授权经销、汽车展厅、showroom、库存、dealer network 等。
-- 平台与行业渠道信号:汽车协会、商会、汽车行业平台、汽车媒体、行业活动组织、车商联盟、经销商资源平台或引荐机构等。
-
-第二部分:人工复核条件至少满足其一。
-
-- 汽车渠道类:主体归属待确认,或新车业务待确认。
-- 平台与行业渠道类:真实机构主体待确认,或汽车行业资源/渠道作用待确认。
-- 只有电话或 WhatsApp:线索已有目标价值信号,但缺少官网、Facebook、LinkedIn、Google Maps 等可复核资料,需要人工联系确认其主体和实际业务。
-
-不要把“只有电话/WhatsApp”单独作为人工复核理由。如果客户没有汽车渠道价值或平台行业价值,应跳过或标记低优先级,不列入人工复核名单。
-
-
-- 可进入人工复核的前提:至少有一个明确正向价值信号,例如汽车渠道、进口/分销、多品牌、showroom/展厅、库存/stock、中国品牌经验、商用车/车队场景、平台/协会/商会资源或可建联入口。
-- 人工复核的典型原因:看起来有渠道价值,但独立主体待确认、新整车业务待确认、或因页面/登录/地区限制导致详细信息无法读取。
-- 不进入人工复核的情况:纯维修/配件/轮胎/保险/洗车、个人卖家、OEM 官方主页/当地分公司、无汽车渠道价值信号且无可建联入口的记录。这些应该跳过或标记低优先级,而不是写成“需人工复核”。
-
-## 客户属性与细分类型
-
-| 客户属性 | 细分客户类型 |
+| 客户属性 | 允许的客户类型 |
 |---|---|
-| `汽车渠道合作伙伴` | 汽车进口商、全国代理商、全国分销商、区域分销商、多品牌经销商、商用车渠道商、中国品牌经销商、新能源或小型车渠道商 |
-| `批量采购与运营客户` | 汽车租赁公司、长期租赁公司、企业车队、物流配送企业、政府或机构采购方 |
-| `二手车转型候选` | 连锁二手车企业、实体二手车企业、进口二手车企业、新车与二手车综合企业 |
-| `平台与行业渠道` | 汽车交易平台、汽车协会、商会、车商联盟、经销商资源引荐机构 |
-| `生态支持资源` | 售后服务网络、备件供应与仓储企业、进口认证与上牌机构、金融保险机构、车辆物流企业 |
-
-分类优先级:先识别 `平台与行业渠道`,再识别更明确的 `批量采购与运营客户` 和 `二手车转型候选`,最后识别泛经销/进口/分销类的 `汽车渠道合作伙伴`。明显 `Occaz/occasion/二手` 的客户优先归为 `二手车转型候选`,除非证据显示其主要身份是汽车进口商或分销商。分类必须严格落入表内five customer attributes and matching subtypes;证据不足时必须继续用公开来源或 Playwright 浏览器补搜,不允许为了填满字段而默认塞进 `汽车渠道合作伙伴 / 多品牌经销商`。
-
-汽车渠道合作伙伴的字段判定必须满足:有实际新整车销售或分销业务、可识别独立经营主体、存在新增品牌可能、具有可建联入口。纯维修/配件/轮胎/个人卖家不写入汽车渠道合作伙伴;纯租车公司写入 `批量采购与运营客户 / 汽车租赁公司`;独立单品牌新车经销商可保留,但 `备注` 必须写明 `排他协议及新增品牌权限待确认`。
-
-## Sheets
-
-| Sheet | Purpose | Notes |
-|---|---|---|
-| `客户信息汇总表` | Consolidated master customer table | Generated after platform searches; includes `客户属性` and `客户来源`. |
-| `Facebook` | Facebook dealer and page collection | Standard columns plus optional `公司官网`. |
-| `Facebook对话记录` | Matched Messenger conversation history | Stores immutable original text, Chinese translation, effective-reply flag, intent, and next-step recommendation. Never merge message rows as customer rows. |
-| `LinkedIn` | Company/person outreach collection | Supports old and normalized LinkedIn headers. |
-| `Google Maps` | Google Maps local business collection | Supports public email extraction from merchant websites. |
-| `TikTok` | TikTok account collection | Placeholder / reserved channel. |
-| `测评博主` | Automotive KOLs | Reference channel, not priority dealer target. |
-| `协会商会` | Associations and chambers | Platform and industry channel source. |
-| `政府采购投标` | Government and tender channels | Reference channel. |
-| `本地汽车网站` | Local vertical auto websites | Local auto website leads. |
-| `汽车网站精选线索` | Curated local auto website leads | Standard fields plus `来源网站`. |
-| `填写说明` | Field guidance | Reference only. |
-| `附件` | Related links | Reference only. |
-
-## Standard Columns
-
-Standard sheets use:
-
-`序号`, `客户姓名/公司`, `国家`, `城市`, `客户属性`, `客户类型`, `主页/链接`, `联系人`, `职位`, `电话/WhatsApp`, `邮箱`, `主营业务`, `建联状态`, `下次跟进`, `备注`
-
-Meaning:
-
-- `客户姓名/公司`: company, account, or person name.
-- `国家`: target country, for example `摩洛哥`.
-- `城市`: city or cities; use `摩洛哥全国` when the lead covers the whole country.
-- `客户属性`: broad customer class.
-- `客户类型`:細分客户类型;不要把长业务描述放这里。
-- `主页/链接`: source URL from Facebook, TikTok, Google Maps, LinkedIn, or website.
-- `邮箱`: only public, traceable email; do not guess.
-- `主营业务`: Chinese business summary. Keep necessary professional terms such as showroom, importation, fleet, location, and vehicules neufs.
-- `建联状态`: outreach status.
-- `备注`: structured business notes, activity, brand signals, evidence, and risk notes.
-
-## LinkedIn Columns
-
-Preferred columns:
-
-`公司名称`, `国家`, `城市`, `客户属性`, `客户类型`, `linkin链接`, `联系人`, `职位`, `公司公共电话`, `公司公共邮箱(任一有效即可)`, `个人邮箱(不一定有效)`, `公司主营业务`, `建联状态`, `备注`
-
-Accepted aliases:
+| `汽车渠道合作伙伴` | `汽车进口商`、`全国代理商`、`全国分销商`、`区域分销商`、`多品牌经销商`、`商用车渠道商`、`中国品牌经销商`、`新能源或小型车渠道商` |
+| `批量采购与运营客户` | `汽车租赁公司`、`长期租赁公司`、`企业车队`、`物流配送企业`、`政府或机构采购方` |
+| `二手车转型候选` | `连锁二手车企业`、`实体二手车企业`、`进口二手车企业`、`新车与二手车综合企业` |
+| `平台与行业渠道` | `汽车交易平台`、`汽车协会`、`商会`、`车商联盟`、`经销商资源引荐机构` |
+| `生态支持资源` | `售后服务网络`、`备件供应与仓储企业`、`进口认证与上牌机构`、`金融保险机构`、`车辆物流企业` |
 
-- Link: `linkin链接`, `linkin连接`, `LinkedIn链接`, `主页/链接`
-- Attribute: `客户属性`, `客户大类`, `大类`
-- Type: `客户类型`, `细分客户类型`
-- Company phone: `公司公共电话`, `电话/WhatsApp`
-- Company email: `公司公共邮箱(任一有效即可)`, `公司公共邮箱`, `公共邮箱`, `邮箱`
-- Personal email: `个人邮箱(不一定有效)`, `个人邮箱`
-- Business: `公司主营业务`, `主营业务`
-- Status: `建联状态`, `建联情况`
+`主营业务` is not a summary-table column in the current reference format. When source records have `主营业务`, preserve it in `备注` as business evidence.
 
-Do not collapse company email and personal email when writing back to the LinkedIn sheet.
+## Lead Grade
 
-## Google Maps Columns
+- `A`: clear importer, agent, distributor, multi-brand group, national network, dealer-resource platform, chamber/association/channel resource, or strong batch-sales potential.
+- `B`: clear independent target but smaller or less proven, such as independent single-brand dealer, regional showroom, local dealership, or limited-scale channel.
+- `C`: retained lead with value signal, but subject ownership, new-vehicle business, channel role, or public information remains uncertain.
 
-Google Maps uses:
+## Manual Review Reasons
 
-`客户姓名/公司`, `国家`, `城市`, `客户属性`, `客户类型`, `主页/链接`, `联系人`, `职位`, `电话/WhatsApp`, `邮箱`, `主营业务`, `建联状态`, `下次跟进`, `备注`
+Do not write these reasons into `客户属性` or `客户类型`; they belong in `需人工确认`, `备注`, or preview JSON `risk_flags`:
 
-Write the Google Maps place URL to `主页/链接`. Write only public emails found on Google Maps text, `mailto:` links, or the merchant website into `邮箱`. If no public email is found, leave `邮箱` blank and summarize `官网未发现公开邮箱` or `无官网,未发现公开邮箱` in `备注`.
+- `主体归属待确认`
+- `新车业务待确认`
+- `平台与行业渠道主体待确认`
+- `平台与行业渠道作用待确认`
+- `仅电话/WhatsApp待人工确认`
+- `详细信息待确认`
+- `排他协议及新增品牌权限待确认`
 
-## Local Auto Website Columns
+Manual review is only for leads that already show target value but lack one key confirmation point. Low-value or irrelevant records should be skipped or marked low priority, not disguised as manual review.
 
-`汽车网站精选线索` uses:
+## Duplicate Handling
 
-`序号`, `客户姓名/公司`, `国家`, `城市`, `客户属性`, `客户类型`, `主页/链接`, `来源网站`, `联系人`, `职位`, `电话/WhatsApp`, `邮箱`, `主营业务`, `建联状态`, `下次跟进`, `备注`
+Before writing, normalize and match by public email, personal email, phone/WhatsApp, Facebook link, LinkedIn link, Google Maps link, company website, then conservative normalized company name. Duplicate records merge into one summary row; fill empty fields and append multi-value fields with `;`.
 
-Rules:
+## Facebook Conversation Sheet
 
-- `来源网站` must name the evidence source, such as `OtoMoto.ma`, `Wandaloo`, `Kerix`, `Kompass`, `Maroc Annuaire`, or `Telecontact`.
-- `主页/链接` should prefer the company website when known; otherwise use the source listing or directory URL.
-- `邮箱` must come from public page text, `mailto:`, directory listing, or merchant website Contact/About pages.
-- Duplicates are handled within this sheet by website, normalized company name, phone, email, or source-detail URL.
-- `备注` must summarize source evidence, business type, batch purchase capacity, inventory/branch/rental/import signals, contact evidence, and risks.
+`Facebook对话记录` remains a separate event log with fixed columns:
 
-## Customer Summary Sheet
+`记录ID`, `客户序号`, `客户姓名/公司`, `Facebook主页链接`, `Messenger线程ID`, `消息时间`, `消息方向`, `发件人`, `原文语言`, `对话原文`, `中文翻译`, `消息类型`, `是否有效客户回复`, `合作意向`, `意向判断依据`, `下一步建议`, `同步时间`, `来源账号/Profile ID`, `风险标记`
 
-`客户信息汇总表` 是搜索完成后的汇总 Sheet,不作为单个平台的原始写入目标。Facebook、LinkedIn、Google Maps、当地汽车网站、Moteur.ma、汽车网站精选线索、协会商会等渠道采集完成后,统一用 `scripts/common/build_customer_summary.py` 生成或覆盖。
+Conversation rows are never merged into customer rows. The latest customer reply summary updates the matched summary-table row. Keep `合作意向` and `意向判断依据` only for backward compatibility; the Skill no longer distinguishes Facebook reply quality, and must not write cooperation intent levels as the main customer status.
 
-Columns must exactly match the reference key-account workbook header:
-
-`公司姓名`, `国家`, `城市`, `客户类型`, `官网链接`, `联系人`, `职位`, `个人邮箱`, `联系人电话`, `Facebook主页链接`, `linkined主页链接`, `google map链接`, `公共电话/WhatsApp`, `公共邮箱`, `客户属性`, `建联状态`, `下次跟进`, `备注`
-
-Source mapping:
-
-- `Facebook` Sheet -> `Facebook`
-- `LinkedIn` Sheet -> `LinkedIn`
-- `Google Maps` Sheet -> `Google Maps`
-- `TikTok` Sheet -> `TikTok`
-- `协会商会` Sheet -> `平台与行业渠道`
-- `本地汽车网站` Sheet -> `当地汽车网站`
-- `Sheet11` -> `Moteur.ma`
-- `汽车网站精选线索` -> prefer row field `来源网站`; if empty, use `汽车网站精选线索`
-
-Summary rules:
-
-- Platform sheets are the source of truth. Do not write newly discovered leads directly to `客户信息汇总表` during browsing or scraping.
-- Rebuild `客户信息汇总表` only after a platform search round is complete or when the user explicitly asks to refresh the master table.
-- Merge duplicates first by public email, phone, normalized homepage/source link, and company website; then by conservative normalized company name.
-- Keep Google Maps query/cid/place identifiers when normalizing links so different Maps businesses do not collapse into one row.
-- Merge multiple contacts, positions, personal emails, public emails, contact phones, public phones, platform links, website links, and notes with `;` after de-duplication.
-- Do not add separate `来源Sheet`, `客户来源`, `来源链接汇总`, or `重复来源数` columns to the master sheet when the user asks to follow the key-account reference format.
-- Preserve source information inside `备注` in the format `合并来源数量:N;客户来源:...;来源Sheet:...;来自 <Sheet> row <n>`.
-- `合并来源数量` must be computed from the number of merged source rows, never from the serial number or Excel row number.
-- Merchant homepages are evidence fields, but should not be used alone as strong duplicate keys because group websites can represent multiple brands, branches, or dealer pages.
-
-## Duplicate Handling Before Scraping
-
-Before opening a Facebook homepage, LinkedIn About page, Google Maps place detail, local auto website listing, or merchant website for detail scraping, load the target sheet and normalize existing links and company names. If a collected URL or company name already exists in the sheet, skip it immediately and do not visit the homepage/detail page again.
-
-## Status Values
-
-Use these values consistently:
-
-- `未联系`
-- `已加好友`
-- `已加好友,待私信`
-- `已加好友,已发私信`
-- `已发私信`
-- `已发送邮件`
-- `已加 WhatsApp,待跟进`
-- `加好友失败`
-- `发送失败`
-- `已回复,有合作意向`
-- `已回复,待跟进`
-- `已回复,待澄清`
-- `已回复,暂不考虑`
-- `已回复,明确拒绝`
-
-
-For email sending, treat `已发送邮件`, `已发邮件`, `邮件已发送`, and `邮件发送成功` as already-sent statuses.
-
-## Facebook Conversation Columns
-
-`Facebook对话记录` uses:
-
-`记录ID`, `客户序号`, `客户姓名/公司`, `Facebook主页链接`, `Messenger线程ID`, `消息时间`, `消息方向`, `发件人`, `原文语言`, `对话原文`, `中文翻译`, `消息类型`, `是否有效客户回复`, `合作意向`, `意向判断依据`, `下一步建议`, `同步时间`, `来源账号/Profile ID`, `风险标记`.
-
-- Preserve `对话原文`; never overwrite it with Chinese.
-- Deduplicate by `记录ID`.
-- Use only the five reply intents defined in `references/facebook-conversation-sync.md`.
-- Keep this Sheet outside `PLATFORM_SHEETS` in the customer summary builder so message rows are never counted as customers.
-- Store the latest reply summary and intent in the matched Facebook customer row note; keep the fixed summary-sheet header unchanged.
-
-## Notes Field
-
-`备注` should be a concise structured summary. Do not paste one raw post as the whole note. Synthesize business-relevant observations such as main business, activity, recent posts, China-brand signals, batch purchase capacity, contact evidence, and risks.
-
-## Facebook 公司官网字段
-
-Facebook Sheet supports an additional field: `公司官网`.
-
-- `主页/链接`: the Facebook Page URL, used for duplicate checks, Facebook outreach, and status tracking.
-- `公司官网`: the merchant's external official website discovered from Facebook Page/About/action links.
-- If the workbook does not already contain `公司官网`, write-enabled scripts may append this column to the Facebook Sheet header.
-- Do not store Instagram, WhatsApp, YouTube, TikTok, LinkedIn, Google Maps, or other social/platform URLs in `公司官网`.
-- `客户信息汇总表` includes `公司官网` after `主页/链接`; duplicate company websites are merged with `;`.
-
-
-## Customer Attribute And Type Taxonomy
-
-`客户属性` may contain only the five broad classes below. `客户类型` may contain only a matching subtype from the same row. Do not write combined categories, multi-line labels, custom labels, or fallback unknown values into official workbook fields.
-
-| Customer attribute | Allowed customer types |
-|---|---|
-| `汽车渠道合作伙伴` | `汽车进口商` ; `全国代理商` ; `全国分销商` ; `区域分销商` ; `多品牌经销商` ; `商用车渠道商` ; `中国品牌经销商` ; `新能源或小型车渠道商` |
-| `批量采购与运营客户` | `汽车租赁公司` ; `长期租赁公司` ; `企业车队` ; `物流配送企业` ; `政府或机构采购方` |
-| `二手车转型候选` | `连锁二手车企业` ; `实体二手车企业` ; `进口二手车企业` ; `新车与二手车综合企业` |
-| `平台与行业渠道` | `汽车交易平台` ; `汽车协会` ; `商会` ; `车商联盟` ; `经销商资源引荐机构` |
-| `生态支持资源` | `售后服务网络` ; `备件供应与仓储企业` ; `进口认证与上牌机构` ; `金融保险机构` ; `车辆物流企业` |
+## Encoding Safety
 
-`主营业务` remains the business-description field and must not be renamed or used as a classification substitute.
+Follow `references/encoding-and-excel-writeback.md` for every workbook write. After writing, reopen the workbook and scan for repeated question marks. A write-back is not complete unless `question_mark_cells = 0`.

+ 55 - 200
references/search-strategy.md

@@ -1,237 +1,92 @@
-# Search Strategy
+# Search Strategy
 
 Use this reference when selecting target dealers, search terms, or screening logic for Wuling overseas dealer expansion.
 
 ## Objective
 
-Find local dealers, importers, automotive groups, and small/medium local vehicle sellers across Morocco, not only in one city. Prioritize channels with real sales capability, import experience, active customer reach, and room for practical first-batch cooperation.
+Find local independent automotive channel partners and platform/industry channels in the target country. The default project market is Morocco, but the rules must work for other countries. Do not limit national discovery to one city unless the user asks.
 
-Do not treat automaker local brand branches or official brand-country pages as target dealers. Examples to exclude: `BYD Maroc`, `BMW Maroc`, `JAC Motors Maroc`, `Jetour Maroc`, `Chery Maroc`, `Volkswagen Maroc`, or any page whose main identity is only `<brand> + Maroc/Morocco`. These pages can help discover the real distributor or dealer group behind the brand, but they should not be written to the outreach workbook as prospects.
+## Main Focus
 
-## Geographic Scope
+Prioritize:
 
-Default to `摩洛哥全国` unless the user explicitly asks for a specific city. City names are search waves, not automatic proof that the dealer is located only in that city.
+1. `汽车渠道合作伙伴`: importers, national agents, national distributors, regional distributors, multi-brand dealers, commercial-vehicle channels, China-brand dealers, and new-energy/small-vehicle channels.
+2. `平台与行业渠道`: automotive marketplaces, associations, chambers, dealer alliances, and dealer-resource referral organizations.
 
-Use a mixed national + city coverage pattern:
+Still classify useful secondary leads into `批量采购与运营客户`, `二手车转型候选`, and `生态支持资源` when evidence fits.
 
-- National queries: `Maroc`, `Morocco`, import, multibrand, showroom, dealer, used-car, commercial-vehicle, and China-vehicle channel terms.
-- Major-city waves: Casablanca, Rabat, Marrakech, Tanger, Fes, Agadir, Meknes, Oujda, Kenitra, Tetouan, Nador, Safi.
-- For small dealers, include `voiture occasion {city}` and `showroom auto {city}`, not only large group terms.
-- For China-brand discovery, search for channel expressions such as `concessionnaire`, `distributeur`, `importateur`, `groupe`, or `showroom` around the brand; avoid using naked brand-country names as final leads.
+## Direct Write Rule
 
+Search scripts must preview first. When the user confirms `--write-excel`, write confirmed customer records directly to `客户信息汇总表`. Do not create or append customer rows in channel sheets. Channel names are evidence only and should be preserved in link columns or `备注`.
 
-## Customer Attribute Focus
+Before opening a detail page, load existing `客户信息汇总表` names, emails, phones, websites, Facebook links, LinkedIn links, and Google Maps links. If the same customer already exists, skip unnecessary homepage/detail browsing and only supplement missing information when writing.
 
-Lead discovery and scoring must classify every lead into `客户属性` and `客户类型` while keeping `主营业务` as a separate business-description field.
+## Automotive Channel Screening
 
-Primary search focus:
+A lead can be treated as `汽车渠道合作伙伴` only when it satisfies these checks:
 
-1. `汽车渠道合作伙伴`: importers, national agents, national distributors, regional distributors, and multi-brand dealers. These are the main Wuling batch-sales channel prospects.
-2. `平台与行业渠道`: automotive marketplaces, associations, chambers, dealer alliances, and dealer-resource referral organizations. These are mainly used to find channel partners, introductions, and structured dealer networks.
+1. Actual new-vehicle sales or distribution: showroom, new-vehicle sales, import, dealership, distribution, stock, dealer network, or `vehicules neufs` evidence.
+2. Independent operating entity: identifiable independent company, group, importer, distributor, or dealer. Exclude OEM country pages, brand local subsidiaries, importer-owned direct outlets, and ordinary brand branches such as `BYD Maroc`, `BMW Maroc`, `JAC Motors Maroc`.
+3. Possible room to add a brand: prioritize importers, distributors, and multi-brand dealers. Independent single-brand new-car dealers may be kept, but mark `排他协议及新增品牌权限待确认`.
+4. Usable outreach entry: at least one phone, WhatsApp, email, Facebook, LinkedIn, website Contact page, or responsible person.
 
-Secondary but still classified:
+Reject or downgrade repair-only, spare-parts-only, tire-only, diagnostic, insurance, car-wash, individual seller, unrelated content, and pure rental companies. Pure rental/fleet companies route to `批量采购与运营客户 / 汽车租赁公司`.
 
-- `批量采购与运营客户`: rental/fleet operators, currently mapped mainly as `汽车租赁公司`.
-- `二手车转型候选`: used-car businesses that may test affordable new vehicles as a complement to used-car stock.
+## Scoring
 
-Do not put long business descriptions into `客户属性` or `客户类型`. Write the actual business summary into `主营业务`, and write source evidence, risk, and batch-purchase reasoning into `备注`.
-## 人工确认标记边界
+Candidate previews must expose `score`, `score_reasons`, `risk_flags`, and `recommended_action`.
 
-以下内容是人工确认/风险标记,不是客户分类。禁止写入 `客户属性` 或 `客户类型`,只能写入候选预览 JSON 的 `risk_flags`、`备注` 或人工复核预览中:
-
-- `主体归属待确认`:汽车渠道线索中,无法判断是独立公司,还是品牌官方主体、进口商直营网点或普通分店。
-- `新车业务待确认`:汽车渠道线索中,无法判断主营新整车,还是二手车、维修、配件、轮胎等业务。
-- `平台与行业渠道主体待确认`:平台与行业渠道线索中,无法确认是否为真实机构主体,还是普通个人页、内容号或非正式资源页。
-- `平台与行业渠道作用待确认`:平台与行业渠道线索中,无法确认是否具备汽车行业资源、渠道引荐、媒体传播或行业活动组织作用。
-- `仅电话/WhatsApp待人工确认`:线索已有目标价值信号,但只有电话或 WhatsApp,缺少官网、Facebook、LinkedIn、Google Maps 等可复核资料,需要人工联系确认主体和实际业务。
-- `详细信息待确认`:当 AI 因登录限制、页面屏蔽、地区限制或网站无法访问,不能读取 Facebook、LinkedIn、官网等内容时填写。
-
-客户分类仍必须严格保持五大客户属性和表内细分客户类型;不要用上述风险标记作为兜底分类值。
-
-人工复核不是低质线索兜底池。只有已经显示出渠道价值、但缺少一个关键确认点的客户,才标记为需人工复核。
-
-输出人工复核名单时,不要输出通用复核规则;只列出那些已经值得推进、但需要人工确认一个关键点的客户。低价值或无关客户应写为跳过/低优先级,不要包装成“建议人工复核”。
-
-### 两段式人工复核标准
-
-人工复核必须先看客户是否有目标价值,再看是否缺少需人工确认的关键点。只有两部分同时成立,才能列为需人工复核。
-
-第一部分:客户属性信号至少满足其一。
-
-- 汽车渠道合作伙伴信号:新车销售、汽车进口、分销、授权经销、汽车展厅、showroom、库存、dealer network 等。
-- 平台与行业渠道信号:汽车协会、商会、汽车行业平台、汽车媒体、行业活动组织、车商联盟、经销商资源平台或引荐机构等。
-
-第二部分:人工复核条件至少满足其一。
-
-- 汽车渠道类:主体归属待确认,或新车业务待确认。
-- 平台与行业渠道类:真实机构主体待确认,或汽车行业资源/渠道作用待确认。
-- 只有电话或 WhatsApp:线索已有目标价值信号,但缺少官网、Facebook、LinkedIn、Google Maps 等可复核资料,需要人工联系确认其主体和实际业务。
-
-不要把“只有电话/WhatsApp”单独作为人工复核理由。如果客户没有汽车渠道价值或平台行业价值,应跳过或标记低优先级,不列入人工复核名单。
-
-
-- 可进入人工复核的前提:至少有一个明确正向价值信号,例如汽车渠道、进口/分销、多品牌、showroom/展厅、库存/stock、中国品牌经验、商用车/车队场景、平台/协会/商会资源或可建联入口。
-- 人工复核的典型原因:看起来有渠道价值,但独立主体待确认、新整车业务待确认、或因页面/登录/地区限制导致详细信息无法读取。
-- 不进入人工复核的情况:纯维修/配件/轮胎/保险/洗车、个人卖家、OEM 官方主页/当地分公司、无汽车渠道价值信号且无可建联入口的记录。这些应该跳过或标记低优先级,而不是写成“需人工复核”。
-
-## Target Priority
-
-| Priority | Target type | Why it matters |
-|----------|-------------|----------------|
-| P0 | Independent dealers or dealer groups with China-brand experience | They have proven local acceptance, import paths, and after-sales familiarity without being just the OEM brand office. |
-| P1 | Multi-brand automotive dealer groups | They have showrooms, sales teams, and room to add a practical new brand. |
-| P2 | Used-car importers, large used-car dealers, and high-stock local sellers | They understand price-sensitive customers and can position Wuling as an affordable new-vehicle option. |
-| P3 | Small/medium local dealers and showrooms | They may be suitable for city-level distribution, sub-dealer relationships, or lead referral partnerships. |
-| P4 | Auto traders, importers, commercial-vehicle, truck, bus, fleet, or rental operators | They may support trial orders, utility vehicles, fleet procurement, and income-generating vehicle use cases. |
-| P4 | Local auto websites and B2B directories | They surface formal companies, public emails, multi-branch networks, inventory signals, and importer/fleet clues outside social platforms. |
-| Reject | OEM local branch or official brand-country page | It is usually the brand's own marketing/subsidiary page, not a channel prospect to contact as a new dealer. |
-
-## Recommended Search Terms For Morocco
-
-| Goal | Terms |
-|------|-------|
-| China-vehicle channel discovery | `concessionnaire voiture chinoise Maroc`, `distributeur voitures chinoises Maroc`, `importateur voitures chinoises Maroc`, `showroom voiture chinoise Maroc`, `groupe automobile marques chinoises Maroc` |
-| Brand-adjacent dealer discovery | `concessionnaire Chery Maroc`, `concessionnaire DFSK Maroc`, `concessionnaire Foton Maroc`, `concessionnaire JAC Maroc`, `concessionnaire Jetour Maroc`, `concessionnaire Geely Maroc` |
-| Commercial-vehicle channel discovery | `concessionnaire utilitaire chinois Maroc`, `importateur camion chinois Maroc`, `distributeur camion chinois Maroc`, `concessionnaire camion Maroc`, `camion chinois Maroc` |
-| China vehicle importers | `import voiture chine Maroc`, `voiture chinoise Maroc`, `importateur auto chine Maroc` |
-| Multi-brand dealer groups | `groupe automobile Maroc`, `concessionnaire automobile Maroc`, `concessionnaire multimarque Maroc` |
-| Used-car and small dealer discovery | `voiture occasion Maroc`, `showroom auto Maroc`, `concessionnaire automobile {city}`, `voiture occasion {city}`, `showroom auto {city}` |
-| Google Maps local dealer discovery | `concessionnaire automobile Maroc`, `showroom auto Maroc`, `voiture occasion Maroc`, `importateur automobile Maroc`, `concessionnaire utilitaire Maroc`, plus city waves |
-| Local auto website discovery | OtoMoto, Wandaloo, Kerix, Kompass, Maroc Annuaire, Telecontact; use `professionnel`, `importation`, `véhicules neufs`, `location`, `utilitaire`, `concessionnaire`, `showroom`, `stock`, and fleet terms |
-| Known target groups | `Auto Hall Group Morocco`, `Sopriam Maroc`, `Bugshan Automotive Morocco`, and other independent distributor or dealer-group names |
-
-
-
-## Local Auto Website Channel
-
-Use this channel when the user asks for Moroccan local automotive websites, business directories, or non-social lead sources. The default source set is OtoMoto, Wandaloo, Kerix, Kompass, Maroc Annuaire, and Telecontact. Do not include Moteur by default when the user says it has already been checked.
-
-Prioritize records with these concrete signals:
-
-- multiple branches, `points de vente`, `réseau`, `succursales`, or city coverage;
-- large inventory, stock pages, `professionnel`, or marketplace seller pages;
-- company website plus public business email;
-- `importation`, `véhicules neufs`, `distributeur`, `concessionnaire`, or `showroom`;
-- `location`, `LLD`, fleet, utility vehicles, microvans, MPVs, trucks, buses, or economy vehicles;
-- evidence that the company can buy, digest, rent, or distribute a batch of vehicles.
-
-Reject platform category pages, add-company pages, generic search-result pages, repair-only shops, spare-parts-only shops, insurance, unrelated B2B service providers, and official brand-country pages. If a page is only a directory/listing page, use it as evidence to identify real companies rather than writing the directory page as a customer.
-
-Write confirmed leads to `汽车网站精选线索`. This sheet has a dedicated `来源网站` column. If the same company appears again inside that sheet, do not create a duplicate row; merge `来源网站` and append evidence to `备注`.
-
-## Terms To Avoid As Primary Searches Or Final Leads
-
-| Term | Reason |
-|------|--------|
-| Naked brand-country terms such as `BYD Maroc`, `BMW Maroc`, `Jetour Maroc`, `JAC Motors Maroc`, `<brand> Morocco` | Usually returns official brand pages/local subsidiaries rather than independent dealer prospects. Use only to discover the real distributor behind the page. |
-| City-only broad searches such as `concessionnaire voiture Casablanca` | Too narrow for national discovery and often returns repair shops, rental firms, or unrelated profiles. |
-| `car dealer Morocco` | English query is too generic and may surface overseas or non-local results. |
-| `diagnostic auto`, `garage réparation`, `pieces auto` | Usually repair/spare-parts intent rather than Wuling dealer expansion. |
-| `location voiture` | Use only as a secondary fleet/rental angle; primary results can be small rental shops. |
-
-## Screening Criteria
-
-`汽车渠道合作伙伴` must satisfy these rules before being treated as a priority channel lead:
-
-1. **Actual new-vehicle sales or distribution business**: keep companies with showroom, new-vehicle sales, import, dealership, distribution, stock, or dealer-network evidence. Do not classify repair-only shops, spare-parts-only shops, tire shops, car-wash/diagnostic services, or individual sellers as automotive channel partners. Pure rental companies should be routed to `批量采购与运营客户 / 汽车租赁公司`.
-2. **Independent operating entity**: the lead should have an identifiable independent company name, registered entity, dealer group, importer, or distributor. Exclude brand local subsidiaries, official brand pages, importer-owned direct outlets, and ordinary branch/store pages such as `BYD Maroc`, `BMW Maroc`, or similar brand official entities.
-3. **Possible room to add a brand**: prioritize importers, distributors, and multi-brand dealers. Independent single-brand new-car dealers may be kept, but mark `排他协议及新增品牌权限待确认` in `risk_flags` or `备注`.
-4. **Usable outreach entry**: require at least one reachable path: phone, WhatsApp, email, Facebook, LinkedIn, website Contact page, or identifiable responsible person. Page activity, follower count, and post interaction are only auxiliary signals, not mandatory criteria.
-
-Reject or mark low priority when the result is only repair, spare parts, tires, diagnostics, insurance, car wash, individual sales, unrelated content, or a brand local branch/official brand page unless the user explicitly asks for those channels.
-
-## Candidate Scoring Criteria
-
-Candidate previews must expose `score`, `score_reasons`, `risk_flags`, and `recommended_action`. The scoring system is evidence-based:
-
-| Rule | Score / action |
+| Evidence | Score/action |
 |---|---|
-| Real new-vehicle sales, showroom, stock, `concessionnaire`, or `véhicules neufs` signal | `+3` |
-| Import, distribution, agent, group, `réseau`, dealer-network capability | `+3` |
-| Multi-brand operation, `multimarque`, `multi-brand`, or multi-brand showroom | `+2` |
-| Usable contact entry: phone, WhatsApp, email, Facebook, LinkedIn, website Contact | `+2` |
-| China-brand, commercial-vehicle, fleet, or Morocco-local context | `+1` auxiliary signal only |
-| Brand local subsidiary, official brand page, importer direct outlet, ordinary brand branch/store | `-10`, `recommended_action=skip_brand_branch` |
-| Repair-only, spare-parts-only, tire-only, diagnostic, insurance, wash/service-only result | `-6`, `recommended_action=skip_non_channel` |
+| New-vehicle sales, showroom, stock, `concessionnaire`, `vehicules neufs` | `+3` |
+| Import, distribution, agent, group, `reseau`, dealer network | `+3` |
+| Multi-brand operation, `multimarque`, multi-brand showroom | `+2` |
+| Usable contact entry | `+2` |
+| China-brand, commercial-vehicle, fleet, local-market context | `+1` auxiliary |
+| Brand local subsidiary, official brand page, direct outlet, ordinary branch | `-10`, `recommended_action=skip_brand_branch` |
+| Repair/spare-parts/tire/diagnostic/insurance/wash/service-only | `-6`, `recommended_action=skip_non_channel` |
 | Individual seller or personal profile | `-5`, `recommended_action=skip_non_channel` |
-| Pure rental/fleet company without sales/import/distribution evidence | route to `批量采购与运营客户 / 汽车租赁公司`, not `汽车渠道合作伙伴` |
-| No new-vehicle sales, import, distribution, or showroom evidence | Deduct and require deep search first; send to manual review only when the lead already has clear channel value but still lacks one key confirmation point. |
-
-Activity, followers, recent posts, and engagement can support confidence after a lead passes the business criteria, but they must not compensate for missing sales/distribution evidence or missing outreach entry.
-
-## OEM Brand-Branch Exclusion
-
-Reject candidates when the page/company identity is just an automotive brand plus a country or official marker, for example:
-
-- `BYD Maroc`, `BYD Morocco`, `bydmaroc`
-- `BMW Maroc`, `bmwmaroc`
-- `JAC Motors Maroc`, `jacmotorsmaroc`
-- `Jetour Maroc`, `jetour-maroc`
-- `Volkswagen Maroc`, `Mercedes-Benz Maroc`, etc.
-
-Do not reject independent dealer names that mention a brand in business scope, such as `La Continentale Auto` distributing Geely/KIA/Fiat or `Prince Auto` selling Volkswagen/Audi group brands. The deciding factor is whether the target entity is a channel operator, not whether a brand keyword appears.
 
-## Browser Execution Rule
+Activity, followers, recent posts, and engagement are confidence signals only; they cannot replace sales/distribution evidence or contactability.
 
-Search, preview, deep scrape, website enrichment, and social outreach flows must use Playwright for every browser operation. This includes Facebook, LinkedIn, Google Maps, local automotive websites, and merchant websites discovered from social pages or Maps.
+## Manual Review
 
-Candidate collection should use Playwright DOM extraction and locators. Deep scraping should use Playwright to open pages and read public text, links, buttons, `mailto:` links, visible bare domains, and contact sections. When a page layout changes or a target element cannot be located safely, stop with a clear reason rather than using coordinate clicks or arbitrary input boxes.
-## Search Workflow
+Manual review requires both target value and one missing key confirmation.
 
-Use a staged funnel rather than opening every search result:
+Target value must include at least one of:
 
-1. Load existing workbook links and company names first, then build a de-duplication set.
-2. Search national dealer, importer, group, used-car, commercial-vehicle, China-vehicle channel, and city-wave terms.
-3. Collect page URLs only and save a candidate preview.
-4. Score each candidate before opening its homepage.
-5. Skip candidates that look like OEM local branches or official brand-country pages.
-6. Deep scrape only candidates above the score threshold or manually selected by the user. For Google Maps, deep scrape place details and then the merchant website to find public emails.
-7. Write back to Excel only after the user asks for confirmed output.
+- automotive channel signal: new-car sales, import, distribution, authorized dealership, showroom, stock, dealer network;
+- platform/industry signal: association, chamber, automotive platform, media, industry event organizer, dealer alliance, dealer-resource platform.
 
-For executable details, see `references/search-workflow.md`.
+Manual review reason must include at least one of:
 
-## Candidate Output
+- automotive channel: `主体归属待确认` or `新车业务待确认`;
+- platform/industry channel: `平台与行业渠道主体待确认` or `平台与行业渠道作用待确认`;
+- contact-only lead: `仅电话/WhatsApp待人工确认` when target value exists but there is no website/Facebook/LinkedIn/Google Maps evidence;
+- access-limited lead: `详细信息待确认` when public pages cannot be read due login, region, blocking, or site failure.
 
-A useful candidate preview should include:
+## Search Terms
 
-- `url`
-- `source_queries`
-- `score`
-- `score_reasons`
-- `risk_flags`
-- `recommended_action`
+For Morocco, mix national and city-wave terms:
 
-Do not treat candidate previews as final leads. Final leads require either deep scraping or explicit user approval.
-## Google Maps Email Extraction
+- China-vehicle channels: `concessionnaire voiture chinoise Maroc`, `distributeur voitures chinoises Maroc`, `importateur voitures chinoises Maroc`, `showroom voiture chinoise Maroc`.
+- Multi-brand groups: `groupe automobile Maroc`, `concessionnaire multimarque Maroc`, `distributeur automobile Maroc`.
+- Commercial channels: `concessionnaire utilitaire Maroc`, `distributeur camion Maroc`, `importateur camion chinois Maroc`.
+- Platform/industry: `association automobile Maroc`, `chambre commerce automobile Maroc`, `salon automobile Maroc`, `annuaire concessionnaire Maroc`.
+- City waves: Casablanca, Rabat, Marrakech, Tanger, Fes, Agadir, Meknes, Oujda, Kenitra, Tetouan, Nador, Safi.
 
-Use Google Maps as a local-business discovery channel, not as an email generator. Email values must come from public evidence only:
+Use naked brand-country searches only to identify the real independent distributor behind the brand page; do not write the official brand page as a prospect.
 
-- Google Maps place text if a public email is visible.
-- Merchant website `mailto:` links.
-- Merchant website body text on homepage, Contact, About, Nous contacter, À propos, or similar pages.
+## Google Maps And Website Emails
 
-Do not guess email patterns from domain names. If an email is found only on an OEM brand-country website such as `byd-maroc.com`, use it as a clue only and do not write it to `邮箱` for the independent dealer record. If no usable public email is found, keep `邮箱` empty and write `官网未发现公开邮箱` or `无官网,未发现公开邮箱` in `备注`.
+Google Maps is a local-business discovery channel, not an email generator. Emails must come from Google Maps visible text, merchant website `mailto:`, or public Contact/About pages. Do not guess email patterns from domains.
 
-## Facebook Website Deep Scrape
+## Facebook Deep Search
 
-When Facebook deep scraping is enabled, treat the Facebook Page as the first evidence layer, not the only source of truth.
+When Facebook deep scraping is enabled, use Playwright in this order:
 
-- Keep the Facebook Page URL in `主页/链接`.
-- Before visiting About, first read the Facebook Page home contact/profile area and action buttons. Extract a real merchant website into `公司官网` when visible on the home page, About section, or Page action links; About and website results must not overwrite home-page contact fields unless the field is empty.
-- Reject social/platform links as company websites: Facebook, Instagram, WhatsApp, YouTube, TikTok, LinkedIn, and Google Maps.
-- After home contact data and About have been collected, visit public website pages such as homepage, Contact, About, Nous contacter, A propos, Services, Vehicules, Occasion, and Location.
-- Use website text to improve customer type, main business, batch-purchase potential, and contact evidence.
-- Notes and analysis should be Chinese except retained domain terms such as showroom, importation, fleet, location, and vehicules neufs.
-- Do not guess email addresses, prices, MOQ, inventory, delivery dates, model lists, or cooperation terms.
-
-
-
-Strict taxonomy for all search outputs:
-
-| Customer attribute | Allowed customer types |
-|---|---|
-| `汽车渠道合作伙伴` | `汽车进口商` ; `全国代理商` ; `全国分销商` ; `区域分销商` ; `多品牌经销商` ; `商用车渠道商` ; `中国品牌经销商` ; `新能源或小型车渠道商` |
-| `批量采购与运营客户` | `汽车租赁公司` ; `长期租赁公司` ; `企业车队` ; `物流配送企业` ; `政府或机构采购方` |
-| `二手车转型候选` | `连锁二手车企业` ; `实体二手车企业` ; `进口二手车企业` ; `新车与二手车综合企业` |
-| `平台与行业渠道` | `汽车交易平台` ; `汽车协会` ; `商会` ; `车商联盟` ; `经销商资源引荐机构` |
-| `生态支持资源` | `售后服务网络` ; `备件供应与仓储企业` ; `进口认证与上牌机构` ; `金融保险机构` ; `车辆物流企业` |
+1. Read Facebook home contact/profile area and action buttons.
+2. Read About.
+3. Extract real company website, excluding social/platform links.
+4. Visit public website pages: homepage, Contact, About, Services, Vehicules, Occasion, Location.
+5. Write website, email, phone, business evidence, lead grade, manual review reason, and Chinese notes directly to `客户信息汇总表`.

+ 73 - 0
references/workflow-automation.md

@@ -0,0 +1,73 @@
+# Automated Workflow
+
+This reference defines the default automation layer for the Wuling overseas dealer expansion skill. It is an orchestration layer, not a replacement for the safety rules in `outreach-rules.md`.
+
+## Trigger
+
+Use `scripts/workflows/run_dealer_pipeline.py` when the user asks to automate the process, run the daily dealer pipeline, refresh the workbook/dashboard, or synchronize the latest customer table after local updates.
+
+Typical command:
+
+```bash
+python scripts/workflows/run_dealer_pipeline.py --config workflow_config.json --write-workbook
+```
+
+Review-only command:
+
+```bash
+python scripts/workflows/run_dealer_pipeline.py --config workflow_config.json --review-only
+```
+
+## Config
+
+The project root may contain `workflow_config.json`. If it does not exist, use `assets/workflow_config.example.json` as the template and tell the user that the project has not been configured yet.
+
+Do not put secrets in the skill package. SMTP codes, browser credentials, cookies, Feishu tokens, and customer workbooks must remain project/runtime data.
+
+Important fields:
+
+- `country`: target country for search and reporting.
+- `profile_id`: AdsPower profile ID for browser stages.
+- `excel`: optional workbook path. If empty, use the workbook resolver.
+- `summary_sheet_name`: default `客户信息汇总表`.
+- `daily_outreach_target`: dashboard/business target, currently 20.
+- `default_stages`: default safe stages, usually `summary`, `dashboard`, `feishu_check`.
+- `sync_feishu`: when true, the agent must check `feishu_sync_config.json` after local writeback.
+- `send_email` and `send_facebook_dm`: must remain false unless the user explicitly asks for sending and confirms the preview.
+
+## Default Pipeline
+
+1. Resolve workbook.
+2. Rebuild or preview `客户信息汇总表`.
+3. Generate the customer dashboard from the summary sheet and `Facebook对话记录` when present.
+4. Check Feishu sync configuration.
+5. If Feishu is enabled, the agent must call WorkBuddy `lark-sheets` after local Excel writeback and verify remote row count/header consistency.
+6. Write a JSON report under `runs/YYYYMMDD/<run_id>/`.
+
+## Human Confirmation Boundaries
+
+The automated pipeline may refresh local summaries and dashboards. It must not silently send messages or emails.
+
+These actions always require a full preview in the chat and explicit confirmation:
+
+- Send emails.
+- Follow or DM customers on Facebook.
+- Send LinkedIn messages or connection notes.
+- Write Facebook conversation analysis back to Excel after agent translation/analysis.
+
+## Browser Rules
+
+Any browser stage must use AdsPower + Playwright. The browser must remain open even when the automation fails. Cross-customer or external-page transitions use major pacing, page-internal steps use minor pacing, and DOM waits remain technical waits as defined in `outreach-rules.md`.
+
+## Feishu Sync
+
+The Python workflow only reports whether Feishu sync is required. The agent is responsible for invoking the WorkBuddy Feishu/Lark sheets plugin because plugin calls are not available inside the local Python process.
+
+If `feishu_sync_config.json` exists and `enabled=true`, local writeback is not considered fully complete until the agent attempts Feishu synchronization or reports a plugin/authorization blocker.
+
+## Failure Handling
+
+- If no workbook is found, stop and report the missing workbook. Only create a workbook from the blank skill template when a write-mode run explicitly allows it.
+- If dashboard generation fails, keep the workbook result and report the dashboard failure.
+- If Feishu sync fails, do not roll back the local workbook.
+- If a browser stage hits verification, throttling, security warning, or thread mismatch, stop the batch and keep the AdsPower browser open.

+ 120 - 542
scripts/common/build_customer_summary.py

@@ -1,16 +1,12 @@
-# -*- coding: utf-8 -*-
-"""Build the consolidated customer summary sheet for the outreach workbook."""
+'''Build or repair the single customer summary sheet.'''
 from __future__ import annotations
 
 import argparse
 import json
 import re
-from collections import Counter, defaultdict
-from dataclasses import dataclass, field
-from datetime import datetime
+from collections import Counter
 from pathlib import Path
 from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
-from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
 
 from openpyxl import load_workbook
 from openpyxl.styles import Font, PatternFill
@@ -19,495 +15,97 @@ from openpyxl.utils import get_column_letter
 try:
     from .artifact_manager import create_backup_once, resolve_artifact_path
     from .workbook_resolver import resolve_workbook_path
-    from .customer_taxonomy import (
-        CUSTOMER_TAXONOMY,
-        VALID_ATTRIBUTES,
-        VALID_CUSTOMER_TYPES,
-        classify_attribute_type,
-        is_valid_pair,
-        normalize_existing_classification,
-    )
-except ImportError:  # pragma: no cover - supports direct CLI execution
+    from .direct_summary import SUMMARY_COLUMNS, SUMMARY_SHEET, identity_keys, merge_record, normalize_summary_record
+except ImportError:  # pragma: no cover
     from artifact_manager import create_backup_once, resolve_artifact_path
     from workbook_resolver import resolve_workbook_path
-    from customer_taxonomy import (
-        CUSTOMER_TAXONOMY,
-        VALID_ATTRIBUTES,
-        VALID_CUSTOMER_TYPES,
-        classify_attribute_type,
-        is_valid_pair,
-        normalize_existing_classification,
-    )
-
-SUMMARY_SHEET = "客户信息汇总表"
-DEFAULT_COUNTRY = "摩洛哥"
-DEFAULT_STATUS = "未联系"
-PLATFORM_SHEETS = [
-    "Facebook",
-    "Google Maps",
-    "LinkedIn",
-    "TikTok",
-    "协会商会",
-    "本地汽车网站",
-    "Sheet11",
-    "汽车网站精选线索",
-]
-
-# Keep this header exactly aligned with the user's reference workbook:
-# 摩洛哥重点客户50家-汽车渠道与平台行业渠道.xlsx
-SUMMARY_HEADERS = [
-    "公司姓名",
-    "国家",
-    "城市",
-    "客户类型",
-    "官网链接",
-    "联系人",
-    "职位",
-    "个人邮箱",
-    "联系人电话",
-    "Facebook主页链接",
-    "linkined主页链接",
-    "google map链接",
-    "公共电话/WhatsApp",
-    "公共邮箱",
-    "客户属性",
-    "建联状态",
-    "下次跟进",
-    "备注",
-]
-
-HEADER_ALIASES = {
-    "name": ["公司姓名", "客户姓名/公司", "公司名称", "客户名称", "Name", "Company"],
-    "country": ["国家", "Country"],
-    "city": ["城市", "City"],
-    "attribute": ["客户属性", "客户大类", "大类", "Customer Attribute"],
-    "type": ["客户类型", "细分客户类型", "类型", "Customer Type"],
-    "link": ["主页/链接", "公司链接", "链接", "Link", "URL"],
-    "facebook_link": ["Facebook主页链接", "Facebook链接", "facebook链接", "主页/链接"],
-    "linkedin_link": ["linkined主页链接", "LinkedIn主页链接", "LinkedIn链接", "linkin链接", "linkin连接"],
-    "google_maps_link": ["google map链接", "Google Maps链接", "Google Map链接", "地图链接"],
-    "website": ["官网链接", "公司官网", "官网", "官方网站", "Website", "Company Website"],
-    "source_site": ["来源网站", "来源", "Source"],
-    "contact": ["联系人", "姓名", "Contact"],
-    "position": ["职位", "职务", "Position", "Title"],
-    "public_phone": ["公共电话/WhatsApp", "电话/WhatsApp", "公司公共电话", "电话", "WhatsApp", "Phone"],
-    "contact_phone": ["联系人电话", "个人电话", "联系电话"],
-    "public_email": ["公共邮箱", "邮箱", "公司公共邮箱(任一有效即可)", "公司公共邮箱", "Email"],
-    "personal_email": ["个人邮箱", "个人邮箱(不一定有效)"],
-    "business": ["主营业务", "公司主营业务", "业务", "Business"],
-    "status": ["建联状态", "建联情况", "状态", "Status"],
-    "next_followup": ["下次跟进", "下次跟进时间", "Next Follow-up"],
-    "note": ["备注", "说明", "Notes"],
+    from direct_summary import SUMMARY_COLUMNS, SUMMARY_SHEET, identity_keys, merge_record, normalize_summary_record
+
+CONVERSATION_SHEET = 'Facebook\u5bf9\u8bdd\u8bb0\u5f55'
+SKIP_SHEETS = {'\u586b\u5199\u8bf4\u660e', '\u9644\u4ef6', CONVERSATION_SHEET}
+LEGACY_SOURCE_SHEETS = {
+    'Facebook', 'LinkedIn', 'Google Maps', 'TikTok', '\u534f\u4f1a\u5546\u4f1a',
+    '\u672c\u5730\u6c7d\u8f66\u7f51\u7ad9', '\u6c7d\u8f66\u7f51\u7ad9\u7cbe\u9009\u7ebf\u7d22',
+    'Sheet11', '\u653f\u5e9c\u91c7\u8d2d\u6295\u6807', '\u6d4b\u8bc4\u535a\u4e3b',
 }
-AUTO_NAME_KEYWORDS = ["auto", "autos", "automobile", "automobiles", "cars", "car center", "motors", "garage"]
-BRAND_CONTEXT_KEYWORDS = ["renault", "dacia", "seat", "cupra", "honda", "peugeot", "citroen", "citroën", "hyundai", "kia", "toyota", "nissan", "ford", "fiat", "volkswagen", "vw", "bmw", "mercedes", "audi", "opel", "skoda", "suzuki", "mazda", "jeep", "chery", "geely", "dfsk", "jac", "byd", "mg", "haval", "foton", "changan", "alfa romeo", "škoda", "porsche", "jaguar", "land rover", "ds", "gwm", "mini", "stellantis", "leapmotor", "sopriam"]
-SOURCE_MAP = {
-    "Facebook": "Facebook",
-    "LinkedIn": "LinkedIn",
-    "Google Maps": "Google Maps",
-    "TikTok": "TikTok",
-    "协会商会": "平台与行业渠道",
-    "本地汽车网站": "当地汽车网站",
-    "Sheet11": "Moteur.ma",
-}
-GENERIC_NAME_WORDS = {
-    "sarl", "sa", "sas", "ltd", "llc", "inc", "co", "company", "groupe", "group", "maroc", "morocco", "officiel", "official",
-}
-# Strict five-attribute taxonomy is imported from customer_taxonomy.py.
-
-EMAIL_RE = re.compile(r"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}", re.I)
-PHONE_RE = re.compile(r"(?:\+?\d[\d\s()./-]{6,}\d)")
-SPLIT_RE = re.compile(r"[;;,,\n]+")
-
-
-@dataclass
-class Record:
-    idx: int
-    sheet: str
-    row: int
-    values: Dict[str, str]
-    sources: List[str] = field(default_factory=list)
-    source_links: List[str] = field(default_factory=list)
-
-
-@dataclass
-class DSU:
-    parent: Dict[int, int] = field(default_factory=dict)
-
-    def find(self, value: int) -> int:
-        self.parent.setdefault(value, value)
-        if self.parent[value] != value:
-            self.parent[value] = self.find(self.parent[value])
-        return self.parent[value]
-
-    def union(self, left: int, right: int) -> None:
-        root_left = self.find(left)
-        root_right = self.find(right)
-        if root_left != root_right:
-            self.parent[root_right] = root_left
 
 
 def clean(value: Any) -> str:
     if value is None:
-        return ""
-    return re.sub(r"\s+", " ", str(value).strip())
-
-
-def split_values(text: str) -> List[str]:
-    output: List[str] = []
-    for part in SPLIT_RE.split(clean(text)):
-        item = part.strip()
-        if item and item not in output:
-            output.append(item)
-    return output
-
-
-def first_non_empty(*values: str, default: str = "") -> str:
-    for value in values:
-        if clean(value):
-            return clean(value)
-    return default
-
-
-STATUS_PRIORITY = {
-    "\u90ae\u4ef6\u9000\u56de": 90,
-    "\u90e8\u5206\u90ae\u4ef6\u9000\u56de": 85,
-    "\u5df2\u53d1\u9001\u90ae\u4ef6": 80,
-    "\u5df2\u53d1\u90ae\u4ef6": 80,
-    "\u5df2\u5efa\u8054": 70,
-    "\u65e0\u6cd5\u786e\u8ba4": 30,
-    "\u5f85\u53d1\u9001": 20,
-    "\u672a\u53d1\u9001": 10,
-    "\u672a\u8054\u7cfb": 5,
-    "\u672a\u5efa\u8054": 5,
-}
-
-
-def normalize_status(value: str) -> str:
-    value = clean(value)
-    if value == "\u5df2\u53d1\u90ae\u4ef6":
-        return "\u5df2\u53d1\u9001\u90ae\u4ef6"
-    if value == "\u672a\u5efa\u8054":
-        return "\u672a\u8054\u7cfb"
-    return value
-
-
-def preferred_status(values: Sequence[str], default: str = DEFAULT_STATUS) -> str:
-    statuses = [normalize_status(value) for value in values if clean(value)]
-    if not statuses:
-        return default
-    return max(statuses, key=lambda value: STATUS_PRIORITY.get(value, 40))
-
-
-def preferred_name(values: Sequence[str]) -> str:
-    cleaned = dedupe_keep_order(values)
-    if not cleaned:
-        return ""
-    normalized_counts = Counter(normalize_name(value) for value in values if normalize_name(value))
-    if not normalized_counts:
-        return cleaned[0]
-    best_key, _ = normalized_counts.most_common(1)[0]
-    for value in cleaned:
-        if normalize_name(value) == best_key:
-            return value
-    return cleaned[0]
-
-
-def dedupe_keep_order(values: Iterable[str]) -> List[str]:
-    seen = set()
-    output: List[str] = []
-    for value in values:
-        item = clean(value)
-        key = item.casefold()
-        if item and key not in seen:
-            seen.add(key)
-            output.append(item)
-    return output
-
-
-def joined(values: Iterable[str]) -> str:
-    return ";".join(dedupe_keep_order(values))
-
-
-def normalize_name(name: str) -> str:
-    lowered = clean(name).casefold()
-    lowered = re.sub(r"[^\w\s]+", " ", lowered, flags=re.U)
-    words = [word for word in lowered.split() if word not in GENERIC_NAME_WORDS]
-    return " ".join(words).strip()
-
-
-def normalize_phone(value: str) -> List[str]:
-    phones: List[str] = []
-    for match in PHONE_RE.findall(value or ""):
-        digits = re.sub(r"\D+", "", match)
-        if len(digits) >= 7 and digits not in phones:
-            phones.append(digits)
-    return phones
-
-
-def normalize_emails(value: str) -> List[str]:
-    emails: List[str] = []
-    for match in EMAIL_RE.findall(value or ""):
-        email = match.casefold()
-        if email not in emails:
-            emails.append(email)
-    return emails
-
-
-def normalize_url(url: str) -> str:
-    text = clean(url)
-    if not text:
-        return ""
-    if not re.match(r"^[a-z]+://", text, re.I):
-        text = "https://" + text
-    parsed = urlparse(text)
-    host = parsed.netloc.casefold().removeprefix("www.")
-    path = re.sub(r"/+$", "", parsed.path or "")
-    query = ""
-    if "google." in host and path.startswith("/maps"):
-        pairs = [(k, v) for k, v in parse_qsl(parsed.query, keep_blank_values=False) if k in {"q", "query", "cid", "place_id"}]
-        query = urlencode(pairs)
-        if path in {"/maps/search", "/maps"} and not query:
-            return ""
-    return urlunparse(("https", host, path, "", query, ""))
-
-
-def header_map(ws) -> Dict[str, int]:
-    raw_headers = {clean(cell.value): idx for idx, cell in enumerate(ws[1], start=1) if clean(cell.value)}
-    mapped: Dict[str, int] = {}
-    for target, aliases in HEADER_ALIASES.items():
-        for alias in aliases:
-            if alias in raw_headers:
-                mapped[target] = raw_headers[alias]
-                break
-    return mapped
-
-
-def row_value(ws, row: int, columns: Dict[str, int], key: str) -> str:
-    col = columns.get(key)
-    if not col:
-        return ""
-    return clean(ws.cell(row=row, column=col).value)
-
-
-def is_blank_reserved(record: Dict[str, str]) -> bool:
-    evidence_keys = ["name", "link", "facebook_link", "linkedin_link", "google_maps_link", "website", "contact", "public_phone", "contact_phone", "public_email", "personal_email", "business", "note"]
-    return not any(clean(record.get(key, "")) for key in evidence_keys)
-
-
-def source_values(sheet_name: str, record: Dict[str, str]) -> List[str]:
-    if sheet_name == "汽车网站精选线索":
-        return split_values(record.get("source_site", "")) or ["汽车网站精选线索"]
-    return [SOURCE_MAP.get(sheet_name, sheet_name)]
-
-
-def all_record_links(values: Dict[str, str]) -> List[str]:
-    links: List[str] = []
-    for key in ["link", "facebook_link", "linkedin_link", "google_maps_link", "website"]:
-        links.extend(split_values(values.get(key, "")))
-    return dedupe_keep_order(links)
+        return ''
+    return re.sub(r'\s+', ' ', str(value).strip())
 
 
-def platform_links(record: Record) -> Dict[str, List[str]]:
-    values = record.values
-    result = {"website": [], "facebook": [], "linkedin": [], "google_maps": []}
-    result["website"].extend(split_values(values.get("website", "")))
-    result["facebook"].extend(split_values(values.get("facebook_link", "")))
-    result["linkedin"].extend(split_values(values.get("linkedin_link", "")))
-    result["google_maps"].extend(split_values(values.get("google_maps_link", "")))
-
-    generic_links = split_values(values.get("link", ""))
-    for link in generic_links:
-        lower = link.casefold()
-        if "facebook.com" in lower or record.sheet == "Facebook":
-            result["facebook"].append(link)
-        elif "linkedin.com" in lower or record.sheet == "LinkedIn":
-            result["linkedin"].append(link)
-        elif "google." in lower and "/maps" in lower or record.sheet == "Google Maps":
-            result["google_maps"].append(link)
-        else:
-            result["website"].append(link)
-    return {key: dedupe_keep_order(value) for key, value in result.items()}
-
-
-def combined_text(values: Dict[str, str], sheet_name: str, sources: Sequence[str]) -> str:
-    return " ".join([
-        sheet_name,
-        " ".join(sources),
-        values.get("name", ""),
-        values.get("attribute", ""),
-        values.get("type", ""),
-        values.get("business", ""),
-        values.get("note", ""),
-        values.get("link", ""),
-        values.get("website", ""),
-    ]).casefold()
+def headers_for(ws) -> Dict[str, int]:
+    return {clean(cell.value): idx for idx, cell in enumerate(ws[1], start=1) if clean(cell.value)}
 
 
-def contains_any(text: str, keywords: Sequence[str]) -> bool:
-    return any(keyword.casefold() in text for keyword in keywords)
+def row_to_record(ws, row_idx: int, headers: Dict[str, int]) -> Dict[str, Any]:
+    return {header: ws.cell(row_idx, col_idx).value for header, col_idx in headers.items()}
 
 
-def classify_existing_type(value: str) -> Optional[Tuple[str, str]]:
-    mapped = normalize_existing_classification("", value, value)
-    return mapped if mapped != ("", "") else None
-
-
-def classify_attribute_and_type(values: Dict[str, str], sheet_name: str, sources: Sequence[str]) -> Tuple[str, str]:
-    return classify_attribute_type(values, sheet_name=sheet_name, sources=sources)
+def has_customer_evidence(record: Dict[str, Any]) -> bool:
+    evidence = [
+        '\u516c\u53f8\u59d3\u540d', '\u5ba2\u6237\u59d3\u540d/\u516c\u53f8', '\u516c\u53f8\u540d\u79f0',
+        '\u4e3b\u9875/\u94fe\u63a5', '\u5b98\u7f51\u94fe\u63a5', '\u516c\u53f8\u5b98\u7f51',
+        'Facebook\u4e3b\u9875\u94fe\u63a5', 'linkined\u4e3b\u9875\u94fe\u63a5', 'google map\u94fe\u63a5',
+        '\u516c\u5171\u90ae\u7bb1', '\u4e2a\u4eba\u90ae\u7bb1', '\u90ae\u7bb1',
+        '\u516c\u5171\u7535\u8bdd/WhatsApp', '\u7535\u8bdd/WhatsApp', '\u8054\u7cfb\u4eba\u7535\u8bdd',
+    ]
+    return any(clean(record.get(key)) for key in evidence)
 
 
-def read_records(wb) -> Tuple[List[Record], Dict[str, int], int]:
-    records: List[Record] = []
-    rows_by_sheet: Dict[str, int] = {}
-    blank_rows = 0
-    idx = 0
-    for sheet_name in PLATFORM_SHEETS:
-        if sheet_name not in wb.sheetnames:
+def source_sheets(wb) -> List[str]:
+    ordered: List[str] = []
+    if SUMMARY_SHEET in wb.sheetnames:
+        ordered.append(SUMMARY_SHEET)
+    for sheet in wb.sheetnames:
+        if sheet == SUMMARY_SHEET or sheet in SKIP_SHEETS:
             continue
-        ws = wb[sheet_name]
-        columns = header_map(ws)
-        if "name" not in columns and "link" not in columns and "website" not in columns:
+        if sheet in LEGACY_SOURCE_SHEETS:
+            ordered.append(sheet)
+    return ordered
+
+
+def read_all_customer_records(wb) -> Tuple[List[Dict[str, Any]], Counter, int]:
+    records: List[Dict[str, Any]] = []
+    source_counts: Counter = Counter()
+    skipped_blank = 0
+    for sheet in source_sheets(wb):
+        ws = wb[sheet]
+        headers = headers_for(ws)
+        if not headers:
             continue
-        for row in range(2, ws.max_row + 1):
-            values = {key: row_value(ws, row, columns, key) for key in HEADER_ALIASES}
-            if is_blank_reserved(values) or not any(values.get(key) for key in ["name", "link", "facebook_link", "linkedin_link", "google_maps_link", "website", "public_phone", "contact_phone", "public_email", "personal_email"]):
-                blank_rows += 1
+        for row_idx in range(2, ws.max_row + 1):
+            raw = row_to_record(ws, row_idx, headers)
+            if not has_customer_evidence(raw):
+                skipped_blank += 1
                 continue
-            idx += 1
-            sources = source_values(sheet_name, values)
-            values["attribute"], values["type"] = classify_attribute_and_type(values, sheet_name, sources)
-            if not values.get("business"):
-                values["business"] = "汽车渠道线索,需人工确认"
-            links = all_record_links(values)
-            records.append(Record(idx=idx, sheet=sheet_name, row=row, values=values, sources=sources, source_links=links))
-            rows_by_sheet[sheet_name] = rows_by_sheet.get(sheet_name, 0) + 1
-    return records, rows_by_sheet, blank_rows
-
-
-def choose_group_classification(group: Sequence[Record]) -> Tuple[str, str]:
-    for item in group:
-        attr = clean(item.values.get("attribute", ""))
-        typ = clean(item.values.get("type", ""))
-        if is_valid_pair(attr, typ):
-            return attr, typ
-    merged_values = {
-        "attribute": joined(item.values.get("attribute", "") for item in group),
-        "type": joined(item.values.get("type", "") for item in group),
-        "name": joined(item.values.get("name", "") for item in group),
-        "business": joined(item.values.get("business", "") for item in group),
-        "note": joined(item.values.get("note", "") for item in group),
-        "link": joined(item.values.get("link", "") for item in group),
-        "website": joined(item.values.get("website", "") for item in group),
-    }
-    sheets = [item.sheet for item in group]
-    sources = [source for item in group for source in item.sources]
-    return classify_attribute_and_type(merged_values, joined(sheets), sources)
-
-
-def merge_records(records: Sequence[Record]) -> List[Dict[str, Any]]:
-    dsu = DSU()
-    buckets: Dict[str, int] = {}
-    name_bucket: Dict[str, int] = {}
+            normalized = normalize_summary_record(raw, sheet)
+            if not has_customer_evidence(normalized):
+                skipped_blank += 1
+                continue
+            records.append(normalized)
+            source_counts[sheet] += 1
+    return records, source_counts, skipped_blank
 
-    for record in records:
-        dsu.find(record.idx)
-        strong_keys: List[str] = []
-        for email in normalize_emails(joined([record.values.get("public_email", ""), record.values.get("personal_email", "")])):
-            strong_keys.append("email:" + email)
-        for phone in normalize_phone(joined([record.values.get("public_phone", ""), record.values.get("contact_phone", "")])):
-            strong_keys.append("phone:" + phone)
-        # Only platform/detail URLs are strong duplicate keys. Merchant homepages are
-        # kept as evidence, but not used alone because group websites can represent
-        # many brands, branches, or dealer pages.
-        strong_link_values: List[str] = []
-        for key_name in ["facebook_link", "linkedin_link", "google_maps_link", "link"]:
-            for link in split_values(record.values.get(key_name, "")):
-                lower = link.casefold()
-                is_platform_detail = (
-                    "facebook.com" in lower
-                    or "linkedin.com" in lower
-                    or ("google." in lower and "/maps" in lower)
-                )
-                if is_platform_detail:
-                    strong_link_values.append(link)
-        for link in strong_link_values:
-            normalized = normalize_url(link)
-            if normalized:
-                strong_keys.append("url:" + normalized)
-        for key in strong_keys:
-            if key in buckets:
-                dsu.union(record.idx, buckets[key])
-            else:
-                buckets[key] = record.idx
 
+def merge_records(records: Iterable[Dict[str, Any]]) -> List[Dict[str, Any]]:
+    output: List[Dict[str, Any]] = []
+    key_to_index: Dict[Tuple[str, str], int] = {}
     for record in records:
-        name_key = normalize_name(record.values.get("name", ""))
-        if len(name_key) < 4:
-            continue
-        key = "name:" + name_key
-        if key in name_bucket:
-            dsu.union(record.idx, name_bucket[key])
+        match_idx: Optional[int] = None
+        for key in identity_keys(record):
+            if key in key_to_index:
+                match_idx = key_to_index[key]
+                break
+        if match_idx is None:
+            output.append(record)
+            match_idx = len(output) - 1
         else:
-            name_bucket[key] = record.idx
-
-    grouped: Dict[int, List[Record]] = defaultdict(list)
-    for record in records:
-        grouped[dsu.find(record.idx)].append(record)
-
-    output: List[Dict[str, Any]] = []
-    for group in sorted(grouped.values(), key=lambda items: min(item.idx for item in items)):
-        link_sets = {"website": [], "facebook": [], "linkedin": [], "google_maps": []}
-        for item in group:
-            item_links = platform_links(item)
-            for key in link_sets:
-                link_sets[key].extend(item_links[key])
-
-        names = [item.values.get("name", "") for item in group]
-        countries = [item.values.get("country", "") for item in group]
-        cities = [item.values.get("city", "") for item in group]
-        summary_attribute, summary_type = choose_group_classification(group)
-        contacts = [item.values.get("contact", "") for item in group]
-        positions = [item.values.get("position", "") for item in group]
-        personal_emails = [value for item in group for value in split_values(item.values.get("personal_email", ""))]
-        public_emails = [value for item in group for value in split_values(item.values.get("public_email", ""))]
-        contact_phones = [value for item in group for value in split_values(item.values.get("contact_phone", ""))]
-        public_phones = [value for item in group for value in split_values(item.values.get("public_phone", ""))]
-        businesses = [item.values.get("business", "") for item in group]
-        statuses = [item.values.get("status", "") for item in group]
-        next_followups = [item.values.get("next_followup", "") for item in group]
-        sources = [source for item in group for source in item.sources]
-        sheets = [item.sheet for item in group]
-        notes = [item.values.get("note", "") for item in group]
-        source_evidence = [f"来自 {item.sheet} row {item.row}" for item in group]
-        merged_count_note = f"合并来源数量:{len(group)}" if len(group) > 1 else "合并来源数量:1"
-        source_note = f"客户来源:{joined(sources)};来源Sheet:{joined(sheets)}"
-        business_note = f"主营业务汇总:{joined(businesses)}" if joined(businesses) else ""
-        note = joined([merged_count_note, source_note, business_note, *source_evidence, *notes])
-
-        row = {
-            "公司姓名": preferred_name(names),
-            "国家": first_non_empty(*countries, default=DEFAULT_COUNTRY),
-            "城市": joined(cities),
-            "客户类型": summary_type,
-            "官网链接": joined(link_sets["website"]),
-            "联系人": joined(contacts),
-            "职位": joined(positions),
-            "个人邮箱": joined(personal_emails),
-            "联系人电话": joined(contact_phones),
-            "Facebook主页链接": joined(link_sets["facebook"]),
-            "linkined主页链接": joined(link_sets["linkedin"]),
-            "google map链接": joined(link_sets["google_maps"]),
-            "公共电话/WhatsApp": joined(public_phones),
-            "公共邮箱": joined(public_emails),
-            "客户属性": summary_attribute,
-            "建联状态": first_non_empty(*statuses, default=DEFAULT_STATUS),
-            "下次跟进": joined(next_followups),
-            "备注": note,
-            "_merge_count": len(group),
-            "_sources": joined(sources),
-        }
-        output.append(row)
+            output[match_idx] = merge_record(output[match_idx], record)
+        for key in identity_keys(output[match_idx]):
+            key_to_index.setdefault(key, match_idx)
     return output
 
 
@@ -515,117 +113,97 @@ def write_summary_sheet(wb, rows: Sequence[Dict[str, Any]], sheet_name: str) ->
     if sheet_name in wb.sheetnames:
         del wb[sheet_name]
     ws = wb.create_sheet(sheet_name, 0)
-    ws.append(SUMMARY_HEADERS)
+    ws.append(SUMMARY_COLUMNS)
     for row in rows:
-        ws.append([row.get(header, "") for header in SUMMARY_HEADERS])
-    ws.freeze_panes = "A2"
+        ws.append([row.get(header, '') for header in SUMMARY_COLUMNS])
+    ws.freeze_panes = 'A2'
     ws.auto_filter.ref = ws.dimensions
-    header_fill = PatternFill(fill_type="solid", fgColor="D9EAF7")
+    header_fill = PatternFill(fill_type='solid', fgColor='D9EAF7')
     for cell in ws[1]:
         cell.font = Font(bold=True)
         cell.fill = header_fill
-    widths = [28, 12, 18, 22, 36, 18, 18, 28, 24, 36, 36, 36, 24, 28, 20, 14, 18, 76]
+    widths = [28, 12, 16, 22, 34, 18, 18, 28, 24, 34, 34, 34, 24, 28, 20, 12, 20, 18, 18, 80]
     for idx, width in enumerate(widths, start=1):
         ws.column_dimensions[get_column_letter(idx)].width = width
 
 
 def lock_file_for(path: Path) -> Path:
-    return path.with_name("~$" + path.name)
-
-
-def build_report(workbook_path: Path, backup_path: Optional[Path], records: Sequence[Record], rows: Sequence[Dict[str, Any]], rows_by_sheet: Dict[str, int], blank_rows: int, dry_run: bool, sheet_name: str) -> Dict[str, Any]:
-    source_counts = Counter(source for record in records for source in record.sources)
-    attribute_counts = Counter(row.get("客户属性", "") for row in rows if clean(row.get("客户属性", "")))
-    type_counts = Counter(row.get("客户类型", "") for row in rows if clean(row.get("客户类型", "")))
-    missing = {
-        "客户属性": sum(1 for row in rows if not clean(row.get("客户属性", ""))),
-        "客户类型": sum(1 for row in rows if not clean(row.get("客户类型", ""))),
-        "主营业务": sum(1 for row in rows if "主营业务" in SUMMARY_HEADERS and not clean(row.get("主营业务", ""))),
-        "备注": sum(1 for row in rows if not clean(row.get("备注", ""))),
-    }
-    invalid_category_rows = [
-        {"name": row.get("公司姓名", ""), "attribute": row.get("客户属性", ""), "type": row.get("客户类型", ""), "sources": row.get("_sources", "")}
-        for row in rows
-        if not is_valid_pair(clean(row.get("客户属性", "")), clean(row.get("客户类型", "")))
-    ]
-    samples = []
-    for row in rows:
-        merge_count = int(row.get("_merge_count", 0) or 0)
-        if merge_count > 1:
-            samples.append({"name": row.get("公司姓名", ""), "attribute": row.get("客户属性", ""), "type": row.get("客户类型", ""), "sources": row.get("_sources", ""), "merged_source_rows": merge_count})
-        if len(samples) >= 5:
-            break
+    return path.with_name('~$' + path.name)
+
+
+def build_report(workbook_path: Path, backup_path: Optional[Path], rows: Sequence[Dict[str, Any]], source_counts: Counter, skipped_blank: int, dry_run: bool, sheet_name: str) -> Dict[str, Any]:
+    grade_col = '\u7ebf\u7d22\u7b49\u7ea7'
+    manual_col = '\u9700\u4eba\u5de5\u786e\u8ba4'
+    attr_col = '\u5ba2\u6237\u5c5e\u6027'
+    type_col = '\u5ba2\u6237\u7c7b\u578b'
     return {
-        "workbook": str(workbook_path),
-        "summary_sheet": sheet_name,
-        "summary_headers": SUMMARY_HEADERS,
-        "dry_run": dry_run,
-        "backup": str(backup_path) if backup_path else "",
-        "input_valid_rows": len(records),
-        "summary_rows": len(rows),
-        "merged_duplicates": len(records) - len(rows),
-        "rows_by_sheet": rows_by_sheet,
-        "source_counts": dict(source_counts),
-        "attribute_counts": dict(attribute_counts),
-        "type_counts": dict(type_counts),
-        "missing_counts": missing,
-        "invalid_category_count": len(invalid_category_rows),
-        "invalid_category_rows": invalid_category_rows[:50],
-        "skipped_blank_or_reserved_rows": blank_rows,
-        "merge_samples": samples,
+        'workbook': str(workbook_path),
+        'summary_sheet': sheet_name,
+        'summary_headers': SUMMARY_COLUMNS,
+        'dry_run': dry_run,
+        'backup': str(backup_path) if backup_path else '',
+        'summary_rows': len(rows),
+        'source_rows': dict(source_counts),
+        'skipped_blank_or_reserved_rows': skipped_blank,
+        'attribute_counts': dict(Counter(clean(row.get(attr_col)) for row in rows if clean(row.get(attr_col)))),
+        'type_counts': dict(Counter(clean(row.get(type_col)) for row in rows if clean(row.get(type_col)))),
+        'lead_grade_counts': dict(Counter(clean(row.get(grade_col)) for row in rows if clean(row.get(grade_col)))),
+        'manual_review_counts': dict(Counter(clean(row.get(manual_col)) for row in rows if clean(row.get(manual_col)))),
+        'missing_counts': {
+            '\u5ba2\u6237\u5c5e\u6027': sum(1 for row in rows if not clean(row.get(attr_col))),
+            '\u5ba2\u6237\u7c7b\u578b': sum(1 for row in rows if not clean(row.get(type_col))),
+            '\u7ebf\u7d22\u7b49\u7ea7': sum(1 for row in rows if not clean(row.get(grade_col))),
+            '\u9700\u4eba\u5de5\u786e\u8ba4': sum(1 for row in rows if not clean(row.get(manual_col))),
+            '\u5907\u6ce8': sum(1 for row in rows if not clean(row.get('\u5907\u6ce8'))),
+        },
     }
 
 
 def save_json_report(report: Dict[str, Any], output: str) -> None:
     if not output:
         return
-    path = resolve_artifact_path(output, kind="summary_report", default_name="report.json")
+    path = resolve_artifact_path(output, kind='summary_report', default_name='report.json')
     path.parent.mkdir(parents=True, exist_ok=True)
-    path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
+    path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding='utf-8')
 
 
 def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:
-    parser = argparse.ArgumentParser(description="Build or preview the consolidated customer summary sheet.")
-    parser.add_argument("--excel", default="", help="Workbook path. If omitted, resolve the project workbook by skill rules.")
-    parser.add_argument("--summary-sheet", default=SUMMARY_SHEET, help="Summary sheet name.")
-    parser.add_argument("--write-summary", action="store_true", help="Write or overwrite the summary sheet. Omit for preview only.")
-    parser.add_argument("--dry-run", action="store_true", help="Preview only; never saves the workbook.")
-    parser.add_argument("--no-backup", action="store_true", help="Skip backup when writing.")
-    parser.add_argument("--output", default="", help="Optional JSON report path.")
-    parser.add_argument("--run-id", default="", help="Run ID used for artifact and backup paths.")
+    parser = argparse.ArgumentParser(description='Build or preview the single customer summary sheet.')
+    parser.add_argument('--excel', default='', help='Workbook path. If omitted, resolve the project workbook by skill rules.')
+    parser.add_argument('--summary-sheet', default=SUMMARY_SHEET, help='Summary sheet name.')
+    parser.add_argument('--write-summary', action='store_true', help='Write or overwrite the summary sheet. Omit for preview only.')
+    parser.add_argument('--dry-run', action='store_true', help='Preview only; never saves the workbook.')
+    parser.add_argument('--no-backup', action='store_true', help='Skip backup when writing.')
+    parser.add_argument('--output', default='', help='Optional JSON report path.')
+    parser.add_argument('--run-id', default='', help='Run ID used for artifact and backup paths.')
     return parser.parse_args(argv)
 
 
 def main(argv: Optional[Sequence[str]] = None) -> int:
     args = parse_args(argv)
     resolved = resolve_workbook_path(args.excel, create_from_template=False)
-    workbook_path = resolved.get("path")
+    workbook_path = resolved.get('path')
     if not workbook_path:
-        raise FileNotFoundError("No outreach workbook found. Pass --excel or create one from the skill blank template in write-enabled workflows.")
+        raise FileNotFoundError('No outreach workbook found. Pass --excel or create one from the skill blank template in write-enabled workflows.')
     workbook_path = Path(workbook_path)
     sheet_name = clean(args.summary_sheet) or SUMMARY_SHEET
     should_write = bool(args.write_summary and not args.dry_run)
-
     if should_write and lock_file_for(workbook_path).exists():
-        raise PermissionError(f"Workbook appears to be open in Excel: {lock_file_for(workbook_path)}")
-
+        raise PermissionError(f'Workbook appears to be open in Excel: {lock_file_for(workbook_path)}')
     wb = load_workbook(workbook_path)
-    records, rows_by_sheet, blank_rows = read_records(wb)
+    records, source_counts, skipped_blank = read_all_customer_records(wb)
     rows = merge_records(records)
-
     backup_path: Optional[Path] = None
     if should_write:
         if not args.no_backup:
-            backup_path = create_backup_once(workbook_path, purpose="summary", run_id=args.run_id or None)
+            backup_path = create_backup_once(workbook_path, purpose='summary', run_id=args.run_id or None)
         write_summary_sheet(wb, rows, sheet_name)
         wb.save(workbook_path)
-
-    report = build_report(workbook_path, backup_path, records, rows, rows_by_sheet, blank_rows, dry_run=not should_write, sheet_name=sheet_name)
+    report = build_report(workbook_path, backup_path, rows, source_counts, skipped_blank, dry_run=not should_write, sheet_name=sheet_name)
     save_json_report(report, args.output)
     print(json.dumps(report, ensure_ascii=False, indent=2))
     return 0
 
 
-if __name__ == "__main__":
+if __name__ == '__main__':
     raise SystemExit(main())
-

+ 314 - 0
scripts/common/direct_summary.py

@@ -0,0 +1,314 @@
+'''Direct, deduplicated writes to the single customer summary sheet.'''
+from __future__ import annotations
+
+import re
+from copy import copy
+from pathlib import Path
+from typing import Any, Dict, Iterable, List, Tuple
+from urllib.parse import urlparse
+
+from openpyxl import load_workbook
+
+SUMMARY_SHEET = '\u5ba2\u6237\u4fe1\u606f\u6c47\u603b\u8868'
+SUMMARY_COLUMNS = [
+    '\u516c\u53f8\u59d3\u540d', '\u56fd\u5bb6', '\u57ce\u5e02', '\u5ba2\u6237\u7c7b\u578b', '\u5b98\u7f51\u94fe\u63a5',
+    '\u8054\u7cfb\u4eba', '\u804c\u4f4d', '\u4e2a\u4eba\u90ae\u7bb1', '\u8054\u7cfb\u4eba\u7535\u8bdd',
+    'Facebook\u4e3b\u9875\u94fe\u63a5', 'linkined\u4e3b\u9875\u94fe\u63a5', 'google map\u94fe\u63a5',
+    '\u516c\u5171\u7535\u8bdd/WhatsApp', '\u516c\u5171\u90ae\u7bb1', '\u5ba2\u6237\u5c5e\u6027',
+    '\u7ebf\u7d22\u7b49\u7ea7', '\u9700\u4eba\u5de5\u786e\u8ba4', '\u5efa\u8054\u72b6\u6001',
+    '\u4e0b\u6b21\u8ddf\u8fdb', '\u5907\u6ce8',
+]
+
+NO = '\u5426'
+UNCONTACTED = '\u672a\u8054\u7cfb'
+SOURCE_PREFIX = '\u5ba2\u6237\u6765\u6e90\uff1a'
+MANUAL_FLAGS = [
+    '\u4e3b\u4f53\u5f52\u5c5e\u5f85\u786e\u8ba4',
+    '\u65b0\u8f66\u4e1a\u52a1\u5f85\u786e\u8ba4',
+    '\u5e73\u53f0\u4e0e\u884c\u4e1a\u6e20\u9053\u4e3b\u4f53\u5f85\u786e\u8ba4',
+    '\u5e73\u53f0\u4e0e\u884c\u4e1a\u6e20\u9053\u4f5c\u7528\u5f85\u786e\u8ba4',
+    '\u4ec5\u7535\u8bdd/WhatsApp\u5f85\u4eba\u5de5\u786e\u8ba4',
+    '\u8be6\u7ec6\u4fe1\u606f\u5f85\u786e\u8ba4',
+    '\u6392\u4ed6\u534f\u8bae\u53ca\u65b0\u589e\u54c1\u724c\u6743\u9650\u5f85\u786e\u8ba4',
+]
+
+MULTI_FIELDS = {
+    '\u5b98\u7f51\u94fe\u63a5', '\u4e2a\u4eba\u90ae\u7bb1', '\u8054\u7cfb\u4eba\u7535\u8bdd',
+    'Facebook\u4e3b\u9875\u94fe\u63a5', 'linkined\u4e3b\u9875\u94fe\u63a5', 'google map\u94fe\u63a5',
+    '\u516c\u5171\u7535\u8bdd/WhatsApp', '\u516c\u5171\u90ae\u7bb1', '\u8054\u7cfb\u4eba', '\u804c\u4f4d', '\u5907\u6ce8',
+}
+GRADE_ORDER = {'A': 3, 'B': 2, 'C': 1}
+PLATFORM_HOSTS = ('facebook.com', 'linkedin.com', 'google.com', 'maps.app.goo.gl', 'instagram.com', 'wa.me', 'youtube.com', 'tiktok.com')
+
+
+def clean(value: Any) -> str:
+    if value is None:
+        return ''
+    return re.sub(r'\s+', ' ', str(value).strip())
+
+
+def first(record: Dict[str, Any], *keys: str) -> str:
+    for key in keys:
+        value = clean(record.get(key))
+        if value:
+            return value
+    return ''
+
+
+def split_values(value: Any) -> List[str]:
+    if isinstance(value, list):
+        raw = [clean(item) for item in value]
+    else:
+        raw = [clean(item) for item in re.split(r'[\uff1b;|\n]+', clean(value))]
+    return list(dict.fromkeys(item for item in raw if item))
+
+
+def merge_values(left: Any, right: Any) -> str:
+    return '\uff1b'.join(dict.fromkeys([*split_values(left), *split_values(right)]))
+
+
+def normalized_name(value: str) -> str:
+    value = clean(value).casefold()
+    return re.sub(r'[^0-9a-z\u00c0-\u024f\u0600-\u06ff\u4e00-\u9fff]+', '', value)
+
+
+def normalized_url(value: str) -> str:
+    value = clean(value).casefold().rstrip('/')
+    parsed = urlparse(value if '://' in value else 'https://' + value)
+    host = parsed.netloc.removeprefix('www.')
+    path = parsed.path.rstrip('/')
+    if 'google.' in host or 'maps.app.goo.gl' in host:
+        return (host + path + ('?' + parsed.query if parsed.query else '')).rstrip('/')
+    return (host + path).rstrip('/')
+
+
+def is_company_website(value: str) -> bool:
+    parsed = urlparse(clean(value) if '://' in clean(value) else 'https://' + clean(value))
+    host = parsed.netloc.casefold().removeprefix('www.')
+    return bool(host and not any(item in host for item in PLATFORM_HOSTS))
+
+
+def host_contains(value: str, token: str) -> bool:
+    parsed = urlparse(clean(value) if '://' in clean(value) else 'https://' + clean(value))
+    return token in parsed.netloc.casefold()
+
+
+def row_dict(ws, row_idx: int, headers: Dict[str, int]) -> Dict[str, Any]:
+    return {header: ws.cell(row_idx, col_idx).value for header, col_idx in headers.items()}
+
+
+def copy_row_style(ws, source_row: int, target_row: int) -> None:
+    if source_row < 1 or source_row == target_row:
+        return
+    for col in range(1, ws.max_column + 1):
+        source = ws.cell(source_row, col)
+        target = ws.cell(target_row, col)
+        if source.has_style:
+            target._style = copy(source._style)
+        if source.number_format:
+            target.number_format = source.number_format
+        if source.alignment:
+            target.alignment = copy(source.alignment)
+        if source.protection:
+            target.protection = copy(source.protection)
+
+
+def ensure_summary_sheet(wb):
+    if SUMMARY_SHEET in wb.sheetnames:
+        ws = wb[SUMMARY_SHEET]
+    else:
+        ws = wb.create_sheet(SUMMARY_SHEET, 0)
+    headers = {clean(cell.value): idx for idx, cell in enumerate(ws[1], start=1) if clean(cell.value)}
+    if not headers:
+        for idx, header in enumerate(SUMMARY_COLUMNS, start=1):
+            ws.cell(1, idx, header)
+        headers = {header: idx for idx, header in enumerate(SUMMARY_COLUMNS, start=1)}
+    for header in SUMMARY_COLUMNS:
+        if header not in headers:
+            col_idx = ws.max_column + 1
+            ws.cell(1, col_idx, header)
+            headers[header] = col_idx
+    return ws, headers
+
+
+def note_text(record: Dict[str, Any], source_sheet: str) -> str:
+    note = first(record, '\u5907\u6ce8', 'note', 'notes', 'remarks')
+    source = first(record, '\u6765\u6e90\u7f51\u7ad9', 'source', 'source_site') or source_sheet
+    if source and SOURCE_PREFIX not in note:
+        note = merge_values(note, SOURCE_PREFIX + source)
+    return note
+
+
+def infer_lead_grade(record: Dict[str, Any]) -> str:
+    explicit = first(record, '\u7ebf\u7d22\u7b49\u7ea7', '\u7b49\u7ea7', 'lead_grade').upper()
+    if explicit in GRADE_ORDER:
+        return explicit
+    text = ' '.join(clean(v) for v in record.values()).casefold()
+    if re.search(r'import|distribut|dealer network|reseau|r\u00e9seau|national|multibrand|multi-brand|multimarque|association|chamber|platform|marketplace|fleet|group', text):
+        return 'A'
+    if re.search(r'showroom|dealer|concession|auto|motors|vehicule|v\u00e9hicule|car sales|new car|used car|occasion', text):
+        return 'B'
+    return 'C'
+
+
+def manual_review_value(record: Dict[str, Any], grade: str) -> str:
+    explicit = first(record, '\u9700\u4eba\u5de5\u786e\u8ba4', 'manual_review')
+    if explicit and explicit.casefold() not in {'no', 'false', '0', NO}:
+        return explicit
+    text = ' '.join(clean(v) for v in record.values())
+    flags = [flag for flag in MANUAL_FLAGS if flag in text]
+    risk_flags = record.get('risk_flags') or record.get('risk_flag') or record.get('\u98ce\u9669\u6807\u8bb0')
+    if isinstance(risk_flags, list):
+        flags.extend(clean(item) for item in risk_flags if clean(item))
+    elif clean(risk_flags):
+        flags.extend(split_values(risk_flags))
+    if flags:
+        return '\uff1b'.join(dict.fromkeys(flags))
+    if grade == 'C':
+        return '\u8be6\u7ec6\u4fe1\u606f\u5f85\u786e\u8ba4'
+    return NO
+
+
+def normalize_summary_record(record: Dict[str, Any], source_sheet: str) -> Dict[str, Any]:
+    link = first(record, '\u4e3b\u9875/\u94fe\u63a5', 'page_url', 'profile_url', 'link', 'url', 'Link', 'URL')
+    website = first(record, '\u5b98\u7f51\u94fe\u63a5', '\u516c\u53f8\u5b98\u7f51', 'website', 'company_website')
+    if not website and link and is_company_website(link):
+        website = link
+    facebook = first(record, 'Facebook\u4e3b\u9875\u94fe\u63a5', 'Facebook\u94fe\u63a5', 'facebook_link', 'facebook_url')
+    linkedin = first(record, 'linkined\u4e3b\u9875\u94fe\u63a5', 'LinkedIn\u4e3b\u9875\u94fe\u63a5', 'linkin\u94fe\u63a5', 'linkedin_link', 'linkedin_url')
+    maps = first(record, 'google map\u94fe\u63a5', 'Google Maps\u94fe\u63a5', 'maps_link', 'maps_url', 'google_maps_url')
+    if link and not is_company_website(link):
+        if host_contains(link, 'facebook.com') and not facebook:
+            facebook = link
+        elif host_contains(link, 'linkedin.com') and not linkedin:
+            linkedin = link
+        elif host_contains(link, 'google.') or host_contains(link, 'maps.app.goo.gl'):
+            maps = link
+
+    public_email = first(record, '\u516c\u5171\u90ae\u7bb1', '\u90ae\u7bb1', '\u516c\u53f8\u516c\u5171\u90ae\u7bb1\uff08\u4efb\u4e00\u6709\u6548\u5373\u53ef\uff09', 'email')
+    personal_email = first(record, '\u4e2a\u4eba\u90ae\u7bb1', '\u4e2a\u4eba\u90ae\u7bb1\uff08\u4e0d\u4e00\u5b9a\u6709\u6548\uff09', 'personal_email')
+    public_phone = first(record, '\u516c\u5171\u7535\u8bdd/WhatsApp', '\u7535\u8bdd/WhatsApp', '\u516c\u53f8\u516c\u5171\u7535\u8bdd', 'phone', 'whatsapp')
+    contact_phone = first(record, '\u8054\u7cfb\u4eba\u7535\u8bdd', 'contact_phone')
+    grade = infer_lead_grade(record)
+    normalized = {
+        '\u516c\u53f8\u59d3\u540d': first(record, '\u516c\u53f8\u59d3\u540d', '\u5ba2\u6237\u59d3\u540d/\u516c\u53f8', '\u516c\u53f8\u540d\u79f0', 'name', 'dealer_name'),
+        '\u56fd\u5bb6': first(record, '\u56fd\u5bb6', 'country'),
+        '\u57ce\u5e02': first(record, '\u57ce\u5e02', 'city'),
+        '\u5ba2\u6237\u7c7b\u578b': first(record, '\u5ba2\u6237\u7c7b\u578b', 'customer_type'),
+        '\u5b98\u7f51\u94fe\u63a5': website,
+        '\u8054\u7cfb\u4eba': first(record, '\u8054\u7cfb\u4eba', 'contact'),
+        '\u804c\u4f4d': first(record, '\u804c\u4f4d', 'title', 'position'),
+        '\u4e2a\u4eba\u90ae\u7bb1': personal_email,
+        '\u8054\u7cfb\u4eba\u7535\u8bdd': contact_phone,
+        'Facebook\u4e3b\u9875\u94fe\u63a5': facebook,
+        'linkined\u4e3b\u9875\u94fe\u63a5': linkedin,
+        'google map\u94fe\u63a5': maps,
+        '\u516c\u5171\u7535\u8bdd/WhatsApp': public_phone,
+        '\u516c\u5171\u90ae\u7bb1': public_email,
+        '\u5ba2\u6237\u5c5e\u6027': first(record, '\u5ba2\u6237\u5c5e\u6027', 'customer_attribute'),
+        '\u7ebf\u7d22\u7b49\u7ea7': grade,
+        '\u9700\u4eba\u5de5\u786e\u8ba4': manual_review_value(record, grade),
+        '\u5efa\u8054\u72b6\u6001': first(record, '\u5efa\u8054\u72b6\u6001', '\u5efa\u8054\u60c5\u51b5', 'status') or UNCONTACTED,
+        '\u4e0b\u6b21\u8ddf\u8fdb': first(record, '\u4e0b\u6b21\u8ddf\u8fdb', 'next_follow_up'),
+        '\u5907\u6ce8': note_text(record, source_sheet),
+    }
+    return normalized
+
+
+def identity_keys(record: Dict[str, Any]) -> List[Tuple[str, str]]:
+    keys: List[Tuple[str, str]] = []
+    for field in ('\u516c\u5171\u90ae\u7bb1', '\u4e2a\u4eba\u90ae\u7bb1'):
+        for value in split_values(record.get(field)):
+            keys.append(('email', value.casefold()))
+    for field in ('\u516c\u5171\u7535\u8bdd/WhatsApp', '\u8054\u7cfb\u4eba\u7535\u8bdd'):
+        for value in split_values(record.get(field)):
+            digits = re.sub(r'\D+', '', value)
+            if len(digits) >= 7:
+                keys.append(('phone', digits))
+    for field in ('\u5b98\u7f51\u94fe\u63a5', 'Facebook\u4e3b\u9875\u94fe\u63a5', 'linkined\u4e3b\u9875\u94fe\u63a5', 'google map\u94fe\u63a5'):
+        for value in split_values(record.get(field)):
+            url_key = normalized_url(value)
+            if url_key:
+                keys.append(('url', url_key))
+    name_key = normalized_name(clean(record.get('\u516c\u53f8\u59d3\u540d')))
+    if name_key:
+        keys.append(('name', name_key))
+    return list(dict.fromkeys(keys))
+
+
+def merge_record(existing: Dict[str, Any], incoming: Dict[str, Any]) -> Dict[str, Any]:
+    merged = dict(existing)
+    for field in SUMMARY_COLUMNS:
+        left = clean(merged.get(field))
+        right = clean(incoming.get(field))
+        if not left and right:
+            merged[field] = right
+        elif field in MULTI_FIELDS and right:
+            merged[field] = merge_values(left, right)
+    if GRADE_ORDER.get(clean(incoming.get('\u7ebf\u7d22\u7b49\u7ea7')), 0) > GRADE_ORDER.get(clean(merged.get('\u7ebf\u7d22\u7b49\u7ea7')), 0):
+        merged['\u7ebf\u7d22\u7b49\u7ea7'] = incoming.get('\u7ebf\u7d22\u7b49\u7ea7')
+    left_manual = clean(merged.get('\u9700\u4eba\u5de5\u786e\u8ba4'))
+    right_manual = clean(incoming.get('\u9700\u4eba\u5de5\u786e\u8ba4'))
+    if right_manual and right_manual != NO:
+        merged['\u9700\u4eba\u5de5\u786e\u8ba4'] = merge_values('' if left_manual == NO else left_manual, right_manual)
+    elif not left_manual:
+        merged['\u9700\u4eba\u5de5\u786e\u8ba4'] = NO
+    return merged
+
+
+def index_existing(ws, headers: Dict[str, int]) -> Dict[Tuple[str, str], int]:
+    index: Dict[Tuple[str, str], int] = {}
+    for row_idx in range(2, ws.max_row + 1):
+        record = row_dict(ws, row_idx, headers)
+        if not any(clean(record.get(col)) for col in SUMMARY_COLUMNS):
+            continue
+        for key in identity_keys(record):
+            index.setdefault(key, row_idx)
+    return index
+
+
+def write_row(ws, headers: Dict[str, int], row_idx: int, record: Dict[str, Any]) -> None:
+    for header in SUMMARY_COLUMNS:
+        ws.cell(row_idx, headers[header], record.get(header, ''))
+
+
+def append_summary_records(excel_path: str, records: Iterable[Dict[str, Any]], source_sheet: str = '') -> Dict[str, Any]:
+    records = list(records)
+    if not records:
+        return {'appended': 0, 'merged': 0, 'skipped': 0, 'total': 0, 'sheet': SUMMARY_SHEET}
+    path = Path(excel_path)
+    if not path.exists():
+        raise FileNotFoundError(f'Excel file not found: {excel_path}')
+    wb = load_workbook(path)
+    ws, headers = ensure_summary_sheet(wb)
+    existing_index = index_existing(ws, headers)
+    appended = 0
+    merged = 0
+    skipped = 0
+    last_style_row = ws.max_row if ws.max_row > 1 else 1
+    for raw in records:
+        normalized = normalize_summary_record(raw, source_sheet)
+        if not any(clean(normalized.get(field)) for field in ('\u516c\u53f8\u59d3\u540d', '\u5b98\u7f51\u94fe\u63a5', 'Facebook\u4e3b\u9875\u94fe\u63a5', 'linkined\u4e3b\u9875\u94fe\u63a5', 'google map\u94fe\u63a5', '\u516c\u5171\u90ae\u7bb1', '\u516c\u5171\u7535\u8bdd/WhatsApp')):
+            skipped += 1
+            continue
+        match_row = None
+        for key in identity_keys(normalized):
+            if key in existing_index:
+                match_row = existing_index[key]
+                break
+        if match_row:
+            existing = row_dict(ws, match_row, headers)
+            write_row(ws, headers, match_row, merge_record(existing, normalized))
+            for key in identity_keys(row_dict(ws, match_row, headers)):
+                existing_index.setdefault(key, match_row)
+            merged += 1
+            continue
+        row_idx = ws.max_row + 1
+        copy_row_style(ws, last_style_row, row_idx)
+        write_row(ws, headers, row_idx, normalized)
+        for key in identity_keys(normalized):
+            existing_index.setdefault(key, row_idx)
+        appended += 1
+        last_style_row = row_idx
+    wb.save(path)
+    return {'appended': appended, 'merged': merged, 'skipped': skipped, 'total': max(ws.max_row - 1, 0), 'sheet': SUMMARY_SHEET, 'source': source_sheet}

+ 88 - 96
scripts/common/excel_io.py

@@ -1,9 +1,9 @@
-"""
+'''
 Excel workbook helpers for the Wuling dealer outreach skill.
 
-Writes use openpyxl so the workbook structure, styles, data validations,
-images, and non-target sheets are preserved.
-"""
+New customer records are written directly to the single summary sheet. Legacy
+channel sheet names are accepted as source labels for compatibility.
+'''
 from __future__ import annotations
 
 from copy import copy
@@ -13,69 +13,82 @@ from typing import Any, Dict, List, Optional
 import pandas as pd
 from openpyxl import load_workbook
 
-STANDARD_COLUMNS = [
-    "序号", "客户姓名/公司", "国家", "城市", "客户属性", "客户类型",
-    "主页/链接", "联系人", "职位", "电话/WhatsApp", "邮箱",
-    "主营业务", "建联状态", "下次跟进", "备注",
-]
+SUMMARY_SHEET = '\u5ba2\u6237\u4fe1\u606f\u6c47\u603b\u8868'
+CONVERSATION_SHEET = 'Facebook\u5bf9\u8bdd\u8bb0\u5f55'
+CHANNEL_SOURCE_SHEETS = {
+    'Facebook', 'LinkedIn', 'Google Maps', 'TikTok', '\u534f\u4f1a\u5546\u4f1a',
+    '\u672c\u5730\u6c7d\u8f66\u7f51\u7ad9', '\u6c7d\u8f66\u7f51\u7ad9\u7cbe\u9009\u7ebf\u7d22',
+    'Sheet11', '\u653f\u5e9c\u91c7\u8d2d\u6295\u6807', '\u6d4b\u8bc4\u535a\u4e3b',
+}
 
-FACEBOOK_COLUMNS = [*STANDARD_COLUMNS[:6], "公司官网", *STANDARD_COLUMNS[6:]]
+SUMMARY_COLUMNS = [
+    '\u516c\u53f8\u59d3\u540d', '\u56fd\u5bb6', '\u57ce\u5e02', '\u5ba2\u6237\u7c7b\u578b', '\u5b98\u7f51\u94fe\u63a5',
+    '\u8054\u7cfb\u4eba', '\u804c\u4f4d', '\u4e2a\u4eba\u90ae\u7bb1', '\u8054\u7cfb\u4eba\u7535\u8bdd',
+    'Facebook\u4e3b\u9875\u94fe\u63a5', 'linkined\u4e3b\u9875\u94fe\u63a5', 'google map\u94fe\u63a5',
+    '\u516c\u5171\u7535\u8bdd/WhatsApp', '\u516c\u5171\u90ae\u7bb1', '\u5ba2\u6237\u5c5e\u6027',
+    '\u7ebf\u7d22\u7b49\u7ea7', '\u9700\u4eba\u5de5\u786e\u8ba4', '\u5efa\u8054\u72b6\u6001',
+    '\u4e0b\u6b21\u8ddf\u8fdb', '\u5907\u6ce8',
+]
 
+STANDARD_COLUMNS = [
+    '\u5e8f\u53f7', '\u5ba2\u6237\u59d3\u540d/\u516c\u53f8', '\u56fd\u5bb6', '\u57ce\u5e02', '\u5ba2\u6237\u5c5e\u6027',
+    '\u5ba2\u6237\u7c7b\u578b', '\u4e3b\u9875/\u94fe\u63a5', '\u8054\u7cfb\u4eba', '\u804c\u4f4d',
+    '\u7535\u8bdd/WhatsApp', '\u90ae\u7bb1', '\u4e3b\u8425\u4e1a\u52a1', '\u5efa\u8054\u72b6\u6001',
+    '\u4e0b\u6b21\u8ddf\u8fdb', '\u5907\u6ce8',
+]
+FACEBOOK_COLUMNS = [*STANDARD_COLUMNS[:6], '\u516c\u53f8\u5b98\u7f51', *STANDARD_COLUMNS[6:]]
 LINKEDIN_COLUMNS = [
-    "公司名称", "国家", "城市", "客户属性", "客户类型", "linkin链接",
-    "联系人", "职位", "公司公共电话", "公司公共邮箱(任一有效即可)",
-    "个人邮箱(不一定有效)", "公司主营业务", "建联状态", "备注",
+    '\u516c\u53f8\u540d\u79f0', '\u56fd\u5bb6', '\u57ce\u5e02', '\u5ba2\u6237\u5c5e\u6027', '\u5ba2\u6237\u7c7b\u578b',
+    'linkin\u94fe\u63a5', '\u8054\u7cfb\u4eba', '\u804c\u4f4d', '\u516c\u53f8\u516c\u5171\u7535\u8bdd',
+    '\u516c\u53f8\u516c\u5171\u90ae\u7bb1\uff08\u4efb\u4e00\u6709\u6548\u5373\u53ef\uff09',
+    '\u4e2a\u4eba\u90ae\u7bb1\uff08\u4e0d\u4e00\u5b9a\u6709\u6548\uff09', '\u516c\u53f8\u4e3b\u8425\u4e1a\u52a1',
+    '\u5efa\u8054\u72b6\u6001', '\u5907\u6ce8',
 ]
-
 GOOGLE_MAPS_COLUMNS = [
-    "客户姓名/公司", "国家", "城市", "客户属性", "客户类型", "主页/链接",
-    "联系人", "职位", "电话/WhatsApp", "邮箱", "主营业务",
-    "建联状态", "下次跟进", "备注",
+    '\u5ba2\u6237\u59d3\u540d/\u516c\u53f8', '\u56fd\u5bb6', '\u57ce\u5e02', '\u5ba2\u6237\u5c5e\u6027', '\u5ba2\u6237\u7c7b\u578b',
+    '\u4e3b\u9875/\u94fe\u63a5', '\u8054\u7cfb\u4eba', '\u804c\u4f4d', '\u7535\u8bdd/WhatsApp', '\u90ae\u7bb1',
+    '\u4e3b\u8425\u4e1a\u52a1', '\u5efa\u8054\u72b6\u6001', '\u4e0b\u6b21\u8ddf\u8fdb', '\u5907\u6ce8',
 ]
-
 AUTO_WEBSITE_COLUMNS = [
-    "序号", "客户姓名/公司", "国家", "城市", "客户属性", "客户类型", "主页/链接",
-    "来源网站", "联系人", "职位", "电话/WhatsApp", "邮箱", "主营业务",
-    "建联状态", "下次跟进", "备注",
+    '\u5e8f\u53f7', '\u5ba2\u6237\u59d3\u540d/\u516c\u53f8', '\u56fd\u5bb6', '\u57ce\u5e02', '\u5ba2\u6237\u5c5e\u6027',
+    '\u5ba2\u6237\u7c7b\u578b', '\u4e3b\u9875/\u94fe\u63a5', '\u6765\u6e90\u7f51\u7ad9', '\u8054\u7cfb\u4eba',
+    '\u804c\u4f4d', '\u7535\u8bdd/WhatsApp', '\u90ae\u7bb1', '\u4e3b\u8425\u4e1a\u52a1', '\u5efa\u8054\u72b6\u6001',
+    '\u4e0b\u6b21\u8ddf\u8fdb', '\u5907\u6ce8',
 ]
-
 FACEBOOK_CONVERSATION_COLUMNS = [
-    "记录ID", "客户序号", "客户姓名/公司", "Facebook主页链接", "Messenger线程ID",
-    "消息时间", "消息方向", "发件人", "原文语言", "对话原文", "中文翻译", "消息类型",
-    "是否有效客户回复", "合作意向", "意向判断依据", "下一步建议", "同步时间",
-    "来源账号/Profile ID", "风险标记",
+    '\u8bb0\u5f55ID', '\u5ba2\u6237\u5e8f\u53f7', '\u5ba2\u6237\u59d3\u540d/\u516c\u53f8', 'Facebook\u4e3b\u9875\u94fe\u63a5',
+    'Messenger\u7ebf\u7a0bID', '\u6d88\u606f\u65f6\u95f4', '\u6d88\u606f\u65b9\u5411', '\u53d1\u4ef6\u4eba',
+    '\u539f\u6587\u8bed\u8a00', '\u5bf9\u8bdd\u539f\u6587', '\u4e2d\u6587\u7ffb\u8bd1', '\u6d88\u606f\u7c7b\u578b',
+    '\u662f\u5426\u6709\u6548\u5ba2\u6237\u56de\u590d', '\u5408\u4f5c\u610f\u5411', '\u610f\u5411\u5224\u65ad\u4f9d\u636e',
+    '\u4e0b\u4e00\u6b65\u5efa\u8bae', '\u540c\u6b65\u65f6\u95f4', '\u6765\u6e90\u8d26\u53f7/Profile ID', '\u98ce\u9669\u6807\u8bb0',
 ]
 
 COLUMN_ALIASES = {
-    "客户姓名/公司": ["客户姓名/公司", "公司名称", "名称"],
-    "公司名称": ["公司名称", "客户姓名/公司", "名称"],
-    "主页/链接": ["主页/链接", "linkin链接", "LinkedIn链接", "链接", "网址"],
-    "公司官网": ["公司官网", "官网", "官方网站", "Website", "Company Website"],
-    "linkin链接": ["linkin链接", "LinkedIn链接", "主页/链接"],
-    "电话/WhatsApp": ["电话/WhatsApp", "公司公共电话", "电话", "WhatsApp"],
-    "公司公共电话": ["公司公共电话", "电话/WhatsApp", "电话"],
-    "邮箱": ["邮箱", "公司公共邮箱(任一有效即可)", "公司公共邮箱", "公共邮箱"],
-    "公司公共邮箱(任一有效即可)": ["公司公共邮箱(任一有效即可)", "公司公共邮箱", "公共邮箱", "邮箱"],
-    "个人邮箱(不一定有效)": ["个人邮箱(不一定有效)", "个人邮箱"],
-    "个人邮箱": ["个人邮箱", "个人邮箱(不一定有效)"],
-    "主营业务": ["主营业务", "公司主营业务"],
-    "公司主营业务": ["公司主营业务", "主营业务"],
-    "建联状态": ["建联状态", "建联情况"],
-    "建联情况": ["建联情况", "建联状态"],
+    '\u5ba2\u6237\u59d3\u540d/\u516c\u53f8': ['\u5ba2\u6237\u59d3\u540d/\u516c\u53f8', '\u516c\u53f8\u540d\u79f0', '\u516c\u53f8\u59d3\u540d', '\u540d\u79f0'],
+    '\u516c\u53f8\u540d\u79f0': ['\u516c\u53f8\u540d\u79f0', '\u5ba2\u6237\u59d3\u540d/\u516c\u53f8', '\u516c\u53f8\u59d3\u540d', '\u540d\u79f0'],
+    '\u4e3b\u9875/\u94fe\u63a5': ['\u4e3b\u9875/\u94fe\u63a5', 'linkin\u94fe\u63a5', 'LinkedIn\u94fe\u63a5', '\u94fe\u63a5', '\u7f51\u5740'],
+    '\u516c\u53f8\u5b98\u7f51': ['\u516c\u53f8\u5b98\u7f51', '\u5b98\u7f51\u94fe\u63a5', '\u5b98\u7f51', 'Website', 'Company Website'],
+    'linkin\u94fe\u63a5': ['linkin\u94fe\u63a5', 'LinkedIn\u94fe\u63a5', 'linkined\u4e3b\u9875\u94fe\u63a5', '\u4e3b\u9875/\u94fe\u63a5'],
+    '\u7535\u8bdd/WhatsApp': ['\u7535\u8bdd/WhatsApp', '\u516c\u53f8\u516c\u5171\u7535\u8bdd', '\u516c\u5171\u7535\u8bdd/WhatsApp', '\u7535\u8bdd', 'WhatsApp'],
+    '\u90ae\u7bb1': ['\u90ae\u7bb1', '\u516c\u5171\u90ae\u7bb1', '\u516c\u53f8\u516c\u5171\u90ae\u7bb1\uff08\u4efb\u4e00\u6709\u6548\u5373\u53ef\uff09', '\u516c\u53f8\u516c\u5171\u90ae\u7bb1'],
+    '\u4e3b\u8425\u4e1a\u52a1': ['\u4e3b\u8425\u4e1a\u52a1', '\u516c\u53f8\u4e3b\u8425\u4e1a\u52a1'],
+    '\u5efa\u8054\u72b6\u6001': ['\u5efa\u8054\u72b6\u6001', '\u5efa\u8054\u60c5\u51b5'],
 }
 
 
 def get_sheet_columns(sheet_name: str) -> List[str]:
     name = sheet_name.strip()
-    if name == "Facebook":
+    if name == SUMMARY_SHEET:
+        return SUMMARY_COLUMNS
+    if name == 'Facebook':
         return FACEBOOK_COLUMNS
-    if name == "LinkedIn":
+    if name == 'LinkedIn':
         return LINKEDIN_COLUMNS
-    if name == "Google Maps":
+    if name == 'Google Maps':
         return GOOGLE_MAPS_COLUMNS
-    if name == "汽车网站精选线索":
+    if name == '\u6c7d\u8f66\u7f51\u7ad9\u7cbe\u9009\u7ebf\u7d22':
         return AUTO_WEBSITE_COLUMNS
-    if name == "Facebook对话记录":
+    if name == CONVERSATION_SHEET:
         return FACEBOOK_CONVERSATION_COLUMNS
     return STANDARD_COLUMNS
 
@@ -87,16 +100,16 @@ def _aliases(column: str) -> List[str]:
 
 def _first_value(record: Dict[str, Any], column: str) -> Any:
     for key in _aliases(column):
-        value = record.get(key, "")
-        if value not in (None, ""):
+        value = record.get(key, '')
+        if value not in (None, ''):
             return value
-    return ""
+    return ''
 
 
 def _headers(ws) -> List[str]:
     values = []
     for cell in ws[1]:
-        value = "" if cell.value is None else str(cell.value).strip()
+        value = '' if cell.value is None else str(cell.value).strip()
         if value:
             values.append(value)
     return values
@@ -115,10 +128,10 @@ def _find_header(headers: Dict[str, int], column: str) -> Optional[str]:
 
 def _cell_value(row_values: Dict[str, Any], column: str) -> str:
     for alias in _aliases(column):
-        value = row_values.get(alias, "")
-        if value not in (None, ""):
+        value = row_values.get(alias, '')
+        if value not in (None, ''):
             return str(value).strip()
-    return ""
+    return ''
 
 
 def _copy_row_style(ws, source_row: int, target_row: int) -> None:
@@ -140,8 +153,7 @@ def _copy_row_style(ws, source_row: int, target_row: int) -> None:
 def read_sheet(excel_path: str, sheet_name: str) -> pd.DataFrame:
     path = Path(excel_path)
     if not path.exists():
-        raise FileNotFoundError(f"Excel 文件不存在: {excel_path}")
-
+        raise FileNotFoundError(f'Excel file not found: {excel_path}')
     df = pd.read_excel(excel_path, sheet_name=sheet_name)
     expected_cols = get_sheet_columns(sheet_name)
     for col in expected_cols:
@@ -152,7 +164,7 @@ def read_sheet(excel_path: str, sheet_name: str) -> pd.DataFrame:
                 df[col] = df[alias]
                 break
         else:
-            df[col] = ""
+            df[col] = ''
     return df
 
 
@@ -163,19 +175,18 @@ def normalize_record(record: Dict[str, Any], sheet_name: str) -> Dict[str, Any]:
     return normalized
 
 
-def append_records(
-    excel_path: str,
-    sheet_name: str,
-    records: List[Dict[str, Any]],
-    dedup_keys: Optional[List[str]] = None,
-) -> Dict[str, Any]:
+def append_records(excel_path: str, sheet_name: str, records: List[Dict[str, Any]], dedup_keys: Optional[List[str]] = None) -> Dict[str, Any]:
+    if sheet_name in CHANNEL_SOURCE_SHEETS or sheet_name == SUMMARY_SHEET:
+        try:
+            from .direct_summary import append_summary_records
+        except ImportError:
+            from direct_summary import append_summary_records
+        return append_summary_records(excel_path, records, source_sheet=sheet_name)
     if not records:
-        return {"appended": 0, "skipped": 0, "total": 0}
-
+        return {'appended': 0, 'skipped': 0, 'total': 0}
     path = Path(excel_path)
     if not path.exists():
-        raise FileNotFoundError(f"Excel 文件不存在: {excel_path}")
-
+        raise FileNotFoundError(f'Excel file not found: {excel_path}')
     wb = load_workbook(path)
     if sheet_name not in wb.sheetnames:
         ws = wb.create_sheet(sheet_name)
@@ -183,74 +194,60 @@ def append_records(
             ws.cell(1, idx, header)
     else:
         ws = wb[sheet_name]
-
     headers = _header_index(ws)
     if not headers:
         for idx, header in enumerate(get_sheet_columns(sheet_name), start=1):
             ws.cell(1, idx, header)
         headers = _header_index(ws)
-
     for expected_header in get_sheet_columns(sheet_name):
         if expected_header not in headers and not _find_header(headers, expected_header):
             next_col = ws.max_column + 1
             ws.cell(1, next_col, expected_header)
             headers[expected_header] = next_col
-
     keys = [key for key in (dedup_keys or []) if _find_header(headers, key)]
     existing_signatures = set()
     if keys:
         for row in ws.iter_rows(min_row=2, values_only=False):
             values = {header: row[col_idx - 1].value for header, col_idx in headers.items() if col_idx <= len(row)}
-            sig = "|".join(_cell_value(values, key).casefold() for key in keys)
-            if sig.strip("|"):
+            sig = '|'.join(_cell_value(values, key).casefold() for key in keys)
+            if sig.strip('|'):
                 existing_signatures.add(sig)
-
     appended = 0
     skipped = 0
     last_style_row = ws.max_row if ws.max_row > 1 else 1
-
     for record in records:
         normalized = normalize_record(record, sheet_name)
         if keys:
-            sig = "|".join(str(_first_value(normalized, key)).strip().casefold() for key in keys)
-            if sig.strip("|") and sig in existing_signatures:
+            sig = '|'.join(str(_first_value(normalized, key)).strip().casefold() for key in keys)
+            if sig.strip('|') and sig in existing_signatures:
                 skipped += 1
                 continue
-            if sig.strip("|"):
+            if sig.strip('|'):
                 existing_signatures.add(sig)
-
         target_row = ws.max_row + 1
         _copy_row_style(ws, last_style_row, target_row)
         for header, col_idx in headers.items():
-            if header.startswith("Unnamed"):
+            if header.startswith('Unnamed'):
                 continue
             value = _first_value(normalized, header)
-            if header == "序号" and value == "":
+            if header == '\u5e8f\u53f7' and value == '':
                 value = target_row - 1
             ws.cell(target_row, col_idx, value)
         appended += 1
         last_style_row = target_row
-
     wb.save(path)
-    return {"appended": appended, "skipped": skipped, "total": max(ws.max_row - 1, 0)}
+    return {'appended': appended, 'skipped': skipped, 'total': max(ws.max_row - 1, 0)}
 
 
-def update_status(
-    excel_path: str,
-    sheet_name: str,
-    filters: Dict[str, Any],
-    updates: Dict[str, Any],
-) -> int:
+def update_status(excel_path: str, sheet_name: str, filters: Dict[str, Any], updates: Dict[str, Any]) -> int:
     path = Path(excel_path)
     if not path.exists():
-        raise FileNotFoundError(f"Excel 文件不存在: {excel_path}")
-
+        raise FileNotFoundError(f'Excel file not found: {excel_path}')
     wb = load_workbook(path)
     if sheet_name not in wb.sheetnames:
-        raise ValueError(f"Sheet 不存在: {sheet_name}")
+        raise ValueError(f'Sheet not found: {sheet_name}')
     ws = wb[sheet_name]
     headers = _header_index(ws)
-
     updated = 0
     for row_idx in range(2, ws.max_row + 1):
         row_values = {header: ws.cell(row_idx, col_idx).value for header, col_idx in headers.items()}
@@ -262,15 +259,10 @@ def update_status(
                 break
         if not matched:
             continue
-
         for key, value in updates.items():
             header = _find_header(headers, key)
             if header:
                 ws.cell(row_idx, headers[header], value)
         updated += 1
-
     wb.save(path)
     return updated
-
-
-

+ 94 - 108
scripts/dashboard/build_dashboard.py

@@ -24,44 +24,45 @@ if str(COMMON_DIR) not in sys.path:
 from artifact_manager import new_run_id, resolve_artifact_path  # type: ignore  # noqa: E402
 from workbook_resolver import resolve_workbook_path  # type: ignore  # noqa: E402
 
-SUMMARY_SHEET_ALIASES = ["客户信息汇总表", "客户信息汇总"]
-CONVERSATION_SHEET = "Facebook对话记录"
-DEFAULT_TITLE = "五菱海外客户建联中台"
-FACEBOOK_INTENTS = ["明确有意向", "潜在意向", "需澄清", "暂不考虑", "明确拒绝"]
+SUMMARY_SHEET_ALIASES = ["\u5ba2\u6237\u4fe1\u606f\u6c47\u603b\u8868", "\u5ba2\u6237\u4fe1\u606f\u6c47\u603b"]
+CONVERSATION_SHEET = "Facebook\u5bf9\u8bdd\u8bb0\u5f55"
+DEFAULT_TITLE = "\u4e94\u83f1\u6d77\u5916\u5ba2\u6237\u5efa\u8054\u4e2d\u53f0"
 
 HEADER_ALIASES = {
-    "company": ["公司姓名", "客户姓名/公司", "公司名称", "客户名称", "公司/客户", "Name", "Company"],
-    "country": ["国家", "Country"],
-    "city": ["城市", "City"],
-    "customer_type": ["客户类型", "细分客户类型", "Customer Type"],
-    "website": ["官网链接", "公司官网", "官网", "官方网站", "Website"],
-    "contact": ["联系人", "姓名", "Contact"],
-    "position": ["职位", "职务", "Position", "Title"],
-    "personal_email": ["个人邮箱", "个人邮箱(不一定有效)"],
-    "contact_phone": ["联系人电话", "个人电话", "联系电话"],
-    "facebook_link": ["Facebook主页链接", "Facebook链接", "facebook链接", "主页/链接"],
-    "linkedin_link": ["linkined主页链接", "LinkedIn主页链接", "LinkedIn链接", "linkin链接", "linkin连接"],
-    "google_maps_link": ["google map链接", "Google Maps链接", "Google Map链接", "地图链接"],
-    "public_phone": ["公共电话/WhatsApp", "电话/WhatsApp", "公司公共电话", "电话", "WhatsApp"],
-    "public_email": ["公共邮箱", "邮箱", "公司公共邮箱(任一有效即可)", "公司公共邮箱", "Email"],
-    "attribute": ["客户属性", "客户大类", "Customer Attribute"],
-    "status": ["建联状态", "建联情况", "状态", "Status"],
-    "next_followup": ["下次跟进", "下次跟进时间", "Next Follow-up"],
-    "note": ["备注", "说明", "Notes"],
+    "company": ["\u516c\u53f8\u59d3\u540d", "\u5ba2\u6237\u59d3\u540d/\u516c\u53f8", "\u516c\u53f8\u540d\u79f0", "\u5ba2\u6237\u540d\u79f0", "\u516c\u53f8/\u5ba2\u6237", "Name", "Company"],
+    "country": ["\u56fd\u5bb6", "Country"],
+    "city": ["\u57ce\u5e02", "City"],
+    "customer_type": ["\u5ba2\u6237\u7c7b\u578b", "\u7ec6\u5206\u5ba2\u6237\u7c7b\u578b", "Customer Type"],
+    "website": ["\u5b98\u7f51\u94fe\u63a5", "\u516c\u53f8\u5b98\u7f51", "\u5b98\u7f51", "\u5b98\u65b9\u7f51\u7ad9", "Website"],
+    "contact": ["\u8054\u7cfb\u4eba", "\u59d3\u540d", "Contact"],
+    "position": ["\u804c\u4f4d", "\u804c\u52a1", "Position", "Title"],
+    "personal_email": ["\u4e2a\u4eba\u90ae\u7bb1", "\u4e2a\u4eba\u90ae\u7bb1\uff08\u4e0d\u4e00\u5b9a\u6709\u6548\uff09"],
+    "contact_phone": ["\u8054\u7cfb\u4eba\u7535\u8bdd", "\u4e2a\u4eba\u7535\u8bdd", "\u8054\u7cfb\u7535\u8bdd"],
+    "facebook_link": ["Facebook\u4e3b\u9875\u94fe\u63a5", "Facebook\u94fe\u63a5", "facebook\u94fe\u63a5", "\u4e3b\u9875/\u94fe\u63a5"],
+    "linkedin_link": ["linkined\u4e3b\u9875\u94fe\u63a5", "LinkedIn\u4e3b\u9875\u94fe\u63a5", "LinkedIn\u94fe\u63a5", "linkin\u94fe\u63a5", "linkin\u8fde\u63a5"],
+    "google_maps_link": ["google map\u94fe\u63a5", "Google Maps\u94fe\u63a5", "Google Map\u94fe\u63a5", "\u5730\u56fe\u94fe\u63a5"],
+    "public_phone": ["\u516c\u5171\u7535\u8bdd/WhatsApp", "\u7535\u8bdd/WhatsApp", "\u516c\u53f8\u516c\u5171\u7535\u8bdd", "\u7535\u8bdd", "WhatsApp"],
+    "public_email": ["\u516c\u5171\u90ae\u7bb1", "\u90ae\u7bb1", "\u516c\u53f8\u516c\u5171\u90ae\u7bb1\uff08\u4efb\u4e00\u6709\u6548\u5373\u53ef\uff09", "\u516c\u53f8\u516c\u5171\u90ae\u7bb1", "Email"],
+    "attribute": ["\u5ba2\u6237\u5c5e\u6027", "\u5ba2\u6237\u5927\u7c7b", "Customer Attribute"],
+    "lead_grade": ["\u7ebf\u7d22\u7b49\u7ea7", "Lead Grade", "Grade"],
+    "manual_review": ["\u9700\u4eba\u5de5\u786e\u8ba4", "Manual Review"],
+    "status": ["\u5efa\u8054\u72b6\u6001", "\u5efa\u8054\u60c5\u51b5", "\u72b6\u6001", "Status"],
+    "next_followup": ["\u4e0b\u6b21\u8ddf\u8fdb", "\u4e0b\u6b21\u8ddf\u8fdb\u65f6\u95f4", "Next Follow-up"],
+    "note": ["\u5907\u6ce8", "\u8bf4\u660e", "Notes"],
 }
 
 CONVERSATION_HEADER_ALIASES = {
-    "record_id": ["记录ID"],
-    "customer_index": ["客户序号"],
-    "company": ["客户姓名/公司", "公司姓名", "公司名称"],
-    "facebook_link": ["Facebook主页链接", "主页/链接"],
-    "message_time": ["消息时间"],
-    "direction": ["消息方向"],
-    "translation": ["中文翻译"],
-    "effective": ["是否有效客户回复"],
-    "intent": ["合作意向"],
-    "intent_reason": ["意向判断依据"],
-    "next_action": ["下一步建议"],
+    "record_id": ["\u8bb0\u5f55ID"],
+    "customer_index": ["\u5ba2\u6237\u5e8f\u53f7"],
+    "company": ["\u5ba2\u6237\u59d3\u540d/\u516c\u53f8", "\u516c\u53f8\u59d3\u540d", "\u516c\u53f8\u540d\u79f0"],
+    "facebook_link": ["Facebook\u4e3b\u9875\u94fe\u63a5", "\u4e3b\u9875/\u94fe\u63a5"],
+    "message_time": ["\u6d88\u606f\u65f6\u95f4"],
+    "direction": ["\u6d88\u606f\u65b9\u5411"],
+    "translation": ["\u4e2d\u6587\u7ffb\u8bd1"],
+    "effective": ["\u662f\u5426\u6709\u6548\u5ba2\u6237\u56de\u590d"],
+    "intent": ["\u5408\u4f5c\u610f\u5411"],
+    "intent_reason": ["\u610f\u5411\u5224\u65ad\u4f9d\u636e"],
+    "next_action": ["\u4e0b\u4e00\u6b65\u5efa\u8bae"],
 }
 
 CONTACTED_KEYWORDS = ["已发送邮件", "已发邮件", "邮件已发送", "邮件发送成功", "已发私信", "已关注并私信", "已建联", "等待回复", "已回复", "有意向"]
@@ -118,7 +119,7 @@ def is_effective_row(row: Dict[str, str]) -> bool:
 
 
 def read_summary_rows(workbook_path: Path, sheet_name: str) -> Tuple[str, List[Dict[str, str]]]:
-    wb = load_workbook(workbook_path, data_only=True, read_only=True)
+    wb = load_workbook(workbook_path, data_only=True, read_only=False)
     actual_sheet = find_sheet_name(wb, sheet_name)
     ws = wb[actual_sheet]
     headers = header_map(ws)
@@ -145,7 +146,7 @@ def customer_key(company: str, facebook_link: str, customer_index: str = "") ->
 
 
 def read_conversation_rows(workbook_path: Path, sheet_name: str) -> List[Dict[str, str]]:
-    wb = load_workbook(workbook_path, data_only=True, read_only=True)
+    wb = load_workbook(workbook_path, data_only=True, read_only=False)
     if sheet_name not in wb.sheetnames:
         return []
     ws = wb[sheet_name]
@@ -189,35 +190,25 @@ def build_conversation_insights(rows: Sequence[Dict[str, str]]) -> Dict[str, Any
     reply_trend: Counter = Counter()
     for row in sorted(rows, key=lambda item: message_datetime(item.get("message_time", ""))):
         key = customer_key(row.get("company", ""), row.get("facebook_link", ""), row.get("customer_index", ""))
-        if row.get("direction") == "我方发送":
+        if row.get("direction") == "\u6211\u65b9\u53d1\u9001":
             sent_customers.add(key)
-        if row.get("direction") != "客户回复" or row.get("effective") != "是":
+        if row.get("direction") != "\u5ba2\u6237\u56de\u590d" or row.get("effective") != "\u662f":
             continue
         replied_customers.add(key)
-        if row.get("intent") in FACEBOOK_INTENTS:
-            latest_by_customer[key] = row
+        latest_by_customer[key] = row
         parsed = message_datetime(row.get("message_time", ""))
         if parsed != datetime.min and (datetime.now() - parsed).days <= 30:
             reply_trend[parsed.strftime("%Y-%m-%d")] += 1
-    intent_counter = Counter(
-        row.get("intent") or "未分析" for row in latest_by_customer.values()
-    )
-    high_intent = [
+    reply_customers = [
         {
             "company": row.get("company", ""),
-            "intent": row.get("intent", ""),
-            "latest_reply": row.get("translation", ""),
-            "reason": row.get("intent_reason", ""),
-            "next_action": row.get("next_action", ""),
+            "latest_reply": row.get("translation", "") or row.get("original", ""),
+            "next_action": row.get("next_action", "") or "\u8bf7\u4eba\u5de5\u67e5\u770b\u5ba2\u6237\u56de\u590d\uff0c\u5e76\u5224\u65ad\u662f\u5426\u9700\u8981\u7ee7\u7eed\u8ddf\u8fdb\u3002",
             "message_time": row.get("message_time", ""),
         }
         for row in latest_by_customer.values()
-        if row.get("intent") in {"明确有意向", "潜在意向"}
-    ]
-    pending = [
-        item for item in high_intent
-        if item.get("next_action")
     ]
+    pending = [item for item in reply_customers if item.get("next_action")]
     trend = [
         {"name": day, "count": count}
         for day, count in sorted(reply_trend.items())
@@ -226,74 +217,71 @@ def build_conversation_insights(rows: Sequence[Dict[str, str]]) -> Dict[str, Any
         "sent_customers": sent_customers,
         "replied_customers": replied_customers,
         "latest_by_customer": latest_by_customer,
-        "intent_counter": intent_counter,
+        "reply_status_counter": Counter({"\u5df2\u56de\u590d": len(replied_customers), "\u672a\u56de\u590d": max(len(sent_customers) - len(replied_customers), 0)}),
         "reply_trend": trend,
-        "high_intent": sorted(high_intent, key=lambda item: item.get("message_time", ""), reverse=True),
-        "pending_followups": sorted(pending, key=lambda item: item.get("message_time", ""), reverse=True),
+        "reply_customers": sorted(reply_customers, key=lambda item: item.get("message_time", ""), reverse=True),
+        "pending_review": sorted(pending, key=lambda item: item.get("message_time", ""), reverse=True),
     }
 
-
-def contains_any(text: str, keywords: Sequence[str]) -> bool:
-    lowered = text.casefold()
-    return any(keyword.casefold() in lowered for keyword in keywords)
+def row_sources(row: Dict[str, str]) -> List[str]:
+    sources: List[str] = []
+    if row.get("facebook_link"):
+        sources.append("Facebook")
+    if row.get("linkedin_link"):
+        sources.append("LinkedIn")
+    if row.get("google_maps_link"):
+        sources.append("Google Maps")
+    note = " ".join([row.get("note", ""), row.get("website", "")])
+    for label, pattern in SOURCE_PATTERNS:
+        if pattern.search(note) and label not in sources:
+            sources.append(label)
+    return sources or ["未标明来源"]
 
 
-def has_email(row: Dict[str, str]) -> bool:
-    return bool(row.get("public_email") or row.get("personal_email"))
+def pct(part: int, total: int) -> float:
+    return round(part * 100 / total, 1) if total else 0.0
 
 
-def has_phone(row: Dict[str, str]) -> bool:
-    return bool(row.get("public_phone") or row.get("contact_phone"))
+def top_counter(counter: Counter, limit: int) -> List[Dict[str, Any]]:
+    total = sum(counter.values())
+    return [{"name": name or "未填写", "count": count, "rate": pct(count, total)} for name, count in counter.most_common(limit)]
 
 
 def has_any_link(row: Dict[str, str]) -> bool:
     return bool(row.get("website") or row.get("facebook_link") or row.get("linkedin_link") or row.get("google_maps_link"))
 
 
-def is_excluded(row: Dict[str, str]) -> bool:
-    return contains_any(" ".join([row.get("status", ""), row.get("note", "")]), EXCLUDED_KEYWORDS)
+def counter_to_items(counter: Counter) -> List[Dict[str, Any]]:
+    total = sum(counter.values())
+    return [{"name": name or "未填写", "count": count, "rate": pct(count, total)} for name, count in counter.most_common()]
 
 
-def is_bounced(row: Dict[str, str]) -> bool:
-    return contains_any(" ".join([row.get("status", ""), row.get("note", "")]), BOUNCED_KEYWORDS)
+def contains_any(text: str, keywords: Sequence[str]) -> bool:
+    lowered = text.casefold()
+    return any(keyword.casefold() in lowered for keyword in keywords)
+
+def is_excluded(row: Dict[str, str]) -> bool:
+    return contains_any(" ".join([row.get("status", ""), row.get("note", "")]), EXCLUDED_KEYWORDS)
 
+def is_contactable(row: Dict[str, str]) -> bool:
+    return (has_email(row) or has_phone(row) or has_any_link(row)) and not is_excluded(row)
 
 def is_contacted(row: Dict[str, str]) -> bool:
     text = " ".join([row.get("status", ""), row.get("note", "")])
     return contains_any(text, CONTACTED_KEYWORDS) and not is_bounced(row)
 
+def is_bounced(row: Dict[str, str]) -> bool:
+    return contains_any(" ".join([row.get("status", ""), row.get("note", "")]), BOUNCED_KEYWORDS)
 
 def needs_review(row: Dict[str, str]) -> bool:
     text = " ".join([row.get("customer_type", ""), row.get("attribute", ""), row.get("note", "")])
     return contains_any(text, REVIEW_KEYWORDS)
 
+def has_email(row: Dict[str, str]) -> bool:
+    return bool(row.get("public_email") or row.get("personal_email"))
 
-def is_contactable(row: Dict[str, str]) -> bool:
-    return (has_email(row) or has_phone(row) or has_any_link(row)) and not is_excluded(row)
-
-
-def row_sources(row: Dict[str, str]) -> List[str]:
-    sources: List[str] = []
-    if row.get("facebook_link"):
-        sources.append("Facebook")
-    if row.get("linkedin_link"):
-        sources.append("LinkedIn")
-    if row.get("google_maps_link"):
-        sources.append("Google Maps")
-    note = " ".join([row.get("note", ""), row.get("website", "")])
-    for label, pattern in SOURCE_PATTERNS:
-        if pattern.search(note) and label not in sources:
-            sources.append(label)
-    return sources or ["未标明来源"]
-
-
-def pct(part: int, total: int) -> float:
-    return round(part * 100 / total, 1) if total else 0.0
-
-
-def top_counter(counter: Counter, limit: int) -> List[Dict[str, Any]]:
-    total = sum(counter.values())
-    return [{"name": name or "未填写", "count": count, "rate": pct(count, total)} for name, count in counter.most_common(limit)]
+def has_phone(row: Dict[str, str]) -> bool:
+    return bool(row.get("public_phone") or row.get("contact_phone"))
 
 
 def display_contact(row: Dict[str, str]) -> str:
@@ -319,10 +307,11 @@ def build_dashboard_data(
     conversation = build_conversation_insights(conversations)
     facebook_sent = len(conversation["sent_customers"])
     facebook_replied = len(conversation["replied_customers"])
-    latest_intents = conversation["latest_by_customer"]
+    latest_replies = conversation["latest_by_customer"]
 
     attribute_counter = Counter(row.get("attribute") or "未填写" for row in valid_rows)
     type_counter = Counter(row.get("customer_type") or "未填写" for row in valid_rows)
+    grade_counter = Counter(row.get("lead_grade") or "未填写" for row in valid_rows)
     status_counter = Counter(row.get("status") or "未填写" for row in rows)
     city_counter = Counter(row.get("city") or "未填写" for row in valid_rows)
     source_counter: Counter = Counter()
@@ -332,13 +321,15 @@ def build_dashboard_data(
 
     dashboard_rows = []
     for row in valid_rows:
-        reply = latest_intents.get(customer_key(row.get("company", ""), row.get("facebook_link", "")), {})
+        reply = latest_replies.get(customer_key(row.get("company", ""), row.get("facebook_link", "")), {})
         dashboard_rows.append({
             "row": row.get("_excel_row", ""),
             "company": row.get("company", ""),
             "city": row.get("city", ""),
             "attribute": row.get("attribute", ""),
             "type": row.get("customer_type", ""),
+            "lead_grade": row.get("lead_grade", ""),
+            "manual_review": row.get("manual_review", ""),
             "source": ";".join(row_sources(row)),
             "contact": display_contact(row),
             "status": row.get("status", ""),
@@ -348,9 +339,9 @@ def build_dashboard_data(
             "bounced": is_bounced(row),
             "review": needs_review(row),
             "note": row.get("note", "")[:220],
-            "facebook_intent": reply.get("intent", ""),
-            "latest_reply": reply.get("translation", "")[:160],
-            "next_action": reply.get("next_action", "")[:160],
+            "facebook_replied": bool(reply),
+            "latest_reply": (reply.get("translation", "") or reply.get("original", ""))[:160],
+            "next_action": (reply.get("next_action", "") or "\u8bf7\u4eba\u5de5\u67e5\u770b\u5ba2\u6237\u56de\u590d\uff0c\u5e76\u5224\u65ad\u662f\u5426\u9700\u8981\u7ee7\u7eed\u8ddf\u8fdb\u3002")[:160] if reply else "",
         })
 
     attention_rows = [row for row in dashboard_rows if row["contact"] and not row["contacted"] and not row["bounced"]][:top_n]
@@ -378,27 +369,22 @@ def build_dashboard_data(
             "facebook_dm_customers": facebook_sent,
             "facebook_reply_customers": facebook_replied,
             "facebook_reply_rate": pct(facebook_replied, facebook_sent),
-            "facebook_clear_intent_customers": sum(
-                1 for row in latest_intents.values() if row.get("intent") == "明确有意向"
-            ),
-            "facebook_potential_intent_customers": sum(
-                1 for row in latest_intents.values() if row.get("intent") == "潜在意向"
-            ),
-            "facebook_pending_followups": len(conversation["pending_followups"]),
+            "facebook_pending_review_customers": len(conversation["pending_review"]),
         },
         "charts": {
             "attribute": top_counter(attribute_counter, top_n),
             "customer_type": top_counter(type_counter, top_n),
+            "lead_grade": counter_to_items(grade_counter),
             "status": top_counter(status_counter, top_n),
             "source": top_counter(source_counter, top_n),
             "city": top_counter(city_counter, top_n),
-            "facebook_intent": top_counter(conversation["intent_counter"], len(FACEBOOK_INTENTS)),
+            "facebook_reply_status": counter_to_items(conversation["reply_status_counter"]),
             "facebook_reply_trend": conversation["reply_trend"],
         },
         "dashboard_rows": dashboard_rows,
         "attention_customers": attention_rows,
-        "facebook_high_intent_customers": conversation["high_intent"][:top_n],
-        "facebook_pending_followups": conversation["pending_followups"][:top_n],
+        "facebook_reply_customers_detail": conversation["reply_customers"][:top_n],
+        "facebook_pending_review_customers": conversation["pending_review"][:top_n],
         "definitions": {
             "有效客户": "总表中未被备注或状态标记为已剔除、跳过、低优先级的客户。",
             "可建联客户": "有效客户中至少有邮箱、电话/WhatsApp、官网、Facebook、LinkedIn 或 Google Maps 入口之一。",

+ 1 - 1
scripts/scraper/scrape_single_page.py

@@ -678,7 +678,7 @@ def main():
     parser.add_argument("--main-business", default="", help="主营业务(脚本识别不准时手动指定)")
     parser.add_argument("--ads-power-url", default="http://127.0.0.1:50325", help="AdsPower API URL")
     parser.add_argument("--excel", default="", help="建联表路径;不传时按项目优先级自动查找")
-    parser.add_argument("--sheet", default="Facebook", help="Sheet 名")
+    parser.add_argument("--sheet", default="\u5ba2\u6237\u4fe1\u606f\u6c47\u603b\u8868", help="Sheet 名")
     parser.add_argument("--write-excel", action="store_true", help="确认写入建联表;否则只输出 JSON 预览")
     parser.add_argument("--run-id", default="", help="Run ID used for runs/YYYYMMDD/<run_id>/ artifacts.")
     parser.add_argument("--output", default="single_page_scraped.json", help="JSON 输出")

+ 1 - 1
scripts/scraper/search_active_dealers.py

@@ -799,7 +799,7 @@ def main():
     parser.add_argument("--run-id", default="", help="Run ID used for runs/YYYYMMDD/<run_id>/ artifacts.")
     parser.add_argument("--output", default="facebook_candidate_preview.json", help="候选预览/深采输出 JSON 文件")
     parser.add_argument("--excel", default="", help="用于预去重和可选回写的 Excel 路径;写表时必须显式提供")
-    parser.add_argument("--sheet", default="Facebook", help="Sheet 名")
+    parser.add_argument("--sheet", default="\u5ba2\u6237\u4fe1\u606f\u6c47\u603b\u8868", help="Sheet 名")
     parser.add_argument("--headless", action="store_true", help="无头模式")
     parser.add_argument("--deep-scrape", action="store_true", help="打开高分候选主页并深采 About + 最近帖子")
     parser.add_argument("--write-excel", action="store_true", help="确认后将深采记录写入 Excel;默认只输出 JSON 预览")

+ 8 - 55
scripts/scraper/search_auto_websites.py

@@ -29,11 +29,11 @@ from playwright.sync_api import BrowserContext, Page, sync_playwright
 
 try:
     from . import discovery_common as dc
-    from ..common import resolve_workbook_path
+    from ..common import resolve_workbook_path, append_records
 except ImportError:
     sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
     from scraper import discovery_common as dc
-    from common import resolve_workbook_path
+    from common import resolve_workbook_path, append_records
 
 if hasattr(sys.stdout, "reconfigure"):
     sys.stdout.reconfigure(encoding="utf-8")
@@ -717,59 +717,12 @@ def read_existing_sheet_index(ws) -> Dict[str, int]:
 def write_records_to_workbook(excel_path: Path, sheet_name: str, records: List[Dict[str, Any]]) -> Dict[str, Any]:
     locks = sorted(p.name for p in excel_path.parent.glob("~$*.xlsx"))
     if locks:
-        raise PermissionError("检测到 Excel 临时锁文件: " + ", ".join(locks))
-
+        raise PermissionError("Excel lock file detected: " + ", ".join(locks))
     backup_path = create_backup_once(excel_path, purpose="auto_websites", run_id="auto_websites")
-
-    wb = load_workbook(excel_path)
-    ws = prepare_sheet(wb, sheet_name)
-    header_map = {header: idx for idx, header in enumerate(HEADERS, start=1)}
-    existing_index = read_existing_sheet_index(ws)
-    appended = 0
-    merged = 0
-    no_email = 0
-
-    for record in records:
-        if not record.get("邮箱"):
-            no_email += 1
-        row = None
-        for key in duplicate_key(record):
-            if key in existing_index:
-                row = existing_index[key]
-                break
-        if row:
-            merged += 1
-            for field in ["来源网站", "备注"]:
-                col = header_map[field]
-                if field == "来源网站":
-                    ws.cell(row, col).value = merge_sources(ws.cell(row, col).value, record.get(field, ""))
-                else:
-                    ws.cell(row, col).value = append_unique_text(ws.cell(row, col).value, record.get(field, ""))
-            for field in ["电话/WhatsApp", "邮箱", "主页/链接", "主营业务", "客户类型", "城市"]:
-                col = header_map[field]
-                if not clean(ws.cell(row, col).value) and clean(record.get(field)):
-                    ws.cell(row, col).value = record.get(field)
-            continue
-
-        appended += 1
-        row = ws.max_row + 1
-        record["序号"] = row - 1
-        for header, col in header_map.items():
-            ws.cell(row, col).value = record.get(header, "")
-        for key in duplicate_key(record):
-            existing_index[key] = row
-
-    wb.save(excel_path)
-    wb.close()
-    return {
-        "backup_path": str(backup_path),
-        "appended": appended,
-        "merged": merged,
-        "no_email": no_email,
-        "sheet": sheet_name,
-        "workbook": str(excel_path),
-    }
-
+    write_result = append_records(str(excel_path), sheet_name, records)
+    write_result["backup_path"] = str(backup_path)
+    write_result["workbook"] = str(excel_path)
+    return write_result
 
 def collect_records(
     profile_id: str,
@@ -844,7 +797,7 @@ def main() -> None:
     parser.add_argument("--profile-id", default="", help="AdsPower profile ID; defaults to current local active browser")
     parser.add_argument("--ads-power-url", default=DEFAULT_ADSPOWER_URL)
     parser.add_argument("--excel", default="", help="Workbook path; defaults to detected Morocco outreach workbook")
-    parser.add_argument("--sheet", default=DEFAULT_SHEET)
+    parser.add_argument("--sheet", default="\u5ba2\u6237\u4fe1\u606f\u6c47\u603b\u8868")
     parser.add_argument("--max-per-source", type=int, default=5)
     parser.add_argument("--max-candidates-per-source", type=int, default=12)
     parser.add_argument("--queries-per-source", type=int, default=3)

+ 1 - 1
scripts/scraper/search_facebook.py

@@ -421,7 +421,7 @@ def main():
     parser.add_argument("--run-id", default="", help="Run ID used for runs/YYYYMMDD/<run_id>/ artifacts.")
     parser.add_argument("--output", default="facebook_scraped.json", help="输出 JSON 文件")
     parser.add_argument("--excel", default="", help="建联表路径;不传时按项目优先级自动查找")
-    parser.add_argument("--sheet", default="Facebook", help="回写 Sheet 名")
+    parser.add_argument("--sheet", default="\u5ba2\u6237\u4fe1\u606f\u6c47\u603b\u8868", help="回写 Sheet 名")
     parser.add_argument("--write-excel", action="store_true", help="确认写入建联表;否则只输出 JSON 预览")
     parser.add_argument("--headless", action="store_true", help="无头模式")
 

+ 4 - 2
scripts/scraper/search_google_maps.py

@@ -34,13 +34,15 @@ from playwright.sync_api import BrowserContext, Page, sync_playwright
 try:
     from ..common import append_records, resolve_workbook_path
     from . import discovery_common as dc
+    from . import website_deep_scraper
 except ImportError:
     sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
     from common import append_records, resolve_workbook_path
     from scraper import discovery_common as dc
+    from scraper import website_deep_scraper
 
 DEFAULT_EXCEL = ""
-DEFAULT_SHEET = "Google Maps"
+DEFAULT_SHEET = "\u5ba2\u6237\u4fe1\u606f\u6c47\u603b\u8868"
 DEFAULT_CITY_SCOPE = "摩洛哥全国"
 
 NATIONWIDE_KEYWORDS = [
@@ -241,7 +243,7 @@ def same_site(url: str, base_url: str) -> bool:
 
 
 def scrape_public_email_from_website(context: BrowserContext, website_url: str, max_pages: int = 4) -> Dict[str, Any]:
-    result = scrape_public_website_enrichment(context, website_url, max_pages=max_pages)
+    result = website_deep_scraper.scrape_public_website(context, website_url, max_pages=max_pages)
     return {
         "email": result.get("email", ""),
         "emails": result.get("emails", []),

+ 1 - 1
scripts/scraper/search_linkedin.py

@@ -41,7 +41,7 @@ except ImportError:
     from scraper import discovery_common as dc
 
 DEFAULT_EXCEL = ""
-DEFAULT_SHEET = "LinkedIn"
+DEFAULT_SHEET = "\u5ba2\u6237\u4fe1\u606f\u6c47\u603b\u8868"
 DEFAULT_CITY_SCOPE = "摩洛哥全国"
 
 DEFAULT_KEYWORDS = [

+ 18 - 87
scripts/social/README.md

@@ -1,94 +1,25 @@
-# Facebook Follow + Messenger 建联脚本
+# Facebook Follow + Messenger 建联脚本
 
-本目录是模块二的正式实现:从建联表生成英文社交建联预览,并通过 AdsPower + Playwright 执行 Facebook Page Follow + Messenger 私信。
+本目录实现 Facebook Page Follow + Messenger 私信建联和 Facebook 对话读取。所有浏览器操作必须通过 AdsPower + Playwright,且不得关闭用户的 AdsPower 指纹浏览器
 
-## 脚本清单
+## 当前入表规则
 
-| 脚本 | 作用 | 默认是否真实发送 | 默认是否写表 |
-|---|---|---:|---:|
-| `prepare_facebook_outreach.py` | 读取 Facebook Sheet,按客户类型生成中文判断 + 英文加好友/私信话术 JSON 预览 | 否 | 否 |
-| `send_facebook_outreach.py` | 根据预览执行 Page Follow + Messenger DM;默认 dry-run,真实执行需 `--confirm` | 否 | 否 |
-| `run_facebook_follow_dm.py` | 模块二总控入口:先生成预览,再调用执行脚本 dry-run 或确认发送 | 否 | 否 |
-| `collect_facebook_conversations.py` | 通过 AdsPower + Playwright 只读采集已匹配客户的 Messenger 历史/增量消息 | 否 | 否 |
-| `write_facebook_conversations.py` | 校验 Agent 中文翻译和五级意向分析,写入 Facebook对话记录并更新客户状态 | 否 | 需 `--write-workbook` |
+客户资料主表为 `客户信息汇总表`。脚本默认从总表读取 Facebook 主页链接、客户属性、客户类型、线索等级、需人工确认、建联状态和备注。旧参数 `--sheet Facebook` 只作为兼容入口;写表时应回写总表,不再新增 Facebook 渠道客户行。
 
-## 推荐流程
+## 脚本
 
-1. 已在 AdsPower 指纹浏览器中登录 Facebook。
-2. 运行总控脚本生成预览,确认客户和英文话术。
-3. 运行 dry-run,检查脚本识别到的 Page 顶部 Follow / Message 和 Messenger 目标。
-4. 用户明确确认后,加 `--confirm-send` 真实执行。
-5. 只有需要回写建联表时,额外加 `--write-workbook`。
+| 脚本 | 用途 |
+|---|---|
+| `prepare_facebook_outreach.py` | 从总表筛选 Facebook 客户,生成中文判断 + 英文私信预览 |
+| `run_facebook_follow_dm.py` | 总控:生成整批预览、节奏预览,并调用执行脚本 |
+| `send_facebook_outreach.py` | 执行 Follow + Messenger DM,真实发送必须 `--confirm --batch-confirmed` |
+| `collect_facebook_conversations.py` | 只读采集匹配客户的 Messenger 对话 |
+| `write_facebook_conversations.py` | 写入 `Facebook对话记录`,并把最新中文摘要/意向回写总表 |
 
-## 只生成预览
+## 执行规则
 
-```powershell
-python scripts/social/run_facebook_follow_dm.py `
-  --excel "D:\WEFANBOT\摩洛哥客户建联表-按渠道分类 .xlsx" `
-  --sheet Facebook `
-  --profile-id k1eu4lc2 `
-  --max-contacts 5 `
-  --review-only
-```
-
-## Dry-run,不真实点击
-
-```powershell
-python scripts/social/run_facebook_follow_dm.py `
-  --excel "D:\WEFANBOT\摩洛哥客户建联表-按渠道分类 .xlsx" `
-  --sheet Facebook `
-  --profile-id k1eu4lc2 `
-  --max-contacts 5 `
-  --use-open-page
-```
-
-## 真实执行,整批确认
-
-```powershell
-python scripts/social/run_facebook_follow_dm.py `
-  --excel "D:\WEFANBOT\摩洛哥客户建联表-按渠道分类 .xlsx" `
-  --sheet Facebook `
-  --profile-id k1eu4lc2 `
-  --max-contacts 5 `
-  --use-open-page `
-  --confirm-send
-```
-
-## 整批已确认后自动逐条执行
-
-```powershell
-python scripts/social/run_facebook_follow_dm.py `
-  --excel "D:\WEFANBOT\摩洛哥客户建联表-按渠道分类 .xlsx" `
-  --sheet Facebook `
-  --profile-id k1eu4lc2 `
-  --max-contacts 5 `
-  --use-open-page `
-  --confirm-send `
-  --batch-confirmed
-```
-
-## 安全规则
-
-- Facebook 对外发送语言固定为英文。
-- 默认不写建联表;需要写表时显式加 `--write-workbook`。
-- 默认且强制保持 AdsPower 浏览器打开;脚本结束时仅断开 Playwright 连接,不停止 AdsPower 配置或关闭浏览器。
-- 私信只能发到 Messenger 小窗或 Messenger 私信页,且必须验证目标客户名称。
-- 禁止向帖子评论框、回复框、页面底部通用输入框发送。
-- 若主页 `發送訊息` 按钮不弹出 Messenger 小窗,脚本会兜底打开 `https://www.facebook.com/messages/t/<page_slug>`,验证客户名称后再发送。
-- 遇到验证、限流、异常活动提示时立即停止。
-
-## Facebook 回复同步
-
-首次历史采集:
-
-```powershell
-python scripts/social/collect_facebook_conversations.py --excel "<建联表>" --profile-id "<profile_id>" --initial-full
-```
-
-后续增量采集将 `--initial-full` 改成 `--incremental`。Agent 必须按 `references/facebook-conversation-sync.md` 翻译和分析,并先在聊天框展示结果。写表时使用:
-
-```powershell
-python scripts/social/write_facebook_conversations.py --excel "<建联表>" --transcript "<raw.json>" --analysis "<analysis.json>" --write-workbook --refresh-summary --refresh-dashboard
-```
-
-回复同步只读取消息,不发送回复、不点击反应、不下载附件、不关闭 AdsPower。
+- 对外话术只用英文,中文只用于内部判断。
+- 先在聊天框展示整批预览,再由用户确认一次后批量执行。
+- 不逐条确认;遇风险、验证、限流、Messenger 定位不匹配立即停止。
+- 不使用帖子评论框、页面底部输入框或坐标点击。
+- 状态写回时优先按 Facebook 主页链接匹配总表行,其次按行号或公司名。

+ 47 - 3
scripts/social/collect_facebook_conversations.py

@@ -34,7 +34,7 @@ from ads_power_client import AdsPowerClient  # type: ignore  # noqa: E402
 from artifact_manager import new_run_id, resolve_artifact_path  # type: ignore  # noqa: E402
 from workbook_resolver import resolve_workbook_path  # type: ignore  # noqa: E402
 
-FACEBOOK_SHEET = "Facebook"
+FACEBOOK_SHEET = "\u5ba2\u6237\u4fe1\u606f\u6c47\u603b\u8868"
 CONVERSATION_SHEET = "Facebook对话记录"
 DEFAULT_MAX_MESSAGES = 2000
 MAJOR_WAIT = (90.0, 200.0)
@@ -71,6 +71,34 @@ OUTGOING_MARKERS = [
     "vous avez envoyé",
     "envoyé par vous",
     "لقد أرسلت",
+    # 我方标准建联话术片段(来自 social_outreach_library 及实际发送话术)
+    "a low-cost new-vehicle line could be",
+    "i am looking at auto channels",
+    "if some of your buyers want",
+    "wuling could be reviewed as an",
+    "your page shows import or distribution",
+    "your page shows rental or fleet",
+    "your page shows showroom or dealer",
+    "your page shows used-car or occasion",
+    "your used-car or occasion activity and",
+    "huatu overseas can support wuling export",
+    "huatu overseas supports wuling export",
+    "would a short model and price-range overview",
+    "would a short first-batch fit check",
+    "should i send a short",
+    "is this something your team would normally evaluate",
+    "does your team usually evaluate vehicle sourcing",
+    "wuling is worth a low-pressure first-batch review",
+    "worth connecting",
+    "thought it would be useful to connect",
+    "open to connect",
+    "wuling export as an affordable",
+    "hi, i'm chris",
+    "wuling overseas business department",
+    "wuling has sold over 30 million",
+    "caught my attention as we review",
+    "would a one-page",
+    "may i send",
 ]
 SYSTEM_MARKERS = [
     "messages and calls are secured",
@@ -272,14 +300,16 @@ def validate_thread(page, customer: Dict[str, str]) -> Dict[str, Any]:
     url_match = "messages" in path_parts and "t" in path_parts and expected_identity in path_parts
     title = visible_thread_title(page)
     title_match = name_match(customer["company"], title)
-    matched = bool(url_match and title_match)
+    # 线程 URL messages/t/<identity> 由客户主页 identity 精确构建,URL 匹配即可
+    # 确认线程归属;页面标题可能命中消息内容而非线程名,标题仅作参考记录。
+    matched = bool(url_match)
     return {
         "matched": matched,
         "url_match": url_match,
         "title_match": title_match,
         "observed_title": title,
         "current_url": current_url,
-        "match_basis": "thread_url+title" if matched else "",
+        "match_basis": "thread_url" if matched else "",
     }
 
 
@@ -353,6 +383,9 @@ def classify_direction(item: Dict[str, Any]) -> str:
     combined = " ".join([clean(item.get("aria")), clean(item.get("text"))]).casefold()
     if any(marker.casefold() in combined for marker in OUTGOING_MARKERS):
         return "我方发送"
+    # 主页简介/粉丝量信息不是对话消息,标记为系统消息(非客户回复)
+    if "people follow" in combined or "followers" in combined:
+        return "系统消息"
     viewport = float(item.get("viewport_width") or 0)
     left = float(item.get("left") or 0)
     width = float(item.get("width") or 0)
@@ -486,6 +519,17 @@ def scroll_history_to_start(page, max_messages: int, pacing: bool) -> Dict[str,
 def collect_customer(page, customer: Dict[str, str], mode: str, max_messages: int, pacing: bool) -> Dict[str, Any]:
     page.goto(customer["thread_url"], wait_until="domcontentloaded", timeout=90000)
     paced_wait("technical thread readiness", TECHNICAL_WAIT, pacing)
+    # 快速模式(--no-pacing)下跳过节流,但必须保证消息 DOM 已渲染:
+    try:
+        page.wait_for_selector(
+            '[role="main"] div[aria-label*="message" i], [role="main"] [data-testid*="message" i], [role="main"] [role="row"]',
+            timeout=8000,
+        )
+    except Exception:
+        pass
+    if not pacing:
+        import time as _time
+        _time.sleep(2.0)
     marker = risk_text(page)
     if marker:
         return {"customer": customer, "status": "risk_stop", "risk_flags": [f"facebook_risk:{marker}"], "messages": []}

+ 116 - 44
scripts/social/prepare_facebook_outreach.py

@@ -22,30 +22,34 @@ try:
 except Exception:  # pragma: no cover
     resolve_workbook_path = None
 
-DEFAULT_SHEET = "Facebook"
-DEFAULT_STATUS = "未联系"
+DEFAULT_SHEET = "\u5ba2\u6237\u4fe1\u606f\u6c47\u603b\u8868"
+DEFAULT_STATUS = "\u672a\u8054\u7cfb"
+TEMPLATE_SOURCE = "assets/social_private_message_template.md"
+TEMPLATE_VERSION = "social_private_message_template_v20260810"
 
 HEADER_ALIASES = {
-    "index": ["序号", "Index", "No."],
-    "name": ["客户姓名/公司", "公司名称", "客户名称", "Name", "Company"],
-    "country": ["国家", "Country"],
-    "city": ["城市", "City"],
-    "type": ["客户类型", "类型", "Type"],
-    "link": ["主页/链接", "Facebook链接", "主页", "链接", "Link", "URL"],
-    "website": ["公司官网", "官网", "Website", "Official Website"],
-    "contact": ["联系人", "姓名", "Contact"],
-    "position": ["职位", "职务", "Position", "Title"],
-    "phone": ["电话/WhatsApp", "电话", "WhatsApp", "Phone"],
-    "email": ["邮箱", "Email"],
-    "business": ["主营业务", "公司主营业务", "业务", "Business"],
-    "status": ["建联状态", "建联情况", "状态", "Status"],
-    "next_followup": ["下次跟进", "Next Follow-up"],
-    "note": ["备注", "说明", "Notes"],
+    "index": ["\u5e8f\u53f7", "Index", "No."],
+    "name": ["\u516c\u53f8\u59d3\u540d", "\u5ba2\u6237\u59d3\u540d/\u516c\u53f8", "\u516c\u53f8\u540d\u79f0", "\u5ba2\u6237\u540d\u79f0", "Name", "Company"],
+    "country": ["\u56fd\u5bb6", "Country"],
+    "city": ["\u57ce\u5e02", "City"],
+    "attribute": ["\u5ba2\u6237\u5c5e\u6027", "Customer Attribute"],
+    "type": ["\u5ba2\u6237\u7c7b\u578b", "\u7c7b\u578b", "Type"],
+    "link": ["Facebook\u4e3b\u9875\u94fe\u63a5", "Facebook\u94fe\u63a5", "facebook\u94fe\u63a5", "\u4e3b\u9875/\u94fe\u63a5", "\u4e3b\u9875", "\u94fe\u63a5", "Link", "URL"],
+    "website": ["\u5b98\u7f51\u94fe\u63a5", "\u516c\u53f8\u5b98\u7f51", "\u5b98\u7f51", "Website", "Official Website"],
+    "contact": ["\u8054\u7cfb\u4eba", "\u59d3\u540d", "Contact"],
+    "position": ["\u804c\u4f4d", "\u804c\u52a1", "Position", "Title"],
+    "phone": ["\u516c\u5171\u7535\u8bdd/WhatsApp", "\u8054\u7cfb\u4eba\u7535\u8bdd", "\u7535\u8bdd/WhatsApp", "\u7535\u8bdd", "WhatsApp", "Phone"],
+    "email": ["\u516c\u5171\u90ae\u7bb1", "\u4e2a\u4eba\u90ae\u7bb1", "\u90ae\u7bb1", "Email"],
+    "business": ["\u4e3b\u8425\u4e1a\u52a1", "\u516c\u53f8\u4e3b\u8425\u4e1a\u52a1", "\u4e1a\u52a1", "Business"],
+    "status": ["\u5efa\u8054\u72b6\u6001", "\u5efa\u8054\u60c5\u51b5", "\u72b6\u6001", "Status"],
+    "next_followup": ["\u4e0b\u6b21\u8ddf\u8fdb", "Next Follow-up"],
+    "note": ["\u5907\u6ce8", "\u8bf4\u660e", "Notes"],
 }
 
 CONTACTED_MARKERS = [
-    "已发送邮件", "已发邮件", "邮件已发送", "邮件发送成功", "已加好友", "已发送好友请求",
-    "已发私信", "已发送私信", "已联系", "email sent", "sent", "success",
+    "\u5df2\u53d1\u9001\u90ae\u4ef6", "\u5df2\u53d1\u90ae\u4ef6", "\u90ae\u4ef6\u5df2\u53d1\u9001", "\u90ae\u4ef6\u53d1\u9001\u6210\u529f",
+    "\u5df2\u52a0\u597d\u53cb", "\u5df2\u53d1\u9001\u597d\u53cb\u8bf7\u6c42", "\u5df2\u53d1\u79c1\u4fe1", "\u5df2\u53d1\u9001\u79c1\u4fe1", "\u5df2\u8054\u7cfb",
+    "email sent", "sent", "success",
 ]
 
 OEM_BRANDS = [
@@ -109,6 +113,14 @@ SCENARIOS = {
         "customer_base": "rental or fleet buyers",
         "fit_context": "fleet operators trying to lower renewal and operating cost",
     },
+    "platform_industry_channel": {
+        "label_cn": "平台与行业渠道",
+        "judgment_cn": "该客户属于平台与行业渠道,本身不一定直接进口或持有车辆库存,但可能拥有车商会员、企业客户、行业流量或项目组织能力。",
+        "angle_cn": "不要要求平台直接采购车辆,应从车商会员激活、B2B线索、订单集采和合格合作伙伴引荐切入。",
+        "signal_label": "automotive platform or dealer-network activity",
+        "customer_base": "dealer members and automotive businesses",
+        "fit_context": "platforms or industry channels that can activate dealer members or organize B2B vehicle opportunities",
+    },
     "unknown_auto_channel": {
         "label_cn": "信息不足的汽车相关渠道",
         "judgment_cn": "该客户看起来与汽车业务相关,但职责和渠道能力不明确。可以轻量建联,但重点是先确认其是否涉及采购、销售、进口或分销,不应直接强推。",
@@ -120,6 +132,7 @@ SCENARIOS = {
 }
 
 SIGNAL_PATTERNS = [
+    ("platform_industry", ["platform", "marketplace", "association", "chamber", "federation", "club", "media", "annuaire", "directory", "\u5e73\u53f0", "\u534f\u4f1a", "\u5546\u4f1a", "\u8f66\u5546\u8054\u76df", "\u5a92\u4f53", "\u8d44\u6e90\u5f15\u8350"], "\u5e73\u53f0/\u884c\u4e1a\u6e20\u9053", "automotive platform, association, or dealer-network activity"),
     ("used_car", ["used", "occasion", "second hand", "pre-owned", "reprise", "二手", "置换"], "二手车/occasion", "used-car or occasion activity"),
     ("showroom", ["showroom", "concessionnaire", "dealer", "multimarque", "multi-brand", "展厅", "经销", "多品牌"], "showroom/经销", "showroom or dealer activity"),
     ("stock", ["stock", "inventory", "parc auto", "annonce", "库存", "车源", "车辆较多"], "库存/车源", "visible stock or vehicle listings"),
@@ -181,17 +194,22 @@ def is_oem_branch(record: Dict[str, str]) -> bool:
 
 
 def classify_channel(record: Dict[str, str]) -> str:
-    combined = " ".join(record.get(key, "") for key in ["type", "business", "note", "name"]).casefold()
-    if any(k in combined for k in ["rental", "rentcar", "location", "flotte", "fleet", "租赁", "租车", "车队"]):
-        return "rental_fleet"
-    if any(k in combined for k in ["import", "importateur", "distributeur", "distribution", "group", "groupe", "集团", "进口", "分销"]):
+    attribute = record.get("attribute", "").casefold()
+    combined = " ".join(record.get(key, "") for key in ["attribute", "type", "business", "note", "name"]).casefold()
+    if "平台与行业渠道" in attribute:
+        return "platform_industry_channel"
+    if any(k in combined for k in ["汽车交易平台", "汽车协会", "商会", "车商联盟", "经销商资源引荐机构", "platform", "marketplace", "association", "chamber", "federation", "auto media", "automotive media", "annuaire"]):
+        return "platform_industry_channel"
+    if any(k in combined for k in ["import", "importateur", "distributeur", "distribution", "group", "groupe", "national", "network", "全国", "集团", "进口", "分销", "代理"]):
         return "importer_group"
+    if any(k in combined for k in ["multi-brand", "multibrand", "multimarque", "concessionnaire", "dealer", "showroom", "多品牌", "展厅", "经销"]):
+        return "multibrand_dealer"
     if any(k in combined for k in ["commercial", "utilitaire", "truck", "camion", "delivery", "fleet", "商用", "货车", "卡车", "配送"]):
         return "commercial_vehicle_channel"
+    if any(k in combined for k in ["rental", "rentcar", "location", "flotte", "fleet", "租赁", "租车", "车队"]):
+        return "rental_fleet"
     if any(k in combined for k in ["used", "occasion", "second hand", "pre-owned", "二手"]):
         return "used_car_dealer"
-    if any(k in combined for k in ["multi-brand", "multibrand", "multimarque", "concessionnaire", "dealer", "showroom", "多品牌", "展厅", "经销"]):
-        return "multibrand_dealer"
     if any(k in combined for k in ["local", "showroom", "本地", "区域"]):
         return "local_showroom"
     return "unknown_auto_channel"
@@ -289,6 +307,11 @@ def extract_customer_signals(record: Dict[str, str], scenario_key: str) -> Dict[
         profit_angle = "低压力确认是否负责车辆采购/分销,不直接强推。"
         light_offer = "a short model and price-range overview"
         reply_question = "Does your team handle vehicle purchasing or distribution?"
+    elif scenario_key == "platform_industry_channel":
+        business_hypothesis = "对方可能拥有车商会员、行业流量或B2B线索组织能力,适合评估会员激活、合作伙伴引荐或订单集采机会。"
+        profit_angle = "用 dealer members、B2B leads 和 consolidated purchasing 机会切入,不要要求平台直接采购。"
+        light_offer = "a one-page vehicle and member-opportunity overview"
+        reply_question = "Would a one-page vehicle and member-opportunity overview be relevant?"
     elif scenario_key == "rental_fleet":
         business_hypothesis = "租赁/车队业务对购置成本、维护成本和周转敏感,可能关注低成本车队更新方案。"
         profit_angle = "用低采购成本和小批量 fleet fit check 切入。"
@@ -333,48 +356,94 @@ def compact_english(text: str) -> str:
     return text.strip()
 
 
+def recipient_name(record: Dict[str, str]) -> str:
+    return clean(record.get("contact")) or clean(record.get("name")) or "there"
+
+
 def build_connect_variants(record: Dict[str, str], scenario: Dict[str, str], signals: Dict[str, Any]) -> Dict[str, str]:
-    contact = record.get("contact") or "there"
+    contact = recipient_name(record)
     name = record.get("name") or "your company"
-    location = signals["location_phrase"]
+    market = signals["market_name"]
     signal = signals["customer_signal"]
+    scenario_label = scenario.get("label_cn", "")
+    if "\u5e73\u53f0" in scenario_label:
+        return {
+            "direct_profit_hook": compact_english(f"Hi {contact}, your automotive platform and dealer network in {market} stood out. Wuling is exploring B2B vehicle projects that could create new opportunities for dealer members. Open to connect?"),
+            "stock_gap_hook": compact_english(f"Hi {contact}, {name} looks relevant to dealer resources in {market}. Wuling is mapping channels for practical B2B vehicle opportunities. Open to connect?"),
+            "soft_research_hook": compact_english(f"Hi {contact}, I am reviewing automotive platforms and industry channels in {market}. Your network seemed relevant for qualified dealer introductions. Thought it would be useful to connect."),
+        }
     if signals["signal_quality"] == "weak":
         return {
-            "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."),
-            "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?"),
-            "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."),
+            "direct_profit_hook": compact_english(f"Hi {contact}, I am mapping auto channels in {market} that may handle vehicle sourcing, import, or distribution. {name} seemed relevant. Thought it would be useful to connect."),
+            "stock_gap_hook": compact_english(f"Hi {contact}, your page looks connected to the auto sector in {market}. I am checking who reviews practical new-vehicle opportunities. Useful to connect?"),
+            "soft_research_hook": compact_english(f"Hi {contact}, I am learning which auto channels in {market} handle sourcing or distribution. {name} came up as relevant, so I thought I would connect."),
+        }
+    if "\u8fdb\u53e3" in scenario_label or "\u96c6\u56e2" in scenario_label:
+        return {
+            "direct_profit_hook": compact_english(f"Hi {contact}, your multi-brand distribution network in {market} stood out. Wuling is evaluating strong automotive groups for practical, competitively positioned vehicle opportunities. Open to connect?"),
+            "stock_gap_hook": compact_english(f"Hi {contact}, {name} appears relevant to import and distribution in {market}. Wuling is reviewing where practical vehicle lines could fit local channels. Open to connect?"),
+            "soft_research_hook": compact_english(f"Hi {contact}, I am reviewing strong auto groups in {market}. Your {signal} seemed relevant to Wuling's local channel evaluation. Thought it would be useful to connect."),
         }
     return {
-        "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."),
-        "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."),
-        "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."),
+        "direct_profit_hook": compact_english(f"Hi {contact}, your showroom and multi-brand vehicle activity in {market} stood out. Wuling is exploring practical, competitively positioned vehicle opportunities with capable local dealers. Open to connect?"),
+        "stock_gap_hook": compact_english(f"Hi {contact}, {name} looks close to {scenario['customer_base']} in {market}. Wuling could complement existing stock with practical, competitively positioned vehicles. Open to connect?"),
+        "soft_research_hook": compact_english(f"Hi {contact}, I am looking at auto channels in {market} serving practical vehicle buyers. {name} stood out from its {signal}. Thought it would be useful to connect."),
     }
 
 
 def build_dm_variants(record: Dict[str, str], scenario_key: str, scenario: Dict[str, str], signals: Dict[str, Any]) -> Dict[str, str]:
+    contact = recipient_name(record)
     market = signals["market_name"]
     signal = signals["customer_signal"]
-    question = signals["reply_question"]
-    light_offer = signals["light_offer"]
-    fit_context = scenario["fit_context"]
+    scenario_label = scenario.get("label_cn", "")
 
     if signals["signal_quality"] == "weak" or scenario_key == "unknown_auto_channel":
         return {
-            "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?"),
-            "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?"),
-            "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?"),
+            "direct_profit_hook": compact_english(f"Hi {contact}, I am Chris Chen from Wuling Overseas Business Department.\n\nWe found your company while reviewing automotive businesses in {market}, but the public information does not clearly identify who manages vehicle sourcing, import, distribution, or partnership decisions.\n\nWuling has sold over 30 million vehicles and operates in 60+ countries. We are evaluating suitable local channels for practical passenger and commercial vehicle opportunities.\n\nAre you the right person to review this type of opportunity?"),
+            "stock_gap_hook": compact_english(f"Hi {contact}, I am Chris Chen from Wuling Overseas Business Department.\n\nYour company appeared in our review of automotive businesses in {market}, though the available information is limited.\n\nWuling has sold over 30 million vehicles and operates in 60+ countries. We are checking whether practical new-vehicle opportunities fit local sourcing or distribution channels.\n\nWho would be the right person to review this type of opportunity?"),
+            "soft_research_hook": compact_english(f"Hi {contact}, I am Chris Chen from Wuling Overseas Business Department.\n\nWe are mapping automotive channels in {market}, and your company seemed potentially relevant from public information.\n\nWuling has sold over 30 million vehicles and operates in 60+ countries. At this stage, I only want to confirm whether your team handles vehicle sourcing or partnership evaluation.\n\nIs this handled by your team?"),
+        }
+
+    if "\u5e73\u53f0" in scenario_label:
+        return {
+            "direct_profit_hook": compact_english(f"Hi {contact}, I am Chris Chen from Wuling Overseas Business Department.\n\nYour access to automotive dealers and industry businesses caught my attention as we review strong channels in {market}.\n\nWuling has sold over 30 million vehicles and operates in 60+ countries. Our practical, competitively priced models could give your members a new supply opportunity while helping your platform generate qualified B2B interest.\n\nWould a one-page vehicle and member-opportunity overview be relevant?"),
+            "stock_gap_hook": compact_english(f"Hi {contact}, I am Chris Chen from Wuling Overseas Business Department.\n\nYour platform's dealer network appears well positioned to organize purchasing demand that may be too fragmented at the individual dealer level.\n\nWuling has sold over 30 million vehicles and operates in 60+ countries. We see potential to identify qualified importers, collect member interest, and develop consolidated B2B vehicle projects through your network.\n\nWould a one-page consolidated-purchasing outline be useful?"),
+            "soft_research_hook": compact_english(f"Hi {contact}, I am Chris Chen from Wuling Overseas Business Department.\n\nYour automotive network caught my attention as we identify qualified importers and dealers for Wuling's development in {market}.\n\nWuling has sold over 30 million vehicles and operates in 60+ countries. We believe your industry reach could support qualified partner introductions and practical B2B vehicle projects.\n\nIs this type of automotive partnership handled by your team?"),
+        }
+
+    if "\u8fdb\u53e3" in scenario_label or "\u96c6\u56e2" in scenario_label:
+        return {
+            "direct_profit_hook": compact_english(f"Hi {contact}, I am Chris Chen from Wuling Overseas Business Department.\n\nYour multi-brand distribution network and market coverage caught my attention as we look for strong automotive partners in {market}.\n\nWuling has sold over 30 million vehicles and operates in 60+ countries. Our competitively priced practical models could complement your current brands and offer clear dealer margin potential without requiring a large initial stock.\n\nMay I send you a one-page model, partner-pricing, and margin overview?"),
+            "stock_gap_hook": compact_english(f"Hi {contact}, I am Chris Chen from Wuling Overseas Business Department.\n\nYour established brand portfolio and distribution structure stood out as we reviewed leading automotive groups in {market}.\n\nWuling has sold over 30 million vehicles and operates in 60+ countries. Our practical vehicles could complement your existing brands in a more accessible price segment, helping you reach additional family, business, and fleet customers.\n\nWould you be open to reviewing a one-page portfolio-fit overview?"),
+            "soft_research_hook": compact_english(f"Hi {contact}, I am Chris Chen from Wuling Overseas Business Department.\n\nYour import, distribution, and after-sales network appears relevant as we assess strong automotive partners in {market}.\n\nWuling has sold over 30 million vehicles and operates in 60+ countries. We are evaluating where our competitively positioned passenger and commercial vehicles could fit within established local channels.\n\nIs new brand or product-line evaluation handled by your team?"),
+        }
+
+    if scenario_key == "used_car_dealer":
+        return {
+            "direct_profit_hook": compact_english(f"Hi {contact}, I am Chris Chen from Wuling Overseas Business Department.\n\nYour used-vehicle and trade-in activity caught my attention as we reviewed automotive businesses in {market}.\n\nWuling has sold over 30 million vehicles and operates in 60+ countries. Our accessible new vehicles could give your customers an upgrade option while allowing your company to retain its existing used-car and trade-in strengths.\n\nWould a one-page new-vehicle and trade-in opportunity overview be useful?"),
+            "stock_gap_hook": compact_english(f"Hi {contact}, I am Chris Chen from Wuling Overseas Business Department.\n\nYour used-car customer base may include buyers comparing total cost carefully.\n\nWuling has sold over 30 million vehicles and operates in 60+ countries. Our accessible practical vehicles could be reviewed as a new-vehicle option beside your existing used-car strengths.\n\nWould a one-page model and price-range overview be useful?"),
+            "soft_research_hook": compact_english(f"Hi {contact}, I am Chris Chen from Wuling Overseas Business Department.\n\nYour {signal} stood out while we reviewed automotive businesses in {market}.\n\nWuling has sold over 30 million vehicles and operates in 60+ countries. I am checking whether affordable new-vehicle options could fit selected used-car channels.\n\nIs this type of opportunity relevant to your team?"),
+        }
+
+    if scenario_key == "rental_fleet":
+        return {
+            "direct_profit_hook": compact_english(f"Hi {contact}, I am Chris Chen from Wuling Overseas Business Department.\n\nYour rental, leasing, logistics, or fleet operations stood out as we reviewed companies with recurring vehicle requirements in {market}.\n\nWuling has sold over 30 million vehicles and operates in 60+ countries. Our competitively priced passenger and light-commercial vehicles could support fleet renewal, staff mobility, delivery, or service operations across several use cases.\n\nWould a one-page fleet-model and application overview help your team assess fit?"),
+            "stock_gap_hook": compact_english(f"Hi {contact}, I am Chris Chen from Wuling Overseas Business Department.\n\nYour fleet-related activity suggests recurring vehicle needs and cost control may matter to your operation.\n\nWuling has sold over 30 million vehicles and operates in 60+ countries. Our practical vehicles could be reviewed for renewal, service, and daily operating scenarios.\n\nWould a short fleet-fit overview be useful?"),
+            "soft_research_hook": compact_english(f"Hi {contact}, I am Chris Chen from Wuling Overseas Business Department.\n\nWe are reviewing fleet and operating companies in {market} where practical vehicles may fit daily business use.\n\nWuling has sold over 30 million vehicles and operates in 60+ countries. I would like to understand whether vehicle renewal or procurement is relevant to your team.\n\nIs this handled by your team?"),
         }
 
     return {
-        "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}"),
-        "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?"),
-        "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?"),
+        "direct_profit_hook": compact_english(f"Hi {contact}, I am Chris Chen from Wuling Overseas Business Department.\n\nYour showroom and multi-brand sales activity caught my attention as we look for capable vehicle dealers in {market}.\n\nWuling has sold over 30 million vehicles and operates in 60+ countries. Our competitively priced practical models could complement your current stock, reach more family and business customers, and offer a clear dealer margin opportunity.\n\nMay I send your sales team a one-page model, partner-pricing, and margin overview?"),
+        "stock_gap_hook": compact_english(f"Hi {contact}, I am Chris Chen from Wuling Overseas Business Department.\n\nYour multi-brand business appears well positioned to serve customers between used vehicles and higher-priced new models.\n\nWuling has sold over 30 million vehicles and operates in 60+ countries. Our practical models could fill this price gap with accessible new vehicles for family, commuting, and business use, without replacing your existing brands.\n\nWould your sales team be open to a one-page portfolio-fit overview?"),
+        "soft_research_hook": compact_english(f"Hi {contact}, I am Chris Chen from Wuling Overseas Business Department.\n\nYour showroom and local sales activity stood out as we identify dealers capable of testing demand for practical new vehicles in {market}.\n\nWuling has sold over 30 million vehicles and operates in 60+ countries. Cooperation could begin with a controlled market test and small initial order, reducing inventory pressure before any larger rollout.\n\nWould your team be open to reviewing this market-validation approach?"),
     }
 
 
 def recommended_variant_key(scenario_key: str, signals: Dict[str, Any]) -> str:
     if signals["signal_quality"] == "weak" or scenario_key == "unknown_auto_channel":
         return "soft_research_hook"
+    if scenario_key in {"importer_group", "multibrand_dealer", "platform_industry_channel"}:
+        return "direct_profit_hook"
     if scenario_key in {"used_car_dealer", "rental_fleet", "commercial_vehicle_channel"}:
         return "direct_profit_hook"
     return "stock_gap_hook"
@@ -602,9 +671,12 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
 
     result = {
         "generated_at": datetime.now().isoformat(timespec="seconds"),
-        "language_policy": {"customer_facing": "English", "internal_review": "Chinese", "facebook_default_uses_french": False},
+        "template_source": TEMPLATE_SOURCE,
+        "template_version": TEMPLATE_VERSION,
+        "language_policy": {"customer_facing_default": "English", "internal_review": "Chinese", "french_allowed_when_customer_language_is_clearly_french": True},
         "message_strategy": {
-            "framework": "specific signal -> commercial hypothesis -> light offer -> one question",
+            "framework": "customer signal -> business role -> Wuling credibility -> one light question",
+            "priority_customers": ["\u6c7d\u8f66\u6e20\u9053\u5408\u4f5c\u4f19\u4f34", "\u5e73\u53f0\u4e0e\u884c\u4e1a\u6e20\u9053"],
             "variants": ["direct_profit_hook", "stock_gap_hook", "soft_research_hook"],
             "recommended_field": "recommended_message",
         },

+ 3 - 2
scripts/social/run_facebook_follow_dm.py

@@ -1,4 +1,4 @@
-#!/usr/bin/env python3
+#!/usr/bin/env python3
 # -*- coding: utf-8 -*-
 """Run the Facebook Page Follow + Messenger DM workflow.
 
@@ -188,7 +188,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
         description="Generate Facebook outreach preview, then run Follow + Messenger DM through AdsPower/Playwright."
     )
     parser.add_argument("--excel", default="", help="Customer outreach workbook path. If omitted, use the workbook resolver.")
-    parser.add_argument("--sheet", default="Facebook", help="Source sheet name. Default: Facebook.")
+    parser.add_argument("--sheet", default="\u5ba2\u6237\u4fe1\u606f\u6c47\u603b\u8868", help="Source sheet name. Default: customer summary sheet.")
     parser.add_argument("--filter", action="append", default=[], help="Filter condition field=value. Can repeat.")
     parser.add_argument("--profile-id", required=True, help="AdsPower profile ID already logged into Facebook.")
     parser.add_argument("--ads-power-url", default=send_mod.DEFAULT_ADS_POWER_URL, help="AdsPower local API URL.")
@@ -251,3 +251,4 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
 if __name__ == "__main__":
     raise SystemExit(main())
 
+

+ 38 - 19
scripts/social/send_facebook_outreach.py

@@ -93,18 +93,20 @@ RISK_STOP_PHRASES = [
     "verification required",
 ]
 STATUS_VALUES = {
-    "friend_success": "已加好友,待私信",
-    "follow_success": "已关注,待私信",
-    "dm_success": "已发私信",
-    "both_success": "已关注,已发私信",
-    "friend_failed": "加好友失败",
-    "follow_failed": "关注失败",
-    "dm_failed": "发送失败",
+    "friend_success": "\u5df2\u52a0\u597d\u53cb,\u5f85\u79c1\u4fe1",
+    "follow_success": "\u5df2\u5173\u6ce8,\u5f85\u79c1\u4fe1",
+    "dm_success": "\u5df2\u53d1\u79c1\u4fe1",
+    "both_success": "\u5df2\u5173\u6ce8,\u5df2\u53d1\u79c1\u4fe1",
+    "friend_failed": "\u52a0\u597d\u53cb\u5931\u8d25",
+    "follow_failed": "\u5173\u6ce8\u5931\u8d25",
+    "dm_failed": "\u53d1\u9001\u5931\u8d25",
 }
 HEADER_ALIASES = {
-    "index": ["序号", "Index", "No."],
-    "status": ["建联状态", "建联情况", "状态", "Status"],
-    "note": ["备注", "说明", "Notes"],
+    "index": ["\u5e8f\u53f7", "Index", "No."],
+    "name": ["\u516c\u53f8\u59d3\u540d", "\u5ba2\u6237\u59d3\u540d/\u516c\u53f8", "\u516c\u53f8\u540d\u79f0", "Name", "Company"],
+    "facebook_link": ["Facebook\u4e3b\u9875\u94fe\u63a5", "Facebook\u94fe\u63a5", "facebook\u94fe\u63a5", "\u4e3b\u9875/\u94fe\u63a5", "Link", "URL"],
+    "status": ["\u5efa\u8054\u72b6\u6001", "\u5efa\u8054\u60c5\u51b5", "\u72b6\u6001", "Status"],
+    "note": ["\u5907\u6ce8", "\u8bf4\u660e", "Notes"],
 }
 COMMENT_HINTS = ["comment", "留言", "reply", "回覆", "回复", "write a comment", "撰寫留言"]
 MESSAGE_BUTTON_RE = re.compile(r"(^|\s)(message|訊息)(\s|$)|send message|發送訊息|发送讯息|发送消息", re.I)
@@ -659,19 +661,35 @@ def header_map(ws) -> Dict[str, int]:
 def update_workbook(excel_path: str, sheet_name: str, updates: List[Dict[str, str]]) -> int:
     wb = load_workbook(excel_path)
     if sheet_name not in wb.sheetnames:
-        raise KeyError(f"Sheet not found: {sheet_name}")
+        summary = "\u5ba2\u6237\u4fe1\u606f\u6c47\u603b\u8868"
+        if summary in wb.sheetnames:
+            sheet_name = summary
+        else:
+            raise KeyError(f"Sheet not found: {sheet_name}")
     ws = wb[sheet_name]
     columns = header_map(ws)
-    if "index" not in columns or "status" not in columns:
-        raise RuntimeError("Workbook is missing 序号 or 建联状态 columns.")
+    if "status" not in columns:
+        raise RuntimeError("Workbook is missing outreach status column.")
 
     update_by_index = {str(item.get("index", "")).strip(): item for item in updates if str(item.get("index", "")).strip()}
+    update_by_url = {norm(item.get("page_url", "")): item for item in updates if norm(item.get("page_url", ""))}
+    update_by_name = {norm(item.get("dealer_name", "")): item for item in updates if norm(item.get("dealer_name", ""))}
     updated = 0
-    for row in ws.iter_rows(min_row=2, values_only=False):
-        row_index = clean(row[columns["index"]].value)
-        if row_index not in update_by_index:
+    for row_number, row in enumerate(ws.iter_rows(min_row=2, values_only=False), start=2):
+        update = None
+        if str(row_number) in update_by_index:
+            update = update_by_index[str(row_number)]
+        if update is None and "index" in columns:
+            row_index = clean(row[columns["index"]].value)
+            update = update_by_index.get(row_index)
+        if update is None and "facebook_link" in columns:
+            row_url = norm(row[columns["facebook_link"]].value)
+            update = update_by_url.get(row_url)
+        if update is None and "name" in columns:
+            row_name = norm(row[columns["name"]].value)
+            update = update_by_name.get(row_name)
+        if update is None:
             continue
-        update = update_by_index[row_index]
         if update.get("status"):
             row[columns["status"]].value = update["status"]
         if update.get("note_append") and "note" in columns:
@@ -682,7 +700,6 @@ def update_workbook(excel_path: str, sheet_name: str, updates: List[Dict[str, st
     wb.save(excel_path)
     return updated
 
-
 def write_audit_log(log_path: Path, entries: List[Dict[str, Any]]) -> None:
     log_path.parent.mkdir(parents=True, exist_ok=True)
     with log_path.open("a", encoding="utf-8") as handle:
@@ -911,7 +928,9 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
             if confirm_mode:
                 new_status, error_note = determine_status(args.action, friend_result, follow_result, dm_result)
                 status_updates.append({
-                    "index": str(item.get("index", "")),
+                    "index": str(item.get("row_number") or item.get("index", "")),
+                    "dealer_name": dealer_name,
+                    "page_url": target_url,
                     "status": new_status,
                     "note_append": f"{now_iso()} Facebook {args.action}->{new_status}" + (f" err={error_note}" if error_note else ""),
                 })

+ 114 - 100
scripts/social/write_facebook_conversations.py

@@ -25,7 +25,7 @@ if str(COMMON_DIR) not in sys.path:
 from artifact_manager import create_backup_once, new_run_id, project_root, resolve_artifact_path  # type: ignore  # noqa: E402
 from workbook_resolver import resolve_workbook_path  # type: ignore  # noqa: E402
 
-FACEBOOK_SHEET = "Facebook"
+FACEBOOK_SHEET = "\u5ba2\u6237\u4fe1\u606f\u6c47\u603b\u8868"
 CONVERSATION_SHEET = "Facebook对话记录"
 CONVERSATION_HEADERS = [
     "记录ID", "客户序号", "客户姓名/公司", "Facebook主页链接", "Messenger线程ID",
@@ -33,24 +33,13 @@ CONVERSATION_HEADERS = [
     "是否有效客户回复", "合作意向", "意向判断依据", "下一步建议", "同步时间",
     "来源账号/Profile ID", "风险标记",
 ]
-INTENTS = ["明确有意向", "潜在意向", "需澄清", "暂不考虑", "明确拒绝"]
-STATUS_BY_INTENT = {
-    "明确有意向": "已回复,有合作意向",
-    "潜在意向": "已回复,待跟进",
-    "需澄清": "已回复,待澄清",
-    "暂不考虑": "已回复,暂不考虑",
-    "明确拒绝": "已回复,明确拒绝",
-}
-FOLLOWUP_DAYS = {
-    "明确有意向": ("business", 1),
-    "潜在意向": ("business", 3),
-    "需澄清": ("business", 2),
-    "暂不考虑": ("calendar", 30),
-    "明确拒绝": ("none", 0),
-}
-REPLY_STATUS_PHRASES = list(STATUS_BY_INTENT.values())
-NOTE_START = "【Facebook回复分析】"
-NOTE_END = "【/Facebook回复分析】"
+FACEBOOK_REPLY_STATUS = "Facebook\u5df2\u56de\u590d"
+OLD_REPLY_STATUS_PHRASES = [
+    "\u5df2\u56de\u590d,\u6709\u5408\u4f5c\u610f\u5411", "\u5df2\u56de\u590d,\u5f85\u8ddf\u8fdb", "\u5df2\u56de\u590d,\u5f85\u6f84\u6e05", "\u5df2\u56de\u590d,\u6682\u4e0d\u8003\u8651", "\u5df2\u56de\u590d,\u660e\u786e\u62d2\u7edd",
+    "\u5df2\u56de\u590d\uff0c\u6709\u5408\u4f5c\u610f\u5411", "\u5df2\u56de\u590d\uff0c\u5f85\u8ddf\u8fdb", "\u5df2\u56de\u590d\uff0c\u5f85\u6f84\u6e05", "\u5df2\u56de\u590d\uff0c\u6682\u4e0d\u8003\u8651", "\u5df2\u56de\u590d\uff0c\u660e\u786e\u62d2\u7edd",
+]
+NOTE_START = "\u3010Facebook\u56de\u590d\u8bb0\u5f55\u3011"
+NOTE_END = "\u3010/Facebook\u56de\u590d\u8bb0\u5f55\u3011"
 QUESTION_MARK_RE = re.compile(r"\?{3,}")
 CJK_RE = re.compile(r"[\u3400-\u9fff]")
 FACEBOOK_ALIASES = {
@@ -132,11 +121,7 @@ def chinese_or_same(original: str, translated: str) -> str:
 
 
 def validate_intent(value: str) -> str:
-    value = clean(value)
-    if value not in INTENTS:
-        raise ValueError(f"Invalid cooperation intent: {value!r}. Expected one of {INTENTS}")
-    return value
-
+    return clean(value)
 
 def parse_date(value: str) -> Optional[date]:
     match = re.search(r"(20\d{2})[-/](\d{1,2})[-/](\d{1,2})", clean(value))
@@ -207,6 +192,33 @@ def message_analysis_map(thread: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
     return {clean(item.get("record_id")): item for item in thread.get("messages", []) if clean(item.get("record_id"))}
 
 
+def is_real_customer_reply(direction: str, kind: str, original: str, translation: str) -> bool:
+    if clean(direction) != "\u5ba2\u6237\u56de\u590d":
+        return False
+    blocked_types = {
+        "\u7cfb\u7edf\u6d88\u606f", "\u81ea\u52a8\u56de\u590d", "\u5df2\u8bfb\u63d0\u793a",
+        "\u70b9\u8d5e", "\u8868\u60c5", "reaction", "read_receipt",
+    }
+    if clean(kind).casefold() in {item.casefold() for item in blocked_types}:
+        return False
+    # 翻译以“(自动回复)”开头说明分析判定为自动回复,不视为人工有效回复
+    if clean(translation).startswith("\uff08\u81ea\u52a8\u56de\u590d\uff09"):
+        return False
+    # 纯时间戳/加载提示不是有效回复
+    _orig = clean(original).strip().lower()
+    _trans = clean(translation).strip().lower()
+    if _orig in ("loading...",) or "loading..." in _trans:
+        return False
+    import re as _re
+    if _re.match(r"^((mon|tue|wed|thu|fri|sat|sun)\s+)?\d{1,2}:\d{2}(\s*(am|pm))?$", _orig):
+        return False
+    # 主页简介(Dealership/Dealer/Showroom 结尾的页面介绍)不是有效回复
+    if _re.search(r"(automotive dealership|car dealership|car dealer|car rental|showroom|cars)$", _orig):
+        return False
+    emoji_only = clean(original or translation) in {"??", "??", "??", "??", "?", "??", "?"}
+    return not emoji_only
+
+
 def merge_threads(raw: Dict[str, Any], analysis: Dict[str, Any]) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[str]]:
     analysis_threads = analysis.get("threads") or []
     exact = {analysis_key(thread): thread for thread in analysis_threads}
@@ -221,79 +233,82 @@ def merge_threads(raw: Dict[str, Any], analysis: Dict[str, Any]) -> Tuple[List[D
         messages = raw_thread.get("messages") or []
         if not messages:
             continue
-        athread = exact.get(raw_thread_key(raw_thread)) or fallback_analysis_match(raw_thread, analysis_threads)
-        if not athread:
-            raise ValueError(f"Missing analysis thread for {raw_thread_key(raw_thread)}")
-        amap = message_analysis_map(athread)
+        athread = exact.get(raw_thread_key(raw_thread)) or fallback_analysis_match(raw_thread, analysis_threads) or {}
+        amap = message_analysis_map(athread) if athread else {}
         customer = raw_thread.get("customer") or {}
-        effective_reply_ids: List[str] = []
+        real_reply_rows: List[Dict[str, Any]] = []
         for raw_message in messages:
             record_id = clean(raw_message.get("record_id"))
             if not record_id:
                 raise ValueError("Raw message is missing record_id")
             analyzed = amap.get(record_id) or {}
             direction = clean(raw_message.get("direction"))
-            kind = clean(raw_message.get("message_type")) or "文本"
+            kind = clean(raw_message.get("message_type")) or "\u6587\u672c"
             original = clean(raw_message.get("original_text"))
-            translation = chinese_or_same(original, clean(analyzed.get("chinese_translation")))
+            translated_text = clean(analyzed.get("chinese_translation"))
+            if direction == "\u5ba2\u6237\u56de\u590d":
+                translation = chinese_or_same(original, translated_text)
+            else:
+                translation = translated_text or original
             original_language = clean(analyzed.get("original_language")) or ("zh" if CJK_RE.search(original) else "")
-            non_reply = direction != "客户回复" or kind in {"系统消息", "自动回复", "已读提示"}
-            effective = bool(analyzed.get("is_effective_customer_reply")) and not non_reply
-            intent = reason = action = ""
-            if effective:
-                intent = validate_intent(clean(analyzed.get("intent")))
-                reason, action = clean(analyzed.get("intent_reason")), clean(analyzed.get("next_action"))
-                if not reason or not action:
-                    raise ValueError(f"Effective reply {record_id} requires intent_reason and next_action")
-                effective_reply_ids.append(record_id)
+            is_reply = is_real_customer_reply(direction, kind, original, translation)
+            next_action = clean(analyzed.get("next_action")) if is_reply else ""
+            if is_reply and not next_action:
+                next_action = "\u8bf7\u4eba\u5de5\u67e5\u770b\u5ba2\u6237\u56de\u590d\uff0c\u5e76\u5224\u65ad\u662f\u5426\u9700\u8981\u7ee7\u7eed\u8ddf\u8fdb\u3002"
             risks = list_values(raw_message.get("risk_flags")) + list_values(analyzed.get("risk_flags"))
-            output_rows.append({
-                "记录ID": record_id,
-                "客户序号": clean(customer.get("index")),
-                "客户姓名/公司": clean(customer.get("company")),
-                "Facebook主页链接": clean(customer.get("facebook_link")),
-                "Messenger线程ID": clean(raw_thread.get("thread_id")),
-                "消息时间": clean(analyzed.get("message_time")) or clean(raw_message.get("message_time_raw")),
-                "消息方向": direction,
-                "发件人": clean(raw_message.get("sender")),
-                "原文语言": original_language,
-                "对话原文": original,
-                "中文翻译": translation,
-                "消息类型": kind,
-                "是否有效客户回复": "是" if effective else "否",
-                "合作意向": intent,
-                "意向判断依据": reason,
-                "下一步建议": action,
-                "同步时间": sync_time,
-                "来源账号/Profile ID": clean(raw.get("profile_id")),
-                "风险标记": ";".join(dict.fromkeys(risks)),
-            })
-        latest = athread.get("latest_analysis") or {}
-        if effective_reply_ids:
+            row = {
+                "\u8bb0\u5f55ID": record_id,
+                "\u5ba2\u6237\u5e8f\u53f7": clean(customer.get("index")),
+                "\u5ba2\u6237\u59d3\u540d/\u516c\u53f8": clean(customer.get("company")),
+                "Facebook\u4e3b\u9875\u94fe\u63a5": clean(customer.get("facebook_link")),
+                "Messenger\u7ebf\u7a0bID": clean(raw_thread.get("thread_id")),
+                "\u6d88\u606f\u65f6\u95f4": clean(analyzed.get("message_time")) or clean(raw_message.get("message_time_raw")),
+                "\u6d88\u606f\u65b9\u5411": direction,
+                "\u53d1\u4ef6\u4eba": clean(raw_message.get("sender")),
+                "\u539f\u6587\u8bed\u8a00": original_language,
+                "\u5bf9\u8bdd\u539f\u6587": original,
+                "\u4e2d\u6587\u7ffb\u8bd1": translation,
+                "\u6d88\u606f\u7c7b\u578b": kind,
+                "\u662f\u5426\u6709\u6548\u5ba2\u6237\u56de\u590d": "\u662f" if is_reply else "\u5426",
+                "\u5408\u4f5c\u610f\u5411": "",
+                "\u610f\u5411\u5224\u65ad\u4f9d\u636e": "",
+                "\u4e0b\u4e00\u6b65\u5efa\u8bae": next_action,
+                "\u540c\u6b65\u65f6\u95f4": sync_time,
+                "\u6765\u6e90\u8d26\u53f7/Profile ID": clean(raw.get("profile_id")),
+                "\u98ce\u9669\u6807\u8bb0": "\uff1b".join(dict.fromkeys(risks)),
+            }
+            output_rows.append(row)
+            if is_reply:
+                real_reply_rows.append(row)
+        if real_reply_rows:
+            latest = athread.get("latest_analysis") or {}
+            latest_row = real_reply_rows[-1]
             latest_record_id = clean(latest.get("latest_reply_record_id"))
-            if latest_record_id not in effective_reply_ids:
-                raise ValueError(f"latest_reply_record_id must reference an effective reply for {clean(customer.get('company'))}")
-            latest_intent = validate_intent(clean(latest.get("intent")))
-            summary = clean(latest.get("chinese_summary"))
-            reason, next_action = clean(latest.get("intent_reason")), clean(latest.get("next_action"))
-            latest_reply_at = clean(latest.get("latest_reply_at"))
-            if not all([summary, reason, next_action, latest_reply_at]):
-                raise ValueError(f"latest_analysis is incomplete for {clean(customer.get('company'))}")
+            if latest_record_id:
+                latest_row = next((row for row in real_reply_rows if row.get("\u8bb0\u5f55ID") == latest_record_id), latest_row)
+            summary = (
+                clean(latest.get("chinese_summary"))
+                or clean(latest_row.get("\u4e2d\u6587\u7ffb\u8bd1"))
+                or clean(latest_row.get("\u5bf9\u8bdd\u539f\u6587"))
+                or "\u5ba2\u6237\u5df2\u5728 Facebook Messenger \u56de\u590d\uff0c\u9700\u4eba\u5de5\u67e5\u770b\u5177\u4f53\u5185\u5bb9\u3002"
+            )
+            next_action = (
+                clean(latest.get("next_action"))
+                or clean(latest_row.get("\u4e0b\u4e00\u6b65\u5efa\u8bae"))
+                or "\u8bf7\u4eba\u5de5\u67e5\u770b\u5ba2\u6237\u56de\u590d\uff0c\u5e76\u5224\u65ad\u662f\u5426\u9700\u8981\u7ee7\u7eed\u8ddf\u8fdb\u3002"
+            )
             customer_updates.append({
                 "index": clean(customer.get("index")),
                 "company": clean(customer.get("company")),
                 "facebook_link": clean(customer.get("facebook_link")),
-                "latest_reply_at": latest_reply_at,
+                "latest_reply_at": clean(latest.get("latest_reply_at")) or clean(latest_row.get("\u6d88\u606f\u65f6\u95f4")),
                 "summary": summary,
-                "intent": latest_intent,
-                "intent_reason": reason,
                 "next_action": next_action,
-                "next_followup": default_followup(latest_intent, latest_reply_at, clean(latest.get("next_followup"))),
-                "latest_reply_record_id": latest_record_id,
+                "next_followup": clean(latest.get("next_followup")),
+                "latest_reply_record_id": clean(latest_row.get("\u8bb0\u5f55ID")),
             })
     return output_rows, customer_updates, warnings
 
-
 def append_or_update_conversations(ws, rows: Iterable[Dict[str, Any]]) -> Tuple[int, int]:
     headers = {clean(cell.value): idx for idx, cell in enumerate(ws[1], start=1)}
     existing = {
@@ -325,27 +340,33 @@ def append_or_update_conversations(ws, rows: Iterable[Dict[str, Any]]) -> Tuple[
     return added, updated
 
 
-def merge_status(existing: str, intent: str) -> str:
+def merge_status(existing: str) -> str:
     result = clean(existing)
-    for phrase in REPLY_STATUS_PHRASES:
+    for phrase in OLD_REPLY_STATUS_PHRASES:
         result = result.replace(phrase, "")
-    result = re.sub(r"[,,;;|]+", ",", result).strip(", ")
-    latest = STATUS_BY_INTENT[intent]
-    return ",".join(dict.fromkeys([clean(part) for part in [*result.split(","), *latest.split(",")] if clean(part)]))
+    result = re.sub(r"[,\uFF0C;\uFF1B|]+", ",", result).strip(", ")
+    parts = [clean(part) for part in result.split(",") if clean(part)]
+    parts.append(FACEBOOK_REPLY_STATUS)
+    return ",".join(dict.fromkeys(parts))
 
 
 def reply_note(update: Dict[str, Any]) -> str:
     return (
-        f"{NOTE_START}最新回复时间:{update['latest_reply_at']};中文摘要:{update['summary']};"
-        f"合作意向:{update['intent']};判断依据:{update['intent_reason']};"
-        f"下一步建议:{update['next_action']}{NOTE_END}"
+        f"{NOTE_START}\u6700\u65b0\u56de\u590d\u65f6\u95f4\uff1a{update['latest_reply_at']}\uff1b\u4e2d\u6587\u6458\u8981\uff1a{update['summary']}\uff1b"
+        f"\u4e0b\u4e00\u6b65\u5efa\u8bae\uff1a{update['next_action']}{NOTE_END}"
     )
 
 
 def replace_reply_note(existing: str, block: str) -> str:
     existing = clean(existing)
-    pattern = re.compile(re.escape(NOTE_START) + r".*?" + re.escape(NOTE_END))
-    return clean(pattern.sub(block, existing)) if pattern.search(existing) else clean(existing + (" | " if existing else "") + block)
+    patterns = [
+        re.compile(re.escape(NOTE_START) + r".*?" + re.escape(NOTE_END)),
+        re.compile(re.escape("\u3010Facebook\u56de\u590d\u5206\u6790\u3011") + r".*?" + re.escape("\u3010/Facebook\u56de\u590d\u5206\u6790\u3011")),
+    ]
+    for pattern in patterns:
+        if pattern.search(existing):
+            return clean(pattern.sub(block, existing))
+    return clean(existing + (" | " if existing else "") + block)
 
 
 def update_facebook_rows(wb, sheet_name: str, updates: Sequence[Dict[str, Any]]) -> Tuple[int, List[str]]:
@@ -366,8 +387,9 @@ def update_facebook_rows(wb, sheet_name: str, updates: Sequence[Dict[str, Any]])
         if normalized_link(clean(ws.cell(row, columns["link"]).value)) != normalized_link(clean(update.get("facebook_link"))):
             missing_customers.append(f"{update.get('company')}: Facebook link mismatch")
             continue
-        ws.cell(row, columns["status"]).value = merge_status(clean(ws.cell(row, columns["status"]).value), update["intent"])
-        ws.cell(row, columns["followup"]).value = update["next_followup"]
+        ws.cell(row, columns["status"]).value = merge_status(clean(ws.cell(row, columns["status"]).value))
+        if clean(update.get("next_followup")):
+            ws.cell(row, columns["followup"]).value = update["next_followup"]
         existing_note = clean(ws.cell(row, columns["note"]).value)
         ws.cell(row, columns["note"]).value = replace_reply_note(existing_note, reply_note(update))
         matched.add(row_index)
@@ -377,7 +399,6 @@ def update_facebook_rows(wb, sheet_name: str, updates: Sequence[Dict[str, Any]])
             missing_customers.append(f"{update.get('company')}: customer row not matched")
     return written, list(dict.fromkeys(missing_customers))
 
-
 def scan_question_marks(wb) -> Tuple[int, int]:
     cells = note_rows = 0
     for ws in wb.worksheets:
@@ -409,7 +430,7 @@ def run_child(command: List[str]) -> Dict[str, Any]:
 
 
 def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:
-    parser = argparse.ArgumentParser(description="Write translated Facebook conversations and intent analysis to Excel.")
+    parser = argparse.ArgumentParser(description="Write translated Facebook conversations and unified reply records to Excel.")
     parser.add_argument("--excel", default="")
     parser.add_argument("--transcript", default="")
     parser.add_argument("--analysis", default="")
@@ -450,20 +471,13 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
         default_name="facebook_conversation_write_report.json", run_id=run_id,
     )
     report: Dict[str, Any] = {
-        "schema_version": "4.26",
+        "schema_version": "4.29",
         "run_id": run_id,
         "workbook": str(workbook_path),
         "dry_run": not args.write_workbook,
         "conversation_rows_ready": len(rows),
         "customer_updates_ready": len(updates),
-        "intent_counts": {intent: sum(1 for item in updates if item.get("intent") == intent) for intent in INTENTS},
-        "high_intent_customers": [
-            {
-                "customer": item["company"], "intent": item["intent"], "summary": item["summary"],
-                "next_action": item["next_action"], "next_followup": item["next_followup"],
-            }
-            for item in updates if item.get("intent") in {"明确有意向", "潜在意向"}
-        ],
+        "facebook_reply_customers_ready": len(updates),
         "warnings": warnings,
     }
     if not args.write_workbook:

+ 1 - 0
scripts/workflows/__init__.py

@@ -0,0 +1 @@
+

+ 319 - 0
scripts/workflows/run_dealer_pipeline.py

@@ -0,0 +1,319 @@
+from __future__ import annotations
+
+import argparse
+import json
+import subprocess
+import sys
+from datetime import datetime
+from pathlib import Path
+from typing import Any, Dict, List, Optional, Sequence
+
+
+SCRIPT_DIR = Path(__file__).resolve().parent
+SKILL_ROOT = SCRIPT_DIR.parents[1]
+COMMON_DIR = SKILL_ROOT / "scripts" / "common"
+if str(COMMON_DIR) not in sys.path:
+    sys.path.insert(0, str(COMMON_DIR))
+
+from artifact_manager import new_run_id, resolve_artifact_path, run_dir, write_json  # type: ignore  # noqa: E402
+from workbook_resolver import resolve_workbook_path  # type: ignore  # noqa: E402
+
+
+SUMMARY_SHEET = "\u5ba2\u6237\u4fe1\u606f\u6c47\u603b\u8868"
+DEFAULT_CONFIG_NAME = "workflow_config.json"
+FEISHU_CONFIG_NAME = "feishu_sync_config.json"
+
+
+DEFAULT_CONFIG: Dict[str, Any] = {
+    "enabled": True,
+    "country": "Morocco",
+    "profile_id": "",
+    "excel": "",
+    "summary_sheet_name": SUMMARY_SHEET,
+    "daily_outreach_target": 20,
+    "default_stages": ["summary", "dashboard", "feishu_check"],
+    "refresh_summary": True,
+    "refresh_dashboard": True,
+    "dashboard_latest_dir": "dashboards/latest",
+    "sync_feishu": True,
+    "prepare_email_preview": False,
+    "prepare_facebook_outreach": False,
+    "collect_facebook_replies": False,
+    "send_email": False,
+    "send_facebook_dm": False,
+    "human_confirmation_required": [
+        "send_email",
+        "send_facebook_dm",
+        "linkedin_outreach",
+        "write_facebook_conversations",
+    ],
+}
+
+
+def read_json(path: Path) -> Dict[str, Any]:
+    with path.open("r", encoding="utf-8") as fh:
+        data = json.load(fh)
+    if not isinstance(data, dict):
+        raise ValueError(f"Config must be a JSON object: {path}")
+    return data
+
+
+def load_config(raw_path: str) -> Dict[str, Any]:
+    config = dict(DEFAULT_CONFIG)
+    root_config = Path.cwd() / DEFAULT_CONFIG_NAME
+    if raw_path:
+        path = Path(raw_path).expanduser()
+        if not path.is_absolute():
+            path = (Path.cwd() / path).resolve()
+    elif root_config.exists():
+        path = root_config.resolve()
+    else:
+        example = SKILL_ROOT / "assets" / "workflow_config.example.json"
+        path = example.resolve() if example.exists() else root_config.resolve()
+
+    loaded = read_json(path) if path.exists() else {}
+    config.update(loaded)
+    config["_config_path"] = str(path) if path.exists() else ""
+    config["_config_found"] = path.exists()
+    return config
+
+
+def parse_stages(raw: str, config: Dict[str, Any]) -> List[str]:
+    if raw:
+        return [item.strip() for item in raw.split(",") if item.strip()]
+    stages = config.get("default_stages") or DEFAULT_CONFIG["default_stages"]
+    return [str(item).strip() for item in stages if str(item).strip()]
+
+
+def run_command(args: List[str], step_name: str, output_dir: Path) -> Dict[str, Any]:
+    started = datetime.now().isoformat(timespec="seconds")
+    proc = subprocess.run(
+        args,
+        cwd=str(SKILL_ROOT),
+        text=True,
+        encoding="utf-8",
+        errors="replace",
+        capture_output=True,
+    )
+    log_path = output_dir / f"{step_name}.log"
+    log_path.write_text(
+        "COMMAND: " + " ".join(args) + "\n\nSTDOUT:\n" + proc.stdout + "\n\nSTDERR:\n" + proc.stderr,
+        encoding="utf-8",
+    )
+    parsed: Optional[Any] = None
+    stdout = proc.stdout.strip()
+    if stdout:
+        try:
+            parsed = json.loads(stdout)
+        except Exception:
+            parsed = None
+    return {
+        "step": step_name,
+        "command": args,
+        "returncode": proc.returncode,
+        "started_at": started,
+        "finished_at": datetime.now().isoformat(timespec="seconds"),
+        "log_path": str(log_path),
+        "json": parsed,
+        "ok": proc.returncode == 0,
+    }
+
+
+def feishu_config_status(config: Dict[str, Any]) -> Dict[str, Any]:
+    project_cfg = Path.cwd() / FEISHU_CONFIG_NAME
+    if not project_cfg.exists():
+        return {
+            "configured": False,
+            "enabled": False,
+            "required": bool(config.get("sync_feishu", True)),
+            "message": "Local workbook updated; Feishu sync is waiting for feishu_sync_config.json.",
+        }
+    try:
+        data = read_json(project_cfg)
+    except Exception as exc:
+        return {
+            "configured": True,
+            "enabled": False,
+            "required": True,
+            "error": str(exc),
+            "message": "Feishu config exists but cannot be parsed.",
+        }
+    enabled = bool(data.get("enabled"))
+    return {
+        "configured": True,
+        "enabled": enabled,
+        "required": enabled,
+        "config_path": str(project_cfg.resolve()),
+        "summary_sheet_name": data.get("summary_sheet_name", SUMMARY_SHEET),
+        "spreadsheet_url_present": bool(data.get("spreadsheet_url") or data.get("spreadsheet_token")),
+        "message": "Agent must call the WorkBuddy lark-sheets plugin after local writeback." if enabled else "Feishu sync disabled by config.",
+    }
+
+
+def build_plan(config: Dict[str, Any], stages: List[str], workbook_info: Dict[str, Any]) -> Dict[str, Any]:
+    blocked_external: List[str] = []
+    for key in ("send_email", "send_facebook_dm"):
+        if config.get(key):
+            blocked_external.append(key)
+    return {
+        "country": config.get("country"),
+        "profile_id": config.get("profile_id"),
+        "daily_outreach_target": config.get("daily_outreach_target"),
+        "stages": stages,
+        "workbook": {key: str(value) if isinstance(value, Path) else value for key, value in workbook_info.items()},
+        "blocked_external_actions": blocked_external,
+        "human_confirmation_required": config.get("human_confirmation_required", []),
+        "notes": [
+            "This workflow orchestrates safe local post-processing by default.",
+            "Email sending and Facebook/LinkedIn outbound actions still require preview and explicit user confirmation.",
+            "Any browser stage must use AdsPower + Playwright and must not close the browser.",
+        ],
+    }
+
+
+def main(argv: Optional[Sequence[str]] = None) -> int:
+    parser = argparse.ArgumentParser(description="Run the Wuling dealer-expansion workflow orchestrator.")
+    parser.add_argument("--config", default="", help="Project workflow config JSON. Default: ./workflow_config.json or skill example.")
+    parser.add_argument("--excel", default="", help="Workbook path. Overrides config.excel.")
+    parser.add_argument("--country", default="", help="Target country. Overrides config.country.")
+    parser.add_argument("--profile-id", default="", help="AdsPower profile ID for browser stages. Overrides config.profile_id.")
+    parser.add_argument("--stages", default="", help="Comma-separated stages. Default from config: summary,dashboard,feishu_check.")
+    parser.add_argument("--run-id", default="", help="Run ID for artifacts.")
+    parser.add_argument("--review-only", action="store_true", help="Only emit plan; do not write workbook or generate dashboard.")
+    parser.add_argument("--write-workbook", action="store_true", help="Allow local workbook write stages such as summary rebuild.")
+    parser.add_argument("--output", default="", help="Optional JSON report path.")
+    args = parser.parse_args(argv)
+
+    config = load_config(args.config)
+    if args.excel:
+        config["excel"] = args.excel
+    if args.country:
+        config["country"] = args.country
+    if args.profile_id:
+        config["profile_id"] = args.profile_id
+
+    run_id = args.run_id or new_run_id("dealer_pipeline")
+    out_dir = run_dir(run_id)
+    out_dir.mkdir(parents=True, exist_ok=True)
+    stages = parse_stages(args.stages, config)
+
+    workbook_info = resolve_workbook_path(
+        str(config.get("excel") or ""),
+        create_from_template=bool(args.write_workbook),
+        template_root=SKILL_ROOT,
+    )
+    plan = build_plan(config, stages, workbook_info)
+    write_json(out_dir / "workflow-config-snapshot.json", config)
+    write_json(out_dir / "workflow-plan.json", plan)
+
+    report: Dict[str, Any] = {
+        "run_id": run_id,
+        "run_dir": str(out_dir),
+        "config_found": config.get("_config_found", False),
+        "config_path": config.get("_config_path", ""),
+        "plan": plan,
+        "steps": [],
+        "feishu": {},
+        "review_only": bool(args.review_only),
+    }
+
+    if workbook_info.get("source") == "missing":
+        report["ok"] = False
+        report["error"] = "No local workbook found. Use --excel, add a project workbook, or rerun with --write-workbook to copy the skill blank template."
+    elif args.review_only:
+        report["ok"] = True
+    else:
+        workbook_path = str(workbook_info["path"])
+        if "summary" in stages:
+            summary_report_path = resolve_artifact_path(
+                "",
+                kind="workflow_summary",
+                default_name="summary-report.json",
+                run_id=run_id,
+            )
+            cmd = [
+                sys.executable,
+                str(SKILL_ROOT / "scripts" / "common" / "build_customer_summary.py"),
+                "--excel",
+                workbook_path,
+                "--output",
+                str(summary_report_path),
+                "--run-id",
+                run_id,
+            ]
+            if args.write_workbook or bool(config.get("refresh_summary", True)):
+                cmd.append("--write-summary")
+            else:
+                cmd.append("--dry-run")
+            report["steps"].append(run_command(cmd, "summary", out_dir))
+
+        if "dashboard" in stages:
+            dashboard_html = resolve_artifact_path(
+                "",
+                kind="workflow_dashboard",
+                default_name="customer_dashboard.html",
+                run_id=run_id,
+            )
+            dashboard_json = resolve_artifact_path(
+                "",
+                kind="workflow_dashboard",
+                default_name="customer_dashboard_data.json",
+                run_id=run_id,
+            )
+            cmd = [
+                sys.executable,
+                str(SKILL_ROOT / "scripts" / "dashboard" / "build_dashboard.py"),
+                "--excel",
+                workbook_path,
+                "--output-html",
+                str(dashboard_html),
+                "--output-json",
+                str(dashboard_json),
+                "--run-id",
+                run_id,
+                "--latest-dir",
+                str(config.get("dashboard_latest_dir") or "dashboards/latest"),
+            ]
+            report["steps"].append(run_command(cmd, "dashboard", out_dir))
+
+        if "facebook_outreach_preview" in stages:
+            if not config.get("profile_id"):
+                report["steps"].append({
+                    "step": "facebook_outreach_preview",
+                    "ok": False,
+                    "skipped": True,
+                    "reason": "profile_id is required for Facebook review planning.",
+                })
+            else:
+                cmd = [
+                    sys.executable,
+                    str(SKILL_ROOT / "scripts" / "social" / "run_facebook_follow_dm.py"),
+                    "--excel",
+                    workbook_path,
+                    "--profile-id",
+                    str(config.get("profile_id")),
+                    "--review-only",
+                    "--run-id",
+                    run_id,
+                ]
+                report["steps"].append(run_command(cmd, "facebook_outreach_preview", out_dir))
+
+        if "feishu_check" in stages:
+            report["feishu"] = feishu_config_status(config)
+
+        report["ok"] = all(step.get("ok") or step.get("skipped") for step in report["steps"])
+
+    output_path = resolve_artifact_path(
+        args.output,
+        kind="workflow_report",
+        default_name="dealer-pipeline-report.json",
+        run_id=run_id,
+    )
+    write_json(output_path, report)
+    report["report_path"] = str(output_path)
+    print(json.dumps(report, ensure_ascii=False, indent=2))
+    return 0 if report.get("ok") else 1
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())