phantomwright 0.1.5__tar.gz → 0.2.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.
Files changed (63) hide show
  1. {phantomwright-0.1.5 → phantomwright-0.2.0}/PKG-INFO +7 -3
  2. phantomwright-0.2.0/phantomwright/_repo_version.py +1 -0
  3. phantomwright-0.2.0/phantomwright/cli/__init__.py +5 -0
  4. phantomwright-0.2.0/phantomwright/cli/client.py +201 -0
  5. phantomwright-0.2.0/phantomwright/cli/commands/__init__.py +1 -0
  6. phantomwright-0.2.0/phantomwright/cli/commands/extraction.py +137 -0
  7. phantomwright-0.2.0/phantomwright/cli/commands/info.py +169 -0
  8. phantomwright-0.2.0/phantomwright/cli/commands/interaction.py +46 -0
  9. phantomwright-0.2.0/phantomwright/cli/commands/navigation.py +70 -0
  10. phantomwright-0.2.0/phantomwright/cli/main.py +57 -0
  11. phantomwright-0.2.0/phantomwright/cli/output.py +51 -0
  12. phantomwright-0.2.0/phantomwright/cli/server.py +889 -0
  13. phantomwright-0.2.0/phantomwright/cli/session.py +63 -0
  14. {phantomwright-0.1.5 → phantomwright-0.2.0}/pyproject.toml +10 -3
  15. phantomwright-0.1.5/phantomwright/_repo_version.py +0 -1
  16. phantomwright-0.1.5/phantomwright/captcha/test.py +0 -39
  17. {phantomwright-0.1.5 → phantomwright-0.2.0}/.gitignore +0 -0
  18. {phantomwright-0.1.5 → phantomwright-0.2.0}/LICENSE +0 -0
  19. {phantomwright-0.1.5 → phantomwright-0.2.0}/README.md +0 -0
  20. {phantomwright-0.1.5 → phantomwright-0.2.0}/phantomwright/__init__.py +0 -0
  21. {phantomwright-0.1.5 → phantomwright-0.2.0}/phantomwright/_impl/__init__.py +0 -0
  22. {phantomwright-0.1.5 → phantomwright-0.2.0}/phantomwright/_impl/_core_debug_patch.py +0 -0
  23. {phantomwright-0.1.5 → phantomwright-0.2.0}/phantomwright/_impl/_evaluate_patch.py +0 -0
  24. {phantomwright-0.1.5 → phantomwright-0.2.0}/phantomwright/_impl/_inconsistency_patch.py +0 -0
  25. {phantomwright-0.1.5 → phantomwright-0.2.0}/phantomwright/async_api/__init__.py +0 -0
  26. {phantomwright-0.1.5 → phantomwright-0.2.0}/phantomwright/captcha/__init__.py +0 -0
  27. {phantomwright-0.1.5 → phantomwright-0.2.0}/phantomwright/captcha/cloudfare/scripts/observer.js +0 -0
  28. {phantomwright-0.1.5 → phantomwright-0.2.0}/phantomwright/captcha/cloudfare/scripts/shadow_root.js +0 -0
  29. {phantomwright-0.1.5 → phantomwright-0.2.0}/phantomwright/captcha/cloudfare/solver.py +0 -0
  30. {phantomwright-0.1.5 → phantomwright-0.2.0}/phantomwright/captcha/cloudfare/utils/build_js.py +0 -0
  31. {phantomwright-0.1.5 → phantomwright-0.2.0}/phantomwright/captcha/cloudfare/utils/consts.py +0 -0
  32. {phantomwright-0.1.5 → phantomwright-0.2.0}/phantomwright/captcha/cloudfare/utils/detection.py +0 -0
  33. {phantomwright-0.1.5 → phantomwright-0.2.0}/phantomwright/captcha/cloudfare/utils/dom_helpers.py +0 -0
  34. {phantomwright-0.1.5 → phantomwright-0.2.0}/phantomwright/captcha/cloudfare/utils/shadow_root.py +0 -0
  35. {phantomwright-0.1.5 → phantomwright-0.2.0}/phantomwright/py.typed +0 -0
  36. {phantomwright-0.1.5 → phantomwright-0.2.0}/phantomwright/stealth/__init__.py +0 -0
  37. {phantomwright-0.1.5 → phantomwright-0.2.0}/phantomwright/stealth/js/evasions/chrome.app.js +0 -0
  38. {phantomwright-0.1.5 → phantomwright-0.2.0}/phantomwright/stealth/js/evasions/chrome.csi.js +0 -0
  39. {phantomwright-0.1.5 → phantomwright-0.2.0}/phantomwright/stealth/js/evasions/chrome.hairline.js +0 -0
  40. {phantomwright-0.1.5 → phantomwright-0.2.0}/phantomwright/stealth/js/evasions/chrome.load.times.js +0 -0
  41. {phantomwright-0.1.5 → phantomwright-0.2.0}/phantomwright/stealth/js/evasions/chrome.runtime.js +0 -0
  42. {phantomwright-0.1.5 → phantomwright-0.2.0}/phantomwright/stealth/js/evasions/error.prototype.js +0 -0
  43. {phantomwright-0.1.5 → phantomwright-0.2.0}/phantomwright/stealth/js/evasions/iframe.contentWindow.js +0 -0
  44. {phantomwright-0.1.5 → phantomwright-0.2.0}/phantomwright/stealth/js/evasions/media.codecs.js +0 -0
  45. {phantomwright-0.1.5 → phantomwright-0.2.0}/phantomwright/stealth/js/evasions/navigator.hardwareConcurrency.js +0 -0
  46. {phantomwright-0.1.5 → phantomwright-0.2.0}/phantomwright/stealth/js/evasions/navigator.languages.js +0 -0
  47. {phantomwright-0.1.5 → phantomwright-0.2.0}/phantomwright/stealth/js/evasions/navigator.permissions.js +0 -0
  48. {phantomwright-0.1.5 → phantomwright-0.2.0}/phantomwright/stealth/js/evasions/navigator.platform.js +0 -0
  49. {phantomwright-0.1.5 → phantomwright-0.2.0}/phantomwright/stealth/js/evasions/navigator.plugins.js +0 -0
  50. {phantomwright-0.1.5 → phantomwright-0.2.0}/phantomwright/stealth/js/evasions/navigator.userAgent.js +0 -0
  51. {phantomwright-0.1.5 → phantomwright-0.2.0}/phantomwright/stealth/js/evasions/navigator.vendor.js +0 -0
  52. {phantomwright-0.1.5 → phantomwright-0.2.0}/phantomwright/stealth/js/evasions/webgl.vendor.js +0 -0
  53. {phantomwright-0.1.5 → phantomwright-0.2.0}/phantomwright/stealth/js/generate.magic.arrays.js +0 -0
  54. {phantomwright-0.1.5 → phantomwright-0.2.0}/phantomwright/stealth/js/utils.js +0 -0
  55. {phantomwright-0.1.5 → phantomwright-0.2.0}/phantomwright/stealth/stealth.py +0 -0
  56. {phantomwright-0.1.5 → phantomwright-0.2.0}/phantomwright/sync_api/__init__.py +0 -0
  57. {phantomwright-0.1.5 → phantomwright-0.2.0}/phantomwright/user_simulator/README.md +0 -0
  58. {phantomwright-0.1.5 → phantomwright-0.2.0}/phantomwright/user_simulator/__init__.py +0 -0
  59. {phantomwright-0.1.5 → phantomwright-0.2.0}/phantomwright/user_simulator/async_basic.py +0 -0
  60. {phantomwright-0.1.5 → phantomwright-0.2.0}/phantomwright/user_simulator/async_simulator.py +0 -0
  61. {phantomwright-0.1.5 → phantomwright-0.2.0}/phantomwright/user_simulator/script.py +0 -0
  62. {phantomwright-0.1.5 → phantomwright-0.2.0}/phantomwright/user_simulator/sync_basic.py +0 -0
  63. {phantomwright-0.1.5 → phantomwright-0.2.0}/phantomwright/user_simulator/sync_simulator.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: phantomwright
3
- Version: 0.1.5
3
+ Version: 0.2.0
4
4
  Summary: Bridging playwright-core patch + extending playwright API for stealth injection & user simulation
5
5
  Project-URL: homepage, https://github.com/ai-microsoft/phantom-wright
6
6
  Project-URL: changelog, https://github.com/ai-microsoft/phantom-wright/blob/main/CHANGELOG.md
@@ -9,8 +9,12 @@ License-Expression: MIT
9
9
  License-File: LICENSE
10
10
  Classifier: Operating System :: OS Independent
11
11
  Classifier: Programming Language :: Python :: 3
12
- Requires-Python: >=3.9
13
- Requires-Dist: phantomwright-driver==1.57.7
12
+ Requires-Python: >=3.10
13
+ Requires-Dist: aiohttp>=3.9.0
14
+ Requires-Dist: click>=8.0.0
15
+ Requires-Dist: httpx>=0.27.0
16
+ Requires-Dist: markitdown>=0.1.0
17
+ Requires-Dist: phantomwright-driver==1.58.3
14
18
  Provides-Extra: black
15
19
  Requires-Dist: black>=25.9.0; extra == 'black'
16
20
  Provides-Extra: dev
@@ -0,0 +1 @@
1
+ version = 'v0.2.0'
@@ -0,0 +1,5 @@
1
+ """PhantomWright CLI - Browser automation from the command line."""
2
+
3
+ from .main import cli
4
+
5
+ __all__ = ["cli"]
@@ -0,0 +1,201 @@
1
+ """Client for communicating with the browser session server."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any, Optional
6
+
7
+ import httpx
8
+
9
+ from .server import SESSION_FILE, DEFAULT_PORT
10
+
11
+
12
+ class SessionClient:
13
+ """Client for communicating with the browser session server."""
14
+
15
+ def __init__(self, port: Optional[int] = None):
16
+ self._port = port
17
+ self._base_url: Optional[str] = None
18
+
19
+ def _get_base_url(self) -> Optional[str]:
20
+ """Get the base URL for the server."""
21
+ if self._base_url:
22
+ return self._base_url
23
+
24
+ # Try to load port from session file
25
+ port = self._port
26
+ if port is None:
27
+ if SESSION_FILE.exists():
28
+ try:
29
+ with open(SESSION_FILE, "r") as f:
30
+ session_info = json.load(f)
31
+ port = session_info.get("port", DEFAULT_PORT)
32
+ except (json.JSONDecodeError, IOError):
33
+ port = DEFAULT_PORT
34
+ else:
35
+ port = DEFAULT_PORT
36
+
37
+ self._base_url = f"http://127.0.0.1:{port}"
38
+ return self._base_url
39
+
40
+ def is_server_running(self) -> bool:
41
+ """Check if the server is running."""
42
+ base_url = self._get_base_url()
43
+ if not base_url:
44
+ return False
45
+
46
+ try:
47
+ with httpx.Client(timeout=2.0) as client:
48
+ response = client.get(f"{base_url}/status")
49
+ return response.status_code == 200
50
+ except httpx.RequestError:
51
+ return False
52
+
53
+ def _request(
54
+ self,
55
+ method: str,
56
+ endpoint: str,
57
+ data: Optional[dict] = None,
58
+ timeout: float = 60.0,
59
+ ) -> dict:
60
+ """Make a request to the server."""
61
+ base_url = self._get_base_url()
62
+ if not base_url:
63
+ return {"error": "NoSession", "message": "No active browser session. Run 'phantomwright start' first."}
64
+
65
+ try:
66
+ with httpx.Client(timeout=timeout) as client:
67
+ if method == "GET":
68
+ response = client.get(f"{base_url}{endpoint}")
69
+ else:
70
+ response = client.post(f"{base_url}{endpoint}", json=data or {})
71
+
72
+ return response.json()
73
+ except httpx.ConnectError:
74
+ return {"error": "NoSession", "message": "No active browser session. Run 'phantomwright start' first."}
75
+ except httpx.RequestError as e:
76
+ return {"error": "ConnectionError", "message": str(e)}
77
+ except json.JSONDecodeError:
78
+ return {"error": "InvalidResponse", "message": "Invalid response from server"}
79
+
80
+ def get_status(self) -> dict:
81
+ """Get server status."""
82
+ return self._request("GET", "/status")
83
+
84
+ def stop(self) -> dict:
85
+ """Stop the server."""
86
+ return self._request("POST", "/stop")
87
+
88
+ def navigate(
89
+ self,
90
+ url: str,
91
+ simulate: bool = True,
92
+ cool_down: bool = True,
93
+ wait_for: Optional[str] = None,
94
+ timeout: int = 30000,
95
+ ) -> dict:
96
+ """Navigate to a URL."""
97
+ return self._request("POST", "/navigate", {
98
+ "url": url,
99
+ "simulate": simulate,
100
+ "cool_down": cool_down,
101
+ "wait_for": wait_for,
102
+ "timeout": timeout,
103
+ }, timeout=timeout / 1000 + 30)
104
+
105
+ def scroll(self, duration: int = 2000) -> dict:
106
+ """Scroll the page."""
107
+ return self._request("POST", "/scroll", {"duration": duration}, timeout=duration / 1000 + 10)
108
+
109
+ def browse(self, duration: int = 2000) -> dict:
110
+ """Simulate browsing."""
111
+ return self._request("POST", "/browse", {"duration": duration}, timeout=duration / 1000 + 10)
112
+
113
+ def click(self, selector: str, simulate: bool = True) -> dict:
114
+ """Click an element."""
115
+ return self._request("POST", "/click", {
116
+ "selector": selector,
117
+ "simulate": simulate,
118
+ })
119
+
120
+ def type_text(self, selector: str, text: str, simulate: bool = True, typos: bool = False) -> dict:
121
+ """Type text into an element."""
122
+ return self._request("POST", "/type", {
123
+ "selector": selector,
124
+ "text": text,
125
+ "simulate": simulate,
126
+ "typos": typos,
127
+ }, timeout=len(text) * 0.5 + 30)
128
+
129
+ def screenshot(
130
+ self,
131
+ selector: Optional[str] = None,
132
+ full_page: bool = False,
133
+ ) -> dict:
134
+ """Take a screenshot."""
135
+ return self._request("POST", "/screenshot", {
136
+ "selector": selector,
137
+ "full_page": full_page,
138
+ })
139
+
140
+ def get_html(self, selector: Optional[str] = None, outer: bool = True) -> dict:
141
+ """Get HTML content."""
142
+ return self._request("POST", "/html", {
143
+ "selector": selector,
144
+ "outer": outer,
145
+ })
146
+
147
+ def get_text(self, selector: str) -> dict:
148
+ """Get text content."""
149
+ return self._request("POST", "/text", {"selector": selector})
150
+
151
+ def get_attr(self, selector: str, attribute: str) -> dict:
152
+ """Get attribute value."""
153
+ return self._request("POST", "/attr", {
154
+ "selector": selector,
155
+ "attribute": attribute,
156
+ })
157
+
158
+ def query(
159
+ self,
160
+ selector: str,
161
+ limit: Optional[int] = None,
162
+ attrs: Optional[str] = None,
163
+ ) -> dict:
164
+ """Query elements."""
165
+ return self._request("POST", "/query", {
166
+ "selector": selector,
167
+ "limit": limit,
168
+ "attrs": attrs,
169
+ })
170
+
171
+ def get_url(self) -> dict:
172
+ """Get current URL."""
173
+ return self._request("GET", "/url")
174
+
175
+ def get_title(self) -> dict:
176
+ """Get page title."""
177
+ return self._request("GET", "/title")
178
+
179
+ def wait(self, selector: str, timeout: int = 30000, state: str = "visible") -> dict:
180
+ """Wait for an element."""
181
+ return self._request("POST", "/wait", {
182
+ "selector": selector,
183
+ "timeout": timeout,
184
+ "state": state,
185
+ }, timeout=timeout / 1000 + 10)
186
+
187
+ def get_markdown(self, selector: Optional[str] = None) -> dict:
188
+ """Get page content as markdown with selectors."""
189
+ return self._request("POST", "/markdown", {"selector": selector})
190
+
191
+
192
+ # Global client instance
193
+ _client: Optional[SessionClient] = None
194
+
195
+
196
+ def get_client() -> SessionClient:
197
+ """Get the global session client."""
198
+ global _client
199
+ if _client is None:
200
+ _client = SessionClient()
201
+ return _client
@@ -0,0 +1 @@
1
+ """PhantomWright CLI commands."""
@@ -0,0 +1,137 @@
1
+ """Extraction commands: screenshot, html, text, attr, query."""
2
+
3
+ import base64
4
+ import os
5
+ import click
6
+
7
+ from ..client import get_client
8
+ from ..output import output_success, output_error
9
+
10
+
11
+ @click.command()
12
+ @click.option("--path", default=None, help="File path to save screenshot")
13
+ @click.option("--selector", default=None, help="CSS selector for element screenshot")
14
+ @click.option("--full-page/--no-full-page", default=False, help="Capture full scrollable page")
15
+ def screenshot(path: str, selector: str, full_page: bool):
16
+ """Take a screenshot of the page or element."""
17
+ client = get_client()
18
+ result = client.screenshot(selector=selector, full_page=full_page)
19
+
20
+ if "error" in result:
21
+ output_error("screenshot", result["error"], result["message"], selector=selector)
22
+ return
23
+
24
+ screenshot_bytes = base64.b64decode(result["base64"])
25
+
26
+ if path:
27
+ # Ensure directory exists
28
+ dir_path = os.path.dirname(path)
29
+ if dir_path:
30
+ os.makedirs(dir_path, exist_ok=True)
31
+ with open(path, "wb") as f:
32
+ f.write(screenshot_bytes)
33
+ output_success("screenshot", {
34
+ "path": os.path.abspath(path),
35
+ "size_bytes": result["size_bytes"],
36
+ "selector": selector,
37
+ "full_page": full_page,
38
+ })
39
+ else:
40
+ # Return base64 encoded image
41
+ output_success("screenshot", {
42
+ "base64": result["base64"],
43
+ "size_bytes": result["size_bytes"],
44
+ "selector": selector,
45
+ "full_page": full_page,
46
+ })
47
+
48
+
49
+ @click.command()
50
+ @click.option("--selector", default=None, help="CSS selector to get HTML from")
51
+ @click.option("--outer/--inner", default=True, help="Get outer HTML (default) or inner HTML")
52
+ def html(selector: str, outer: bool):
53
+ """Get page or element HTML content."""
54
+ client = get_client()
55
+ result = client.get_html(selector=selector, outer=outer)
56
+
57
+ if "error" in result:
58
+ output_error("html", result["error"], result["message"], selector=selector)
59
+ else:
60
+ output_success("html", {
61
+ "html": result["html"],
62
+ "length": result["length"],
63
+ "selector": selector,
64
+ "type": result["type"],
65
+ })
66
+
67
+
68
+ @click.command()
69
+ @click.argument("selector")
70
+ def text(selector: str):
71
+ """Get text content of an element."""
72
+ client = get_client()
73
+ result = client.get_text(selector=selector)
74
+
75
+ if "error" in result:
76
+ output_error("text", result["error"], result["message"], selector=selector)
77
+ else:
78
+ output_success("text", {
79
+ "text": result["text"],
80
+ "length": result["length"],
81
+ "selector": selector,
82
+ })
83
+
84
+
85
+ @click.command()
86
+ @click.argument("selector")
87
+ @click.argument("attribute")
88
+ def attr(selector: str, attribute: str):
89
+ """Get an attribute value from an element."""
90
+ client = get_client()
91
+ result = client.get_attr(selector=selector, attribute=attribute)
92
+
93
+ if "error" in result:
94
+ output_error("attr", result["error"], result["message"], selector=selector)
95
+ else:
96
+ output_success("attr", {
97
+ "attribute": result["attribute"],
98
+ "value": result["value"],
99
+ "selector": selector,
100
+ })
101
+
102
+
103
+ @click.command()
104
+ @click.argument("selector")
105
+ @click.option("--limit", default=None, type=int, help="Maximum number of elements to return")
106
+ @click.option("--attrs", default=None, help="Comma-separated list of attributes to extract")
107
+ def query(selector: str, limit: int, attrs: str):
108
+ """Query all elements matching a selector."""
109
+ client = get_client()
110
+ result = client.query(selector=selector, limit=limit, attrs=attrs)
111
+
112
+ if "error" in result:
113
+ output_error("query", result["error"], result["message"], selector=selector)
114
+ else:
115
+ output_success("query", {
116
+ "elements": result["elements"],
117
+ "count": result["count"],
118
+ "total_matches": result.get("total_matches", result["count"]),
119
+ "selector": selector,
120
+ })
121
+
122
+
123
+ @click.command()
124
+ @click.option("--selector", default=None, help="CSS selector to convert (default: entire page)")
125
+ def markdown(selector: str):
126
+ """Get page content as markdown with embedded selectors for agent interaction."""
127
+ client = get_client()
128
+ result = client.get_markdown(selector=selector)
129
+
130
+ if "error" in result:
131
+ output_error("markdown", result["error"], result["message"], selector=selector)
132
+ else:
133
+ output_success("markdown", {
134
+ "markdown": result["markdown"],
135
+ "url": result["url"],
136
+ "title": result["title"],
137
+ })
@@ -0,0 +1,169 @@
1
+ """Info commands: url, title, wait, status, start, stop."""
2
+
3
+ import asyncio
4
+ import subprocess
5
+ import sys
6
+ import click
7
+
8
+ from ..session import load_session, clear_session, is_session_active
9
+ from ..client import get_client
10
+ from ..output import output_success, output_error
11
+
12
+
13
+ @click.command()
14
+ def url():
15
+ """Get the current page URL."""
16
+ client = get_client()
17
+ result = client.get_url()
18
+
19
+ if "error" in result:
20
+ output_error("url", result["error"], result["message"])
21
+ else:
22
+ output_success("url", {"url": result["url"]})
23
+
24
+
25
+ @click.command()
26
+ def title():
27
+ """Get the current page title."""
28
+ client = get_client()
29
+ result = client.get_title()
30
+
31
+ if "error" in result:
32
+ output_error("title", result["error"], result["message"])
33
+ else:
34
+ output_success("title", {
35
+ "title": result["title"],
36
+ "url": result["url"],
37
+ })
38
+
39
+
40
+ @click.command()
41
+ @click.argument("selector")
42
+ @click.option("--timeout", default=30000, help="Timeout in milliseconds")
43
+ @click.option("--state", default="visible", type=click.Choice(["attached", "detached", "visible", "hidden"]), help="Element state to wait for")
44
+ def wait(selector: str, timeout: int, state: str):
45
+ """Wait for an element to appear."""
46
+ client = get_client()
47
+ result = client.wait(selector, timeout=timeout, state=state)
48
+
49
+ if "error" in result:
50
+ output_error("wait", result["error"], result["message"], selector=selector, timeout=timeout)
51
+ else:
52
+ output_success("wait", {
53
+ "selector": selector,
54
+ "state": state,
55
+ "found": result["found"],
56
+ "count": result["count"],
57
+ })
58
+
59
+
60
+ @click.command()
61
+ def status():
62
+ """Get the current session status."""
63
+ client = get_client()
64
+ result = client.get_status()
65
+ output_success("status", result)
66
+
67
+
68
+ @click.command()
69
+ @click.option("--browser", default="chrome", type=click.Choice(["chrome", "msedge"]), help="Browser to use (default: chrome)")
70
+ @click.option("--headless/--no-headless", default=False, help="Run browser in headless mode")
71
+ @click.option("--stealth/--no-stealth", default=True, help="Enable stealth mode")
72
+ @click.option("--cloudflare-solver/--no-cloudflare-solver", default=True, help="Enable Cloudflare solver")
73
+ @click.option("--visualize-mouse/--no-visualize-mouse", default=None, help="Show visual cursor (default: on when not headless)")
74
+ @click.option("--port", default=9323, help="Server port (default: 9323)")
75
+ @click.option("--foreground/--no-foreground", default=False, help="Run server in foreground (default: background)")
76
+ @click.option("--user-data-dir", default=None, help="Browser user data directory for persistent sessions (preserves logins)")
77
+ def start(browser: str, headless: bool, stealth: bool, cloudflare_solver: bool, visualize_mouse: bool, port: int, foreground: bool, user_data_dir: str):
78
+ """Start a new browser session."""
79
+ # Check if already running
80
+ if is_session_active():
81
+ output_error("start", "SessionExists", "A browser session is already active. Run 'phantomwright stop' first.")
82
+ return
83
+
84
+ if not foreground:
85
+ # Start server in background subprocess
86
+ args = [
87
+ sys.executable, "-m", "phantomwright.cli.server",
88
+ "--port", str(port),
89
+ "--browser", browser,
90
+ ]
91
+ if headless:
92
+ args.append("--headless")
93
+ if not stealth:
94
+ args.append("--no-stealth")
95
+ if not cloudflare_solver:
96
+ args.append("--no-cloudflare-solver")
97
+ if visualize_mouse is not None:
98
+ args.append("--visualize-mouse" if visualize_mouse else "--no-visualize-mouse")
99
+ if user_data_dir:
100
+ args.extend(["--user-data-dir", user_data_dir])
101
+
102
+ # Use CREATE_NEW_PROCESS_GROUP on Windows, start_new_session on Unix
103
+ if sys.platform == "win32":
104
+ subprocess.Popen(
105
+ args,
106
+ creationflags=subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS,
107
+ stdout=subprocess.DEVNULL,
108
+ stderr=subprocess.DEVNULL,
109
+ )
110
+ else:
111
+ subprocess.Popen(
112
+ args,
113
+ start_new_session=True,
114
+ stdout=subprocess.DEVNULL,
115
+ stderr=subprocess.DEVNULL,
116
+ )
117
+
118
+ # Wait for server to start
119
+ import time
120
+ from ..client import SessionClient
121
+ client = SessionClient(port=port)
122
+ for _ in range(300): # Wait up to 30 seconds
123
+ time.sleep(0.1)
124
+ if client.is_server_running():
125
+ result = client.get_status()
126
+ output_success("start", result)
127
+ return
128
+
129
+ output_error("start", "StartupTimeout", "Server did not start within timeout.")
130
+ else:
131
+ # Run server in foreground
132
+ asyncio.run(_start_foreground(browser, headless, stealth, cloudflare_solver, visualize_mouse, port, user_data_dir))
133
+
134
+
135
+ async def _start_foreground(browser: str, headless: bool, stealth: bool, cloudflare_solver: bool, visualize_mouse: bool, port: int, user_data_dir: str):
136
+ """Start server in foreground."""
137
+ from ..server import run_server
138
+ await run_server(
139
+ port=port,
140
+ browser=browser,
141
+ headless=headless,
142
+ stealth=stealth,
143
+ cloudflare_solver=cloudflare_solver,
144
+ visualize_mouse=visualize_mouse,
145
+ user_data_dir=user_data_dir,
146
+ )
147
+
148
+
149
+ @click.command()
150
+ def stop():
151
+ """Stop the current browser session."""
152
+ client = get_client()
153
+
154
+ if not client.is_server_running():
155
+ # Check if there's a stale session file
156
+ stored_session = load_session()
157
+ if stored_session:
158
+ clear_session()
159
+ output_success("stop", {"message": "Cleaned up stale session file."})
160
+ else:
161
+ output_error("stop", "NoSession", "No active browser session to stop.")
162
+ return
163
+
164
+ result = client.stop()
165
+
166
+ if "error" in result:
167
+ output_error("stop", result["error"], result["message"])
168
+ else:
169
+ output_success("stop", {"message": "Browser session stopped."})
@@ -0,0 +1,46 @@
1
+ """Interaction commands: click, type."""
2
+
3
+ import click
4
+
5
+ from ..client import get_client
6
+ from ..output import output_success, output_error
7
+
8
+
9
+ @click.command("click")
10
+ @click.argument("selector")
11
+ @click.option("--simulate/--no-simulate", default=True, help="Use human-like mouse movement (default: on)")
12
+ def click_cmd(selector: str, simulate: bool):
13
+ """Click an element with human-like behavior."""
14
+ client = get_client()
15
+ result = client.click(selector=selector, simulate=simulate)
16
+
17
+ if "error" in result:
18
+ output_error("click", result["error"], result["message"], selector=selector)
19
+ else:
20
+ output_success("click", {
21
+ "selector": selector,
22
+ "element_count": result["element_count"],
23
+ "simulated": result["simulated"],
24
+ "clicked_at": result["clicked_at"],
25
+ })
26
+
27
+
28
+ @click.command("type")
29
+ @click.argument("selector")
30
+ @click.argument("text")
31
+ @click.option("--simulate/--no-simulate", default=True, help="Use human-like typing (default: on)")
32
+ @click.option("--typos/--no-typos", default=False, help="Simulate typing mistakes")
33
+ def type_cmd(selector: str, text: str, simulate: bool, typos: bool):
34
+ """Type text into an element with human-like timing."""
35
+ client = get_client()
36
+ result = client.type_text(selector=selector, text=text, simulate=simulate, typos=typos)
37
+
38
+ if "error" in result:
39
+ output_error("type", result["error"], result["message"], selector=selector)
40
+ else:
41
+ output_success("type", {
42
+ "selector": selector,
43
+ "text_length": result["text_length"],
44
+ "simulated": result["simulated"],
45
+ "typos_enabled": result["typos_enabled"],
46
+ })
@@ -0,0 +1,70 @@
1
+ """Navigation commands: navigate, scroll, browse."""
2
+
3
+ import time
4
+ import click
5
+
6
+ from ..client import get_client
7
+ from ..output import output_success, output_error
8
+
9
+
10
+ @click.command()
11
+ @click.argument("url")
12
+ @click.option("--simulate/--no-simulate", default=True, help="Use human-like navigation behavior (default: on)")
13
+ @click.option("--cool-down/--no-cool-down", default=True, help="Simulate browsing after navigation")
14
+ @click.option("--wait-for", default=None, help="CSS selector to wait for after navigation")
15
+ @click.option("--timeout", default=30000, help="Navigation timeout in milliseconds")
16
+ def navigate(url: str, simulate: bool, cool_down: bool, wait_for: str, timeout: int):
17
+ """Navigate to a URL with human-like behavior."""
18
+ client = get_client()
19
+ start_time = time.time()
20
+
21
+ result = client.navigate(
22
+ url=url,
23
+ simulate=simulate,
24
+ cool_down=cool_down,
25
+ wait_for=wait_for,
26
+ timeout=timeout,
27
+ )
28
+
29
+ if "error" in result:
30
+ output_error("navigate", result["error"], result["message"], url=url)
31
+ else:
32
+ load_time = int((time.time() - start_time) * 1000)
33
+ output_success("navigate", {
34
+ "url": result["url"],
35
+ "title": result["title"],
36
+ "simulated": result["simulated"],
37
+ "load_time_ms": load_time,
38
+ })
39
+
40
+
41
+ @click.command()
42
+ @click.option("--duration", default=2000, help="Scroll duration in milliseconds")
43
+ def scroll(duration: int):
44
+ """Simulate reading behavior with scrolling."""
45
+ client = get_client()
46
+ result = client.scroll(duration=duration)
47
+
48
+ if "error" in result:
49
+ output_error("scroll", result["error"], result["message"])
50
+ else:
51
+ output_success("scroll", {
52
+ "duration_ms": result["duration_ms"],
53
+ "current_url": result["current_url"],
54
+ })
55
+
56
+
57
+ @click.command()
58
+ @click.option("--duration", default=2000, help="Browse duration in milliseconds")
59
+ def browse(duration: int):
60
+ """Simulate general browsing behavior."""
61
+ client = get_client()
62
+ result = client.browse(duration=duration)
63
+
64
+ if "error" in result:
65
+ output_error("browse", result["error"], result["message"])
66
+ else:
67
+ output_success("browse", {
68
+ "duration_ms": result["duration_ms"],
69
+ "current_url": result["current_url"],
70
+ })