Skip to content

feat: replace Playwright auth with CDP for private tweets#16

Merged
RichardBray merged 17 commits intomainfrom
feature/cdp-private-tweets
Mar 28, 2026
Merged

feat: replace Playwright auth with CDP for private tweets#16
RichardBray merged 17 commits intomainfrom
feature/cdp-private-tweets

Conversation

@RichardBray
Copy link
Copy Markdown
Owner

@RichardBray RichardBray commented Mar 27, 2026

Summary

  • Replace deprecated Playwright persistent-context auth (--profile, --login, --verify-auth) with CDP-based approach via x-dl cdp <url>
  • New src/cdp.ts module connects to user's real Chrome browser via Chrome DevTools Protocol for authenticated tweet downloads
  • Auto-detects running Chrome, launches headlessly if needed, and triggers login flow when not authenticated

Test plan

  • bun test test/unit/ — 63 tests passing
  • x-dl <public-tweet-url> — regression test, public tweets still work
  • x-dl cdp <private-tweet-url> — CDP mode with Chrome remote debugging enabled
  • x-dl cdp <url> with Chrome not running — auto-launch flow
  • x-dl --help — shows CDP MODE section, no deprecated flags

🤖 Generated with Claude Code


Open with Devin

Richard Oliver Bray and others added 8 commits March 27, 2026 19:59
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>
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

Richard Oliver Bray and others added 7 commits March 27, 2026 20:32
…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>
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 new potential issues.

View 7 additional findings in Devin Review.

Open in Devin Review

src/index.ts Outdated
Comment on lines +577 to +601
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');
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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> {
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is externalPage used for?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to authenticatedPage in 56a3a97 — makes the intent clearer.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@RichardBray RichardBray merged commit d8e32e7 into main Mar 28, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant