anyx 0.1.0__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.
anyx/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ """anyx: use X from any browser, as any of your accounts. No API keys.
2
+
3
+ import anyx
4
+ with anyx.Session("alice") as x:
5
+ post = x.tweet("https://x.com/jack/status/20")
6
+ x.like(post["id"])
7
+ x.post("hello", quote=post["url"])
8
+ """
9
+
10
+ from .errors import (ActionFailed, AnyxError, NotFound, RateLimited, SessionExpired, UIChanged, # noqa: F401
11
+ UsageError, WrongAccount)
12
+ from .session import Session # noqa: F401
13
+
14
+ __version__ = "0.1.0"
anyx/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
anyx/accounts.py ADDED
@@ -0,0 +1,155 @@
1
+ """Getting accounts into the vault.
2
+
3
+ add log in by hand once in a browser window anyx opens; anyx keeps the cookies
4
+ import take cookies exported from any browser (JSON list or {name: value})
5
+ harvest read every account logged into a browser (an AdsPower profile, a CDP browser, ...)
6
+ and store each one separately
7
+ check open the account and see whether X still accepts it
8
+ """
9
+
10
+ import time
11
+
12
+ from . import browsers, config, vault
13
+ from .errors import AnyxError, SessionExpired, UsageError
14
+
15
+
16
+ def add(handle, timeout=600):
17
+ """Open a visible Chromium on X's login page and wait until `handle` is logged in."""
18
+ from playwright.sync_api import sync_playwright
19
+ handle = handle.lstrip("@").lower()
20
+ drv = browsers.get("chromium")
21
+ with sync_playwright() as pw:
22
+ ctx = drv.open(pw, handle, {"browser": "chromium"}, None, headless=False)
23
+ try:
24
+ p = ctx.pages[0] if ctx.pages else ctx.new_page()
25
+ p.goto("https://x.com/i/flow/login", wait_until="domcontentloaded")
26
+ end = time.monotonic() + timeout
27
+ who = None
28
+ while time.monotonic() < end:
29
+ link = p.locator('[data-testid="AppTabBar_Profile_Link"]')
30
+ if link.count():
31
+ who = (link.get_attribute("href") or "").strip("/").lower()
32
+ if who:
33
+ break
34
+ p.wait_for_timeout(1500)
35
+ if not who:
36
+ raise AnyxError("gave up waiting for the login")
37
+ if who != handle:
38
+ raise AnyxError(f"logged in as @{who}, not @{handle}; nothing saved")
39
+ cookies = [c for c in ctx.cookies("https://x.com")]
40
+ vault.save(handle, cookies)
41
+ vault.set_born(handle, "chromium")
42
+ config.set_account(handle, browser="chromium")
43
+ return {"user": handle, "result": "saved"}
44
+ finally:
45
+ drv.close()
46
+ drv.stop(handle) # the login window; the next command starts the account's own browser
47
+
48
+
49
+ def import_file(handle, path, browser=None, profile=None):
50
+ """Cookies exported from a browser. Say which browser they came from (--browser, --profile): anyx
51
+ will use them only there."""
52
+ if not browser:
53
+ raise UsageError("say which browser these cookies were made in: --browser chromium|chrome|cdp|adspower "
54
+ "[--profile <adspower profile>]; anyx uses them only there")
55
+ import json
56
+ with open(path, encoding="utf-8") as f:
57
+ data = json.load(f)
58
+ if isinstance(data, dict) and handle in data:
59
+ data = data[handle]
60
+ cookies = vault.normalize(data)
61
+ if not any(c["name"] == "auth_token" for c in cookies):
62
+ raise UsageError("no auth_token in that file")
63
+ vault.save(handle, cookies)
64
+ vault.set_born(handle, browser, profile)
65
+ config.set_account(handle, browser=browser, profile=profile)
66
+ return {"user": handle.lstrip("@").lower(), "result": "saved"}
67
+
68
+
69
+ def harvest(browser, profile=None, cdp=None, set_browser=True):
70
+ """Store every account logged into one browser. With AdsPower each account is opened once to learn
71
+ its handle, then the browser is left as the first account it was."""
72
+ from playwright.sync_api import sync_playwright
73
+ acct = {"browser": browser, "profile": profile, "cdp": cdp, "_adspower": config.load().get("adspower", {})}
74
+ drv = browsers.get(browser)
75
+ out = {}
76
+ with sync_playwright() as pw:
77
+ ctx = drv.open(pw, "_harvest", acct, None, headless=True)
78
+ jar = drv.cookies()
79
+ drv.close()
80
+ sets = vault.split(jar)
81
+ if not sets:
82
+ raise AnyxError("no logged-in X account in that browser")
83
+ active = vault.user_id(vault.normalize(jar))
84
+ for uid, cookies in sets.items():
85
+ drv = browsers.get(browser)
86
+ ctx = drv.open(pw, "_harvest", acct, cookies, headless=True)
87
+ try:
88
+ p = ctx.pages[0] if ctx.pages else ctx.new_page()
89
+ p.set_viewport_size({"width": 1280, "height": 1000}) # narrower windows fold the side bar away
90
+ p.goto("https://x.com/home", wait_until="domcontentloaded")
91
+ link = p.locator('[data-testid="AppTabBar_Profile_Link"]')
92
+ try:
93
+ link.wait_for(state="attached", timeout=40000)
94
+ who = (link.get_attribute("href") or "").strip("/").lower()
95
+ except Exception:
96
+ who = None
97
+ finally:
98
+ drv.close()
99
+ if not who:
100
+ out[uid] = None
101
+ continue
102
+ vault.save(who, cookies)
103
+ vault.set_born(who, browser, profile)
104
+ if set_browser:
105
+ config.set_account(who, browser=browser, profile=profile, cdp=cdp)
106
+ out[uid] = who
107
+ if browser == "adspower" and active in sets:
108
+ # leave the profile logged in as the account it showed before
109
+ drv = browsers.get(browser)
110
+ drv._setup(acct)
111
+ import json as _j
112
+ drv.call("/api/v1/user/update", method="POST", user_id=drv.profile_id(profile),
113
+ cookie=_j.dumps(sets[active]))
114
+ return [{"user_id": u, "user": h} for u, h in out.items()]
115
+
116
+
117
+ def check(handle):
118
+ """Is the account usable right now? Opens it once and reports what X shows:
119
+ ok, expired (logged out), locked (X wants a check), suspended, rate_limited, wrong_account."""
120
+ from .errors import NotFound, RateLimited, WrongAccount
121
+ from .session import Session
122
+ out = {"user": handle.lstrip("@").lower()}
123
+ try:
124
+ with Session(handle, verify="full") as x:
125
+ out["browser"] = x.account["browser"]
126
+ try:
127
+ u = x.user(x.me)
128
+ out.update(result="ok", followers=u["followers"], following=u["following"], posts=u["posts"],
129
+ protected=u["protected"], verified=u["verified"])
130
+ except NotFound as e:
131
+ out.update(result="suspended" if "Suspended" in str(e) else "unavailable", detail=str(e))
132
+ if out.get("result") == "ok" and (u.get("posts") or 0) > 0:
133
+ try:
134
+ hits = x.search(f"from:{x.me}", limit=3)
135
+ out["in_search"] = len(hits) > 0
136
+ except Exception as e: # noqa: BLE001
137
+ out["in_search"] = None
138
+ out["search_error"] = str(e)
139
+ except SessionExpired as e:
140
+ out.update(result="locked" if "locked" in str(e) else "expired", detail=str(e))
141
+ except RateLimited as e:
142
+ out.update(result="rate_limited", detail=str(e))
143
+ except WrongAccount as e:
144
+ out.update(result="wrong_account", detail=str(e))
145
+ return out
146
+
147
+
148
+ def listing():
149
+ cfg = config.load()
150
+ rows = []
151
+ for h in sorted(set(vault.handles()) | set(cfg["accounts"])):
152
+ a = cfg["accounts"].get(h, {})
153
+ rows.append({"user": h, "browser": a.get("browser", cfg["default_browser"]),
154
+ "profile": a.get("profile"), "cdp": a.get("cdp"), "cookies": vault.load(h) is not None})
155
+ return rows
anyx/act.py ADDED
@@ -0,0 +1,298 @@
1
+ """Acting on posts and accounts. Each action clicks what a person would click, then waits for X's
2
+ own request (FavoriteTweet, friendships/create, ...) to come back before it reports success. If the
3
+ state is already what was asked (liking a post that is liked), it returns "already" and clicks nothing.
4
+ """
5
+
6
+ from . import graphql
7
+ from .errors import ActionFailed, UIChanged
8
+
9
+ # Menu entries X gives no data-testid. Matched by their text in the languages anyx knows.
10
+ TEXT = {
11
+ "delete": ("削除", "Delete", "Eliminar", "Supprimer", "Löschen", "삭제", "删除"),
12
+ }
13
+
14
+
15
+ def _post_action(self, ref, button, confirm=None, wait=None, done_button=None):
16
+ from .session import tweet_id
17
+ tid = tweet_id(ref)
18
+ p = self.page()
19
+ cap = graphql.Capture(p)
20
+ try:
21
+ self.goto(p, f"https://x.com/i/status/{tid}")
22
+ art = self.article(p, tid)
23
+ if done_button and art.locator(f'[data-testid="{done_button}"]').count():
24
+ return {"id": tid, "result": "already"}
25
+ b = art.locator(f'[data-testid="{button}"]').first
26
+ if not b.count():
27
+ raise UIChanged(f'no [data-testid="{button}"] on post {tid}')
28
+ self.before_write()
29
+ n = len(cap.items)
30
+ b.click()
31
+ if confirm:
32
+ c = p.locator(f'[data-testid="{confirm}"]').first
33
+ c.wait_for(state="visible", timeout=15000)
34
+ c.click()
35
+ if wait:
36
+ b = cap.wait_for((wait,), timeout=20, after=n)
37
+ if b is None:
38
+ raise ActionFailed(f"X did not confirm {wait} for {tid}")
39
+ if cap.refusal(b):
40
+ raise ActionFailed(f"X refused {wait} for {tid}: {cap.refusal(b)}")
41
+ return {"id": tid, "result": "done"}
42
+ finally:
43
+ cap.detach()
44
+ p.close()
45
+
46
+
47
+ def like(self, ref):
48
+ return _post_action(self, ref, "like", wait="FavoriteTweet", done_button="unlike")
49
+
50
+
51
+ def unlike(self, ref):
52
+ return _post_action(self, ref, "unlike", wait="UnfavoriteTweet", done_button="like")
53
+
54
+
55
+ def retweet(self, ref):
56
+ return _post_action(self, ref, "retweet", confirm="retweetConfirm", wait="CreateRetweet", done_button="unretweet")
57
+
58
+
59
+ def unretweet(self, ref):
60
+ return _post_action(self, ref, "unretweet", confirm="unretweetConfirm", wait="DeleteRetweet", done_button="retweet")
61
+
62
+
63
+ def bookmark(self, ref):
64
+ return _post_action(self, ref, "bookmark", wait="CreateBookmark", done_button="removeBookmark")
65
+
66
+
67
+ def unbookmark(self, ref):
68
+ return _post_action(self, ref, "removeBookmark", wait="DeleteBookmark", done_button="bookmark")
69
+
70
+
71
+ def _caret_menu(self, p, art):
72
+ for _ in range(3):
73
+ if p.locator('#layers [data-testid="mask"]').count() == 0:
74
+ break
75
+ p.keyboard.press("Escape")
76
+ p.wait_for_timeout(600)
77
+ art.locator('[data-testid="caret"]').first.click()
78
+ menu = p.locator('[role="menu"]').first
79
+ menu.wait_for(state="visible", timeout=15000)
80
+ return menu
81
+
82
+
83
+ def delete(self, ref):
84
+ """Delete one of this account's own posts."""
85
+ from .session import tweet_id
86
+ tid = tweet_id(ref)
87
+ p = self.page()
88
+ cap = graphql.Capture(p)
89
+ try:
90
+ self.goto(p, f"https://x.com/i/status/{tid}")
91
+ art = self.article(p, tid)
92
+ menu = _caret_menu(self, p, art)
93
+ item = menu.locator('[role="menuitem"]').filter(has_text=TEXT["delete"][0])
94
+ for word in TEXT["delete"][1:]:
95
+ if item.count():
96
+ break
97
+ item = menu.locator('[role="menuitem"]').filter(has_text=word)
98
+ if not item.count():
99
+ p.keyboard.press("Escape")
100
+ raise UIChanged(f"no delete entry in the menu of {tid} (is it this account's post?)")
101
+ self.before_write()
102
+ n = len(cap.items)
103
+ item.first.click()
104
+ c = p.locator('[data-testid="confirmationSheetConfirm"]').first
105
+ c.wait_for(state="visible", timeout=15000)
106
+ c.click()
107
+ b = cap.wait_for(("DeleteTweet",), timeout=20, after=n)
108
+ if b is None:
109
+ raise ActionFailed(f"X did not confirm DeleteTweet for {tid}")
110
+ if cap.refusal(b):
111
+ raise ActionFailed(f"X refused to delete {tid}: {cap.refusal(b)}")
112
+ return {"id": tid, "result": "deleted"}
113
+ finally:
114
+ cap.detach()
115
+ p.close()
116
+
117
+
118
+ def _pin(self, ref, testid, op):
119
+ from .session import tweet_id
120
+ tid = tweet_id(ref)
121
+ p = self.page()
122
+ cap = graphql.Capture(p)
123
+ try:
124
+ self.goto(p, f"https://x.com/i/status/{tid}")
125
+ art = self.article(p, tid)
126
+ menu = _caret_menu(self, p, art)
127
+ item = menu.locator(f'[data-testid="{testid}"]')
128
+ if not item.count():
129
+ p.keyboard.press("Escape")
130
+ other = "unpin" if testid == "pin" else "pin"
131
+ if menu.locator(f'[data-testid="{other}"]').count():
132
+ return {"id": tid, "result": "already"}
133
+ raise UIChanged(f'no [data-testid="{testid}"] in the menu of {tid}')
134
+ self.before_write()
135
+ n = len(cap.items)
136
+ item.first.click()
137
+ c = p.locator('[data-testid="confirmationSheetConfirm"]').first
138
+ try:
139
+ c.wait_for(state="visible", timeout=8000)
140
+ c.click()
141
+ except Exception:
142
+ pass
143
+ cap.confirm((op,), f"{op} for {tid}", after=n, timeout=20)
144
+ return {"id": tid, "result": "done"}
145
+ finally:
146
+ cap.detach()
147
+ p.close()
148
+
149
+
150
+ def pin(self, ref):
151
+ return _pin(self, ref, "pin", "PinTweet")
152
+
153
+
154
+ def unpin(self, ref):
155
+ return _pin(self, ref, "unpin", "UnpinTweet")
156
+
157
+
158
+ def _profile(self, ref):
159
+ """Open a profile page and return (page, capture, user id)."""
160
+ from .session import handle_of
161
+ h = handle_of(ref)
162
+ p = self.page()
163
+ cap = graphql.Capture(p)
164
+ self.goto(p, f"https://x.com/{h}")
165
+ b = cap.wait_for(("UserByScreenName",), timeout=30)
166
+ _, us = graphql.walk([b] if b else [])
167
+ u = next((x for x in us if (x["handle"] or "").lower() == h.lower()), None)
168
+ if not u:
169
+ cap.detach()
170
+ p.close()
171
+ from .errors import NotFound
172
+ raise NotFound(f"@{h} not found")
173
+ p.locator('[data-testid="primaryColumn"]').wait_for(state="visible", timeout=20000)
174
+ p.wait_for_timeout(1500)
175
+ return p, cap, u
176
+
177
+
178
+ def _account_action(self, ref, button, rest, confirm=False, state=None, want=True, menu=False, alt=None):
179
+ """Click an account-level button. `state` names the user flag (i_follow, i_mute, i_block) that tells
180
+ whether the change is already in place; X's own JSON says so, so no button text is read."""
181
+ p, cap, u = _profile(self, ref)
182
+ try:
183
+ if state and bool(u.get(state)) == want:
184
+ return {"user": u["handle"], "result": "already"}
185
+ col = p.locator('[data-testid="primaryColumn"]')
186
+ bt = button.format(id=u["id"])
187
+ target = None
188
+ if alt and col.locator(f'[data-testid="{alt}"]').count():
189
+ target = col.locator(f'[data-testid="{alt}"]')
190
+ elif menu:
191
+ col.locator('[data-testid="userActions"]').first.click()
192
+ m = p.locator('[role="menu"]').first
193
+ m.wait_for(state="visible", timeout=15000)
194
+ target = m.locator(f'[data-testid="{bt}"]')
195
+ if not target.count():
196
+ p.keyboard.press("Escape")
197
+ raise UIChanged(f'no [data-testid="{bt}"] in the profile menu of @{u["handle"]}')
198
+ else:
199
+ target = col.locator(f'[data-testid="{bt}"]')
200
+ if not target.count():
201
+ raise UIChanged(f'no [data-testid="{bt}"] on the profile of @{u["handle"]}')
202
+ self.before_write()
203
+ n = len(cap.rest)
204
+ target.first.click()
205
+ # Some of these ask "are you sure" and some do not, and X changes which. Confirm when asked.
206
+ c = p.locator('[data-testid="confirmationSheetConfirm"]').first
207
+ try:
208
+ c.wait_for(state="visible", timeout=15000 if confirm else 4000)
209
+ c.click()
210
+ except Exception:
211
+ if confirm:
212
+ raise UIChanged(f"no confirmation sheet after {bt} on @{u['handle']}")
213
+ b = cap.wait_rest(rest, timeout=20, after=n)
214
+ if b is None:
215
+ raise ActionFailed(f"X did not confirm {rest} for @{u['handle']}")
216
+ if cap.refusal(b):
217
+ raise ActionFailed(f"X refused {rest} for @{u['handle']}: {cap.refusal(b)}")
218
+ return {"user": u["handle"], "result": "done"}
219
+ finally:
220
+ cap.detach()
221
+ p.close()
222
+
223
+
224
+ def follow(self, ref):
225
+ return _account_action(self, ref, "{id}-follow", "friendships/create", state="i_follow", want=True)
226
+
227
+
228
+ def unfollow(self, ref):
229
+ return _account_action(self, ref, "{id}-unfollow", "friendships/destroy", confirm=True, state="i_follow", want=False)
230
+
231
+
232
+ def mute(self, ref):
233
+ return _account_action(self, ref, "mute", "mutes/users/create", menu=True, state="i_mute", want=True)
234
+
235
+
236
+ def unmute(self, ref):
237
+ # A muted profile shows an Unmute link in its header; the menu entry keeps the testid "mute".
238
+ return _account_action(self, ref, "mute", "mutes/users/destroy", menu=True, alt="unmuteLink",
239
+ state="i_mute", want=False)
240
+
241
+
242
+ def block(self, ref):
243
+ return _account_action(self, ref, "block", "blocks/create", confirm=True, menu=True, state="i_block", want=True)
244
+
245
+
246
+ def unblock(self, ref):
247
+ return _account_action(self, ref, "{id}-unblock", "blocks/destroy", confirm=True, state="i_block", want=False)
248
+
249
+
250
+ def edit_profile(self, name=None, bio=None, location=None, website=None, avatar=None, banner=None, drop_banner=False):
251
+ """Change this account's profile. Anything left as None is not touched. The handle is never changed."""
252
+ p = self.page()
253
+ cap = graphql.Capture(p)
254
+ try:
255
+ self.goto(p, "https://x.com/settings/profile")
256
+ nm = p.locator('input[name="displayName"]').first
257
+ nm.wait_for(state="visible", timeout=30000)
258
+ p.wait_for_timeout(1500)
259
+ for sel, val in (('input[name="displayName"]', name), ('textarea[name="description"]', bio),
260
+ ('input[name="location"]', location), ('input[name="url"]', website)):
261
+ if val is not None:
262
+ f = p.locator(sel).first
263
+ if not f.count():
264
+ raise UIChanged(f"no {sel} on the profile editor")
265
+ f.fill(val)
266
+ files = p.locator('input[type="file"]')
267
+ for idx, img in ((1, avatar), (0, banner)):
268
+ if img is None:
269
+ continue
270
+ if files.count() < 2:
271
+ raise UIChanged("the profile editor has no image inputs")
272
+ files.nth(idx).set_input_files(img)
273
+ p.locator('[data-testid="applyButton"]').first.click(timeout=20000)
274
+ p.wait_for_timeout(2000)
275
+ if drop_banner:
276
+ # The banner row shows a remove button only when a banner exists. It has no testid; its
277
+ # position is the second button in the banner area.
278
+ rm = p.locator('[aria-label="画像を削除"], [aria-label="Remove photo"]')
279
+ if rm.count():
280
+ rm.first.click()
281
+ p.wait_for_timeout(800)
282
+ self.before_write()
283
+ n = len(cap.rest)
284
+ p.locator('[data-testid="Profile_Save_Button"]').first.click(timeout=20000)
285
+ p.wait_for_timeout(1500)
286
+ ok = p.locator('[data-testid="confirmationSheetConfirm"]')
287
+ if ok.count():
288
+ ok.first.click(timeout=10000)
289
+ b = cap.wait_rest("account/update_profile", timeout=25, after=n)
290
+ if b is None:
291
+ raise ActionFailed("X did not confirm the profile update")
292
+ if cap.refusal(b):
293
+ raise ActionFailed(f"X refused the profile update: {cap.refusal(b)}")
294
+ p.wait_for_timeout(2000)
295
+ return {"user": self.me, "result": "done"}
296
+ finally:
297
+ cap.detach()
298
+ p.close()
@@ -0,0 +1,28 @@
1
+ """Browsers anyx can drive. Each one only has to open a Playwright BrowserContext with the given
2
+ cookies poured in, and close it again. Everything X-specific lives above this layer.
3
+
4
+ chromium Playwright's own Chromium with a profile under ~/.anyx/profiles/<handle>/. The default.
5
+ chrome The Google Chrome installed on this machine, same profile layout.
6
+ cdp A browser you already started with --remote-debugging-port. anyx attaches and leaves it open.
7
+ adspower An AdsPower profile, started through AdsPower's Local API.
8
+
9
+ Adding a browser is one class with open() and close().
10
+ """
11
+
12
+ from ..errors import UsageError
13
+
14
+
15
+ def get(name):
16
+ if name in ("chromium", "chrome"):
17
+ from .chromium import Chromium
18
+ return Chromium(channel="chrome" if name == "chrome" else None)
19
+ if name == "cdp":
20
+ from .cdp import CDP
21
+ return CDP()
22
+ if name == "adspower":
23
+ from .adspower import AdsPower
24
+ return AdsPower()
25
+ raise UsageError(f"unknown browser: {name} (chromium, chrome, cdp, adspower)")
26
+
27
+
28
+ NAMES = ("chromium", "chrome", "cdp", "adspower")
@@ -0,0 +1,136 @@
1
+ """AdsPower, through its Local API.
2
+
3
+ The API key comes from `adspower.key` in config (`anyx config set adspower.key KEY`), or else from
4
+ the environment variable named by `adspower.key_env` (default ADSPOWER_API_KEY). Calls are serialized per machine with a lock file and
5
+ spaced at least 1.1 s apart, which is what AdsPower tolerates.
6
+ """
7
+
8
+ import fcntl
9
+ import json
10
+ import os
11
+ import time
12
+ import urllib.parse
13
+ import urllib.request
14
+ from contextlib import contextmanager
15
+
16
+ from ..errors import AnyxError, UsageError
17
+
18
+ LOCK = "/tmp/adspower-api.lock"
19
+ GAP = 1.1
20
+ DEFAULT_API = "http://local.adspower.net:50325"
21
+
22
+
23
+ @contextmanager
24
+ def _gate():
25
+ with open(LOCK, "a") as f:
26
+ deadline = time.monotonic() + 150
27
+ while True:
28
+ try:
29
+ fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB)
30
+ break
31
+ except BlockingIOError:
32
+ if time.monotonic() > deadline:
33
+ raise AnyxError("AdsPower API is busy (another process holds the lock)")
34
+ time.sleep(0.2)
35
+ try:
36
+ age = time.time() - os.path.getmtime(LOCK)
37
+ if age < GAP:
38
+ time.sleep(GAP - age)
39
+ yield
40
+ finally:
41
+ os.utime(LOCK, None)
42
+ fcntl.flock(f, fcntl.LOCK_UN)
43
+
44
+
45
+ class AdsPower:
46
+ def __init__(self):
47
+ self.uid = None
48
+ self.ctx = None
49
+ self.fresh = False
50
+ self.api = DEFAULT_API
51
+ self.key = None
52
+
53
+ def call(self, path, method="GET", **q):
54
+ url = self.api + path
55
+ data = None
56
+ headers = {"Authorization": "Bearer " + (self.key or "")}
57
+ if method == "POST":
58
+ data = json.dumps(q).encode()
59
+ headers["Content-Type"] = "application/json"
60
+ elif q:
61
+ url += "?" + urllib.parse.urlencode(q)
62
+ with _gate():
63
+ with urllib.request.urlopen(urllib.request.Request(url, data=data, headers=headers), timeout=120) as r:
64
+ return json.load(r)
65
+
66
+ def _setup(self, account):
67
+ ap = account.get("_adspower") or {}
68
+ self.api = ap.get("api") or os.environ.get("ADSPOWER_API") or DEFAULT_API
69
+ self.key = ap.get("key") or os.environ.get(ap.get("key_env") or "ADSPOWER_API_KEY")
70
+ if not self.key:
71
+ raise UsageError("AdsPower API key missing: anyx config set adspower.key <key> (or set ADSPOWER_API_KEY)")
72
+
73
+ def profile_id(self, name):
74
+ page = 1
75
+ while True:
76
+ users = self.call("/api/v1/user/list", page=page, page_size=100)["data"]["list"]
77
+ for u in users:
78
+ if u["name"] == name or u["user_id"] == name:
79
+ return u["user_id"]
80
+ if len(users) < 100:
81
+ break
82
+ page += 1
83
+ raise UsageError(f"no AdsPower profile named {name}")
84
+
85
+ def open(self, pw, handle, account, cookies, headless=True):
86
+ """Attach to the profile if it is already open (left open by an earlier command, or by hand);
87
+ start it otherwise. It is left open afterwards."""
88
+ from .. import run
89
+ self._setup(account)
90
+ if not account.get("profile"):
91
+ raise UsageError(f"@{handle} uses adspower but has no profile "
92
+ f"(anyx accounts set {handle} --browser adspower --profile <name>)")
93
+ last = run.load(handle).get("browser") or {}
94
+ self.uid = last.get("user_id") if last.get("profile") == account["profile"] else None
95
+ self.uid = self.uid or self.profile_id(account["profile"])
96
+ st = self.call("/api/v1/browser/active", user_id=self.uid)
97
+ ws = ((st.get("data") or {}).get("ws") or {}).get("puppeteer") if (st.get("data") or {}).get("status") == "Active" else None
98
+ if not ws:
99
+ if cookies:
100
+ r = self.call("/api/v1/user/update", method="POST", user_id=self.uid, cookie=json.dumps(cookies))
101
+ if r.get("code") != 0:
102
+ raise AnyxError(f"AdsPower refused the cookies: {r.get('msg')}")
103
+ time.sleep(1.5)
104
+ r = self.call("/api/v1/browser/start", user_id=self.uid, headless="1" if headless else "0", open_tabs=1)
105
+ if r.get("code") != 0:
106
+ raise AnyxError(f"AdsPower could not start {account['profile']}: {r.get('msg')}")
107
+ ws = r["data"]["ws"]["puppeteer"]
108
+ self.fresh = True
109
+ browser = pw.chromium.connect_over_cdp(ws)
110
+ self.ctx = browser.contexts[0]
111
+ if cookies:
112
+ have = {c["name"]: c["value"] for c in self.ctx.cookies("https://x.com")}
113
+ if have.get("auth_token") != {c["name"]: c["value"] for c in cookies}.get("auth_token"):
114
+ self.ctx.clear_cookies(domain=".x.com")
115
+ self.ctx.add_cookies(cookies)
116
+ self.fresh = True
117
+ run.save(handle, browser={"kind": "adspower", "profile": account["profile"], "user_id": self.uid})
118
+ return self.ctx
119
+
120
+ def close(self):
121
+ # the profile stays open for the next command; `anyx stop` closes it
122
+ self.ctx = None
123
+
124
+ def cookies(self):
125
+ return self.ctx.cookies("https://x.com") if self.ctx else []
126
+
127
+ @staticmethod
128
+ def stop(handle):
129
+ from .. import config, run
130
+ info = run.load(handle).get("browser") or {}
131
+ if info.get("user_id"):
132
+ a = AdsPower()
133
+ a._setup(config.account(handle))
134
+ a.call("/api/v1/browser/stop", user_id=info["user_id"])
135
+ run.forget(handle, "browser")
136
+ return bool(info)