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 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
+ )