Forráskód Böngészése

feat: add Facebook conversation sync and intent analysis

梁朝伟 2 hete
szülő
commit
18b861df9d

+ 16 - 2
SKILL.md

@@ -1,6 +1,6 @@
 ---
 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.
+description: 为五菱汽车海外经销商拓展提供端到端自动化能力:使用 AdsPower/Playwright 搜索海外经销商线索,去重和评分,回写及汇总建联表,生成邮件和社交建联预览,执行经确认的建联,并读取匹配的 Facebook Messenger 对话、翻译成中文、分析合作意向和跟进动作。Use when Codex needs to find overseas Wuling dealer leads, assess dealer fit, prepare/send confirmed outreach, synchronize Facebook replies, translate customer conversations, assess cooperation intent, or update the outreach workbook and dashboard.
 ---
 
 # 五菱汽车海外经销商拓展
@@ -17,6 +17,7 @@ description: 为五菱汽车海外经销商拓展提供端到端自动化能力
 - `references/encoding-and-excel-writeback.md`: mandatory text encoding and Excel write-back rules to prevent repeated-question-mark text corruption.
 - `references/feishu-sync.md`: WorkBuddy/Lark Sheets post-write sync rules for keeping the Feishu online spreadsheet aligned with the local workbook.
 - `references/dashboard.md`: customer dashboard generation rules, KPI definitions, dashboard artifact locations, and optional Feishu metric-sync boundary.
+- `references/facebook-conversation-sync.md`: matched Messenger collection, Chinese translation, five-level intent analysis, workbook write-back, and reply-dashboard rules.
 
 不要把真实客户表、JSON 预览、采集结果、发送日志、截图、缓存、`__pycache__`、临时文件或任何账号/SMTP 授权码放进 skill 包。唯一允许放进 `assets/` 的 Excel 是空白模板 `assets/blank_customer_outreach_workbook.xlsx`。
 
@@ -40,6 +41,8 @@ description: 为五菱汽车海外经销商拓展提供端到端自动化能力
 | Facebook 社交建联预览 | `scripts/social/prepare_facebook_outreach.py` | 已实现,输出中文判断 + 英文话术 JSON 预览 |
 | Facebook Follow + Messenger 建联总控 | `scripts/social/run_facebook_follow_dm.py` | 已实现,输出整批聊天框预览;确认后批量执行;不断开 Playwright 连接,浏览器保持打开 |
 | Facebook 社交建联执行 | `scripts/social/send_facebook_outreach.py` | 已实现,默认 dry-run;支持 `--action follow_dm`;真实执行必须 `--confirm --batch-confirmed`,不逐条确认 |
+| Facebook 对话采集 | `scripts/social/collect_facebook_conversations.py` | 已实现,只读;首次全量、后续增量;始终保持浏览器打开 |
+| Facebook 对话翻译/意向写回 | `scripts/social/write_facebook_conversations.py` + `references/facebook-conversation-sync.md` | 已实现,先聊天框预览,再显式写表 |
 
 ## 必读规则
 
@@ -110,6 +113,17 @@ python scripts/social/send_facebook_outreach.py --preview facebook_social_previe
 
 禁止编造具体价格、MOQ、车型清单、利润率、库存数量、政府关系或未经确认的合作条件。
 
+## Facebook 回复同步流程
+
+当用户要求查看 Facebook 回复、同步 Messenger 对话、翻译客户消息或判断下一步合作意向时,必须读取 `references/facebook-conversation-sync.md`。
+
+1. 使用 `collect_facebook_conversations.py` 只读取建联表中可可靠匹配的客户线程;首次用 `--initial-full`,后续用 `--incremental`。
+2. 保留双方原文并把非中文内容翻译成中文;仅对有效客户回复进行五级合作意向判断。
+3. 在聊天框展示回复摘要、意向分布和高意向客户,不生成英文回复、不自动发送。
+4. 用户明确要求写表后,使用 `write_facebook_conversations.py --write-workbook --refresh-summary --refresh-dashboard`。
+5. 对话逐条写入 `Facebook对话记录`;Facebook 客户行只更新最新中文摘要、合作意向、状态和下次跟进。
+6. 写表后校验乱码、刷新总表/看板,并按飞书配置同步。
+
 
 ## Customer Attribute Taxonomy
 
@@ -210,7 +224,7 @@ python scripts/common/build_customer_summary.py --excel "<建联表路径>" --wr
 
 当用户提到中台、看台、看板、客户统计、建联率、客户数量、渠道分布或邮件发送统计时,读取 `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`。
+看板以 `客户信息汇总表` 为客户主数据,并在存在 `Facebook对话记录` 时读取它计算唯一客户回复率、五级意向分布、高意向客户和最近 30 天回复趋势。消息行不得计作客户行。看板不直接写入 Excel,不触发飞书同步,也不保存真实客户数据到 skill 包。
 
 如果本轮刚完成渠道采集、邮件状态写回、社交状态写回或其他 Excel 写入,必须先运行 `scripts/common/build_customer_summary.py --write-summary` 刷新 `客户信息汇总表`,再生成看板。预览、dry-run、搜索未写表时,不强制刷新看板。
 

BIN
assets/blank_customer_outreach_workbook.xlsx


+ 27 - 7
assets/dashboard_template.html

@@ -50,7 +50,7 @@
     }
     .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; }
+    .kpis { grid-template-columns: repeat(4, 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; }
@@ -156,6 +156,19 @@
       <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="grid charts">
+      <div class="card panel"><div class="panel-head"><h2>Facebook 合作意向</h2><span class="hint">按最新有效回复的唯一客户统计</span></div><div class="bar-list" id="intentBars"></div></div>
+      <div class="card panel"><div class="panel-head"><h2>最近 30 天有效回复</h2><span class="hint">按客户回复日期</span></div><div class="columns" id="replyTrendColumns"></div></div>
+    </section>
+    <section class="card panel wide" style="margin-bottom:14px">
+      <div class="panel-head"><h2>高意向客户</h2><span class="hint" id="highIntentCount"></span></div>
+      <div class="table-wrap">
+        <table>
+          <thead><tr><th>客户</th><th>合作意向</th><th>最新中文回复</th><th>判断依据</th><th>下一步建议</th><th>回复时间</th></tr></thead>
+          <tbody id="highIntentRows"></tbody>
+        </table>
+      </div>
+    </section>
     <section class="card panel wide">
       <div class="panel-head"><h2>待关注客户</h2><span class="hint" id="tableCount"></span></div>
       <div class="toolbar">
@@ -197,12 +210,14 @@
         ['建联率', 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']
+        ['待人工复核', metrics.needs_review_customers, `${pct(metrics.review_rate)} 的有效客户`, metrics.review_rate, '#b7791f'],
+        ['Facebook 回复率', pct(metrics.facebook_reply_rate), `有效回复 ${metrics.facebook_reply_customers} / 已私信 ${metrics.facebook_dm_customers}`, metrics.facebook_reply_rate, '#13899b'],
+        ['Facebook 高意向', Number(metrics.facebook_clear_intent_customers || 0) + Number(metrics.facebook_potential_intent_customers || 0), `明确 ${metrics.facebook_clear_intent_customers || 0},潜在 ${metrics.facebook_potential_intent_customers || 0}`, metrics.facebook_reply_customers ? (Number(metrics.facebook_clear_intent_customers || 0) + Number(metrics.facebook_potential_intent_customers || 0)) * 100 / metrics.facebook_reply_customers : 0, '#16885a']
       ];
       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 items = [['总表客户', metrics.total_rows], ['有效客户', metrics.valid_customers], ['可建联', metrics.contactable_customers], ['已建联', metrics.contacted_customers], ['Facebook有效回复', metrics.facebook_reply_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('');
     }
@@ -210,10 +225,10 @@
       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);
+    function renderColumns(targetId, sourceItems) {
+      const items = (sourceItems || []).slice(-10);
       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>';
+      document.getElementById(targetId).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);
@@ -254,10 +269,15 @@
         return `<tr><td>${safe(row.row)}</td><td><strong>${safe(row.company)}</strong><div class="muted">${safe(row.city || '')}</div></td><td><span class="badge">${safe(row.attribute || '未填写')}</span></td><td>${safe(row.type || '未填写')}</td><td>${safe(row.source || '未标明')}</td><td>${safe(row.contact || '无')}</td><td><span class="badge ${statusClass}">${safe(row.status || '未填写')}</span></td><td class="note">${safe(row.note || '')}</td></tr>`;
       }).join('') || '<tr><td colspan="8" class="muted">没有匹配客户</td></tr>';
     }
+    function renderHighIntent() {
+      const items = data.facebook_high_intent_customers || [];
+      document.getElementById('highIntentCount').textContent = `共 ${items.length} 个`;
+      document.getElementById('highIntentRows').innerHTML = items.map(item => `<tr><td><strong>${safe(item.company)}</strong></td><td><span class="badge green">${safe(item.intent)}</span></td><td class="note">${safe(item.latest_reply)}</td><td class="note">${safe(item.reason)}</td><td class="note">${safe(item.next_action)}</td><td>${safe(item.message_time)}</td></tr>`).join('') || '<tr><td colspan="6" class="muted">暂无明确或潜在意向客户</td></tr>';
+    }
     function 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();
+    renderKpis(); renderFunnel(); renderBars('attributeBars', charts.attribute || [], '#16885a'); renderBars('typeBars', charts.customer_type || [], '#1d5fd1'); renderBars('cityBars', charts.city || [], '#13899b'); renderBars('intentBars', charts.facebook_intent || [], '#16885a'); renderColumns('sourceColumns', charts.source || []); renderColumns('replyTrendColumns', charts.facebook_reply_trend || []); renderDonut(); renderHighIntent(); renderDefinitions(); initFilters(); renderRows();
   </script>
 </body>
 </html>

+ 4 - 1
assets/feishu_sync_config.example.json

@@ -4,12 +4,15 @@
   "spreadsheet_token": "",
   "sync_scope": "summary_first",
   "summary_sheet_name": "客户信息汇总表",
+  "conversation_sheet_name": "Facebook对话记录",
+  "sync_conversation_sheet": true,
   "channel_sheets": [
     "Facebook",
+    "Facebook对话记录",
     "LinkedIn",
     "Google Maps",
     "汽车网站精选线索",
     "协会商会"
   ],
   "report_path": "runs/YYYYMMDD/<run_id>/feishu-sync-report.json"
-}
+}

+ 9 - 2
references/dashboard.md

@@ -19,7 +19,7 @@ Do not put generated dashboards, customer rows, Feishu URLs, tokens, screenshots
 
 ## Data Source
 
-The default source is the local workbook sheet `客户信息汇总表`.
+The default customer source is `客户信息汇总表`. If `Facebook对话记录` exists, read it as an optional event source for Facebook reply metrics; never treat message rows as customer rows.
 
 Before generating a dashboard after new scraping or write-back work, refresh the summary sheet first:
 
@@ -44,6 +44,9 @@ Preview, dry-run, or search-only tasks do not need to refresh the dashboard unle
 - `邮箱覆盖率`: valid customers with `个人邮箱` or `公共邮箱` divided by all valid customers.
 - `退信率`: customers marked with `邮件退回`, `退回`, `拒收`, `无法送达`, `域名不存在`, `邮箱不存在`, or `发送失败` divided by customers with email.
 - `待人工复核`: valid customers whose notes, type, or attribute contain `待确认`, `人工复核`, `需人工`, `需确认`, or `信息不足`.
+- `Facebook回复率`: unique customers with at least one effective customer reply divided by unique customers with at least one outgoing Facebook message.
+- `Facebook高意向`: unique customers whose latest effective reply is `明确有意向` or `潜在意向`.
+- System messages, auto replies, reactions, likes, and read receipts do not count as effective replies.
 
 ## Dashboard Views
 
@@ -63,6 +66,10 @@ The default HTML dashboard should show:
   - 细分客户类型 Top
 - Attention table:
   - contactable customers that have not yet been contacted and are not bounced.
+- Facebook follow-up sections:
+  - five-level cooperation-intent distribution;
+  - effective-reply trend for the latest 30 days;
+  - high-intent customer table with Chinese reply summary and next action.
 
 The dashboard is a management view. Do not use it as the source of truth for customer edits; the local Excel workbook remains the source of truth.
 
@@ -84,7 +91,7 @@ When enabled, the agent should use WorkBuddy `lark-sheets` to sync dashboard met
 
 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.
+After generating dashboard files, scan them for repeated-question-mark corruption. If found, stop and repair the source text generation before sharing the dashboard.
 
 
 ## Visual Design Requirements

+ 145 - 0
references/facebook-conversation-sync.md

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

+ 4 - 0
references/feishu-sync.md

@@ -26,6 +26,8 @@ Recommended project config file: `feishu_sync_config.json`.
   "spreadsheet_token": "",
   "sync_scope": "summary_first",
   "summary_sheet_name": "客户信息汇总表",
+  "conversation_sheet_name": "Facebook对话记录",
+  "sync_conversation_sheet": true,
   "channel_sheets": [
     "Facebook",
     "LinkedIn",
@@ -48,6 +50,8 @@ Rules:
 Default `sync_scope` is `summary_first`.
 
 - Sync `客户信息汇总表` by default after it is rebuilt successfully.
+- A Facebook conversation write-back is a special event-log workflow: when `sync_conversation_sheet=true`, sync both `Facebook对话记录` and the rebuilt `客户信息汇总表`.
+- Overwrite the remote conversation Sheet from the local Sheet so repeated syncs do not append duplicate message IDs.
 - If a channel sheet was written but the summary sheet was not rebuilt in the same run, report that the local channel sheet was updated and recommend refreshing the summary before Feishu sync.
 - Sync all configured channel sheets only when the user explicitly asks for full workbook sync or `sync_scope` is set to `all_configured_sheets`.
 - Never sync previews, JSON candidates, HTML previews, sent logs, backups, temporary files, or SMTP data to Feishu.

+ 18 - 1
references/field-schema.md

@@ -86,6 +86,7 @@ All text fields that may contain non-ASCII content must follow `references/encod
 |---|---|---|
 | `客户信息汇总表` | Consolidated master customer table | Generated after platform searches; includes `客户属性` and `客户来源`. |
 | `Facebook` | Facebook dealer and page collection | Standard columns plus optional `公司官网`. |
+| `Facebook对话记录` | Matched Messenger conversation history | Stores immutable original text, Chinese translation, effective-reply flag, intent, and next-step recommendation. Never merge message rows as customer rows. |
 | `LinkedIn` | Company/person outreach collection | Supports old and normalized LinkedIn headers. |
 | `Google Maps` | Google Maps local business collection | Supports public email extraction from merchant websites. |
 | `TikTok` | TikTok account collection | Placeholder / reserved channel. |
@@ -205,10 +206,27 @@ Use these values consistently:
 - `已加 WhatsApp,待跟进`
 - `加好友失败`
 - `发送失败`
+- `已回复,有合作意向`
+- `已回复,待跟进`
+- `已回复,待澄清`
+- `已回复,暂不考虑`
+- `已回复,明确拒绝`
 
 
 For email sending, treat `已发送邮件`, `已发邮件`, `邮件已发送`, and `邮件发送成功` as already-sent statuses.
 
+## Facebook Conversation Columns
+
+`Facebook对话记录` uses:
+
+`记录ID`, `客户序号`, `客户姓名/公司`, `Facebook主页链接`, `Messenger线程ID`, `消息时间`, `消息方向`, `发件人`, `原文语言`, `对话原文`, `中文翻译`, `消息类型`, `是否有效客户回复`, `合作意向`, `意向判断依据`, `下一步建议`, `同步时间`, `来源账号/Profile ID`, `风险标记`.
+
+- Preserve `对话原文`; never overwrite it with Chinese.
+- Deduplicate by `记录ID`.
+- Use only the five reply intents defined in `references/facebook-conversation-sync.md`.
+- Keep this Sheet outside `PLATFORM_SHEETS` in the customer summary builder so message rows are never counted as customers.
+- Store the latest reply summary and intent in the matched Facebook customer row note; keep the fixed summary-sheet header unchanged.
+
 ## Notes Field
 
 `备注` should be a concise structured summary. Do not paste one raw post as the whole note. Synthesize business-relevant observations such as main business, activity, recent posts, China-brand signals, batch purchase capacity, contact evidence, and risks.
@@ -237,4 +255,3 @@ Facebook Sheet supports an additional field: `公司官网`.
 | `生态支持资源` | `售后服务网络` ; `备件供应与仓储企业` ; `进口认证与上牌机构` ; `金融保险机构` ; `车辆物流企业` |
 
 `主营业务` remains the business-description field and must not be renamed or used as a classification substitute.
-

+ 13 - 1
references/outreach-rules.md

@@ -64,6 +64,19 @@ Facebook outreach uses English as the customer-facing language. Chinese is only
 - 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 Reply Synchronization
+
+When the user asks to inspect replies, synchronize Messenger history, translate conversations, or judge cooperation intent, read `references/facebook-conversation-sync.md`.
+
+- The reply workflow is read-only in Facebook: it may navigate, scroll, and read matched Messenger threads, but it must not type, send, react, or change the account state.
+- First sync uses `--initial-full`; later syncs use `--incremental`.
+- Preserve both directions and translate all non-Chinese text to Chinese. Analyze intent only for effective customer replies.
+- Before workbook write-back, show the reply summary, five-level intent distribution, and high-intent customers in chat.
+- Do not generate or send an English reply. Output only a Chinese next-step recommendation.
+- Write conversation rows to `Facebook对话记录`; update the matched Facebook customer row status, follow-up date, and replaceable reply-analysis note block.
+- Preserve existing email, WhatsApp, and outreach evidence in the customer status and notes.
+- After write-back, refresh the summary and dashboard, then check the Feishu sync configuration.
+
 ## 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.
@@ -146,4 +159,3 @@ Record these fields for browser outreach when possible:
 - 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.
-

+ 9 - 0
scripts/common/excel_io.py

@@ -39,6 +39,13 @@ AUTO_WEBSITE_COLUMNS = [
     "建联状态", "下次跟进", "备注",
 ]
 
+FACEBOOK_CONVERSATION_COLUMNS = [
+    "记录ID", "客户序号", "客户姓名/公司", "Facebook主页链接", "Messenger线程ID",
+    "消息时间", "消息方向", "发件人", "原文语言", "对话原文", "中文翻译", "消息类型",
+    "是否有效客户回复", "合作意向", "意向判断依据", "下一步建议", "同步时间",
+    "来源账号/Profile ID", "风险标记",
+]
+
 COLUMN_ALIASES = {
     "客户姓名/公司": ["客户姓名/公司", "公司名称", "名称"],
     "公司名称": ["公司名称", "客户姓名/公司", "名称"],
@@ -68,6 +75,8 @@ def get_sheet_columns(sheet_name: str) -> List[str]:
         return GOOGLE_MAPS_COLUMNS
     if name == "汽车网站精选线索":
         return AUTO_WEBSITE_COLUMNS
+    if name == "Facebook对话记录":
+        return FACEBOOK_CONVERSATION_COLUMNS
     return STANDARD_COLUMNS
 
 

+ 162 - 3
scripts/dashboard/build_dashboard.py

@@ -25,7 +25,9 @@ from artifact_manager import new_run_id, resolve_artifact_path  # type: ignore
 from workbook_resolver import resolve_workbook_path  # type: ignore  # noqa: E402
 
 SUMMARY_SHEET_ALIASES = ["客户信息汇总表", "客户信息汇总"]
+CONVERSATION_SHEET = "Facebook对话记录"
 DEFAULT_TITLE = "五菱海外客户建联中台"
+FACEBOOK_INTENTS = ["明确有意向", "潜在意向", "需澄清", "暂不考虑", "明确拒绝"]
 
 HEADER_ALIASES = {
     "company": ["公司姓名", "客户姓名/公司", "公司名称", "客户名称", "公司/客户", "Name", "Company"],
@@ -48,6 +50,20 @@ HEADER_ALIASES = {
     "note": ["备注", "说明", "Notes"],
 }
 
+CONVERSATION_HEADER_ALIASES = {
+    "record_id": ["记录ID"],
+    "customer_index": ["客户序号"],
+    "company": ["客户姓名/公司", "公司姓名", "公司名称"],
+    "facebook_link": ["Facebook主页链接", "主页/链接"],
+    "message_time": ["消息时间"],
+    "direction": ["消息方向"],
+    "translation": ["中文翻译"],
+    "effective": ["是否有效客户回复"],
+    "intent": ["合作意向"],
+    "intent_reason": ["意向判断依据"],
+    "next_action": ["下一步建议"],
+}
+
 CONTACTED_KEYWORDS = ["已发送邮件", "已发邮件", "邮件已发送", "邮件发送成功", "已发私信", "已关注并私信", "已建联", "等待回复", "已回复", "有意向"]
 BOUNCED_KEYWORDS = ["邮件退回", "退回", "拒收", "无法送达", "域名不存在", "邮箱不存在", "发送失败"]
 EXCLUDED_KEYWORDS = ["已剔除", "剔除", "跳过", "低优先级", "skip_"]
@@ -115,6 +131,108 @@ def read_summary_rows(workbook_path: Path, sheet_name: str) -> Tuple[str, List[D
     return actual_sheet, rows
 
 
+def normalized_link(value: str) -> str:
+    value = clean(value).casefold().split("?", 1)[0].rstrip("/")
+    return value.removeprefix("https://").removeprefix("http://").removeprefix("www.")
+
+
+def customer_key(company: str, facebook_link: str, customer_index: str = "") -> str:
+    link = normalized_link(facebook_link)
+    if link:
+        return "link:" + link
+    name = re.sub(r"[^0-9a-z\u00c0-\u024f\u0600-\u06ff\u4e00-\u9fff]+", "", clean(company).casefold())
+    return "name:" + name if name else "index:" + clean(customer_index)
+
+
+def read_conversation_rows(workbook_path: Path, sheet_name: str) -> List[Dict[str, str]]:
+    wb = load_workbook(workbook_path, data_only=True, read_only=True)
+    if sheet_name not in wb.sheetnames:
+        return []
+    ws = wb[sheet_name]
+    raw_headers = {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 CONVERSATION_HEADER_ALIASES.items():
+        for alias in aliases:
+            if alias in raw_headers:
+                mapped[key] = raw_headers[alias]
+                break
+    if "record_id" not in mapped:
+        return []
+    rows: List[Dict[str, str]] = []
+    for row_idx in range(2, ws.max_row + 1):
+        row = {
+            key: clean(ws.cell(row=row_idx, column=column).value)
+            for key, column in mapped.items()
+        }
+        if row.get("record_id"):
+            rows.append(row)
+    return rows
+
+
+def message_datetime(value: str) -> datetime:
+    value = clean(value)
+    if not value:
+        return datetime.min
+    try:
+        return datetime.fromisoformat(value.replace("Z", "+00:00")).replace(tzinfo=None)
+    except ValueError:
+        match = re.search(r"(20\d{2})[-/](\d{1,2})[-/](\d{1,2})", value)
+        if match:
+            return datetime(int(match.group(1)), int(match.group(2)), int(match.group(3)))
+    return datetime.min
+
+
+def build_conversation_insights(rows: Sequence[Dict[str, str]]) -> Dict[str, Any]:
+    sent_customers = set()
+    replied_customers = set()
+    latest_by_customer: Dict[str, Dict[str, str]] = {}
+    reply_trend: Counter = Counter()
+    for row in sorted(rows, key=lambda item: message_datetime(item.get("message_time", ""))):
+        key = customer_key(row.get("company", ""), row.get("facebook_link", ""), row.get("customer_index", ""))
+        if row.get("direction") == "我方发送":
+            sent_customers.add(key)
+        if row.get("direction") != "客户回复" or row.get("effective") != "是":
+            continue
+        replied_customers.add(key)
+        if row.get("intent") in FACEBOOK_INTENTS:
+            latest_by_customer[key] = row
+        parsed = message_datetime(row.get("message_time", ""))
+        if parsed != datetime.min and (datetime.now() - parsed).days <= 30:
+            reply_trend[parsed.strftime("%Y-%m-%d")] += 1
+    intent_counter = Counter(
+        row.get("intent") or "未分析" for row in latest_by_customer.values()
+    )
+    high_intent = [
+        {
+            "company": row.get("company", ""),
+            "intent": row.get("intent", ""),
+            "latest_reply": row.get("translation", ""),
+            "reason": row.get("intent_reason", ""),
+            "next_action": row.get("next_action", ""),
+            "message_time": row.get("message_time", ""),
+        }
+        for row in latest_by_customer.values()
+        if row.get("intent") in {"明确有意向", "潜在意向"}
+    ]
+    pending = [
+        item for item in high_intent
+        if item.get("next_action")
+    ]
+    trend = [
+        {"name": day, "count": count}
+        for day, count in sorted(reply_trend.items())
+    ]
+    return {
+        "sent_customers": sent_customers,
+        "replied_customers": replied_customers,
+        "latest_by_customer": latest_by_customer,
+        "intent_counter": intent_counter,
+        "reply_trend": trend,
+        "high_intent": sorted(high_intent, key=lambda item: item.get("message_time", ""), reverse=True),
+        "pending_followups": sorted(pending, key=lambda item: item.get("message_time", ""), reverse=True),
+    }
+
+
 def contains_any(text: str, keywords: Sequence[str]) -> bool:
     lowered = text.casefold()
     return any(keyword.casefold() in lowered for keyword in keywords)
@@ -183,7 +301,14 @@ def display_contact(row: Dict[str, str]) -> str:
     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]:
+def build_dashboard_data(
+    rows: Sequence[Dict[str, str]],
+    conversations: 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)]
@@ -191,6 +316,10 @@ def build_dashboard_data(rows: Sequence[Dict[str, str]], workbook_path: Path, sh
     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)]
+    conversation = build_conversation_insights(conversations)
+    facebook_sent = len(conversation["sent_customers"])
+    facebook_replied = len(conversation["replied_customers"])
+    latest_intents = conversation["latest_by_customer"]
 
     attribute_counter = Counter(row.get("attribute") or "未填写" for row in valid_rows)
     type_counter = Counter(row.get("customer_type") or "未填写" for row in valid_rows)
@@ -203,6 +332,7 @@ def build_dashboard_data(rows: Sequence[Dict[str, str]], workbook_path: Path, sh
 
     dashboard_rows = []
     for row in valid_rows:
+        reply = latest_intents.get(customer_key(row.get("company", ""), row.get("facebook_link", "")), {})
         dashboard_rows.append({
             "row": row.get("_excel_row", ""),
             "company": row.get("company", ""),
@@ -218,6 +348,9 @@ def build_dashboard_data(rows: Sequence[Dict[str, str]], workbook_path: Path, sh
             "bounced": is_bounced(row),
             "review": needs_review(row),
             "note": row.get("note", "")[:220],
+            "facebook_intent": reply.get("intent", ""),
+            "latest_reply": reply.get("translation", "")[:160],
+            "next_action": reply.get("next_action", "")[:160],
         })
 
     attention_rows = [row for row in dashboard_rows if row["contact"] and not row["contacted"] and not row["bounced"]][:top_n]
@@ -242,6 +375,16 @@ def build_dashboard_data(rows: Sequence[Dict[str, str]], workbook_path: Path, sh
             "bounce_rate": pct(len(bounced_rows), len(email_rows)),
             "needs_review_customers": len(review_rows),
             "review_rate": pct(len(review_rows), len(valid_rows)),
+            "facebook_dm_customers": facebook_sent,
+            "facebook_reply_customers": facebook_replied,
+            "facebook_reply_rate": pct(facebook_replied, facebook_sent),
+            "facebook_clear_intent_customers": sum(
+                1 for row in latest_intents.values() if row.get("intent") == "明确有意向"
+            ),
+            "facebook_potential_intent_customers": sum(
+                1 for row in latest_intents.values() if row.get("intent") == "潜在意向"
+            ),
+            "facebook_pending_followups": len(conversation["pending_followups"]),
         },
         "charts": {
             "attribute": top_counter(attribute_counter, top_n),
@@ -249,9 +392,13 @@ def build_dashboard_data(rows: Sequence[Dict[str, str]], workbook_path: Path, sh
             "status": top_counter(status_counter, top_n),
             "source": top_counter(source_counter, top_n),
             "city": top_counter(city_counter, top_n),
+            "facebook_intent": top_counter(conversation["intent_counter"], len(FACEBOOK_INTENTS)),
+            "facebook_reply_trend": conversation["reply_trend"],
         },
         "dashboard_rows": dashboard_rows,
         "attention_customers": attention_rows,
+        "facebook_high_intent_customers": conversation["high_intent"][:top_n],
+        "facebook_pending_followups": conversation["pending_followups"][:top_n],
         "definitions": {
             "有效客户": "总表中未被备注或状态标记为已剔除、跳过、低优先级的客户。",
             "可建联客户": "有效客户中至少有邮箱、电话/WhatsApp、官网、Facebook、LinkedIn 或 Google Maps 入口之一。",
@@ -259,6 +406,8 @@ def build_dashboard_data(rows: Sequence[Dict[str, str]], workbook_path: Path, sh
             "建联率": "已建联客户数 / 可建联客户数。",
             "邮箱覆盖率": "有个人邮箱或公共邮箱的有效客户数 / 有效客户数。",
             "退信率": "邮件退回、拒收、无法送达或域名/邮箱不存在客户数 / 有邮箱客户数。",
+            "Facebook回复率": "Facebook对话记录中有有效客户回复的唯一客户数 / 已发送Facebook私信的唯一客户数;系统消息和自动回复不计入。",
+            "合作意向": "按每个客户最新一条有效回复计算五级意向,同一客户多条消息只计一次。",
         },
     }
 
@@ -292,6 +441,7 @@ 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("--conversation-sheet", default=CONVERSATION_SHEET, help="Facebook conversation 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.")
@@ -309,12 +459,21 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
         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)
+    conversations = read_conversation_rows(workbook_path, args.conversation_sheet)
+    data = build_dashboard_data(rows, conversations, 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"]}
+    report = {
+        "workbook": str(workbook_path),
+        "summary_sheet": actual_sheet,
+        "conversation_sheet": args.conversation_sheet if conversations else "",
+        "conversation_rows": len(conversations),
+        "run_id": run_id,
+        "outputs": outputs,
+        "metrics": data["metrics"],
+    }
     print(json.dumps(report, ensure_ascii=False, indent=2))
     return 0
 

+ 11 - 7
scripts/scraper/ads_power_client.py

@@ -82,13 +82,8 @@ class AdsPowerClient:
             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.
-        """
+    def detach(self) -> bool:
+        """Detach Playwright while leaving the AdsPower browser process open."""
         self.browser = None
         if self.playwright:
             try:
@@ -99,6 +94,15 @@ class AdsPowerClient:
         print("AdsPower browser left open; Playwright connection stopped only.")
         return True
 
+    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.
+        """
+        return self.detach()
+
     def __enter__(self):
         return self
 

+ 20 - 2
scripts/social/README.md

@@ -9,6 +9,8 @@
 | `prepare_facebook_outreach.py` | 读取 Facebook Sheet,按客户类型生成中文判断 + 英文加好友/私信话术 JSON 预览 | 否 | 否 |
 | `send_facebook_outreach.py` | 根据预览执行 Page Follow + Messenger DM;默认 dry-run,真实执行需 `--confirm` | 否 | 否 |
 | `run_facebook_follow_dm.py` | 模块二总控入口:先生成预览,再调用执行脚本 dry-run 或确认发送 | 否 | 否 |
+| `collect_facebook_conversations.py` | 通过 AdsPower + Playwright 只读采集已匹配客户的 Messenger 历史/增量消息 | 否 | 否 |
+| `write_facebook_conversations.py` | 校验 Agent 中文翻译和五级意向分析,写入 Facebook对话记录并更新客户状态 | 否 | 需 `--write-workbook` |
 
 ## 推荐流程
 
@@ -40,7 +42,7 @@ python scripts/social/run_facebook_follow_dm.py `
   --use-open-page
 ```
 
-## 真实执行,仍逐条确认
+## 真实执行,整批确认
 
 ```powershell
 python scripts/social/run_facebook_follow_dm.py `
@@ -69,8 +71,24 @@ python scripts/social/run_facebook_follow_dm.py `
 
 - Facebook 对外发送语言固定为英文。
 - 默认不写建联表;需要写表时显式加 `--write-workbook`。
-- 默认且强制保持 AdsPower 浏览器打开;不再支持断开 Playwright 连接,浏览器保持打开
+- 默认且强制保持 AdsPower 浏览器打开;脚本结束时仅断开 Playwright 连接,不停止 AdsPower 配置或关闭浏览器
 - 私信只能发到 Messenger 小窗或 Messenger 私信页,且必须验证目标客户名称。
 - 禁止向帖子评论框、回复框、页面底部通用输入框发送。
 - 若主页 `發送訊息` 按钮不弹出 Messenger 小窗,脚本会兜底打开 `https://www.facebook.com/messages/t/<page_slug>`,验证客户名称后再发送。
 - 遇到验证、限流、异常活动提示时立即停止。
+
+## Facebook 回复同步
+
+首次历史采集:
+
+```powershell
+python scripts/social/collect_facebook_conversations.py --excel "<建联表>" --profile-id "<profile_id>" --initial-full
+```
+
+后续增量采集将 `--initial-full` 改成 `--incremental`。Agent 必须按 `references/facebook-conversation-sync.md` 翻译和分析,并先在聊天框展示结果。写表时使用:
+
+```powershell
+python scripts/social/write_facebook_conversations.py --excel "<建联表>" --transcript "<raw.json>" --analysis "<analysis.json>" --write-workbook --refresh-summary --refresh-dashboard
+```
+
+回复同步只读取消息,不发送回复、不点击反应、不下载附件、不关闭 AdsPower。

+ 614 - 0
scripts/social/collect_facebook_conversations.py

@@ -0,0 +1,614 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""Collect matched Facebook Messenger conversations through AdsPower + Playwright.
+
+This collector is read-only: it never sends messages, writes the workbook, or
+closes the user's AdsPower browser. Translation and intent analysis are
+performed by the agent in a separate JSON artifact before workbook write-back.
+"""
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import random
+import re
+import sys
+import time
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any, Dict, Iterable, List, Optional, Sequence, Set, Tuple
+from urllib.parse import parse_qs, urlparse
+
+from openpyxl import load_workbook
+
+SCRIPT_DIR = Path(__file__).resolve().parent
+SKILL_ROOT = SCRIPT_DIR.parents[1]
+COMMON_DIR = SKILL_ROOT / "scripts" / "common"
+SCRAPER_DIR = SKILL_ROOT / "scripts" / "scraper"
+for directory in (COMMON_DIR, SCRAPER_DIR):
+    if str(directory) not in sys.path:
+        sys.path.insert(0, str(directory))
+
+from ads_power_client import AdsPowerClient  # type: ignore  # noqa: E402
+from artifact_manager import new_run_id, resolve_artifact_path  # type: ignore  # noqa: E402
+from workbook_resolver import resolve_workbook_path  # type: ignore  # noqa: E402
+
+FACEBOOK_SHEET = "Facebook"
+CONVERSATION_SHEET = "Facebook对话记录"
+DEFAULT_MAX_MESSAGES = 2000
+MAJOR_WAIT = (90.0, 200.0)
+MINOR_WAIT = (30.0, 90.0)
+TECHNICAL_WAIT = (0.5, 8.0)
+
+HEADER_ALIASES = {
+    "index": ["序号", "编号", "ID"],
+    "company": ["客户姓名/公司", "公司名称", "公司姓名", "客户名称"],
+    "facebook_link": ["主页/链接", "Facebook主页链接", "Facebook链接", "facebook链接"],
+    "attribute": ["客户属性"],
+    "customer_type": ["客户类型"],
+    "business": ["主营业务", "公司主营业务"],
+    "status": ["建联状态", "建联情况"],
+    "note": ["备注", "说明"],
+}
+RISK_MARKERS = [
+    "temporarily blocked",
+    "verification required",
+    "confirm your identity",
+    "suspicious activity",
+    "try again later",
+    "暂时封锁",
+    "验证",
+    "确认身份",
+    "异常活动",
+]
+OUTGOING_MARKERS = [
+    "you sent",
+    "sent by you",
+    "您发送",
+    "你发送",
+    "你已发送",
+    "vous avez envoyé",
+    "envoyé par vous",
+    "لقد أرسلت",
+]
+SYSTEM_MARKERS = [
+    "messages and calls are secured",
+    "end-to-end encrypted",
+    "created this group",
+    "joined the conversation",
+    "changed the theme",
+    "missed a call",
+    "通话和消息",
+    "端到端加密",
+    "加入了对话",
+    "更改了主题",
+]
+AUTO_REPLY_MARKERS = [
+    "automated response",
+    "automatic reply",
+    "auto-reply",
+    "away message",
+    "réponse automatique",
+    "message automatique",
+    "رسالة تلقائية",
+    "自动回复",
+]
+READ_ONLY_MARKERS = ["seen", "已读", "vu", "تمت المشاهدة"]
+
+
+def clean(value: Any) -> str:
+    if value is None:
+        return ""
+    return re.sub(r"\s+", " ", str(value).strip())
+
+
+def now_iso() -> str:
+    return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
+
+
+def normalize_name(value: str) -> str:
+    value = clean(value).casefold()
+    value = re.sub(r"https?://|www\.", " ", value)
+    value = re.sub(r"[^0-9a-z\u00c0-\u024f\u0600-\u06ff\u4e00-\u9fff]+", " ", value)
+    return " ".join(part for part in value.split() if len(part) > 1)
+
+
+def page_identity(url: str) -> Tuple[str, str]:
+    parsed = urlparse(clean(url))
+    host = parsed.netloc.casefold().removeprefix("www.")
+    if "facebook.com" not in host:
+        return "", ""
+    query = parse_qs(parsed.query)
+    if parsed.path.rstrip("/").casefold() == "/profile.php" and query.get("id"):
+        value = clean(query["id"][0])
+        return value, value
+    parts = [part for part in parsed.path.split("/") if part]
+    if not parts:
+        return "", ""
+    reserved = {"pages", "groups", "marketplace", "watch", "messages", "home.php"}
+    if parts[0].casefold() in reserved:
+        if parts[0].casefold() == "pages" and parts[-1].isdigit():
+            return parts[-1], parts[-1]
+        return "", ""
+    slug = parts[0]
+    return slug, slug
+
+
+def thread_url_for(page_url: str) -> Tuple[str, str]:
+    slug, page_id = page_identity(page_url)
+    identity = page_id or slug
+    if not identity:
+        return "", ""
+    return f"https://www.facebook.com/messages/t/{identity}", identity
+
+
+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 cell_value(ws, row: int, columns: Dict[str, int], key: str) -> str:
+    column = columns.get(key)
+    return clean(ws.cell(row=row, column=column).value) if column else ""
+
+
+def read_customers(workbook_path: Path, sheet_name: str) -> List[Dict[str, str]]:
+    wb = load_workbook(workbook_path, data_only=True, read_only=True)
+    if sheet_name not in wb.sheetnames:
+        raise KeyError(f"Sheet not found: {sheet_name}")
+    ws = wb[sheet_name]
+    columns = header_map(ws)
+    missing = [key for key in ("index", "company", "facebook_link") if key not in columns]
+    if missing:
+        raise RuntimeError(f"Facebook sheet is missing required columns: {', '.join(missing)}")
+    rows: List[Dict[str, str]] = []
+    for row in range(2, ws.max_row + 1):
+        link = cell_value(ws, row, columns, "facebook_link")
+        name = cell_value(ws, row, columns, "company")
+        thread_url, identity = thread_url_for(link)
+        if not name or not thread_url:
+            continue
+        rows.append(
+            {
+                "excel_row": str(row),
+                "index": cell_value(ws, row, columns, "index"),
+                "company": name,
+                "facebook_link": link,
+                "thread_url": thread_url,
+                "expected_identity": identity,
+                "attribute": cell_value(ws, row, columns, "attribute"),
+                "customer_type": cell_value(ws, row, columns, "customer_type"),
+                "business": cell_value(ws, row, columns, "business"),
+                "status": cell_value(ws, row, columns, "status"),
+                "note": cell_value(ws, row, columns, "note"),
+            }
+        )
+    wb.close()
+    return rows
+
+
+def read_existing_record_ids(workbook_path: Path, sheet_name: str = CONVERSATION_SHEET) -> Set[str]:
+    wb = load_workbook(workbook_path, data_only=True, read_only=True)
+    if sheet_name not in wb.sheetnames:
+        wb.close()
+        return set()
+    ws = wb[sheet_name]
+    headers = {clean(cell.value): idx for idx, cell in enumerate(ws[1], start=1)}
+    record_col = headers.get("记录ID")
+    if not record_col:
+        wb.close()
+        return set()
+    result = {
+        clean(ws.cell(row=row, column=record_col).value)
+        for row in range(2, ws.max_row + 1)
+        if clean(ws.cell(row=row, column=record_col).value)
+    }
+    wb.close()
+    return result
+
+
+def paced_wait(label: str, bounds: Tuple[float, float], enabled: bool = True) -> float:
+    if not enabled:
+        return 0.0
+    seconds = random.uniform(*bounds)
+    print(f"  -> {label}: {seconds:.1f}s", flush=True)
+    time.sleep(seconds)
+    return seconds
+
+
+def risk_text(page) -> str:
+    try:
+        text = clean(page.locator("body").inner_text(timeout=5000)).casefold()
+    except Exception:
+        return ""
+    return next((marker for marker in RISK_MARKERS if marker.casefold() in text), "")
+
+
+def visible_thread_title(page) -> str:
+    selectors = [
+        "[role='main'] h1",
+        "[role='main'] h2",
+        "header h1",
+        "header h2",
+        "a[role='link'][href*='/messages/t/']",
+    ]
+    for selector in selectors:
+        try:
+            locator = page.locator(selector)
+            for idx in range(min(locator.count(), 8)):
+                text = clean(locator.nth(idx).inner_text(timeout=1000))
+                if text and len(text) <= 160:
+                    return text
+        except Exception:
+            continue
+    return ""
+
+
+def name_match(expected: str, observed: str) -> bool:
+    expected_norm = normalize_name(expected)
+    observed_norm = normalize_name(observed)
+    if not expected_norm or not observed_norm:
+        return False
+    if expected_norm in observed_norm or observed_norm in expected_norm:
+        return True
+    expected_tokens = set(expected_norm.split())
+    observed_tokens = set(observed_norm.split())
+    overlap = expected_tokens & observed_tokens
+    return bool(overlap) and len(overlap) / max(1, min(len(expected_tokens), len(observed_tokens))) >= 0.6
+
+
+def validate_thread(page, customer: Dict[str, str]) -> Dict[str, Any]:
+    current_url = clean(page.url)
+    expected_identity = customer["expected_identity"].casefold()
+    parsed = urlparse(current_url)
+    path_parts = [part.casefold() for part in parsed.path.split("/") if part]
+    url_match = "messages" in path_parts and "t" in path_parts and expected_identity in path_parts
+    title = visible_thread_title(page)
+    title_match = name_match(customer["company"], title)
+    matched = bool(url_match and title_match)
+    return {
+        "matched": matched,
+        "url_match": url_match,
+        "title_match": title_match,
+        "observed_title": title,
+        "current_url": current_url,
+        "match_basis": "thread_url+title" if matched else "",
+    }
+
+
+EXTRACT_SCRIPT = r"""
+() => {
+  const main = document.querySelector('[role="main"]') || document.body;
+  const selectors = [
+    '[role="row"]',
+    '[data-testid*="message" i]',
+    '[data-scope*="message" i]',
+    'div[aria-label*="message" i]',
+    'div[aria-label*="sent" i]'
+  ];
+  const nodes = [];
+  const seen = new Set();
+  for (const selector of selectors) {
+    for (const node of main.querySelectorAll(selector)) {
+      if (!(node instanceof HTMLElement)) continue;
+      const rect = node.getBoundingClientRect();
+      if (rect.width < 24 || rect.height < 12) continue;
+      const text = (node.innerText || '').trim();
+      const aria = (node.getAttribute('aria-label') || '').trim();
+      if (!text && !aria && !node.querySelector('img,video,audio,[role="img"]')) continue;
+      const key = [text, aria, Math.round(rect.top), Math.round(rect.left)].join('|');
+      if (seen.has(key)) continue;
+      seen.add(key);
+      const timeNode = node.querySelector('time[datetime], abbr[data-tooltip-content], [data-tooltip-content]');
+      const datetime = timeNode
+        ? (timeNode.getAttribute('datetime') || timeNode.getAttribute('data-tooltip-content') || timeNode.textContent || '').trim()
+        : '';
+      const idNode = node.closest('[data-message-id], [data-testid], [id]') || node;
+      const rawId = idNode.getAttribute('data-message-id') || idNode.getAttribute('id') || '';
+      const senderNode = node.querySelector('h3,h4,strong,a[role="link"]');
+      const sender = senderNode ? (senderNode.textContent || '').trim() : '';
+      const hrefs = Array.from(node.querySelectorAll('a[href]')).map(a => a.href).filter(Boolean).slice(0, 8);
+      nodes.push({
+        text,
+        aria,
+        datetime,
+        raw_id: rawId,
+        sender,
+        hrefs,
+        left: rect.left,
+        width: rect.width,
+        viewport_width: window.innerWidth,
+        image_count: node.querySelectorAll('img,[role="img"]').length,
+        video_count: node.querySelectorAll('video').length,
+        audio_count: node.querySelectorAll('audio').length,
+        file_count: node.querySelectorAll('a[download],a[href*="/file/"],a[href*="attachment"]').length
+      });
+    }
+  }
+  return nodes;
+}
+"""
+
+
+def message_type(item: Dict[str, Any]) -> str:
+    if item.get("audio_count"):
+        return "语音"
+    if item.get("video_count"):
+        return "视频"
+    if item.get("file_count"):
+        return "文件"
+    if item.get("image_count") and not clean(item.get("text")):
+        return "图片"
+    return "文本"
+
+
+def classify_direction(item: Dict[str, Any]) -> str:
+    combined = " ".join([clean(item.get("aria")), clean(item.get("text"))]).casefold()
+    if any(marker.casefold() in combined for marker in OUTGOING_MARKERS):
+        return "我方发送"
+    viewport = float(item.get("viewport_width") or 0)
+    left = float(item.get("left") or 0)
+    width = float(item.get("width") or 0)
+    if viewport and left + width / 2 >= viewport * 0.58:
+        return "我方发送"
+    return "客户回复"
+
+
+def classify_raw_kind(item: Dict[str, Any]) -> Tuple[str, List[str]]:
+    combined = " ".join([clean(item.get("aria")), clean(item.get("text"))]).casefold()
+    risks: List[str] = []
+    if any(marker.casefold() in combined for marker in SYSTEM_MARKERS):
+        return "系统消息", risks
+    if any(marker.casefold() in combined for marker in AUTO_REPLY_MARKERS):
+        risks.append("auto_reply")
+        return "自动回复", risks
+    if any(marker.casefold() == combined or marker.casefold() in combined for marker in READ_ONLY_MARKERS):
+        return "已读提示", risks
+    return message_type(item), risks
+
+
+def stable_record_id(thread_id: str, item: Dict[str, Any], direction: str, ordinal: int) -> str:
+    raw_id = clean(item.get("raw_id"))
+    if raw_id and len(raw_id) >= 6:
+        return f"fb:{raw_id}"
+    basis = "|".join(
+        [
+            thread_id,
+            clean(item.get("datetime")),
+            direction,
+            clean(item.get("sender")),
+            clean(item.get("text")),
+            ";".join(item.get("hrefs") or []),
+            str(ordinal),
+        ]
+    )
+    return "fbh:" + hashlib.sha256(basis.encode("utf-8")).hexdigest()[:32]
+
+
+def normalize_messages(raw_items: Iterable[Dict[str, Any]], thread_id: str) -> List[Dict[str, Any]]:
+    messages: List[Dict[str, Any]] = []
+    seen: Set[str] = set()
+    for ordinal, item in enumerate(raw_items):
+        text = clean(item.get("text"))
+        aria = clean(item.get("aria"))
+        if not text and not aria and not any(item.get(key) for key in ("image_count", "video_count", "audio_count", "file_count")):
+            continue
+        direction = classify_direction(item)
+        kind, risks = classify_raw_kind(item)
+        record_id = stable_record_id(thread_id, item, direction, ordinal)
+        if record_id in seen:
+            continue
+        seen.add(record_id)
+        messages.append(
+            {
+                "record_id": record_id,
+                "message_time_raw": clean(item.get("datetime")),
+                "direction": direction,
+                "sender": clean(item.get("sender")),
+                "original_text": text or aria,
+                "message_type": kind,
+                "attachment_links": item.get("hrefs") or [],
+                "raw_aria": aria,
+                "risk_flags": risks,
+            }
+        )
+    return messages
+
+
+def find_scroll_container(page) -> bool:
+    return bool(
+        page.evaluate(
+            r"""
+() => {
+  const main = document.querySelector('[role="main"]') || document.body;
+  const nodes = [main, ...main.querySelectorAll('*')].filter(el => {
+    const style = getComputedStyle(el);
+    return el.scrollHeight - el.clientHeight > 240 && ['auto','scroll'].includes(style.overflowY);
+  });
+  nodes.sort((a, b) => (b.clientWidth * b.clientHeight) - (a.clientWidth * a.clientHeight));
+  const target = nodes[0];
+  if (!target) return false;
+  target.dataset.wulingConversationScroller = '1';
+  target.scrollTop = 0;
+  return true;
+}
+"""
+        )
+    )
+
+
+def scroll_history_to_start(page, max_messages: int, pacing: bool) -> Dict[str, Any]:
+    stable_rounds = 0
+    previous_signature = ""
+    estimated_count = 0
+    truncated = False
+    if not find_scroll_container(page):
+        return {"scroll_rounds": 0, "history_start_reached": False, "history_truncated": False}
+    rounds = 0
+    while stable_rounds < 3:
+        rounds += 1
+        raw = page.evaluate(EXTRACT_SCRIPT)
+        estimated_count = len(raw)
+        first = raw[0] if raw else {}
+        signature = clean(first.get("raw_id")) or clean(first.get("datetime")) or clean(first.get("text"))[:120]
+        stable_rounds = stable_rounds + 1 if signature == previous_signature else 0
+        previous_signature = signature
+        if estimated_count >= max_messages:
+            truncated = True
+            break
+        page.evaluate(
+            r"""
+() => {
+  const target = document.querySelector('[data-wuling-conversation-scroller="1"]');
+  if (target) target.scrollTop = 0;
+}
+"""
+        )
+        paced_wait("minor history expansion wait", MINOR_WAIT, pacing)
+        if rounds >= 200:
+            truncated = True
+            break
+    return {
+        "scroll_rounds": rounds,
+        "history_start_reached": stable_rounds >= 3,
+        "history_truncated": truncated,
+        "estimated_loaded_nodes": estimated_count,
+    }
+
+
+def collect_customer(page, customer: Dict[str, str], mode: str, max_messages: int, pacing: bool) -> Dict[str, Any]:
+    page.goto(customer["thread_url"], wait_until="domcontentloaded", timeout=90000)
+    paced_wait("technical thread readiness", TECHNICAL_WAIT, pacing)
+    marker = risk_text(page)
+    if marker:
+        return {"customer": customer, "status": "risk_stop", "risk_flags": [f"facebook_risk:{marker}"], "messages": []}
+    paced_wait("major new-thread wait", MAJOR_WAIT, pacing)
+    match = validate_thread(page, customer)
+    if not match["matched"]:
+        return {
+            "customer": customer,
+            "status": "thread_match_failed",
+            "thread_match": match,
+            "risk_flags": ["thread_match_failed"],
+            "messages": [],
+        }
+    history = {"scroll_rounds": 0, "history_start_reached": False, "history_truncated": False}
+    if mode == "initial_full":
+        history = scroll_history_to_start(page, max_messages, pacing)
+    raw_items = page.evaluate(EXTRACT_SCRIPT)
+    messages = normalize_messages(raw_items, customer["expected_identity"])[:max_messages]
+    if len(messages) >= max_messages:
+        history["history_truncated"] = True
+    return {
+        "customer": customer,
+        "status": "collected",
+        "thread_id": customer["expected_identity"],
+        "thread_match": match,
+        "history": history,
+        "risk_flags": ["history_truncated"] if history.get("history_truncated") else [],
+        "messages": messages,
+    }
+
+
+def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:
+    parser = argparse.ArgumentParser(description="Collect matched Facebook Messenger conversations via AdsPower + Playwright.")
+    mode = parser.add_mutually_exclusive_group()
+    mode.add_argument("--initial-full", action="store_true", help="Scroll each matched thread to the available history start.")
+    mode.add_argument("--incremental", action="store_true", help="Collect only records not already stored in Facebook对话记录 (default).")
+    parser.add_argument("--profile-id", required=True, help="AdsPower profile ID.")
+    parser.add_argument("--adspower-url", default="http://127.0.0.1:50325")
+    parser.add_argument("--api-key", default="")
+    parser.add_argument("--excel", default="")
+    parser.add_argument("--facebook-sheet", default=FACEBOOK_SHEET)
+    parser.add_argument("--conversation-sheet", default=CONVERSATION_SHEET)
+    parser.add_argument("--max-customers", type=int, default=0, help="0 means all matching Facebook rows.")
+    parser.add_argument("--max-messages-per-thread", type=int, default=DEFAULT_MAX_MESSAGES)
+    parser.add_argument("--output", default="")
+    parser.add_argument("--run-id", default="")
+    parser.add_argument("--no-pacing", action="store_true", help=argparse.SUPPRESS)
+    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("facebook_conversation_collect")
+    resolved = resolve_workbook_path(args.excel, create_from_template=False)
+    workbook = resolved.get("path")
+    if not workbook:
+        raise FileNotFoundError("No outreach workbook found. Pass --excel.")
+    workbook_path = Path(workbook)
+    customers = read_customers(workbook_path, args.facebook_sheet)
+    if args.max_customers > 0:
+        customers = customers[: args.max_customers]
+    existing_ids = read_existing_record_ids(workbook_path, args.conversation_sheet)
+    mode = "initial_full" if args.initial_full else "incremental"
+    pacing = not args.no_pacing
+    output_path = resolve_artifact_path(
+        args.output,
+        kind="facebook_conversations",
+        default_name="facebook_conversations_raw.json",
+        run_id=run_id,
+    )
+    client = AdsPowerClient(args.adspower_url, args.api_key)
+    threads: List[Dict[str, Any]] = []
+    stopped_for_risk = False
+    try:
+        browser = client.start_browser(args.profile_id)
+        if not browser.contexts:
+            raise RuntimeError("AdsPower browser has no Playwright context.")
+        context = browser.contexts[0]
+        page = context.pages[0] if context.pages else context.new_page()
+        for index, customer in enumerate(customers):
+            if index:
+                paced_wait("major customer-switch wait", MAJOR_WAIT, pacing)
+            print(f"[{index + 1}/{len(customers)}] {customer['company']}", flush=True)
+            result = collect_customer(page, customer, mode, args.max_messages_per_thread, pacing)
+            if mode == "incremental":
+                result["messages"] = [
+                    message for message in result.get("messages", []) if message.get("record_id") not in existing_ids
+                ]
+            threads.append(result)
+            if result.get("status") == "risk_stop":
+                stopped_for_risk = True
+                break
+    finally:
+        client.detach()
+    payload = {
+        "schema_version": "4.26",
+        "run_id": run_id,
+        "created_at": now_iso(),
+        "mode": mode,
+        "profile_id": args.profile_id,
+        "workbook": str(workbook_path),
+        "facebook_sheet": args.facebook_sheet,
+        "conversation_sheet": args.conversation_sheet,
+        "read_only": True,
+        "messages_sent": 0,
+        "browser_left_open": True,
+        "stopped_for_risk": stopped_for_risk,
+        "summary": {
+            "customers_selected": len(customers),
+            "threads_attempted": len(threads),
+            "threads_matched": sum(1 for thread in threads if thread.get("status") == "collected"),
+            "thread_match_failed": sum(1 for thread in threads if thread.get("status") == "thread_match_failed"),
+            "new_messages": sum(len(thread.get("messages", [])) for thread in threads),
+            "history_truncated": sum(1 for thread in threads if thread.get("history", {}).get("history_truncated")),
+        },
+        "threads": threads,
+        "agent_next_step": "Translate both directions to Chinese, analyze only effective customer replies, preview in chat, then run write_facebook_conversations.py with the validated analysis JSON.",
+    }
+    output_path.parent.mkdir(parents=True, exist_ok=True)
+    output_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
+    print(json.dumps({"output": str(output_path), **payload["summary"], "browser_left_open": True}, ensure_ascii=False, indent=2))
+    return 2 if stopped_for_risk else 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 551 - 0
scripts/social/write_facebook_conversations.py

@@ -0,0 +1,551 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""Validate translated Facebook conversations and write them to the workbook."""
+from __future__ import annotations
+
+import argparse
+import json
+import re
+import subprocess
+import sys
+from datetime import date, datetime, timedelta
+from pathlib import Path
+from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
+
+from openpyxl import load_workbook
+from openpyxl.styles import Font, PatternFill
+from openpyxl.utils import get_column_letter
+
+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 create_backup_once, new_run_id, project_root, resolve_artifact_path  # type: ignore  # noqa: E402
+from workbook_resolver import resolve_workbook_path  # type: ignore  # noqa: E402
+
+FACEBOOK_SHEET = "Facebook"
+CONVERSATION_SHEET = "Facebook对话记录"
+CONVERSATION_HEADERS = [
+    "记录ID", "客户序号", "客户姓名/公司", "Facebook主页链接", "Messenger线程ID",
+    "消息时间", "消息方向", "发件人", "原文语言", "对话原文", "中文翻译", "消息类型",
+    "是否有效客户回复", "合作意向", "意向判断依据", "下一步建议", "同步时间",
+    "来源账号/Profile ID", "风险标记",
+]
+INTENTS = ["明确有意向", "潜在意向", "需澄清", "暂不考虑", "明确拒绝"]
+STATUS_BY_INTENT = {
+    "明确有意向": "已回复,有合作意向",
+    "潜在意向": "已回复,待跟进",
+    "需澄清": "已回复,待澄清",
+    "暂不考虑": "已回复,暂不考虑",
+    "明确拒绝": "已回复,明确拒绝",
+}
+FOLLOWUP_DAYS = {
+    "明确有意向": ("business", 1),
+    "潜在意向": ("business", 3),
+    "需澄清": ("business", 2),
+    "暂不考虑": ("calendar", 30),
+    "明确拒绝": ("none", 0),
+}
+REPLY_STATUS_PHRASES = list(STATUS_BY_INTENT.values())
+NOTE_START = "【Facebook回复分析】"
+NOTE_END = "【/Facebook回复分析】"
+QUESTION_MARK_RE = re.compile(r"\?{3,}")
+CJK_RE = re.compile(r"[\u3400-\u9fff]")
+FACEBOOK_ALIASES = {
+    "index": ["序号", "编号", "ID"],
+    "company": ["客户姓名/公司", "公司名称", "公司姓名", "客户名称"],
+    "link": ["主页/链接", "Facebook主页链接", "Facebook链接", "facebook链接"],
+    "status": ["建联状态", "建联情况"],
+    "followup": ["下次跟进", "下次跟进时间"],
+    "note": ["备注", "说明"],
+}
+
+
+def clean(value: Any) -> str:
+    if value is None:
+        return ""
+    return re.sub(r"\s+", " ", str(value).strip())
+
+
+def now_text() -> str:
+    return datetime.now().astimezone().isoformat(timespec="seconds")
+
+
+def read_json(path: Path) -> Dict[str, Any]:
+    return json.loads(path.read_text(encoding="utf-8-sig"))
+
+
+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 normalized_link(value: str) -> str:
+    value = clean(value).casefold().split("?", 1)[0].rstrip("/")
+    return value.removeprefix("https://").removeprefix("http://").removeprefix("www.")
+
+
+def list_values(value: Any) -> List[str]:
+    if isinstance(value, list):
+        return [clean(item) for item in value if clean(item)]
+    return [part for part in re.split(r"[;;|]+", clean(value)) if part] if clean(value) else []
+
+
+def analysis_key(thread: Dict[str, Any]) -> Tuple[str, str, str]:
+    return (
+        clean(thread.get("customer_index")),
+        clean(thread.get("thread_id")),
+        normalized_link(clean(thread.get("facebook_link"))),
+    )
+
+
+def raw_thread_key(thread: Dict[str, Any]) -> Tuple[str, str, str]:
+    customer = thread.get("customer") or {}
+    return (
+        clean(customer.get("index")),
+        clean(thread.get("thread_id") or customer.get("expected_identity")),
+        normalized_link(clean(customer.get("facebook_link"))),
+    )
+
+
+def fallback_analysis_match(raw_thread: Dict[str, Any], candidates: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
+    raw_key = raw_thread_key(raw_thread)
+    for candidate in candidates:
+        candidate_key = analysis_key(candidate)
+        checks = [left and right and left == right for left, right in zip(raw_key, candidate_key)]
+        if sum(checks) >= 2:
+            return candidate
+    return None
+
+
+def chinese_or_same(original: str, translated: str) -> str:
+    original, translated = clean(original), clean(translated)
+    if translated:
+        return translated
+    if not original:
+        return ""
+    if CJK_RE.search(original):
+        return original
+    raise ValueError(f"Missing Chinese translation for message: {original[:80]}")
+
+
+def validate_intent(value: str) -> str:
+    value = clean(value)
+    if value not in INTENTS:
+        raise ValueError(f"Invalid cooperation intent: {value!r}. Expected one of {INTENTS}")
+    return value
+
+
+def parse_date(value: str) -> Optional[date]:
+    match = re.search(r"(20\d{2})[-/](\d{1,2})[-/](\d{1,2})", clean(value))
+    if not match:
+        return None
+    try:
+        return date(int(match.group(1)), int(match.group(2)), int(match.group(3)))
+    except ValueError:
+        return None
+
+
+def add_business_days(start: date, days: int) -> date:
+    result = start
+    while days > 0:
+        result += timedelta(days=1)
+        if result.weekday() < 5:
+            days -= 1
+    return result
+
+
+def default_followup(intent: str, latest_reply_at: str, explicit: str = "") -> str:
+    if clean(explicit):
+        parsed = parse_date(explicit)
+        if not parsed:
+            raise ValueError(f"Invalid next_followup date: {explicit!r}")
+        return parsed.isoformat()
+    kind, amount = FOLLOWUP_DAYS[intent]
+    if kind == "none":
+        return ""
+    start = parse_date(latest_reply_at) or date.today()
+    return (add_business_days(start, amount) if kind == "business" else start + timedelta(days=amount)).isoformat()
+
+
+def header_map(ws, aliases: Dict[str, List[str]]) -> 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, names in aliases.items():
+        for name in names:
+            if name in raw:
+                mapped[key] = raw[name]
+                break
+    return mapped
+
+
+def ensure_conversation_sheet(wb, sheet_name: str):
+    if sheet_name not in wb.sheetnames:
+        ws = wb.create_sheet(sheet_name)
+        ws.append(CONVERSATION_HEADERS)
+    else:
+        ws = wb[sheet_name]
+        existing = {clean(cell.value) for cell in ws[1] if clean(cell.value)}
+        for header in CONVERSATION_HEADERS:
+            if header not in existing:
+                ws.cell(row=1, column=ws.max_column + 1).value = header
+    header_fill = PatternFill(fill_type="solid", fgColor="D9EAF7")
+    for cell in ws[1]:
+        cell.font, cell.fill = Font(bold=True), header_fill
+    headers = {clean(cell.value): idx for idx, cell in enumerate(ws[1], start=1)}
+    widths = [38, 12, 28, 38, 24, 22, 12, 20, 12, 56, 56, 14, 18, 16, 52, 52, 22, 22, 32]
+    for header, width in zip(CONVERSATION_HEADERS, widths):
+        ws.column_dimensions[get_column_letter(headers[header])].width = width
+    ws.freeze_panes = "A2"
+    ws.auto_filter.ref = ws.dimensions
+    return ws
+
+
+def message_analysis_map(thread: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
+    return {clean(item.get("record_id")): item for item in thread.get("messages", []) if clean(item.get("record_id"))}
+
+
+def merge_threads(raw: Dict[str, Any], analysis: Dict[str, Any]) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[str]]:
+    analysis_threads = analysis.get("threads") or []
+    exact = {analysis_key(thread): thread for thread in analysis_threads}
+    output_rows: List[Dict[str, Any]] = []
+    customer_updates: List[Dict[str, Any]] = []
+    warnings: List[str] = []
+    sync_time = now_text()
+    for raw_thread in raw.get("threads") or []:
+        if raw_thread.get("status") != "collected":
+            warnings.append(f"{clean((raw_thread.get('customer') or {}).get('company'))}: {clean(raw_thread.get('status'))}")
+            continue
+        messages = raw_thread.get("messages") or []
+        if not messages:
+            continue
+        athread = exact.get(raw_thread_key(raw_thread)) or fallback_analysis_match(raw_thread, analysis_threads)
+        if not athread:
+            raise ValueError(f"Missing analysis thread for {raw_thread_key(raw_thread)}")
+        amap = message_analysis_map(athread)
+        customer = raw_thread.get("customer") or {}
+        effective_reply_ids: List[str] = []
+        for raw_message in messages:
+            record_id = clean(raw_message.get("record_id"))
+            if not record_id:
+                raise ValueError("Raw message is missing record_id")
+            analyzed = amap.get(record_id) or {}
+            direction = clean(raw_message.get("direction"))
+            kind = clean(raw_message.get("message_type")) or "文本"
+            original = clean(raw_message.get("original_text"))
+            translation = chinese_or_same(original, clean(analyzed.get("chinese_translation")))
+            original_language = clean(analyzed.get("original_language")) or ("zh" if CJK_RE.search(original) else "")
+            non_reply = direction != "客户回复" or kind in {"系统消息", "自动回复", "已读提示"}
+            effective = bool(analyzed.get("is_effective_customer_reply")) and not non_reply
+            intent = reason = action = ""
+            if effective:
+                intent = validate_intent(clean(analyzed.get("intent")))
+                reason, action = clean(analyzed.get("intent_reason")), clean(analyzed.get("next_action"))
+                if not reason or not action:
+                    raise ValueError(f"Effective reply {record_id} requires intent_reason and next_action")
+                effective_reply_ids.append(record_id)
+            risks = list_values(raw_message.get("risk_flags")) + list_values(analyzed.get("risk_flags"))
+            output_rows.append({
+                "记录ID": record_id,
+                "客户序号": clean(customer.get("index")),
+                "客户姓名/公司": clean(customer.get("company")),
+                "Facebook主页链接": clean(customer.get("facebook_link")),
+                "Messenger线程ID": clean(raw_thread.get("thread_id")),
+                "消息时间": clean(analyzed.get("message_time")) or clean(raw_message.get("message_time_raw")),
+                "消息方向": direction,
+                "发件人": clean(raw_message.get("sender")),
+                "原文语言": original_language,
+                "对话原文": original,
+                "中文翻译": translation,
+                "消息类型": kind,
+                "是否有效客户回复": "是" if effective else "否",
+                "合作意向": intent,
+                "意向判断依据": reason,
+                "下一步建议": action,
+                "同步时间": sync_time,
+                "来源账号/Profile ID": clean(raw.get("profile_id")),
+                "风险标记": ";".join(dict.fromkeys(risks)),
+            })
+        latest = athread.get("latest_analysis") or {}
+        if effective_reply_ids:
+            latest_record_id = clean(latest.get("latest_reply_record_id"))
+            if latest_record_id not in effective_reply_ids:
+                raise ValueError(f"latest_reply_record_id must reference an effective reply for {clean(customer.get('company'))}")
+            latest_intent = validate_intent(clean(latest.get("intent")))
+            summary = clean(latest.get("chinese_summary"))
+            reason, next_action = clean(latest.get("intent_reason")), clean(latest.get("next_action"))
+            latest_reply_at = clean(latest.get("latest_reply_at"))
+            if not all([summary, reason, next_action, latest_reply_at]):
+                raise ValueError(f"latest_analysis is incomplete for {clean(customer.get('company'))}")
+            customer_updates.append({
+                "index": clean(customer.get("index")),
+                "company": clean(customer.get("company")),
+                "facebook_link": clean(customer.get("facebook_link")),
+                "latest_reply_at": latest_reply_at,
+                "summary": summary,
+                "intent": latest_intent,
+                "intent_reason": reason,
+                "next_action": next_action,
+                "next_followup": default_followup(latest_intent, latest_reply_at, clean(latest.get("next_followup"))),
+                "latest_reply_record_id": latest_record_id,
+            })
+    return output_rows, customer_updates, warnings
+
+
+def append_or_update_conversations(ws, rows: Iterable[Dict[str, Any]]) -> Tuple[int, int]:
+    headers = {clean(cell.value): idx for idx, cell in enumerate(ws[1], start=1)}
+    existing = {
+        clean(ws.cell(row=row, column=headers["记录ID"]).value): row
+        for row in range(2, ws.max_row + 1)
+        if clean(ws.cell(row=row, column=headers["记录ID"]).value)
+    }
+    immutable = {"记录ID", "客户序号", "客户姓名/公司", "Facebook主页链接", "Messenger线程ID", "对话原文"}
+    added = updated = 0
+    for item in rows:
+        record_id = clean(item.get("记录ID"))
+        if record_id in existing:
+            target, changed = existing[record_id], False
+            for header in CONVERSATION_HEADERS:
+                if header in immutable:
+                    continue
+                value = item.get(header, "")
+                if value not in (None, "") and ws.cell(target, headers[header]).value != value:
+                    ws.cell(target, headers[header]).value = value
+                    changed = True
+            updated += int(changed)
+        else:
+            target = ws.max_row + 1
+            for header in CONVERSATION_HEADERS:
+                ws.cell(target, headers[header]).value = item.get(header, "")
+            existing[record_id] = target
+            added += 1
+    ws.auto_filter.ref = ws.dimensions
+    return added, updated
+
+
+def merge_status(existing: str, intent: str) -> str:
+    result = clean(existing)
+    for phrase in REPLY_STATUS_PHRASES:
+        result = result.replace(phrase, "")
+    result = re.sub(r"[,,;;|]+", ",", result).strip(", ")
+    latest = STATUS_BY_INTENT[intent]
+    return ",".join(dict.fromkeys([clean(part) for part in [*result.split(","), *latest.split(",")] if clean(part)]))
+
+
+def reply_note(update: Dict[str, Any]) -> str:
+    return (
+        f"{NOTE_START}最新回复时间:{update['latest_reply_at']};中文摘要:{update['summary']};"
+        f"合作意向:{update['intent']};判断依据:{update['intent_reason']};"
+        f"下一步建议:{update['next_action']}{NOTE_END}"
+    )
+
+
+def replace_reply_note(existing: str, block: str) -> str:
+    existing = clean(existing)
+    pattern = re.compile(re.escape(NOTE_START) + r".*?" + re.escape(NOTE_END))
+    return clean(pattern.sub(block, existing)) if pattern.search(existing) else clean(existing + (" | " if existing else "") + block)
+
+
+def update_facebook_rows(wb, sheet_name: str, updates: Sequence[Dict[str, Any]]) -> Tuple[int, List[str]]:
+    if sheet_name not in wb.sheetnames:
+        raise KeyError(f"Sheet not found: {sheet_name}")
+    ws = wb[sheet_name]
+    columns = header_map(ws, FACEBOOK_ALIASES)
+    required = {"index", "company", "link", "status", "followup", "note"}
+    if missing := sorted(required - set(columns)):
+        raise RuntimeError(f"Facebook sheet is missing columns: {', '.join(missing)}")
+    by_index = {clean(item.get("index")): item for item in updates if clean(item.get("index"))}
+    written, missing_customers, matched = 0, [], set()
+    for row in range(2, ws.max_row + 1):
+        row_index = clean(ws.cell(row, columns["index"]).value)
+        update = by_index.get(row_index)
+        if not update:
+            continue
+        if normalized_link(clean(ws.cell(row, columns["link"]).value)) != normalized_link(clean(update.get("facebook_link"))):
+            missing_customers.append(f"{update.get('company')}: Facebook link mismatch")
+            continue
+        ws.cell(row, columns["status"]).value = merge_status(clean(ws.cell(row, columns["status"]).value), update["intent"])
+        ws.cell(row, columns["followup"]).value = update["next_followup"]
+        existing_note = clean(ws.cell(row, columns["note"]).value)
+        ws.cell(row, columns["note"]).value = replace_reply_note(existing_note, reply_note(update))
+        matched.add(row_index)
+        written += 1
+    for update in updates:
+        if clean(update.get("index")) not in matched:
+            missing_customers.append(f"{update.get('company')}: customer row not matched")
+    return written, list(dict.fromkeys(missing_customers))
+
+
+def scan_question_marks(wb) -> Tuple[int, int]:
+    cells = note_rows = 0
+    for ws in wb.worksheets:
+        note_columns = {idx for idx, cell in enumerate(ws[1], start=1) if clean(cell.value) in {"备注", "说明"}}
+        bad_note_rows = set()
+        for row in ws.iter_rows():
+            for cell in row:
+                if isinstance(cell.value, str) and QUESTION_MARK_RE.search(cell.value):
+                    cells += 1
+                    if cell.column in note_columns:
+                        bad_note_rows.add(cell.row)
+        note_rows += len(bad_note_rows)
+    return cells, note_rows
+
+
+def ensure_generated_text_clean(rows: Sequence[Dict[str, Any]], updates: Sequence[Dict[str, Any]]) -> None:
+    for item in [*rows, *updates]:
+        for key, value in item.items():
+            if isinstance(value, str) and QUESTION_MARK_RE.search(value):
+                raise ValueError(f"Repeated question marks detected before write: field={key}")
+
+
+def run_child(command: List[str]) -> Dict[str, Any]:
+    completed = subprocess.run(command, text=True, encoding="utf-8", capture_output=True, check=True)
+    try:
+        return json.loads(completed.stdout)
+    except json.JSONDecodeError:
+        return {"stdout": completed.stdout.strip(), "stderr": completed.stderr.strip()}
+
+
+def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:
+    parser = argparse.ArgumentParser(description="Write translated Facebook conversations and intent analysis to Excel.")
+    parser.add_argument("--excel", default="")
+    parser.add_argument("--transcript", default="")
+    parser.add_argument("--analysis", default="")
+    parser.add_argument("--facebook-sheet", default=FACEBOOK_SHEET)
+    parser.add_argument("--conversation-sheet", default=CONVERSATION_SHEET)
+    parser.add_argument("--write-workbook", action="store_true")
+    parser.add_argument("--ensure-sheet-only", action="store_true")
+    parser.add_argument("--refresh-summary", action="store_true")
+    parser.add_argument("--refresh-dashboard", action="store_true")
+    parser.add_argument("--latest-dashboard-dir", default="dashboards/latest")
+    parser.add_argument("--run-id", default="")
+    parser.add_argument("--output", default="")
+    parser.add_argument("--no-backup", action="store_true")
+    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("facebook_conversation_write")
+    resolved = resolve_workbook_path(args.excel, create_from_template=bool(args.write_workbook))
+    workbook = resolved.get("path")
+    if not workbook:
+        raise FileNotFoundError("No outreach workbook found. Pass --excel.")
+    workbook_path = Path(workbook)
+    lock_path = workbook_path.with_name("~$" + workbook_path.name)
+    if args.write_workbook and lock_path.exists():
+        raise PermissionError(f"Workbook appears to be open: {lock_path}")
+
+    rows, updates, warnings = [], [], []
+    if not args.ensure_sheet_only:
+        if not args.transcript or not args.analysis:
+            raise ValueError("--transcript and --analysis are required unless --ensure-sheet-only is used")
+        rows, updates, warnings = merge_threads(read_json(Path(args.transcript)), read_json(Path(args.analysis)))
+        ensure_generated_text_clean(rows, updates)
+
+    report_path = resolve_artifact_path(
+        args.output, kind="facebook_conversation_write",
+        default_name="facebook_conversation_write_report.json", run_id=run_id,
+    )
+    report: Dict[str, Any] = {
+        "schema_version": "4.26",
+        "run_id": run_id,
+        "workbook": str(workbook_path),
+        "dry_run": not args.write_workbook,
+        "conversation_rows_ready": len(rows),
+        "customer_updates_ready": len(updates),
+        "intent_counts": {intent: sum(1 for item in updates if item.get("intent") == intent) for intent in INTENTS},
+        "high_intent_customers": [
+            {
+                "customer": item["company"], "intent": item["intent"], "summary": item["summary"],
+                "next_action": item["next_action"], "next_followup": item["next_followup"],
+            }
+            for item in updates if item.get("intent") in {"明确有意向", "潜在意向"}
+        ],
+        "warnings": warnings,
+    }
+    if not args.write_workbook:
+        write_json(report_path, report)
+        print(json.dumps({"report": str(report_path), **report}, ensure_ascii=False, indent=2))
+        return 0
+
+    backup_path = None if args.no_backup else create_backup_once(
+        workbook_path, purpose="facebook_conversations", run_id=run_id,
+    )
+    wb = load_workbook(workbook_path)
+    before_question_cells, before_question_notes = scan_question_marks(wb)
+    if before_question_cells:
+        raise ValueError(
+            f"Workbook already contains repeated-question-mark corruption: cells={before_question_cells}, "
+            f"note_rows={before_question_notes}"
+        )
+    conversation_ws = ensure_conversation_sheet(wb, args.conversation_sheet)
+    added, conversation_updated = append_or_update_conversations(conversation_ws, rows)
+    customer_rows_updated, missing_customers = (
+        update_facebook_rows(wb, args.facebook_sheet, updates) if updates else (0, [])
+    )
+    if scan_question_marks(wb)[0]:
+        raise ValueError("Repeated question marks detected before save")
+    wb.save(workbook_path)
+
+    summary_report, dashboard_report = {}, {}
+    refresh_summary = bool(args.refresh_summary or args.refresh_dashboard)
+    if refresh_summary:
+        summary_report = run_child([
+            sys.executable, str(SKILL_ROOT / "scripts" / "common" / "build_customer_summary.py"),
+            "--excel", str(workbook_path), "--write-summary", "--no-backup", "--run-id", run_id,
+        ])
+    if args.refresh_dashboard:
+        dashboard_report = run_child([
+            sys.executable, str(SKILL_ROOT / "scripts" / "dashboard" / "build_dashboard.py"),
+            "--excel", str(workbook_path), "--run-id", run_id,
+            "--latest-dir", args.latest_dashboard_dir,
+        ])
+
+    reopened = load_workbook(workbook_path, data_only=True, read_only=True)
+    question_mark_cells, question_mark_note_rows = scan_question_marks(reopened)
+    reopened.close()
+    report.update({
+        "dry_run": False,
+        "backup": str(backup_path) if backup_path else "",
+        "conversation_rows_added": added,
+        "conversation_rows_updated": conversation_updated,
+        "customer_rows_updated": customer_rows_updated,
+        "missing_customers": missing_customers,
+        "question_mark_cells": question_mark_cells,
+        "question_mark_note_rows": question_mark_note_rows,
+        "summary_refreshed": refresh_summary,
+        "summary_report": summary_report,
+        "dashboard_refreshed": bool(args.refresh_dashboard),
+        "dashboard_report": dashboard_report,
+    })
+    config_path = project_root(workbook_path.parent) / "feishu_sync_config.json"
+    if config_path.exists():
+        try:
+            config = read_json(config_path)
+        except Exception as exc:
+            config = {"enabled": False, "config_error": str(exc)}
+        report["feishu_sync"] = {
+            "config_found": True,
+            "enabled": bool(config.get("enabled")),
+            "sync_required": bool(config.get("enabled")),
+            "conversation_sheet_name": config.get("conversation_sheet_name", args.conversation_sheet),
+            "sync_conversation_sheet": config.get("sync_conversation_sheet", True),
+            "agent_action": "Use lark-sheets after local write; Python never stores Feishu credentials.",
+        }
+    else:
+        report["feishu_sync"] = {
+            "config_found": False,
+            "enabled": False,
+            "sync_required": False,
+            "message": "本地建联表已更新;未发现 feishu_sync_config.json,飞书同步未执行。",
+        }
+    write_json(report_path, report)
+    print(json.dumps({"report": str(report_path), **report}, ensure_ascii=False, indent=2))
+    return 3 if question_mark_cells or missing_customers else 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 61 - 0
tests/test_facebook_conversation_sync.py

@@ -0,0 +1,61 @@
+from __future__ import annotations
+
+import importlib.util
+import sys
+import unittest
+from pathlib import Path
+
+from openpyxl import Workbook
+
+ROOT = Path(__file__).resolve().parents[1]
+COMMON = ROOT / 'scripts' / 'common'
+sys.path.insert(0, str(COMMON))
+
+
+def load_module(name, relative_path):
+    spec = importlib.util.spec_from_file_location(name, ROOT / relative_path)
+    module = importlib.util.module_from_spec(spec)
+    sys.modules[name] = module
+    spec.loader.exec_module(module)
+    return module
+
+
+writer = load_module('facebook_conversation_writer', 'scripts/social/write_facebook_conversations.py')
+dashboard = load_module('customer_dashboard', 'scripts/dashboard/build_dashboard.py')
+
+
+class FacebookConversationSyncTests(unittest.TestCase):
+    def test_five_intents_and_translation_guard(self):
+        expected = {
+            '明确有意向': '已回复,有合作意向',
+            '潜在意向': '已回复,待跟进',
+            '需澄清': '已回复,待澄清',
+            '暂不考虑': '已回复,暂不考虑',
+            '明确拒绝': '已回复,明确拒绝',
+        }
+        for intent, status in expected.items():
+            self.assertEqual(writer.merge_status('已发送邮件', intent), f'已发送邮件,{status}')
+        self.assertEqual(writer.default_followup('明确有意向', '2026-08-07'), '2026-08-10')
+        self.assertEqual(writer.default_followup('明确拒绝', '2026-08-07'), '')
+        with self.assertRaises(ValueError):
+            writer.chinese_or_same('Bonjour', '')
+
+    def test_conversation_records_deduplicate(self):
+        wb = Workbook()
+        ws = writer.ensure_conversation_sheet(wb, writer.CONVERSATION_SHEET)
+        row = {header: '' for header in writer.CONVERSATION_HEADERS}
+        row.update({'记录ID': 'message-1', '客户序号': '1', '对话原文': 'Bonjour', '中文翻译': '您好'})
+        self.assertEqual(writer.append_or_update_conversations(ws, [row]), (1, 0))
+        self.assertEqual(writer.append_or_update_conversations(ws, [row]), (0, 0))
+        self.assertEqual(ws.max_row, 2)
+
+    def test_dashboard_counts_unique_customers(self):
+        rows = [
+            {'customer_index': '1', 'direction': '我方发送', 'effective': '否'},
+            {'customer_index': '1', 'direction': '客户回复', 'effective': '是', 'intent': '明确有意向', 'message_time': '2026-08-05', 'company': 'Atlas'},
+            {'customer_index': '1', 'direction': '客户回复', 'effective': '是', 'intent': '明确有意向', 'message_time': '2026-08-06', 'company': 'Atlas'},
+        ]
+        result = dashboard.build_conversation_insights(rows)
+        self.assertEqual(len(result['sent_customers']), 1)
+        self.assertEqual(len(result['replied_customers']), 1)
+        self.assertEqual(result['intent_counter']['明确有意向'], 1)