oculr 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- oculr-0.1.0/.gitignore +5 -0
- oculr-0.1.0/PKG-INFO +68 -0
- oculr-0.1.0/README.md +54 -0
- oculr-0.1.0/pyproject.toml +24 -0
- oculr-0.1.0/src/oculr/__init__.py +145 -0
- oculr-0.1.0/tests/test_client.py +76 -0
oculr-0.1.0/.gitignore
ADDED
oculr-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: oculr
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Client for the Oculr local Automation API: launch real browser profiles, pick a per-launch stealth level, and connect over CDP.
|
|
5
|
+
Project-URL: Homepage, https://oculr.ai
|
|
6
|
+
Project-URL: Documentation, https://oculr.ai/developers
|
|
7
|
+
Project-URL: Repository, https://github.com/oculr-ai/oculr-sdk
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: ai-agent,ai-agents,anti-detect,antidetect,antidetect-browser,automation,browser,browser-automation,cdp,fingerprint,mcp,model-context-protocol,oculr,playwright,puppeteer,stealth
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Provides-Extra: playwright
|
|
12
|
+
Requires-Dist: playwright; extra == 'playwright'
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# oculr (Python)
|
|
16
|
+
|
|
17
|
+
Thin Python client for the **Oculr local Automation API**. Launch real browser
|
|
18
|
+
profiles on your own machine, choose a per-launch stealth level, and connect over
|
|
19
|
+
CDP with Playwright or Selenium.
|
|
20
|
+
|
|
21
|
+
Zero runtime dependencies (stdlib only). Playwright is optional, for the
|
|
22
|
+
`connect_playwright()` helper.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install oculr
|
|
28
|
+
# optional, for connect_playwright():
|
|
29
|
+
pip install playwright
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
import os
|
|
36
|
+
from oculr import OculrClient
|
|
37
|
+
from playwright.sync_api import sync_playwright
|
|
38
|
+
|
|
39
|
+
# The desktop app shows the port and token under Settings.
|
|
40
|
+
oculr = OculrClient("http://127.0.0.1:8378", token=os.environ["OCULR_TOKEN"])
|
|
41
|
+
|
|
42
|
+
# Low-level: launch and get a ready CDP endpoint + driver recipes.
|
|
43
|
+
res = oculr.launch("de-store-01", stealth="balanced", headless=False)
|
|
44
|
+
print(res["ws_endpoint"])
|
|
45
|
+
|
|
46
|
+
# High-level: launch and connect a Playwright browser in one call.
|
|
47
|
+
with sync_playwright() as p:
|
|
48
|
+
browser, _ = oculr.connect_playwright(p, "de-store-01", stealth="max")
|
|
49
|
+
page = browser.contexts[0].pages[0]
|
|
50
|
+
page.goto("https://example.com")
|
|
51
|
+
oculr.stop("de-store-01")
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Stealth dial
|
|
55
|
+
|
|
56
|
+
`stealth` is chosen per launch: `"off"` (clean Chromium, no fingerprint
|
|
57
|
+
injection), `"balanced"` (native fingerprints, the default) or `"max"` (strongest
|
|
58
|
+
noise). Overrides apply to that launch only and are never written back to the
|
|
59
|
+
profile.
|
|
60
|
+
|
|
61
|
+
## API
|
|
62
|
+
|
|
63
|
+
`list_profiles()`, `create_profile(**body)`, `get_profile(id)`,
|
|
64
|
+
`update_profile(id, **body)`, `delete_profile(id)`, `generate_profile(**body)`,
|
|
65
|
+
`launch(id, **options)`, `stop(id)`, `status()`,
|
|
66
|
+
`connect_playwright(playwright, id, **options)`.
|
|
67
|
+
|
|
68
|
+
Errors raise `OculrError` with `.status` and `.body`.
|
oculr-0.1.0/README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# oculr (Python)
|
|
2
|
+
|
|
3
|
+
Thin Python client for the **Oculr local Automation API**. Launch real browser
|
|
4
|
+
profiles on your own machine, choose a per-launch stealth level, and connect over
|
|
5
|
+
CDP with Playwright or Selenium.
|
|
6
|
+
|
|
7
|
+
Zero runtime dependencies (stdlib only). Playwright is optional, for the
|
|
8
|
+
`connect_playwright()` helper.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install oculr
|
|
14
|
+
# optional, for connect_playwright():
|
|
15
|
+
pip install playwright
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
import os
|
|
22
|
+
from oculr import OculrClient
|
|
23
|
+
from playwright.sync_api import sync_playwright
|
|
24
|
+
|
|
25
|
+
# The desktop app shows the port and token under Settings.
|
|
26
|
+
oculr = OculrClient("http://127.0.0.1:8378", token=os.environ["OCULR_TOKEN"])
|
|
27
|
+
|
|
28
|
+
# Low-level: launch and get a ready CDP endpoint + driver recipes.
|
|
29
|
+
res = oculr.launch("de-store-01", stealth="balanced", headless=False)
|
|
30
|
+
print(res["ws_endpoint"])
|
|
31
|
+
|
|
32
|
+
# High-level: launch and connect a Playwright browser in one call.
|
|
33
|
+
with sync_playwright() as p:
|
|
34
|
+
browser, _ = oculr.connect_playwright(p, "de-store-01", stealth="max")
|
|
35
|
+
page = browser.contexts[0].pages[0]
|
|
36
|
+
page.goto("https://example.com")
|
|
37
|
+
oculr.stop("de-store-01")
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Stealth dial
|
|
41
|
+
|
|
42
|
+
`stealth` is chosen per launch: `"off"` (clean Chromium, no fingerprint
|
|
43
|
+
injection), `"balanced"` (native fingerprints, the default) or `"max"` (strongest
|
|
44
|
+
noise). Overrides apply to that launch only and are never written back to the
|
|
45
|
+
profile.
|
|
46
|
+
|
|
47
|
+
## API
|
|
48
|
+
|
|
49
|
+
`list_profiles()`, `create_profile(**body)`, `get_profile(id)`,
|
|
50
|
+
`update_profile(id, **body)`, `delete_profile(id)`, `generate_profile(**body)`,
|
|
51
|
+
`launch(id, **options)`, `stop(id)`, `status()`,
|
|
52
|
+
`connect_playwright(playwright, id, **options)`.
|
|
53
|
+
|
|
54
|
+
Errors raise `OculrError` with `.status` and `.body`.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "oculr"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Client for the Oculr local Automation API: launch real browser profiles, pick a per-launch stealth level, and connect over CDP."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
keywords = ["oculr", "browser", "automation", "browser-automation", "antidetect", "anti-detect", "antidetect-browser", "fingerprint", "stealth", "playwright", "puppeteer", "cdp", "mcp", "model-context-protocol", "ai-agent", "ai-agents"]
|
|
13
|
+
dependencies = []
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Homepage = "https://oculr.ai"
|
|
17
|
+
Documentation = "https://oculr.ai/developers"
|
|
18
|
+
Repository = "https://github.com/oculr-ai/oculr-sdk"
|
|
19
|
+
|
|
20
|
+
[project.optional-dependencies]
|
|
21
|
+
playwright = ["playwright"]
|
|
22
|
+
|
|
23
|
+
[tool.hatch.build.targets.wheel]
|
|
24
|
+
packages = ["src/oculr"]
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""oculr — a thin client for the Oculr local Automation API.
|
|
2
|
+
|
|
3
|
+
Zero runtime dependencies (stdlib ``urllib``). Point it at your local agent (the
|
|
4
|
+
desktop app shows the port and token under Settings) and you get a ready CDP
|
|
5
|
+
endpoint back, which any driver can attach to.
|
|
6
|
+
|
|
7
|
+
from oculr import OculrClient
|
|
8
|
+
from playwright.sync_api import sync_playwright
|
|
9
|
+
|
|
10
|
+
oculr = OculrClient.from_agent_file() # auto-discovers base_url + token
|
|
11
|
+
res = oculr.launch("de-store-01", stealth="balanced", headless=False)
|
|
12
|
+
|
|
13
|
+
with sync_playwright() as p:
|
|
14
|
+
browser, _ = oculr.connect_playwright(p, "de-store-01", stealth="max")
|
|
15
|
+
page = browser.contexts[0].pages[0]
|
|
16
|
+
page.goto("https://example.com")
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import urllib.error
|
|
24
|
+
import urllib.request
|
|
25
|
+
from typing import Any, Optional
|
|
26
|
+
|
|
27
|
+
__all__ = ["OculrClient", "OculrError"]
|
|
28
|
+
__version__ = "0.1.0"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class OculrError(Exception):
|
|
32
|
+
"""Raised on a non-2xx response from the Oculr API."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, status: int, body: Any, message: str) -> None:
|
|
35
|
+
super().__init__(message)
|
|
36
|
+
self.status = status
|
|
37
|
+
self.body = body
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class OculrClient:
|
|
41
|
+
"""Client for the Oculr local Automation API.
|
|
42
|
+
|
|
43
|
+
:param base_url: Base URL of the local agent, e.g. ``http://127.0.0.1:8765``.
|
|
44
|
+
:param token: Bearer token from the desktop app Settings (omit if auth is off).
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, base_url: str, token: Optional[str] = None, timeout: float = 30.0) -> None:
|
|
48
|
+
if not base_url:
|
|
49
|
+
raise ValueError("OculrClient: base_url is required")
|
|
50
|
+
self.base_url = base_url.rstrip("/")
|
|
51
|
+
self.token = token
|
|
52
|
+
self.timeout = timeout
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def from_agent_file(cls, path: Optional[str] = None, timeout: float = 30.0) -> "OculrClient":
|
|
56
|
+
"""Build a client from the running agent's 0600 runtime descriptor.
|
|
57
|
+
|
|
58
|
+
The desktop app (and ``oculr-agent serve``) writes an ``agent.json`` with
|
|
59
|
+
the agent's ``base_url`` and — when token publishing is on — its auth
|
|
60
|
+
``token``, so you don't have to copy them from Settings. Searches
|
|
61
|
+
``$OCULR_RUNTIME_FILE``, then ``$OCULR_DATA_DIR/agent.json``, then
|
|
62
|
+
``~/.oculr/data/agent.json``. Raises :class:`OculrError` if none is found.
|
|
63
|
+
"""
|
|
64
|
+
candidates = []
|
|
65
|
+
if path:
|
|
66
|
+
candidates.append(path)
|
|
67
|
+
if os.environ.get("OCULR_RUNTIME_FILE"):
|
|
68
|
+
candidates.append(os.environ["OCULR_RUNTIME_FILE"])
|
|
69
|
+
if os.environ.get("OCULR_DATA_DIR"):
|
|
70
|
+
candidates.append(os.path.join(os.environ["OCULR_DATA_DIR"], "agent.json"))
|
|
71
|
+
candidates.append(os.path.expanduser("~/.oculr/data/agent.json"))
|
|
72
|
+
for candidate in candidates:
|
|
73
|
+
try:
|
|
74
|
+
with open(candidate, encoding="utf-8") as fh:
|
|
75
|
+
desc = json.load(fh)
|
|
76
|
+
except (OSError, ValueError):
|
|
77
|
+
continue
|
|
78
|
+
base = desc.get("base_url")
|
|
79
|
+
if base:
|
|
80
|
+
return cls(base, token=desc.get("token"), timeout=timeout)
|
|
81
|
+
raise OculrError(0, None, "no Oculr agent descriptor found (is the app running?)")
|
|
82
|
+
|
|
83
|
+
def _request(self, method: str, path: str, body: Any = None) -> Any:
|
|
84
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
85
|
+
req = urllib.request.Request(self.base_url + path, data=data, method=method)
|
|
86
|
+
req.add_header("Content-Type", "application/json")
|
|
87
|
+
if self.token:
|
|
88
|
+
req.add_header("Authorization", f"Bearer {self.token}")
|
|
89
|
+
try:
|
|
90
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
91
|
+
text = resp.read().decode()
|
|
92
|
+
return json.loads(text) if text else None
|
|
93
|
+
except urllib.error.HTTPError as e: # pragma: no cover - exercised via mock
|
|
94
|
+
text = e.read().decode()
|
|
95
|
+
parsed = json.loads(text) if text else None
|
|
96
|
+
raise OculrError(e.code, parsed, f"Oculr {method} {path} failed: {e.code}") from None
|
|
97
|
+
|
|
98
|
+
# ── Profiles ────────────────────────────────────────────────────────────
|
|
99
|
+
def list_profiles(self) -> Any:
|
|
100
|
+
return self._request("GET", "/api/profiles")
|
|
101
|
+
|
|
102
|
+
def create_profile(self, **body: Any) -> Any:
|
|
103
|
+
return self._request("POST", "/api/profiles", body)
|
|
104
|
+
|
|
105
|
+
def get_profile(self, profile_id: str) -> Any:
|
|
106
|
+
return self._request("GET", f"/api/profiles/{profile_id}")
|
|
107
|
+
|
|
108
|
+
def update_profile(self, profile_id: str, **body: Any) -> Any:
|
|
109
|
+
return self._request("PUT", f"/api/profiles/{profile_id}", body)
|
|
110
|
+
|
|
111
|
+
def delete_profile(self, profile_id: str) -> Any:
|
|
112
|
+
return self._request("DELETE", f"/api/profiles/{profile_id}")
|
|
113
|
+
|
|
114
|
+
def generate_profile(self, **body: Any) -> Any:
|
|
115
|
+
"""Generate a coherent random fingerprint."""
|
|
116
|
+
return self._request("POST", "/api/profiles/generate", body or {})
|
|
117
|
+
|
|
118
|
+
# ── Lifecycle ───────────────────────────────────────────────────────────
|
|
119
|
+
def launch(self, profile_id: str, **options: Any) -> Any:
|
|
120
|
+
"""Launch a profile. With no options the stored profile defaults are used.
|
|
121
|
+
|
|
122
|
+
Options (all per-launch, never written back): ``stealth`` ("off" |
|
|
123
|
+
"balanced" | "max"), ``headless``, ``proxy``, ``args``, ``window``,
|
|
124
|
+
``automation``, ``ephemeral``.
|
|
125
|
+
"""
|
|
126
|
+
return self._request("POST", f"/api/profiles/{profile_id}/launch", options or None)
|
|
127
|
+
|
|
128
|
+
def stop(self, profile_id: str) -> Any:
|
|
129
|
+
return self._request("POST", f"/api/profiles/{profile_id}/stop")
|
|
130
|
+
|
|
131
|
+
def status(self) -> Any:
|
|
132
|
+
return self._request("GET", "/api/status")
|
|
133
|
+
|
|
134
|
+
# ── High-level ──────────────────────────────────────────────────────────
|
|
135
|
+
def connect_playwright(self, playwright: Any, profile_id: str, **options: Any):
|
|
136
|
+
"""Launch a profile and connect a Playwright browser over CDP.
|
|
137
|
+
|
|
138
|
+
Pass the value from ``sync_playwright()``. Returns ``(browser, response)``.
|
|
139
|
+
"""
|
|
140
|
+
res = self.launch(profile_id, **options)
|
|
141
|
+
ws = (res or {}).get("ws_endpoint")
|
|
142
|
+
if not ws:
|
|
143
|
+
raise OculrError(0, res, "launch did not return a ws_endpoint")
|
|
144
|
+
browser = playwright.chromium.connect_over_cdp(ws)
|
|
145
|
+
return browser, res
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import urllib.error
|
|
4
|
+
import urllib.request
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from oculr import OculrClient, OculrError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class _FakeResp:
|
|
12
|
+
def __init__(self, text: str):
|
|
13
|
+
self._t = text
|
|
14
|
+
|
|
15
|
+
def read(self):
|
|
16
|
+
return self._t.encode()
|
|
17
|
+
|
|
18
|
+
def __enter__(self):
|
|
19
|
+
return self
|
|
20
|
+
|
|
21
|
+
def __exit__(self, *a):
|
|
22
|
+
return False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_launch_posts_options_and_parses(monkeypatch):
|
|
26
|
+
captured = {}
|
|
27
|
+
|
|
28
|
+
def fake_urlopen(req, timeout=None):
|
|
29
|
+
captured["req"] = req
|
|
30
|
+
return _FakeResp(json.dumps({
|
|
31
|
+
"profile_id": "p1",
|
|
32
|
+
"status": "running",
|
|
33
|
+
"ws_endpoint": "ws://127.0.0.1:8378/api/profiles/p1/cdp",
|
|
34
|
+
}))
|
|
35
|
+
|
|
36
|
+
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
|
|
37
|
+
client = OculrClient("http://127.0.0.1:8378/", token="tok")
|
|
38
|
+
res = client.launch("p1", stealth="max", headless=True)
|
|
39
|
+
|
|
40
|
+
assert res["ws_endpoint"].endswith("/cdp")
|
|
41
|
+
req = captured["req"]
|
|
42
|
+
assert req.full_url == "http://127.0.0.1:8378/api/profiles/p1/launch"
|
|
43
|
+
assert req.get_method() == "POST"
|
|
44
|
+
assert req.headers["Authorization"] == "Bearer tok"
|
|
45
|
+
assert json.loads(req.data.decode()) == {"stealth": "max", "headless": True}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_launch_without_options_sends_no_body(monkeypatch):
|
|
49
|
+
captured = {}
|
|
50
|
+
|
|
51
|
+
def fake_urlopen(req, timeout=None):
|
|
52
|
+
captured["req"] = req
|
|
53
|
+
return _FakeResp(json.dumps({"profile_id": "p1", "status": "running"}))
|
|
54
|
+
|
|
55
|
+
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
|
|
56
|
+
client = OculrClient("http://127.0.0.1:8378")
|
|
57
|
+
client.launch("p1")
|
|
58
|
+
assert captured["req"].data is None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_http_error_raises_oculr_error(monkeypatch):
|
|
62
|
+
def fake_urlopen(req, timeout=None):
|
|
63
|
+
raise urllib.error.HTTPError(req.full_url, 404, "Not Found", {}, _io_bytes(b'{"detail":"nope"}'))
|
|
64
|
+
|
|
65
|
+
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
|
|
66
|
+
client = OculrClient("http://127.0.0.1:8378")
|
|
67
|
+
with pytest.raises(OculrError) as ei:
|
|
68
|
+
client.get_profile("nope")
|
|
69
|
+
assert ei.value.status == 404
|
|
70
|
+
assert ei.value.body == {"detail": "nope"}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _io_bytes(b: bytes):
|
|
74
|
+
import io
|
|
75
|
+
|
|
76
|
+
return io.BytesIO(b)
|