autotok 2.0.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.
- autotok/__init__.py +63 -0
- autotok/__main__.py +7 -0
- autotok/accounts.py +233 -0
- autotok/auth.py +175 -0
- autotok/browsers/__init__.py +90 -0
- autotok/browsers/base.py +112 -0
- autotok/browsers/browserbase.py +131 -0
- autotok/browsers/http.py +40 -0
- autotok/browsers/local.py +75 -0
- autotok/browsers/steel.py +136 -0
- autotok/captions.py +93 -0
- autotok/cli.py +368 -0
- autotok/env.py +76 -0
- autotok/errors.py +84 -0
- autotok/proxy.py +188 -0
- autotok/py.typed +0 -0
- autotok/settings.py +80 -0
- autotok/signer.py +153 -0
- autotok/signer_js/signer.js +1 -0
- autotok/signer_js/webmssdk.js +7 -0
- autotok/signer_js/xbogus.js +562 -0
- autotok/uploader.py +447 -0
- autotok/youtube.py +62 -0
- autotok-2.0.0.dist-info/METADATA +601 -0
- autotok-2.0.0.dist-info/RECORD +29 -0
- autotok-2.0.0.dist-info/WHEEL +4 -0
- autotok-2.0.0.dist-info/entry_points.txt +2 -0
- autotok-2.0.0.dist-info/licenses/LICENSE +661 -0
- autotok-2.0.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +18 -0
autotok/__init__.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""autotok: upload and schedule TikTok videos from Python or the command line.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
import autotok
|
|
6
|
+
|
|
7
|
+
autotok.login("alice") # opens a browser once
|
|
8
|
+
autotok.upload_video("alice", "clip.mp4", "Hello #fyp")
|
|
9
|
+
|
|
10
|
+
client = autotok.Client.from_account("alice", proxy="http://user:pass@host:8000")
|
|
11
|
+
client.upload("clip.mp4", "Scheduled #fyp", schedule=3600)
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
__version__ = "2.0.0"
|
|
16
|
+
|
|
17
|
+
from .accounts import Account, AccountStore
|
|
18
|
+
from .errors import (
|
|
19
|
+
AccountNotFoundError,
|
|
20
|
+
AutotokError,
|
|
21
|
+
BrowserNotInstalledError,
|
|
22
|
+
LoginError,
|
|
23
|
+
MissingDependencyError,
|
|
24
|
+
NotLoggedInError,
|
|
25
|
+
PublishError,
|
|
26
|
+
PublishUncertainError,
|
|
27
|
+
SigningError,
|
|
28
|
+
UploadError,
|
|
29
|
+
ValidationError,
|
|
30
|
+
)
|
|
31
|
+
from .auth import import_session, login_interactive
|
|
32
|
+
from .env import load_env
|
|
33
|
+
from .proxy import Proxy, check_proxy, parse_proxy
|
|
34
|
+
from .uploader import Client, UploadResult, upload_video
|
|
35
|
+
|
|
36
|
+
login = login_interactive
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"__version__",
|
|
40
|
+
"Account",
|
|
41
|
+
"AccountStore",
|
|
42
|
+
"Client",
|
|
43
|
+
"Proxy",
|
|
44
|
+
"UploadResult",
|
|
45
|
+
"check_proxy",
|
|
46
|
+
"import_session",
|
|
47
|
+
"load_env",
|
|
48
|
+
"login",
|
|
49
|
+
"login_interactive",
|
|
50
|
+
"parse_proxy",
|
|
51
|
+
"upload_video",
|
|
52
|
+
"AutotokError",
|
|
53
|
+
"AccountNotFoundError",
|
|
54
|
+
"BrowserNotInstalledError",
|
|
55
|
+
"LoginError",
|
|
56
|
+
"MissingDependencyError",
|
|
57
|
+
"NotLoggedInError",
|
|
58
|
+
"PublishError",
|
|
59
|
+
"PublishUncertainError",
|
|
60
|
+
"SigningError",
|
|
61
|
+
"UploadError",
|
|
62
|
+
"ValidationError",
|
|
63
|
+
]
|
autotok/__main__.py
ADDED
autotok/accounts.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Saved TikTok sessions.
|
|
2
|
+
|
|
3
|
+
Each account is one JSON file in ``$AUTOTOK_HOME/accounts/<name>.json`` holding
|
|
4
|
+
the TikTok cookies, the user agent of the browser that logged in, and the
|
|
5
|
+
account's proxy. Files are written with ``0600`` permissions: a ``sessionid``
|
|
6
|
+
cookie is full access to the TikTok account.
|
|
7
|
+
|
|
8
|
+
Sessions saved by TiktokAutoUploader 1.x (pickled ``CookiesDir/tiktok_session-
|
|
9
|
+
<name>.cookie`` files) are found automatically and migrated on first use. They
|
|
10
|
+
are read with a restricted unpickler that only accepts plain data, so a
|
|
11
|
+
tampered cookie file cannot execute code.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import pickle
|
|
19
|
+
import re
|
|
20
|
+
import tempfile
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from . import settings
|
|
26
|
+
from .errors import AccountNotFoundError, ValidationError
|
|
27
|
+
from .proxy import Proxy, parse_proxy
|
|
28
|
+
|
|
29
|
+
log = logging.getLogger("autotok")
|
|
30
|
+
|
|
31
|
+
SESSION_COOKIE = "sessionid"
|
|
32
|
+
DATACENTER_COOKIE = "tt-target-idc"
|
|
33
|
+
LEGACY_PREFIX = "tiktok_session-"
|
|
34
|
+
LEGACY_SUFFIX = ".cookie"
|
|
35
|
+
|
|
36
|
+
_NAME_RE = re.compile(r"^[A-Za-z0-9_.\-]{1,128}$")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def validate_account_name(name: str) -> str:
|
|
40
|
+
if not isinstance(name, str) or not _NAME_RE.match(name) or name in (".", ".."):
|
|
41
|
+
raise ValidationError(
|
|
42
|
+
f"invalid account name {name!r}: use 1-128 letters, digits, '.', '_' or '-'"
|
|
43
|
+
)
|
|
44
|
+
return name
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _now() -> str:
|
|
48
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class Account:
|
|
53
|
+
name: str
|
|
54
|
+
cookies: list[dict] = field(default_factory=list)
|
|
55
|
+
user_agent: str | None = None
|
|
56
|
+
proxy: str | None = None
|
|
57
|
+
created_at: str = field(default_factory=_now)
|
|
58
|
+
updated_at: str = field(default_factory=_now)
|
|
59
|
+
|
|
60
|
+
def __post_init__(self) -> None:
|
|
61
|
+
validate_account_name(self.name)
|
|
62
|
+
if self.proxy is not None:
|
|
63
|
+
parsed = parse_proxy(self.proxy)
|
|
64
|
+
self.proxy = parsed.url if parsed else None
|
|
65
|
+
|
|
66
|
+
def cookie(self, name: str) -> str | None:
|
|
67
|
+
for c in self.cookies:
|
|
68
|
+
if c.get("name") == name and c.get("value"):
|
|
69
|
+
return str(c["value"])
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def session_id(self) -> str | None:
|
|
74
|
+
return self.cookie(SESSION_COOKIE)
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def datacenter(self) -> str | None:
|
|
78
|
+
return self.cookie(DATACENTER_COOKIE)
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def has_session(self) -> bool:
|
|
82
|
+
return self.session_id is not None
|
|
83
|
+
|
|
84
|
+
def get_proxy(self) -> Proxy | None:
|
|
85
|
+
return parse_proxy(self.proxy)
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def from_session_id(cls, name: str, session_id: str, *, datacenter: str | None = None,
|
|
89
|
+
user_agent: str | None = None, proxy: "str | Proxy | None" = None) -> "Account":
|
|
90
|
+
"""Build an account from a ``sessionid`` copied out of a browser."""
|
|
91
|
+
if not session_id or not session_id.strip():
|
|
92
|
+
raise ValidationError("session id is empty")
|
|
93
|
+
cookies = [_cookie(SESSION_COOKIE, session_id.strip())]
|
|
94
|
+
if datacenter:
|
|
95
|
+
cookies.append(_cookie(DATACENTER_COOKIE, datacenter.strip()))
|
|
96
|
+
p = parse_proxy(proxy)
|
|
97
|
+
return cls(name=name, cookies=cookies, user_agent=user_agent, proxy=p.url if p else None)
|
|
98
|
+
|
|
99
|
+
def to_dict(self) -> dict:
|
|
100
|
+
return {
|
|
101
|
+
"name": self.name,
|
|
102
|
+
"cookies": self.cookies,
|
|
103
|
+
"user_agent": self.user_agent,
|
|
104
|
+
"proxy": self.proxy,
|
|
105
|
+
"created_at": self.created_at,
|
|
106
|
+
"updated_at": self.updated_at,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
@classmethod
|
|
110
|
+
def from_dict(cls, data: dict) -> "Account":
|
|
111
|
+
return cls(
|
|
112
|
+
name=data["name"],
|
|
113
|
+
cookies=list(data.get("cookies") or []),
|
|
114
|
+
user_agent=data.get("user_agent"),
|
|
115
|
+
proxy=data.get("proxy"),
|
|
116
|
+
created_at=data.get("created_at") or _now(),
|
|
117
|
+
updated_at=data.get("updated_at") or _now(),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _cookie(name: str, value: str) -> dict:
|
|
122
|
+
return {"name": name, "value": value, "domain": ".tiktok.com", "path": "/"}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class _PlainDataUnpickler(pickle.Unpickler):
|
|
126
|
+
"""Only lists/dicts/str/numbers/bools/None: refuse every class lookup."""
|
|
127
|
+
|
|
128
|
+
def find_class(self, module, name): # noqa: D401
|
|
129
|
+
raise pickle.UnpicklingError(f"refusing to load {module}.{name} from a cookie file")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def read_legacy_cookie_file(path: Path) -> list[dict]:
|
|
133
|
+
with open(path, "rb") as f:
|
|
134
|
+
data = _PlainDataUnpickler(f).load()
|
|
135
|
+
if not isinstance(data, list) or not all(isinstance(c, dict) for c in data):
|
|
136
|
+
raise ValueError(f"{path} is not a list of cookies")
|
|
137
|
+
return data
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class AccountStore:
|
|
141
|
+
def __init__(self, root: "str | Path | None" = None, legacy_dirs: "list[Path] | None" = None):
|
|
142
|
+
self.root = Path(root) if root else settings.accounts_dir()
|
|
143
|
+
self.legacy_dirs = settings.legacy_cookie_dirs() if legacy_dirs is None else list(legacy_dirs)
|
|
144
|
+
|
|
145
|
+
def path_for(self, name: str) -> Path:
|
|
146
|
+
return self.root / f"{validate_account_name(name)}.json"
|
|
147
|
+
|
|
148
|
+
def _legacy_path(self, name: str) -> Path | None:
|
|
149
|
+
for d in self.legacy_dirs:
|
|
150
|
+
p = d / f"{LEGACY_PREFIX}{name}{LEGACY_SUFFIX}"
|
|
151
|
+
if p.is_file():
|
|
152
|
+
return p
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
def exists(self, name: str) -> bool:
|
|
156
|
+
return self.path_for(name).is_file() or self._legacy_path(name) is not None
|
|
157
|
+
|
|
158
|
+
def load(self, name: str) -> Account:
|
|
159
|
+
path = self.path_for(name)
|
|
160
|
+
if path.is_file():
|
|
161
|
+
try:
|
|
162
|
+
with open(path, encoding="utf-8") as f:
|
|
163
|
+
return Account.from_dict(json.load(f))
|
|
164
|
+
except (ValueError, KeyError, TypeError) as exc:
|
|
165
|
+
raise ValidationError(
|
|
166
|
+
f"account file {path} is unreadable ({exc}); log in again with --force"
|
|
167
|
+
) from None
|
|
168
|
+
legacy = self._legacy_path(name)
|
|
169
|
+
if legacy is None:
|
|
170
|
+
raise AccountNotFoundError(
|
|
171
|
+
f"no saved session for '{name}'. Run: autotok login -n {name}"
|
|
172
|
+
)
|
|
173
|
+
try:
|
|
174
|
+
cookies = read_legacy_cookie_file(legacy)
|
|
175
|
+
except (pickle.UnpicklingError, ValueError, EOFError) as exc:
|
|
176
|
+
raise ValidationError(f"cannot migrate legacy session {legacy}: {exc}") from None
|
|
177
|
+
account = Account(name=name, cookies=cookies)
|
|
178
|
+
self.save(account)
|
|
179
|
+
log.info("Migrated legacy session %s to %s", legacy, path)
|
|
180
|
+
return account
|
|
181
|
+
|
|
182
|
+
def save(self, account: Account) -> Path:
|
|
183
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
184
|
+
path = self.path_for(account.name)
|
|
185
|
+
account.updated_at = _now()
|
|
186
|
+
payload = json.dumps(account.to_dict(), indent=2)
|
|
187
|
+
fd, tmp = tempfile.mkstemp(prefix=".tmp-", dir=self.root)
|
|
188
|
+
try:
|
|
189
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
190
|
+
f.write(payload)
|
|
191
|
+
os.chmod(tmp, 0o600)
|
|
192
|
+
os.replace(tmp, path)
|
|
193
|
+
except BaseException:
|
|
194
|
+
try:
|
|
195
|
+
os.unlink(tmp)
|
|
196
|
+
except OSError:
|
|
197
|
+
pass
|
|
198
|
+
raise
|
|
199
|
+
return path
|
|
200
|
+
|
|
201
|
+
def delete(self, name: str) -> bool:
|
|
202
|
+
"""Delete the saved session (and its legacy file, if any)."""
|
|
203
|
+
removed = False
|
|
204
|
+
path = self.path_for(name)
|
|
205
|
+
if path.is_file():
|
|
206
|
+
path.unlink()
|
|
207
|
+
removed = True
|
|
208
|
+
legacy = self._legacy_path(name)
|
|
209
|
+
if legacy is not None:
|
|
210
|
+
legacy.unlink()
|
|
211
|
+
removed = True
|
|
212
|
+
return removed
|
|
213
|
+
|
|
214
|
+
def list(self) -> list[str]:
|
|
215
|
+
names: set[str] = set()
|
|
216
|
+
if self.root.is_dir():
|
|
217
|
+
names.update(p.stem for p in self.root.glob("*.json") if _NAME_RE.match(p.stem))
|
|
218
|
+
for d in self.legacy_dirs:
|
|
219
|
+
if d.is_dir():
|
|
220
|
+
for p in d.iterdir():
|
|
221
|
+
n = p.name
|
|
222
|
+
if n.startswith(LEGACY_PREFIX) and n.endswith(LEGACY_SUFFIX):
|
|
223
|
+
stem = n[len(LEGACY_PREFIX):-len(LEGACY_SUFFIX)]
|
|
224
|
+
if _NAME_RE.match(stem):
|
|
225
|
+
names.add(stem)
|
|
226
|
+
return sorted(names)
|
|
227
|
+
|
|
228
|
+
def set_proxy(self, name: str, proxy: "str | Proxy | None") -> Account:
|
|
229
|
+
account = self.load(name)
|
|
230
|
+
p = parse_proxy(proxy)
|
|
231
|
+
account.proxy = p.url if p else None
|
|
232
|
+
self.save(account)
|
|
233
|
+
return account
|
autotok/auth.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Logging in to TikTok and saving the session.
|
|
2
|
+
|
|
3
|
+
``login_interactive`` opens a real (headed) Chromium window on TikTok's login
|
|
4
|
+
page, routed through the account's proxy, and waits until TikTok sets the
|
|
5
|
+
``sessionid`` cookie. The cookies and the browser's user agent are saved, so
|
|
6
|
+
later uploads present the same browser identity that created the session.
|
|
7
|
+
|
|
8
|
+
With a cloud browser (``AUTOTOK_BROWSER=browserbase`` or ``steel``) the
|
|
9
|
+
browser runs remotely instead, and you log in through its live view link from
|
|
10
|
+
your own browser.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import time
|
|
16
|
+
from typing import Callable, Optional
|
|
17
|
+
|
|
18
|
+
from . import settings
|
|
19
|
+
from .accounts import DATACENTER_COOKIE, SESSION_COOKIE, Account, AccountStore, validate_account_name
|
|
20
|
+
from .browsers import open_browser, sync_playwright
|
|
21
|
+
from .errors import LoginError
|
|
22
|
+
from .proxy import Proxy, parse_proxy
|
|
23
|
+
|
|
24
|
+
log = logging.getLogger("autotok")
|
|
25
|
+
|
|
26
|
+
REQUIRED_COOKIES = (SESSION_COOKIE, DATACENTER_COOKIE)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def wait_for_session_cookies(
|
|
30
|
+
get_cookies: Callable[[], list[dict]],
|
|
31
|
+
*,
|
|
32
|
+
poll_interval: float = 1.0,
|
|
33
|
+
timeout: float | None = None,
|
|
34
|
+
should_stop: Optional[Callable[[], bool]] = None,
|
|
35
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
36
|
+
) -> list[dict]:
|
|
37
|
+
"""Poll ``get_cookies`` until ``sessionid`` and ``tt-target-idc`` are set.
|
|
38
|
+
|
|
39
|
+
Returns every TikTok cookie at that moment. Raises :class:`LoginError` on
|
|
40
|
+
timeout, cancellation, or when the browser is closed.
|
|
41
|
+
"""
|
|
42
|
+
start = time.monotonic()
|
|
43
|
+
while True:
|
|
44
|
+
if should_stop and should_stop():
|
|
45
|
+
raise LoginError("login cancelled")
|
|
46
|
+
try:
|
|
47
|
+
cookies = get_cookies()
|
|
48
|
+
except Exception as exc:
|
|
49
|
+
raise LoginError(f"the browser was closed before login finished ({exc})") from None
|
|
50
|
+
names = {c.get("name") for c in cookies if c.get("value")}
|
|
51
|
+
if all(n in names for n in REQUIRED_COOKIES):
|
|
52
|
+
return [c for c in cookies if "tiktok" in str(c.get("domain", ""))]
|
|
53
|
+
if timeout is not None and time.monotonic() - start > timeout:
|
|
54
|
+
raise LoginError(f"timed out after {int(timeout)}s waiting for TikTok login")
|
|
55
|
+
try:
|
|
56
|
+
sleep(poll_interval)
|
|
57
|
+
except Exception as exc: # e.g. the user closed the browser window
|
|
58
|
+
raise LoginError(f"the browser was closed before login finished ({exc})") from None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _keep_login_tab_in_front(context, login_page) -> None:
|
|
62
|
+
"""Cloud live views show only the login tab, so a popup (e.g. "Continue
|
|
63
|
+
with Google") would be invisible and steal focus from it. Close popups and
|
|
64
|
+
keep the login tab in front."""
|
|
65
|
+
closed = False
|
|
66
|
+
for p in list(context.pages):
|
|
67
|
+
if p is not login_page and not p.is_closed():
|
|
68
|
+
try:
|
|
69
|
+
p.close()
|
|
70
|
+
closed = True
|
|
71
|
+
except Exception: # pragma: no cover - best effort
|
|
72
|
+
pass
|
|
73
|
+
if closed:
|
|
74
|
+
log.warning("Closed a popup window: popups (e.g. 'Continue with Google') can't be shown in "
|
|
75
|
+
"the live view. Log in with the QR code, email/username or phone instead.")
|
|
76
|
+
try:
|
|
77
|
+
login_page.bring_to_front()
|
|
78
|
+
except Exception: # pragma: no cover
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _announce_live_view(url: str) -> None:
|
|
83
|
+
log.warning("Open this link in your browser and log in to TikTok there:\n %s", url)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def open_login_session(
|
|
87
|
+
*,
|
|
88
|
+
proxy: "str | Proxy | None" = None,
|
|
89
|
+
timeout: float | None = 600,
|
|
90
|
+
should_stop: Optional[Callable[[], bool]] = None,
|
|
91
|
+
on_live_url: Optional[Callable[[str], None]] = None,
|
|
92
|
+
) -> tuple[list[dict], str]:
|
|
93
|
+
"""Open a browser for the user to log in. Returns ``(cookies, user_agent)``.
|
|
94
|
+
|
|
95
|
+
Shared by the CLI and the Docker web app's virtual browser. For cloud
|
|
96
|
+
browsers, ``on_live_url`` receives the link the user logs in through
|
|
97
|
+
(default: it is logged).
|
|
98
|
+
"""
|
|
99
|
+
from playwright.sync_api import Error as PlaywrightError
|
|
100
|
+
|
|
101
|
+
p = parse_proxy(proxy)
|
|
102
|
+
with sync_playwright() as pw:
|
|
103
|
+
session_timeout = (timeout or 3600) + 300
|
|
104
|
+
handle = open_browser(pw, headless=False, proxy=p, args=["--start-maximized"], timeout=session_timeout)
|
|
105
|
+
try:
|
|
106
|
+
if handle.is_remote:
|
|
107
|
+
context = handle.context()
|
|
108
|
+
page = handle.page(context)
|
|
109
|
+
else:
|
|
110
|
+
context = handle.context(no_viewport=True)
|
|
111
|
+
page = context.new_page()
|
|
112
|
+
try:
|
|
113
|
+
page.goto(settings.TIKTOK_LOGIN_URL, wait_until="domcontentloaded", timeout=60_000)
|
|
114
|
+
user_agent = page.evaluate("() => navigator.userAgent")
|
|
115
|
+
except PlaywrightError as exc:
|
|
116
|
+
hint = " (check the proxy)" if p else ""
|
|
117
|
+
raise LoginError(f"could not open TikTok's login page{hint}: {exc.message}") from None
|
|
118
|
+
if handle.is_remote:
|
|
119
|
+
(on_live_url or _announce_live_view)(handle.live_view_url())
|
|
120
|
+
|
|
121
|
+
def get_cookies() -> list[dict]:
|
|
122
|
+
if page.is_closed():
|
|
123
|
+
raise RuntimeError("page closed")
|
|
124
|
+
if handle.is_remote:
|
|
125
|
+
_keep_login_tab_in_front(context, page)
|
|
126
|
+
return context.cookies()
|
|
127
|
+
|
|
128
|
+
cookies = wait_for_session_cookies(
|
|
129
|
+
get_cookies,
|
|
130
|
+
timeout=timeout,
|
|
131
|
+
should_stop=should_stop,
|
|
132
|
+
# Keep Playwright's event loop running while we wait.
|
|
133
|
+
sleep=lambda s: page.wait_for_timeout(s * 1000),
|
|
134
|
+
)
|
|
135
|
+
finally:
|
|
136
|
+
handle.close()
|
|
137
|
+
return cookies, user_agent
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def login_interactive(
|
|
141
|
+
name: str,
|
|
142
|
+
*,
|
|
143
|
+
proxy: "str | Proxy | None" = None,
|
|
144
|
+
timeout: float | None = 600,
|
|
145
|
+
store: AccountStore | None = None,
|
|
146
|
+
on_live_url: Optional[Callable[[str], None]] = None,
|
|
147
|
+
) -> Account:
|
|
148
|
+
"""Log in through a browser window and save the session as ``name``.
|
|
149
|
+
|
|
150
|
+
With a cloud browser, ``on_live_url`` gets the link to log in through.
|
|
151
|
+
"""
|
|
152
|
+
validate_account_name(name)
|
|
153
|
+
p = parse_proxy(proxy)
|
|
154
|
+
cookies, user_agent = open_login_session(proxy=p, timeout=timeout, on_live_url=on_live_url)
|
|
155
|
+
account = Account(name=name, cookies=cookies, user_agent=user_agent, proxy=p.url if p else None)
|
|
156
|
+
(store or AccountStore()).save(account)
|
|
157
|
+
log.info("Saved session for '%s'", name)
|
|
158
|
+
return account
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def import_session(
|
|
162
|
+
name: str,
|
|
163
|
+
session_id: str,
|
|
164
|
+
*,
|
|
165
|
+
datacenter: str | None = None,
|
|
166
|
+
user_agent: str | None = None,
|
|
167
|
+
proxy: "str | Proxy | None" = None,
|
|
168
|
+
store: AccountStore | None = None,
|
|
169
|
+
) -> Account:
|
|
170
|
+
"""Save a ``sessionid`` copied from a browser's cookies (no browser needed)."""
|
|
171
|
+
account = Account.from_session_id(
|
|
172
|
+
name, session_id, datacenter=datacenter, user_agent=user_agent, proxy=proxy
|
|
173
|
+
)
|
|
174
|
+
(store or AccountStore()).save(account)
|
|
175
|
+
return account
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Where login and signing browsers run.
|
|
2
|
+
|
|
3
|
+
A small factory picks the :class:`BrowserProvider` named by ``AUTOTOK_BROWSER``:
|
|
4
|
+
|
|
5
|
+
* ``local``: Chromium on this machine (default)
|
|
6
|
+
* ``browserbase``: Browserbase cloud browsers (``BROWSERBASE_API_KEY``)
|
|
7
|
+
* ``steel``: Steel Cloud, or a self-hosted steel-browser server (``STEEL_BASE_URL``)
|
|
8
|
+
|
|
9
|
+
Add your own with :func:`register_provider`::
|
|
10
|
+
|
|
11
|
+
from autotok.browsers import CloudProvider, register_provider
|
|
12
|
+
|
|
13
|
+
class MyProvider(CloudProvider):
|
|
14
|
+
label = "My cloud"
|
|
15
|
+
def create_session(self, *, proxy=None, timeout=None): ...
|
|
16
|
+
|
|
17
|
+
register_provider("mycloud", MyProvider) # then AUTOTOK_BROWSER=mycloud
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from typing import Callable
|
|
22
|
+
|
|
23
|
+
from .. import settings
|
|
24
|
+
from ..errors import ConfigurationError, MissingDependencyError
|
|
25
|
+
from ..proxy import Proxy
|
|
26
|
+
from .base import BrowserHandle, BrowserProvider, CloudProvider, CloudSession
|
|
27
|
+
from .browserbase import BrowserbaseProvider
|
|
28
|
+
from .local import LocalProvider, install_browser, launch_chromium
|
|
29
|
+
from .steel import SteelProvider
|
|
30
|
+
|
|
31
|
+
_REGISTRY: dict[str, Callable[[], BrowserProvider]] = {
|
|
32
|
+
"local": LocalProvider,
|
|
33
|
+
"browserbase": BrowserbaseProvider,
|
|
34
|
+
"steel": SteelProvider,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def register_provider(name: str, factory: Callable[[], BrowserProvider]) -> None:
|
|
39
|
+
"""Make ``factory`` available as ``AUTOTOK_BROWSER=<name>``."""
|
|
40
|
+
_REGISTRY[name.strip().lower()] = factory
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def available_providers() -> list[str]:
|
|
44
|
+
return sorted(_REGISTRY)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_provider(name: str | None = None) -> BrowserProvider:
|
|
48
|
+
"""The provider called ``name``, or the one ``AUTOTOK_BROWSER`` selects."""
|
|
49
|
+
key = (name or settings.browser_provider()).strip().lower()
|
|
50
|
+
try:
|
|
51
|
+
factory = _REGISTRY[key]
|
|
52
|
+
except KeyError:
|
|
53
|
+
raise ConfigurationError(
|
|
54
|
+
f"unknown browser provider {key!r}; use one of: {', '.join(available_providers())}"
|
|
55
|
+
) from None
|
|
56
|
+
return factory()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def open_browser(pw, *, headless: bool = True, proxy: Proxy | None = None,
|
|
60
|
+
args: list[str] | None = None, offline: bool = False,
|
|
61
|
+
timeout: float | None = None, provider: str | None = None) -> BrowserHandle:
|
|
62
|
+
"""Start a browser from the selected provider (see :meth:`BrowserProvider.open`)."""
|
|
63
|
+
return get_provider(provider).open(pw, headless=headless, proxy=proxy, args=args,
|
|
64
|
+
offline=offline, timeout=timeout)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def sync_playwright():
|
|
68
|
+
try:
|
|
69
|
+
from playwright.sync_api import sync_playwright as _sp
|
|
70
|
+
except ImportError as exc: # pragma: no cover - playwright is a core dependency
|
|
71
|
+
raise MissingDependencyError("playwright is not installed: pip install autotok") from exc
|
|
72
|
+
return _sp()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
__all__ = [
|
|
76
|
+
"BrowserHandle",
|
|
77
|
+
"BrowserProvider",
|
|
78
|
+
"CloudProvider",
|
|
79
|
+
"CloudSession",
|
|
80
|
+
"BrowserbaseProvider",
|
|
81
|
+
"LocalProvider",
|
|
82
|
+
"SteelProvider",
|
|
83
|
+
"available_providers",
|
|
84
|
+
"get_provider",
|
|
85
|
+
"install_browser",
|
|
86
|
+
"launch_chromium",
|
|
87
|
+
"open_browser",
|
|
88
|
+
"register_provider",
|
|
89
|
+
"sync_playwright",
|
|
90
|
+
]
|
autotok/browsers/base.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""The browser provider interface.
|
|
2
|
+
|
|
3
|
+
A :class:`BrowserProvider` starts a browser and returns a :class:`BrowserHandle`.
|
|
4
|
+
:class:`CloudProvider` covers services that hand out a remote Chromium over
|
|
5
|
+
CDP (Browserbase, Steel): subclasses only implement :meth:`create_session`.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from abc import ABC, abstractmethod
|
|
10
|
+
|
|
11
|
+
from ..errors import RemoteBrowserError
|
|
12
|
+
from ..proxy import Proxy
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CloudSession(ABC):
|
|
16
|
+
"""A remote browser session."""
|
|
17
|
+
|
|
18
|
+
id: str
|
|
19
|
+
connect_url: str # CDP endpoint for Playwright's connect_over_cdp
|
|
20
|
+
|
|
21
|
+
@abstractmethod
|
|
22
|
+
def live_view_url(self) -> str:
|
|
23
|
+
"""Link that lets a person watch and control the browser."""
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def release(self) -> None:
|
|
27
|
+
"""End the session now (stops billing). Must not raise."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class BrowserHandle:
|
|
31
|
+
"""A running browser: local Chromium, or a cloud session."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, browser, *, session: CloudSession | None = None, provider: str = "local"):
|
|
34
|
+
self.browser = browser
|
|
35
|
+
self.session = session
|
|
36
|
+
self.provider = provider
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def is_remote(self) -> bool:
|
|
40
|
+
return self.session is not None
|
|
41
|
+
|
|
42
|
+
def context(self, **options):
|
|
43
|
+
"""A browser context to work in. Cloud sessions come with one
|
|
44
|
+
preconfigured context (fingerprint, proxy), so ``options`` only apply
|
|
45
|
+
to local browsers."""
|
|
46
|
+
if self.is_remote:
|
|
47
|
+
return self.browser.contexts[0] if self.browser.contexts else self.browser.new_context()
|
|
48
|
+
return self.browser.new_context(**options)
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def page(context):
|
|
52
|
+
"""The context's first page (cloud sessions open with one), or a new one."""
|
|
53
|
+
return context.pages[0] if context.pages else context.new_page()
|
|
54
|
+
|
|
55
|
+
def live_view_url(self) -> str | None:
|
|
56
|
+
return self.session.live_view_url() if self.session is not None else None
|
|
57
|
+
|
|
58
|
+
def close(self) -> None:
|
|
59
|
+
try:
|
|
60
|
+
self.browser.close()
|
|
61
|
+
except Exception: # pragma: no cover - best effort cleanup
|
|
62
|
+
pass
|
|
63
|
+
if self.session is not None:
|
|
64
|
+
self.session.release()
|
|
65
|
+
|
|
66
|
+
def __enter__(self) -> "BrowserHandle":
|
|
67
|
+
return self
|
|
68
|
+
|
|
69
|
+
def __exit__(self, *exc) -> None:
|
|
70
|
+
self.close()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class BrowserProvider(ABC):
|
|
74
|
+
"""Starts browsers for login and signing."""
|
|
75
|
+
|
|
76
|
+
#: Human-readable name used in messages.
|
|
77
|
+
label: str = "browser"
|
|
78
|
+
|
|
79
|
+
@abstractmethod
|
|
80
|
+
def open(self, pw, *, headless: bool = True, proxy: Proxy | None = None,
|
|
81
|
+
args: list[str] | None = None, offline: bool = False,
|
|
82
|
+
timeout: float | None = None) -> BrowserHandle:
|
|
83
|
+
"""Start a browser.
|
|
84
|
+
|
|
85
|
+
``proxy`` routes its traffic through the account's proxy. ``offline``
|
|
86
|
+
browsers only run page scripts (every request is fulfilled by a route
|
|
87
|
+
handler), so they need no proxy. ``timeout`` (seconds) bounds how long
|
|
88
|
+
a cloud session may live.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class CloudProvider(BrowserProvider):
|
|
93
|
+
"""A provider that hands out remote Chromium sessions over CDP."""
|
|
94
|
+
|
|
95
|
+
@abstractmethod
|
|
96
|
+
def create_session(self, *, proxy: Proxy | None = None, timeout: float | None = None,
|
|
97
|
+
offline: bool = False) -> CloudSession:
|
|
98
|
+
"""Start a remote session (its traffic goes through ``proxy`` if given).
|
|
99
|
+
|
|
100
|
+
``offline`` sessions only run page scripts, so providers should skip
|
|
101
|
+
any managed proxy for them."""
|
|
102
|
+
|
|
103
|
+
def open(self, pw, *, headless: bool = True, proxy: Proxy | None = None,
|
|
104
|
+
args: list[str] | None = None, offline: bool = False,
|
|
105
|
+
timeout: float | None = None) -> BrowserHandle:
|
|
106
|
+
session = self.create_session(proxy=None if offline else proxy, timeout=timeout, offline=offline)
|
|
107
|
+
try:
|
|
108
|
+
browser = pw.chromium.connect_over_cdp(session.connect_url)
|
|
109
|
+
except Exception as exc:
|
|
110
|
+
session.release()
|
|
111
|
+
raise RemoteBrowserError(f"could not connect to the {self.label} session: {exc}") from None
|
|
112
|
+
return BrowserHandle(browser, session=session, provider=self.label)
|