TripSense 行知 · 周边出行顾问:首版

零依赖 Node 服务,三步流程(选出发地 → 分析候选 → 生成方案 + 路线图 + 住宿参考)。

- 候选发现:高德 place/text 类型词跨城检索 + 内置景区/城市/小众库 + Photon 兜底
- 区县级「排除同城」判定(adname),过滤发生在轮转取样之前
- 路线图画的是 AI 方案里实际安排的每个地点,按正文顺序编号 + 虚线动线 + 真实高德底图
- 住宿:RollingGo 只读 CLI 取实时报价,无价格源时如实标注不编造
- 服务端内存缓存(指纹 + TTL + LRU),数据源层与 HTTP 层分别缓存
- 上下文瘦身:逐日天气只给 rank1,紧凑 JSON,实测约 1335 token
This commit is contained in:
2026-11-05 20:25:03 +08:00
commit c5c2ffae68
53 changed files with 8586 additions and 0 deletions
+214
View File
@@ -0,0 +1,214 @@
'use strict';
// ------------------------------------------------------------------ 通用工具
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
const round = (v, n = 0) => {
const p = 10 ** n;
return Math.round((Number(v) || 0) * p) / p;
};
/** 带超时的 fetch,返回原始 Response */
async function fetchWithTimeout(url, opts = {}, timeoutMs = 15000) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
try {
return await fetch(url, { ...opts, signal: ctrl.signal });
} finally {
clearTimeout(timer);
}
}
async function fetchJSON(url, opts = {}, timeoutMs = 15000, retries = 2) {
let lastErr;
for (let attempt = 0; attempt <= retries; attempt += 1) {
try {
const res = await fetchWithTimeout(url, opts, timeoutMs);
if (!res.ok) {
const text = await res.text().catch(() => '');
// 4xx 是确定性错误,不必重试
if (res.status >= 400 && res.status < 500) {
throw new Error(`HTTP ${res.status} ${res.statusText} ${text.slice(0, 160)}`);
}
throw new Error(`HTTP ${res.status} ${res.statusText} ${text.slice(0, 160)}`);
}
return await res.json();
} catch (err) {
lastErr = err;
if (attempt < retries) await new Promise((r) => setTimeout(r, 300 * (attempt + 1)));
}
}
throw lastErr;
}
/** 并发限制的 map,失败不中断整体 */
async function mapLimit(items, limit, fn) {
const out = new Array(items.length);
let cursor = 0;
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (true) {
const i = cursor++;
if (i >= items.length) return;
try {
out[i] = await fn(items[i], i);
} catch (err) {
out[i] = null;
console.warn(`[mapLimit] ${i} failed: ${err.message}`);
}
}
});
await Promise.all(workers);
return out;
}
/** 安全地尝试,失败返回 fallback */
async function attempt(label, fn, fallback) {
try {
return await fn();
} catch (err) {
console.warn(`[${label}] ${err.message}`);
return typeof fallback === 'function' ? fallback(err) : fallback;
}
}
// ------------------------------------------------------------------ 地理计算
const R_EARTH = 6371;
const toRad = (d) => (d * Math.PI) / 180;
function haversine(lat1, lon1, lat2, lon2) {
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return 2 * R_EARTH * Math.asin(Math.sqrt(a));
}
/** 方位角:0 = 正北,顺时针递增 */
function bearing(lat1, lon1, lat2, lon2) {
const p1 = toRad(lat1);
const p2 = toRad(lat2);
const dl = toRad(lon2 - lon1);
const y = Math.sin(dl) * Math.cos(p2);
const x = Math.cos(p1) * Math.sin(p2) - Math.sin(p1) * Math.cos(p2) * Math.cos(dl);
return (Math.atan2(y, x) * 180) / Math.PI;
}
const COMPASS_8 = ['北', '东北', '东', '东南', '南', '西南', '西', '西北'];
function compassLabel(deg) {
return COMPASS_8[Math.round((((deg % 360) + 360) % 360) / 45) % 8];
}
/** WGS-84 -> GCJ-02(国内高德/腾讯地图需要火星坐标) */
function wgs84ToGcj02(lng, lat) {
const PI = Math.PI;
const A = 6378245.0;
const EE = 0.00669342162296594323;
const outOfChina = (l, b) => l < 73.66 || l > 135.05 || b < 3.86 || b > 53.55;
if (outOfChina(lng, lat)) return [lng, lat];
const transformLat = (x, y) => {
let r = -100 + 2 * x + 3 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x));
r += ((20 * Math.sin(6 * x * PI) + 20 * Math.sin(2 * x * PI)) * 2) / 3;
r += ((20 * Math.sin(y * PI) + 40 * Math.sin((y / 3) * PI)) * 2) / 3;
r += ((160 * Math.sin((y / 12) * PI) + 320 * Math.sin((y * PI) / 30)) * 2) / 3;
return r;
};
const transformLon = (x, y) => {
let r = 300 + x + 2 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x));
r += ((20 * Math.sin(6 * x * PI) + 20 * Math.sin(2 * x * PI)) * 2) / 3;
r += ((20 * Math.sin(x * PI) + 40 * Math.sin((x / 3) * PI)) * 2) / 3;
r += ((150 * Math.sin((x / 12) * PI) + 300 * Math.sin((x / 30) * PI)) * 2) / 3;
return r;
};
let dLat = transformLat(lng - 105, lat - 35);
let dLon = transformLon(lng - 105, lat - 35);
const radLat = (lat / 180) * PI;
let magic = Math.sin(radLat);
magic = 1 - EE * magic * magic;
const sqrtMagic = Math.sqrt(magic);
dLat = (dLat * 180) / ((A * (1 - EE)) / (magic * sqrtMagic) * PI);
dLon = (dLon * 180) / (A / sqrtMagic * Math.cos(radLat) * PI);
return [lng + dLon, lat + dLat];
}
/** GCJ-02 -> WGS-84(迭代逼近,误差 < 1m),用于把高德结果转回通用坐标 */
function gcj02ToWgs84(lng, lat) {
let wLng = lng;
let wLat = lat;
for (let i = 0; i < 5; i += 1) {
const [gLng, gLat] = wgs84ToGcj02(wLng, wLat);
wLng += lng - gLng;
wLat += lat - gLat;
}
return [wLng, wLat];
}
/** 由中心点与公里半径得到 bbox:minLon,minLat,maxLon,maxLat */
function bboxFromRadius(lat, lon, radiusKm) {
const dLat = radiusKm / 111.32;
const cos = Math.max(0.2, Math.cos(toRad(lat)));
const dLon = radiusKm / (111.32 * cos);
return [lon - dLon, lat - dLat, lon + dLon, lat + dLat].map((v) => round(v, 4));
}
// -------------------------------------------------------------- 日期工具
function todayISO() {
const d = new Date();
return new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 10);
}
function isoToUTC(iso) {
const [y, m, d] = iso.split('-').map(Number);
return Date.UTC(y, m - 1, d);
}
function utcToISO(ms) {
return new Date(ms).toISOString().slice(0, 10);
}
function addDaysISO(iso, n) {
return utcToISO(isoToUTC(iso) + n * 86400000);
}
function addYearsISO(iso, n) {
const [y, m, d] = iso.split('-').map(Number);
// 处理 2/29
const day = m === 2 && d === 29 ? 28 : d;
return `${y + n}-${String(m).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}
function diffDays(aISO, bISO) {
return Math.round((isoToUTC(bISO) - isoToUTC(aISO)) / 86400000);
}
function eachDate(startISO, endISO, cap = 31) {
const out = [];
const n = Math.min(cap, Math.max(0, diffDays(startISO, endISO)) + 1);
for (let i = 0; i < n; i += 1) out.push(addDaysISO(startISO, i));
return out;
}
function weekdayCN(iso) {
return ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][new Date(isoToUTC(iso)).getUTCDay()];
}
function isValidISO(s) {
return /^\d{4}-\d{2}-\d{2}$/.test(s) && !Number.isNaN(isoToUTC(s));
}
function chunk(arr, size) {
const out = [];
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
return out;
}
// ------------------------------------------------------------------- 其它
function normalizeName(s) {
return String(s || '').replace(/[\s·・()()\[\]【】"'"']/g, '').toLowerCase();
}
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
module.exports = {
clamp, round, fetchWithTimeout, fetchJSON, mapLimit, attempt,
haversine, bearing, compassLabel, bboxFromRadius, wgs84ToGcj02, gcj02ToWgs84,
todayISO, isoToUTC, utcToISO, addDaysISO, addYearsISO, diffDays, eachDate,
weekdayCN, isValidISO, chunk, normalizeName, esc,
};