lithium-web 1.0__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.
lithium/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ from .client import Client, Config, AsyncClient
2
+ from .response import Response, RequestInfo, NetworkLog
3
+ from .session import Session
4
+ from .dom import Dom
5
+ from .element import Element
6
+
7
+ __all__ = ["Client", "Config", "AsyncClient", "Response", "Session", "Dom", "Element", "RequestInfo", "NetworkLog"]
8
+ __version__ = "1.0"
Binary file
Binary file
Binary file
Binary file
lithium/client.py ADDED
@@ -0,0 +1,436 @@
1
+ import asyncio
2
+ import json as jsonlib
3
+ import time
4
+ import uuid
5
+ from urllib.parse import urlencode
6
+
7
+ from .transport import connection
8
+ from .response import Response, NetworkLog
9
+ from .session import Session
10
+ from .utils import LithiumError
11
+
12
+
13
+ class Config:
14
+
15
+ def __init__(self, file=None, profile="chrome-windows", fingerprint=None, platform=None, browser=None, proxy=None,
16
+ verify_ssl=True, trust_env=True, execute_scripts=False, js_timeout=10.0, timeout=30.0,
17
+ solution=False, shared=True, verbose=False, binary=None,
18
+ auto_location=None, auto_timezone=None, auto_currency=None, auto_language=None,
19
+ virtual_gpu=True, canvas_noise=True, tls_spoof=None, http3=False,
20
+ socket_timeout=None, storage_file=None,
21
+ viewport_width=None, viewport_height=None, device_scale_factor=None, screen_color_depth=None,
22
+ hardware_concurrency=None, device_memory=None, max_touch_points=None, do_not_track=None,
23
+ audio_sample_rate=None, screenshot_width=None, screenshot_height=None,
24
+ max_idle_conns=None, max_idle_conns_per_host=None, geo_url=None,
25
+ proxies=None, max_redirects=0, ca_cert=None, client_cert=None, client_key=None,
26
+ max_retries=0, retry_backoff=0.5, retry_codes=(429, 500, 502, 503, 504), auth=None):
27
+ data = {}
28
+ if file:
29
+ with open(file) as f:
30
+ data = jsonlib.load(f)
31
+
32
+ self.profile = data.get("profile", profile)
33
+ self.fingerprint = data.get("fingerprint", fingerprint)
34
+ self.platform = data.get("platform", platform)
35
+ self.browser = data.get("browser", browser)
36
+ self.proxy = data.get("proxy", proxy)
37
+ self.verify_ssl = data.get("verify_ssl", verify_ssl)
38
+ self.trust_env = data.get("trust_env", trust_env)
39
+ self.execute_scripts = data.get("execute_scripts", execute_scripts)
40
+ self.js_timeout = data.get("js_timeout", js_timeout)
41
+ self.timeout = data.get("timeout", timeout)
42
+ self.solution = data.get("solution", solution)
43
+ self.shared = data.get("shared", shared)
44
+ self.verbose = data.get("verbose", verbose)
45
+ self.binary = data.get("binary", binary)
46
+ self.auto_location = data.get("auto_location", auto_location)
47
+ self.auto_timezone = data.get("auto_timezone", auto_timezone)
48
+ self.auto_currency = data.get("auto_currency", auto_currency)
49
+ self.auto_language = data.get("auto_language", auto_language)
50
+ self.virtual_gpu = data.get("virtual_gpu", virtual_gpu)
51
+ self.canvas_noise = data.get("canvas_noise", canvas_noise)
52
+ self.tls_spoof = data.get("tls_spoof", tls_spoof)
53
+ self.http3 = data.get("http3", http3)
54
+ self.socket_timeout = data.get("socket_timeout", socket_timeout)
55
+ self.storage_file = data.get("storage_file", storage_file)
56
+ self.viewport_width = data.get("viewport_width", viewport_width)
57
+ self.viewport_height = data.get("viewport_height", viewport_height)
58
+ self.device_scale_factor = data.get("device_scale_factor", device_scale_factor)
59
+ self.screen_color_depth = data.get("screen_color_depth", screen_color_depth)
60
+ self.hardware_concurrency = data.get("hardware_concurrency", hardware_concurrency)
61
+ self.device_memory = data.get("device_memory", device_memory)
62
+ self.max_touch_points = data.get("max_touch_points", max_touch_points)
63
+ self.do_not_track = data.get("do_not_track", do_not_track)
64
+ self.audio_sample_rate = data.get("audio_sample_rate", audio_sample_rate)
65
+ self.screenshot_width = data.get("screenshot_width", screenshot_width)
66
+ self.screenshot_height = data.get("screenshot_height", screenshot_height)
67
+ self.max_idle_conns = data.get("max_idle_conns", max_idle_conns)
68
+ self.max_idle_conns_per_host = data.get("max_idle_conns_per_host", max_idle_conns_per_host)
69
+ self.geo_url = data.get("geo_url", geo_url)
70
+ self.proxies = data.get("proxies", proxies)
71
+ self.max_redirects = data.get("max_redirects", max_redirects)
72
+ self.ca_cert = data.get("ca_cert", ca_cert)
73
+ self.client_cert = data.get("client_cert", client_cert)
74
+ self.client_key = data.get("client_key", client_key)
75
+ self.max_retries = data.get("max_retries", max_retries)
76
+ self.retry_backoff = data.get("retry_backoff", retry_backoff)
77
+ self.retry_codes = data.get("retry_codes", retry_codes)
78
+ self.auth = data.get("auth", auth)
79
+ self._proxy_index = 0
80
+
81
+
82
+ def build_body(data, json, headers):
83
+ if json is not None:
84
+ headers.setdefault("Content-Type", "application/json")
85
+ return jsonlib.dumps(json).encode()
86
+ if data is None:
87
+ return None
88
+ if isinstance(data, (bytes, bytearray)):
89
+ return bytes(data)
90
+ if isinstance(data, str):
91
+ return data.encode()
92
+ if isinstance(data, dict):
93
+ headers.setdefault("Content-Type", "application/x-www-form-urlencoded")
94
+ return urlencode(data, doseq=True).encode()
95
+ raise TypeError(f"unsupported type for data=: {type(data).__name__}, use bytes, str, dict, or json=")
96
+
97
+
98
+ def build_multipart(data, files):
99
+ boundary = uuid.uuid4().hex
100
+ parts = []
101
+ for k, v in (data or {}).items():
102
+ parts.append(f'--{boundary}\r\nContent-Disposition: form-data; name="{k}"\r\n\r\n{v}\r\n'.encode())
103
+ for name, item in (files or {}).items():
104
+ if isinstance(item, tuple):
105
+ filename, content = item
106
+ else:
107
+ filename, content = name, item
108
+ if isinstance(content, str):
109
+ content = content.encode()
110
+ head = (f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"; filename="{filename}"\r\n'
111
+ f"Content-Type: application/octet-stream\r\n\r\n").encode()
112
+ parts.append(head + bytes(content) + b"\r\n")
113
+ parts.append(f"--{boundary}--\r\n".encode())
114
+ return b"".join(parts), boundary
115
+
116
+
117
+ class Client:
118
+ """Undetected, requests + Selenium-style HTTP client.
119
+
120
+ Zero-config requests-style use:
121
+ client = Client()
122
+ resp = client.get("https://example.com")
123
+ print(resp.status_code, resp.text, resp.json())
124
+
125
+ Selenium-style page interaction (client.get returns a page):
126
+ page = client.get("https://example.com/login")
127
+ page.fill("#username", "alice")
128
+ page.fill("#password", "secret")
129
+ page.click("button[type=submit]")
130
+ page.wait_for("#dashboard")
131
+ print(page.text)
132
+ page.screenshot("dashboard.png")
133
+ for req in page.network.requests:
134
+ print(req.method, req.url)
135
+
136
+ Config can be passed as keywords directly:
137
+ client = Client(profile="chrome-windows")
138
+ client = Client(proxy="socks5://user:pass@host:1080", execute_scripts=True)
139
+ """
140
+
141
+ def __init__(self, config=None, **kwargs):
142
+ if config is not None and kwargs:
143
+ raise TypeError("pass either a Config object or keyword options, not both")
144
+ cfg = config or Config(**kwargs) or Config()
145
+ self._closed = False
146
+ self._timeout = cfg.timeout
147
+ self._cfg_proxies = cfg.proxies or []
148
+ self._proxy_index = 0
149
+ self._shot_w = getattr(cfg, "screenshot_width", None)
150
+ self._shot_h = getattr(cfg, "screenshot_height", None)
151
+ self.auth = cfg.auth
152
+ self.max_retries = cfg.max_retries
153
+ self.retry_backoff = cfg.retry_backoff
154
+ self.retry_codes = cfg.retry_codes or set()
155
+
156
+ self._conn = connection(binary=cfg.binary, verbose=cfg.verbose, solution=cfg.solution,
157
+ shared=cfg.shared, socket_timeout=cfg.socket_timeout)
158
+ self._conn.connect()
159
+
160
+ payload = {
161
+ "profile": cfg.profile,
162
+ "fingerprint": cfg.fingerprint or "",
163
+ "platform": cfg.platform or "",
164
+ "browser": cfg.browser or "",
165
+ "proxy": cfg.proxy or "",
166
+ "verify_ssl": cfg.verify_ssl,
167
+ "trust_env": cfg.trust_env,
168
+ "execute_scripts": cfg.execute_scripts,
169
+ "js_timeout": cfg.js_timeout,
170
+ "timeout": cfg.timeout,
171
+ "storage_file": cfg.storage_file or "",
172
+ "virtual_gpu": cfg.virtual_gpu,
173
+ "canvas_noise": cfg.canvas_noise,
174
+ "http3": cfg.http3,
175
+ }
176
+ if cfg.auto_location is not None:
177
+ payload["auto_location"] = cfg.auto_location
178
+ if cfg.auto_timezone is not None:
179
+ payload["auto_timezone"] = cfg.auto_timezone
180
+ if cfg.auto_currency is not None:
181
+ payload["auto_currency"] = cfg.auto_currency
182
+ if cfg.auto_language is not None:
183
+ payload["auto_language"] = cfg.auto_language
184
+ if cfg.tls_spoof is not None:
185
+ payload["tls_spoof"] = cfg.tls_spoof
186
+
187
+ for key, val in [
188
+ ("viewport_width", cfg.viewport_width),
189
+ ("viewport_height", cfg.viewport_height),
190
+ ("device_scale_factor", cfg.device_scale_factor),
191
+ ("screen_color_depth", cfg.screen_color_depth),
192
+ ("hardware_concurrency", cfg.hardware_concurrency),
193
+ ("device_memory", cfg.device_memory),
194
+ ("max_touch_points", cfg.max_touch_points),
195
+ ("audio_sample_rate", cfg.audio_sample_rate),
196
+ ("screenshot_width", cfg.screenshot_width),
197
+ ("screenshot_height", cfg.screenshot_height),
198
+ ("max_idle_conns", cfg.max_idle_conns),
199
+ ("max_idle_conns_per_host", cfg.max_idle_conns_per_host),
200
+ ("max_redirects", cfg.max_redirects),
201
+ ]:
202
+ if val is not None:
203
+ payload[key] = val
204
+ if cfg.geo_url is not None:
205
+ payload["geo_url"] = cfg.geo_url
206
+ for key, val in [("ca_cert", cfg.ca_cert), ("client_cert", cfg.client_cert), ("client_key", cfg.client_key)]:
207
+ if val:
208
+ payload[key] = val
209
+ if cfg.do_not_track is not None:
210
+ payload["do_not_track"] = cfg.do_not_track
211
+
212
+ result = self._conn.call("session.create", payload)
213
+ self._session = Session(self._conn, result["session_id"], cfg.profile, cfg.fingerprint)
214
+
215
+ def get(self, url, headers=None, timeout=None, proxy=None, fingerprint=None, platform=None, browser=None, profile=None, params=None, auth=None):
216
+ return self._request("GET", url, headers=headers, timeout=timeout, proxy=proxy,
217
+ fingerprint=fingerprint, platform=platform, browser=browser, profile=profile, params=params, auth=auth)
218
+
219
+ def post(self, url, data=None, json=None, files=None, headers=None, timeout=None, proxy=None,
220
+ fingerprint=None, platform=None, browser=None, profile=None, params=None, auth=None):
221
+ headers = dict(headers or {})
222
+ if files is not None:
223
+ mpbody, boundary = build_multipart(data, files)
224
+ headers["Content-Type"] = f"multipart/form-data; boundary={boundary}"
225
+ else:
226
+ mpbody = build_body(data, json, headers)
227
+ return self._request("POST", url, headers=headers, body=mpbody, timeout=timeout, proxy=proxy,
228
+ fingerprint=fingerprint, platform=platform, browser=browser, profile=profile, params=params, auth=auth)
229
+
230
+ def put(self, url, data=None, json=None, files=None, headers=None, timeout=None, proxy=None,
231
+ fingerprint=None, platform=None, browser=None, profile=None, params=None, auth=None):
232
+ headers = dict(headers or {})
233
+ if files is not None:
234
+ mpbody, boundary = build_multipart(data, files)
235
+ headers["Content-Type"] = f"multipart/form-data; boundary={boundary}"
236
+ else:
237
+ mpbody = build_body(data, json, headers)
238
+ return self._request("PUT", url, headers=headers, body=mpbody, timeout=timeout, proxy=proxy,
239
+ fingerprint=fingerprint, platform=platform, browser=browser, profile=profile, params=params, auth=auth)
240
+
241
+ def patch(self, url, data=None, json=None, files=None, headers=None, timeout=None, proxy=None,
242
+ fingerprint=None, platform=None, browser=None, profile=None, params=None, auth=None):
243
+ headers = dict(headers or {})
244
+ if files is not None:
245
+ mpbody, boundary = build_multipart(data, files)
246
+ headers["Content-Type"] = f"multipart/form-data; boundary={boundary}"
247
+ else:
248
+ mpbody = build_body(data, json, headers)
249
+ return self._request("PATCH", url, headers=headers, body=mpbody, timeout=timeout, proxy=proxy,
250
+ fingerprint=fingerprint, platform=platform, browser=browser, profile=profile, params=params, auth=auth)
251
+
252
+ def delete(self, url, headers=None, timeout=None, proxy=None, fingerprint=None, platform=None, browser=None, profile=None, params=None, auth=None):
253
+ return self._request("DELETE", url, headers=headers, timeout=timeout, proxy=proxy,
254
+ fingerprint=fingerprint, platform=platform, browser=browser, profile=profile, params=params, auth=auth)
255
+
256
+ def head(self, url, headers=None, timeout=None, proxy=None, fingerprint=None, platform=None, browser=None, profile=None, params=None, auth=None):
257
+ return self._request("HEAD", url, headers=headers, timeout=timeout, proxy=proxy,
258
+ fingerprint=fingerprint, platform=platform, browser=browser, profile=profile, params=params, auth=auth)
259
+
260
+ def options(self, url, headers=None, timeout=None, proxy=None, fingerprint=None, platform=None, browser=None, profile=None, params=None, auth=None):
261
+ return self._request("OPTIONS", url, headers=headers, timeout=timeout, proxy=proxy,
262
+ fingerprint=fingerprint, platform=platform, browser=browser, profile=profile, params=params, auth=auth)
263
+
264
+ def _request(self, method, url, headers=None, body=None, timeout=None, proxy=None,
265
+ fingerprint=None, platform=None, browser=None, profile=None, params=None, auth=None):
266
+ if self._closed:
267
+ raise LithiumError("this client is closed, create a new Client() to make more requests")
268
+
269
+ if proxy is None and self._cfg_proxies:
270
+ proxy = self._next_proxy()
271
+
272
+ if params:
273
+ url = url + ("&" if "?" in url else "?") + urlencode(params, doseq=True)
274
+
275
+ headers = dict(headers or {})
276
+ credentials = auth or self.auth
277
+ if credentials:
278
+ import base64
279
+ raw = f"{credentials[0]}:{credentials[1]}".encode()
280
+ headers["Authorization"] = "Basic " + base64.b64encode(raw).decode()
281
+
282
+ req_params = {
283
+ "session_id": self._session.id,
284
+ "method": method,
285
+ "url": url,
286
+ "headers": headers,
287
+ "timeout": timeout if timeout is not None else self._timeout,
288
+ "proxy": proxy or "",
289
+ "fingerprint": fingerprint or "",
290
+ "platform": platform or "",
291
+ "browser": browser or "",
292
+ "profile": profile or "",
293
+ }
294
+ if body is not None:
295
+ req_params["body"] = body
296
+
297
+ attempts = self.max_retries + 1
298
+ for attempt in range(attempts):
299
+ data = self._conn.call("http.fetch", req_params)
300
+ resp = Response(data, conn=self._conn, session_id=self._session.id,
301
+ screenshot_width=self._shot_w, screenshot_height=self._shot_h)
302
+ if attempt < self.max_retries and resp.status_code in self.retry_codes:
303
+ time.sleep(self.retry_backoff * (2 ** attempt))
304
+ continue
305
+ return resp
306
+ return resp
307
+
308
+ def request(self, method, url, **kwargs):
309
+ method = method.upper()
310
+ if method == "GET":
311
+ return self.get(url, **kwargs)
312
+ if method in ("POST", "PUT", "PATCH"):
313
+ return getattr(self, method.lower())(url, **kwargs)
314
+ if method == "DELETE":
315
+ return self.delete(url, **kwargs)
316
+ if method == "HEAD":
317
+ return self.head(url, **kwargs)
318
+ if method == "OPTIONS":
319
+ return self.options(url, **kwargs)
320
+ raise LithiumError(f"unsupported method: {method}")
321
+
322
+ def map(self, urls, method="GET", max_workers=8, **kwargs):
323
+ from concurrent.futures import ThreadPoolExecutor
324
+ with ThreadPoolExecutor(max_workers=max_workers) as pool:
325
+ return list(pool.map(lambda u: self.request(method, u, **kwargs), urls))
326
+
327
+ def cookies(self, url):
328
+ data = self._conn.call("session.cookies", {"session_id": self._session.id, "url": url})
329
+ return {c["name"]: c["value"] for c in data.get("cookies", [])}
330
+
331
+ def clear_cookies(self):
332
+ self._session.clear_cookies()
333
+
334
+ def _next_proxy(self):
335
+ pool = self._cfg_proxies
336
+ p = pool[self._proxy_index % len(pool)]
337
+ self._proxy_index += 1
338
+ return p
339
+
340
+ def stream_to_file(self, url, path, method="GET", headers=None, body=None, timeout=None,
341
+ proxy=None, fingerprint=None, platform=None, browser=None, profile=None, params=None, auth=None):
342
+ if self._closed:
343
+ raise LithiumError("this client is closed, create a new Client() to make more requests")
344
+ if proxy is None and self._cfg_proxies:
345
+ proxy = self._next_proxy()
346
+ params = {
347
+ "session_id": self._session.id,
348
+ "method": method,
349
+ "url": url,
350
+ "headers": headers or {},
351
+ "timeout": timeout if timeout is not None else self._timeout,
352
+ "proxy": proxy or "",
353
+ "fingerprint": fingerprint or "",
354
+ "platform": platform or "",
355
+ "browser": browser or "",
356
+ "profile": profile or "",
357
+ "path": path,
358
+ }
359
+ if body is not None:
360
+ params["body"] = body
361
+ data = self._conn.call("http.download", params)
362
+ return Response(data, conn=self._conn, session_id=self._session.id)
363
+
364
+ def download(self, url, path, timeout=None, proxy=None):
365
+ response = self.get(url, timeout=timeout, proxy=proxy)
366
+ with open(path, "wb") as f:
367
+ f.write(response.content)
368
+ return path
369
+
370
+ @property
371
+ def network(self):
372
+ if self._closed:
373
+ raise LithiumError("this client is closed, create a new Client() to inspect network activity")
374
+ data = self._conn.call("network.requests", {"session_id": self._session.id})
375
+ return NetworkLog(data.get("requests", []))
376
+
377
+ def close(self):
378
+ if self._closed:
379
+ return
380
+ self._closed = True
381
+ try:
382
+ self._session.close()
383
+ finally:
384
+ self._conn.close()
385
+
386
+ def __enter__(self):
387
+ return self
388
+
389
+ def __exit__(self, *exc):
390
+ self.close()
391
+
392
+
393
+ class AsyncClient:
394
+
395
+ def __init__(self, config=None, **kwargs):
396
+ self._client = Client(config=config, **kwargs)
397
+
398
+ async def get(self, *a, **k):
399
+ return await asyncio.to_thread(self._client.get, *a, **k)
400
+
401
+ async def post(self, *a, **k):
402
+ return await asyncio.to_thread(self._client.post, *a, **k)
403
+
404
+ async def put(self, *a, **k):
405
+ return await asyncio.to_thread(self._client.put, *a, **k)
406
+
407
+ async def patch(self, *a, **k):
408
+ return await asyncio.to_thread(self._client.patch, *a, **k)
409
+
410
+ async def delete(self, *a, **k):
411
+ return await asyncio.to_thread(self._client.delete, *a, **k)
412
+
413
+ async def head(self, *a, **k):
414
+ return await asyncio.to_thread(self._client.head, *a, **k)
415
+
416
+ async def options(self, *a, **k):
417
+ return await asyncio.to_thread(self._client.options, *a, **k)
418
+
419
+ async def stream_to_file(self, *a, **k):
420
+ return await asyncio.to_thread(self._client.stream_to_file, *a, **k)
421
+
422
+ async def download(self, *a, **k):
423
+ return await asyncio.to_thread(self._client.download, *a, **k)
424
+
425
+ async def close(self):
426
+ self._client.close()
427
+
428
+ async def __aenter__(self):
429
+ return self
430
+
431
+ async def __aexit__(self, *exc):
432
+ await self.close()
433
+
434
+ @property
435
+ def network(self):
436
+ return self._client.network
lithium/diagnostics.py ADDED
@@ -0,0 +1,214 @@
1
+ import socket
2
+ import ssl
3
+ from urllib.parse import urlparse
4
+
5
+
6
+ catalog = {
7
+ "timeout": {
8
+ "explain": "the server did not respond before the timeout elapsed",
9
+ "fix": [
10
+ "increase the timeout: client.get(url, timeout=60)",
11
+ "check if the target server is slow or overloaded right now",
12
+ "confirm your network can reach the host at all",
13
+ ],
14
+ },
15
+ "dns_failure": {
16
+ "explain": "the hostname could not be resolved to an ip address",
17
+ "fix": [
18
+ "check the url for typos",
19
+ "confirm your dns resolver is working (try: nslookup <host>)",
20
+ ],
21
+ },
22
+ "connection_refused": {
23
+ "explain": "the remote host actively refused the connection",
24
+ "fix": [
25
+ "confirm the port is correct and the service is actually running",
26
+ "check firewall rules on the target host",
27
+ ],
28
+ },
29
+ "connection_reset": {
30
+ "explain": "the connection was closed abruptly by the remote server",
31
+ "fix": [
32
+ "retry the request, this is often transient",
33
+ "check if the server is rate-limiting or blocking this client",
34
+ ],
35
+ },
36
+ "tls_error": {
37
+ "explain": "the tls handshake or certificate verification failed",
38
+ "fix": [
39
+ "check if the site's certificate is valid or expired",
40
+ "confirm your system clock is correct (a bad clock breaks tls)",
41
+ ],
42
+ },
43
+ "canceled": {
44
+ "explain": "the request was canceled before it completed",
45
+ "fix": ["check for a manual cancellation or a short timeout upstream"],
46
+ },
47
+ "invalid_method": {
48
+ "explain": "the http method given isn't supported",
49
+ "fix": ["use one of GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS"],
50
+ },
51
+ "empty_url": {
52
+ "explain": "no url was provided to the request",
53
+ "fix": ["pass a non-empty url, e.g. client.get('https://example.com')"],
54
+ },
55
+ "session_not_found": {
56
+ "explain": "the session backing this client no longer exists on the runtime",
57
+ "fix": ["create a new Client() instance, the previous one was likely closed"],
58
+ },
59
+ "page_not_found": {
60
+ "explain": "the page (parsed dom) referenced no longer exists on the runtime",
61
+ "fix": ["re-fetch the page with client.get(url) before querying its dom again"],
62
+ },
63
+ "node_not_found": {
64
+ "explain": "the dom element referenced no longer exists, likely removed",
65
+ "fix": ["re-query the element with page.dom.query(...) before using it again"],
66
+ },
67
+ "unknown_op": {
68
+ "explain": "the python client sent an operation the go runtime doesn't recognize",
69
+ "fix": ["this usually means python/lithium and the go binary are out of sync, rebuild the go binary"],
70
+ },
71
+ "invalid_proxy": {
72
+ "explain": "the proxy url couldn't be parsed",
73
+ "fix": [
74
+ "check the proxy url format, e.g. socks5://user:pass@host:port",
75
+ "supported schemes: http, https, socks4, socks4a, socks5, socks5h",
76
+ ],
77
+ },
78
+ "unsupported_proxy_scheme": {
79
+ "explain": "the proxy url's scheme isn't one lithium understands",
80
+ "fix": ["use one of: http, https, socks4, socks4a, socks5, socks5h"],
81
+ },
82
+ "proxy_setup_failed": {
83
+ "explain": "the proxy dialer couldn't be constructed",
84
+ "fix": ["check the proxy host/port and credentials are correct"],
85
+ },
86
+ "proxy_connect_failed": {
87
+ "explain": "the proxy server rejected the connection request",
88
+ "fix": [
89
+ "confirm the proxy is online and accepting connections",
90
+ "double check proxy credentials if the proxy requires auth",
91
+ ],
92
+ },
93
+ "proxy_resolve_failed": {
94
+ "explain": "the target hostname couldn't be resolved for a socks4 proxy",
95
+ "fix": ["socks4 needs the client to resolve dns itself - try socks4a to let the proxy resolve it instead"],
96
+ },
97
+ "proxy_unsupported_address": {
98
+ "explain": "socks4 only supports ipv4 targets",
99
+ "fix": ["use socks5 or socks5h if the target requires ipv6"],
100
+ },
101
+ "js_error": {
102
+ "explain": "the javascript engine raised an error while running the script",
103
+ "fix": [
104
+ "check the error message above for the js exception detail",
105
+ "if the script never finished, it may have hit the execution timeout (10s default)",
106
+ ],
107
+ },
108
+ "geo_lookup_failed": {
109
+ "explain": "auto_timezone/auto_currency/auto_language couldn't resolve geo info for the current outbound ip",
110
+ "fix": [
111
+ "check the proxy (if any) actually has working outbound connectivity",
112
+ "the lookup service may be rate limiting - it's capped around 45 requests/minute",
113
+ ],
114
+ },
115
+ "tls_h2_unsupported": {
116
+ "explain": "the spoofed tls handshake negotiated http/2 with the server, which this transport can't speak yet over a spoofed connection - this is a safety net catching an edge case, not expected in normal use",
117
+ "fix": [
118
+ "retry the request, this can be transient depending on the target server",
119
+ "set tls_spoof=False on Config to fall back to plain (non-spoofed) tls for this target",
120
+ "if this keeps happening for a specific site, please report it",
121
+ ],
122
+ },
123
+ "socket_timeout": {
124
+ "explain": "the go runtime didn't respond within Config(socket_timeout=...) seconds",
125
+ "fix": [
126
+ "increase socket_timeout if this call is legitimately slow (e.g. a long execute_script)",
127
+ "rerun with Client(Config(verbose=True)) to see whether the go side is stuck or just slow",
128
+ ],
129
+ },
130
+ "connection_lost": {
131
+ "explain": "the connection to the go runtime dropped mid-call, usually because the runtime process exited or crashed",
132
+ "fix": [
133
+ "rerun with Client(Config(verbose=True)) to see go-side logs on stderr",
134
+ "check the runtime binary is present and executable (LITHIUM_BINARY if set)",
135
+ "if using a shared runtime, the process may have been killed externally",
136
+ ],
137
+ },
138
+ "not_a_form": {
139
+ "explain": "submit() was called on an element that isn't a <form> and has no ancestor <form>",
140
+ "fix": ["call submit() on the form itself, or on a button/input inside one"],
141
+ },
142
+ "option_not_found": {
143
+ "explain": "select_option() was given a value that doesn't match any <option> in the <select>",
144
+ "fix": ["check the exact value or text of the <option> elements (el.html can help here)"],
145
+ },
146
+ }
147
+
148
+
149
+ def humanize(code: str) -> str:
150
+ return code.replace("_", " ").strip().capitalize()
151
+
152
+
153
+ def live_probe(url: str):
154
+ findings = []
155
+ try:
156
+ parsed = urlparse(url)
157
+ host = parsed.hostname
158
+ port = parsed.port or (443 if parsed.scheme == "https" else 80)
159
+ except Exception:
160
+ return ["could not parse url to run live diagnostics"]
161
+
162
+ if not host:
163
+ return ["url has no hostname to probe"]
164
+
165
+ try:
166
+ socket.getaddrinfo(host, port)
167
+ findings.append(f"dns resolves for {host}")
168
+ except socket.gaierror as e:
169
+ findings.append(f"dns resolution failed for {host}: {e}")
170
+ return findings
171
+
172
+ try:
173
+ with socket.create_connection((host, port), timeout=5):
174
+ findings.append(f"tcp connect to {host}:{port} succeeded")
175
+ except OSError as e:
176
+ findings.append(f"tcp connect to {host}:{port} failed: {e}")
177
+ return findings
178
+
179
+ if parsed.scheme == "https":
180
+ try:
181
+ ctx = ssl.create_default_context()
182
+ with socket.create_connection((host, port), timeout=5) as sock:
183
+ with ctx.wrap_socket(sock, server_hostname=host):
184
+ findings.append("tls handshake succeeded")
185
+ except Exception as e:
186
+ findings.append(f"tls handshake failed: {e}")
187
+
188
+ return findings
189
+
190
+
191
+ def build_solution(code: str, url=None, live=False) -> str:
192
+ entry = catalog.get(code)
193
+ lines = [f"[lithium diagnostics] error code: {code}"]
194
+
195
+ if entry:
196
+ lines.append(f"likely cause: {entry['explain']}")
197
+ lines.append("suggested fixes:")
198
+ for fix in entry["fix"]:
199
+ lines.append(f" - {fix}")
200
+ else:
201
+ lines.append(f"likely cause: {humanize(code)} (not in the catalog yet, generic guidance below)")
202
+ lines.append("suggested fixes:")
203
+ lines.append(" - check the error message above for specifics")
204
+ lines.append(" - retry the request, this may be transient")
205
+ lines.append(" - rerun with Client(verbose=True) to see go-side logs")
206
+
207
+ if live and url:
208
+ findings = live_probe(url)
209
+ if findings:
210
+ lines.append("live diagnostics:")
211
+ for f in findings:
212
+ lines.append(f" - {f}")
213
+
214
+ return "\n".join(lines)