从飞书文档到全球产业版图,一键生成关系地图。 自动提取 -> 地理编码 -> 关系建模 -> 地图可视化。
本项目已开源👉github.com/lucianaib03...
开发者对于腾讯位置服务的开发,常见方式一般是做地图可视化:把点、线、面、POI、轨迹、热力图展示到地图上,或者围绕旅游攻略、路线推荐、商业选址做一些单点能力。
这些方向当然有价值,但我一直觉得还缺少一个东西:地图能力没有真正进入 AI Agent 的工作流。
如果只是把数据画到地图上,地图仍然只是一个展示容器。GeoMind 想解决的问题是:能不能让地图成为 AI 处理信息、理解关系、输出决策材料的一部分?
飞书 CLI 是一个很强的自动化入口。飞书本身已经具备大量能力,例如通过飞书 Aily 做文档分析展示、消息提醒、多维表到期自动化提醒、飞书群实时监控等。很多只发生在飞书内部的操作,飞书原生能力或飞书 Aily 已经可以完成。
所以这次我没有选择重复做一个普通的飞书文档分析工具,而是提出一个更具体的问题:
当飞书CLI和腾讯位置服务真正结合以后,会发生什么?
GeoMind 给出的答案是:把飞书文档中的科研机构、企业、厂址、实验室、园区、供应链节点和合作关系,自动抽取成结构化地理情报,再通过腾讯位置服务完成地理编码,最后生成一张可运行、可演示、可继续扩展的产业关系地图。
这就是本次参赛作品:科研与产业地理情报可视化 Skill。
先看产品 Demo。
1.运行效果动图(可运行的 Demo)
Demo 主题为"中国新能源与智能制造产业分布网络"。

系统会从示例文档中提取产业实体与关系,并在腾讯地图底图上展示全国范围内的产业协同关系。
上面这个效果不是静态概念图,而是由项目生成的可运行 Demo。核心展示包括:
- 在真实腾讯地图底图上展示产业节点。
- 按实体类型区分科研机构、企业、工厂、实验室、产业园区、供应链节点等。
- 使用蓝色荧光弧线呈现跨区域协作、供应、联合实验室、技术转移等关系。
- 使用流动动画表现"数据、技术或供应链能力正在传输"的过程。
- 右侧面板支持查看实体列表、地点、技术领域和定位状态。
- 可以把生成的 HTML、GIF 或图片插入飞书文档中,形成"文档 + 地图 + 可视化附件"的展示方式。
2.技术架构与实现思路
GeoMind 的目标不是只生成一张图片,而是做一个可以复用、可以开源、可以接入 Skill 体系的工程。整体流程围绕飞书 CLI、腾讯位置服务、地图可视化和结构化数据校验展开。

项目采用 TypeScript + Node.js,核心模块拆分如下:
md
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 提供文档读取、自动化执行和后续写回飞书的入口。两者结合以后,就形成了一个更完整的智能应用方案:

这样做以后,飞书不再只是"资料存放处",腾讯地图也不再只是"展示底图"。两者共同构成了一个 AI Agent 可执行的地理情报工作流。
实际场景包括:
- 科研机构合作网络分析:识别高校、研究院、实验室之间的联合研发关系。
- 新能源产业链分布分析:展示电池、储能、光伏、智能装备企业之间的供应链网络。
- 招商与园区选址分析:把目标企业、产业园区、交通节点和区域政策放在同一张地图中比较。
- 企业战略研究:快速看出某一领域的研发、制造、供应链和客户节点分布。
- 飞书文档自动化展示:把原本文字型资料转成可视化附件,方便汇报、评审和协作。
当前 MVP 已经跑通从文档到地图的闭环。
4.如何获取相关 API 与运行项目
这一部分是我认为参赛项目里非常关键的地方:Demo 不应该只停留在截图,而应该尽量让别人可以真实跑起来。
所以写了本章节。
4.1 准备 Node.js
项目使用 TypeScript + Node.js,建议安装 Node.js 20 以上版本。
Bash
node -v
npm -v
4.2 获取腾讯位置服务 Key
腾讯位置服务 Key 用于两件事:
- 后端调用 WebService Geocoder,把地点文本转换成经纬度。
- 前端加载腾讯地图 JavaScript API GL,展示真实地图底图。
获取方式:
- 打开腾讯位置服务控制台:lbs.qq.com/dev/console...
- 登录腾讯位置服务账号。
- 创建应用。
- 在应用下创建 Key。
- 根据需要开启 WebService API 和 JavaScript API GL 相关能力。
- 如果前端页面需要在浏览器中展示,建议配置 WebService 和域名白名单。
- 把 Key 写入本地
.env文件。


.env 示例:
Bash
TENCENT_MAP_KEY=your-tencent-map-key
GEOCODE_CACHE_PATH=cache/geocode-cache.json
GEOCODE_TIMEOUT_MS=8000
如果只是跑离线演示,可以使用:
Bash
npm run demo:offline
离线模式会跳过真实地理编码,依赖示例数据或兜底逻辑完成演示。
4.3 安装飞书 CLI
安装飞书 CLI:
Bash
npm install -g @larksuite/cli
安装 Skills:
Bash
npx skills add larksuite/cli -y -g
初始化与登录:
Bash
lark-cli config init --new
lark-cli auth login --recommend
lark-cli auth status
lark-cli doctor
Windows PowerShell 如果遇到 lark-cli.ps1 无法执行,通常是 PowerShell 执行策略限制。可以临时使用:
PowerShell
lark-cli.cmd --version
或者调整当前用户执行策略:
PowerShell
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
如果系统找不到 lark-cli,检查 npm 全局 bin 是否在 Path 中:
PowerShell
$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,例如:
Plain
https://example.feishu.cn/wiki/RclvwAdA2igSAMk7cqhcJDt1nPf
上面这个 URL 里的:
Plain
RclvwAdA2igSAMk7cqhcJDt1nPf

就是 wiki token。
常见 token 类型:
Plain
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 中可以配置:
Bash
FEISHU_CLI_COMMAND_TEMPLATE="lark-cli docs fetch --doc {url} --format json"
支持的占位符:
Plain
{url} 飞书文档 URL
{token} 从 URL 中解析出的 token
{kind} 文档类型,例如 wiki/doc/docx/unknown
如果你的飞书 CLI 命令和上面不同,只需要修改模板,不需要改 GeoMind 的主流程代码。
4.6 克隆和运行
Bash
git clone git@github.com:lucianaib0318/GeoMind.git
cd GeoMind
npm install
cp .env.example .env
Windows PowerShell:
PowerShell
Copy-Item .env.example .env
运行示例 Demo:
Bash
npm run demo
运行离线 Demo:
Bash
npm run demo:offline
输出文件:
Plain
examples/sample-output.json # 结构化 JSON
output/geomind.html # 腾讯地图交互前端
output/geomind.svg # 白板 DSL 的 SVG 预览
打开地图(示例),你可以换为你自己的:
Bash
start output/geomind.html

macOS:
Bash
open output/geomind.html
Linux:
Bash
xdg-open output/geomind.html
4.7 使用真实飞书文档运行
传入飞书文档 URL:
Bash
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 做演示:
Bash
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。为了提高抽取稳定性,示例文档中可以使用明确的实体和关系描述。
Plain
# 中国新能源与智能制造产业分布网络
实体: 北京智能制造协调中心 | 类型: 政府机构 | 地点: 北京海淀 | 技术: 智能制造、大模型、工业互联网
实体: 上海张江研发中心 | 类型: 实验室 | 地点: 上海浦东新区 | 技术: 高端装备、风电设备、工业自动化
实体: 深圳南山 AI 计算中心 | 类型: 企业 | 地点: 广东深圳市南山区 | 技术: AI 计算、边缘计算
实体: 合肥动力电池工厂 | 类型: 工厂 | 地点: 安徽合肥市 | 技术: 动力电池、储能系统
实体: 西安新能源材料中转中心 | 类型: 供应链节点 | 地点: 陕西西安市 | 技术: 单晶硅材料、光伏组件
关系: 北京智能制造协调中心 -> 上海张江研发中心 | 类型: 协同合作 | 证据: 共同推进智能制造标准验证。
关系: 深圳南山 AI 计算中心 -> 合肥动力电池工厂 | 类型: 技术转移 | 证据: 提供电池产线视觉检测模型。
关系: 西安新能源材料中转中心 -> 合肥动力电池工厂 | 类型: 供应 | 证据: 提供新能源材料中转服务。
抽取后会变成类似结构:
JSON
{
"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。
TypeScript
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 命令写死。
TypeScript
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 文本清洗
文档内容进入抽取模块之前,要先做清洗。清洗不是为了"美化文本",而是为了减少格式噪声对实体识别的影响。
JavaScript
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。这里的原则是:先让工程跑通,再逐步增强模型能力。
TypeScript
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 失败时返回兜底坐标或失败状态。
TypeScript
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;
}
}
}
真正请求腾讯位置服务的逻辑:
TypeScript
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);
}
}
缓存逻辑:
TypeScript
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
主流程把"读取文档 -> 清洗 -> 抽取 -> 地理编码 -> 生成可视化"串起来。
TypeScript
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 兜底预览。
XML
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>`;
}
前端中可以根据经纬度绘制节点和关系线。关系线不是普通直线,而是弧线,并用蓝色荧光样式和流动动画表现"关系正在传输"。
JavaScript
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 层把复杂命令行参数收敛成一个更适合工具调用的输入结构。
TypeScript
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:
JSON
{
"schemaVersion": "0.1.0",
"generatedAt": "2026-04-28T00:00:00.000Z",
"entities": [],
"relations": [],
"whiteboard": {},
"summary": {
"entityCount": 32,
"relationCount": 35,
"geocodedCount": 32
},
"warnings": []
}
第二类是地图前端:
Plain
output/geomind.html
它可以直接在浏览器打开,展示腾讯地图底图、产业节点、弧形荧光关系线、右侧实体面板。
第三类是白板和文档展示素材:
Plain
output/geomind.svg
output/geomind-feishu-preview.gif
8.与普通地图 Demo 的区别
GeoMind 不是"把点放到地图上"的 Demo,它更像一个 AI Agent 工作流:
普通地图 Demo:
Plain
已有坐标 -> 地图展示
GeoMind:
Plain
飞书文档 -> 文本清洗 -> 实体抽取 -> 关系建模
-> 腾讯位置服务地理编码 -> 地图可视化
-> 飞书文档展示
区别在于**,GeoMind 的输入不是已经整理好的地图数据,而是文档中的非结构化产业信息。地图只是最后的可视化载体,真正的价值在于前面的自动提取、结构化、地理编码和关系建模。**

9.总结:有门槛,但更有价值
整体来看,GeoMind 这个项目并不是一个简单的地图展示 Demo,而是一次把 飞书CLI、腾讯位置服务、AI Agent工作流和产业地理情报可视化 串起来的完整尝试。
当然,它也不是完全没有门槛。比如,飞书 CLI 需要一定的本地环境配置,对新手来说可能需要花一点时间;腾讯位置服务 Key 也需要提前申请和配置;如果文档里的地点描述不够标准,地理编码结果可能还需要缓存、兜底或人工校验。另外,当前 MVP 阶段主要依靠规则抽取实体和关系,如果面对特别复杂的自然语言文档,后续最好接入大模型抽取能力,让系统更智能、更灵活。
但这些缺点并不影响 GeoMind 的核心价值,反而说明它是一个真正贴近真实业务场景的工程项目。
GeoMind 最大的亮点在于:它把原本散落在飞书文档里的文字信息,自动转化成了可以理解、可以展示、可以继续分析的地图情报。过去我们看产业调研、科研合作、供应链分布时,往往只能在文档和表格里来回翻找,很难直观看到不同城市、机构和企业之间的空间关系。而 GeoMind 通过实体抽取、关系建模和腾讯位置服务地理编码,把这些隐藏在文字里的信息"搬"到了地图上,让复杂关系变得一眼可见。
更重要的是,它不是只做了一个好看的前端页面,而是跑通了从 文档读取 → 文本清洗 → 实体抽取 → 关系建模 → 地理编码 → 地图可视化 →飞书文档展示 的完整闭环。这让它具备很强的扩展性:未来可以接入大模型抽取、知识图谱、飞书机器人、MCP 工具,甚至进一步发展成企业内部的产业情报分析系统。
从参赛作品角度看,GeoMind 也很有辨识度。它没有停留在"把点画到地图上",而是把腾讯位置服务放进了一个更完整的 AI Agent 工作流里,让地图从展示容器升级成了决策材料生成器。这个方向既能体现腾讯位置服务的地理编码和地图渲染能力,也能展示飞书 CLI 在自动化办公场景中的连接价值。
总体来说,GeoMind 虽然有一定配置成本,但优点明显大于缺点。它解决的不是一个单点功能问题,而是让"文档里的信息真正变成地图上的智能"。对于科研协作、产业研究、招商选址、供应链分析和企业战略研究这类场景来说,这套工具栈非常值得尝试,也很适合作为 AI + 地图 + 办公自动化结合的创新案例。
这也是我对"飞书 CLI + 腾讯位置服务"组合的一次探索:让文档里的信息真正变成地图上的智能。 本项目已开源👉github.com/lucianaib03... 欢迎各位交流~~~