pybroswer 0.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pybroswer/__init__.py +40 -0
- pybroswer/__main__.py +23 -0
- pybroswer/_http.py +89 -0
- pybroswer/client.py +155 -0
- pybroswer/dom.py +351 -0
- pybroswer/engine.py +615 -0
- pybroswer/exceptions.py +21 -0
- pybroswer/frames.py +88 -0
- pybroswer/http_engine.py +579 -0
- pybroswer/models.py +246 -0
- pybroswer/py.typed +0 -0
- pybroswer/server.py +146 -0
- pybroswer/session.py +281 -0
- pybroswer/ua.py +110 -0
- pybroswer-0.3.1.dist-info/METADATA +117 -0
- pybroswer-0.3.1.dist-info/RECORD +21 -0
- pybroswer-0.3.1.dist-info/WHEEL +5 -0
- pybroswer-0.3.1.dist-info/entry_points.txt +2 -0
- pybroswer-0.3.1.dist-info/top_level.txt +2 -0
- pybrowser/__init__.py +23 -0
- pybrowser/py.typed +0 -0
pybroswer/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Python browser for PyDroid. HTTP engine in-app; optional remote Chromium."""
|
|
2
|
+
|
|
3
|
+
from pybroswer.client import Browser
|
|
4
|
+
from pybroswer.exceptions import ActionFailed, ApiError, EngineError, PyBroswerError, SessionNotFound
|
|
5
|
+
from pybroswer.models import (
|
|
6
|
+
DevicePreset,
|
|
7
|
+
FrameInfo,
|
|
8
|
+
FrameTarget,
|
|
9
|
+
Health,
|
|
10
|
+
InspectResult,
|
|
11
|
+
LogEntry,
|
|
12
|
+
SessionInfo,
|
|
13
|
+
Snapshot,
|
|
14
|
+
UploadFile,
|
|
15
|
+
Viewport,
|
|
16
|
+
)
|
|
17
|
+
from pybroswer.session import Session
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"ActionFailed",
|
|
21
|
+
"ApiError",
|
|
22
|
+
"Browser",
|
|
23
|
+
"DevicePreset",
|
|
24
|
+
"EngineError",
|
|
25
|
+
"FrameInfo",
|
|
26
|
+
"FrameTarget",
|
|
27
|
+
"Health",
|
|
28
|
+
"InspectResult",
|
|
29
|
+
"LogEntry",
|
|
30
|
+
"PyBroswerError",
|
|
31
|
+
"Session",
|
|
32
|
+
"SessionInfo",
|
|
33
|
+
"SessionNotFound",
|
|
34
|
+
"Snapshot",
|
|
35
|
+
"UploadFile",
|
|
36
|
+
"Viewport",
|
|
37
|
+
"__version__",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
__version__ = "0.3.1"
|
pybroswer/__main__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
|
|
5
|
+
from pybroswer.server import serve
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main() -> None:
|
|
9
|
+
parser = argparse.ArgumentParser(prog="pybroswer", description="Local Chromium REST for PyDroid")
|
|
10
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
11
|
+
|
|
12
|
+
serve_p = sub.add_parser("serve", help="Start Chromium on this PC and expose REST for phones")
|
|
13
|
+
serve_p.add_argument("--host", default="0.0.0.0")
|
|
14
|
+
serve_p.add_argument("--port", type=int, default=5173)
|
|
15
|
+
serve_p.add_argument("--headed", action="store_true", help="Show the browser window")
|
|
16
|
+
|
|
17
|
+
args = parser.parse_args()
|
|
18
|
+
if args.cmd == "serve":
|
|
19
|
+
serve(host=args.host, port=args.port, headless=not args.headed)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
if __name__ == "__main__":
|
|
23
|
+
main()
|
pybroswer/_http.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Protocol
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from pybroswer.exceptions import ActionFailed, ApiError, SessionNotFound
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Transport(Protocol):
|
|
11
|
+
base_url: str
|
|
12
|
+
|
|
13
|
+
def close(self) -> None: ...
|
|
14
|
+
|
|
15
|
+
def request(
|
|
16
|
+
self,
|
|
17
|
+
method: str,
|
|
18
|
+
path: str,
|
|
19
|
+
*,
|
|
20
|
+
json: dict[str, Any] | None = None,
|
|
21
|
+
params: dict[str, Any] | None = None,
|
|
22
|
+
expect_json: bool = True,
|
|
23
|
+
) -> Any: ...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class HttpTransport:
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
base_url: str,
|
|
30
|
+
*,
|
|
31
|
+
timeout: float = 60.0,
|
|
32
|
+
headers: dict[str, str] | None = None,
|
|
33
|
+
client: httpx.Client | None = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
self.base_url = base_url.rstrip("/")
|
|
36
|
+
self._owns = client is None
|
|
37
|
+
self._client = client or httpx.Client(
|
|
38
|
+
base_url=self.base_url,
|
|
39
|
+
timeout=timeout,
|
|
40
|
+
headers={"Accept": "application/json", **(headers or {})},
|
|
41
|
+
follow_redirects=True,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def close(self) -> None:
|
|
45
|
+
if self._owns:
|
|
46
|
+
self._client.close()
|
|
47
|
+
|
|
48
|
+
def request(
|
|
49
|
+
self,
|
|
50
|
+
method: str,
|
|
51
|
+
path: str,
|
|
52
|
+
*,
|
|
53
|
+
json: dict[str, Any] | None = None,
|
|
54
|
+
params: dict[str, Any] | None = None,
|
|
55
|
+
expect_json: bool = True,
|
|
56
|
+
) -> Any:
|
|
57
|
+
response = self._client.request(method, path, json=json, params=params)
|
|
58
|
+
if response.status_code == 404:
|
|
59
|
+
payload = _safe_json(response)
|
|
60
|
+
message = _error_message(payload, response)
|
|
61
|
+
if "session" in message.lower():
|
|
62
|
+
raise SessionNotFound(message, status_code=404, payload=payload)
|
|
63
|
+
raise ApiError(message, status_code=404, payload=payload)
|
|
64
|
+
if response.status_code >= 400:
|
|
65
|
+
payload = _safe_json(response)
|
|
66
|
+
raise ApiError(_error_message(payload, response), status_code=response.status_code, payload=payload)
|
|
67
|
+
if not expect_json:
|
|
68
|
+
return response.content
|
|
69
|
+
if not response.content:
|
|
70
|
+
return {}
|
|
71
|
+
ctype = response.headers.get("content-type", "")
|
|
72
|
+
if "application/json" not in ctype:
|
|
73
|
+
return response.content
|
|
74
|
+
data = response.json()
|
|
75
|
+
if isinstance(data, dict) and data.get("ok") is False and data.get("error"):
|
|
76
|
+
raise ActionFailed(str(data["error"]), status_code=response.status_code, payload=data)
|
|
77
|
+
return data
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _safe_json(response: httpx.Response) -> dict[str, Any]:
|
|
81
|
+
try:
|
|
82
|
+
data = response.json()
|
|
83
|
+
except Exception:
|
|
84
|
+
return {"error": response.text}
|
|
85
|
+
return data if isinstance(data, dict) else {"error": data}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _error_message(payload: dict[str, Any], response: httpx.Response) -> str:
|
|
89
|
+
return str(payload.get("error") or response.reason_phrase or f"HTTP {response.status_code}")
|
pybroswer/client.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pybroswer._http import HttpTransport, Transport
|
|
6
|
+
from pybroswer.models import (
|
|
7
|
+
DeviceName,
|
|
8
|
+
DevicePreset,
|
|
9
|
+
Health,
|
|
10
|
+
SessionInfo,
|
|
11
|
+
Snapshot,
|
|
12
|
+
presets_from,
|
|
13
|
+
session_info_from,
|
|
14
|
+
snapshot_from,
|
|
15
|
+
)
|
|
16
|
+
from pybroswer.session import Session
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Browser:
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
base_url: str | None = None,
|
|
23
|
+
*,
|
|
24
|
+
timeout: float = 60.0,
|
|
25
|
+
headers: dict[str, str] | None = None,
|
|
26
|
+
transport: Transport | None = None,
|
|
27
|
+
engine: Any | None = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
if transport is not None:
|
|
30
|
+
self._http: Transport = transport
|
|
31
|
+
elif engine is not None:
|
|
32
|
+
from pybroswer.engine import EngineTransport
|
|
33
|
+
|
|
34
|
+
self._http = EngineTransport(engine, owns=False)
|
|
35
|
+
elif base_url:
|
|
36
|
+
self._http = HttpTransport(base_url, timeout=timeout, headers=headers)
|
|
37
|
+
else:
|
|
38
|
+
self._http = _http_engine_transport(max_sessions=5, idle_ms=10 * 60 * 1000, timeout=timeout)
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def launch(
|
|
42
|
+
cls,
|
|
43
|
+
*,
|
|
44
|
+
engine: str = "http",
|
|
45
|
+
headless: bool = True,
|
|
46
|
+
max_sessions: int = 5,
|
|
47
|
+
idle_ms: int = 10 * 60 * 1000,
|
|
48
|
+
timeout: float = 30.0,
|
|
49
|
+
executable_path: str | None = None,
|
|
50
|
+
channel: str | None = None,
|
|
51
|
+
) -> Browser:
|
|
52
|
+
from pybroswer.engine import EngineTransport
|
|
53
|
+
|
|
54
|
+
if engine in {"chromium", "playwright"}:
|
|
55
|
+
from pybroswer.engine import ChromiumEngine
|
|
56
|
+
|
|
57
|
+
chromium = ChromiumEngine(
|
|
58
|
+
headless=headless,
|
|
59
|
+
max_sessions=max_sessions,
|
|
60
|
+
idle_ms=idle_ms,
|
|
61
|
+
executable_path=executable_path,
|
|
62
|
+
channel=channel,
|
|
63
|
+
)
|
|
64
|
+
chromium.start()
|
|
65
|
+
return cls(transport=EngineTransport(chromium, owns=True))
|
|
66
|
+
return cls(transport=_http_engine_transport(max_sessions=max_sessions, idle_ms=idle_ms, timeout=timeout))
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def connect(
|
|
70
|
+
cls,
|
|
71
|
+
url: str = "http://127.0.0.1:5173",
|
|
72
|
+
*,
|
|
73
|
+
timeout: float = 60.0,
|
|
74
|
+
headers: dict[str, str] | None = None,
|
|
75
|
+
) -> Browser:
|
|
76
|
+
return cls(url, timeout=timeout, headers=headers)
|
|
77
|
+
|
|
78
|
+
def close(self) -> None:
|
|
79
|
+
self._http.close()
|
|
80
|
+
|
|
81
|
+
def health(self) -> Health:
|
|
82
|
+
data = self._http.request("GET", "/api/v1/health")
|
|
83
|
+
return Health(
|
|
84
|
+
ok=bool(data.get("ok")),
|
|
85
|
+
service=data.get("service"),
|
|
86
|
+
engine=data.get("engine"),
|
|
87
|
+
sessions=int(data.get("sessions") or 0),
|
|
88
|
+
max_sessions=int(data.get("maxSessions") or 0),
|
|
89
|
+
error=data.get("error"),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def ua_presets(self) -> list[DevicePreset]:
|
|
93
|
+
data = self._http.request("GET", "/api/v1/ua")
|
|
94
|
+
return presets_from(data.get("presets"))
|
|
95
|
+
|
|
96
|
+
def sessions(self) -> list[SessionInfo]:
|
|
97
|
+
data = self._http.request("GET", "/api/v1/sessions")
|
|
98
|
+
return [session_info_from(item) for item in data.get("sessions") or []]
|
|
99
|
+
|
|
100
|
+
def create_session(
|
|
101
|
+
self,
|
|
102
|
+
*,
|
|
103
|
+
device: DeviceName | str = "mobile",
|
|
104
|
+
url: str | None = None,
|
|
105
|
+
user_agent: str | None = None,
|
|
106
|
+
width: int | None = None,
|
|
107
|
+
height: int | None = None,
|
|
108
|
+
) -> Session:
|
|
109
|
+
body: dict[str, Any] = {"device": device}
|
|
110
|
+
if url:
|
|
111
|
+
body["url"] = url
|
|
112
|
+
if user_agent:
|
|
113
|
+
body["userAgent"] = user_agent
|
|
114
|
+
if width is not None:
|
|
115
|
+
body["width"] = width
|
|
116
|
+
if height is not None:
|
|
117
|
+
body["height"] = height
|
|
118
|
+
data = self._http.request("POST", "/api/v1/sessions", json=body)
|
|
119
|
+
return Session(self._http, str(data["id"]))
|
|
120
|
+
|
|
121
|
+
def session(self, session_id: str) -> Session:
|
|
122
|
+
return Session(self._http, session_id)
|
|
123
|
+
|
|
124
|
+
def browse(
|
|
125
|
+
self,
|
|
126
|
+
url: str,
|
|
127
|
+
*,
|
|
128
|
+
device: DeviceName | str = "desktop",
|
|
129
|
+
user_agent: str | None = None,
|
|
130
|
+
width: int | None = None,
|
|
131
|
+
height: int | None = None,
|
|
132
|
+
) -> Snapshot:
|
|
133
|
+
body: dict[str, Any] = {"url": url, "device": device}
|
|
134
|
+
if user_agent:
|
|
135
|
+
body["userAgent"] = user_agent
|
|
136
|
+
if width is not None:
|
|
137
|
+
body["width"] = width
|
|
138
|
+
if height is not None:
|
|
139
|
+
body["height"] = height
|
|
140
|
+
return snapshot_from(self._http.request("POST", "/api/v1/browse", json=body))
|
|
141
|
+
|
|
142
|
+
def __enter__(self) -> Browser:
|
|
143
|
+
return self
|
|
144
|
+
|
|
145
|
+
def __exit__(self, *args: object) -> None:
|
|
146
|
+
self.close()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _http_engine_transport(*, max_sessions: int, idle_ms: int, timeout: float) -> Any:
|
|
150
|
+
from pybroswer.engine import EngineTransport
|
|
151
|
+
from pybroswer.http_engine import HttpEngine
|
|
152
|
+
|
|
153
|
+
engine = HttpEngine(max_sessions=max_sessions, idle_ms=idle_ms, timeout=timeout)
|
|
154
|
+
engine.start()
|
|
155
|
+
return EngineTransport(engine, owns=True)
|
pybroswer/dom.py
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from html.parser import HTMLParser
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import urljoin
|
|
7
|
+
|
|
8
|
+
VOID = {
|
|
9
|
+
"area",
|
|
10
|
+
"base",
|
|
11
|
+
"br",
|
|
12
|
+
"col",
|
|
13
|
+
"embed",
|
|
14
|
+
"hr",
|
|
15
|
+
"img",
|
|
16
|
+
"input",
|
|
17
|
+
"link",
|
|
18
|
+
"meta",
|
|
19
|
+
"param",
|
|
20
|
+
"source",
|
|
21
|
+
"track",
|
|
22
|
+
"wbr",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Node:
|
|
27
|
+
__slots__ = ("tag", "attrs", "children", "parent")
|
|
28
|
+
|
|
29
|
+
def __init__(self, tag: str, attrs: dict[str, str] | None = None, parent: Node | None = None) -> None:
|
|
30
|
+
self.tag = tag.lower()
|
|
31
|
+
self.attrs = {k.lower(): v for k, v in (attrs or {}).items()}
|
|
32
|
+
self.children: list[Node | str] = []
|
|
33
|
+
self.parent = parent
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def id(self) -> str:
|
|
37
|
+
return self.attrs.get("id", "")
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def name(self) -> str:
|
|
41
|
+
return self.attrs.get("name", "")
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def type(self) -> str:
|
|
45
|
+
return self.attrs.get("type", "")
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def classes(self) -> set[str]:
|
|
49
|
+
return {c for c in self.attrs.get("class", "").split() if c}
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def href(self) -> str | None:
|
|
53
|
+
return self.attrs.get("href")
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def value(self) -> str:
|
|
57
|
+
if "value" in self.attrs:
|
|
58
|
+
return self.attrs["value"]
|
|
59
|
+
if self.tag == "textarea":
|
|
60
|
+
return self.inner_text
|
|
61
|
+
return ""
|
|
62
|
+
|
|
63
|
+
@value.setter
|
|
64
|
+
def value(self, text: str) -> None:
|
|
65
|
+
self.attrs["value"] = text
|
|
66
|
+
if self.tag in {"textarea", "option"}:
|
|
67
|
+
self.children = [text]
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def inner_text(self) -> str:
|
|
71
|
+
parts: list[str] = []
|
|
72
|
+
for child in self.children:
|
|
73
|
+
if isinstance(child, str):
|
|
74
|
+
parts.append(child)
|
|
75
|
+
elif child.tag not in {"script", "style"}:
|
|
76
|
+
parts.append(child.inner_text)
|
|
77
|
+
return re.sub(r"\s+", " ", "".join(parts)).strip()
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def inner_html(self) -> str:
|
|
81
|
+
return "".join(_serialize(c) for c in self.children)
|
|
82
|
+
|
|
83
|
+
def descendants(self) -> list[Node]:
|
|
84
|
+
out: list[Node] = []
|
|
85
|
+
for child in self.children:
|
|
86
|
+
if isinstance(child, Node):
|
|
87
|
+
out.append(child)
|
|
88
|
+
out.extend(child.descendants())
|
|
89
|
+
return out
|
|
90
|
+
|
|
91
|
+
def form(self) -> Node | None:
|
|
92
|
+
node: Node | None = self
|
|
93
|
+
while node is not None:
|
|
94
|
+
if node.tag == "form":
|
|
95
|
+
return node
|
|
96
|
+
node = node.parent
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _serialize(node: Node | str) -> str:
|
|
101
|
+
if isinstance(node, str):
|
|
102
|
+
return node
|
|
103
|
+
attrs = "".join(f' {k}="{v}"' for k, v in node.attrs.items())
|
|
104
|
+
if node.tag in VOID:
|
|
105
|
+
return f"<{node.tag}{attrs}>"
|
|
106
|
+
return f"<{node.tag}{attrs}>{''.join(_serialize(c) for c in node.children)}</{node.tag}>"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class _Parser(HTMLParser):
|
|
110
|
+
def __init__(self) -> None:
|
|
111
|
+
super().__init__(convert_charrefs=True)
|
|
112
|
+
self.root = Node("document")
|
|
113
|
+
self._stack = [self.root]
|
|
114
|
+
|
|
115
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
116
|
+
parent = self._stack[-1]
|
|
117
|
+
node = Node(tag, {k: v or "" for k, v in attrs}, parent)
|
|
118
|
+
parent.children.append(node)
|
|
119
|
+
if tag.lower() not in VOID:
|
|
120
|
+
self._stack.append(node)
|
|
121
|
+
|
|
122
|
+
def handle_endtag(self, tag: str) -> None:
|
|
123
|
+
tag = tag.lower()
|
|
124
|
+
if tag in VOID:
|
|
125
|
+
return
|
|
126
|
+
for i in range(len(self._stack) - 1, 0, -1):
|
|
127
|
+
if self._stack[i].tag == tag:
|
|
128
|
+
del self._stack[i:]
|
|
129
|
+
break
|
|
130
|
+
|
|
131
|
+
def handle_data(self, data: str) -> None:
|
|
132
|
+
if data:
|
|
133
|
+
self._stack[-1].children.append(data)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def parse_html(html: str) -> Node:
|
|
137
|
+
parser = _Parser()
|
|
138
|
+
parser.feed(html or "")
|
|
139
|
+
parser.close()
|
|
140
|
+
return parser.root
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def document_element(root: Node) -> Node:
|
|
144
|
+
for child in root.children:
|
|
145
|
+
if isinstance(child, Node) and child.tag == "html":
|
|
146
|
+
return child
|
|
147
|
+
return root
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def title_of(root: Node) -> str:
|
|
151
|
+
for node in root.descendants():
|
|
152
|
+
if node.tag == "title":
|
|
153
|
+
return node.inner_text
|
|
154
|
+
return ""
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def body_of(root: Node) -> Node | None:
|
|
158
|
+
for node in root.descendants():
|
|
159
|
+
if node.tag == "body":
|
|
160
|
+
return node
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
_ATTR_RE = re.compile(
|
|
165
|
+
r"\[([^\s\]=~|^$*]+)\s*(?:([~|^$*]?=)\s*(?:\"([^\"]*)\"|'([^']*)'|([^\]]+)))?\]"
|
|
166
|
+
)
|
|
167
|
+
_SIMPLE_RE = re.compile(r"([a-zA-Z][\w:-]*|\*)|#([\w-]+)|\.([\w-]+)|(\[[^\]]+\])")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def query_all(root: Node, selector: str) -> list[Node]:
|
|
171
|
+
selector = (selector or "").strip()
|
|
172
|
+
if not selector:
|
|
173
|
+
return []
|
|
174
|
+
found: list[Node] = []
|
|
175
|
+
seen: set[int] = set()
|
|
176
|
+
for group in selector.split(","):
|
|
177
|
+
for node in _query_group(root, group.strip()):
|
|
178
|
+
key = id(node)
|
|
179
|
+
if key not in seen:
|
|
180
|
+
seen.add(key)
|
|
181
|
+
found.append(node)
|
|
182
|
+
return found
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def query_one(root: Node, selector: str) -> Node | None:
|
|
186
|
+
nodes = query_all(root, selector)
|
|
187
|
+
return nodes[0] if nodes else None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _query_group(root: Node, selector: str) -> list[Node]:
|
|
191
|
+
parts = re.split(r"\s*(>|\s+)\s*", selector.strip())
|
|
192
|
+
parts = [p for p in parts if p and p.strip()]
|
|
193
|
+
combinators: list[str] = []
|
|
194
|
+
simples: list[str] = []
|
|
195
|
+
expect = "simple"
|
|
196
|
+
for part in parts:
|
|
197
|
+
if part in {">"}:
|
|
198
|
+
combinators.append(part)
|
|
199
|
+
expect = "simple"
|
|
200
|
+
continue
|
|
201
|
+
if expect == "simple":
|
|
202
|
+
if simples:
|
|
203
|
+
combinators.append(" ")
|
|
204
|
+
simples.append(part)
|
|
205
|
+
expect = "combo"
|
|
206
|
+
else:
|
|
207
|
+
combinators.append(" ")
|
|
208
|
+
simples.append(part)
|
|
209
|
+
if not simples:
|
|
210
|
+
return []
|
|
211
|
+
matches = [n for n in _all_nodes(root) if _match_simple(n, simples[0])]
|
|
212
|
+
for combo, simple in zip(combinators, simples[1:]):
|
|
213
|
+
nxt: list[Node] = []
|
|
214
|
+
seen: set[int] = set()
|
|
215
|
+
for node in matches:
|
|
216
|
+
candidates = node.children if combo == ">" else node.descendants()
|
|
217
|
+
for child in candidates:
|
|
218
|
+
if isinstance(child, Node) and _match_simple(child, simple) and id(child) not in seen:
|
|
219
|
+
seen.add(id(child))
|
|
220
|
+
nxt.append(child)
|
|
221
|
+
matches = nxt
|
|
222
|
+
return matches
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _all_nodes(root: Node) -> list[Node]:
|
|
226
|
+
return [root, *root.descendants()]
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _match_simple(node: Node, simple: str) -> bool:
|
|
230
|
+
tag = None
|
|
231
|
+
nid = None
|
|
232
|
+
classes: list[str] = []
|
|
233
|
+
attrs: list[tuple[str, str | None, str | None]] = []
|
|
234
|
+
pos = 0
|
|
235
|
+
for m in _SIMPLE_RE.finditer(simple):
|
|
236
|
+
if m.start() != pos:
|
|
237
|
+
return False
|
|
238
|
+
pos = m.end()
|
|
239
|
+
if m.group(1):
|
|
240
|
+
tag = m.group(1).lower()
|
|
241
|
+
elif m.group(2):
|
|
242
|
+
nid = m.group(2)
|
|
243
|
+
elif m.group(3):
|
|
244
|
+
classes.append(m.group(3))
|
|
245
|
+
else:
|
|
246
|
+
am = _ATTR_RE.match(m.group(4))
|
|
247
|
+
if not am:
|
|
248
|
+
return False
|
|
249
|
+
op = am.group(2)
|
|
250
|
+
val = am.group(3) or am.group(4) or am.group(5)
|
|
251
|
+
if val:
|
|
252
|
+
val = val.strip()
|
|
253
|
+
attrs.append((am.group(1).lower(), op, val))
|
|
254
|
+
if pos != len(simple):
|
|
255
|
+
return False
|
|
256
|
+
if tag and tag != "*" and node.tag != tag:
|
|
257
|
+
return False
|
|
258
|
+
if nid is not None and node.id != nid:
|
|
259
|
+
return False
|
|
260
|
+
if any(cls not in node.classes for cls in classes):
|
|
261
|
+
return False
|
|
262
|
+
for name, op, val in attrs:
|
|
263
|
+
actual = node.attrs.get(name)
|
|
264
|
+
if op is None:
|
|
265
|
+
if actual is None:
|
|
266
|
+
return False
|
|
267
|
+
continue
|
|
268
|
+
if actual is None:
|
|
269
|
+
return False
|
|
270
|
+
if op == "=" and actual != val:
|
|
271
|
+
return False
|
|
272
|
+
if op == "~=" and val not in actual.split():
|
|
273
|
+
return False
|
|
274
|
+
if op == "|=" and actual != val and not actual.startswith(f"{val}-"):
|
|
275
|
+
return False
|
|
276
|
+
if op == "^=" and not actual.startswith(val or ""):
|
|
277
|
+
return False
|
|
278
|
+
if op == "$=" and not actual.endswith(val or ""):
|
|
279
|
+
return False
|
|
280
|
+
if op == "*=" and (val or "") not in actual:
|
|
281
|
+
return False
|
|
282
|
+
return True
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def form_fields(form: Node) -> dict[str, str]:
|
|
286
|
+
data: dict[str, str] = {}
|
|
287
|
+
for node in form.descendants():
|
|
288
|
+
if node.tag not in {"input", "textarea", "select"}:
|
|
289
|
+
continue
|
|
290
|
+
name = node.name
|
|
291
|
+
if not name:
|
|
292
|
+
continue
|
|
293
|
+
if node.tag == "input" and node.type in {"submit", "button", "image", "reset", "file"}:
|
|
294
|
+
continue
|
|
295
|
+
if node.tag == "input" and node.type in {"checkbox", "radio"} and "checked" not in node.attrs:
|
|
296
|
+
continue
|
|
297
|
+
if node.tag == "select":
|
|
298
|
+
selected = None
|
|
299
|
+
first = None
|
|
300
|
+
for opt in node.descendants():
|
|
301
|
+
if opt.tag != "option":
|
|
302
|
+
continue
|
|
303
|
+
if first is None:
|
|
304
|
+
first = opt.attrs.get("value", opt.inner_text)
|
|
305
|
+
if "selected" in opt.attrs:
|
|
306
|
+
selected = opt.attrs.get("value", opt.inner_text)
|
|
307
|
+
break
|
|
308
|
+
data[name] = selected if selected is not None else (first or "")
|
|
309
|
+
continue
|
|
310
|
+
data[name] = node.value
|
|
311
|
+
return data
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def form_action(form: Node, base: str) -> tuple[str, str]:
|
|
315
|
+
method = (form.attrs.get("method") or "GET").upper()
|
|
316
|
+
action = form.attrs.get("action") or base
|
|
317
|
+
return method, urljoin(base, action)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def frames_of(root: Node, page_url: str) -> list[dict[str, Any]]:
|
|
321
|
+
out = [{"index": 0, "name": "", "url": page_url, "parentUrl": None, "isMain": True}]
|
|
322
|
+
i = 1
|
|
323
|
+
for node in root.descendants():
|
|
324
|
+
if node.tag != "iframe":
|
|
325
|
+
continue
|
|
326
|
+
src = node.attrs.get("src") or ""
|
|
327
|
+
out.append(
|
|
328
|
+
{
|
|
329
|
+
"index": i,
|
|
330
|
+
"name": node.attrs.get("name") or node.id,
|
|
331
|
+
"url": urljoin(page_url, src) if src else "",
|
|
332
|
+
"parentUrl": page_url,
|
|
333
|
+
"isMain": False,
|
|
334
|
+
}
|
|
335
|
+
)
|
|
336
|
+
i += 1
|
|
337
|
+
return out
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def inspect_node(node: Node) -> dict[str, Any]:
|
|
341
|
+
return {
|
|
342
|
+
"tag": node.tag.upper(),
|
|
343
|
+
"id": node.id,
|
|
344
|
+
"name": node.name or None,
|
|
345
|
+
"type": node.type or None,
|
|
346
|
+
"value": node.value,
|
|
347
|
+
"text": node.inner_text[:2000],
|
|
348
|
+
"href": node.href,
|
|
349
|
+
"visible": True,
|
|
350
|
+
"rect": {"x": 0, "y": 0, "width": 0, "height": 0, "top": 0, "right": 0, "bottom": 0, "left": 0},
|
|
351
|
+
}
|