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

+1 -1
View File
@@ -270,7 +270,7 @@ On top of that, one parameterised test per method drives it against a fake
server and asserts the exact verb, path, query string and JSON body it sends.
That is what ties the table to reality rather than to a comment.
Of the 71 published operations, 70 are wrapped. The one omission:
Of the 84 published operations, 83 are wrapped. The one omission:
- `GET /v1/remote-sessions/{id}/cdp` is a WebSocket upgrade, not a request an
HTTP client can make, and bundling a websocket implementation would end the
+66 -1
View File
@@ -2,7 +2,7 @@
"source": "src-tauri/src/api_server.rs",
"regenerate_with": "python3 sdk/tools/extract-api-paths.py",
"description": "Every operation the desktop app publishes in its /openapi.json. The SDK test suites assert this list and their own coverage tables match exactly, so an endpoint added to the app fails the SDK tests until it is either wrapped or deliberately listed as omitted.",
"operation_count": 71,
"operation_count": 84,
"operations": [
{
"operation_id": "download_browser_api",
@@ -174,6 +174,11 @@
"method": "POST",
"path": "/v1/profiles/batch/stop"
},
{
"operation_id": "bulk_edit_profiles",
"method": "POST",
"path": "/v1/profiles/bulk-edit"
},
{
"operation_id": "distribute_proxies",
"method": "POST",
@@ -264,6 +269,16 @@
"method": "POST",
"path": "/v1/profiles/{id}/run-remote"
},
{
"operation_id": "export_storage_state",
"method": "GET",
"path": "/v1/profiles/{id}/storage-state"
},
{
"operation_id": "import_storage_state",
"method": "POST",
"path": "/v1/profiles/{id}/storage-state"
},
{
"operation_id": "get_proxies",
"method": "GET",
@@ -294,6 +309,11 @@
"method": "PUT",
"path": "/v1/proxies/{id}"
},
{
"operation_id": "rotate_proxy",
"method": "POST",
"path": "/v1/proxies/{id}/rotate"
},
{
"operation_id": "get_remote_hours",
"method": "GET",
@@ -319,11 +339,56 @@
"method": "GET",
"path": "/v1/remote-sessions/{id}/cdp"
},
{
"operation_id": "get_statuses",
"method": "GET",
"path": "/v1/statuses"
},
{
"operation_id": "create_status",
"method": "POST",
"path": "/v1/statuses"
},
{
"operation_id": "delete_status",
"method": "DELETE",
"path": "/v1/statuses/{id}"
},
{
"operation_id": "update_status",
"method": "PUT",
"path": "/v1/statuses/{id}"
},
{
"operation_id": "get_tags",
"method": "GET",
"path": "/v1/tags"
},
{
"operation_id": "get_templates",
"method": "GET",
"path": "/v1/templates"
},
{
"operation_id": "create_template",
"method": "POST",
"path": "/v1/templates"
},
{
"operation_id": "delete_template",
"method": "DELETE",
"path": "/v1/templates/{id}"
},
{
"operation_id": "update_template",
"method": "PUT",
"path": "/v1/templates/{id}"
},
{
"operation_id": "create_profiles_from_template",
"method": "POST",
"path": "/v1/templates/{id}/profiles"
},
{
"operation_id": "get_vpns",
"method": "GET",
+176 -2
View File
@@ -12,15 +12,18 @@ import { DonutConnectionError, DonutError, errorForStatus } from "./errors.mts";
import type {
AgentClick,
AgentTyping,
ApiBulkCreateResponse,
ApiGroupResponse,
ApiProfileResponse,
ApiProfilesResponse,
ApiProxyResponse,
ApiRemoteSessionsResponse,
ApiStatusResponse,
ApiVpnExportResponse,
ApiVpnResponse,
BatchRunResponse,
BatchStopResponse,
BulkEditResponse,
CookieBotConflictCheck,
CookieBotPresetList,
CookieBotRun,
@@ -46,14 +49,20 @@ import type {
PerceptionPage,
PickedElement,
ProfileImportBatchResult,
ProfilePatch,
ProfileTemplate,
ProxyPair,
ProxySettings,
RemoteHoursQuota,
RemoteSessionState,
RotationResult,
RunProfileResponse,
RunRemoteResponse,
SetCloudSyncResponse,
StopRemoteResponse,
StorageState,
StorageStateImport,
TemplateSettings,
WayfernConfig,
} from "./types.mts";
@@ -286,6 +295,10 @@ export class DonutClient {
* resolve names.
*/
dns_server?: string;
/** A status id from `listStatuses`. */
status_id?: string;
/** At most 20 http(s) URLs; a bare host gets `https://`. */
start_urls?: string[];
}): Promise<ApiProfileResponse> {
return this.#request("POST", "/v1/profiles", body({ ...request }));
}
@@ -315,6 +328,10 @@ export class DonutClient {
clear_on_close?: boolean;
/** Same formats as on create; `""` lets the proxy resolve names again. */
dns_server?: string;
/** `""` clears the status. */
status_id?: string;
/** Replaces the start pages; `[]` removes them. */
start_urls?: string[];
},
): Promise<ApiProfileResponse> {
return this.#request("PUT", `/v1/profiles/${segment(profileId)}`, body({ ...request }));
@@ -437,6 +454,53 @@ export class DonutClient {
});
}
/**
* GET /v1/profiles/{id}/storage-state
*
* Playwright's `storageState`: each origin's Local Storage, plus the cookies
* when `include_cookies` is true. Readable while the browser runs, but writes
* from the last few seconds may not be on disk yet.
*/
exportStorageState(
profileId: string,
options: { include_cookies?: boolean } = {},
): Promise<StorageState> {
return this.#request(
"GET",
`/v1/profiles/${segment(profileId)}/storage-state`,
undefined,
query({ include_cookies: options.include_cookies }),
);
}
/**
* POST /v1/profiles/{id}/storage-state
*
* `state` is a Playwright `storageState`; what `context.storageState()`
* 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.
*/
importStorageState(profileId: string, state: StorageState): Promise<StorageStateImport> {
return this.#request("POST", `/v1/profiles/${segment(profileId)}/storage-state`, {
...state,
});
}
/**
* 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
* `null` in it clears that field. `changed` counts only the profiles that
* really changed.
*/
bulkEditProfiles(profileIds: string[], patch: ProfilePatch): Promise<BulkEditResponse> {
return this.#request("POST", "/v1/profiles/bulk-edit", {
profile_ids: profileIds,
patch,
});
}
/**
* POST /v1/profiles/distribute-proxies
*
@@ -772,6 +836,98 @@ export class DonutClient {
return this.#request("GET", "/v1/tags");
}
// ------------------------------------------------------------------
// Statuses
// ------------------------------------------------------------------
/** GET /v1/statuses */
listStatuses(): Promise<ApiStatusResponse[]> {
return this.#request("GET", "/v1/statuses");
}
/**
* POST /v1/statuses
*
* `name` is 1 to 40 characters, unique regardless of case, and `color` is
* `#rrggbb`.
*/
createStatus(request: { name: string; color: string }): Promise<ApiStatusResponse> {
return this.#request("POST", "/v1/statuses", { ...request });
}
/** PUT /v1/statuses/{id} */
updateStatus(
statusId: string,
request: { name: string; color: string },
): Promise<ApiStatusResponse> {
return this.#request("PUT", `/v1/statuses/${segment(statusId)}`, { ...request });
}
/** DELETE /v1/statuses/{id}. The status is also taken off every profile that had it. */
deleteStatus(statusId: string): Promise<void> {
return this.#request("DELETE", `/v1/statuses/${segment(statusId)}`);
}
// ------------------------------------------------------------------
// Templates
// ------------------------------------------------------------------
/** GET /v1/templates */
listTemplates(): Promise<ProfileTemplate[]> {
return this.#request("GET", "/v1/templates");
}
/**
* 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.
*/
createTemplate(request: {
name: string;
settings?: TemplateSettings;
}): Promise<ProfileTemplate> {
return this.#request("POST", "/v1/templates", body({ ...request }));
}
/**
* 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.
*/
updateTemplate(
templateId: string,
request: { name: string; settings?: TemplateSettings },
): Promise<ProfileTemplate> {
return this.#request("PUT", `/v1/templates/${segment(templateId)}`, body({ ...request }));
}
/** DELETE /v1/templates/{id}. Profiles made from the template keep their settings. */
deleteTemplate(templateId: string): Promise<void> {
return this.#request("DELETE", `/v1/templates/${segment(templateId)}`);
}
/**
* 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.
*/
createProfilesFromTemplate(
templateId: string,
request: { count: number; name?: string },
): Promise<ApiBulkCreateResponse> {
return this.#request(
"POST",
`/v1/templates/${segment(templateId)}/profiles`,
body({ ...request }),
);
}
// ------------------------------------------------------------------
// Proxies
// ------------------------------------------------------------------
@@ -790,14 +946,21 @@ export class DonutClient {
createProxy(request: {
name: string;
proxy_settings: ProxySettings;
/** The provider's http(s) "change IP" link, used by {@link rotateProxy}. */
rotation_url?: string;
}): Promise<ApiProxyResponse> {
return this.#request("POST", "/v1/proxies", { ...request });
return this.#request("POST", "/v1/proxies", body({ ...request }));
}
/** PUT /v1/proxies/{id} */
updateProxy(
proxyId: string,
request: { name?: string; proxy_settings?: ProxySettings },
request: {
name?: string;
proxy_settings?: ProxySettings;
/** `""` clears the "change IP" link. */
rotation_url?: string;
},
): Promise<ApiProxyResponse> {
return this.#request("PUT", `/v1/proxies/${segment(proxyId)}`, body({ ...request }));
}
@@ -807,6 +970,17 @@ export class DonutClient {
return this.#request("DELETE", `/v1/proxies/${segment(proxyId)}`);
}
/**
* 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 throws `ValidationError`, and a
* provider that refuses or does not answer throws `BadGateway`.
*/
rotateProxy(proxyId: string): Promise<RotationResult> {
return this.#request("POST", `/v1/proxies/${segment(proxyId)}/rotate`);
}
/**
* POST /v1/proxies/import
*
+13
View File
@@ -53,6 +53,7 @@ export const OPERATIONS: ReadonlyMap<OperationKey, string> = new Map([
["POST /v1/profiles", "createProfile"],
["POST /v1/profiles/batch/run", "batchRunProfiles"],
["POST /v1/profiles/batch/stop", "batchStopProfiles"],
["POST /v1/profiles/bulk-edit", "bulkEditProfiles"],
["POST /v1/profiles/distribute-proxies", "distributeProxies"],
["POST /v1/profiles/import", "importProfiles"],
["GET /v1/profiles/import/detect", "detectImportProfiles"],
@@ -71,17 +72,29 @@ export const OPERATIONS: ReadonlyMap<OperationKey, string> = new Map([
["POST /v1/profiles/{id}/open-url", "openUrl"],
["POST /v1/profiles/{id}/run", "runProfile"],
["POST /v1/profiles/{id}/run-remote", "runProfileRemote"],
["GET /v1/profiles/{id}/storage-state", "exportStorageState"],
["POST /v1/profiles/{id}/storage-state", "importStorageState"],
["GET /v1/proxies", "listProxies"],
["POST /v1/proxies", "createProxy"],
["POST /v1/proxies/import", "importProxies"],
["DELETE /v1/proxies/{id}", "deleteProxy"],
["GET /v1/proxies/{id}", "getProxy"],
["PUT /v1/proxies/{id}", "updateProxy"],
["POST /v1/proxies/{id}/rotate", "rotateProxy"],
["GET /v1/remote-hours", "getRemoteHours"],
["GET /v1/remote-sessions", "listRemoteSessions"],
["DELETE /v1/remote-sessions/{id}", "stopRemoteSession"],
["GET /v1/remote-sessions/{id}", "getRemoteSession"],
["GET /v1/statuses", "listStatuses"],
["POST /v1/statuses", "createStatus"],
["DELETE /v1/statuses/{id}", "deleteStatus"],
["PUT /v1/statuses/{id}", "updateStatus"],
["GET /v1/tags", "listTags"],
["GET /v1/templates", "listTemplates"],
["POST /v1/templates", "createTemplate"],
["DELETE /v1/templates/{id}", "deleteTemplate"],
["PUT /v1/templates/{id}", "updateTemplate"],
["POST /v1/templates/{id}/profiles", "createProfilesFromTemplate"],
["GET /v1/vpns", "listVpns"],
["POST /v1/vpns", "createVpn"],
["POST /v1/vpns/import", "importVpn"],
+132
View File
@@ -43,6 +43,13 @@ export interface ApiProfile {
* VPN. `null` when the proxy resolves them.
*/
dns_server?: string | null;
/** The profile's status id (see `listStatuses`), or `null`. */
status_id?: string | null;
/**
* Pages a launch from the app opens, in order, instead of the last session.
* A run with a `url` opens only that URL.
*/
start_urls: string[];
extension_group_id?: string | null;
ephemeral: boolean;
temporary: boolean;
@@ -75,10 +82,135 @@ export interface ApiGroupResponse {
profile_count: number;
}
/** A named, colored label for where a profile's account stands. */
export interface ApiStatusResponse {
id: string;
name: string;
/** `#rrggbb`. */
color: string;
}
/**
* One change applied to many profiles by `bulkEditProfiles`.
*
* A property left out keeps that field as it is. For the fields that accept
* `null`, an explicit `null` clears the field, so the patch is sent exactly as
* given. An unknown property is refused with 400.
*/
export interface ProfilePatch {
status_id?: string | null;
tags_add?: string[];
tags_remove?: string[];
note?: string | null;
group_id?: string | null;
extension_group_id?: string | null;
/** `light`, `normal`, `pro`, `pro_plus`, `ultimate`, `custom`, or `null`. */
dns_blocklist?: string | null;
dns_server?: string | null;
proxy_bypass_rules?: string[];
start_urls?: string[];
launch_hook?: string | null;
/** Left off ephemeral and password-protected profiles, where it means nothing. */
clear_on_close?: boolean;
randomize_fingerprint_on_launch?: boolean;
/** `auto`, `tcp_only` or `block`; `null` clears it. */
webrtc_mode?: string | null;
block_images?: boolean;
block_webgl?: boolean;
restore_session?: boolean;
/** Drop the stored device, so the next launch generates a new one. */
new_device?: boolean;
}
export interface BulkEditResponse {
/** How many of the profiles actually changed. */
changed: number;
}
/** What every profile made from a template starts with. Left out is empty. */
export interface TemplateSettings {
/** What the profiles made from the template are named after. */
name_prefix?: string | null;
proxy_id?: string | null;
vpn_id?: string | null;
/** Proxies to spread new profiles across, one each in turn. Wins over `proxy_id`. */
proxy_ids?: string[];
group_id?: string | null;
tags?: string[];
status_id?: string | null;
note?: string | null;
extension_group_id?: string | null;
dns_blocklist?: string | null;
dns_server?: string | null;
proxy_bypass_rules?: string[];
start_urls?: string[];
launch_hook?: string | null;
ephemeral?: boolean;
clear_on_close?: boolean;
/** Fingerprint settings. The device fields are always dropped. */
wayfern_config?: WayfernConfig | null;
}
export interface ProfileTemplate {
id: string;
name: string;
settings: TemplateSettings;
position: number;
updated_at: number;
}
export interface ApiBulkCreateResponse {
/** The profiles made, in order. */
profiles: ApiProfile[];
/** Why creation stopped before `count` (a `{"code": ...}` payload), or `null`. */
error?: string | null;
}
export interface StorageItem {
name: string;
value: string;
}
export interface OriginStorage {
/** `https://example.com`, the way a page's `origin` reads. */
origin: string;
localStorage?: StorageItem[];
}
/** Playwright's `storageState`: cookies and each origin's Local Storage. */
export interface StorageState {
/**
* Playwright's own cookie objects (`name`, `value`, `domain`, `path`,
* `expires`, `httpOnly`, `secure`, `sameSite`), passed through untouched.
*/
cookies?: Record<string, unknown>[];
origins?: OriginStorage[];
}
export interface StorageStateImport {
origins: number;
items: number;
cookies_imported: number;
cookies_replaced: number;
/** Codes of the cookies that could not be imported. */
cookie_errors: string[];
}
export interface ApiProxyResponse {
id: string;
name: string;
proxy_settings: ProxySettings;
/** The provider's "change IP" link, or `null`. `rotateProxy` uses it. */
rotation_url?: string | null;
}
export interface RotationResult {
proxy_id: string;
/** The provider's HTTP status, when it answered. */
status?: number | null;
elapsed_ms: number;
/** A coded error when the IP change was not confirmed. */
error?: string | null;
}
export interface ApiVpnResponse {
+187
View File
@@ -26,6 +26,13 @@ interface Case {
const LOCATOR = { role: "button", name: "Sign in" };
const STORAGE_STATE = {
cookies: [{ name: "sid", value: "1", domain: ".example.com", path: "/" }],
origins: [{ origin: "https://example.com", localStorage: [{ name: "k", value: "v" }] }],
};
const TEMPLATE_SETTINGS = { name_prefix: "Shop", tags: ["eu"], proxy_ids: ["x1", "x2"] };
const CASES: Case[] = [
// -- profiles ------------------------------------------------------------
{
@@ -60,6 +67,26 @@ const CASES: Case[] = [
body: { name: "Bare", browser: "wayfern" },
operation: "POST /v1/profiles",
},
{
method: "createProfile",
args: [
{
name: "Started",
browser: "wayfern",
status_id: "st1",
start_urls: ["https://example.com"],
},
],
verb: "POST",
path: "/v1/profiles",
body: {
name: "Started",
browser: "wayfern",
status_id: "st1",
start_urls: ["https://example.com"],
},
operation: "POST /v1/profiles",
},
{
method: "updateProfile",
args: [
@@ -81,6 +108,14 @@ const CASES: Case[] = [
},
operation: "PUT /v1/profiles/{id}",
},
{
method: "updateProfile",
args: ["p1", { status_id: "", start_urls: [] }],
verb: "PUT",
path: "/v1/profiles/p1",
body: { status_id: "", start_urls: [] },
operation: "PUT /v1/profiles/{id}",
},
{
method: "deleteProfile",
args: ["p1"],
@@ -202,6 +237,42 @@ const CASES: Case[] = [
body: { content: "[]" },
operation: "POST /v1/profiles/{id}/cookies/import",
},
{
method: "exportStorageState",
args: ["p1", { include_cookies: true }],
verb: "GET",
path: "/v1/profiles/p1/storage-state",
body: null,
query: { include_cookies: "true" },
operation: "GET /v1/profiles/{id}/storage-state",
},
{
method: "exportStorageState",
args: ["p1"],
verb: "GET",
path: "/v1/profiles/p1/storage-state",
body: null,
operation: "GET /v1/profiles/{id}/storage-state",
},
{
method: "importStorageState",
args: ["p1", STORAGE_STATE],
verb: "POST",
path: "/v1/profiles/p1/storage-state",
body: STORAGE_STATE,
operation: "POST /v1/profiles/{id}/storage-state",
},
{
method: "bulkEditProfiles",
args: [["p1", "p2"], { status_id: null, tags_add: ["eu"], new_device: true }],
verb: "POST",
path: "/v1/profiles/bulk-edit",
body: {
profile_ids: ["p1", "p2"],
patch: { status_id: null, tags_add: ["eu"], new_device: true },
},
operation: "POST /v1/profiles/bulk-edit",
},
// -- agent ---------------------------------------------------------------
{
method: "agentPerceive",
@@ -458,6 +529,88 @@ const CASES: Case[] = [
body: null,
operation: "GET /v1/tags",
},
// -- statuses ------------------------------------------------------------
{
method: "listStatuses",
args: [],
verb: "GET",
path: "/v1/statuses",
body: null,
operation: "GET /v1/statuses",
},
{
method: "createStatus",
args: [{ name: "Warming", color: "#ffaa00" }],
verb: "POST",
path: "/v1/statuses",
body: { name: "Warming", color: "#ffaa00" },
operation: "POST /v1/statuses",
},
{
method: "updateStatus",
args: ["st1", { name: "Ready", color: "#00aa55" }],
verb: "PUT",
path: "/v1/statuses/st1",
body: { name: "Ready", color: "#00aa55" },
operation: "PUT /v1/statuses/{id}",
},
{
method: "deleteStatus",
args: ["st1"],
verb: "DELETE",
path: "/v1/statuses/st1",
body: null,
operation: "DELETE /v1/statuses/{id}",
},
// -- templates -----------------------------------------------------------
{
method: "listTemplates",
args: [],
verb: "GET",
path: "/v1/templates",
body: null,
operation: "GET /v1/templates",
},
{
method: "createTemplate",
args: [{ name: "Shop EU", settings: TEMPLATE_SETTINGS }],
verb: "POST",
path: "/v1/templates",
body: { name: "Shop EU", settings: TEMPLATE_SETTINGS },
operation: "POST /v1/templates",
},
{
method: "createTemplate",
args: [{ name: "Empty", settings: undefined }],
verb: "POST",
path: "/v1/templates",
body: { name: "Empty" },
operation: "POST /v1/templates",
},
{
method: "updateTemplate",
args: ["t1", { name: "Shop EU 2", settings: TEMPLATE_SETTINGS }],
verb: "PUT",
path: "/v1/templates/t1",
body: { name: "Shop EU 2", settings: TEMPLATE_SETTINGS },
operation: "PUT /v1/templates/{id}",
},
{
method: "deleteTemplate",
args: ["t1"],
verb: "DELETE",
path: "/v1/templates/t1",
body: null,
operation: "DELETE /v1/templates/{id}",
},
{
method: "createProfilesFromTemplate",
args: ["t1", { count: 3, name: "Shop" }],
verb: "POST",
path: "/v1/templates/t1/profiles",
body: { count: 3, name: "Shop" },
operation: "POST /v1/templates/{id}/profiles",
},
// -- proxies -------------------------------------------------------------
{
method: "listProxies",
@@ -483,6 +636,24 @@ const CASES: Case[] = [
body: { name: "EU", proxy_settings: { proxy_type: "http", host: "h", port: 8080 } },
operation: "POST /v1/proxies",
},
{
method: "createProxy",
args: [
{
name: "Rotating",
proxy_settings: { proxy_type: "http", host: "h", port: 8080 },
rotation_url: "https://provider.example/rotate?key=1",
},
],
verb: "POST",
path: "/v1/proxies",
body: {
name: "Rotating",
proxy_settings: { proxy_type: "http", host: "h", port: 8080 },
rotation_url: "https://provider.example/rotate?key=1",
},
operation: "POST /v1/proxies",
},
{
method: "updateProxy",
args: ["x1", { name: "EU 2" }],
@@ -499,6 +670,22 @@ const CASES: Case[] = [
body: null,
operation: "DELETE /v1/proxies/{id}",
},
{
method: "updateProxy",
args: ["x1", { rotation_url: "" }],
verb: "PUT",
path: "/v1/proxies/x1",
body: { rotation_url: "" },
operation: "PUT /v1/proxies/{id}",
},
{
method: "rotateProxy",
args: ["x1"],
verb: "POST",
path: "/v1/proxies/x1/rotate",
body: null,
operation: "POST /v1/proxies/{id}/rotate",
},
{
method: "importProxies",
args: [{ format: "txt", content: "h:1:u:p", name_prefix: "EU" }],
+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",
(),