feat: SDK for new routes

This commit is contained in:
zhom committed 2026-10-12 07:45:31 +04:00
1 parent 74c507bd9b
commit 9797b8b148
10 files changed
+1115 -12

No files matched your search

+192 -6
View File
@@ -22,15 +22,18 @@ from .errors import DonutConnectionError, DonutError, error_for_status
from .models import (
AgentClick,
AgentTyping,
ApiBulkCreateResponse,
ApiGroupResponse,
ApiProfileResponse,
ApiProfilesResponse,
ApiProxyResponse,
ApiRemoteSessionsResponse,
ApiStatusResponse,
ApiVpnExportResponse,
ApiVpnResponse,
BatchRunResponse,
BatchStopResponse,
BulkEditResponse,
CookieBotConflictCheck,
CookieBotPresetList,
CookieBotRun,
@@ -56,14 +59,20 @@ from .models import (
PerceptionPage,
PickedElement,
ProfileImportBatchResult,
ProfilePatch,
ProfileTemplate,
ProxyPair,
ProxySettings,
RemoteHoursQuota,
RemoteSessionState,
RotationResult,
RunProfileResponse,
RunRemoteResponse,
SetCloudSyncResponse,
StopRemoteResponse,
StorageState,
StorageStateImport,
TemplateSettings,
WayfernConfig,
)
@@ -289,6 +298,8 @@ class DonutClient:
ephemeral: Optional[bool] = None,
temporary: Optional[bool] = None,
dns_server: Optional[str] = None,
status_id: Optional[str] = None,
start_urls: Optional[Sequence[str]] = None,
) -> ApiProfileResponse:
"""POST /v1/profiles
@@ -298,7 +309,8 @@ class DonutClient:
names through the profile's proxy or VPN: ``https://host/path``,
``tls://host[:port]``, ``tcp://host[:port]`` or a bare ``host[:port]``;
a bare address of a well-known resolver such as ``"1.1.1.1"`` uses its
DNS over HTTPS endpoint.
DNS over HTTPS endpoint. ``status_id`` comes from :meth:`list_statuses`;
``start_urls`` is at most 20 http(s) URLs.
"""
return self._request(
"POST",
@@ -317,6 +329,8 @@ class DonutClient:
ephemeral=ephemeral,
temporary=temporary,
dns_server=dns_server,
status_id=status_id,
start_urls=list(start_urls) if start_urls is not None else None,
),
)
@@ -337,13 +351,16 @@ class DonutClient:
sync_mode: Optional[str] = None,
clear_on_close: Optional[bool] = None,
dns_server: Optional[str] = None,
status_id: Optional[str] = None,
start_urls: Optional[Sequence[str]] = None,
) -> ApiProfileResponse:
"""PUT /v1/profiles/{id}
A profile's browser engine is fixed at creation, so there is no
``browser`` argument. Pass ``proxy_id=""`` or ``vpn_id=""`` to detach
one; leaving either unset changes nothing. ``dns_server=""`` lets the
proxy resolve names again.
proxy resolve names again, ``status_id=""`` clears the status, and
``start_urls=[]`` removes the start pages.
"""
return self._request(
"PUT",
@@ -364,6 +381,8 @@ class DonutClient:
sync_mode=sync_mode,
clear_on_close=clear_on_close,
dns_server=dns_server,
status_id=status_id,
start_urls=list(start_urls) if start_urls is not None else None,
),
)
@@ -497,6 +516,51 @@ class DonutClient:
body={"content": content},
)
def export_storage_state(
self, profile_id: str, *, include_cookies: Optional[bool] = None
) -> StorageState:
"""GET /v1/profiles/{id}/storage-state
Playwright's ``storageState``: each origin's Local Storage, plus the
cookies when ``include_cookies=True``. Readable while the browser runs,
but writes from the last few seconds may not be on disk yet.
"""
return self._request(
"GET",
f"/v1/profiles/{_segment(profile_id)}/storage-state",
query=_query(include_cookies=include_cookies),
)
def import_storage_state(self, profile_id: str, state: StorageState) -> StorageStateImport:
"""POST /v1/profiles/{id}/storage-state
``state`` is a Playwright ``storageState``; what
``context.storage_state()`` returns can be passed as is. An item
replaces the one of the same name, and the origin's other items stay.
The browser must be stopped.
"""
return self._request(
"POST",
f"/v1/profiles/{_segment(profile_id)}/storage-state",
body=dict(state),
)
def bulk_edit_profiles(
self, profile_ids: Sequence[str], patch: ProfilePatch
) -> BulkEditResponse:
"""POST /v1/profiles/bulk-edit
Every profile is checked before any is saved, so a bad value, an
unknown profile or a running browser changes nothing. ``patch`` is sent
as given: a ``None`` in it clears that field rather than being dropped.
``changed`` counts only the profiles that really changed.
"""
return self._request(
"POST",
"/v1/profiles/bulk-edit",
body={"profile_ids": list(profile_ids), "patch": dict(patch)},
)
def distribute_proxies(self, pairs: Sequence[ProxyPair]) -> DistributeProxiesResponse:
"""POST /v1/profiles/distribute-proxies
@@ -849,6 +913,101 @@ class DonutClient:
"""GET /v1/tags"""
return self._request("GET", "/v1/tags")
# ------------------------------------------------------------------
# Statuses
# ------------------------------------------------------------------
def list_statuses(self) -> List[ApiStatusResponse]:
"""GET /v1/statuses"""
return self._request("GET", "/v1/statuses")
def create_status(self, *, name: str, color: str) -> ApiStatusResponse:
"""POST /v1/statuses
``name`` is 1 to 40 characters, unique regardless of case, and
``color`` is ``#rrggbb``.
"""
return self._request("POST", "/v1/statuses", body={"name": name, "color": color})
def update_status(self, status_id: str, *, name: str, color: str) -> ApiStatusResponse:
"""PUT /v1/statuses/{id}"""
return self._request(
"PUT",
f"/v1/statuses/{_segment(status_id)}",
body={"name": name, "color": color},
)
def delete_status(self, status_id: str) -> None:
"""DELETE /v1/statuses/{id}
The status is also taken off every profile that had it.
"""
self._request("DELETE", f"/v1/statuses/{_segment(status_id)}")
# ------------------------------------------------------------------
# Templates
# ------------------------------------------------------------------
def list_templates(self) -> List[ProfileTemplate]:
"""GET /v1/templates"""
return self._request("GET", "/v1/templates")
def create_template(
self, *, name: str, settings: Optional[TemplateSettings] = None
) -> ProfileTemplate:
"""POST /v1/templates
``name`` is 1 to 60 characters, unique regardless of case. Device
fields in ``settings["wayfern_config"]`` are dropped: every profile
made from the template gets its own device.
"""
return self._request(
"POST",
"/v1/templates",
body=_body(
name=name, settings=dict(settings) if settings is not None else None
),
)
def update_template(
self, template_id: str, *, name: str, settings: Optional[TemplateSettings] = None
) -> ProfileTemplate:
"""PUT /v1/templates/{id}
Replaces the whole template. Settings left out are reset to empty, so
to rename one, send its current ``settings`` back with the new name.
"""
return self._request(
"PUT",
f"/v1/templates/{_segment(template_id)}",
body=_body(
name=name, settings=dict(settings) if settings is not None else None
),
)
def delete_template(self, template_id: str) -> None:
"""DELETE /v1/templates/{id}
Profiles made from the template keep their settings.
"""
self._request("DELETE", f"/v1/templates/{_segment(template_id)}")
def create_profiles_from_template(
self, template_id: str, *, count: int, name: Optional[str] = None
) -> ApiBulkCreateResponse:
"""POST /v1/templates/{id}/profiles
Makes ``count`` (1 to 100) profiles with the newest downloaded Wayfern,
each with its own device. One profile takes ``name``; several take it
followed by numbers. When ``error`` is set, creation stopped early and
``profiles`` lists what was made.
"""
return self._request(
"POST",
f"/v1/templates/{_segment(template_id)}/profiles",
body=_body(count=count, name=name),
)
# ------------------------------------------------------------------
# Proxies
# ------------------------------------------------------------------
@@ -861,12 +1020,24 @@ class DonutClient:
"""GET /v1/proxies/{id}"""
return self._request("GET", f"/v1/proxies/{_segment(proxy_id)}")
def create_proxy(self, *, name: str, proxy_settings: ProxySettings) -> ApiProxyResponse:
"""POST /v1/proxies"""
def create_proxy(
self,
*,
name: str,
proxy_settings: ProxySettings,
rotation_url: Optional[str] = None,
) -> ApiProxyResponse:
"""POST /v1/proxies
``rotation_url`` is the provider's http(s) "change IP" link, used by
:meth:`rotate_proxy`.
"""
return self._request(
"POST",
"/v1/proxies",
body={"name": name, "proxy_settings": dict(proxy_settings)},
body=_body(
name=name, proxy_settings=dict(proxy_settings), rotation_url=rotation_url
),
)
def update_proxy(
@@ -875,14 +1046,19 @@ class DonutClient:
*,
name: Optional[str] = None,
proxy_settings: Optional[ProxySettings] = None,
rotation_url: Optional[str] = None,
) -> ApiProxyResponse:
"""PUT /v1/proxies/{id}"""
"""PUT /v1/proxies/{id}
``rotation_url=""`` clears the "change IP" link.
"""
return self._request(
"PUT",
f"/v1/proxies/{_segment(proxy_id)}",
body=_body(
name=name,
proxy_settings=dict(proxy_settings) if proxy_settings is not None else None,
rotation_url=rotation_url,
),
)
@@ -890,6 +1066,16 @@ class DonutClient:
"""DELETE /v1/proxies/{id}"""
self._request("DELETE", f"/v1/proxies/{_segment(proxy_id)}")
def rotate_proxy(self, proxy_id: str) -> RotationResult:
"""POST /v1/proxies/{id}/rotate
Calls the proxy's "change IP" link; every profile behind the proxy
moves to the new IP. A proxy without a link raises
:class:`~donutbrowser.errors.ValidationError`, and a provider that
refuses or does not answer raises :class:`~donutbrowser.errors.BadGateway`.
"""
return self._request("POST", f"/v1/proxies/{_segment(proxy_id)}/rotate")
def import_proxies(
self,
*,
+13
View File
@@ -60,6 +60,7 @@ OPERATIONS: Dict[Operation, str] = {
("POST", "/v1/profiles"): "create_profile",
("POST", "/v1/profiles/batch/run"): "batch_run_profiles",
("POST", "/v1/profiles/batch/stop"): "batch_stop_profiles",
("POST", "/v1/profiles/bulk-edit"): "bulk_edit_profiles",
("POST", "/v1/profiles/distribute-proxies"): "distribute_proxies",
("POST", "/v1/profiles/import"): "import_profiles",
("GET", "/v1/profiles/import/detect"): "detect_import_profiles",
@@ -78,17 +79,29 @@ OPERATIONS: Dict[Operation, str] = {
("POST", "/v1/profiles/{id}/open-url"): "open_url",
("POST", "/v1/profiles/{id}/run"): "run_profile",
("POST", "/v1/profiles/{id}/run-remote"): "run_profile_remote",
("GET", "/v1/profiles/{id}/storage-state"): "export_storage_state",
("POST", "/v1/profiles/{id}/storage-state"): "import_storage_state",
("GET", "/v1/proxies"): "list_proxies",
("POST", "/v1/proxies"): "create_proxy",
("POST", "/v1/proxies/import"): "import_proxies",
("DELETE", "/v1/proxies/{id}"): "delete_proxy",
("GET", "/v1/proxies/{id}"): "get_proxy",
("PUT", "/v1/proxies/{id}"): "update_proxy",
("POST", "/v1/proxies/{id}/rotate"): "rotate_proxy",
("GET", "/v1/remote-hours"): "get_remote_hours",
("GET", "/v1/remote-sessions"): "list_remote_sessions",
("DELETE", "/v1/remote-sessions/{id}"): "stop_remote_session",
("GET", "/v1/remote-sessions/{id}"): "get_remote_session",
("GET", "/v1/statuses"): "list_statuses",
("POST", "/v1/statuses"): "create_status",
("DELETE", "/v1/statuses/{id}"): "delete_status",
("PUT", "/v1/statuses/{id}"): "update_status",
("GET", "/v1/tags"): "list_tags",
("GET", "/v1/templates"): "list_templates",
("POST", "/v1/templates"): "create_template",
("DELETE", "/v1/templates/{id}"): "delete_template",
("PUT", "/v1/templates/{id}"): "update_template",
("POST", "/v1/templates/{id}/profiles"): "create_profiles_from_template",
("GET", "/v1/vpns"): "list_vpns",
("POST", "/v1/vpns"): "create_vpn",
("POST", "/v1/vpns/import"): "import_vpn",
+149 -2
View File
@@ -17,14 +17,25 @@ passed straight into the next.
from __future__ import annotations
from typing import Any, Dict, List, TypedDict
from typing import Any, Dict, List, Optional, TypedDict
__all__ = [
"ApiProfile",
"ApiProfilesResponse",
"ApiProfileResponse",
"ApiGroupResponse",
"ApiStatusResponse",
"ProfilePatch",
"BulkEditResponse",
"TemplateSettings",
"ProfileTemplate",
"ApiBulkCreateResponse",
"StorageItem",
"OriginStorage",
"StorageState",
"StorageStateImport",
"ApiProxyResponse",
"RotationResult",
"ApiVpnResponse",
"ApiVpnExportResponse",
"DownloadBrowserResponse",
@@ -102,6 +113,7 @@ class _ApiProfileRequired(TypedDict):
tags: List[str]
is_running: bool
proxy_bypass_rules: List[str]
start_urls: List[str]
ephemeral: bool
temporary: bool
clear_on_close: bool
@@ -118,6 +130,7 @@ class ApiProfile(_ApiProfileRequired, total=False):
group_id: str
vpn_id: str
dns_server: str
status_id: str
extension_group_id: str
host_os: str
fingerprint_os: str
@@ -138,12 +151,146 @@ class ApiGroupResponse(TypedDict):
profile_count: int
class ApiProxyResponse(TypedDict):
class ApiStatusResponse(TypedDict):
"""A named, colored label for where a profile's account stands."""
id: str
name: str
color: str
class ProfilePatch(TypedDict, total=False):
"""One change applied to many profiles by ``bulk_edit_profiles``.
A key left out keeps that field as it is. For the fields typed
``Optional``, an explicit ``None`` (JSON ``null``) clears the field, so
the patch is sent exactly as given. An unknown key is refused with 400.
"""
status_id: Optional[str]
tags_add: List[str]
tags_remove: List[str]
note: Optional[str]
group_id: Optional[str]
extension_group_id: Optional[str]
dns_blocklist: Optional[str]
dns_server: Optional[str]
proxy_bypass_rules: List[str]
start_urls: List[str]
launch_hook: Optional[str]
clear_on_close: bool
randomize_fingerprint_on_launch: bool
webrtc_mode: Optional[str]
block_images: bool
block_webgl: bool
restore_session: bool
new_device: bool
class BulkEditResponse(TypedDict):
changed: int
class TemplateSettings(TypedDict, total=False):
"""What every profile made from a template starts with.
A key left out is empty. ``proxy_ids``, when not empty, wins over
``proxy_id`` and spreads new profiles across those proxies in turn.
"""
name_prefix: Optional[str]
proxy_id: Optional[str]
vpn_id: Optional[str]
proxy_ids: List[str]
group_id: Optional[str]
tags: List[str]
status_id: Optional[str]
note: Optional[str]
extension_group_id: Optional[str]
dns_blocklist: Optional[str]
dns_server: Optional[str]
proxy_bypass_rules: List[str]
start_urls: List[str]
launch_hook: Optional[str]
ephemeral: bool
clear_on_close: bool
wayfern_config: Optional[WayfernConfig]
class ProfileTemplate(TypedDict):
id: str
name: str
settings: TemplateSettings
position: int
updated_at: int
class _ApiBulkCreateResponseRequired(TypedDict):
profiles: List[ApiProfile]
class ApiBulkCreateResponse(_ApiBulkCreateResponseRequired, total=False):
"""``error`` is a ``{"code": ...}`` payload when creation stopped early."""
error: str
class StorageItem(TypedDict):
name: str
value: str
class _OriginStorageRequired(TypedDict):
origin: str
class OriginStorage(_OriginStorageRequired, total=False):
# Playwright's own spelling.
localStorage: List[StorageItem]
class StorageState(TypedDict, total=False):
"""Playwright's ``storageState``: cookies and each origin's Local Storage.
Each cookie is Playwright's own object (``name``, ``value``, ``domain``,
``path``, ``expires``, ``httpOnly``, ``secure``, ``sameSite``), passed
through untouched.
"""
cookies: List[Dict[str, Any]]
origins: List[OriginStorage]
class StorageStateImport(TypedDict):
origins: int
items: int
cookies_imported: int
cookies_replaced: int
cookie_errors: List[str]
class _ApiProxyResponseRequired(TypedDict):
id: str
name: str
proxy_settings: ProxySettings
class ApiProxyResponse(_ApiProxyResponseRequired, total=False):
rotation_url: str
class _RotationResultRequired(TypedDict):
proxy_id: str
elapsed_ms: int
class RotationResult(_RotationResultRequired, total=False):
"""``status`` is the provider's HTTP status, when it answered."""
status: int
error: str
class _ApiVpnRequired(TypedDict):
id: str
name: str
+186
View File
@@ -31,6 +31,15 @@ Case = Tuple[
LOCATOR = {"role": "button", "name": "Sign in"}
STORAGE_STATE = {
"cookies": [{"name": "sid", "value": "1", "domain": ".example.com", "path": "/"}],
"origins": [
{"origin": "https://example.com", "localStorage": [{"name": "k", "value": "v"}]}
],
}
TEMPLATE_SETTINGS = {"name_prefix": "Shop", "tags": ["eu"], "proxy_ids": ["x1", "x2"]}
CASES: List[Case] = [
# -- profiles ----------------------------------------------------------
("list_profiles", (), {}, "GET", "/v1/profiles", None, {}, "/v1/profiles"),
@@ -55,6 +64,26 @@ CASES: List[Case] = [
{},
"/v1/profiles",
),
(
"create_profile",
(),
{
"name": "Started",
"browser": "wayfern",
"status_id": "st1",
"start_urls": ["https://example.com"],
},
"POST",
"/v1/profiles",
{
"name": "Started",
"browser": "wayfern",
"status_id": "st1",
"start_urls": ["https://example.com"],
},
{},
"/v1/profiles",
),
(
"update_profile",
("p1",),
@@ -65,6 +94,16 @@ CASES: List[Case] = [
{},
"/v1/profiles/{id}",
),
(
"update_profile",
("p1",),
{"status_id": "", "start_urls": []},
"PUT",
"/v1/profiles/p1",
{"status_id": "", "start_urls": []},
{},
"/v1/profiles/{id}",
),
("delete_profile", ("p1",), {}, "DELETE", "/v1/profiles/p1", None, {}, "/v1/profiles/{id}"),
(
"run_profile",
@@ -194,6 +233,49 @@ CASES: List[Case] = [
{},
"/v1/profiles/{id}/cookies/import",
),
(
"export_storage_state",
("p1",),
{"include_cookies": True},
"GET",
"/v1/profiles/p1/storage-state",
None,
{"include_cookies": "true"},
"/v1/profiles/{id}/storage-state",
),
(
"export_storage_state",
("p1",),
{},
"GET",
"/v1/profiles/p1/storage-state",
None,
{},
"/v1/profiles/{id}/storage-state",
),
(
"import_storage_state",
("p1", STORAGE_STATE),
{},
"POST",
"/v1/profiles/p1/storage-state",
STORAGE_STATE,
{},
"/v1/profiles/{id}/storage-state",
),
(
"bulk_edit_profiles",
(["p1", "p2"], {"status_id": None, "tags_add": ["eu"], "new_device": True}),
{},
"POST",
"/v1/profiles/bulk-edit",
{
"profile_ids": ["p1", "p2"],
"patch": {"status_id": None, "tags_add": ["eu"], "new_device": True},
},
{},
"/v1/profiles/bulk-edit",
),
# -- agent -------------------------------------------------------------
(
"agent_perceive",
@@ -440,6 +522,72 @@ CASES: List[Case] = [
),
("delete_group", ("g1",), {}, "DELETE", "/v1/groups/g1", None, {}, "/v1/groups/{id}"),
("list_tags", (), {}, "GET", "/v1/tags", None, {}, "/v1/tags"),
# -- statuses ----------------------------------------------------------
("list_statuses", (), {}, "GET", "/v1/statuses", None, {}, "/v1/statuses"),
(
"create_status",
(),
{"name": "Warming", "color": "#ffaa00"},
"POST",
"/v1/statuses",
{"name": "Warming", "color": "#ffaa00"},
{},
"/v1/statuses",
),
(
"update_status",
("st1",),
{"name": "Ready", "color": "#00aa55"},
"PUT",
"/v1/statuses/st1",
{"name": "Ready", "color": "#00aa55"},
{},
"/v1/statuses/{id}",
),
("delete_status", ("st1",), {}, "DELETE", "/v1/statuses/st1", None, {}, "/v1/statuses/{id}"),
# -- templates ---------------------------------------------------------
("list_templates", (), {}, "GET", "/v1/templates", None, {}, "/v1/templates"),
(
"create_template",
(),
{"name": "Shop EU", "settings": TEMPLATE_SETTINGS},
"POST",
"/v1/templates",
{"name": "Shop EU", "settings": TEMPLATE_SETTINGS},
{},
"/v1/templates",
),
(
"create_template",
(),
{"name": "Empty"},
"POST",
"/v1/templates",
{"name": "Empty"},
{},
"/v1/templates",
),
(
"update_template",
("t1",),
{"name": "Shop EU 2", "settings": TEMPLATE_SETTINGS},
"PUT",
"/v1/templates/t1",
{"name": "Shop EU 2", "settings": TEMPLATE_SETTINGS},
{},
"/v1/templates/{id}",
),
("delete_template", ("t1",), {}, "DELETE", "/v1/templates/t1", None, {}, "/v1/templates/{id}"),
(
"create_profiles_from_template",
("t1",),
{"count": 3, "name": "Shop"},
"POST",
"/v1/templates/t1/profiles",
{"count": 3, "name": "Shop"},
{},
"/v1/templates/{id}/profiles",
),
# -- proxies -----------------------------------------------------------
("list_proxies", (), {}, "GET", "/v1/proxies", None, {}, "/v1/proxies"),
("get_proxy", ("x1",), {}, "GET", "/v1/proxies/x1", None, {}, "/v1/proxies/{id}"),
@@ -453,6 +601,24 @@ CASES: List[Case] = [
{},
"/v1/proxies",
),
(
"create_proxy",
(),
{
"name": "Rotating",
"proxy_settings": {"proxy_type": "http", "host": "h", "port": 8080},
"rotation_url": "https://provider.example/rotate?key=1",
},
"POST",
"/v1/proxies",
{
"name": "Rotating",
"proxy_settings": {"proxy_type": "http", "host": "h", "port": 8080},
"rotation_url": "https://provider.example/rotate?key=1",
},
{},
"/v1/proxies",
),
(
"update_proxy",
("x1",),
@@ -464,6 +630,26 @@ CASES: List[Case] = [
"/v1/proxies/{id}",
),
("delete_proxy", ("x1",), {}, "DELETE", "/v1/proxies/x1", None, {}, "/v1/proxies/{id}"),
(
"update_proxy",
("x1",),
{"rotation_url": ""},
"PUT",
"/v1/proxies/x1",
{"rotation_url": ""},
{},
"/v1/proxies/{id}",
),
(
"rotate_proxy",
("x1",),
{},
"POST",
"/v1/proxies/x1/rotate",
None,
{},
"/v1/proxies/{id}/rotate",
),
(
"import_proxies",
(),