Metadata-Version: 2.4
Name: lithium-web
Version: 1.0
Summary: An HTTP-first, stealth web runtime for Python — the simplicity of requests + the interaction model of Selenium.
Author: lithium-web contributors
License-Expression: MIT
Project-URL: Homepage, https://github.com/OfficialDex/lithium-web
Project-URL: Documentation, https://github.com/OfficialDex/lithium-web#readme
Keywords: http,http-client,web-scraping,undetected,browser-runtime,requests,selenium,fingerprint,curl,primp
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Go
Classifier: Operating System :: OS Independent
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: msgpack>=1.0.0
Requires-Dist: weasyprint>=60
Requires-Dist: pypdfium2>=4
Requires-Dist: pillow>=10
Dynamic: license-file

# Lithium
# Version – 1.0

**An HTTP-first, high stealth web runtime for Python.** 
The simplicity of `requests` + the interaction model of Selenium, backed by a tiny native Go runtime — no browser to install, no drivers, no config to get going.

```python
from lithium import Client

client = Client()                      # zero-config, undetected out of the box
page = client.get("https://example.com/login")

page.fill("#username", "alice")
page.fill("#password", "secret")
page.click("button[type=submit]")
page.wait_for("#dashboard")

print(page.text)
page.screenshot("dashboard.png")
```

> Fun fact #1: the name comes from lithium — the lightest solid element — because the whole runtime binary is only ~19 MB.
> Fun fact #2: Python talks to the Go runtime over a Unix socket using MessagePack. No browser process, no WebDriver, no Chromium. Just a runtime.
> Fun fact #3: by default the runtime speaks a Chrome TLS (JA3) fingerprint over uTLS, so `Client()` is already impersonating a real browser — you don't have to turn anything on.

---

## Installation

```bash
pip install lithium-web
```

That's it.

---

## Building & publishing to PyPI (maintainers)

The wheel must bundle the **static Go runtime binary** — if you skip the build
step the wheel has no runtime and won't work (`RuntimeNotStarted`).

```bash
# 1. install build deps
pip install build twine cython

# 2. cross-compile the static Go binary + build the wheel (this is what bundles it)
./build_release.sh            # fat wheel (all os/arch), source visible
# ./build_cython.sh           # optional: obfuscated wheel (current platform only)

# 3. sanity-check the binary actually got bundled
unzip -l python/dist/*.whl | grep 'lithium/bin/'   # must list lithium-runtime-* files

# 4. upload (test first, then real)
twine upload --repository testpypi python/dist/*.whl
twine upload python/dist/*.whl
```

Before publishing, confirm the wheel isn't missing the binary and that
`version` in `python/pyproject.toml` matches what you intend to ship.

---

## Quickstart — major features, individually

### Requests-style HTTP

```python
from lithium import Client

client = Client()
r = client.get("https://api.example.com/users?limit=10")
print(r.status_code)          # 200
print(r.ok)                   # True
print(r.json())               # parsed body

# POST with JSON
r = client.post("https://api.example.com/users", json={"name": "Ada"})

# POST with a form body (auto urlencoded)
r = client.post("https://httpbin.org/post", data={"q": "lithium"})

# POST multipart upload
r = client.post("https://httpbin.org/post",
                data={"note": "hi"},
                files={"file": ("report.pdf", b"%PDF-1.4 ...")})
```

### Selenium-style page interaction

```python
from lithium import Client

page = client.get("https://example.com/login")
page.fill("#username", "alice")
page.fill("#password", "secret")
page.click("button[type=submit]")     # navigates; `page` updates in place
page.wait_for("#dashboard")           # waits for the element to appear
print(page.text)                      # text of the current page
```

### Execute JavaScript

```python
page = client.get("https://example.com")
page.execute_script("document.body.dataset.loaded = 'true'")
print(page.dom.query("body").get_attr("data-loaded"))   # 'true'
```

### Query the DOM

```python
page = client.get("https://example.com/products")
items = page.dom.query_all(".product")
for el in items:
    print(el.text, el.get_attr("data-price"))
```

### Screenshots

Real HTML/CSS rendering via WeasyPrint (no browser) — real text, fonts, colors, and layout:

```python
page = client.get("https://example.com")
page.screenshot("page.png")                     # writes a PNG, returns the path
png_bytes = page.screenshot()                   # or get raw bytes
page.screenshot("w.png", width=1200, scale=2.0) # exact output (width/height are the actual pixels)
page.screenshot("h.png", width=800, height=600) # fixed 800x600 output
```

### Async client

```python
import asyncio
from lithium import AsyncClient

async def main():
    async with AsyncClient() as client:
        r = await client.get("https://example.com")
        print(r.status_code)

asyncio.run(main())
```

### Streaming downloads

```python
client = Client()
client.stream_to_file("https://example.com/bigfile.bin", "bigfile.bin")
```

### Proxies & rotating proxies

```python
# single proxy
client = Client(proxy="socks5://user:pass@host:1080")

# rotating proxy pool (round-robin per request)
client = Client(proxies=[
    "socks5://u:p@proxy1:1080",
    "socks5://u:p@proxy2:1080",
])
```

### Custom CA & mutual TLS

```python
client = Client(
    ca_cert="/path/to/ca.pem",
    client_cert="/path/to/client.crt",
    client_key="/path/to/client.key",
)
```

### Geo / locale auto-detection

```python
client = Client(auto_location=True)     # timezone + currency + country
r = client.get("https://example.com")
print(r.timezone, r.currency, r.country_code)
```

### Network inspection

```python
page = client.get("https://example.com")
for req in page.network.requests:        # every HTTP + in-page fetch/XHR
    print(req.method, req.status, req.url)
```

### Custom browser profile / fingerprint

```python
# pick a coherent identity (UA + JA3 + navigator + GPU all agree)
client = Client(platform="windows", browser="chrome", fingerprint="my-identity")
```

---

## One script that uses everything

```python
import asyncio, json
from lithium import Client, AsyncClient

# 1. Requests-style client, undetected by default
client = Client(proxies=["socks5://u:p@h1:1080", "socks5://u:p@h2:1080"])
client.get("https://example.com/set-session")

# 2. A page we interact with, like Selenium
page = client.get("https://example.com/login")
page.fill("#username", "alice")
page.fill("#password", "secret")
page.check("#terms")
page.click("button[type=submit]")
page.wait_for("#dashboard")
print("landed:", page.status_code)

# 3. DOM + JS
rows = page.dom.query_all("table tbody tr")
page.execute_script("document.title = 'hello from js'")

# 4. Run JS that does real network (in-page fetch)
page.execute_script(
    "fetch('/api/data').then(r=>r.json())"
    ".then(d=>document.body.setAttribute('data-h', d.hello));"
)

# 5. Rich media + screenshot
print("media:", page.media)            # discovered video/audio/img sources
page.screenshot("dashboard.png")

# 6. Inspect every request the session made (HTTP + JS-driven)
for req in page.network.requests:
    print("  net:", req.method, req.status, req.url)

# 7. Large download, streamed to disk
client.stream_to_file("https://example.com/db.sqlite", "db.sqlite")

# 8. Plain HTTP / JSON when you don't need a page
r = client.post("https://example.com/api", json={"a": 1})
print(r.json())

# 9. Async when you need concurrency
async def main():
    async with AsyncClient() as ac:
        results = await asyncio.gather(*[ac.get("https://example.com") for _ in range(5)])
        print([r.status_code for r in results])
asyncio.run(main())
```

---

## Config reference

All options work as **direct keyword args** to `Client(...)` or via a `Config`/JSON file.

| Option | Default | What it does |
|---|---|---|
| `profile` | `"chrome-windows"` | Coherent browser identity (UA + headers + JA3 + navigator). |
| `fingerprint` | `None` | Persistent identity name; same name = same profile across runs. |
| `platform` | `None` | Force `windows` / `macos` / `linux` / `android` / `ios`. |
| `browser` | `None` | Force `chrome` / `firefox` / `opera` / `brave`. |
| `proxy` | `None` | Single proxy URL (http/https/socks4/socks4a/socks5/socks5h). |
| `proxies` | `None` | List of proxy URLs, rotated round-robin per request. |
| `verify_ssl` | `True` | Verify server certificates. |
| `ca_cert`, `client_cert`, `client_key` | `None` | Custom CA bundle and client certificate (mTLS). |
| `trust_env` | `True` | Respect `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY` env vars. |
| `tls_spoof` | `None` | Explicitly enable/disable TLS (JA3) impersonation. |
| `http3` | `False` | Try HTTP/3 (QUIC) with graceful h1/h2 fallback. |
| `execute_scripts` | `False` | Auto-run inline `<script>` on page load. |
| `js_timeout` | `10.0` | Max seconds a JS call may run. |
| `timeout` | `30.0` | Default request timeout (seconds). |
| `max_redirects` | `0` | Redirect limit (0=none, `N>0`=follow up to N). |
| `max_retries` | `0` | Auto-retry count for transient errors (429/5xx). |
| `retry_backoff` | `0.5` | Seconds between retries (doubles each attempt). |
| `retry_codes` | `(429,500,502,503,504)` | Status codes that trigger a retry. |
| `auth` | `None` | `(user, pass)` tuple for HTTP Basic auth on every request. |
| `max_idle_conns`, `max_idle_conns_per_host` | `100`, `10` | HTTP connection-pool sizing. |
| `viewport_width`, `viewport_height` | platform default | Screen/inner size (`screen`, `innerWidth`, `matchMedia`). |
| `device_scale_factor` | platform default | `devicePixelRatio` (mobile auto-sets to 2). |
| `screen_color_depth` | `24` | `screen.colorDepth` / `pixelDepth`. |
| `hardware_concurrency`, `device_memory` | from fingerprint | `navigator.hardwareConcurrency` / `.deviceMemory`. |
| `max_touch_points` | `0` | `navigator.maxTouchPoints`. |
| `do_not_track` | `None` | `navigator.doNotTrack`. |
| `audio_sample_rate` | `44100` | `AudioContext().sampleRate`. |
| `screenshot_width`, `screenshot_height` | `1280`, `None` | Page size (width/height) for `page.screenshot()`. |
| `virtual_gpu` | `True` | Enable virtual WebGL context. |
| `canvas_noise` | `True` | Make `toDataURL` vary per identity. |
| `auto_location`, `auto_timezone`, `auto_currency`, `auto_language` | `None` | Auto-detect locale from your IP (via `geo_url`). |
| `geo_url` | ip-api.com | Custom geo lookup endpoint. |
| `storage_file` | `None` | Persist `localStorage` across runs/sessions. |
| `socket_timeout` | `None` | Seconds to wait for a reply from the runtime. |
| `shared` | `True` | Reuse one runtime process across clients. |
| `binary` | auto | Path to the runtime binary (override). |
| `verbose` | `False` | Print go-side logs to stderr. |
| `solution` | `False` | Attach live DNS/TCP/TLS diagnostics to errors. |

---

## Browser runtime — every Web API

`page.execute_script(js)` runs real JavaScript against the page's live DOM, and a large slice of the browser API surface is implemented. Here is each API and how to use it.

> In the examples below, `page = client.get("https://example.com")` gives you a live page whose DOM and JS state persist across `execute_script` calls.

### console & timers

```python
page = client.get("https://example.com")
page.execute_script("console.log('hello from js')")
page.execute_script("setTimeout(() => document.body.dataset.t = '1', 50)")
page.execute_script("requestAnimationFrame(() => document.body.dataset.raf = '1')")
```

### TextEncoder / TextDecoder

```python
# crypto.subtle.digest needs an ArrayBuffer; these encode/decode it
page.execute_script("window.__enc = new TextEncoder().encode('hi').length")   # 2
page.execute_script("window.__dec = new TextDecoder().decode(new Uint8Array([104,105]))")  # "hi"
```

### WebCrypto

```python
page.execute_script("""
  window.__rv = crypto.randomUUID();                       // e.g. "8f...-...."
  var b = new Uint8Array(8); crypto.getRandomValues(b);    // random bytes
  window.__len = b.length;
  crypto.subtle.digest('SHA-256', new TextEncoder().encode('hi'))
    .then(function(buf){ window.__digest = new Uint8Array(buf).length; });   // 32
""")
print(page.execute_script("window.__digest"))   # "32"
```

### performance

```python
page.execute_script("window.__now = typeof performance.now() === 'number'")
page.execute_script("window.__mem = typeof performance.memory")   # "object" on chrome-family
```

### matchMedia

```python
page.execute_script("matchMedia('(min-width: 100px)').matches")     # "true"
page.execute_script("matchMedia('(prefers-color-scheme: dark)').matches")  # "false"
```

### getComputedStyle

```python
page = client.get("https://example.com")
page.execute_script("document.body.setAttribute('style','display: flex')")
page.execute_script("window.getComputedStyle(document.body).display")  # "flex"
```

### Fonts (document.fonts — realistic, per-identity)

Real browsers expose the OS font list; this runtime ships a curated per-platform
list with a **deterministic subset per identity**, probed via `document.fonts.check`:

```python
page.execute_script('document.fonts.check("12px Arial")')          # True (common font)
page.execute_script('document.fonts.check("12px Consolas")')       # varies by identity, stable
page.execute_script('document.fonts.check("12px NotARealFont")')   # False
page.execute_script("document.fonts.status")                       # "loaded"

# each identity gives a distinct-but-stable font signature
page.execute_script("JSON.stringify([...document.fonts.values()].length)")  # count of "installed"

# FontFace constructor + load()
page.execute_script("new FontFace('MyFont', 'url(f.woff2)').status")     # "loaded"
page.execute_script("document.fonts.load('12px Arial')")                 # resolved Promise

# measureText widths are font- and size-dependent (real glyph metrics)
page.execute_script("var c=document.createElement('canvas'),x=c.getContext('2d')")
page.execute_script("x.font='16px Arial';      x.measureText('Hi').width")  # 38.4
page.execute_script("x.font='16px Courier New'; x.measureText('Hi').width")  # ~34.7 (different)
page.execute_script("x.font='32px Arial';      x.measureText('Hi').width")  # ~76.8 (scales)
```

### navigator — a fully coherent profile

```python
page.execute_script("JSON.stringify({ua:navigator.userAgent, platform:navigator.platform, vendor:navigator.vendor})")
# hardware, memory, plugins, languages, javaEnabled, sendBeacon, doNotTrack, getBattery, permissions...
page.execute_script("navigator.hardwareConcurrency")   # 8
page.execute_script("navigator.javaEnabled()")         # "false"
```

### screen & window

```python
page.execute_script("JSON.stringify([screen.width, screen.height, screen.colorDepth, devicePixelRatio])")
# e.g. [1920, 1080, 24, 1] on desktop, [390, 844, 24, 2] on a mobile fingerprint
```

### Storage — localStorage / sessionStorage

```python
page.execute_script("localStorage.setItem('greeting','hi')")
page.execute_script("localStorage.getItem('greeting')")   # "hi"
# sessionStorage is per-session; set storage_file=... to persist localStorage across runs
```

### Network from JS — fetch() & XMLHttpRequest (real HTTP)

```python
page.execute_script("""
  fetch('/api/data').then(r => r.json())
    .then(d => document.body.setAttribute('data-hello', d.hello));
""")
page.execute_script("""
  var x = new XMLHttpRequest();
  x.open('GET', '/api/data');
  x.onload = function(){ document.body.setAttribute('data-x', x.responseText); };
  x.send();
""")
```

### MutationObserver

```python
page.execute_script("""
  window.__seen = null;
  var t = document.querySelector('body');
  new MutationObserver(function(recs){ window.__seen = recs[0].attributeName; })
    .observe(t, {attributes:true});
  t.setAttribute('data-flag','1');            // triggers the observer
""")
print(page.execute_script("window.__seen"))    # "data-flag"
```

### WebSocket (real connection)

```python
page.execute_script("""
  window.__msg = null;
  var ws = new WebSocket('wss://echo.websocket.org');
  ws.onopen = function(){ ws.send('ping'); };
  ws.onmessage = function(e){ window.__msg = e.data; };
""")
```

### IndexedDB

```python
page.execute_script("""
  window.__got = null;
  var req = indexedDB.open('db', 1);
  req.onupgradeneeded = function(){
    var db = req.result;
    if(!db.objectStoreNames.contains('k')) db.createObjectStore('k');
  };
  req.onsuccess = function(){
    var tx = req.result.transaction('k','readwrite').objectStore('k');
    tx.put('v','key');
    window.__got = 'ready';
  };
""")
```

### History & location

```python
page.execute_script("history.pushState({}, '', '/pushed?x=1')")
page.execute_script("location.pathname + location.search")   # "/pushed?x=1"
```

### Permissions & Battery

```python
page.execute_script("navigator.permissions.query({name:'geolocation'}).then(s=>window.__perm=s.state)")
page.execute_script("navigator.getBattery().then(b=>window.__battery=b.level)")
```

### Canvas 2D

```python
page.execute_script("""
  var c = document.createElement('canvas');
  var x = c.getContext('2d');
  x.fillStyle = 'red'; x.fillRect(0,0,10,10);
  c.toDataURL();                 // "data:image/png;base64,..." (varies per identity if canvas_noise)
""")
```

### WebGL

```python
page.execute_script("""
  var c = document.createElement('canvas');
  var gl = c.getContext('webgl');
  var ext = gl.getExtension('WEBGL_debug_renderer_info');
  JSON.stringify([gl.getParameter(ext.UNMASKED_VENDOR_WEBGL), gl.getParameter(ext.UNMASKED_RENDERER_WEBGL)]);
""")
```

### AudioContext

```python
page.execute_script("new AudioContext().sampleRate")        # "44100" (configurable)
page.execute_script("new OfflineAudioContext(1, 4096, 44100).startRendering()")
```

### Media elements

```python
page.execute_script("document.createElement('video').canPlayType('video/mp4; codecs=\"avc1.42E01E, mp4a.40.2\"')")  # "probably"
page.execute_script("document.createElement('video').canPlayType('video/mp4')")  # "maybe"
```

---

## Element API

`page.dom.query(sel)` and `page.dom.query_all(sel)` return `Element` objects with these methods:

```python
page = client.get("https://example.com")
el = page.dom.query("#content")            # or .query_all(".item")

el.get_attr("data-price", default="0")     # read an attribute
el.set_attr("data-price", "99")            # set an attribute
el.text                                   # trimmed text content
el.html                                   # outer HTML
el.remove()                               # remove from the DOM

# DOM events (Python callbacks)
def on_click(e): print("clicked", e["type"], e["target"])
el.add_event_listener("click", on_click)
el.dispatch_event("click")                 # fires the python listener
```

Interaction methods on `page` itself (Selenium-style) all accept a selector:

```python
page.fill("#username", "alice")            # set input value (fires input/change)
page.type("#username", "ab", delay=0.05)   # type char-by-char (fires keydown/input/keyup)
page.click("button[type=submit]")          # click; navigates -> page updates in place
page.submit("#myform")                     # submit a form
page.select_option("select[name=color]", "blue")
page.check("#terms") / page.uncheck("#terms")
page.clear("#username")
page.wait_for("#dashboard", timeout=5.0)   # poll until the selector matches
page.goto("https://example.com/next")      # navigate; page updates in place
```

Selenium pointer/keyboard actions (all dispatch real DOM events):

```python
page.hover("#menu")                        # mouseover/enter/move
page.double_click("#row")                  # dblclick
page.right_click("#target")                # contextmenu (right click)
page.send_keys("#input", "hello")          # keydown/keypress/keyup
page.drag_and_drop("#drag", "#drop")       # dragstart -> drop -> mouseup
```

The same methods live on `Element` (`el.hover()`, `el.double_click()`, `el.right_click()`, `el.send_keys("x")`, `el.drag_to(target)`).

### Human-like mouse movement (Fitts's law)

`lithium.human_mouse.trajectory(start, end, ...)` generates a **minimum-jerk**
(Fitts's-law) cursor path — it accelerates, coasts, then decelerates into the
target with a slight overshoot + correction, like a real hand:

```python
from lithium import Client
from lithium.human_mouse import trajectory

client = Client()
page = client.get("https://example.com")

# a realistic cursor path from (0,0) to (400,120)
path = trajectory((0, 0), (400, 120), duration=0.6, overshoot=True)
for x, y, t in path:                        # replay each point with timing
    time.sleep(0.005)                       # -> human-looking momentum
```

---

## BeautifulSoup-style DOM

`page.dom` and every `Element` speak fluent BeautifulSoup — no second parser needed:

```python
page = client.get("https://example.com")

page.title                                    # the <title> text
page.find("div")                              # first <div> in the document
page.dom.find("div", {"id": "content"})       # bs4 `.find(name, attrs=...)`
page.dom.find_all("p", class_="product")      # `.find_all` with class_=
page.dom.select(".product")                   # CSS selector (`.select`)
page.dom.select_one("#content")

el = page.dom.find("div")
el.name                                       # tag name
el.string / el.get_text()                     # text content
el.get("data-price", default="0")             # attribute access
el.has_attr("id")
el.parent / el.children / el.siblings         # tree traversal
el.next_sibling / el.previous_sibling
el.descendants                                # every descendant element
el.find("a") / el.find_all("span")            # search within a subtree
el.select(".x") / el.select_one(".x")
el.find_next_sibling("p") / el.find_previous_sibling("p")
el.decompose() / el.extract()                 # remove from the tree
```

---

## New helpers & everyday conveniences

```python
client = Client()

# generic method + query params + basic auth
client.request("POST", url, json={"a": 1})
client.get(url, params={"page": 2, "q": "hello world"})
client.get(url, auth=("user", "pass"))

# automatic retries with exponential backoff (retries 429/5xx)
client = Client(max_retries=3, retry_backoff=0.5, retry_codes=(429, 500, 502, 503, 504))

# concurrent requests (bounded thread pool)
results = client.map([url1, url2, url3], max_workers=8)

# cookies
client.cookies(url)          # -> dict of name -> value
client.clear_cookies()

# response helpers
resp.raise_for_status()
resp.save("body.html")
for chunk in resp.iter_content(65536): ...
for line in resp.iter_lines(): ...
resp.cookies                 # cookies for this response's url
```

---

## Advanced HTTP

### Redirects

```python
# redirects are NOT followed by default (the 3xx response is returned as-is)
client = Client()
client.get("https://example.com/302")   # -> status_code 302

# follow up to N redirects
client = Client(max_redirects=5)
client.get("https://example.com/302")   # -> status_code 200 (final url)
```

### HTTP/3 (QUIC)

```python
client = Client(http3=True)      # tries h3, falls back to h1/h2 cleanly
```

### Download to disk

```python
client = Client()
client.download("https://example.com/file.bin", "file.bin")       # buffered
client.stream_to_file("https://example.com/big.bin", "big.bin")   # streamed
```

### HTTP trailers

```python
r = client.get("https://example.com")
print(r.trailers)      # trailing headers (dict)
```

### Custom profile from a JSON file

```python
# profile.json -> {"user_agent": "...", "platform": "Win32", "accept_language": "en-US,en;q=0.9", ...}
client = Client(profile="/path/to/profile.json")
```

### Storage persistence

```python
client = Client(storage_file="store.json")   # localStorage survives restarts/sessions
```

### Diagnostics / debugging

```python
client = Client(verbose=True)    # print go-side logs to stderr
client = Client(solution=True)   # attach live DNS/TCP/TLS diagnostics to any error
```

---

## Why lithium (comparison)

| Capability | **lithium** | primp | tls_client | curl_cffi |
|---|:---:|:---:|:---:|:---:|
| **HTTP core** | | | | |
| TLS (JA3) impersonation | ✅ | ✅ | ✅ | ✅ |
| Coherent multi-layer identity (UA↔headers↔navigator↔GPU↔screen) | ✅ | partial | partial | partial |
| Async client | ✅ | ✅ | ✅ | ✅ |
| HTTP/2 fingerprint (Chrome SETTINGS) | partial | ✅ | ✅ | ✅ |
| HTTP/3 (QUIC) | opt-in | ✅ | ✅ | ✅ |
| Streaming downloads | ✅ | ✅ | ✅ | ✅ |
| Proxy rotation / mTLS / CA | ✅ | ✅ | ✅ | ✅ |
| Custom CA + client cert | ✅ | ✅ | ✅ | ✅ |
| Geo / locale auto-detection | ✅ | ❌ | ❌ | ❌ |
| **Browser runtime** (the big differentiator) | | | | |
| **JavaScript execution engine** | ✅ | ❌ | ❌ | ❌ |
| **Full DOM parsing + query** (`query`/`query_all`/`getElementById`) | ✅ | ❌ | ❌ | ❌ |
| **Selenium-style interaction** (`click`/`fill`/`type`/`select`/`wait_for`) | ✅ | ❌ | ❌ | ❌ |
| **Element events** (`addEventListener`/`dispatchEvent`) | ✅ | ❌ | ❌ | ❌ |
| **In-page `fetch()`/XHR → real HTTP** | ✅ | ❌ | ❌ | ❌ |
| **`localStorage`/`sessionStorage`** (+ optional persistence) | ✅ | ❌ | ❌ | ❌ |
| **IndexedDB** | ✅ | ❌ | ❌ | ❌ |
| **WebSocket** (real connection) | ✅ | ❌ | ❌ | ❌ |
| **WebCrypto** (`getRandomValues`/`randomUUID`/`subtle.digest`) | ✅ | ❌ | ❌ | ❌ |
| **MutationObserver** | ✅ | ❌ | ❌ | ❌ |
| **History/location API** | ✅ | ❌ | ❌ | ❌ |
| **Virtual Canvas 2D + WebGL** | ✅ | ❌ | ❌ | ❌ |
| **AudioContext / OfflineAudioContext** | ✅ | ❌ | ❌ | ❌ |
| **matchMedia / getComputedStyle / performance / permissions / getBattery** | ✅ | ❌ | ❌ | ❌ |
| **Media discovery + download** (`page.media`) | ✅ | ❌ | ❌ | ❌ |
| **Screenshots** | ✅ | ❌ | ❌ | ❌ |
| **Network inspection** (`page.network.requests`) | ✅ | ❌ | ❌ | ❌ |
| **Zero-config browser identity** (no libs/browser install) | ✅ | ❌ | ❌ | ❌ |
| Per-request custom fingerprint profiles | ✅ | partial | partial | partial |
| Live diagnostics on errors (`solution=True`) | ✅ | ❌ | ❌ | ❌ |

---

## High stealth: Sannysoft proof

The runtime drives the **official `bot.sannysoft.com`** page and reads its **own detection table**, field by field. Every bot-detection field passes — **10 / 10 = 100%**.

```python
import json, re
from lithium import Client

with Client(execute_scripts=False, browser="chrome", platform="windows") as client:
    page = client.get("https://bot.sannysoft.com/")
    html = page.text

    # the site's own detection needs lodash (it uses _.has on navigator.webdriver)
    page.execute_script(
        client.get("https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.11/lodash.min.js", timeout=15).text
    )

    # run the site's OWN detection script (fills the real result table)
    for s in re.findall(r'<script(?![^>]*src)[^>]*>(.*?)</script>', html, re.S):
        if "runBotDetection" in s:
            try:
                page.execute_script(s)   # the site's real detection code
            except Exception:
                pass                     # harmless canvas-hash noise; table already filled
            break

    # read EVERY detection field the site marks pass/fail
    fields = json.loads(page.execute_script(r"""
      var out = [];
      function ok(el){ var c=' '+(el.className||'')+' '; return c.indexOf(' passed ')!==-1; }
      document.querySelectorAll('.result').forEach(function(el){
        out.push({field:el.id.replace('-result',''), ok:ok(el), value:(el.textContent||'').trim().slice(0,28)});
      });
      ['webgl-vendor','webgl-renderer'].forEach(function(id){
        var el=document.getElementById(id);
        out.push({field:id, ok:ok(el), value:(el.textContent||'').trim().slice(0,28)});
      });
      JSON.stringify(out);
    """))

    passed = sum(1 for f in fields if f['ok']); total = len(fields)
    for f in fields:
        print(f"  [{'PASS' if f['ok'] else 'FAIL'}] {f['field']:>18}  {f['value']}")
    pct = 100.0 * passed / total
    verdict = "PERFECT 100%" if pct >= 100 else ("90+ (high stealth)" if pct >= 90 else "below 90")
    print(f"SANNYSOFT: {passed}/{total} = {pct:.1f}% -> {verdict}")
```

Output:

```
  [PASS]         user-agent  Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/...
  [PASS]          webdriver  missing (passed)
  [PASS] advanced-webdriver  passed
  [PASS]             chrome  present (passed)
  [PASS]        permissions  prompt
  [PASS]     plugins-length  5
  [PASS]       plugins-type  passed
  [PASS]          languages  en-US,en
  [PASS]       webgl-vendor  Google Inc. (NVIDIA)
  [PASS]     webgl-renderer  ANGLE (NVIDIA, NVIDIA GeForce ...)
SANNYSOFT: 10/10 = 100.0% -> PERFECT 100%
```

> **Full honesty about the site's other cells:** Sannysoft also has an advanced `#fp2` table (21 fingerprint tests) filled by the site's async `fpCollect.generateFingerprint()`, which doesn't resolve inside a scripted runtime yet — so it can't be driven from a script. Its `broken-image` and `canvas#` cells are display cells (they need real `Image.onerror` and canvas rasterisation), not pass/fail bot checks. The 10 fields above are the core stealth/bot-detection checks, and all pass.

> Proof of undetected: the default `Client()` also speaks a Chrome **JA3** (e.g. `771,4865-4866-4867-49195-49199-...`) verified against `https://tls.peet.ws/api/all`, and the JA3 version matches the UA version.

---

## Every feature at a glance

**HTTP**
- `get` / `post` / `put` / `patch` / `delete` / `head` / `options`
- `request(method, url, ...)` generic method + query `params=`
- Bodies: plain bytes/str, JSON (`json=`), urlencoded (`data=dict`), multipart (`files=`)
- Cookies persisted per session automatically + `client.cookies()` / `clear_cookies()`
- Redirect policy (`max_redirects`)
- Basic auth (`auth=(user, pass)`)
- Retries with backoff (`max_retries`, `retry_backoff`, `retry_codes`)
- Concurrent requests (`client.map(urls, max_workers=)`)
- Rotating proxy pools (`proxies=[...]`)
- Connection pooling tuning (`max_idle_conns*`)
- Streaming downloads (`stream_to_file`)
- Custom CA + mTLS (`ca_cert` / `client_cert` / `client_key`)
- Geo / locale auto-detect (`auto_location` + friends, custom `geo_url`)
- Network inspection (`client.network`, `page.network.requests`)
- Async client (`AsyncClient`)
- `raise_for_status()`, `save()`, `iter_content()`, `iter_lines()`
- HTTP trailers (`resp.trailers`)

**Browser runtime**
- DOM: `page.dom.query` / `query_all`, attributes, text, html, events
- Interaction: `fill`, `type`, `click`, `submit`, `select_option`, `check`, `uncheck`, `clear`, `wait_for`, `goto`
- JavaScript: `page.execute_script`, auto-run inline scripts
- In-page `fetch()` / `XMLHttpRequest` → real HTTP
- Storage: `localStorage`, `sessionStorage` (optionally persisted)
- APIs: `WebCrypto`, `MutationObserver`, `History`/`location`, `WebSocket`, `IndexedDB`, `matchMedia`, `getComputedStyle`, `performance`, `permissions`, `getBattery`
- Virtual graphics: Canvas 2D, WebGL, `AudioContext`
- Screenshots (`page.screenshot`)
- Media discovery + download (`page.media`, `download_media`)
- BeautifulSoup-style DOM (`find`/`find_all`/`select`, `name`/`string`/`get_text`, `parent`/`children`/`siblings`/`descendants`, `next_sibling`/`previous_sibling`)
- Coherent fingerprints (UA ↔ JA3 ↔ `navigator` ↔ GPU ↔ screen)

---

## API reference — unique functions

**`Client`**
- `client.get/post/put/patch/delete/head/options(...)`
- `client.download(url, path)` — fetch and write to disk
- `client.stream_to_file(url, path)` — stream response straight to disk
- `client.network` — `NetworkLog` of every request (iterable, `.requests`, `.responses`)
- `client.close()` / context manager

**`Response` (a.k.a. the page)**
- `status_code`, `headers`, `trailers`, `url`, `elapsed_ms`, `ok`, `text`, `json()`, `content`
- `html` (full live-DOM HTML), `title` (the page `<title>`)
- `timezone`, `currency`, `country_code` (when auto-location on)
- `dom` → query/query_all/wait_for
- `execute_script(js)`
- `fill`, `type`, `click`, `submit`, `select_option`, `check`, `uncheck`, `clear`
- `hover`, `double_click`, `right_click`, `send_keys`, `drag_and_drop` (Selenium-style)
- `wait_for(selector, timeout=)`, `goto(url)` (both update the page in place)
- `screenshot(path=None, width=None, height=None, scale=2.0)` → real CSS render, exact pixel dims
- `network` → `NetworkLog`
- `media` → discovered `<video>/<audio>/<img>` sources
- `download(url, path)`, `download_media(dir, kinds=None)`
- `stream(chunk_size=)` — chunked iterator
- `cookies` → dict for this response's url; `raise_for_status()`, `save(path)`, `iter_content`, `iter_lines`

**`Element`**
- `text`, `html`, `get_attr(name, default=None)`, `set_attr(name, value)`, `remove()`
- `add_event_listener(event, cb)`, `dispatch_event(event, detail=None)`
- `click()`, `fill(v)`, `type(text, delay=)`, `select_option(v)`, `clear()`, `check()`, `uncheck()`, `submit()`
- `hover()`, `double_click()`, `right_click()`, `send_keys(text)`, `drag_to(target)`
- bs4: `name`, `string`, `strings`, `get_text()`, `get(k)`, `has_attr(k)`, `parent`, `children`, `siblings`, `descendants`, `next_sibling`, `previous_sibling`, `find`, `find_all`, `select`, `select_one`, `find_next_sibling`, `find_previous_sibling`, `decompose()`, `extract()`

**`AsyncClient`**
- `await` versions of `get/post/put/patch/delete/head/options/stream_to_file/download`, plus `network`.

**`human_mouse`**
- `trajectory(start, end, duration=, overshoot=, steps=)` → min-jerk (Fitts's-law) cursor path.

---

# Made by Blaze, available on [discord](https://discord.com/users/1238444724386533417) & [github](https://github.com/OfficialDex)
feel free to contact me regarding any issues, suggestions or queries

## License & note

`lithium-web` is under active development. The name, PyPI, and trademark availability should be confirmed before any public release, since similarly-named projects already exist.



