Files
tamakyi c5c2ffae68 TripSense 行知 · 周边出行顾问:首版
零依赖 Node 服务,三步流程(选出发地 → 分析候选 → 生成方案 + 路线图 + 住宿参考)。

- 候选发现:高德 place/text 类型词跨城检索 + 内置景区/城市/小众库 + Photon 兜底
- 区县级「排除同城」判定(adname),过滤发生在轮转取样之前
- 路线图画的是 AI 方案里实际安排的每个地点,按正文顺序编号 + 虚线动线 + 真实高德底图
- 住宿:RollingGo 只读 CLI 取实时报价,无价格源时如实标注不编造
- 服务端内存缓存(指纹 + TTL + LRU),数据源层与 HTTP 层分别缓存
- 上下文瘦身:逐日天气只给 rank1,紧凑 JSON,实测约 1335 token
2026-11-05 20:25:03 +08:00

215 lines
7.2 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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,
};