---
title: "【腾讯位置服务开发者征文大赛】基于飞书 CLI + 腾讯位置的科研与产业地理情报可视化 Skill"
source_name: "掘金本周最热"
original_url: "https://juejin.cn/post/7633697912285118516"
canonical_url: "https://www.traeai.com/articles/31546ebc-a3ad-4c9c-b940-816d6c64d746"
content_type: "article"
language: "中文"
score: 7.2
tags: ["飞书CLI","腾讯位置服务","地理编码","AI Agent","可视化"]
published_at: "2026-04-29T02:14:16+00:00"
created_at: "2026-05-01T23:07:33.624386+00:00"
---

# 【腾讯位置服务开发者征文大赛】基于飞书 CLI + 腾讯位置的科研与产业地理情报可视化 Skill

Canonical URL: https://www.traeai.com/articles/31546ebc-a3ad-4c9c-b940-816d6c64d746
Original source: https://juejin.cn/post/7633697912285118516

## Summary

项目 GeoMind 将飞书 CLI 与腾讯位置服务结合，实现从飞书文档自动抽取科研/产业实体、地理编码、关系建模到动态地图可视化的端到端流程，推动地图能力融入 AI Agent 工作流。

## Key Takeaways

- 地图不应仅作展示容器，而应成为 AI 理解地理关系与输出决策的环节
- 通过飞书 CLI 提取结构化实体，调用腾讯位置服务完成批量地理编码
- 生成可嵌入飞书文档的交互式 HTML 地图，支持实体分类、关系弧线与流动动画

## Content

Title: 【腾讯位置服务开发者征文大赛】基于飞书 CLI + 腾讯位置的科研与产业地理情报可视化 Skill

URL Source: http://juejin.cn/post/7633697912285118516

Published Time: 2026-04-29T02:14:16+00:00

Markdown Content:
> 从飞书文档到全球产业版图，一键生成关系地图。 自动提取 -> 地理编码 -> 关系建模 -> 地图可视化。 ![Image 1: 请在此添加图片描述](https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/52c2addea86c4f84824a81f3c10bfc9e~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgTHVjaWFuYWlC:q75.awebp?rk3s=f64ab15b&x-expires=1778033656&x-signature=KPMK1vy%2B5l4jE38r0542jAh5nKg%3D) 本项目已开源👉[github.com/lucianaib03…](https://link.juejin.cn/?target=https%3A%2F%2Fgithub.com%2Flucianaib0318%2FGeoMind "https://github.com/lucianaib0318/GeoMind")

开发者对于腾讯位置服务的开发，常见方式一般是做地图可视化：把点、线、面、POI、轨迹、热力图展示到地图上，或者围绕旅游攻略、路线推荐、商业选址做一些单点能力。

这些方向当然有价值，但我一直觉得还缺少一个东西：地图能力没有真正进入 AI Agent 的工作流。

如果只是把数据画到地图上，地图仍然只是一个展示容器。GeoMind 想解决的问题是：能不能让地图成为 AI 处理信息、理解关系、输出决策材料的一部分？

飞书 CLI 是一个很强的自动化入口。飞书本身已经具备大量能力，例如通过飞书 Aily 做文档分析展示、消息提醒、多维表到期自动化提醒、飞书群实时监控等。很多只发生在飞书内部的操作，飞书原生能力或飞书 Aily 已经可以完成。

所以这次我没有选择重复做一个普通的飞书文档分析工具，而是提出一个更具体的问题：

**当飞书CLI和腾讯位置服务真正结合以后，会发生什么？**

GeoMind 给出的答案是：**把飞书文档中的科研机构、企业、厂址、实验室、园区、供应链节点和合作关系，自动抽取成结构化地理情报，再通过腾讯位置服务完成地理编码，最后生成一张可运行、可演示、可继续扩展的产业关系地图。**

这就是本次参赛作品：**科研与产业地理情报可视化 Skill。**

先看产品 Demo。

## 1.运行效果动图(可运行的 Demo)

Demo 主题为“中国新能源与智能制造产业分布网络”。

![Image 2: 中国新能源与智能制造产业分布网络.png](https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/44fa8a44e5e9411a9efcffa76c79f05d~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgTHVjaWFuYWlC:q75.awebp?rk3s=f64ab15b&x-expires=1778033656&x-signature=O7bDHVFbcqmirUTmakYyAwx566I%3D)

系统会从示例文档中提取产业实体与关系，并在腾讯地图底图上展示全国范围内的产业协同关系。

上面这个效果不是静态概念图，而是由项目生成的可运行 Demo。核心展示包括：

*   在真实腾讯地图底图上展示产业节点。
*   按实体类型区分科研机构、企业、工厂、实验室、产业园区、供应链节点等。
*   使用蓝色荧光弧线呈现跨区域协作、供应、联合实验室、技术转移等关系。
*   使用流动动画表现“数据、技术或供应链能力正在传输”的过程。
*   右侧面板支持查看实体列表、地点、技术领域和定位状态。
*   可以把生成的 HTML、GIF 或图片插入飞书文档中，形成“文档 + 地图 + 可视化附件”的展示方式。

## 2.技术架构与实现思路

GeoMind 的目标不是只生成一张图片，而是做一个可以复用、可以开源、可以接入 Skill 体系的工程。整体流程围绕飞书 CLI、腾讯位置服务、地图可视化和结构化数据校验展开。

![Image 3: 请在此添加图片描述](https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/c60d8ac667fb41e2a91681a10dc759a0~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgTHVjaWFuYWlC:q75.awebp?rk3s=f64ab15b&x-expires=1778033656&x-signature=q2cz6HbcBnzW6TCGYZ8LehSCHvo%3D)

项目采用 TypeScript + Node.js，核心模块拆分如下：

```
src
|-- config          # 环境变量与运行配置
|-- document        # 飞书 CLI 读取适配层与本地文档读取
|-- text            # 文本清洗
|-- extraction      # 实体与关系抽取
|-- geocoding       # 腾讯位置服务封装、缓存与兜底
|-- schemas         # JSON Schema 校验
|-- whiteboard      # 白板 DSL、SVG、腾讯地图 HTML 渲染
|-- orchestrator    # 主流程编排
|-- skill           # Skill 封装入口
`-- feishu          # 飞书文档发布脚本
```

技术实现重点围绕腾讯位置服务地图、定位、地理编码，以及腾讯地图 Map Skills 体系下的 `tencentmap-jsapi-gl-skill`、`tencentmap-miniprogram-skill` 等方向展开。

当前 MVP 中已经落地的能力是：

*   通过飞书 CLI 或本地 Markdown 读取文档。
*   从文档中抽取实体和关系。
*   调用腾讯位置服务 WebService Geocoder 获取经纬度。
*   使用缓存和兜底坐标保证 Demo 可运行。
*   生成结构化 JSON，并进行 JSON Schema 校验。
*   生成白板 DSL、SVG 预览和腾讯地图 JSAPI GL 前端页面。
*   生成适合写回飞书文档的图片或 GIF 展示素材。

## 3.飞书 CLI 与 腾讯位置结合

GeoMind 的核心价值在于把“文档里的信息”变成“地图上的情报”。

飞书文档适合沉淀资料，但产业研究、招商分析、供应链分析和科研协同分析往往存在一个问题：信息写在文档里时是线性的，很难看出空间分布和跨区域关系。

例如，某个飞书文档里可能记录了北京的研发中心、上海的实验室、深圳的 AI 计算中心、合肥的电池企业、西安的材料中转中心，以及它们之间的供应、联合研发或技术转移关系。单纯阅读文档，很难快速判断这些节点在全国范围内的协同结构。

**腾讯位置服务提供地理编码和地图展示能力，可以把地点文本变成经纬度，并在真实地图上进行渲染。飞书 CLI 提供文档读取、自动化执行和后续写回飞书的入口。两者结合以后，就形成了一个更完整的智能应用方案：**

![Image 4: 请在此添加图片描述](https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/b71906c6fd0240f28e9d0b053497744c~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgTHVjaWFuYWlC:q75.awebp?rk3s=f64ab15b&x-expires=1778033656&x-signature=tlSkgYKvEAG%2Bx8ip1E3DRkcrz7g%3D)

这样做以后，飞书不再只是“资料存放处”，腾讯地图也不再只是“展示底图”。两者共同构成了一个 AI Agent 可执行的地理情报工作流。

实际场景包括：

*   科研机构合作网络分析：识别高校、研究院、实验室之间的联合研发关系。
*   新能源产业链分布分析：展示电池、储能、光伏、智能装备企业之间的供应链网络。
*   招商与园区选址分析：把目标企业、产业园区、交通节点和区域政策放在同一张地图中比较。
*   企业战略研究：快速看出某一领域的研发、制造、供应链和客户节点分布。
*   飞书文档自动化展示：把原本文字型资料转成可视化附件，方便汇报、评审和协作。

当前 MVP 已经跑通从文档到地图的闭环。

## 4.如何获取相关 API 与运行项目

这一部分是我认为参赛项目里非常关键的地方：Demo 不应该只停留在截图，而应该尽量让别人可以真实跑起来。

所以写了本章节。

#### 4.1 准备 Node.js

项目使用 TypeScript + Node.js，建议安装 Node.js 20 以上版本。

```
node -v
npm -v
```

#### 4.2 获取腾讯位置服务 Key

腾讯位置服务 Key 用于两件事：

*   后端调用 WebService Geocoder，把地点文本转换成经纬度。
*   前端加载腾讯地图 JavaScript API GL，展示真实地图底图。

获取方式：

1.   打开腾讯位置服务控制台：[lbs.qq.com/dev/console…](https://link.juejin.cn/?target=https%3A%2F%2Flbs.qq.com%2Fdev%2Fconsole%2Fapplication%2Fmine "https://lbs.qq.com/dev/console/application/mine")
2.   登录腾讯位置服务账号。
3.   创建应用。
4.   在应用下创建 Key。
5.   根据需要开启 WebService API 和 JavaScript API GL 相关能力。
6.   如果前端页面需要在浏览器中展示，建议配置 WebService 和域名白名单。
7.   把 Key 写入本地 `.env` 文件。

![Image 5: 请在此添加图片描述](https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/1b4202fab53b41cc8067ad60256e77dc~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgTHVjaWFuYWlC:q75.awebp?rk3s=f64ab15b&x-expires=1778033656&x-signature=YIk8%2Bqeai9qpv%2F0heQgYBW0oe4s%3D)

![Image 6: 请在此添加图片描述](https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/16fe8786cd5b421bbeef9b9bf5148d52~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgTHVjaWFuYWlC:q75.awebp?rk3s=f64ab15b&x-expires=1778033656&x-signature=WIziDh2hdP15nAaMs8yAn3lXZus%3D)

`.env` 示例：

```
TENCENT_MAP_KEY=your-tencent-map-key
GEOCODE_CACHE_PATH=cache/geocode-cache.json
GEOCODE_TIMEOUT_MS=8000
```

如果只是跑离线演示，可以使用：

```
npm run demo:offline
```

离线模式会跳过真实地理编码，依赖示例数据或兜底逻辑完成演示。

#### 4.3 安装飞书 CLI

安装飞书 CLI：

```
npm install -g @larksuite/cli
```

安装 Skills：

```
npx skills add larksuite/cli -y -g
```

初始化与登录：

```
lark-cli config init --new
lark-cli auth login --recommend
lark-cli auth status
lark-cli doctor
```

Windows PowerShell 如果遇到 `lark-cli.ps1` 无法执行，通常是 PowerShell 执行策略限制。可以临时使用：

```
lark-cli.cmd --version
```

或者调整当前用户执行策略：

```
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
```

如果系统找不到 `lark-cli`，检查 npm 全局 bin 是否在 Path 中：

```
$npmBin = npm prefix -g
$env:Path = "$npmBin;$env:Path"
[Environment]::SetEnvironmentVariable(
  "Path",
  "$npmBin;$([Environment]::GetEnvironmentVariable('Path', 'User'))",
  "User"
)
```

#### 4.4 如何获取飞书文档 token

GeoMind 支持输入飞书文档 URL，也支持直接输入 token。

飞书 URL 中一般会带有 wiki/doc/docx token，例如：

```
https://example.feishu.cn/wiki/RclvwAdA2igSAMk7cqhcJDt1nPf
```

上面这个 URL 里的：

```
RclvwAdA2igSAMk7cqhcJDt1nPf
```

![Image 7: 请在此添加图片描述](https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/871303c80ae0424bad6ad5b1e4f90de4~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgTHVjaWFuYWlC:q75.awebp?rk3s=f64ab15b&x-expires=1778033656&x-signature=%2BJfwOT7EqiVu8CVp1sSsrGfDzKg%3D)

就是 wiki token。

常见 token 类型：

```
wiki:  https://xxx.feishu.cn/wiki/{wiki_token}
docx:  https://xxx.feishu.cn/docx/{docx_token}
doc:   https://xxx.feishu.cn/docs/{doc_token}
```

项目里有一个 `parseFeishuInput` 适配层，会尽量从 URL 中自动解析 token 和文档类型，所以大多数情况下直接传 URL 即可。

#### 4.5 配置飞书 CLI 命令模板

不同版本的飞书 CLI 真实命令可能会变化，所以 GeoMind 没有把某一个命令写死，而是通过环境变量做适配。

`.env` 中可以配置：

```
FEISHU_CLI_COMMAND_TEMPLATE="lark-cli docs fetch --doc {url} --format json"
```

支持的占位符：

```
{url}    飞书文档 URL
{token}  从 URL 中解析出的 token
{kind}   文档类型，例如 wiki/doc/docx/unknown
```

如果你的飞书 CLI 命令和上面不同，只需要修改模板，不需要改 GeoMind 的主流程代码。

#### 4.6 克隆和运行

```
git clone git@github.com:lucianaib0318/GeoMind.git
cd GeoMind
npm install
cp .env.example .env
```

Windows PowerShell：

```
Copy-Item .env.example .env
```

运行示例 Demo：

```
npm run demo
```

运行离线 Demo：

```
npm run demo:offline
```

输出文件：

```
examples/sample-output.json  # 结构化 JSON
output/geomind.html          # 腾讯地图交互前端
output/geomind.svg           # 白板 DSL 的 SVG 预览
```

打开地图（示例），你可以换为你自己的：

```
start output/geomind.html
```

![Image 8: 请在此添加图片描述](https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/a96900236cdb4f119933864f5dd0d839~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgTHVjaWFuYWlC:q75.awebp?rk3s=f64ab15b&x-expires=1778033656&x-signature=Pgl0PtqUCrM3MOtGZ0ek3uq9jx8%3D)

macOS：

```
open output/geomind.html
```

Linux：

```
xdg-open output/geomind.html
```

#### 4.7 使用真实飞书文档运行

传入飞书文档 URL：

```
npm run dev -- \
  --url "https://example.feishu.cn/wiki/your_wiki_token" \
  --out examples/sample-output.json \
  --html-out output/geomind.html \
  --svg-out output/geomind.svg
```

如果飞书 CLI 命令模板已配置，GeoMind 会通过飞书 CLI 读取文档内容。如果暂时没有配置飞书 CLI，也可以先用本地 Markdown 做演示：

```
npm run dev -- \
  --input-file examples/sample-input.md \
  --out examples/sample-output.json \
  --html-out output/geomind.html \
  --svg-out output/geomind.svg
```

## 5. 示例输入文档

GeoMind 的输入可以是自然语言，也可以是更结构化的 Markdown。为了提高抽取稳定性，示例文档中可以使用明确的实体和关系描述。

```
# 中国新能源与智能制造产业分布网络

实体: 北京智能制造协调中心 | 类型: 政府机构 | 地点: 北京海淀 | 技术: 智能制造、大模型、工业互联网
实体: 上海张江研发中心 | 类型: 实验室 | 地点: 上海浦东新区 | 技术: 高端装备、风电设备、工业自动化
实体: 深圳南山 AI 计算中心 | 类型: 企业 | 地点: 广东深圳市南山区 | 技术: AI 计算、边缘计算
实体: 合肥动力电池工厂 | 类型: 工厂 | 地点: 安徽合肥市 | 技术: 动力电池、储能系统
实体: 西安新能源材料中转中心 | 类型: 供应链节点 | 地点: 陕西西安市 | 技术: 单晶硅材料、光伏组件

关系: 北京智能制造协调中心 -> 上海张江研发中心 | 类型: 协同合作 | 证据: 共同推进智能制造标准验证。
关系: 深圳南山 AI 计算中心 -> 合肥动力电池工厂 | 类型: 技术转移 | 证据: 提供电池产线视觉检测模型。
关系: 西安新能源材料中转中心 -> 合肥动力电池工厂 | 类型: 供应 | 证据: 提供新能源材料中转服务。
```

抽取后会变成类似结构：

```
{
  "entities": [
    {
      "id": "ent_beijing_smart_manufacturing",
      "name": "北京智能制造协调中心",
      "type": "government_agency",
      "locationText": "北京海淀",
      "techFields": ["智能制造", "大模型", "工业互联网"]
    }
  ],
  "relations": [
    {
      "source": "ent_beijing_smart_manufacturing",
      "target": "ent_shanghai_zhangjiang_lab",
      "relationType": "collaboration",
      "evidence": "共同推进智能制造标准验证。"
    }
  ]
}
```

## 6.关键代码片段

这一部分放一些核心代码，方便读者理解项目到底是怎么跑起来的。

#### 6.1 核心类型定义

GeoMind 的关键原则是：不要只输出自然语言，要输出结构化 JSON。

```
export type EntityType =
  | "research_institute"
  | "university"
  | "company"
  | "factory"
  | "lab"
  | "industrial_park"
  | "government_agency"
  | "supply_chain_node"
  | "location"
  | "other";

export type RelationType =
  | "collaboration"
  | "investment"
  | "supply"
  | "customer"
  | "joint_lab"
  | "located_in"
  | "subsidiary"
  | "technology_transfer"
  | "competition"
  | "other";

export interface GeoMindEntity {
  id: string;
  name: string;
  type: EntityType;
  locationText?: string;
  techFields: string[];
  aliases?: string[];
  evidence?: string[];
  confidence?: number;
}

export interface GeoMindRelation {
  id: string;
  source: string;
  target: string;
  relationType: RelationType;
  evidence: string;
  confidence?: number;
}

export interface Coordinates {
  lat: number;
  lng: number;
}

export interface GeocodedLocation {
  provider: "tencent";
  query: string;
  status: "resolved" | "cached" | "fallback" | "failed";
  coordinates?: Coordinates;
  formattedAddress?: string;
  province?: string;
  city?: string;
  district?: string;
  cachedAt?: string;
  error?: string;
}

export interface EnrichedEntity extends GeoMindEntity {
  geocode?: GeocodedLocation;
}

export interface GeoMindOutput {
  schemaVersion: string;
  generatedAt: string;
  input: DocumentInput;
  extraction: ExtractionResult;
  entities: EnrichedEntity[];
  relations: GeoMindRelation[];
  whiteboard: WhiteboardDsl;
  summary: GeoMindSummary;
  warnings: string[];
}
```

#### 6.2 飞书 CLI 文档读取适配层

这里的设计重点是“适配”，而不是把某条 CLI 命令写死。

```
import { exec } from "node:child_process";
import { promisify } from "node:util";
import type { DocumentInput, RawDocument } from "../types/index.js";
import { parseTokenFromUrl } from "./feishuInput.js";

const execAsync = promisify(exec);

export interface FeishuCliReaderOptions {
  commandTemplate?: string;
  timeoutMs?: number;
}

export class FeishuCliDocumentReader {
  private readonly commandTemplate: string | undefined;
  private readonly timeoutMs: number;

  constructor(options: FeishuCliReaderOptions = {}) {
    this.commandTemplate = options.commandTemplate;
    this.timeoutMs = options.timeoutMs ?? 15000;
  }

  async read(input: DocumentInput): Promise<RawDocument> {
    const normalized = normalizeInput(input);

    if (!this.commandTemplate) {
      throw new Error(
        "Missing FEISHU_CLI_COMMAND_TEMPLATE. Set a command that prints document text or JSON."
      );
    }

    const command = renderCommandTemplate(this.commandTemplate, normalized);
    const { stdout, stderr } = await execAsync(command, {
      timeout: this.timeoutMs,
      maxBuffer: 10 * 1024 * 1024
    });

    const parsed = parseCliOutput(stdout);

    return {
      input: normalized,
      ...(parsed.title ? { title: parsed.title } : {}),
      text: parsed.text,
      fetchedAt: new Date().toISOString(),
      metadata: {
        adapter: "feishu-cli",
        commandTemplate: this.commandTemplate,
        ...(stderr.trim() ? { stderr: stderr.trim() } : {})
      }
    };
  }
}

function normalizeInput(input: DocumentInput): DocumentInput {
  if (input.token || !input.url) {
    return input;
  }

  const parsed = parseTokenFromUrl(input.url);
  return {
    ...input,
    ...(parsed.token ? { token: parsed.token } : {}),
    kind: input.kind ?? parsed.kind
  };
}

function renderCommandTemplate(template: string, input: DocumentInput): string {
  return template
    .replaceAll("{url}", shellQuote(input.url ?? ""))
    .replaceAll("{token}", shellQuote(input.token ?? ""))
    .replaceAll("{kind}", shellQuote(input.kind ?? "unknown"));
}

function shellQuote(value: string): string {
  return "${value.replaceAll('"', '\\"')}";
}
```

这个模块的好处是：飞书 CLI 的命令一旦变化，只需要调整 `.env` 里的命令模板，不影响后面的抽取、地理编码和可视化流程。

#### 6.3 文本清洗

文档内容进入抽取模块之前，要先做清洗。清洗不是为了“美化文本”，而是为了减少格式噪声对实体识别的影响。

```
export function cleanDocument(raw: RawDocument): CleanedDocument {
  const normalizedText = raw.text
    .replace(/\r\n/g, "\n")
    .replace(/\t/g, " ")
    .replace(/[ \u00A0]{2,}/g, " ")
    .replace(/\n{3,}/g, "\n\n")
    .trim();

  const sections = splitSections(normalizedText);

  return {
    ...(raw.title ? { title: raw.title } : {}),
    text: normalizedText,
    sections,
    stats: {
      originalChars: raw.text.length,
      cleanedChars: normalizedText.length,
      sectionCount: sections.length
    }
  };
}
```

#### 6.4 实体与关系抽取

MVP 先使用规则抽取，后续可以替换成 LLM Extractor。这里的原则是：先让工程跑通，再逐步增强模型能力。

```
export function extractEntitiesAndRelations(
  document: CleanedDocument
): ExtractionResult {
  const warnings: string[] = [];
  const entitiesByName = new Map<string, GeoMindEntity>();
  const relations: GeoMindRelation[] = [];

  const lines = document.text
    .split("\n")
    .map((line) => line.trim())
    .filter(Boolean);

  for (const line of lines) {
    const structuredEntity = parseStructuredEntity(line);
    if (structuredEntity) {
      upsertEntity(entitiesByName, structuredEntity);
      continue;
    }

    const structuredRelation = parseStructuredRelation(line);
    if (structuredRelation) {
      relations.push(structuredRelation);
    }
  }

  const entities = [...entitiesByName.values()];
  const nameToId = new Map(
    entities.map((entity) => [normalizeKey(entity.name), entity.id])
  );

  const normalizedRelations = relations
    .map((relation) => normalizeRelationEntityIds(relation, nameToId))
    .filter((relation): relation is GeoMindRelation => Boolean(relation));

  if (entities.length === 0) {
    warnings.push("No entities were extracted.");
  }

  return {
    entities,
    relations: dedupeRelations(normalizedRelations),
    warnings
  };
}
```

示例中的结构化行：

实体: 北京智能制造协调中心 | 类型: 政府机构 | 地点: 北京海淀 | 技术: 智能制造、大模型、工业互联网

关系: 北京智能制造协调中心 -> 上海张江研发中心 | 类型: 协同合作 | 证据: 共同推进智能制造标准验证。

会被解析成实体节点和关系边。

#### 6.5 腾讯位置服务 Geocoder

腾讯位置服务的封装是整个项目中最关键的外部 API 模块。它负责：

*   调用腾讯位置服务 WebService Geocoder。
*   控制请求超时。
*   写入本地缓存，避免重复请求。
*   API 失败时返回兜底坐标或失败状态。

```
export interface TencentGeocoderOptions {
  apiKey?: string;
  cachePath: string;
  timeoutMs?: number;
}

export class TencentGeocoder {
  private readonly apiKey: string | undefined;
  private readonly cachePath: string;
  private readonly timeoutMs: number;
  private cache?: Record<string, GeocodedLocation>;

  constructor(options: TencentGeocoderOptions) {
    this.apiKey = options.apiKey;
    this.cachePath = options.cachePath;
    this.timeoutMs = options.timeoutMs ?? 8000;
  }

  async geocode(query: string): Promise<GeocodedLocation> {
    const normalizedQuery = query.trim();

    if (!normalizedQuery) {
      return {
        provider: "tencent",
        query,
        status: "failed",
        error: "Empty geocode query."
      };
    }

    const cache = await this.loadCache();
    const cached = cache[normalizedQuery];

    if (cached?.coordinates && (cached.status === "resolved" || !this.apiKey)) {
      return {
        ...cached,
        status: "cached"
      };
    }

    if (!this.apiKey) {
      const fallback = fallbackGeocode(
        normalizedQuery,
        "TENCENT_MAP_KEY is not configured."
      );
      cache[normalizedQuery] = fallback;
      await this.saveCache(cache);
      return fallback;
    }

    try {
      const resolved = await this.fetchTencent(normalizedQuery);
      cache[normalizedQuery] = resolved;
      await this.saveCache(cache);
      return resolved;
    } catch (error) {
      const fallback = fallbackGeocode(normalizedQuery, errorMessage(error));
      cache[normalizedQuery] = fallback;
      await this.saveCache(cache);
      return fallback;
    }
  }
}
```

真正请求腾讯位置服务的逻辑：

```
private async fetchTencent(query: string): Promise<GeocodedLocation> {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), this.timeoutMs);

  const url = new URL("https://apis.map.qq.com/ws/geocoder/v1/");
  url.searchParams.set("address", query);
  url.searchParams.set("key", this.apiKey ?? "");

  try {
    const response = await fetch(url, {
      signal: controller.signal,
      headers: {
        accept: "application/json"
      }
    });

    if (!response.ok) {
      throw new Error(Tencent geocoder HTTP ${response.status});
    }

    const payload = await response.json() as TencentGeocoderResponse;

    if (payload.status !== 0 || !payload.result?.location) {
      throw new Error(payload.message || Tencent geocoder status ${payload.status});
    }

    const components = payload.result.address_components;

    return {
      provider: "tencent",
      query,
      status: "resolved",
      coordinates: payload.result.location,
      formattedAddress: payload.result.address ?? payload.result.title,
      country: components?.nation,
      province: components?.province,
      city: components?.city,
      district: components?.district,
      cachedAt: new Date().toISOString(),
      raw: payload
    };
  } finally {
    clearTimeout(timeout);
  }
}
```

缓存逻辑：

```
private async loadCache(): Promise<Record<string, GeocodedLocation>> {
  if (this.cache) {
    return this.cache;
  }

  try {
    const raw = await readFile(this.cachePath, "utf8");
    this.cache = JSON.parse(raw);
  } catch {
    this.cache = {};
  }

  return this.cache;
}

private async saveCache(cache: Record<string, GeocodedLocation>): Promise<void> {
  await mkdir(path.dirname(this.cachePath), { recursive: true });
  await writeFile(this.cachePath, ${JSON.stringify(cache, null, 2)}\n, "utf8");
}
```

#### 6.6 主流程 Orchestrator

主流程把“读取文档 -> 清洗 -> 抽取 -> 地理编码 -> 生成可视化”串起来。

```
export interface RunGeoMindOptions {
  input?: string;
  inputFile?: string;
  outputPath?: string;
  whiteboardPath?: string;
  htmlPath?: string;
  svgPath?: string;
  title?: string;
  feishuCliCommandTemplate?: string;
  skipGeocode?: boolean;
}

export async function runGeoMind(
  options: RunGeoMindOptions,
  config: GeoMindConfig
): Promise<GeoMindOutput> {
  const rawDocument = options.inputFile
    ? await readLocalDocument(options.inputFile)
    : await readFeishuDocument(options, config);

  const cleanedDocument = cleanDocument(rawDocument);
  const extraction = extractEntitiesAndRelations(cleanedDocument);
  const entities = await enrichEntitiesWithGeocoding(
    extraction.entities,
    options,
    config
  );

  const whiteboard = generateWhiteboardDsl(
    entities,
    extraction.relations,
    options.title ?? rawDocument.title ?? "GeoMind"
  );

  const summary = buildSummary(entities, extraction.relations);

  const output: GeoMindOutput = {
    schemaVersion: GEOMIND_SCHEMA_VERSION,
    generatedAt: new Date().toISOString(),
    input: rawDocument.input,
    document: {
      ...(cleanedDocument.title ? { title: cleanedDocument.title } : {}),
      stats: cleanedDocument.stats
    },
    extraction,
    entities,
    relations: extraction.relations,
    whiteboard,
    summary,
    warnings: extraction.warnings
  };

  assertValidGeoMindOutput(output);

  if (options.outputPath) {
    await writeJson(options.outputPath, output);
  }

  if (options.whiteboardPath) {
    await writeJson(options.whiteboardPath, whiteboard);
  }

  if (options.htmlPath) {
    await writeText(
      options.htmlPath,
      renderGeoMindHtml(
        output,
        config.tencentMapKey ? { tencentMapKey: config.tencentMapKey } : {}
      )
    );
  }

  if (options.svgPath) {
    await writeText(options.svgPath, renderWhiteboardSvg(whiteboard, summary));
  }

  return output;
}
地理编码增强：
async function enrichEntitiesWithGeocoding(
  entities: EnrichedEntity[],
  options: RunGeoMindOptions,
  config: GeoMindConfig
): Promise<EnrichedEntity[]> {
  if (options.skipGeocode) {
    return entities;
  }

  const geocoder = new TencentGeocoder({
    ...(config.tencentMapKey ? { apiKey: config.tencentMapKey } : {}),
    cachePath: config.geocodeCachePath,
    timeoutMs: config.geocodeTimeoutMs
  });

  const enriched: EnrichedEntity[] = [];

  for (const entity of entities) {
    if (!entity.locationText) {
      enriched.push(entity);
      continue;
    }

    const geocode = await geocoder.geocode(entity.locationText);
    enriched.push({
      ...entity,
      geocode
    });
  }

  return enriched;
}
```

#### 6.7 腾讯地图前端生成

地图前端的核心是：如果配置了 `TENCENT_MAP_KEY`，就加载腾讯地图 JSAPI GL；如果没有配置，就保留 SVG 兜底预览。

```
export function renderGeoMindHtml(
  output: GeoMindOutput,
  options: GeoMindHtmlRenderOptions = {}
): string {
  const svg = renderWhiteboardSvg(output.whiteboard, output.summary)
    .replace(/^<\?xml[^>]+>\n/, "");

  const mapData = buildMapData(output);
  const displayTitle = normalizeDisplayTitle(output.whiteboard.title);

  const scriptSrc = options.tencentMapKey
    ? https://map.qq.com/api/gljs?v=1.exp&key=${encodeURIComponent(options.tencentMapKey)}
    : "";

  return `<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>${escapeHtml(displayTitle)} - GeoMind</title>
</head>
<body>
  <header>
    <h1>${escapeHtml(displayTitle)}</h1>
  </header>
  <main>
    <section class="workspace">
      <div class="map-shell">
        <div id="tencent-map"></div>
        <div class="map-fallback">${svg}</div>
      </div>
      <aside class="side-panel">
        ${renderEntityPanel(output.entities)}
      </aside>
    </section>
  </main>
  ${scriptSrc ? <script src="${scriptSrc}"></script> : ""}
  <script>
    window.GEOMIND_MAP_DATA = ${JSON.stringify(mapData)};
  </script>
</body>
</html>`;
}
```

前端中可以根据经纬度绘制节点和关系线。关系线不是普通直线，而是弧线，并用蓝色荧光样式和流动动画表现“关系正在传输”。

```
function createArcPath(source, target) {
  const midLng = (source.lng + target.lng) / 2;
  const midLat = (source.lat + target.lat) / 2;
  const curveOffset = Math.max(
    Math.abs(source.lng - target.lng),
    Math.abs(source.lat - target.lat)
  ) * 0.25;

  return [
    source,
    {
      lng: midLng,
      lat: midLat + curveOffset
    },
    target
  ];
}
```

#### 6.8 Skill 封装入口

Skill 层把复杂命令行参数收敛成一个更适合工具调用的输入结构。

```
export interface GeoMindSkillInput {
  feishuUrl?: string;
  feishuToken?: string;
  inputFile?: string;
  title?: string;
  outputPath?: string;
  whiteboardPath?: string;
  skipGeocode?: boolean;
}

export async function runGeoMindSkill(
  input: GeoMindSkillInput,
  config: GeoMindConfig = loadGeoMindConfig()
): Promise<GeoMindOutput> {
  const options: RunGeoMindOptions = {
    ...(input.feishuUrl || input.feishuToken
      ? { input: input.feishuUrl ?? input.feishuToken }
      : {}),
    ...(input.inputFile ? { inputFile: input.inputFile } : {}),
    ...(input.title ? { title: input.title } : {}),
    ...(input.outputPath ? { outputPath: input.outputPath } : {}),
    ...(input.whiteboardPath ? { whiteboardPath: input.whiteboardPath } : {}),
    skipGeocode: Boolean(input.skipGeocode)
  };

  return runGeoMind(options, config);
}
```

这样后续接入飞书 CLI Skill 体系、Agent 工具调用或 MCP 工具时，就不需要把内部实现暴露给上层。

## 7.项目输出

一次完整运行会得到三类输出。

第一类是结构化 JSON：

```
{
  "schemaVersion": "0.1.0",
  "generatedAt": "2026-04-28T00:00:00.000Z",
  "entities": [],
  "relations": [],
  "whiteboard": {},
  "summary": {
    "entityCount": 32,
    "relationCount": 35,
    "geocodedCount": 32
  },
  "warnings": []
}
```

第二类是地图前端：

```
output/geomind.html
```

它可以直接在浏览器打开，展示腾讯地图底图、产业节点、弧形荧光关系线、右侧实体面板。

第三类是白板和文档展示素材：

```
output/geomind.svg
output/geomind-feishu-preview.gif
```

## 8.与普通地图 Demo 的区别

GeoMind 不是“把点放到地图上”的 Demo，它更像一个 AI Agent 工作流：

普通地图 Demo:

```
已有坐标 -> 地图展示
```

GeoMind:

```
飞书文档 -> 文本清洗 -> 实体抽取 -> 关系建模
  -> 腾讯位置服务地理编码 -> 地图可视化
  -> 飞书文档展示
```

区别在于**，GeoMind 的输入不是已经整理好的地图数据，而是文档中的非结构化产业信息。地图只是最后的可视化载体，真正的价值在于前面的自动提取、结构化、地理编码和关系建模。**

![Image 9: 请在此添加图片描述](https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/ff5693237cec42e8b8afc560b6ca9b91~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgTHVjaWFuYWlC:q75.awebp?rk3s=f64ab15b&x-expires=1778033656&x-signature=HhQizAIRMQG05dURBmZKo9bJs5I%3D)

## 9.总结：有门槛，但更有价值

整体来看，GeoMind 这个项目并不是一个简单的地图展示 Demo，而是一次把 **飞书CLI、腾讯位置服务、AI Agent工作流和产业地理情报可视化** 串起来的完整尝试。

当然，它也不是完全没有门槛。比如，飞书 CLI 需要一定的本地环境配置，对新手来说可能需要花一点时间；腾讯位置服务 Key 也需要提前申请和配置；如果文档里的地点描述不够标准，地理编码结果可能还需要缓存、兜底或人工校验。另外，当前 MVP 阶段主要依靠规则抽取实体和关系，如果面对特别复杂的自然语言文档，后续最好接入大模型抽取能力，让系统更智能、更灵活。

但这些缺点并不影响 GeoMind 的核心价值，反而说明它是一个真正贴近真实业务场景的工程项目。

GeoMind 最大的亮点在于：它把原本散落在飞书文档里的文字信息，自动转化成了可以理解、可以展示、可以继续分析的地图情报。过去我们看产业调研、科研合作、供应链分布时，往往只能在文档和表格里来回翻找，很难直观看到不同城市、机构和企业之间的空间关系。而 GeoMind 通过实体抽取、关系建模和腾讯位置服务地理编码，把这些隐藏在文字里的信息“搬”到了地图上，让复杂关系变得一眼可见。

更重要的是，它不是只做了一个好看的前端页面，而是跑通了从 **文档读取 → 文本清洗 → 实体抽取 → 关系建模 → 地理编码 → 地图可视化 →飞书文档展示** 的完整闭环。这让它具备很强的扩展性：未来可以接入大模型抽取、知识图谱、飞书机器人、MCP 工具，甚至进一步发展成企业内部的产业情报分析系统。

从参赛作品角度看，GeoMind 也很有辨识度。它没有停留在“把点画到地图上”，而是把腾讯位置服务放进了一个更完整的 AI Agent 工作流里，让地图从展示容器升级成了决策材料生成器。这个方向既能体现腾讯位置服务的地理编码和地图渲染能力，也能展示飞书 CLI 在自动化办公场景中的连接价值。

**总体来说，GeoMind 虽然有一定配置成本，但优点明显大于缺点。它解决的不是一个单点功能问题，而是让“文档里的信息真正变成地图上的智能”。对于科研协作、产业研究、招商选址、供应链分析和企业战略研究这类场景来说，这套工具栈非常值得尝试，也很适合作为 AI + 地图 + 办公自动化结合的创新案例。**

> 这也是我对“飞书 CLI + 腾讯位置服务”组合的一次探索：让文档里的信息真正变成地图上的智能。 本项目已开源👉[github.com/lucianaib03…](https://link.juejin.cn/?target=https%3A%2F%2Fgithub.com%2Flucianaib0318%2FGeoMind "https://github.com/lucianaib0318/GeoMind") 欢迎各位交流~~~
