227 lines
6.6 KiB
TypeScript
227 lines
6.6 KiB
TypeScript
import { type ESTree, parse } from "meriyah";
|
|
import { traverse, VisitorOption } from "estraverse";
|
|
import { z } from "zod";
|
|
|
|
type Candidate = { key: number; prio: number };
|
|
|
|
const HOOK = `(() => {
|
|
if (globalThis.__secretHookInstalled) return;
|
|
globalThis.__secretHookInstalled = true;
|
|
globalThis.__captures = [];
|
|
globalThis.__validUntil = null;
|
|
Object.defineProperty(Object.prototype, "secret", {
|
|
configurable: true,
|
|
set: function (v) {
|
|
__captures.push(this);
|
|
Object.defineProperty(this, "secret", {
|
|
value: v,
|
|
writable: true,
|
|
configurable: true,
|
|
enumerable: true,
|
|
});
|
|
},
|
|
});
|
|
})();
|
|
`;
|
|
|
|
const MOD_LOADER = `
|
|
const modEnv = {};
|
|
let currentlyImporting = null;
|
|
|
|
function n(id) {
|
|
if (modEnv[id]) {
|
|
return modEnv[id];
|
|
}
|
|
if (__webpack_modules__[id]) {
|
|
modEnv[id] = {};
|
|
currentlyImporting = id
|
|
__webpack_modules__[id]({id}, modEnv[id], n);
|
|
console.error("imported", id)
|
|
currentlyImporting = null;
|
|
return modEnv[id];
|
|
}
|
|
console.error(\`failed to import \${id} (during import of \${currentlyImporting})\`);
|
|
return {};
|
|
}
|
|
n.d = () => {};
|
|
|
|
`;
|
|
|
|
const READOUT = `
|
|
globalThis.__captures.filter((c) => c.secret && c.version)
|
|
`;
|
|
|
|
const outputSchema = z
|
|
.array(z.object({ secret: z.string().nonempty(), version: z.int().positive() }))
|
|
.nonempty();
|
|
type SpotifySecrets = z.infer<typeof outputSchema>;
|
|
type SpotifySecretDict = { [key: number]: number[] };
|
|
type SpotifySecretBytes = { version: number; secret: number[] }[];
|
|
|
|
function extractWebpackModules(playerJs: string): ESTree.ObjectExpression {
|
|
const ast = parse(playerJs, { ranges: true });
|
|
|
|
let wpm = null;
|
|
|
|
traverse(ast, {
|
|
enter: (node) => {
|
|
if (node.type === "VariableDeclaration") {
|
|
const found = node.declarations.find(
|
|
(d) => d.id.type === "Identifier" && d.id.name === "__webpack_modules__"
|
|
);
|
|
if (found?.init && found.init.type === "ObjectExpression") {
|
|
wpm = found.init;
|
|
console.error("__webpack_modules__ found at", found.init.range);
|
|
return VisitorOption.Break;
|
|
}
|
|
}
|
|
},
|
|
});
|
|
|
|
if (!wpm) throw new Error("could not find __webpack_modules__");
|
|
return wpm;
|
|
}
|
|
|
|
function findOtpModule(playerJs: string, wpm: ESTree.ObjectExpression): Candidate[] {
|
|
const searchPatterns = [
|
|
"Hash#digest()",
|
|
".validUntil",
|
|
".secrets",
|
|
'"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/="',
|
|
];
|
|
|
|
const candidates: { key: number; prio: number }[] = [];
|
|
wpm.properties.forEach((p) => {
|
|
if (
|
|
p.type === "Property" &&
|
|
p.key.type === "Literal" &&
|
|
typeof p.key.value === "number" &&
|
|
(p.value.type === "ArrowFunctionExpression" ||
|
|
p.value.type === "FunctionExpression") &&
|
|
p.value.body
|
|
) {
|
|
const body_js = playerJs.substring(p.value.body.start!, p.value.body.end!);
|
|
const prio = searchPatterns.findIndex((x) => body_js.includes(x));
|
|
if (prio !== -1) {
|
|
candidates.push({ key: p.key.value, prio });
|
|
}
|
|
}
|
|
return false;
|
|
});
|
|
|
|
if (candidates.length === 0) throw new Error("could not find OTP module");
|
|
candidates.sort((a, b) => b.prio - a.prio);
|
|
return candidates;
|
|
}
|
|
|
|
function getWpmString(playerJs: string, wpm: ESTree.ObjectExpression): string {
|
|
return "const __webpack_modules__ = " + playerJs.substring(wpm.start!, wpm.end!);
|
|
}
|
|
|
|
function buildEvalScript(wpmStr: string, otpCandidates: Candidate[]): string {
|
|
let otpCode = "\n\n";
|
|
otpCandidates.forEach((c) => {
|
|
otpCode += `n(${c.key});\n`;
|
|
});
|
|
|
|
return HOOK + MOD_LOADER + wpmStr + otpCode + READOUT;
|
|
}
|
|
|
|
function runEvalScript(evalScript: string): SpotifySecrets {
|
|
const res = (() => eval(evalScript))();
|
|
const spotifySecrets = outputSchema.parse(res);
|
|
spotifySecrets.sort((a, b) => a.version - b.version);
|
|
return spotifySecrets;
|
|
}
|
|
|
|
function secretsToBytes(secrets: SpotifySecrets): SpotifySecretBytes {
|
|
return secrets.map(({ version, secret }) => {
|
|
return { version, secret: Array.from(secret).map((c) => c.charCodeAt(0)) };
|
|
});
|
|
}
|
|
|
|
function secretsToDict(secrets: SpotifySecrets): SpotifySecretDict {
|
|
return Object.fromEntries(
|
|
secrets.map(({ version, secret }) => [
|
|
String(version),
|
|
Array.from(secret).map((c) => c.charCodeAt(0)),
|
|
])
|
|
);
|
|
}
|
|
|
|
const HTTP_OPTIONS = {
|
|
headers: {
|
|
"User-Agent":
|
|
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
|
|
},
|
|
};
|
|
|
|
function isOk(status: number) {
|
|
if (status < 200 || status > 299) throw new Error(`HTTP status code ${status}`);
|
|
}
|
|
|
|
async function fetchPlayerJsUrl(): Promise<string> {
|
|
const htmlResp = await fetch("https://open.spotify.com/", HTTP_OPTIONS);
|
|
isOk(htmlResp.status);
|
|
const html = await htmlResp.text();
|
|
const rePlayerJs = /"(https:\/\/[^" ]+\/web-player\.[0-9a-f]+\.js)"/;
|
|
const m = html.match(rePlayerJs);
|
|
if (!m) throw new Error("Player JS URL not found");
|
|
const playerJsUrl = m[1];
|
|
return playerJsUrl;
|
|
}
|
|
|
|
async function fetchPlayerJs(playerJsUrl: string): Promise<string> {
|
|
const jsResp = await fetch(playerJsUrl, HTTP_OPTIONS);
|
|
isOk(jsResp.status);
|
|
const ct = jsResp.headers.get("content-type");
|
|
if (!ct?.match(/text\/javascript\b/)) throw new Error(`Invalid content type: ${ct}`);
|
|
const playerJs = await jsResp.text();
|
|
|
|
return playerJs;
|
|
}
|
|
|
|
async function main() {
|
|
let playerJs;
|
|
if (Deno.args[0]) {
|
|
playerJs = Deno.readTextFileSync(Deno.args[0]);
|
|
} else {
|
|
const playerJsUrl = await fetchPlayerJsUrl();
|
|
console.error("Player JS URL", playerJsUrl);
|
|
let lastPlayerJsUrl = "";
|
|
try {
|
|
lastPlayerJsUrl = Deno.readTextFileSync("tmp/playerUrl.txt").trim();
|
|
// deno-lint-ignore no-empty
|
|
} catch {}
|
|
if (playerJsUrl === lastPlayerJsUrl) {
|
|
console.error("no player updates");
|
|
return;
|
|
}
|
|
|
|
playerJs = await fetchPlayerJs(playerJsUrl);
|
|
Deno.writeTextFileSync("tmp/playerUrl.txt", playerJsUrl);
|
|
}
|
|
|
|
const wpm = extractWebpackModules(playerJs);
|
|
const otpCandidates = findOtpModule(playerJs, wpm);
|
|
const wpmString = getWpmString(playerJs, wpm);
|
|
const evalScript = buildEvalScript(wpmString, otpCandidates);
|
|
const spotifySecrets = runEvalScript(evalScript);
|
|
const spotifySecretBytes = secretsToBytes(spotifySecrets);
|
|
const spotifySecretDict = secretsToDict(spotifySecrets);
|
|
|
|
console.log(spotifySecrets);
|
|
Deno.writeTextFileSync(
|
|
"secrets/secrets.json",
|
|
JSON.stringify(spotifySecrets, null, 2)
|
|
);
|
|
Deno.writeTextFileSync(
|
|
"secrets/secretBytes.json",
|
|
JSON.stringify(spotifySecretBytes)
|
|
);
|
|
Deno.writeTextFileSync("secrets/secretDict.json", JSON.stringify(spotifySecretDict));
|
|
}
|
|
|
|
if (import.meta.main) {
|
|
main()
|
|
}
|