feat: 재생 403 우회용 poToken 자동 생성·주입 (youtube-plugin 런타임 엔드포인트)

googlevideo 재생 403(스트림 거절)은 Lavalink youtube-plugin에 유효한
poToken/visitorData가 없어서 발생. 봇이 노드 ready 시점과 2시간 주기로
poToken을 생성해 Lavalink의 POST /youtube 엔드포인트에 주입한다.
- scripts/potoken-refresh.mjs: youtubei.js+bgutils-js+jsdom로 생성 후 주입 (독립 ESM 프로세스)
- LavalinkManager: ready 이벤트 + 2h 인터벌로 자식 프로세스 실행
- deps: bgutils-js 3.2.0, jsdom, youtubei.js 추가 / Docker base node:22-alpine

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-08-28 23:25:38 +09:00
parent 5003c044c5
commit 1263ca4308
5 changed files with 630 additions and 3 deletions

View File

@@ -0,0 +1,84 @@
// YouTube poToken/visitorData 생성 후 Lavalink youtube-plugin 런타임 엔드포인트(POST /youtube)에 주입.
// googlevideo 재생 403(스트림 거절) 우회용. 런타임 주입은 휘발성(Lavalink 재시작 시 소멸, 토큰도 수 시간 뒤 만료)이라
// 봇이 노드 ready 시점과 일정 주기로 이 스크립트를 자식 프로세스로 실행한다.
// 독립 ESM 프로세스로 돌려 BotGuard 생성 시의 메모리 스파이크를 봇 본체와 격리한다.
//
// 필요 env: LAVALINK_HOST, LAVALINK_PORT, LAVALINK_PW
import { Innertube } from "youtubei.js";
import { BG } from "bgutils-js";
import { JSDOM } from "jsdom";
const HOST = process.env.LAVALINK_HOST?.trim();
const PORT = process.env.LAVALINK_PORT?.trim();
const PW = process.env.LAVALINK_PW?.trim();
const REQUEST_KEY = "O43z0dpjhgX20SCx4KAo";
function log(msg) {
process.stdout.write(`[potoken] ${msg}\n`);
}
if (!HOST || !PORT || !PW) {
log("FAIL: LAVALINK_HOST/PORT/PW env 누락");
process.exit(1);
}
async function generate() {
const innertube = await Innertube.create({ retrieve_player: false });
const visitorData = innertube.session.context.client.visitorData;
if (!visitorData) throw new Error("visitorData 획득 실패");
const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>", { url: "https://www.youtube.com/" });
Object.assign(globalThis, {
window: dom.window,
document: dom.window.document,
location: dom.window.location,
origin: dom.window.origin,
});
if (!Reflect.has(globalThis, "navigator"))
Object.defineProperty(globalThis, "navigator", { value: dom.window.navigator });
const bgConfig = {
fetch: (url, options) => fetch(url, options),
globalObj: globalThis,
identifier: visitorData,
requestKey: REQUEST_KEY,
};
const challenge = await BG.Challenge.create(bgConfig);
if (!challenge) throw new Error("BotGuard challenge 생성 실패");
const js = challenge.interpreterJavascript.privateDoNotAccessOrElseSafeScriptWrappedValue;
if (js) new Function(js)();
else throw new Error("BotGuard interpreter 로드 실패");
const pot = await BG.PoToken.generate({
program: challenge.program,
globalName: challenge.globalName,
bgConfig,
});
if (!pot?.poToken) throw new Error("poToken 생성 실패");
return { visitorData, poToken: pot.poToken };
}
async function inject({ visitorData, poToken }) {
const res = await fetch(`http://${HOST}:${PORT}/youtube`, {
method: "POST",
headers: { Authorization: PW, "Content-Type": "application/json" },
body: JSON.stringify({ poToken, visitorData }),
});
return res.status;
}
try {
const pair = await generate();
const status = await inject(pair);
if (status === 204) {
log(`OK poToken 주입 완료 (visitorData ${pair.visitorData.length}자, poToken ${pair.poToken.length}자)`);
process.exit(0);
}
log(`WARN 주입 응답 http ${status}`);
process.exit(2);
} catch (e) {
log(`FAIL ${e && e.stack ? e.stack : e}`);
process.exit(1);
}