114 lines
6.9 KiB
JavaScript
114 lines
6.9 KiB
JavaScript
// 离线 weapi 替身:解开真实请求参数,按接口的分页方式回包;绝不连接网络。
|
|
import crypto from 'node:crypto';
|
|
import { appendFileSync } from 'node:fs';
|
|
import './mpv-ipc.mjs';
|
|
|
|
const secret = Buffer.alloc(8, 1);
|
|
crypto.randomBytes = () => secret;
|
|
const scenario = process.env.NETEASE_TEST_SCENARIO || '';
|
|
const coverUrl = 'https://artwork.invalid/cover.png';
|
|
const logCall = (call) => process.env.NETEASE_TEST_API_CALLS
|
|
? appendFileSync(process.env.NETEASE_TEST_API_CALLS, JSON.stringify(call) + '\n')
|
|
: console.error(JSON.stringify(call));
|
|
const song = (id) => ({
|
|
id, name: `歌曲 ${id}`, ar: [{ name: '歌手' }], al: { name: '专辑' }, dt: 180000,
|
|
...(scenario === 'liked-flags' ? { liked: id % 2 === 0 } : {}),
|
|
...(scenario === 'liked-unknown' ? { liked: null } : {}),
|
|
...(scenario.startsWith('cover') && scenario !== 'cover-missing' ? {
|
|
al: { name: '专辑', picUrl: ['cover-album', 'cover-song'].includes(scenario) ? '' : coverUrl },
|
|
album: { picUrl: scenario === 'cover-song' ? '' : `${coverUrl}?album` },
|
|
picUrl: `${coverUrl}?song`,
|
|
} : {}),
|
|
});
|
|
const songs = Array.from({ length: scenario === 'empty' ? 0 : 507 }, (_, i) => song(1000 + i));
|
|
const rows = songs.slice(0, 7).map((s, i) => ({ ...s, trackCount: 20, specialType: i ? 0 : 5, subscribed: i > 2, programCount: 7, size: 7 }));
|
|
const withTotal = (key, value) => scenario === 'no-total' ? {} : { [key]: scenario === 'empty' ? 0 : value };
|
|
const page = (data) => rows.slice(+data.offset || 0, (+data.offset || 0) + +data.limit);
|
|
const decrypt = (value, key) => {
|
|
const decipher = crypto.createDecipheriv('aes-128-cbc', Buffer.from(key), Buffer.from('0102030405060708'));
|
|
return Buffer.concat([decipher.update(value, 'base64'), decipher.final()]).toString();
|
|
};
|
|
|
|
globalThis.fetch = async (url, options) => {
|
|
const u = new URL(url);
|
|
if (u.hostname === 'artwork.invalid') {
|
|
if (options.headers.Cookie) throw new Error('图片请求不应包含登录 cookie');
|
|
logCall({ path: 'artwork', data: { url: u.href } });
|
|
const headers = { 'Content-Type': scenario === 'cover-non-image' ? 'text/html' : 'image/png' };
|
|
let body = Buffer.from('89504e4700ff1080', 'hex');
|
|
if (scenario === 'cover-large-header') headers['Content-Length'] = '10000001';
|
|
if (scenario === 'cover-large-stream' || scenario === 'cover-false-length') {
|
|
// 多块响应;分别验证缺少/虚报 Content-Length 都不能绕过上限。
|
|
body = new ReadableStream({ start(controller) {
|
|
controller.enqueue(Buffer.alloc(6_000_000));
|
|
controller.enqueue(Buffer.alloc(4_000_001));
|
|
controller.close();
|
|
} });
|
|
if (scenario === 'cover-false-length') headers['Content-Length'] = '8';
|
|
}
|
|
if (scenario === 'cover-exact-limit') body = Buffer.alloc(10_000_000);
|
|
if (scenario === 'cover-empty-image') body = Buffer.alloc(0);
|
|
return new Response(body, { headers, status: scenario === 'cover-http-missing' ? 404 : 200 });
|
|
}
|
|
const form = new URLSearchParams(options.body);
|
|
const data = u.pathname.startsWith('/weapi/')
|
|
? JSON.parse(decrypt(decrypt(form.get('params'), secret.toString('hex')), '0CoJUm6Qyw8W8jud'))
|
|
: Object.fromEntries(form);
|
|
const path = u.pathname.replace(/^\/(weapi|api)\//, '');
|
|
logCall({ path, data });
|
|
let body;
|
|
switch (path) {
|
|
case 'w/nuser/account/get': body = { profile: { userId: 42 } }; break;
|
|
case 'song/like/get':
|
|
body = scenario === 'liked-error' ? { code: 405, message: '操作频繁' }
|
|
: scenario === 'liked-auth' ? { code: 301 }
|
|
: scenario === 'liked-no-ids' ? {}
|
|
: scenario === 'liked-bad-ids' ? { ids: ['invalid'] }
|
|
: { ids: scenario === 'empty' ? [] : [1000, '1002', 1004] };
|
|
break;
|
|
case 'radio/trash/add': body = scenario === 'dislike-error' ? { code: -460, message: '拒绝' }
|
|
: scenario === 'dislike-auth' ? { code: 301 } : {}; break;
|
|
case 'radio/get': body = { data: songs.slice(0, 3) }; break;
|
|
case 'song/enhance/player/url/v1':
|
|
body = { data: JSON.parse(data.ids).map((id) => ({ id, level: 'lossless',
|
|
url: scenario === 'no-urls' || (scenario === 'missing-url' && id === 1004) ? null : `https://audio.invalid/${id}.flac`,
|
|
})) };
|
|
break;
|
|
case 'user/playlist': body = { playlist: rows, more: false }; break;
|
|
case 'v6/playlist/detail':
|
|
body = { playlist: {
|
|
id: +data.id, name: '长歌单', ...withTotal('trackCount', 507),
|
|
tracks: songs.slice(0, Math.min(+data.n, 3)),
|
|
...(scenario === 'no-track-ids' ? {} : { trackIds: songs.map((s, i) => ({ id: s.id, at: i })) }),
|
|
} };
|
|
break;
|
|
case 'v3/song/detail':
|
|
// 详情接口故意乱序/混入额外歌曲,验证调用方确实按所选 ID 还原。
|
|
body = { songs: JSON.parse(data.c).map(({ id }) => song(id)).filter((s) => scenario !== 'cover-no-song' && (scenario !== 'missing-song' || s.id !== 1004)).reverse().concat(song(9999)) };
|
|
break;
|
|
case 'v1/album/1': body = { album: { name: '专辑', ...withTotal('size', 7) }, songs: songs.slice(0, 7) }; break;
|
|
case 'v1/artist/1': body = { artist: { name: '歌手', musicSize: 507 }, hotSongs: scenario === 'artist-fallback' ? [] : songs.slice(0, 7) }; break;
|
|
case 'artist/top/song': body = { songs: songs.slice(0, 7) }; break;
|
|
case 'artist/albums/1': body = { artist: withTotal('albumSize', 7), hotAlbums: page(data) }; break;
|
|
case 'search/get/web':
|
|
case 'cloudsearch/get/web':
|
|
if (path === 'cloudsearch/get/web' && scenario === 'search-fallback') { body = {}; break; }
|
|
body = { result: +data.type === 1000
|
|
? { playlists: page(data), ...withTotal('playlistCount', 7) }
|
|
: { songs: scenario === 'cover-no-song' ? [] : page(data).map((s) => scenario === 'cover-search-no-art'
|
|
? { ...s, al: { name: '专辑' }, album: undefined, picUrl: undefined } : s), ...withTotal('songCount', 7) } };
|
|
break;
|
|
case 'toplist': body = { list: rows, ...withTotal('total', 7) }; break;
|
|
case 'album/sublist':
|
|
case 'artist/sublist': body = { data: page(data), ...withTotal('count', 7) }; break;
|
|
case 'v1/play/record': body = { weekData: rows.map((s) => ({ song: scenario === 'missing-song' && s.id === 1004 ? undefined : s, playCount: 2 })) }; break;
|
|
case 'djradio/get/subed':
|
|
case 'djradio/recommend/v1': body = { djRadios: rows.slice(0, +data.limit), ...(path.endsWith('subed') ? withTotal('count', 7) : {}) }; break;
|
|
case 'dj/program/byradio': body = { programs: page(data).map((s) => ({ id: s.id, mainSong: scenario === 'missing-song' && s.id === 1004 ? undefined : s })), ...withTotal('count', 7) }; break;
|
|
case 'dj/program/detail': body = { program: { id: +data.id, mainSong: song(1000) } }; break;
|
|
case 'v2/discovery/recommend/songs': body = { data: { dailySongs: songs.slice(0, 7) } }; break;
|
|
default: throw new Error(`测试不允许的接口:${path}`);
|
|
}
|
|
return { status: 200, headers: { getSetCookie: () => [] }, text: async () => JSON.stringify({ code: 200, ...body }) };
|
|
};
|