anyx 0.1.0__tar.gz

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-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 sic
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
anyx-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,173 @@
1
+ Metadata-Version: 2.4
2
+ Name: anyx
3
+ Version: 0.1.0
4
+ Summary: Use X (Twitter) from any browser, as any of your accounts, from the command line or Python. No API keys.
5
+ Author: sic
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/sicdotsh/anyx
8
+ Keywords: x,twitter,playwright,automation,cli,adspower
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Environment :: Console
12
+ Classifier: Topic :: Internet :: WWW/HTTP :: Browsers
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: playwright>=1.45
17
+ Dynamic: license-file
18
+
19
+ # anyx
20
+
21
+ Use X from any browser, as any of your accounts. From the command line or from Python. No API keys.
22
+
23
+ ```
24
+ pip install anyx && playwright install chromium
25
+ anyx accounts add yourhandle # log in once in the window that opens
26
+ anyx tweet https://x.com/jack/status/20
27
+ ```
28
+
29
+ anyx drives a real browser the way a person would, and reads what X's own web app reads: while a page loads, x.com fetches its content as JSON from its GraphQL endpoints, and anyx listens to those responses. You get the same counts the app shows (views, likes, reposts, replies, quotes, bookmarks), full text, media and quoted posts, without scraping the page's markup and without an API plan.
30
+
31
+ ## What it does
32
+
33
+ ```
34
+ read tweet thread quotes user posts search home trends notifications
35
+ bookmarks likes followers following analytics shot
36
+ post post: plain, quote, reply, thread, up to 4 images with alt text or a video,
37
+ poll, who can reply, scheduled, draft
38
+ delete scheduled (list, move, delete) drafts (list, delete)
39
+ engage like unlike retweet unretweet bookmark unbookmark pin unpin
40
+ people follow unfollow mute unmute block unblock
41
+ lists lists list create | add | remove | posts | members | delete
42
+ groups community (info, posts) join leave
43
+ chat chat setup chats messages dm
44
+ account profile settings (read all; change 14, and the @handle) muted (words)
45
+ accounts add login import harvest set check list
46
+ ```
47
+
48
+ ```
49
+ anyx posts nasa -n 20
50
+ anyx search "from:nasa min_faves:1000" --tab top
51
+ anyx thread https://x.com/jack/status/20
52
+ anyx trends --tab news
53
+ anyx --as alice post "hello" --media cat.jpg --alt "a cat on a desk"
54
+ anyx --as alice post "agreed" --quote https://x.com/jack/status/20
55
+ anyx --as alice post "1/3" --thread "2/3" "3/3"
56
+ anyx --as alice post "which?" --poll "this|that" --poll-days 1
57
+ anyx --as alice post "later" --at "2026-12-01 09:00"
58
+ anyx --as bob like https://x.com/jack/status/20
59
+ anyx --as bob dm alice "hi"
60
+ anyx --as bob messages alice
61
+ anyx --as alice,bob,carol follow nasa # each account, in parallel
62
+ anyx --as all --wait notifications -n 5 # every account; sleep through rate limits
63
+ anyx --json user jack | jq .followers
64
+ ```
65
+
66
+ `--json` on any command prints JSON instead of text. `--show` opens a visible window so you can watch. `--as a,b,c` (or `all`) runs the command as each account, `-j` at a time; accounts that share one browser profile take turns. `--wait` sleeps until the moment X says a rate limit lifts, then carries on.
67
+
68
+ ## Chat
69
+
70
+ X's chat is end-to-end capable, so message bodies cross the wire encoded and encrypted; anyx reads them from the page after X's own app has decrypted them. Chat asks each account for a 4-digit passcode once, and again on every new browser. Give it to anyx once and it answers X whenever asked:
71
+
72
+ ```
73
+ anyx --as alice chat setup --pin 1234
74
+ ```
75
+
76
+ X only delivers messages between accounts that follow each other, or to accounts that open their requests (`anyx settings set dms everyone`).
77
+
78
+ The chat keys live in the browser, one set per browser profile. When several accounts share one profile and anyx switches between them, it clears x.com's storage on the switch and enters the next account's passcode, once; a wrong passcode is reported and never retried, because X destroys the keys after too many.
79
+
80
+ ## How it behaves
81
+
82
+ - Each account's browser stays open between commands (`anyx status`, `anyx stop`). The first command starts it; later ones reuse the same tab.
83
+ - Pages change inside X's app, the way clicking a link does, instead of reloading it. A reload boots X's whole app (about 30 requests); a change inside it costs 6 to 12.
84
+ - A rate limit is reported with the time X says it lifts (exit code 6); `--wait` sleeps until then and carries on.
85
+
86
+ How hard to drive an account is yours to decide. Two switches in `~/.anyx/config.json`, both off by default:
87
+
88
+ ```json
89
+ {"pace": {"page": [2, 5], "write": [6, 14]}, "rest": true}
90
+ ```
91
+
92
+ `pace` pauses a random number of seconds before each page change and before each write. `rest` puts an account to rest after X pushes back (a 429 until X's reset time, an unexplained refusal for an hour, a login wall for a day); anyx then refuses to act as it until then.
93
+
94
+ ## Accounts
95
+
96
+ anyx logs each account in itself, in that account's own browser, and keeps the cookies in `~/.anyx/accounts/<handle>.json` together with the browser they were made in. Using them in another browser works, with a note: to X it looks like one login moving between devices, and X sometimes refuses things from it.
97
+
98
+ ```
99
+ anyx accounts login - --file creds.tsv # log every account in, in its own Chromium
100
+ anyx accounts login alice --file creds.tsv # just one
101
+ anyx accounts add alice # a visible window, for when X asks for more than a password
102
+ anyx accounts harvest --browser adspower --profile work # adopt accounts already logged into that profile
103
+ anyx accounts import alice cookies.json --browser chrome # cookies exported from that browser
104
+ anyx accounts check all # ok, expired, locked, suspended, rate_limited, in search?
105
+ anyx accounts list
106
+ ```
107
+
108
+ `creds.tsv` has one account per line: `handle<TAB>password<TAB>totp-secret`, the last column only for accounts with two-factor on. X asks new devices for an email code now and then; an account that stops there needs `accounts add` once.
109
+
110
+ Before anything is done, anyx checks that the browser really is the account asked for. If it is not, it stops without touching anything.
111
+
112
+ ## Browsers
113
+
114
+ | browser | what it is |
115
+ |---|---|
116
+ | `chromium` | Playwright's Chromium with a profile per account under `~/.anyx/profiles/`. The default. |
117
+ | `chrome` | Your installed Google Chrome, same layout. |
118
+ | `cdp` | A browser you started with `--remote-debugging-port`. anyx attaches and leaves it running. |
119
+ | `adspower` | An AdsPower profile, started through AdsPower's Local API. Its key is stored once with `anyx config set adspower.key KEY` (or read from `ADSPOWER_API_KEY`). |
120
+
121
+ ```
122
+ anyx config set adspower.key KEY
123
+ anyx accounts set bob --browser adspower --profile x-bob
124
+ anyx accounts set carol --browser cdp --cdp http://127.0.0.1:9222
125
+ anyx accounts set dave --proxy http://user:pass@host:port
126
+ ```
127
+
128
+ A browser is one small class with `open()` and `close()` in `anyx/browsers/`. Adding another anti-detect browser is a matter of starting it and handing back a Playwright context.
129
+
130
+ ## Python
131
+
132
+ ```python
133
+ import anyx
134
+
135
+ with anyx.Session("alice") as x:
136
+ t = x.tweet("https://x.com/jack/status/20")
137
+ print(t["views"], t["likes"], t["text"])
138
+ for q in x.quotes(t["id"], limit=20):
139
+ print(q["author"]["handle"], q["text"])
140
+ x.post("hello", quote=t["url"])
141
+ ```
142
+
143
+ Every command is a method of the same name on `Session`, and returns the same dicts `--json` prints.
144
+
145
+ ## When something goes wrong
146
+
147
+ anyx exits with a code that says what happened, so scripts can react:
148
+
149
+ | code | meaning |
150
+ |---|---|
151
+ | 0 | done (or already in that state: liking a liked post returns `already`) |
152
+ | 2 | bad arguments |
153
+ | 3 | the account is logged out or locked (`anyx accounts add <handle>` again) |
154
+ | 4 | the browser showed a different account; nothing was touched |
155
+ | 5 | something anyx relies on was not on the page: X changed its layout |
156
+ | 6 | rate limited by X (or resting after one, when `rest` is on) |
157
+ | 7 | the post or account does not exist or is not visible |
158
+ | 8 | X did not confirm the change, or refused it (its own reason is printed, e.g. a duplicate post) |
159
+
160
+ Writes are only reported as done after X's own request for them (FavoriteTweet, CreateTweet, friendships/create, ...) comes back.
161
+
162
+ ## Things to know
163
+
164
+ - A quote is made through the quoted post's Repost > Quote menu, never by pasting its URL. Both look like quotes, but only the first appears in the quoted post's quote list.
165
+ - Scheduled posts are posted by X at the given time; your machine can be off by then. X needs them at least 5 minutes ahead. X sometimes disables scheduling for an account; anyx says so instead of posting.
166
+ - Settings switches are found by their position on X's settings pages; the live test checks those positions.
167
+ - `tests/live.py` runs every command against real X and puts everything back. Run it daily: when X changes a page, the step that breaks names what went missing.
168
+ - Every page load costs requests against the account's rate limits, like browsing by hand does. Many commands in a row on one account will hit them.
169
+ - Automating an account is your responsibility under X's rules.
170
+
171
+ ## License
172
+
173
+ MIT
anyx-0.1.0/README.md ADDED
@@ -0,0 +1,155 @@
1
+ # anyx
2
+
3
+ Use X from any browser, as any of your accounts. From the command line or from Python. No API keys.
4
+
5
+ ```
6
+ pip install anyx && playwright install chromium
7
+ anyx accounts add yourhandle # log in once in the window that opens
8
+ anyx tweet https://x.com/jack/status/20
9
+ ```
10
+
11
+ anyx drives a real browser the way a person would, and reads what X's own web app reads: while a page loads, x.com fetches its content as JSON from its GraphQL endpoints, and anyx listens to those responses. You get the same counts the app shows (views, likes, reposts, replies, quotes, bookmarks), full text, media and quoted posts, without scraping the page's markup and without an API plan.
12
+
13
+ ## What it does
14
+
15
+ ```
16
+ read tweet thread quotes user posts search home trends notifications
17
+ bookmarks likes followers following analytics shot
18
+ post post: plain, quote, reply, thread, up to 4 images with alt text or a video,
19
+ poll, who can reply, scheduled, draft
20
+ delete scheduled (list, move, delete) drafts (list, delete)
21
+ engage like unlike retweet unretweet bookmark unbookmark pin unpin
22
+ people follow unfollow mute unmute block unblock
23
+ lists lists list create | add | remove | posts | members | delete
24
+ groups community (info, posts) join leave
25
+ chat chat setup chats messages dm
26
+ account profile settings (read all; change 14, and the @handle) muted (words)
27
+ accounts add login import harvest set check list
28
+ ```
29
+
30
+ ```
31
+ anyx posts nasa -n 20
32
+ anyx search "from:nasa min_faves:1000" --tab top
33
+ anyx thread https://x.com/jack/status/20
34
+ anyx trends --tab news
35
+ anyx --as alice post "hello" --media cat.jpg --alt "a cat on a desk"
36
+ anyx --as alice post "agreed" --quote https://x.com/jack/status/20
37
+ anyx --as alice post "1/3" --thread "2/3" "3/3"
38
+ anyx --as alice post "which?" --poll "this|that" --poll-days 1
39
+ anyx --as alice post "later" --at "2026-12-01 09:00"
40
+ anyx --as bob like https://x.com/jack/status/20
41
+ anyx --as bob dm alice "hi"
42
+ anyx --as bob messages alice
43
+ anyx --as alice,bob,carol follow nasa # each account, in parallel
44
+ anyx --as all --wait notifications -n 5 # every account; sleep through rate limits
45
+ anyx --json user jack | jq .followers
46
+ ```
47
+
48
+ `--json` on any command prints JSON instead of text. `--show` opens a visible window so you can watch. `--as a,b,c` (or `all`) runs the command as each account, `-j` at a time; accounts that share one browser profile take turns. `--wait` sleeps until the moment X says a rate limit lifts, then carries on.
49
+
50
+ ## Chat
51
+
52
+ X's chat is end-to-end capable, so message bodies cross the wire encoded and encrypted; anyx reads them from the page after X's own app has decrypted them. Chat asks each account for a 4-digit passcode once, and again on every new browser. Give it to anyx once and it answers X whenever asked:
53
+
54
+ ```
55
+ anyx --as alice chat setup --pin 1234
56
+ ```
57
+
58
+ X only delivers messages between accounts that follow each other, or to accounts that open their requests (`anyx settings set dms everyone`).
59
+
60
+ The chat keys live in the browser, one set per browser profile. When several accounts share one profile and anyx switches between them, it clears x.com's storage on the switch and enters the next account's passcode, once; a wrong passcode is reported and never retried, because X destroys the keys after too many.
61
+
62
+ ## How it behaves
63
+
64
+ - Each account's browser stays open between commands (`anyx status`, `anyx stop`). The first command starts it; later ones reuse the same tab.
65
+ - Pages change inside X's app, the way clicking a link does, instead of reloading it. A reload boots X's whole app (about 30 requests); a change inside it costs 6 to 12.
66
+ - A rate limit is reported with the time X says it lifts (exit code 6); `--wait` sleeps until then and carries on.
67
+
68
+ How hard to drive an account is yours to decide. Two switches in `~/.anyx/config.json`, both off by default:
69
+
70
+ ```json
71
+ {"pace": {"page": [2, 5], "write": [6, 14]}, "rest": true}
72
+ ```
73
+
74
+ `pace` pauses a random number of seconds before each page change and before each write. `rest` puts an account to rest after X pushes back (a 429 until X's reset time, an unexplained refusal for an hour, a login wall for a day); anyx then refuses to act as it until then.
75
+
76
+ ## Accounts
77
+
78
+ anyx logs each account in itself, in that account's own browser, and keeps the cookies in `~/.anyx/accounts/<handle>.json` together with the browser they were made in. Using them in another browser works, with a note: to X it looks like one login moving between devices, and X sometimes refuses things from it.
79
+
80
+ ```
81
+ anyx accounts login - --file creds.tsv # log every account in, in its own Chromium
82
+ anyx accounts login alice --file creds.tsv # just one
83
+ anyx accounts add alice # a visible window, for when X asks for more than a password
84
+ anyx accounts harvest --browser adspower --profile work # adopt accounts already logged into that profile
85
+ anyx accounts import alice cookies.json --browser chrome # cookies exported from that browser
86
+ anyx accounts check all # ok, expired, locked, suspended, rate_limited, in search?
87
+ anyx accounts list
88
+ ```
89
+
90
+ `creds.tsv` has one account per line: `handle<TAB>password<TAB>totp-secret`, the last column only for accounts with two-factor on. X asks new devices for an email code now and then; an account that stops there needs `accounts add` once.
91
+
92
+ Before anything is done, anyx checks that the browser really is the account asked for. If it is not, it stops without touching anything.
93
+
94
+ ## Browsers
95
+
96
+ | browser | what it is |
97
+ |---|---|
98
+ | `chromium` | Playwright's Chromium with a profile per account under `~/.anyx/profiles/`. The default. |
99
+ | `chrome` | Your installed Google Chrome, same layout. |
100
+ | `cdp` | A browser you started with `--remote-debugging-port`. anyx attaches and leaves it running. |
101
+ | `adspower` | An AdsPower profile, started through AdsPower's Local API. Its key is stored once with `anyx config set adspower.key KEY` (or read from `ADSPOWER_API_KEY`). |
102
+
103
+ ```
104
+ anyx config set adspower.key KEY
105
+ anyx accounts set bob --browser adspower --profile x-bob
106
+ anyx accounts set carol --browser cdp --cdp http://127.0.0.1:9222
107
+ anyx accounts set dave --proxy http://user:pass@host:port
108
+ ```
109
+
110
+ A browser is one small class with `open()` and `close()` in `anyx/browsers/`. Adding another anti-detect browser is a matter of starting it and handing back a Playwright context.
111
+
112
+ ## Python
113
+
114
+ ```python
115
+ import anyx
116
+
117
+ with anyx.Session("alice") as x:
118
+ t = x.tweet("https://x.com/jack/status/20")
119
+ print(t["views"], t["likes"], t["text"])
120
+ for q in x.quotes(t["id"], limit=20):
121
+ print(q["author"]["handle"], q["text"])
122
+ x.post("hello", quote=t["url"])
123
+ ```
124
+
125
+ Every command is a method of the same name on `Session`, and returns the same dicts `--json` prints.
126
+
127
+ ## When something goes wrong
128
+
129
+ anyx exits with a code that says what happened, so scripts can react:
130
+
131
+ | code | meaning |
132
+ |---|---|
133
+ | 0 | done (or already in that state: liking a liked post returns `already`) |
134
+ | 2 | bad arguments |
135
+ | 3 | the account is logged out or locked (`anyx accounts add <handle>` again) |
136
+ | 4 | the browser showed a different account; nothing was touched |
137
+ | 5 | something anyx relies on was not on the page: X changed its layout |
138
+ | 6 | rate limited by X (or resting after one, when `rest` is on) |
139
+ | 7 | the post or account does not exist or is not visible |
140
+ | 8 | X did not confirm the change, or refused it (its own reason is printed, e.g. a duplicate post) |
141
+
142
+ Writes are only reported as done after X's own request for them (FavoriteTweet, CreateTweet, friendships/create, ...) comes back.
143
+
144
+ ## Things to know
145
+
146
+ - A quote is made through the quoted post's Repost > Quote menu, never by pasting its URL. Both look like quotes, but only the first appears in the quoted post's quote list.
147
+ - Scheduled posts are posted by X at the given time; your machine can be off by then. X needs them at least 5 minutes ahead. X sometimes disables scheduling for an account; anyx says so instead of posting.
148
+ - Settings switches are found by their position on X's settings pages; the live test checks those positions.
149
+ - `tests/live.py` runs every command against real X and puts everything back. Run it daily: when X changes a page, the step that breaks names what went missing.
150
+ - Every page load costs requests against the account's rate limits, like browsing by hand does. Many commands in a row on one account will hit them.
151
+ - Automating an account is your responsibility under X's rules.
152
+
153
+ ## License
154
+
155
+ MIT
@@ -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"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
@@ -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