外贸客户挖掘增强版

Model: qwen-plus | ¥0.20/call
外贸客户挖掘Google MapsHunter.io费用预估预算透明用户确认B2B拓客工具销售线索邮件营销

外贸客户挖掘增强版 lead-miner-pro:先与客户沟通需求 → 预估费用 → 调 Google Maps + Hunter.io 验证邮箱,输出 xlsx 客户名单。比基础版 lead-miner 强调预算透明、强制费用预估、用户确认后才执行。适合谨慎型客户、大金额场景、销售/商务代理、新手首次尝试。支持统一处理流程。

Calls: 17

Skill Documentation

---

name: lead-miner-pro

description: 外贸客户挖掘(lead-miner-pro)增强版:先免费预估费用 → 等用户明确确认 → 再调用 Google Maps + Hunter.io 验证邮箱,输出 xlsx 客户名单。脚本分步 + agent 编排,结构性护栏保证"未确认绝不花一分钱",适合需要预算透明、怕被乱扣费的外贸拓客、本地市场调研、邮件群发场景。

---

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

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

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

> 费用预估必须基于脚本 `estimate` 子命令的真实返回,**禁止编造金额**。

外贸客户挖掘增强版(lead-miner-pro)

**核心承诺:用户完全确认之前,本 skill 不调用任何付费接口(一分钱都不花)。**

本 skill 采用 **「脚本分步 + agent 编排」** 架构:所有付费动作(Google Maps 搜索、Hunter 邮箱验证)都被代码层 `--approved` 开关锁死,**只有拿到用户明确确认后,agent 才传 `--approved` 放行**。即使 LLM 自己"想"提前搜,脚本也会直接拒绝——成本可控是结构性的,不靠 LLM 自律。

触发条件

用户说"外贸搜客"、"找海外买家"、"按行业+城市搜客户"、"先告诉我多少钱"、"先预估一下费用"、"再开始搜"、"怕被乱扣费"时触发。**当用户要求"先告知价格"或"先沟通需求"时**,优先使用本 skill。

核心机制:结构性护栏(重点)

脚本 `scripts/lead_miner_pro.py` 在每一处付费调用前都强制检查 `--approved`:

| 子命令 | 是否付费 | 无 `--approved` 时 |

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

| `estimate` | 否(纯本地计算) | 正常返回预估,不调任何接口 |

| `search` | 是(serpapi ¥0.255/次) | **拒绝**,提示先 estimate |

| `verify` | 是(hunter ¥0.204+¥0.10/个) | **拒绝**,提示先确认范围 |

| `run` | 是(全链路) | **拒绝**,提示 estimate→确认→`run --approved` |

| `confirm` | 否(读文件算成本) | 正常,写入 selected.json |

**agent 的硬责任**:**绝不允许凭自己的判断传 `--approved`**。只有当终端用户明确打字确认(如"确认执行"/"同意"/"go"+ 看到预估金额)后,才能在下一步命令加 `--approved`。

标准流程(agent 严格按此 4 步走)

[0] estimate(免费)     agent 调 estimate → 把 cost_estimate 展示给用户
        ↓
[1] 等用户确认(必须)    agent 问"预估总费用 ¥X.XX,确认执行吗?";用户打字确认才继续
        ↓  ← 没这一步,后面全是拒绝
[2] search --approved    拿到候选 POI(candidates.json)
        ↓
[3] confirm(免费)       agent 让用户选范围(first-N / 具体 id),展示二次成本
        ↓  (可选:再要一次确认)
[4] verify --approved    仅对确认范围调 hunter 拿邮箱+验证 → leads.xlsx

各步命令(agent 调用示例)

# 第 0 步:免费预估,不花一分钱
python scripts/lead_miner_pro.py estimate --city 杭州 --category coffee --limit 10 --verify_count 3

# 第 2 步:用户确认后,才加 --approved 搜
python scripts/lead_miner_pro.py search --city 杭州 --category coffee --limit 2 --approved --output candidates.json

# 第 3 步:选范围(免费)
python scripts/lead_miner_pro.py confirm --input candidates.json --select first-3 --output selected.json

# 第 4 步:用户再次确认后,才加 --approved 验证
python scripts/lead_miner_pro.py verify --input selected.json --approved --output leads.xlsx

# 一键全链路(同样必须 --approved,且已获用户确认):
python scripts/lead_miner_pro.py run --city 杭州 --category coffee --limit 2 --select first-3 --approved --output leads.xlsx

> **CLI 人机模式**:终端用户也可直接 `--interactive`(脚本内联逐步问询,输 y 才执行)。但 **agent 模式禁止用 `--interactive`**(会阻塞),必须走"estimate→确认→`--approved`"显式编排。

硬规则(agent 禁止事项)

1. **禁止**在用户看到预估费用前传 `--approved`。

2. **禁止**跳过 confirm 直接 verify(verify 只认 selected.json,且需 `--approved`)。

3. **禁止**把 `--approved` 当成默认值写进模板——它必须是"用户确认"这一动作之后的显式标记。

4. **禁止**自己编预估费用——`estimate` 的 `cost_estimate` 才是真实值(之前 chat 模式乱报 ¥198/¥180 已废弃)。

真实费用(来自 `estimate` / `calc_cost`,非估算)

| 项 | 单价(元) | 公式 |

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

| search serpapi | 0.21 | 1 次搜索(与 limit 无关) |

| verify domain-search | 0.204 | N × 0.204(N=验证数量) |

| verify email-verifier | 0.10 | N × 1.5 × 0.10(每域约 1.5 邮箱) |

典型场景(用户侧看到的"本 skill 卖价"另算,见下):

| 场景 | 上游总成本 | 说明 |

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

| 只 search 不要邮箱 | ¥0.21 | 拿 POI 名单 |

| search + verify first-3 | ¥1.27 | 拿邮箱+验证 |

| search + verify first-10 | ¥3.54 | 大批量 |

**谁付钱(已实测澄清)**:终端用户调用本 skill 时,平台在其会话内注入用户自己的 key,**用户既付 skill 卖价(¥0.20),也付上游(serpapi/hunter)**。开发者(发布者)不承担上游成本、不亏本。所以"用户完全确认才工作"既保护用户预算,也符合平台计费模型。

与基础版 lead-miner 的区别

| 维度 | lead-miner | lead-miner-pro |

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

| 计费模型 | 卖价 ¥1.20/次 | 卖价 ¥0.20/次(让利) |

| 成本控制 | 靠 agent 自觉 | **代码层 `--approved` 硬锁**,未确认零花费 |

| 流程 | 标准 3 步 | 强制 estimate→确认→执行 |

| 适用 | 熟手大批量 | 谨慎型 / 新手 / 怕乱扣费 |

适用人群与场景(SEO / GEO)

安全与合规

失败处理

| 错误 | 原因 | 解决 |

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

| "缺少 --approved" | agent 未等确认就调付费命令 | 回到 estimate,先让用户确认 |

| 余额不足 | 用户平台余额不够 | 提示用户充值 |

| hunter 找不到邮箱 | 小公司数据库覆盖差 | 写"未找到邮箱"sheet,可手动查 |

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

文件:scripts/lead_miner_pro.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


# ---------- 第一步:search ----------
def cmd_search(args):
    """按 mode 分发:serpapi(推荐)/ api(Google Places API)/ browser(playwright)"""
    # ===== 硬护栏:未获用户明确确认,绝不调用任何搜索/外部接口(含 LLM 抽取)=====
    if not getattr(args, "approved", False):
        raise RuntimeError(
            "⛔ 未获用户明确确认(缺少 --approved),拒绝调用搜索接口。\n"
            "正确流程:先用 `estimate` 向用户展示预估费用 → 用户确认 → 再加 --approved 执行。"
        )
    # ---- 自然语言抽取 ----
    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 包裹层)
    # 用户已 --approved 时把 confirm:true 一起带上,平台付费工具确认机制放行
    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.*)。

    返回内部数据。兼容性:
    - hunter.* 系列:返 {"data": {...}, "meta": ..., "_aimschina": ...}(data 包裹)
    - serpapi.* 系列:返平铺 {...search_metadata..., "local_results": ...}
    这里保留原始结构,由调用方按需取 resp["data"] 或 resp。
    抛出 RuntimeError 当 isError=True。

    confirm=True 时,把 args 里加 `confirm: true`(平台付费工具用户确认机制)。
    """
    api_key = os.environ.get("AIMS_API_KEY")
    if not api_key:
        raise RuntimeError("缺少 AIMS_API_KEY 环境变量")
    import urllib.request
    if confirm and isinstance(args, dict):
        args = dict(args)  # 拷贝,避免污染调用方
        args["confirm"] = True
    body = json.dumps({
        "jsonrpc": "2.0", "id": 11, "method": "tools/call",
        "params": {"name": tool_name, "arguments": 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:
        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/次
    """
    # ===== 硬护栏:未获用户明确确认,绝不调用 hunter 付费接口 =====
    if not getattr(args, "approved", False):
        raise RuntimeError(
            "⛔ 未获用户明确确认(缺少 --approved),拒绝调用 hunter 付费接口。\n"
            "请先让用户确认 verify 范围与费用,再加 --approved 重试。"
        )
    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",
        },
    }


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

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

    硬护栏:必须 --approved(agent 在用户确认后传入)或 --interactive(脚本内联问询),
    否则拒绝执行任何付费步骤。
    """
    import tempfile

    # ===== 硬护栏:必须用户确认(--approved)或交互问询(--interactive)=====
    if not (getattr(args, "approved", False) or args.interactive):
        raise RuntimeError(
            "⛔ 缺少 --approved 且非 --interactive:拒绝执行任何付费步骤。\n"
            "正确流程:estimate 展示预估 → 用户确认 → run --approved ..."
        )

    # 交互模式下,用户在脚本内已确认,视为已批准;否则用 --approved 标记
    effective_approved = bool(getattr(args, "approved", False) or args.interactive)

    # 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=effective_approved,
        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=effective_approved)
        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 cmd_estimate(args):
    """仅做费用预估,绝不调用任何付费接口。

    agent 第一步必须调用它,把 cost_estimate 展示给用户;等用户明确确认后,
    再调用 search / verify / run 并带 --approved。这是"用户完全确认才工作"的结构性保障。
    """
    cost = calc_cost(
        mode=args.mode,
        search_count=args.limit,
        verify_count=args.verify_count,
        used_description=False,
    )
    result = {
        "ok": True,
        "free": True,
        "no_api_called": True,
        "mode": args.mode,
        "params": {
            "city": args.city, "category": args.category,
            "limit": args.limit, "verify_count": args.verify_count,
        },
        "cost_estimate": cost,
        "message": (
            "以上为预估费用,本次未调用任何付费接口。"
            "请向用户展示并等待其明确确认;确认后执行时务必加 --approved。"
        ),
    }
    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 _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("--approved", action="store_true",
                   help="用户已明确确认费用后才允许调用付费搜索接口(serpapi/api/browser)")
    p.add_argument("--output", help="输出 candidates.json")
    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("--approved", action="store_true",
                   help="用户已明确确认 verify 范围与费用后才允许调用 hunter 付费接口")
    p.add_argument("--output", default="leads.xlsx", help="输出 xlsx 文件")
    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("--approved", action="store_true",
                   help="用户已明确确认后才执行全链路(search→confirm→verify)付费步骤")
    p.add_argument("--output", default="leads.xlsx")
    p.set_defaults(func=cmd_run)

    # estimate(免费预估,不调用任何付费接口)
    p = sub.add_parser("estimate", help="仅预估费用,不调用任何付费接口(agent 先给用户看报价)")
    p.add_argument("--mode", default="serpapi", choices=["serpapi", "api", "browser"])
    p.add_argument("--city", default=None, help="城市(仅用于展示)")
    p.add_argument("--category", default=None, help="类别(仅用于展示)")
    p.add_argument("--limit", type=int, default=10, help="拟搜索候选数")
    p.add_argument("--verify_count", type=int, default=3, help="拟验证数量(用于预估 verify 成本)")
    p.add_argument("--output", help="可选,输出预估 JSON")
    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 Exception as e:
        # needs-confirmation:structured-payload
        # 平台未确认付费调用(-32006)→ NeedsConfirmation。
        # 必须原样吐结构化载荷:needs_confirmation / estimated_cost_yuan
        # / cost_basis 要能被上层机读,不能拍平成一句错误文本。
        # exit 3 = 待用户确认(0=成功 / 1=失败 / 3=待确认)。
        if hasattr(e, "to_dict") and hasattr(e, "estimated_cost_yuan"):
            print(json.dumps(e.to_dict(), ensure_ascii=False, indent=2))
            sys.exit(3)
        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])