🚌 台北市公交车
定点车机 AI Skill
接入台北市定点车机实时 API 与 PTX 开放数据,提供公交车实时位置查询、 按站序精确 ETA 估算、路线搜索、站序查询、脱班检测、 热点站位与地图可视化功能。
📖 这个 Skill 能做什么?
taipei-bus 是一个专为 OpenClaw Agent 设计的公交车动态查询技能,
接入三个核心数据来源,专注解决「公交车多久到?」这个最常见的问题。
每笔含车牌、路线、站点、方向
28,741 站全覆盖,已本地缓存
415 条公交车路线完整去返程站序
TstBusEvent 的 RouteID(如 158701)与
PTX 的 RouteID(如 10132,对应 234 线)为独立系统,
两者不相通。需通过「站名」或「路线名」搜索建立对照。
📦 安装方式
支持两种安装方式。方式一(SkillHub)是推荐方式,会自动处理所有依赖。
方式一:SkillHub 安装(推荐)
skillhub_install install_skill taipei-bus
方式二:手动复制
将 lib/ 和 data/ 目录手动复制到你的项目:
# 复制 lib 目录(bus-api.js + route-api.js + stop-coords.js)
cp -r ~/.qclaw/skills/taipei-bus/lib ./taipei-bus-lib
# 复制缓存数据(28,741 站坐标 + 415 条路线站序)
cp -r ~/.qclaw/skills/taipei-bus/data ./taipei-bus-data
# 或完整复制整个 skill
cp -r ~/.qclaw/skills/taipei-bus /your/project/
fetch API(Web Standard Fetch),
需要 Node.js 18 或更高版本。较旧版本请安装 node-fetch 作为 polyfill。
⚡ 第一支公交车查询程序
前置需求
- Node.js 18.0+(含原生
fetch) - 已安装本 Skill 并复制
lib/与data/目录 - 网络连接(对外访问 PTX API + 车机 API)
基本查询:234 线何时到站?
const { searchRouteByName, etaBySequence, getRouteStopSequence } = require('./lib/bus-api.js');
async function main() {
// Step 1:以路名搜索(支持数字、干线、颜色关键字)
const route = searchRouteByName('234');
if (!route) {
console.log('查无此路线');
return;
}
console.log(`找到:${route.routeName}(RouteID: ${route.routeId})`);
// Step 2:获取去程站序
const stops = getRouteStopSequence(route.routeId, 0); // 0=去程, 1=返程
console.log(`共 ${stops.length} 站`);
// Step 3:找"欢仔园"站(可用任意站名)
const target = stops.find(s => s.stopName.includes('欢仔园'));
if (!target) {
console.log('站序中找不到目标站');
return;
}
// Step 4:按站序估算(最精确版本)
const eta = etaBySequence(route.routeId, target.stopId, 0);
console.log(`\n🚌 ${route.routeName} → ${target.stopName}`);
console.log(`⏱️ 预估到站:${eta.minutes} 分钟`);
console.log(`📌 ${eta.note}`);
}
main().catch(console.error);
执行方式
node quickstart.js
# 输出:
# 找到:234(RouteID: 10132)
# 共 40 站
# 🚌 234 → 欢仔园
# ⏱️ 预估到站:8 分钟
# 📌 按站序(第 12/40 站),离目标站尚有 5 站(去程)
🔔 AI Agent 触发关键字
在 OpenClaw Agent 对话中提及以下关键字,即会启用本 Skill
| 类别 | 触发关键字 | 说明 |
|---|---|---|
| 动态 | 公交车动态、等公交车、公交车到站、公交车多久、公交车实时 |
查询公交车到站时间 |
| 追踪 | 公交车追踪、公交车来了、公交车怎么还没到 |
持续追踪特定公交车 |
| 地图 | 公交车地图、公交车车站、公交车热点 |
地图可视化或站位查询 |
| 路线 | 公交车路线、公交车搜索、公交车首班、公交车末班 |
路线信息或搜索 |
| 异常 | 公交车脱班、公交车异常 |
脱班或异常检测 |
| 英文 | bus dynamic、bus ETA、Taipei bus |
英文触发(混合对话) |
📡 四个 API 数据源
| 数据源 | URL / 端点 | 内容说明 | 缓存策略 |
|---|---|---|---|
| 实时 定点车机 OD |
tcgbusfs.blob.core.windows.net/blobbus/TstBusEvent.json |
车牌、路线代码、站点代码、行驶方向、更新时间、勤务状态 | 无(每次实时抓取) |
| PTX Stop 站点坐标 |
ptx.transportdata.tw/MOTC/v2/Bus/Stop/City/Taipei |
28,741 个站点名称、坐标(lat/lon)、StationID | data/stop-coords.json(~2 MB,每季重建) |
| PTX Route 路线清单 |
ptx.transportdata.tw/MOTC/v2/Bus/Route/City/Taipei |
415 条公交车路线名称、起讫站、首末班、业者 | data/route-full-cache.json(~11 MB,每季重建) |
| PTX StopOfRoute 站序 |
ptx.transportdata.tw/MOTC/v2/Bus/StopOfRoute/City/Taipei |
各路线的去程/返程站序,含各站 StopID 与坐标 | data/route-full-cache.json(~11 MB,每季重建) |
scripts/build-cache.py 产生。
若 PTX 数据有更新,执行:
python3 ~/.qclaw/skills/taipei-bus/scripts/build-cache.py
⚙️ 函数完整参考
所有函数以 require('./lib/bus-api.js') 引入
📡 实时车机函数(bus-api.js)
Promise<BusRecord[]> — 按更新时间倒序排列的公交车数组
const { fetchAll } = require('./lib/bus-api.js');
const buses = await fetchAll();
console.log(`全系统共 ${buses.length} 台公交车在线`);
buses.slice(0, 3).forEach(b => console.log(b.BusID, b.RouteID, b.StopID));
Promise<BusRecord[]> — 该路线所有在线公交车
| 参数 | 类型 | 说明 |
|---|---|---|
routeId | string | TstBusEvent 路线代码(如 '158701')⚠️ 非 PTX RouteID |
routeId 是 TstBusEvent 体系的代码(如 158701),
不是 PTX RouteID(如 10132)。建议先用 searchRouteByName 确认。
const { findByRoute } = require('./lib/bus-api.js');
const buses = await findByRoute('158701');
console.log(`路线 ${buses.length} 台公交车在线`);
buses.forEach(b => console.log(`${b.BusID} 在 ${b.StopID}`));
Promise<BusRecord | null> — 找不到时返回 null
const { findByBus, formatBus } = require('./lib/bus-api.js');
const bus = await findByBus('KKC-1100');
if (bus) {
console.log(formatBus(bus, true));
} else {
console.log('查无此车牌或车机未上线');
}
Promise<BusRecord[]> — CarOnStop === '1' 的公交车数组
const { findAtStation } = require('./lib/bus-api.js');
const buses = await findAtStation('179647');
console.log(`站位 ${buses.length} 台停在站位`);
buses.forEach(b => console.log(b.BusID, b.RouteID));
Promise<BusRecord[]> — 异常公交车数组,空数组表示全系统正常
const { detectAnomalies, formatBus } = require('./lib/bus-api.js');
const anomalies = await detectAnomalies();
if (anomalies.length === 0) {
console.log('✅ 全系统正常');
} else {
console.log(`⚠️ ${anomalies.length} 台异常`);
anomalies.forEach(b => console.log(formatBus(b)));
}
Promise<{[routeId]: {count, buses[]}}>
const { routeSummary } = require('./lib/bus-api.js');
const summary = await routeSummary();
const top5 = Object.entries(summary)
.sort(([,a],[,b]) => b.count - a.count)
.slice(0, 5);
top5.forEach(([routeId, {count}]) => {
console.log(`路线 ${routeId}: ${count} 台`);
});
🗺️ PTX 坐标整合函数(bus-api.js + stop-coords.js)
Promise<EnrichedBusRecord[]> — 每条记录附加 _stopCoords 字段
const { fetchAllWithCoords } = require('./lib/bus-api.js');
const buses = await fetchAllWithCoords();
const first = buses[0];
console.log(first.BusID, first.RouteID);
console.log('📍', first._stopCoords?.name,
`(${first._stopCoords?.lat?.toFixed(5)}, ${first._stopCoords?.lon?.toFixed(5)})`);
Promise<Array<{stopId, stopName, lat, lon, count}>>
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
topN | number | 10 | 返回前 N 个热点站 |
const { getHotspots } = require('./lib/bus-api.js');
const hotspots = await getHotspots(5);
hotspots.forEach((h, i) => {
console.log(`${i+1}. ${h.stopName} — ${h.count} 台`);
console.log(` 📍 (${h.lat.toFixed(5)}, ${h.lon.toFixed(5)})`);
});
{name, lat, lon, stationId} | null
const { getStopCoords } = require('./lib/bus-api.js');
const coords = getStopCoords('33210');
if (coords) {
console.log(coords.name); // "公馆"
console.log(coords.lat, coords.lon);
}
Array<{stopId, name, lat, lon...}>
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
keyword | string | — | 站名关键字 |
limit | number | 10 | 最多返回条数 |
const { searchStops } = require('./lib/bus-api.js');
const hits = searchStops('捷运', 5);
hits.forEach(s => {
console.log(`${s.stopId}: ${s.name}`);
console.log(` 📍 (${s.lat?.toFixed(5)}, ${s.lon?.toFixed(5)})`);
});
🚌 PTX 路线整合函数(bus-api.js + route-api.js)
RouteInfo | null — 含 routeId, routeName, departure, terminal
const { searchRouteByName } = require('./lib/bus-api.js');
const route = searchRouteByName('蓝10');
if (route) {
console.log(`${route.routeName}(${route.routeId})`);
console.log(`${route.departure} → ${route.terminal}`);
}
RouteInfo | null
const { getRouteInfo, routeApi } = require('./lib/bus-api.js');
const route = getRouteInfo('10132'); // 234线
console.log(routeApi.formatRoute(route));
RouteInfo[]
const { searchRoutes } = require('./lib/bus-api.js');
const routes = searchRoutes('蓝', 5);
routes.forEach(r => console.log(`${r.routeName}: ${r.departure} → ${r.terminal}`));
StopEntry[] — 含 stopId, stopName, lat, lon
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
routeId | string | — | PTX RouteID |
direction | number | 0 | 0=去程,1=返程 |
const { getRouteStopSequence } = require('./lib/bus-api.js');
const stops = getRouteStopSequence('10132', 0); // 234去程
stops.forEach((s, i) => {
console.log(`${i+1}. ${s.stopName}(${s.stopId})`);
});
{minutes, remaining, total, stop, note}
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
routeId | string | — | PTX RouteID |
targetStopId | string | — | 目标站 PTX StopID |
direction | number | 0 | 0=去程,1=返程 |
avgSecPerStop | number | 90 | 平均每站秒数(默认市区 1.5min) |
const { etaBySequence } = require('./lib/bus-api.js');
const eta = etaBySequence('10132', '33210', 0);
console.log(`预估到站:${eta.minutes} 分钟(${eta.note})`);
console.log(`尚有 ${eta.remaining}/${eta.total} 站`);
{routeId, routeName, direction, index}[]
const { getRoutesByStop } = require('./lib/bus-api.js');
const routes = getRoutesByStop('33210');
routes.forEach(r => {
const dir = r.direction === 0 ? '去程' : '返程';
console.log(`${r.routeName}(${dir})站序第 ${r.index} 站`);
});
⏱️ ETA 精度三级系统
| 等级 | 计算方式 | 精度 | 适用场景 |
|---|---|---|---|
| ★★★★★ 站序版 |
剩余站数 × 90s | ±1-2 站 | 已知公交车位置 + 完整站序时(etaBySequence) |
| ★★★ Haversine |
直线距离 ÷ 25 km/h | ±2-5 分钟 | 有公交车坐标 + 站点坐标时(etaEstimate) |
| ★ Fallback |
500m ÷ 25 km/h = 1.2 分 | 粗估 | 无坐标时(固定站距估算) |
💡 五个实务场景
const { searchRouteByName, etaBySequence, getRouteStopSequence } = require('./lib/bus-api.js');
async function getBusEta(routeName, targetStopName) {
const route = searchRouteByName(routeName);
if (!route) return `查无"${routeName}"路线`;
const stops = getRouteStopSequence(route.routeId, 0);
const target = stops.find(s => s.stopName.includes(targetStopName));
if (!target) return `站序中找不到"${targetStopName}"`;
const eta = etaBySequence(route.routeId, target.stopId, 0);
return `🚌 ${route.routeName} → ${target.stopName}:约 ${eta.minutes} 分钟\n${eta.note}`;
}
await getBusEta('234', '欢仔园');
const { detectAnomalies, formatBus } = require('./lib/bus-api.js');
async function checkSystemHealth() {
const anomalies = await detectAnomalies();
if (anomalies.length === 0) return '✅ 目前全系统公交车正常';
const lines = [`⚠️ 共 ${anomalies.length} 台异常:\n`];
anomalies.forEach(b => {
const reasons = [];
if (b.DutyStatus !== '1') reasons.push(`勤务(${b.DutyStatus})`);
if (b.BusStatus !== '0') reasons.push(`状态(${b.BusStatus})`);
lines.push(`🚨 ${b.BusID} 路线:${b.RouteID} → ${reasons.join('、')}`);
});
return lines.join('\n');
}
await checkSystemHealth();
const { searchRouteByName, getRouteStopSequence } = require('./lib/bus-api.js');
function showRouteStops(routeName, direction = 0) {
const route = searchRouteByName(routeName);
if (!route) return;
const dirLabel = direction === 0 ? '去程' : '返程';
const stops = getRouteStopSequence(route.routeId, direction);
console.log(`🚌 ${route.routeName} ${dirLabel}(共 ${stops.length} 站)\n`);
stops.forEach((s, i) => console.log(` ${String(i+1).padStart(2,'0')}. ${s.stopName}`));
}
showRouteStops('蓝10', 0);
const { searchStops, getRoutesByStop } = require('./lib/bus-api.js');
async function findBusRoutesAtStop(stopKeyword) {
const hits = searchStops(stopKeyword, 3);
if (!hits.length) return `找不到含"${stopKeyword}"的站点`;
const results = [];
for (const stop of hits) {
const routes = getRoutesByStop(stop.stopId);
results.push({
stop: stop.name,
routes: routes.map(r => `${r.routeName}(${r.direction===0?'去':'返'}第${r.index}站)`)
});
}
return results;
}
await findBusRoutesAtStop('捷运公馆');
// 使用 qclaw-cron-skill 设定每 5 分钟检查一次
cron(action='add', job={
name: '台北市公交车脱班检测',
schedule: { kind: 'every', everyMs: 5 * 60 * 1000 },
sessionTarget: 'isolated',
payload: {
kind: 'agentTurn',
message: `执行 detectAnomalies() 并报告结果。若有异常,
输出"⚠️ N台异常:车牌/路线/原因";若无异常,输出"✅ 全系统正常"。`,
timeoutSeconds: 60
},
delivery: { mode: 'announce' }
})
详见 examples/example-cron.js,包含每 5 分钟脱班检测、
每日早晨路线概况报告、一次性到站提醒等多种 Cron Job 模板。
💬 对话式使用场景
用户提问 → Agent 调用函数 → 回复格式
searchRouteByName + etaBySequence→ 约 N 分钟(含站序说明)
searchStops + findAtStation→ N 台停在站位(车牌列表)
findByBus + formatBus→ 车牌/路线/站点/方向/更新时间
getHotspots→ TOP N 热点站(站名 + 车数 + 坐标)
searchRouteByName + getRouteStopSequence→ 完整站名列表(去程/返程)
detectAnomalies→ ✅ 全正常 或 ⚠️ N台异常(含明细)
searchRouteByName + getRouteInfo→ 去程/返程首末班时间
「我在 XX 站,要去 OO 地址」的换乘推荐,需要:
① 地址 geocoding(坐标)→ ② 找最近站点 → ③ 换乘规划。
目前 geocoding 与换乘算法尚未实现,
建议搭配 tencentmap-jsapi-gl-skill(腾讯地图 geocoding API),
或使用 Google Maps / TPTP(大众运输)换乘 API。
⚠️ 使用前请先了解
TstBusEvent 的 RouteID/StopID(如 158701、179647)
与 PTX 的 RouteID/StopID(如 10132、33210)
为独立系统,两者不相通。需通过「站名」或「路线名」搜索建立对照。
etaBySequence),ETA 仍受限於:
① 车机只上报最近一站,非实时 GPS 轨迹;② 缓存是静态 snapshot,
站序实际可能因路况微调。建议将结果标记为「预估」而非「精确」。
tencentmap-jsapi-gl-skill 取得地址坐标,
再配合公交车站点搜索建立衔接,但复杂换乘仍建议转介 Google Maps / TPTP。
data/ 目录)由
scripts/build-cache.py 产生,默认每季重建。
若 PTX 数据有更新(路线调整、站位搬迁),需手动执行重建脚本。
🌐 部署到 GitHub Pages
本文档网站为纯静态 HTML,可直接部署到 GitHub Pages、Netlify、Vercel 等任意静态托管服务。 以下以 GitHub Pages 为例。
docs/ 目录已包含所有 HTML、CSS、JS 文件,
并推送到你的 GitHub 仓库(需建立 gh-pages 分支或使用 main 分支)。
main branch,folder 选择 /docs。
https://<username>.github.io/<repo>/
即可看到文档网站。
docs/CNAME 文件写入你的域名(如 taipei-bus.openclaw.ai),
并在 DNS 供应商设定 CNAME 指向 <username>.github.io。
📄 授权与知识产权
本 Skill 收集、整合的公交车动态数据,依据政府资料开放授权条款(OGDL)提供。 可自由使用于非商业及商业用途,唯需标注资料来源为政府开放资料。
PTX 开放资料平台之数据依其使用规范,允许自由使用、修改与散布。 本 Skill 之 PTX 整合模组以 MIT License 授权。