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.
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
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).
# 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
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
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
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
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:
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
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
client = Client()
client.stream_to_file("https://example.com/bigfile.bin", "bigfile.bin")
Proxies & rotating proxies
# 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
client = Client(
ca_cert="/path/to/ca.pem",
client_cert="/path/to/client.crt",
client_key="/path/to/client.key",
)
Geo / locale auto-detection
client = Client(auto_location=True) # timezone + currency + country
r = client.get("https://example.com")
print(r.timezone, r.currency, r.country_code)
Network inspection
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
# pick a coherent identity (UA + JA3 + navigator + GPU all agree)
client = Client(platform="windows", browser="chrome", fingerprint="my-identity")
One script that uses everything
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 acrossexecute_scriptcalls.
console & timers
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
# 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
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
page.execute_script("window.__now = typeof performance.now() === 'number'")
page.execute_script("window.__mem = typeof performance.memory") # "object" on chrome-family
matchMedia
page.execute_script("matchMedia('(min-width: 100px)').matches") # "true"
page.execute_script("matchMedia('(prefers-color-scheme: dark)').matches") # "false"
getComputedStyle
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:
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
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
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
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)
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
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)
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
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
page.execute_script("history.pushState({}, '', '/pushed?x=1')")
page.execute_script("location.pathname + location.search") # "/pushed?x=1"
Permissions & Battery
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
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
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
page.execute_script("new AudioContext().sampleRate") # "44100" (configurable)
page.execute_script("new OfflineAudioContext(1, 4096, 44100).startRendering()")
Media elements
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:
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:
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):
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:
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:
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
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
# 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)
client = Client(http3=True) # tries h3, falls back to h1/h2 cleanly
Download to disk
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
r = client.get("https://example.com")
print(r.trailers) # trailing headers (dict)
Custom profile from a JSON file
# profile.json -> {"user_agent": "...", "platform": "Win32", "accept_language": "en-US,en;q=0.9", ...}
client = Client(profile="/path/to/profile.json")
Storage persistence
client = Client(storage_file="store.json") # localStorage survives restarts/sessions
Diagnostics / debugging
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%.
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
#fp2table (21 fingerprint tests) filled by the site's asyncfpCollect.generateFingerprint(), which doesn't resolve inside a scripted runtime yet — so it can't be driven from a script. Itsbroken-imageandcanvas#cells are display cells (they need realImage.onerrorand 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 againsthttps://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/optionsrequest(method, url, ...)generic method + queryparams=- 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, customgeo_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 diskclient.stream_to_file(url, path)— stream response straight to diskclient.network—NetworkLogof 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(),contenthtml(full live-DOM HTML),title(the page<title>)timezone,currency,country_code(when auto-location on)dom→ query/query_all/wait_forexecute_script(js)fill,type,click,submit,select_option,check,uncheck,clearhover,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 dimsnetwork→NetworkLogmedia→ discovered<video>/<audio>/<img>sourcesdownload(url, path),download_media(dir, kinds=None)stream(chunk_size=)— chunked iteratorcookies→ 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
awaitversions ofget/post/put/patch/delete/head/options/stream_to_file/download, plusnetwork.
human_mouse
trajectory(start, end, duration=, overshoot=, steps=)→ min-jerk (Fitts's-law) cursor path.
Made by Blaze, available on discord & github
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.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distributions
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file lithium_web-1.0-py3-none-any.whl.
File metadata
- Download URL: lithium_web-1.0-py3-none-any.whl
- Upload date:
- Size: 38.7 MB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.14.4
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
23c408ec92e144bd1343e9fff79777ea060b1618ba2d93273c65ee89ba5d0272
|
|
| MD5 |
4e7814292b1432e1dae1b10574b1f7da
|
|
| BLAKE2b-256 |
36d8e7230894009418eebfa71aac0f64ed68992332b8c425a98835834aec8521
|