feat: replace Playwright auth with CDP for private tweets#16
feat: replace Playwright auth with CDP for private tweets#16RichardBray merged 17 commits intomainfrom
Conversation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ages Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace connectOverCDP (which hangs on Chrome 146) with Playwright's launchPersistentContext using channel: 'chrome' and a dedicated profile at ~/.x-dl-chrome-profile. Remove --port flag, update help text and tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Remove references to --port, chrome://inspect, and remote debugging. Document the new persistent profile flow at ~/.x-dl-chrome-profile. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Twitter blocks login in Playwright-controlled browsers. Instead, launch real Chrome (not Playwright) for the login flow using the same profile dir. User logs in normally, closes Chrome, then Playwright picks up the persisted cookies headlessly for extraction. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Opens real Chrome with the dedicated profile dir so the user can log in without Playwright automation detection. Session persists for cdp mode. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Real Chrome and Playwright don't share cookie storage, so using real Chrome for login and Playwright for extraction doesn't work. Instead, use Playwright for both but with --disable-blink-features=AutomationControlled and navigator.webdriver override to avoid Twitter's automation detection. Login subcommand now uses Playwright headed mode. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
src/index.ts
Outdated
| const connection = await launchPrivateBrowser({ headed: true }); | ||
|
|
||
| await connection.page.goto('https://x.com/i/flow/login', { | ||
| waitUntil: 'domcontentloaded', | ||
| timeout: 30000, | ||
| }); | ||
|
|
||
| // Poll for auth_token cookie (5 min timeout) | ||
| const start = Date.now(); | ||
| const timeoutMs = 300000; | ||
| while (Date.now() - start < timeoutMs) { | ||
| const cookies = await connection.context.cookies('https://x.com'); | ||
| if (cookies.some(c => c.name === 'auth_token')) break; | ||
| await new Promise(r => setTimeout(r, 2000)); | ||
| } | ||
|
|
||
| const cookies = await connection.context.cookies('https://x.com'); | ||
| await connection.cleanup(); | ||
|
|
||
| if (!cookies.some(c => c.name === 'auth_token')) { | ||
| console.error('❌ Login timed out (5 minutes). Please try again.\n'); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| console.log('✅ Login successful! You can now use: x-dl cdp <url>\n'); |
There was a problem hiding this comment.
🔴 process.exit(1) inside try/finally bypasses async browser cleanup in handleLoginMode
In handleLoginMode, when login times out, process.exit(1) is called at line 604 inside a try...finally block. The finally block (line 608-610) contains await connection.cleanup(), which closes the Chrome browser process. Because process.exit() terminates the process synchronously — before the async await in finally can complete — the Chrome browser process is left running as an orphan.
The correct pattern is already used in handleCdpMode (src/index.ts:380-533): use an exitCode variable with return to trigger the finally block naturally, then call process.exit() after cleanup completes.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Good catch — fixed in 02b98fd. handleLoginMode now wraps everything after launchPrivateBrowser() in a try/finally so connection.cleanup() always runs.
Replace process.exit() calls inside try blocks with return + exitCode pattern so the finally block always runs connection.cleanup(). Also wrap handleLoginMode in try/finally. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
src/extractor.ts
Outdated
| } | ||
|
|
||
| async extract(url: string): Promise<ExtractResult> { | ||
| async extract(url: string, externalPage?: Page): Promise<ExtractResult> { |
There was a problem hiding this comment.
what is externalPage used for?
There was a problem hiding this comment.
externalPage allows CDP mode (x-dl cdp) to pass in a Playwright Page that's already connected to a logged-in Chrome session. When provided, the extractor reuses that page instead of launching its own browser. This is how private tweets work — the caller manages the browser lifecycle (login, cookies) and the extractor just navigates and intercepts video URLs on the pre-authenticated page.
For the normal public flow (x-dl <url>), externalPage is undefined and the extractor creates/closes its own browser as before.
There was a problem hiding this comment.
Renamed to authenticatedPage in 56a3a97 — makes the intent clearer.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Summary
--profile,--login,--verify-auth) with CDP-based approach viax-dl cdp <url>src/cdp.tsmodule connects to user's real Chrome browser via Chrome DevTools Protocol for authenticated tweet downloadsTest plan
bun test test/unit/— 63 tests passingx-dl <public-tweet-url>— regression test, public tweets still workx-dl cdp <private-tweet-url>— CDP mode with Chrome remote debugging enabledx-dl cdp <url>with Chrome not running — auto-launch flowx-dl --help— shows CDP MODE section, no deprecated flags🤖 Generated with Claude Code