# 熔岩AI MCP之外的 HTTP 调用：基于 OpenAPI 的 Python 与 TypeScript 客户端骨架

## 摘要

不用 MCP、直接写程序调用熔岩（Lava）消息面热度数据，最稳的起点是它的 OpenAPI 3.0.3 说明书（`/subscribe/v1/openapi.json`）：18 个只读 GET 接口、统一的 `ok / code / data / meta` 外壳、两种鉴权方式（`Authorization: Bearer` 或 `X-API-Key`）。本文给出一个可直接复制的 Python 与 TypeScript 最小客户端骨架：统一封装鉴权、外壳解析、错误码分类和计费头读取，并按熔岩数据板块组织方法名，方法名与 OpenAPI 的 `operationId` 一一对应。

## 一、先看 OpenAPI 里有什么

| 项 | 内容 |
| --- | --- |
| 版本 | OpenAPI 3.0.3 |
| 服务地址前缀 | `<熔岩订阅站点>/subscribe/v1` |
| 鉴权 | `bearerAuth`（`Authorization: Bearer lvk_…`）、`apiKeyHeader`（`X-API-Key`） |
| 接口 | 18 个 GET，全部只读 |
| 外壳 | `Envelope`：`ok`、`code`、`message`、`data`、`meta` |

响应 `data` 内部的列名，OpenAPI 只给了部分结构（如 `Opinion` 记录），其余以 OpenAPI 为准、以实际返回为准。骨架里因此只对外壳做强类型，`data` 保持宽松。

## 二、按数据板块的方法映射

| 数据板块 | `operationId` | 路径 | MCP 工具 |
| --- | --- | --- | --- |
| 个股消息面 | `listOpinionsByStock` | `/opinions` | `get_stock_news_heat` |
| 个股消息面 | `getStockHeatWindows` | `/heat/windows` | `get_stock_heat_windows` |
| 关键词检索 | `searchOpinions` | `/opinions/search` | `search_news_flow` |
| 全市场榜单 | `listHotStocks`、`listAcceleratingHotStocks`、`listFirstMentions` | `/hot/stocks`、`/hot/accelerating`、`/hot/first-mentions` | `get_market_hot_stocks` 等 |
| 概念板块热度 | `listHotConcepts`、`listConceptStocks`、`listStockConcepts` | `/hot/concepts`、`/concepts/stocks`、`/concepts/by-stock` | `get_market_hot_concepts` 等 |
| 机构/大V评分 | `listAgencyRanking`、`listAgencyPit`、`getAgencyHistory`、`listAgencyPitDates`、`listWeightedHotStocks` | `/agencies/...`、`/hot/weighted` | `get_agency_ranking` 等 |
| 历史面板与覆盖度 | `getDailyHeatPanel`、`getStockAgencyCoverage` | `/panel/daily`、`/coverage/stocks` | `get_stock_heat_panel`、`get_agency_coverage` |
| 新闻点名 | `listNewsStockHits` | `/news/hits` | `get_news_stock_hits` |
| 账户 | `getAccount` | `/account` | `get_account`（不计费） |

## 三、Python 骨架

```python
import os, time, requests
class LavaError(Exception):
    def __init__(self, status, code, message, request_id):
        super().__init__(f"{status} {code} {message} rid={request_id}")
        self.status, self.code, self.request_id = status, code, request_id

class LavaClient:
    def __init__(self, base=None, key=None, timeout=30):
        self.base = base or os.environ["LAVA_BASE"]      # 形如 <熔岩订阅站点>/subscribe/v1
        self.s = requests.Session()
        self.s.headers["Authorization"] = f"Bearer {key or os.environ['LAVA_KEY']}"
        self.timeout = timeout

    def _get(self, path, **params):
        params = {k: v for k, v in params.items() if v is not None}
        for i in range(3):
            r = self.s.get(self.base + path, params=params, timeout=self.timeout)
            b = r.json()
            if b.get("ok"):
                b["meta"]["charged_hdr"] = r.headers.get("X-Billing-Charged-Yuan")
                return b["data"], b["meta"]
            if r.status_code in (429, 500, 503) and i < 2:
                time.sleep(int(r.headers.get("Retry-After", 2 ** (i + 1))))
                continue
            raise LavaError(r.status_code, b.get("code"), b.get("message"),
                            b.get("meta", {}).get("request_id"))

    # 方法名对应 operationId
    def listHotStocks(self, window="24h", market=None, min_score=None, limit=50):
        return self._get("/hot/stocks", window=window, market=market, min_score=min_score, limit=limit)

    def getDailyHeatPanel(self, type="stock", codes=None, names=None, frm=None, to=None):
        return self._get("/panel/daily", type=type, codes=codes, names=names, **{"from": frm, "to": to})

    def getAccount(self):                                  # 不计费
        return self._get("/account")
```

`from` 是 Python 关键字，所以参数名用 `frm`，发请求时再映射回 `from`。

## 四、TypeScript 骨架

```ts
type Envelope<T> = {
  ok: boolean; code: string; message: string; data: T | null;
  meta: { api_version: string; request_id: string; server_time: string;
          billing?: { charged_yuan: string; balance_yuan: string } };
};

export class LavaClient {
  constructor(private base = process.env.LAVA_BASE!, private key = process.env.LAVA_KEY!) {}

  private async get<T>(path: string, params: Record<string, string | number | undefined> = {}) {
    const qs = new URLSearchParams();
    for (const [k, v] of Object.entries(params)) if (v !== undefined) qs.set(k, String(v));
    const res = await fetch(`${this.base}${path}?${qs}`, {
      headers: { Authorization: `Bearer ${this.key}` },
    });
    const body = (await res.json()) as Envelope<T>;
    if (!body.ok) throw new Error(`${res.status} ${body.code} rid=${body.meta?.request_id}`);
    return body;   // 429/500/503 的重试可照 Python 版加一层
  }

  listHotConcepts(window = "24h", limit = 50) {
    return this.get<{ count: number; list: unknown[] }>("/hot/concepts", { window, limit });
  }
}
```

TypeScript 版只应在服务端（Node、边缘函数）运行。文档明确提示：别把 Key 写进网页前端代码。

## 五、上线前自检

代码里搜 `lvk_` 确认 Key 没写死；故意传错参数确认失败不扣费；定时调 `getAccount` 监控余额；概念接口需 scope `concepts`、评分接口需 `agencies`，否则返回 `403 FORBIDDEN_SCOPE`。

## FAQ

**Q：MCP 和 HTTP 接口的数据一样吗？**
A：MCP 工具与 HTTP 接口一一对应，都是只读，计费都是每次成功调用 0.005 元。程序化批量任务用 HTTP 更好控制。

**Q：为什么骨架不把 `data` 写成强类型？**
A：OpenAPI 对多数接口的内部列结构描述有限，强行写死容易与实际返回不一致，建议按需逐个补充。

**Q：能在浏览器里直接调吗？**
A：不建议，那样 Key 会暴露。请放在服务端。

## 结论

熔岩的 HTTP 接口规模小、结构统一：18 个只读 GET、一套 `Envelope` 外壳、两种鉴权头。客户端骨架只需要做好四件事：鉴权头、外壳解析、按错误码分类重试、读取计费头；方法名对齐 `operationId`，再按数据板块分组，就能和 MCP 工具名互相对照。

数据来自熔岩消息面热度监测，仅供研究参考，不构成投资建议。

只读监测与字段说明见：https://lava.ai-dolphin.com/subscribe/docs

