x-browser 0.1.2__py3-none-any.whl
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.
- x_browser/__init__.py +1 -0
- x_browser/actions.py +389 -0
- x_browser/browser.py +174 -0
- x_browser/cli.py +449 -0
- x_browser/config.py +89 -0
- x_browser/formatters.py +285 -0
- x_browser/scrapers.py +458 -0
- x_browser-0.1.2.dist-info/METADATA +364 -0
- x_browser-0.1.2.dist-info/RECORD +12 -0
- x_browser-0.1.2.dist-info/WHEEL +4 -0
- x_browser-0.1.2.dist-info/entry_points.txt +2 -0
- x_browser-0.1.2.dist-info/licenses/LICENSE +21 -0
x_browser/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""x-browser: Browser-based X/Twitter automation using Playwright."""
|
x_browser/actions.py
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"""Write actions for X: post, like, retweet, reply, quote, delete, bookmark.
|
|
2
|
+
|
|
3
|
+
Every action calls politeness_wait() before executing.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from playwright.async_api import Page
|
|
13
|
+
|
|
14
|
+
from .config import Config
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _normalize_tweet_url(id_or_url: str) -> str:
|
|
18
|
+
"""Turn a tweet ID or URL into a canonical x.com URL."""
|
|
19
|
+
id_or_url = id_or_url.strip()
|
|
20
|
+
# Already a URL
|
|
21
|
+
if id_or_url.startswith("http"):
|
|
22
|
+
return id_or_url.split("?")[0] # strip query params
|
|
23
|
+
# Bare numeric ID — we need a placeholder username, but X redirects anyway
|
|
24
|
+
if id_or_url.isdigit():
|
|
25
|
+
return f"https://x.com/i/status/{id_or_url}"
|
|
26
|
+
return id_or_url
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def _goto_tweet(page: Page, id_or_url: str) -> None:
|
|
30
|
+
"""Navigate to a tweet page and wait for it to load."""
|
|
31
|
+
url = _normalize_tweet_url(id_or_url)
|
|
32
|
+
await page.goto(url, wait_until="domcontentloaded")
|
|
33
|
+
# Wait for the tweet article to appear
|
|
34
|
+
await page.wait_for_selector('article[data-testid="tweet"]', timeout=15_000)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
# Post a tweet
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
async def tweet_post(
|
|
42
|
+
page: Page,
|
|
43
|
+
text: str,
|
|
44
|
+
*,
|
|
45
|
+
config: Config | None = None,
|
|
46
|
+
min_delay: float | None = None,
|
|
47
|
+
max_delay: float | None = None,
|
|
48
|
+
poll_options: list[str] | None = None,
|
|
49
|
+
poll_duration: int = 1440,
|
|
50
|
+
) -> dict:
|
|
51
|
+
"""Compose and post a new tweet.
|
|
52
|
+
|
|
53
|
+
Returns {"ok": True, "text": <posted text>} on success.
|
|
54
|
+
"""
|
|
55
|
+
cfg = config or Config.load()
|
|
56
|
+
await cfg.politeness_wait(min_delay=min_delay, max_delay=max_delay)
|
|
57
|
+
|
|
58
|
+
await page.goto("https://x.com/compose/post", wait_until="domcontentloaded")
|
|
59
|
+
# Wait for the compose box
|
|
60
|
+
editor = await page.wait_for_selector(
|
|
61
|
+
'div[data-testid="tweetTextarea_0"]', timeout=10_000
|
|
62
|
+
)
|
|
63
|
+
await editor.click()
|
|
64
|
+
await page.keyboard.type(text, delay=30)
|
|
65
|
+
|
|
66
|
+
# Optional: add poll
|
|
67
|
+
if poll_options and len(poll_options) >= 2:
|
|
68
|
+
# Click the poll icon
|
|
69
|
+
poll_btn = await page.wait_for_selector(
|
|
70
|
+
'button[data-testid="pollButton"]', timeout=5_000
|
|
71
|
+
)
|
|
72
|
+
await poll_btn.click()
|
|
73
|
+
await page.wait_for_timeout(500)
|
|
74
|
+
# Fill poll options
|
|
75
|
+
poll_inputs = await page.query_selector_all('input[placeholder*="Choice"]')
|
|
76
|
+
for i, opt in enumerate(poll_options[:4]): # max 4 options
|
|
77
|
+
if i < len(poll_inputs):
|
|
78
|
+
await poll_inputs[i].fill(opt)
|
|
79
|
+
elif i >= 2:
|
|
80
|
+
# Click "Add" to add more options (3rd, 4th)
|
|
81
|
+
add_btn = await page.query_selector('button[data-testid="addPollOptionButton"]')
|
|
82
|
+
if add_btn:
|
|
83
|
+
await add_btn.click()
|
|
84
|
+
await page.wait_for_timeout(300)
|
|
85
|
+
poll_inputs = await page.query_selector_all('input[placeholder*="Choice"]')
|
|
86
|
+
if i < len(poll_inputs):
|
|
87
|
+
await poll_inputs[i].fill(opt)
|
|
88
|
+
|
|
89
|
+
# Click Post button
|
|
90
|
+
post_btn = await page.wait_for_selector(
|
|
91
|
+
'button[data-testid="tweetButton"]', timeout=5_000
|
|
92
|
+
)
|
|
93
|
+
await post_btn.click()
|
|
94
|
+
|
|
95
|
+
# Wait for navigation or confirmation
|
|
96
|
+
await page.wait_for_timeout(3000)
|
|
97
|
+
|
|
98
|
+
return {"ok": True, "text": text}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
# Delete a tweet
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
async def tweet_delete(
|
|
106
|
+
page: Page,
|
|
107
|
+
id_or_url: str,
|
|
108
|
+
*,
|
|
109
|
+
config: Config | None = None,
|
|
110
|
+
min_delay: float | None = None,
|
|
111
|
+
max_delay: float | None = None,
|
|
112
|
+
) -> dict:
|
|
113
|
+
"""Delete a tweet you own."""
|
|
114
|
+
cfg = config or Config.load()
|
|
115
|
+
await cfg.politeness_wait(min_delay=min_delay, max_delay=max_delay)
|
|
116
|
+
|
|
117
|
+
await _goto_tweet(page, id_or_url)
|
|
118
|
+
|
|
119
|
+
# Click the "..." (more) menu on the tweet
|
|
120
|
+
more_btn = await page.wait_for_selector(
|
|
121
|
+
'article[data-testid="tweet"] button[data-testid="caret"]', timeout=5_000
|
|
122
|
+
)
|
|
123
|
+
await more_btn.click()
|
|
124
|
+
await page.wait_for_timeout(500)
|
|
125
|
+
|
|
126
|
+
# Click "Delete"
|
|
127
|
+
delete_item = await page.wait_for_selector(
|
|
128
|
+
'div[data-testid="Dropdown"] [role="menuitem"]:has-text("Delete")',
|
|
129
|
+
timeout=5_000,
|
|
130
|
+
)
|
|
131
|
+
await delete_item.click()
|
|
132
|
+
await page.wait_for_timeout(500)
|
|
133
|
+
|
|
134
|
+
# Confirm deletion
|
|
135
|
+
confirm_btn = await page.wait_for_selector(
|
|
136
|
+
'button[data-testid="confirmationSheetConfirm"]', timeout=5_000
|
|
137
|
+
)
|
|
138
|
+
await confirm_btn.click()
|
|
139
|
+
await page.wait_for_timeout(2000)
|
|
140
|
+
|
|
141
|
+
return {"ok": True, "deleted": id_or_url}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
# Reply to a tweet
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
async def tweet_reply(
|
|
149
|
+
page: Page,
|
|
150
|
+
id_or_url: str,
|
|
151
|
+
text: str,
|
|
152
|
+
*,
|
|
153
|
+
config: Config | None = None,
|
|
154
|
+
min_delay: float | None = None,
|
|
155
|
+
max_delay: float | None = None,
|
|
156
|
+
) -> dict:
|
|
157
|
+
"""Reply to a tweet."""
|
|
158
|
+
cfg = config or Config.load()
|
|
159
|
+
await cfg.politeness_wait(min_delay=min_delay, max_delay=max_delay)
|
|
160
|
+
|
|
161
|
+
await _goto_tweet(page, id_or_url)
|
|
162
|
+
|
|
163
|
+
# Click the reply button on the tweet
|
|
164
|
+
reply_btn = await page.wait_for_selector(
|
|
165
|
+
'article[data-testid="tweet"] button[data-testid="reply"]', timeout=5_000
|
|
166
|
+
)
|
|
167
|
+
await reply_btn.click()
|
|
168
|
+
await page.wait_for_timeout(1000)
|
|
169
|
+
|
|
170
|
+
# Type in the reply compose box
|
|
171
|
+
editor = await page.wait_for_selector(
|
|
172
|
+
'div[data-testid="tweetTextarea_0"]', timeout=5_000
|
|
173
|
+
)
|
|
174
|
+
await editor.click()
|
|
175
|
+
await page.keyboard.type(text, delay=30)
|
|
176
|
+
|
|
177
|
+
# Click Reply/Post button
|
|
178
|
+
post_btn = await page.wait_for_selector(
|
|
179
|
+
'button[data-testid="tweetButton"]', timeout=5_000
|
|
180
|
+
)
|
|
181
|
+
await post_btn.click()
|
|
182
|
+
await page.wait_for_timeout(3000)
|
|
183
|
+
|
|
184
|
+
return {"ok": True, "replied_to": id_or_url, "text": text}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ---------------------------------------------------------------------------
|
|
188
|
+
# Quote tweet
|
|
189
|
+
# ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
async def tweet_quote(
|
|
192
|
+
page: Page,
|
|
193
|
+
id_or_url: str,
|
|
194
|
+
text: str,
|
|
195
|
+
*,
|
|
196
|
+
config: Config | None = None,
|
|
197
|
+
min_delay: float | None = None,
|
|
198
|
+
max_delay: float | None = None,
|
|
199
|
+
) -> dict:
|
|
200
|
+
"""Quote a tweet with your own commentary."""
|
|
201
|
+
cfg = config or Config.load()
|
|
202
|
+
await cfg.politeness_wait(min_delay=min_delay, max_delay=max_delay)
|
|
203
|
+
|
|
204
|
+
await _goto_tweet(page, id_or_url)
|
|
205
|
+
|
|
206
|
+
# Click the retweet/repost button
|
|
207
|
+
repost_btn = await page.wait_for_selector(
|
|
208
|
+
'article[data-testid="tweet"] button[data-testid="retweet"]', timeout=5_000
|
|
209
|
+
)
|
|
210
|
+
await repost_btn.click()
|
|
211
|
+
await page.wait_for_timeout(500)
|
|
212
|
+
|
|
213
|
+
# Click "Quote" from the dropdown
|
|
214
|
+
quote_item = await page.wait_for_selector(
|
|
215
|
+
'[role="menuitem"]:has-text("Quote")', timeout=5_000
|
|
216
|
+
)
|
|
217
|
+
await quote_item.click()
|
|
218
|
+
await page.wait_for_timeout(1000)
|
|
219
|
+
|
|
220
|
+
# Type in the quote compose box
|
|
221
|
+
editor = await page.wait_for_selector(
|
|
222
|
+
'div[data-testid="tweetTextarea_0"]', timeout=5_000
|
|
223
|
+
)
|
|
224
|
+
await editor.click()
|
|
225
|
+
await page.keyboard.type(text, delay=30)
|
|
226
|
+
|
|
227
|
+
# Click Post button
|
|
228
|
+
post_btn = await page.wait_for_selector(
|
|
229
|
+
'button[data-testid="tweetButton"]', timeout=5_000
|
|
230
|
+
)
|
|
231
|
+
await post_btn.click()
|
|
232
|
+
await page.wait_for_timeout(3000)
|
|
233
|
+
|
|
234
|
+
return {"ok": True, "quoted": id_or_url, "text": text}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# ---------------------------------------------------------------------------
|
|
238
|
+
# Like a tweet
|
|
239
|
+
# ---------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
async def like(
|
|
242
|
+
page: Page,
|
|
243
|
+
id_or_url: str,
|
|
244
|
+
*,
|
|
245
|
+
config: Config | None = None,
|
|
246
|
+
min_delay: float | None = None,
|
|
247
|
+
max_delay: float | None = None,
|
|
248
|
+
) -> dict:
|
|
249
|
+
"""Like a tweet."""
|
|
250
|
+
cfg = config or Config.load()
|
|
251
|
+
await cfg.politeness_wait(min_delay=min_delay, max_delay=max_delay)
|
|
252
|
+
|
|
253
|
+
await _goto_tweet(page, id_or_url)
|
|
254
|
+
|
|
255
|
+
like_btn = await page.wait_for_selector(
|
|
256
|
+
'article[data-testid="tweet"] button[data-testid="like"]', timeout=5_000
|
|
257
|
+
)
|
|
258
|
+
await like_btn.click()
|
|
259
|
+
await page.wait_for_timeout(1000)
|
|
260
|
+
|
|
261
|
+
return {"ok": True, "liked": id_or_url}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# ---------------------------------------------------------------------------
|
|
265
|
+
# Retweet
|
|
266
|
+
# ---------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
async def retweet(
|
|
269
|
+
page: Page,
|
|
270
|
+
id_or_url: str,
|
|
271
|
+
*,
|
|
272
|
+
config: Config | None = None,
|
|
273
|
+
min_delay: float | None = None,
|
|
274
|
+
max_delay: float | None = None,
|
|
275
|
+
) -> dict:
|
|
276
|
+
"""Retweet (repost) a tweet."""
|
|
277
|
+
cfg = config or Config.load()
|
|
278
|
+
await cfg.politeness_wait(min_delay=min_delay, max_delay=max_delay)
|
|
279
|
+
|
|
280
|
+
await _goto_tweet(page, id_or_url)
|
|
281
|
+
|
|
282
|
+
repost_btn = await page.wait_for_selector(
|
|
283
|
+
'article[data-testid="tweet"] button[data-testid="retweet"]', timeout=5_000
|
|
284
|
+
)
|
|
285
|
+
await repost_btn.click()
|
|
286
|
+
await page.wait_for_timeout(500)
|
|
287
|
+
|
|
288
|
+
# Click "Repost" from the dropdown
|
|
289
|
+
repost_item = await page.wait_for_selector(
|
|
290
|
+
'[role="menuitem"]:has-text("Repost")', timeout=5_000
|
|
291
|
+
)
|
|
292
|
+
await repost_item.click()
|
|
293
|
+
await page.wait_for_timeout(2000)
|
|
294
|
+
|
|
295
|
+
return {"ok": True, "retweeted": id_or_url}
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# ---------------------------------------------------------------------------
|
|
299
|
+
# Bookmark / Unbookmark
|
|
300
|
+
# ---------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
async def bookmark(
|
|
303
|
+
page: Page,
|
|
304
|
+
id_or_url: str,
|
|
305
|
+
*,
|
|
306
|
+
config: Config | None = None,
|
|
307
|
+
min_delay: float | None = None,
|
|
308
|
+
max_delay: float | None = None,
|
|
309
|
+
) -> dict:
|
|
310
|
+
"""Bookmark a tweet."""
|
|
311
|
+
cfg = config or Config.load()
|
|
312
|
+
await cfg.politeness_wait(min_delay=min_delay, max_delay=max_delay)
|
|
313
|
+
|
|
314
|
+
await _goto_tweet(page, id_or_url)
|
|
315
|
+
|
|
316
|
+
bookmark_btn = await page.wait_for_selector(
|
|
317
|
+
'article[data-testid="tweet"] button[data-testid="bookmark"]', timeout=5_000
|
|
318
|
+
)
|
|
319
|
+
await bookmark_btn.click()
|
|
320
|
+
await page.wait_for_timeout(1000)
|
|
321
|
+
|
|
322
|
+
return {"ok": True, "bookmarked": id_or_url}
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
async def unbookmark(
|
|
326
|
+
page: Page,
|
|
327
|
+
id_or_url: str,
|
|
328
|
+
*,
|
|
329
|
+
config: Config | None = None,
|
|
330
|
+
min_delay: float | None = None,
|
|
331
|
+
max_delay: float | None = None,
|
|
332
|
+
) -> dict:
|
|
333
|
+
"""Remove bookmark from a tweet."""
|
|
334
|
+
cfg = config or Config.load()
|
|
335
|
+
await cfg.politeness_wait(min_delay=min_delay, max_delay=max_delay)
|
|
336
|
+
|
|
337
|
+
await _goto_tweet(page, id_or_url)
|
|
338
|
+
|
|
339
|
+
# If already bookmarked, the testid is "removeBookmark"
|
|
340
|
+
unbookmark_btn = await page.wait_for_selector(
|
|
341
|
+
'article[data-testid="tweet"] button[data-testid="removeBookmark"]',
|
|
342
|
+
timeout=5_000,
|
|
343
|
+
)
|
|
344
|
+
await unbookmark_btn.click()
|
|
345
|
+
await page.wait_for_timeout(1000)
|
|
346
|
+
|
|
347
|
+
return {"ok": True, "unbookmarked": id_or_url}
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
# ---------------------------------------------------------------------------
|
|
351
|
+
# Follow a user
|
|
352
|
+
# ---------------------------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
async def follow(
|
|
355
|
+
page: Page,
|
|
356
|
+
username: str,
|
|
357
|
+
*,
|
|
358
|
+
config: Config | None = None,
|
|
359
|
+
min_delay: float | None = None,
|
|
360
|
+
max_delay: float | None = None,
|
|
361
|
+
) -> dict:
|
|
362
|
+
"""Follow a user by username."""
|
|
363
|
+
cfg = config or Config.load()
|
|
364
|
+
await cfg.politeness_wait(min_delay=min_delay, max_delay=max_delay)
|
|
365
|
+
|
|
366
|
+
username = username.lstrip("@").strip()
|
|
367
|
+
await page.goto(f"https://x.com/{username}", wait_until="domcontentloaded")
|
|
368
|
+
await page.wait_for_timeout(2000)
|
|
369
|
+
|
|
370
|
+
# Look for the Follow button (not Following — that means already followed)
|
|
371
|
+
follow_btn = await page.query_selector(
|
|
372
|
+
f'button[data-testid="{username}-follow"]'
|
|
373
|
+
)
|
|
374
|
+
if not follow_btn:
|
|
375
|
+
# Try generic follow button
|
|
376
|
+
follow_btn = await page.query_selector(
|
|
377
|
+
'div[data-testid="placementTracking"] button:not([data-testid*="unfollow"])'
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
if follow_btn:
|
|
381
|
+
label = (await follow_btn.inner_text()).strip()
|
|
382
|
+
if label == "Follow":
|
|
383
|
+
await follow_btn.click()
|
|
384
|
+
await page.wait_for_timeout(1500)
|
|
385
|
+
return {"ok": True, "followed": username}
|
|
386
|
+
else:
|
|
387
|
+
return {"ok": True, "already_following": username}
|
|
388
|
+
else:
|
|
389
|
+
return {"ok": False, "error": f"Could not find follow button for @{username}"}
|
x_browser/browser.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Browser session management for x-browser.
|
|
2
|
+
|
|
3
|
+
Launches Chrome as a normal subprocess (zero automation flags) with
|
|
4
|
+
--remote-debugging-port, then connects via CDP. X cannot distinguish
|
|
5
|
+
this from a human-opened Chrome window.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
import socket
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
from playwright.async_api import Browser, BrowserContext, Page, async_playwright
|
|
20
|
+
|
|
21
|
+
from .config import CONFIG_DIR
|
|
22
|
+
|
|
23
|
+
USER_DATA_DIR = CONFIG_DIR / "chrome-data"
|
|
24
|
+
|
|
25
|
+
# Where Chrome lives on macOS
|
|
26
|
+
CHROME_PATHS = [
|
|
27
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
28
|
+
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
|
|
29
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _find_chrome() -> str:
|
|
34
|
+
for p in CHROME_PATHS:
|
|
35
|
+
if Path(p).exists():
|
|
36
|
+
return p
|
|
37
|
+
raise FileNotFoundError(
|
|
38
|
+
"Google Chrome not found. Install it or set CHROME_PATH env var."
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _free_port() -> int:
|
|
43
|
+
"""Find a free TCP port."""
|
|
44
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
45
|
+
s.bind(("127.0.0.1", 0))
|
|
46
|
+
return s.getsockname()[1]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def _wait_for_devtools(port: int, timeout: float = 15) -> str:
|
|
50
|
+
"""Poll the DevTools JSON endpoint until Chrome is ready.
|
|
51
|
+
Returns the WebSocket debugger URL.
|
|
52
|
+
"""
|
|
53
|
+
url = f"http://127.0.0.1:{port}/json/version"
|
|
54
|
+
deadline = time.monotonic() + timeout
|
|
55
|
+
async with httpx.AsyncClient() as client:
|
|
56
|
+
while time.monotonic() < deadline:
|
|
57
|
+
try:
|
|
58
|
+
resp = await client.get(url, timeout=2)
|
|
59
|
+
data = resp.json()
|
|
60
|
+
return data["webSocketDebuggerUrl"]
|
|
61
|
+
except Exception:
|
|
62
|
+
await asyncio.sleep(0.3)
|
|
63
|
+
raise TimeoutError(f"Chrome DevTools did not start on port {port}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class XBrowser:
|
|
67
|
+
"""Launches real Chrome and connects via CDP — fully undetectable."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, *, headless: bool = True) -> None:
|
|
70
|
+
self._headless = headless
|
|
71
|
+
self._chrome_proc: subprocess.Popen | None = None
|
|
72
|
+
self._pw = None
|
|
73
|
+
self._browser: Browser | None = None
|
|
74
|
+
self._ctx: BrowserContext | None = None
|
|
75
|
+
self.page: Page | None = None
|
|
76
|
+
|
|
77
|
+
async def launch(self) -> Page:
|
|
78
|
+
USER_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
chrome = _find_chrome()
|
|
80
|
+
port = _free_port()
|
|
81
|
+
|
|
82
|
+
cmd = [
|
|
83
|
+
chrome,
|
|
84
|
+
f"--user-data-dir={USER_DATA_DIR}",
|
|
85
|
+
f"--remote-debugging-port={port}",
|
|
86
|
+
"--no-first-run",
|
|
87
|
+
"--no-default-browser-check",
|
|
88
|
+
"--disable-default-apps",
|
|
89
|
+
f"--window-size=1280,900",
|
|
90
|
+
]
|
|
91
|
+
if self._headless:
|
|
92
|
+
cmd.append("--headless=new")
|
|
93
|
+
|
|
94
|
+
# Launch Chrome as a regular process — no automation flags at all
|
|
95
|
+
self._chrome_proc = subprocess.Popen(
|
|
96
|
+
cmd,
|
|
97
|
+
stdout=subprocess.DEVNULL,
|
|
98
|
+
stderr=subprocess.DEVNULL,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Wait for DevTools to be ready and get the WS URL
|
|
102
|
+
ws_url = await _wait_for_devtools(port)
|
|
103
|
+
|
|
104
|
+
# Connect Playwright over CDP (read-only — no automation flags injected)
|
|
105
|
+
self._pw = await async_playwright().start()
|
|
106
|
+
self._browser = await self._pw.chromium.connect_over_cdp(ws_url)
|
|
107
|
+
self._ctx = self._browser.contexts[0]
|
|
108
|
+
|
|
109
|
+
# Grab the first page or open one
|
|
110
|
+
if self._ctx.pages:
|
|
111
|
+
self.page = self._ctx.pages[0]
|
|
112
|
+
else:
|
|
113
|
+
self.page = await self._ctx.new_page()
|
|
114
|
+
|
|
115
|
+
return self.page
|
|
116
|
+
|
|
117
|
+
async def close(self) -> None:
|
|
118
|
+
if self._browser:
|
|
119
|
+
await self._browser.close()
|
|
120
|
+
if self._pw:
|
|
121
|
+
await self._pw.stop()
|
|
122
|
+
if self._chrome_proc:
|
|
123
|
+
self._chrome_proc.terminate()
|
|
124
|
+
try:
|
|
125
|
+
self._chrome_proc.wait(timeout=5)
|
|
126
|
+
except subprocess.TimeoutExpired:
|
|
127
|
+
self._chrome_proc.kill()
|
|
128
|
+
|
|
129
|
+
async def __aenter__(self) -> "XBrowser":
|
|
130
|
+
await self.launch()
|
|
131
|
+
return self
|
|
132
|
+
|
|
133
|
+
async def __aexit__(self, *exc) -> None:
|
|
134
|
+
await self.close()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def ensure_logged_in(page: Page) -> bool:
|
|
138
|
+
"""Navigate to X and check if we're logged in."""
|
|
139
|
+
await page.goto("https://x.com/home", wait_until="domcontentloaded")
|
|
140
|
+
await page.wait_for_timeout(3000)
|
|
141
|
+
url = page.url
|
|
142
|
+
if "/login" in url or "/i/flow/login" in url:
|
|
143
|
+
return False
|
|
144
|
+
try:
|
|
145
|
+
await page.wait_for_selector('a[href="/compose/post"]', timeout=5000)
|
|
146
|
+
return True
|
|
147
|
+
except Exception:
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
async def login_interactive() -> None:
|
|
152
|
+
"""Open a headed Chrome window for the user to log in manually."""
|
|
153
|
+
print("Opening Chrome for manual login...", file=sys.stderr)
|
|
154
|
+
print(
|
|
155
|
+
"Log in to X, then wait — session saves automatically.",
|
|
156
|
+
file=sys.stderr,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
async with XBrowser(headless=False) as xb:
|
|
160
|
+
page = xb.page
|
|
161
|
+
await page.goto("https://x.com/login", wait_until="domcontentloaded")
|
|
162
|
+
print(
|
|
163
|
+
"Waiting for login... (navigate to your home timeline)",
|
|
164
|
+
file=sys.stderr,
|
|
165
|
+
)
|
|
166
|
+
try:
|
|
167
|
+
await page.wait_for_url("**/home**", timeout=300_000)
|
|
168
|
+
await page.wait_for_timeout(3000)
|
|
169
|
+
print("Login successful! Session saved.", file=sys.stderr)
|
|
170
|
+
except Exception:
|
|
171
|
+
print(
|
|
172
|
+
"Login timed out. Run `x-browser login` again.",
|
|
173
|
+
file=sys.stderr,
|
|
174
|
+
)
|