前言
图寻是一款通过现实全景图来猜测位置的娱乐化学习应用。类似于国外的 GeoGuessr,你可以通过观察街景进行判断,通过点击地图得分。它使用的街景车全景,主要来自百度、腾讯、谷歌三家提供商。
本文将制作能够解析来自图寻服务端响应的图寻复盘助手,借此探讨使用浏览器扩展或脚本拦截请求(Fetch)的基本方法。从控制台网络抓包起步,到编写自动化解析请求/响应信息的浏览器脚本。
准备工作
💻我的开发环境:Windows 10, Chrome 138.0.0.0
安装 Tampermonkey 浏览器扩展。
注册百度开放平台,完成个人实名认证,申请 AK (API Key),应用类型为浏览器端,IP白名单填写
*
。
研究
注意
请勿滥用。在图寻中的匹配/积分赛中启用脚本,可能被永久封禁。
访问图寻,使用F12
开启浏览器开发者面板,切换到网络选项卡。随便进入一局单人游戏,完成抓包工作。

筛选Fetch/XHR
,浏览抓到的请求,其中:
getSelfProfile
获取玩家的个人信息,如果在多人比赛对局内,则为所有场上玩家的信息。listEmojis
玩家对局中可用的表情包列表。join?
get?
则为对局基本信息。
重点在于getPanoInfo?
这一请求,它的请求地址为图寻的 API,但是竟然能够得到包括具体经纬度的响应。

我们来看一看这样的响应包含了多少信息:
{
"success": true,
"data": {
"fov": null,
"lat": 41.20634678633731, // WGS84 坐标系
"lng": 123.20092025890284,
"bd09Lat": 41.212183, // 百度坐标系
"bd09Lng": 123.207489,
"pano": "09011100011605101534289442L",// 街景 ID
"centerHeading": 82.52000000000001,// 朝向
"height": null,
"width": null,
"links": [ // 周边街景点
{
"pano": "09011100011605101534260722L",
"heading": 353.647909642982,
"centerHeading": 83.72999999999999
},
{
"pano": "09011100011605101532194292L",
"heading": 288.20622437119687,
"centerHeading": 349.13
},
{
"pano": "09011100011605101532237942L",
"heading": 11.61148642388849,
"centerHeading": 2.480000000000004
},
{
"pano": "09011100011605101534308772L",
"heading": 173.50419815458238,
"centerHeading": 84.58000000000001
}
]
},
"hintCode": null,
"hintMessage": null
}
图寻为何要将包含答案的响应发送给客户端,我们无从得知。事实上,已经有很多人向图寻汇报了这一问题,可官方宁愿在排位赛中加强对插件的监控,也不愿意修复这一问题。
进一步深入探究发现,提交答案时,包含用户选择经纬度的请求被发送到服务器,服务器验证答案后返回结果。答案的验证是在服务器上进行的,没必要把答案发送到客户端。
可能的解释是,街景块是直接从百度地图服务器发送到用户的,而不经过图寻服务器或其 CDN 中转,其中包街景 ID 的数据,不需要刻意隐藏。然而,谷歌街景即使通过中转,也没有被抹去经纬度信息。
实现
通过研究,我们发现街景的详细信息已经发送给玩家了。那么只需要截获对应的响应信息,就可以在复盘时得到经纬度,进而运用地图 API 的逆地理编码功能解析可读的地址。
碎碎念:由于一时疏忽,支持从 API 获取 POI 信息、支持谷歌街景国家大区解析的 Demo 被意外删除,无法找回… 此版本能够正常解析百度、腾讯两家街景的经纬度、周边街景、省市区信息,无法解析谷歌街景。
劫持
通过重写XMLHttpRequest
来拦截请求、响应信息,然后使用return originalOpen.apply(this, arguments);
放行。
// UI 代码已除去,仅保留核心逻辑
(function() {
'use strict';
// 调试模式开关
const DEBUG_MODE = true;
const OriginalXHR = window.XMLHttpRequest;
// 日志函数
function log(...args) {
if (DEBUG_MODE) {
console.log('[图寻小助手-Log]', ...args);
}
}
// 重写 XMLHttpRequest 以拦截请求
window.XMLHttpRequest = function() {
const xhr = new OriginalXHR();
const originalOpen = xhr.open;
const originalSend = xhr.send;
xhr.open = function(method, url) {
log('拦截到XHR请求:', url);
this._url = url;
return originalOpen.apply(this, arguments);
};
xhr.send = function() {
xhr.addEventListener('load', () => {
const url = this._url;
// 这里通过游戏中不同街景时的抓包,获取相应 API 地址,然后再此处判断
const baiduPanoRegex = /https:\/\/tuxun\.fun\/api\/v0\/tuxun\/mapProxy\/getPanoInfo/;
const tencentPanoRegex = /https:\/\/tuxun\.fun\/api\/v0\/tuxun\/mapProxy\/getQQPanoInfo/;
const googlePanoRegex = /https:\/\/tile\.chao-fan\.com\/\$rpc\/google\.internal\.maps\.mapsjs\.v1\.MapsJsInternalService\/GetMetadata/;
// 转给解析函数负责
if (baiduPanoRegex.test(url)) {
log('检测到百度街景getPanoInfo XHR请求');
handleXHRPanoResponse(xhr, 'baidu');
} else if (tencentPanoRegex.test(url)) {
log('检测到腾讯街景getQQPanoInfo XHR请求');
handleXHRPanoResponse(xhr, 'tencent');
} else if (googlePanoRegex.test(url)) {
log('检测到谷歌街景XHR请求');
handleXHRPanoResponse(xhr, 'google');
}
});
return originalSend.apply(this, arguments);
};
return xhr;
};
// 处理XHR街景响应
function handleXHRPanoResponse(xhr, mapType) {
const urlObj = new URL(xhr._url);
let panoId = '';
if (mapType === 'baidu' || mapType === 'tencent') {
panoId = urlObj.searchParams.get('pano');
} else if (mapType === 'google') {
// 谷歌街景的响应非常无序复杂,需要正则表达式判断,暂时不做
panoId = '无';
}
log(`${mapType === 'baidu' ? '百度' : mapType === 'tencent' ? '腾讯' : '未知'}街景XHR请求的Pano ID:`, panoId);
try {
// 解析响应
const responseText = xhr.responseText;
let jsonData;
try {
jsonData = JSON.parse(responseText);
log(`${mapType === 'baidu' ? '百度' : mapType === 'tencent' ? '腾讯' : '谷歌'}街景XHR响应JSON:`, jsonData);
} catch (parseError) {
// 不是JSON格式,则一定是谷歌
log(`谷歌响应JSON:`, jsonData);
}
}
catch (error) {
error('处理XHR请求出错:', error);
}
}
})();
利用百度 API 解析
通过百度 API 提供的坐标转换、逆地理编码功能。完成从经纬度到地址的解析。
const BAIDU_AK = '';// AK
function fetchLocationInfo(lat, lng) {
if (window.BMap) {
doGeocode(lat, lng);
return;
}
const script = document.createElement('script');
script.src = `https://api.map.baidu.com/api?v=3.0&ak=${BAIDU_AK}&callback=initBaiduMap`;
script.type = 'text/javascript';
document.head.appendChild(script);
window.initBaiduMap = function() {
doGeocode(lat, lng);
};
// 超时处理
setTimeout(() => {
if (!window.BMap) {
error("百度 API 超时");
}, 5000);
}
// 执行逆地理编码
function doGeocode(lat, lng) {
const locationInfo = document.getElementById('tuxun-location-info');
const locationStatus = document.getElementById('tuxun-location-status');
try {
const point = new BMap.Point(lng, lat);
const convertor = new BMap.Convertor();
const points = [point];
// 后续如果要支持谷歌街景,会发现谷歌街景没有百度坐标系。所以这里使用 BGS 坐标系转换为百度坐标系,而不是直接使用响应信息中的百度坐标系
convertor.translate(points, 1, 5, (data) => {
if (data.status === 0) {
const bdPoint = data.points[0];
const geoc = new BMap.Geocoder();
geoc.getLocation(bdPoint, (rs) => {
const address = rs.addressComponents;
log(address);
});
} else {
throw new Error('坐标转换失败:' + data.status);
}
});
} catch (error) {
error('位置解析失败:', error);
}
}
写在最后
文章中拦截 XML 请求的方法具有广泛适用性。Fetch 请求本文虽没有涉及,但是基本思路一致,即重写对应方法,拦截并储存请求数据,然后二次放行。拿到响应的 JSON 数据后,就可以根据项目需求进行对应的解析。