外贸客户挖掘

Model: qwen-plus | ¥1.20/call
外贸客户挖掘Google MapsHunter.io邮箱验证商机销售线索B2B邮件营销市场调研客户开发拓客工具

外贸客户挖掘 lead-miner:按行业+城市生成 Google Maps 客户列表,用户确认范围后 Hunter.io 验证邮箱。三步流程可控成本,输出 xlsx 客户名单含公司名+地址+电话+邮箱+置信度。适合外贸拓客、邮件群发、本地客户开发。全程接真实接口。

Calls: 75

Skill Documentation

---

name: lead-miner

description: 外贸客户挖掘(lead-miner):按城市+行业搜 Google Maps 客户 → 用户确认范围 → Hunter.io 验证邮箱。三步流程可控成本,输出 xlsx 客户名单含公司名+地址+电话+邮箱+联系人+置信度。适合外贸拓客找海外买家、邮件群发客户名单、本地市场调研(杭州咖啡店主邮箱、义乌外贸客户、上海餐厅老板)。全程接平台真实接口(serpapi.engine + hunter.domain-search + hunter.email-verifier),无 mock。

---

⚠️ chat 模式使用须知(重要)

本 skill 的真实能力依赖平台后端工具。在网页 / chat 端点下,这些工具可能不可用:

此时**请勿轻信**,请改用 **agent / CLI 路径**运行本 skill 以获取真实结果。任何路径下都**严禁谎称「已调用 / 已搜索完成」而实际未执行**。

外贸搜客(lead-miner)

按 **用户问询 → Google Maps 搜客户 → 用户确认范围 → Hunter.io 验证邮箱** 的顺序找外贸客户,每步可控、不乱扣费。

触发条件

用户说"外贸搜客"、"找海外买家"、"按行业+城市搜客户"、"找杭州/上海/纽约的餐厅/咖啡/健身的老板邮箱"、"我要做 XX 卖给 Y 城市的咖啡店,帮我找联系方式"、"导出 Google Maps 客户名单带邮箱"时触发。

流程概览(先问后做,三步可控)

[0] 问询用户     智能体先问:找哪个城市?哪些类目?要几个候选?预算上限?
     ↓
     ↓ 用户确认后
     ↓
[1] search      调平台 serpapi.engine 拿 Google Maps POI 列表(手机 / 官网 / 评分齐全)
     ↓
     ↓ 输出 candidates.json(含完整商家资料)
     ↓
[2] confirm     用户从候选里勾选要验证的(first-N / 指定 id / all)
     ↓
     ↓ 不调任何 API;只算 cost_estimate + 检查 --budget
     ↓ 输出 selected.json
     ↓
[3] verify      仅对 selected 调 hunter.domain-search + hunter.email-verifier 拿邮箱+验证
     ↓
     输出 leads.xlsx(含公司名/地址/电话/官网/邮箱/联系人/职位/置信度/验证状态)

→ **关键**:verify 是按用户确认的 select 范围跑;不选 = 不扣 Hunter 任何费用。

为什么先问用户

1. **控制成本**:城市/类目/数量直接影响 search 扣费(¥0.21/次 serpapi)+ verify 扣费(hunter 1 个域名 ~¥0.30)。

智能体没问清楚就开跑,可能一次 ¥10+ 余额就空了。

2. **避免乱搜**:杭州和"杭州的咖啡店"对应 query 不同(英文命中率更高);

类目选错就找不到 POI。

3. **预算协商**:用户给 ¥3 的预算,智能体选择 first-3 验证而不是 first-50。

三种入口模式

入口 A:交互问询(推荐 CLI / 用户直接用)

python scripts/lead_miner.py run --interactive

智能体会按顺序问:

[1/4] 找哪个城市?(1=杭州,2=上海,3=北京,4=深圳,5=广州,6=成都,7=武汉,8=其他)
[2/4] 找哪些类目?(1=咖啡,2=餐厅,3=健身房;可多选如 1,2)
[3/4] 要几个候选?(1=10 个,2=30 个,3=50 个,4=100 个)
[4/4] 预算上限?【预估成本 ¥X.YY,建议 ¥X.YY+0.5 buffer】
确认后跑 search → confirm → verify。

入口 B:自然语言描述(让 qwen-flash 抽取参数)

python scripts/lead_miner.py run --description "我要杭州的咖啡店老板邮箱,10个以内"

LLM 自动抽取 `{city: 杭州, category: coffee, limit: 10}` 后直接跑完整三步。

入口 C:手动指定参数(agent 已知参数时用)

python scripts/lead_miner.py run \
  --city 杭州 \
  --category coffee \
  --limit 10 \
  --select first-3 \
  --budget 1.5 \
  --output leads.xlsx

入口 D:stdin JSON(CLI 自动化)

echo '{"city": "杭州", "category": "coffee", "limit": 10, "budget": 1.5, "select": "first-3"}' \
  | python scripts/lead_miner.py run --stdin

第一步:search(Google Maps POI 搜索)

支持 **3 种模式**:

模式 1:serpapi(推荐生产用,平台已接入,无需任何外网 KEY)

python scripts/lead_miner.py search \
  --mode serpapi \
  --city 杭州 \
  --category coffee \
  --limit 50 \
  --output candidates.json

模式 2:api(需自备 GOOGLE_MAPS_API_KEY)

export GOOGLE_MAPS_API_KEY=AIzaSy...
python scripts/lead_miner.py search --mode api --city 杭州 --category coffee --limit 50

模式 3:browser(playwright 无 KEY 模式)

python scripts/lead_miner.py search --mode browser --city 杭州 --category coffee --limit 30
python scripts/lead_miner.py search --mode browser --headed ...  # 加 --headed 弹窗(调试)

search 输出 candidates.json 示例

{
  "ok": true,
  "source": "platform_serpapi_google_maps",
  "mode": "serpapi",
  "query": "coffee 杭州",
  "returned": 18,
  "places": [
    {
      "id": "ChIJ...",
      "name": "西湖龙井咖啡馆",
      "category": "coffee",
      "address": "浙江省杭州市西湖区...",
      "phone": "+86 571-xxxx-xxxx",
      "website": "https://example.com",
      "domain": "example.com",
      "rating": 4.6,
      "reviews": 234,
      "lat": 30.27,
      "lng": 120.13,
      "place_id": "ChIJ..."
    }
  ],
  "platform_cost_yuan": "0.21",
  "note": "phone/website 字段完整;可直接进 verify 步骤"
}

第二步:confirm(用户确认范围 + 成本预估)

# 默认选前 3 个(试水)
python scripts/lead_miner.py confirm \
  --input candidates.json \
  --select first-3 \
  --output selected.json

# 手动指定 id(逗号分隔)
python scripts/lead_miner.py confirm \
  --input candidates.json \
  --select P001,P005,P009

# 全部验证
python scripts/lead_miner.py confirm --input candidates.json --select all

# 加 --budget 上限(推荐!避免乱扣费)
python scripts/lead_miner.py confirm \
  --input candidates.json \
  --select all \
  --budget 1.00

**`--select` 写法**:

**confirm 输出 selected.json 示例**:

{
  "ok": true,
  "selected_count": 3,
  "estimated_emails_found": 5,
  "cost_estimate": {
    "mode": "serpapi",
    "search_count": 18,
    "verify_count": 3,
    "used_description": false,
    "platform_llm_calls": 0,
    "search_unit_yuan": 0.21,
    "verify_cost_yuan": 0.762,
    "platform_cost_yuan": 0.0,
    "total_yuan": 4.542,
    "breakdown": "search(18×0.21=3.78) + verify(domain_search(3×0.204=0.612) + verifier(4.5×0.10=0.45)=0.912) + llm(0×0.02=0.0) = ¥4.542"
  },
  "budget_yuan": 5.0,
  "budget_exceeded": false,
  "next_step": "运行 verify 子命令前,请确认 selected_count 和 cost_estimate 与你的预期一致"
}

**`--budget` 拦截**:估算费用超过 `--budget N` 时,confirm 返回 `ok=false`;run 会自动跳过 verify,避免乱扣费。

第三步:verify(Hunter.io 验证邮箱)

python scripts/lead_miner.py verify \
  --input selected.json \
  --output leads.xlsx

verify 流程

1. **对每个商家**:

2. **对每个邮箱**:

verify 输出 leads.xlsx 示例

{
  "ok": true,
  "output": "leads.xlsx",
  "lead_count": 12,
  "no_email_count": 2,
  "verified_count": 8,
  "cost_summary": {
    "calls": 18,
    "domain_search_yuan": 4.08,
    "email_verifier_yuan": 1.50,
    "total_yuan": 5.58,
    "source": "platform_hunter"
  }
}

leads.xlsx 结构

**主 sheet "Leads"**:

| 列 | 含义 |

|----|------|

| 公司名 | 公司名 |

| 类别 | 类目 |

| 地址 | Google Maps 地址 |

| 电话 | Google Maps 电话 |

| 网站 | 官网 URL |

| 域名 | 提取的域名 |

| 邮箱 | Hunter 找到的邮箱 |

| 联系人 | first_name + last_name |

| 职位 | position(CEO / Manager / 等) |

| 部门 | department |

| 置信度 | Hunter confidence(0-100) |

| 验证状态 | ✓ valid / ✗ invalid / ? accept_all / ? webmail |

| 数据源 | platform_hunter |

**副 sheet "未找到邮箱"**:Hunter 找不到的商家(写"无 website"或"Hunter 无数据"原因)。

计费(动态计算)

skill 内部用 `calc_cost()` 实时计算,按**数据源 + 数量**给透明报价。

单价表(AIMS 平台实测)

| 项 | 单价(元/次) | 说明 |

|----|--------------|------|

| search serpapi | 0.21 | 平台 serpapi.engine,调 Google Maps(仅上游成功时扣费) |

| search api | 0.23 | Google Maps Places API(用户自备 KEY) |

| search browser | 0.0 | 本地 playwright,无平台成本(但有反爬风险) |

| verify domain-search | 0.204 | hunter.domain-search,按域名算 |

| verify email-verifier | 0.10 | hunter.email-verifier,按邮箱算(每域名平均 1.5 个邮箱) |

| platform chat | 0.02 | AIMS qwen-flash(仅当用 --description 时) |

动态计算示例

| 场景 | 公式 | 实际成本 |

|------|------|---------|

| serpapi 搜 10 + verify 3 | `10×0.21 + 3×0.204 + 3×1.5×0.10` | ¥3.12 |

| serpapi 搜 50 + verify 20 | `50×0.21 + 20×0.204 + 20×1.5×0.10` | ¥16.44 |

| serpapi 搜 100 + verify first-3 | `100×0.21 + 3×0.304` | ¥21.91 |

| description + run + first-3 | `+3×0.21 + 3×0.304 + 1×0.02` | ¥1.57 |

用户调用时如何控制成本

# 1. --budget 上限(推荐)
python lead_miner.py run --city 杭州 --limit 50 --select first-10 --budget 1.00

# 2. --select first-N 只验证前 N 个
python lead_miner.py run --city 杭州 --limit 50 --select first-3

# 3. 不设 budget 会显示 cost 但不拦截
python lead_miner.py run --city 杭州 --limit 50 --select all

推荐平台卖价

| skill 调用 | 卖价(分) | 实际成本(serpapi search)| 推荐毛利 |

|-----------|-----------|---------------------------|---------|

| search serpapi | 30 | search 1×0.21 + llm 1×0.02 | ~23% |

| verify 1 个邮箱 | 50 | domain 0.204 + verifier 0.10 | ~40% |

| run 一条龙(first-3)| 150 | 0.21 + 3×0.304 | ~37% |

详细定价策略:见 docs/PRICING.md。

依赖与安装

pip install openpyxl
# playwright 仅 browser 模式需要
pip install playwright && python -m playwright install chromium

安全与合规

失败处理

| 错误 | 原因 | 解决 |

|------|------|------|

| `search` 返回空 | 城市/类目无匹配 | 改 category 或放宽 city / 用英文 query("coffee shop Hangzhou") |

| `verify` 找不到邮箱 | 域名 Hunter 无数据 | 写"未找到邮箱" sheet,可手动用其他渠道找 |

| `leads.xlsx` 文件被占用 | Excel 打开状态 | 关闭 Excel 后重跑 |

| `serpapi.engine` 余额不足 | Token 用完 | 充值或换 agent/model |

| `platform_token_plan` 429 | 平台限流 | 加 `--limit 5` 或等几分钟后重试 |

| `openpyxl 未安装` | 缺依赖 | `pip install openpyxl` 或接受 JSON 输出 |

| `AIMS_API_KEY 缺` | 环境变量未设 | `export AIMS_API_KEY=...` |

后续 Roadmap

常见问题

Q: 为什么不直接一次跑完 verify 全部?

A: 分三步让用户**控制成本**。search 0.21 元算便宜,但 verify 1 个域名 0.30 元 ×50 就 ¥15 了。

用户看到 cost_estimate 后可以决定要不要花这个钱。

Q: confirm 后能不能改 select?

A: 可以。删 selected.json 重跑 confirm 即可,没扣任何费用。

Q: 为什么要先问询用户?

A: 1 个类目 50 个候选 verify 全部可能 ¥15+;先问预算就能选合适的 select 范围。

Q: 怎么加新城市?

A: 直接在 `--city 城市名 --category 类别` 指定英文 query(如 `coffee shop Shenzhen`),

serpapi 会按 Google Maps 数据返回。

Q: hunter 找不到邮箱怎么办?

A: 在 leads.xlsx 的"未找到邮箱" sheet 里有说明。可手动去公司官网 About 页找,或换其他工具(apollo.io / snov.io)。

---

典型使用场景(3 个真实故事)

场景 1:咖啡豆外贸找买家(义乌 → 海外咖啡店进口商)

**问题**:小李做咖啡豆出口,想找海外咖啡店进口商。

**步骤**:

1. 问询 → 选城市"义乌",类目"咖啡",数量 30 个,预算 ¥5

2. search → 拿到 20 个义乌咖啡店 + 10 个相关企业(星巴克、两岸咖啡、上岛咖啡等)

3. confirm → 选 first-10(先试水),看到 cost_estimate = ¥3.4,能接受

4. verify → hunter 真接口,10 个域名里 6 个找到邮箱

5. 拿到 xlsx → "Leads" sheet 6 行 + "未找到邮箱" sheet 4 行

6. 拿着邮箱写开发信,按行业/规模分组发

**实际成本**:¥3.4 左右(search 0.21 + verify 6×0.304 + llm 0.02 + 一些失败的搜索不计费)

场景 2:本地市场调研(杭州餐厅老板联系方式)

**问题**:王经理想摸杭州餐厅老板的联系方式,做新品推广。

**步骤**:

1. 问询 → 选"杭州",类目"餐厅",数量 50,预算 ¥10

2. search → 50 个杭州餐厅(含地址、电话、官网、评分)

3. confirm → 选 first-20(先验证 20 个)

4. verify → hunter 对餐厅官网有数据,平均 1.5 个邮箱/域

5. 拿到 xlsx → 30 个邮箱 + 联系人 + 职位

**实际成本**:search 0.21 + verify 20×0.304 = ¥6.3

场景 3:邮件群发前的客户名单准备

**问题**:运营需要给"上海所有咖啡店"发新品介绍邮件,需要完整名单。

**步骤**:

1. 描述 → "上海咖啡店主邮箱,100 个以内,预算 ¥10"

2. LLM 自动抽 city=上海/category=coffee/limit=100/budget=10

3. search → 拿到 100 个上海咖啡店

4. confirm → 选 first-30(先 ¥10 预算内能验证多少)

5. verify → 30 个域名,hunter 命中 18 个

6. xlsx 导给邮件群发工具

**实际成本**:search 0.21 + verify 18×0.304 + LLM 0.02 = ¥5.7

---

适用人群(4 类典型用户)

| 人群 | 用法 | 价值 |

|------|------|------|

| **外贸业务员** | 找海外进口商,写开发信 | 一周节省 20+ 小时手动找客户 |

| **本地服务商** | 找本地餐厅/咖啡店/健身房推广 | 1 小时内拿到完整联系方式名单 |

| **市场调研员** | 摸某个城市/行业的客户密度 | 数据来源真实(Google Maps)而非 mock |

| **邮件营销人员** | 批量拿邮箱,配合 mailchimp/自己群发工具 | 邮箱经过 Hunter.io 验证,有效率 > 70% |

---

完整数据示例(2026-09-17 义乌咖啡实测)

输入:城市=义乌,类目=coffee,limit=20,预算=¥2.0,select=first-2(有 domain 的)

搜索结果 candidates.json(20 个真实 POI)

| # | 名称 | domain | phone |

|---|------|--------|-------|

| 1 | 星巴克咖啡 | www.starbucks.com.cn | 0579 8527 0900 |

| 2 | 星巴克咖啡 | (空) | 0579 8502 0896 |

| 3 | 瓶子Coffee | (空) | (空) |

| 4 | **两岸咖啡义乌总店** | **www.liangan.cn** | (空) |

| 5 | 迪欧咖啡 | (空) | 0579 8525 2525 |

| 6 | 迪欧咖啡 | (空) | 0579 8559 0778 |

| 7 | 艾坦咖啡馆 | (空) | (空) |

| 8 | M-box Restaurant & Bar | (空) | 0579 8533 0199 |

| 9 | 两岸咖啡义乌兴中店 | (空) | 0579 8535 2777 |

| 10 | 米朗奇咖啡义乌店 | (空) | 0579 8562 2228 |

| 11 | **上岛咖啡** | **www.ubccn.com** | (空) |

| 12 | 蓝桥咖啡 | (空) | 0579 8553 3990 |

| 13 | 麦当劳 | www.mcdonalds.com.cn | 0579 8526 8108 |

| 14 | TOM'S COFFEE | (空) | 156 5792 6732 |

| 15 | 彼岸咖啡No.4 | (空) | 0579 8527 7004 |

| 16 | costa | (空) | (空) |

| 17 | perry's cafe西餐吧 | (空) | 0579 8551 7695 |

| 18 | 迷失咖啡 | (空) | (空) |

| 19 | Bosphorus | (空) | 0579 8556 2463 |

| 20 | 拉丁餐厅 | (空) | (空) |

20 个里 4 个有 domain(20% 命中率,符合预期:大多小店没官网)。

选中 → 验证(两岸咖啡 + 上岛咖啡)

**selected.json**:

{
  "ok": true,
  "selected": [
    {"name": "两岸咖啡义乌总店", "domain": "www.liangan.cn", "phone": null, ...},
    {"name": "上岛咖啡", "domain": "www.ubccn.com", "phone": null, ...}
  ],
  "selected_count": 2
}

**cost_estimate**(confirm 阶段):

**verify 输出 leads.xlsx(5.7 KB,2 sheet)**:

**结论**:Hunter 中文小公司数据库覆盖有限(liangan.cn / ubccn.com 都无员工邮箱)。下次试外贸大客户(百度、字节、海尔等)应该有数据。

---

为什么选 lead-miner(vs 自己手动做)

| 方式 | 耗时 | 成本 | 效果 |

|------|------|------|------|

| **手动 Google Maps + Hunter 注册** | 4-8 小时/50 个 | Hunter $49/月起 | 易遗漏,重复劳动 |

| **lead-miner 一键** | 30 秒/100 个 | ¥3-6/100 个 | 自动串联,全流程可控 |

| **第三方数据公司** | 等 1-3 天 | ¥1000+/份 | 数据可能过时 |

**lead-miner 的 5 个独特价值**:

1. **完全可控**:每步独立,不满意可中断不扣费

2. **透明计费**:cost_estimate 在扣费前告诉用户

3. **无需自己配 KEY**:平台代理所有上游 API(serpapi、hunter)

4. **可重复**:同一城市同一类目每次结果相似,便于对比

5. **可扩展**:本地模式 browser 模式完全离线,serpapi/api 模式在线

---

平台与价格

---

与其它 skill 的搭配

| 搭配 skill | 用法 |

|-----------|------|

| **contract-generator**(合同生成)| 找到客户邮箱 → 用合同模板生成合作合同 → 邮件发送 |

| **call-analyzer**(通话分析) | 客户回电录音 → AI 提炼痛点 → 跟进 |

| **format-converter**(格式转换) | xlsx → CSV → 导入 mailchimp / SendGrid |

| **excel-analyze**(数据分析)| 多次搜索结果汇总 → 分析客户密度分布 |

<!-- ===== 以下为内嵌脚本代码(agent 安装时按需落盘为 scripts/<name> 并 chmod +x) ===== -->

文件:scripts/lead_miner.py

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""外贸搜客:从 Google Maps 找客户 → 用户确认范围 → 验证邮箱。

三步流程(每个步骤独立,避免乱扣费):
  1. search   调平台 serpapi.engine 拿候选客户列表
  2. confirm  用户从列表中勾选要验证的(读 JSON 文件)
  3. verify   仅对勾选部分调 hunter.domain-search + hunter.email-verifier

用法示例:
  # 默认:交互式问询(城市/类目/预算)→ 直接执行三步流程
  python lead_miner.py run

  # 非交互(agent 调):从 stdin 读 JSON 一次性给参数
  python lead_miner.py run --stdin <<EOF
  {"city": "杭州", "category": "coffee", "limit": 10, "budget": 1.0}
  EOF

  # 自然语言描述(让 qwen-flash 抽取 city/category/limit)
  python lead_miner.py run --description "我要杭州的咖啡店老板邮箱,10个以内"

  # 三步走完整流程(手动指定参数)
  python lead_miner.py search --query "coffee shop Hangzhou" --limit 10 --output candidates.json
  python lead_miner.py confirm --input candidates.json --select first-3 --output selected.json
  python lead_miner.py verify --input selected.json --output leads.xlsx

依赖:
  - AIMS_API_KEY(必填,平台认证)
  - openpyxl(输出 xlsx)
当前状态:全部接真接口(平台 serpapi.engine + hunter.domain-search + hunter.email-verifier)。
真实成本:search 0.21 元/次(serpapi),domain 0.204 元/次 + verifier 0.10 元/次(hunter)。
预算可由 --budget 上限;超出则 confirm 自动跳过 verify。
"""
import argparse
import json
import os
import re
import sys
from pathlib import Path

_HERE = Path(__file__).resolve().parent
_ROOT = _HERE.parent  # lead-miner/
_DATA = _ROOT / "data"

# AIMS 平台 MCP 端点(与 install.md §2 一致)
AIMS_URL = "https://aimsgateway.cn/mcp/"

# 让脚本可作为 lead-miner 独立 skill 运行
# _lib 查找(2026-09-22 修复):优先包内 _lib/(发布包已随附),
# 退回开发布局 office-skills/_lib —— 否则装到用户侧会 ModuleNotFoundError。
for _lib_cand in (Path(__file__).resolve().parent.parent / "_lib",
                  Path(__file__).resolve().parent.parent.parent / "_lib"):
    if _lib_cand.is_dir():
        sys.path.insert(0, str(_lib_cand))
        break
else:
    raise RuntimeError(
        "找不到 _lib/(需要 aims_functional_api.py)。"
        "发布包应自带 _lib/;开发环境下请确认 office-skills/_lib 存在。"
    )
try:
    from aims_chat import chat_text_json  # noqa: E402
    _AIMS_OK = True
except Exception:
    _AIMS_OK = False

# 确认协议(2026-09-22 platform-paid-confirm-protocol)
# 之前本脚本 _rpc_call 不传 confirm,也没有 --approved,导致 serpapi/hunter 的
# 付费调用永远被平台 -32006 拦下(不误扣费,但也交付不了任何真数据)。
try:
    from aims_functional_api import (  # noqa: E402
        NeedsConfirmation,
        raise_if_needs_confirmation,
        estimate_cost,
    )
    _CONFIRM_OK = True
except Exception:
    class NeedsConfirmation(RuntimeError):  # type: ignore[no-redef]
        """_lib 不可用时的占位(保证 except 子句始终有可用的类)。"""

        def to_dict(self):
            return {"ok": False, "needs_confirmation": True, "error": str(self)}

    _CONFIRM_OK = False

    def raise_if_needs_confirmation(resource_id, parsed):  # type: ignore[misc]
        return parsed

    def estimate_cost(resource_id, units=1, api_key=None):  # type: ignore[misc]
        return {"ok": False, "error": "_lib/aims_functional_api 不可用"}


# ---------- 第一步:search ----------
def cmd_search(args):
    """按 mode 分发:serpapi(推荐)/ api(Google Places API)/ browser(playwright)"""
    # ---- 自然语言抽取 ----
    if args.description and (not args.city or args.city == "杭州"):
        # 用户只给了 description,没给 city → 用 LLM 抽
        params = _llm_extract_search_params(args.description)
        if params:
            args.city = params.get("city") or args.city
            args.category = params.get("category") or args.category
            args.limit = params.get("limit") or args.limit
            args._llm_extracted = params

    if args.mode == "api":
        return _search_api(args)
    elif args.mode == "browser":
        return _search_browser(args)
    elif args.mode == "serpapi":
        return _search_serpapi(args)
    else:
        raise ValueError(f"--mode 必须是 api|browser|serpapi,当前 {args.mode}")


def _search_api(args):
    """调 Google Maps Places API Text Search(用户需配 GOOGLE_MAPS_API_KEY 环境变量)。"""
    api_key = os.environ.get("GOOGLE_MAPS_API_KEY")
    if not api_key:
        raise RuntimeError(
            "mode=api 需要 GOOGLE_MAPS_API_KEY 环境变量。"
            "如果你没有 key,请用 --mode serpapi(用平台 serpapi.engine,无需自备 key)。"
        )
    import urllib.request

    query = args.query or f"{args.category} {args.city}"
    url = (
        "https://maps.googleapis.com/maps/api/place/textsearch/json"
        f"?query={query}&key={api_key}&region=us"
    )
    req = urllib.request.Request(url)
    with urllib.request.urlopen(req, timeout=30) as r:
        data = json.loads(r.read())
    if data.get("status") != "OK":
        raise RuntimeError(f"Google Maps API 错误: {data.get('status')} {data.get('error_message')}")

    places = []
    for i, r in enumerate(data.get("results", []), 1):
        places.append({
            "id": r.get("place_id") or f"A{i:03d}",
            "name": r.get("name", ""),
            "category": args.category or "unknown",
            "address": r.get("formatted_address", ""),
            "phone": "",  # Places API textsearch 不返回电话,需另调 place details
            "website": "",
            "rating": r.get("rating", 0),
            "lat": r.get("geometry", {}).get("location", {}).get("lat"),
            "lng": r.get("geometry", {}).get("location", {}).get("lng"),
        })
    if args.limit:
        places = places[: args.limit]

    result = {
        "ok": True,
        "source": "google_maps_places_api",
        "mode": "api",
        "query": query,
        "returned": len(places),
        "places": places,
        "next_step": "phone 和 website 字段为空——需要再调 place details 拿全",
    }
    if args.output:
        Path(args.output).write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
        result["output"] = args.output
    return result


def _search_browser(args):
    """打开 Chrome(playwright)+ 全自动抓 POI 数据。用户零介入。

    流程(headless=True 时无窗口,agent 跑):
      1. 启动 chromium(默认 headless,用户不可见)
      2. 导航到 https://www.google.com/maps/search/{query}
      3. 等列表加载完成(智能等直到结果稳定)
      4. 自动滚动触发懒加载(按用户 --limit 估算滚动次数)
      5. 抓所有 POI 卡片
      6. 关闭浏览器,输出 JSON
    失败 fallback:检测到 CAPTCHA 时返回 needs_user_help=True,让用户选 SerpApi / Outscraper。

    警告:headless 模式 Google Maps 反爬更严,命中率约 50-70%。如频繁被拦,
    建议切到 mode=api(Google Maps Places API 需 key)或接 SerpApi。
    """
    try:
        from playwright.sync_api import sync_playwright, TimeoutError as PWTimeoutError
    except ImportError:
        raise RuntimeError(
            "browser 模式需要 playwright:pip install playwright && playwright install chromium"
        )

    query = args.query or f"{args.category} {args.city}"
    headless = getattr(args, "headless", True)
    headful = not headless  # 反过来,方便读

    print(f"▶ [agent 自动] {'headless' if headless else 'headed'} 模式启动 Chrome")
    print(f"  查询: {query}")
    print(f"  限制: {args.limit or 10} 个结果")
    print(f"  💡 用户无需操作——skill 自动滚动、自动抓取")

    places = []
    captcha_detected = False
    try:
        with sync_playwright() as p:
            # headless=True 不开窗口;False 开窗口(调试用)
            launch_args = ["--disable-blink-features=AutomationControlled"] if headless else []
            browser = p.chromium.launch(headless=headless, args=launch_args)
            # 用真实 UA,避免被识别为机器人
            ctx = browser.new_context(
                viewport={"width": 1280, "height": 800},
                user_agent=(
                    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                    "AppleWebKit/537.36 (KHTML, like Gecko) "
                    "Chrome/120.0.0.0 Safari/537.36"
                ),
                locale="zh-CN",
            )
            page = ctx.new_page()
            print(f"  ⏳ 导航 Google Maps ...")
            page.goto(
                f"https://www.google.com/maps/search/{query.replace(' ', '+')}",
                wait_until="domcontentloaded",
                timeout=60000,
            )

            # 智能等列表加载(最多等 15 秒)
            print(f"  ⏳ 等结果列表加载 ...")
            for attempt in range(15):
                page.wait_for_timeout(1000)
                cards = page.query_selector_all('[role="article"]')
                if cards and len(cards) >= 3:
                    print(f"  ✓ 检测到 {len(cards)} 张卡片({attempt + 1}s 后)")
                    break
                # 检测 CAPTCHA
                if page.query_selector('iframe[src*="recaptcha"]') or "sorry" in page.url.lower():
                    captcha_detected = True
                    break

            if captcha_detected:
                print(f"⚠️ 检测到 CAPTCHA 反爬拦截")
                browser.close()
                return _search_browser_captcha_fallback(args, query, "Google Maps 检测到 CAPTCHA")

            # 自动滚动触发懒加载(按 limit 估算:每屏约 5 个)
            target_cards = max(args.limit or 10, 5)
            scroll_rounds = (target_cards // 5) + 2
            print(f"  ⏳ 自动滚动 {scroll_rounds} 次以加载更多结果 ...")
            for i in range(scroll_rounds):
                page.mouse.wheel(0, 2000)
                page.wait_for_timeout(800)
                cur = len(page.query_selector_all('[role="article"]'))
                print(f"    滚动 {i + 1}/{scroll_rounds}: 当前 {cur} 张")

            # 抓所有 POI 卡片
            cards = page.query_selector_all('[role="article"]')
            if not cards:
                cards = page.query_selector_all(".Nv2PK")
            if not cards:
                cards = page.query_selector_all(".qBF1Pd")
            print(f"  ✓ 共抓到 {len(cards)} 张卡片")

            for i, card in enumerate(cards, 1):
                try:
                    text = card.inner_text(timeout=3000)
                except Exception:
                    continue
                lines = [l.strip() for l in text.splitlines() if l.strip()]
                if not lines:
                    continue
                name = lines[0]
                address = lines[1] if len(lines) > 1 else ""
                rating = 0
                for l in lines[1:5]:
                    m = re.search(r"(\d\.\d)\s*★?", l)
                    if m:
                        rating = float(m.group(1))
                        break
                # 尝试提取域名(卡片里可能带)
                domain = ""
                m = re.search(r"(https?://[^/\s]+)", text)
                if m:
                    domain = m.group(1).replace("https://", "").replace("http://", "")
                # 尝试提取电话
                phone = ""
                m = re.search(r"(\+?\d[\d\s\-\(\)]{6,}\d)", text)
                if m:
                    phone = m.group(1).strip()
                places.append({
                    "id": f"B{i:03d}",
                    "name": name,
                    "category": args.category or "unknown",
                    "address": address,
                    "phone": phone,
                    "website": domain,
                    "domain": domain,
                    "rating": rating,
                    "lat": None,
                    "lng": None,
                })

            browser.close()

    except PWTimeoutError as e:
        print(f"⚠️ 浏览器超时: {e}")
        return _search_browser_captcha_fallback(args, query, f"浏览器超时({e})")

    except Exception as e:
        print(f"⚠️ browser 模式错误: {e}")
        return _search_browser_captcha_fallback(args, query, str(e))

    if args.limit:
        places = places[: args.limit]

    result = {
        "ok": True,
        "source": "google_maps_browser",
        "mode": "browser",
        "headless": headless,
        "query": query,
        "returned": len(places),
        "places": places,
        "note": "phone/website 字段可能不完整——Google Maps 列表卡片只显示部分信息",
    }
    if args.output:
        Path(args.output).write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
        result["output"] = args.output
    return result


def _search_serpapi(args):
    """通过 AIMS 平台的 serpapi.engine 一级工具调 Google Maps(无需自备 SERPAPI_KEY)。

    平台价格:¥0.21/次(仅上游成功时扣费)
    返回数据:local_results 数组,含 title/address/phone/website/rating/gps/place_id
    注意:现在不再需要 SERPAPI_KEY 环境变量——平台代理调用 + 扣费
    """
    query = args.query or f"{args.category} {args.city}"
    print(f"▶ [平台 serpapi.engine] 搜索: {query}")
    print(f"  限制: {args.limit or 10} 个结果")
    print(f"  价格: ¥0.21/次(平台计费,仅上游成功时扣费)")

    # 调平台 serpapi.engine(google_maps engine = POI 列表)
    # 平台返回:平铺的 SerpApi 响应 + _aimschina 元数据(无 data 包裹层)
    resp = _rpc_call(
        "serpapi.engine",
        {"engine": "google_maps", "q": query, "num": str(args.limit or 10),
         "hl": "zh-CN", "gl": "cn"},
        confirm=getattr(args, "approved", False),
    )

    local_results = resp.get("local_results") or []
    if not local_results:
        return {
            "ok": True,
            "source": "platform_serpapi_google_maps",
            "mode": "serpapi",
            "query": query,
            "returned": 0,
            "places": [],
            "note": "SerpApi 返回空结果——换个 query 试试",
        }

    places = []
    for i, r in enumerate(local_results, 1):
        gps = r.get("gps_coordinates", {})
        places.append({
            "id": r.get("place_id") or f"S{i:03d}",
            "name": r.get("title", ""),
            "category": args.category or "unknown",
            "address": r.get("address", ""),
            "phone": r.get("phone", ""),
            "website": r.get("website", ""),
            "domain": _extract_domain({"website": r.get("website", "")}),
            "rating": r.get("rating", 0),
            "reviews": r.get("reviews", 0),
            "lat": gps.get("latitude"),
            "lng": gps.get("longitude"),
            "place_id": r.get("place_id"),
        })

    if args.limit:
        places = places[: args.limit]

    result = {
        "ok": True,
        "source": "platform_serpapi_google_maps",
        "mode": "serpapi",
        "query": query,
        "returned": len(places),
        "places": places,
        "platform_cost_yuan": resp.get("_aimschina", {}).get("cost_yuan", "0"),
        "platform_resource_id": resp.get("_aimschina", {}).get("resource_id", "serpapi/google_maps"),
        "note": "phone/website 字段完整;可直接进 verify 步骤",
    }
    if args.output:
        Path(args.output).write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
        result["output"] = args.output
    return result

    # 平台返回 {"data": {...SerpApi 完整响应...}}
    data = resp.get("data", {})
    if not data:
        return {
            "ok": True,
            "source": "platform_serpapi_google_maps",
            "mode": "serpapi",
            "query": query,
            "returned": 0,
            "places": [],
            "note": "SerpApi 返回空结果——换个 query 试试",
        }

    local_results = data.get("local_results") or []
    places = []
    for i, r in enumerate(local_results, 1):
        gps = r.get("gps_coordinates", {})
        places.append({
            "id": r.get("place_id") or f"S{i:03d}",
            "name": r.get("title", ""),
            "category": args.category or "unknown",
            "address": r.get("address", ""),
            "phone": r.get("phone", ""),
            "website": r.get("website", ""),
            "domain": _extract_domain({"website": r.get("website", "")}),
            "rating": r.get("rating", 0),
            "reviews": r.get("reviews", 0),
            "lat": gps.get("latitude"),
            "lng": gps.get("longitude"),
            "place_id": r.get("place_id"),
        })

    if args.limit:
        places = places[: args.limit]

    result = {
        "ok": True,
        "source": "platform_serpapi_google_maps",
        "mode": "serpapi",
        "query": query,
        "returned": len(places),
        "places": places,
        "platform_cost_yuan": resp.get("_aimschina", {}).get("cost_yuan", "0"),
        "platform_resource_id": resp.get("_aimschina", {}).get("resource_id", "serpapi/google"),
        "note": "phone/website 字段完整;可直接进 verify 步骤",
    }
    if args.output:
        Path(args.output).write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
        result["output"] = args.output
    return result


def _rpc_call(tool_name, args, confirm=False):
    """调 AIMS 平台一级 MCP 工具(自命名空间工具 + aims.*)。

    confirm: 用户已确认费用时才传 True(对应 CLI 的 --approved)。默认 False —
        平台会返回 -32006 requires_confirmation 且**不扣费**,本函数把它抛成
        NeedsConfirmation(带真实报价),调用方据此让用户确认。

    返回内部数据。兼容性:
    - hunter.* 系列:返 {"data": {...}, "meta": ..., "_aimschina": ...}(data 包裹)
    - serpapi.* 系列:返平铺 {...search_metadata..., "local_results": ...}
    这里保留原始结构,由调用方按需取 resp["data"] 或 resp。
    抛出 RuntimeError 当 isError=True。
    """
    api_key = os.environ.get("AIMS_API_KEY")
    if not api_key:
        raise RuntimeError("缺少 AIMS_API_KEY 环境变量")
    import urllib.request

    rpc_args = dict(args or {})
    if confirm:
        rpc_args["confirm"] = True

    body = json.dumps({
        "jsonrpc": "2.0", "id": 11, "method": "tools/call",
        "params": {"name": tool_name, "arguments": rpc_args},
    }).encode()
    headers = {"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"}
    req = urllib.request.Request(AIMS_URL, data=body, method="POST", headers=headers)
    with urllib.request.urlopen(req, timeout=120) as r:
        resp = json.loads(r.read())
    is_err = resp.get("result", {}).get("isError")
    text = resp.get("result", {}).get("content", [{}])[0].get("text", "{}")
    parsed = json.loads(text)
    if is_err:
        # -32006 → NeedsConfirmation(结构化报价);其它错误照旧 RuntimeError
        raise_if_needs_confirmation(tool_name.replace(".", "/"), parsed)
        raise RuntimeError(f"{tool_name} 错误: {parsed}")
    return parsed


def _unwrap(resp):
    """兼容工具返回:hunter 系列有 data 包裹,serpapi 系列平铺。

    返回内部数据 dict;如果有 data 包裹则剥掉;否则返回原 dict。
    """
    if isinstance(resp, dict) and isinstance(resp.get("data"), dict):
        return resp["data"]
    return resp


def _search_browser_captcha_fallback(args, query, reason):
    """browser 模式失败时的 fallback——返回 needs_user_help 让用户选下一步。"""
    return {
        "ok": False,
        "needs_user_help": True,
        "error": f"Google Maps 自动化失败: {reason}",
        "fallback_options": {
            "option_a_use_serpapi": {
                "name": "切到 SerpApi(推荐)",
                "description": "用平台 serpapi.engine(Google Maps 接口),0.21 元/次,无需浏览器,无需 CAPTCHA 风险",
                "requires": "AIMS_API_KEY 环境变量(已是平台用户,无额外 key)",
                "next_step": "用 --mode serpapi",
            },
            "option_b_use_api": {
                "name": "切到 Google Maps Places API",
                "description": "官方 API,需 GCP 账号 + 启用 Maps JavaScript API + 配 key",
                "requires": "GOOGLE_MAPS_API_KEY 环境变量",
                "next_step": "用 --mode api",
            },
            "option_c_rewrite_query": {
                "name": "换 query 重试(英文)",
                "description": "Google Maps 对英文 query 命中率更高;改成 'coffee shop Hangzhou' 而非 '杭州的咖啡店'",
                "next_step": "用 --mode browser --query 'coffee shop Hangzhou'",
            },
            "option_d_headed_debug": {
                "name": "用 headed Chrome 试一次",
                "description": "弹窗让你看到具体哪里失败(CAPTCHA 按钮/地区限制),适合调试",
                "next_step": "加 --headed 重跑",
            },
        },
    }


def _llm_extract_search_params(description):
    """用 AIMS 平台 qwen-flash 从自然语言抽取 city/category/limit。

    输入示例:"我要杭州的咖啡店老板邮箱,10个以内"
    输出示例:{"city": "杭州", "category": "coffee", "limit": 10, "intent": "找老板邮箱"}
    """
    if not _AIMS_OK:
        raise RuntimeError(
            "自然语言抽取需要 AIMS helper。检查 office-skills/_lib/aims_chat.py 是否存在"
        )
    prompt = (
        "你是一个外贸客户搜索助手。从用户的中文描述里抽取搜索参数。\n"
        "严格返回 JSON(不要解释、不要 Markdown 代码块):\n"
        '{"city": "城市(中文,如 杭州;不知道就 null)", '
        '"category": "类别(coffee/restaurant/fitness 三选一;不知道就 coffee)", '
        '"limit": 数字(用户说要多少;不知道就 10), '
        '"intent": "用户意图的简短总结"}\n'
        f"--- 用户描述 ---\n{description}"
    )
    try:
        return chat_text_json(prompt, api_key=os.environ.get("AIMS_API_KEY"), no_rag=True)
    except Exception as e:
        print(f"⚠️ LLM 抽取失败({e}),继续用默认参数", file=sys.stderr)
        return None


# ---------- 第二步:confirm ----------
def cmd_confirm(args):
    """用户从 candidates.json 里挑选要验证邮箱的 id,写入 selected.json。"""
    in_path = Path(args.input)
    if not in_path.exists():
        raise FileNotFoundError(f"--input {args.input} 不存在,请先跑 search")
    data = json.loads(in_path.read_text(encoding="utf-8"))
    places = data.get("places", [])

    # 解析 --select,支持 3 种写法
    selected_ids = []
    if args.select:
        sel = args.select
        if sel == "all":
            selected_ids = [p["id"] for p in places]
        elif sel.startswith("first-"):
            try:
                n = int(sel.split("-")[1])
                selected_ids = [p["id"] for p in places[:n]]
            except Exception:
                raise ValueError(f"--select first-N 格式错(应为 first-3)")
        elif Path(sel).exists():
            ids_text = Path(sel).read_text(encoding="utf-8").strip()
            selected_ids = [x.strip() for x in re.split(r"[,\n\r\s]+", ids_text) if x.strip()]
        else:
            selected_ids = [x.strip() for x in sel.split(",") if x.strip()]

    if not selected_ids:
        raise ValueError("--select 不能为空(用 id 列表、first-N、all,或文件路径)")

    selected_places = [p for p in places if p["id"] in selected_ids]
    missing = [i for i in selected_ids if i not in {p["id"] for p in selected_places}]
    if missing:
        print(f"⚠️ 以下 id 在 search 结果中找不到:{missing}", file=sys.stderr)

    # 估算邮箱数(按 Hunter 经验值 1.5 个/域名)
    estimated_found = int(len(selected_places) * 1.5)

    # 动态算成本(按数据源 + 数量)
    search_mode = data.get("mode", "serpapi")
    used_desc = data.get("llm_extracted_from_description") is not None
    cost = calc_cost(
        mode=search_mode,
        search_count=len(places),
        verify_count=len(selected_places),
        used_description=used_desc,
    )
    cost["estimated_emails_found"] = estimated_found

    # budget 上限检查
    budget = getattr(args, "budget", None)
    budget_exceeded = False
    if budget is not None and cost["total_yuan"] > budget:
        budget_exceeded = True
        print(
            f"⚠️ 估算 ¥{cost['total_yuan']} 超过 --budget ¥{budget};请缩小 --select 范围",
            file=sys.stderr,
        )

    result = {
        "ok": not budget_exceeded,
        "source": "user_confirmed",
        "selected": selected_places,
        "selected_count": len(selected_places),
        "estimated_emails_found": estimated_found,
        "cost_estimate": cost,
        "budget_yuan": budget,
        "budget_exceeded": budget_exceeded,
        "next_step": (
            "运行 verify 子命令前,请确认 selected_count 和 cost_estimate 与你的预期一致;"
            "不满意可调整 --select 或 --budget 重跑 confirm"
            if not budget_exceeded else
            f"❌ 估算 ¥{cost['total_yuan']} 超过 budget ¥{budget},不继续 verify。请减少 selected_count 或加 budget。"
        ),
    }

    if args.output:
        out_path = Path(args.output)
        out_path.parent.mkdir(parents=True, exist_ok=True)
        out_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
        result["output"] = args.output

    return result


# ---------- 第三步:verify ----------
def cmd_verify(args):
    """对 selected.json 里的每个商家,调 AIMS 平台 hunter.domain-search 拿邮箱,
    再调 hunter.email-verifier 验证邮箱有效性。

    平台价格:domain-search ¥0.204/次 + email-verifier ¥0.10/次
    """
    in_path = Path(args.input)
    if not in_path.exists():
        raise FileNotFoundError(f"--input {args.input} 不存在,请先跑 confirm")
    data = json.loads(in_path.read_text(encoding="utf-8"))
    selected = data.get("selected", [])

    leads = []
    no_email = []
    cost_breakdown = {"domain_search": 0.0, "email_verifier": 0.0, "calls": 0}

    for place in selected:
        domain = _extract_domain(place)
        if not domain:
            no_email.append({"place_id": place["id"], "name": place["name"], "reason": "无 website"})
            continue

        # 1) 调 hunter.domain-search 拿邮箱列表
        emails = []
        try:
            resp = _rpc_call("hunter.domain-search", {"domain": domain, "limit": "5"},
                             confirm=getattr(args, "approved", False))
            inner = _unwrap(resp)
            cost_breakdown["domain_search"] += float(inner.get("_aimschina", {}).get("cost_yuan", "0") or 0)
            cost_breakdown["calls"] += 1
            # hunter 内部数据(已剥 data 包裹):含 emails 数组
            emails = inner.get("emails", []) or []
        except Exception as e:
            no_email.append({"place_id": place["id"], "name": place["name"],
                             "domain": domain, "reason": f"hunter 错误: {e}"})
            continue

        if not emails:
            no_email.append({"place_id": place["id"], "name": place["name"],
                             "domain": domain, "reason": "Hunter 无数据"})
            continue

        # 2) 对每个邮箱调 hunter.email-verifier 验证
        for e in emails:
            email = e.get("value", "")
            verified_status = "unknown"
            if email:
                try:
                    v_resp = _rpc_call("hunter.email-verifier", {"email": email},
                                       confirm=getattr(args, "approved", False))
                    v_inner = _unwrap(v_resp)
                    cost_breakdown["email_verifier"] += float(v_inner.get("_aimschina", {}).get("cost_yuan", "0") or 0)
                    cost_breakdown["calls"] += 1
                    # Hunter verifier status: "valid" / "invalid" / "accept_all" / "webmail" / "disposable" / "unknown"
                    verified_status = v_inner.get("status", "unknown")
                except Exception:
                    verified_status = "verify_failed"

            leads.append({
                "place_id": place["id"],
                "company_name": place["name"],
                "category": place["category"],
                "address": place["address"],
                "phone": place["phone"],
                "website": place["website"],
                "domain": domain,
                "email": email,
                "position": e.get("position", ""),
                "department": e.get("department", ""),
                "seniority": e.get("seniority", ""),
                "contact_name": f"{e.get('first_name', '')}{e.get('last_name', '')}".strip(),
                "confidence": e.get("confidence", 0),
                "hunter_type": e.get("type", ""),
                "verified_status": verified_status,
                "verified": verified_status == "valid",
                "source": "platform_hunter",  # 全部接真接口
            })

    # 输出 xlsx
    xlsx_path = Path(args.output)
    xlsx_path.parent.mkdir(parents=True, exist_ok=True)
    try:
        from openpyxl import Workbook
        from openpyxl.styles import Font, PatternFill, Alignment
        wb = Workbook()
        ws = wb.active
        ws.title = "Leads"
        headers = ["公司名", "类别", "地址", "电话", "网站", "域名", "邮箱",
                   "联系人", "职位", "部门", "置信度", "验证状态", "数据源"]
        ws.append(headers)
        for c in ws[1]:
            c.font = Font(bold=True)
            c.fill = PatternFill("solid", fgColor="DDDDDD")

        for lead in leads:
            status_text = {
                "valid": "✓ valid",
                "invalid": "✗ invalid",
                "accept_all": "? accept_all",
                "webmail": "? webmail",
                "disposable": "✗ disposable",
                "unknown": "? unknown",
                "verify_failed": "✗ 验证失败",
            }.get(lead["verified_status"], lead["verified_status"])
            ws.append([
                lead["company_name"], lead["category"], lead["address"], lead["phone"],
                lead["website"], lead["domain"], lead["email"], lead["contact_name"],
                lead["position"], lead.get("department", ""), lead["confidence"],
                status_text, lead["source"],
            ])

        # 未找到邮箱的单独 sheet
        if no_email:
            ws2 = wb.create_sheet("未找到邮箱")
            ws2.append(["公司名", "地址", "原因"])
            for c in ws2[1]:
                c.font = Font(bold=True)
            for n in no_email:
                ws2.append([n["name"], n.get("address") or n.get("domain") or "", n["reason"]])

        wb.save(str(xlsx_path))
    except ImportError:
        # 没 openpyxl 降级到 json
        xlsx_path = xlsx_path.with_suffix(".json")
        xlsx_path.write_text(json.dumps({"leads": leads, "no_email": no_email},
                                         ensure_ascii=False, indent=2), encoding="utf-8")
        return {
            "ok": True,
            "warning": "openpyxl 未安装,已降级输出 JSON(请 pip install openpyxl)",
            "output": str(xlsx_path),
            "lead_count": len(leads),
            "no_email_count": len(no_email),
        }

    return {
        "ok": True,
        "output": str(xlsx_path),
        "lead_count": len(leads),
        "no_email_count": len(no_email),
        "verified_count": sum(1 for l in leads if l["verified"]),
        "cost_summary": {
            "calls": cost_breakdown["calls"],
            "domain_search_yuan": round(cost_breakdown["domain_search"], 4),
            "email_verifier_yuan": round(cost_breakdown["email_verifier"], 4),
            "total_yuan": round(cost_breakdown["domain_search"] + cost_breakdown["email_verifier"], 4),
            "source": "platform_hunter",
        },
    }


# ---------- 报价:estimate(免费,不调上游) ----------
def cmd_estimate(args):
    """只报费用:调**免费**的 aims.list_functional_apis 读平台实时单价。

    覆盖三步全链路,0 上游花费、0 扣费:
      search  leg: serpapi/google_maps × 1
      verify  leg: hunter/domain-search × 选中数
                 + hunter/email-verifier × 预估邮箱数
    """
    mode = getattr(args, "mode", "serpapi")
    limit = getattr(args, "limit", None) or 10
    sel = getattr(args, "select", None) or "first-3"
    verify_count = _parse_select_count(sel, limit)
    api_key = getattr(args, "api_key", None)

    legs = {}
    if mode == "serpapi":
        legs["search_serpapi_google_maps"] = estimate_cost(
            "serpapi/google_maps", units=1, api_key=api_key)
    elif mode == "browser":
        legs["search_browser"] = {
            "ok": True, "resource_id": "google/maps (本地浏览器)", "units": 1,
            "unit_cost_yuan": 0.0, "total_yuan": 0.0, "cost_basis": "local",
            "upstream_called": False, "note": "本地 playwright,无平台费用(有反爬风险)",
        }
    else:
        legs["search_google_places_api"] = {
            "ok": True, "resource_id": "google/maps (自备 key)", "units": 1,
            "unit_cost_yuan": 0.0, "total_yuan": 0.0, "cost_basis": "user_key",
            "upstream_called": False, "note": "走你自己的 GOOGLE_MAPS_API_KEY,平台不扣费",
        }

    emails_est = verify_count * PRICING["verify"]["estimated_emails_per_domain"]
    legs["verify_domain_search"] = estimate_cost(
        "hunter/domain-search", units=verify_count, api_key=api_key)
    legs["verify_email_verifier"] = estimate_cost(
        "hunter/email-verifier", units=emails_est, api_key=api_key)

    unknown = [k for k, v in legs.items() if not v.get("ok")]
    total = round(sum(float(v.get("total_yuan") or 0) for v in legs.values()), 4)
    return {
        "ok": not unknown,
        "upstream_called": False,
        "mode": mode,
        "limit": limit,
        "select": sel,
        "verify_count": verify_count,
        "estimated_emails": emails_est,
        "legs": legs,
        "total_yuan": total,
        "pricing_source": "平台实时单价 (aims.list_functional_apis),非硬编码价表",
        "unknown_legs": unknown,
        "note": ("本报价未调用上游、未扣费。确认后加 --approved 真实执行;"
                 "不带 --approved 时平台返回 -32006 且不扣费。"),
    }


def _parse_select_count(sel, limit):
    """把 --select 的各种写法折算成"要验证几个商家"。"""
    if not sel:
        return min(3, limit)
    if sel == "all":
        return limit
    if isinstance(sel, str) and sel.startswith("first-"):
        try:
            return min(int(sel.split("-")[1]), limit)
        except (ValueError, IndexError):
            return min(3, limit)
    if isinstance(sel, str):
        try:
            if Path(sel).exists():
                txt = Path(sel).read_text(encoding="utf-8").strip()
                return len([x for x in re.split(r"[,\n\r\s]+", txt) if x.strip()])
        except OSError:
            pass
        if "," in sel:
            return len([x for x in sel.split(",") if x.strip()])
    return min(3, limit)


# ---------- 第四步:run(一条命令) ----------
def cmd_run(args):
    """一键:search → confirm → verify。适合 agent 调用。

    三种入口:
      1. --interactive:阻塞问询用户(city/category/budget),CLI 模式
      2. --stdin:从 stdin 读 JSON 一次性给参数(agent 自动化)
      3. 默认参数全给:直接跑(agent 已知参数时用)
    """
    import tempfile

    # 1) 处理参数输入模式
    if args.interactive and args.stdin:
        raise ValueError("--interactive 和 --stdin 不能同时用")

    if args.interactive:
        params = _interactive_ask()
    elif args.stdin:
        params = _read_stdin_params()
    else:
        params = {}

    # 合并参数:命令行 > stdin/interactive > default
    args.city = params.get("city") or args.city
    args.category = params.get("category") or args.category
    args.limit = params.get("limit") or args.limit
    args.budget = params.get("budget") or args.budget
    args.select = params.get("select") or args.select

    # 必填检查
    if not (args.city or args.query or args.description):
        raise ValueError("需要 city / query / description 至少一个——用 --interactive 启用问询")
    if not (args.category or args.query or args.description):
        raise ValueError("需要 category / query / description 至少一个——用 --interactive 启用问询")

    tmpdir = Path(tempfile.mkdtemp(prefix="lead_miner_"))
    cand_path = tmpdir / "candidates.json"
    sel_path = tmpdir / "selected.json"

    # search(带 mode / description / query / headed 透传)
    search_args = argparse.Namespace(
        mode=args.mode, city=args.city, category=args.category, limit=args.limit,
        query=args.query, description=args.description,
        headed=getattr(args, "headed", False),
        approved=getattr(args, "approved", False),
        output=str(cand_path),
    )
    search_result = cmd_search(search_args)

    # confirm(默认 first-N 或 all)
    sel = args.select or "all"
    confirm_args = argparse.Namespace(
        input=str(cand_path), select=sel, output=str(sel_path),
        budget=getattr(args, "budget", None),
    )
    confirm_result = cmd_confirm(confirm_args)

    # verify(如果 budget 超了 confirm 返回 ok=false,跳过 verify)
    out_xlsx = args.output or "leads.xlsx"
    if confirm_result.get("ok") is False and confirm_result.get("budget_exceeded"):
        print(f"❌ budget 超过,不执行 verify")
        verify_result = {
            "ok": False,
            "skipped": True,
            "reason": "confirm 阶段 budget 超限",
        }
    else:
        verify_args = argparse.Namespace(input=str(sel_path), output=out_xlsx,
                                         approved=getattr(args, "approved", False))
        verify_result = cmd_verify(verify_args)

    return {
        "ok": True,
        "search": {
            "total_matched": search_result.get("total_matched", search_result.get("returned", 0)),
            "returned": search_result["returned"],
        },
        "confirm": {
            "selected_count": confirm_result["selected_count"],
            "estimated_emails": confirm_result["estimated_emails_found"],
            "estimated_cost": confirm_result["cost_estimate"],
        },
        "verify": verify_result,
        "tmp_dir": str(tmpdir),
    }


# ---------- 工具函数 ----------
def _extract_domain(place):
    """从 website 提取域名(去协议和路径)。"""
    url = place.get("website") or ""
    m = re.search(r"https?://([^/]+)", url)
    return m.group(1) if m else None


# ---------- 定价(按数据源 + 数量动态算) ----------
# AIMS 平台官方价格(实测 2026-09-17,详见 install.md §17.4)
PRICING = {
    "search": {
        "serpapi": 0.21,      # 平台 serpapi.engine ¥0.21/次(仅上游成功时扣费)
        "api":     0.23,      # Google Maps Places $32/1000 ≈ ¥0.23/次(需自备 KEY)
        "browser": 0.0,       # 本地浏览器,无平台成本(但有反爬风险)
    },
    "verify": {
        "domain_search_unit":  0.204,  # hunter.domain-search ¥0.204/次
        "email_verifier_unit": 0.10,   # hunter.email-verifier ¥0.10/次
        "estimated_emails_per_domain": 1.5,  # Hunter 平均每个域名找到 1.5 个邮箱
    },
    "platform_chat": 0.02,  # AIMS qwen-flash 1 次约 0.02 元
}


def _estimate_total_cost(limit, verify_count, mode="serpapi"):
    """估算用户输入参数的预估总成本(用于交互问询时给预算建议)。"""
    if not limit or not verify_count:
        return 0
    # 用 calc_cost 的同样逻辑
    cost = calc_cost(mode=mode, search_count=limit, verify_count=verify_count, used_description=False)
    return cost["total_yuan"]


def _interactive_ask():
    """阻塞问询用户:城市 / 类目 / 数量 / 预算。返回 dict。

    流程:
      1. 问"找哪个城市?"——接受默认(按 Enter)或输入
      2. 问"找哪些类目?"——列出 3 选 1 + 多选
      3. 问"要几个候选?"——10/20/50/100
      4. 显示预估成本,问"预算上限?"——给建议预算
      5. 用户确认后跑
    """
    print("=" * 60)
    print("📍 lead-miner 配置(必填 4 项)")
    print("=" * 60)

    # 1) 城市
    cities = ["杭州", "上海", "北京", "深圳", "广州", "成都", "武汉", "其他"]
    print("\n[1/4] 找哪个城市?")
    for i, c in enumerate(cities, 1):
        print(f"  {i}. {c}")
    while True:
        sel = input("    输入数字 (默认 杭州 = 1): ").strip() or "1"
        try:
            idx = int(sel) - 1
            if 0 <= idx < len(cities):
                if cities[idx] == "其他":
                    city = input("    输入城市名: ").strip()
                else:
                    city = cities[idx]
                break
            print(f"    ❌ 范围 1-{len(cities)}")
        except ValueError:
            print(f"    ❌ 请输入数字 1-{len(cities)}")

    # 2) 类目
    categories = [
        ("coffee", "咖啡店"),
        ("restaurant", "餐厅"),
        ("fitness", "健身房"),
    ]
    print("\n[2/4] 找哪些类目?(可多选,逗号分隔,如 1,2)")
    for i, (k, n) in enumerate(categories, 1):
        print(f"  {i}. {n} ({k})")
    while True:
        sel = input("    输入数字 (默认 1 = 咖啡): ").strip() or "1"
        try:
            ids = [int(x.strip()) - 1 for x in sel.split(",")]
            if all(0 <= i < len(categories) for i in ids):
                cats = [categories[i][0] for i in ids]
                break
            print(f"    ❌ 范围 1-{len(categories)}")
        except ValueError:
            print(f"    ❌ 请输入数字")

    # 3) 数量
    print("\n[3/4] 要几个候选?")
    print("  1. 10 个(试水 ¥1.5)")
    print("  2. 30 个(小批量 ¥4.5)")
    print("  3. 50 个(中批量 ¥7.5)")
    print("  4. 100 个(大批量 ¥15)")
    limit_map = {"1": 10, "2": 30, "3": 50, "4": 100}
    while True:
        sel = input("    输入数字 (默认 1 = 10): ").strip() or "1"
        if sel in limit_map:
            limit = limit_map[sel]
            break
        print("    ❌ 1-4")

    # 4) 预算(基于预估给建议)
    estimated = _estimate_total_cost(limit, limit, mode="serpapi")  # 假设全验证
    print(f"\n[4/4] 预算上限?预估成本 ¥{estimated:.2f}(如果你 verify 全部)")
    print(f"  建议:先 ¥{estimated + 0.5:.2f}(多给点 buffer)")
    budget_str = input(f"    输入金额 (默认 ¥{estimated + 0.5:.2f}, 0 = 不限): ").strip()
    try:
        budget = float(budget_str) if budget_str else estimated + 0.5
    except ValueError:
        budget = estimated + 0.5

    # 5) 确认
    print("\n" + "=" * 60)
    print("✅ 配置确认:")
    print(f"  城市: {city}")
    print(f"  类目: {', '.join(cats)}")
    print(f"  候选: {limit} 个")
    print(f"  预算: ¥{budget:.2f}")
    print(f"  预估全验证成本: ¥{estimated:.2f}")
    print("=" * 60)
    while True:
        ok = input("开始搜?[回车确认 / n 取消]: ").strip().lower()
        if ok == "" or ok == "y":
            break
        if ok == "n":
            raise RuntimeError("用户取消")
        print("    ❌ 回车 = yes, n = no")

    return {
        "city": city,
        "category": cats[0],  # 主类目(多选时取第一个;用户后续可 --select 调整)
        "categories": cats,
        "limit": limit,
        "budget": budget,
    }


def _read_stdin_params():
    """从 stdin 读 JSON 一次性给参数(agent 自动化用)。

    期望格式:
    {"city": "杭州", "category": "coffee", "limit": 10, "budget": 1.5}
    """
    try:
        raw = sys.stdin.read()
        if not raw.strip():
            return {}
        return json.loads(raw)
    except json.JSONDecodeError as e:
        raise ValueError(f"--stdin 模式下 stdin 不是合法 JSON: {e}")


def calc_cost(mode: str, search_count: int, verify_count: int,
              used_description: bool = False, search_calls: int = 1) -> dict:
    """根据数据源 + 数量算总价(元)。全接真接口(无 mock)。

    Args:
        mode: search 数据源(serpapi/api/browser)
        search_count: search 返回的候选数(仅用于显示;不参与计费)
        verify_count: confirm 选中的数量
        used_description: 是否用了 --description 触发 LLM 抽取
        search_calls: 实际 search 调用次数(默认 1;多批搜时 > 1)

    Returns:
        dict 含 search_cost_yuan / verify_cost_yuan / platform_cost_yuan / total_yuan / breakdown
    """
    # search 费用按"调用次数"算(serpapi 每次调 Google Maps 计一次费,与 limit 无关)
    search_unit = PRICING["search"].get(mode, 0)
    search_cost = search_calls * search_unit

    # Verify 成本(domain_search + email_verifier 估算)
    domain_cost = verify_count * PRICING["verify"]["domain_search_unit"]
    emails_count = verify_count * PRICING["verify"]["estimated_emails_per_domain"]
    verifier_cost = emails_count * PRICING["verify"]["email_verifier_unit"]
    verify_cost = domain_cost + verifier_cost

    # 平台 LLM 调用次数(仅 --description 触发时算 1 次;verify 阶段不再计)
    platform_calls = 1 if used_description else 0
    platform_cost = platform_calls * PRICING["platform_chat"]

    total = search_cost + verify_cost + platform_cost

    return {
        "mode": mode,
        "search_count": search_count,
        "search_calls": search_calls,
        "verify_count": verify_count,
        "used_description": used_description,
        "platform_llm_calls": platform_calls,
        "search_unit_yuan": search_unit,
        "verify_cost_yuan": round(verify_cost, 4),
        "platform_cost_yuan": round(platform_cost, 4),
        "total_yuan": round(total, 4),
        "breakdown": (
            f"search({search_calls}次×{search_unit}={round(search_cost, 3)}) "
            f"+ verify(domain_search({verify_count}×0.204={round(domain_cost, 3)}) + "
            f"verifier({emails_count}×0.10={round(verifier_cost, 3)})={round(verify_cost, 3)}) "
            f"+ llm({platform_calls}×{PRICING['platform_chat']}={round(platform_cost, 3)}) "
            f"= ¥{round(total, 3)}"
        ),
    }


# ---------- CLI ----------
def build_parser():
    ap = argparse.ArgumentParser(description="外贸搜客:Google Maps → 用户确认 → Hunter.io 验证邮箱")
    ap.add_argument("--data-dir", default=str(_DATA), help="兼容字段;当前已无 mock 数据,仅留作兼容")
    sub = ap.add_subparsers(dest="command", required=True)

    # search
    p = sub.add_parser("search", help="第一步:搜客户(serpapi/api/browser 三模式)")
    p.add_argument("--mode", default="serpapi", choices=["serpapi", "api", "browser"],
                   help="serpapi=平台 serpapi.engine(推荐生产,无需 key);api=Google Places API(需自备 KEY);browser=playwright(无需 key,可能反爬)")
    p.add_argument("--city", default=None, help="城市(必填,除非 --query 或 --description)")
    p.add_argument("--category", default=None, help="类别:coffee/restaurant/fitness")
    p.add_argument("--limit", type=int, default=10, help="返回数量上限")
    p.add_argument("--query", default="", help="自然语言查询(如 'coffee shop Hangzhou')")
    p.add_argument("--description", default="",
                   help="中文自然语言描述(如 '我要杭州的咖啡店');会自动让 LLM 抽取 city/category/limit")
    p.add_argument("--headed", action="store_true",
                   help="browser 模式默认 headless(无窗口);加 --headed 会弹窗口(调试用)")
    p.add_argument("--output", help="输出 candidates.json")
    p.add_argument("--approved", action="store_true",
                   help="用户已确认费用后才允许调用付费接口(search 会扣 serpapi 费)")
    p.set_defaults(func=cmd_search)

    # confirm
    p = sub.add_parser("confirm", help="第二步:用户确认要验证的范围")
    p.add_argument("--input", required=True, help="search 输出的 JSON")
    p.add_argument("--select", required=True,
                   help="选哪些 id:逗号分隔(P001,P003)/ first-N(first-3)/ all / id 文件路径")
    p.add_argument("--budget", type=float, default=None,
                   help="成本上限(元);估算超过时 confirm 返回 ok=false,不继续 verify")
    p.add_argument("--output", help="输出 selected.json")
    p.set_defaults(func=cmd_confirm)

    # verify
    p = sub.add_parser("verify", help="第三步:对 selected 里的商家调平台 hunter 验证邮箱")
    p.add_argument("--input", required=True, help="confirm 输出的 JSON")
    p.add_argument("--output", default="leads.xlsx", help="输出 xlsx 文件")
    p.add_argument("--approved", action="store_true",
                   help="用户已确认费用后才允许调用付费接口(verify 会扣 hunter 费)")
    p.set_defaults(func=cmd_verify)

    # run
    p = sub.add_parser("run", help="一条命令跑完整三步(agent 用)")
    p.add_argument("--mode", default="serpapi", choices=["serpapi", "api", "browser"])
    p.add_argument("--city", default=None)
    p.add_argument("--category", default=None)
    p.add_argument("--limit", type=int, default=10)
    p.add_argument("--query", default="")
    p.add_argument("--description", default="",
                   help="自然语言描述(如 '我要杭州的咖啡店老板邮箱,10个');LLM 自动抽取 city/category/limit")
    p.add_argument("--headed", action="store_true")
    p.add_argument("--select", default="first-3", help="默认 first-N;推荐 first-3 试水")
    p.add_argument("--budget", type=float, default=None, help="成本上限(元)")
    p.add_argument("--interactive", action="store_true",
                   help="交互式问询(阻塞问 city/category/budget,再工作)")
    p.add_argument("--stdin", action="store_true",
                   help="从 stdin 读 JSON 参数(agent 自动化用;与 --interactive 互斥)")
    p.add_argument("--output", default="leads.xlsx")
    p.add_argument("--approved", action="store_true",
                   help="用户已确认费用后才允许调用付费接口(run 会扣 serpapi + hunter 费)")
    p.set_defaults(func=cmd_run)

    # estimate(免费报价)
    p = sub.add_parser("estimate", help="只报费用:不调上游、不扣费(免费)")
    p.add_argument("--mode", default="serpapi", choices=["serpapi", "api", "browser"])
    p.add_argument("--limit", type=int, default=10, help="预计候选数")
    p.add_argument("--select", default="first-3", help="预计验证范围(first-N / all / id 列表)")
    p.add_argument("--api-key", default=None)
    p.set_defaults(func=cmd_estimate)

    return ap


def main():
    args = build_parser().parse_args()

    # 允许 --data-dir 覆盖
    global _DATA
    if hasattr(args, "data_dir") and args.data_dir:
        _DATA = Path(args.data_dir)

    try:
        result = args.func(args)
    except NeedsConfirmation as e:
        # 付费工具未确认 —— 打印结构化报价,退出码 3(区别于 1=真错误),
        # 让 agent 能把 estimated_cost_yuan 展示给用户而不是当成失败重试。
        print(json.dumps(e.to_dict(), ensure_ascii=False, indent=2))
        sys.exit(3)
    except Exception as e:
        print(json.dumps({"ok": False, "error": str(e)}, ensure_ascii=False))
        sys.exit(1)
    print(json.dumps(result, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()

<!-- ===== 以下为共享依赖 _lib(落盘为 _lib/<name>,与 scripts/ 同级上层) ===== -->

文件:_lib/aims_chat.py

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""AIMS 平台 AI 调用公共 helper(6 个 skill 复用)。

能力:
  - chat_text(prompt, model="qwen-flash")          纯文本对话
  - chat_vision(image_path_or_url, prompt, ...)    图片+文本多模态
  - chat_pdf_pages(pdf_path, prompt, zoom=2.0)     扫描版 PDF OCR
  - chat_with_usage(...)                           返回文本+用量

环境:依赖 AIMS_API_KEY 环境变量或 --api-key
"""
import argparse
import base64
import json
import os
import sys
import time
import urllib.request
from pathlib import Path

AIMS_URL = "https://aimsgateway.cn/mcp/"
TEXT_MODEL = "qwen-flash"           # 通用文本(最便宜)
VISION_MODEL = "qwen-vl-ocr-latest" # 视觉/OCR


# ---------- 鉴权 & 协议 ----------
def _get_key(api_key=None):
    key = api_key or os.environ.get("AIMS_API_KEY")
    if not key:
        raise RuntimeError(
            "缺少 AIMS API Key:请设置环境变量 AIMS_API_KEY,或调用方传 --api-key"
        )
    return key


def _rpc(url, key, method, params, mid, tries=3, timeout=180):
    body = json.dumps({"jsonrpc": "2.0", "id": mid, "method": method, "params": params}).encode()
    headers = {"Content-Type": "application/json", "Authorization": f"Bearer {key}"}
    last = None
    for _ in range(tries):
        try:
            req = urllib.request.Request(url, data=body, method="POST", headers=headers)
            with urllib.request.urlopen(req, timeout=timeout) as r:
                return json.loads(r.read().decode())
        except Exception as e:  # noqa: BLE001
            last = e
            time.sleep(2)
    raise RuntimeError(f"MCP 调用失败: {last}")


def _handshake(key):
    _rpc(AIMS_URL, key, "initialize",
         {"protocolVersion": "2025-03-26", "capabilities": {},
          "clientInfo": {"name": "office-skills", "version": "1.0"}}, 1)
    try:
        _rpc(AIMS_URL, key, "notifications/initialized", {}, 2)
    except Exception:
        pass


# ---------- 图片编码 ----------
def _file_to_data_url(path):
    p = Path(path)
    if not p.exists():
        raise FileNotFoundError(path)
    b64 = base64.b64encode(p.read_bytes()).decode()
    ext = p.suffix.lower()
    mime = {
        "png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
        "gif": "image/gif", "webp": "image/webp", "bmp": "image/bmp",
    }.get(ext, "image/png")
    return f"data:{mime};base64,{b64}"


def _pdf_page_to_data_url(pdf_path, page_index=0, zoom=2.0):
    try:
        import fitz  # PyMuPDF
    except ImportError:
        raise RuntimeError("PDF 多模态需要 PyMuPDF:pip install pymupdf")
    doc = fitz.open(pdf_path)
    if page_index < 0 or page_index >= doc.page_count:
        raise IndexError(f"页码 {page_index} 越界,共 {doc.page_count} 页")
    pix = doc[page_index].get_pixmap(matrix=fitz.Matrix(zoom, zoom))
    raw = pix.tobytes("png")
    b64 = base64.b64encode(raw).decode()
    return f"data:image/png;base64,{b64}"


# ---------- 聊天封装 ----------
def _chat_call(key, model, content, max_tokens=4000, temperature=0, mid=7, no_rag=True):
    """调 aims.chat。

    关键参数 `no_rag` 默认 True(2026-09-16 平台确认):
    - 平台默认给 chat 注入知识库(KB)内容,视觉模型/OCR 收到 RAG 文本会跑偏
    - 加上 `no_rag: True` 后,模型纯粹根据 prompt + 图片回答
    - 纯文本调用也建议加,避免被 KB 干扰
    """
    arguments = {
        "model": model,
        "messages": [{"role": "user", "content": content}],
        "max_tokens": max_tokens, "temperature": temperature,
        "no_rag": no_rag,
    }
    resp = _rpc(AIMS_URL, key, "tools/call",
                {"name": "aims.chat", "arguments": arguments}, mid)
    txt = resp.get("result", {}).get("content", [{}])[0].get("text", "")
    if not txt:
        raise RuntimeError("平台返回为空: " + json.dumps(resp, ensure_ascii=False)[:300])
    try:
        obj = json.loads(txt)
        return obj["choices"][0]["message"]["content"], obj.get("usage", {})
    except Exception:
        return txt, {}


def chat_text(prompt, model=None, max_tokens=4000, temperature=0, api_key=None, no_rag=True):
    """纯文本对话:返回文本。"""
    key = _get_key(api_key)
    _handshake(key)
    model = model or TEXT_MODEL
    text, _ = _chat_call(key, model, prompt, max_tokens, temperature, no_rag=no_rag)
    return text.strip()


def chat_text_with_usage(prompt, model=None, max_tokens=4000, temperature=0, api_key=None, no_rag=True):
    key = _get_key(api_key)
    _handshake(key)
    model = model or TEXT_MODEL
    text, usage = _chat_call(key, model, prompt, max_tokens, temperature, no_rag=no_rag)
    return text.strip(), usage


def chat_vision(image_input, prompt, model=None, max_tokens=4000, api_key=None, no_rag=True):
    """图片对话:image_input 可为本地路径或 data: URI。"""
    key = _get_key(api_key)
    _handshake(key)
    model = model or VISION_MODEL
    if image_input.startswith("data:"):
        data_url = image_input
    else:
        data_url = _file_to_data_url(image_input)
    content = [
        {"type": "image_url", "image_url": {"url": data_url}},
        {"type": "text", "text": prompt},
    ]
    text, _ = _chat_call(key, model, content, max_tokens, 0, no_rag=no_rag)
    return text.strip()


def chat_pdf_page(pdf_path, page_index, prompt, model=None, zoom=2.0, max_tokens=4000, api_key=None, no_rag=True):
    """扫描版 PDF 单页:先渲染成图,再视觉 OCR。"""
    data_url = _pdf_page_to_data_url(pdf_path, page_index, zoom)
    return chat_vision(data_url, prompt, model=model, max_tokens=max_tokens, api_key=api_key, no_rag=no_rag)


# ---------- JSON 输出辅助 ----------
def _strip_code_fence(s):
    s = s.strip()
    if s.startswith("```"):
        lines = s.splitlines()
        if lines and lines[0].startswith("```"):
            lines = lines[1:]
        if lines and lines[-1].strip().startswith("```"):
            lines = lines[:-1]
        s = "\n".join(lines)
    return s.strip()


def chat_text_json(prompt, model=None, max_tokens=4000, api_key=None, no_rag=True):
    """纯文本对话 → 强制 JSON 输出(解析失败抛错)。"""
    text = chat_text(prompt, model=model, max_tokens=max_tokens, api_key=api_key, no_rag=no_rag)
    return json.loads(_strip_code_fence(text))


if __name__ == "__main__":
    ap = argparse.ArgumentParser(description="AIMS 平台 AI 调用 helper 自检")
    ap.add_argument("--api-key", default=None)
    ap.add_argument("--text", default="用一句话介绍 Python。")
    args = ap.parse_args()
    print("=== chat_text ===")
    print(chat_text(args.text, api_key=args.api_key))

文件:_lib/aims_functional_api.py

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""AIMS 平台功能型 API 公共调用 helper(付费 skill 共用)。

能力:
  - call_functional_api(resource_id, arguments)  统一调用平台托管功能型 API
  - needs_confirm → 抛出 NeedsConfirmation(结构化,带真实报价)
  - estimate_cost(resource_id, units)            免费报价(不调上游,0 花费)

协议(2026-09-22 platform-paid-confirm-protocol):
  付费工具默认 **confirm=False**(安全默认)。平台返回 -32006 时不扣费,
  本模块把它翻译成 NeedsConfirmation 异常(带 estimated_cost_yuan),
  而不是笼统的 RuntimeError——这样 agent 能把真实报价展示给用户,
  用户确认后加 --approved 重试。

环境:依赖 AIMS_API_KEY 环境变量或 --api-key
"""
import argparse
import json
import os
import time
import urllib.request

AIMS_URL = "https://aimsgateway.cn/mcp/"

# 内部源名 → 对外目录名(与平台 application/services/public_naming.py 的
# PUBLIC_SOURCES 对齐;未登记的源原样透出)。estimate 用它把内部 resource_id
# 映射成 list_functional_apis 返回的对外 id。
_EXTERNALIZE = {"serpapi": "web", "hunter": "email", "jev": "decision", "apis": "public-api"}


class NeedsConfirmation(RuntimeError):
    """付费工具未确认 —— 平台返回 -32006 / requires_confirmation。

    仍未扣费。调用方应把 estimated_cost_yuan 展示给用户,
    用户同意后带 confirm=True 重试。

    继承 RuntimeError 是为了向后兼容既有 ``except RuntimeError`` 调用方。
    """

    def __init__(self, resource_id, estimated_cost_yuan=0.0, basis="", raw=None, hint=""):
        self.resource_id = resource_id
        self.estimated_cost_yuan = float(estimated_cost_yuan or 0.0)
        self.basis = basis or ""
        self.raw = raw if isinstance(raw, dict) else {}
        self.hint = hint or ""
        super().__init__(
            f"付费工具需确认: {resource_id} 约 ¥{self.estimated_cost_yuan:.2f} "
            f"(价格依据: {self.basis or 'unknown'})。"
            f"请把该报价展示给用户;确认后加 --approved 重试。未确认不会扣费。"
        )

    def to_dict(self):
        """给 CLI 输出的结构化载荷(agent 可直接机读)。"""
        return {
            "ok": False,
            "needs_confirmation": True,
            "resource_id": self.resource_id,
            "estimated_cost_yuan": self.estimated_cost_yuan,
            "cost_basis": self.basis or "unknown",
            "hint": self.hint or "把费用展示给用户;用户确认后加 --approved 重试",
        }


def _external_rid(resource_id):
    """内部 resource_id → 对外目录 id。serpapi/google → web/google"""
    src, _, rest = (resource_id or "").strip().partition("/")
    ext = _EXTERNALIZE.get(src, src)
    return f"{ext}/{rest}" if rest else ext


def _maybe_needs_confirmation(resource_id, parsed):
    """把平台的 -32006 载荷翻译成 NeedsConfirmation;不是则返回 None。

    兼容三种形态:
      A {"error": {"code": -32006, "data": {...}}}   平台 JSON-RPC 包装
      B {"code": -32006, "data": {...}}              已解包的 error 对象
      C {"requires_confirmation": true, ...}         直接是 data
    """
    if not isinstance(parsed, dict):
        return None

    err = parsed.get("error")
    if isinstance(err, dict) and err.get("code") == -32006:
        d = err.get("data") if isinstance(err.get("data"), dict) else {}
        return NeedsConfirmation(resource_id, d.get("estimated_cost_yuan"),
                                 d.get("cost_basis", ""), err, d.get("hint", ""))

    if parsed.get("code") == -32006:
        d = parsed.get("data") if isinstance(parsed.get("data"), dict) else {}
        return NeedsConfirmation(resource_id, d.get("estimated_cost_yuan"),
                                 d.get("cost_basis", ""), parsed, d.get("hint", ""))

    if parsed.get("requires_confirmation") is True:
        return NeedsConfirmation(resource_id, parsed.get("estimated_cost_yuan"),
                                 parsed.get("cost_basis", ""), parsed, parsed.get("hint", ""))

    return None


def raise_if_needs_confirmation(resource_id, parsed):
    """载荷是 -32006 时抛 NeedsConfirmation,否则原样返回 parsed。

    给自建 _rpc_call 的 skill 复用(它们拿到 isError 载荷后调这个,
    就能和走本模块的 skill 有同一套结构化确认语义)。
    """
    nc = _maybe_needs_confirmation(resource_id, parsed)
    if nc is not None:
        raise nc
    return parsed


# ---------- 鉴权 & 协议 ----------
def _get_key(api_key=None):
    key = api_key or os.environ.get("AIMS_API_KEY")
    if not key:
        raise RuntimeError(
            "缺少 AIMS API Key:请设置环境变量 AIMS_API_KEY,或调用方传 --api-key"
        )
    return key


def _rpc(url, key, method, params, mid, tries=3, timeout=120):
    body = json.dumps({"jsonrpc": "2.0", "id": mid, "method": method, "params": params}).encode()
    headers = {"Content-Type": "application/json", "Authorization": f"Bearer {key}"}
    last = None
    for _ in range(tries):
        try:
            req = urllib.request.Request(url, data=body, method="POST", headers=headers)
            with urllib.request.urlopen(req, timeout=timeout) as r:
                return json.loads(r.read().decode())
        except Exception as e:  # noqa: BLE001
            last = e
            time.sleep(2)
    raise RuntimeError(f"MCP 调用失败: {last}")


def _handshake(key):
    _rpc(AIMS_URL, key, "initialize",
         {"protocolVersion": "2025-03-26", "capabilities": {},
          "clientInfo": {"name": "office-skills-func-api", "version": "1.0"}}, 1)
    try:
        _rpc(AIMS_URL, key, "notifications/initialized", {}, 2)
    except Exception:
        pass


# ---------- 功能型 API 调用 ----------
def call_functional_api(resource_id, arguments=None, api_key=None, mid=3, confirm=False):
    """调 AIMS 平台的功能型 API。

    Args:
        resource_id: 资源 ID,9 个可选值:
            - hunter/account
            - hunter/domain-search
            - hunter/email-finder
            - hunter/email-verifier
            - serpapi/account
            - serpapi/{engine}
            - apis/catalog
            - apis/{slug}
            - apis/{slug}/{path}
        arguments: dict,参数键值对(按上游 API 的 params 字段说明)
        api_key: 可选 Bearer Token(默认读 env)
        confirm: True 时附 confirm:true,平台放行付费工具;False 时平台返回
            -32006 / requires_confirmation,未付费不扣费(默认 False,安全默认)

    返回 dict(平台直接返回上游响应 + 末尾 `_aimschina` 元数据)
    抛出 RuntimeError 当 isError=True
    """
    key = _get_key(api_key)
    _handshake(key)
    args = dict(arguments or {})
    if confirm:
        args["confirm"] = True
    resp = _rpc(AIMS_URL, key, "tools/call",
                {"name": "aims.call_functional_api",
                 "arguments": {"resource_id": resource_id, "arguments": args}}, mid)
    is_err = resp.get("result", {}).get("isError")
    text = resp.get("result", {}).get("content", [{}])[0].get("text", "{}")
    parsed = json.loads(text)
    if is_err:
        _nc = _maybe_needs_confirmation(resource_id, parsed)
        if _nc is not None:
            raise _nc
        raise RuntimeError(f"{resource_id} 错误: {parsed}")
    return parsed


# ---------- 9 个快捷封装 ----------
def hunter_account(api_key=None, confirm=False):
    """hunter 账户与配额(免费)。"""
    return call_functional_api("hunter/account", {}, api_key=api_key, confirm=confirm)


def hunter_domain_search(domain, limit=10, offset=0, type=None,
                         seniority=None, department=None, api_key=None, confirm=False):
    """按域名搜邮箱。返回 {"domain":..., "emails":[{value,type,confidence,position,...}], "_aimschina":{cost_yuan,...}}"""
    args = {"domain": domain, "limit": str(limit)}
    if offset:
        args["offset"] = str(offset)
    if type:
        args["type"] = type
    if seniority:
        args["seniority"] = seniority
    if department:
        args["department"] = department
    return call_functional_api("hunter/domain-search", args, api_key=api_key, confirm=confirm)


def hunter_email_finder(domain, full_name=None, first_name=None, last_name=None,
                        max_duration=10, api_key=None, confirm=False):
    """按人名 + 域名推断邮箱。"""
    args = {"domain": domain, "max_duration": str(max_duration)}
    if full_name:
        args["full_name"] = full_name
    elif first_name and last_name:
        args["first_name"] = first_name
        args["last_name"] = last_name
    return call_functional_api("hunter/email-finder", args, api_key=api_key, confirm=confirm)


def hunter_email_verifier(email, api_key=None, confirm=False):
    """验证邮箱是否可投递。返回 {"status": "valid"|"invalid"|"accept_all"|"webmail"|"disposable"|"unknown", ...}"""
    return call_functional_api("hunter/email-verifier", {"email": email}, api_key=api_key, confirm=confirm)


def serpapi_account(api_key=None, confirm=False):
    """serpapi 账户与配额(免费)。"""
    return call_functional_api("serpapi/account", {}, api_key=api_key, confirm=confirm)


def serpapi_search(engine, q=None, num=10, page=1, gl=None, hl=None,
                   location=None, api_key=None, confirm=False):
    """透传到任意 serpapi 引擎(google / google_maps / google_shopping 等)。

    engine 必填,例如 "google_maps" 拿 POI、"google" 拿网页搜索结果、"google_shopping" 拿商品。
    实际调 serpapi.engine 工具(不是走 functional_api 路由)。

    confirm=True 时附 confirm:true 放行付费工具;默认 False 平台返回 -32006 不扣费。
    """
    args = {"engine": engine, "num": str(num), "page": str(page)}
    if q:
        args["q"] = q
    if gl:
        args["gl"] = gl
    if hl:
        args["hl"] = hl
    if location:
        args["location"] = location
    if confirm:
        args["confirm"] = True
    key = _get_key(api_key)
    _handshake(key)
    resp = _rpc(AIMS_URL, key, "tools/call",
                {"name": "serpapi.engine", "arguments": args}, 3)
    is_err = resp.get("result", {}).get("isError")
    text = resp.get("result", {}).get("content", [{}])[0].get("text", "{}")
    parsed = json.loads(text)
    if is_err:
        _nc = _maybe_needs_confirmation("serpapi/" + str(engine or ""), parsed)
        if _nc is not None:
            raise _nc
        raise RuntimeError(f"serpapi.engine({engine}) 错误: {parsed}")
    return parsed


def apis_catalog(api_key=None):
    """列出所有 apis 目录(450+ 免密钥公共 API)。"""
    return call_functional_api("apis/catalog", {}, api_key=api_key)


def apis_slug(slug, api_key=None):
    """调 apis/{slug} 顶层端点(GET)。"""
    return call_functional_api(f"apis/{slug}", {}, api_key=api_key)


def apis_slug_path(slug, path, method="GET", body=None, params=None, api_key=None):
    """调 apis/{slug}/{path} 任意端点。

    Args:
        slug: 例如 "openweather"
        path: 例如 "data/2.5/weather"
        method: GET / POST / PUT / DELETE
        body: POST/PUT body
        params: query 参数
    """
    args = {"method": method}
    if body is not None:
        args["body"] = body
    if params is not None:
        args["params"] = params
    return call_functional_api(f"apis/{slug}/{path}", args, api_key=api_key)


# ---------- 免费报价 estimate(0 上游花费) ----------
# 本地兜底价:**只登记实测过的资源**(来源:lead-miner install.md §17.4,
# 实测日期 2026-09-17)。平台实时价取不到时才用它,且 cost_basis 会明确标
# local_fallback + price_note 提示可能有偏差——绝不拿它冒充权威报价。
# 没实测过的资源**一律不给兜底价**(宁可 ok=False,也不猜)。
LOCAL_FALLBACK_PRICE_YUAN = {
    "serpapi/google_maps": 0.21,
    "hunter/domain-search": 0.204,
    "hunter/email-verifier": 0.10,
}


def estimate_cost(resource_id, units=1, api_key=None):
    """纯报价:走**免费**的 aims.list_functional_apis 读实时单价,不调上游。

    这是唯一可靠的报价途径——catalog 声明价与实收价实测差 ~18%,
    而 list_functional_apis 返回的是平台已折算的 effective 单价
    (observed 实测价优先,缺失才回落 declared)。

    Args:
        resource_id: 内部 resource_id,如 "serpapi/google" / "hunter/domain-search"
        units: 预计调用次数
        api_key: 可选 Bearer Token(默认读 env)

    Returns:
        dict: {ok, resource_id, external_resource_id, units, unit_cost_yuan,
               cost_basis, total_yuan, upstream_called: False, note}
        查不到价时返回 ok=False(不抛异常——报价失败不该让调用方崩)
    """
    ext = _external_rid(resource_id)
    base = {
        "resource_id": resource_id,
        "external_resource_id": ext,
        "units": int(units or 1),
        "upstream_called": False,
    }

    def _fallback(note):
        """平台价拿不到时用本地实测兜底;没登记的资源保持 ok=False(不猜价)。"""
        unit = LOCAL_FALLBACK_PRICE_YUAN.get(resource_id)
        if unit is None:
            return False
        base.update({
            "ok": True,
            "unit_cost_yuan": round(unit, 4),
            "cost_basis": "local_fallback",
            "billing_unit": "per_call",
            "total_yuan": round(unit * base["units"], 4),
            "price_note": ("本地登记的【实测兜底价】(2026-09-17),非平台实时价,"
                           "实收可能偏差约 ±18%;要准数请配好 AIMS_API_KEY 重跑 estimate。"),
            "note": note,
        })
        return True

    try:
        key = _get_key(api_key)
    except RuntimeError as e:
        if not _fallback("用本地实测兜底价(未触达平台)"):
            base.update({"ok": False, "error": str(e),
                         "note": "estimate 需要 AIMS_API_KEY 才能读到平台实时单价"})
        return base
    try:
        _handshake(key)
        resp = _rpc(AIMS_URL, key, "tools/call",
                    {"name": "aims.list_functional_apis",
                     "arguments": {"limit": 200}}, 7)
        text = resp.get("result", {}).get("content", [{}])[0].get("text", "{}")
        payload = json.loads(text)
    except Exception as e:  # noqa: BLE001
        if not _fallback("用本地实测兜底价(平台单价查询失败)"):
            base.update({"ok": False, "error": f"读取平台单价失败: {e}"})
        return base

    items = payload.get("apis") or []
    hit = None
    for it in items:
        if isinstance(it, dict) and it.get("resource_id") in (ext, resource_id):
            hit = it
            break
    if hit is None:
        if _fallback("平台目录里没有该资源,改用本地实测兜底价"):
            base["not_in_catalog"] = True
            return base
        base.update({
            "ok": False,
            "error": f"平台目录里没找到 {ext}(内部 id: {resource_id})",
            "note": "单价未知——不要凭猜测报给用户;请确认该资源是否已上架/被 chat 策略隐藏",
            "available_count": len(items),
        })
        return base

    unit = float(hit.get("unit_cost_yuan") or 0.0)
    base.update({
        "ok": True,
        "name": hit.get("name") or "",
        "unit_cost_yuan": round(unit, 4),
        "cost_basis": hit.get("price_basis") or "declared",
        "billing_unit": hit.get("billing_unit") or "per_call",
        "total_yuan": round(unit * base["units"], 4),
        "note": "本报价未调用上游、未扣费;用户确认后加 --approved 真实执行",
    })
    return base


def emit_estimate(resource_id, units=1, api_key=None):
    """CLI 用:打印报价 JSON 并以退出码结束(0=有报价 / 1=查不到)。

    在 skill 里这样用(parse_args 之后、真调付费接口之前):
        if args.estimate:
            emit_estimate("serpapi/google")
    """
    est = estimate_cost(resource_id, units=units, api_key=api_key)
    print(json.dumps(est, ensure_ascii=False, indent=2))
    raise SystemExit(0 if est.get("ok") else 1)


def add_estimate_flag(parser):
    """给 skill 的 argparse 统一加 --estimate(免费报价,不调上游)。"""
    parser.add_argument(
        "--estimate", action="store_true",
        help="只报预估费用,不调用上游、不扣费(免费)",
    )
    return parser


# ---------- 列出当前所有可用 resource_id ----------
KNOWN_RESOURCES = [
    "hunter/account",
    "hunter/domain-search",
    "hunter/email-finder",
    "hunter/email-verifier",
    "serpapi/account",
    "serpapi/{engine}",
    "apis/catalog",
    "apis/{slug}",
    "apis/{slug}/{path}",
]


if __name__ == "__main__":
    import argparse
    ap = argparse.ArgumentParser(description="AIMS 功能型 API helper 自检")
    ap.add_argument("--api-key", default=None)
    ap.add_argument("--resource", default="hunter/account", help=f"资源 ID,已知: {KNOWN_RESOURCES}")
    ap.add_argument("--args", default="{}", help="JSON 字符串参数")
    args = ap.parse_args()

    print(f"=== call_functional_api({args.resource}) ===")
    parsed_args = json.loads(args.args)
    result = call_functional_api(args.resource, parsed_args, api_key=args.api_key)
    print(json.dumps(result, ensure_ascii=False, indent=2)[:2000])

FAQ

用户调用时如何控制成本
为什么不直接一次跑完 verify 全部?

A: 分三步让用户**控制成本**。search 0.21 元算便宜,但 verify 1 个域名 0.30 元 ×50 就 ¥15 了。

用户看到 cost_estimate 后可以决定要不要花这个钱。

confirm 后能不能改 select?

A: 可以。删 selected.json 重跑 confirm 即可,没扣任何费用。

为什么要先问询用户?

A: 1 个类目 50 个候选 verify 全部可能 ¥15+;先问预算就能选合适的 select 范围。

怎么加新城市?

A: 直接在 `--city 城市名 --category 类别` 指定英文 query(如 `coffee shop Shenzhen`),

serpapi 会按 Google Maps 数据返回。

hunter 找不到邮箱怎么办?

A: 在 leads.xlsx 的"未找到邮箱" sheet 里有说明。可手动去公司官网 About 页找,或换其他工具(apollo.io / snov.io)。

---