feat(oshi-tabi-speedtest): add JR oshi-tabi speedtest bypass userscript

Simulates movement along the Tokaido Shinkansen route, supports Nagoya
inbound/outbound direction checks, overrides watch/getCurrentPosition,
and includes a night-time API window bypass switch.
This commit is contained in:
Stardream
2026-06-24 07:14:19 +10:00
parent c0d4d34b41
commit 2d59bb772c
2 changed files with 280 additions and 0 deletions
@@ -0,0 +1,62 @@
# fuck-jr-central-oshi-tabi-speedtest 去他妈的JR东海/东日本 "推し旅" 测速
> 本项目修改自 [kiritoxkiriko/fuck-jr-central-oshi-tabi-speedtest](https://github.com/kiritoxkiriko/fuck-jr-central-oshi-tabi-speedtest)
## 简介
众所周知,JR 东海/东日本经常推出和热门 IP 联动的 "推し旅" 活动。
这些活动往往需要乘坐新干线进行测速来领取特典,比如 [JR東海×「BanG Dream! 10th ANNIVERSARY」](https://recommend.jr-central.co.jp/oshi-tabi/bang-dream-10th/)、
[ブルーアーカイブ × JR東海 推し旅 名古屋](https://oshi-tabi.voistock.com/2605bluearchive/)。
本脚本用来快速绕过这个测速。
<p align="center">
<img src="https://oshi-tabi.voistock.com/view/event/2605bluearchive/assets/img/kv.jpg" width="500">
</p>
## 原理
活动页通过浏览器的 `navigator.geolocation` API 获取你的**速度**和**位置**,并校验位置是否落在一组预设的矩形区域(`area`)内。
原项目针对的 `recommend.jr-central.co.jp` 旧活动是在**前端**直接判定的,所以只要 hook `navigator.geolocation.watchPosition`
让它返回一个**固定速度**和一个**范围内随机经纬度**即可绕过。
`oshi-tabi.voistock.com` 的活动则把采样点上传到**服务端**判定,详见下方「服务端测速」一节;但数据源同样是 `navigator.geolocation`,所以 hook 的思路通用。)
不过 voistock 的 ブルアカ × 名古屋 活动除了速度,还会判定**方向**:
它会比较连续位置点相对目标车站(名古屋 `[35.17099309, 136.8815566]`)的运动方向,
入口 `名古屋行き` 要求逐步**靠近**名古屋(`towards`),`名古屋帰り` 要求逐步**远离**`away`)。
旧脚本返回的“东京矩形内随机点”没有稳定路线,连续采样的方向来回跳变,会触发:
```
進行方向が逆(反対方向)です。正しい方向の新幹線で再度お試しください。
```
因此本脚本改为**沿东海道新干线真实途经点的折线轨迹模拟移动**:
- 内置东京→名古屋的途经点折线,按真实时间推进位置(`已行驶距离 = 经过秒数 × 速度`),
保证上报的 `speed` 字段与相邻采样点算出的“距离/时间”速度自洽,且落在活动页要求的 `150360 km/h` 区间内。
- 根据相邻点的方位角填入 `heading`(不再是 `null`)。
- 同时覆写 `watchPosition``getCurrentPosition``clearWatch`,覆盖页面可能用到的多种采样方式。
- 右下角提供方向选择悬浮面板(`名古屋行き` / `名古屋帰り`),切换即从对应起点重新出发;
`towards` 时到名古屋距离单调递减,`away` 时单调递增。
可调参数集中在脚本顶部(`SPEED_KMH``SAMPLE_INTERVAL_MS`、途经点 `ROUTE_TO_NAGOYA``ENABLE_NIGHT_BYPASS` 等),
不同活动页如有差异可自行调整。
## 服务端测速与接口营业时间
`oshi-tabi.voistock.com` 上的 ブルアカ × 名古屋 活动(第 1、2 弾共用同一套 `scrt_measure.js`)的测速判定在**服务端**
浏览器只负责用 `navigator.geolocation` 采集 GPS 点、成批 `POST``/api/measure/start``/api/measure/point(s)`
最终由服务器返回通过/失败。
(这跟原项目针对的 `recommend.jr-central.co.jp` 旧活动不是同一套实现,后者据原项目描述为纯前端判定。)
好消息是 - 服务器仍然是**按你上传的 GPS 轨迹**判定的,并不需要真实乘车凭证或额外认证,
所以本脚本的轨迹模拟**同样有效**,方向选对即可通过。
需要注意的一个坑是 **接口营业时间**:测速 API 在**深夜会关闭**,此时请求会直接返回
```json
{ "status": "unavailable", "message": "このAPIは深夜の間はご利用いただけません。", "available_from": "05:55 JST" }
```
控制台对应 `[MeasureClient] Error: 503 error`。**这跟脚本无关**,是接口本身按时间关闭。处理方式:
- 正常情况下,**等日本时间 05:55 之后再测**即可。
- 夜间调试时,脚本顶部的 `ENABLE_NIGHT_BYPASS`(默认开启)会在 `document-start` 阶段写入
`localStorage.devMode = '1'`,让页面启用其内置的夜间测试 token 以绕开营业时间限制。
这只绕开“营业时间”,token 由 JR 维护、随时可能失效;不需要时把它设为 `false` 即可。
@@ -0,0 +1,218 @@
// ==UserScript==
// @name fuck-jr-central-oshi-tabi-speedtest
// @namespace https://recommend.jr-central.co.jp/oshi-tabi/
// @version 1.0
// @description 去他妈的JR东海/东日本 推し旅 活动测速(沿新干线轨迹模拟,支持方向判定)
// @author Stardream
// @match https://oshi-tabi.voistock.com/*
// @match https://recommend.jr-central.co.jp/oshi-tabi/*
// @grant none
// @run-at document-start
// @license MIT
// ==/UserScript==
(function () {
'use strict';
// ===== 夜间测试开关 =====
// 测速接口在深夜会关闭(503 / available_from 05:55 JST)。
// 页面内联脚本会在 devMode 下设置 window.NIGHT_BYPASS_TOKEN 以绕开夜间窗口。
// 本脚本 @run-at document-start 先于该内联脚本执行,所以这里提前置位即可生效。
// 注意:这只是绕开“营业时间”,且 token 由 JR 维护,随时可能失效;白天测试无需此项。
const ENABLE_NIGHT_BYPASS = true;
if (ENABLE_NIGHT_BYPASS) {
try {
if (localStorage.getItem('devMode') !== '1') {
localStorage.setItem('devMode', '1');
}
} catch (e) { /* localStorage 不可用时忽略 */ }
}
// ===== 可配置项 =====
// 模拟速度,单位 km/h(活动页要求落在 150-360 之间)
const SPEED_KMH = 250;
// 成功回调的采样间隔(毫秒)
const SAMPLE_INTERVAL_MS = 1000;
// GPS 精度(米)
const ACCURACY_M = 10;
// 默认行进方向:'towards' = 名古屋行き(驶向名古屋),'away' = 名古屋帰り(驶离名古屋)
let DIRECTION = 'towards';
const SPEED_MPS = (SPEED_KMH * 1000) / 3600;
// ===== 东海道新干线 东京→名古屋 主要途经点(名古屋为终点)=====
// 沿真实线路排列,保证“驶向名古屋”时到名古屋的距离单调递减。
const ROUTE_TO_NAGOYA = [
{ lat: 35.681236, lon: 139.767125 }, // 東京
{ lat: 35.628471, lon: 139.738760 }, // 品川
{ lat: 35.507871, lon: 139.617495 }, // 新横浜
{ lat: 35.256293, lon: 139.155720 }, // 小田原
{ lat: 35.103156, lon: 139.078000 }, // 熱海
{ lat: 35.126363, lon: 138.911040 }, // 三島
{ lat: 35.142395, lon: 138.663320 }, // 新富士
{ lat: 34.971401, lon: 138.389500 }, // 静岡
{ lat: 34.838074, lon: 138.130700 }, // 掛川付近
{ lat: 34.703608, lon: 137.734900 }, // 浜松
{ lat: 34.762960, lon: 137.381900 }, // 豊橋
{ lat: 34.962145, lon: 137.081050 }, // 三河安城
{ lat: 35.170993, lon: 136.881557 } // 名古屋(目标点)
];
// ===== 几何工具 =====
const R = 6371000; // 地球半径(米)
const toRad = (d) => (d * Math.PI) / 180;
const toDeg = (r) => (r * 180) / Math.PI;
function haversine(a, b) {
const dLat = toRad(b.lat - a.lat);
const dLon = toRad(b.lon - a.lon);
const lat1 = toRad(a.lat);
const lat2 = toRad(b.lat);
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(h));
}
// 计算从 a 指向 b 的方位角(0-360,正北为0,顺时针)
function bearing(a, b) {
const lat1 = toRad(a.lat);
const lat2 = toRad(b.lat);
const dLon = toRad(b.lon - a.lon);
const y = Math.sin(dLon) * Math.cos(lat2);
const x =
Math.cos(lat1) * Math.sin(lat2) -
Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLon);
return (toDeg(Math.atan2(y, x)) + 360) % 360;
}
// 在两点之间按比例 t 线性插值(短距离下足够精确)
function lerp(a, b, t) {
return {
lat: a.lat + (b.lat - a.lat) * t,
lon: a.lon + (b.lon - a.lon) * t
};
}
// ===== 预计算某方向路线的累计距离 =====
function buildRoute(points) {
const cum = [0];
for (let i = 1; i < points.length; i++) {
cum.push(cum[i - 1] + haversine(points[i - 1], points[i]));
}
return { points, cum, total: cum[cum.length - 1] };
}
const routeTowards = buildRoute(ROUTE_TO_NAGOYA);
const routeAway = buildRoute([...ROUTE_TO_NAGOYA].reverse());
function activeRoute() {
return DIRECTION === 'away' ? routeAway : routeTowards;
}
// 给定沿路线已行驶的距离(米),返回当前坐标与航向
function pointAtDistance(route, dist) {
const { points, cum, total } = route;
const d = Math.max(0, Math.min(dist, total));
let i = 0;
while (i < cum.length - 2 && cum[i + 1] < d) i++;
const segLen = cum[i + 1] - cum[i] || 1;
const t = (d - cum[i]) / segLen;
const pos = lerp(points[i], points[i + 1], t);
const head = bearing(points[i], points[i + 1]);
return { pos, head };
}
// ===== 轨迹状态 =====
let startTime = null; // 本次测速的起始时间戳
function ensureStarted() {
if (startTime === null) startTime = Date.now();
}
function currentCoords() {
ensureStarted();
const elapsedSec = (Date.now() - startTime) / 1000;
const traveled = elapsedSec * SPEED_MPS;
const { pos, head } = pointAtDistance(activeRoute(), traveled);
return {
latitude: pos.lat,
longitude: pos.lon,
accuracy: ACCURACY_M,
altitude: null,
altitudeAccuracy: null,
heading: head,
speed: SPEED_MPS // 单位 m/s
};
}
function createPosition() {
return {
coords: currentCoords(),
timestamp: Date.now()
};
}
// ===== 覆写 geolocation API =====
const geo = navigator.geolocation;
geo.watchPosition = function (success, error, options) {
// 每次开始监听都重置轨迹,从线路起点出发
startTime = Date.now();
if (typeof success === 'function') success(createPosition());
const id = setInterval(() => {
if (typeof success === 'function') success(createPosition());
}, SAMPLE_INTERVAL_MS);
return id;
};
geo.clearWatch = function (watchId) {
clearInterval(watchId);
};
geo.getCurrentPosition = function (success, error, options) {
if (typeof success === 'function') success(createPosition());
};
// ===== 方向选择悬浮面板 =====
function injectPanel() {
if (document.getElementById('fjct-panel')) return;
const panel = document.createElement('div');
panel.id = 'fjct-panel';
panel.style.cssText = [
'position:fixed', 'right:12px', 'bottom:12px', 'z-index:2147483647',
'background:rgba(20,20,28,0.92)', 'color:#fff', 'font:12px/1.5 sans-serif',
'padding:10px 12px', 'border-radius:10px', 'box-shadow:0 4px 16px rgba(0,0,0,.4)',
'min-width:150px'
].join(';');
panel.innerHTML =
'<div style="font-weight:700;margin-bottom:6px">推し旅 测速模拟</div>' +
'<label style="display:block;margin:2px 0;cursor:pointer">' +
'<input type="radio" name="fjct-dir" value="towards" checked> 名古屋行き(去)</label>' +
'<label style="display:block;margin:2px 0;cursor:pointer">' +
'<input type="radio" name="fjct-dir" value="away"> 名古屋帰り(回)</label>' +
'<div id="fjct-status" style="margin-top:6px;opacity:.75"></div>';
const mount = () => {
(document.body || document.documentElement).appendChild(panel);
panel.querySelectorAll('input[name="fjct-dir"]').forEach((r) => {
r.addEventListener('change', (e) => {
DIRECTION = e.target.value;
startTime = Date.now(); // 切换方向后重新出发
updateStatus();
});
});
updateStatus();
};
if (document.body) mount();
else document.addEventListener('DOMContentLoaded', mount);
}
function updateStatus() {
const el = document.getElementById('fjct-status');
if (!el) return;
const dir = DIRECTION === 'away' ? '驶离名古屋' : '驶向名古屋';
el.textContent = `${dir} · ${SPEED_KMH}km/h`;
}
injectPanel();
})();