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 +14 -0
- anyx/__main__.py +3 -0
- anyx/accounts.py +155 -0
- anyx/act.py +298 -0
- anyx/browsers/__init__.py +28 -0
- anyx/browsers/adspower.py +136 -0
- anyx/browsers/cdp.py +45 -0
- anyx/browsers/chromium.py +114 -0
- anyx/chat.py +255 -0
- anyx/cli.py +584 -0
- anyx/communities.py +124 -0
- anyx/compose.py +434 -0
- anyx/config.py +103 -0
- anyx/errors.py +43 -0
- anyx/graphql.py +347 -0
- anyx/lists.py +212 -0
- anyx/login.py +150 -0
- anyx/read.py +323 -0
- anyx/run.py +90 -0
- anyx/session.py +283 -0
- anyx/settings.py +309 -0
- anyx/vault.py +132 -0
- anyx-0.1.0.dist-info/METADATA +173 -0
- anyx-0.1.0.dist-info/RECORD +28 -0
- anyx-0.1.0.dist-info/WHEEL +5 -0
- anyx-0.1.0.dist-info/entry_points.txt +2 -0
- anyx-0.1.0.dist-info/licenses/LICENSE +21 -0
- anyx-0.1.0.dist-info/top_level.txt +1 -0
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
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)
|