-
Notifications
You must be signed in to change notification settings - Fork 1.9k
Expand file tree
/
Copy pathparse.js
More file actions
406 lines (373 loc) · 17.7 KB
/
Copy pathparse.js
File metadata and controls
406 lines (373 loc) · 17.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
// 坐标解析: 接受地图链接(苹果地图 / 高德, 含短链), 抠出经纬度+名称。
// 高德为 GCJ-02; 苹果地图在中国大陆同为 GCJ-02。两者都转 WGS84 再喂给 wloc;
// gcj02ToWgs84 内含 out_of_china 判断, 境外坐标原样返回(无操作)。
export function safeDecode(s) {
if (!s) return "";
try {
return decodeURIComponent(String(s).replace(/\+/g, " "));
} catch (e) {
return String(s);
}
}
// 从一段字符串里提取经纬度+名称。兼容:
// 苹果地图 coordinate=/ll=/sll=纬度,经度 (名称在 name=...)
// 高德 ?p=POIID,纬度,经度,名称,城市 (逗号或 %2C)
// 高德 ?q=纬度,经度,名称 (新版分享链, 逗号或 %2C)
// 纯文本 纬度,经度
// 高德 URI ?lnglat=/?position=经度,纬度 (与上面几条顺序相反)
// opts.allowBare=false 时不启用"两个裸小数"兜底。扫描页面正文必须关掉它:
// 正文里任何一对小数都会命中(百度页面的 "view_dir":"-0.8477,0.0000" 就是如此),
// 结果是静默返回一个错误坐标 —— 比解析失败危险得多。
export function extractFromString(s, opts) {
const hit = extractRaw(s, opts);
// 值域是最后一道闸。上面的兜底规则不带语义, 匹配到什么就返回什么, 经纬颠倒
// (lat=113.9)或纯粹的垃圾数字都能一路走到调用方。这里拦掉的是"解析成了错的",
// 它比"解析失败"危险得多 —— 后者会提示用户, 前者会把设备定位挪到别处。
return hit && inRange(hit.lat, hit.lon) ? hit : null;
}
// 纬度绝对值 <= 90, 经度 <= 180; NaN / Infinity 一并挡掉。
export function inRange(lat, lon) {
return (
Number.isFinite(lat) && Number.isFinite(lon) && Math.abs(lat) <= 90 && Math.abs(lon) <= 180
);
}
function extractRaw(s, opts) {
if (!s) return null;
const allowBare = !opts || opts.allowBare !== false;
const str = String(s);
let m;
// 前缀 (?:^|[?&]) 是必需的: 无锚定时 "ll=" 会匹配任何以 ll 结尾的参数名,
// 例如 scroll=1.5,2.5 / pull=... 都会被当成坐标。
m = str.match(/(?:^|[?&])(?:coordinate|ll|sll)=(-?\d{1,3}\.\d+)(?:,|%2C)(-?\d{1,3}\.\d+)/i);
if (m) return { lat: +m[1], lon: +m[2], name: queryName(str), src: "apple" };
// Google: !3d<lat>!4d<lon> 是地点针脚的真实坐标, 必须优先于 @lat,lon —— 后者是
// 相机视口中心, 与缩放级别绑定, 可以离目标十几公里。
m = str.match(/!3d(-?\d{1,3}\.\d+)!4d(-?\d{1,3}\.\d+)/);
if (m) return { lat: +m[1], lon: +m[2], name: googleName(str), src: "google" };
m = str.match(
/[?&]p=[^,&%]*(?:,|%2C)(-?\d{1,3}\.\d+)(?:,|%2C)(-?\d{1,3}\.\d+)(?:(?:,|%2C)((?:(?!,|%2C|&).)+))?/i
);
if (m) return { lat: +m[1], lon: +m[2], name: m[3] ? safeDecode(m[3]) : "", src: "amap" };
m = str.match(
/[?&]q=(-?\d{1,3}\.\d+)(?:,|%2C)(-?\d{1,3}\.\d+)(?:(?:,|%2C)((?:(?!,|%2C|&).)+))?/i
);
if (m) return { lat: +m[1], lon: +m[2], name: m[3] ? safeDecode(m[3]) : "", src: "amap" };
// 高德 URI API 的 lnglat= / position= 是「经度,纬度」序, 与上面所有规则相反。
// 不要照搬旧页面里的 location=/center= 规则: 那条也按 lon,lat 解, 但百度的
// location= 实际是 lat,lng, 搬过来会把百度链接解颠倒。宁可少认一种也不要认错。
m = str.match(/(?:^|[?&])(?:lnglat|position)=(-?\d{1,3}\.\d+)(?:,|%2C)(-?\d{1,3}\.\d+)/i);
if (m) return { lat: +m[2], lon: +m[1], name: queryName(str), src: "amap" };
// 百度网页版把 BD09MC 米制坐标写进路径: /poi/名称/@12709535.375,2529761.45,19z
// 位数(6~9)本身就把它和经纬度形式的 @ 区分开了。
// 这是港澳台百度链接在服务端唯一能拿到坐标的形式 —— 那些地区的分享短链展开后
// 正文里没有坐标, 得由页面脚本带反爬令牌去查 detailConInfo, Worker 复现不了。
m = str.match(/baidu\.com\/[^\s]*?@(-?\d{6,9}(?:\.\d+)?)(?:,|%2C)(-?\d{6,9}(?:\.\d+)?)/i);
if (m) {
const bd = bd09mcToBd09(+m[1], +m[2]);
if (bd) return { lat: bd.lat, lon: bd.lon, name: baiduPathName(str), src: "baidu" };
}
// 只有在没有针脚坐标时才退而求其次用视口中心。
m = str.match(/\/maps\/[^\s]*@(-?\d{1,3}\.\d+),(-?\d{1,3}\.\d+)/);
if (m) return { lat: +m[1], lon: +m[2], name: googleName(str), src: "google" };
if (allowBare) {
m = str.match(/(-?\d{1,3}\.\d{4,})\s*(?:,|%2C)\s*(-?\d{1,3}\.\d{4,})/);
if (m) return { lat: +m[1], lon: +m[2], name: "", src: "text" };
}
return null;
}
// 查询串里的 ?name=/ &name= —— 苹果地图和高德 URI 都用这个键。
function queryName(str) {
const m = str.match(/[?&]name=([^&]+)/i);
return m ? safeDecode(m[1]) : "";
}
// 百度网页版的地名在路径里: /poi/Apple台北101/@...
function baiduPathName(str) {
const m = str.match(/\/poi\/([^/@?]+)/);
return m ? safeDecode(m[1]).trim() : "";
}
// Google 的地名在路径里: /maps/place/Apple+Park/@...
function googleName(str) {
const m = str.match(/\/maps\/place\/([^/@?]+)/);
return m ? safeDecode(m[1]).replace(/\+/g, " ").trim() : "";
}
// /api/parse 会去 fetch 调用方给的任意 URL。Workers 出网到不了内网, 所以经典的
// SSRF(打内网/元数据服务)基本不成立, 剩下的风险是资源耗尽 —— 一个永不结束的响应
// 能把子请求挂死, 一个几百 MB 的响应能把 128 MB 的 Worker 内存打爆。下面两个常量
// 和 isFetchable() 挡的就是这个, 而不是"防止访问某些站点"。
const FETCH_TIMEOUT_MS = 8000;
const MAX_BODY_BYTES = 512 * 1024;
function isFetchable(u) {
let url;
try {
url = new URL(u);
} catch (e) {
return false;
}
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
const h = url.hostname.toLowerCase();
if (h === "localhost" || h.endsWith(".local") || h.endsWith(".internal")) return false;
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(h) || h.startsWith("[")) return false; // IP 字面量
return true;
}
// 只读前 MAX_BODY_BYTES, 读满就掐掉连接。坐标总在页面靠前的位置, 读全文没有收益。
async function readCapped(resp) {
if (!resp.body || typeof resp.body.getReader !== "function") {
return (await resp.text()).slice(0, MAX_BODY_BYTES);
}
const reader = resp.body.getReader();
const chunks = [];
let total = 0;
while (total < MAX_BODY_BYTES) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
total += value.length;
}
try {
await reader.cancel();
} catch (e) {}
const buf = new Uint8Array(total);
let off = 0;
for (const c of chunks) {
buf.set(c, off);
off += c.length;
}
return new TextDecoder("utf-8", { fatal: false }).decode(buf);
}
function isBaiduHost(u) {
try {
return /(^|\.)baidu\.com$/i.test(new URL(u).hostname);
} catch (e) {
return false;
}
}
// 接受原文(可能含中文地名+链接), 抠出 URL, 必要时跟随重定向展开短链, 提取坐标。
export async function parseCoords(raw) {
const text = String(raw || "").trim();
if (!text) throw new Error("空输入");
const urlMatch = text.match(/https?:\/\/[^\s'"<>]+/i);
let target = urlMatch ? urlMatch[0] : text;
let hit = extractFromString(target);
if (hit) return hit;
if (urlMatch) {
let cur = target;
for (let i = 0; i < 5; i++) {
if (!isFetchable(cur)) break;
let resp;
try {
resp = await fetch(cur, {
redirect: "manual",
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
headers: {
"user-agent":
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/27.0 Mobile/24A5370h Safari/604.1",
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"accept-language": "zh-CN,zh-Hans;q=0.9",
},
});
} catch (e) {
break;
}
const loc = resp.headers.get("location");
if (loc) {
hit = extractFromString(loc);
if (hit) return hit;
cur = new URL(loc, cur).toString();
hit = extractFromString(cur);
if (hit) return hit;
continue;
}
hit = extractFromString(resp.url);
if (hit) return hit;
try {
const body = await readCapped(resp);
hit = extractFromString(body, { allowBare: false });
if (hit) return hit;
// 百度分享链展开后 URL 里只有 uid, 坐标以 BD09MC 墨卡托米制藏在正文中。
if (isBaiduHost(cur)) {
hit = extractBaiduFromBody(body);
if (hit) return hit;
}
} catch (e) {}
break;
}
}
// 百度对大陆 POI 会把坐标直出在移动版页面里, 港澳台的则不会 —— 那边要靠页面
// 脚本带 auth/seckey 反爬令牌去查 detailConInfo, 服务端无法复现。与其只说一句
// "解析不了", 不如告诉用户那条确实走得通的路。
if (urlMatch && isBaiduHost(target)) {
throw new Error(
"百度这条链接的坐标要靠网页脚本才能取到(港澳台的 POI 多为此类)。" +
"请在浏览器打开该链接, 等地址栏变成 map.baidu.com/poi/名称/@数字,数字,19z 之后, 复制整条地址再粘贴。"
);
}
throw new Error("未能从链接中解析出经纬度");
}
export function round6(n) {
return Math.round(Number(n) * 1e6) / 1e6;
}
// ---- 百度: BD09MC(墨卡托米制) -> BD09(经纬度) ----
// 百度用的不是标准 Web 墨卡托, 而是按纬度分 6 段的高次多项式拟合。
// 用标准墨卡托逆算会差约 10 公里, 必须用下面这张系数表。
const MCBAND = [12890594.86, 8362377.87, 5591021, 3481989.83, 1678043.12, 0];
const MC2LL = [
[1.410526172116255e-8, 8.98305509648872e-6, -1.9939833816331, 200.9824383106796, -187.2403703815547, 91.6087516669843, -23.38765649603339, 2.57121317296198, -0.03801003308653, 1.73379812e7],
[-7.435856389565537e-9, 8.983055097726239e-6, -0.78625201886289, 96.32687599759846, -1.85204757529826, -59.36935905485877, 47.40033549296737, -16.50741931063887, 2.28786674699375, 1.026014486e7],
[-3.030883460898826e-8, 8.98305509983578e-6, 0.30071316287616, 59.74293618442277, 7.357984074871, -25.38371002664745, 13.45380521110908, -3.29883767235584, 0.32710905363475, 6.85681737e6],
[-1.981981304930552e-8, 8.983055099779535e-6, 0.03278182852591, 40.31678527705744, 0.65659298677277, -4.44255534477492, 0.85341911805263, 0.12923347998204, -0.04625736007561, 4.48277706e6],
[3.09191371068437e-9, 8.983055096812155e-6, 6.995724062e-5, 23.10934304144901, -0.00023663490511, -0.6321817810242, -0.00663494467273, 0.03430082397953, -0.00466043876332, 2.5551644e6],
[2.890871144776878e-9, 8.983055095805407e-6, -3.068298e-8, 7.47137025468032, -3.53937994e-6, -0.02145144861037, -1.234426596e-5, 0.00010322952773, -3.23890364e-6, 8.260885e5],
];
export function bd09mcToBd09(x, y) {
const ax = Math.abs(x), ay = Math.abs(y);
let f = null;
for (let i = 0; i < MCBAND.length; i++) {
if (ay >= MCBAND[i]) { f = MC2LL[i]; break; }
}
if (!f) return null;
const c = ay / f[9];
let lon = f[0] + f[1] * ax;
let lat = f[2] + f[3] * c + f[4] * c ** 2 + f[5] * c ** 3 + f[6] * c ** 4 + f[7] * c ** 5 + f[8] * c ** 6;
lon *= x < 0 ? -1 : 1;
lat *= y < 0 ? -1 : 1;
return { lat, lon };
}
// BD09 -> GCJ02 (百度在 GCJ 之上再加了一层自有偏移)
const X_PI = (Math.PI * 3000) / 180;
export function bd09ToGcj02(lat, lon) {
const x = lon - 0.0065, y = lat - 0.006;
const z = Math.sqrt(x * x + y * y) - 0.00002 * Math.sin(y * X_PI);
const t = Math.atan2(y, x) - 0.000003 * Math.cos(x * X_PI);
return { lat: z * Math.sin(t), lon: z * Math.cos(t) };
}
// ---- 港澳台: 苹果/Google 在这三地发的是 WGS84 ----
//
// GCJ-02 的偏移只施加于中国大陆, 但 gcjOutOfChina 是个粗矩形, 把港澳台整个圈在
// 里面, 于是对本来就是 WGS84 的坐标白做一次反算, 实测偏约 570~600 米。
//
// 关键在于: 这不是一个纯地理判断, 必须按来源区分。高德在香港的瓦片实测仍是
// GCJ-02(把卫星图和高德图放在同一坐标上比对, 差 596 米, 与大陆同量级), 百度的
// BD-09 建在 GCJ 之上同理。所以只有 apple/google 才在港澳台跳过换算。
//
// 实测基准(链接原始值即真值, 与设备 GPS 逐位相同):
// 香港 ifc mall 22.284774, 114.159437
// 澳门 Galaxy Macau 22.148148, 113.555399
// 台北 101 25.033626, 121.564215
// 香港必须用多边形而不是矩形: 任何包住香港的矩形都会把深圳南山/福田一起圈进去,
// 而深圳正是本项目最常用的坐标区域。北界沿深圳河与深圳湾, 自西向东抬升。
// 这条线是近似的, 口岸一带(罗湖/落马洲/沙头角)两侧约 1 公里内可能判错 ——
// 那些地方本身就骑在边界上, 无法用几个折点分清。
const HK_POLY = [
[113.8, 22.1],
[113.8, 22.43],
[113.9, 22.455],
[113.98, 22.487],
[114.05, 22.507],
[114.11, 22.527],
[114.17, 22.543],
[114.24, 22.552],
[114.32, 22.545],
[114.5, 22.45],
[114.5, 22.1],
];
// 射线法。poly 的点是 [经度, 纬度]。
function pointInPoly(lat, lon, poly) {
let inside = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const [xi, yi] = poly[i];
const [xj, yj] = poly[j];
if (yi > lat !== yj > lat && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) inside = !inside;
}
return inside;
}
// 澳门与珠海拱北只隔一道关闸(约 250 米), 矩形分不开; 北界取关闸纬度, 误判范围
// 限于口岸那一小片。
function inMacau(lat, lon) {
return lat >= 22.1 && lat <= 22.215 && lon >= 113.525 && lon <= 113.605;
}
// 台湾本岛 + 澎湖。金门/马祖紧贴厦门与福州, 用矩形圈会误伤大陆, 故不含。
function inTaiwan(lat, lon) {
return lat >= 21.85 && lat <= 25.35 && lon >= 119.3 && lon <= 122.1;
}
// 该来源在该位置是否直接提供 WGS84(即不需要做 GCJ 反算)。
export function usesWgs84Locally(lat, lon, src) {
if (src !== "apple" && src !== "google") return false;
return inMacau(lat, lon) || inTaiwan(lat, lon) || pointInPoly(lat, lon, HK_POLY);
}
// 按来源把坐标统一换算到 WGS84。text 源(用户直接输入的裸坐标)视为已是 WGS84。
//
// 注意换算与分派的分工: gcj02ToWgs84 回答"这两个坐标系在此处相差多少", 这个关系
// 在香港同样成立(高德就在用), 所以港澳台的例外不能塞进那个函数里 —— 否则就没法
// 让苹果走一条路、高德走另一条路了。
export function toWgs84(lat, lon, src) {
if (src === "baidu") {
const g = bd09ToGcj02(lat, lon);
return gcj02ToWgs84(g.lat, g.lon);
}
if (src === "amap" || src === "apple" || src === "google") {
if (usesWgs84Locally(lat, lon, src)) return { lat, lon };
return gcj02ToWgs84(lat, lon);
}
return { lat, lon };
}
// 百度页面正文里的 "x":"12686385.66","y":"2560876.53" —— BD09MC 米制。
// 量级校验用于把它和页面里其它同名字段(像素坐标等)区分开。
export function extractBaiduFromBody(body) {
const m = String(body).match(/"x"\s*:\s*"?(-?\d+(?:\.\d+)?)"?\s*,\s*"y"\s*:\s*"?(-?\d+(?:\.\d+)?)"?/);
if (!m) return null;
const x = +m[1], y = +m[2];
if (!(Math.abs(x) > 1e5 && Math.abs(y) > 1e5)) return null;
const bd = bd09mcToBd09(x, y);
if (!bd || Math.abs(bd.lat) > 90 || Math.abs(bd.lon) > 180) return null;
const nm = String(body).match(/<title>[^<]*?【([^】]{1,40})】/);
return { lat: bd.lat, lon: bd.lon, name: nm ? nm[1] : "", src: "baidu" };
}
const GCJ_A = 6378245.0;
const GCJ_EE = 0.00669342162296594323;
function gcjOutOfChina(lng, la) {
return lng < 72.004 || lng > 137.8347 || la < 0.8293 || la > 55.8271;
}
function gcjDeltaLat(x, y) {
let r = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x));
r += ((20.0 * Math.sin(6.0 * x * Math.PI) + 20.0 * Math.sin(2.0 * x * Math.PI)) * 2.0) / 3.0;
r += ((20.0 * Math.sin(y * Math.PI) + 40.0 * Math.sin((y / 3.0) * Math.PI)) * 2.0) / 3.0;
r += ((160.0 * Math.sin((y / 12.0) * Math.PI) + 320 * Math.sin((y * Math.PI) / 30.0)) * 2.0) / 3.0;
return r;
}
function gcjDeltaLon(x, y) {
let r = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x));
r += ((20.0 * Math.sin(6.0 * x * Math.PI) + 20.0 * Math.sin(2.0 * x * Math.PI)) * 2.0) / 3.0;
r += ((20.0 * Math.sin(x * Math.PI) + 40.0 * Math.sin((x / 3.0) * Math.PI)) * 2.0) / 3.0;
r += ((150.0 * Math.sin((x / 12.0) * Math.PI) + 300.0 * Math.sin((x / 30.0) * Math.PI)) * 2.0) / 3.0;
return r;
}
// WGS84 -> GCJ-02 (正向偏移), 与高德/苹果中国所用偏移一致。
export function wgs84ToGcj02(lat, lon) {
if (gcjOutOfChina(lon, lat)) return { lat, lon };
let dLat = gcjDeltaLat(lon - 105.0, lat - 35.0);
let dLon = gcjDeltaLon(lon - 105.0, lat - 35.0);
const radLat = (lat / 180.0) * Math.PI;
let magic = Math.sin(radLat);
magic = 1 - GCJ_EE * magic * magic;
const sqrtMagic = Math.sqrt(magic);
dLat = (dLat * 180.0) / (((GCJ_A * (1 - GCJ_EE)) / (magic * sqrtMagic)) * Math.PI);
dLon = (dLon * 180.0) / ((GCJ_A / sqrtMagic) * Math.cos(radLat) * Math.PI);
return { lat: lat + dLat, lon: lon + dLon };
}
// GCJ-02 -> WGS84 (迭代反算, 亚米级)。
// 单程反算在偏移梯度大的地区会残留 1~2m, 这里用不动点迭代收敛到 <0.1m,
// 与高德自身的 WGS84->GCJ 逆运算严格对齐, 消除回看时的残差。
export function gcj02ToWgs84(lat, lon) {
if (gcjOutOfChina(lon, lat)) return { lat, lon };
let wgsLat = lat;
let wgsLon = lon;
for (let i = 0; i < 6; i++) {
const g = wgs84ToGcj02(wgsLat, wgsLon);
const errLat = g.lat - lat;
const errLon = g.lon - lon;
if (Math.abs(errLat) < 1e-9 && Math.abs(errLon) < 1e-9) break;
wgsLat -= errLat;
wgsLon -= errLon;
}
return { lat: wgsLat, lon: wgsLon };
}