Переглянути джерело

Initial import of Wuling dealer expansion skill v4.25

梁朝伟 2 тижнів тому
коміт
b386182dd0
60 змінених файлів з 12873 додано та 0 видалено
  1. 52 0
      .gitignore
  2. 321 0
      SKILL.md
  3. BIN
      assets/blank_customer_outreach_workbook.xlsx
  4. 17 0
      assets/dashboard_config.example.json
  5. 263 0
      assets/dashboard_template.html
  6. 1 0
      assets/email_subject.txt
  7. 1 0
      assets/email_subjects/平台与行业渠道.txt
  8. 31 0
      assets/email_template.md
  9. 29 0
      assets/email_templates/平台与行业渠道.md
  10. 31 0
      assets/email_templates/汽车渠道合作伙伴.md
  11. 15 0
      assets/feishu_sync_config.example.json
  12. BIN
      assets/morocco-cooperation-overview.jpg
  13. 227 0
      assets/social_outreach_library.md
  14. 29 0
      assets/social_templates.md
  15. BIN
      assets/wuling-channel-margin-comparison.png
  16. BIN
      assets/wuling-platform-industry-channel.png
  17. 88 0
      references/adspower-setup.md
  18. 42 0
      references/artifact-policy.md
  19. 133 0
      references/brand-keywords.md
  20. 104 0
      references/dashboard.md
  21. 66 0
      references/encoding-and-excel-writeback.md
  22. 85 0
      references/feishu-sync.md
  23. 240 0
      references/field-schema.md
  24. 149 0
      references/outreach-rules.md
  25. 237 0
      references/search-strategy.md
  26. 79 0
      references/search-workflow.md
  27. 5 0
      requirements-lobster.txt
  28. 22 0
      scripts/common/__init__.py
  29. 248 0
      scripts/common/artifact_manager.py
  30. 74 0
      scripts/common/brand_detector.py
  31. 631 0
      scripts/common/build_customer_summary.py
  32. 307 0
      scripts/common/customer_taxonomy.py
  33. 45 0
      scripts/common/deduplicator.py
  34. 267 0
      scripts/common/excel_io.py
  35. 120 0
      scripts/common/workbook_resolver.py
  36. 1 0
      scripts/dashboard/__init__.py
  37. 323 0
      scripts/dashboard/build_dashboard.py
  38. 7 0
      scripts/email_outreach/__init__.py
  39. 94 0
      scripts/email_outreach/prepare_and_send.py
  40. 348 0
      scripts/email_outreach/prepare_outreach_emails.py
  41. 311 0
      scripts/email_outreach/send_outreach_emails.py
  42. 7 0
      scripts/scraper/__init__.py
  43. 132 0
      scripts/scraper/ads_power_client.py
  44. 157 0
      scripts/scraper/control_opened_browser.py
  45. 154 0
      scripts/scraper/control_opened_browser_v2.py
  46. 139 0
      scripts/scraper/demo_visit_facebook_dealer.py
  47. 749 0
      scripts/scraper/discovery_common.py
  48. 726 0
      scripts/scraper/scrape_single_page.py
  49. 880 0
      scripts/scraper/search_active_dealers.py
  50. 902 0
      scripts/scraper/search_auto_websites.py
  51. 470 0
      scripts/scraper/search_facebook.py
  52. 528 0
      scripts/scraper/search_google_maps.py
  53. 650 0
      scripts/scraper/search_linkedin.py
  54. 10 0
      scripts/scraper/search_tiktok.py
  55. 371 0
      scripts/scraper/website_deep_scraper.py
  56. 76 0
      scripts/social/README.md
  57. 1 0
      scripts/social/__init__.py
  58. 633 0
      scripts/social/prepare_facebook_outreach.py
  59. 253 0
      scripts/social/run_facebook_follow_dm.py
  60. 992 0
      scripts/social/send_facebook_outreach.py

+ 52 - 0
.gitignore

@@ -0,0 +1,52 @@
+# Python cache and local interpreter output
+__pycache__/
+*.py[cod]
+*$py.class
+
+# Local shell / notebook history
+.Rhistory
+.python_history
+
+# Runtime artifacts
+runs/
+logs/
+backups/
+archives/
+.tmp/
+tmp/
+*.tmp
+*.log
+*.jsonl
+*.sent-log.jsonl
+
+# Generated previews and candidate data
+*preview*.json
+*preview*.html
+*candidate*.json
+*scraped*.json
+*selected*.json
+*results*.json
+*status_updates*.json
+*verification*.txt
+*_report.json
+
+# Local customer workbooks and backups
+~$*.xlsx
+*_backup_*.xlsx
+*backup_before*.xlsx
+*客户建联表*.xlsx
+*客户信息*.xlsx
+*重点客户*.xlsx
+!assets/blank_customer_outreach_workbook.xlsx
+
+# Local credentials and real sync config
+.env
+.env.*
+feishu_sync_config.json
+dashboard_config.json
+*credential*
+*secret*
+*token*
+
+# Packaged release archives should be created outside the repo
+*.zip

+ 321 - 0
SKILL.md

@@ -0,0 +1,321 @@
+---
+name: wuling-overseas-dealer-expansion
+description: 为五菱汽车海外经销商拓展提供端到端自动化能力:使用 AdsPower/Playwright 搜索海外经销商线索,去重和评分,回写建联表,汇总客户总表,生成英文 Facebook/LinkedIn 社交建联预览,使用固定邮件模板生成邮件预览,并在用户确认后执行小批量邮件或社交建联。Use when Codex needs to find overseas Wuling dealer leads, scrape dealer contact info, assess dealer fit, generate English social outreach copy, prepare/send confirmed outreach emails, or run semi-automated Facebook outreach in AdsPower.
+---
+
+# 五菱汽车海外经销商拓展
+
+## 文件边界
+
+这个目录是独立 skill 包,只放可复用文件:
+
+- `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.
+
+不要把真实客户表、JSON 预览、采集结果、发送日志、截图、缓存、`__pycache__`、临时文件或任何账号/SMTP 授权码放进 skill 包。唯一允许放进 `assets/` 的 Excel 是空白模板 `assets/blank_customer_outreach_workbook.xlsx`。
+
+## 任务路由
+
+| 任务 | 使用文件 | 状态 |
+|---|---|---|
+| 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`,不逐条确认 |
+
+## 必读规则
+
+- 先生成预览,再写表或发送。
+- 查找客户默认面向用户指定国家全国范围,不局限单一城市;当前摩洛哥只是本项目默认市场。
+- 表里已有同名、同主页、同电话或同邮箱的客户,直接跳过或补充资料,不重复新建。
+- 跳过 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 和 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` 三个备选版本,方便人工挑选。不要写成泛泛合作介绍。
+
+禁止编造具体价格、MOQ、车型清单、利润率、库存数量、政府关系或未经确认的合作条件。
+
+
+## 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 或负责人入口。页面活跃度、粉丝互动、近期帖子只作为辅助参考,不作为必须条件。
+
+评分准则写入候选预览的 `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。
+
+看板只读取 `客户信息汇总表`,不直接写入 Excel,不触发飞书同步,也不保存真实客户数据到 skill 包。默认产物位置为 `runs/YYYYMMDD/<run_id>/customer_dashboard.html` 和 `runs/YYYYMMDD/<run_id>/dashboard_data.json`;如果需要固定入口,可同时复制到项目目录 `dashboards/latest/customer_dashboard.html`。
+
+如果本轮刚完成渠道采集、邮件状态写回、社交状态写回或其他 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`。
+
+- 如果配置存在且 `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`。
+
+## 工作簿优先级
+
+写表脚本必须按同一顺序解析建联表:
+
+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 内的空白模板文件。
+
+## 邮件规则
+
+当前默认邮件模板面向 `客户属性=汽车渠道合作伙伴`,正式发送正文使用 `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
+
+- 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.
+
+
+
+## Sender Identity And Name Replacement
+
+- 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


+ 17 - 0
assets/dashboard_config.example.json

@@ -0,0 +1,17 @@
+{
+  "enabled": true,
+  "source_sheet": "客户信息汇总表",
+  "title": "五菱海外客户建联中台看板",
+  "default_top_n": 12,
+  "output_mode": "runs",
+  "latest_dir": "dashboards/latest",
+  "refresh_after_summary_write": true,
+  "sync_dashboard_metrics_to_feishu": false,
+  "feishu_dashboard_sheets": [
+    "看板指标",
+    "客户属性分布",
+    "客户类型分布",
+    "建联状态分布",
+    "来源分布"
+  ]
+}

+ 263 - 0
assets/dashboard_template.html

@@ -0,0 +1,263 @@
+<!doctype html>
+<html lang="zh-CN">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>五菱海外客户建联中台</title>
+  <style>
+    :root {
+      color-scheme: light;
+      --bg: #f3f6fa;
+      --panel: #ffffff;
+      --panel-soft: #f8fafc;
+      --ink: #142033;
+      --muted: #66758c;
+      --line: #dfe6ef;
+      --line-soft: #edf2f7;
+      --blue: #1d5fd1;
+      --blue-2: #2f80ed;
+      --cyan: #13899b;
+      --green: #16885a;
+      --amber: #b7791f;
+      --red: #c9443d;
+      --navy: #0f2745;
+      --shadow: 0 12px 30px rgba(20, 32, 52, .08);
+    }
+    * { box-sizing: border-box; }
+    body {
+      margin: 0;
+      min-width: 320px;
+      background: var(--bg);
+      color: var(--ink);
+      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", Arial, sans-serif;
+      line-height: 1.45;
+    }
+    .shell { max-width: 1440px; margin: 0 auto; padding: 24px 24px 44px; }
+    .topbar {
+      display: grid;
+      grid-template-columns: minmax(0, 1fr) auto;
+      align-items: end;
+      gap: 18px;
+      margin-bottom: 18px;
+    }
+    h1 { margin: 0 0 6px; font-size: 26px; line-height: 1.2; letter-spacing: 0; }
+    .subtitle { color: var(--muted); font-size: 13px; overflow-wrap: anywhere; }
+    .status-stack { display: flex; gap: 8px; align-items: center; justify-content: flex-end; flex-wrap: wrap; }
+    .status-chip {
+      display: inline-flex; align-items: center; gap: 6px; padding: 7px 10px;
+      border: 1px solid var(--line); border-radius: 999px; background: rgba(255,255,255,.8);
+      color: var(--muted); font-size: 12px; white-space: nowrap;
+    }
+    .dot { width: 7px; height: 7px; border-radius: 999px; background: var(--green); }
+    .grid { display: grid; gap: 14px; }
+    .kpis { grid-template-columns: repeat(6, minmax(0, 1fr)); margin-bottom: 14px; }
+    .card { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; box-shadow: var(--shadow); }
+    .kpi { padding: 15px 15px 13px; min-height: 132px; position: relative; overflow: hidden; }
+    .kpi .label { color: var(--muted); font-size: 12px; margin-bottom: 8px; }
+    .kpi .value { font-size: 29px; font-weight: 760; letter-spacing: 0; }
+    .kpi .note { color: var(--muted); font-size: 12px; margin-top: 5px; }
+    .kpi .meter { height: 5px; margin-top: 14px; background: #edf2f7; border-radius: 999px; overflow: hidden; }
+    .kpi .meter span { display: block; height: 100%; width: var(--w); background: var(--tone, var(--blue)); border-radius: 999px; }
+    .main { grid-template-columns: 1.12fr .88fr; align-items: start; margin-bottom: 14px; }
+    .panel { padding: 16px; }
+    .panel-head { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; margin-bottom: 12px; }
+    h2 { margin: 0; font-size: 16px; letter-spacing: 0; }
+    .hint { color: var(--muted); font-size: 12px; }
+    .funnel { display: grid; gap: 9px; }
+    .funnel-row { display: grid; grid-template-columns: 116px minmax(0, 1fr) 86px; align-items: center; gap: 10px; }
+    .funnel-name { color: var(--muted); font-size: 13px; }
+    .funnel-track { height: 24px; background: #eef3f8; border-radius: 5px; overflow: hidden; }
+    .funnel-fill { height: 100%; width: var(--w); background: linear-gradient(90deg, var(--blue), var(--cyan)); border-radius: 5px; }
+    .funnel-count { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
+    .charts { grid-template-columns: repeat(2, minmax(0, 1fr)); margin-bottom: 14px; }
+    .bar-list { display: grid; gap: 10px; }
+    .bar-row { display: grid; grid-template-columns: minmax(132px, .62fr) minmax(120px, 1fr) 70px; align-items: center; gap: 10px; }
+    .bar-label { font-size: 13px; overflow-wrap: anywhere; }
+    .bar-track { height: 10px; background: #edf2f7; border-radius: 999px; overflow: hidden; }
+    .bar-fill { height: 100%; width: var(--w); background: var(--tone, var(--blue)); border-radius: 999px; }
+    .bar-count { text-align: right; color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
+    .columns { height: 230px; display: flex; align-items: end; gap: 10px; padding-top: 8px; }
+    .col-wrap { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 7px; }
+    .col-track { width: 100%; height: 160px; display: flex; align-items: end; justify-content: center; background: linear-gradient(180deg, transparent, #f6f8fb); border-bottom: 1px solid var(--line); }
+    .col { width: 56%; min-width: 18px; max-width: 42px; height: var(--h); border-radius: 5px 5px 0 0; background: linear-gradient(180deg, var(--blue-2), var(--blue)); }
+    .col-count { font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; }
+    .col-label { max-width: 100%; min-height: 34px; color: var(--muted); font-size: 11px; text-align: center; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
+    .donut-wrap { display: grid; grid-template-columns: 160px minmax(0, 1fr); gap: 12px; align-items: center; }
+    .donut { width: 150px; height: 150px; border-radius: 50%; background: conic-gradient(var(--blue) 0 100%); position: relative; margin: auto; }
+    .donut::after { content: ""; position: absolute; inset: 24px; background: var(--panel); border-radius: 50%; box-shadow: inset 0 0 0 1px var(--line-soft); }
+    .legend { display: grid; gap: 8px; }
+    .legend-row { display: grid; grid-template-columns: 10px minmax(0, 1fr) auto; gap: 8px; align-items: center; color: var(--muted); font-size: 12px; }
+    .legend-dot { width: 10px; height: 10px; border-radius: 3px; background: var(--tone); }
+    .toolbar { display: grid; grid-template-columns: 1.25fr repeat(3, minmax(145px, .5fr)); gap: 10px; margin: 0 0 12px; }
+    input, select {
+      width: 100%; border: 1px solid var(--line); border-radius: 7px; background: #fff;
+      color: var(--ink); padding: 9px 10px; font: inherit; font-size: 13px; outline: none;
+    }
+    input:focus, select:focus { border-color: rgba(29,95,209,.65); box-shadow: 0 0 0 3px rgba(29,95,209,.12); }
+    .table-wrap { overflow: auto; border: 1px solid var(--line); border-radius: 8px; }
+    table { width: 100%; border-collapse: collapse; min-width: 1020px; font-size: 13px; }
+    th, td { text-align: left; padding: 10px 10px; border-bottom: 1px solid var(--line-soft); vertical-align: top; }
+    th { position: sticky; top: 0; z-index: 1; background: #f7f9fc; color: var(--muted); font-weight: 650; }
+    tbody tr:nth-child(even) { background: #fbfcfe; }
+    tbody tr:hover { background: #f1f6ff; }
+    .badge { display: inline-flex; align-items: center; max-width: 100%; padding: 3px 8px; border-radius: 999px; background: #eef4ff; color: #174ea6; font-size: 12px; white-space: nowrap; }
+    .badge.green { background: #eaf7f1; color: #11734c; }
+    .badge.amber { background: #fff6e6; color: #946200; }
+    .badge.red { background: #fff0ef; color: #b12b24; }
+    .muted { color: var(--muted); }
+    .note { max-width: 420px; color: #39465a; }
+    .wide { grid-column: 1 / -1; }
+    .defs { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
+    .def { background: var(--panel-soft); border: 1px solid var(--line-soft); border-radius: 7px; padding: 10px; }
+    .def strong { display: block; margin-bottom: 5px; }
+    .def span { color: var(--muted); font-size: 12px; }
+    @media (max-width: 1180px) {
+      .kpis { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+      .main, .charts { grid-template-columns: 1fr; }
+      .defs { grid-template-columns: 1fr; }
+    }
+    @media (max-width: 720px) {
+      .shell { padding: 18px 14px 32px; }
+      .topbar { grid-template-columns: 1fr; }
+      .status-stack { justify-content: flex-start; }
+      .kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+      .toolbar { grid-template-columns: 1fr; }
+      .donut-wrap { grid-template-columns: 1fr; }
+    }
+  </style>
+</head>
+<body>
+  <main class="shell">
+    <header class="topbar">
+      <div>
+        <h1 id="title">五菱海外客户建联中台</h1>
+        <div class="subtitle" id="subtitle"></div>
+      </div>
+      <div class="status-stack">
+        <span class="status-chip"><span class="dot"></span>本地总表</span>
+        <span class="status-chip" id="generatedAt"></span>
+        <span class="status-chip" id="runId"></span>
+      </div>
+    </header>
+    <section class="grid kpis" id="kpis"></section>
+    <section class="grid main">
+      <div class="card panel">
+        <div class="panel-head"><h2>建联运营漏斗</h2><span class="hint">从客户池到已触达</span></div>
+        <div class="funnel" id="funnel"></div>
+      </div>
+      <div class="card panel">
+        <div class="panel-head"><h2>建联状态结构</h2><span class="hint">按当前状态聚合</span></div>
+        <div class="donut-wrap"><div class="donut" id="statusDonut"></div><div class="legend" id="statusLegend"></div></div>
+      </div>
+    </section>
+    <section class="grid charts">
+      <div class="card panel"><div class="panel-head"><h2>客户属性分布</h2><span class="hint">横向条形图</span></div><div class="bar-list" id="attributeBars"></div></div>
+      <div class="card panel"><div class="panel-head"><h2>客户来源分布</h2><span class="hint">纵向棒形图</span></div><div class="columns" id="sourceColumns"></div></div>
+      <div class="card panel"><div class="panel-head"><h2>细分客户类型 Top</h2><span class="hint">优先看大客户池</span></div><div class="bar-list" id="typeBars"></div></div>
+      <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="card panel wide">
+      <div class="panel-head"><h2>待关注客户</h2><span class="hint" id="tableCount"></span></div>
+      <div class="toolbar">
+        <input id="searchInput" type="search" placeholder="搜索客户、城市、邮箱、备注">
+        <select id="attributeFilter"><option value="">全部客户属性</option></select>
+        <select id="statusFilter"><option value="">全部建联状态</option></select>
+        <select id="emailFilter"><option value="">邮箱不限</option><option value="yes">有邮箱</option><option value="no">无邮箱</option></select>
+      </div>
+      <div class="table-wrap">
+        <table>
+          <thead><tr><th>总表行</th><th>客户</th><th>客户属性</th><th>客户类型</th><th>来源</th><th>联系方式</th><th>状态</th><th>备注摘要</th></tr></thead>
+          <tbody id="customerRows"></tbody>
+        </table>
+      </div>
+    </section>
+    <section class="card panel wide" style="margin-top:14px">
+      <div class="panel-head"><h2>指标口径</h2><span class="hint">保持每次生成口径一致</span></div>
+      <div class="defs" id="definitions"></div>
+    </section>
+  </main>
+  <script id="dashboardData" type="application/json">{{dashboard_json}}</script>
+  <script>
+    const data = JSON.parse(document.getElementById('dashboardData').textContent);
+    const metrics = data.metrics;
+    const charts = data.charts;
+    const rows = data.dashboard_rows || data.attention_customers || [];
+    const palette = ['#1d5fd1', '#16885a', '#b7791f', '#c9443d', '#13899b', '#6b7280', '#7c3aed', '#0f766e'];
+    const pct = v => `${Number(v || 0).toFixed(1)}%`;
+    const clampPct = v => Math.max(2, Math.min(100, Number(v || 0)));
+    const safe = v => String(v ?? '').replace(/[&<>"]/g, s => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[s]));
+    document.getElementById('title').textContent = data.title || '五菱海外客户建联中台';
+    document.getElementById('subtitle').textContent = `数据来源:${data.workbook} / ${data.sheet_name}`;
+    document.getElementById('generatedAt').textContent = `生成:${data.generated_at}`;
+    document.getElementById('runId').textContent = `Run:${data.run_id}`;
+    function renderKpis() {
+      const cards = [
+        ['有效客户', metrics.valid_customers, `原始 ${metrics.total_rows},剔除 ${metrics.excluded_customers}`, 100, '#1d5fd1'],
+        ['可建联客户', metrics.contactable_customers, '有邮箱、电话或平台入口', metrics.valid_customers ? metrics.contactable_customers * 100 / metrics.valid_customers : 0, '#13899b'],
+        ['建联率', pct(metrics.outreach_rate), `已建联 ${metrics.contacted_customers}`, metrics.outreach_rate, '#16885a'],
+        ['邮箱覆盖率', 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']
+      ];
+      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('');
+    }
+    function renderFunnel() {
+      const items = [['总表客户', metrics.total_rows], ['有效客户', metrics.valid_customers], ['可建联', metrics.contactable_customers], ['已建联', metrics.contacted_customers], ['待复核', metrics.needs_review_customers]];
+      const max = Math.max(...items.map(i => Number(i[1] || 0)), 1);
+      document.getElementById('funnel').innerHTML = items.map(([name, count]) => `<div class="funnel-row"><div class="funnel-name">${safe(name)}</div><div class="funnel-track"><div class="funnel-fill" style="--w:${clampPct(count * 100 / max)}%"></div></div><div class="funnel-count">${safe(count)}</div></div>`).join('');
+    }
+    function renderBars(targetId, items, tone = '#1d5fd1') {
+      const max = Math.max(...(items || []).map(i => Number(i.count || 0)), 1);
+      document.getElementById(targetId).innerHTML = (items || []).map((item, idx) => `<div class="bar-row"><div class="bar-label">${safe(item.name || '未填写')}</div><div class="bar-track"><div class="bar-fill" style="--w:${clampPct(item.count * 100 / max)}%;--tone:${palette[idx % palette.length] || tone}"></div></div><div class="bar-count">${safe(item.count)} / ${safe(item.rate)}%</div></div>`).join('') || '<div class="muted">暂无数据</div>';
+    }
+    function renderColumns() {
+      const items = (charts.source || []).slice(0, 8);
+      const max = Math.max(...items.map(i => Number(i.count || 0)), 1);
+      document.getElementById('sourceColumns').innerHTML = items.map((item, idx) => `<div class="col-wrap"><div class="col-count">${safe(item.count)}</div><div class="col-track"><div class="col" style="--h:${clampPct(item.count * 100 / max)}%;background:linear-gradient(180deg, ${palette[idx % palette.length]}, #0f2745)"></div></div><div class="col-label" title="${safe(item.name)}">${safe(item.name)}</div></div>`).join('') || '<div class="muted">暂无数据</div>';
+    }
+    function renderDonut() {
+      const items = (charts.status || []).slice(0, 6);
+      const total = items.reduce((sum, item) => sum + Number(item.count || 0), 0) || 1;
+      let acc = 0;
+      const stops = items.map((item, idx) => {
+        const start = acc; acc += Number(item.count || 0) / total * 100;
+        return `${palette[idx % palette.length]} ${start}% ${acc}%`;
+      });
+      document.getElementById('statusDonut').style.background = `conic-gradient(${stops.join(', ')})`;
+      document.getElementById('statusLegend').innerHTML = items.map((item, idx) => `<div class="legend-row"><span class="legend-dot" style="--tone:${palette[idx % palette.length]}"></span><span>${safe(item.name)}</span><strong>${safe(item.count)}</strong></div>`).join('');
+    }
+    function optionList(values) { return [...new Set(values.filter(Boolean))].sort((a, b) => a.localeCompare(b, 'zh-Hans')); }
+    function initFilters() {
+      const attr = document.getElementById('attributeFilter');
+      const status = document.getElementById('statusFilter');
+      attr.innerHTML += optionList(rows.map(r => r.attribute)).map(v => `<option value="${safe(v)}">${safe(v)}</option>`).join('');
+      status.innerHTML += optionList(rows.map(r => r.status || '未填写')).map(v => `<option value="${safe(v)}">${safe(v)}</option>`).join('');
+      ['searchInput', 'attributeFilter', 'statusFilter', 'emailFilter'].forEach(id => document.getElementById(id).addEventListener('input', renderRows));
+    }
+    function renderRows() {
+      const q = document.getElementById('searchInput').value.trim().toLowerCase();
+      const attr = document.getElementById('attributeFilter').value;
+      const status = document.getElementById('statusFilter').value;
+      const emailMode = document.getElementById('emailFilter').value;
+      const filtered = rows.filter(row => {
+        const hay = [row.company, row.city, row.attribute, row.type, row.contact, row.status, row.note, row.source].join(' ').toLowerCase();
+        if (q && !hay.includes(q)) return false;
+        if (attr && row.attribute !== attr) return false;
+        if (status && (row.status || '未填写') !== status) return false;
+        if (emailMode === 'yes' && !row.has_email) return false;
+        if (emailMode === 'no' && row.has_email) return false;
+        return true;
+      });
+      document.getElementById('tableCount').textContent = `显示 ${filtered.length} / ${rows.length}`;
+      document.getElementById('customerRows').innerHTML = filtered.slice(0, 80).map(row => {
+        const statusClass = row.bounced ? 'red' : row.contacted ? 'green' : row.review ? 'amber' : '';
+        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 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'); renderColumns(); renderDonut(); renderDefinitions(); initFilters(); renderRows();
+  </script>
+</body>
+</html>

+ 1 - 0
assets/email_subject.txt

@@ -0,0 +1 @@
+Wuling Morocco National-Level Distribution Partnership Opportunity

+ 1 - 0
assets/email_subjects/平台与行业渠道.txt

@@ -0,0 +1 @@
+Wuling Morocco B2B Platform and Industry Network Partnership Opportunity

+ 31 - 0
assets/email_template.md

@@ -0,0 +1,31 @@
+Dear **{{customer_name}}**,
+
+I am **Chris Chen from Wuling Overseas Business Department**.
+
+Wuling is one of China’s most widely recognized practical vehicle brands serving mass-market families and business users. With large-scale manufacturing capabilities and a mature product portfolio, Wuling has achieved cumulative vehicle production and sales of more than **30 million units**, exported more than **1 million vehicles**, and expanded its overseas business to more than **60 countries**.
+
+We understand that your company has **{{capability_phrase}}** in Morocco. We would therefore like to invite your company to evaluate a **national-level non-exclusive distribution opportunity with Wuling**.
+
+We can offer our partners an indicative gross margin of approximately **USD 895 per vehicle**, which is around **USD 250 per vehicle higher than the DFSK K07**, based on the model and reference prices shown below.
+
+[PICTURE]
+
+At the same time, we understand that your company may be concerned about inventory, after-sales service, and spare-parts risks when introducing a new brand. Cooperation can therefore begin with **market validation, small-batch orders, and rolling replenishment**, followed by further discussions regarding **spare-parts supply, maintenance training, product-material support, and the development of a local service network**.
+
+**Wuling is currently evaluating national-level distribution partners in Morocco with the following capabilities:**
+
+- **Vehicle importation and certification capabilities;**
+- **Local showrooms, sales teams, or a downstream dealer network;**
+- **Corporate, fleet, or government-agency customer resources;**
+- **Continuous purchasing, spare-parts, and after-sales service capabilities.**
+
+Qualified companies may further apply to become Wuling’s **national-level exclusive agency partner in Morocco**. If your company is interested, please reply to this email and provide a brief company introduction, details of your channel coverage, and your automotive brand operation experience. Upon receiving the information, we will communicate with your company further to jointly evaluate suitable models, pricing, and the potential cooperation pathway.
+
+Best regards,
+
+Chris Chen
+Senior Business Development Manager
+Wuling Overseas Business Department
+WhatsApp / Tel:  +86 19513825970
+Email: [contact@huatu.hk](mailto:contact@huatu.hk)
+Website:https://wulingcars.com/

+ 29 - 0
assets/email_templates/平台与行业渠道.md

@@ -0,0 +1,29 @@
+Dear **{{customer_name}}**,
+
+I am **Chris Chen from the Wuling Overseas Business Department**. Wuling has achieved cumulative vehicle production and sales of more than **30 million units**, exported more than **1 million vehicles**, and expanded its overseas business to more than **60 countries**.
+
+We understand that your company has **{{platform_resource_phrase}}** in Morocco. We would therefore like to invite your company to jointly evaluate a platform and industry-channel cooperation opportunity with Wuling.
+
+Wuling can offer dealers connected through your platform an indicative gross margin opportunity of approximately **USD 895 per vehicle**, around **USD 250 per vehicle higher than the DFSK K07**, based on the model and reference prices shown below.
+
+![Wuling Morocco B2B Platform and Industry Network Partnership Opportunity](assets/wuling-platform-industry-channel.png)
+
+We hope to combine your platform reach and automotive network to support **dealer and importer recruitment, consolidated member purchasing, and procurement lead generation for corporate, fleet, and institutional customers**. This can help convert your existing traffic and member resources into **B2B leads, consolidated orders, and long-term cooperation projects**.
+
+We also understand the quality, supply, after-sales, and spare-parts risks involved in recommending a new brand. Both parties can therefore begin with **partner screening, market validation, and small-batch projects**, before gradually developing spare-parts supply, maintenance training, and a local service network.
+
+Wuling is currently seeking platform and industry-channel partners in Morocco with:
+
+- Access to automobile dealers, importers, or corporate fleets;
+- Demand organization, consolidated purchasing, or B2B project capabilities;
+- The ability to identify and connect partners with vehicle importation, sales, and after-sales capabilities.
+
+If your company is interested, please reply with a brief **platform introduction, the scale of your membership or industry resources, and relevant project experience**. We can then jointly evaluate suitable models, cooperation methods, and an initial market project.
+
+Best regards,
+
+Chris Chen
+Wuling Overseas Business Department
+WhatsApp / Tel: +86 19513825970
+Email: [contact@huatu.hk](mailto:contact@huatu.hk)
+Website:https://wulingcars.com/

+ 31 - 0
assets/email_templates/汽车渠道合作伙伴.md

@@ -0,0 +1,31 @@
+Dear **{{customer_name}}**,
+
+I am **Chris Chen from Wuling Overseas Business Department**.
+
+Wuling is one of China’s most widely recognized practical vehicle brands serving mass-market families and business users. With large-scale manufacturing capabilities and a mature product portfolio, Wuling has achieved cumulative vehicle production and sales of more than **30 million units**, exported more than **1 million vehicles**, and expanded its overseas business to more than **60 countries**.
+
+We understand that your company has **{{capability_phrase}}** in Morocco. We would therefore like to invite your company to evaluate a **national-level non-exclusive distribution opportunity with Wuling**.
+
+We can offer our partners an indicative gross margin of approximately **USD 895 per vehicle**, which is around **USD 250 per vehicle higher than the DFSK K07**, based on the model and reference prices shown below.
+
+[PICTURE]
+
+At the same time, we understand that your company may be concerned about inventory, after-sales service, and spare-parts risks when introducing a new brand. Cooperation can therefore begin with **market validation, small-batch orders, and rolling replenishment**, followed by further discussions regarding **spare-parts supply, maintenance training, product-material support, and the development of a local service network**.
+
+**Wuling is currently evaluating national-level distribution partners in Morocco with the following capabilities:**
+
+- **Vehicle importation and certification capabilities;**
+- **Local showrooms, sales teams, or a downstream dealer network;**
+- **Corporate, fleet, or government-agency customer resources;**
+- **Continuous purchasing, spare-parts, and after-sales service capabilities.**
+
+Qualified companies may further apply to become Wuling’s **national-level exclusive agency partner in Morocco**. If your company is interested, please reply to this email and provide a brief company introduction, details of your channel coverage, and your automotive brand operation experience. Upon receiving the information, we will communicate with your company further to jointly evaluate suitable models, pricing, and the potential cooperation pathway.
+
+Best regards,
+
+Chris Chen
+Senior Business Development Manager
+Wuling Overseas Business Department
+WhatsApp / Tel:  +86 19513825970
+Email: [contact@huatu.hk](mailto:contact@huatu.hk)
+Website:https://wulingcars.com/

+ 15 - 0
assets/feishu_sync_config.example.json

@@ -0,0 +1,15 @@
+{
+  "enabled": true,
+  "spreadsheet_url": "https://example.feishu.cn/sheets/xxxxxx",
+  "spreadsheet_token": "",
+  "sync_scope": "summary_first",
+  "summary_sheet_name": "客户信息汇总表",
+  "channel_sheets": [
+    "Facebook",
+    "LinkedIn",
+    "Google Maps",
+    "汽车网站精选线索",
+    "协会商会"
+  ],
+  "report_path": "runs/YYYYMMDD/<run_id>/feishu-sync-report.json"
+}

BIN
assets/morocco-cooperation-overview.jpg


+ 227 - 0
assets/social_outreach_library.md

@@ -0,0 +1,227 @@
+# Social Outreach Library
+
+This library is the canonical prompt and template source for Facebook and LinkedIn social outreach in target-market Wuling dealer expansion.
+
+## 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.
+
+## 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.
+
+Official brand-country examples to skip:
+
+- `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
+
+Use a signal-driven structure instead of a fixed template:
+
+```text
+specific signal -> commercial hypothesis -> light offer -> one question
+```
+
+- `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.
+
+Avoid flat openings such as:
+
+```text
+We are Huatu Overseas and want to cooperate.
+I noticed your auto business. Open to connect?
+```
+
+Prefer customer-centered openings such as:
+
+```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.
+```
+
+## Output Variants
+
+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. |
+
+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
+客户判断:
+中文说明客户属于哪类客户,引用了什么真实信号,为什么值得或不值得建联,是否有批量采购/分销潜力。
+
+推荐切入点:
+中文说明应该用什么抓点切入,例如二手车库存补充、低成本新车、小批量测试、车队更新成本、商用车需求等,并说明推荐哪一个话术版本。
+
+英文加好友话术:
+Short, natural, attractive, and not like a mass ad.
+
+英文首轮私信:
+No more than 2 short paragraphs. First paragraph explains the business opportunity and fit. Second paragraph asks one light question.
+
+备选话术:
+输出 direct_profit_hook、stock_gap_hook、soft_research_hook 三种英文版本,方便人工挑选。
+
+风险提示:
+中文说明是否建议跳过、是否疑似官方品牌页、是否信息不足、是否需要人工确认。
+```
+
+## Selling Points
+
+Select only 1-2 per first touch:
+
+- `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`
+
+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.
+
+## Scenario Selection
+
+| 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. |
+
+## Variant Examples
+
+### used_car_dealer
+
+`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.
+
+Should I send a short model and price-range overview?
+```
+
+`stock_gap_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.
+
+Would a short model and price-range overview help your team judge fit?
+```
+
+`soft_research_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.
+
+Is this something your team would normally evaluate?
+```
+
+### rental_fleet
+
+```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.
+
+Should I send a short fleet-fit and price-range overview?
+```
+
+### unknown_auto_channel
+
+```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.
+
+Does your team handle vehicle purchasing or distribution?
+```
+
+## Platform Tone
+
+- 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.
+
+## 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?
+```
+
+Ask for the right person:
+
+```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.
+```
+
+## Safety
+
+- 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
+
+
+- `{{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.
+

+ 29 - 0
assets/social_templates.md

@@ -0,0 +1,29 @@
+# Social Templates Compatibility Note
+
+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 rule:
+
+- 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.
+
+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?
+```

BIN
assets/wuling-channel-margin-comparison.png


BIN
assets/wuling-platform-industry-channel.png


+ 88 - 0
references/adspower-setup.md

@@ -0,0 +1,88 @@
+# AdsPower Setup
+
+Use this reference when launching, connecting, or troubleshooting browser automation through AdsPower.
+
+## Preconditions
+
+1. Install and start AdsPower.
+2. Enable the local API. Default base URL: `http://127.0.0.1:50325`.
+3. Create or choose a browser profile for the target platform.
+4. Log in to the platform account inside that profile before running collection or outreach.
+5. Install Python dependencies in the active environment: `playwright`, `requests`, `pandas`, `openpyxl`.
+6. Install Playwright Chromium once with `playwright install chromium`.
+
+If automatic translation is needed, install `deep-translator`. Translation failure should not block collection; scripts should keep the original text.
+
+## Playwright Browser Rule
+
+All browser work in this skill must be implemented through Playwright. AdsPower provides the isolated logged-in browser profile and CDP websocket only; Playwright performs page navigation, DOM extraction, locator-based clicking, typing, scrolling, screenshots, and verification. v4.1 hard rule: never close or stop the user's AdsPower browser; only stop the Playwright connection when a script exits.
+
+Do not implement browser actions with screenshot coordinates, OS-level mouse/keyboard automation, extension scripts, or generic “bottom-most input” heuristics. If Playwright cannot reliably locate a Facebook Messenger input, LinkedIn page element, Google Maps detail panel, local auto website listing, or merchant website link, stop and report the blocker instead of falling back to unsafe interaction.
+
+For Facebook website extraction, use Playwright to read `a[href]`, visible link text, aria labels, action buttons, and body text. Bare domains visible in the About/contact area should be parsed as website candidates, while Instagram, WhatsApp, YouTube, TikTok, LinkedIn, Google Maps, and Facebook links must remain excluded from `公司官网`.
+
+## Browser Pacing Levels
+
+Browser automation uses layered pacing. Major steps wait 90-200 seconds, minor page-level steps wait 30-90 seconds, and technical waits remain short at about 0.5-8 seconds. Major steps include opening a new target page, moving to the next customer, submitting a search, completing Follow, completing DM send, or moving from Facebook to website deep scraping. Minor steps include opening About/Contact/Services pages, scrolling, expanding page sections, opening Messenger, and waiting before Enter. Technical waits are only for Playwright locator readiness, DOM load, popup appearance, and textbox availability.
+
+Pacing is for throttling and reducing accidental rapid-fire operations. It is not anti-detection or platform-evasion behavior. If verification, rate limit, suspicious activity, identity confirmation, or temporary block appears, stop the workflow and keep AdsPower open.
+
+## Profile Isolation
+
+Use separate profiles for different platforms or account types:
+
+- Facebook collection profile
+- Facebook outreach profile
+- LinkedIn profile
+- TikTok profile
+- Google Maps or browser research profile
+
+Avoid mixing high-volume collection and human outreach in the same profile when possible.
+
+## Connection Modes
+
+| Mode | Use case | Script |
+|------|----------|--------|
+| Start or attach to an AdsPower browser | Batch collection where the script may open/connect but must leave the browser running | `scripts/scraper/search_facebook.py`, `scripts/scraper/search_active_dealers.py` |
+| Connect to an already-open AdsPower browser | User has manually logged in or wants to keep the browser open | `scripts/scraper/control_opened_browser_v2.py` |
+
+## Active Browser Connection
+
+When connecting to an already-open profile:
+
+1. Call AdsPower `GET /api/v1/browser/active?user_id=<profile_id>`.
+2. Read the returned websocket endpoint.
+3. Connect Playwright with `connect_over_cdp(ws_endpoint)`.
+4. Open a new page in the existing context.
+5. Stop Playwright after completion, but never close or stop the user's AdsPower browser.
+
+## Example Commands
+
+Start a Facebook search:
+
+```powershell
+python scripts/scraper/search_facebook.py `
+  --query "Chery Maroc" `
+  --region "Maroc" `
+  --profile-id "YOUR_PROFILE_ID" `
+  --ads-power-url "http://127.0.0.1:50325" `
+  --max-results 10 `
+  --output "facebook_scraped.json"
+```
+
+Connect to an already-open profile:
+
+```powershell
+python scripts/scraper/control_opened_browser_v2.py `
+  --profile-id "YOUR_PROFILE_ID" `
+  --query "Chery Maroc" `
+  --stay 60
+```
+
+## Troubleshooting
+
+- If AdsPower cannot connect, confirm the local API is enabled and the port is correct.
+- If Playwright cannot connect, restart the profile and retry.
+- If the account is logged out, log in manually inside AdsPower before automation.
+- If Facebook layout changes, prefer deep single-page verification before running a batch.
+- If Excel write fails with `PermissionError`, close the workbook in Excel and rerun the write step.

+ 42 - 0
references/artifact-policy.md

@@ -0,0 +1,42 @@
+# Artifact Policy
+
+Use this reference before generating previews, reports, send logs, backups, or temporary files.
+
+## Directory Layout
+
+- `runs/YYYYMMDD/<run_id>/`: JSON previews, HTML previews, retry manifests, send logs, and run reports.
+- `backups/YYYYMMDD/`: Excel backups created immediately before writing a workbook.
+- `logs/`: durable operational ledgers, such as Facebook account daily limits.
+- `archives/`: compressed old logs/backups after retention cleanup.
+- `.tmp/`: short-lived development or repair scripts; safe to clear after the task ends.
+- `dashboards/latest/`: optional stable copy of the latest generated customer dashboard for quick local viewing.
+
+Do not write previews, retry manifests, send logs, or scraper JSON outputs to the project root unless the user explicitly provides an absolute path there.
+
+## Backup Rules
+
+- Create an Excel backup only before a write-enabled operation changes the official workbook.
+- Do not create backups for preview, dry-run, HTML rendering, search-only JSON generation, or SMTP send-only operations.
+- For the same logical task, pass the same `run_id` and create only one backup per workbook/purpose.
+- Keep the official workbook in the project root; put backups under `backups/YYYYMMDD/`.
+
+## Preview and Log Rules
+
+- JSON preview is the main audit artifact.
+- HTML preview should reference image files by path by default; avoid repeating base64 image data for every email.
+- Use embedded/base64 assets only when the user explicitly asks for a standalone offline HTML file.
+- Send logs must be JSONL and must never include SMTP authorization codes.
+- Dashboard HTML/JSON files follow preview/report rules: write them under `runs/YYYYMMDD/<run_id>/` by default, and only copy to `dashboards/latest/` when a stable local viewing entry is requested.
+
+## Retention Defaults
+
+- Keep the latest 10 Excel backups per official workbook; keep same-day backups.
+- Keep JSON/HTML previews and send logs for 30 days, then move or archive them.
+- Keep Facebook account ledger logs for 30 days unless the user asks for a longer audit trail.
+- Clear `.tmp` after the task unless a file is still actively needed.
+
+## Cleanup Safety
+
+- Always support dry-run cleanup first.
+- Never delete the official workbook, skill package, assets, templates, development MD, or recent backups.
+- Prefer moving legacy root artifacts into `runs/legacy_YYYYMM/` or `backups/legacy_YYYYMM/` before deletion when they may be audit-relevant.

+ 133 - 0
references/brand-keywords.md

@@ -0,0 +1,133 @@
+# Brand Keywords
+
+Use this reference when detecting automotive brands and estimating whether a prospect may have China-brand or brand-channel implications.
+
+Brand keywords are signals, not lead qualification by themselves. A page named only `<brand> + Maroc/Morocco` is usually an OEM local branch or official brand page and should be excluded from outreach leads.
+
+## China-Brand Keywords
+
+Use lowercase matching and include common variants when extending scripts.
+
+### Passenger And Light Commercial
+
+- `baic`
+- `byd`
+- `changan`
+- `chery`
+- `dfsk`
+- `dongfeng`
+- `foton`
+- `forland`
+- `gac`
+- `geely`
+- `great wall`
+- `haval`
+- `jac`
+- `jetour`
+- `li auto`
+- `maxus`
+- `mg`
+- `nio`
+- `omoda`
+- `ora`
+- `saic`
+- `wuling`
+- `xpeng`
+- `zeekr`
+- `brilliance`
+
+### Commercial Vehicle, Truck, And Bus Signals
+
+- `sitrak`
+- `sinotruk`
+- `cnhtc`
+- `shacman`
+- `faw`
+- `faw trucks`
+- `foton trucks`
+- `yutong`
+- `king long`
+- `golden dragon`
+- `higer`
+
+## Non-China Brand Keywords
+
+- `audi`
+- `bmw`
+- `chevrolet`
+- `citroen`
+- `dacia`
+- `daf`
+- `fiat`
+- `ford`
+- `hino`
+- `honda`
+- `hyundai`
+- `isuzu`
+- `iveco`
+- `jeep`
+- `kia`
+- `man`
+- `mazda`
+- `mercedes`
+- `mitsubishi`
+- `nissan`
+- `opel`
+- `peugeot`
+- `renault`
+- `scania`
+- `seat`
+- `skoda`
+- `suzuki`
+- `toyota`
+- `volkswagen`
+- `volvo`
+- `volvo trucks`
+
+## OEM Local Branch Exclusion
+
+Exclude a candidate when the page/company identity is just a brand plus country or official marker, for example:
+
+- `BYD Maroc`, `BYD Morocco`, `bydmaroc`
+- `BMW Maroc`, `bmwmaroc`
+- `JAC Motors Maroc`, `jacmotorsmaroc`
+- `Jetour Maroc`, `jetour-maroc`
+- `Chery Maroc`, `Volkswagen Maroc`, `Mercedes-Benz Maroc`
+
+These pages may show who distributes the brand, but the page itself is not the target customer. Record the independent channel behind it instead, such as a dealer group, importer, showroom, or used-car operator.
+
+Do not exclude an independent channel just because it mentions a brand. Examples to keep when otherwise qualified:
+
+- `La Continentale Auto` distributing Geely/KIA/Fiat.
+- `Prince Auto` selling Audi/Skoda/Cupra/Seat/Volkswagen.
+- `Kifal Auto` as a used-car buying/selling platform.
+- `Auto Hall`, `SMEIA`, `Sopriam`, or other distributor/dealer-group names when the entity is not only the brand page.
+
+## Assessment Rules
+
+| Signals | Output |
+|---------|--------|
+| OEM local branch / official brand-country page | `品牌当地分公司/官方页,排除` |
+| Independent dealer/group has China-brand keyword in business scope | `已代理中国品牌,需评估` |
+| Independent dealer/group has non-China brands and no China brand appears | `疑似无排他,可合作` |
+| No usable brand or channel signal | `信息不足,待判断` |
+
+This assessment is only a screening aid. Never mark a dealer as confirmed cooperation without human review.
+
+## Where To Apply
+
+Check brand keywords and OEM-branch exclusion in:
+
+- `客户姓名/公司`
+- `主页/链接`
+- `主营业务`
+- `备注`
+- About text
+- Recent post summaries
+- Page title and visible profile text
+- Website snippets if collected
+- Candidate scoring reasons
+
+## Maintenance
+
+The shared executable keyword list and OEM local branch filter for LinkedIn and Google Maps live in `scripts/scraper/discovery_common.py`. The active Facebook funnel still has local scoring helpers in `scripts/scraper/search_active_dealers.py`. The older detector in `scripts/common/brand_detector.py` is only used for basic brand enrichment; keep it aligned when permissions allow, then run a small detection test.

+ 104 - 0
references/dashboard.md

@@ -0,0 +1,104 @@
+# Customer Dashboard
+
+Use this reference when the user asks for a 中台、看台、看板、客户统计、建联率、客户数量、渠道分布、邮件发送统计, or wants a more visual view of the outreach workbook.
+
+## Scope
+
+The dashboard belongs in the skill as a reusable generator, not as customer data.
+
+- Skill package contains only reusable files:
+  - `scripts/dashboard/build_dashboard.py`
+  - `assets/dashboard_template.html`
+  - `assets/dashboard_config.example.json`
+- 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`
+
+Do not put generated dashboards, customer rows, Feishu URLs, tokens, screenshots, or run JSON files into the skill package.
+
+## Data Source
+
+The default source is the local workbook sheet `客户信息汇总表`.
+
+Before generating a dashboard after new scraping or write-back work, refresh the summary sheet first:
+
+```bash
+python scripts/common/build_customer_summary.py --excel "<建联表路径>" --write-summary --run-id "<run_id>"
+```
+
+Then generate the dashboard:
+
+```bash
+python scripts/dashboard/build_dashboard.py --excel "<建联表路径>" --run-id "<run_id>" --latest-dir "dashboards/latest"
+```
+
+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 `信息不足`.
+
+## 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.
+
+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 marks such as `????`. 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.

+ 66 - 0
references/encoding-and-excel-writeback.md

@@ -0,0 +1,66 @@
+# Text Encoding and Excel Write-Back Rules
+
+These rules are mandatory for every script, temporary repair, and manual write-back that touches Excel text fields such as customer notes, outreach status, follow-up status, Chinese summaries, or HTML previews.
+
+## Why this exists
+
+Windows PowerShell inline scripts can corrupt Chinese text when a command contains Chinese literals inside a here-string, heredoc-like block, or inline Python source. The corrupted text appears in Excel or HTML as repeated question marks. This has already happened in the workbook notes after email status write-back, so future runs must treat encoding as a hard safety rule.
+
+## Hard rules
+
+- Do not write Chinese customer notes, status text, field names, or HTML labels by embedding Chinese literals directly inside PowerShell inline Python scripts.
+- For temporary Python scripts launched from PowerShell, use one of these safe methods:
+  - read Chinese text from an existing UTF-8 file;
+  - build Chinese strings with Unicode escape sequences such as `\u90ae\u4ef6\u53d1\u9001\u8bb0\u5f55`;
+  - call a shared skill script that already stores its source as UTF-8;
+  - pass structured JSON generated by a UTF-8 script, not handwritten Chinese snippets from the shell command.
+- Use `encoding="utf-8"` or `encoding="utf-8-sig"` explicitly when reading or writing JSON, Markdown, HTML, CSV, or text artifacts.
+- Before saving Excel after any text write-back, scan the target values for repeated question marks. If found, abort the save or immediately repair before reporting success.
+- After saving Excel, reopen the workbook and scan all written cells for repeated question marks. Verification must include `备注`, `建联状态`, `下次跟进`, `客户属性`, `客户类型`, and generated HTML preview labels when applicable.
+- Do not trust a successful SMTP send, workbook save, or preview generation as proof that text is clean. Encoding verification is a separate step.
+- When a workbook is locked, write a pending JSON file using UTF-8 and do not handwrite Chinese additions later from PowerShell. The later write-back must consume the pending JSON or a UTF-8 script.
+
+## Required verification snippets
+
+Every write-back report should include these counts:
+
+- `question_mark_cells`: number of workbook cells containing repeated question marks after save.
+- `question_mark_note_rows`: number of note rows containing repeated question marks after save.
+- `html_question_mark_groups`: number of repeated-question-mark groups in generated HTML previews.
+
+If any count is greater than zero, the task is not complete.
+
+## Recommended note construction
+
+For email write-back notes, prefer structured data first:
+
+```json
+{
+  "event": "email_sent",
+  "sent_at": "2026-07-29 12:40:58",
+  "template_kind": "auto_channel_partner",
+  "emails": ["example@company.com"],
+  "send_log": "runs/YYYYMMDD/<run_id>/send-log.jsonl"
+}
+```
+
+Then render the Chinese note inside a UTF-8 Python module or via Unicode escapes. Do not render it inside a PowerShell command containing raw Chinese.
+
+## Standard clean Chinese phrases
+
+Use these meanings consistently when rendering from safe code:
+
+- email send record: `邮件发送记录:... 已按...模板发送至 ...;发送日志:...。`
+- email status repair: `邮件状态修复:发送日志确认已于 ... 向 ... 发送邮件;原状态为 ...。`
+- wait for reply: `等待回复`
+- sent email status: `已发送邮件`
+
+## Failure handling
+
+If repeated question marks appear:
+
+1. Stop further write-back or sending-related status updates.
+2. Backup the workbook before repair.
+3. Reconstruct the affected notes from source JSON/send logs, not from the corrupted text.
+4. Save and rescan the entire workbook.
+5. Only report completion when `question_mark_cells = 0`.

+ 85 - 0
references/feishu-sync.md

@@ -0,0 +1,85 @@
+# Feishu Sheet Sync
+
+Use this reference whenever a workflow updates the local customer outreach workbook and the WorkBuddy/Lark Sheets plugin is available.
+
+## Trigger Rule
+
+Feishu sync is a mandatory post-write step when all conditions are true:
+
+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 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.
+
+## Config
+
+Real Feishu URLs or tokens must live in the project root config file, not inside the skill package.
+
+Recommended project config file: `feishu_sync_config.json`.
+
+```json
+{
+  "enabled": true,
+  "spreadsheet_url": "https://example.feishu.cn/sheets/xxxxxx",
+  "spreadsheet_token": "",
+  "sync_scope": "summary_first",
+  "summary_sheet_name": "客户信息汇总表",
+  "channel_sheets": [
+    "Facebook",
+    "LinkedIn",
+    "Google Maps",
+    "汽车网站精选线索",
+    "协会商会"
+  ]
+}
+```
+
+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.
+- 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.
+
+## Acceptance Criteria
+
+A completed write-enabled workflow should end with one of these statuses:
+
+- `本地建联表已更新,飞书客户信息汇总表已同步并回读校验通过。`
+- `本地建联表已更新;feishu_sync_config.json 未配置,飞书同步未执行。`
+- `本地建联表已更新;飞书同步失败:<原因>。本地数据已保留。`

+ 240 - 0
references/field-schema.md

@@ -0,0 +1,240 @@
+# Field Schema
+
+## Encoding Safety for Text Fields
+
+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.
+
+- 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.
+
+## 工作簿优先级与空白模板
+
+- 正常运行优先使用用户传入的 `--excel`。
+- 未传 `--excel` 时,在当前项目目录和上级目录查找已有建联表,优先匹配 `摩洛哥客户建联表-按渠道分类.xlsx` 或同名带空格版本。
+- 查找时必须排除 `~$`、`_backup_`、`backup_before`、`_with_`、`sent_`、`preview`、`candidate`、`filtered`、预览和临时工作簿。
+- Skill 内置空白建联表模板:`assets/blank_customer_outreach_workbook.xlsx`。
+- 模板由正式建联表 `摩洛哥客户建联表-按渠道分类.xlsx` 生成,保留当前 Sheet、表头、列宽、基础格式、`填写说明` 和 `附件`,清空所有客户数据行。
+- 模板只在全新环境兜底使用:当项目目录没有建联表且用户明确写表时,先复制到运行目录,再写复制品。
+- 禁止把客户数据、发送状态或采集结果写入 skill 内的模板文件。
+- 预览模式不需要创建建联表;只有写表模式才允许自动复制空白模板。
+
+## 字段口径
+
+- `客户属性`:客户大类,只填写分类结果,例如 `汽车渠道合作伙伴`、`批量采购与运营客户`、`二手车转型候选`、`平台与行业渠道`、`生态支持资源`。
+- `客户类型`:细分客户类型,例如 `汽车进口商`、`全国代理商`、`区域分销商`、`多品牌经销商`、`汽车交易平台`、`汽车租赁公司`、`实体二手车企业` 等。
+- `主营业务`:客户实际经营内容和业务摘要,保持业务描述用途,不改名、不删除、不用于存放大类。
+- `备注`:结构化证据、来源判断、批量采购潜力、风险和待确认项。
+
+## 人工确认标记边界
+
+以下内容是人工确认/风险标记,不是客户分类。禁止写入 `客户属性` 或 `客户类型`,只能写入候选预览 JSON 的 `risk_flags`、`备注` 或人工复核预览中:
+
+- `主体归属待确认`:汽车渠道线索中,无法判断是独立公司,还是品牌官方主体、进口商直营网点或普通分店。
+- `新车业务待确认`:汽车渠道线索中,无法判断主营新整车,还是二手车、维修、配件、轮胎等业务。
+- `平台与行业渠道主体待确认`:平台与行业渠道线索中,无法确认是否为真实机构主体,还是普通个人页、内容号或非正式资源页。
+- `平台与行业渠道作用待确认`:平台与行业渠道线索中,无法确认是否具备汽车行业资源、渠道引荐、媒体传播或行业活动组织作用。
+- `仅电话/WhatsApp待人工确认`:线索已有目标价值信号,但只有电话或 WhatsApp,缺少官网、Facebook、LinkedIn、Google Maps 等可复核资料,需要人工联系确认主体和实际业务。
+- `详细信息待确认`:当 AI 因登录限制、页面屏蔽、地区限制或网站无法访问,不能读取 Facebook、LinkedIn、官网等内容时填写。
+
+客户分类仍必须严格保持五大客户属性和表内细分客户类型;不要用上述风险标记作为兜底分类值。
+
+人工复核不是低质线索兜底池。只有已经显示出渠道价值、但缺少一个关键确认点的客户,才标记为需人工复核。
+
+输出人工复核名单时,不要输出通用复核规则;只列出那些已经值得推进、但需要人工确认一个关键点的客户。低价值或无关客户应写为跳过/低优先级,不要包装成“建议人工复核”。
+
+### 两段式人工复核标准
+
+人工复核必须先看客户是否有目标价值,再看是否缺少需人工确认的关键点。只有两部分同时成立,才能列为需人工复核。
+
+第一部分:客户属性信号至少满足其一。
+
+- 汽车渠道合作伙伴信号:新车销售、汽车进口、分销、授权经销、汽车展厅、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 `公司官网`. |
+| `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: `建联状态`, `建联情况`
+
+Do not collapse company email and personal email when writing back to the LinkedIn sheet.
+
+## Google Maps Columns
+
+Google Maps uses:
+
+`客户姓名/公司`, `国家`, `城市`, `客户属性`, `客户类型`, `主页/链接`, `联系人`, `职位`, `电话/WhatsApp`, `邮箱`, `主营业务`, `建联状态`, `下次跟进`, `备注`
+
+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 `备注`.
+
+## Local Auto Website Columns
+
+`汽车网站精选线索` uses:
+
+`序号`, `客户姓名/公司`, `国家`, `城市`, `客户属性`, `客户类型`, `主页/链接`, `来源网站`, `联系人`, `职位`, `电话/WhatsApp`, `邮箱`, `主营业务`, `建联状态`, `下次跟进`, `备注`
+
+Rules:
+
+- `来源网站` 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.
+
+## Customer Summary Sheet
+
+`客户信息汇总表` 是搜索完成后的汇总 Sheet,不作为单个平台的原始写入目标。Facebook、LinkedIn、Google Maps、当地汽车网站、Moteur.ma、汽车网站精选线索、协会商会等渠道采集完成后,统一用 `scripts/common/build_customer_summary.py` 生成或覆盖。
+
+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.
+
+## 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 |
+|---|---|
+| `汽车渠道合作伙伴` | `汽车进口商` ; `全国代理商` ; `全国分销商` ; `区域分销商` ; `多品牌经销商` ; `商用车渠道商` ; `中国品牌经销商` ; `新能源或小型车渠道商` |
+| `批量采购与运营客户` | `汽车租赁公司` ; `长期租赁公司` ; `企业车队` ; `物流配送企业` ; `政府或机构采购方` |
+| `二手车转型候选` | `连锁二手车企业` ; `实体二手车企业` ; `进口二手车企业` ; `新车与二手车综合企业` |
+| `平台与行业渠道` | `汽车交易平台` ; `汽车协会` ; `商会` ; `车商联盟` ; `经销商资源引荐机构` |
+| `生态支持资源` | `售后服务网络` ; `备件供应与仓储企业` ; `进口认证与上牌机构` ; `金融保险机构` ; `车辆物流企业` |
+
+`主营业务` remains the business-description field and must not be renamed or used as a classification substitute.
+

+ 149 - 0
references/outreach-rules.md

@@ -0,0 +1,149 @@
+# Outreach Rules
+
+Use this reference before sending social messages, emails, or any operation that changes outreach status.
+
+## Human Confirmation
+
+Always generate a preview before outreach.
+
+- Email: generate JSON preview with `prepare_outreach_emails.py` first.
+- Facebook social: generate JSON preview with `prepare_facebook_outreach.py` first.
+- Browser outreach must run dry-run before real execution.
+- Do not send or click unless the user explicitly confirms and the command uses the required confirmation flag.
+
+
+
+## Sender Identity And Name Replacement
+
+- 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.
+
+## Email Workflow
+
+Email identity is fixed to `Chris Chen`. Email templates, preview bodies, and SMTP From display names should use Chris Chen by default. Do not ask for a dynamic social account name before email sending; `{{sender_name}}` is social-only.
+
+Current default email template is for 客户属性=汽车渠道合作伙伴: use assets/email_template.md, subject assets/email_subject.txt, and inline image assets/wuling-channel-margin-comparison.png. The actual sending renderer must convert Markdown bold/list/link syntax into HTML, strip Markdown from the plain-text fallback, and constrain the inline image to width=300 with inline styles. The same body is stored at assets/email_templates/汽车渠道合作伙伴.md for future customer-attribute template selection. The preview step must replace [PICTURE] with the inline image reference and choose {{capability_phrase}} from the customer business evidence, rather than leaving the bracketed generic phrase in the final email.
+
+1. Filter target records, normally `建联状态=未联系` and `邮箱` is not empty.
+2. Generate preview JSON with `scripts/email_outreach/prepare_outreach_emails.py`.
+3. Review `summary.ready_to_send` and `summary.skipped`.
+4. Send with `scripts/email_outreach/send_outreach_emails.py` only when the user provides SMTP details and confirms sending.
+5. After successful sending, update the workbook row `建联状态` to `已发送邮件`.
+6. Keep send logs, but do not store SMTP authorization codes.
+
+Already-sent statuses include `已发送邮件`, `已发邮件`, `邮件已发送`, and `邮件发送成功`.
+
+## Facebook Social Workflow
+
+Facebook outreach uses English as the customer-facing language. Chinese is only used for internal reasoning and preview review.
+
+1. **Prepare preview**: `scripts/social/prepare_facebook_outreach.py` reads the Facebook sheet, filters uncontacted non-OEM targets, classifies each customer by business signal, and writes a JSON preview with Chinese judgment plus English connect/DM copy.
+2. **Review preview**: Check `summary.ready_to_send`, `summary.skipped`, customer fit, and rendered English messages. Edit the workbook or re-run if a message is too generic.
+3. **Recommended module-2 entrypoint**: use `scripts/social/run_facebook_follow_dm.py` to generate preview and then run AdsPower/Playwright dry-run or confirmed execution in one controlled workflow.
+4. **Dry-run in AdsPower**: without `--confirm-send`, `run_facebook_follow_dm.py` calls `send_facebook_outreach.py --action follow_dm --keep-browser-open --no-write-workbook`, opens/checks each Facebook Page, prints the Page-header Follow/Message targets and Messenger target, and performs no real clicks. It must not ask for per-customer confirmation.
+5. **Confirm and execute**: Add `--confirm-send` only after the full Markdown preview has been shown in chat and the user confirms the whole batch once. The executor sends with `--confirm --batch-confirmed` and does not ask per customer.
+6. **Write back status**: Default is no workbook write. Only update the Facebook sheet when the user explicitly uses `--write-workbook`.
+7. **Refresh summary**: After a completed Facebook outreach round, rebuild `客户信息汇总` with `scripts/common/build_customer_summary.py --write-summary`.
+8. **Log**: Every action is appended to `logs/facebook_social_<date>.jsonl` with profile_id, target URL, action, timestamp, result, and error.
+
+## Facebook Page Follow + Messenger Targeting Rules
+
+- For Facebook Page outreach, use `--action follow_dm`: follow the Page first, then send a Messenger DM.
+- Follow must be detected in the Page header. `追蹤中`, `Following`, or equivalent means already active and must not be clicked again.
+- Message must start from the Page header `發送訊息 / Message` button, not from a post, comment box, or floating compose button.
+- A DM textbox is valid only when it is inside a right-side Messenger dialog whose title matches the dealer name.
+- Never use a global `contenteditable` textbox or the bottom-most page textbox as a message target.
+- If the candidate textbox is near `Comment / 留言 / Reply / 回覆`, stop and output `Blocked: detected comment composer, not Messenger dialog`.
+- If the Messenger dialog title does not match the target customer, stop and do not send.
+- Always keep the already logged-in AdsPower browser open. `--keep-browser-open` is a compatibility flag; scripts must not close or stop AdsPower even after errors or risk prompts.
+- If the Page-header Message button does not open the right-side Messenger dialog, the executor may fall back to `https://www.facebook.com/messages/t/<page_slug>`, but only after validating that the Messenger page contains the target customer name and a valid DM textbox.
+
+## Facebook Message Content Rules
+
+- Use English for customer-facing Facebook connect and DM text. Chinese is only for internal judgment and preview review. Do not use French as the default Facebook message language.
+- Country and city rendering must be market-adaptive: use the workbook country or user-selected target country, and only use city names when they are real customer-facing city names. Blank, nationwide, multi-city, or Chinese internal city notes must render as `in <country>`.
+- Required preview format: `客户判断`、`推荐切入点`、`英文加好友话术`、`英文首轮私信`、`风险提示`.
+- Produce Chinese internal judgment that explains customer type, the exact real signal used, why the customer is or is not worth contacting, and whether it has batch purchase/distribution potential.
+- Treat the social objective as batch-sales qualification: first get a reply, then identify whether the customer can evaluate, purchase, digest, or distribute a first small batch of Wuling vehicles.
+- Generate copy with the signal-driven structure: `specific signal -> commercial hypothesis -> light offer -> one question`.
+- Every sendable message must cite at least one real customer signal from the workbook, Facebook page, About text, website, public contact data, or remarks. If the signal is weak, use a low-pressure role-confirmation message instead of a strong sales pitch.
+- Do not use `We want to cooperate`, `We are an exporter`, or similar self-introduction as the first sentence. Lead from the customer's likely business opportunity.
+- Produce `recommended_message` plus three alternatives: `direct_profit_hook`, `stock_gap_hook`, and `soft_research_hook`. Keep `english_connect` and `english_first_dm` as backward-compatible aliases for the recommended version.
+- Facebook Page DM may be more direct about profit, inventory gap, total cost, fleet cost, and first-batch fit. LinkedIn connection notes should be lighter and less sales-heavy.
+- Use only 1-2 Wuling value points per first touch: 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, or possible volume potential.
+- Use only one core question in the first DM. Do not ask for a meeting in the first touch.
+- Mention Huatu Overseas only after the hook or in a compact sentence when needed.
+- Do not discuss brand authorization, territory rights, regional rights, sole-right arrangements, or similar channel-right topics.
+- Do not invent exact prices, MOQ, delivery dates, model lists, profit margins, available stock, government relationships, or unsupported cooperation terms.
+- If real data is unavailable, use soft wording such as `model and price-range overview`, `small first-batch evaluation`, or `export-support information`.
+- If the lead appears to be an official brand-country page, mark `建议跳过` and do not generate sendable copy.
+
+## Facebook Status Mapping
+
+- Preview only: keep `未联系`.
+- Add friend only success: `已加好友,待私信`.
+- Add friend plus message success: `已加好友,已发私信`.
+- Message only success: `已发私信`.
+- Add friend failure: `加好友失败` plus reason in `备注`.
+- Message failure: `发送失败` plus reason in `备注`.
+- Already emailed and now messaged on Facebook: append the social status in `备注`; do not erase the existing email evidence.
+
+## Batch Limits
+
+Use small batches by default:
+
+| Action | Recommended limit |
+|--------|-------------------|
+| Scrape search results | `--max-results` no more than 20 per run unless the user asks otherwise. |
+| Facebook Page Follow | 3 per real run, no more than 20 per account per day. |
+| Facebook direct messages | 3 per real run, no more than 20 customer outreach DMs per account per day. |
+| Email sending | Start with a small test batch before larger sends. |
+
+Use bounded pacing waits for browser actions to throttle the workflow and reduce accidental rapid-fire operations. These waits are not for bypassing platform detection. Increase waits or stop if the account shows risk signals.
+
+## Facebook Compliant Risk Reduction
+
+- Do not design or describe Facebook automation as anti-detection, verification bypass, fingerprint rotation, or evasion of platform enforcement.
+- Use `--risk-profile very_conservative` by default. Keep Facebook outreach small, relevant, and auditable.
+- Use the account ledger `logs/facebook_account_ledger_<profile_id>_<YYYYMMDD>.json` to enforce daily limits before opening AdsPower.
+- Default daily outreach cap: 20 customers per AdsPower profile. Follow <= 20 and Messenger DM <= 20; session max remains <= 3 by default. Operators may lower these caps, but should not raise them for cold outreach without a separate review.
+- `--dry-run-schedule` must print the customer list, estimated pacing, used quota, remaining quota, stop rules, and staged wait ranges without opening the browser.
+- Stop the entire batch immediately on verification, rate-limit, suspicious activity, identity confirmation, temporary block, exhausted quota, or repeated failures. Keep the browser open.
+- Same-batch message previews should not use identical first sentences; weak-signal leads should receive low-pressure role-confirmation copy.
+- Use layered browser pacing instead of one long interval for every action. Major steps use 90-200 seconds; minor page-level steps use 30-90 seconds; technical waits stay short at roughly 0.5-8 seconds.
+- Major steps: switching to the next customer, opening a new customer page/company page/Google Maps detail/website homepage, waiting after a search query, waiting after successful Follow, waiting after successful DM send, and moving from a Facebook Page to website deep scraping.
+- Minor steps: About/contact/services tab changes, scrolling, expanding more content, opening the Messenger dialog, waiting before pressing Enter, and switching between website Contact/About pages.
+- Technical waits: Playwright locator readiness, DOM load, popup appearance, textbox availability, and similar element checks. Do not stretch technical waits to 30 seconds or more unless the site is genuinely slow.
+- Session cooldown remains an extra post-run recommendation. These waits throttle the workflow and reduce accidental rapid-fire operations; they are not for bypassing platform detection.
+- When a risk prompt or repeated failure is recorded, write `cooldown_until` in the account ledger and refuse additional confirmed Facebook outreach for that profile until the cooldown ends.
+
+## Compliance And Risk
+
+- Use collected public business contact data only for relevant B2B outreach.
+- Do not scrape or store unnecessary personal data.
+- Do not bypass platform restrictions or security checks.
+- Stop immediately when Facebook shows verification, rate-limit, suspicious activity, identity confirmation, or temporary block prompts.
+- Do not send to OEM local brand-country pages such as `BYD Maroc`, `BMW Maroc`, `JAC Motors Maroc`, `Changan Maroc`, `FOTON Maroc`, or similar official branches.
+- Do not continue follow-up more than twice without a reply.
+- Do not store account passwords, cookies, SMTP authorization codes, or other sensitive secrets in skill files, JSON previews, or logs.
+
+## Audit Trail
+
+Record these fields for browser outreach when possible:
+
+- `profile_id`
+- target URL or account
+- action type
+- timestamp
+- result
+- error reason when failed
+- whether the run was dry-run or confirmed
+
+For email, keep JSONL send logs with recipient, dealer name, subject, sender, and timestamp. Never log SMTP auth codes.
+

+ 237 - 0
references/search-strategy.md

@@ -0,0 +1,237 @@
+# 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.
+
+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.
+
+## Geographic Scope
+
+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.
+
+Use a mixed national + city coverage pattern:
+
+- 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.
+
+
+## Customer Attribute Focus
+
+Lead discovery and scoring must classify every lead into `客户属性` and `客户类型` while keeping `主营业务` as a separate business-description field.
+
+Primary search focus:
+
+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.
+
+Secondary but still classified:
+
+- `批量采购与运营客户`: rental/fleet operators, currently mapped mainly as `汽车租赁公司`.
+- `二手车转型候选`: used-car businesses that may test affordable new vehicles as a complement to used-car stock.
+
+Do not put long business descriptions into `客户属性` or `客户类型`. Write the actual business summary into `主营业务`, and write source evidence, risk, and batch-purchase reasoning into `备注`.
+## 人工确认标记边界
+
+以下内容是人工确认/风险标记,不是客户分类。禁止写入 `客户属性` 或 `客户类型`,只能写入候选预览 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 |
+|---|---|
+| 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` |
+| 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
+
+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.
+
+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
+
+Use a staged funnel rather than opening every search result:
+
+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.
+
+For executable details, see `references/search-workflow.md`.
+
+## Candidate Output
+
+A useful candidate preview should include:
+
+- `url`
+- `source_queries`
+- `score`
+- `score_reasons`
+- `risk_flags`
+- `recommended_action`
+
+Do not treat candidate previews as final leads. Final leads require either deep scraping or explicit user approval.
+## Google Maps Email Extraction
+
+Use Google Maps as a local-business discovery channel, not as an email generator. Email values must come from public evidence only:
+
+- 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.
+
+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 `备注`.
+
+## Facebook Website Deep Scrape
+
+When Facebook deep scraping is enabled, treat the Facebook Page as the first evidence layer, not the only source of truth.
+
+- 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 |
+|---|---|
+| `汽车渠道合作伙伴` | `汽车进口商` ; `全国代理商` ; `全国分销商` ; `区域分销商` ; `多品牌经销商` ; `商用车渠道商` ; `中国品牌经销商` ; `新能源或小型车渠道商` |
+| `批量采购与运营客户` | `汽车租赁公司` ; `长期租赁公司` ; `企业车队` ; `物流配送企业` ; `政府或机构采购方` |
+| `二手车转型候选` | `连锁二手车企业` ; `实体二手车企业` ; `进口二手车企业` ; `新车与二手车综合企业` |
+| `平台与行业渠道` | `汽车交易平台` ; `汽车协会` ; `商会` ; `车商联盟` ; `经销商资源引荐机构` |
+| `生态支持资源` | `售后服务网络` ; `备件供应与仓储企业` ; `进口认证与上牌机构` ; `金融保险机构` ; `车辆物流企业` |

+ 79 - 0
references/search-workflow.md

@@ -0,0 +1,79 @@
+# Search Workflow
+
+Use this reference when running dealer discovery so the agent behaves like a lead researcher rather than a blind crawler.
+
+## Default Funnel
+
+1. Load existing workbook links first unless the user asks for `--no-excel`.
+2. Search high-signal channel terms and collect page/company/place URLs only.
+3. Normalize and de-duplicate links across the current run and existing workbook.
+4. Score candidates before opening homepages, company About pages, Google Maps place details, or merchant websites.
+5. Reject or hold candidates that look like OEM local branches or official brand-country pages.
+6. Save a candidate preview JSON for human review.
+7. Deep scrape only candidates above the score threshold or explicitly selected by the user. For Google Maps, deep scrape the place detail and then public merchant website pages for email evidence. For local auto websites, deep scrape source listings and merchant websites, but write only real companies, not category/listing/search/add-company pages.
+8. Write to Excel only when the user asks for confirmed output or passes `--write-excel`.
+
+## Morocco-Wide Discovery
+
+Default to `摩洛哥全国` when the user asks for Moroccan dealers without naming a city. Use national channel keywords first, then city waves for large and small dealers. City waves are search coverage tools; do not assume the city field is known unless the page itself confirms it.
+
+The active Facebook search script uses national dealer/importer/showroom/group queries plus these city waves: Casablanca, Rabat, Marrakech, Tanger, Fes, Agadir, Meknes, Oujda, Kenitra, Tetouan, Nador, and Safi.
+
+## Candidate Scoring
+
+Prioritize candidates with these signals:
+
+- Independent dealer, distributor, importer, showroom, automotive group, used-car, or commercial-vehicle channel terms.
+- China-brand or commercial-vehicle brand appears inside a dealer/group context, not as the whole page identity.
+- Import, multibrand, concessionnaire, groupe, showroom, stock, or used-car business terms.
+- URL/page slug that looks like an automotive channel or Moroccan dealer.
+- A candidate seen from more than one search query.
+
+Down-rank or hold for manual review when these signals dominate:
+
+- diagnostics, repair-only, spare parts, insurance, rental-only, personal profile, add-company pages, platform category pages, directory-only pages, or unrelated page names.
+- weak URL slug with no automotive channel signal.
+- already present in the workbook.
+
+## OEM Brand-Branch Rejection
+
+Reject candidates before deep scraping when the company/page identity is just an automotive brand plus country or official marker. Examples:
+
+- `BYD Maroc`, `BYD Morocco`, `bydmaroc`
+- `BMW Maroc`, `bmwmaroc`
+- `JAC Motors Maroc`, `jacmotorsmaroc`
+- `Jetour Maroc`, `jetour-maroc`
+- `Chery Maroc`, `Volkswagen Maroc`, `Mercedes-Benz Maroc`
+
+Set `recommended_action` to `skip_brand_branch` or keep it as a low-priority preview item. Do not write these pages to the outreach workbook as customers.
+
+Use official brand pages only as clue sources: inspect them lightly only if needed to identify the actual independent distributor, dealer group, showroom, or importer behind the brand. For example, keep `La Continentale Auto` if it distributes Geely/KIA/Fiat because the entity is a dealer; reject `Geely Maroc` if the entity is only the brand-country page.
+
+
+
+## Local Auto Website Funnel
+
+Use `scripts/scraper/search_auto_websites.py` for local automotive websites and directories. The default source set is OtoMoto, Wandaloo, Kerix, Kompass, Maroc Annuaire, and Telecontact.
+
+1. Connect to the already-open AdsPower profile.
+2. Visit source entry pages and run limited source-specific searches.
+3. Keep only candidates that point to a real company, importer, dealer, showroom, fleet/rental operator, or commercial-vehicle channel.
+4. Extract public phones/emails from directory text, source listing pages, `mailto:` links, and merchant website Contact/About pages.
+5. Score batch-sales fit from multiple-branch, stock, `professionnel`, `importation`, `véhicules neufs`, `location`, `utilitaire`, MPV, microvan, truck, bus, fleet, or distributor signals.
+6. Write to `汽车网站精选线索` only when `--write-excel` is used. This sheet has `来源网站`; duplicates within the sheet must merge sources and append evidence to `备注`.
+
+If a source only produces category pages or low-quality pages in the current run, record that in the JSON report but do not write those pages as customer rows.
+
+## Output Rules
+
+Candidate preview output should include:
+
+- `url`
+- `source_queries`
+- `score`
+- `score_reasons`
+- `risk_flags`
+- `recommended_action`
+
+Deep-scraped records may then be written to the workbook. Candidate previews should not be written to the outreach workbook as final leads. Local auto website records must include `来源网站` when written to `汽车网站精选线索`. For Google Maps, do not fabricate emails; leave `邮箱` blank when no public email is found and explain that in `备注`.
+

+ 5 - 0
requirements-lobster.txt

@@ -0,0 +1,5 @@
+playwright
+pandas
+openpyxl
+requests
+deep-translator

+ 22 - 0
scripts/common/__init__.py

@@ -0,0 +1,22 @@
+"""
+公共工具模块
+"""
+from .excel_io import read_sheet, append_records, update_status, normalize_record
+from .deduplicator import deduplicate_records, merge_records
+from .brand_detector import detect_brands, assess_exclusivity, enrich_record_with_brands
+from .workbook_resolver import find_project_workbook, resolve_workbook_path, template_workbook_path
+
+__all__ = [
+    "read_sheet",
+    "append_records",
+    "update_status",
+    "normalize_record",
+    "deduplicate_records",
+    "merge_records",
+    "detect_brands",
+    "assess_exclusivity",
+    "enrich_record_with_brands",
+    "find_project_workbook",
+    "resolve_workbook_path",
+    "template_workbook_path",
+]

+ 248 - 0
scripts/common/artifact_manager.py

@@ -0,0 +1,248 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""Shared run artifact, backup, and cleanup helpers for the Wuling skill."""
+from __future__ import annotations
+
+import argparse
+import json
+import re
+import shutil
+import zipfile
+from dataclasses import dataclass
+from datetime import datetime, timedelta
+from pathlib import Path
+from typing import Any, Dict, Iterable, List, Optional
+
+
+DEFAULT_BACKUP_KEEP = 10
+DEFAULT_RETENTION_DAYS = 30
+
+
+@dataclass
+class CleanupItem:
+    action: str
+    path: Path
+    size: int
+    reason: str
+    target: Optional[Path] = None
+
+
+def project_root(start: Optional[Path] = None) -> Path:
+    """Return the project root; prefer the current working tree over the skill folder."""
+    start = (start or Path.cwd()).resolve()
+    candidates = [start, *start.parents]
+    for candidate in candidates:
+        if (candidate / ".kimi").exists() or (candidate / "摩洛哥客户建联表-按渠道分类.xlsx").exists():
+            return candidate
+    return Path.cwd().resolve()
+
+
+def today_key() -> str:
+    return datetime.now().strftime("%Y%m%d")
+
+
+def timestamp() -> str:
+    return datetime.now().strftime("%Y%m%d_%H%M%S")
+
+
+def safe_slug(value: str, default: str = "run") -> str:
+    slug = re.sub(r"[^0-9A-Za-z_.-]+", "_", (value or "").strip()).strip("._-")
+    return slug or default
+
+
+def new_run_id(prefix: str = "run") -> str:
+    return f"{safe_slug(prefix)}_{timestamp()}"
+
+
+def run_dir(run_id: Optional[str] = None, root: Optional[Path] = None) -> Path:
+    run_id = safe_slug(run_id or new_run_id())
+    return project_root(root) / "runs" / today_key() / run_id
+
+
+def backups_dir(root: Optional[Path] = None) -> Path:
+    return project_root(root) / "backups" / today_key()
+
+
+def archives_dir(root: Optional[Path] = None) -> Path:
+    return project_root(root) / "archives"
+
+
+def is_bare_filename(path: Path) -> bool:
+    return not path.is_absolute() and str(path.parent) in {"", "."}
+
+
+def resolve_artifact_path(
+    raw: str,
+    *,
+    kind: str,
+    default_name: str,
+    run_id: Optional[str] = None,
+    root: Optional[Path] = None,
+) -> Path:
+    """Resolve output artifacts into runs/ unless the user supplied a real path."""
+    if raw:
+        path = Path(raw).expanduser()
+        if path.is_absolute():
+            return path
+        if not is_bare_filename(path):
+            return (Path.cwd() / path).resolve()
+        filename = path.name
+    else:
+        filename = default_name
+    base = run_dir(run_id or f"{safe_slug(kind)}_{timestamp()}", root)
+    return base / filename
+
+
+def create_backup_once(
+    workbook_path: Path,
+    *,
+    purpose: str,
+    run_id: Optional[str] = None,
+    root: Optional[Path] = None,
+) -> Path:
+    """Create one workbook backup for a run/purpose; return existing backup if present."""
+    workbook_path = workbook_path.resolve()
+    run_id = safe_slug(run_id or f"{safe_slug(purpose)}_{timestamp()}")
+    backup_dir = backups_dir(root)
+    backup_dir.mkdir(parents=True, exist_ok=True)
+    backup_path = backup_dir / f"{workbook_path.stem}_backup_before_{safe_slug(purpose)}_{run_id}{workbook_path.suffix}"
+    if not backup_path.exists():
+        shutil.copy2(workbook_path, backup_path)
+    return backup_path
+
+
+def write_json(path: Path, data: Dict[str, Any]) -> None:
+    path.parent.mkdir(parents=True, exist_ok=True)
+    path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
+
+
+def file_size(path: Path) -> int:
+    return path.stat().st_size if path.exists() and path.is_file() else 0
+
+
+def iter_legacy_root_artifacts(root: Path) -> Iterable[Path]:
+    patterns = [
+        "email_preview*",
+        "email_retry*",
+        "*.sent-log.jsonl",
+        "facebook_social_status_updates_*.json",
+    ]
+    seen = set()
+    for pattern in patterns:
+        for path in root.glob(pattern):
+            key = str(path.resolve()).casefold()
+            if key not in seen:
+                seen.add(key)
+                yield path
+
+
+def build_cleanup_plan(
+    *,
+    root: Optional[Path] = None,
+    retention_days: int = DEFAULT_RETENTION_DAYS,
+    backup_keep: int = DEFAULT_BACKUP_KEEP,
+) -> List[CleanupItem]:
+    root = project_root(root)
+    cutoff = datetime.now() - timedelta(days=retention_days)
+    items: List[CleanupItem] = []
+
+    tmp_dir = root / ".tmp"
+    if tmp_dir.exists():
+        for path in tmp_dir.iterdir():
+            if path.is_file():
+                items.append(CleanupItem("delete", path, file_size(path), ".tmp temporary file"))
+
+    for path in iter_legacy_root_artifacts(root):
+        if not path.is_file():
+            continue
+        reason = "legacy run artifact in project root"
+        if path.name == "email_preview_summary_rows_357_419_20260724_170613.html" or path.name == "email_preview_summary_rows_357_419_20260724_170613.json":
+            items.append(CleanupItem("delete", path, file_size(path), "invalid intermediate preview"))
+            continue
+        if re.match(r"email_retry_summary_rows_357_419_chunk_\d+_20260724\.json$", path.name):
+            items.append(CleanupItem("delete", path, file_size(path), "superseded retry manifest"))
+            continue
+        target = root / "runs" / "legacy_202607" / path.name
+        items.append(CleanupItem("move", path, file_size(path), reason, target))
+
+    backups = sorted(
+        [p for p in root.glob("*_backup_*.xlsx") if p.is_file()],
+        key=lambda p: p.stat().st_mtime,
+        reverse=True,
+    )
+    for idx, path in enumerate(backups):
+        target = root / "backups" / "legacy_202607" / path.name
+        # Keep recent backups discoverable by moving, not deleting. The newest
+        # backup_keep rule applies to future cleanup runs inside backups/.
+        items.append(CleanupItem("move", path, file_size(path), f"legacy workbook backup #{idx + 1}", target))
+
+    backup_groups: Dict[str, List[Path]] = {}
+    for path in (root / "backups").rglob("*_backup_*.xlsx") if (root / "backups").exists() else []:
+        key = re.sub(r"_backup_before_.+$", "", path.name)
+        backup_groups.setdefault(key, []).append(path)
+    for group in backup_groups.values():
+        group.sort(key=lambda p: p.stat().st_mtime, reverse=True)
+        for path in group[backup_keep:]:
+            modified = datetime.fromtimestamp(path.stat().st_mtime)
+            if modified < cutoff:
+                target = root / "archives" / "old_backups" / (path.name + ".zip")
+                items.append(CleanupItem("archive", path, file_size(path), "old backup beyond retention", target))
+
+    return items
+
+
+def apply_cleanup_plan(items: Iterable[CleanupItem], *, dry_run: bool = True) -> Dict[str, Any]:
+    applied = []
+    total_size = 0
+    for item in items:
+        total_size += item.size
+        entry = {
+            "action": item.action,
+            "path": str(item.path),
+            "target": str(item.target) if item.target else "",
+            "size": item.size,
+            "reason": item.reason,
+        }
+        if not dry_run:
+            try:
+                if item.action == "delete":
+                    item.path.unlink(missing_ok=True)
+                elif item.action == "move" and item.target:
+                    item.target.parent.mkdir(parents=True, exist_ok=True)
+                    if item.target.exists():
+                        item.target = item.target.with_name(f"{item.target.stem}_{timestamp()}{item.target.suffix}")
+                    shutil.move(str(item.path), str(item.target))
+                    entry["target"] = str(item.target)
+                elif item.action == "archive" and item.target:
+                    item.target.parent.mkdir(parents=True, exist_ok=True)
+                    with zipfile.ZipFile(item.target, "w", compression=zipfile.ZIP_DEFLATED) as archive:
+                        archive.write(item.path, arcname=item.path.name)
+                    item.path.unlink(missing_ok=True)
+                entry["status"] = "ok"
+            except Exception as exc:  # keep cleanup best-effort and auditable
+                entry["status"] = "failed"
+                entry["error"] = str(exc)
+        applied.append(entry)
+    return {
+        "dry_run": dry_run,
+        "count": len(applied),
+        "total_size_bytes": total_size,
+        "total_size_mb": round(total_size / 1024 / 1024, 3),
+        "items": applied,
+    }
+
+
+def main(argv: Optional[List[str]] = None) -> int:
+    parser = argparse.ArgumentParser(description="Preview or apply Wuling skill artifact cleanup.")
+    parser.add_argument("--apply", action="store_true", help="Actually move/delete/archive files. Omit for dry-run.")
+    parser.add_argument("--retention-days", type=int, default=DEFAULT_RETENTION_DAYS)
+    parser.add_argument("--backup-keep", type=int, default=DEFAULT_BACKUP_KEEP)
+    args = parser.parse_args(argv)
+    plan = build_cleanup_plan(retention_days=args.retention_days, backup_keep=args.backup_keep)
+    report = apply_cleanup_plan(plan, dry_run=not args.apply)
+    print(json.dumps(report, ensure_ascii=False, indent=2))
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 74 - 0
scripts/common/brand_detector.py

@@ -0,0 +1,74 @@
+"""
+品牌检测与排他协议辅助判断
+"""
+import re
+from typing import List, Dict, Any
+
+# 中国品牌
+CHINESE_BRANDS = [
+    "byd", "chery", "dfsk", "dongfeng", "foton", "gac", "geely",
+    "great wall", "haval", "jac", "jetour", "li auto", "mg", "nio",
+    "omoda", "ora", "wuling", "xpeng", "zeekr", "changan", "baic",
+    " brilliance", "maxus", "saic"
+]
+
+# 常见非中国品牌(用于判断“无排他”)
+NON_CHINESE_BRANDS = [
+    "audi", "bmw", "citroen", "dacia", "fiat", "ford", "honda",
+    "hyundai", "kia", "mercedes", "nissan", "opel", "peugeot",
+    "renault", "seat", "skoda", "toyota", "volkswagen", "volvo",
+    "jeep", "chevrolet", "mazda", "mitsubishi", "suzuki"
+]
+
+
+def detect_brands(text: str) -> Dict[str, List[str]]:
+    """从文本中检测品牌词"""
+    if not text:
+        return {"chinese": [], "non_chinese": [], "all": []}
+
+    text_lower = text.lower()
+    found_chinese = []
+    found_non_chinese = []
+
+    for brand in CHINESE_BRANDS:
+        if re.search(r'\b' + re.escape(brand) + r'\b', text_lower):
+            found_chinese.append(brand)
+
+    for brand in NON_CHINESE_BRANDS:
+        if re.search(r'\b' + re.escape(brand) + r'\b', text_lower):
+            found_non_chinese.append(brand)
+
+    return {
+        "chinese": found_chinese,
+        "non_chinese": found_non_chinese,
+        "all": found_chinese + found_non_chinese
+    }
+
+
+def assess_exclusivity(text: str) -> str:
+    """
+    辅助判断排他协议状态
+    返回: 疑似无排他,可合作 / 已代理中国品牌,需评估 / 信息不足,待判断
+    """
+    brands = detect_brands(text)
+
+    if brands["chinese"]:
+        return "已代理中国品牌,需评估"
+
+    if brands["non_chinese"] and len(brands["non_chinese"]) >= 1:
+        return "疑似无排他,可合作"
+
+    return "信息不足,待判断"
+
+
+def enrich_record_with_brands(record: Dict[str, Any]) -> Dict[str, Any]:
+    """给记录添加品牌检测字段"""
+    combined_text = " ".join([
+        str(record.get("主营业务", "")),
+        str(record.get("备注", "")),
+        str(record.get("客户类型", ""))
+    ])
+    brands = detect_brands(combined_text)
+    record["detected_brands"] = brands["all"]
+    record["exclusivity_assessment"] = assess_exclusivity(combined_text)
+    return record

+ 631 - 0
scripts/common/build_customer_summary.py

@@ -0,0 +1,631 @@
+# -*- coding: utf-8 -*-
+"""Build the consolidated customer summary sheet for the outreach workbook."""
+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 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
+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 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"],
+}
+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)
+
+
+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 contains_any(text: str, keywords: Sequence[str]) -> bool:
+    return any(keyword.casefold() in text for keyword in keywords)
+
+
+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 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:
+            continue
+        ws = wb[sheet_name]
+        columns = header_map(ws)
+        if "name" not in columns and "link" not in columns and "website" not in columns:
+            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
+                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] = {}
+
+    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
+
+    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])
+        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)
+    return output
+
+
+def write_summary_sheet(wb, rows: Sequence[Dict[str, Any]], sheet_name: str) -> None:
+    if sheet_name in wb.sheetnames:
+        del wb[sheet_name]
+    ws = wb.create_sheet(sheet_name, 0)
+    ws.append(SUMMARY_HEADERS)
+    for row in rows:
+        ws.append([row.get(header, "") for header in SUMMARY_HEADERS])
+    ws.freeze_panes = "A2"
+    ws.auto_filter.ref = ws.dimensions
+    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]
+    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 {
+        "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,
+    }
+
+
+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.parent.mkdir(parents=True, exist_ok=True)
+    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.")
+    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")
+    if not workbook_path:
+        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)}")
+
+    wb = load_workbook(workbook_path)
+    records, rows_by_sheet, blank_rows = read_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)
+        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)
+    save_json_report(report, args.output)
+    print(json.dumps(report, ensure_ascii=False, indent=2))
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
+

+ 307 - 0
scripts/common/customer_taxonomy.py

@@ -0,0 +1,307 @@
+# -*- coding: utf-8 -*-
+"""Strict customer taxonomy and classification helpers for Wuling outreach.
+
+Source intentionally uses unicode escapes for Chinese labels so scripted edits do
+not depend on the Windows console code page. Runtime values are normal unicode.
+"""
+from __future__ import annotations
+
+import re
+from typing import Any, Mapping, Sequence, Tuple
+
+
+def zh(value: str) -> str:
+    return value.encode("ascii").decode("unicode_escape")
+
+
+AUTO_CHANNEL = zh(r"\u6c7d\u8f66\u6e20\u9053\u5408\u4f5c\u4f19\u4f34")
+BULK_OPERATOR = zh(r"\u6279\u91cf\u91c7\u8d2d\u4e0e\u8fd0\u8425\u5ba2\u6237")
+USED_TRANSITION = zh(r"\u4e8c\u624b\u8f66\u8f6c\u578b\u5019\u9009")
+PLATFORM_CHANNEL = zh(r"\u5e73\u53f0\u4e0e\u884c\u4e1a\u6e20\u9053")
+ECOSYSTEM_SUPPORT = zh(r"\u751f\u6001\u652f\u6301\u8d44\u6e90")
+
+CAR_IMPORTER = zh(r"\u6c7d\u8f66\u8fdb\u53e3\u5546")
+NATIONAL_AGENT = zh(r"\u5168\u56fd\u4ee3\u7406\u5546")
+NATIONAL_DISTRIBUTOR = zh(r"\u5168\u56fd\u5206\u9500\u5546")
+REGIONAL_DISTRIBUTOR = zh(r"\u533a\u57df\u5206\u9500\u5546")
+MULTI_BRAND_DEALER = zh(r"\u591a\u54c1\u724c\u7ecf\u9500\u5546")
+COMMERCIAL_VEHICLE_CHANNEL = zh(r"\u5546\u7528\u8f66\u6e20\u9053\u5546")
+CHINA_BRAND_DEALER = zh(r"\u4e2d\u56fd\u54c1\u724c\u7ecf\u9500\u5546")
+NEV_SMALL_VEHICLE_CHANNEL = zh(r"\u65b0\u80fd\u6e90\u6216\u5c0f\u578b\u8f66\u6e20\u9053\u5546")
+
+CAR_RENTAL = zh(r"\u6c7d\u8f66\u79df\u8d41\u516c\u53f8")
+LONG_TERM_RENTAL = zh(r"\u957f\u671f\u79df\u8d41\u516c\u53f8")
+CORPORATE_FLEET = zh(r"\u4f01\u4e1a\u8f66\u961f")
+LOGISTICS_DELIVERY = zh(r"\u7269\u6d41\u914d\u9001\u4f01\u4e1a")
+GOV_PROCUREMENT = zh(r"\u653f\u5e9c\u6216\u673a\u6784\u91c7\u8d2d\u65b9")
+
+USED_CHAIN = zh(r"\u8fde\u9501\u4e8c\u624b\u8f66\u4f01\u4e1a")
+USED_PHYSICAL = zh(r"\u5b9e\u4f53\u4e8c\u624b\u8f66\u4f01\u4e1a")
+USED_IMPORTER = zh(r"\u8fdb\u53e3\u4e8c\u624b\u8f66\u4f01\u4e1a")
+NEW_USED_COMPREHENSIVE = zh(r"\u65b0\u8f66\u4e0e\u4e8c\u624b\u8f66\u7efc\u5408\u4f01\u4e1a")
+
+CAR_MARKETPLACE = zh(r"\u6c7d\u8f66\u4ea4\u6613\u5e73\u53f0")
+CAR_ASSOCIATION = zh(r"\u6c7d\u8f66\u534f\u4f1a")
+CHAMBER = zh(r"\u5546\u4f1a")
+DEALER_ALLIANCE = zh(r"\u8f66\u5546\u8054\u76df")
+DEALER_REFERRAL = zh(r"\u7ecf\u9500\u5546\u8d44\u6e90\u5f15\u8350\u673a\u6784")
+
+AFTER_SALES_NETWORK = zh(r"\u552e\u540e\u670d\u52a1\u7f51\u7edc")
+PARTS_WAREHOUSE = zh(r"\u5907\u4ef6\u4f9b\u5e94\u4e0e\u4ed3\u50a8\u4f01\u4e1a")
+IMPORT_CERTIFICATION = zh(r"\u8fdb\u53e3\u8ba4\u8bc1\u4e0e\u4e0a\u724c\u673a\u6784")
+FINANCE_INSURANCE = zh(r"\u91d1\u878d\u4fdd\u9669\u673a\u6784")
+VEHICLE_LOGISTICS = zh(r"\u8f66\u8f86\u7269\u6d41\u4f01\u4e1a")
+
+CUSTOMER_TAXONOMY = {
+    AUTO_CHANNEL: [
+        CAR_IMPORTER,
+        NATIONAL_AGENT,
+        NATIONAL_DISTRIBUTOR,
+        REGIONAL_DISTRIBUTOR,
+        MULTI_BRAND_DEALER,
+        COMMERCIAL_VEHICLE_CHANNEL,
+        CHINA_BRAND_DEALER,
+        NEV_SMALL_VEHICLE_CHANNEL,
+    ],
+    BULK_OPERATOR: [
+        CAR_RENTAL,
+        LONG_TERM_RENTAL,
+        CORPORATE_FLEET,
+        LOGISTICS_DELIVERY,
+        GOV_PROCUREMENT,
+    ],
+    USED_TRANSITION: [
+        USED_CHAIN,
+        USED_PHYSICAL,
+        USED_IMPORTER,
+        NEW_USED_COMPREHENSIVE,
+    ],
+    PLATFORM_CHANNEL: [
+        CAR_MARKETPLACE,
+        CAR_ASSOCIATION,
+        CHAMBER,
+        DEALER_ALLIANCE,
+        DEALER_REFERRAL,
+    ],
+    ECOSYSTEM_SUPPORT: [
+        AFTER_SALES_NETWORK,
+        PARTS_WAREHOUSE,
+        IMPORT_CERTIFICATION,
+        FINANCE_INSURANCE,
+        VEHICLE_LOGISTICS,
+    ],
+}
+
+VALID_ATTRIBUTES = set(CUSTOMER_TAXONOMY.keys())
+VALID_CUSTOMER_TYPES = {item for items in CUSTOMER_TAXONOMY.values() for item in items}
+TYPE_TO_ATTRIBUTE = {item: attr for attr, items in CUSTOMER_TAXONOMY.items() for item in items}
+
+KEY_NAME = zh(r"\u516c\u53f8\u59d3\u540d")
+KEY_ATTRIBUTE = zh(r"\u5ba2\u6237\u5c5e\u6027")
+KEY_TYPE = zh(r"\u5ba2\u6237\u7c7b\u578b")
+KEY_BUSINESS = zh(r"\u4e3b\u8425\u4e1a\u52a1")
+KEY_NOTE = zh(r"\u5907\u6ce8")
+
+OLD_TO_NEW_TYPE = {
+    zh(r"\u533a\u57df\u65b0\u8f66\u7ecf\u9500\u516c\u53f8"): REGIONAL_DISTRIBUTOR,
+    zh(r"\u533a\u57df\u591a\u54c1\u724c\u6388\u6743\u7ecf\u9500\u516c\u53f8"): MULTI_BRAND_DEALER,
+    zh(r"\u5168\u56fd\u591a\u54c1\u724c\u6c7d\u8f66\u5206\u9500\u96c6\u56e2"): NATIONAL_DISTRIBUTOR,
+    zh(r"\u5168\u56fd\u591a\u54c1\u724c\u8fdb\u53e3\u4e0e\u5206\u9500\u96c6\u56e2"): NATIONAL_DISTRIBUTOR,
+    zh(r"\u6c7d\u8f66\u8fdb\u53e3\u4e0e\u5206\u9500\u516c\u53f8"): CAR_IMPORTER,
+    zh(r"\u6c7d\u8f66\u8fdb\u53e3\u4e0e\u5206\u9500\u8fd0\u8425\u516c\u53f8"): CAR_IMPORTER,
+    zh(r"\u8f7b\u578b\u5546\u7528\u8f66\u8fdb\u53e3\u4e0e\u5206\u9500\u516c\u53f8"): COMMERCIAL_VEHICLE_CHANNEL,
+    zh(r"\u591a\u54c1\u724c\u6c7d\u8f66\u4e0e\u5546\u7528\u8f66\u5206\u9500\u516c\u53f8"): COMMERCIAL_VEHICLE_CHANNEL,
+    zh(r"\u6c7d\u8f66\u5bfc\u8d2d\u3001\u5185\u5bb9\u4e0e\u4ea4\u6613\u5e73\u53f0"): CAR_MARKETPLACE,
+    zh(r"\u6c7d\u8f66\u4e0e\u5546\u7528\u8f66\u4ea4\u6613\u5e73\u53f0"): CAR_MARKETPLACE,
+    zh(r"\u7efc\u5408\u6c7d\u8f66\u4ea4\u6613\u4e0e\u4e13\u4e1a\u76ee\u5f55\u5e73\u53f0"): CAR_MARKETPLACE,
+    zh(r"\u7efc\u5408\u5206\u7c7b\u4fe1\u606f\u4e0e\u6c7d\u8f66\u4ea4\u6613\u5e73\u53f0"): CAR_MARKETPLACE,
+    zh(r"\u8ba4\u8bc1\u4e8c\u624b\u8f66\u4ea4\u6613\u5e73\u53f0"): CAR_MARKETPLACE,
+    zh(r"\u96c6\u56e2\u5316\u4e8c\u624b\u8f66\u4ea4\u6613\u4e0e\u7f6e\u6362\u5e73\u53f0"): CAR_MARKETPLACE,
+    zh(r"\u6c7d\u8f66\u4ea4\u6613\u4e0e\u7ecf\u9500\u5546\u83b7\u5ba2\u5e73\u53f0"): CAR_MARKETPLACE,
+    zh(r"\u8ba4\u8bc1\u4e8c\u624b\u8f66\u4e0e\u6c7d\u8f66\u4ea4\u6613\u5e73\u53f0"): CAR_MARKETPLACE,
+    zh(r"\u6c7d\u8f66\u8fdb\u53e3\u5546\u884c\u4e1a\u534f\u4f1a"): CAR_ASSOCIATION,
+    zh(r"\u6c7d\u8f66\u5de5\u4e1a\u4e0e\u884c\u4e1a\u534f\u4f1a"): CAR_ASSOCIATION,
+    zh(r"\u65e0\u53f8\u673a\u6c7d\u8f66\u79df\u8d41\u884c\u4e1a\u8054\u5408\u4f1a"): CAR_ASSOCIATION,
+    zh(r"\u884c\u4e1aB2B\u64ae\u5408\u4e0e\u4f1a\u8bae\u5e73\u53f0"): DEALER_REFERRAL,
+    zh(r"\u6c7d\u8f66\u884c\u4e1a\u5c55\u4f1a\u4e0e\u4f01\u4e1a\u6d3b\u52a8\u5e73\u53f0"): DEALER_REFERRAL,
+    zh(r"\u4e8c\u624b\u8f66\u5546"): USED_PHYSICAL,
+    zh(r"\u4e8c\u624b\u8f66\u5546/\u4e8c\u624b\u8f66\u5e73\u53f0"): USED_PHYSICAL,
+    zh(r"\u5546\u7528\u8f66/\u8f66\u961f\u6e20\u9053"): COMMERCIAL_VEHICLE_CHANNEL,
+    zh(r"\u79df\u8d41/\u8f66\u961f\u516c\u53f8"): CAR_RENTAL,
+    zh(r"\u8fdb\u53e3\u5546/\u6c7d\u8f66\u96c6\u56e2"): CAR_IMPORTER,
+    zh(r"\u591a\u54c1\u724c\u7ecf\u9500\u5546/showroom"): MULTI_BRAND_DEALER,
+}
+
+QUESTION_MARK_RE = re.compile(r"\?{3,}")
+SPLIT_CLASS_RE = re.compile(r"[\n\r;/??,?]+")
+
+
+def clean(value: Any) -> str:
+    if value is None:
+        return ""
+    return re.sub(r"\s+", " ", str(value).strip())
+
+
+def contains_any(text: str, keywords: Sequence[str]) -> bool:
+    lower = text.casefold()
+    return any(keyword.casefold() in lower for keyword in keywords)
+
+
+def is_valid_pair(attribute: str, customer_type: str) -> bool:
+    return clean(customer_type) in CUSTOMER_TAXONOMY.get(clean(attribute), [])
+
+
+def type_to_attribute(customer_type: str) -> str:
+    return TYPE_TO_ATTRIBUTE.get(clean(customer_type), "")
+
+
+def _split_class_values(value: str) -> list[str]:
+    return [item for item in (clean(part) for part in SPLIT_CLASS_RE.split(value or "")) if item]
+
+
+def _match_old_type(text: str) -> str:
+    for old, new in OLD_TO_NEW_TYPE.items():
+        if old.casefold() in text.casefold():
+            return new
+    return ""
+
+
+def _record_text(record: Mapping[str, Any], sheet_name: str = "", sources: Sequence[str] = ()) -> str:
+    preferred = [
+        sheet_name,
+        " ".join(str(item) for item in sources),
+        str(record.get("name", "")),
+        str(record.get("attribute", "")),
+        str(record.get("type", "")),
+        str(record.get("business", "")),
+        str(record.get("note", "")),
+        str(record.get("link", "")),
+        str(record.get("website", "")),
+        str(record.get(KEY_NAME, "")),
+        str(record.get(KEY_ATTRIBUTE, "")),
+        str(record.get(KEY_TYPE, "")),
+        str(record.get(KEY_BUSINESS, "")),
+        str(record.get(KEY_NOTE, "")),
+        str(record.get("??", "")),
+    ]
+    return " ".join(item for item in preferred if item).casefold()
+
+
+def _platform_type(text: str) -> str:
+    if contains_any(text, ["chamber", "commerce chamber", zh(r"\u5546\u4f1a")]):
+        return CHAMBER
+    if contains_any(text, ["association", "federation", "f?d?ration", "federation", "syndicat", zh(r"\u534f\u4f1a")]):
+        return CAR_ASSOCIATION
+    if contains_any(text, ["alliance", "union", zh(r"\u8054\u76df")]):
+        return DEALER_ALLIANCE
+    if contains_any(text, ["lead", "referral", "b2b", "event", "exhibition", "salon", zh(r"\u5f15\u8350"), zh(r"\u8d44\u6e90"), zh(r"\u64ae\u5408"), zh(r"\u5c55\u4f1a"), zh(r"\u83b7\u5ba2")]):
+        return DEALER_REFERRAL
+    return CAR_MARKETPLACE
+
+
+def _ecosystem_type(text: str) -> str:
+    if contains_any(text, ["spare", "parts", "piece", "pi?ce", "warehouse", "storage", zh(r"\u5907\u4ef6"), zh(r"\u914d\u4ef6"), zh(r"\u4ed3\u50a8")]):
+        return PARTS_WAREHOUSE
+    if contains_any(text, ["homolog", "certification", "registration", "immatriculation", zh(r"\u8ba4\u8bc1"), zh(r"\u4e0a\u724c")]):
+        return IMPORT_CERTIFICATION
+    if contains_any(text, ["insurance", "assurance", "finance", "financement", "bank", zh(r"\u4fdd\u9669"), zh(r"\u91d1\u878d")]):
+        return FINANCE_INSURANCE
+    if contains_any(text, ["vehicle logistics", "transport automobile", "shipping", zh(r"\u8f66\u8f86\u7269\u6d41")]):
+        return VEHICLE_LOGISTICS
+    return AFTER_SALES_NETWORK
+
+
+def _bulk_type(text: str) -> str:
+    if contains_any(text, ["government", "public agency", "tender", "procurement", zh(r"\u653f\u5e9c"), zh(r"\u673a\u6784"), zh(r"\u91c7\u8d2d")]):
+        return GOV_PROCUREMENT
+    if contains_any(text, ["logistics", "delivery", "courier", "express", zh(r"\u7269\u6d41"), zh(r"\u914d\u9001")]):
+        return LOGISTICS_DELIVERY
+    if contains_any(text, ["corporate fleet", "fleet", "flotte", zh(r"\u4f01\u4e1a\u8f66\u961f"), zh(r"\u8f66\u961f")]):
+        return CORPORATE_FLEET
+    if contains_any(text, ["long-term", "long term", "lld", "leasing longue", zh(r"\u957f\u671f\u79df\u8d41")]):
+        return LONG_TERM_RENTAL
+    return CAR_RENTAL
+
+
+def _used_type(text: str) -> str:
+    if contains_any(text, ["chain", "network", "r?seau", "reseau", "branches", "succursale", "multi-city", zh(r"\u8fde\u9501"), zh(r"\u591a\u95e8\u5e97")]):
+        return USED_CHAIN
+    if contains_any(text, ["import", "importation", zh(r"\u8fdb\u53e3")]):
+        return USED_IMPORTER
+    if contains_any(text, ["new vehicle", "v?hicules neufs", "vehicules neufs", "neuf", zh(r"\u65b0\u8f66")]):
+        return NEW_USED_COMPREHENSIVE
+    return USED_PHYSICAL
+
+
+def _auto_channel_type(text: str) -> str:
+    if contains_any(text, ["commercial vehicle", "utilitaire", "camion", "truck", "van", "bus", "mpv", zh(r"\u5546\u7528\u8f66"), zh(r"\u8d27\u8f66"), zh(r"\u5361\u8f66")]):
+        return COMMERCIAL_VEHICLE_CHANNEL
+    if contains_any(text, ["byd", "jac", "dfsk", "foton", "changan", "chery", "geely", "mg", "haval", "gwm", "leapmotor", zh(r"\u4e2d\u56fd\u54c1\u724c")]):
+        return CHINA_BRAND_DEALER
+    if contains_any(text, ["ev", "electric", "hybrid", "new energy", "mini", "micro", "small car", zh(r"\u65b0\u80fd\u6e90"), zh(r"\u5c0f\u578b"), zh(r"\u5fae\u578b")]):
+        return NEV_SMALL_VEHICLE_CHANNEL
+    if contains_any(text, ["national agent", "exclusive agent", "importateur exclusif", zh(r"\u5168\u56fd\u4ee3\u7406")]):
+        return NATIONAL_AGENT
+    if contains_any(text, ["national distributor", "nationwide", "dealer network", "r?seau", "reseau", "group", "groupe", "succursale", zh(r"\u5168\u56fd\u5206\u9500"), zh(r"\u5168\u56fd"), zh(r"\u96c6\u56e2"), zh(r"\u7f51\u7edc")]):
+        return NATIONAL_DISTRIBUTOR
+    if contains_any(text, ["importateur", "importer", "importation", zh(r"\u8fdb\u53e3")]):
+        return CAR_IMPORTER
+    if contains_any(text, ["distributeur", "distribution", zh(r"\u5206\u9500")]):
+        return REGIONAL_DISTRIBUTOR
+    return MULTI_BRAND_DEALER
+
+
+def classify_from_text(text: str) -> Tuple[str, str]:
+    text = clean(text).casefold()
+    old = _match_old_type(text)
+    if old:
+        return type_to_attribute(old), old
+
+    # Strong non-dealer classes first, then dealership/channel evidence.
+    if contains_any(text, ["association", "federation", "f?d?ration", "chamber", "marketplace", "annuaire", "directory", "classified", "portal", "b2b", "exhibition", zh(r"\u5e73\u53f0"), zh(r"\u534f\u4f1a"), zh(r"\u5546\u4f1a"), zh(r"\u8054\u76df"), zh(r"\u5f15\u8350"), zh(r"\u8d44\u6e90"), zh(r"\u76ee\u5f55")]):
+        item = _platform_type(text)
+        return PLATFORM_CHANNEL, item
+
+    if contains_any(text, ["after-sales", "service network", "maintenance", "repair", "garage", "spare", "parts", "homolog", "certification", "registration", "insurance", "assurance", "finance", "vehicle logistics", zh(r"\u552e\u540e"), zh(r"\u7ef4\u4fee"), zh(r"\u5907\u4ef6"), zh(r"\u914d\u4ef6"), zh(r"\u8ba4\u8bc1"), zh(r"\u4e0a\u724c"), zh(r"\u91d1\u878d"), zh(r"\u4fdd\u9669"), zh(r"\u8f66\u8f86\u7269\u6d41")]):
+        item = _ecosystem_type(text)
+        return ECOSYSTEM_SUPPORT, item
+
+    if contains_any(text, ["rent", "rental", "location", "leasing", "fleet", "flotte", "logistics", "delivery", "procurement", zh(r"\u79df\u8d41"), zh(r"\u79df\u8f66"), zh(r"\u8f66\u961f"), zh(r"\u7269\u6d41"), zh(r"\u914d\u9001"), zh(r"\u91c7\u8d2d")]):
+        item = _bulk_type(text)
+        return BULK_OPERATOR, item
+
+    if contains_any(text, ["occasion", "used", "second hand", "second-hand", "pre-owned", "reprise", "parc occasion", "occaz", zh(r"\u4e8c\u624b"), zh(r"\u7f6e\u6362")]):
+        item = _used_type(text)
+        return USED_TRANSITION, item
+
+    if contains_any(text, ["auto", "automobile", "cars", "motors", "concessionnaire", "dealer", "showroom", "vehicle", "v?hicule", "vehicule", "neuf", "import", "distributeur", "distribution", "multimarque", "multi-brand", "brand", "marque", zh(r"\u6c7d\u8f66"), zh(r"\u65b0\u8f66"), zh(r"\u5c55\u5385"), zh(r"\u7ecf\u9500"), zh(r"\u5206\u9500"), zh(r"\u8fdb\u53e3"), zh(r"\u591a\u54c1\u724c")]):
+        item = _auto_channel_type(text)
+        return AUTO_CHANNEL, item
+
+    return "", ""
+
+
+def normalize_existing_classification(attribute: str, customer_type: str, evidence_text: str = "") -> Tuple[str, str]:
+    attr = clean(attribute)
+    typ = clean(customer_type)
+    if is_valid_pair(attr, typ) and "\n" not in attr and "\n" not in typ:
+        return attr, typ
+    for part in _split_class_values(typ):
+        old = OLD_TO_NEW_TYPE.get(part)
+        if old:
+            return type_to_attribute(old), old
+        if part in VALID_CUSTOMER_TYPES:
+            return type_to_attribute(part), part
+    if attr in VALID_ATTRIBUTES and typ in CUSTOMER_TAXONOMY.get(attr, []):
+        return attr, typ
+    return classify_from_text(" ".join([attribute or "", customer_type or "", evidence_text or ""]))
+
+
+def classify_attribute_type(record: Mapping[str, Any], sheet_name: str = "", sources: Sequence[str] = ()) -> Tuple[str, str]:
+    attr = clean(record.get("attribute") or record.get(KEY_ATTRIBUTE) or "")
+    typ = clean(record.get("type") or record.get(KEY_TYPE) or "")
+    text = _record_text(record, sheet_name, sources)
+    normalized = normalize_existing_classification(attr, typ, text)
+    if normalized != ("", ""):
+        return normalized
+    return classify_from_text(text)

+ 45 - 0
scripts/common/deduplicator.py

@@ -0,0 +1,45 @@
+"""
+去重工具模块
+"""
+from typing import List, Dict, Any
+
+
+def deduplicate_records(
+    records: List[Dict[str, Any]],
+    keys: List[str] = None
+) -> List[Dict[str, Any]]:
+    """
+    根据指定 key 去重
+    默认 key: 客户姓名/公司 + 城市 + 主页/链接
+    """
+    if keys is None:
+        keys = ["客户姓名/公司", "城市", "主页/链接"]
+
+    seen = set()
+    unique_records = []
+
+    for record in records:
+        key_values = []
+        for key in keys:
+            value = str(record.get(key, "")).strip().lower()
+            key_values.append(value)
+        signature = "|".join(key_values)
+
+        if signature and signature not in seen:
+            seen.add(signature)
+            unique_records.append(record)
+        elif not signature:
+            # 空签名也保留,避免误删
+            unique_records.append(record)
+
+    return unique_records
+
+
+def merge_records(
+    existing: List[Dict[str, Any]],
+    new: List[Dict[str, Any]],
+    keys: List[str] = None
+) -> List[Dict[str, Any]]:
+    """合并两组记录并去重"""
+    combined = existing + new
+    return deduplicate_records(combined, keys)

+ 267 - 0
scripts/common/excel_io.py

@@ -0,0 +1,267 @@
+"""
+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.
+"""
+from __future__ import annotations
+
+from copy import copy
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+import pandas as pd
+from openpyxl import load_workbook
+
+STANDARD_COLUMNS = [
+    "序号", "客户姓名/公司", "国家", "城市", "客户属性", "客户类型",
+    "主页/链接", "联系人", "职位", "电话/WhatsApp", "邮箱",
+    "主营业务", "建联状态", "下次跟进", "备注",
+]
+
+FACEBOOK_COLUMNS = [*STANDARD_COLUMNS[:6], "公司官网", *STANDARD_COLUMNS[6:]]
+
+LINKEDIN_COLUMNS = [
+    "公司名称", "国家", "城市", "客户属性", "客户类型", "linkin链接",
+    "联系人", "职位", "公司公共电话", "公司公共邮箱(任一有效即可)",
+    "个人邮箱(不一定有效)", "公司主营业务", "建联状态", "备注",
+]
+
+GOOGLE_MAPS_COLUMNS = [
+    "客户姓名/公司", "国家", "城市", "客户属性", "客户类型", "主页/链接",
+    "联系人", "职位", "电话/WhatsApp", "邮箱", "主营业务",
+    "建联状态", "下次跟进", "备注",
+]
+
+AUTO_WEBSITE_COLUMNS = [
+    "序号", "客户姓名/公司", "国家", "城市", "客户属性", "客户类型", "主页/链接",
+    "来源网站", "联系人", "职位", "电话/WhatsApp", "邮箱", "主营业务",
+    "建联状态", "下次跟进", "备注",
+]
+
+COLUMN_ALIASES = {
+    "客户姓名/公司": ["客户姓名/公司", "公司名称", "名称"],
+    "公司名称": ["公司名称", "客户姓名/公司", "名称"],
+    "主页/链接": ["主页/链接", "linkin链接", "LinkedIn链接", "链接", "网址"],
+    "公司官网": ["公司官网", "官网", "官方网站", "Website", "Company Website"],
+    "linkin链接": ["linkin链接", "LinkedIn链接", "主页/链接"],
+    "电话/WhatsApp": ["电话/WhatsApp", "公司公共电话", "电话", "WhatsApp"],
+    "公司公共电话": ["公司公共电话", "电话/WhatsApp", "电话"],
+    "邮箱": ["邮箱", "公司公共邮箱(任一有效即可)", "公司公共邮箱", "公共邮箱"],
+    "公司公共邮箱(任一有效即可)": ["公司公共邮箱(任一有效即可)", "公司公共邮箱", "公共邮箱", "邮箱"],
+    "个人邮箱(不一定有效)": ["个人邮箱(不一定有效)", "个人邮箱"],
+    "个人邮箱": ["个人邮箱", "个人邮箱(不一定有效)"],
+    "主营业务": ["主营业务", "公司主营业务"],
+    "公司主营业务": ["公司主营业务", "主营业务"],
+    "建联状态": ["建联状态", "建联情况"],
+    "建联情况": ["建联情况", "建联状态"],
+}
+
+
+def get_sheet_columns(sheet_name: str) -> List[str]:
+    name = sheet_name.strip()
+    if name == "Facebook":
+        return FACEBOOK_COLUMNS
+    if name == "LinkedIn":
+        return LINKEDIN_COLUMNS
+    if name == "Google Maps":
+        return GOOGLE_MAPS_COLUMNS
+    if name == "汽车网站精选线索":
+        return AUTO_WEBSITE_COLUMNS
+    return STANDARD_COLUMNS
+
+
+def _aliases(column: str) -> List[str]:
+    values = COLUMN_ALIASES.get(column, [column])
+    return list(dict.fromkeys([column, *values]))
+
+
+def _first_value(record: Dict[str, Any], column: str) -> Any:
+    for key in _aliases(column):
+        value = record.get(key, "")
+        if value not in (None, ""):
+            return value
+    return ""
+
+
+def _headers(ws) -> List[str]:
+    values = []
+    for cell in ws[1]:
+        value = "" if cell.value is None else str(cell.value).strip()
+        if value:
+            values.append(value)
+    return values
+
+
+def _header_index(ws) -> Dict[str, int]:
+    return {header: idx for idx, header in enumerate(_headers(ws), start=1)}
+
+
+def _find_header(headers: Dict[str, int], column: str) -> Optional[str]:
+    for alias in _aliases(column):
+        if alias in headers:
+            return alias
+    return None
+
+
+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, ""):
+            return str(value).strip()
+    return ""
+
+
+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 read_sheet(excel_path: str, sheet_name: str) -> pd.DataFrame:
+    path = Path(excel_path)
+    if not path.exists():
+        raise FileNotFoundError(f"Excel 文件不存在: {excel_path}")
+
+    df = pd.read_excel(excel_path, sheet_name=sheet_name)
+    expected_cols = get_sheet_columns(sheet_name)
+    for col in expected_cols:
+        if col in df.columns:
+            continue
+        for alias in _aliases(col):
+            if alias in df.columns:
+                df[col] = df[alias]
+                break
+        else:
+            df[col] = ""
+    return df
+
+
+def normalize_record(record: Dict[str, Any], sheet_name: str) -> Dict[str, Any]:
+    normalized: Dict[str, Any] = {}
+    for col in get_sheet_columns(sheet_name):
+        normalized[col] = _first_value(record, col)
+    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]:
+    if not records:
+        return {"appended": 0, "skipped": 0, "total": 0}
+
+    path = Path(excel_path)
+    if not path.exists():
+        raise FileNotFoundError(f"Excel 文件不存在: {excel_path}")
+
+    wb = load_workbook(path)
+    if sheet_name not in wb.sheetnames:
+        ws = wb.create_sheet(sheet_name)
+        for idx, header in enumerate(get_sheet_columns(sheet_name), start=1):
+            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("|"):
+                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:
+                skipped += 1
+                continue
+            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"):
+                continue
+            value = _first_value(normalized, header)
+            if header == "序号" 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)}
+
+
+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}")
+
+    wb = load_workbook(path)
+    if sheet_name not in wb.sheetnames:
+        raise ValueError(f"Sheet 不存在: {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()}
+        matched = True
+        for key, expected in filters.items():
+            actual = _cell_value(row_values, key)
+            if actual != str(expected):
+                matched = False
+                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
+
+
+

+ 120 - 0
scripts/common/workbook_resolver.py

@@ -0,0 +1,120 @@
+"""
+Workbook resolution helpers for the Wuling overseas dealer expansion skill.
+
+Rules:
+- An explicit --excel path always wins.
+- Otherwise, prefer an existing project workbook in the current directory or parents.
+- Preview mode never creates a workbook.
+- Write mode may copy the blank skill template into the project directory.
+- Never write directly to the skill template.
+"""
+from __future__ import annotations
+
+import shutil
+from pathlib import Path
+from typing import Any, Dict, Optional
+
+CANONICAL_WORKBOOK_NAME = "\u6469\u6d1b\u54e5\u5ba2\u6237\u5efa\u8054\u8868-\u6309\u6e20\u9053\u5206\u7c7b.xlsx"
+TEMPLATE_RELATIVE_PATH = Path("assets") / "blank_customer_outreach_workbook.xlsx"
+WORKBOOK_NAME_MARKERS = (
+    "\u6469\u6d1b\u54e5\u5ba2\u6237\u5efa\u8054\u8868-\u6309\u6e20\u9053\u5206\u7c7b",
+    "\u5ba2\u6237\u5efa\u8054\u8868",
+)
+EXCLUDE_NAME_MARKERS = (
+    "~$",
+    "_backup_",
+    "backup_before",
+    "_with_",
+    "sent_",
+    "preview",
+    "candidate",
+    "filtered",
+)
+
+
+def skill_root() -> Path:
+    return Path(__file__).resolve().parents[2]
+
+
+def template_workbook_path(root: Optional[Path] = None) -> Path:
+    return (root or skill_root()) / TEMPLATE_RELATIVE_PATH
+
+
+def _resolve_user_path(excel_path: str) -> Path:
+    path = Path(excel_path).expanduser()
+    if not path.is_absolute():
+        path = Path.cwd() / path
+    return path.resolve()
+
+
+def _excluded(path: Path) -> bool:
+    name = path.name
+    lowered = name.casefold()
+    return name.startswith("~$") or any(marker.casefold() in lowered for marker in EXCLUDE_NAME_MARKERS)
+
+
+def _looks_like_workbook(path: Path) -> bool:
+    if path.suffix.casefold() != ".xlsx" or _excluded(path):
+        return False
+    return any(marker in path.name for marker in WORKBOOK_NAME_MARKERS)
+
+
+def find_project_workbook(start_dir: Optional[Path] = None) -> Optional[Path]:
+    """Find the newest likely outreach workbook in start_dir or its parents."""
+    current = (start_dir or Path.cwd()).resolve()
+    search_dirs = [current, *current.parents]
+    for directory in search_dirs:
+        if not directory.exists() or not directory.is_dir():
+            continue
+        candidates = [path for path in directory.glob("*.xlsx") if _looks_like_workbook(path)]
+        if not candidates:
+            continue
+        exact = [path for path in candidates if path.name == CANONICAL_WORKBOOK_NAME]
+        return sorted(exact or candidates, key=lambda path: path.stat().st_mtime, reverse=True)[0]
+    return None
+
+
+def _copy_template_to(target: Path, root: Optional[Path] = None) -> Path:
+    template = template_workbook_path(root)
+    if not template.exists():
+        raise FileNotFoundError(f"Blank workbook template not found: {template}")
+    if template.resolve() == target.resolve():
+        raise ValueError("Refusing to write directly to the skill blank workbook template.")
+    target.parent.mkdir(parents=True, exist_ok=True)
+    shutil.copy2(template, target)
+    return target
+
+
+def resolve_workbook_path(
+    excel_path: str = "",
+    *,
+    start_dir: Optional[Path] = None,
+    create_from_template: bool = False,
+    template_root: Optional[Path] = None,
+) -> Dict[str, Any]:
+    """
+    Resolve the workbook path according to skill rules.
+
+    Returns a dict with: path, source, created.
+    source is one of explicit, explicit_template_copy, project, template_copy, missing.
+    """
+    if excel_path:
+        path = _resolve_user_path(excel_path)
+        if path.resolve() == template_workbook_path(template_root).resolve():
+            raise ValueError("Do not write to the skill blank workbook template. Copy it to the project first.")
+        if create_from_template and not path.exists():
+            _copy_template_to(path, template_root)
+            return {"path": path, "source": "explicit_template_copy", "created": True}
+        return {"path": path, "source": "explicit", "created": False}
+
+    project = find_project_workbook(start_dir)
+    if project:
+        return {"path": project, "source": "project", "created": False}
+
+    if create_from_template:
+        target_dir = (start_dir or Path.cwd()).resolve()
+        target = target_dir / CANONICAL_WORKBOOK_NAME
+        _copy_template_to(target, template_root)
+        return {"path": target, "source": "template_copy", "created": True}
+
+    return {"path": None, "source": "missing", "created": False}

+ 1 - 0
scripts/dashboard/__init__.py

@@ -0,0 +1 @@
+"""Dashboard generation scripts for the Wuling dealer outreach skill."""

+ 323 - 0
scripts/dashboard/build_dashboard.py

@@ -0,0 +1,323 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""Build a visual HTML dashboard from the consolidated outreach workbook."""
+from __future__ import annotations
+
+import argparse
+import json
+import re
+import shutil
+import sys
+from collections import Counter
+from datetime import datetime
+from pathlib import Path
+from typing import Any, Dict, List, Optional, Sequence, Tuple
+
+from openpyxl import load_workbook
+
+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  # type: ignore  # noqa: E402
+from workbook_resolver import resolve_workbook_path  # type: ignore  # noqa: E402
+
+SUMMARY_SHEET_ALIASES = ["客户信息汇总表", "客户信息汇总"]
+DEFAULT_TITLE = "五菱海外客户建联中台"
+
+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"],
+}
+
+CONTACTED_KEYWORDS = ["已发送邮件", "已发邮件", "邮件已发送", "邮件发送成功", "已发私信", "已关注并私信", "已建联", "等待回复", "已回复", "有意向"]
+BOUNCED_KEYWORDS = ["邮件退回", "退回", "拒收", "无法送达", "域名不存在", "邮箱不存在", "发送失败"]
+EXCLUDED_KEYWORDS = ["已剔除", "剔除", "跳过", "低优先级", "skip_"]
+REVIEW_KEYWORDS = ["待确认", "人工复核", "需人工", "需确认", "信息不足"]
+
+SOURCE_PATTERNS = [
+    ("Facebook", re.compile(r"facebook", re.I)),
+    ("LinkedIn", re.compile(r"linkedin|linkin", re.I)),
+    ("Google Maps", re.compile(r"google\s*maps|google map|maps", re.I)),
+    ("Moteur.ma", re.compile(r"moteur", re.I)),
+    ("当地汽车网站", re.compile(r"当地汽车网站|OtoMoto|Wandaloo|Kerix|Kompass|Maroc Annuaire|Telecontact", re.I)),
+    ("平台与行业渠道", re.compile(r"协会商会|平台与行业渠道|商会|协会")),
+]
+
+
+def clean(value: Any) -> str:
+    if value is None:
+        return ""
+    return re.sub(r"\s+", " ", str(value).strip())
+
+
+def find_sheet_name(wb, requested: str) -> str:
+    if requested and requested in wb.sheetnames:
+        return requested
+    for candidate in SUMMARY_SHEET_ALIASES:
+        if candidate in wb.sheetnames:
+            return candidate
+    raise ValueError(f"未找到客户信息汇总 Sheet,可用 Sheet:{', '.join(wb.sheetnames)}")
+
+
+def header_map(ws) -> Dict[str, int]:
+    raw = {clean(cell.value): idx for idx, cell in enumerate(ws[1], start=1) if clean(cell.value)}
+    mapped: Dict[str, int] = {}
+    for key, aliases in HEADER_ALIASES.items():
+        for alias in aliases:
+            if alias in raw:
+                mapped[key] = raw[alias]
+                break
+    return mapped
+
+
+def row_value(ws, row: int, headers: Dict[str, int], key: str) -> str:
+    col = headers.get(key)
+    if not col:
+        return ""
+    return clean(ws.cell(row=row, column=col).value)
+
+
+def is_effective_row(row: Dict[str, str]) -> bool:
+    evidence = ["company", "website", "facebook_link", "linkedin_link", "google_maps_link", "public_email", "personal_email", "public_phone", "contact_phone", "note"]
+    return any(row.get(key) for key in evidence)
+
+
+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)
+    actual_sheet = find_sheet_name(wb, sheet_name)
+    ws = wb[actual_sheet]
+    headers = header_map(ws)
+    rows: List[Dict[str, str]] = []
+    for row_idx in range(2, ws.max_row + 1):
+        row = {key: row_value(ws, row_idx, headers, key) for key in HEADER_ALIASES}
+        row["_excel_row"] = str(row_idx)
+        if is_effective_row(row):
+            rows.append(row)
+    return actual_sheet, rows
+
+
+def contains_any(text: str, keywords: Sequence[str]) -> bool:
+    lowered = text.casefold()
+    return any(keyword.casefold() in lowered for keyword in keywords)
+
+
+def has_email(row: Dict[str, str]) -> bool:
+    return bool(row.get("public_email") or row.get("personal_email"))
+
+
+def has_phone(row: Dict[str, str]) -> bool:
+    return bool(row.get("public_phone") or row.get("contact_phone"))
+
+
+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 is_bounced(row: Dict[str, str]) -> bool:
+    return contains_any(" ".join([row.get("status", ""), row.get("note", "")]), BOUNCED_KEYWORDS)
+
+
+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 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 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 display_contact(row: Dict[str, str]) -> str:
+    values = [row.get("public_email"), row.get("personal_email"), row.get("public_phone"), row.get("contact_phone")]
+    return next((value for value in values if value), "有主页/链接" if has_any_link(row) else "")
+
+
+def build_dashboard_data(rows: Sequence[Dict[str, str]], workbook_path: Path, sheet_name: str, run_id: str, top_n: int) -> Dict[str, Any]:
+    valid_rows = [row for row in rows if not is_excluded(row)]
+    contactable_rows = [row for row in valid_rows if is_contactable(row)]
+    contacted_rows = [row for row in valid_rows if is_contacted(row)]
+    bounced_rows = [row for row in rows if is_bounced(row)]
+    review_rows = [row for row in valid_rows if needs_review(row)]
+    email_rows = [row for row in valid_rows if has_email(row)]
+    phone_rows = [row for row in valid_rows if has_phone(row)]
+
+    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)
+    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()
+    for row in valid_rows:
+        for source in row_sources(row):
+            source_counter[source] += 1
+
+    dashboard_rows = []
+    for row in valid_rows:
+        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", ""),
+            "source": ";".join(row_sources(row)),
+            "contact": display_contact(row),
+            "status": row.get("status", ""),
+            "has_email": has_email(row),
+            "has_phone": has_phone(row),
+            "contacted": is_contacted(row),
+            "bounced": is_bounced(row),
+            "review": needs_review(row),
+            "note": row.get("note", "")[:220],
+        })
+
+    attention_rows = [row for row in dashboard_rows if row["contact"] and not row["contacted"] and not row["bounced"]][:top_n]
+    return {
+        "title": DEFAULT_TITLE,
+        "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
+        "run_id": run_id,
+        "workbook": str(workbook_path),
+        "sheet_name": sheet_name,
+        "metrics": {
+            "total_rows": len(rows),
+            "valid_customers": len(valid_rows),
+            "excluded_customers": len(rows) - len(valid_rows),
+            "contactable_customers": len(contactable_rows),
+            "contacted_customers": len(contacted_rows),
+            "outreach_rate": pct(len(contacted_rows), len(contactable_rows)),
+            "email_customers": len(email_rows),
+            "email_coverage_rate": pct(len(email_rows), len(valid_rows)),
+            "phone_customers": len(phone_rows),
+            "phone_coverage_rate": pct(len(phone_rows), len(valid_rows)),
+            "bounced_customers": len(bounced_rows),
+            "bounce_rate": pct(len(bounced_rows), len(email_rows)),
+            "needs_review_customers": len(review_rows),
+            "review_rate": pct(len(review_rows), len(valid_rows)),
+        },
+        "charts": {
+            "attribute": top_counter(attribute_counter, top_n),
+            "customer_type": top_counter(type_counter, top_n),
+            "status": top_counter(status_counter, top_n),
+            "source": top_counter(source_counter, top_n),
+            "city": top_counter(city_counter, top_n),
+        },
+        "dashboard_rows": dashboard_rows,
+        "attention_customers": attention_rows,
+        "definitions": {
+            "有效客户": "总表中未被备注或状态标记为已剔除、跳过、低优先级的客户。",
+            "可建联客户": "有效客户中至少有邮箱、电话/WhatsApp、官网、Facebook、LinkedIn 或 Google Maps 入口之一。",
+            "已建联客户": "状态或备注包含已发送邮件、已发私信、已关注并私信、等待回复、已回复、已建联或有意向,且未被标记退信/拒收。",
+            "建联率": "已建联客户数 / 可建联客户数。",
+            "邮箱覆盖率": "有个人邮箱或公共邮箱的有效客户数 / 有效客户数。",
+            "退信率": "邮件退回、拒收、无法送达或域名/邮箱不存在客户数 / 有邮箱客户数。",
+        },
+    }
+
+
+def render_html(data: Dict[str, Any]) -> str:
+    template = (SKILL_ROOT / "assets" / "dashboard_template.html").read_text(encoding="utf-8")
+    payload = json.dumps(data, ensure_ascii=False).replace("<", "\\u003c")
+    return template.replace("{{dashboard_json}}", payload)
+
+
+def write_outputs(data: Dict[str, Any], html_text: str, html_path: Path, json_path: Path, latest_dir: str = "") -> Dict[str, str]:
+    html_path.parent.mkdir(parents=True, exist_ok=True)
+    json_path.parent.mkdir(parents=True, exist_ok=True)
+    html_path.write_text(html_text, encoding="utf-8")
+    json_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
+    copied_latest = ""
+    if latest_dir:
+        target_dir = Path(latest_dir).expanduser()
+        if not target_dir.is_absolute():
+            target_dir = Path.cwd() / target_dir
+        target_dir.mkdir(parents=True, exist_ok=True)
+        latest_html = target_dir / "customer_dashboard.html"
+        latest_json = target_dir / "dashboard_data.json"
+        shutil.copy2(html_path, latest_html)
+        shutil.copy2(json_path, latest_json)
+        copied_latest = str(latest_html)
+    return {"html": str(html_path), "json": str(json_path), "latest_html": copied_latest}
+
+
+def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:
+    parser = argparse.ArgumentParser(description="Generate a Wuling outreach dashboard from 客户信息汇总表.")
+    parser.add_argument("--excel", default="", help="Workbook path. If omitted, resolve the project workbook by skill rules.")
+    parser.add_argument("--summary-sheet", default="客户信息汇总表", help="Summary sheet name.")
+    parser.add_argument("--output-html", default="", help="Optional HTML output path. Bare filename goes to runs/.")
+    parser.add_argument("--output-json", default="", help="Optional JSON output path. Bare filename goes to runs/.")
+    parser.add_argument("--run-id", default="", help="Run ID used for artifacts.")
+    parser.add_argument("--top-n", type=int, default=12, help="Top N categories and attention rows.")
+    parser.add_argument("--latest-dir", default="", help="Optional stable directory for a copy of the latest dashboard.")
+    return parser.parse_args(argv)
+
+
+def main(argv: Optional[Sequence[str]] = None) -> int:
+    args = parse_args(argv)
+    run_id = args.run_id or new_run_id("dashboard")
+    resolved = resolve_workbook_path(args.excel, create_from_template=False)
+    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.")
+    workbook_path = Path(workbook_path)
+    actual_sheet, rows = read_summary_rows(workbook_path, args.summary_sheet)
+    data = build_dashboard_data(rows, workbook_path, actual_sheet, run_id, args.top_n)
+    html_text = render_html(data)
+    html_path = resolve_artifact_path(args.output_html, kind="dashboard", default_name="customer_dashboard.html", run_id=run_id)
+    json_path = resolve_artifact_path(args.output_json, kind="dashboard", default_name="dashboard_data.json", run_id=run_id)
+    outputs = write_outputs(data, html_text, html_path, json_path, args.latest_dir)
+    report = {"workbook": str(workbook_path), "summary_sheet": actual_sheet, "run_id": run_id, "outputs": outputs, "metrics": data["metrics"]}
+    print(json.dumps(report, ensure_ascii=False, indent=2))
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 7 - 0
scripts/email_outreach/__init__.py

@@ -0,0 +1,7 @@
+"""
+邮件发送模块
+复用 dealer-email-outreach Skill 的能力
+"""
+from .prepare_and_send import prepare_emails, send_emails
+
+__all__ = ["prepare_emails", "send_emails"]

+ 94 - 0
scripts/email_outreach/prepare_and_send.py

@@ -0,0 +1,94 @@
+"""
+邮件发送入口
+调用同目录下的 dealer-email-outreach 脚本
+"""
+import subprocess
+import sys
+from pathlib import Path
+from typing import Dict, Any, Optional
+
+
+def _get_script_dir() -> Path:
+    return Path(__file__).parent
+
+
+def _get_skill_dir() -> Path:
+    return _get_script_dir().parent.parent
+
+
+def _get_asset(name: str) -> Path:
+    return _get_skill_dir() / "assets" / name
+
+
+def prepare_emails(
+    excel_path: str,
+    sheet_name: str,
+    filter_str: str,
+    subject: str,
+    template_name: str = "email_template.md",
+    output_path: str = "outreach_preview.json"
+) -> str:
+    """生成邮件预览 JSON"""
+    script = _get_script_dir() / "prepare_outreach_emails.py"
+    template = _get_asset(template_name)
+
+    if not script.exists():
+        raise FileNotFoundError(f"未找到脚本: {script}")
+    if not template.exists():
+        raise FileNotFoundError(f"未找到模板: {template}")
+
+    cmd = [
+        sys.executable,
+        str(script),
+        "--excel", excel_path,
+        "--sheet", sheet_name,
+        "--filter", filter_str,
+        "--template", str(template),
+        "--subject", subject,
+        "--output", output_path,
+    ]
+
+    result = subprocess.run(cmd, capture_output=True, text=True)
+    if result.returncode != 0:
+        raise RuntimeError(f"生成邮件预览失败: {result.stderr}")
+
+    return output_path
+
+
+def send_emails(
+    preview_path: str,
+    sender: str,
+    auth_code: str,
+    smtp_host: str = "smtp.qq.com",
+    smtp_port: int = 465
+) -> Dict[str, Any]:
+    """发送邮件"""
+    script = _get_script_dir() / "send_outreach_emails.py"
+
+    if not script.exists():
+        raise FileNotFoundError(f"未找到脚本: {script}")
+
+    cmd = [
+        sys.executable,
+        str(script),
+        "--input", preview_path,
+        "--smtp-host", smtp_host,
+        "--smtp-port", str(smtp_port),
+        "--sender", sender,
+        "--auth-code", auth_code,
+        "--confirm-send",
+    ]
+
+    result = subprocess.run(cmd, capture_output=True, text=True)
+    if result.returncode != 0:
+        raise RuntimeError(f"发送邮件失败: {result.stderr}")
+
+    return {
+        "stdout": result.stdout,
+        "stderr": result.stderr,
+    }
+
+
+if __name__ == "__main__":
+    print("邮件模块脚本路径:", _get_script_dir())
+    print("模板路径:", _get_asset("email_template.md"))

+ 348 - 0
scripts/email_outreach/prepare_outreach_emails.py

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

+ 311 - 0
scripts/email_outreach/send_outreach_emails.py

@@ -0,0 +1,311 @@
+#!/usr/bin/env python3
+import argparse
+import html
+import json
+import mimetypes
+import re
+import smtplib
+import ssl
+import uuid
+from email.message import EmailMessage
+from email.utils import formatdate, make_msgid
+from datetime import datetime
+from pathlib import Path
+
+try:
+    from common.artifact_manager import resolve_artifact_path
+except ImportError:  # pragma: no cover - supports direct CLI execution
+    import sys
+    sys.path.append(str(Path(__file__).resolve().parents[1]))
+    from common.artifact_manager import resolve_artifact_path
+
+IMAGE_PATTERN = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)")
+STRONG_PHRASES = [
+    "Wuling Overseas Business Department",
+    "SGMW / Wuling",
+    "30 million vehicles",
+    "1 million vehicles",
+    "60+ countries",
+    "200+ overseas sales and service outlets",
+    "BYD",
+    "Chery",
+    "more practical vehicle models, broader customer scenarios, and clearer visible profit margins for dealers",
+    "20-30 minute online meeting",
+    "20–30 minute online meeting",
+]
+
+
+def extract_inline_images(markdown_body, base_dirs=None):
+    images = []
+    base_dirs = [Path(item) for item in (base_dirs or []) if item]
+
+    def replace(match):
+        alt = match.group(1).strip() or "image"
+        raw_path = match.group(2).strip().strip('"')
+        path = Path(raw_path)
+        if not path.is_absolute():
+            for base_dir in base_dirs:
+                candidate = (base_dir / raw_path).resolve()
+                if candidate.exists():
+                    path = candidate
+                    break
+        cid = uuid.uuid4().hex
+        images.append({"alt": alt, "path": path, "cid": cid})
+        return f"[[INLINE_IMAGE:{len(images) - 1}]]"
+
+    return IMAGE_PATTERN.sub(replace, markdown_body), images
+
+
+def render_inline_markdown(text):
+    escaped = html.escape(text)
+    escaped = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", escaped)
+    escaped = re.sub(
+        r"\[([^\]]+)\]\((mailto:[^)]+)\)",
+        r'<a href="\2">\1</a>',
+        escaped,
+    )
+    escaped = re.sub(
+        r"\[([^\]]+)\]\((https?://[^)]+)\)",
+        r'<a href="\2">\1</a>',
+        escaped,
+    )
+    escaped = re.sub(
+        r"(Email:\s*)(?!<a)([A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,})",
+        r'\1<a href="mailto:\2">\2</a>',
+        escaped,
+    )
+    escaped = re.sub(
+        r"(Website:\s*)(?!<a)(https?://[^\s<]+)",
+        r'\1<a href="\2">\2</a>',
+        escaped,
+    )
+    return escaped
+
+
+def markdownish_to_html(body, base_dirs=None):
+    body_without_images, images = extract_inline_images(body, base_dirs=base_dirs)
+    lines = [line.rstrip() for line in body_without_images.replace("\r\n", "\n").split("\n")]
+    html_blocks = []
+    in_list = False
+
+    def close_list():
+        nonlocal in_list
+        if in_list:
+            html_blocks.append("</ul>")
+            in_list = False
+
+    for line in lines:
+        stripped = line.strip()
+        if not stripped:
+            close_list()
+            continue
+
+        if stripped.startswith("[[INLINE_IMAGE:"):
+            close_list()
+            index = int(re.search(r"\d+", stripped).group(0))
+            image = images[index]
+            alt = html.escape(image["alt"])
+            html_blocks.append(
+                f'<div class="image-wrap" style="margin:10px 0 16px 0;">'
+                f'<img src="cid:{image["cid"]}" alt="{alt}" width="300" '
+                f'style="display:block;width:300px;max-width:300px;height:auto;border:0;outline:none;text-decoration:none;">'
+                f'</div>'
+            )
+            continue
+
+        if stripped.startswith("- "):
+            if not in_list:
+                html_blocks.append('<ul style="margin:4px 0 14px 23px;padding:0;">')
+                in_list = True
+            html_blocks.append(
+                f'<li style="margin:0 0 7px 0;padding-left:3px;">{render_inline_markdown(stripped[2:].strip())}</li>'
+            )
+            continue
+
+        close_list()
+        css_class = "signature" if stripped in {"Senior Business Development Manager", "Wuling Overseas Business Department"} or stripped.startswith(("WhatsApp", "Email:", "Website:")) else ""
+        margin = "0 0 3px 0" if css_class else "0 0 10px 0"
+        if stripped.startswith("We can offer our partners"):
+            margin = "24px 0 10px 0"
+        if stripped == "Best regards,":
+            margin = "24px 0 10px 0"
+        html_blocks.append(
+            f'<p class="{css_class}" style="margin:{margin};">{render_inline_markdown(stripped)}</p>'
+        )
+
+    close_list()
+
+    styles = """
+.email-shell {
+  margin: 0;
+  padding: 0;
+  color: #1f2937;
+  font-family: Arial, Helvetica, sans-serif;
+  font-size: 12px;
+  line-height: 1.55;
+}
+.email-shell p {
+  margin: 0 0 10px 0;
+}
+.email-shell strong {
+  font-weight: 700;
+  color: #111827;
+}
+.email-shell .signature {
+  margin: 0 0 3px 0;
+}
+.email-shell .image-wrap {
+  margin: 10px 0 16px 0;
+}
+.email-shell img {
+  display: block;
+  width: 300px;
+  max-width: 300px;
+  height: auto;
+  border: 0;
+  outline: none;
+  text-decoration: none;
+}
+.email-shell a {
+  color: #2563eb;
+  text-decoration: underline;
+}
+"""
+    html_body = "\n".join(html_blocks)
+    return f"""<!doctype html>
+<html>
+  <body>
+    <style>{styles}</style>
+    <div class="email-shell" style="margin:0;padding:0;color:#1f2937;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:1.55;">
+      {html_body}
+    </div>
+  </body>
+</html>""", images
+
+
+def plain_text_without_markdown_images(body):
+    text = IMAGE_PATTERN.sub("", body)
+    text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
+    text = re.sub(r"\[([^\]]+)\]\((?:mailto:)?([^\)]+)\)", r"\1", text)
+    return text.strip() + "\n"
+
+
+def attach_inline_images(message, images):
+    html_part = message.get_payload()[1]
+    for image in images:
+        path = image["path"]
+        if not path.exists():
+            raise FileNotFoundError(f"图片文件不存在:{path}")
+        content_type, _ = mimetypes.guess_type(str(path))
+        if not content_type or not content_type.startswith("image/"):
+            content_type = "image/png"
+        maintype, subtype = content_type.split("/", 1)
+        html_part.add_related(path.read_bytes(), maintype=maintype, subtype=subtype, cid=f"<{image['cid']}>", disposition="inline")
+
+
+def build_message(sender, sender_name, to, subject, body, base_dirs=None):
+    message = EmailMessage()
+    from_value = sender
+    if sender_name:
+        from_value = f"{sender_name} <{sender}>"
+    message["From"] = from_value
+    message["To"] = to
+    message["Subject"] = subject
+    message["Date"] = formatdate(localtime=True)
+    message["Message-ID"] = make_msgid(domain="huatu.hk")
+    message["Reply-To"] = sender
+    message.set_content(plain_text_without_markdown_images(body))
+    html_body, images = markdownish_to_html(body, base_dirs=base_dirs)
+    message.add_alternative(html_body, subtype="html")
+    attach_inline_images(message, images)
+    return message
+
+
+def main():
+    parser = argparse.ArgumentParser(description="发送已预览确认的经销商建联 HTML 邮件。")
+    parser.add_argument("--input", required=True, help="prepare 脚本生成的 JSON 预览文件")
+    parser.add_argument("--smtp-host", required=True, help="SMTP 主机,例如 smtp.qq.com")
+    parser.add_argument("--smtp-port", type=int, default=465, help="SMTP SSL 端口,默认 465")
+    parser.add_argument("--sender", required=True, help="发件邮箱")
+    parser.add_argument("--auth-code", required=True, help="SMTP 授权码;只用于本次连接,不写入日志")
+    parser.add_argument("--sender-name", default="Chris Chen", help="Email From display name. Official email identity is fixed to Chris Chen.")
+    parser.add_argument("--confirm-send", action="store_true", help="必须显式提供才会发送")
+    parser.add_argument("--sent-log", default="", help="发送日志 JSONL 路径;默认写到预览文件旁边,不写入 skill 包")
+    parser.add_argument("--run-id", default="", help="Run ID; default send log is stored under runs/YYYYMMDD/<run_id>/")
+    args = parser.parse_args()
+
+    if not args.confirm_send:
+        raise SystemExit("未提供 --confirm-send,已停止发送。")
+
+    input_path = Path(args.input)
+    preview = json.loads(input_path.read_text(encoding="utf-8"))
+    emails = preview.get("emails", [])
+    if not emails:
+        raise SystemExit("预览文件中没有可发送邮件。")
+
+    template_value = preview.get("source", {}).get("template", "")
+    template_path = Path(template_value) if template_value else None
+    skill_root = Path(__file__).resolve().parents[2]
+    base_dirs = [input_path.parent, Path.cwd(), skill_root]
+    if template_path:
+        if template_path.is_absolute():
+            base_dirs.append(template_path.parent)
+        else:
+            base_dirs.append((Path.cwd() / template_path).parent)
+            base_dirs.append((skill_root / template_path).parent)
+    if args.sent_log:
+        sent_log_path = Path(args.sent_log).expanduser()
+        if not sent_log_path.is_absolute():
+            sent_log_path = Path.cwd() / sent_log_path
+    else:
+        if "runs" in input_path.parts:
+            sent_log_path = input_path.with_name("send-log.jsonl")
+        else:
+            sent_log_path = resolve_artifact_path(
+                "",
+                kind="email_send",
+                default_name="send-log.jsonl",
+                run_id=args.run_id or input_path.stem,
+            )
+
+    context = ssl.create_default_context()
+    results = []
+    with smtplib.SMTP_SSL(args.smtp_host, args.smtp_port, context=context) as server:
+        server.login(args.sender, args.auth_code)
+        for item in emails:
+            try:
+                message = build_message(
+                    sender=args.sender,
+                    sender_name=args.sender_name,
+                    to=item["to"],
+                    subject=item["subject"],
+                    body=item["body"],
+                    base_dirs=base_dirs,
+                )
+                server.send_message(message)
+                sent_result = {"to": item["to"], "dealer_name": item.get("dealer_name", ""), "status": "sent"}
+                results.append(sent_result)
+                log_entry = {
+                    "sent_at": datetime.now().isoformat(timespec="seconds"),
+                    "to": item["to"],
+                    "dealer_name": item.get("dealer_name", ""),
+                    "subject": item.get("subject", ""),
+                    "sender": args.sender,
+                }
+                sent_log_path.parent.mkdir(parents=True, exist_ok=True)
+                with sent_log_path.open("a", encoding="utf-8") as log_file:
+                    log_file.write(json.dumps(log_entry, ensure_ascii=False) + "\n")
+            except Exception as exc:
+                results.append({
+                    "to": item["to"],
+                    "dealer_name": item.get("dealer_name", ""),
+                    "status": "failed",
+                    "error": str(exc),
+                })
+
+    print(json.dumps({"sent": sum(1 for r in results if r["status"] == "sent"), "results": results}, ensure_ascii=False, indent=2))
+
+
+if __name__ == "__main__":
+    main()
+

+ 7 - 0
scripts/scraper/__init__.py

@@ -0,0 +1,7 @@
+"""
+经销商搜索采集模块
+"""
+from .ads_power_client import AdsPowerClient
+from .search_facebook import FacebookScraper
+
+__all__ = ["AdsPowerClient", "FacebookScraper"]

+ 132 - 0
scripts/scraper/ads_power_client.py

@@ -0,0 +1,132 @@
+"""
+AdsPower 指纹浏览器连接客户端
+通过 AdsPower 本地 API 连接指纹浏览器,并返回 Playwright browser 实例;默认永不关闭用户浏览器
+"""
+import time
+import requests
+from typing import Optional
+from playwright.sync_api import Browser, sync_playwright, Page
+
+
+class AdsPowerClient:
+    """AdsPower 客户端"""
+
+    def __init__(self, base_url: str = "http://127.0.0.1:50325", api_key: str = ""):
+        self.base_url = base_url.rstrip("/")
+        self.api_key = api_key
+        self.profile_id: Optional[str] = None
+        self.browser: Optional[Browser] = None
+        self.playwright = None
+        self._ws_endpoint: Optional[str] = None
+
+    def _headers(self) -> dict:
+        # AdsPower 本地 API 使用 query param 'apikey' 认证,不使用 header
+        return {}
+
+    def start_browser(self, profile_id: str, headless: bool = False) -> Browser:
+        """
+        启动 AdsPower 浏览器并返回 Playwright Browser 实例
+        """
+        self.profile_id = profile_id
+        url = f"{self.base_url}/api/v1/browser/start"
+        params = {
+            "user_id": profile_id,
+            "headless": int(headless),
+        }
+        if self.api_key:
+            params["apikey"] = self.api_key
+
+        try:
+            resp = requests.get(url, params=params, headers=self._headers(), timeout=30)
+            resp.raise_for_status()
+        except requests.RequestException as e:
+            raise ConnectionError(f"无法连接 AdsPower API: {e}") from e
+
+        data = resp.json()
+        if data.get("code") != 0:
+            raise RuntimeError(f"AdsPower 启动浏览器失败: {data}")
+
+        ws_data = data.get("data", {}).get("ws", {})
+        # AdsPower 返回的 ws endpoint,优先 puppeteer
+        ws_endpoint = ws_data.get("puppeteer") or ws_data.get("selenium")
+        if not ws_endpoint:
+            raise RuntimeError(f"AdsPower 未返回 ws endpoint: {data}")
+
+        self._ws_endpoint = ws_endpoint
+
+        # 等待浏览器启动
+        time.sleep(2)
+
+        self.playwright = sync_playwright().start()
+
+        # 连接 CDP
+        try:
+            self.browser = self.playwright.chromium.connect_over_cdp(ws_endpoint)
+        except Exception as e:
+            raise ConnectionError(f"Playwright 连接 AdsPower 浏览器失败: {e}") from e
+
+        return self.browser
+
+    def new_page(self) -> Page:
+        """新建一个页面"""
+        if not self.browser:
+            raise RuntimeError("浏览器未启动,请先调用 start_browser()")
+        context = self.browser.contexts[0] if self.browser.contexts else self.browser.new_context()
+        return context.new_page()
+
+    def get_open_page(self) -> Optional[Page]:
+        """获取 AdsPower 启动时已打开的页面"""
+        if not self.browser:
+            return None
+        if self.browser.contexts and self.browser.contexts[0].pages:
+            return self.browser.contexts[0].pages[0]
+        return None
+
+    def close_browser(self) -> bool:
+        """
+        Compatibility method: detach Playwright only, never stop or close AdsPower.
+
+        v4.1 hard rule: scripts must not close the user's logged-in fingerprint
+        browser. Keep this method name for older callers, but make it safe.
+        """
+        self.browser = None
+        if self.playwright:
+            try:
+                self.playwright.stop()
+            except Exception:
+                pass
+            self.playwright = None
+        print("AdsPower browser left open; Playwright connection stopped only.")
+        return True
+
+    def __enter__(self):
+        return self
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        self.close_browser()
+
+
+def test_connection(base_url: str = "http://127.0.0.1:50325", profile_id: str = ""):
+    """测试 AdsPower 连接"""
+    if not profile_id:
+        print("请提供 profile_id")
+        return
+
+    client = AdsPowerClient(base_url)
+    try:
+        browser = client.start_browser(profile_id)
+        page = client.get_open_page() or client.new_page()
+        page.goto("https://www.facebook.com")
+        print(f"页面标题: {page.title()}")
+        time.sleep(3)
+    finally:
+        client.close_browser()
+
+
+if __name__ == "__main__":
+    import sys
+    if len(sys.argv) >= 3:
+        test_connection(sys.argv[1], sys.argv[2])
+    else:
+        print("用法: python ads_power_client.py <base_url> <profile_id>")
+        print("示例: python ads_power_client.py http://127.0.0.1:50325 abc123")

+ 157 - 0
scripts/scraper/control_opened_browser.py

@@ -0,0 +1,157 @@
+"""
+连接已打开的 AdsPower 浏览器,不重新启动/关闭
+用于用户已手动开启浏览器并希望直接控制当前窗口的场景
+"""
+import time
+import random
+import argparse
+from pathlib import Path
+from urllib.parse import urlencode
+
+from playwright.sync_api import sync_playwright, Page
+
+try:
+    from .ads_power_client import AdsPowerClient
+except ImportError:
+    import sys
+    sys.path.insert(0, str(Path(__file__).parent))
+    from ads_power_client import AdsPowerClient
+
+
+def get_active_ws_endpoint(base_url: str, profile_id: str) -> str:
+    """通过 AdsPower API 获取已打开浏览器的 ws endpoint"""
+    import requests
+    url = f"{base_url}/api/v1/browser/active"
+    resp = requests.get(url, params={"user_id": profile_id}, timeout=10)
+    resp.raise_for_status()
+    data = resp.json()
+    if data.get("code") != 0:
+        raise RuntimeError(f"获取活动浏览器失败: {data}")
+    ws = data["data"]["ws"]["puppeteer"]
+    return ws
+
+
+def control_opened_browser(
+    profile_id: str,
+    query: str = "concessionnaire voiture Casablanca",
+    ads_power_url: str = "http://127.0.0.1:50325",
+    screenshot_path: str = "facebook_dealer_controlled.png",
+    stay_seconds: int = 60
+):
+    """连接已打开的浏览器并控制它访问摩洛哥汽车经销商主页"""
+    ws_endpoint = get_active_ws_endpoint(ads_power_url, profile_id)
+    print(f"连接到已打开的浏览器: {ws_endpoint}")
+
+    playwright = sync_playwright().start()
+    browser = playwright.chromium.connect_over_cdp(ws_endpoint)
+
+    # 获取第一个页面
+    context = browser.contexts[0] if browser.contexts else browser.new_context()
+    page = context.pages[0] if context.pages else context.new_page()
+    page.set_viewport_size({"width": 1280, "height": 800})
+
+    try:
+        # 构建 Facebook 搜索 URL
+        encoded_query = urlencode({"q": query})
+        search_url = f"https://www.facebook.com/search/pages/?{encoded_query}"
+        print(f"在当前浏览器窗口访问搜索页: {search_url}")
+        page.goto(search_url, wait_until="domcontentloaded", timeout=60000)
+        time.sleep(random.uniform(4, 6))
+
+        # 等待页面稳定后截图
+        time.sleep(random.uniform(3, 5))
+        try:
+            page.screenshot(path=f"{screenshot_path}_search.png", full_page=False, timeout=60000)
+            print(f"搜索结果已截图: {screenshot_path}_search.png")
+        except Exception as e:
+            print(f"搜索结果截图超时或失败: {e}")
+
+        # 收集页面链接
+        links = set()
+        elements = page.query_selector_all('a[href*="/"]')
+        for el in elements:
+            href = el.get_attribute("href")
+            if href and _is_valid_page_link(href):
+                links.add(_normalize_url(href))
+
+        links = list(links)
+        print(f"找到 {len(links)} 个可能的主页链接")
+
+        if not links:
+            print("未找到任何主页链接,请检查搜索词或页面是否已加载")
+            return
+
+        # 访问第一个主页
+        first_link = links[0]
+        print(f"准备进入第一个主页: {first_link}")
+        page.goto(first_link, wait_until="domcontentloaded", timeout=60000)
+        time.sleep(random.uniform(3, 5))
+
+        # 截图主页
+        time.sleep(random.uniform(2, 4))
+        try:
+            page.screenshot(path=screenshot_path, full_page=False, timeout=60000)
+            print(f"主页已截图: {screenshot_path}")
+        except Exception as e:
+            print(f"主页截图超时或失败: {e}")
+        print(f"当前页面标题: {page.title()}")
+        print(f"当前 URL: {page.url}")
+
+        # 停留,不发送任何信息,不断开 Playwright 连接,浏览器保持打开
+        print(f"浏览器将停留 {stay_seconds} 秒,不执行任何发送操作,也不断开 Playwright 连接,浏览器保持打开...")
+        print("你可以在自己的电脑屏幕上看到这个窗口")
+        time.sleep(stay_seconds)
+
+    finally:
+        # 不断开 Playwright 连接,浏览器保持打开,只停止 Playwright(保持浏览器窗口打开)
+        print("停止 Playwright,浏览器窗口保持打开")
+        playwright.stop()
+
+
+def _is_valid_page_link(href: str) -> bool:
+    """判断是否是有效的 Facebook 主页链接"""
+    from urllib.parse import urlparse
+    parsed = urlparse(href)
+    if parsed.netloc not in ["www.facebook.com", "facebook.com", "m.facebook.com"]:
+        return False
+    excluded = ["/search/", "/watch", "/groups", "/marketplace",
+                "/messages", "/notifications", "/settings",
+                "/photo.php", "/videos", "/posts", "/reel"]
+    for pattern in excluded:
+        if pattern in parsed.path:
+            return False
+    path = parsed.path.strip("/")
+    if not path or "/" in path:
+        return False
+    return True
+
+
+def _normalize_url(url: str) -> str:
+    """标准化 Facebook URL"""
+    from urllib.parse import urlparse
+    parsed = urlparse(url)
+    path = parsed.path.strip("/").split("/")[0]
+    return f"https://www.facebook.com/{path}/"
+
+
+def main():
+    parser = argparse.ArgumentParser(description="控制已打开的 AdsPower 浏览器访问 Facebook 经销商主页")
+    parser.add_argument("--profile-id", required=True, help="AdsPower profile ID")
+    parser.add_argument("--query", default="concessionnaire voiture Casablanca", help="搜索关键词")
+    parser.add_argument("--ads-power-url", default="http://127.0.0.1:50325", help="AdsPower API URL")
+    parser.add_argument("--screenshot", default="D:/WEFANBOT/facebook_dealer_controlled.png", help="截图保存路径")
+    parser.add_argument("--stay", type=int, default=60, help="停留秒数")
+
+    args = parser.parse_args()
+
+    control_opened_browser(
+        profile_id=args.profile_id,
+        query=args.query,
+        ads_power_url=args.ads_power_url,
+        screenshot_path=args.screenshot,
+        stay_seconds=args.stay
+    )
+
+
+if __name__ == "__main__":
+    main()

+ 154 - 0
scripts/scraper/control_opened_browser_v2.py

@@ -0,0 +1,154 @@
+"""
+连接已打开的 AdsPower 浏览器,在新标签页执行搜索,不重新启动/断开 Playwright 连接,浏览器保持打开
+"""
+import time
+import random
+import argparse
+from pathlib import Path
+from urllib.parse import urlencode
+
+from playwright.sync_api import sync_playwright
+
+try:
+    from .ads_power_client import AdsPowerClient
+except ImportError:
+    import sys
+    sys.path.insert(0, str(Path(__file__).parent))
+    from ads_power_client import AdsPowerClient
+
+
+def get_active_ws_endpoint(base_url: str, profile_id: str) -> str:
+    """通过 AdsPower API 获取已打开浏览器的 ws endpoint"""
+    import requests
+    url = f"{base_url}/api/v1/browser/active"
+    resp = requests.get(url, params={"user_id": profile_id}, timeout=10)
+    resp.raise_for_status()
+    data = resp.json()
+    if data.get("code") != 0:
+        raise RuntimeError(f"获取活动浏览器失败: {data}")
+    ws = data["data"]["ws"]["puppeteer"]
+    return ws
+
+
+def control_opened_browser(
+    profile_id: str,
+    query: str = "concessionnaire voiture Casablanca",
+    ads_power_url: str = "http://127.0.0.1:50325",
+    screenshot_path: str = "facebook_dealer_controlled.png",
+    stay_seconds: int = 60
+):
+    """连接已打开的浏览器,在新标签页搜索摩洛哥汽车经销商"""
+    ws_endpoint = get_active_ws_endpoint(ads_power_url, profile_id)
+    print(f"连接到已打开的浏览器: {ws_endpoint}")
+
+    playwright = sync_playwright().start()
+    browser = playwright.chromium.connect_over_cdp(ws_endpoint)
+
+    # 新建标签页,避免影响用户当前页面
+    context = browser.contexts[0] if browser.contexts else browser.new_context()
+    page = context.new_page()
+    print("已新建标签页")
+
+    try:
+        # 构建 Facebook 搜索 URL
+        encoded_query = urlencode({"q": query})
+        search_url = f"https://www.facebook.com/search/pages/?{encoded_query}"
+        print(f"在新标签页访问搜索页: {search_url}")
+        page.goto(search_url, wait_until="domcontentloaded", timeout=60000)
+        time.sleep(random.uniform(5, 8))
+
+        # 截图搜索结果
+        try:
+            page.screenshot(path=f"{screenshot_path}_search.png", full_page=False, timeout=60000)
+            print(f"搜索结果已截图: {screenshot_path}_search.png")
+        except Exception as e:
+            print(f"搜索结果截图失败: {e}")
+
+        # 收集页面链接
+        links = set()
+        elements = page.query_selector_all('a[href*="/"]')
+        for el in elements:
+            href = el.get_attribute("href")
+            if href and _is_valid_page_link(href):
+                links.add(_normalize_url(href))
+
+        links = list(links)
+        print(f"找到 {len(links)} 个可能的主页链接")
+
+        if not links:
+            print("未找到任何主页链接")
+            return
+
+        # 访问第一个主页
+        first_link = links[0]
+        print(f"准备进入第一个主页: {first_link}")
+        page.goto(first_link, wait_until="domcontentloaded", timeout=60000)
+        time.sleep(random.uniform(4, 6))
+
+        # 截图主页
+        try:
+            page.screenshot(path=screenshot_path, full_page=False, timeout=60000)
+            print(f"主页已截图: {screenshot_path}")
+        except Exception as e:
+            print(f"主页截图失败: {e}")
+
+        print(f"当前页面标题: {page.title()}")
+        print(f"当前 URL: {page.url}")
+
+        # 停留,不断开 Playwright 连接,浏览器保持打开
+        print(f"浏览器将停留 {stay_seconds} 秒,不执行任何发送操作,也不断开 Playwright 连接,浏览器保持打开...")
+        print("你可以在自己的电脑屏幕上看到这个窗口")
+        time.sleep(stay_seconds)
+
+    finally:
+        # 不断开 Playwright 连接,浏览器保持打开,只停止 Playwright(保持浏览器窗口打开)
+        print("停止 Playwright,浏览器窗口保持打开")
+        playwright.stop()
+
+
+def _is_valid_page_link(href: str) -> bool:
+    from urllib.parse import urlparse
+    parsed = urlparse(href)
+    if parsed.netloc not in ["www.facebook.com", "facebook.com", "m.facebook.com"]:
+        return False
+    excluded = ["/search/", "/watch", "/groups", "/marketplace",
+                "/messages", "/notifications", "/settings",
+                "/photo.php", "/videos", "/posts", "/reel",
+                "/profile.php"]  # 排除个人资料页
+    for pattern in excluded:
+        if pattern in parsed.path:
+            return False
+    path = parsed.path.strip("/")
+    if not path or "/" in path:
+        return False
+    return True
+
+
+def _normalize_url(url: str) -> str:
+    from urllib.parse import urlparse
+    parsed = urlparse(url)
+    path = parsed.path.strip("/").split("/")[0]
+    return f"https://www.facebook.com/{path}/"
+
+
+def main():
+    parser = argparse.ArgumentParser(description="控制已打开的 AdsPower 浏览器访问 Facebook 经销商主页")
+    parser.add_argument("--profile-id", required=True, help="AdsPower profile ID")
+    parser.add_argument("--query", default="concessionnaire voiture Casablanca", help="搜索关键词")
+    parser.add_argument("--ads-power-url", default="http://127.0.0.1:50325", help="AdsPower API URL")
+    parser.add_argument("--screenshot", default="D:/WEFANBOT/facebook_dealer_controlled.png", help="截图保存路径")
+    parser.add_argument("--stay", type=int, default=60, help="停留秒数")
+
+    args = parser.parse_args()
+
+    control_opened_browser(
+        profile_id=args.profile_id,
+        query=args.query,
+        ads_power_url=args.ads_power_url,
+        screenshot_path=args.screenshot,
+        stay_seconds=args.stay
+    )
+
+
+if __name__ == "__main__":
+    main()

+ 139 - 0
scripts/scraper/demo_visit_facebook_dealer.py

@@ -0,0 +1,139 @@
+"""
+Facebook 摩洛哥汽车经销商主页访问演示
+- 启动 AdsPower
+- 在 Facebook 搜索摩洛哥汽车经销商
+- 点击第一个结果进入主页
+- 截图并停留,不发送任何信息
+"""
+import time
+import random
+import argparse
+from pathlib import Path
+from urllib.parse import urlencode
+
+from playwright.sync_api import Page
+
+try:
+    from .ads_power_client import AdsPowerClient
+except ImportError:
+    import sys
+    sys.path.insert(0, str(Path(__file__).parent))
+    from ads_power_client import AdsPowerClient
+
+
+def search_and_visit(
+    profile_id: str,
+    query: str = "Morocco car dealer",
+    ads_power_url: str = "http://127.0.0.1:50325",
+    api_key: str = "",
+    screenshot_path: str = "facebook_dealer_visited.png",
+    stay_seconds: int = 60
+):
+    """搜索并访问第一个经销商主页"""
+    client = AdsPowerClient(ads_power_url, api_key=api_key)
+    page: Page = None
+
+    try:
+        print(f"启动 AdsPower profile: {profile_id}")
+        client.start_browser(profile_id, headless=False)
+        page = client.get_open_page() or client.new_page()
+        page.set_viewport_size({"width": 1280, "height": 800})
+
+        # 构建 Facebook 搜索 URL
+        encoded_query = urlencode({"q": query})
+        search_url = f"https://www.facebook.com/search/pages/?{encoded_query}"
+        print(f"访问搜索页: {search_url}")
+        page.goto(search_url, wait_until="domcontentloaded", timeout=60000)
+        time.sleep(random.uniform(4, 6))
+
+        # 截图搜索结果
+        page.screenshot(path=f"{screenshot_path}_search.png", full_page=False)
+        print(f"搜索结果已截图: {screenshot_path}_search.png")
+
+        # 收集页面链接
+        links = set()
+        elements = page.query_selector_all('a[href*="/"]')
+        for el in elements:
+            href = el.get_attribute("href")
+            if href and _is_valid_page_link(href):
+                links.add(_normalize_url(href))
+
+        links = list(links)
+        print(f"找到 {len(links)} 个可能的主页链接")
+
+        if not links:
+            print("未找到任何主页链接,请检查搜索词或页面是否已加载")
+            return
+
+        # 访问第一个主页
+        first_link = links[0]
+        print(f"准备进入第一个主页: {first_link}")
+        page.goto(first_link, wait_until="domcontentloaded", timeout=60000)
+        time.sleep(random.uniform(3, 5))
+
+        # 截图主页
+        page.screenshot(path=screenshot_path, full_page=False)
+        print(f"主页已截图: {screenshot_path}")
+        print(f"当前页面标题: {page.title()}")
+        print(f"当前 URL: {page.url}")
+
+        # 停留,不发送任何信息
+        print(f"浏览器将停留 {stay_seconds} 秒,不执行任何发送操作...")
+        print("请勿关闭此窗口,关闭后浏览器会自动关闭")
+        time.sleep(stay_seconds)
+
+    finally:
+        print("断开 Playwright 连接,AdsPower 浏览器保持打开")
+        client.close_browser()
+
+
+def _is_valid_page_link(href: str) -> bool:
+    """判断是否是有效的 Facebook 主页链接"""
+    from urllib.parse import urlparse
+    parsed = urlparse(href)
+    if parsed.netloc not in ["www.facebook.com", "facebook.com", "m.facebook.com"]:
+        return False
+    excluded = ["/search/", "/watch", "/groups", "/marketplace",
+                "/messages", "/notifications", "/settings",
+                "/photo.php", "/videos", "/posts", "/reel",
+                "/profile.php"]
+    for pattern in excluded:
+        if pattern in parsed.path:
+            return False
+    path = parsed.path.strip("/")
+    if not path or "/" in path:
+        return False
+    return True
+
+
+def _normalize_url(url: str) -> str:
+    """标准化 Facebook URL"""
+    from urllib.parse import urlparse
+    parsed = urlparse(url)
+    path = parsed.path.strip("/").split("/")[0]
+    return f"https://www.facebook.com/{path}/"
+
+
+def main():
+    parser = argparse.ArgumentParser(description="Facebook 摩洛哥汽车经销商主页访问演示")
+    parser.add_argument("--profile-id", required=True, help="AdsPower profile ID")
+    parser.add_argument("--query", default="Morocco car dealer", help="搜索关键词")
+    parser.add_argument("--ads-power-url", default="http://127.0.0.1:50325", help="AdsPower API URL")
+    parser.add_argument("--api-key", default="", help="AdsPower API Key(如已开启认证)")
+    parser.add_argument("--screenshot", default="facebook_dealer_visited.png", help="截图保存路径")
+    parser.add_argument("--stay", type=int, default=60, help="停留秒数")
+
+    args = parser.parse_args()
+
+    search_and_visit(
+        profile_id=args.profile_id,
+        query=args.query,
+        ads_power_url=args.ads_power_url,
+        api_key=args.api_key,
+        screenshot_path=args.screenshot,
+        stay_seconds=args.stay
+    )
+
+
+if __name__ == "__main__":
+    main()

+ 749 - 0
scripts/scraper/discovery_common.py

@@ -0,0 +1,749 @@
+"""
+Shared helpers for Morocco dealer discovery.
+
+The scrapers in this folder are preview-first lead research tools. This module
+keeps platform-specific scripts aligned on duplicate handling, OEM brand-page
+rejection, customer classification, and public contact extraction.
+"""
+import json
+import re
+from pathlib import Path
+from typing import Any, Dict, Iterable, List, Optional, Sequence, Set, Tuple
+from urllib.parse import urljoin, urlparse, urlunparse
+
+import pandas as pd
+import requests
+
+try:
+    from ..common.customer_taxonomy import classify_from_text
+except ImportError:  # pragma: no cover - direct script execution
+    import sys
+    sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+    from common.customer_taxonomy import classify_from_text
+
+
+DEFAULT_CITY_SCOPE = "摩洛哥全国"
+
+MOROCCO_TARGET_CITIES = [
+    "Casablanca",
+    "Rabat",
+    "Marrakech",
+    "Tanger",
+    "Fes",
+    "Agadir",
+    "Meknes",
+    "Oujda",
+    "Kenitra",
+    "Tetouan",
+    "Nador",
+    "Safi",
+]
+
+MOROCCO_TERMS = {
+    "maroc", "morocco", "casablanca", "rabat", "marrakech", "tanger",
+    "fes", "fès", "agadir", "meknes", "oujda", "kenitra", "tetouan",
+    "tétouan", "nador", "safi", "settat", "dar bouazza", "el jadida",
+}
+
+CHINA_BRAND_TERMS = {
+    "baic", "byd", "changan", "chery", "dfsk", "dongfeng", "foton",
+    "forland", "gac", "geely", "great wall", "haval", "jac", "jetour",
+    "maxus", "mg", "omoda", "saic", "sitrak", "sinotruk", "shacman",
+    "faw", "faw trucks", "yutong", "king long", "golden dragon", "higer",
+    "wuling",
+}
+
+NON_CHINA_BRAND_TERMS = {
+    "audi", "bmw", "chevrolet", "citroen", "citroën", "dacia", "daf",
+    "fiat", "ford", "hino", "honda", "hyundai", "isuzu", "iveco", "jeep",
+    "kia", "man", "mazda", "mercedes", "mercedes-benz", "mitsubishi",
+    "nissan", "opel", "peugeot", "renault", "scania", "seat", "skoda",
+    "suzuki", "toyota", "volkswagen", "volvo", "volvo trucks",
+}
+
+OEM_BRANCH_BRANDS = CHINA_BRAND_TERMS | NON_CHINA_BRAND_TERMS
+
+INDEPENDENT_CHANNEL_CLUES = {
+    "auto hall": "autohall",
+    "smaa": "smaa",
+    "smeia": "smeia",
+    "auto nejma": "autonejma",
+    "la continentale": "lacontinentale",
+    "prince auto": "princeauto",
+    "kifal": "kifal",
+    "autochek": "autochek",
+    "bugshan": "bugshan",
+    "bamotors": "bamotors",
+    "sopriam": "sopriam",
+    "cac": "cac",
+    "centrale automobile": "centraleautomobile",
+    "univers motors": "universmotors",
+    "m-automotiv": "mautomotiv",
+    "m automotiv": "mautomotiv",
+}
+
+INDEPENDENT_CHANNEL_DISPLAY = {
+    "auto hall": "Auto Hall",
+    "smaa": "SMAA",
+    "smeia": "SMEIA",
+    "auto nejma": "Auto Nejma",
+    "la continentale": "La Continentale Auto",
+    "prince auto": "Prince Auto",
+    "kifal": "Kifal Auto",
+    "autochek": "Autochek Morocco",
+    "bugshan": "Bugshan Automotive",
+    "bamotors": "Bamotors Maroc",
+    "sopriam": "Sopriam",
+    "centrale automobile": "Centrale Automobile Chérifienne",
+    "univers motors": "Univers Motors",
+    "m-automotiv": "M-AUTOMOTIV",
+    "m automotiv": "M-AUTOMOTIV",
+}
+
+HIGH_INTENT_TERMS = {
+    "concessionnaire", "dealer", "distributeur", "distribution", "importateur",
+    "importation", "showroom", "groupe", "group", "automobile", "auto",
+    "retail", "vente", "voiture", "vehicule", "v?hicule", "stock", "parc auto",
+    "multimarque", "multi-brand", "v?hicules neufs", "vehicules neufs", "voitures neuves",
+    "new vehicle", "new cars", "r?seau", "reseau", "dealer network",
+    "fleet", "flotte", "utilitaire", "camion",
+}
+
+NEW_VEHICLE_SALES_TERMS = {
+    "v?hicules neufs", "vehicules neufs", "voitures neuves", "new vehicle",
+    "new vehicles", "new cars", "concessionnaire", "dealer", "showroom",
+    "vente automobile", "vente de voitures", "stock", "parc auto", "retail",
+}
+
+IMPORT_DISTRIBUTION_TERMS = {
+    "importateur", "importation", "importer", "distributeur", "distribution",
+    "r?seau", "reseau", "dealer network", "national distributor", "regional distributor",
+    "agent", "groupe", "group",
+}
+
+MULTIBRAND_TERMS = {
+    "multimarque", "multi-brand", "multi brand", "multi marques", "plusieurs marques",
+    "marques multiples", "showroom multimarque",
+}
+
+CONTACT_ENTRY_TERMS = {
+    "whatsapp", "wa.me", "tel", "t?l", "telephone", "t?l?phone", "phone",
+    "email", "mail", "contact", "linkedin.com", "facebook.com", "site web", "website",
+    "+212", "@",
+}
+
+PURE_SERVICE_TERMS = {
+    "garage r?paration", "garage reparation", "r?paration", "reparation", "repair",
+    "diagnostic", "diag", "pi?ces d?tach?es", "pieces detachees", "spare parts",
+    "pieces auto", "pi?ces auto", "pneus", "tires", "tyres", "tuning", "lavage",
+    "car wash", "pare-brise", "assurance", "insurance",
+}
+
+RENTAL_FLEET_TERMS = {
+    "location", "rental", "rent car", "rentcar", "flotte", "fleet", "leasing", "lld",
+}
+
+PERSONAL_SELLER_TERMS = {
+    "particulier", "personal seller", "vendeur particulier", "marketplace seller", "profile.php",
+}
+
+DIRECT_BRANCH_TERMS = {
+    "succursale", "branch", "filiale", "subsidiary", "official dealer", "concession officielle",
+    "showroom officiel", "page officielle", "official page", "officiel", "official",
+}
+
+LOW_VALUE_TERMS = PURE_SERVICE_TERMS | {
+    "aerospace", "a?ronautique", "software", "marketing", "emailing",
+    "real estate", "immobilier", "location uniquement", "rental only",
+}
+
+CITY_PATTERNS = [
+    "Casablanca", "Rabat", "Marrakech", "Tanger", "Fes", "Fès", "Agadir",
+    "Meknes", "Oujda", "Kenitra", "Tetouan", "Tétouan", "Nador", "Safi",
+    "Settat", "Dar Bouazza", "El Jadida",
+]
+
+GENERIC_COMPANY_WORDS = {
+    "maroc", "morocco", "ma", "officiel", "official", "page", "company",
+    "automobile", "auto", "cars", "motors", "motor", "groupe", "group",
+    "sarl", "sa", "llc", "ltd",
+}
+
+OEM_BRANCH_FILLER_TERMS = {
+    "auto", "autos", "car", "cars", "motor", "motors", "truck", "trucks",
+    "vehicle", "vehicles", "vehicule", "vehicules", "official", "officiel",
+}
+
+MANUAL_REVIEW_FLAGS = {
+    "ownership": "主体归属待确认:无法判断是独立公司,还是品牌官方主体、进口商直营网点或普通分店。",
+    "new_vehicle": "新车业务待确认:无法判断主营新整车,还是二手车、维修、配件、轮胎等业务。",
+    "platform_entity": "平台与行业渠道主体待确认:无法确认是真实机构主体,还是普通个人页、内容号或非正式资源页。",
+    "platform_role": "平台与行业渠道作用待确认:无法确认其是否具备汽车行业资源、渠道引荐、媒体传播或行业活动组织作用。",
+    "phone_only": "仅电话/WhatsApp待人工确认:线索已有目标价值信号,但只有电话或 WhatsApp,缺少官网、Facebook、LinkedIn、Google Maps 等可复核资料,需人工联系确认主体和实际业务。",
+    "detail": "详细信息待确认:因登录限制、页面屏蔽、地区限制或网站无法访问,无法读取 Facebook、LinkedIn、官网等内容。",
+}
+
+
+def add_manual_review_flag(risks: List[str], key: str) -> None:
+    flag = MANUAL_REVIEW_FLAGS.get(key, "")
+    if flag and flag not in risks:
+        risks.append(flag)
+
+
+def should_request_manual_review(score: int, recommended_action: str, reasons: Sequence[str]) -> bool:
+    if recommended_action in {"skip_brand_branch", "skip_non_channel", "skip_existing"}:
+        return False
+    reason_text = " ".join(str(item) for item in reasons)
+    value_markers = (
+        '实际新整车销售',
+        'showroom',
+        '库存',
+        '进口/分销',
+        '网络能力',
+        '多品牌',
+        '已知独立',
+        '中国品牌',
+        '商用车',
+        '车队',
+        '汽车行业平台',
+        '汽车媒体',
+        '行业活动',
+        '协会',
+        '商会',
+        '车商联盟',
+        '经销商资源',
+        '平台与行业渠道',
+        '搜索词意图强',
+        '主页链接像汽车业务',
+        'stock',
+        'dealer',
+        'media',
+        'event',
+        'association',
+        'chamber',
+        'platform',
+        '可建联入口',
+    )
+    return score >= 4 or any(marker in reason_text for marker in value_markers)
+
+
+def clean_space(value: Any) -> str:
+    return re.sub(r"\s+", " ", str(value or "")).strip()
+
+
+def compact_text(value: Any) -> str:
+    return re.sub(r"[^a-z0-9]+", "", str(value or "").casefold())
+
+
+def matched_terms(text: str, terms: Iterable[str]) -> List[str]:
+    text_lower = str(text or "").casefold()
+    return sorted(term for term in terms if term.casefold() in text_lower)
+
+
+def normalize_url(value: Any) -> str:
+    link = str(value or "").strip()
+    if not link or link.casefold() == "nan":
+        return ""
+    parsed = urlparse(link)
+    if not parsed.scheme or not parsed.netloc:
+        return compact_text(link)
+    netloc = parsed.netloc.casefold()
+    if netloc.startswith("m."):
+        netloc = "www." + netloc[2:]
+    path = re.sub(r"/+", "/", parsed.path).rstrip("/")
+    normalized = urlunparse((parsed.scheme.casefold(), netloc, path, "", "", ""))
+    return normalized.rstrip("/").casefold()
+
+
+def normalize_linkedin_url(url: str) -> str:
+    parsed = urlparse(str(url or "").strip())
+    if "linkedin.com" not in parsed.netloc.casefold():
+        return ""
+    parts = [part for part in parsed.path.split("/") if part]
+    if len(parts) < 2 or parts[0] != "company":
+        return ""
+    return f"https://www.linkedin.com/company/{parts[1]}/".casefold()
+
+
+def normalize_google_maps_url(url: str) -> str:
+    parsed = urlparse(str(url or "").strip())
+    if "google." not in parsed.netloc.casefold() or "/maps/" not in parsed.path:
+        return normalize_url(url)
+    path = parsed.path.rstrip("/")
+    return urlunparse(("https", parsed.netloc.casefold(), path, "", "", "")).casefold()
+
+
+def normalized_company_key(name: Any) -> str:
+    text = clean_space(name).casefold()
+    if not text or text == "nan":
+        return ""
+    tokens = re.findall(r"[a-z0-9]+", text)
+    tokens = [token for token in tokens if token not in GENERIC_COMPANY_WORDS]
+    return "".join(tokens) or compact_text(text)
+
+
+def independent_channel_display_name(text: str) -> str:
+    combined = str(text or "").casefold()
+    for clue, display in INDEPENDENT_CHANNEL_DISPLAY.items():
+        if clue in combined:
+            return display
+    return ""
+
+
+def canonical_dealer_name(name: str, text: str = "") -> str:
+    combined = f"{name} {text}"
+    display = independent_channel_display_name(combined)
+    if not display:
+        return clean_space(name)
+    brand_terms = matched_terms(combined, OEM_BRANCH_BRANDS)
+    name_key = normalized_company_key(name)
+    display_key = normalized_company_key(display)
+    if brand_terms and name_key != display_key:
+        return display
+    return clean_space(name) or display
+
+def dealer_group_key(candidate: Dict[str, Any]) -> str:
+    name = clean_space(candidate.get("name") or candidate.get("公司名称") or candidate.get("客户姓名/公司"))
+    combined = f"{name} {candidate.get('href', '')} {candidate.get('url', '')} {candidate.get('text', '')}".casefold()
+    for clue, key in INDEPENDENT_CHANNEL_CLUES.items():
+        if clue in combined:
+            return key
+    return normalized_company_key(name)
+
+
+def load_existing_identity(
+    excel_path: str,
+    sheet_name: str,
+    link_columns: Sequence[str],
+    name_columns: Sequence[str],
+) -> Dict[str, Set[str]]:
+    path = Path(excel_path)
+    if not excel_path or not path.exists():
+        return {"links": set(), "names": set()}
+    try:
+        df = pd.read_excel(path, sheet_name=sheet_name)
+    except Exception:
+        return {"links": set(), "names": set()}
+
+    links: Set[str] = set()
+    for col in link_columns:
+        if col not in df.columns:
+            continue
+        for value in df[col].tolist():
+            normalized = normalize_url(value)
+            if "linkedin.com" in normalized:
+                normalized = normalize_linkedin_url(str(value))
+            elif "google." in normalized and "/maps/" in normalized:
+                normalized = normalize_google_maps_url(str(value))
+            if normalized:
+                links.add(normalized)
+
+    names: Set[str] = set()
+    for col in name_columns:
+        if col not in df.columns:
+            continue
+        for value in df[col].tolist():
+            key = normalized_company_key(value)
+            if key:
+                names.add(key)
+
+    return {"links": links, "names": names}
+
+
+def load_blocklist_json(blocklist_path: Optional[str], normalizer=normalize_url) -> Set[str]:
+    if not blocklist_path:
+        return set()
+    path = Path(blocklist_path)
+    if not path.exists():
+        return set()
+    try:
+        data = json.loads(path.read_text(encoding="utf-8"))
+    except Exception:
+        return set()
+    values: Iterable[Any]
+    if isinstance(data, list):
+        values = data
+    elif isinstance(data, dict) and "blocklist" in data:
+        values = data["blocklist"]
+    else:
+        return set()
+    return {normalized for normalized in (normalizer(str(value).strip()) for value in values) if normalized}
+
+
+def get_active_ws_endpoint(base_url: str, profile_id: str) -> str:
+    resp = requests.get(
+        f"{base_url.rstrip('/')}/api/v1/browser/active",
+        params={"user_id": profile_id},
+        timeout=10,
+    )
+    resp.raise_for_status()
+    data = resp.json()
+    if data.get("code") != 0:
+        raise RuntimeError(f"获取活动浏览器失败: {data}")
+    ws = (data.get("data") or {}).get("ws") or {}
+    endpoint = ws.get("puppeteer") or ws.get("selenium")
+    if not endpoint:
+        raise RuntimeError(f"未找到已打开浏览器 ws endpoint,请确认 AdsPower profile 已打开: {profile_id}")
+    return endpoint
+
+
+def looks_like_oem_local_branch(name: str, url: str = "", text: str = "") -> Tuple[bool, str]:
+    combined = f"{name} {url} {text}".casefold()
+    name_clean = clean_space(name).casefold()
+    compact_name = compact_text(name)
+    slug = urlparse(str(url or "")).path.strip("/").split("/")[-1]
+    compact_slug = compact_text(slug)
+
+    if any(clue in combined for clue in INDEPENDENT_CHANNEL_CLUES):
+        return False, ""
+
+    official_marker = re.search(r"\b(page officielle|official page|officiel|official)\b", combined)
+    country_marker = re.search(r"\b(maroc|morocco|ma)\b", combined)
+
+    for brand in OEM_BRANCH_BRANDS:
+        brand_key = compact_text(brand)
+        if not brand_key:
+            continue
+        direct_variants = {
+            f"{brand_key}maroc",
+            f"{brand_key}morocco",
+            f"{brand_key}ma",
+            f"maroc{brand_key}",
+            f"morocco{brand_key}",
+        }
+        if compact_name in direct_variants or compact_slug in direct_variants:
+            return True, brand
+        filler_keys = {compact_text(term) for term in OEM_BRANCH_FILLER_TERMS}
+        for country in ("maroc", "morocco", "ma"):
+            if compact_name.startswith(brand_key) and compact_name.endswith(country):
+                middle = compact_name[len(brand_key):-len(country)]
+                if not middle or middle in filler_keys:
+                    return True, brand
+            if compact_slug.startswith(brand_key) and compact_slug.endswith(country):
+                middle = compact_slug[len(brand_key):-len(country)]
+                if not middle or middle in filler_keys:
+                    return True, brand
+        if re.fullmatch(rf"{re.escape(brand.casefold())}\s+(maroc|morocco)", name_clean):
+            return True, brand
+        if brand.casefold() in name_clean and official_marker and country_marker:
+            return True, brand
+
+    return False, ""
+
+
+def score_dealer_candidate(
+    candidate: Dict[str, Any],
+    existing_links: Optional[Set[str]] = None,
+    existing_names: Optional[Set[str]] = None,
+    platform: str = "",
+) -> Dict[str, Any]:
+    existing_links = existing_links or set()
+    existing_names = existing_names or set()
+    name = clean_space(candidate.get("name") or candidate.get("title") or "")
+    href = clean_space(candidate.get("href") or candidate.get("url") or "")
+    text = clean_space(candidate.get("text") or "")
+    queries = candidate.get("source_queries", []) or []
+    combined = f"{name} {href} {text} {' '.join(queries)}"
+    combined_lower = combined.casefold()
+
+    normalized_link = normalize_url(href)
+    if "linkedin.com" in normalized_link:
+        normalized_link = normalize_linkedin_url(href)
+    elif "google." in normalized_link and "/maps/" in normalized_link:
+        normalized_link = normalize_google_maps_url(href)
+
+    name_key = normalized_company_key(name)
+    if normalized_link and normalized_link in existing_links:
+        return {**candidate, "score": -10, "score_reasons": ["Already exists in workbook"], "risk_flags": ["duplicate_existing_link"], "recommended_action": "skip_existing"}
+    if name_key and name_key in existing_names:
+        return {**candidate, "score": -9, "score_reasons": ["Company name already exists in workbook"], "risk_flags": ["duplicate_existing_name"], "recommended_action": "skip_existing"}
+
+    score = 0
+    reasons: List[str] = []
+    risks: List[str] = []
+    recommended_action = "preview_only"
+
+    is_oem, brand = looks_like_oem_local_branch(name, href, text)
+    brand_terms = matched_terms(combined, OEM_BRANCH_BRANDS)
+    direct_branch_terms = matched_terms(combined, DIRECT_BRANCH_TERMS)
+    if is_oem or (brand_terms and direct_branch_terms and not any(clue in combined_lower for clue in INDEPENDENT_CHANNEL_CLUES)):
+        score -= 10
+        label = brand or ", ".join(brand_terms[:2]) or "brand"
+        risks.append(f"疑似品牌当地分公司/官方主页/直营网点,排除: {label}")
+        recommended_action = "skip_brand_branch"
+
+    service_terms = matched_terms(combined, PURE_SERVICE_TERMS)
+    if service_terms:
+        score -= 6
+        risks.append("纯维修/配件/轮胎/服务类,不纳入汽车渠道合作伙伴: " + ", ".join(service_terms[:5]))
+        if recommended_action == "preview_only":
+            recommended_action = "skip_non_channel"
+
+    personal_terms = matched_terms(combined, PERSONAL_SELLER_TERMS)
+    if personal_terms:
+        score -= 5
+        risks.append("疑似个人卖家,不纳入汽车渠道合作伙伴: " + ", ".join(personal_terms[:4]))
+        if recommended_action == "preview_only":
+            recommended_action = "skip_non_channel"
+
+    sales_terms = matched_terms(combined, NEW_VEHICLE_SALES_TERMS)
+    import_terms = matched_terms(combined, IMPORT_DISTRIBUTION_TERMS)
+    multibrand_terms = matched_terms(combined, MULTIBRAND_TERMS)
+    rental_terms = matched_terms(combined, RENTAL_FLEET_TERMS)
+    contact_terms = matched_terms(combined, CONTACT_ENTRY_TERMS)
+    morocco_terms = matched_terms(combined, MOROCCO_TERMS)
+
+    has_actual_channel = bool(sales_terms or import_terms or multibrand_terms)
+    ownership_needs_review = brand_terms and recommended_action != "skip_brand_branch" and not any(clue in combined_lower for clue in INDEPENDENT_CHANNEL_CLUES) and not (import_terms or multibrand_terms)
+    if sales_terms:
+        score += 3
+        reasons.append("实际新整车销售/showroom/库存信号: " + ", ".join(sales_terms[:5]))
+    if import_terms:
+        score += 3
+        reasons.append("进口/分销/网络能力信号: " + ", ".join(import_terms[:5]))
+    if multibrand_terms:
+        score += 2
+        reasons.append("多品牌经营信号: " + ", ".join(multibrand_terms[:4]))
+    if morocco_terms:
+        score += 1
+        reasons.append("Morocco signal: " + ", ".join(morocco_terms[:4]))
+
+    if any(clue in combined_lower for clue in INDEPENDENT_CHANNEL_CLUES):
+        score += 3
+        reasons.append("已知独立汽车渠道主体")
+
+    if href and any(host in href.casefold() for host in ["facebook.com", "linkedin.com", "google."]):
+        score += 2
+        reasons.append("可建联入口: 来源主页/平台页")
+    elif contact_terms:
+        score += 2
+        reasons.append("可建联入口: " + ", ".join(contact_terms[:4]))
+
+    china_terms = matched_terms(combined, CHINA_BRAND_TERMS)
+    if china_terms and recommended_action != "skip_brand_branch":
+        score += 1
+        reasons.append("中国品牌渠道语境: " + ", ".join(china_terms[:4]))
+
+    commercial_terms = matched_terms(combined, {"utilitaire", "camion", "truck", "fleet", "flotte"})
+    if commercial_terms:
+        score += 1
+        reasons.append("商用车/车队相关信号: " + ", ".join(commercial_terms[:4]))
+
+    if rental_terms and not has_actual_channel:
+        score += 1
+        risks.append("纯租赁/车队线索,转入批量采购与运营客户/汽车租赁公司,不作为汽车渠道合作伙伴: " + ", ".join(rental_terms[:4]))
+
+    if brand_terms and sales_terms and not (import_terms or multibrand_terms) and recommended_action != "skip_brand_branch":
+        risks.append("独立单品牌新车经销商可保留;排他协议及新增品牌权限待确认")
+        score += 1
+
+    new_vehicle_needs_review = False
+    if not has_actual_channel and not rental_terms:
+        score -= 3
+        risks.append("未发现新整车销售、进口、分销或 showroom 证据")
+        new_vehicle_needs_review = True
+
+    if ownership_needs_review and should_request_manual_review(score, recommended_action, reasons):
+        add_manual_review_flag(risks, "ownership")
+    if new_vehicle_needs_review and should_request_manual_review(score, recommended_action, reasons):
+        add_manual_review_flag(risks, "new_vehicle")
+
+    canonical_name = canonical_dealer_name(name, text)
+    if canonical_name and canonical_name != name:
+        candidate = {**candidate, "canonical_name": canonical_name}
+        reasons.append(f"映射到独立经销集团: {canonical_name}")
+
+    if not reasons:
+        reasons.append(f"{platform or '搜索'} 信号较弱,低优先级;如无更多渠道价值证据应跳过")
+
+    threshold = 4 if platform.casefold() == "google maps" else 5
+    if recommended_action == "preview_only" and score >= threshold:
+        recommended_action = "deep_scrape"
+
+    return {**candidate, "score": score, "score_reasons": reasons, "risk_flags": risks, "recommended_action": recommended_action}
+
+def merge_candidates_by_url(items: List[Dict[str, Any]], normalizer=normalize_url) -> List[Dict[str, Any]]:
+    by_key: Dict[str, Dict[str, Any]] = {}
+    for item in items:
+        url = item.get("href") or item.get("url") or ""
+        key = normalizer(url) or normalized_company_key(item.get("name", ""))
+        if not key:
+            continue
+        if key not in by_key:
+            by_key[key] = item
+            continue
+        existing = by_key[key]
+        existing["source_queries"] = sorted(set(existing.get("source_queries", []) + item.get("source_queries", [])))
+        snippets = existing.setdefault("snippets", [existing.get("text", "")])
+        if item.get("text") and item["text"] not in snippets:
+            snippets.append(item["text"])
+    return list(by_key.values())
+
+
+def dedupe_dealer_groups(candidates: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
+    grouped: Dict[str, Dict[str, Any]] = {}
+    for candidate in candidates:
+        key = dealer_group_key(candidate)
+        if not key:
+            continue
+        current = grouped.get(key)
+        if not current:
+            grouped[key] = candidate
+            continue
+
+        current_score = int(current.get("score", 0))
+        candidate_score = int(candidate.get("score", 0))
+        current_name = clean_space(current.get("name", ""))
+        candidate_name = clean_space(candidate.get("name", ""))
+        prefer_candidate = (
+            candidate.get("recommended_action") != "skip_brand_branch"
+            and (
+                current.get("recommended_action") == "skip_brand_branch"
+                or candidate_score > current_score
+                or (candidate_score == current_score and len(candidate_name) < len(current_name))
+            )
+        )
+        keeper = candidate if prefer_candidate else current
+        merged = current if prefer_candidate else candidate
+        keeper["source_queries"] = sorted(set(keeper.get("source_queries", []) + merged.get("source_queries", [])))
+        keeper.setdefault("merged_duplicate_candidates", [])
+        keeper["merged_duplicate_candidates"].append({
+            "name": merged.get("name", ""),
+            "url": merged.get("href") or merged.get("url", ""),
+            "score": merged.get("score"),
+            "recommended_action": merged.get("recommended_action"),
+        })
+        risks = set(keeper.get("risk_flags", []))
+        risks.add("同一集团/重复名称候选已合并")
+        keeper["risk_flags"] = sorted(risks)
+        grouped[key] = keeper
+
+    return sorted(grouped.values(), key=lambda c: (-int(c.get("score", 0)), clean_space(c.get("name", ""))))
+
+
+def extract_urls(text: str) -> List[str]:
+    urls = re.findall(r"https?://[^\s<>()\"']+", str(text or ""))
+    cleaned: List[str] = []
+    for url in urls:
+        url = url.rstrip(".,,。;;")
+        if url not in cleaned:
+            cleaned.append(url)
+    return cleaned
+
+
+def extract_emails(text: str) -> List[str]:
+    value = str(text or "")
+    value = re.sub(r"\s*(?:\[at\]|\(at\)|\sat\s)\s*", "@", value, flags=re.I)
+    value = re.sub(r"\s*(?:\[dot\]|\(dot\)|\sdot\s)\s*", ".", value, flags=re.I)
+    emails = re.findall(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", value)
+    filtered: List[str] = []
+    for email in emails:
+        email = email.strip(".,;:()[]<>").casefold()
+        if email.endswith((".png", ".jpg", ".jpeg", ".webp", ".gif")):
+            continue
+        if email not in filtered:
+            filtered.append(email)
+    return filtered
+
+
+def extract_email(text: str) -> str:
+    emails = extract_emails(text)
+    return emails[0] if emails else ""
+
+
+def extract_phone(text: str) -> str:
+    value = str(text or "")
+    explicit = re.search(r"(?:Téléphone|Telephone|电话|Tél|Tel|WhatsApp|Phone)[::]?\s*([+()\d][+()\d\s.-]{6,}\d)", value, re.I)
+    if explicit:
+        return re.sub(r"\s+", " ", explicit.group(1)).strip()
+    match = re.search(r"(?:\+212|0)\s?\d[\d\s.-]{6,}\d", value)
+    return re.sub(r"\s+", " ", match.group(0)).strip() if match else ""
+
+
+def extract_city(text: str, fallback: str = DEFAULT_CITY_SCOPE) -> str:
+    hits = []
+    for city in CITY_PATTERNS:
+        if re.search(rf"\b{re.escape(city)}\b", str(text or ""), re.I) and city not in hits:
+            hits.append(city)
+    return " / ".join(hits[:4]) if hits else fallback
+
+
+def classify_customer_type(text: str) -> str:
+    _attribute, customer_type = classify_from_text(str(text or ""))
+    return customer_type
+
+
+def summarize_business(text: str) -> str:
+    lower = str(text or "").casefold()
+    parts: List[str] = []
+    if any(term in lower for term in ["occasion", "reprise", "voiture d'occasion"]):
+        parts.append("二手车买卖/置换")
+    if any(term in lower for term in ["concessionnaire", "showroom", "vente automobile", "汽车零售"]):
+        parts.append("汽车销售/showroom")
+    if any(term in lower for term in ["importateur", "distribution", "distributeur"]):
+        parts.append("进口/分销")
+    if any(term in lower for term in ["utilitaire", "camion", "truck", "fleet", "flotte"]):
+        parts.append("商用车/车队")
+    china = matched_terms(text, CHINA_BRAND_TERMS)
+    non_china = matched_terms(text, NON_CHINA_BRAND_TERMS)
+    if china:
+        parts.append("涉及中国品牌: " + ", ".join(china[:4]))
+    elif non_china:
+        parts.append("主要品牌信号: " + ", ".join(non_china[:5]))
+    return ";".join(parts) if parts else "汽车渠道线索,主营业务证据不足,需深搜确认"
+
+
+def parse_keywords(value: str) -> Optional[List[str]]:
+    if not value.strip():
+        return None
+    return [item.strip() for item in value.split(",") if item.strip()]
+
+
+def is_external_business_url(url: str) -> bool:
+    parsed = urlparse(str(url or ""))
+    if parsed.scheme not in {"http", "https"} or not parsed.netloc:
+        return False
+    host = parsed.netloc.casefold()
+    blocked = [
+        "google.", "gstatic.", "facebook.com", "instagram.com", "linkedin.com",
+        "tiktok.com", "youtube.com", "youtu.be", "twitter.com", "x.com",
+        "wa.me", "whatsapp.com", "maps.apple.com",
+    ]
+    return not any(term in host for term in blocked)
+
+
+def looks_like_oem_brand_country_url(url: str) -> Tuple[bool, str]:
+    parsed = urlparse(str(url or ""))
+    compact = compact_text(" ".join([parsed.netloc, parsed.path]))
+    if not compact:
+        return False, ""
+    for brand in OEM_BRANCH_BRANDS:
+        brand_key = compact_text(brand)
+        if not brand_key:
+            continue
+        variants = {
+            f"{brand_key}maroc",
+            f"{brand_key}morocco",
+            f"{brand_key}ma",
+            f"maroc{brand_key}",
+            f"morocco{brand_key}",
+        }
+        if any(variant in compact for variant in variants):
+            return True, brand
+    return False, ""
+
+def normalize_website_url(url: str, base_url: str = "") -> str:
+    url = clean_space(url)
+    if not url:
+        return ""
+    if base_url:
+        url = urljoin(base_url, url)
+    parsed = urlparse(url)
+    if not parsed.scheme and parsed.netloc:
+        url = "https:" + url
+    elif not parsed.scheme:
+        url = "https://" + url
+    return url
+
+
+

+ 726 - 0
scripts/scraper/scrape_single_page.py

@@ -0,0 +1,726 @@
+"""
+单个 Facebook 主页完整采集
+- 连接已打开的 AdsPower 浏览器
+- 访问指定主页 URL
+- 采集完整信息
+- 回写到 Excel
+"""
+import re
+import sys
+import time
+import random
+import argparse
+from pathlib import Path
+
+import sys
+sys.path.append(str(Path(__file__).resolve().parents[1]))
+from common.artifact_manager import resolve_artifact_path, create_backup_once
+from typing import Dict, Any
+
+# Windows 控制台输出中文/阿拉伯文时避免 GBK 编码错误
+if hasattr(sys.stdout, "reconfigure"):
+    sys.stdout.reconfigure(encoding="utf-8")
+if hasattr(sys.stderr, "reconfigure"):
+    sys.stderr.reconfigure(encoding="utf-8")
+
+from playwright.sync_api import sync_playwright
+
+try:
+    from deep_translator import GoogleTranslator
+    TRANSLATOR_AVAILABLE = True
+except ImportError:
+    TRANSLATOR_AVAILABLE = False
+    GoogleTranslator = None
+
+try:
+    from ..common import append_records, enrich_record_with_brands, resolve_workbook_path
+    from . import discovery_common as dc
+    from .website_deep_scraper import (
+        build_chinese_notes,
+        classify_customer_type_cn,
+        extract_company_website_from_facebook,
+        scrape_public_website,
+        summarize_business_cn,
+    )
+except ImportError:
+    import sys
+    sys.path.insert(0, str(Path(__file__).parent.parent))
+    from common import append_records, enrich_record_with_brands, resolve_workbook_path
+    from scraper import discovery_common as dc
+    from scraper.website_deep_scraper import (
+        build_chinese_notes,
+        classify_customer_type_cn,
+        extract_company_website_from_facebook,
+        scrape_public_website,
+        summarize_business_cn,
+    )
+
+
+def get_active_ws_endpoint(base_url: str, profile_id: str) -> str:
+    """通过 AdsPower API 获取已打开浏览器的 ws endpoint"""
+    import requests
+    url = f"{base_url}/api/v1/browser/active"
+    resp = requests.get(url, params={"user_id": profile_id}, timeout=10)
+    resp.raise_for_status()
+    data = resp.json()
+    if data.get("code") != 0:
+        raise RuntimeError(f"获取活动浏览器失败: {data}")
+    ws = data["data"]["ws"]["puppeteer"]
+    return ws
+
+
+def is_mostly_chinese(text: str) -> bool:
+    """判断文本是否主要为中文"""
+    if not text:
+        return False
+    chinese_chars = sum(1 for c in text if "\u4e00" <= c <= "\u9fff")
+    return chinese_chars / max(len(text), 1) > 0.3
+
+
+def translate_to_chinese(text: str) -> str:
+    """将文本翻译为中文;失败或已是中文时返回原文"""
+    if not text or len(text.strip()) < 3:
+        return text
+    if is_mostly_chinese(text):
+        return text
+    if not TRANSLATOR_AVAILABLE:
+        return text
+    try:
+        translated = GoogleTranslator(source="auto", target="zh-CN").translate(text)
+        # 简单限流,避免 Google 免费接口触发频率限制
+        time.sleep(0.3)
+        return translated or text
+    except Exception as exc:
+        print(f"翻译失败,保留原文: {exc}")
+        return text
+
+
+def extract_phone(text: str) -> str:
+    """Extract a public phone/WhatsApp number from Facebook text across countries."""
+    if not text:
+        return ""
+    value = str(text or "")
+    explicit = re.search(
+        r"(?:Téléphone|Telephone|电话|Tél|Tel|WhatsApp|Phone|Fone|FONE|Commercial|Comercial)[::]?\s*([+()\d][+()\d\s.-]{6,}\d)",
+        value,
+        re.I,
+    )
+    if explicit:
+        return re.sub(r"\s+", " ", explicit.group(1)).strip(" .,-")
+
+    patterns = [
+        r'\+\d{1,3}[\s\-.]?(?:\(?\d{1,4}\)?[\s\-.]?){2,6}\d',
+        r'\+212[\s\-]?\d[\s\-]?\d{3}[\s\-]?\d{2}[\s\-]?\d{2}[\s\-]?\d{2}',
+        r'\+212[\s\-]?\d{3}[\s\-]?\d{2}[\s\-]?\d{2}[\s\-]?\d{2}',
+        r'0\d[\s\-]?\d{4}[\s\-]?\d{4}',
+        r'0\d{3}[\s\-]?\d{2}[\s\-]?\d{2}[\s\-]?\d{2}',
+    ]
+    for pattern in patterns:
+        match = re.search(pattern, value)
+        if match:
+            return re.sub(r"\s+", " ", match.group(0)).strip(" .,-")
+    return ""
+
+def extract_email(text: str) -> str:
+    """从文本中提取邮箱"""
+    if not text:
+        return ""
+    pattern = r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}'
+    matches = re.findall(pattern, text)
+    excluded = ["@facebook.com", "@fb.com", "@example.com"]
+    for m in matches:
+        if all(e not in m.lower() for e in excluded):
+            return m.strip()
+    return ""
+
+
+def extract_followers(text: str) -> str:
+    """提取粉丝数,支持英文、法文、中文(含万/千)"""
+    if not text:
+        return ""
+
+    # 优先匹配中文“X 万位粉丝 / X 万粉丝”格式
+    cn_match = re.search(r'(\d+[\d\s,.]*)\s*([万千]?)\s*(位粉丝|粉丝)', text, re.IGNORECASE)
+    if cn_match:
+        num = cn_match.group(1).strip()
+        unit = cn_match.group(2).strip()
+        suffix = cn_match.group(3).strip()
+        # 合并单位与后缀,如“万位粉丝”“万粉丝”
+        if unit:
+            return f"{num}{unit}{suffix}"
+        return f"{num} {suffix}"
+
+    # 英文/法文格式:1.2k fans / 3M followers
+    en_match = re.search(r'(\d+[\d\s,.]*[KkMm]?)\s*(followers|fans|abonnés|likes)', text, re.IGNORECASE)
+    if en_match:
+        return f"{en_match.group(1).strip()} {en_match.group(2).strip()}"
+
+    return ""
+
+
+def _looks_like_time(text: str) -> bool:
+    """判断文本是否像时间戳"""
+    text_lower = text.lower()
+    time_units = ["h", "hr", "hrs", "hour", "hours", "d", "day", "days", "w", "week",
+                  "m", "min", "mins", "month", "months", "y", "year", "years",
+                  "昨天", "今天", "刚刚", "分钟", "小时", "天", "周", "月", "年"]
+    has_unit = any(unit in text_lower for unit in time_units)
+    has_number = bool(re.search(r'\d', text))
+    has_date_separator = bool(re.search(r'\d{1,4}[/-]\d{1,2}[/-]\d{1,4}', text))
+    short_relative = len(text) <= 8 and has_number
+    date_like = has_number and (has_unit or has_date_separator or len(text) < 25)
+    return (has_unit and has_number) or short_relative or date_like
+
+
+def _extract_post_time(element) -> str:
+    """尝试从帖子元素中提取发布时间"""
+    time_text = ""
+    try:
+        time_selectors = [
+            'a[href*="posts"] span',
+            'a[href*="posts"]',
+            'span[dir="auto"] a[role="link"] span',
+            'abbr',
+            'span[aria-label]',
+            'a[role="link"] span',
+        ]
+        for sel in time_selectors:
+            time_el = element.query_selector(sel)
+            if time_el:
+                txt = (time_el.get_attribute("aria-label") or time_el.inner_text() or "").strip()
+                if txt and _looks_like_time(txt):
+                    time_text = txt
+                    break
+    except Exception:
+        pass
+    return time_text
+
+
+def scrape_posts(page, max_posts: int = 8) -> list:
+    """
+    滚动主页并提取最近帖子文本及发布时间
+    返回: [{"text": str, "time": str}, ...]
+    """
+    posts = []
+    seen_texts = set()
+    start_time = time.time()
+
+    # 回到主页
+    current_url = page.url
+    if "/about" in current_url:
+        home_url = current_url.split("/about")[0] + "/"
+        try:
+            page.goto(home_url, wait_until="domcontentloaded", timeout=30000)
+        except Exception:
+            pass
+        time.sleep(random.uniform(2, 4))
+
+    # 滚动几次,加载帖子
+    for i in range(3):
+        # 尝试多种帖子容器选择器
+        selectors = [
+            'div[role="article"]',
+            'div[data-ad-preview="message"]',
+            '[data-pagelet="ProfileTimeline"] div[role="article"]',
+        ]
+
+        for sel in selectors:
+            elements = page.query_selector_all(sel)
+            for el in elements:
+                try:
+                    # 获取帖子文本:优先从 dir="auto" 的 div 中取
+                    text_els = el.query_selector_all('div[dir="auto"]')
+                    text_parts = []
+                    for tel in text_els:
+                        t = tel.inner_text().strip()
+                        if t and len(t) > 5:
+                            text_parts.append(t)
+                    text = " ".join(text_parts).strip()
+
+                    # 过滤:长度适中、不是导航文字、未重复
+                    if 20 < len(text) < 800 and text not in seen_texts:
+                        excluded = ["个人资料", "帖子", "简介", "提及", "好友", "照片", "视频", "Reels", "签到", "展开", "查看更多", "评论", "分享", "点赞"]
+                        if not any(e in text for e in excluded):
+                            post_time = _extract_post_time(el)
+                            posts.append({"text": text, "time": post_time})
+                            seen_texts.add(text)
+                except Exception:
+                    continue
+
+        if len(posts) >= max_posts:
+            break
+
+        # 单页面帖子采集总时间限制 45 秒
+        if time.time() - start_time > 45:
+            print("帖子采集时间超过 45 秒,提前结束")
+            break
+
+        # 滚动
+        page.evaluate("window.scrollTo(0, window.scrollY + 800)")
+        time.sleep(random.uniform(2, 4))
+
+    return posts[:max_posts]
+
+
+def _assess_activity_level(latest_time: str) -> str:
+    """根据最近发帖时间评估活跃度"""
+    if not latest_time or latest_time == "未识别":
+        return "活跃度:未识别"
+    text = latest_time.lower()
+    # 近期:小时、天、本周
+    if any(u in text for u in ["h", "hr", "hour", "hours", "小时", "d", "day", "days", "天", "w", "week", "周"]):
+        return "活跃度:高"
+    # 较近期:月且 <=3
+    month_match = re.search(r"(\d+)\s*(month|months|mo|月)", text)
+    if month_match:
+        months = int(month_match.group(1))
+        if months <= 3:
+            return "活跃度:中"
+    # 其他具体日期或更久远
+    return "活跃度:低或陈旧"
+
+
+def analyze_posts(posts: list, about_text: str = "") -> str:
+    """
+    综合分析最近帖子内容,输出自然语言备注。
+    包含:主营业务判断、近期动态、关注方向、活跃度评估、最近发帖时间、最新帖子摘要。
+    posts: [{"text": str, "time": str}, ...]
+    """
+    if not posts:
+        return ""
+
+    texts = [p["text"] for p in posts]
+    all_text = " ".join(texts).lower()
+
+    # 1. 主营业务判断
+    business_signals = {
+        "汽车诊断/维修": ["diagnostic", "diag", "défaut", "moteur", "voyant", "réparation", "panne", "ordinateur de bord", "valise", "scanner"],
+        "二手车交易": ["occasion", "vente", "achat", "vendre", "à vendre", "prix", "km", "kilométrage"],
+        "汽车销售/经销商": ["concessionnaire", "neuf", "showroom", "livraison", "commande", "véhicule neuf"],
+        "租车服务": ["location", "louer", "rental", "à louer", "jour", "mois"],
+        "汽车美容/改装": ["tuning", "jantes", "peinture", "covering", "cleaning", "detailing"],
+    }
+
+    business_scores = {}
+    for biz, keywords in business_signals.items():
+        score = sum(all_text.count(kw) for kw in keywords)
+        if score > 0:
+            business_scores[biz] = score
+
+    main_business = ""
+    if business_scores:
+        main_business = max(business_scores, key=business_scores.get)
+
+    # 2. 近期动态
+    activity_signals = {
+        "促销活动": ["promo", "promotion", "offre", "réduction", "discount", "prix spécial", "vente flash"],
+        "服务展示": ["service", "intervention", "réparation", "diagnostic", "résultat", "avant/après"],
+        "客户案例": ["client", "témoignage", "satisfait", "merci", "avis"],
+        "日常内容": ["bonjour", "bonne journée", "week-end", "maroc", "casablanca"],
+    }
+
+    activity_scores = {}
+    for act, keywords in activity_signals.items():
+        score = sum(all_text.count(kw) for kw in keywords)
+        if score > 0:
+            activity_scores[act] = score
+
+    recent_activities = sorted(activity_scores, key=activity_scores.get, reverse=True)[:2]
+
+    # 3. 关注方向 / 高频词(翻译为中文)
+    focus_map = {
+        "diagnostic": "诊断",
+        "voiture": "汽车",
+        "auto": "汽车",
+        "moteur": "发动机",
+        "réparation": "维修",
+        "occasion": "二手车",
+        "prix": "价格",
+        "casablanca": "卡萨布兰卡",
+        "maroc": "摩洛哥",
+        "service": "服务",
+        "client": "客户",
+        "promo": "促销",
+    }
+    focus_counts = {}
+    for kw, cn in focus_map.items():
+        count = all_text.count(kw)
+        if count > 0:
+            focus_counts[cn] = focus_counts.get(cn, 0) + count
+    top_focus = sorted(focus_counts, key=focus_counts.get, reverse=True)[:5]
+
+    # 4. 最近帖子时间 + 摘要
+    latest_post = posts[0]
+    latest_time = latest_post.get("time", "").strip()
+    latest_text = latest_post["text"]
+    latest_summary = latest_text[:120] + "..." if len(latest_text) > 120 else latest_text
+
+    activity_level = _assess_activity_level(latest_time)
+
+    # 组装备注
+    parts = []
+    if main_business:
+        parts.append(f"主营业务判断:{main_business}")
+    if recent_activities:
+        parts.append(f"近期动态:{', '.join(recent_activities)}")
+    if top_focus:
+        parts.append(f"关注方向:{', '.join(top_focus)}")
+    parts.append(f"{activity_level}(最近发帖时间:{latest_time or '未识别'})")
+    parts.append(f"最新帖子摘要:{latest_summary}")
+
+    return " | ".join(parts)
+
+
+def infer_dealer_type(text: str) -> str:
+    """推断客户类型"""
+    if not text:
+        return "待判断"
+    text_lower = text.lower()
+
+    # 个人资料页信号
+    personal_signals = [
+        "personal information", "informations personnelles", "个人信息",
+        "date of birth", "date de naissance", "生日",
+        "works at", "travail chez", "études à", "studied at",
+        "lives in", "habite à", "vit à", "relationship", "relation",
+        "family members", "membres de la famille",
+    ]
+    if any(k in text_lower for k in personal_signals):
+        return "个人用户"
+
+    if any(k in text_lower for k in ["diagnostic", "diag", "diagnostique", "诊断"]):
+        return "汽车诊断/维修"
+    if any(k in text_lower for k in ["used car", "occasion", "二手车", "occaz"]):
+        return "二手车商"
+    if any(k in text_lower for k in ["dealer", "concessionnaire", "distributeur", "经销商"]):
+        return "经销商"
+    if any(k in text_lower for k in ["rental", "location", "租车"]):
+        return "租车公司"
+    if any(k in text_lower for k in ["repair", "garage", "维修"]):
+        return "汽车维修"
+    return "待判断"
+
+
+def scrape_page_record(
+    page,
+    page_url: str,
+    city: str = "Casablanca",
+    country: str = "摩洛哥",
+    name: str = "",
+    main_business: str = "",
+) -> Dict[str, Any]:
+    """使用已存在的 Playwright page 采集单个 Facebook 主页信息"""
+    start_time = time.time()
+    record = {
+        "客户姓名/公司": "",
+        "国家": country,
+        "城市": city,
+        "客户类型": "",
+        "主页/链接": page_url,
+        "公司官网": "",
+        "联系人": "",
+        "职位": "",
+        "电话/WhatsApp": "",
+        "邮箱": "",
+        "主营业务": "",
+        "建联状态": "未联系",
+        "下次跟进": "",
+        "备注": "",
+    }
+
+    try:
+        # 访问主页
+        page.goto(page_url, wait_until="domcontentloaded", timeout=30000)
+        time.sleep(random.uniform(3, 5))
+
+        # 先提取主页上的粉丝数
+        home_page_text = ""
+        try:
+            home_page_text = page.locator("body").inner_text(timeout=10000)
+        except Exception:
+            pass
+        followers_from_home = extract_followers(home_page_text)
+        website_from_home = extract_company_website_from_facebook(page)
+        if website_from_home:
+            record["公司官网"] = website_from_home
+            print(f"从主页联络资料提取到公司官网: {website_from_home}")
+        home_phone = extract_phone(home_page_text)
+        if home_phone:
+            record["电话/WhatsApp"] = home_phone
+            print(f"从主页联络资料提取到电话: {home_phone}")
+        home_email = extract_email(home_page_text)
+        if home_email:
+            record["邮箱"] = home_email
+            print(f"从主页联络资料提取到邮箱: {home_email}")
+        if followers_from_home:
+            print(f"从主页联络资料提取到粉丝数: {followers_from_home}")
+
+        # 如果用户提供了名称,直接使用
+        if name:
+            record["客户姓名/公司"] = name
+        else:
+            # 尝试多种方式获取页面名称
+            record["客户姓名/公司"] = ""
+            name_selectors = [
+                '[role="main"] h1',
+                '[data-pagelet="ProfileActions"] h1',
+                'h1',
+            ]
+            for sel in name_selectors:
+                try:
+                    el = page.query_selector(sel)
+                    if el:
+                        text = el.inner_text().strip()
+                        if text and len(text) < 100 and "Facebook" not in text and text not in ["个人资料", "帖子", "简介", "提及"]:
+                            record["客户姓名/公司"] = text
+                            break
+                except Exception:
+                    continue
+
+            # 如果 h1 都没拿到,从 title 或 URL 推断
+            if not record["客户姓名/公司"]:
+                title = page.title()
+                name_from_title = title.split("|")[0].strip() if "|" in title else title.replace("Facebook", "").strip()
+                if name_from_title and name_from_title not in ["个人资料", "帖子", "简介", "提及", "通知"] and len(name_from_title) < 100:
+                    record["客户姓名/公司"] = name_from_title
+                else:
+                    from urllib.parse import urlparse
+                    path = urlparse(page_url).path.strip("/").split("/")[0]
+                    record["客户姓名/公司"] = path or "Unknown"
+
+        # 主页联络资料读取完成后,再获取 About 信息
+        about_url = f"{page_url.rstrip('/')}/about/"
+        print(f"访问 About 页: {about_url}")
+        page.goto(about_url, wait_until="domcontentloaded", timeout=30000)
+        time.sleep(random.uniform(2, 4))
+
+        page_text = ""
+        try:
+            page_text = page.locator("body").inner_text(timeout=15000)
+        except Exception:
+            pass
+
+        about_website = extract_company_website_from_facebook(page)
+        if about_website:
+            if not record.get("公司官网"):
+                record["公司官网"] = about_website
+            elif about_website not in record["公司官网"]:
+                record["公司官网"] = record["公司官网"] + ";" + about_website
+        company_website = record.get("公司官网", "").split(";")[0].strip()
+
+        website_result = {"email": "", "phone": "", "text": "", "business_summary": "", "evidence_notes": []}
+        if company_website:
+            try:
+                print(f"深搜公司官网: {company_website}")
+                website_result = scrape_public_website(page.context, company_website, max_pages=5)
+            except Exception as exc:
+                website_result = {"email": "", "phone": "", "text": "", "business_summary": "", "evidence_notes": [f"官网深搜失败:{str(exc)[:80]}"]}
+
+        # 提取电话
+        phone = extract_phone(page_text)
+        if phone and not record.get("电话/WhatsApp"):
+            record["电话/WhatsApp"] = phone
+
+        # 提取邮箱,About 只补充空字段
+        email = extract_email(page_text)
+        if email and not record.get("邮箱"):
+            record["邮箱"] = email
+
+        if not record.get("邮箱") and website_result.get("email"):
+            record["邮箱"] = website_result.get("email", "")
+        if not record.get("电话/WhatsApp") and website_result.get("phone"):
+            record["电话/WhatsApp"] = website_result.get("phone", "")
+
+        # 如果用户提供了主营业务,直接使用
+        if main_business:
+            record["主营业务"] = main_business
+        else:
+            # 提取主营业务/描述
+            description = ""
+            lines = [line.strip() for line in page_text.split("\n") if line.strip()]
+
+            # 优先找 "个人资料" 下方的描述句
+            profile_index = -1
+            for i, line in enumerate(lines):
+                if line in ["个人资料", "About", "À propos", "简介"]:
+                    profile_index = i
+                    break
+
+            if profile_index >= 0:
+                for line in lines[profile_index+1:profile_index+10]:
+                    if len(line) > 10 and len(line) < 300:
+                        description = line
+                        break
+
+            # 如果没找到,找包含关键词的完整描述句
+            if not description:
+                keywords = ["car", "auto", "voiture", "vehicle", "motor", "dealer", "occasion", "automotive", "diagnostic", "diag", "concessionnaire", "vente", "location"]
+                best_line = ""
+                for line in lines[:80]:
+                    lower_line = line.lower()
+                    if any(k in lower_line for k in keywords):
+                        if len(line) > len(best_line) and len(line) < 300:
+                            best_line = line
+                if best_line:
+                    description = best_line
+
+            # 兜底:用公司名称
+            if not description:
+                description = record["客户姓名/公司"]
+            record["主营业务"] = translate_to_chinese(description)
+
+        # 推断客户类型,官网深搜证据优先参与判断
+        combined_business_text = "\n".join([home_page_text, page_text, website_result.get("text", "")])
+        inferred_type = classify_customer_type_cn(combined_business_text)
+        legacy_type = infer_dealer_type(page_text)
+        record["客户类型"] = legacy_type if legacy_type == "个人用户" else inferred_type
+        if not main_business:
+            record["主营业务"] = summarize_business_cn(combined_business_text)
+
+        # 备注:粉丝数 + 帖子分析
+        # 优先使用主页提取的粉丝数,若主页没有则尝试 About 页
+        followers = followers_from_home or extract_followers(page_text)
+        post_analysis = ""
+        try:
+            print("正在浏览最近帖子并分析...")
+            posts = scrape_posts(page, max_posts=8)
+            post_analysis = analyze_posts(posts, about_text=page_text)
+            print(f"采集到 {len(posts)} 条帖子")
+        except Exception as e:
+            print(f"帖子分析失败: {e}")
+
+        translated_post_analysis = translate_to_chinese(post_analysis) if post_analysis else ""
+
+        # 先把帖子分析写入备注,便于品牌检测提取上下文
+        if translated_post_analysis:
+            record["备注"] = translated_post_analysis
+
+        # 品牌检测辅助
+        record = enrich_record_with_brands(record)
+
+        # 组装中文结构化备注:Facebook证据 + 官网证据 + 业务判断 + 联系方式证据
+        detected = record.get("detected_brands", [])
+        exclusivity = record.get("exclusivity_assessment", "")
+        record["备注"] = build_chinese_notes(
+            facebook_url=page_url,
+            facebook_text="\n".join([home_page_text, page_text]),
+            website_url=record.get("公司官网", ""),
+            website_result=website_result,
+            followers=followers,
+            post_analysis=translated_post_analysis,
+            detected_brands=detected if isinstance(detected, list) else [],
+            exclusivity=exclusivity,
+        )
+
+        if record.get("备注"):
+            record["备注"] = "证据采集顺序:主页联络资料 -> About -> 官网深搜 | " + record["备注"]
+
+        print("采集完成:")
+        for k, v in record.items():
+            if v:
+                print(f"  {k}: {v}")
+
+    except Exception as e:
+        elapsed = time.time() - start_time
+        print(f"采集过程异常({elapsed:.1f}s): {e}")
+        if not record["客户姓名/公司"]:
+            from urllib.parse import urlparse
+            path = urlparse(page_url).path.strip("/").split("/")[0]
+            record["客户姓名/公司"] = path or "Unknown"
+        record["备注"] = (record.get("备注", "") + f" | 采集异常: {str(e)[:80]}").strip(" | ")
+
+    return record
+
+
+def scrape_facebook_page(
+    page_url: str,
+    profile_id: str,
+    city: str = "Casablanca",
+    country: str = "摩洛哥",
+    name: str = "",
+    main_business: str = "",
+    ads_power_url: str = "http://127.0.0.1:50325",
+) -> Dict[str, Any]:
+    """采集单个 Facebook 主页完整信息(自动管理 Playwright 生命周期)"""
+    ws_endpoint = get_active_ws_endpoint(ads_power_url, profile_id)
+    print(f"连接到已打开的浏览器: {ws_endpoint}")
+
+    playwright = sync_playwright().start()
+    browser = playwright.chromium.connect_over_cdp(ws_endpoint)
+
+    context = browser.contexts[0] if browser.contexts else browser.new_context()
+    page = context.new_page()
+    print(f"新建标签页,访问: {page_url}")
+
+    try:
+        record = scrape_page_record(
+            page=page,
+            page_url=page_url,
+            city=city,
+            country=country,
+            name=name,
+            main_business=main_business,
+        )
+    finally:
+        print("停止 Playwright,浏览器窗口保持打开")
+        playwright.stop()
+
+    return record
+
+
+def main():
+    parser = argparse.ArgumentParser(description="单个 Facebook 主页完整采集并写入 Excel")
+    parser.add_argument("--url", required=True, help="Facebook 主页 URL")
+    parser.add_argument("--profile-id", required=True, help="AdsPower profile ID")
+    parser.add_argument("--city", default="摩洛哥全国", help="城市或覆盖范围")
+    parser.add_argument("--country", default="摩洛哥", help="国家")
+    parser.add_argument("--name", default="", help="公司名称(脚本识别不准时手动指定)")
+    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("--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 输出")
+    parser.add_argument("--no-excel", action="store_true", help="不写入 Excel,只输出 JSON")
+
+    args = parser.parse_args()
+
+    record = scrape_facebook_page(
+        page_url=args.url,
+        profile_id=args.profile_id,
+        city=args.city,
+        country=args.country,
+        name=args.name,
+        main_business=args.main_business,
+        ads_power_url=args.ads_power_url,
+    )
+
+    # 保存 JSON
+    import json
+    output_path = resolve_artifact_path(args.output, kind="single_page_scrape", default_name=Path(args.output).name, run_id=args.run_id or None)
+    output_path.parent.mkdir(parents=True, exist_ok=True)
+    with open(output_path, "w", encoding="utf-8") as f:
+        json.dump(record, f, ensure_ascii=False, indent=2)
+    print(f"已保存 JSON: {args.output}")
+
+    # 写入 Excel
+    if args.write_excel and not args.no_excel:
+        workbook_info = resolve_workbook_path(args.excel, create_from_template=True)
+        args.excel = str(workbook_info["path"])
+        print(f"Workbook for writing: {args.excel} ({workbook_info['source']})")
+        result = append_records(
+            excel_path=args.excel,
+            sheet_name=args.sheet,
+            records=[record],
+            dedup_keys=["客户姓名/公司", "城市", "主页/链接"]
+        )
+        print(f"Excel write result: {result}")
+    else:
+        print("默认预览模式:未写入 Excel。确认要入表时再使用 --write-excel。")
+
+
+if __name__ == "__main__":
+    main()
+
+

+ 880 - 0
scripts/scraper/search_active_dealers.py

@@ -0,0 +1,880 @@
+"""
+Facebook 高活跃度摩洛哥经销商搜索与采集
+- 使用多个本地化的搜索关键词
+- 过滤个人资料/群组/非主页链接
+- 先生成候选预览并评分
+- 仅在显式 --deep-scrape 时打开高分主页深采
+- 仅在显式 --write-excel 时写入 Excel
+"""
+import re
+import sys
+import time
+import random
+import argparse
+import json
+from pathlib import Path
+
+import sys
+sys.path.append(str(Path(__file__).resolve().parents[1]))
+from common.artifact_manager import resolve_artifact_path, create_backup_once
+from typing import List, Dict, Any, Set, Tuple, Optional
+from urllib.parse import urlencode, urlparse
+
+# Windows 控制台输出中文/阿拉伯文时避免 GBK 编码错误
+if hasattr(sys.stdout, "reconfigure"):
+    sys.stdout.reconfigure(encoding="utf-8")
+if hasattr(sys.stderr, "reconfigure"):
+    sys.stderr.reconfigure(encoding="utf-8")
+
+import pandas as pd
+from playwright.sync_api import Page
+
+try:
+    from .ads_power_client import AdsPowerClient
+    from .scrape_single_page import scrape_page_record
+    from . import discovery_common as dc
+    from ..common import append_records, resolve_workbook_path
+except ImportError:
+    import sys
+    sys.path.insert(0, str(Path(__file__).parent.parent))
+    from scraper.ads_power_client import AdsPowerClient
+    from scraper.scrape_single_page import scrape_page_record
+    from scraper import discovery_common as dc
+    from common import append_records, resolve_workbook_path
+
+
+PAGE_NAME_BLACKLIST = {
+    "通知", "个人资料", "帖子", "简介", "提及", "好友", "照片", "视频",
+    "Notifications", "Profile", "Posts", "Photos", "Videos", "About",
+    "Friends", "Home", "Page", "Facebook", "Unknown",
+}
+
+DEFAULT_CITY_SCOPE = "摩洛哥全国"
+
+MOROCCO_TARGET_CITIES = [
+    "Casablanca",
+    "Rabat",
+    "Marrakech",
+    "Tanger",
+    "Fes",
+    "Agadir",
+    "Meknes",
+    "Oujda",
+    "Kenitra",
+    "Tetouan",
+    "Nador",
+    "Safi",
+]
+
+NATIONWIDE_KEYWORDS = [
+    "concessionnaire voiture chinoise Maroc",
+    "distributeur voitures chinoises Maroc",
+    "importateur voitures chinoises Maroc",
+    "showroom voiture chinoise Maroc",
+    "groupe automobile marques chinoises Maroc",
+    "concessionnaire multimarque voitures chinoises Maroc",
+    "concessionnaire utilitaire chinois Maroc",
+    "importateur camion chinois Maroc",
+    "distributeur camion chinois Maroc",
+    "concessionnaire camion Maroc",
+    "concessionnaire Chery Maroc",
+    "concessionnaire DFSK Maroc",
+    "concessionnaire Foton Maroc",
+    "concessionnaire JAC Maroc",
+    "concessionnaire Jetour Maroc",
+    "concessionnaire Geely Maroc",
+    "concessionnaire SITRAK Maroc",
+    "distributeur Sinotruk Maroc",
+    "distributeur Shacman Maroc",
+    "camion chinois Maroc",
+    "import voiture chine Maroc",
+    "voiture chinoise Maroc",
+    "importateur auto chine Maroc",
+    "groupe automobile Maroc",
+    "concessionnaire automobile Maroc",
+    "concessionnaire multimarque Maroc",
+    "importateur voiture occasion Maroc",
+    "voiture occasion Maroc",
+    "showroom auto Maroc",
+]
+
+CITY_KEYWORD_PATTERNS = [
+    "concessionnaire automobile {city}",
+    "voiture occasion {city}",
+    "showroom auto {city}",
+]
+
+
+def build_default_keywords() -> List[str]:
+    """Build nationwide Morocco queries plus city waves for large and small dealers."""
+    keywords = list(NATIONWIDE_KEYWORDS)
+    for city in MOROCCO_TARGET_CITIES:
+        for pattern in CITY_KEYWORD_PATTERNS:
+            keywords.append(pattern.format(city=city))
+
+    deduped: List[str] = []
+    seen: Set[str] = set()
+    for keyword in keywords:
+        key = keyword.casefold()
+        if key not in seen:
+            deduped.append(keyword)
+            seen.add(key)
+    return deduped
+
+
+DEFAULT_KEYWORDS = build_default_keywords()
+
+
+PATH_BLACKLIST = {
+    "photo", "photos", "video", "videos", "watch", "home", "messages",
+    "notifications", "settings", "events", "marketplace", "friends",
+    "friend", "login", "logout", "recover", "help", "support", "privacy",
+    "terms", "ads", "business", "creatorstudio", "gaming", "jobs",
+    "weather", "places", "explore", "bookmarks", "memories", "saved",
+    "reels", "stories", "shop", "donate", "fundraisers", "pages",
+    "groups", "profile.php",
+}
+
+BUSINESS_SUFFIXES = {
+    "ma", "com", "net", "org", "fr", "uk", "eu", "info", "shop", "store",
+    "auto", "cars", "voiture", "maroc", "morocco", "casa", "group",
+}
+
+CHINA_BRAND_TERMS = {
+    "baic", "byd", "changan", "chery", "dfsk", "dongfeng", "foton", "gac",
+    "geely", "great wall", "haval", "jac", "jetour", "maxus", "mg", "omoda",
+    "saic", "wuling", "sitrak", "sinotruk", "shacman", "faw", "yutong",
+    "king long", "golden dragon", "higer", "forland",
+}
+
+NON_CHINA_BRAND_TERMS = {
+    "audi", "bmw", "chevrolet", "citroen", "dacia", "daf", "fiat", "ford",
+    "hino", "honda", "hyundai", "isuzu", "iveco", "jeep", "kia", "man",
+    "mazda", "mercedes", "mitsubishi", "nissan", "opel", "peugeot",
+    "renault", "scania", "seat", "skoda", "suzuki", "toyota", "volkswagen",
+    "volvo",
+}
+
+OEM_BRANCH_BRAND_TERMS = CHINA_BRAND_TERMS | NON_CHINA_BRAND_TERMS
+
+OEM_BRANCH_COUNTRY_SUFFIXES = {"maroc", "morocco", "ma"}
+
+OEM_BRANCH_FILLER_TERMS = {
+    "auto", "autos", "car", "cars", "motor", "motors", "truck", "trucks",
+    "vehicle", "vehicles", "vehicule", "vehicules", "official", "officiel",
+}
+
+COMMERCIAL_VEHICLE_TERMS = {
+    "truck", "trucks", "camion", "camions", "utilitaire", "utilitaires",
+    "heavy", "light truck", "pickup", "van", "mpv", "bus", "fleet", "flotte",
+}
+
+HIGH_INTENT_TERMS = {
+    "import", "importateur", "importation", "chine", "chinoise", "chinese",
+    "concessionnaire", "dealer", "distributeur", "distribution", "multimarque",
+    "groupe", "group", "showroom", "occasion", "voiture occasion",
+    "automobile", "auto", "revendeur", "vendeur", "vente", "stock", "parc auto",
+}
+
+AUTOMOTIVE_URL_TERMS = {
+    "auto", "cars", "car", "moteur", "motor", "voiture", "vehicule", "vehicle",
+    "maroc", "morocco", "casa", "casablanca", "rabat", "marrakech", "tanger",
+    "fes", "agadir", "meknes", "oujda", "kenitra", "tetouan", "nador", "safi",
+    "group", "groupe", "garage", "truck", "camion",
+    "sitrak", "sinotruk", "foton", "chery", "dfsk", "byd", "haval", "jac",
+}
+
+NEW_VEHICLE_SALES_TERMS = {
+    "v?hicules neufs", "vehicules neufs", "voitures neuves", "new vehicle",
+    "new cars", "concessionnaire", "dealer", "showroom", "vente", "stock", "parc auto",
+}
+
+IMPORT_DISTRIBUTION_TERMS = {
+    "import", "importateur", "importation", "distributeur", "distribution",
+    "groupe", "group", "r?seau", "reseau", "dealer network",
+}
+
+MULTIBRAND_TERMS = {
+    "multimarque", "multi-brand", "multi brand", "plusieurs marques", "marques multiples",
+}
+
+CONTACT_ENTRY_TERMS = {
+    "facebook.com", "whatsapp", "wa.me", "tel", "phone", "telephone", "t?l?phone",
+    "email", "mail", "contact", "linkedin", "+212", "@",
+}
+
+PURE_SERVICE_TERMS = {
+    "garage r?paration", "garage reparation", "diagnostic", "diag", "pieces", "pi?ces",
+    "spare", "parts", "pneus", "tires", "tyres", "lavage", "wash", "repair", "reparation",
+}
+
+RENTAL_FLEET_TERMS = {"location", "rental", "rent car", "rentcar", "fleet", "flotte", "leasing", "lld"}
+PERSONAL_SELLER_TERMS = {"particulier", "personal seller", "vendeur particulier", "profile.php"}
+DIRECT_BRANCH_TERMS = {"succursale", "branch", "filiale", "subsidiary", "officiel", "official", "page officielle", "official page"}
+
+LOW_VALUE_TERMS = PURE_SERVICE_TERMS | {
+    "assurance", "insurance", "immobilier", "emploi", "marketing", "software",
+}
+
+
+def compact_text(value: str) -> str:
+    """Normalize a short page/name hint for brand-branch detection."""
+    return re.sub(r"[^a-z0-9]+", "", value.lower())
+
+
+def looks_like_oem_local_branch(slug: str) -> Tuple[bool, str]:
+    """
+    Detect official brand-country pages such as BYD Maroc or BMW Morocco.
+
+    These are usually OEM local branches or brand pages, not independent dealer
+    channels. Independent entities with owner/channel names should pass through.
+    """
+    compact = compact_text(slug)
+    if not compact:
+        return False, ""
+
+    fillers = {compact_text(term) for term in OEM_BRANCH_FILLER_TERMS}
+    countries = {compact_text(term) for term in OEM_BRANCH_COUNTRY_SUFFIXES}
+
+    for brand in OEM_BRANCH_BRAND_TERMS:
+        brand_key = compact_text(brand)
+        if not brand_key or brand_key not in compact:
+            continue
+
+        for country in countries:
+            direct_variants = {
+                f"{brand_key}{country}",
+                f"{country}{brand_key}",
+                f"{brand_key}official{country}",
+                f"{brand_key}officiel{country}",
+                f"{brand_key}{country}official",
+                f"{brand_key}{country}officiel",
+            }
+            if compact in direct_variants:
+                return True, brand
+
+            if compact.startswith(brand_key) and compact.endswith(country):
+                middle = compact[len(brand_key):-len(country)] if country else ""
+                if not middle or middle in fillers:
+                    return True, brand
+
+            if compact.startswith(country) and compact.endswith(brand_key):
+                middle = compact[len(country):-len(brand_key)]
+                if not middle or middle in fillers:
+                    return True, brand
+
+    return False, ""
+
+
+def normalize_link(value: Any) -> str:
+    """Normalize page links for pre-scrape duplicate checks."""
+    if value is None:
+        return ""
+
+    link = str(value).strip()
+    if not link or link.lower() == "nan":
+        return ""
+
+    link = link.split("#", 1)[0].split("?", 1)[0].rstrip("/")
+    replacements = {
+        "https://m.facebook.com/": "https://www.facebook.com/",
+        "http://m.facebook.com/": "https://www.facebook.com/",
+        "http://facebook.com/": "https://www.facebook.com/",
+        "https://facebook.com/": "https://www.facebook.com/",
+    }
+    for src, dst in replacements.items():
+        link = link.replace(src, dst)
+    return link.lower()
+
+
+def load_existing_links(excel_path: str, sheet_name: str) -> Set[str]:
+    """Load existing homepage links from the target workbook before scraping."""
+    if not excel_path:
+        return set()
+
+    path = Path(excel_path)
+    if not path.exists():
+        raise FileNotFoundError(f"Excel 文件不存在: {excel_path}")
+
+    df = pd.read_excel(path, sheet_name=sheet_name)
+    if "主页/链接" not in df.columns:
+        return set()
+
+    return {
+        normalized
+        for normalized in (normalize_link(value) for value in df["主页/链接"].tolist())
+        if normalized
+    }
+
+
+def load_blocklist_json(blocklist_path: Optional[str]) -> Set[str]:
+    """Load additional Facebook page URLs to skip from a JSON blocklist file."""
+    if not blocklist_path:
+        return set()
+    path = Path(blocklist_path)
+    if not path.exists():
+        return set()
+    try:
+        data = json.loads(path.read_text(encoding="utf-8"))
+    except Exception:
+        return set()
+    if isinstance(data, list):
+        return {normalized for normalized in (normalize_link(value) for value in data) if normalized}
+    if isinstance(data, dict) and "blocklist" in data:
+        values = data["blocklist"]
+        return {normalized for normalized in (normalize_link(value) for value in values) if normalized}
+    return set()
+
+
+def is_page_link(href: str) -> bool:
+    """判断是否是 Facebook 公开主页链接,排除个人资料、群组、搜索等"""
+    parsed = urlparse(href)
+    if parsed.netloc not in ["www.facebook.com", "facebook.com", "m.facebook.com"]:
+        return False
+
+    path = parsed.path.strip("/").lower()
+    if not path or "/" in path:
+        return False
+
+    if path in PATH_BLACKLIST:
+        return False
+
+    if not re.match(r"^[A-Za-z0-9._\-]+$", path):
+        return False
+
+    if "." in path:
+        parts = path.split(".")
+        if len(parts) == 2 and len(parts[0]) >= 2 and len(parts[1]) >= 2:
+            suffix = parts[1].rstrip("0123456789").lower()
+            if suffix not in BUSINESS_SUFFIXES:
+                return False
+
+    return True
+
+
+def normalize_facebook_url(url: str) -> str:
+    """标准化为 https://www.facebook.com/{pagename}/"""
+    parsed = urlparse(url)
+    path = parsed.path.strip("/").split("/")[0]
+    return f"https://www.facebook.com/{path}/"
+
+
+def page_name_hint(url: str) -> str:
+    """Return the Facebook slug as a weak candidate name hint."""
+    parsed = urlparse(url)
+    return parsed.path.strip("/").split("/")[0]
+
+
+def matched_terms(text: str, terms: Set[str]) -> List[str]:
+    text_lower = text.lower()
+    return sorted(term for term in terms if term in text_lower)
+
+
+def score_candidate(url: str, source_queries: List[str]) -> Dict[str, Any]:
+    """Score a candidate before opening its homepage."""
+    source_queries = sorted(set(q for q in source_queries if q))
+    query_text = " ".join(source_queries).lower()
+    slug = page_name_hint(url)
+    slug_text = re.sub(r"[._\-]+", " ", slug).lower()
+    combined = f"{query_text} {slug_text} {url}".casefold()
+
+    score = 0
+    reasons: List[str] = []
+    risks: List[str] = []
+    recommended_action = "preview_only"
+
+    is_oem_branch, oem_brand = looks_like_oem_local_branch(slug)
+    brand_terms = matched_terms(combined, OEM_BRANCH_BRAND_TERMS)
+    direct_branch_terms = matched_terms(combined, DIRECT_BRANCH_TERMS)
+    if is_oem_branch or (brand_terms and direct_branch_terms):
+        score -= 10
+        risks.append(f"疑似汽车品牌当地官方分公司/官方主页/直营网点,非目标经销渠道: {oem_brand or ', '.join(brand_terms[:2])}")
+        recommended_action = "skip_brand_branch"
+
+    service_terms = matched_terms(combined, PURE_SERVICE_TERMS)
+    if service_terms:
+        score -= 6
+        risks.append("纯维修/配件/轮胎/服务类,不纳入汽车渠道合作伙伴: " + ", ".join(service_terms[:5]))
+        if recommended_action == "preview_only":
+            recommended_action = "skip_non_channel"
+
+    personal_terms = matched_terms(combined, PERSONAL_SELLER_TERMS)
+    if personal_terms:
+        score -= 5
+        risks.append("疑似个人卖家,不纳入汽车渠道合作伙伴: " + ", ".join(personal_terms[:4]))
+        if recommended_action == "preview_only":
+            recommended_action = "skip_non_channel"
+
+    sales_terms = matched_terms(combined, NEW_VEHICLE_SALES_TERMS)
+    import_terms = matched_terms(combined, IMPORT_DISTRIBUTION_TERMS)
+    multibrand_terms = matched_terms(combined, MULTIBRAND_TERMS)
+    rental_terms = matched_terms(combined, RENTAL_FLEET_TERMS)
+    contact_terms = matched_terms(combined, CONTACT_ENTRY_TERMS)
+    china_terms = matched_terms(combined, CHINA_BRAND_TERMS)
+    commercial_terms = matched_terms(combined, COMMERCIAL_VEHICLE_TERMS)
+    high_intent_terms = matched_terms(query_text, HIGH_INTENT_TERMS)
+    slug_terms = matched_terms(slug_text, AUTOMOTIVE_URL_TERMS)
+
+    has_actual_channel = bool(sales_terms or import_terms or multibrand_terms)
+    ownership_needs_review = brand_terms and recommended_action != "skip_brand_branch" and not (import_terms or multibrand_terms)
+    if sales_terms:
+        score += 3
+        reasons.append("实际新整车销售/showroom/库存信号: " + ", ".join(sales_terms[:5]))
+    if import_terms:
+        score += 3
+        reasons.append("进口/分销/网络能力信号: " + ", ".join(import_terms[:5]))
+    if multibrand_terms:
+        score += 2
+        reasons.append("多品牌经营信号: " + ", ".join(multibrand_terms[:4]))
+    if china_terms and recommended_action != "skip_brand_branch":
+        score += 1
+        reasons.append("命中中国品牌/商用车品牌: " + ", ".join(china_terms[:5]))
+    if commercial_terms:
+        score += 1
+        reasons.append("命中商用车/车队相关词: " + ", ".join(commercial_terms[:5]))
+    if high_intent_terms:
+        score += min(3, len(high_intent_terms))
+        reasons.append("搜索词意图强: " + ", ".join(high_intent_terms[:5]))
+    if slug_terms:
+        score += 1
+        reasons.append("主页链接像汽车业务: " + ", ".join(slug_terms[:5]))
+    if contact_terms or "facebook.com" in url.casefold():
+        score += 2
+        reasons.append("可建联入口: Facebook Page")
+
+    if len(source_queries) > 1:
+        extra = min(2, len(source_queries) - 1)
+        score += extra
+        reasons.append(f"被 {len(source_queries)} 个搜索词重复命中")
+
+    if rental_terms and not has_actual_channel:
+        score += 1
+        risks.append("纯租赁/车队线索,转入批量采购与运营客户/汽车租赁公司,不作为汽车渠道合作伙伴: " + ", ".join(rental_terms[:4]))
+
+    if brand_terms and sales_terms and not (import_terms or multibrand_terms) and recommended_action != "skip_brand_branch":
+        score += 1
+        risks.append("独立单品牌新车经销商可保留;排他协议及新增品牌权限待确认")
+
+    new_vehicle_needs_review = False
+    if not has_actual_channel and not rental_terms:
+        score -= 3
+        risks.append("缺少新整车销售、进口、分销或 showroom 证据,建议先人工看预览")
+        new_vehicle_needs_review = True
+
+    if ownership_needs_review and dc.should_request_manual_review(score, recommended_action, reasons):
+        dc.add_manual_review_flag(risks, "ownership")
+    if new_vehicle_needs_review and dc.should_request_manual_review(score, recommended_action, reasons):
+        dc.add_manual_review_flag(risks, "new_vehicle")
+
+    if not reasons:
+        reasons.append("仅从 Facebook 搜索结果获得,需人工判断")
+
+    return {"url": url, "normalized_url": normalize_link(url), "candidate_name_hint": slug, "source_queries": source_queries, "score": score, "score_reasons": reasons, "risk_flags": risks, "recommended_action": recommended_action}
+
+
+
+def enrich_record_with_candidate_signals(record: Dict[str, Any], candidate: Dict[str, Any]) -> Dict[str, Any]:
+    """Apply the local expanded brand terms even if the shared detector is not writable."""
+    combined = " ".join([
+        str(record.get("客户姓名/公司", "")),
+        str(record.get("主页/链接", "")),
+        str(record.get("主营业务", "")),
+        str(record.get("备注", "")),
+        str(record.get("candidate_reasons", "")),
+        " ".join(candidate.get("score_reasons", [])),
+        " ".join(candidate.get("source_queries", [])),
+    ])
+    china_terms = matched_terms(combined, CHINA_BRAND_TERMS)
+    if china_terms:
+        existing = record.get("detected_brands", []) or []
+        if not isinstance(existing, list):
+            existing = [str(existing)]
+        record["detected_brands"] = sorted(set(existing + china_terms))
+        record["exclusivity_assessment"] = "已代理中国品牌,需评估"
+    return record
+
+def collect_page_links(
+    page: Page,
+    query: str,
+    max_links: int = 10,
+    scroll_attempts: int = 3,
+) -> List[str]:
+    """访问 Facebook search/top 并收集主页链接"""
+    encoded = urlencode({"q": query})
+    search_url = f"https://www.facebook.com/search/top/?{encoded}"
+
+    print(f"\n搜索关键词: {query}", flush=True)
+    print(f"访问: {search_url}", flush=True)
+
+    try:
+        page.goto(search_url, wait_until="domcontentloaded", timeout=30000)
+        time.sleep(random.uniform(2, 4))
+    except Exception as e:
+        print(f"  搜索页加载失败: {e}", flush=True)
+        return []
+
+    try:
+        for sel in ['[aria-label="关闭"]', '[aria-label="Close"]']:
+            for btn in page.query_selector_all(sel):
+                try:
+                    btn.click()
+                    time.sleep(0.5)
+                except Exception:
+                    pass
+    except Exception:
+        pass
+
+    links: Set[str] = set()
+    start_time = time.time()
+    for attempt in range(scroll_attempts):
+        selectors = [
+            'a[href*="facebook.com/"]',
+            '[role="article"] a',
+            'a[href*="/"]',
+        ]
+        for sel in selectors:
+            try:
+                for el in page.query_selector_all(sel):
+                    try:
+                        href = el.get_attribute("href") or ""
+                        if is_page_link(href):
+                            links.add(normalize_facebook_url(href))
+                    except Exception:
+                        continue
+            except Exception as e:
+                print(f"  选择器 {sel} 提取失败: {e}", flush=True)
+                continue
+
+        print(f"  滚动 {attempt + 1}/{scroll_attempts}, 已收集 {len(links)} 个主页链接", flush=True)
+        if len(links) >= max_links:
+            break
+
+        if time.time() - start_time > 30:
+            print("  搜索时间超过 30 秒,提前结束", flush=True)
+            break
+
+        page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
+        time.sleep(random.uniform(1, 2))
+
+    return list(links)[:max_links]
+
+
+def looks_old_from_time_text(time_text: str) -> bool:
+    """根据帖子时间文本判断是否明显陈旧。"""
+    if not time_text:
+        return False
+
+    text = time_text.lower()
+    if re.search(r"(201[0-9]|202[0-3])", text):
+        return True
+    if re.search(r"\d+\s*(year|years|yr|yrs|年)", text):
+        return True
+    month_match = re.search(r"(\d+)\s*(month|months|mo|月)", text)
+    if month_match and int(month_match.group(1)) > 3:
+        return True
+    return False
+
+
+def looks_recently_active(time_text: str) -> bool:
+    """判断时间文本是否暗示近期活跃(90 天内)。"""
+    if not time_text:
+        return False
+    text = time_text.lower()
+
+    recent_units = [
+        "h", "hr", "hrs", "hour", "hours", "小时",
+        "min", "mins", "minute", "minutes", "分钟",
+        "d", "day", "days", "天",
+        "w", "week", "weeks", "周",
+        "just now", "刚刚", "now", "现在", "昨天", "yesterday",
+    ]
+    if any(unit in text for unit in recent_units):
+        return True
+
+    month_match = re.search(r"\b(\d+)\s*(month|months|mo|月)\b", text)
+    if month_match and int(month_match.group(1)) <= 3:
+        return True
+
+    if re.search(r"(2024|2025|2026)", text):
+        return True
+
+    return False
+
+
+def extract_latest_time_from_notes(notes: str) -> str:
+    """从备注字段中解析最近发帖时间"""
+    if not notes:
+        return ""
+    match = re.search(r"最近发帖时间:([^|]+)", notes)
+    if match:
+        return match.group(1).strip()
+    return ""
+
+
+def build_candidates(
+    all_page_links: List[str],
+    link_sources: Dict[str, Set[str]],
+    existing_links: Set[str],
+    min_candidate_score: int,
+) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
+    """Create scored candidates and skipped-existing records."""
+    candidates: List[Dict[str, Any]] = []
+    skipped_existing: List[Dict[str, Any]] = []
+
+    for link in all_page_links:
+        normalized = normalize_link(link)
+        source_queries = sorted(link_sources.get(link, set()))
+        candidate = score_candidate(link, source_queries)
+        if normalized and normalized in existing_links:
+            candidate["recommended_action"] = "skip_existing"
+            skipped_existing.append(candidate)
+            print(f"  已在表中,跳过不访问主页: {link}")
+            continue
+        if candidate.get("recommended_action") == "skip_brand_branch":
+            print(f"  疑似品牌当地分公司/官方页,跳过深采: {link}")
+        elif candidate["score"] >= min_candidate_score:
+            candidate["recommended_action"] = "deep_scrape"
+        candidates.append(candidate)
+
+    candidates.sort(key=lambda c: (-int(c.get("score", 0)), c.get("url", "")))
+    skipped_existing.sort(key=lambda c: c.get("url", ""))
+    return candidates, skipped_existing
+
+
+def search_active_dealers(
+    profile_id: str,
+    keywords: List[str] = None,
+    city: str = DEFAULT_CITY_SCOPE,
+    country: str = "摩洛哥",
+    max_links_per_keyword: int = 5,
+    max_pages_to_scrape: int = 20,
+    min_active_score: int = 1,
+    min_candidate_score: int = 3,
+    ads_power_url: str = "http://127.0.0.1:50325",
+    api_key: str = "",
+    headless: bool = False,
+    existing_links: Set[str] = None,
+    deep_scrape: bool = False,
+) -> Dict[str, Any]:
+    """
+    执行多关键词搜索。默认只生成候选预览;显式 deep_scrape=True 才打开高分主页。
+    min_active_score: 0=不过滤, 1=过滤明显陈旧的, 2=仅保留明确近期活跃的
+    """
+    if keywords is None:
+        keywords = DEFAULT_KEYWORDS
+    if existing_links is None:
+        existing_links = set()
+
+    client = AdsPowerClient(ads_power_url, api_key=api_key)
+    client.start_browser(profile_id, headless=headless)
+    page = client.get_open_page() or client.new_page()
+    page.set_viewport_size({"width": 1280, "height": 800})
+
+    all_page_links: List[str] = []
+    link_sources: Dict[str, Set[str]] = {}
+    records: List[Dict[str, Any]] = []
+    candidates: List[Dict[str, Any]] = []
+    skipped_existing: List[Dict[str, Any]] = []
+
+    try:
+        for query in keywords:
+            links = collect_page_links(
+                page=page,
+                query=query,
+                max_links=max_links_per_keyword,
+                scroll_attempts=5,
+            )
+            print(f"  关键词 '{query}' 收集到 {len(links)} 个主页链接")
+            for link in links:
+                if link not in all_page_links:
+                    all_page_links.append(link)
+                link_sources.setdefault(link, set()).add(query)
+            time.sleep(random.uniform(2, 4))
+
+        print(f"\n去重后共 {len(all_page_links)} 个搜索到主页,开始候选评分")
+        candidates, skipped_existing = build_candidates(
+            all_page_links=all_page_links,
+            link_sources=link_sources,
+            existing_links=existing_links,
+            min_candidate_score=min_candidate_score,
+        )
+
+        if skipped_existing:
+            print(f"已按 Excel 现有主页链接跳过 {len(skipped_existing)} 个重复主页")
+
+        selected_candidates = [c for c in candidates if c.get("recommended_action") == "deep_scrape"]
+        if len(selected_candidates) > max_pages_to_scrape:
+            print(f"高分候选 {len(selected_candidates)} 个,限制深采前 {max_pages_to_scrape} 个")
+        selected_candidates = selected_candidates[:max_pages_to_scrape]
+
+        if not deep_scrape:
+            print("预览模式:已生成候选评分,不打开主页深采。需要深采时加 --deep-scrape。")
+        else:
+            for idx, candidate in enumerate(selected_candidates, 1):
+                link = candidate["url"]
+                print(f"\n[{idx}/{len(selected_candidates)}] 深采主页: {link} (score={candidate['score']})")
+                try:
+                    record = scrape_page_record(
+                        page=page,
+                        page_url=link,
+                        city=city,
+                        country=country,
+                    )
+                    name = record.get("客户姓名/公司", "")
+                    dealer_type = record.get("客户类型", "")
+                    if name and name != "Unknown" and name not in PAGE_NAME_BLACKLIST and dealer_type != "个人用户":
+                        record["candidate_score"] = candidate.get("score", 0)
+                        record["candidate_reasons"] = "; ".join(candidate.get("score_reasons", []))
+                        record["risk_flags"] = "; ".join(candidate.get("risk_flags", []))
+                        record = enrich_record_with_candidate_signals(record, candidate)
+                        records.append(record)
+                        normalized = normalize_link(link)
+                        if normalized:
+                            existing_links.add(normalized)
+                    else:
+                        print(f"  跳过无效主页: {name} ({dealer_type})")
+                except Exception as e:
+                    print(f"采集失败 {link}: {e}", flush=True)
+                time.sleep(random.uniform(2, 4))
+
+    finally:
+        print("\n断开 Playwright 连接,浏览器保持打开...", flush=True)
+        client.close_browser()
+
+    if records:
+        print(f"\n深采完成,原始记录 {len(records)} 条")
+        if min_active_score >= 1:
+            filtered = []
+            for r in records:
+                latest_time = extract_latest_time_from_notes(r.get("备注", ""))
+                if not looks_old_from_time_text(latest_time):
+                    filtered.append(r)
+                else:
+                    print(f"  过滤陈旧账号: {r.get('客户姓名/公司')} ({latest_time})")
+            records = filtered
+            print(f"过滤明显陈旧后剩余 {len(records)} 条")
+
+        def sort_key(r):
+            latest_time = extract_latest_time_from_notes(r.get("备注", ""))
+            if looks_recently_active(latest_time):
+                return 0
+            if not latest_time or latest_time == "未识别":
+                return 1
+            if looks_old_from_time_text(latest_time):
+                return 3
+            return 2
+
+        records.sort(key=sort_key)
+
+    return {
+        "summary": {
+            "keywords": keywords,
+            "found_links": len(all_page_links),
+            "candidate_count": len(candidates),
+            "skipped_existing": len(skipped_existing),
+            "selected_for_deep_scrape": len([c for c in candidates if c.get("recommended_action") == "deep_scrape"][:max_pages_to_scrape]),
+            "deep_scrape": deep_scrape,
+            "scraped_records": len(records),
+            "min_candidate_score": min_candidate_score,
+            "max_pages_to_scrape": max_pages_to_scrape,
+        },
+        "candidates": candidates,
+        "skipped_existing": skipped_existing,
+        "records": records,
+    }
+
+
+def main():
+    parser = argparse.ArgumentParser(description="Facebook 摩洛哥经销商候选搜索、评分与可选深采")
+    parser.add_argument("--profile-id", required=True, help="AdsPower profile ID")
+    parser.add_argument("--ads-power-url", default="http://127.0.0.1:50325", help="AdsPower API URL")
+    parser.add_argument("--api-key", default="", help="AdsPower API Key")
+    parser.add_argument("--keywords", default="", help="逗号分隔的搜索关键词,留空使用默认列表")
+    parser.add_argument("--city", default=DEFAULT_CITY_SCOPE, help="城市或覆盖范围,默认摩洛哥全国")
+    parser.add_argument("--country", default="摩洛哥", help="国家")
+    parser.add_argument("--max-links-per-keyword", type=int, default=5, help="每个关键词最多收集链接数;全国搜索默认较小以控制账号风险")
+    parser.add_argument("--max-pages", type=int, default=20, help="--deep-scrape 时最多打开深采的新主页数")
+    parser.add_argument("--min-candidate-score", type=int, default=3, help="候选进入深采队列的最低分")
+    parser.add_argument("--min-active-score", type=int, default=1, choices=[0, 1, 2], help="活跃度过滤: 0=不过滤, 1=过滤明显陈旧, 2=仅保留明确近期活跃")
+    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("--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 预览")
+    parser.add_argument("--blocklist-json", default="", help="可选 JSON 文件,包含额外要跳过的 Facebook 主页链接")
+    parser.add_argument("--no-excel", action="store_true", help="不读取也不写入 Excel,只输出 JSON")
+
+    args = parser.parse_args()
+
+    keywords = None
+    if args.keywords:
+        keywords = [k.strip() for k in args.keywords.split(",") if k.strip()]
+
+    existing_links: Set[str] = set()
+    if not args.no_excel:
+        read_workbook_info = resolve_workbook_path(args.excel, create_from_template=False)
+        if read_workbook_info.get("path"):
+            args.excel = str(read_workbook_info["path"])
+            print(f"Workbook for duplicate checking: {args.excel} ({read_workbook_info['source']})")
+    if not args.no_excel and args.excel:
+        try:
+            existing_links = load_existing_links(args.excel, args.sheet)
+            print(f"已从 Excel 加载 {len(existing_links)} 个现有主页链接,采集前将直接跳过")
+        except FileNotFoundError as e:
+            print(f"未读取到现有 Excel,跳过预去重: {e}")
+
+    blocklist_links = load_blocklist_json(args.blocklist_json or None)
+    if blocklist_links:
+        existing_links = existing_links | blocklist_links
+        print(f"已从黑名单 JSON 加载 {len(blocklist_links)} 个额外链接,合并后去重库共 {len(existing_links)} 个")
+
+    result = search_active_dealers(
+        profile_id=args.profile_id,
+        keywords=keywords,
+        city=args.city,
+        country=args.country,
+        max_links_per_keyword=args.max_links_per_keyword,
+        max_pages_to_scrape=args.max_pages,
+        min_active_score=args.min_active_score,
+        min_candidate_score=args.min_candidate_score,
+        ads_power_url=args.ads_power_url,
+        api_key=args.api_key,
+        headless=args.headless,
+        existing_links=existing_links,
+        deep_scrape=args.deep_scrape,
+    )
+
+    output_path = resolve_artifact_path(args.output, kind="scraper_preview", default_name=Path(args.output).name, run_id=args.run_id or None)
+    output_path.parent.mkdir(parents=True, exist_ok=True)
+    with open(output_path, "w", encoding="utf-8") as f:
+        json.dump(result, f, ensure_ascii=False, indent=2)
+    print(f"\n已保存候选/采集结果到 {output_path}")
+
+    if args.write_excel and not args.no_excel:
+        write_workbook_info = resolve_workbook_path(args.excel, create_from_template=True)
+        args.excel = str(write_workbook_info["path"])
+        if write_workbook_info.get("created"):
+            print(f"Created blank workbook from skill template: {args.excel}", flush=True)
+        records = result.get("records", [])
+        if not records:
+            print("No Excel written: no deep-scraped records. Use --deep-scrape first.")
+        else:
+            try:
+                write_result = append_records(
+                    excel_path=args.excel,
+                    sheet_name=args.sheet,
+                    records=records,
+                    dedup_keys=["客户姓名/公司", "城市", "主页/链接"],
+                )
+                print(f"Excel write result: {write_result}", flush=True)
+            except PermissionError as e:
+                print(f"Excel write failed. Close the workbook and retry: {e}", flush=True)
+    else:
+        print("Preview mode: Excel was not written. Use --deep-scrape --write-excel after confirmation.")
+
+
+
+if __name__ == "__main__":
+    main()

+ 902 - 0
scripts/scraper/search_auto_websites.py

@@ -0,0 +1,902 @@
+"""
+Collect Morocco auto-dealer leads from vertical auto sites and business directories.
+
+This script uses an already-open AdsPower browser profile, searches six approved
+source websites, deep-scrapes candidate/company pages, follows public merchant
+website contact pages for emails, and writes results to a dedicated workbook
+sheet. It intentionally does not use Moteur.
+"""
+import argparse
+import json
+import random
+import re
+import shutil
+import sys
+import time
+from dataclasses import dataclass, field
+from datetime import datetime, timezone
+from pathlib import Path
+
+import sys
+sys.path.append(str(Path(__file__).resolve().parents[1]))
+from common.artifact_manager import resolve_artifact_path, create_backup_once
+from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
+from urllib.parse import quote_plus, urljoin, urlparse
+
+import requests
+from openpyxl import Workbook, load_workbook
+from playwright.sync_api import BrowserContext, Page, sync_playwright
+
+try:
+    from . import discovery_common as dc
+    from ..common import resolve_workbook_path
+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
+
+if hasattr(sys.stdout, "reconfigure"):
+    sys.stdout.reconfigure(encoding="utf-8")
+if hasattr(sys.stderr, "reconfigure"):
+    sys.stderr.reconfigure(encoding="utf-8")
+
+
+DEFAULT_EXCEL_DIR = Path.cwd()
+DEFAULT_SHEET = "汽车网站精选线索"
+DEFAULT_COUNTRY = "摩洛哥"
+DEFAULT_CITY = "摩洛哥全国"
+DEFAULT_ADSPOWER_URL = "http://127.0.0.1:50325"
+
+HEADERS = [
+    "序号",
+    "客户姓名/公司",
+    "国家",
+    "城市",
+    "客户类型",
+    "主页/链接",
+    "来源网站",
+    "联系人",
+    "职位",
+    "电话/WhatsApp",
+    "邮箱",
+    "主营业务",
+    "建联状态",
+    "下次跟进",
+    "备注",
+]
+
+CONTACT_LINK_HINTS = {
+    "contact",
+    "contactez",
+    "nous contacter",
+    "a propos",
+    "à propos",
+    "apropos",
+    "qui sommes",
+    "mentions",
+    "legal",
+    "devis",
+}
+
+HIGH_VALUE_TERMS = {
+    "importation",
+    "importateur",
+    "importateurs",
+    "véhicules neufs",
+    "vehicules neufs",
+    "voitures neuves",
+    "professionnel",
+    "professionnels",
+    "concessionnaire",
+    "concessionnaires",
+    "distributeur",
+    "distributeurs",
+    "showroom",
+    "stock",
+    "parc auto",
+    "location",
+    "lld",
+    "leasing",
+    "flotte",
+    "fleet",
+    "utilitaire",
+    "utilitaires",
+    "camionnette",
+    "fourgon",
+    "pick-up",
+    "pickup",
+    "mpv",
+    "minibus",
+    "économique",
+    "economique",
+}
+
+LOW_VALUE_TERMS = {
+    "garage réparation",
+    "garage reparation",
+    "diagnostic",
+    "pièces détachées",
+    "pieces detachees",
+    "assurance",
+    "lavage",
+    "car wash",
+    "pare-brise",
+    "immobilier",
+    "emploi",
+}
+
+
+@dataclass
+class SourceConfig:
+    name: str
+    domains: Tuple[str, ...]
+    queries: List[str]
+    start_urls: List[str] = field(default_factory=list)
+
+
+SOURCES = [
+    SourceConfig(
+        name="OtoMoto.ma",
+        domains=("otomoto.ma",),
+        start_urls=["https://otomoto.ma/guide/professionnels", "https://otomoto.ma/"],
+        queries=[
+            "site:otomoto.ma Maroc professionnel concessionnaire automobile",
+            "site:otomoto.ma Maroc revendeur automobile professionnel",
+            "site:otomoto.ma Maroc importateur voiture professionnel",
+        ],
+    ),
+    SourceConfig(
+        name="Wandaloo",
+        domains=("wandaloo.com",),
+        start_urls=["https://www.wandaloo.com/neuf/maroc/concessionnaire.html"],
+        queries=[
+            "site:wandaloo.com/neuf/maroc concessionnaire distributeur automobile Maroc",
+            "site:wandaloo.com/neuf/maroc importateur showroom Maroc",
+            "site:wandaloo.com/neuf/maroc utilitaire concessionnaire Maroc",
+        ],
+    ),
+    SourceConfig(
+        name="Kerix",
+        domains=("kerix.net",),
+        start_urls=["https://www.kerix.net/fr/annuaire-entreprise/automobiles.html"],
+        queries=[
+            "site:kerix.net Maroc automobiles importation concessionnaire",
+            "site:kerix.net Maroc concessionnaires régionaux automobiles",
+            "site:kerix.net Maroc vehicules utilitaires importateur",
+        ],
+    ),
+    SourceConfig(
+        name="Kompass",
+        domains=("kompass.com",),
+        start_urls=["https://ma.kompass.com/y/importer/a/vehicules-utilitaires/66340/"],
+        queries=[
+            "site:ma.kompass.com Maroc importateur véhicules utilitaires",
+            "site:ma.kompass.com Maroc concessionnaire automobile",
+            "site:ma.kompass.com Maroc distributeur véhicules automobiles",
+        ],
+    ),
+    SourceConfig(
+        name="Maroc Annuaire",
+        domains=("marocannuaire.org",),
+        start_urls=["https://marocannuaire.org/Annuaire/Activite.php?activite=Automobile+%28Concessionnaires%29"],
+        queries=[
+            "site:marocannuaire.org Automobile Concessionnaires Maroc email",
+            "site:marocannuaire.org importateur automobile Maroc email",
+            "site:marocannuaire.org location voitures Maroc entreprise",
+        ],
+    ),
+    SourceConfig(
+        name="Telecontact",
+        domains=("telecontact.ma",),
+        start_urls=["https://www.telecontact.ma/villes/automobiles-agents-concessionnaires.php"],
+        queries=[
+            "site:telecontact.ma automobiles agents concessionnaires Maroc",
+            "site:telecontact.ma concessionnaire automobile Casablanca Maroc",
+            "site:telecontact.ma véhicules utilitaires concessionnaire Maroc",
+        ],
+    ),
+]
+
+
+def clean(value: Any) -> str:
+    return re.sub(r"\s+", " ", str(value or "")).strip()
+
+
+def is_source_url(url: str, source: SourceConfig) -> bool:
+    host = urlparse(str(url or "")).netloc.casefold()
+    return any(domain in host for domain in source.domains)
+
+
+def is_html_candidate(url: str) -> bool:
+    if not url.startswith(("http://", "https://")):
+        return False
+    lowered = url.casefold()
+    blocked_parts = [
+        "facebook.com",
+        "instagram.com",
+        "linkedin.com",
+        "youtube.com",
+        "wa.me",
+        "whatsapp",
+        "google.",
+        "bing.com",
+        "/login",
+        "/signup",
+        "/privacy",
+    ]
+    if any(part in lowered for part in blocked_parts):
+        return False
+    if re.search(r"\.(pdf|jpg|jpeg|png|gif|webp|zip|rar)(?:$|\?)", lowered):
+        return False
+    return True
+
+
+def find_workbook(base_dir: Path = DEFAULT_EXCEL_DIR) -> Path:
+    candidates: List[Path] = []
+    for path in base_dir.glob("*.xlsx"):
+        if path.name.startswith("~$") or "_backup_" in path.name or "_with_" in path.name:
+            continue
+        try:
+            workbook = load_workbook(path, read_only=True, data_only=False)
+            if any(sheet in workbook.sheetnames for sheet in ["Facebook", "Google Maps", "LinkedIn", "本地汽车网站"]):
+                candidates.append(path)
+            workbook.close()
+        except Exception:
+            continue
+    if not candidates:
+        raise FileNotFoundError("未找到摩洛哥客户建联表")
+    return sorted(candidates, key=lambda p: p.stat().st_mtime, reverse=True)[0]
+
+
+def get_active_profile(base_url: str) -> Tuple[str, str]:
+    resp = requests.get(f"{base_url.rstrip('/')}/api/v1/browser/local-active", timeout=10)
+    resp.raise_for_status()
+    data = resp.json()
+    active = ((data.get("data") or {}).get("list") or [])
+    if not active:
+        raise RuntimeError("未发现已打开的 AdsPower 浏览器配置")
+    item = active[0]
+    ws = (item.get("ws") or {}).get("puppeteer") or (item.get("ws") or {}).get("selenium")
+    if not ws:
+        raise RuntimeError(f"已打开配置没有 ws endpoint: {item}")
+    return item.get("user_id", ""), ws
+
+
+def dismiss_dialogs(page: Page) -> None:
+    labels = [
+        "Accept all",
+        "Tout accepter",
+        "J'accepte",
+        "Accepter",
+        "Reject all",
+        "Plus tard",
+        "Fermer",
+        "Close",
+        "OK",
+    ]
+    for label in labels:
+        try:
+            page.get_by_text(label, exact=False).first.click(timeout=800)
+            time.sleep(0.3)
+        except Exception:
+            pass
+
+
+def safe_body_text(page: Page, timeout: int = 12000) -> str:
+    try:
+        return page.locator("body").inner_text(timeout=timeout)
+    except Exception:
+        return ""
+
+
+def page_links(page: Page) -> List[Dict[str, str]]:
+    try:
+        return page.evaluate(
+            """
+            () => Array.from(document.querySelectorAll('a[href]')).map((a) => ({
+              href: a.href || '',
+              raw: a.getAttribute('href') || '',
+              text: (a.innerText || '').trim(),
+              aria: (a.getAttribute('aria-label') || '').trim()
+            }))
+            """
+        )
+    except Exception:
+        return []
+
+
+def collect_bing_results(page: Page, source: SourceConfig, query: str, limit: int) -> List[Dict[str, Any]]:
+    url = f"https://www.bing.com/search?q={quote_plus(query)}"
+    print(f"搜索 {source.name}: {query}", flush=True)
+    page.goto(url, wait_until="domcontentloaded", timeout=60000)
+    time.sleep(random.uniform(2.5, 4.0))
+    dismiss_dialogs(page)
+    for _ in range(2):
+        page.mouse.wheel(0, 1000)
+        time.sleep(random.uniform(0.7, 1.2))
+    links = page.evaluate(
+        """
+        () => Array.from(document.querySelectorAll('li.b_algo h2 a, a[href]')).map((a) => ({
+          href: a.href || '',
+          text: (a.innerText || a.getAttribute('aria-label') || '').trim()
+        }))
+        """
+    )
+    results: List[Dict[str, Any]] = []
+    seen: Set[str] = set()
+    for item in links:
+        href = item.get("href", "")
+        if not is_source_url(href, source) or not is_html_candidate(href):
+            continue
+        key = dc.normalize_url(href)
+        if not key or key in seen:
+            continue
+        seen.add(key)
+        results.append({
+            "url": href,
+            "title": clean(item.get("text")),
+            "source": source.name,
+            "source_queries": [query],
+            "collection_method": "bing",
+        })
+        if len(results) >= limit:
+            break
+    return results
+
+
+def collect_start_url_links(page: Page, source: SourceConfig, start_url: str, limit: int) -> List[Dict[str, Any]]:
+    print(f"打开来源页 {source.name}: {start_url}", flush=True)
+    page.goto(start_url, wait_until="domcontentloaded", timeout=60000)
+    time.sleep(random.uniform(2.5, 4.0))
+    dismiss_dialogs(page)
+    for _ in range(3):
+        page.mouse.wheel(0, 1200)
+        time.sleep(random.uniform(0.7, 1.2))
+    body = safe_body_text(page)[:3000]
+    results: List[Dict[str, Any]] = [{
+        "url": page.url,
+        "title": clean(page.title()),
+        "text_hint": body,
+        "source": source.name,
+        "source_queries": [start_url],
+        "collection_method": "start_url",
+    }]
+    seen = {dc.normalize_url(page.url)}
+    for item in page_links(page):
+        href = urljoin(page.url, item.get("href", ""))
+        if not is_source_url(href, source) or not is_html_candidate(href):
+            continue
+        label = clean(" ".join([item.get("text", ""), item.get("aria", "")]))
+        if not label or len(label) > 120:
+            continue
+        key = dc.normalize_url(href)
+        if not key or key in seen:
+            continue
+        if not any(term in f"{label} {href}".casefold() for term in ["concession", "auto", "voiture", "garage", "import", "vehicule", "véhicule", "dealer", "annuaire", "societe", "entreprise"]):
+            continue
+        seen.add(key)
+        results.append({
+            "url": href,
+            "title": label,
+            "source": source.name,
+            "source_queries": [start_url],
+            "collection_method": "start_url_link",
+        })
+        if len(results) >= limit:
+            break
+    return results
+
+
+def merge_candidates(candidates: Iterable[Dict[str, Any]]) -> List[Dict[str, Any]]:
+    merged: Dict[str, Dict[str, Any]] = {}
+    for candidate in candidates:
+        key = dc.normalize_url(candidate.get("url", "")) or dc.normalized_company_key(candidate.get("title"))
+        if not key:
+            continue
+        if key not in merged:
+            merged[key] = candidate
+            continue
+        existing = merged[key]
+        existing["source_queries"] = sorted(set(existing.get("source_queries", []) + candidate.get("source_queries", [])))
+        if candidate.get("text_hint"):
+            existing["text_hint"] = clean(" ".join([existing.get("text_hint", ""), candidate.get("text_hint", "")]))[:4000]
+    return list(merged.values())
+
+
+def extract_company_name(page: Page, candidate: Dict[str, Any], body: str) -> str:
+    for selector in ["h1", "h2"]:
+        try:
+            value = clean(page.locator(selector).first.inner_text(timeout=1500))
+            if value and len(value) <= 100:
+                return re.sub(r"\s*[-|].*$", "", value).strip()
+        except Exception:
+            pass
+    title = clean(page.title() or candidate.get("title", ""))
+    title = re.sub(r"\s*[-|]\s*(Kerix|Kompass|Telecontact|Wandaloo|OtoMoto|Maroc Annuaire).*$", "", title, flags=re.I)
+    return title[:100] or clean(candidate.get("title", ""))[:100]
+
+
+def extract_external_websites(current_url: str, links: List[Dict[str, str]], source: SourceConfig) -> List[str]:
+    websites: List[str] = []
+    source_hosts = set(source.domains)
+    for item in links:
+        href = urljoin(current_url, item.get("href") or item.get("raw") or "")
+        if not dc.is_external_business_url(href):
+            continue
+        host = urlparse(href).netloc.casefold().removeprefix("www.")
+        if any(domain in host for domain in source_hosts):
+            continue
+        label = clean(" ".join([item.get("text", ""), item.get("aria", ""), item.get("raw", "")])).casefold()
+        if any(term in label for term in ["site web", "website", "web", "www", "visiter", "voir le site", "site internet"]) or len(websites) < 2:
+            normalized = href.split("#")[0]
+            if normalized not in websites:
+                websites.append(normalized)
+    return websites[:3]
+
+
+def same_host(url: str, base_url: str) -> bool:
+    return urlparse(url).netloc.casefold().removeprefix("www.") == urlparse(base_url).netloc.casefold().removeprefix("www.")
+
+
+def scrape_website_email(context: BrowserContext, website_url: str, max_pages: int = 4) -> Dict[str, Any]:
+    if not website_url or not dc.is_external_business_url(website_url):
+        return {"email": "", "emails": [], "sources": [], "checked_urls": []}
+    checked: List[str] = []
+    queue: List[str] = [website_url]
+    emails: List[str] = []
+    sources: List[str] = []
+    page = context.new_page()
+    page.set_viewport_size({"width": 1280, "height": 850})
+    try:
+        while queue and len(checked) < max_pages:
+            url = dc.normalize_website_url(queue.pop(0))
+            if url in checked or (checked and not same_host(url, website_url)):
+                continue
+            checked.append(url)
+            try:
+                print(f"    检查官网邮箱: {url}", flush=True)
+                page.goto(url, wait_until="domcontentloaded", timeout=35000)
+                time.sleep(random.uniform(1.2, 2.2))
+                dismiss_dialogs(page)
+                body = safe_body_text(page)
+                links = page_links(page)
+                mailto_text = " ".join(item.get("raw", "") for item in links if item.get("raw", "").casefold().startswith("mailto:"))
+                for email in dc.extract_emails(body + " " + mailto_text):
+                    if email not in emails:
+                        emails.append(email)
+                        sources.append(url)
+                if emails:
+                    break
+                for item in links:
+                    label = clean(" ".join([item.get("raw", ""), item.get("text", ""), item.get("aria", "")])).casefold()
+                    if not any(hint in label for hint in CONTACT_LINK_HINTS):
+                        continue
+                    href = dc.normalize_website_url(item.get("href") or item.get("raw") or "", page.url)
+                    if href and href not in checked and href not in queue and same_host(href, website_url):
+                        queue.append(href)
+            except Exception:
+                continue
+    finally:
+        try:
+            page.close()
+        except Exception:
+            pass
+    return {"email": emails[0] if emails else "", "emails": emails, "sources": sources, "checked_urls": checked}
+
+
+def score_record(name: str, url: str, body: str, source: str) -> Tuple[int, List[str], List[str]]:
+    combined = f"{name} {url} {body}".casefold()
+    score = 0
+    reasons: List[str] = []
+    risks: List[str] = []
+
+    sales_terms = [term for term in ["véhicules neufs", "vehicules neufs", "voitures neuves", "concessionnaire", "showroom", "vente automobile", "stock", "parc auto", "professionnel"] if term in combined]
+    import_terms = [term for term in ["importation", "importateur", "importateurs", "distributeur", "distributeurs", "réseau", "reseau", "points de vente", "succursales", "agences"] if term in combined]
+    multibrand_terms = [term for term in ["multimarque", "multi-brand", "multi brand", "plusieurs marques", "marques multiples"] if term in combined]
+    rental_terms = [term for term in ["location", "lld", "leasing", "flotte", "fleet"] if term in combined]
+    service_terms = [term for term in ["garage réparation", "garage reparation", "diagnostic", "pièces détachées", "pieces detachees", "pneus", "tires", "lavage", "car wash", "pare-brise", "assurance", "immobilier", "emploi"] if term in combined]
+
+    has_actual_channel = bool(sales_terms or import_terms or multibrand_terms)
+    if sales_terms:
+        score += 3
+        reasons.append("实际新整车销售/showroom/库存信号: " + ", ".join(sales_terms[:5]))
+    if import_terms:
+        score += 3
+        reasons.append("进口/分销/网络能力信号: " + ", ".join(import_terms[:5]))
+    if multibrand_terms:
+        score += 2
+        reasons.append("多品牌经营信号: " + ", ".join(multibrand_terms[:4]))
+    if source in {"Kerix", "Kompass", "Maroc Annuaire", "Telecontact"}:
+        score += 1
+        reasons.append("annuaire professionnel")
+    if re.search(r"(?:\+212|0)\s?\d[\d\s.-]{6,}\d", body):
+        score += 2
+        reasons.append("可建联入口: 电话/WhatsApp")
+    if dc.extract_emails(body):
+        score += 2
+        reasons.append("可建联入口: email public")
+    if any(term in combined for term in ["facebook.com", "linkedin.com", "whatsapp", "wa.me", "contact"]):
+        score += 1
+        reasons.append("可建联入口: social/contact link")
+    if any(term in combined for term in ["stock", "annonces", "véhicules disponibles", "vehicules disponibles", "parc"]):
+        score += 1
+        reasons.append("stock véhicules")
+
+    if service_terms:
+        score -= 6
+        risks.append("纯维修/配件/轮胎/服务类,不纳入汽车渠道合作伙伴: " + ", ".join(service_terms[:5]))
+    if rental_terms and not has_actual_channel:
+        score += 1
+        risks.append("纯租赁/车队线索,转入批量采购与运营客户/汽车租赁公司: " + ", ".join(rental_terms[:4]))
+    new_vehicle_needs_review = False
+    if not has_actual_channel and not rental_terms:
+        score -= 3
+        risks.append("未发现新整车销售、进口、分销或 showroom 证据")
+        new_vehicle_needs_review = True
+
+    if new_vehicle_needs_review and dc.should_request_manual_review(score, "preview_only", reasons):
+        dc.add_manual_review_flag(risks, "new_vehicle")
+
+    return score, reasons, risks
+
+
+def summarize_record(
+    source: str,
+    score: int,
+    reasons: List[str],
+    risks: List[str],
+    page_url: str,
+    website: str,
+    email_source: str,
+    queries: List[str],
+) -> str:
+    capability = "批量采购能力判断:"
+    reason_text = "、".join(reasons[:8]) if reasons else "证据不足,低优先级;如无更多渠道价值证据应跳过"
+    if any(term in reason_text for term in ["multi-site", "stock", "importation", "distributeur", "utilitaire", "flotte", "location"]):
+        capability += "有批量/车队/分销潜力"
+    else:
+        capability += "待确认"
+    parts = [
+        f"来源线索:{source};详情页 {page_url}",
+        f"主营业务判断:{reason_text}",
+        capability,
+        f"库存/门店/租赁/进口证据:{reason_text}",
+        f"联系方式证据:官网 {website}" if website else "联系方式证据:未发现独立官网",
+        f"邮箱来源:{email_source}" if email_source else "邮箱来源:未发现公开邮箱",
+        f"来源搜索词:{', '.join(queries[:3])}",
+        f"评分:{score}",
+    ]
+    if risks:
+        parts.append("风险/待确认项:" + "、".join(risks[:5]))
+    return " | ".join(parts)[:1400]
+
+
+def deep_scrape_candidate(page: Page, context: BrowserContext, candidate: Dict[str, Any], source: SourceConfig) -> Optional[Dict[str, Any]]:
+    url = candidate["url"]
+    print(f"深采 {source.name}: {url}", flush=True)
+    try:
+        page.goto(url, wait_until="domcontentloaded", timeout=60000)
+        time.sleep(random.uniform(2.0, 3.4))
+        dismiss_dialogs(page)
+        body = safe_body_text(page)
+    except Exception as exc:
+        return {
+            "_skip": True,
+            "_skip_reason": f"打开失败: {exc}",
+            "url": url,
+            "source": source.name,
+        }
+
+    if not body or len(body) < 80:
+        return {"_skip": True, "_skip_reason": "页面正文过短", "url": url, "source": source.name}
+
+    name = extract_company_name(page, candidate, body)
+    if not name or len(name) < 2:
+        return {"_skip": True, "_skip_reason": "未识别公司名", "url": url, "source": source.name}
+
+    is_oem, brand = dc.looks_like_oem_local_branch(name, url, body)
+    if is_oem:
+        return {"_skip": True, "_skip_reason": f"疑似品牌官方国家页: {brand}", "url": url, "source": source.name}
+
+    score, reasons, risks = score_record(name, url, body, source.name)
+    if score < 3:
+        return {"_skip": True, "_skip_reason": f"评分过低: {score}", "url": url, "source": source.name}
+
+    links = page_links(page)
+    websites = extract_external_websites(page.url, links, source)
+    website = websites[0] if websites else ""
+    page_emails = dc.extract_emails(body + " " + " ".join(item.get("raw", "") for item in links))
+    website_email_result = scrape_website_email(context, website) if website else {"email": "", "emails": [], "sources": [], "checked_urls": []}
+    email = website_email_result.get("email") or (page_emails[0] if page_emails else "")
+    email_source = ""
+    if website_email_result.get("email"):
+        email_source = "官网公开页面 " + ", ".join(website_email_result.get("sources", [])[:2])
+    elif email:
+        email_source = "来源网站页面公开文本"
+
+    combined_text = f"{name}\n{body}\n{' '.join(candidate.get('source_queries', []))}"
+    phone = dc.extract_phone(body)
+    city = dc.extract_city(combined_text, fallback=DEFAULT_CITY)
+    business = dc.summarize_business(combined_text)
+    customer_type = dc.classify_customer_type(combined_text)
+    note = summarize_record(
+        source=source.name,
+        score=score,
+        reasons=reasons,
+        risks=risks,
+        page_url=page.url,
+        website=website,
+        email_source=email_source,
+        queries=candidate.get("source_queries", []),
+    )
+    return {
+        "序号": "",
+        "客户姓名/公司": name,
+        "国家": DEFAULT_COUNTRY,
+        "城市": city,
+        "客户类型": customer_type,
+        "主页/链接": page.url,
+        "来源网站": source.name,
+        "联系人": "",
+        "职位": "",
+        "电话/WhatsApp": phone,
+        "邮箱": email,
+        "主营业务": business,
+        "建联状态": "未联系",
+        "下次跟进": "",
+        "备注": note,
+        "_score": score,
+        "_score_reasons": reasons,
+        "_risk_flags": risks,
+        "_website": website,
+        "_website_email_result": website_email_result,
+    }
+
+
+def split_multi(value: str) -> List[str]:
+    return [clean(part) for part in re.split(r"[;;|]+", str(value or "")) if clean(part)]
+
+
+def append_unique_text(old: str, addition: str, separator: str = " | ") -> str:
+    old = clean(old)
+    addition = clean(addition)
+    if not addition:
+        return old
+    if not old:
+        return addition
+    if addition in old:
+        return old
+    return (old + separator + addition)[:3000]
+
+
+def merge_sources(old: str, addition: str) -> str:
+    values = []
+    for item in split_multi(old) + split_multi(addition):
+        if item and item not in values:
+            values.append(item)
+    return ";".join(values)
+
+
+def duplicate_key(record: Dict[str, Any]) -> List[str]:
+    keys = []
+    website = record.get("_website") or ""
+    if website:
+        keys.append("website:" + dc.normalize_url(website))
+    name = record.get("客户姓名/公司")
+    if name:
+        keys.append("name:" + dc.normalized_company_key(name))
+    for field in ["电话/WhatsApp", "邮箱", "主页/链接"]:
+        value = clean(record.get(field))
+        if value:
+            normalized = dc.normalize_url(value) if field == "主页/链接" else value.casefold()
+            keys.append(f"{field}:{normalized}")
+    return [key for key in keys if key and not key.endswith(":")]
+
+
+def prepare_sheet(workbook: Workbook, sheet_name: str):
+    if sheet_name in workbook.sheetnames:
+        ws = workbook[sheet_name]
+        for idx, header in enumerate(HEADERS, start=1):
+            ws.cell(row=1, column=idx).value = header
+        return ws
+    ws = workbook.create_sheet(sheet_name)
+    ws.append(HEADERS)
+    return ws
+
+
+def read_existing_sheet_index(ws) -> Dict[str, int]:
+    index: Dict[str, int] = {}
+    header_map = {clean(ws.cell(1, col).value): col for col in range(1, ws.max_column + 1)}
+    for row in range(2, ws.max_row + 1):
+        record = {header: ws.cell(row, col).value for header, col in header_map.items()}
+        for key in duplicate_key(record):
+            index[key] = row
+    return index
+
+
+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))
+
+    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),
+    }
+
+
+def collect_records(
+    profile_id: str,
+    ws_endpoint: str,
+    max_per_source: int,
+    max_candidates_per_source: int,
+    queries_per_source: int,
+    start_urls_per_source: int,
+) -> Dict[str, Any]:
+    playwright = sync_playwright().start()
+    browser = playwright.chromium.connect_over_cdp(ws_endpoint)
+    context = browser.contexts[0] if browser.contexts else browser.new_context()
+    page = context.new_page()
+    page.set_viewport_size({"width": 1366, "height": 850})
+    source_logs: List[Dict[str, Any]] = []
+    records: List[Dict[str, Any]] = []
+    skipped: List[Dict[str, Any]] = []
+
+    try:
+        for source in SOURCES:
+            raw: List[Dict[str, Any]] = []
+            errors: List[str] = []
+            try:
+                for start_url in source.start_urls[:start_urls_per_source]:
+                    raw.extend(collect_start_url_links(page, source, start_url, max_candidates_per_source))
+                    time.sleep(random.uniform(1.2, 2.0))
+                for query in source.queries[:queries_per_source]:
+                    raw.extend(collect_bing_results(page, source, query, max_candidates_per_source))
+                    time.sleep(random.uniform(1.5, 2.5))
+            except Exception as exc:
+                errors.append(str(exc))
+
+            candidates = merge_candidates(raw)[:max_candidates_per_source]
+            source_records: List[Dict[str, Any]] = []
+            for candidate in candidates:
+                if len(source_records) >= max_per_source:
+                    break
+                result = deep_scrape_candidate(page, context, candidate, source)
+                if not result:
+                    continue
+                if result.get("_skip"):
+                    skipped.append(result)
+                    continue
+                source_records.append(result)
+                records.append(result)
+                time.sleep(random.uniform(1.4, 2.4))
+            source_logs.append({
+                "source": source.name,
+                "raw_candidates": len(raw),
+                "merged_candidates": len(candidates),
+                "records": len(source_records),
+                "errors": errors,
+            })
+    finally:
+        try:
+            page.close()
+        except Exception:
+            pass
+        playwright.stop()
+
+    return {
+        "generated_at": datetime.now(timezone.utc).isoformat(),
+        "profile_id": profile_id,
+        "records": records,
+        "skipped": skipped,
+        "source_logs": source_logs,
+    }
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="Collect Morocco auto website leads via AdsPower")
+    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("--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)
+    parser.add_argument("--start-urls-per-source", type=int, default=1)
+    parser.add_argument("--run-id", default="", help="Run ID used for runs/YYYYMMDD/<run_id>/ artifacts.")
+    parser.add_argument("--output", default="auto_website_leads.json")
+    parser.add_argument("--write-excel", action="store_true")
+    args = parser.parse_args()
+
+    if args.profile_id:
+        ws_endpoint = dc.get_active_ws_endpoint(args.ads_power_url, args.profile_id)
+        profile_id = args.profile_id
+    else:
+        profile_id, ws_endpoint = get_active_profile(args.ads_power_url)
+
+    workbook_info = resolve_workbook_path(args.excel, create_from_template=args.write_excel)
+    excel_path = workbook_info.get("path")
+    if excel_path and (args.write_excel or workbook_info.get("source") != "missing"):
+        print(f"Workbook resolved: {excel_path} ({workbook_info['source']})", flush=True)
+    result = collect_records(
+        profile_id=profile_id,
+        ws_endpoint=ws_endpoint,
+        max_per_source=args.max_per_source,
+        max_candidates_per_source=args.max_candidates_per_source,
+        queries_per_source=max(0, args.queries_per_source),
+        start_urls_per_source=max(0, args.start_urls_per_source),
+    )
+    result["summary"] = {
+        "record_count": len(result["records"]),
+        "records_with_email": len([r for r in result["records"] if r.get("邮箱")]),
+        "sources": {log["source"]: log["records"] for log in result["source_logs"]},
+        "skipped_count": len(result["skipped"]),
+        "excel": str(excel_path) if excel_path else "",
+        "sheet": args.sheet,
+    }
+
+    output_path = resolve_artifact_path(args.output, kind="scraper_preview", default_name=Path(args.output).name, run_id=args.run_id or None)
+    output_path.parent.mkdir(parents=True, exist_ok=True)
+    output_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
+    print(f"已保存采集结果: {output_path}", flush=True)
+
+    if args.write_excel:
+        if not excel_path:
+            raise SystemExit("使用 --write-excel 时必须提供 --excel 工作簿路径,或在当前目录放置可识别的建联表。")
+        write_result = write_records_to_workbook(excel_path, args.sheet, result["records"])
+        result["write_result"] = write_result
+        output_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
+        print("Excel 写入结果: " + json.dumps(write_result, ensure_ascii=False), flush=True)
+    else:
+        print("预览模式:未写入 Excel。", flush=True)
+
+
+if __name__ == "__main__":
+    main()
+

+ 470 - 0
scripts/scraper/search_facebook.py

@@ -0,0 +1,470 @@
+"""
+Facebook 经销商搜索与采集
+使用 Playwright + AdsPower 指纹浏览器
+"""
+import re
+import time
+import random
+import argparse
+import json
+from typing import List, Dict, Any, Optional
+from urllib.parse import urlencode, urlparse, parse_qs
+from pathlib import Path
+
+import sys
+sys.path.append(str(Path(__file__).resolve().parents[1]))
+from common.artifact_manager import resolve_artifact_path, create_backup_once
+
+from playwright.sync_api import Page, TimeoutError as PlaywrightTimeout
+
+try:
+    from deep_translator import GoogleTranslator
+    TRANSLATOR_AVAILABLE = True
+except ImportError:
+    TRANSLATOR_AVAILABLE = False
+    GoogleTranslator = None
+
+# 导入同目录或上级 common 模块
+try:
+    from .ads_power_client import AdsPowerClient
+    from .website_deep_scraper import build_chinese_notes, classify_customer_type_cn, extract_company_website_from_facebook, scrape_public_website, summarize_business_cn
+    from ..common import deduplicate_records, enrich_record_with_brands, append_records, resolve_workbook_path
+except ImportError:
+    import sys
+    sys.path.insert(0, str(Path(__file__).parent.parent))
+    from scraper.ads_power_client import AdsPowerClient
+    from scraper.website_deep_scraper import build_chinese_notes, classify_customer_type_cn, extract_company_website_from_facebook, scrape_public_website, summarize_business_cn
+    from common import deduplicate_records, enrich_record_with_brands, append_records, resolve_workbook_path
+
+
+def is_mostly_chinese(text: str) -> bool:
+    """判断文本是否主要为中文"""
+    if not text:
+        return False
+    chinese_chars = sum(1 for c in text if "\u4e00" <= c <= "\u9fff")
+    return chinese_chars / max(len(text), 1) > 0.3
+
+
+def translate_to_chinese(text: str) -> str:
+    """将文本翻译为中文;失败或已是中文时返回原文"""
+    if not text or len(text.strip()) < 3:
+        return text
+    if is_mostly_chinese(text):
+        return text
+    if not TRANSLATOR_AVAILABLE:
+        return text
+    try:
+        translated = GoogleTranslator(source="auto", target="zh-CN").translate(text)
+        time.sleep(0.3)
+        return translated or text
+    except Exception as exc:
+        print(f"翻译失败,保留原文: {exc}")
+        return text
+
+
+class FacebookScraper:
+    """Facebook 经销商搜索采集器"""
+
+    def __init__(
+        self,
+        ads_power_url: str = "http://127.0.0.1:50325",
+        api_key: str = "",
+        profile_id: str = "",
+        max_results: int = 20,
+        scroll_attempts: int = 5,
+        headless: bool = False
+    ):
+        self.ads_power_url = ads_power_url
+        self.api_key = api_key
+        self.profile_id = profile_id
+        self.max_results = max_results
+        self.scroll_attempts = scroll_attempts
+        self.headless = headless
+        self.client: Optional[AdsPowerClient] = None
+        self.page: Optional[Page] = None
+
+    def start(self):
+        """启动浏览器"""
+        self.client = AdsPowerClient(self.ads_power_url, api_key=self.api_key)
+        self.client.start_browser(self.profile_id, headless=self.headless)
+        self.page = self.client.get_open_page() or self.client.new_page()
+        self.page.set_viewport_size({"width": 1280, "height": 800})
+
+    def stop(self):
+        """断开 Playwright 连接,浏览器保持打开"""
+        if self.client:
+            self.client.close_browser()
+
+    def search(self, query: str, region: str = "") -> List[Dict[str, Any]]:
+        """
+        在 Facebook 搜索经销商主页
+        """
+        search_query = f"{query} {region}".strip() if region else query
+        encoded_query = urlencode({"q": search_query})
+        search_url = f"https://www.facebook.com/search/pages/?{encoded_query}"
+
+        print(f"访问搜索页: {search_url}")
+        self.page.goto(search_url, wait_until="domcontentloaded", timeout=60000)
+
+        # 等待页面加载
+        time.sleep(random.uniform(3, 5))
+
+        # 处理可能的登录弹窗或提示
+        self._dismiss_dialogs()
+
+        # 滚动加载结果
+        page_links = self._collect_page_links()
+
+        print(f"收集到 {len(page_links)} 个主页链接")
+
+        # 采集每个主页详情
+        records = []
+        for idx, link in enumerate(page_links[:self.max_results], 1):
+            print(f"[{idx}/{min(len(page_links), self.max_results)}] 采集: {link}")
+            try:
+                record = self._scrape_page(link)
+                if record.get("客户姓名/公司"):
+                    record["主页/链接"] = link
+                    record["国家"] = "摩洛哥"  # 默认,后续可按 region 推断
+                    record["城市"] = region
+                    record = enrich_record_with_brands(record)
+                    records.append(record)
+            except Exception as e:
+                print(f"采集失败 {link}: {e}")
+            # 随机间隔,降低风控
+            time.sleep(random.uniform(2, 4))
+
+        return deduplicate_records(records)
+
+    def _dismiss_dialogs(self):
+        """尝试关闭可能的弹窗"""
+        try:
+            # 常见的 cookie/登录提示关闭按钮
+            selectors = [
+                '[aria-label="关闭"]',
+                '[aria-label="Close"]',
+                '[role="button"][tabindex="0"]',
+            ]
+            for sel in selectors:
+                buttons = self.page.query_selector_all(sel)
+                for btn in buttons:
+                    text = btn.inner_text() if btn else ""
+                    if any(k in text.lower() for k in ["close", "关闭", "not now", "以后再说", "ok"]):
+                        btn.click()
+                        time.sleep(0.5)
+        except Exception:
+            pass
+
+    def _collect_page_links(self) -> List[str]:
+        """从搜索结果页收集主页链接"""
+        links = set()
+
+        for attempt in range(self.scroll_attempts):
+            # 获取当前页所有可能的页面链接
+            elements = self.page.query_selector_all('a[href*="/"]')
+            for el in elements:
+                href = el.get_attribute("href")
+                if href and self._is_page_link(href):
+                    links.add(self._normalize_facebook_url(href))
+
+            print(f"滚动 {attempt + 1}/{self.scroll_attempts}, 当前收集 {len(links)} 个链接")
+
+            if len(links) >= self.max_results:
+                break
+
+            # 滚动到底部
+            self.page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
+            time.sleep(random.uniform(2, 4))
+
+        return list(links)
+
+    def _is_page_link(self, href: str) -> bool:
+        """判断是否是 Facebook 主页链接"""
+        parsed = urlparse(href)
+        if parsed.netloc not in ["www.facebook.com", "facebook.com", "m.facebook.com"]:
+            return False
+        # 排除搜索、个人资料、帖子等
+        excluded_patterns = [
+            "/search/", "/watch", "/groups", "/marketplace",
+            "/messages", "/notifications", "/settings",
+            "/photo.php", "/videos", "/posts", "/reel",
+            "/profile.php"
+        ]
+        for pattern in excluded_patterns:
+            if pattern in parsed.path:
+                return False
+        # 主页链接通常是 /xxx/ 形式
+        path = parsed.path.strip("/")
+        if not path or "/" in path:
+            return False
+        return True
+
+    def _normalize_facebook_url(self, url: str) -> str:
+        """标准化 Facebook URL"""
+        parsed = urlparse(url)
+        path = parsed.path.strip("/").split("/")[0]
+        return f"https://www.facebook.com/{path}/"
+
+    def _scrape_page(self, page_url: str) -> Dict[str, Any]:
+        """采集单个 Facebook 主页信息"""
+        record = {
+            "客户姓名/公司": "",
+            "客户类型": "",
+            "公司官网": "",
+            "联系人": "",
+            "职位": "",
+            "电话/WhatsApp": "",
+            "邮箱": "",
+            "主营业务": "",
+            "建联状态": "未联系",
+            "下次跟进": "",
+            "备注": "",
+        }
+
+        self.page.goto(page_url, wait_until="domcontentloaded", timeout=60000)
+        time.sleep(random.uniform(3, 5))
+
+        # 1. 先读取主页联络资料:官网、电话、邮箱、粉丝数和简介。About 只能补充,不覆盖主页已确认字段。
+        home_page_text = ""
+        try:
+            home_page_text = self.page.inner_text("body")
+        except Exception:
+            pass
+        website_from_home = extract_company_website_from_facebook(self.page)
+        if website_from_home:
+            record["公司官网"] = website_from_home
+            print(f"从主页联络资料提取到公司官网: {website_from_home}")
+        home_phone = self._extract_phone(home_page_text)
+        if home_phone:
+            record["电话/WhatsApp"] = home_phone
+            print(f"从主页联络资料提取到电话: {home_phone}")
+        home_email = self._extract_email(home_page_text)
+        if home_email:
+            record["邮箱"] = home_email
+            print(f"从主页联络资料提取到邮箱: {home_email}")
+        followers_from_home = self._extract_followers(home_page_text)
+
+        # 2. 页面标题作为公司名称
+        try:
+            title = self.page.title()
+            # Facebook 标题通常是 "Page Name | Facebook"
+            record["客户姓名/公司"] = title.split("|")[0].strip() if "|" in title else title.replace("Facebook", "").strip()
+        except Exception:
+            pass
+
+        # 3. 尝试多种方式获取页面名称
+        name_selectors = [
+            'h1',
+            '[role="main"] h2',
+            '[data-pagelet="ProfileActions"] h1',
+            'a[aria-label]',
+        ]
+        for sel in name_selectors:
+            try:
+                el = self.page.query_selector(sel)
+                if el:
+                    text = el.inner_text().strip()
+                    if text and len(text) < 100 and "Facebook" not in text:
+                        record["客户姓名/公司"] = text
+                        break
+            except Exception:
+                continue
+
+        # 4. 主页联络资料读取完成后,再获取 About 信息
+        about_url = f"{page_url.rstrip('/')}/about/"
+        self.page.goto(about_url, wait_until="domcontentloaded", timeout=60000)
+        time.sleep(random.uniform(2, 4))
+
+        page_text = ""
+        try:
+            page_text = self.page.inner_text("body")
+        except Exception:
+            pass
+
+        about_website = extract_company_website_from_facebook(self.page)
+        if about_website:
+            if not record.get("公司官网"):
+                record["公司官网"] = about_website
+            elif about_website not in record["公司官网"]:
+                record["公司官网"] = record["公司官网"] + ";" + about_website
+        company_website = record.get("公司官网", "").split(";")[0].strip()
+
+        website_result = {"email": "", "phone": "", "text": "", "business_summary": "", "evidence_notes": []}
+        if company_website:
+            try:
+                print(f"深搜公司官网: {company_website}")
+                website_result = scrape_public_website(self.page.context, company_website, max_pages=5)
+            except Exception as exc:
+                website_result = {"email": "", "phone": "", "text": "", "business_summary": "", "evidence_notes": [f"官网深搜失败:{str(exc)[:80]}"]}
+
+        # 4. 提取电话
+        phone = self._extract_phone(page_text)
+        if phone and not record.get("电话/WhatsApp"):
+            record["电话/WhatsApp"] = phone
+
+        # 5. 提取邮箱,About 只补充空字段
+        email = self._extract_email(page_text)
+        if email and not record.get("邮箱"):
+            record["邮箱"] = email
+
+        if not record.get("邮箱") and website_result.get("email"):
+            record["邮箱"] = website_result.get("email", "")
+        if not record.get("电话/WhatsApp") and website_result.get("phone"):
+            record["电话/WhatsApp"] = website_result.get("phone", "")
+
+        # 6. 提取主营业务/描述并翻译为中文
+        description = self._extract_description(page_text)
+        if description:
+            record["主营业务"] = translate_to_chinese(description)
+
+        # 7. 综合 Facebook 与官网证据推断客户类型和主营业务
+        combined_business_text = "\n".join([home_page_text, page_text, website_result.get("text", "")])
+        record["客户类型"] = classify_customer_type_cn(combined_business_text)
+        record["主营业务"] = summarize_business_cn(combined_business_text)
+
+        # 8. 备注:中文结构化摘要
+        followers = followers_from_home or self._extract_followers(page_text)
+        record["备注"] = build_chinese_notes(
+            facebook_url=page_url,
+            facebook_text="\n".join(["证据采集顺序:主页联络资料 -> About -> 官网深搜", home_page_text, page_text]),
+            website_url=record.get("公司官网", ""),
+            website_result=website_result,
+            followers=followers,
+            post_analysis="",
+            detected_brands=[],
+            exclusivity="",
+        )
+
+        if record.get("备注"):
+            record["备注"] = "证据采集顺序:主页联络资料 -> About -> 官网深搜 | " + record["备注"]
+        return record
+
+    def _extract_phone(self, text: str) -> str:
+        """从文本中提取电话号码"""
+        if not text:
+            return ""
+        patterns = [
+            r'\+212[\s\-]?\d[\s\-]?\d{3}[\s\-]?\d{2}[\s\-]?\d{2}[\s\-]?\d{2}',
+            r'\+212[\s\-]?\d{3}[\s\-]?\d{2}[\s\-]?\d{2}[\s\-]?\d{2}',
+            r'0\d[\s\-]?\d{4}[\s\-]?\d{4}',
+            r'0\d{3}[\s\-]?\d{2}[\s\-]?\d{2}[\s\-]?\d{2}',
+        ]
+        for pattern in patterns:
+            match = re.search(pattern, text)
+            if match:
+                return match.group(0).strip()
+        return ""
+
+    def _extract_email(self, text: str) -> str:
+        """从文本中提取邮箱"""
+        if not text:
+            return ""
+        pattern = r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}'
+        matches = re.findall(pattern, text)
+        # 过滤掉常见非企业邮箱
+        excluded = ["@facebook.com", "@fb.com", "@example.com"]
+        for m in matches:
+            if all(e not in m.lower() for e in excluded):
+                return m.strip()
+        return ""
+
+    def _extract_description(self, text: str) -> str:
+        """提取页面描述/主营业务"""
+        if not text:
+            return ""
+        lines = [line.strip() for line in text.split("\n") if line.strip()]
+        # 找包含汽车相关关键词的短句
+        keywords = ["car", "auto", "voiture", "vehicle", "motor", "dealer", "occasion", "automotive"]
+        for line in lines[:50]:
+            if any(k in line.lower() for k in keywords) and len(line) < 200:
+                return line
+        return ""
+
+    def _extract_followers(self, text: str) -> str:
+        """提取粉丝数"""
+        if not text:
+            return ""
+        patterns = [
+            r'(\d+[\d\s,.]*[KkMm]?)\s*(followers|fans|abonnés|likes)',
+            r'(\d+[\d\s,.]*)\s*(followers|fans|abonnés|likes)',
+        ]
+        for pattern in patterns:
+            match = re.search(pattern, text, re.IGNORECASE)
+            if match:
+                return f"{match.group(1).strip()} {match.group(2)}"
+        return ""
+
+    def _infer_dealer_type(self, text: str) -> str:
+        """根据页面文本推断客户类型"""
+        if not text:
+            return "待判断"
+        text_lower = text.lower()
+        if any(k in text_lower for k in ["used car", "occasion", "二手车", "occaz"]):
+            return "二手车商"
+        if any(k in text_lower for k in ["dealer", "concessionnaire", "distributor", "经销商"]):
+            return "经销商"
+        if any(k in text_lower for k in ["rental", "location", "租车"]):
+            return "租车公司"
+        if any(k in text_lower for k in ["repair", "garage", "维修"]):
+            return "汽车维修"
+        return "待判断"
+
+
+def main():
+    parser = argparse.ArgumentParser(description="Facebook 经销商搜索采集")
+    parser.add_argument("--query", required=True, help="搜索关键词")
+    parser.add_argument("--region", default="", help="地区过滤")
+    parser.add_argument("--profile-id", required=True, help="AdsPower profile ID")
+    parser.add_argument("--ads-power-url", default="http://127.0.0.1:50325", help="AdsPower API URL")
+    parser.add_argument("--api-key", default="", help="AdsPower API Key(如已开启认证)")
+    parser.add_argument("--max-results", type=int, default=10, help="最大采集数")
+    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("--write-excel", action="store_true", help="确认写入建联表;否则只输出 JSON 预览")
+    parser.add_argument("--headless", action="store_true", help="无头模式")
+
+    args = parser.parse_args()
+
+    scraper = FacebookScraper(
+        ads_power_url=args.ads_power_url,
+        api_key=args.api_key,
+        profile_id=args.profile_id,
+        max_results=args.max_results,
+        headless=args.headless
+    )
+
+    records = []
+    try:
+        scraper.start()
+        records = scraper.search(args.query, args.region)
+    finally:
+        scraper.stop()
+
+    # 保存 JSON
+    output_path = resolve_artifact_path(args.output, kind="scraper_preview", default_name=Path(args.output).name, run_id=args.run_id or None)
+    output_path.parent.mkdir(parents=True, exist_ok=True)
+    with open(output_path, "w", encoding="utf-8") as f:
+        json.dump(records, f, ensure_ascii=False, indent=2)
+    print(f"已保存 {len(records)} 条记录到 {output_path}")
+
+    # 可选回写 Excel
+    if args.write_excel:
+        workbook_info = resolve_workbook_path(args.excel, create_from_template=True)
+        args.excel = str(workbook_info["path"])
+        print(f"Workbook for writing: {args.excel} ({workbook_info['source']})")
+        result = append_records(
+            excel_path=args.excel,
+            sheet_name=args.sheet,
+            records=records,
+            dedup_keys=["客户姓名/公司", "城市", "主页/链接"]
+        )
+        print(f"Excel 回写结果: {result}")
+    else:
+        print("默认预览模式:未写入 Excel。确认要入表时再使用 --write-excel。")
+
+
+if __name__ == "__main__":
+    main()
+

+ 528 - 0
scripts/scraper/search_google_maps.py

@@ -0,0 +1,528 @@
+"""
+Google Maps Morocco dealer discovery with public website email extraction.
+
+Preview-first workflow:
+- Connect to an already-open AdsPower browser by profile ID.
+- Search Google Maps with Morocco-wide dealer/importer/showroom keywords.
+- Score and de-duplicate candidates before opening place detail pages.
+- Reject OEM local brand-country pages such as BYD Maroc or BMW Maroc.
+- Optionally deep-scrape place details and merchant websites for public emails.
+- Write to Excel only when --write-excel is explicitly passed.
+"""
+import argparse
+import json
+import random
+import re
+import sys
+import time
+from datetime import datetime, timezone
+from pathlib import Path
+
+import sys
+sys.path.append(str(Path(__file__).resolve().parents[1]))
+from common.artifact_manager import resolve_artifact_path, create_backup_once
+from typing import Any, Dict, List, Optional, Set
+from urllib.parse import parse_qs, quote_plus, urlparse
+
+if hasattr(sys.stdout, "reconfigure"):
+    sys.stdout.reconfigure(encoding="utf-8")
+if hasattr(sys.stderr, "reconfigure"):
+    sys.stderr.reconfigure(encoding="utf-8")
+
+from playwright.sync_api import BrowserContext, Page, sync_playwright
+
+try:
+    from ..common import append_records, resolve_workbook_path
+    from . import discovery_common as dc
+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
+
+DEFAULT_EXCEL = ""
+DEFAULT_SHEET = "Google Maps"
+DEFAULT_CITY_SCOPE = "摩洛哥全国"
+
+NATIONWIDE_KEYWORDS = [
+    "concessionnaire automobile Maroc",
+    "concessionnaire multimarque Maroc",
+    "showroom auto Maroc",
+    "voiture occasion Maroc",
+    "importateur automobile Maroc",
+    "distributeur automobile Maroc",
+    "groupe automobile Maroc",
+    "concessionnaire utilitaire Maroc",
+    "camion Maroc concessionnaire",
+    "voiture chinoise Maroc showroom",
+]
+
+CITY_KEYWORD_PATTERNS = [
+    "concessionnaire automobile {city}",
+    "showroom auto {city}",
+    "voiture occasion {city}",
+    "importateur automobile {city}",
+]
+
+CONTACT_LINK_HINTS = {
+    "contact", "nous contacter", "contactez", "about", "a-propos", "apropos",
+    "à propos", "qui sommes", "mentions", "legal", "devis", "service client",
+}
+
+
+def build_default_keywords() -> List[str]:
+    keywords = list(NATIONWIDE_KEYWORDS)
+    for city in dc.MOROCCO_TARGET_CITIES:
+        for pattern in CITY_KEYWORD_PATTERNS:
+            keywords.append(pattern.format(city=city))
+    deduped: List[str] = []
+    seen: Set[str] = set()
+    for keyword in keywords:
+        key = keyword.casefold()
+        if key not in seen:
+            deduped.append(keyword)
+            seen.add(key)
+    return deduped
+
+
+DEFAULT_KEYWORDS = build_default_keywords()
+
+
+def dismiss_google_dialogs(page: Page) -> None:
+    labels = [
+        "Accept all", "I agree", "Tout accepter", "J'accepte", "Accepter", "Reject all",
+        "Plus tard", "Not now", "Fermer", "Close",
+    ]
+    for label in labels:
+        try:
+            page.get_by_text(label, exact=False).first.click(timeout=1200)
+            time.sleep(0.4)
+        except Exception:
+            pass
+
+
+def clean_place_name(value: str) -> str:
+    text = dc.clean_space(value)
+    text = re.sub(r"\b(Directions|Itinéraire|Website|Site Web|Call|Appeler)\b.*$", "", text, flags=re.I).strip()
+    lines = [dc.clean_space(line) for line in text.splitlines() if dc.clean_space(line)]
+    if lines:
+        text = lines[0]
+    text = re.sub(r"\s+\d(?:[.,]\d)?\s*\(?\d*\)?$", "", text).strip()
+    return text
+
+
+def collect_place_results(page: Page, query: str, max_results: int) -> List[Dict[str, Any]]:
+    search_url = f"https://www.google.com/maps/search/{quote_plus(query)}"
+    print(f"搜索 Google Maps: {query}", flush=True)
+    page.goto(search_url, wait_until="domcontentloaded", timeout=60000)
+    time.sleep(random.uniform(3, 5))
+    dismiss_google_dialogs(page)
+
+    for _ in range(5):
+        try:
+            feed = page.locator('div[role="feed"]').first
+            if feed.count():
+                feed.evaluate("el => el.scrollBy(0, 1400)")
+            else:
+                page.mouse.wheel(0, 1200)
+        except Exception:
+            page.mouse.wheel(0, 1200)
+        time.sleep(random.uniform(1, 1.7))
+
+    raw = page.evaluate(
+        """
+        () => {
+          const anchors = Array.from(document.querySelectorAll('a[href*="/maps/place/"], a[href*="google.com/maps/place/"]'));
+          return anchors.map((a) => {
+            const node = a.closest('[role="article"]') || a.closest('.Nv2PK') || a.parentElement;
+            const text = (node && node.innerText ? node.innerText : a.innerText || '').trim();
+            const label = (a.getAttribute('aria-label') || '').trim();
+            return {href: a.href || '', name: label || '', text};
+          });
+        }
+        """
+    )
+
+    results: List[Dict[str, Any]] = []
+    seen: Set[str] = set()
+    for item in raw:
+        href = dc.normalize_google_maps_url(item.get("href", ""))
+        if not href or href in seen:
+            continue
+        name = clean_place_name(item.get("name") or item.get("text", ""))
+        text = re.sub(r"\n{2,}", "\n", item.get("text", "")).strip()
+        if not name or name.casefold() in {"results", "google maps", "directions"}:
+            continue
+        seen.add(href)
+        results.append({
+            "name": name,
+            "href": item.get("href", ""),
+            "text": text[:1200],
+            "source_queries": [query],
+        })
+        if len(results) >= max_results:
+            break
+    print(f"  收集到 {len(results)} 个地图候选", flush=True)
+    return results
+
+
+def unwrap_google_redirect(url: str) -> str:
+    parsed = urlparse(str(url or ""))
+    if "google." in parsed.netloc.casefold() and parsed.path == "/url":
+        target = parse_qs(parsed.query).get("q", [""])[0]
+        if target:
+            return target
+    return url
+
+
+def extract_website_link(page: Page) -> str:
+    raw_links = page.evaluate(
+        """
+        () => Array.from(document.querySelectorAll('a[href]')).map((a) => ({
+          href: a.href || '',
+          text: (a.innerText || '').trim(),
+          aria: (a.getAttribute('aria-label') || '').trim(),
+          data: (a.getAttribute('data-item-id') || '').trim()
+        }))
+        """
+    )
+    for item in raw_links:
+        href = unwrap_google_redirect(item.get("href", ""))
+        label = " ".join([item.get("text", ""), item.get("aria", ""), item.get("data", "")]).casefold()
+        if not dc.is_external_business_url(href):
+            continue
+        if "authority" in label or "website" in label or "site web" in label:
+            return href
+    for item in raw_links:
+        href = unwrap_google_redirect(item.get("href", ""))
+        if dc.is_external_business_url(href):
+            return href
+    return ""
+
+
+def extract_maps_phone(page: Page, text: str) -> str:
+    try:
+        phone = page.evaluate(
+            """
+            () => {
+              const nodes = Array.from(document.querySelectorAll('button, a'));
+              for (const el of nodes) {
+                const href = el.href || '';
+                const aria = el.getAttribute('aria-label') || '';
+                const text = el.innerText || '';
+                if (href.startsWith('tel:')) return href.replace(/^tel:/, '');
+                const combined = `${aria} ${text}`;
+                const match = combined.match(/(?:\+212|0)\s?\d[\d\s.-]{6,}\d/);
+                if (match) return match[0];
+              }
+              return '';
+            }
+            """
+        )
+        if phone:
+            return re.sub(r"\s+", " ", phone).strip()
+    except Exception:
+        pass
+    return dc.extract_phone(text)
+
+
+def extract_rating(text: str) -> str:
+    match = re.search(r"(\d[.,]\d)\s*\(?\s*(\d+[\d\s,.]*)?\s*(avis|reviews)?", text, re.I)
+    if not match:
+        return ""
+    rating = match.group(1).replace(",", ".")
+    reviews = dc.clean_space(match.group(2) or "")
+    return f"{rating} ({reviews} avis)" if reviews else rating
+
+
+def same_site(url: str, base_url: str) -> bool:
+    host = urlparse(url).netloc.casefold().removeprefix("www.")
+    base_host = urlparse(base_url).netloc.casefold().removeprefix("www.")
+    return bool(host and base_host and host == base_host)
+
+
+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)
+    return {
+        "email": result.get("email", ""),
+        "emails": result.get("emails", []),
+        "sources": result.get("checked_urls", []),
+        "checked_urls": result.get("checked_urls", []),
+        "error": "" if result.get("checked_urls") else "no readable public website pages",
+    }
+
+def extract_place_name(page: Page, fallback: str) -> str:
+    for selector in ["h1", '[role="main"] h1']:
+        try:
+            value = page.locator(selector).first.inner_text(timeout=2500)
+            value = clean_place_name(value)
+            if value:
+                return value
+        except Exception:
+            pass
+    title = page.title().replace(" - Google Maps", "")
+    return clean_place_name(title) or fallback
+
+
+def deep_scrape_place(page: Page, context: BrowserContext, candidate: Dict[str, Any], country: str) -> Optional[Dict[str, Any]]:
+    print(f"深采 Google Maps: {candidate.get('name')}", flush=True)
+    page.goto(candidate["href"], wait_until="domcontentloaded", timeout=60000)
+    time.sleep(random.uniform(3, 5))
+    dismiss_google_dialogs(page)
+
+    try:
+        body_text = page.locator("body").inner_text(timeout=15000)
+    except Exception:
+        body_text = ""
+
+    name = extract_place_name(page, candidate.get("name", ""))
+    name = dc.canonical_dealer_name(name, body_text + "\n" + candidate.get("text", ""))
+    place_url = page.url or candidate.get("href", "")
+    website = extract_website_link(page)
+    phone = extract_maps_phone(page, body_text)
+    rating = extract_rating(body_text)
+    city = dc.extract_city(body_text, fallback=DEFAULT_CITY_SCOPE)
+    combined = "\n".join([name, place_url, body_text, candidate.get("text", ""), " ".join(candidate.get("source_queries", []))])
+
+    is_oem, brand = dc.looks_like_oem_local_branch(name, place_url, body_text)
+    if is_oem:
+        candidate["recommended_action"] = "skip_brand_branch"
+        candidate.setdefault("risk_flags", []).append(f"深采确认疑似品牌官方页: {brand}")
+        dc.add_manual_review_flag(candidate["risk_flags"], "ownership")
+        return None
+
+    website_is_oem, website_brand = dc.looks_like_oem_brand_country_url(website)
+    if not body_text.strip():
+        candidate.setdefault("risk_flags", [])
+        dc.add_manual_review_flag(candidate["risk_flags"], "detail")
+    website_email = scrape_public_email_from_website(context, website) if website else {"email": "", "emails": [], "sources": [], "checked_urls": []}
+    maps_email = dc.extract_email(body_text)
+    if website_is_oem and website_email.get("email"):
+        email = maps_email if maps_email and website_email.get("email") != maps_email else ""
+    else:
+        email = website_email.get("email") or maps_email
+    customer_type = dc.classify_customer_type(combined)
+    business = dc.summarize_business(combined)
+
+    email_note = ""
+    if email:
+        if website_email.get("email") and not website_is_oem:
+            email_note = "邮箱来源:官网公开页面 " + ", ".join(website_email.get("sources", [])[:2])
+        else:
+            email_note = "邮箱来源:Google Maps 页面公开文本"
+    elif website_is_oem and website_email.get("email"):
+        email_note = f"发现 {website_brand} 品牌官网邮箱,未写入客户邮箱;需人工确认独立经销主体联系方式"
+        candidate.setdefault("risk_flags", [])
+        dc.add_manual_review_flag(candidate["risk_flags"], "ownership")
+    elif website:
+        email_note = "官网未发现公开邮箱"
+    else:
+        email_note = "无官网,未发现公开邮箱"
+
+    note_parts = [
+        "Google Maps深采",
+        f"官网:{website}" if website else "无官网",
+        f"电话:{phone}" if phone else "未发现电话",
+        f"评分:{rating}" if rating else "",
+        email_note,
+        f"官网检查页:{', '.join(website_email.get('checked_urls', [])[:3])}" if website_email.get("checked_urls") else "",
+        f"来源搜索词:{', '.join(candidate.get('source_queries', []))}",
+        f"评分:{candidate.get('score')};原因:{'; '.join(candidate.get('score_reasons', []))}",
+    ]
+    if candidate.get("risk_flags"):
+        note_parts.append("风险:" + "; ".join(candidate["risk_flags"]))
+
+    return {
+        "客户姓名/公司": name,
+        "国家": country,
+        "城市": city,
+        "客户类型": customer_type,
+        "主页/链接": place_url,
+        "联系人": "",
+        "职位": "",
+        "电话/WhatsApp": phone,
+        "邮箱": email,
+        "主营业务": business,
+        "建联状态": "未联系",
+        "下次跟进": "",
+        "备注": " | ".join(part for part in note_parts if part)[:1200],
+        "_website": website,
+        "_website_email_result": website_email,
+    }
+
+
+def search_google_maps_dealers(
+    profile_id: str,
+    keywords: Optional[List[str]],
+    ads_power_url: str,
+    excel_path: str,
+    sheet_name: str,
+    max_results: int,
+    max_results_per_query: int,
+    min_score: int,
+    deep_scrape: bool,
+    country: str,
+    no_excel: bool = False,
+) -> Dict[str, Any]:
+    if no_excel:
+        existing_links: Set[str] = set()
+        existing_names: Set[str] = set()
+    else:
+        existing_identity = dc.load_existing_identity(
+            excel_path=excel_path,
+            sheet_name=sheet_name,
+            link_columns=["主页/链接"],
+            name_columns=["客户姓名/公司"],
+        )
+        existing_links = existing_identity["links"]
+        existing_names = existing_identity["names"]
+    print(f"已从 {sheet_name} Sheet 加载 {len(existing_links)} 个 Google Maps 现有链接、{len(existing_names)} 个公司名用于去重", flush=True)
+
+    ws_endpoint = dc.get_active_ws_endpoint(ads_power_url, profile_id)
+    playwright = sync_playwright().start()
+    browser = playwright.chromium.connect_over_cdp(ws_endpoint)
+    context = browser.contexts[0] if browser.contexts else browser.new_context()
+    page = context.new_page()
+    page.set_viewport_size({"width": 1366, "height": 850})
+
+    queries = keywords or DEFAULT_KEYWORDS
+    raw_candidates: List[Dict[str, Any]] = []
+    search_log: List[Dict[str, Any]] = []
+    records: List[Dict[str, Any]] = []
+    selected: List[Dict[str, Any]] = []
+    scored: List[Dict[str, Any]] = []
+    skipped_existing: List[Dict[str, Any]] = []
+
+    try:
+        for query in queries:
+            items = collect_place_results(page, query, max_results_per_query)
+            raw_candidates.extend(items)
+            search_log.append({"query": query, "found": len(items), "url": page.url, "title": page.title()})
+            time.sleep(random.uniform(2, 4))
+
+        merged = dc.merge_candidates_by_url(raw_candidates, normalizer=dc.normalize_google_maps_url)
+        scored_all = [
+            dc.score_dealer_candidate(item, existing_links, existing_names, platform="Google Maps")
+            for item in merged
+        ]
+        skipped_existing = [c for c in scored_all if c.get("recommended_action") == "skip_existing"]
+        active_scored = [c for c in scored_all if c.get("recommended_action") != "skip_existing"]
+        scored = dc.dedupe_dealer_groups(active_scored)
+        scored.sort(key=lambda c: (-int(c.get("score", 0)), c.get("name", "")))
+        selected = [
+            c for c in scored
+            if c.get("recommended_action") == "deep_scrape" and int(c.get("score", 0)) >= min_score
+        ][:max_results]
+
+        if deep_scrape:
+            for candidate in selected:
+                record = deep_scrape_place(page, context, candidate, country=country)
+                if record:
+                    records.append(record)
+                    normalized = dc.normalize_google_maps_url(record.get("主页/链接", ""))
+                    if normalized:
+                        existing_links.add(normalized)
+                time.sleep(random.uniform(2, 4))
+        else:
+            print("预览模式:已生成候选评分,不打开地图详情或官网。需要邮箱提取时加 --deep-scrape。", flush=True)
+    finally:
+        try:
+            page.close()
+        except Exception:
+            pass
+        playwright.stop()
+
+    return {
+        "generated_at": datetime.now(timezone.utc).isoformat(),
+        "source": "Google Maps search via AdsPower active profile",
+        "summary": {
+            "profile_id": profile_id,
+            "queries": queries,
+            "raw_candidates": len(raw_candidates),
+            "candidate_count": len(scored),
+            "skipped_existing": len(skipped_existing),
+            "selected_for_deep_scrape": len(selected),
+            "deep_scrape": deep_scrape,
+            "record_count": len(records),
+            "records_with_email": len([r for r in records if r.get("邮箱")]),
+            "min_score": min_score,
+            "max_results": max_results,
+            "existing_links": len(existing_links),
+            "existing_names": len(existing_names),
+        },
+        "search_log": search_log,
+        "candidates": scored,
+        "skipped_existing": skipped_existing,
+        "selected_candidates": selected,
+        "records": records,
+    }
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="Google Maps Morocco dealer discovery with public email extraction")
+    parser.add_argument("--profile-id", required=True, help="AdsPower profile ID; must already be open")
+    parser.add_argument("--ads-power-url", default="http://127.0.0.1:50325", help="AdsPower local API URL")
+    parser.add_argument("--keywords", default="", help="Comma-separated Google Maps search keywords")
+    parser.add_argument("--excel", default=DEFAULT_EXCEL, help="Workbook for duplicate checking and optional write-back")
+    parser.add_argument("--sheet", default=DEFAULT_SHEET, help="Target sheet, normally Google Maps")
+    parser.add_argument("--country", default="摩洛哥", help="Country value for records")
+    parser.add_argument("--max-results", type=int, default=10, help="Maximum deep-scraped records")
+    parser.add_argument("--max-results-per-query", type=int, default=6, help="Place links collected per query")
+    parser.add_argument("--min-score", type=int, default=4, help="Minimum score for deep-scrape selection")
+    parser.add_argument("--deep-scrape", action="store_true", help="Open selected place pages and merchant websites for emails")
+    parser.add_argument("--write-excel", action="store_true", help="Write deep-scraped records to the workbook")
+    parser.add_argument("--no-excel", action="store_true", help="Do not read or write Excel; pure JSON preview")
+    parser.add_argument("--run-id", default="", help="Run ID used for runs/YYYYMMDD/<run_id>/ artifacts.")
+    parser.add_argument("--output", default="google_maps_candidate_preview.json", help="Output JSON path")
+
+    args = parser.parse_args()
+
+    if not args.no_excel:
+        read_workbook_info = resolve_workbook_path(args.excel, create_from_template=False)
+        if read_workbook_info.get("path"):
+            args.excel = str(read_workbook_info["path"])
+            print(f"Workbook for duplicate checking: {args.excel} ({read_workbook_info['source']})", flush=True)
+
+    result = search_google_maps_dealers(
+        profile_id=args.profile_id,
+        keywords=dc.parse_keywords(args.keywords),
+        ads_power_url=args.ads_power_url,
+        excel_path=args.excel,
+        sheet_name=args.sheet,
+        max_results=args.max_results,
+        max_results_per_query=args.max_results_per_query,
+        min_score=args.min_score,
+        deep_scrape=args.deep_scrape,
+        country=args.country,
+        no_excel=args.no_excel,
+    )
+
+    output_path = resolve_artifact_path(args.output, kind="scraper_preview", default_name=Path(args.output).name, run_id=args.run_id or None)
+    output_path.parent.mkdir(parents=True, exist_ok=True)
+    output_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
+    print(f"已保存 Google Maps 候选结果: {output_path}", flush=True)
+
+    if args.write_excel and not args.no_excel:
+        write_workbook_info = resolve_workbook_path(args.excel, create_from_template=True)
+        if write_workbook_info.get("path"):
+            args.excel = str(write_workbook_info["path"])
+            print(f"Workbook for writing: {args.excel} ({write_workbook_info['source']})", flush=True)
+        if not args.deep_scrape:
+            print("未写入 Excel:需要先使用 --deep-scrape 生成 records。", flush=True)
+        elif not result.get("records"):
+            print("未写入 Excel:没有可写入记录。", flush=True)
+        else:
+            write_result = append_records(
+                excel_path=args.excel,
+                sheet_name=args.sheet,
+                records=result["records"],
+                dedup_keys=["客户姓名/公司", "主页/链接"],
+            )
+            print(f"Excel 回写结果: {write_result}", flush=True)
+    else:
+        print("默认预览模式:未写入 Excel。确认要入表时再使用 --deep-scrape --write-excel。", flush=True)
+
+
+if __name__ == "__main__":
+    main()
+
+

+ 650 - 0
scripts/scraper/search_linkedin.py

@@ -0,0 +1,650 @@
+"""
+LinkedIn Morocco dealer discovery.
+
+Preview-first workflow:
+- Connect to an already-open AdsPower browser by profile ID.
+- Search LinkedIn company results with Morocco-wide dealer/importer keywords.
+- Score and de-duplicate candidates before opening company pages.
+- Reject OEM local brand-country pages such as BYD Maroc or BMW Maroc.
+- Optionally deep-scrape company About pages and write records to the LinkedIn sheet.
+"""
+import argparse
+import json
+import random
+import re
+import sys
+import time
+from datetime import datetime, timezone
+from pathlib import Path
+
+import sys
+sys.path.append(str(Path(__file__).resolve().parents[1]))
+from common.artifact_manager import resolve_artifact_path, create_backup_once
+from typing import Any, Dict, List, Optional, Set, Tuple
+from urllib.parse import quote_plus, urlparse
+
+if hasattr(sys.stdout, "reconfigure"):
+    sys.stdout.reconfigure(encoding="utf-8")
+if hasattr(sys.stderr, "reconfigure"):
+    sys.stderr.reconfigure(encoding="utf-8")
+
+import pandas as pd
+import requests
+from playwright.sync_api import Page, sync_playwright
+
+try:
+    from ..common import append_records, resolve_workbook_path
+    from . import discovery_common as dc
+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
+
+DEFAULT_EXCEL = ""
+DEFAULT_SHEET = "LinkedIn"
+DEFAULT_CITY_SCOPE = "摩洛哥全国"
+
+DEFAULT_KEYWORDS = [
+    "showroom auto Maroc",
+    "concessionnaire automobile Maroc",
+    "concessionnaire multimarque Maroc",
+    "distributeur automobile Maroc",
+    "importateur automobile Maroc",
+    "groupe automobile Maroc",
+    "voiture occasion Maroc",
+    "concessionnaire utilitaire Maroc",
+    "La Continentale Auto Maroc",
+    "Prince Auto Maroc",
+    "Kifal Auto Maroc",
+    "Bamotors Maroc",
+    "SMEIA Maroc",
+    "Sopriam Maroc",
+    "Auto Hall Maroc",
+    "Auto Nejma Maroc",
+    "Centrale Automobile Chérifienne Maroc",
+]
+
+MOROCCO_TERMS = {
+    "maroc", "morocco", "casablanca", "rabat", "marrakech", "tanger",
+    "fes", "fès", "agadir", "meknes", "oujda", "kenitra", "tetouan",
+    "nador", "safi", "settat", "dar bouazza", "el jadida",
+}
+
+CHINA_BRAND_TERMS = {
+    "baic", "byd", "changan", "chery", "dfsk", "dongfeng", "foton", "forland",
+    "gac", "geely", "great wall", "haval", "jac", "jetour", "maxus", "mg",
+    "omoda", "saic", "sitrak", "sinotruk", "shacman", "faw", "yutong",
+    "king long", "golden dragon", "higer", "wuling",
+}
+
+NON_CHINA_BRAND_TERMS = {
+    "audi", "bmw", "chevrolet", "citroen", "citroën", "dacia", "daf", "fiat",
+    "ford", "hino", "honda", "hyundai", "isuzu", "iveco", "jeep", "kia", "man",
+    "mazda", "mercedes", "mercedes-benz", "mitsubishi", "nissan", "opel", "peugeot",
+    "renault", "scania", "seat", "skoda", "suzuki", "toyota", "volkswagen", "volvo",
+}
+
+OEM_BRANCH_BRANDS = CHINA_BRAND_TERMS | NON_CHINA_BRAND_TERMS
+
+INDEPENDENT_CHANNEL_CLUES = {
+    "auto hall", "smaa", "smeia", "auto nejma", "la continentale", "prince auto",
+    "kifal", "autochek", "bugshan", "bamotors", "sopriam", "cac",
+    "centrale automobile", "univers motors", "m-automotiv", "m automotiv",
+}
+
+HIGH_INTENT_TERMS = {
+    "concessionnaire", "dealer", "distributeur", "distribution", "importateur",
+    "importation", "showroom", "groupe", "group", "automobile", "auto", "retail",
+    "vente", "voiture", "occasion", "reprise", "multimarque", "service après-vente",
+    "après-vente", "pieces", "pièces", "fleet", "flotte", "utilitaire", "camion",
+}
+
+LOW_VALUE_TERMS = {
+    "aerospace", "aéronautique", "assurance", "insurance", "software", "marketing",
+    "emailing", "real estate", "immobilier", "building materials", "construction materials",
+    "diagnostic", "spare parts only", "pièces uniquement",
+}
+
+CITY_PATTERNS = [
+    "Casablanca", "Rabat", "Marrakech", "Tanger", "Fes", "Fès", "Agadir", "Meknes",
+    "Oujda", "Kenitra", "Tetouan", "Tétouan", "Nador", "Safi", "Settat",
+    "Dar Bouazza", "El Jadida",
+]
+
+
+def compact_text(value: str) -> str:
+    return re.sub(r"[^a-z0-9]+", "", value.casefold())
+
+
+def normalize_linkedin_url(url: str) -> str:
+    return dc.normalize_linkedin_url(url)
+
+
+def load_existing_links(excel_path: str, sheet_name: str) -> Set[str]:
+    path = Path(excel_path)
+    if not path.exists():
+        return set()
+    try:
+        df = pd.read_excel(path, sheet_name=sheet_name)
+    except Exception:
+        return set()
+    link_col = "linkin链接" if "linkin链接" in df.columns else "主页/链接"
+    if link_col not in df.columns:
+        return set()
+    return {
+        normalized
+        for normalized in (normalize_linkedin_url(str(value).strip()) for value in df[link_col].tolist())
+        if normalized
+    }
+
+
+def load_blocklist_json(blocklist_path: Optional[str]) -> Set[str]:
+    return dc.load_blocklist_json(blocklist_path, normalizer=dc.normalize_linkedin_url)
+
+
+def matched_terms(text: str, terms: Set[str]) -> List[str]:
+    text_lower = text.casefold()
+    return sorted(term for term in terms if term.casefold() in text_lower)
+
+
+def looks_like_oem_local_branch(name: str, url: str, text: str = "") -> Tuple[bool, str]:
+    return dc.looks_like_oem_local_branch(name, url, text)
+
+
+def score_candidate(candidate: Dict[str, Any], existing_links: Set[str], existing_names: Optional[Set[str]] = None) -> Dict[str, Any]:
+    name = candidate.get("name", "")
+    href = candidate.get("href", "")
+    text = candidate.get("text", "")
+    queries = candidate.get("source_queries", [])
+    entity_text = " ".join([name, href, text]).casefold()
+    query_text = " ".join(queries).casefold()
+    combined = " ".join([entity_text, query_text]).casefold()
+
+    score = 0
+    reasons: List[str] = []
+    risks: List[str] = []
+    recommended_action = "preview_only"
+
+    normalized = normalize_linkedin_url(href)
+    existing_names = existing_names or set()
+    name_key = dc.normalized_company_key(name)
+    if normalized in existing_links:
+        return {
+            **candidate,
+            "score": -10,
+            "score_reasons": ["Already exists in workbook"],
+            "risk_flags": ["duplicate_existing_link"],
+            "recommended_action": "skip_existing",
+        }
+    if name_key and name_key in existing_names:
+        return {
+            **candidate,
+            "score": -9,
+            "score_reasons": ["Company name already exists in workbook"],
+            "risk_flags": ["duplicate_existing_name"],
+            "recommended_action": "skip_existing",
+        }
+
+    is_oem, brand = looks_like_oem_local_branch(name, href, text)
+    brand_terms = matched_terms(entity_text, OEM_BRANCH_BRANDS)
+    if is_oem:
+        score -= 8
+        risks.append(f"疑似品牌当地分公司/官方页,排除: {brand}")
+        recommended_action = "skip_brand_branch"
+    ownership_needs_review = (not is_oem) and brand_terms and not any(clue in entity_text for clue in INDEPENDENT_CHANNEL_CLUES)
+
+    morocco_terms = matched_terms(entity_text, MOROCCO_TERMS)
+    if morocco_terms:
+        score += 2
+        reasons.append("Morocco signal: " + ", ".join(morocco_terms[:4]))
+
+    intent_terms = matched_terms(entity_text, HIGH_INTENT_TERMS)
+    if intent_terms:
+        score += min(5, len(intent_terms))
+        reasons.append("dealer/import/channel signal: " + ", ".join(intent_terms[:6]))
+
+    china_terms = matched_terms(entity_text, CHINA_BRAND_TERMS)
+    if china_terms and not is_oem:
+        score += 2
+        reasons.append("China-brand context inside dealer channel: " + ", ".join(china_terms[:4]))
+
+    canonical_name = dc.canonical_dealer_name(name, text)
+    if canonical_name and canonical_name != name:
+        candidate = {**candidate, "canonical_name": canonical_name}
+        reasons.append(f"mapped to independent dealer group: {canonical_name}")
+
+    if any(clue in entity_text for clue in INDEPENDENT_CHANNEL_CLUES):
+        score += 4
+        reasons.append("known independent Moroccan auto channel")
+
+    low_terms = matched_terms(entity_text, LOW_VALUE_TERMS)
+    if low_terms:
+        score -= min(4, len(low_terms) * 2)
+        risks.append("possible low-value/non-dealer result: " + ", ".join(low_terms[:4]))
+
+    channel_terms = matched_terms(entity_text, {"concessionnaire", "dealer", "showroom", "new vehicle", "new cars", "vehicules neufs", "v?hicules neufs", "vente", "importateur", "distributeur", "distribution", "groupe", "group", "multimarque", "multi-brand"})
+    new_vehicle_needs_review = not channel_terms
+
+    if ownership_needs_review and dc.should_request_manual_review(score, recommended_action, reasons):
+        dc.add_manual_review_flag(risks, "ownership")
+    if new_vehicle_needs_review and dc.should_request_manual_review(score, recommended_action, reasons):
+        dc.add_manual_review_flag(risks, "new_vehicle")
+
+    if not reasons:
+        reasons.append("weak LinkedIn search signal; low priority unless deeper evidence confirms channel value")
+
+    if recommended_action == "preview_only" and score >= 5:
+        recommended_action = "deep_scrape"
+
+    return {
+        **candidate,
+        "score": score,
+        "score_reasons": reasons,
+        "risk_flags": risks,
+        "recommended_action": recommended_action,
+    }
+
+
+def get_active_ws_endpoint(base_url: str, profile_id: str) -> str:
+    return dc.get_active_ws_endpoint(base_url, profile_id)
+
+
+def dismiss_linkedin_dialogs(page: Page) -> None:
+    labels = [
+        "继续前往公司主页",
+        "Continue to company page",
+        "Continuer vers la page de l’entreprise",
+        "关闭",
+        "Close",
+    ]
+    for label in labels:
+        try:
+            page.get_by_text(label, exact=False).first.click(timeout=1500)
+            time.sleep(0.5)
+        except Exception:
+            pass
+
+
+def collect_company_results(page: Page, query: str, max_results: int) -> List[Dict[str, Any]]:
+    url = f"https://www.linkedin.com/search/results/companies/?keywords={quote_plus(query)}&origin=GLOBAL_SEARCH_HEADER"
+    print(f"搜索 LinkedIn: {query}", flush=True)
+    page.goto(url, wait_until="domcontentloaded", timeout=60000)
+    time.sleep(random.uniform(3, 5))
+    dismiss_linkedin_dialogs(page)
+
+    for _ in range(2):
+        page.mouse.wheel(0, 900)
+        time.sleep(random.uniform(1, 1.8))
+
+    raw = page.evaluate(
+        """
+        () => {
+          const anchors = Array.from(document.querySelectorAll('a[href*="/company/"]'));
+          return anchors.map((a) => {
+            let node = a.closest('li') || a.closest('.reusable-search__result-container') || a.parentElement;
+            let text = '';
+            let cur = node;
+            for (let i = 0; i < 7 && cur; i++) {
+              const value = (cur.innerText || '').trim();
+              if (value.length > text.length) text = value;
+              if (value.length > 80) break;
+              cur = cur.parentElement;
+            }
+            return { href: a.href || '', anchorText: (a.innerText || '').trim(), text };
+          });
+        }
+        """
+    )
+
+    results: List[Dict[str, Any]] = []
+    seen: Set[str] = set()
+    for item in raw:
+        href = normalize_linkedin_url(item.get("href", ""))
+        if not href or href in seen:
+            continue
+        text = re.sub(r"\n{2,}", "\n", item.get("text", "")).strip()
+        lines = [line.strip() for line in text.splitlines() if line.strip()]
+        anchor = re.sub(r"\s+", " ", item.get("anchorText", "")).strip()
+        if lines:
+            name = lines[0]
+        elif anchor and len(anchor) <= 80:
+            name = anchor
+        else:
+            name = anchor.split("  ")[0].strip()
+        name = re.sub(r"\s+\d+(?:\.\d+)?\s*万?\s*位关注者.*$", "", name).strip()
+        if not name or name.lower() in {"linkedin", "home", "search"}:
+            continue
+        seen.add(href)
+        results.append({
+            "name": name,
+            "href": href,
+            "text": text[:1000],
+            "source_queries": [query],
+        })
+        if len(results) >= max_results:
+            break
+    print(f"  收集到 {len(results)} 个公司候选", flush=True)
+    return results
+
+
+def merge_candidates(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
+    by_href: Dict[str, Dict[str, Any]] = {}
+    for item in items:
+        href = item["href"]
+        if href not in by_href:
+            by_href[href] = item
+            continue
+        existing = by_href[href]
+        existing["source_queries"] = sorted(set(existing.get("source_queries", []) + item.get("source_queries", [])))
+        snippets = existing.setdefault("snippets", [existing.get("text", "")])
+        if item.get("text") and item["text"] not in snippets:
+            snippets.append(item["text"])
+    return list(by_href.values())
+
+
+def extract_lines(text: str) -> List[str]:
+    return [line.strip() for line in text.splitlines() if line.strip()]
+
+
+def value_after_label(lines: List[str], label: str) -> str:
+    for idx, line in enumerate(lines):
+        if line.strip() == label and idx + 1 < len(lines):
+            return lines[idx + 1].strip()
+    return ""
+
+
+def extract_urls(text: str) -> List[str]:
+    urls = re.findall(r"https?://[^\s<>()]+", text)
+    cleaned: List[str] = []
+    for url in urls:
+        url = url.rstrip(".,,。;;")
+        if "linkedin.com" not in url and url not in cleaned:
+            cleaned.append(url)
+    return cleaned
+
+
+def extract_email(text: str) -> str:
+    match = re.search(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", text)
+    return match.group(0) if match else ""
+
+
+def extract_phone(text: str) -> str:
+    explicit = re.search(r"(?:Téléphone|电话|Tél|Tel|WhatsApp|电话号码)[::]?\s*([+()\d][+()\d\s.-]{6,}\d)", text, re.I)
+    if explicit:
+        return re.sub(r"\s+", " ", explicit.group(1)).strip()
+    match = re.search(r"(?:\+212|0)\s?\d[\d\s.-]{6,}\d", text)
+    return re.sub(r"\s+", " ", match.group(0)).strip() if match else ""
+
+
+def extract_city(text: str, fallback: str = DEFAULT_CITY_SCOPE) -> str:
+    hits = []
+    for city in CITY_PATTERNS:
+        if re.search(rf"\b{re.escape(city)}\b", text, re.I) and city not in hits:
+            hits.append(city)
+    return " / ".join(hits[:4]) if hits else fallback
+
+
+def extract_followers(text: str) -> str:
+    match = re.search(r"([\d,.]+\s*万?|[\d,.]+)\s*位关注者", text)
+    return match.group(1).strip() + "位关注者" if match else ""
+
+
+def classify_customer_type(text: str) -> str:
+    return dc.classify_customer_type(str(text or ""))
+
+
+def summarize_business(text: str) -> str:
+    lower = text.casefold()
+    parts: List[str] = []
+    if any(term in lower for term in ["occasion", "reprise", "voiture d'occasion"]):
+        parts.append("二手车买卖/置换")
+    if any(term in lower for term in ["concessionnaire", "showroom", "vente automobile", "汽车零售"]):
+        parts.append("汽车销售/showroom")
+    if any(term in lower for term in ["importateur", "distribution", "distributeur"]):
+        parts.append("进口/分销")
+    if any(term in lower for term in ["utilitaire", "camion", "truck", "fleet", "flotte"]):
+        parts.append("商用车/车队")
+    china = matched_terms(text, CHINA_BRAND_TERMS)
+    non_china = matched_terms(text, NON_CHINA_BRAND_TERMS)
+    if china:
+        parts.append("涉及中国品牌: " + ", ".join(china[:4]))
+    elif non_china:
+        parts.append("主要品牌信号: " + ", ".join(non_china[:5]))
+    return ";".join(parts) if parts else "LinkedIn 汽车渠道线索,主营业务证据不足,需深搜确认"
+
+
+def deep_scrape_about(page: Page, candidate: Dict[str, Any], country: str) -> Dict[str, Any]:
+    about_url = candidate["href"].rstrip("/") + "/about/"
+    print(f"深采 LinkedIn About: {candidate['name']}", flush=True)
+    page.goto(about_url, wait_until="domcontentloaded", timeout=60000)
+    time.sleep(random.uniform(3, 5))
+    dismiss_linkedin_dialogs(page)
+    time.sleep(random.uniform(1, 2))
+
+    title = page.title()
+    text = page.locator("body").inner_text(timeout=10000)
+    if not text.strip():
+        candidate.setdefault("risk_flags", [])
+        dc.add_manual_review_flag(candidate["risk_flags"], "detail")
+    lines = extract_lines(text)
+    urls = extract_urls(text)
+    website = value_after_label(lines, "网站") or (urls[0] if urls else "")
+    phone = value_after_label(lines, "电话") or extract_phone(text)
+    email = extract_email(text)
+    industry = value_after_label(lines, "行业")
+    city = extract_city(text)
+    followers = extract_followers(text)
+    company_type = classify_customer_type(text + "\n" + candidate.get("text", ""))
+    business = summarize_business(text + "\n" + candidate.get("text", ""))
+
+    is_oem, brand = looks_like_oem_local_branch(candidate.get("name", ""), candidate.get("href", ""), text)
+    if is_oem:
+        candidate["recommended_action"] = "skip_brand_branch"
+        candidate.setdefault("risk_flags", []).append(f"深采确认疑似品牌官方页: {brand}")
+        dc.add_manual_review_flag(candidate["risk_flags"], "ownership")
+
+    note_parts = [
+        "LinkedIn深采",
+        f"行业:{industry}" if industry else "",
+        f"关注者:{followers}" if followers else "",
+        f"网站:{website}" if website else "",
+        f"来源搜索词:{', '.join(candidate.get('source_queries', []))}",
+        f"评分:{candidate.get('score')};原因:{'; '.join(candidate.get('score_reasons', []))}",
+    ]
+    if candidate.get("risk_flags"):
+        note_parts.append("风险:" + "; ".join(candidate["risk_flags"]))
+    note = " | ".join(part for part in note_parts if part)
+
+    return {
+        "公司名称": candidate.get("canonical_name") or candidate.get("name", ""),
+        "国家": country,
+        "城市": city,
+        "客户类型": company_type,
+        "linkin链接": candidate.get("href", ""),
+        "联系人": "",
+        "职位": "",
+        "公司公共电话": phone,
+        "公司公共邮箱(任一有效即可)": email,
+        "个人邮箱": "",
+        "公司主营业务": business,
+        "建联状态": "未联系",
+        "备注": note[:1200],
+        "_debug_title": title,
+        "_about_head": lines[:60],
+    }
+
+
+def search_linkedin_dealers(
+    profile_id: str,
+    keywords: Optional[List[str]],
+    ads_power_url: str,
+    excel_path: str,
+    sheet_name: str,
+    max_results: int,
+    max_results_per_query: int,
+    min_score: int,
+    deep_scrape: bool,
+    country: str,
+    blocklist_json: Optional[str] = None,
+) -> Dict[str, Any]:
+    existing_identity = dc.load_existing_identity(
+        excel_path=excel_path,
+        sheet_name=sheet_name,
+        link_columns=["linkin链接", "主页/链接"],
+        name_columns=["公司名称", "客户姓名/公司"],
+    )
+    existing_links = existing_identity["links"]
+    existing_names = existing_identity["names"]
+    blocklist_links = load_blocklist_json(blocklist_json)
+    existing_links = existing_links | blocklist_links
+    print(f"已从 {sheet_name} Sheet 加载 {len(existing_links)} 个 LinkedIn 现有链接、{len(existing_names)} 个公司名用于去重", flush=True)
+    if blocklist_json:
+        print(f"  其中来自黑名单 JSON: {len(blocklist_links)} 个", flush=True)
+    ws_endpoint = get_active_ws_endpoint(ads_power_url, profile_id)
+
+    playwright = sync_playwright().start()
+    browser = playwright.chromium.connect_over_cdp(ws_endpoint)
+    context = browser.contexts[0] if browser.contexts else browser.new_context()
+    page = context.new_page()
+    page.set_viewport_size({"width": 1366, "height": 850})
+
+    queries = keywords or DEFAULT_KEYWORDS
+    raw_candidates: List[Dict[str, Any]] = []
+    search_log: List[Dict[str, Any]] = []
+    records: List[Dict[str, Any]] = []
+
+    try:
+        for query in queries:
+            items = collect_company_results(page, query, max_results_per_query)
+            raw_candidates.extend(items)
+            search_log.append({"query": query, "found": len(items), "url": page.url, "title": page.title()})
+            time.sleep(random.uniform(2, 4))
+
+        merged = merge_candidates(raw_candidates)
+        scored_all = [score_candidate(item, existing_links, existing_names) for item in merged]
+        skipped_existing = [c for c in scored_all if c.get("recommended_action") == "skip_existing"]
+        active_scored = [c for c in scored_all if c.get("recommended_action") != "skip_existing"]
+        scored = dc.dedupe_dealer_groups(active_scored)
+        scored.sort(key=lambda c: (-int(c.get("score", 0)), c.get("name", "")))
+
+        selected = [
+            c for c in scored
+            if c.get("recommended_action") == "deep_scrape" and int(c.get("score", 0)) >= min_score
+        ][:max_results]
+
+        if deep_scrape:
+            for candidate in selected:
+                record = deep_scrape_about(page, candidate, country=country)
+                if candidate.get("recommended_action") == "skip_brand_branch":
+                    continue
+                records.append(record)
+                time.sleep(random.uniform(2, 4))
+    finally:
+        try:
+            page.close()
+        except Exception:
+            pass
+        playwright.stop()
+
+    return {
+        "generated_at": datetime.now(timezone.utc).isoformat(),
+        "source": "LinkedIn company search via AdsPower active profile",
+        "summary": {
+            "profile_id": profile_id,
+            "queries": queries,
+            "raw_candidates": len(raw_candidates),
+            "candidate_count": len(scored),
+            "selected_for_deep_scrape": len(selected),
+            "deep_scrape": deep_scrape,
+            "record_count": len(records),
+            "min_score": min_score,
+            "max_results": max_results,
+            "existing_links": len(existing_links),
+            "existing_names": len(existing_names),
+            "skipped_existing": len(skipped_existing),
+        },
+        "search_log": search_log,
+        "candidates": scored,
+        "skipped_existing": skipped_existing,
+        "selected_candidates": selected,
+        "records": records,
+    }
+
+
+def parse_keywords(value: str) -> Optional[List[str]]:
+    if not value.strip():
+        return None
+    return [item.strip() for item in value.split(",") if item.strip()]
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="LinkedIn Morocco dealer discovery, preview-first")
+    parser.add_argument("--profile-id", required=True, help="AdsPower profile ID; must already be open and logged in")
+    parser.add_argument("--ads-power-url", default="http://127.0.0.1:50325", help="AdsPower local API URL")
+    parser.add_argument("--keywords", default="", help="Comma-separated LinkedIn company search keywords")
+    parser.add_argument("--excel", default=DEFAULT_EXCEL, help="Workbook for duplicate checking and optional write-back")
+    parser.add_argument("--sheet", default=DEFAULT_SHEET, help="Target sheet, normally LinkedIn")
+    parser.add_argument("--country", default="摩洛哥", help="Country value for records")
+    parser.add_argument("--max-results", type=int, default=10, help="Maximum deep-scraped records")
+    parser.add_argument("--max-results-per-query", type=int, default=5, help="Company links collected per query")
+    parser.add_argument("--min-score", type=int, default=5, help="Minimum score for deep-scrape selection")
+    parser.add_argument("--deep-scrape", action="store_true", help="Open selected company About pages and build records")
+    parser.add_argument("--write-excel", action="store_true", help="Write deep-scraped records to the workbook")
+    parser.add_argument("--blocklist-json", default="", help="Optional JSON file with additional LinkedIn URLs to skip")
+    parser.add_argument("--run-id", default="", help="Run ID used for runs/YYYYMMDD/<run_id>/ artifacts.")
+    parser.add_argument("--output", default="linkedin_candidate_preview.json", help="Output JSON path")
+
+    args = parser.parse_args()
+
+    read_workbook_info = resolve_workbook_path(args.excel, create_from_template=False)
+    if read_workbook_info.get("path"):
+        args.excel = str(read_workbook_info["path"])
+        print(f"Workbook for duplicate checking: {args.excel} ({read_workbook_info['source']})", flush=True)
+
+    result = search_linkedin_dealers(
+        profile_id=args.profile_id,
+        keywords=parse_keywords(args.keywords),
+        ads_power_url=args.ads_power_url,
+        excel_path=args.excel,
+        sheet_name=args.sheet,
+        max_results=args.max_results,
+        max_results_per_query=args.max_results_per_query,
+        min_score=args.min_score,
+        deep_scrape=args.deep_scrape,
+        country=args.country,
+        blocklist_json=args.blocklist_json or None,
+    )
+
+    output_path = resolve_artifact_path(args.output, kind="scraper_preview", default_name=Path(args.output).name, run_id=args.run_id or None)
+    output_path.parent.mkdir(parents=True, exist_ok=True)
+    output_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
+    print(f"已保存 LinkedIn 候选结果: {output_path}", flush=True)
+
+    if args.write_excel:
+        write_workbook_info = resolve_workbook_path(args.excel, create_from_template=True)
+        if write_workbook_info.get("path"):
+            args.excel = str(write_workbook_info["path"])
+            print(f"Workbook for writing: {args.excel} ({write_workbook_info['source']})", flush=True)
+        if not args.deep_scrape:
+            print("未写入 Excel:需要先使用 --deep-scrape 生成 records。", flush=True)
+        elif not result.get("records"):
+            print("未写入 Excel:没有可写入记录。", flush=True)
+        else:
+            write_result = append_records(
+                excel_path=args.excel,
+                sheet_name=args.sheet,
+                records=result["records"],
+                dedup_keys=["公司名称", "linkin链接"],
+            )
+            print(f"Excel 回写结果: {write_result}", flush=True)
+    else:
+        print("默认预览模式:未写入 Excel。确认要入表时再使用 --deep-scrape --write-excel。", flush=True)
+
+
+if __name__ == "__main__":
+    main()
+
+
+
+
+

+ 10 - 0
scripts/scraper/search_tiktok.py

@@ -0,0 +1,10 @@
+"""
+TikTok 经销商搜索与采集(占位实现)
+"""
+
+
+class TikTokScraper:
+    """TikTok 经销商搜索采集器"""
+
+    def __init__(self, *args, **kwargs):
+        raise NotImplementedError("TikTok scraper 尚未实现,请先使用 Facebook scraper")

+ 371 - 0
scripts/scraper/website_deep_scraper.py

@@ -0,0 +1,371 @@
+"""Public website enrichment helpers for dealer discovery.
+
+The helpers only use public pages. They do not guess emails, prices, MOQ,
+inventory, or cooperation terms.
+"""
+from __future__ import annotations
+
+import re
+import time
+from typing import Any, Dict, List, Set
+from urllib.parse import parse_qs, parse_qsl, urlencode, unquote, urlparse, urlunparse
+
+from playwright.sync_api import BrowserContext, Page
+
+try:
+    from . import discovery_common as dc
+except ImportError:  # pragma: no cover - direct script execution
+    import sys
+    from pathlib import Path
+
+    sys.path.insert(0, str(Path(__file__).resolve().parent))
+    import discovery_common as dc
+
+
+CONTACT_LINK_HINTS = {
+    "contact",
+    "nous contacter",
+    "contactez",
+    "about",
+    "a-propos",
+    "apropos",
+    "à propos",
+    "qui sommes",
+    "service",
+    "services",
+    "vehicule",
+    "véhicule",
+    "vehicules",
+    "véhicules",
+    "occasion",
+    "location",
+}
+
+SOCIAL_OR_PLATFORM_HOSTS = {
+    "facebook.com",
+    "m.facebook.com",
+    "fb.com",
+    "instagram.com",
+    "linkedin.com",
+    "tiktok.com",
+    "youtube.com",
+    "youtu.be",
+    "wa.me",
+    "whatsapp.com",
+    "google.com",
+    "maps.google.com",
+    "bing.com",
+    "maps.bing.com",
+    "gmail.com",
+    "mail.google.com",
+    "outlook.com",
+    "hotmail.com",
+    "yahoo.com",
+    "goo.gl",
+}
+
+BARE_DOMAIN_RE = re.compile(
+    r"\b(?:https?://)?(?:www\.)?([a-z0-9][a-z0-9-]{1,63}(?:\.[a-z0-9][a-z0-9-]{1,63})+\b(?:/[^\s<>'\"]*)?)",
+    re.IGNORECASE,
+)
+
+GENERIC_NON_WEBSITE_DOMAINS = {
+    "facebook.com",
+    "fb.com",
+    "instagram.com",
+    "youtube.com",
+    "youtu.be",
+    "tiktok.com",
+    "wa.me",
+    "whatsapp.com",
+    "google.com",
+    "bing.com",
+    "gmail.com",
+    "outlook.com",
+    "hotmail.com",
+    "yahoo.com",
+    "goo.gl",
+}
+
+BUSINESS_SIGNALS = {
+    "二手车销售/置换": ["occasion", "voiture d'occasion", "reprise", "used car", "achat", "vente"],
+    "汽车销售/showroom": ["showroom", "concessionnaire", "vente automobile", "véhicules neufs", "vehicules neufs", "vehicle sales"],
+    "进口/分销": ["importation", "importateur", "distribution", "distributeur", "import"],
+    "租赁/车队服务": ["location", "rental", "fleet", "flotte", "lld", "leasing"],
+    "商用车/工具车": ["utilitaire", "camion", "truck", "van", "mpv", "minibus", "bus"],
+    "售后/维修支持": ["service après-vente", "après-vente", "sav", "garage", "réparation", "maintenance"],
+}
+
+BATCH_CAPACITY_SIGNALS = {
+    "多门店/区域覆盖": ["réseau", "reseau", "succursale", "points de vente", "agence", "branches", "network"],
+    "库存/展厅信号": ["stock", "parc auto", "showroom", "disponible", "inventory"],
+    "进口/分销能力": ["importation", "importateur", "distribution", "distributeur"],
+    "车队/租赁需求": ["fleet", "flotte", "location", "lld", "leasing"],
+    "商用车适配": ["utilitaire", "camion", "truck", "van", "mpv", "minibus"],
+}
+
+
+def normalize_host(url: str) -> str:
+    return urlparse(str(url or "")).netloc.casefold().removeprefix("www.")
+
+
+def is_social_or_platform_url(url: str) -> bool:
+    host = normalize_host(url)
+    return any(host == blocked or host.endswith("." + blocked) for blocked in SOCIAL_OR_PLATFORM_HOSTS)
+
+
+def is_company_website_url(url: str) -> bool:
+    if not dc.is_external_business_url(url):
+        return False
+    if is_social_or_platform_url(url):
+        return False
+    parsed = urlparse(str(url or ""))
+    if parsed.scheme not in {"http", "https"} or not parsed.netloc:
+        return False
+    host = normalize_host(url)
+    labels = host.split(".")
+    if not labels:
+        return False
+    tld = labels[-1]
+    if len(tld) > 6:
+        return False
+    if not (len(tld) == 2 or tld in {"com", "net", "org", "info", "biz", "auto", "cars", "shop", "store"}):
+        return False
+    return True
+
+
+def _same_site(url: str, base_url: str) -> bool:
+    return normalize_host(url) == normalize_host(base_url)
+
+
+def _strip_tracking_query(url: str) -> str:
+    parsed = urlparse(str(url or ""))
+    if not parsed.scheme or not parsed.netloc:
+        return url
+    blocked_prefixes = ("utm_",)
+    blocked_keys = {"fbclid", "gclid", "yclid", "mc_cid", "mc_eid", "igshid"}
+    query_items = [
+        (key, value)
+        for key, value in parse_qsl(parsed.query, keep_blank_values=True)
+        if key.casefold() not in blocked_keys and not key.casefold().startswith(blocked_prefixes)
+    ]
+    return urlunparse(parsed._replace(query=urlencode(query_items, doseq=True), fragment=""))
+
+
+def _unwrap_facebook_redirect(url: str) -> str:
+    """Return the real destination when Facebook wraps an external link."""
+    parsed = urlparse(str(url or ""))
+    host = parsed.netloc.casefold().removeprefix("www.")
+    if host not in {"facebook.com", "m.facebook.com", "l.facebook.com"}:
+        return url
+    query = parse_qs(parsed.query)
+    for key in ("u", "url", "href"):
+        values = query.get(key)
+        if values:
+            return unquote(values[0])
+    return url
+
+
+def _candidate_urls_from_visible_text(text: str) -> List[str]:
+    """Extract bare domains visible in Facebook contact/about sections."""
+    candidates: List[str] = []
+    for match in BARE_DOMAIN_RE.finditer(str(text or "")):
+        raw = match.group(1).rstrip(".,;:)")
+        if not raw or "@" in raw:
+            continue
+        url = raw if raw.startswith(("http://", "https://")) else f"https://{raw}"
+        host = normalize_host(url)
+        if host in GENERIC_NON_WEBSITE_DOMAINS:
+            continue
+        if is_company_website_url(url) and url not in candidates:
+            candidates.append(url)
+    return candidates
+
+
+def _extract_external_links(page: Page) -> List[Dict[str, str]]:
+    try:
+        return page.evaluate(
+            """
+            () => Array.from(document.querySelectorAll('a[href]')).map((a) => ({
+              href: a.href || '',
+              text: (a.innerText || '').trim(),
+              aria: (a.getAttribute('aria-label') || '').trim()
+            }))
+            """
+        )
+    except Exception:
+        return []
+
+
+def extract_company_website_from_facebook(page: Page) -> str:
+    """Extract the best external company website visible on a Facebook page."""
+    candidates: List[tuple[int, str]] = []
+    for item in _extract_external_links(page):
+        href = _strip_tracking_query(dc.normalize_website_url(_unwrap_facebook_redirect(item.get("href", ""))))
+        if not is_company_website_url(href):
+            visible_candidates = _candidate_urls_from_visible_text(
+                " ".join([item.get("text", ""), item.get("aria", "")])
+            )
+            if not visible_candidates:
+                continue
+            href = _strip_tracking_query(visible_candidates[0])
+        label = " ".join([item.get("text", ""), item.get("aria", ""), href]).casefold()
+        score = 0
+        if any(term in label for term in ["website", "site web", "site", "www.", ".ma", ".com", ".net", ".org"]):
+            score += 2
+        if any(term in label for term in ["contact", "official", "officiel"]):
+            score += 1
+        candidates.append((score, href))
+    try:
+        body_text = page.locator("body").inner_text(timeout=5000)
+    except Exception:
+        body_text = ""
+    for href in _candidate_urls_from_visible_text(body_text):
+        candidates.append((1, _strip_tracking_query(href)))
+    if not candidates:
+        return ""
+    candidates.sort(key=lambda item: (-item[0], len(item[1])))
+    return candidates[0][1]
+
+
+def _score_terms(text: str, signal_map: Dict[str, List[str]]) -> List[str]:
+    lower = str(text or "").casefold()
+    hits: List[str] = []
+    for label, terms in signal_map.items():
+        if any(term.casefold() in lower for term in terms):
+            hits.append(label)
+    return hits
+
+
+def summarize_business_cn(combined_text: str) -> str:
+    business = _score_terms(combined_text, BUSINESS_SIGNALS)
+    capacity = _score_terms(combined_text, BATCH_CAPACITY_SIGNALS)
+    parts: List[str] = []
+    if business:
+        parts.append("主营业务判断:" + ";".join(business[:4]))
+    else:
+        parts.append("主营业务判断:汽车渠道线索,需人工确认")
+    if capacity:
+        parts.append("批量采购能力判断:发现" + "、".join(capacity[:4]) + ",可评估小批量采购/分销潜力")
+    else:
+        parts.append("批量采购能力判断:暂未发现明确批量采购证据")
+    return ";".join(parts)
+
+
+def classify_customer_type_cn(combined_text: str) -> str:
+    return dc.classify_customer_type(str(combined_text or ""))
+
+
+def scrape_public_website(context: BrowserContext, website_url: str, max_pages: int = 5) -> Dict[str, Any]:
+    """Scrape a public company website for contact and business evidence."""
+    if not is_company_website_url(website_url):
+        return {"website": "", "email": "", "emails": [], "phone": "", "checked_urls": [], "business_summary": "", "evidence_notes": ["未发现可用公司官网"], "text": ""}
+
+    checked: List[str] = []
+    queue: List[str] = [website_url]
+    emails: List[str] = []
+    phones: List[str] = []
+    text_chunks: List[str] = []
+    errors: List[str] = []
+    page = context.new_page()
+    page.set_viewport_size({"width": 1280, "height": 850})
+
+    try:
+        while queue and len(checked) < max_pages:
+            url = dc.normalize_website_url(queue.pop(0))
+            if url in checked or not is_company_website_url(url):
+                continue
+            if checked and not _same_site(url, website_url):
+                continue
+            checked.append(url)
+            try:
+                page.goto(url, wait_until="domcontentloaded", timeout=35000)
+                time.sleep(1.2)
+                body_text = page.locator("body").inner_text(timeout=12000)
+                text_chunks.append(body_text[:5000])
+                links = _extract_external_links(page)
+                mailto_text = " ".join(item.get("href", "") for item in links if item.get("href", "").casefold().startswith("mailto:"))
+                for email in dc.extract_emails(body_text + " " + mailto_text):
+                    if email not in emails:
+                        emails.append(email)
+                phone = dc.extract_phone(body_text)
+                if phone and phone not in phones:
+                    phones.append(phone)
+                for item in links:
+                    label = " ".join([item.get("href", ""), item.get("text", ""), item.get("aria", "")]).casefold()
+                    if not any(hint in label for hint in CONTACT_LINK_HINTS):
+                        continue
+                    href = dc.normalize_website_url(item.get("href", ""), page.url)
+                    if href and href not in checked and href not in queue and is_company_website_url(href) and _same_site(href, website_url):
+                        queue.append(href)
+            except Exception as exc:
+                errors.append(str(exc)[:120])
+                continue
+    finally:
+        try:
+            page.close()
+        except Exception:
+            pass
+
+    combined_text = "\n".join(text_chunks)
+    summary = summarize_business_cn(combined_text) if combined_text else "主营业务判断:官网未能读取正文,详细信息待确认;批量采购能力判断:暂未发现明确证据"
+    evidence: List[str] = []
+    if checked:
+        evidence.append("官网检查页:" + ";".join(checked[:3]))
+    if emails:
+        evidence.append("官网发现公开邮箱")
+    else:
+        evidence.append("官网未发现公开邮箱")
+    if errors and not text_chunks:
+        evidence.append("官网读取失败:" + ";".join(errors[:2]))
+        evidence.append(dc.MANUAL_REVIEW_FLAGS["detail"])
+
+    return {
+        "website": _strip_tracking_query(dc.normalize_website_url(website_url)),
+        "email": emails[0] if emails else "",
+        "emails": emails,
+        "phone": phones[0] if phones else "",
+        "checked_urls": checked,
+        "business_summary": summary,
+        "evidence_notes": evidence,
+        "text": combined_text[:12000],
+    }
+
+
+def build_chinese_notes(
+    facebook_url: str,
+    facebook_text: str,
+    website_url: str,
+    website_result: Dict[str, Any],
+    followers: str = "",
+    post_analysis: str = "",
+    detected_brands: List[str] | None = None,
+    exclusivity: str = "",
+) -> str:
+    combined = "\n".join([facebook_text or "", website_result.get("text", "") or "", post_analysis or ""])
+    fb_summary = summarize_business_cn(facebook_text or post_analysis or "")
+    website_summary = website_result.get("business_summary", "")
+    contact_parts: List[str] = []
+    if website_result.get("email"):
+        contact_parts.append("官网公开邮箱:" + website_result["email"])
+    if website_result.get("phone"):
+        contact_parts.append("官网公开电话:" + website_result["phone"])
+    if not contact_parts:
+        contact_parts.append("未发现新的官网公开联系方式")
+
+    notes = [
+        "来源线索:Facebook主页 " + facebook_url,
+        "Facebook证据:" + ("粉丝量:" + followers if followers else "已检查主页/About/近期帖子"),
+        "官网证据:" + (website_url if website_url else "Facebook 未发现公司官网"),
+        "主营业务判断:" + summarize_business_cn(combined).replace("主营业务判断:", "", 1),
+        "联系方式证据:" + ";".join(contact_parts),
+    ]
+    if website_result.get("evidence_notes"):
+        notes.append("官网检查:" + ";".join(website_result["evidence_notes"]))
+    if post_analysis:
+        notes.append("近期动态:" + post_analysis[:260])
+    if detected_brands:
+        notes.append("品牌信号:涉及中国品牌 " + "、".join(detected_brands))
+    if exclusivity:
+        notes.append("风险/待确认项:" + exclusivity)
+    return " | ".join(part for part in notes if part)[:1500]

+ 76 - 0
scripts/social/README.md

@@ -0,0 +1,76 @@
+# Facebook Follow + Messenger 建联脚本
+
+本目录是模块二的正式实现:从建联表生成英文社交建联预览,并通过 AdsPower + Playwright 执行 Facebook Page Follow + Messenger 私信。
+
+## 脚本清单
+
+| 脚本 | 作用 | 默认是否真实发送 | 默认是否写表 |
+|---|---|---:|---:|
+| `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 或确认发送 | 否 | 否 |
+
+## 推荐流程
+
+1. 已在 AdsPower 指纹浏览器中登录 Facebook。
+2. 运行总控脚本生成预览,确认客户和英文话术。
+3. 运行 dry-run,检查脚本识别到的 Page 顶部 Follow / Message 和 Messenger 目标。
+4. 用户明确确认后,加 `--confirm-send` 真实执行。
+5. 只有需要回写建联表时,额外加 `--write-workbook`。
+
+## 只生成预览
+
+```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 连接,浏览器保持打开。
+- 私信只能发到 Messenger 小窗或 Messenger 私信页,且必须验证目标客户名称。
+- 禁止向帖子评论框、回复框、页面底部通用输入框发送。
+- 若主页 `發送訊息` 按钮不弹出 Messenger 小窗,脚本会兜底打开 `https://www.facebook.com/messages/t/<page_slug>`,验证客户名称后再发送。
+- 遇到验证、限流、异常活动提示时立即停止。

+ 1 - 0
scripts/social/__init__.py

@@ -0,0 +1 @@
+# Social outreach scripts for Facebook / LinkedIn

+ 633 - 0
scripts/social/prepare_facebook_outreach.py

@@ -0,0 +1,633 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""Generate English Facebook outreach previews from the customer workbook."""
+from __future__ import annotations
+
+import argparse
+import json
+import random
+import re
+import sys
+from datetime import datetime
+from pathlib import Path
+from typing import Any, Dict, List, Optional, Sequence, Tuple
+
+from openpyxl import load_workbook
+
+SCRIPT_DIR = Path(__file__).resolve().parent
+SKILL_ROOT = SCRIPT_DIR.parents[1]
+sys.path.insert(0, str(SKILL_ROOT / "scripts" / "common"))
+try:
+    from workbook_resolver import resolve_workbook_path
+except Exception:  # pragma: no cover
+    resolve_workbook_path = None
+
+DEFAULT_SHEET = "Facebook"
+DEFAULT_STATUS = "未联系"
+
+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"],
+}
+
+CONTACTED_MARKERS = [
+    "已发送邮件", "已发邮件", "邮件已发送", "邮件发送成功", "已加好友", "已发送好友请求",
+    "已发私信", "已发送私信", "已联系", "email sent", "sent", "success",
+]
+
+OEM_BRANDS = [
+    "byd", "bmw", "jac", "mercedes", "toyota", "renault", "dacia", "kia", "hyundai",
+    "volkswagen", "peugeot", "citroen", "citroën", "ford", "chery", "geely", "audi",
+    "honda", "nissan", "suzuki", "mazda", "fiat", "opel", "skoda", "seat", "mg",
+    "changan", "foton", "sitrak", "dfsk", "great wall", "haval", "dongfeng", "jetour", "baic",
+    "gac", "maxus", "wuling",
+]
+OEM_BRANCH_HINTS = [
+    "official", "officiel", "page officielle", "maroc", "morocco", "branch", "subsidiary",
+    "官方", "分公司", "当地分公司", "国家页",
+]
+
+SCENARIOS = {
+    "used_car_dealer": {
+        "label_cn": "二手车商/occasion渠道",
+        "judgment_cn": "该客户有二手车或 occasion 客户基础。值得建联的原因是其客户通常对总购车成本敏感,可能愿意评估低成本新车作为二手车库存补充;批量潜力取决于库存周转和本地客源规模。",
+        "angle_cn": "从二手车客户升级到低成本新车的利润机会切入,强调先看小批量和价格区间,不压库存。",
+        "signal_label": "used-car/showroom activity",
+        "customer_base": "used-car buyers",
+        "fit_context": "buyers who want a new vehicle but still care most about total cost",
+    },
+    "multibrand_dealer": {
+        "label_cn": "多品牌经销商/展厅",
+        "judgment_cn": "该客户像多品牌经销商或 showroom,已有汽车销售场景和客户流量,可能具备消化一批高性价比车型的能力。",
+        "angle_cn": "从补充现有品牌和库存缺口切入,突出低成本新车线和小批量测试带来的走量可能。",
+        "signal_label": "multi-brand/showroom activity",
+        "customer_base": "showroom buyers",
+        "fit_context": "buyers comparing practical new vehicles across brands",
+    },
+    "commercial_vehicle_channel": {
+        "label_cn": "商用车/车队/实用车型渠道",
+        "judgment_cn": "该客户涉及商用车、车队、配送或实用车型。五菱的经济实用定位适合小企业、配送和家商两用需求,有机会形成批量采购或渠道分销。",
+        "angle_cn": "围绕小企业、配送、工具车和家商两用需求,测试实用低成本车型的批量消化能力。",
+        "signal_label": "commercial/practical vehicle activity",
+        "customer_base": "SME and practical-vehicle buyers",
+        "fit_context": "customers watching purchase cost, uptime, and practical daily use",
+    },
+    "local_showroom": {
+        "label_cn": "本地展厅/区域车商",
+        "judgment_cn": "该客户像本地展厅或区域车商,直接接触本地终端客户,适合用小批量方式测试价格敏感市场的接受度。",
+        "angle_cn": "从本地客户对价格和周转敏感切入,强调低压力首批试单。",
+        "signal_label": "local auto sales activity",
+        "customer_base": "local showroom buyers",
+        "fit_context": "local buyers who compare total cost before choosing a vehicle",
+    },
+    "importer_group": {
+        "label_cn": "进口商/集团/分销渠道",
+        "judgment_cn": "该客户可能具备进口、集团、分销或区域渠道能力,可能不只消化零售订单,也可能评估持续批量供货和区域分销。",
+        "angle_cn": "从进口/分销能力切入,先判断是否愿意评估首批试单和后续批量潜力。",
+        "signal_label": "import/distribution activity",
+        "customer_base": "regional dealer or importer networks",
+        "fit_context": "channels that can evaluate a first batch and possible later volume",
+    },
+    "rental_fleet": {
+        "label_cn": "租赁/车队客户",
+        "judgment_cn": "该客户经营租赁或车队。车队客户对购置成本、维护成本和车辆周转敏感,可能通过小批量先验证五菱车型适配度。",
+        "angle_cn": "围绕车队更新成本和车辆使用成本切入,先验证小批量车辆是否适合租赁/车队场景。",
+        "signal_label": "rental/fleet activity",
+        "customer_base": "rental or fleet buyers",
+        "fit_context": "fleet operators trying to lower renewal and operating cost",
+    },
+    "unknown_auto_channel": {
+        "label_cn": "信息不足的汽车相关渠道",
+        "judgment_cn": "该客户看起来与汽车业务相关,但职责和渠道能力不明确。可以轻量建联,但重点是先确认其是否涉及采购、销售、进口或分销,不应直接强推。",
+        "angle_cn": "信息不足时降低推销强度,先确认对方是否负责车辆采购、销售、进口或分销。",
+        "signal_label": "auto-sector activity",
+        "customer_base": "local auto-sector contacts",
+        "fit_context": "channels that may handle vehicle sourcing, sales, import, or distribution",
+    },
+}
+
+SIGNAL_PATTERNS = [
+    ("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"),
+    ("rental_fleet", ["rental", "rentcar", "location", "flotte", "fleet", "租赁", "租车", "车队"], "location/fleet", "rental or fleet activity"),
+    ("import_distribution", ["import", "importation", "importateur", "distributeur", "distribution", "group", "groupe", "进口", "分销", "集团"], "importation/distribution", "import or distribution activity"),
+    ("commercial", ["commercial", "utilitaire", "truck", "camion", "van", "delivery", "mpv", "商用", "货车", "卡车", "配送", "微型车"], "商用/实用车型", "commercial or practical-vehicle activity"),
+    ("website", ["官网", "website", "http", ".ma", ".com", ".net"], "官网/正式页面", "official website or public business page"),
+    ("contactable", ["whatsapp", "phone", "email", "电话", "邮箱", "公开联系方式", "+212"], "公开联系方式", "public WhatsApp, phone, or email"),
+    ("active_page", ["recent", "post", "active", "近期", "发帖", "活跃", "followers", "粉丝"], "主页活跃信号", "recent page activity"),
+]
+
+
+def clean(value: Any) -> str:
+    if value is None:
+        return ""
+    return re.sub(r"\s+", " ", str(value).strip())
+
+
+def header_map(headers: Sequence[Any]) -> Dict[str, int]:
+    raw = {clean(header): idx for idx, header in enumerate(headers) if clean(header)}
+    mapped: Dict[str, int] = {}
+    for key, aliases in HEADER_ALIASES.items():
+        for alias in aliases:
+            if alias in raw:
+                mapped[key] = raw[alias]
+                break
+    return mapped
+
+
+def read_records(excel_path: Path, sheet_name: str) -> List[Dict[str, str]]:
+    wb = load_workbook(excel_path, read_only=True, data_only=True)
+    if sheet_name not in wb.sheetnames:
+        raise KeyError(f"Sheet not found: {sheet_name}")
+    ws = wb[sheet_name]
+    rows = list(ws.iter_rows(values_only=True))
+    if not rows:
+        return []
+    columns = header_map(rows[0])
+    records: List[Dict[str, str]] = []
+    for row_number, raw in enumerate(rows[1:], start=2):
+        record = {key: clean(raw[idx]) if idx < len(raw) else "" for key, idx in columns.items()}
+        record["_row_number"] = str(row_number)
+        if any(record.get(key) for key in ["name", "link", "phone", "email", "website", "business", "note"]):
+            records.append(record)
+    return records
+
+
+def is_contacted(status: str) -> bool:
+    lowered = clean(status).casefold()
+    return bool(lowered) and any(marker.casefold() in lowered for marker in CONTACTED_MARKERS)
+
+
+def is_oem_branch(record: Dict[str, str]) -> bool:
+    name = record.get("name", "").casefold()
+    text = " ".join(record.get(key, "") for key in ["name", "type", "business", "note", "link"]).casefold()
+    has_brand = any(brand in name or brand in text for brand in OEM_BRANDS)
+    has_branch_hint = any(hint in name or hint in text for hint in OEM_BRANCH_HINTS)
+    return has_brand and has_branch_hint
+
+
+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", "集团", "进口", "分销"]):
+        return "importer_group"
+    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 ["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"
+
+
+def apply_filters(record: Dict[str, str], filters: Sequence[Tuple[str, str]]) -> bool:
+    for key, expected in filters:
+        value = record.get(key, record.get(key.lower(), ""))
+        if clean(value) != expected:
+            return False
+    return True
+
+
+COUNTRY_NAME_MAP = {
+    "摩洛哥": "Morocco",
+    "埃及": "Egypt",
+    "阿联酋": "the UAE",
+    "沙特": "Saudi Arabia",
+    "沙特阿拉伯": "Saudi Arabia",
+    "智利": "Chile",
+    "秘鲁": "Peru",
+    "墨西哥": "Mexico",
+    "哥伦比亚": "Colombia",
+    "阿尔及利亚": "Algeria",
+    "突尼斯": "Tunisia",
+    "南非": "South Africa",
+}
+GENERIC_CITY_MARKERS = {
+    "", "多城市", "多个城市", "全国", "全国范围", "全境", "多地区", "多个地区", "各地",
+    "morocco", "maroc", "national", "nationwide", "multiple cities", "multi-city", "all cities",
+}
+
+
+def market_name(country: str) -> str:
+    value = clean(country)
+    if not value:
+        return "the target market"
+    return COUNTRY_NAME_MAP.get(value, value)
+
+
+def is_generic_city(city: str) -> bool:
+    value = clean(city)
+    lowered = value.casefold()
+    if lowered in GENERIC_CITY_MARKERS or value in GENERIC_CITY_MARKERS:
+        return True
+    return any(ord(ch) > 127 for ch in value)
+
+
+def city_phrase(city: str, country: str) -> str:
+    market = market_name(country)
+    if is_generic_city(city):
+        return f"in {market}"
+    return f"in {clean(city)}"
+
+
+def sentence_join(parts: Sequence[str]) -> str:
+    return "; ".join(dict.fromkeys(clean(part) for part in parts if clean(part)))
+
+
+def english_signal_phrase(parts: Sequence[str], fallback: str) -> str:
+    unique = list(dict.fromkeys(clean(part) for part in parts if clean(part)))
+    if not unique:
+        return fallback
+    selected = unique[:2]
+    if len(selected) == 1:
+        return selected[0]
+    return f"{selected[0]} and {selected[1]}"
+
+
+def has_signal_text(record: Dict[str, str]) -> str:
+    return " ".join(record.get(key, "") for key in ["name", "type", "business", "note", "website", "phone", "email", "link"]).casefold()
+
+
+def extract_customer_signals(record: Dict[str, str], scenario_key: str) -> Dict[str, Any]:
+    text = has_signal_text(record)
+    hits_cn: List[str] = []
+    hits_en: List[str] = []
+    hit_keys: List[str] = []
+    for key, keywords, label_cn, label_en in SIGNAL_PATTERNS:
+        if any(keyword.casefold() in text for keyword in keywords):
+            hits_cn.append(label_cn)
+            hits_en.append(label_en)
+            hit_keys.append(key)
+    scenario = SCENARIOS[scenario_key]
+    market = market_name(record.get("country", ""))
+    location = city_phrase(record.get("city", ""), record.get("country", ""))
+    observed_signal_cn = sentence_join(hits_cn[:4]) or scenario["signal_label"]
+    observed_signal_en = sentence_join(hits_en[:3]) or scenario["signal_label"]
+    customer_signal_en = english_signal_phrase(hits_en, scenario["signal_label"])
+    strong_signal = len(hit_keys) >= 1 and scenario_key != "unknown_auto_channel"
+    signal_quality = "strong" if len(hit_keys) >= 2 else "medium" if strong_signal else "weak"
+
+    if scenario_key == "unknown_auto_channel" or signal_quality == "weak":
+        business_hypothesis = "对方与汽车行业相关,但采购、销售、进口或分销职责不清,首轮应先确认角色。"
+        profit_angle = "低压力确认是否负责车辆采购/分销,不直接强推。"
+        light_offer = "a short model and price-range overview"
+        reply_question = "Does your team handle vehicle purchasing or distribution?"
+    elif scenario_key == "rental_fleet":
+        business_hypothesis = "租赁/车队业务对购置成本、维护成本和周转敏感,可能关注低成本车队更新方案。"
+        profit_angle = "用低采购成本和小批量 fleet fit check 切入。"
+        light_offer = "a small fleet-fit and price-range overview"
+        reply_question = "Should I send a short fleet-fit and price-range overview?"
+    elif scenario_key == "importer_group":
+        business_hypothesis = "对方可能具备进口、集团或区域分销能力,适合验证首批试单和后续批量潜力。"
+        profit_angle = "用 import/distribution 能力和可能的 volume potential 切入。"
+        light_offer = "a short first-batch fit check"
+        reply_question = "Would a short first-batch fit check be useful for your team?"
+    elif scenario_key == "commercial_vehicle_channel":
+        business_hypothesis = "对方客户可能重视实用车型、配送、小企业和家商两用需求。"
+        profit_angle = "用低成本实用新车补充商用/工具车需求。"
+        light_offer = "a practical-vehicle model and price-range overview"
+        reply_question = "Should I send a short practical-vehicle overview for your team to judge fit?"
+    else:
+        business_hypothesis = "对方已有汽车销售或 showroom 客户基础,可能接触价格敏感买家。"
+        profit_angle = "用 affordable new-vehicle line 补充现有库存,先小批量判断周转潜力。"
+        light_offer = "a short model and price-range overview"
+        reply_question = "Should I send a short model and price-range overview?"
+
+    return {
+        "observed_signal": observed_signal_en,
+        "observed_signal_cn": observed_signal_cn,
+        "customer_signal": customer_signal_en,
+        "observed_signal_keys": hit_keys,
+        "signal_quality": signal_quality,
+        "business_hypothesis": business_hypothesis,
+        "profit_angle": profit_angle,
+        "light_offer": light_offer,
+        "reply_question": reply_question,
+        "market_name": market,
+        "location_phrase": location,
+        "risk_reason": "信息不足,话术已降级为低压确认型。" if signal_quality == "weak" else "需人工确认页面真实性和客户是否负责采购/分销。",
+    }
+
+
+def compact_english(text: str) -> str:
+    text = re.sub(r"\s+([.,;:!?])", r"\1", text)
+    text = re.sub(r"[ \t]+", " ", text)
+    text = re.sub(r"\n ", "\n", text)
+    return text.strip()
+
+
+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"
+    name = record.get("name") or "your company"
+    location = signals["location_phrase"]
+    signal = signals["customer_signal"]
+    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."),
+        }
+    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."),
+    }
+
+
+def build_dm_variants(record: Dict[str, str], scenario_key: str, scenario: Dict[str, str], signals: Dict[str, Any]) -> Dict[str, str]:
+    market = signals["market_name"]
+    signal = signals["customer_signal"]
+    question = signals["reply_question"]
+    light_offer = signals["light_offer"]
+    fit_context = scenario["fit_context"]
+
+    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?"),
+        }
+
+    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?"),
+    }
+
+
+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 {"used_car_dealer", "rental_fleet", "commercial_vehicle_channel"}:
+        return "direct_profit_hook"
+    return "stock_gap_hook"
+
+
+def build_message_variants(record: Dict[str, str], scenario_key: str) -> Dict[str, Any]:
+    scenario = SCENARIOS[scenario_key]
+    signals = extract_customer_signals(record, scenario_key)
+    connects = build_connect_variants(record, scenario, signals)
+    dms = build_dm_variants(record, scenario_key, scenario, signals)
+    alternatives = {
+        key: {
+            "label_cn": {
+                "direct_profit_hook": "直接利润机会",
+                "stock_gap_hook": "库存/车型补充机会",
+                "soft_research_hook": "低压行业交流",
+            }[key],
+            "english_connect": connects[key],
+            "english_first_dm": dms[key],
+        }
+        for key in ["direct_profit_hook", "stock_gap_hook", "soft_research_hook"]
+    }
+    recommended_key = recommended_variant_key(scenario_key, signals)
+    return {
+        "recommended_key": recommended_key,
+        "recommended_message": alternatives[recommended_key]["english_first_dm"],
+        "recommended_connect": alternatives[recommended_key]["english_connect"],
+        "alternatives": alternatives,
+        "signals": signals,
+    }
+
+
+
+def first_sentence(text: str) -> str:
+    value = clean(text)
+    match = re.search(r"^(.+?[.!?])(?:\s|$)", value)
+    return clean(match.group(1) if match else value[:120]).casefold()
+
+
+def apply_variant_to_item(item: Dict[str, Any], variant_key: str) -> None:
+    variant = item.get("alternatives", {}).get(variant_key, {})
+    if not variant:
+        return
+    connect = variant.get("english_connect", "")
+    dm = variant.get("english_first_dm", "")
+    item["recommended_variant"] = variant_key
+    item["recommended_message"] = dm
+    item["english_connect"] = connect
+    item["english_first_dm"] = dm
+    item["messages"]["connect"]["en"] = connect
+    item["messages"]["dm"]["en"] = dm
+    item["formatted_preview"]["英文加好友话术"] = connect
+    item["formatted_preview"]["英文首轮私信"] = dm
+
+
+def ensure_unique_message_openers(items: List[Dict[str, Any]]) -> None:
+    seen: set[str] = set()
+    variant_order = ["direct_profit_hook", "stock_gap_hook", "soft_research_hook"]
+    for item in items:
+        current = item.get("recommended_variant") or ""
+        candidates = [current] + [key for key in variant_order if key != current]
+        selected = current
+        for key in candidates:
+            message = item.get("alternatives", {}).get(key, {}).get("english_first_dm", "")
+            opener = first_sentence(message)
+            if opener and opener not in seen:
+                selected = key
+                break
+        apply_variant_to_item(item, selected)
+        opener = first_sentence(item.get("recommended_message", ""))
+        if opener:
+            seen.add(opener)
+
+
+def suggested_action(status: str) -> str:
+    if not clean(status) or clean(status) == DEFAULT_STATUS:
+        return "follow_and_first_dm"
+    if is_contacted(status):
+        return "skip_or_follow_up"
+    return "first_dm"
+
+
+def parse_filters(raw_filters: Sequence[str]) -> List[Tuple[str, str]]:
+    filters: List[Tuple[str, str]] = []
+    for item in raw_filters:
+        if "=" not in item:
+            raise ValueError(f"Invalid filter, expected field=value: {item}")
+        key, value = item.split("=", 1)
+        filters.append((key.strip(), value.strip()))
+    return filters
+
+
+def resolve_excel(excel: str) -> Path:
+    if excel:
+        path = Path(excel).expanduser()
+        return path if path.is_absolute() else (Path.cwd() / path).resolve()
+    if resolve_workbook_path:
+        resolved = resolve_workbook_path("", create_from_template=False)
+        if resolved.get("path"):
+            return Path(resolved["path"])
+    raise FileNotFoundError("No workbook found. Pass --excel.")
+
+
+def build_preview(records: Sequence[Dict[str, str]], filters: Sequence[Tuple[str, str]], include_sent: bool) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
+    items: List[Dict[str, Any]] = []
+    skipped: List[Dict[str, Any]] = []
+    for ordinal, record in enumerate(records, start=1):
+        name = record.get("name", "")
+        link = record.get("link", "")
+        status = record.get("status", "")
+        if not name or not link:
+            skipped.append({"row_number": record.get("_row_number"), "dealer_name": name or "(blank)", "reason": "missing name or Facebook page link"})
+            continue
+        if filters and not apply_filters(record, filters):
+            continue
+        if is_contacted(status) and not include_sent:
+            skipped.append({"row_number": record.get("_row_number"), "dealer_name": name, "reason": f"already contacted: {status}"})
+            continue
+        if is_oem_branch(record):
+            skipped.append({"row_number": record.get("_row_number"), "dealer_name": name, "reason": "建议跳过:疑似官方品牌当地页,不生成发送话术"})
+            continue
+
+        scenario_key = classify_channel(record)
+        scenario = SCENARIOS[scenario_key]
+        variant_pack = build_message_variants(record, scenario_key)
+        signals = variant_pack["signals"]
+        connect_en = variant_pack["recommended_connect"]
+        dm_en = variant_pack["recommended_message"]
+        signal_line = f"真实信号:{signals['observed_signal_cn']};商业假设:{signals['business_hypothesis']}"
+        risk_note = f"{signals['risk_reason']} 预览用途。真实执行前必须先在聊天框展示整批预览,并由用户一次性确认。"
+        item = {
+            "index": record.get("index") or str(ordinal),
+            "row_number": record.get("_row_number"),
+            "dealer_name": name,
+            "city": record.get("city", ""),
+            "dealer_type": record.get("type", ""),
+            "main_business": record.get("business", ""),
+            "page_url": link,
+            "company_website": record.get("website", ""),
+            "status": status or DEFAULT_STATUS,
+            "channel": scenario_key,
+            "customer_judgment_cn": f"{scenario['judgment_cn']} {signal_line}",
+            "recommended_angle_cn": f"{scenario['angle_cn']} 推荐使用“{variant_pack['alternatives'][variant_pack['recommended_key']]['label_cn']}”版本。",
+            "observed_signal": signals["observed_signal"],
+            "observed_signal_cn": signals["observed_signal_cn"],
+            "signal_quality": signals["signal_quality"],
+            "business_hypothesis": signals["business_hypothesis"],
+            "profit_angle": signals["profit_angle"],
+            "light_offer": signals["light_offer"],
+            "reply_question": signals["reply_question"],
+            "suggested_action": suggested_action(status),
+            "recommended_variant": variant_pack["recommended_key"],
+            "recommended_message": dm_en,
+            "english_connect": connect_en,
+            "english_first_dm": dm_en,
+            "alternatives": variant_pack["alternatives"],
+            "message_variants": variant_pack["alternatives"],
+            "messages": {
+                "connect": {"客户判断": f"{scenario['judgment_cn']} {signal_line}", "推荐切入点": scenario["angle_cn"], "en": connect_en},
+                "dm": {"客户判断": f"{scenario['judgment_cn']} {signal_line}", "推荐切入点": scenario["angle_cn"], "en": dm_en},
+            },
+            "formatted_preview": {
+                "客户判断": f"{scenario['judgment_cn']} {signal_line}",
+                "推荐切入点": f"{scenario['angle_cn']} 推荐使用“{variant_pack['alternatives'][variant_pack['recommended_key']]['label_cn']}”版本。",
+                "英文加好友话术": connect_en,
+                "英文首轮私信": dm_en,
+                "备选话术": variant_pack["alternatives"],
+                "风险提示": risk_note,
+            },
+            "risk_note": risk_note,
+            "risk_note_cn": risk_note,
+            "record": record,
+        }
+        items.append(item)
+    return items, skipped
+
+
+def render_human_preview(result: Dict[str, Any], max_chars: int = 16000) -> str:
+    """Render a chat-friendly preview for one batch before any send action."""
+    summary = result.get("summary", {})
+    lines: List[str] = []
+    lines.append("# Facebook 建联话术预览")
+    lines.append(f"准备发送:{summary.get('ready_to_send', 0)} 条;跳过:{summary.get('skipped', 0)} 条")
+    lines.append("确认后将按本批预览批量执行 Follow + Messenger DM,不再逐条确认。")
+    lines.append("")
+    for idx, item in enumerate(result.get("items", []), start=1):
+        fp = item.get("formatted_preview", {}) or {}
+        lines.append(f"## {idx}. {item.get('dealer_name', '')}")
+        lines.append(f"主页:{item.get('page_url', '')}")
+        lines.append(f"客户判断:{fp.get('客户判断') or item.get('customer_judgment_cn', '')}")
+        lines.append(f"推荐切入点:{fp.get('推荐切入点') or item.get('outreach_angle_cn', '')}")
+        lines.append("英文首轮私信:")
+        lines.append(item.get("recommended_message") or item.get("english_first_dm", ""))
+        lines.append(f"风险提示:{fp.get('风险提示') or item.get('risk_note', '')}")
+        lines.append("")
+    rendered = "\n".join(lines).strip()
+    if len(rendered) > max_chars:
+        rendered = rendered[:max_chars] + "\n\n[预览过长,已截断;完整 JSON 见输出文件]"
+    return rendered
+
+
+def main(argv: Optional[Sequence[str]] = None) -> int:
+    parser = argparse.ArgumentParser(description="Generate English Facebook outreach preview JSON.")
+    parser.add_argument("--excel", default="", help="Customer outreach workbook path. If omitted, use workbook resolver.")
+    parser.add_argument("--sheet", default=DEFAULT_SHEET, help="Source sheet name.")
+    parser.add_argument("--filter", action="append", default=[], help="Filter condition, field=value. Can repeat.")
+    parser.add_argument("--sample", type=int, default=0, help="Randomly sample N matched records. 0 means all.")
+    parser.add_argument("--include-sent", action="store_true", help="Include already-contacted records for follow-up preview.")
+    parser.add_argument("--seed", type=int, default=None, help="Random seed for sampling.")
+    parser.add_argument("--output", required=True, help="Output JSON preview path.")
+    args = parser.parse_args(argv)
+
+    excel_path = resolve_excel(args.excel)
+    if not excel_path.exists():
+        raise FileNotFoundError(f"Workbook not found: {excel_path}")
+    filters = parse_filters(args.filter)
+    records = read_records(excel_path, args.sheet)
+    items, skipped = build_preview(records, filters, args.include_sent)
+
+    if args.sample and args.sample < len(items):
+        if args.seed is not None:
+            random.seed(args.seed)
+        items = random.sample(items, args.sample)
+    ensure_unique_message_openers(items)
+
+    result = {
+        "generated_at": datetime.now().isoformat(timespec="seconds"),
+        "language_policy": {"customer_facing": "English", "internal_review": "Chinese", "facebook_default_uses_french": False},
+        "message_strategy": {
+            "framework": "specific signal -> commercial hypothesis -> light offer -> one question",
+            "variants": ["direct_profit_hook", "stock_gap_hook", "soft_research_hook"],
+            "recommended_field": "recommended_message",
+        },
+        "source": {"excel": str(excel_path), "sheet": args.sheet, "filters": args.filter, "sample": args.sample, "seed": args.seed, "include_sent": args.include_sent},
+        "summary": {"total_records": len(records), "ready_to_send": len(items), "matched_records": len(items), "skipped": len(skipped)},
+        "items": items,
+        "skipped": skipped,
+    }
+    result["human_preview"] = render_human_preview(result)
+
+    output_path = Path(args.output).expanduser()
+    if not output_path.is_absolute():
+        output_path = Path.cwd() / output_path
+    output_path.parent.mkdir(parents=True, exist_ok=True)
+    output_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
+    print(json.dumps(result["summary"], ensure_ascii=False))
+    print("\n" + result["human_preview"] + "\n")
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
+
+
+
+

+ 253 - 0
scripts/social/run_facebook_follow_dm.py

@@ -0,0 +1,253 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""Run the Facebook Page Follow + Messenger DM workflow.
+
+This is the module-2 orchestration entrypoint:
+1. Read the Facebook sheet and generate an English outreach preview.
+2. Run the AdsPower/Playwright Follow + Messenger DM executor.
+3. Print the full human-readable preview before any real action.
+
+Real browser actions require --confirm-send, which passes one batch-level
+confirmation to the executor. Workbook updates require --write-workbook. The
+AdsPower browser is always kept open.
+"""
+from __future__ import annotations
+
+import argparse
+import importlib.util
+import json
+import sys
+from datetime import datetime
+from pathlib import Path
+from typing import Any, List, Optional, Sequence
+
+sys.path.append(str(Path(__file__).resolve().parents[1]))
+from common.artifact_manager import resolve_artifact_path
+
+SCRIPT_DIR = Path(__file__).resolve().parent
+SKILL_ROOT = SCRIPT_DIR.parents[1]
+PROJECT_ROOT = Path.cwd()
+
+
+def load_module(name: str, path: Path):
+    spec = importlib.util.spec_from_file_location(name, path)
+    if spec is None or spec.loader is None:
+        raise ImportError(f"Cannot load module: {path}")
+    module = importlib.util.module_from_spec(spec)
+    spec.loader.exec_module(module)
+    return module
+
+
+prepare_mod = load_module("prepare_facebook_outreach", SCRIPT_DIR / "prepare_facebook_outreach.py")
+send_mod = load_module("send_facebook_outreach", SCRIPT_DIR / "send_facebook_outreach.py")
+
+
+def timestamp() -> str:
+    return datetime.now().strftime("%Y%m%d_%H%M%S")
+
+
+def resolve_output_path(raw: str, output_dir: str, run_id: str = "") -> Path:
+    if output_dir and not raw:
+        base = Path(output_dir).expanduser()
+        if not base.is_absolute():
+            base = Path.cwd() / base
+        return base / f"facebook_follow_dm_preview_{timestamp()}.json"
+    return resolve_artifact_path(
+        raw,
+        kind="facebook_follow_dm",
+        default_name=f"facebook_follow_dm_preview_{timestamp()}.json",
+        run_id=run_id or None,
+    )
+
+
+def build_prepare_args(args: argparse.Namespace, preview_path: Path) -> List[str]:
+    prepare_args: List[str] = [
+        "--sheet", args.sheet,
+        "--output", str(preview_path),
+    ]
+    if args.excel:
+        prepare_args.extend(["--excel", args.excel])
+    for item in args.filter:
+        prepare_args.extend(["--filter", item])
+    if args.max_contacts > 0:
+        prepare_args.extend(["--sample", str(args.max_contacts)])
+    if args.include_sent:
+        prepare_args.append("--include-sent")
+    if args.seed is not None:
+        prepare_args.extend(["--seed", str(args.seed)])
+    return prepare_args
+
+
+def build_send_args(args: argparse.Namespace, preview_path: Path) -> List[str]:
+    send_args: List[str] = [
+        "--preview", str(preview_path),
+        "--profile-id", args.profile_id,
+        "--ads-power-url", args.ads_power_url,
+        "--action", "follow_dm",
+        "--max-per-run", str(args.max_contacts),
+        "--risk-profile", args.risk_profile,
+        "--daily-follow-limit", str(args.daily_follow_limit),
+        "--daily-dm-limit", str(args.daily_dm_limit),
+        "--session-max", str(args.session_max),
+    ]
+    if args.confirm_send:
+        send_args.extend(["--confirm", "--batch-confirmed"])
+    if args.use_open_page:
+        send_args.append("--use-open-page")
+    send_args.append("--keep-browser-open")
+    if args.dry_run_schedule:
+        send_args.append("--dry-run-schedule")
+    if args.write_workbook:
+        send_args.append("--write-workbook")
+    else:
+        send_args.append("--no-write-workbook")
+    return send_args
+
+
+def read_preview_summary(preview_path: Path) -> dict[str, Any]:
+    data = json.loads(preview_path.read_text(encoding="utf-8"))
+    return data.get("summary", {})
+
+
+def render_human_preview(preview_path: Path, max_chars: int = 16000) -> str:
+    data = json.loads(preview_path.read_text(encoding="utf-8"))
+    lines: List[str] = []
+    summary = data.get("summary", {})
+    lines.append("# Facebook Follow + Messenger DM 预览")
+    lines.append(f"准备发送:{summary.get('ready_to_send', 0)} 条;跳过:{summary.get('skipped', 0)} 条")
+    lines.append("")
+    for idx, item in enumerate(data.get("items", []), start=1):
+        fp = item.get("formatted_preview", {}) or {}
+        lines.append(f"## {idx}. {item.get('dealer_name', '')}")
+        lines.append(f"主页:{item.get('page_url', '')}")
+        lines.append(f"客户判断:{fp.get('客户判断') or item.get('customer_judgment_cn', '')}")
+        lines.append(f"推荐切入点:{fp.get('推荐切入点') or item.get('outreach_angle_cn', '')}")
+        lines.append("英文首轮私信:")
+        lines.append(item.get("recommended_message") or item.get("english_first_dm", ""))
+        lines.append(f"风险提示:{fp.get('风险提示') or item.get('risk_note', '')}")
+        lines.append("")
+    rendered = "\n".join(lines).strip()
+    if len(rendered) > max_chars:
+        rendered = rendered[:max_chars] + "\n\n[预览过长,已截断;完整 JSON 见输出文件]"
+    return rendered
+
+
+
+def build_schedule_preview(preview_path: Path, args: argparse.Namespace) -> dict[str, Any]:
+    data = json.loads(preview_path.read_text(encoding="utf-8"))
+    all_items = data.get("items", [])
+    ledger = send_mod.load_ledger(args.profile_id)
+    profile = send_mod.PACING_PROFILES[args.risk_profile]
+    requested = max(0, args.max_contacts)
+    allowed = send_mod.allowed_item_count("follow_dm", requested, ledger, args.daily_follow_limit, args.daily_dm_limit, args.session_max)
+    items = all_items[:allowed]
+    return send_mod.build_schedule_preview(items, argparse.Namespace(
+        confirm=args.confirm_send,
+        risk_profile=args.risk_profile,
+        profile_id=args.profile_id,
+        daily_follow_limit=args.daily_follow_limit,
+        daily_dm_limit=args.daily_dm_limit,
+        session_max=args.session_max,
+        action="follow_dm",
+    ), ledger, profile)
+
+
+def render_schedule_markdown(schedule: dict[str, Any]) -> str:
+    sched = schedule.get("schedule", {})
+    used = schedule.get("today_used", {})
+    remaining = schedule.get("today_remaining", {})
+    limits = schedule.get("limits", {})
+    lines = [
+        "# Facebook 合规执行节奏预览",
+        f"风险档位:{schedule.get('risk_profile')};账号:{schedule.get('profile_id')}",
+        f"本轮客户:{sched.get('customers', 0)};预计耗时:{sched.get('estimated_minutes_min', 0)}-{sched.get('estimated_minutes_max', 0)} 分钟",
+        f"今日已用:Follow {used.get('follow', 0)} / DM {used.get('dm', 0)} / 失败 {used.get('failures', 0)} / 风险 {used.get('risk_events', 0)}",
+        f"今日剩余:Follow {remaining.get('follow', 0)} / DM {remaining.get('dm', 0)};单轮上限:{limits.get('session_max', 0)}",
+        "等待分层:大层级 major=90-200s;小层级 minor=30-90s;技术等待 technical=0.5-8s,只用于 DOM/元素检测,不计入对外行为节奏。",
+        "停止规则:出现验证、限流、异常活动、身份确认或失败阈值达到时立即停止,不关闭浏览器。",
+        "",
+        "阶段等待:",
+    ]
+    stages = sched.get("stages", {}) or {}
+    stage_tiers = sched.get("stage_tiers", {}) or {}
+    for stage, wait_range in stages.items():
+        tier = stage_tiers.get(stage, "technical")
+        if isinstance(wait_range, (list, tuple)) and len(wait_range) == 2:
+            range_text = f"{wait_range[0]}-{wait_range[1]}s"
+        else:
+            range_text = str(wait_range)
+        lines.append(f"- {stage}: {tier} / {range_text}")
+    lines.append("")
+    for idx, item in enumerate(schedule.get("customers", []), start=1):
+        lines.append(f"{idx}. {item.get('dealer_name', '')} - {item.get('page_url', '')}")
+    return "\n".join(lines)
+
+
+def main(argv: Optional[Sequence[str]] = None) -> int:
+    parser = argparse.ArgumentParser(
+        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("--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.")
+    parser.add_argument("--max-contacts", type=int, default=3, help="Maximum customers for this run. Default: 3.")
+    parser.add_argument("--output", default="", help="Preview JSON output path. Default: previews/facebook_follow_dm_preview_<time>.json.")
+    parser.add_argument("--output-dir", default="", help="Preview directory when --output is omitted. Default uses runs/YYYYMMDD/<run_id>/.")
+    parser.add_argument("--run-id", default="", help="Run ID used for runs/YYYYMMDD/<run_id>/ artifacts.")
+    parser.add_argument("--seed", type=int, default=None, help="Random seed for sampling.")
+    parser.add_argument("--include-sent", action="store_true", help="Include already-contacted rows for follow-up testing.")
+    parser.add_argument("--review-only", action="store_true", help="Only generate preview, do not connect to AdsPower.")
+    parser.add_argument("--confirm-send", action="store_true", help="Allow real Follow + Messenger DM after the full preview is shown in chat and approved once.")
+    parser.add_argument("--auto-confirm-each", action="store_true", help=argparse.SUPPRESS)
+    parser.add_argument("--use-open-page", action="store_true", help="Use an already opened matching Facebook Page tab when possible.")
+    parser.add_argument("--keep-browser-open", action="store_true", default=True, help="Compatibility flag; AdsPower browser is always kept open.")
+    parser.add_argument("--write-workbook", action="store_true", help="Write status back to workbook after confirmed execution.")
+    parser.add_argument("--risk-profile", choices=sorted(send_mod.PACING_PROFILES), default="very_conservative", help="Pacing policy for compliant low-frequency outreach. Default: very_conservative.")
+    parser.add_argument("--daily-follow-limit", type=int, default=send_mod.DEFAULT_DAILY_FOLLOW_LIMIT, help="Per-profile daily Follow cap. Default: 20.")
+    parser.add_argument("--daily-dm-limit", type=int, default=send_mod.DEFAULT_DAILY_DM_LIMIT, help="Per-profile daily DM/customer outreach cap. Default: 20.")
+    parser.add_argument("--session-max", type=int, default=send_mod.DEFAULT_SESSION_MAX, help="Maximum customers per execution session. Default: 3.")
+    parser.add_argument("--dry-run-schedule", action="store_true", help="Print pacing schedule and customer list without opening AdsPower.")
+    args = parser.parse_args(argv)
+
+    if args.max_contacts < 1:
+        raise ValueError("--max-contacts must be at least 1")
+    if args.max_contacts > 3 and not args.confirm_send:
+        print("Warning: dry-run batch is above the recommended 3 contacts for Facebook outreach.")
+    if args.auto_confirm_each:
+        print("--auto-confirm-each is deprecated; v4.1 uses one batch-level confirmation after the chat preview.")
+
+    preview_path = resolve_output_path(args.output, args.output_dir, args.run_id)
+    preview_path.parent.mkdir(parents=True, exist_ok=True)
+
+    print("Step 1/2: generating Facebook outreach preview...")
+    prepare_rc = prepare_mod.main(build_prepare_args(args, preview_path))
+    if prepare_rc != 0:
+        return int(prepare_rc)
+
+    summary = read_preview_summary(preview_path)
+    print(json.dumps({"preview": str(preview_path), "summary": summary}, ensure_ascii=False, indent=2))
+    print("\n" + render_human_preview(preview_path) + "\n")
+    schedule = build_schedule_preview(preview_path, args)
+    print("\n" + render_schedule_markdown(schedule) + "\n")
+
+    if args.review_only:
+        print("Review-only mode: browser execution skipped.")
+        return 0
+    if args.dry_run_schedule:
+        print("Schedule-only mode: browser execution skipped.")
+        return send_mod.main(build_send_args(args, preview_path))
+
+    if not args.confirm_send:
+        print("Step 2/2: dry-run only. Add --confirm-send for real Follow + Messenger DM.")
+    else:
+        print("Step 2/2: confirmed Follow + Messenger DM execution.")
+
+    send_rc = send_mod.main(build_send_args(args, preview_path))
+    return int(send_rc or 0)
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
+

+ 992 - 0
scripts/social/send_facebook_outreach.py

@@ -0,0 +1,992 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""Semi-automated Facebook follow/message execution through AdsPower.
+
+Default behavior is dry-run. Real execution requires --confirm plus
+--batch-confirmed after the whole preview has been shown to the user. Messenger
+sending is deliberately scoped to the Facebook Page header Message button and
+the matching Messenger dialog only.
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import random
+import re
+import sys
+import time
+from datetime import datetime, timedelta
+from math import ceil
+from pathlib import Path
+
+import sys
+sys.path.append(str(Path(__file__).resolve().parents[1]))
+from common.artifact_manager import resolve_artifact_path
+from typing import Any, Dict, List, Optional, Sequence
+
+from openpyxl import load_workbook
+
+SCRIPT_DIR = Path(__file__).resolve().parent
+SKILL_ROOT = SCRIPT_DIR.parents[1]
+sys.path.insert(0, str(SKILL_ROOT))
+from scripts.scraper.ads_power_client import AdsPowerClient  # noqa: E402
+
+DEFAULT_ADS_POWER_URL = "http://127.0.0.1:50325"
+MAX_PER_RUN = 3
+DEFAULT_DAILY_FOLLOW_LIMIT = 20
+DEFAULT_DAILY_DM_LIMIT = 20
+DEFAULT_SESSION_MAX = 3
+FAILURE_STOP_THRESHOLD = 3
+DEFAULT_RISK_COOLDOWN_MINUTES = 24 * 60
+WAIT_TIERS = {
+    "major": (90, 200),
+    "minor": (30, 90),
+    "technical": (0.5, 8),
+}
+PACING_STAGE_TIERS = {
+    "page_open": "major",
+    "after_follow": "major",
+    "after_send": "major",
+    "between_customers": "major",
+    "session_cooldown": "major",
+    "after_message_open": "minor",
+    "before_send": "minor",
+}
+PACING_PROFILES = {
+    "very_conservative": {
+        "page_open": WAIT_TIERS["major"],
+        "after_follow": WAIT_TIERS["major"],
+        "after_message_open": WAIT_TIERS["minor"],
+        "before_send": WAIT_TIERS["minor"],
+        "after_send": WAIT_TIERS["major"],
+        "between_customers": WAIT_TIERS["major"],
+        "session_cooldown": (1200, 3600),
+    },
+    "conservative": {
+        "page_open": WAIT_TIERS["major"],
+        "after_follow": WAIT_TIERS["major"],
+        "after_message_open": WAIT_TIERS["minor"],
+        "before_send": WAIT_TIERS["minor"],
+        "after_send": WAIT_TIERS["major"],
+        "between_customers": WAIT_TIERS["major"],
+        "session_cooldown": (600, 1800),
+    },
+    "standard": {
+        "page_open": WAIT_TIERS["major"],
+        "after_follow": WAIT_TIERS["major"],
+        "after_message_open": WAIT_TIERS["minor"],
+        "before_send": WAIT_TIERS["minor"],
+        "after_send": WAIT_TIERS["major"],
+        "between_customers": WAIT_TIERS["major"],
+        "session_cooldown": (300, 900),
+    },
+}
+RISK_STOP_PHRASES = [
+    "we limit",
+    "you are temporarily blocked",
+    "you're temporarily blocked",
+    "action blocked",
+    "confirm your identity",
+    "suspicious activity",
+    "temporarily unavailable",
+    "identity confirmation",
+    "verification required",
+]
+STATUS_VALUES = {
+    "friend_success": "已加好友,待私信",
+    "follow_success": "已关注,待私信",
+    "dm_success": "已发私信",
+    "both_success": "已关注,已发私信",
+    "friend_failed": "加好友失败",
+    "follow_failed": "关注失败",
+    "dm_failed": "发送失败",
+}
+HEADER_ALIASES = {
+    "index": ["序号", "Index", "No."],
+    "status": ["建联状态", "建联情况", "状态", "Status"],
+    "note": ["备注", "说明", "Notes"],
+}
+COMMENT_HINTS = ["comment", "留言", "reply", "回覆", "回复", "write a comment", "撰寫留言"]
+MESSAGE_BUTTON_RE = re.compile(r"(^|\s)(message|訊息)(\s|$)|send message|發送訊息|发送讯息|发送消息", re.I)
+FOLLOW_BUTTON_RE = re.compile(r"(^|\s)(follow|追蹤|关注)(\s|$)", re.I)
+FOLLOWING_BUTTON_RE = re.compile(r"following|追蹤中|已关注|已追蹤", re.I)
+
+
+def now_iso() -> str:
+    return datetime.now().isoformat(timespec="seconds")
+
+
+def today_key() -> str:
+    return datetime.now().strftime("%Y%m%d")
+
+
+def ledger_path(profile_id: str) -> Path:
+    safe_profile = re.sub(r"[^0-9A-Za-z_.-]+", "_", profile_id.strip() or "unknown")
+    return Path("logs") / f"facebook_account_ledger_{safe_profile}_{today_key()}.json"
+
+
+def empty_ledger(profile_id: str) -> Dict[str, Any]:
+    return {
+        "profile_id": profile_id,
+        "date": today_key(),
+        "follow_count": 0,
+        "dm_count": 0,
+        "failure_count": 0,
+        "risk_events": [],
+        "events": [],
+        "last_run_at": "",
+        "cooldown_until": "",
+    }
+
+
+def load_ledger(profile_id: str) -> Dict[str, Any]:
+    path = ledger_path(profile_id)
+    if not path.exists():
+        return empty_ledger(profile_id)
+    try:
+        data = json.loads(path.read_text(encoding="utf-8"))
+    except Exception:
+        data = empty_ledger(profile_id)
+    defaults = empty_ledger(profile_id)
+    for key, value in defaults.items():
+        data.setdefault(key, value)
+    return data
+
+
+def save_ledger(profile_id: str, ledger: Dict[str, Any]) -> None:
+    path = ledger_path(profile_id)
+    path.parent.mkdir(parents=True, exist_ok=True)
+    path.write_text(json.dumps(ledger, ensure_ascii=False, indent=2), encoding="utf-8")
+
+
+def ledger_event(ledger: Dict[str, Any], event_type: str, dealer_name: str = "", detail: str = "") -> None:
+    ledger["last_run_at"] = now_iso()
+    ledger.setdefault("events", []).append({
+        "timestamp": now_iso(),
+        "type": event_type,
+        "dealer_name": dealer_name,
+        "detail": detail,
+    })
+    if event_type == "follow":
+        ledger["follow_count"] = int(ledger.get("follow_count", 0)) + 1
+    elif event_type == "dm":
+        ledger["dm_count"] = int(ledger.get("dm_count", 0)) + 1
+    elif event_type == "failure":
+        ledger["failure_count"] = int(ledger.get("failure_count", 0)) + 1
+    elif event_type == "risk":
+        ledger.setdefault("risk_events", []).append({"timestamp": now_iso(), "dealer_name": dealer_name, "detail": detail})
+        ledger["cooldown_until"] = (datetime.now() + timedelta(minutes=DEFAULT_RISK_COOLDOWN_MINUTES)).isoformat(timespec="seconds")
+
+
+def cooldown_active(ledger: Dict[str, Any]) -> str:
+    raw = clean(ledger.get("cooldown_until", ""))
+    if not raw:
+        return ""
+    try:
+        until = datetime.fromisoformat(raw)
+    except ValueError:
+        return ""
+    if until > datetime.now():
+        return raw
+    return ""
+
+
+def action_needs_follow(action: str) -> bool:
+    return action in {"follow", "follow_dm"}
+
+
+def action_needs_dm(action: str) -> bool:
+    return action in {"dm", "both", "follow_dm"}
+
+
+def remaining_capacity(ledger: Dict[str, Any], daily_follow_limit: int, daily_dm_limit: int) -> Dict[str, int]:
+    return {
+        "follow": max(0, daily_follow_limit - int(ledger.get("follow_count", 0))),
+        "dm": max(0, daily_dm_limit - int(ledger.get("dm_count", 0))),
+    }
+
+
+def allowed_item_count(action: str, requested: int, ledger: Dict[str, Any], daily_follow_limit: int, daily_dm_limit: int, session_max: int) -> int:
+    capacity = remaining_capacity(ledger, daily_follow_limit, daily_dm_limit)
+    limits = [requested, session_max]
+    if action_needs_follow(action):
+        limits.append(capacity["follow"])
+    if action_needs_dm(action):
+        limits.append(capacity["dm"])
+    return max(0, min(limits))
+
+
+def pacing_range(profile: Dict[str, tuple[int, int]], stage: str) -> tuple[int, int]:
+    return profile.get(stage, (10, 20))
+
+
+def pacing_stage_tier(stage: str) -> str:
+    return PACING_STAGE_TIERS.get(stage, "technical")
+
+
+def sample_wait(profile: Dict[str, tuple[int, int]], stage: str) -> float:
+    low, high = pacing_range(profile, stage)
+    return random.uniform(low, high)
+
+
+def pause(profile: Dict[str, tuple[int, int]], stage: str, confirm: bool, reason: str) -> float:
+    wait = sample_wait(profile, stage)
+    print(f"  -> pacing {reason}: {wait:.1f}s")
+    if confirm:
+        time.sleep(wait)
+    return wait
+
+
+def estimate_schedule(items: List[Dict[str, Any]], action: str, profile: Dict[str, tuple[int, int]]) -> Dict[str, Any]:
+    stages = ["page_open", "between_customers"]
+    if action_needs_follow(action):
+        stages.append("after_follow")
+    if action_needs_dm(action):
+        stages.extend(["after_message_open", "before_send", "after_send"])
+    per_customer_min = sum(pacing_range(profile, stage)[0] for stage in stages)
+    per_customer_max = sum(pacing_range(profile, stage)[1] for stage in stages)
+    cooldown = pacing_range(profile, "session_cooldown")
+    return {
+        "customers": len(items),
+        "estimated_seconds_min": per_customer_min * len(items) + cooldown[0],
+        "estimated_seconds_max": per_customer_max * len(items) + cooldown[1],
+        "estimated_minutes_min": ceil((per_customer_min * len(items) + cooldown[0]) / 60),
+        "estimated_minutes_max": ceil((per_customer_max * len(items) + cooldown[1]) / 60),
+        "stages": {stage: pacing_range(profile, stage) for stage in stages + ["session_cooldown"]},
+        "stage_tiers": {stage: pacing_stage_tier(stage) for stage in stages + ["session_cooldown"]},
+        "tier_ranges": WAIT_TIERS,
+    }
+
+
+def build_schedule_preview(items: List[Dict[str, Any]], args: argparse.Namespace, ledger: Dict[str, Any], profile: Dict[str, tuple[int, int]]) -> Dict[str, Any]:
+    return {
+        "mode": "confirmed" if args.confirm else "dry_run",
+        "risk_profile": args.risk_profile,
+        "profile_id": args.profile_id,
+        "today_used": {
+            "follow": int(ledger.get("follow_count", 0)),
+            "dm": int(ledger.get("dm_count", 0)),
+            "failures": int(ledger.get("failure_count", 0)),
+            "risk_events": len(ledger.get("risk_events", [])),
+        },
+        "today_remaining": remaining_capacity(ledger, args.daily_follow_limit, args.daily_dm_limit),
+        "limits": {
+            "daily_follow_limit": args.daily_follow_limit,
+            "daily_dm_limit": args.daily_dm_limit,
+            "session_max": args.session_max,
+            "failure_stop_threshold": FAILURE_STOP_THRESHOLD,
+        },
+        "schedule": estimate_schedule(items, args.action, profile),
+        "customers": [{"dealer_name": item.get("dealer_name", ""), "page_url": item.get("page_url", "")} for item in items],
+        "stop_rules": [
+            "Stop immediately on Facebook verification, temporary block, suspicious activity, or identity prompt.",
+            "Stop when the account daily Follow/DM limit is exhausted.",
+            "Stop when failure count reaches threshold.",
+        ],
+    }
+
+
+
+def detect_risk(page) -> str:
+    try:
+        text = page.content().casefold()
+    except Exception:
+        return ""
+    for phrase in RISK_STOP_PHRASES:
+        if phrase.casefold() in text:
+            return phrase
+    return ""
+
+
+def clean(value: Any) -> str:
+    return "" if value is None else str(value).strip()
+
+
+def norm(value: str) -> str:
+    return re.sub(r"\s+", " ", clean(value)).casefold()
+
+
+def name_tokens(name: str) -> List[str]:
+    return [part for part in re.split(r"[^0-9a-zA-Z]+", name.casefold()) if len(part) >= 3]
+
+
+def get_element_text(locator) -> str:
+    try:
+        return clean(locator.inner_text(timeout=1000))
+    except Exception:
+        try:
+            return clean(locator.get_attribute("aria-label"))
+        except Exception:
+            return ""
+
+
+def first_visible(locator, timeout_ms: int = 1800):
+    try:
+        count = locator.count() if locator else 0
+        for idx in range(count):
+            item = locator.nth(idx)
+            if item.is_visible(timeout=timeout_ms):
+                return item
+    except Exception:
+        return None
+    return None
+
+
+def find_page_header_button(page, include_re: re.Pattern, exclude_re: Optional[re.Pattern] = None):
+    """Find a visible action button in the Page header, not a post/comment area."""
+    try:
+        viewport = page.viewport_size or {"width": 1600, "height": 1000}
+        max_y = viewport["height"] * 0.76
+        min_y = 72
+        min_x = viewport["width"] * 0.45
+        candidates = []
+        locator = page.locator("div[role='button'], a[role='button'], button")
+        for idx in range(locator.count()):
+            item = locator.nth(idx)
+            if not item.is_visible(timeout=500):
+                continue
+            box = item.bounding_box()
+            if not box:
+                continue
+            if box.get("y", 0) < min_y or box.get("y", 0) > max_y or box.get("x", 0) < min_x:
+                continue
+            text = get_element_text(item)
+            aria = clean(item.get_attribute("aria-label"))
+            combined = f"{text} {aria}".strip()
+            if not include_re.search(combined):
+                continue
+            if exclude_re and exclude_re.search(combined):
+                continue
+            candidates.append((box.get("y", 0), box.get("x", 0), item, combined))
+        if candidates:
+            candidates.sort(key=lambda row: (row[0], row[1]))
+            return candidates[0][2], candidates[0][3]
+    except Exception:
+        return None, ""
+    return None, ""
+
+
+def click_follow(page, confirm: bool) -> Dict[str, Any]:
+    result = {"action": "follow", "clicked": False, "already_active": False, "error": ""}
+    try:
+        active_button, active_label = find_page_header_button(page, FOLLOWING_BUTTON_RE)
+        if active_button is not None:
+            result["already_active"] = True
+            print(f"  -> Follow already active: {active_label}")
+            return result
+
+        button, label = find_page_header_button(page, FOLLOW_BUTTON_RE, exclude_re=FOLLOWING_BUTTON_RE)
+        if button is None:
+            result["error"] = "Follow button not found in Page header"
+            return result
+        print(f"  [Follow] button is visible: {label}")
+        if confirm:
+            button.click()
+            result["clicked"] = True
+            print("  -> Follow clicked")
+        else:
+            print("  -> dry-run: follow not clicked")
+    except Exception as exc:  # pragma: no cover - browser dependent
+        result["error"] = str(exc)
+    return result
+
+
+def click_add_friend(page, confirm: bool) -> Dict[str, Any]:
+    result = {"action": "add_friend", "clicked": False, "error": ""}
+    try:
+        button, label = find_page_header_button(page, re.compile(r"add\s*friend|ajouter|connect", re.I))
+        if button is None:
+            result["error"] = "Add Friend button not found in Page header"
+            return result
+        print(f"  [Add Friend] button is visible: {label}")
+        if confirm:
+            button.click()
+            result["clicked"] = True
+            print("  -> clicked Add Friend")
+        else:
+            print("  -> dry-run: no click")
+    except Exception as exc:  # pragma: no cover - browser dependent
+        result["error"] = str(exc)
+    return result
+
+
+def open_message_dialog(page, confirm: bool) -> Dict[str, Any]:
+    result = {"action": "open_message", "clicked": False, "error": ""}
+    button, label = find_page_header_button(page, MESSAGE_BUTTON_RE)
+    if button is None:
+        result["error"] = "Message button not found in Page header"
+        return result
+    print(f"  [Message] button is visible in Page header: {label}")
+    if confirm:
+        button.click()
+        result["clicked"] = True
+        print("  -> Message button clicked")
+    else:
+        print("  -> dry-run: message dialog not opened")
+    return result
+
+
+def dialog_matches_customer(dialog, dealer_name: str) -> bool:
+    text = norm(get_element_text(dialog))
+    tokens = name_tokens(dealer_name)
+    if not tokens:
+        return False
+    return sum(1 for token in tokens if token in text) >= min(2, len(tokens))
+
+
+def find_matching_messenger_dialog(page, dealer_name: str):
+    """Return a right-side Messenger dialog whose visible title matches dealer_name."""
+    try:
+        viewport = page.viewport_size or {"width": 1600, "height": 1000}
+        min_x = viewport["width"] * 0.42
+        min_y = viewport["height"] * 0.28
+        candidates = []
+        containers = page.locator("div[role='dialog'], div[aria-label*='Messenger' i], div[aria-label*='訊息' i], div[aria-label*='Message' i]")
+        for idx in range(containers.count()):
+            dialog = containers.nth(idx)
+            if not dialog.is_visible(timeout=500):
+                continue
+            box = dialog.bounding_box()
+            if not box:
+                continue
+            if box.get("x", 0) < min_x or box.get("y", 0) < min_y:
+                continue
+            if not dialog_matches_customer(dialog, dealer_name):
+                continue
+            dialog_text = norm(get_element_text(dialog))
+            if any(hint in dialog_text for hint in COMMENT_HINTS):
+                continue
+            candidates.append((box.get("x", 0), box.get("y", 0), dialog))
+        if candidates:
+            candidates.sort(key=lambda row: (row[0], row[1]), reverse=True)
+            return candidates[0][2]
+    except Exception:
+        return None
+    return None
+
+
+def find_dialog_textbox(dialog):
+    selectors = [
+        'div[contenteditable="true"][role="textbox"]',
+        'div[contenteditable="true"][data-lexical-editor="true"]',
+        '[aria-label="Aa"][contenteditable="true"]',
+        '[aria-label*="Message" i][contenteditable="true"]',
+        '[aria-label*="訊息" i][contenteditable="true"]',
+    ]
+    for selector in selectors:
+        textbox = first_visible(dialog.locator(selector), timeout_ms=800)
+        if textbox is not None:
+            label = clean(textbox.get_attribute("aria-label"))
+            text = norm(get_element_text(textbox))
+            if any(hint in norm(label) or hint in text for hint in COMMENT_HINTS):
+                return None, "Blocked: detected comment composer, not Messenger dialog"
+            return textbox, ""
+    return None, "Message textbox not found inside matching Messenger dialog"
+
+
+def facebook_thread_url(page_url: str) -> str:
+    match = re.search(r"facebook\.com/([^/?#]+)", page_url or "", re.I)
+    if not match:
+        return ""
+    slug = match.group(1).strip("/")
+    if not slug or slug in {"messages", "profile.php", "pages"}:
+        return ""
+    return f"https://www.facebook.com/messages/t/{slug}"
+
+
+def messenger_body_matches_customer(body: str, dealer_name: str) -> bool:
+    if not dealer_name:
+        return False
+    body_norm = norm(body)
+    direct = norm(dealer_name)
+    if direct and direct in body_norm:
+        return True
+    tokens = name_tokens(dealer_name)
+    if not tokens:
+        return False
+    return sum(1 for token in tokens if token in body_norm) >= min(2, len(tokens))
+
+
+def find_messenger_thread_textbox(thread_page, dealer_name: str):
+    try:
+        body = thread_page.locator("body").inner_text(timeout=3000)
+    except Exception:
+        body = ""
+    if not messenger_body_matches_customer(body, dealer_name):
+        return None, f"Messenger thread title mismatch for {dealer_name}"
+
+    locator = thread_page.locator('[role="textbox"], div[contenteditable="true"]')
+    for idx in range(locator.count()):
+        textbox = locator.nth(idx)
+        try:
+            if not textbox.is_visible(timeout=500):
+                continue
+            label = clean(textbox.get_attribute("aria-label"))
+            nearby = norm(label + " " + get_element_text(textbox))
+            if any(hint in nearby for hint in COMMENT_HINTS):
+                return None, "Blocked: detected comment composer, not Messenger thread"
+            box = textbox.bounding_box()
+            if not box or box.get("y", 0) < 400:
+                continue
+            if dealer_name and dealer_name.casefold() in label.casefold():
+                return textbox, ""
+            if label or box:
+                return textbox, ""
+        except Exception:
+            continue
+    return None, "Messenger thread textbox not found"
+
+
+def send_dm_via_messenger_thread(page, dealer_name: str, text: str, confirm: bool, before_send_wait: tuple[int, int] = (0, 0)) -> Dict[str, Any]:
+    result = {"action": "send_dm_thread", "clicked": False, "error": ""}
+    thread_url = facebook_thread_url(page.url)
+    if not thread_url:
+        result["error"] = "Could not build Messenger thread URL from Facebook Page URL"
+        return result
+    if not confirm:
+        print(f"  -> dry-run: Messenger thread fallback available: {thread_url}")
+        return result
+
+    thread_page = page.context.new_page()
+    thread_page.goto(thread_url, wait_until="domcontentloaded", timeout=60000)
+    thread_page.wait_for_timeout(8000)
+    thread_page.bring_to_front()
+
+    textbox, error = find_messenger_thread_textbox(thread_page, dealer_name)
+    if textbox is None:
+        result["error"] = error
+        return result
+
+    textbox.click()
+    try:
+        existing_text = textbox.evaluate("el => (el.innerText || el.textContent || '').trim()") or ""
+    except Exception:
+        existing_text = ""
+    if text[:80] not in existing_text:
+        textbox.fill(text)
+        print("  -> Message typed into Messenger thread")
+    else:
+        print("  -> existing drafted message detected; not inserting duplicate text")
+    if before_send_wait != (0, 0):
+        delay = random.uniform(*before_send_wait)
+        print(f"  -> pacing before send: {delay:.1f}s")
+        time.sleep(delay)
+    thread_page.keyboard.press("Enter")
+    result["clicked"] = True
+    print("  -> Message sent by Enter in Messenger thread")
+    return result
+
+
+def send_dm(page, dealer_name: str, text: str, confirm: bool, message_open_wait: tuple[int, int] = (0, 0), before_send_wait: tuple[int, int] = (0, 0)) -> Dict[str, Any]:
+    result = {"action": "send_dm", "clicked": False, "error": ""}
+    try:
+        dialog = find_matching_messenger_dialog(page, dealer_name)
+        if dialog is None:
+            opened = open_message_dialog(page, confirm=confirm)
+            if opened.get("error"):
+                print(f"  ! Message button issue: {opened['error']}; trying Messenger thread fallback")
+                fallback = send_dm_via_messenger_thread(page, dealer_name, text, confirm, before_send_wait=before_send_wait)
+                if fallback.get("clicked"):
+                    result["clicked"] = True
+                    return result
+                result["error"] = fallback.get("error") or opened["error"]
+                return result
+            if not confirm:
+                print("  -> dry-run: no message sent")
+                return result
+            if message_open_wait != (0, 0):
+                delay = random.uniform(*message_open_wait)
+                print(f"  -> pacing after message open: {delay:.1f}s")
+                time.sleep(delay)
+            for _ in range(10):
+                time.sleep(0.8)
+                dialog = find_matching_messenger_dialog(page, dealer_name)
+                if dialog is not None:
+                    break
+        if dialog is None:
+            print(f"  ! Messenger dialog for {dealer_name} not found; trying Messenger thread fallback")
+            fallback = send_dm_via_messenger_thread(page, dealer_name, text, confirm, before_send_wait=before_send_wait)
+            if fallback.get("clicked"):
+                result["clicked"] = True
+                return result
+            result["error"] = fallback.get("error") or f"Messenger dialog for {dealer_name} not found or title mismatch"
+            return result
+        print(f"  -> Messenger dialog matched: {dealer_name}")
+
+        textbox, error = find_dialog_textbox(dialog)
+        if textbox is None:
+            result["error"] = error
+            return result
+        if not confirm:
+            print("  -> dry-run: Messenger textbox found; no message typed")
+            return result
+
+        textbox.click()
+        existing_text = ""
+        try:
+            existing_text = textbox.evaluate("el => (el.innerText || el.textContent || '').trim()") or ""
+        except Exception:
+            existing_text = ""
+        if text[:80] not in existing_text:
+            page.keyboard.insert_text(text)
+            print("  -> Message typed into Messenger dialog")
+        else:
+            print("  -> existing drafted message detected; not inserting duplicate text")
+
+        if before_send_wait != (0, 0):
+            delay = random.uniform(*before_send_wait)
+            print(f"  -> pacing before send: {delay:.1f}s")
+            time.sleep(delay)
+        page.keyboard.press("Enter")
+        result["clicked"] = True
+        print("  -> Message sent by Enter")
+    except Exception as exc:  # pragma: no cover - browser dependent
+        result["error"] = str(exc)
+    return result
+
+
+def header_map(ws) -> Dict[str, int]:
+    raw = {clean(cell.value): idx for idx, cell in enumerate(ws[1]) if clean(cell.value)}
+    mapped: Dict[str, int] = {}
+    for key, aliases in HEADER_ALIASES.items():
+        for alias in aliases:
+            if alias in raw:
+                mapped[key] = raw[alias]
+                break
+    return mapped
+
+
+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}")
+    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.")
+
+    update_by_index = {str(item.get("index", "")).strip(): item for item in updates if str(item.get("index", "")).strip()}
+    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:
+            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:
+            existing = clean(row[columns["note"]].value)
+            separator = " | " if existing else ""
+            row[columns["note"]].value = existing + separator + update["note_append"]
+        updated += 1
+    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:
+        for entry in entries:
+            handle.write(json.dumps(entry, ensure_ascii=False) + "\n")
+
+
+def select_english_dm(item: Dict[str, Any]) -> str:
+    message = item.get("messages", {}).get("dm", {}).get("en", "")
+    if not message:
+        raise ValueError(f"Missing English DM text for {item.get('dealer_name', '(unknown)')}")
+    return message
+
+
+def determine_status(action: str, friend_result: Optional[Dict[str, Any]], follow_result: Optional[Dict[str, Any]], dm_result: Optional[Dict[str, Any]]) -> tuple[str, str]:
+    friend_ok = bool(friend_result and friend_result.get("clicked"))
+    follow_ok = bool(follow_result and (follow_result.get("clicked") or follow_result.get("already_active")))
+    dm_ok = bool(dm_result and dm_result.get("clicked"))
+    if action == "friend":
+        return (STATUS_VALUES["friend_success"], "") if friend_ok else (STATUS_VALUES["friend_failed"], friend_result.get("error", "unknown") if friend_result else "unknown")
+    if action == "follow":
+        return (STATUS_VALUES["follow_success"], "") if follow_ok else (STATUS_VALUES["follow_failed"], follow_result.get("error", "unknown") if follow_result else "unknown")
+    if action == "dm":
+        return (STATUS_VALUES["dm_success"], "") if dm_ok else (STATUS_VALUES["dm_failed"], dm_result.get("error", "unknown") if dm_result else "unknown")
+    if action == "follow_dm":
+        if follow_ok and dm_ok:
+            return STATUS_VALUES["both_success"], ""
+        if follow_ok:
+            return STATUS_VALUES["follow_success"], dm_result.get("error", "DM not sent") if dm_result else "DM not sent"
+        if dm_ok:
+            return STATUS_VALUES["dm_success"], follow_result.get("error", "follow not completed") if follow_result else "follow not completed"
+        return STATUS_VALUES["dm_failed"], "follow and DM were not completed"
+    if friend_ok and dm_ok:
+        return STATUS_VALUES["both_success"], ""
+    if friend_ok:
+        return STATUS_VALUES["friend_success"], dm_result.get("error", "DM not sent") if dm_result else "DM not sent"
+    if dm_ok:
+        return STATUS_VALUES["dm_success"], friend_result.get("error", "friend request not sent") if friend_result else "friend request not sent"
+    return STATUS_VALUES["dm_failed"], "friend request and DM were not completed"
+
+
+def should_navigate(page, target_url: str, use_open_page: bool) -> bool:
+    if not use_open_page:
+        return True
+    current = clean(getattr(page, "url", ""))
+    if not current:
+        return True
+    return target_url.rstrip("/") not in current.rstrip("/")
+
+
+def find_open_page_for_target(browser, target_url: str):
+    """Pick the most recently opened tab that already matches the target URL."""
+    target = target_url.rstrip("/")
+    fallback = None
+    for context in browser.contexts:
+        for candidate in context.pages:
+            fallback = candidate
+            try:
+                current = clean(candidate.url).rstrip("/")
+            except Exception:
+                continue
+            if target and target in current:
+                fallback = candidate
+    return fallback
+
+
+def main(argv: Optional[Sequence[str]] = None) -> int:
+    parser = argparse.ArgumentParser(description="Run dry-run or confirmed English Facebook outreach in AdsPower.")
+    parser.add_argument("--preview", required=True, help="JSON preview generated by prepare_facebook_outreach.py")
+    parser.add_argument("--profile-id", required=True, help="AdsPower profile ID")
+    parser.add_argument("--ads-power-url", default=DEFAULT_ADS_POWER_URL, help="AdsPower local API URL")
+    parser.add_argument("--action", choices=["friend", "follow", "dm", "both", "follow_dm"], default="follow_dm", help="Action to perform")
+    parser.add_argument("--max-per-run", type=int, default=MAX_PER_RUN, help="Maximum customers per run. Default: 3.")
+    parser.add_argument("--confirm", action="store_true", help="Allow real clicks/sends after the whole preview has been shown and approved")
+    parser.add_argument("--batch-confirmed", action="store_true", help="Required with --confirm; means the full batch preview was shown in chat and approved once")
+    parser.add_argument("--headless", action="store_true", help="Start AdsPower browser in headless mode if supported")
+    parser.add_argument("--keep-browser-open", action="store_true", default=True, help="Compatibility flag; AdsPower browser is always kept open")
+    parser.add_argument("--no-write-workbook", action="store_true", help="Do not write outreach status back to the workbook; default behavior unless --write-workbook is set")
+    parser.add_argument("--write-workbook", action="store_true", help="Explicitly write outreach status back to the workbook")
+    parser.add_argument("--use-open-page", action="store_true", help="Use the first currently open AdsPower page instead of creating a new tab")
+    parser.add_argument("--auto-confirm-each", action="store_true", help=argparse.SUPPRESS)
+    parser.add_argument("--risk-profile", choices=sorted(PACING_PROFILES), default="very_conservative", help="Pacing policy for compliant low-frequency outreach. Default: very_conservative.")
+    parser.add_argument("--daily-follow-limit", type=int, default=DEFAULT_DAILY_FOLLOW_LIMIT, help="Per-profile daily Follow cap. Default: 20.")
+    parser.add_argument("--daily-dm-limit", type=int, default=DEFAULT_DAILY_DM_LIMIT, help="Per-profile daily DM/customer outreach cap. Default: 20.")
+    parser.add_argument("--session-max", type=int, default=DEFAULT_SESSION_MAX, help="Maximum customers per execution session. Default: 3.")
+    parser.add_argument("--dry-run-schedule", action="store_true", help="Print the pacing schedule, limits, and customer list without opening AdsPower.")
+    args = parser.parse_args(argv)
+    if args.auto_confirm_each:
+        print("--auto-confirm-each is deprecated; v4.1 uses one batch-level confirmation via --batch-confirmed.")
+        args.batch_confirmed = True
+    if args.session_max < 1:
+        raise ValueError("--session-max must be at least 1")
+    if args.daily_follow_limit < 0 or args.daily_dm_limit < 0:
+        raise ValueError("Daily limits cannot be negative")
+    if args.confirm and not args.batch_confirmed:
+        raise ValueError("Real Facebook sending requires --confirm --batch-confirmed after the full preview is shown in chat and approved.")
+
+    preview_path = Path(args.preview).expanduser()
+    if not preview_path.is_absolute():
+        preview_path = Path.cwd() / preview_path
+    if not preview_path.exists():
+        raise FileNotFoundError(f"Preview file not found: {preview_path}")
+
+    preview = json.loads(preview_path.read_text(encoding="utf-8"))
+    language_policy = preview.get("language_policy", {})
+    if language_policy.get("customer_facing") and language_policy.get("customer_facing") != "English":
+        raise ValueError("Preview is not marked as English customer-facing. Regenerate with the updated prepare script.")
+
+    all_items = preview.get("items", [])
+    requested_count = max(0, args.max_per_run)
+    pacing_profile = PACING_PROFILES[args.risk_profile]
+    ledger = load_ledger(args.profile_id)
+    active_cooldown = cooldown_active(ledger)
+    if active_cooldown:
+        raise RuntimeError(f"Profile {args.profile_id} is cooling down until {active_cooldown}; stop Facebook outreach for this account.")
+    if ledger.get("risk_events"):
+        raise RuntimeError(f"Profile {args.profile_id} has risk events today; stop Facebook outreach for this account.")
+    if int(ledger.get("failure_count", 0)) >= FAILURE_STOP_THRESHOLD:
+        raise RuntimeError(f"Profile {args.profile_id} reached today's failure threshold; stop Facebook outreach for this account.")
+    allowed_count = allowed_item_count(args.action, requested_count, ledger, args.daily_follow_limit, args.daily_dm_limit, args.session_max)
+    items = all_items[:allowed_count]
+    if requested_count and allowed_count < requested_count:
+        print(f"Pacing limit reduced this run from {requested_count} to {allowed_count} customers.")
+    schedule_preview = build_schedule_preview(items, args, ledger, pacing_profile)
+    print(json.dumps({"schedule_preview": schedule_preview}, ensure_ascii=False, indent=2))
+    if args.dry_run_schedule:
+        print("Dry-run schedule only: browser execution skipped.")
+        return 0
+    if requested_count and not items:
+        raise RuntimeError("No Facebook outreach capacity remains for this profile today.")
+    source = preview.get("source", {})
+    excel_path = source.get("excel", "")
+    sheet_name = source.get("sheet", "Facebook")
+    confirm_mode = bool(args.confirm)
+    write_workbook = bool(args.write_workbook and not args.no_write_workbook)
+
+    print(f"Mode: {'CONFIRMED batch execution' if confirm_mode else 'dry-run only'}")
+    print("Customer-facing language: English")
+    print(f"AdsPower profile: {args.profile_id}")
+    print(f"Items: {len(items)} / {len(all_items)}")
+    print(f"Workbook write: {'enabled' if write_workbook else 'disabled'}")
+    print("-" * 60)
+
+    client = AdsPowerClient(base_url=args.ads_power_url)
+    audit_entries: List[Dict[str, Any]] = []
+    status_updates: List[Dict[str, str]] = []
+    stopped_early = False
+
+    try:
+        browser = client.start_browser(args.profile_id, headless=args.headless)
+        page = client.get_open_page() if args.use_open_page else None
+        page = page or client.new_page()
+
+        for offset, item in enumerate(items, start=1):
+            dm_text = select_english_dm(item)
+            dealer_name = clean(item.get("dealer_name", ""))
+            target_url = item.get("page_url", "")
+            print(f"\n[{offset}/{len(items)}] {dealer_name} | {target_url}")
+            print(f"    Current status: {item.get('status', '未联系')} | Suggested action: {item.get('suggested_action', '')}")
+            print(f"    English DM preview:\n    {dm_text[:500]}{'...' if len(dm_text) > 500 else ''}")
+
+            if args.use_open_page:
+                matched_page = find_open_page_for_target(browser, target_url)
+                if matched_page is not None:
+                    page = matched_page
+
+            if should_navigate(page, target_url, args.use_open_page):
+                page.goto(target_url, wait_until="domcontentloaded", timeout=60000)
+                pause(pacing_profile, "page_open", confirm=confirm_mode, reason="after page open")
+            else:
+                print("  -> using current open Page tab")
+                pause(pacing_profile, "page_open", confirm=confirm_mode, reason="after existing page focus")
+
+            try:
+                page.bring_to_front()
+                time.sleep(1)
+            except Exception:
+                pass
+
+            risk = detect_risk(page)
+            if risk:
+                print(f"\nRisk prompt detected: {risk}. Stopping immediately.")
+                ledger_event(ledger, "risk", dealer_name, risk)
+                save_ledger(args.profile_id, ledger)
+                stopped_early = True
+                break
+
+            if confirm_mode:
+                print("  -> batch preview already confirmed; executing this customer without per-row prompt")
+            else:
+                print("  -> dry-run: no click/send and no per-row prompt")
+
+            friend_result = None
+            follow_result = None
+            dm_result = None
+            if args.action in {"friend", "both"}:
+                friend_result = click_add_friend(page, confirm=confirm_mode)
+                if friend_result.get("error"):
+                    print(f"  ! Add Friend: {friend_result['error']}")
+            if args.action in {"follow", "follow_dm"}:
+                follow_result = click_follow(page, confirm=confirm_mode)
+                if follow_result.get("error"):
+                    print(f"  ! Follow: {follow_result['error']}")
+                elif confirm_mode and follow_result.get("clicked"):
+                    ledger_event(ledger, "follow", dealer_name)
+                    save_ledger(args.profile_id, ledger)
+                pause(pacing_profile, "after_follow", confirm=confirm_mode, reason="after Follow")
+            if args.action in {"dm", "both", "follow_dm"}:
+                dm_result = send_dm(
+                    page,
+                    dealer_name,
+                    dm_text,
+                    confirm=confirm_mode,
+                    message_open_wait=pacing_range(pacing_profile, "after_message_open"),
+                    before_send_wait=pacing_range(pacing_profile, "before_send"),
+                )
+                if dm_result.get("error"):
+                    print(f"  ! Message: {dm_result['error']}")
+                elif confirm_mode and dm_result.get("clicked"):
+                    ledger_event(ledger, "dm", dealer_name)
+                    save_ledger(args.profile_id, ledger)
+                pause(pacing_profile, "after_send", confirm=confirm_mode, reason="after DM send")
+
+            new_status = ""
+            error_note = ""
+            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", "")),
+                    "status": new_status,
+                    "note_append": f"{now_iso()} Facebook {args.action}->{new_status}" + (f" err={error_note}" if error_note else ""),
+                })
+                if error_note:
+                    ledger_event(ledger, "failure", dealer_name, error_note)
+                    save_ledger(args.profile_id, ledger)
+
+            audit_entries.append({
+                "timestamp": now_iso(),
+                "profile_id": args.profile_id,
+                "target_url": target_url,
+                "dealer_name": dealer_name,
+                "action": args.action,
+                "confirm_mode": confirm_mode,
+                "auto_confirm_each": bool(args.auto_confirm_each),
+                "language": "English",
+                "new_status": new_status,
+                "result": "dry_run" if not confirm_mode else ("success" if not error_note else "partial_or_failed"),
+                "error": error_note,
+            })
+
+            pause(pacing_profile, "between_customers", confirm=confirm_mode, reason="between customers")
+            if int(ledger.get("failure_count", 0)) >= FAILURE_STOP_THRESHOLD:
+                print("Failure threshold reached. Stopping remaining customers for today.")
+                stopped_early = True
+                break
+            capacity_now = remaining_capacity(ledger, args.daily_follow_limit, args.daily_dm_limit)
+            if (action_needs_follow(args.action) and capacity_now["follow"] <= 0) or (action_needs_dm(args.action) and capacity_now["dm"] <= 0):
+                print("Daily Follow/DM capacity exhausted. Stopping remaining customers.")
+                stopped_early = True
+                break
+
+    except KeyboardInterrupt:
+        print("\nInterrupted by user.")
+    finally:
+        print("Browser left open")
+        try:
+            if client.playwright:
+                client.playwright.stop()
+        except Exception:
+            pass
+
+    log_path = Path("logs") / f"facebook_social_{datetime.now().strftime('%Y%m%d')}.jsonl"
+    if audit_entries:
+        write_audit_log(log_path, audit_entries)
+        print(f"\nAudit log written: {log_path}")
+    save_ledger(args.profile_id, ledger)
+    if args.confirm:
+        cooldown = sample_wait(pacing_profile, "session_cooldown")
+        print(f"Session cooldown recommendation: {cooldown / 60:.1f} minutes before the next Facebook outreach run.")
+
+    if confirm_mode and status_updates and write_workbook:
+        if not excel_path:
+            backup = resolve_artifact_path("", kind="facebook_status_updates", default_name=f"facebook_social_status_updates_{now_iso().replace(':', '-')}.json")
+            backup.write_text(json.dumps(status_updates, ensure_ascii=False, indent=2), encoding="utf-8")
+            print(f"Workbook path missing in preview. Status updates saved to: {backup}")
+        else:
+            try:
+                updated = update_workbook(excel_path, sheet_name, status_updates)
+                print(f"Workbook updated: {updated} rows ({excel_path})")
+            except Exception as exc:
+                backup = resolve_artifact_path("", kind="facebook_status_updates", default_name=f"facebook_social_status_updates_{now_iso().replace(':', '-')}.json")
+                backup.write_text(json.dumps(status_updates, ensure_ascii=False, indent=2), encoding="utf-8")
+                print(f"Workbook update failed: {exc}")
+                print(f"Status updates saved to: {backup}")
+
+    if confirm_mode and status_updates and not write_workbook:
+        print("Workbook update skipped")
+
+    print("\nDone.")
+    if stopped_early:
+        print("Stopped early because a Facebook risk prompt was detected.")
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
+