diff --git a/bun.lock b/bun.lock index 74a0c24..47fb0a2 100644 --- a/bun.lock +++ b/bun.lock @@ -5,15 +5,15 @@ "": { "name": "x-dl", "dependencies": { - "playwright": "^1.57.0", + "playwright": "^1.60.0", }, }, }, "packages": { "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], - "playwright": ["playwright@1.57.0", "", { "dependencies": { "playwright-core": "1.57.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw=="], + "playwright": ["playwright@1.62.1", "", { "dependencies": { "playwright-core": "1.62.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg=="], - "playwright-core": ["playwright-core@1.57.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ=="], + "playwright-core": ["playwright-core@1.62.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw=="], } } diff --git a/package.json b/package.json index eaf9295..775bcef 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "author": "", "license": "MIT", "dependencies": { - "playwright": "^1.57.0" + "playwright": "^1.60.0" }, "devDependencies": {} } diff --git a/src/extractor.ts b/src/extractor.ts index 3896dfb..7fd7f09 100644 --- a/src/extractor.ts +++ b/src/extractor.ts @@ -11,6 +11,7 @@ import { isValidTwitterUrl, parseTweetUrl, } from './utils.ts'; +import { findChromePath, DESKTOP_CHROME_UA } from './private.ts'; type ExtractCandidate = { url: string; @@ -37,7 +38,7 @@ export class VideoExtractor { } async extract(url: string, authenticatedPage?: Page): Promise { - console.log(`\ud83c\udfac Extracting video from: ${url}`); + console.log(`🎬 Extracting video from: ${url}`); if (!isValidTwitterUrl(url)) { return { @@ -56,7 +57,7 @@ export class VideoExtractor { }; } - console.log(`\ud83d\udcdd Tweet: @${tweetInfo.author} (ID: ${tweetInfo.id})`); + console.log(`📝 Tweet: @${tweetInfo.author} (ID: ${tweetInfo.id})`); const { chromium } = await import('playwright'); @@ -79,7 +80,7 @@ export class VideoExtractor { candidates.add(u); }); - console.log('\ud83c\udf10 Opening tweet in browser...'); + console.log('🌐 Opening tweet in browser...'); await page.goto(url, { waitUntil: 'domcontentloaded', timeout: this.timeout }); // Give X a moment to hydrate @@ -89,7 +90,7 @@ export class VideoExtractor { const loginWall = hasLoginWall(pageHtml); if (loginWall) { - console.log('\u26a0\ufe0f Login wall detected; trying to extract anyway...'); + console.log('⚠️ Login wall detected; trying to extract anyway...'); } // Try to trigger media loading. @@ -116,8 +117,8 @@ export class VideoExtractor { } const filename = generateFilename(tweetInfo); - console.log(`\u2705 Video extracted: ${videoUrl.url}`); - console.log(`\ud83d\udccb Suggested filename: ${filename}`); + console.log(`✅ Video extracted: ${videoUrl.url}`); + console.log(`📋 Suggested filename: ${filename}`); return { videoUrl }; } catch (error) { @@ -145,10 +146,16 @@ export class VideoExtractor { launchOptions.executablePath = this.browserExecutablePath; } else if (this.browserChannel) { launchOptions.channel = this.browserChannel; + } else { + const chromePath = findChromePath(); + if (chromePath) { + // X fingerprints bundled Chromium itself; prefer a real Chrome installation. + launchOptions.executablePath = chromePath; + } } const browser = await chromium.launch(launchOptions); - const context = await browser.newContext(); + const context = await browser.newContext({ userAgent: !this.headed ? DESKTOP_CHROME_UA : undefined }); const page = await context.newPage(); return { browser, context, page }; } @@ -191,7 +198,7 @@ export class VideoExtractor { const htmlPath = path.join(this.debugArtifactsDir, `${prefix}.html`); fs.writeFileSync(htmlPath, pageHtml, 'utf-8'); debugInfo.htmlPath = htmlPath; - console.log(`\ud83d\udcc3 HTML saved to: ${htmlPath}`); + console.log(`📄 HTML saved to: ${htmlPath}`); } // Save screenshot @@ -199,7 +206,7 @@ export class VideoExtractor { const screenshotPath = path.join(this.debugArtifactsDir, `${prefix}.png`); await page.screenshot({ path: screenshotPath, fullPage: true }); debugInfo.screenshotPath = screenshotPath; - console.log(`\ud83d\udcf7 Screenshot saved to: ${screenshotPath}`); + console.log(`📷 Screenshot saved to: ${screenshotPath}`); } catch { // Screenshot failed, continue without it } @@ -207,7 +214,7 @@ export class VideoExtractor { return debugInfo; } catch (error) { console.warn( - `\u26a0\ufe0f Failed to save debug artifacts: ${error instanceof Error ? error.message : 'Unknown error'}` + `⚠️ Failed to save debug artifacts: ${error instanceof Error ? error.message : 'Unknown error'}` ); return undefined; } @@ -269,7 +276,7 @@ export class VideoExtractor { page: Page; networkCandidates: string[]; }): Promise { - console.log('\ud83d\udd0d Looking for video...'); + console.log('🔍 Looking for video...'); const perfCandidates = await this.getPerformanceCandidates(page); const domCandidates = await this.getDomCandidates(page); diff --git a/src/private.ts b/src/private.ts index 3c2105d..061d9ad 100644 --- a/src/private.ts +++ b/src/private.ts @@ -5,6 +5,10 @@ import type { BrowserContext, Page } from 'playwright'; const DEFAULT_PROFILE_DIR = path.join(os.homedir(), '.x-dl-chrome-profile'); +// Headless Chrome's default UA includes "HeadlessChrome", which X blocks with a 403. +export const DESKTOP_CHROME_UA = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36'; + const CHROME_PATHS_MACOS = [ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', ]; @@ -17,9 +21,24 @@ const CHROME_PATHS_LINUX = [ '/snap/bin/chromium', ]; +const CHROME_PATHS_WINDOWS = [ + path.join(os.homedir(), 'AppData/Local/Google/Chrome/Application/chrome.exe'), + 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', + 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', +]; + export function findChromePath(): string | null { const platform = os.platform(); - const candidates = platform === 'darwin' ? CHROME_PATHS_MACOS : CHROME_PATHS_LINUX; + let candidates: string[] = []; + + if (platform === 'darwin') { + candidates = CHROME_PATHS_MACOS; + } else if (platform === 'linux') { + candidates = CHROME_PATHS_LINUX; + } else if (platform === 'win32') { + candidates = CHROME_PATHS_WINDOWS; + } + for (const p of candidates) { if (fs.existsSync(p)) return p; } @@ -41,9 +60,12 @@ export async function launchPrivateBrowser(options?: { }): Promise { const { chromium } = await import('playwright'); + const headless = !(options?.headed); + const context = await chromium.launchPersistentContext(DEFAULT_PROFILE_DIR, { channel: 'chrome', - headless: !(options?.headed), + headless, + userAgent: headless ? DESKTOP_CHROME_UA : undefined, args: [ '--disable-blink-features=AutomationControlled', ],