// 离线 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 }) }; };