@mkterswingman/yt-mcp 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/setupCookies.d.ts +33 -0
- package/dist/cli/setupCookies.js +51 -42
- package/dist/tools/subtitles.js +71 -6
- package/dist/utils/cookieRefresh.d.ts +18 -0
- package/dist/utils/cookieRefresh.js +70 -0
- package/dist/utils/cookies.d.ts +7 -0
- package/dist/utils/cookies.js +50 -0
- package/package.json +2 -2
|
@@ -1 +1,34 @@
|
|
|
1
|
+
export type PlaywrightCookie = {
|
|
2
|
+
name: string;
|
|
3
|
+
value: string;
|
|
4
|
+
domain: string;
|
|
5
|
+
path: string;
|
|
6
|
+
secure: boolean;
|
|
7
|
+
httpOnly: boolean;
|
|
8
|
+
expires: number;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Detect which browser channel is available on the system.
|
|
12
|
+
* Prefers Chrome → Edge → falls back to bundled Chromium.
|
|
13
|
+
*/
|
|
14
|
+
export declare function detectBrowserChannel(chromium: typeof import("playwright").chromium): Promise<string>;
|
|
15
|
+
export declare const CHANNEL_LABELS: Record<string, string>;
|
|
16
|
+
/** Check if YouTube SID cookies are present — the real signal of a logged-in session. */
|
|
17
|
+
export declare function hasYouTubeSession(cookies: Array<{
|
|
18
|
+
name: string;
|
|
19
|
+
domain: string;
|
|
20
|
+
}>): boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Save cookies to Netscape format file and close the browser context.
|
|
23
|
+
*/
|
|
24
|
+
export declare function saveCookiesAndClose(context: {
|
|
25
|
+
close(): Promise<void>;
|
|
26
|
+
}, rawCookies: PlaywrightCookie[], silent?: boolean): Promise<void>;
|
|
27
|
+
/**
|
|
28
|
+
* Save cookies to disk WITHOUT closing the context (caller manages lifecycle).
|
|
29
|
+
*/
|
|
30
|
+
export declare function saveCookiesToDisk(rawCookies: PlaywrightCookie[]): void;
|
|
31
|
+
/**
|
|
32
|
+
* Interactive cookie setup — opens a visible browser for user to log in.
|
|
33
|
+
*/
|
|
1
34
|
export declare function runSetupCookies(): Promise<void>;
|
package/dist/cli/setupCookies.js
CHANGED
|
@@ -5,7 +5,7 @@ import { cookiesToNetscape } from "../utils/cookies.js";
|
|
|
5
5
|
* Detect which browser channel is available on the system.
|
|
6
6
|
* Prefers Chrome → Edge → falls back to bundled Chromium.
|
|
7
7
|
*/
|
|
8
|
-
async function detectBrowserChannel(chromium) {
|
|
8
|
+
export async function detectBrowserChannel(chromium) {
|
|
9
9
|
for (const channel of ["chrome", "msedge"]) {
|
|
10
10
|
try {
|
|
11
11
|
const browser = await chromium.launch({ channel, headless: true });
|
|
@@ -18,16 +18,57 @@ async function detectBrowserChannel(chromium) {
|
|
|
18
18
|
}
|
|
19
19
|
return "chromium";
|
|
20
20
|
}
|
|
21
|
-
const CHANNEL_LABELS = {
|
|
21
|
+
export const CHANNEL_LABELS = {
|
|
22
22
|
chrome: "Google Chrome",
|
|
23
23
|
msedge: "Microsoft Edge",
|
|
24
24
|
chromium: "Playwright Chromium",
|
|
25
25
|
};
|
|
26
26
|
/** Check if YouTube SID cookies are present — the real signal of a logged-in session. */
|
|
27
|
-
function hasYouTubeSession(cookies) {
|
|
27
|
+
export function hasYouTubeSession(cookies) {
|
|
28
28
|
return cookies.some((c) => (c.name === "SID" || c.name === "HSID" || c.name === "SSID") &&
|
|
29
29
|
c.domain.includes("youtube.com"));
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Save cookies to Netscape format file and close the browser context.
|
|
33
|
+
*/
|
|
34
|
+
export async function saveCookiesAndClose(context, rawCookies, silent = false) {
|
|
35
|
+
try {
|
|
36
|
+
await context.close();
|
|
37
|
+
}
|
|
38
|
+
catch { /* already closed */ }
|
|
39
|
+
const entries = rawCookies.map((c) => ({
|
|
40
|
+
name: c.name,
|
|
41
|
+
value: c.value,
|
|
42
|
+
domain: c.domain,
|
|
43
|
+
path: c.path,
|
|
44
|
+
secure: c.secure,
|
|
45
|
+
httpOnly: c.httpOnly,
|
|
46
|
+
expires: c.expires,
|
|
47
|
+
}));
|
|
48
|
+
const netscape = cookiesToNetscape(entries);
|
|
49
|
+
writeFileSync(PATHS.cookiesTxt, netscape, { mode: 0o600 });
|
|
50
|
+
if (!silent) {
|
|
51
|
+
const httpOnlyCount = entries.filter((c) => c.httpOnly).length;
|
|
52
|
+
console.log(`✅ Cookies saved to ${PATHS.cookiesTxt}`);
|
|
53
|
+
console.log(` ${entries.length} cookies extracted (${httpOnlyCount} httpOnly)\n`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Save cookies to disk WITHOUT closing the context (caller manages lifecycle).
|
|
58
|
+
*/
|
|
59
|
+
export function saveCookiesToDisk(rawCookies) {
|
|
60
|
+
const entries = rawCookies.map((c) => ({
|
|
61
|
+
name: c.name,
|
|
62
|
+
value: c.value,
|
|
63
|
+
domain: c.domain,
|
|
64
|
+
path: c.path,
|
|
65
|
+
secure: c.secure,
|
|
66
|
+
httpOnly: c.httpOnly,
|
|
67
|
+
expires: c.expires,
|
|
68
|
+
}));
|
|
69
|
+
const netscape = cookiesToNetscape(entries);
|
|
70
|
+
writeFileSync(PATHS.cookiesTxt, netscape, { mode: 0o600 });
|
|
71
|
+
}
|
|
31
72
|
/**
|
|
32
73
|
* Poll cookies at intervals until YouTube session cookies appear or timeout.
|
|
33
74
|
* Returns the full cookie list on success, or null on timeout / browser closed.
|
|
@@ -35,30 +76,28 @@ function hasYouTubeSession(cookies) {
|
|
|
35
76
|
async function waitForLogin(context, isClosed, timeoutMs, pollIntervalMs = 2000) {
|
|
36
77
|
const deadline = Date.now() + timeoutMs;
|
|
37
78
|
while (Date.now() < deadline) {
|
|
38
|
-
|
|
39
|
-
if (isClosed()) {
|
|
79
|
+
if (isClosed())
|
|
40
80
|
return null;
|
|
41
|
-
}
|
|
42
81
|
try {
|
|
43
82
|
const cookies = await context.cookies("https://www.youtube.com");
|
|
44
83
|
if (hasYouTubeSession(cookies)) {
|
|
45
|
-
// Give a moment for all cookies to settle after login redirect
|
|
46
84
|
await new Promise((r) => setTimeout(r, 2000));
|
|
47
85
|
return await context.cookies("https://www.youtube.com");
|
|
48
86
|
}
|
|
49
87
|
}
|
|
50
88
|
catch {
|
|
51
|
-
// Browser was closed by user — bail out
|
|
52
89
|
return null;
|
|
53
90
|
}
|
|
54
91
|
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
|
55
92
|
}
|
|
56
|
-
return null;
|
|
93
|
+
return null;
|
|
57
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* Interactive cookie setup — opens a visible browser for user to log in.
|
|
97
|
+
*/
|
|
58
98
|
export async function runSetupCookies() {
|
|
59
99
|
console.log("\n🍪 YouTube Cookie Setup\n");
|
|
60
100
|
ensureConfigDir();
|
|
61
|
-
// Dynamic import of playwright to avoid pulling it in at serve time
|
|
62
101
|
let chromium;
|
|
63
102
|
try {
|
|
64
103
|
const pw = await import("playwright");
|
|
@@ -88,20 +127,14 @@ export async function runSetupCookies() {
|
|
|
88
127
|
}
|
|
89
128
|
throw err;
|
|
90
129
|
}
|
|
91
|
-
// Graceful handling when user closes browser manually
|
|
92
130
|
let browserClosed = false;
|
|
93
|
-
context.on("close", () => {
|
|
94
|
-
browserClosed = true;
|
|
95
|
-
});
|
|
131
|
+
context.on("close", () => { browserClosed = true; });
|
|
96
132
|
const page = context.pages()[0] ?? (await context.newPage());
|
|
97
|
-
// Also detect when user closes the page/window (not just the whole context)
|
|
98
133
|
page.on("close", () => {
|
|
99
|
-
// If no more pages remain, treat as browser closed
|
|
100
134
|
if (context.pages().length === 0) {
|
|
101
135
|
browserClosed = true;
|
|
102
136
|
}
|
|
103
137
|
});
|
|
104
|
-
// Navigate to YouTube first — familiar page for the user
|
|
105
138
|
try {
|
|
106
139
|
await page.goto("https://www.youtube.com");
|
|
107
140
|
await page.waitForLoadState("domcontentloaded");
|
|
@@ -113,14 +146,12 @@ export async function runSetupCookies() {
|
|
|
113
146
|
}
|
|
114
147
|
throw new Error("Failed to navigate to YouTube");
|
|
115
148
|
}
|
|
116
|
-
// Check if already logged in (e.g., from a previous session in this profile)
|
|
117
149
|
const existingCookies = await context.cookies("https://www.youtube.com");
|
|
118
150
|
if (hasYouTubeSession(existingCookies)) {
|
|
119
151
|
console.log("✅ Already logged in to YouTube!\n");
|
|
120
152
|
await saveCookiesAndClose(context, existingCookies);
|
|
121
153
|
return;
|
|
122
154
|
}
|
|
123
|
-
// Try to click the "Sign in" button on YouTube homepage
|
|
124
155
|
try {
|
|
125
156
|
const signInBtn = page.locator('a[href*="accounts.google.com/ServiceLogin"], ' +
|
|
126
157
|
'tp-yt-paper-button#button:has-text("Sign in"), ' +
|
|
@@ -131,7 +162,6 @@ export async function runSetupCookies() {
|
|
|
131
162
|
console.log("🔑 Opened sign-in page. Please log in to your Google account.\n");
|
|
132
163
|
}
|
|
133
164
|
catch {
|
|
134
|
-
// Couldn't find sign-in button — navigate directly
|
|
135
165
|
try {
|
|
136
166
|
await page.goto("https://accounts.google.com/ServiceLogin?continue=https://www.youtube.com");
|
|
137
167
|
console.log("🔑 Please log in to your Google account in the browser window.\n");
|
|
@@ -144,7 +174,7 @@ export async function runSetupCookies() {
|
|
|
144
174
|
throw new Error("Failed to navigate to Google login");
|
|
145
175
|
}
|
|
146
176
|
}
|
|
147
|
-
const LOGIN_TIMEOUT_MS = 2 * 60 * 1000;
|
|
177
|
+
const LOGIN_TIMEOUT_MS = 2 * 60 * 1000;
|
|
148
178
|
console.log("⏳ Waiting for login (up to 2 minutes)...");
|
|
149
179
|
console.log(" Login will be detected automatically once you sign in.\n");
|
|
150
180
|
const finalCookies = await waitForLogin(context, () => browserClosed, LOGIN_TIMEOUT_MS);
|
|
@@ -154,7 +184,6 @@ export async function runSetupCookies() {
|
|
|
154
184
|
return;
|
|
155
185
|
}
|
|
156
186
|
if (!finalCookies) {
|
|
157
|
-
// Timeout — clean up
|
|
158
187
|
try {
|
|
159
188
|
await context.close();
|
|
160
189
|
}
|
|
@@ -165,23 +194,3 @@ export async function runSetupCookies() {
|
|
|
165
194
|
}
|
|
166
195
|
await saveCookiesAndClose(context, finalCookies);
|
|
167
196
|
}
|
|
168
|
-
async function saveCookiesAndClose(context, rawCookies) {
|
|
169
|
-
try {
|
|
170
|
-
await context.close();
|
|
171
|
-
}
|
|
172
|
-
catch { /* already closed */ }
|
|
173
|
-
const entries = rawCookies.map((c) => ({
|
|
174
|
-
name: c.name,
|
|
175
|
-
value: c.value,
|
|
176
|
-
domain: c.domain,
|
|
177
|
-
path: c.path,
|
|
178
|
-
secure: c.secure,
|
|
179
|
-
httpOnly: c.httpOnly,
|
|
180
|
-
expires: c.expires,
|
|
181
|
-
}));
|
|
182
|
-
const netscape = cookiesToNetscape(entries);
|
|
183
|
-
writeFileSync(PATHS.cookiesTxt, netscape, { mode: 0o600 });
|
|
184
|
-
const httpOnlyCount = entries.filter((c) => c.httpOnly).length;
|
|
185
|
-
console.log(`✅ Cookies saved to ${PATHS.cookiesTxt}`);
|
|
186
|
-
console.log(` ${entries.length} cookies extracted (${httpOnlyCount} httpOnly)\n`);
|
|
187
|
-
}
|
package/dist/tools/subtitles.js
CHANGED
|
@@ -3,7 +3,8 @@ import { join } from "node:path";
|
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { PATHS } from "../utils/config.js";
|
|
5
5
|
import { runYtDlp } from "../utils/ytdlp.js";
|
|
6
|
-
import { hasSIDCookies } from "../utils/cookies.js";
|
|
6
|
+
import { hasSIDCookies, areCookiesExpired } from "../utils/cookies.js";
|
|
7
|
+
import { tryHeadlessRefresh } from "../utils/cookieRefresh.js";
|
|
7
8
|
import { resolveVideoInput, normalizeVideoInputs } from "../utils/videoInput.js";
|
|
8
9
|
const AUTH_REQUIRED_MSG = "❌ 未认证。请先登录:\n• OAuth: npx @mkterswingman/yt-mcp setup\n• PAT: 设置环境变量 YT_MCP_TOKEN 或在 https://mkterswingman.com/pat/login 生成 token";
|
|
9
10
|
function toolOk(payload) {
|
|
@@ -284,6 +285,17 @@ export function registerSubtitleTools(server, config, tokenManager) {
|
|
|
284
285
|
const videoId = resolveVideoInput(video);
|
|
285
286
|
if (!videoId)
|
|
286
287
|
return toolErr("INVALID_INPUT", `无法解析视频 ID: ${video}`);
|
|
288
|
+
// Cookie pre-check: missing or expired → try headless refresh
|
|
289
|
+
if (!hasSIDCookies(PATHS.cookiesTxt) || areCookiesExpired(PATHS.cookiesTxt)) {
|
|
290
|
+
let refreshed = false;
|
|
291
|
+
try {
|
|
292
|
+
refreshed = await tryHeadlessRefresh();
|
|
293
|
+
}
|
|
294
|
+
catch { /* */ }
|
|
295
|
+
if (!refreshed || !hasSIDCookies(PATHS.cookiesTxt)) {
|
|
296
|
+
return toolErr("COOKIES_MISSING", "No valid YouTube cookies found.\nPlease run in your terminal: yt-mcp setup-cookies");
|
|
297
|
+
}
|
|
298
|
+
}
|
|
287
299
|
const usingDefaults = !languages;
|
|
288
300
|
const langs = languages ?? config.default_languages;
|
|
289
301
|
const fmt = format ?? "vtt";
|
|
@@ -295,7 +307,30 @@ export function registerSubtitleTools(server, config, tokenManager) {
|
|
|
295
307
|
const lang = langs[i];
|
|
296
308
|
const dl = await downloadSubtitle(videoId, lang, fmt);
|
|
297
309
|
if (dl.cookiesExpired) {
|
|
298
|
-
|
|
310
|
+
// Try headless auto-refresh
|
|
311
|
+
let refreshed = false;
|
|
312
|
+
try {
|
|
313
|
+
refreshed = await tryHeadlessRefresh();
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
// Playwright not available or browser failed — fall through
|
|
317
|
+
}
|
|
318
|
+
if (refreshed) {
|
|
319
|
+
// Retry the download with fresh cookies
|
|
320
|
+
const retry = await downloadSubtitle(videoId, lang, fmt);
|
|
321
|
+
if (retry.ok) {
|
|
322
|
+
if (retry.text) {
|
|
323
|
+
results.push({ language: lang, status: "ok", text: retry.text, file_path: retry.filePath });
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
results.push({ language: lang, status: "ok", file_path: retry.filePath });
|
|
327
|
+
}
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
// Retry also failed with expired cookies — give up
|
|
331
|
+
}
|
|
332
|
+
return toolErr("COOKIES_EXPIRED", "YouTube cookies have expired and auto-refresh failed.\n" +
|
|
333
|
+
"Please run in your terminal: yt-mcp setup-cookies");
|
|
299
334
|
}
|
|
300
335
|
if (!dl.ok) {
|
|
301
336
|
// When using default languages, silently skip unavailable ones
|
|
@@ -348,9 +383,16 @@ export function registerSubtitleTools(server, config, tokenManager) {
|
|
|
348
383
|
}
|
|
349
384
|
const langs = languages ?? config.default_languages;
|
|
350
385
|
const fmt = format ?? "vtt";
|
|
351
|
-
// Cookie pre-check
|
|
352
|
-
if (!hasSIDCookies(PATHS.cookiesTxt)) {
|
|
353
|
-
|
|
386
|
+
// Cookie pre-check: missing or expired → try headless refresh
|
|
387
|
+
if (!hasSIDCookies(PATHS.cookiesTxt) || areCookiesExpired(PATHS.cookiesTxt)) {
|
|
388
|
+
let refreshed = false;
|
|
389
|
+
try {
|
|
390
|
+
refreshed = await tryHeadlessRefresh();
|
|
391
|
+
}
|
|
392
|
+
catch { /* */ }
|
|
393
|
+
if (!refreshed || !hasSIDCookies(PATHS.cookiesTxt)) {
|
|
394
|
+
return toolErr("COOKIES_MISSING", "No valid YouTube cookies found.\nPlease run in your terminal: yt-mcp setup-cookies");
|
|
395
|
+
}
|
|
354
396
|
}
|
|
355
397
|
const results = [];
|
|
356
398
|
let succeeded = 0;
|
|
@@ -380,7 +422,30 @@ export function registerSubtitleTools(server, config, tokenManager) {
|
|
|
380
422
|
}
|
|
381
423
|
}
|
|
382
424
|
if (cookiesExpired) {
|
|
383
|
-
|
|
425
|
+
// Try headless auto-refresh
|
|
426
|
+
let refreshed = false;
|
|
427
|
+
try {
|
|
428
|
+
refreshed = await tryHeadlessRefresh();
|
|
429
|
+
}
|
|
430
|
+
catch { /* fall through */ }
|
|
431
|
+
if (refreshed) {
|
|
432
|
+
// Retry this video's subtitles
|
|
433
|
+
let retrySuccess = false;
|
|
434
|
+
for (const lang of langs) {
|
|
435
|
+
const retry = await downloadSubtitle(videoId, lang, fmt);
|
|
436
|
+
if (retry.ok) {
|
|
437
|
+
langFound.push(lang);
|
|
438
|
+
retrySuccess = true;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
if (retrySuccess) {
|
|
442
|
+
cookiesExpired = false;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if (cookiesExpired) {
|
|
446
|
+
return toolErr("COOKIES_EXPIRED", "YouTube cookies have expired and auto-refresh failed.\n" +
|
|
447
|
+
"Please run in your terminal: yt-mcp setup-cookies");
|
|
448
|
+
}
|
|
384
449
|
}
|
|
385
450
|
if (langFound.length > 0) {
|
|
386
451
|
succeeded++;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless cookie auto-refresh.
|
|
3
|
+
*
|
|
4
|
+
* Uses Playwright to open YouTube in a headless browser with the existing
|
|
5
|
+
* browser-profile. If Google session is still valid, YouTube cookies are
|
|
6
|
+
* extracted and saved automatically — no user interaction needed.
|
|
7
|
+
*
|
|
8
|
+
* If Google session has expired too, returns false so the caller can
|
|
9
|
+
* prompt the user for interactive login.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Attempt to refresh YouTube cookies headlessly using existing browser profile.
|
|
13
|
+
*
|
|
14
|
+
* @returns true if cookies were refreshed, false if Google session expired
|
|
15
|
+
* (needs interactive login).
|
|
16
|
+
* @throws if Playwright is not installed or browser can't launch.
|
|
17
|
+
*/
|
|
18
|
+
export declare function tryHeadlessRefresh(): Promise<boolean>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless cookie auto-refresh.
|
|
3
|
+
*
|
|
4
|
+
* Uses Playwright to open YouTube in a headless browser with the existing
|
|
5
|
+
* browser-profile. If Google session is still valid, YouTube cookies are
|
|
6
|
+
* extracted and saved automatically — no user interaction needed.
|
|
7
|
+
*
|
|
8
|
+
* If Google session has expired too, returns false so the caller can
|
|
9
|
+
* prompt the user for interactive login.
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync } from "node:fs";
|
|
12
|
+
import { PATHS, ensureConfigDir } from "./config.js";
|
|
13
|
+
import { detectBrowserChannel, hasYouTubeSession, saveCookiesToDisk, } from "../cli/setupCookies.js";
|
|
14
|
+
const HEADLESS_TIMEOUT_MS = 30_000;
|
|
15
|
+
/**
|
|
16
|
+
* Attempt to refresh YouTube cookies headlessly using existing browser profile.
|
|
17
|
+
*
|
|
18
|
+
* @returns true if cookies were refreshed, false if Google session expired
|
|
19
|
+
* (needs interactive login).
|
|
20
|
+
* @throws if Playwright is not installed or browser can't launch.
|
|
21
|
+
*/
|
|
22
|
+
export async function tryHeadlessRefresh() {
|
|
23
|
+
// No browser profile = never logged in = can't refresh
|
|
24
|
+
if (!existsSync(PATHS.browserProfile)) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
ensureConfigDir();
|
|
28
|
+
// Dynamic import to keep serve startup fast
|
|
29
|
+
let chromium;
|
|
30
|
+
try {
|
|
31
|
+
const pw = await import("playwright");
|
|
32
|
+
chromium = pw.chromium;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return false; // Playwright not installed — can't refresh
|
|
36
|
+
}
|
|
37
|
+
const channel = await detectBrowserChannel(chromium);
|
|
38
|
+
let context;
|
|
39
|
+
try {
|
|
40
|
+
context = await chromium.launchPersistentContext(PATHS.browserProfile, {
|
|
41
|
+
headless: true,
|
|
42
|
+
channel,
|
|
43
|
+
args: ["--disable-blink-features=AutomationControlled"],
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return false; // Browser launch failed
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const page = context.pages()[0] ?? (await context.newPage());
|
|
51
|
+
await page.goto("https://www.youtube.com", { timeout: HEADLESS_TIMEOUT_MS });
|
|
52
|
+
await page.waitForLoadState("domcontentloaded");
|
|
53
|
+
const cookies = await context.cookies("https://www.youtube.com");
|
|
54
|
+
if (!hasYouTubeSession(cookies)) {
|
|
55
|
+
// Google session expired — can't auto-refresh
|
|
56
|
+
await context.close().catch(() => { });
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
// Wait a moment for all cookies to settle
|
|
60
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
61
|
+
const finalCookies = await context.cookies("https://www.youtube.com");
|
|
62
|
+
saveCookiesToDisk(finalCookies);
|
|
63
|
+
await context.close().catch(() => { });
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
await context.close().catch(() => { });
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
package/dist/utils/cookies.d.ts
CHANGED
|
@@ -9,3 +9,10 @@ export interface CookieEntry {
|
|
|
9
9
|
}
|
|
10
10
|
export declare function cookiesToNetscape(cookies: CookieEntry[]): string;
|
|
11
11
|
export declare function hasSIDCookies(cookiesPath: string): boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Check if YouTube session cookies have expired by parsing Netscape cookie
|
|
14
|
+
* file timestamps (field 5, Unix epoch seconds).
|
|
15
|
+
*
|
|
16
|
+
* Returns true if: file missing, no session cookies, or ALL session cookies expired.
|
|
17
|
+
*/
|
|
18
|
+
export declare function areCookiesExpired(cookiesPath: string): boolean;
|
package/dist/utils/cookies.js
CHANGED
|
@@ -26,3 +26,53 @@ export function hasSIDCookies(cookiesPath) {
|
|
|
26
26
|
return false;
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Cookie names that indicate a valid YouTube session.
|
|
31
|
+
* If at least one of these is present and not expired, cookies are valid.
|
|
32
|
+
*/
|
|
33
|
+
const SESSION_COOKIE_PATTERNS = [
|
|
34
|
+
"SID", "HSID", "SSID", "APISID", "SAPISID",
|
|
35
|
+
"__Secure-1PSID", "__Secure-3PSID",
|
|
36
|
+
"__Secure-1PSIDTS", "__Secure-3PSIDTS",
|
|
37
|
+
"LOGIN_INFO",
|
|
38
|
+
];
|
|
39
|
+
/**
|
|
40
|
+
* Check if YouTube session cookies have expired by parsing Netscape cookie
|
|
41
|
+
* file timestamps (field 5, Unix epoch seconds).
|
|
42
|
+
*
|
|
43
|
+
* Returns true if: file missing, no session cookies, or ALL session cookies expired.
|
|
44
|
+
*/
|
|
45
|
+
export function areCookiesExpired(cookiesPath) {
|
|
46
|
+
if (!existsSync(cookiesPath))
|
|
47
|
+
return true;
|
|
48
|
+
try {
|
|
49
|
+
const content = readFileSync(cookiesPath, "utf8");
|
|
50
|
+
const nowSec = Date.now() / 1000;
|
|
51
|
+
let foundAnySession = false;
|
|
52
|
+
let foundAnyValid = false;
|
|
53
|
+
for (const line of content.split("\n")) {
|
|
54
|
+
if (line.startsWith("#") || !line.trim())
|
|
55
|
+
continue;
|
|
56
|
+
const fields = line.split("\t");
|
|
57
|
+
if (fields.length < 7)
|
|
58
|
+
continue;
|
|
59
|
+
const name = fields[5];
|
|
60
|
+
if (!SESSION_COOKIE_PATTERNS.includes(name))
|
|
61
|
+
continue;
|
|
62
|
+
foundAnySession = true;
|
|
63
|
+
const expiry = Number(fields[4]);
|
|
64
|
+
// expiry 0 = session cookie (no expiration) — treat as valid
|
|
65
|
+
if (expiry === 0 || expiry >= nowSec) {
|
|
66
|
+
foundAnyValid = true;
|
|
67
|
+
break; // At least one valid session cookie — not expired
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// No session cookies at all = need login; all expired = need refresh
|
|
71
|
+
if (!foundAnySession)
|
|
72
|
+
return true;
|
|
73
|
+
return !foundAnyValid;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mkterswingman/yt-mcp",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "YouTube MCP client — local subtitles + remote API proxy",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -17,10 +17,10 @@
|
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
19
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
20
|
+
"playwright": "^1.58.0",
|
|
20
21
|
"zod": "^4.3.6"
|
|
21
22
|
},
|
|
22
23
|
"optionalDependencies": {
|
|
23
|
-
"playwright": "^1.58.0",
|
|
24
24
|
"puppeteer-core": "^24.0.0"
|
|
25
25
|
},
|
|
26
26
|
"keywords": [
|