mirror of
https://github.com/lexmount/moli.git
synced 2026-09-30 00:01:36 +00:00
feat(benchmark): add native CDP fingerprint survey runner
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
# Native CDP fingerprint observations
|
||||
|
||||
This observational suite visits twelve public fingerprint/bot-test sites. It is
|
||||
not a performance benchmark or a bot-evasion guarantee. `complete` means the
|
||||
collector obtained its required fields, **not** that the site judged the browser
|
||||
normal. Missing, partial, timed-out, network-failed and crashed samples stay in
|
||||
the report; repetitions are declared before running, never retried to green.
|
||||
|
||||
Install the optional client and run from the repository root:
|
||||
|
||||
```sh
|
||||
uv sync --project moli-benchmark --locked --extra fingerprint
|
||||
xvfb-run -a uv run --project moli-benchmark --locked --extra fingerprint \
|
||||
python -m moli_benchmark.fingerprint \
|
||||
--moli-bin target/release/moli --chrome-bin /usr/lib/chromium/chromium \
|
||||
--output-dir target/benchmark/fingerprint-example
|
||||
```
|
||||
|
||||
Use the native Chromium executable and a real display (or Xvfb), not headless
|
||||
Chromium. This suite owns fresh loopback CDP servers and browser profiles. It
|
||||
does not override identity, disable TLS verification or launch Chromium with
|
||||
`--disable-gpu`. Proxy settings are disabled consistently. Moli uses `serve
|
||||
--layout --resource`. Both engines enable Runtime, Page and Network via CDP.
|
||||
Cases run serially with alternating engine order; no resource sampler runs
|
||||
beside the browser. Default Moli identity remains unchanged.
|
||||
|
||||
`--site ID` and `--engine moli|chromium` can repeat to select a subset. `--runs N`
|
||||
declares 1..10 repetitions; the default is one. Existing output directories are
|
||||
rejected. Results contain binary, client and collector provenance plus selected
|
||||
DOM/API values. API bodies, tokens, visitor IDs, IP addresses, console strings
|
||||
and page HTML are not stored. Script auditing retains only host, hash and size.
|
||||
|
||||
## Sampling contract
|
||||
|
||||
Each case waits its declared number of seconds after DOMContentLoaded. The
|
||||
interaction demo types fixed dummy credentials and submits its public demo form;
|
||||
Pixelscan clicks the public scan link. Both have an explicit post-action window.
|
||||
These are scripted actions, not human-behavior simulations. The manifest records
|
||||
all waits and limits; results from different workloads are not interchangeable.
|
||||
|
||||
Collectors read scoped DOM `textContent` or selected existing result objects.
|
||||
They never take a screenshot or force a rendering checkpoint to make a result
|
||||
appear. Network evidence is frozen before the DOM snapshot: only result bodies
|
||||
completed by that cutoff qualify. Subsequent audit work cannot fill a missing
|
||||
sample with a late response. Snapshot start/end and response timing are recorded.
|
||||
|
||||
Incolumitas Proxy currently redirects to a host with a certificate mismatch in
|
||||
the tested environment. No complete result schema has been verified there. Its
|
||||
collector reports labels/errors as **partial**, never as a successful proxy
|
||||
check. BrowserLeaks pages are capability reports, not binary bot verdicts.
|
||||
Pixelscan's summary can complete while individual sections are still collecting;
|
||||
their separate states remain visible. CreepJS ratings can be incomplete even
|
||||
when lie/error totals are available. Fingerprint Pro requires a finished,
|
||||
unambiguous API result; its empty HTML shell does not qualify.
|
||||
|
||||
Local real-DOM fixtures, independent of the public services:
|
||||
|
||||
```sh
|
||||
uv run --project moli-benchmark --locked --extra fingerprint python \
|
||||
moli-benchmark/tests/check_fingerprint_dom.py --engine moli \
|
||||
--binary target/release/moli --output target/benchmark/fingerprint-dom-moli
|
||||
```
|
||||
|
||||
These fixtures originated in the survey's CDP-smoke diagnostics. They test
|
||||
collector correctness, not new CDP product functionality.
|
||||
@@ -0,0 +1 @@
|
||||
"""Observational public-site compatibility benchmark; never an aggregate bot pass gate."""
|
||||
@@ -0,0 +1,3 @@
|
||||
from .cli import main
|
||||
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Native launch profile using the benchmark's existing owned-process manager."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
import socket
|
||||
|
||||
from ..artifacts import write_json
|
||||
from ..raw_cdp import discover_websocket_url
|
||||
from ..target_serve import start_target_serve, stop_target_serve
|
||||
|
||||
|
||||
def endpoint_closed(endpoint: str) -> bool:
|
||||
port = int(endpoint.rsplit(":", 1)[1])
|
||||
with socket.socket() as sock:
|
||||
sock.settimeout(1)
|
||||
return sock.connect_ex(("127.0.0.1", port)) != 0
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def browser_session(binary: Path, engine: str, output: Path):
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
output.mkdir(parents=True, exist_ok=False)
|
||||
handle = None
|
||||
browser = None
|
||||
metadata = {"engine": engine, "native": True, "identity_override": False,
|
||||
"tls_verification": True, "process_cleaned_up": False}
|
||||
try:
|
||||
# Startup is bounded by the existing manager. Do not cancel a to_thread
|
||||
# launch: its still-running thread could otherwise leave an unowned process.
|
||||
target = "moli-full-cdp" if engine == "moli" else "chrome-cdp"
|
||||
handle = start_target_serve(target, binary, 15, native=True)
|
||||
websocket = await discover_websocket_url(handle.endpoint)
|
||||
async with async_playwright() as pw:
|
||||
browser = await pw.chromium.connect_over_cdp(websocket, timeout=15000)
|
||||
try:
|
||||
context = await asyncio.wait_for(browser.new_context(no_viewport=True), 15)
|
||||
page = await asyncio.wait_for(context.new_page(), 15)
|
||||
cdp = await asyncio.wait_for(context.new_cdp_session(page), 10)
|
||||
metadata["browser"] = await asyncio.wait_for(cdp.send("Browser.getVersion"), 10)
|
||||
for domain in ["Runtime", "Page", "Network"]:
|
||||
await asyncio.wait_for(cdp.send(domain + ".enable"), 10)
|
||||
yield page, cdp
|
||||
finally:
|
||||
if browser is not None:
|
||||
try:
|
||||
await asyncio.wait_for(browser.close(), 10)
|
||||
except Exception as error:
|
||||
metadata["close_error_type"] = type(error).__name__
|
||||
finally:
|
||||
if handle is not None:
|
||||
stopped = stop_target_serve(handle)
|
||||
metadata.update(returncode=stopped["returncode"], endpoint_closed=endpoint_closed(handle.endpoint))
|
||||
metadata["process_cleaned_up"] = stopped["process_exited"] and metadata["endpoint_closed"]
|
||||
write_json(output / "environment.json", metadata)
|
||||
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Case:
|
||||
id: str
|
||||
url: str
|
||||
wait_seconds: int
|
||||
kind: str
|
||||
action: str | None = None
|
||||
action_wait_seconds: int = 0
|
||||
|
||||
|
||||
# Keep the original twelve-site workload and fixed observation windows. Changing
|
||||
# a URL, action, window or projector changes the workload hash in every report.
|
||||
CASES = (
|
||||
Case("fingerprint-pro", "https://demo.fingerprint.com/playground", 12, "commercial-fingerprint"),
|
||||
Case("browserscan-bot", "https://www.browserscan.net/bot-detection", 10, "bot-signals"),
|
||||
Case("incolumitas-bot", "https://bot.incolumitas.com/", 16, "bot-and-behavior"),
|
||||
Case("incolumitas-proxy", "https://bot.incolumitas.com/proxy_detect.html", 15, "network-consistency"),
|
||||
Case("device-browser-static", "https://deviceandbrowserinfo.com/are_you_a_bot", 10, "bot-signals"),
|
||||
Case("device-browser-behavior", "https://deviceandbrowserinfo.com/are_you_a_bot_interactions", 20,
|
||||
"behavior", "demo_login", 20),
|
||||
Case("sannysoft", "https://bot.sannysoft.com/", 8, "legacy-headless"),
|
||||
Case("creepjs", "https://abrahamjuliot.github.io/creepjs/", 20, "fingerprint-consistency"),
|
||||
Case("pixelscan", "https://pixelscan.net/", 15, "fingerprint-consistency", "start_scan", 15),
|
||||
Case("browserleaks-js", "https://browserleaks.com/javascript", 8, "javascript-fingerprint"),
|
||||
Case("browserleaks-webgl", "https://browserleaks.com/webgl", 8, "graphics-fingerprint"),
|
||||
Case("browserscan-tls", "https://www.browserscan.net/tls", 10, "network-fingerprint"),
|
||||
)
|
||||
|
||||
|
||||
def select_cases(ids: list[str] | None = None) -> tuple[Case, ...]:
|
||||
known = {case.id for case in CASES}
|
||||
if ids is not None and (not ids or len(ids) != len(set(ids)) or set(ids) - known):
|
||||
raise ValueError("case selection must contain unique, known site IDs")
|
||||
return tuple(case for case in CASES if ids is None or case.id in ids)
|
||||
|
||||
|
||||
def manifest(cases: tuple[Case, ...]) -> list[dict]:
|
||||
return [asdict(case) for case in cases]
|
||||
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from ..config import RESULTS_ROOT, moli_binary, optional_binary
|
||||
from .cases import CASES, select_cases
|
||||
from .runner import run_suite
|
||||
|
||||
|
||||
def add_arguments(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument("--site", action="append", choices=[case.id for case in CASES])
|
||||
parser.add_argument("--engine", action="append", choices=["moli", "chromium"])
|
||||
parser.add_argument("--moli-bin")
|
||||
parser.add_argument("--chrome-bin")
|
||||
parser.add_argument("--runs", type=int, default=1, help="predeclared repetitions, never retries; 1..10")
|
||||
parser.add_argument("--output-dir", type=Path, default=None)
|
||||
|
||||
|
||||
def command(args) -> int:
|
||||
engines = args.engine or ["moli", "chromium"]
|
||||
if len(set(engines)) != len(engines):
|
||||
raise ValueError("engine selection must be unique")
|
||||
cases = select_cases(args.site)
|
||||
binaries = {}
|
||||
for engine in engines:
|
||||
binary = moli_binary(args.moli_bin) if engine == "moli" else optional_binary(
|
||||
"CHROME_BIN", ("chromium", "chromium-browser", "google-chrome"), args.chrome_bin)
|
||||
if binary is None:
|
||||
raise ValueError("native Chromium is required; supply --chrome-bin (no bundled browser is launched)")
|
||||
binaries[engine] = binary
|
||||
output = args.output_dir or RESULTS_ROOT / ("fingerprint-" + datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S.%fZ"))
|
||||
summary = asyncio.run(run_suite(output, binaries, cases, runs=args.runs))
|
||||
print(output)
|
||||
# A site's bot verdict never determines this process exit code. An incomplete
|
||||
# experiment or a broken collector still cannot silently return success.
|
||||
harness_errors = {"collector_error", "browser_crash", "cleanup_error"}
|
||||
return int(not summary["matrix_complete"] or not summary["inputs_unchanged"]
|
||||
or any(row["status"] in harness_errors for row in summary["suites"][0]["samples"]))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Native CDP twelve-site observations; availability is not bot pass rate")
|
||||
add_arguments(parser)
|
||||
try:
|
||||
return command(parser.parse_args())
|
||||
except (RuntimeError, ValueError, FileExistsError, ImportError) as error:
|
||||
parser.exit(1, f"fingerprint benchmark: {error}\n")
|
||||
except KeyboardInterrupt:
|
||||
return 130
|
||||
@@ -0,0 +1,92 @@
|
||||
// Passive projections of result containers, independent of layout and paint.
|
||||
// Return null ONLY for an unsupported site; missing results never select a fallback.
|
||||
({site}) => {
|
||||
const supported = ['browserscan-bot', 'browserscan-tls', 'creepjs'];
|
||||
if (!supported.includes(site)) return null;
|
||||
|
||||
const report = {readyState: document.readyState, valid: false, collector: 'dom-results-v1'};
|
||||
const text = node => (node?.textContent || '').trim().replace(/\s+/g, ' ');
|
||||
// Exclude explicitly inactive markup without consulting computed style or geometry.
|
||||
const active = node => !node.closest('script, style, template, [hidden], [aria-hidden="true"]');
|
||||
const labelled = (selector, label) => Array.from(document.querySelectorAll(selector))
|
||||
.filter(node => active(node) && text(node) === label);
|
||||
|
||||
if (site === 'browserscan-bot') {
|
||||
const labels = labelled('strong', 'Test Results:');
|
||||
report.verdict = null;
|
||||
report.state = labels.length === 0 ? 'missing' : labels.length > 1 ? 'ambiguous' : 'pending';
|
||||
if (labels.length === 1) {
|
||||
// The label and verdict are sibling <strong>s; do not scan explanatory prose.
|
||||
const values = Array.from(labels[0].parentElement.querySelectorAll(':scope > strong'))
|
||||
.filter(node => node !== labels[0] && active(node));
|
||||
if (values.length === 1 && ['Normal', 'Robot'].includes(text(values[0]))) {
|
||||
report.verdict = text(values[0]);
|
||||
report.state = 'complete';
|
||||
report.valid = true; // Complete is not the same as a passing verdict.
|
||||
} else if (values.length > 1) {
|
||||
report.state = 'ambiguous';
|
||||
}
|
||||
}
|
||||
return report;
|
||||
}
|
||||
|
||||
if (site === 'browserscan-tls') {
|
||||
const fields = {
|
||||
akamaiHash: ['Akamai Hash', /^[a-f0-9]{32}$/i],
|
||||
ja3: ['JA3', /^\d+,(?:\d+(?:-\d+)*)?,(?:\d+(?:-\d+)*)?,(?:\d+(?:-\d+)*)?,(?:\d+(?:-\d+)*)?$/],
|
||||
ja4: ['JA4', /^[tqd][a-z0-9]{9}_[a-f0-9]{12}_[a-f0-9]{12}$/i],
|
||||
};
|
||||
report.kind = 'report-only';
|
||||
report.reportLabels = ['HTTP/2 Fingerprint', 'Akamai Hash', 'JA3', 'JA4']
|
||||
.filter(label => labelled('h3', label).length === 1);
|
||||
report.fields = Object.fromEntries(Object.entries(fields).map(([key, [label, pattern]]) => {
|
||||
const labels = labelled('h3', label);
|
||||
if (labels.length !== 1) return [key, labels.length ? 'ambiguous' : 'missing'];
|
||||
// A report card has a heading wrapper followed by a value wrapper. Avoid
|
||||
// hashed CSS classes and never accept hashes from unrelated cards/examples.
|
||||
const heading = labels[0].parentElement?.parentElement;
|
||||
const body = heading?.nextElementSibling;
|
||||
const values = body ? Array.from(body.querySelectorAll(':scope > div > p')).filter(active) : [];
|
||||
if (values.length > 1) return [key, 'ambiguous'];
|
||||
// The scalar is direct text in <p>. Ad annotations can append child DOM
|
||||
// (e.g. "Convert Data Files"); those are not part of the fingerprint.
|
||||
const value = Array.from(values[0]?.childNodes || []).filter(node => node.nodeType === Node.TEXT_NODE)
|
||||
.map(node => node.textContent).join('').trim();
|
||||
const state = !value || /^(?:loading[.\u2026]*|pending|-+)$/i.test(value) ? 'pending'
|
||||
: pattern.test(value) ? 'populated' : 'unrecognized';
|
||||
return [key, state]; // Do not retain the fingerprint or network identity.
|
||||
}));
|
||||
report.valid = report.reportLabels.length === 4
|
||||
&& Object.values(report.fields).every(state => state === 'populated');
|
||||
return report;
|
||||
}
|
||||
|
||||
// CreepJS publishes its completed measurements here. Rating fields are the
|
||||
// source for the DOM percentages; neither a layout snapshot nor modal text is needed.
|
||||
const fingerprint = window.Fingerprint;
|
||||
const count = value => Number.isInteger(value) && value >= 0 ? value : null;
|
||||
const boolean = value => typeof value === 'boolean' ? value : null;
|
||||
const percent = value => typeof value === 'number' && Number.isFinite(value)
|
||||
&& value >= 0 && value <= 100 ? value : null;
|
||||
report.metrics = {
|
||||
totalLies: count(fingerprint?.lies?.totalLies),
|
||||
capturedErrorCount: Array.isArray(fingerprint?.capturedErrors?.data)
|
||||
? fingerprint.capturedErrors.data.length : null,
|
||||
audioLied: boolean(fingerprint?.offlineAudioContext?.lied),
|
||||
svgLied: boolean(fingerprint?.svg?.lied),
|
||||
};
|
||||
const names = {likeHeadless: 'like headless', headless: 'headless', stealth: 'stealth'};
|
||||
report.ratings = Object.fromEntries(Object.keys(names)
|
||||
.map(key => [key, percent(fingerprint?.headless?.[key + 'Rating'])]));
|
||||
report.percentages = Object.entries(report.ratings)
|
||||
.filter(([, value]) => value !== null).map(([key, value]) => `${value}% ${names[key]}`);
|
||||
report.ratingsComplete = Object.values(report.ratings).every(value => value !== null);
|
||||
report.headlessSignals = Object.fromEntries(Object.keys(names).map(key => {
|
||||
const signals = fingerprint?.headless?.[key];
|
||||
return [key, signals && typeof signals === 'object' ? Object.fromEntries(Object.entries(signals)
|
||||
.filter(([name, value]) => /^[A-Za-z0-9_]{1,80}$/.test(name) && typeof value === 'boolean')) : null];
|
||||
}));
|
||||
// Retain the survey's core-result criterion, but expose partial ratings separately.
|
||||
report.valid = report.metrics.totalLies !== null && report.metrics.capturedErrorCount !== null;
|
||||
return report;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Bounded, allowlisted observations. Never save raw CDP messages or API bodies."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
from copy import deepcopy
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
||||
def is_fp_result(url: str) -> bool:
|
||||
parsed = urlsplit(url)
|
||||
return parsed.scheme == "https" and parsed.hostname == "demo.fingerprint.com" and parsed.path.startswith("/api/event/")
|
||||
|
||||
|
||||
def select_fp_result(value: object) -> dict:
|
||||
if not isinstance(value, dict):
|
||||
return {}
|
||||
result = {key: value[key] for key in ["tampering", "virtual_machine", "developer_tools", "incognito"]
|
||||
if type(value.get(key)) is bool}
|
||||
if value.get("bot") in ("not_detected", "good", "bad"):
|
||||
result["bot"] = value["bot"]
|
||||
for key in ["suspect_score", "tampering_ml_score", "virtual_machine_ml_score"]:
|
||||
score = value.get(key)
|
||||
maximum = 100 if key == "suspect_score" else 1
|
||||
if type(score) in {int, float} and math.isfinite(score) and 0 <= score <= maximum:
|
||||
result[key] = score
|
||||
return result
|
||||
|
||||
|
||||
def complete_fp_result(value: dict) -> bool:
|
||||
return all(key in value for key in ["bot", "tampering", "suspect_score", "virtual_machine"])
|
||||
|
||||
|
||||
def error_record(phase: str, error: BaseException) -> dict:
|
||||
return {"phase": phase, "type": type(error).__name__,
|
||||
"network_codes": sorted(set(re.findall(r"(?:net::)?ERR_[A-Z_]+", str(error))))}
|
||||
|
||||
|
||||
class Observer:
|
||||
def __init__(self, cdp, clock):
|
||||
self.cdp, self.clock = cdp, clock
|
||||
self.requests: dict[str, dict] = {}
|
||||
self.exception_count = 0
|
||||
self.network_failures: dict[str, int] = {}
|
||||
self.script_limit = 40
|
||||
self.script_count = 0
|
||||
self.script_limit_reached = False
|
||||
self.result_count = 0
|
||||
self.result_limit_reached = False
|
||||
cdp.on("Network.requestWillBeSent", self.requested)
|
||||
cdp.on("Network.responseReceived", self.received)
|
||||
cdp.on("Network.loadingFinished", self.finished)
|
||||
cdp.on("Network.loadingFailed", self.failed)
|
||||
cdp.on("Runtime.exceptionThrown", self.exception)
|
||||
|
||||
def requested(self, params):
|
||||
url = params.get("request", {}).get("url", "")
|
||||
kind = "result" if is_fp_result(url) else "script" if params.get("type") == "Script" else None
|
||||
# Redirects reuse a request id; the final destination must still qualify.
|
||||
self.requests.pop(params["requestId"], None)
|
||||
if kind == "script":
|
||||
self.script_count += 1
|
||||
if self.script_count > self.script_limit:
|
||||
self.script_limit_reached = True
|
||||
return
|
||||
if kind == "result":
|
||||
self.result_count += 1
|
||||
if self.result_count > 20:
|
||||
self.result_limit_reached = True
|
||||
return
|
||||
if kind is not None:
|
||||
self.requests[params["requestId"]] = {"kind": kind, "host": urlsplit(url).hostname,
|
||||
"sent_at": self.clock()}
|
||||
|
||||
def received(self, params):
|
||||
if (record := self.requests.get(params["requestId"])) is not None:
|
||||
record.update(status=params["response"]["status"], headers_at=self.clock())
|
||||
|
||||
def finished(self, params):
|
||||
if (record := self.requests.get(params["requestId"])) is not None:
|
||||
record["finished_at"] = self.clock()
|
||||
|
||||
def failed(self, params):
|
||||
for code in set(re.findall(r"(?:net::)?ERR_[A-Z_]+", params.get("errorText", ""))):
|
||||
self.network_failures[code] = self.network_failures.get(code, 0) + 1
|
||||
if (record := self.requests.get(params["requestId"])) is not None:
|
||||
record["failed_at"] = self.clock()
|
||||
|
||||
def exception(self, _params):
|
||||
self.exception_count += 1
|
||||
|
||||
def snapshot(self, cutoff: float) -> dict:
|
||||
return {"cutoff": cutoff, "requests": deepcopy(self.requests), "exception_count": self.exception_count,
|
||||
"network_failures": dict(self.network_failures), "script_limit_reached": self.script_limit_reached,
|
||||
"result_limit_reached": self.result_limit_reached}
|
||||
|
||||
async def resolve(self, frozen: dict) -> dict:
|
||||
result = {"responses": [], "scripts": [], "exception_count": frozen["exception_count"],
|
||||
"network_failures": frozen["network_failures"], "script_limit_reached": frozen["script_limit_reached"],
|
||||
"result_limit_reached": frozen["result_limit_reached"]}
|
||||
# Read only bodies that were complete at the cutoff; later responses can
|
||||
# never fill earlier samples. Result bodies have priority over script hashes.
|
||||
requests = sorted(frozen["requests"].items(), key=lambda item: item[1]["kind"] != "result")
|
||||
try:
|
||||
async with asyncio.timeout(15):
|
||||
for request_id, record in requests:
|
||||
complete = (record.get("status") == 200 and "failed_at" not in record
|
||||
and record.get("finished_at", math.inf) <= frozen["cutoff"])
|
||||
projected = {**record, "within_window": complete}
|
||||
if record["kind"] == "result":
|
||||
result["responses"].append(projected)
|
||||
elif not complete:
|
||||
continue
|
||||
else:
|
||||
result["scripts"].append(projected)
|
||||
if not complete:
|
||||
continue
|
||||
try:
|
||||
response = await asyncio.wait_for(self.cdp.send("Network.getResponseBody", {"requestId": request_id}), 3)
|
||||
body = base64.b64decode(response["body"]) if response.get("base64Encoded") else response["body"].encode()
|
||||
if record["kind"] == "result":
|
||||
projected["result"] = select_fp_result(json.loads(body))
|
||||
else:
|
||||
projected.update(sha256=hashlib.sha256(body).hexdigest(), bytes=len(body))
|
||||
except Exception as error:
|
||||
projected["read_error"] = type(error).__name__
|
||||
except TimeoutError:
|
||||
result["body_audit_incomplete"] = True
|
||||
return result
|
||||
@@ -0,0 +1,12 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).parent
|
||||
|
||||
|
||||
def dom_probe(fallback: str | None = None) -> str:
|
||||
source = (ROOT / "dom_results.js").read_text(encoding="utf-8")
|
||||
return source if fallback is None else f"arg => (\n{source}\n)(arg) ?? (\n{fallback}\n)(arg)"
|
||||
|
||||
|
||||
def site_probe() -> str:
|
||||
return dom_probe((ROOT / "site_results.js").read_text(encoding="utf-8"))
|
||||
@@ -0,0 +1,191 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from copy import deepcopy
|
||||
from dataclasses import asdict
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import importlib.metadata
|
||||
import json
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
from ..artifacts import write_json
|
||||
from ..config import REPO_ROOT
|
||||
from .browser import browser_session
|
||||
from .cases import Case, manifest
|
||||
from .observations import Observer, complete_fp_result, error_record
|
||||
from .probe import site_probe
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
def digest(path: Path) -> str:
|
||||
with path.open("rb") as stream:
|
||||
return hashlib.file_digest(stream, "sha256").hexdigest()
|
||||
|
||||
|
||||
def collector_hashes() -> dict[str, str]:
|
||||
root = Path(__file__).parent
|
||||
paths = [*root.glob("*.py"), *root.glob("*.js"), root.parent / "target_serve.py"]
|
||||
return {str(path.relative_to(root.parent)): digest(path) for path in sorted(paths)}
|
||||
|
||||
|
||||
def workload(cases: tuple[Case, ...]) -> dict:
|
||||
return {"cases": manifest(cases), "mode": "native-cdp", "runtime_enabled": True,
|
||||
"layout": "on-demand", "resources": "all", "identity_override": False,
|
||||
"tls_verification": True, "proxy": "disabled", "parallelism": 1,
|
||||
"navigation_timeout_seconds": 45, "sample_timeout_seconds": 150,
|
||||
"typing_delay_ms": 80, "dummy_credentials": True, "screenshot": False,
|
||||
"playwright_version": importlib.metadata.version("playwright"),
|
||||
"collector": collector_hashes()}
|
||||
|
||||
|
||||
def workload_hash(value: dict) -> str:
|
||||
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
||||
|
||||
|
||||
def classify(row: dict) -> str:
|
||||
if not row.get("process_cleaned_up"):
|
||||
return "cleanup_error"
|
||||
if row.get("browser_crashed"):
|
||||
return "browser_crash"
|
||||
errors = row.get("errors", [])
|
||||
if errors:
|
||||
if any(error.get("network_codes") for error in errors):
|
||||
return "network_error"
|
||||
if any("Timeout" in error["type"] for error in errors):
|
||||
return "timeout"
|
||||
return "collector_error"
|
||||
if (row.get("navigation_status") or 0) >= 400:
|
||||
return "network_error"
|
||||
report = row.get("report") or {}
|
||||
if report.get("valid") is True:
|
||||
return "complete"
|
||||
evidence = [value for key, value in report.items() if key not in {"valid", "readyState", "collector", "kind", "source"}]
|
||||
return "partial" if any(value not in (None, False, "", {}, []) for value in evidence) else "missing"
|
||||
|
||||
|
||||
async def act(page, case: Case):
|
||||
if case.action == "demo_login":
|
||||
await page.locator('input[type=email]').first.click(timeout=10000)
|
||||
await page.keyboard.type("moli-cdp-demo@example.com", delay=80)
|
||||
await page.locator('input[type=password]').first.click(timeout=10000)
|
||||
await page.keyboard.type("Moli-Demo-Only-2026!", delay=80)
|
||||
await page.get_by_role("button", name="Login", exact=True).click(timeout=10000)
|
||||
elif case.action == "start_scan":
|
||||
link = page.locator('a[href="/fingerprint-check"]').filter(has_text="Scan My Browser Now")
|
||||
if await link.count() != 1:
|
||||
raise ValueError("public scan link is absent or ambiguous")
|
||||
await link.click(timeout=10000)
|
||||
elif case.action is not None:
|
||||
raise ValueError("unknown site action")
|
||||
|
||||
|
||||
async def capture(case: Case, engine: str, binary: Path, output: Path, *, probe: str | None = None, run: int = 1) -> dict:
|
||||
row = {"site": case.id, "engine": engine, "run": run, "case": asdict(case), "errors": [],
|
||||
"started_at": datetime.now(timezone.utc).isoformat(), "report": None,
|
||||
"browser_crashed": False}
|
||||
stage = "session"
|
||||
try:
|
||||
async with browser_session(binary, engine, output) as (page, cdp):
|
||||
def crashed(_page):
|
||||
row["browser_crashed"] = True
|
||||
page.on("crash", crashed)
|
||||
started = time.monotonic()
|
||||
clock = lambda: time.monotonic() - started
|
||||
observer = Observer(cdp, clock)
|
||||
try:
|
||||
async with asyncio.timeout(150):
|
||||
stage = "navigation"
|
||||
response = await page.goto(case.url, wait_until="domcontentloaded", timeout=45000)
|
||||
row["navigation_status"] = response.status if response else None
|
||||
row["domcontentloaded_at"] = clock()
|
||||
await asyncio.sleep(case.wait_seconds)
|
||||
if case.action:
|
||||
stage = case.action
|
||||
await act(page, case)
|
||||
row["action_completed_at"] = clock()
|
||||
await asyncio.sleep(case.action_wait_seconds)
|
||||
stage = "projection"
|
||||
cutoff = clock()
|
||||
frozen = observer.snapshot(cutoff)
|
||||
row["cutoff"] = cutoff
|
||||
report = await asyncio.wait_for(page.evaluate(probe or site_probe(), {"site": case.id}), 15)
|
||||
if not isinstance(report, dict) or type(report.get("valid")) is not bool:
|
||||
raise ValueError("projector returned an invalid report envelope")
|
||||
row["report"] = report
|
||||
row["snapshot_completed_at"] = clock()
|
||||
stage = "network_evidence"
|
||||
row["observations"] = await observer.resolve(frozen)
|
||||
if case.id == "fingerprint-pro":
|
||||
completed = [entry["result"] for entry in row["observations"]["responses"]
|
||||
if entry["within_window"] and complete_fp_result(entry.get("result", {}))]
|
||||
# Multiple contradictory replies are not a license to select the best score.
|
||||
unique = {json.dumps(value, sort_keys=True) for value in completed}
|
||||
row["report"] = {"valid": len(unique) == 1 and not row["observations"]["result_limit_reached"], "results": completed,
|
||||
"ambiguous": len(unique) > 1}
|
||||
row["elapsed_seconds"] = clock()
|
||||
except Exception as error:
|
||||
row["errors"].append(error_record(stage, error))
|
||||
except Exception as error:
|
||||
row["errors"].append(error_record(stage, error))
|
||||
metadata_path = output / "environment.json"
|
||||
metadata = json.loads(metadata_path.read_text()) if metadata_path.exists() else {}
|
||||
row["process_cleaned_up"] = metadata.get("process_cleaned_up", False)
|
||||
row["browser"] = metadata.get("browser", {})
|
||||
row["status"] = classify(row)
|
||||
row["finished_at"] = datetime.now(timezone.utc).isoformat()
|
||||
write_json(output / "result.json", row)
|
||||
return row
|
||||
|
||||
|
||||
def build_summary(rows: list[dict], cases: tuple[Case, ...], engines: list[str], *, runs: int = 1) -> dict:
|
||||
states = ("complete", "partial", "missing", "timeout", "network_error", "collector_error", "browser_crash", "cleanup_error")
|
||||
coverage = {engine: {"planned": len(cases) * runs, "attempted": sum(r["engine"] == engine for r in rows),
|
||||
**{state: sum(r["engine"] == engine and r["status"] == state for r in rows) for state in states}}
|
||||
for engine in engines}
|
||||
return {"suite": "fingerprint", "observational": True, "availability_is_not_pass_count": True,
|
||||
"cases": [case.id for case in cases], "runs": runs, "coverage": coverage, "samples": deepcopy(rows)}
|
||||
|
||||
|
||||
async def run_suite(output: Path, binaries: dict[str, Path], cases: tuple[Case, ...], *, runs: int = 1) -> dict:
|
||||
if not cases or not binaries or set(binaries) - {"moli", "chromium"} or not 1 <= runs <= 10:
|
||||
raise ValueError("supply cases, native engines and 1..10 predeclared runs")
|
||||
binaries = {engine: path.resolve(strict=True) for engine, path in binaries.items()}
|
||||
config = workload(cases)
|
||||
provenance = {"schema_version": SCHEMA_VERSION, "suite": "fingerprint", "workload": config,
|
||||
"workload_hash": workload_hash(config), "started_at": datetime.now(timezone.utc).isoformat(),
|
||||
"environment": {"platform": platform.platform(), "machine": platform.machine(),
|
||||
"python": platform.python_version()},
|
||||
"binaries": {engine: {"path": str(path), "sha256": digest(path)} for engine, path in binaries.items()}}
|
||||
revision = subprocess.run(["git", "rev-parse", "HEAD"], cwd=REPO_ROOT, text=True, capture_output=True, check=True)
|
||||
provenance["revision"] = revision.stdout.strip()
|
||||
output.mkdir(parents=True, exist_ok=False)
|
||||
write_json(output / "manifest.json", provenance)
|
||||
rows: list[dict] = []
|
||||
summary = {"schema_version": SCHEMA_VERSION, "manifest": provenance, "suites": []}
|
||||
try:
|
||||
for run in range(1, runs + 1):
|
||||
for index, case in enumerate(cases):
|
||||
engines = list(binaries)
|
||||
if (index + run - 1) % 2:
|
||||
engines.reverse()
|
||||
for engine in engines:
|
||||
print(f"fingerprint {run}/{runs} {engine} {case.id}", flush=True)
|
||||
row = await capture(case, engine, binaries[engine], output / "samples" / str(run) / engine / case.id, run=run)
|
||||
rows.append(row)
|
||||
summary["suites"] = [build_summary(rows, cases, list(binaries), runs=runs)]
|
||||
write_json(output / "summary.json", summary)
|
||||
if not row["process_cleaned_up"]:
|
||||
raise RuntimeError("browser cleanup failed; refusing to start another public sample")
|
||||
finally:
|
||||
summary["suites"] = [build_summary(rows, cases, list(binaries), runs=runs)]
|
||||
summary["inputs_unchanged"] = collector_hashes() == config["collector"] and all(
|
||||
digest(path) == provenance["binaries"][engine]["sha256"] for engine, path in binaries.items())
|
||||
summary["finished_at"] = datetime.now(timezone.utc).isoformat()
|
||||
summary["matrix_complete"] = len(rows) == len(cases) * len(binaries) * runs
|
||||
write_json(output / "summary.json", summary)
|
||||
return summary
|
||||
@@ -0,0 +1,116 @@
|
||||
// Allowlisted, passive DOM projections for the other nine sites. Only selected
|
||||
// results cross CDP; no page HTML, API tokens, IP addresses or console text.
|
||||
({site}) => {
|
||||
const text = node => (node?.textContent || '').trim();
|
||||
const parse = id => { try { return JSON.parse(text(document.getElementById(id))); } catch { return null; } };
|
||||
const bool = value => typeof value === 'boolean' ? value : null;
|
||||
const states = value => value && typeof value === 'object' ? Object.fromEntries(Object.entries(value)
|
||||
.filter(([key, v]) => /^[A-Za-z_]+$/.test(key) && ['OK', 'FAIL', 'WARN'].includes(v))) : null;
|
||||
const score = (value, maximum) => {
|
||||
if (typeof value === 'string' && /^\d+(?:\.\d+)?$/.test(value.trim())) value = Number(value);
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= maximum ? value : null;
|
||||
};
|
||||
const report = {readyState: document.readyState, valid: false};
|
||||
if (site === 'fingerprint-pro') return {...report, source: 'network-result-required'};
|
||||
if (site === 'device-browser-static' || site === 'device-browser-behavior') {
|
||||
const value = parse('jsonResult');
|
||||
report.isBot = bool(value?.isBot);
|
||||
report.details = value?.details && typeof value.details === 'object' ? Object.fromEntries(
|
||||
Object.entries(value.details).filter(([k, v]) => /^(?:is|has|suspicious)[A-Za-z]+$/.test(k)
|
||||
&& typeof v === 'boolean')) : null;
|
||||
report.valid = report.isBot !== null && report.details !== null && Object.keys(report.details).length > 0;
|
||||
return report;
|
||||
}
|
||||
if (site === 'incolumitas-bot') {
|
||||
const newer = parse('new-tests'), detection = parse('detection-tests');
|
||||
report.newTests = states(newer);
|
||||
report.intoli = states(detection?.intoli);
|
||||
report.fpscanner = states(detection?.fpscanner);
|
||||
report.valid = [report.newTests, report.intoli, report.fpscanner].every(v => v && Object.keys(v).length > 0);
|
||||
report.behavior = {jsonScore: score(newer?.behavioralClassificationScore, 1),
|
||||
domScore: score(text(document.getElementById('behavioralScore')), 1)};
|
||||
return report;
|
||||
}
|
||||
if (site === 'incolumitas-proxy') {
|
||||
// This page mixes several independent remote probes. Labels alone do not
|
||||
// establish a completed network report. Keep this explicitly partial until
|
||||
// the site exposes verifiable result values; never invent a pass from prose.
|
||||
const body = text(document.body);
|
||||
report.kind = 'report-only';
|
||||
report.certificateErrors = [...new Set(body.match(/(?:net::)?ERR_CERT_[A-Z_]+/g) || [])];
|
||||
report.reportLabels = ['WebRTC', 'TCP/IP', 'DNS', 'Timezone', 'Proxy'].filter(label => body.includes(label));
|
||||
report.completion = 'unverified';
|
||||
return report;
|
||||
}
|
||||
if (site === 'sannysoft') {
|
||||
const ids = ['user-agent-result', 'webdriver-result', 'advanced-webdriver-result', 'chrome-result',
|
||||
'permissions-result', 'plugins-length-result', 'plugins-type-result', 'languages-result',
|
||||
'webgl-vendor', 'webgl-renderer', 'broken-image-dimensions'];
|
||||
report.tests = Object.fromEntries(ids.map(id => {
|
||||
const node = document.getElementById(id);
|
||||
return [id, node ? {state: Array.from(node.classList).find(s => ['passed', 'failed', 'warn'].includes(s)) || null,
|
||||
value: text(node).slice(0, 250)} : null];
|
||||
}));
|
||||
report.fpscanner = Object.fromEntries(Array.from(document.querySelectorAll('tr'), row =>
|
||||
Array.from(row.querySelectorAll(':scope > th, :scope > td'), text)).filter(row =>
|
||||
/^[A-Z_]+$/.test(row[0] || '') && /^(ok|fail|warn)$/i.test(row[1] || '')).map(row => [row[0], row[1]]));
|
||||
report.valid = Object.values(report.tests).every(v => v?.state !== null && v?.state !== undefined);
|
||||
return report;
|
||||
}
|
||||
if (site === 'pixelscan') {
|
||||
const cards = Array.from(document.querySelectorAll('[checkervalue]'), text);
|
||||
const lower = cards.map(t => t.toLowerCase());
|
||||
report.masking = lower.includes('masking detected') ? true : lower.includes('no masking detected') ? false : null;
|
||||
report.automated = lower.includes('automated behavior detected') ? true
|
||||
: lower.includes('no automated behavior detected') ? false : null;
|
||||
report.browser = cards.find(t => /^(Chrome|Chromium|Firefox|Safari|Edge)\b/.test(t))?.slice(0, 100) || null;
|
||||
report.valid = location.pathname === '/fingerprint-check' && report.masking !== null && report.automated !== null;
|
||||
const selectors = {browser:'pxlscn-browser-integrity', fingerprint:'pxlscn-fingerprint-masking',
|
||||
automation:'pxlscn-bot-detection', location:'pxlscn-location-masking', proxy:'pxlscn-proxy',
|
||||
hardware:'pxlscn-hardware-fingerprints', navigator:'pxlscn-navigator', fonts:'pxlscn-fonts',
|
||||
timeLanguage:'pxlscn-time-language', connections:'pxlscn-connections'};
|
||||
const known = ['Masking detected', 'No masking detected', 'Automated behavior detected',
|
||||
'No automated behavior detected', 'Proxy detected', 'No proxy detected', 'Proxy check error',
|
||||
'Timezone spoofed', 'Location not detected'];
|
||||
report.sections = Object.fromEntries(Object.entries(selectors).map(([key, selector]) => {
|
||||
const node = document.querySelector(selector);
|
||||
if (!node) return [key, {present:false}];
|
||||
const cell = node.querySelector('[checkervalue]'), value = text(cell);
|
||||
const status = !cell ? 'absent' : !value ? 'empty' : /^Collecting Data/.test(value) ? 'collecting'
|
||||
: known.includes(value) ? value : 'populated';
|
||||
const details = Array.from(node.querySelectorAll('.checker-details-value, .checker-details-value--small'));
|
||||
return [key, {present:true, status, collecting:text(node).includes('Collecting Data'),
|
||||
loaders:node.querySelectorAll('pxlscn-loader').length, detailCells:details.length,
|
||||
populatedDetailCells:details.filter(e => text(e)).length}];
|
||||
}));
|
||||
return report;
|
||||
}
|
||||
if (site === 'browserleaks-js') {
|
||||
report.kind = 'report-only';
|
||||
report.fields = Object.fromEntries(['userAgent', 'platform', 'hardwareConcurrency', 'webdriver']
|
||||
.map(key => [key, text(document.getElementById('js-' + key)).slice(0, 250) || null]));
|
||||
report.valid = Object.values(report.fields).every(v => v !== null);
|
||||
return report;
|
||||
}
|
||||
if (site === 'browserleaks-webgl') {
|
||||
report.kind = 'report-only';
|
||||
const rows = Array.from(document.querySelectorAll('tr'), row =>
|
||||
Array.from(row.querySelectorAll(':scope > th, :scope > td'), text));
|
||||
const selected = label => {
|
||||
const row = rows.find(row => row[0] === label);
|
||||
return row?.length > 1 ? row.slice(1).join(' ').slice(0, 250) || null : null;
|
||||
};
|
||||
report.fields = Object.fromEntries(['WebGL Report Hash', 'WebGL Image Hash', 'WebGL Image',
|
||||
'Unmasked Vendor', 'Unmasked Renderer', 'Max Texture Size', 'Max Combined Texture Image Units',
|
||||
'Max Viewport Dimensions', 'Aliased Point Size Range'].map(key => [key, selected(key)]));
|
||||
report.unavailable = Array.from(document.querySelectorAll('p, .warning, .notice'), text)
|
||||
.some(t => /^(?:WebGL (?:is |seems to be )?(?:disabled or unavailable|not supported)|Your browser (?:does not support|doesn't support) WebGL)\b/i.test(t));
|
||||
report.valid = report.unavailable || report.fields['WebGL Report Hash'] !== null;
|
||||
const canvas = document.getElementById('gl-image-src')?.querySelector('canvas');
|
||||
const hash = text(document.getElementById('gl-image-hash'));
|
||||
report.imageProbe = {canvasInserted:!!canvas, dimensions:canvas ? [canvas.width, canvas.height] : null,
|
||||
hash:/^[a-f0-9]{32}$/i.test(hash) ? hash : null};
|
||||
return report;
|
||||
}
|
||||
throw new Error('Unknown fingerprint site');
|
||||
}
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
@@ -12,6 +13,7 @@ from typing import Any
|
||||
|
||||
from .config import REPO_ROOT, ReservedPort, clear_proxy_env, reserve_port
|
||||
from .sampling import ResourceSampler
|
||||
from .process import _kill_process_group
|
||||
from .serve import probe_url
|
||||
from .synthetic_compare import target_enables_all_resource_fetch, target_metadata
|
||||
|
||||
@@ -35,11 +37,12 @@ class TargetServeHandle:
|
||||
endpoint: str
|
||||
command: list[str]
|
||||
logs: list[str]
|
||||
sampler: ResourceSampler
|
||||
sampler: ResourceSampler | None
|
||||
log_threads: list[threading.Thread]
|
||||
port_lease: ReservedPort
|
||||
temp_dir: Path | None = None
|
||||
ready_ms: float | None = None
|
||||
native: bool = False
|
||||
|
||||
|
||||
def _append_log(logs: list[str], line: str) -> None:
|
||||
@@ -89,6 +92,8 @@ def _serve_command(
|
||||
port: int,
|
||||
temp_dir: Path | None,
|
||||
extra_args: tuple[str, ...] = (),
|
||||
*,
|
||||
native: bool = False,
|
||||
) -> list[str]:
|
||||
engine = target_metadata(target)["engine"]
|
||||
if engine == "moli" or engine == "lightpanda":
|
||||
@@ -112,6 +117,15 @@ def _serve_command(
|
||||
if engine == "chrome":
|
||||
if temp_dir is None:
|
||||
raise RuntimeError("chrome target requires a temporary profile directory")
|
||||
if native:
|
||||
if extra_args:
|
||||
raise ValueError("native browser launches do not accept identity or feature overrides")
|
||||
return [
|
||||
str(binary), "--no-first-run", "--no-default-browser-check",
|
||||
"--no-proxy-server", "--password-store=basic", "--window-size=1920,1080",
|
||||
"--remote-debugging-address=127.0.0.1",
|
||||
f"--user-data-dir={temp_dir}", f"--remote-debugging-port={port}", "about:blank",
|
||||
]
|
||||
return [
|
||||
str(binary),
|
||||
"--headless=new",
|
||||
@@ -134,7 +148,10 @@ def start_target_serve(
|
||||
extra_args: tuple[str, ...] = (),
|
||||
*,
|
||||
sample_interval_seconds: float | None = None,
|
||||
native: bool = False,
|
||||
) -> TargetServeHandle:
|
||||
if native and (target not in {"moli-full-cdp", "chrome-cdp"} or extra_args):
|
||||
raise ValueError("native mode requires moli-full-cdp or chrome-cdp without extra flags")
|
||||
engine = target_metadata(target)["engine"]
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix=f"moli-benchmark-{target}-")) if engine == "chrome" else None
|
||||
logs: list[str] = []
|
||||
@@ -142,7 +159,7 @@ def start_target_serve(
|
||||
try:
|
||||
port = reserved_port.port
|
||||
endpoint = f"http://127.0.0.1:{port}"
|
||||
command = _serve_command(target, binary, port, temp_dir, extra_args)
|
||||
command = _serve_command(target, binary, port, temp_dir, extra_args, native=native)
|
||||
reserved_port.release_socket()
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
@@ -163,12 +180,13 @@ def start_target_serve(
|
||||
]
|
||||
for thread in log_threads:
|
||||
thread.start()
|
||||
sampler = (
|
||||
sampler = None if native else (
|
||||
ResourceSampler(process.pid)
|
||||
if sample_interval_seconds is None
|
||||
else ResourceSampler(process.pid, interval_seconds=sample_interval_seconds)
|
||||
)
|
||||
sampler.start()
|
||||
if sampler is not None:
|
||||
sampler.start()
|
||||
handle = TargetServeHandle(
|
||||
target=target,
|
||||
process=process,
|
||||
@@ -179,6 +197,7 @@ def start_target_serve(
|
||||
log_threads=log_threads,
|
||||
port_lease=reserved_port,
|
||||
temp_dir=temp_dir,
|
||||
native=native,
|
||||
)
|
||||
started = time.perf_counter()
|
||||
deadline = started + timeout_seconds
|
||||
@@ -212,19 +231,24 @@ def stop_target_serve(
|
||||
resources: dict[str, Any] = {}
|
||||
try:
|
||||
process_exited = _terminate_process(handle.process)
|
||||
if handle.native and os.name == "posix":
|
||||
# Native sessions own a fresh process group. Reap residual children
|
||||
# even if Chromium's root crashed or Browser.close already exited it.
|
||||
_kill_process_group(handle.process.pid, signal.SIGKILL)
|
||||
if process_exited:
|
||||
for thread in handle.log_threads:
|
||||
thread.join(timeout=0.2)
|
||||
else:
|
||||
_append_log(handle.logs, "process did not exit after SIGKILL; skipped pipe drain")
|
||||
resources = handle.sampler.stop()
|
||||
if include_resource_samples:
|
||||
resources = handle.sampler.stop() if handle.sampler is not None else {}
|
||||
if include_resource_samples and handle.sampler is not None:
|
||||
resources["samples"] = list(handle.sampler.samples)
|
||||
finally:
|
||||
handle.port_lease.close()
|
||||
if handle.temp_dir is not None:
|
||||
shutil.rmtree(handle.temp_dir, ignore_errors=True)
|
||||
return {
|
||||
"process_exited": process_exited,
|
||||
"returncode": handle.process.returncode,
|
||||
"resources": resources,
|
||||
"log_tail": handle.logs[-40:],
|
||||
|
||||
@@ -8,6 +8,9 @@ dependencies = [
|
||||
"websockets>=13,<16",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
fingerprint = ["playwright>=1.48,<2"]
|
||||
|
||||
[project.scripts]
|
||||
moli-benchmark = "moli_benchmark.cli:main"
|
||||
moli-stress = "moli_benchmark.stress:main"
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Run deterministic DOM-reader fixtures on a real CDP browser, without screenshots.
|
||||
|
||||
Not part of offline unittest discovery: pass an explicit owned browser binary.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
from moli_benchmark.artifacts import write_json as save
|
||||
from moli_benchmark.fingerprint.browser import browser_session
|
||||
from moli_benchmark.fingerprint.probe import dom_probe as survey_probe
|
||||
|
||||
|
||||
def bot(value: str) -> str:
|
||||
return f"<div><strong>Test <span>Results:</span></strong><svg></svg><strong>{value}</strong></div>"
|
||||
|
||||
|
||||
def card(label: str, value: str) -> str:
|
||||
return (f"<section><div><div><h3>{label}</h3></div></div>"
|
||||
f"<div><div><p>{value}</p></div></div></section>")
|
||||
|
||||
|
||||
HASH = "a" * 32
|
||||
JA3 = "771,4865-4866,0-23-35,29-23,0"
|
||||
JA4 = "t13d1516h2_" + "b" * 12 + "_" + "c" * 12
|
||||
TLS = ("<h3>HTTP/2 Fingerprint</h3>" + card("Akamai Hash", HASH)
|
||||
+ card("JA3", JA3) + card("JA4", JA4))
|
||||
CREEP = {"lies": {"totalLies": 0}, "capturedErrors": {"data": []},
|
||||
"offlineAudioContext": {"lied": False}, "svg": {"lied": False},
|
||||
"headless": {"likeHeadlessRating": 38, "headlessRating": 0, "stealthRating": 0,
|
||||
"headless": {"webDriverIsOn": False, "opaqueToken": "not retained"}}}
|
||||
|
||||
|
||||
def fixtures():
|
||||
for verdict in ["Normal", "Robot"]:
|
||||
yield verdict, "browserscan-bot", bot(verdict), None, {"valid": True, "verdict": verdict}
|
||||
yield "missing", "browserscan-bot", "<p>Normal</p>", None, {"valid": False, "verdict": None}
|
||||
yield "pending", "browserscan-bot", bot("Loading..."), None, {"valid": False, "verdict": None}
|
||||
yield "hidden", "browserscan-bot", f"<div hidden>{bot('Normal')}</div>", None, {"valid": False}
|
||||
yield "template", "browserscan-bot", f"<template>{bot('Normal')}</template>", None, {"valid": False}
|
||||
yield "duplicate", "browserscan-bot", bot("Normal") + bot("Robot"), None, {"valid": False}
|
||||
yield "duplicate-value", "browserscan-bot", bot("Normal").replace(
|
||||
"</div>", "<strong>Robot</strong></div>"), None, {"valid": False}
|
||||
yield "hidden-decoy", "browserscan-bot", bot("Robot") + f"<aside hidden>{bot('Normal')}</aside>", None, {
|
||||
"valid": True, "verdict": "Robot"}
|
||||
yield "explanation-decoy", "browserscan-bot", bot("") + "<p>Normal</p>", None, {"valid": False}
|
||||
yield "tls-complete", "browserscan-tls", TLS, None, {"valid": True, "fields": {
|
||||
"akamaiHash": "populated", "ja3": "populated", "ja4": "populated"}}
|
||||
# Real ad scripts append a div inside p. Putting that in HTML source instead
|
||||
# would implicitly close p during parsing, exercising a different DOM.
|
||||
yield "tls-ad-annotation", "browserscan-tls", TLS + """<script>
|
||||
for (const p of document.querySelectorAll('section p')) {
|
||||
const ad = document.createElement('div');
|
||||
ad.textContent = 'Convert Data Files'; p.append(ad);
|
||||
}
|
||||
</script>""", None, {"valid": True}
|
||||
yield "tls-hash-only-in-ad", "browserscan-tls", TLS.replace(HASH, "") + f"""<script>
|
||||
const ad = document.createElement('div'); ad.textContent = '{HASH}';
|
||||
document.querySelector('section p').append(ad);
|
||||
</script>""", None, {"valid": False}
|
||||
yield "tls-hashes-in-prose", "browserscan-tls", (
|
||||
"<h3>HTTP/2 Fingerprint</h3>" + card("Akamai Hash", "") + card("JA3", "") + card("JA4", "")
|
||||
+ f"<p>{HASH} {HASH}</p><script>const example='{HASH}'</script>"), None, {"valid": False}
|
||||
yield "tls-no-ja4", "browserscan-tls", TLS.replace(JA4, "Loading..."), None, {"valid": False}
|
||||
yield "tls-malformed", "browserscan-tls", TLS.replace(JA3, HASH), None, {"valid": False}
|
||||
yield "tls-duplicate", "browserscan-tls", TLS + card("JA3", JA3), None, {"valid": False}
|
||||
yield "tls-hidden", "browserscan-tls", f"<div aria-hidden=true>{TLS}</div>", None, {"valid": False}
|
||||
yield "creep-zero", "creepjs", "<p>99% headless</p>", CREEP, {
|
||||
"valid": True, "ratingsComplete": True,
|
||||
"percentages": ["38% like headless", "0% headless", "0% stealth"],
|
||||
"metrics": {"totalLies": 0, "capturedErrorCount": 0, "audioLied": False, "svgLied": False},
|
||||
"headlessSignals": {"headless": {"webDriverIsOn": False}, "likeHeadless": None, "stealth": None}}
|
||||
yield "creep-absent", "creepjs", "<p>0% headless</p>", None, {
|
||||
"valid": False, "ratingsComplete": False, "percentages": []}
|
||||
partial = deepcopy(CREEP)
|
||||
del partial["headless"]["headlessRating"]
|
||||
yield "creep-partial", "creepjs", "", partial, {"valid": True, "ratingsComplete": False,
|
||||
"ratings": {"likeHeadless": 38, "headless": None, "stealth": 0}}
|
||||
invalid = deepcopy(CREEP)
|
||||
invalid["lies"]["totalLies"] = "0"
|
||||
invalid["headless"].update(likeHeadlessRating=101, headlessRating=False, stealthRating="0")
|
||||
yield "creep-invalid-types", "creepjs", "", invalid, {
|
||||
"valid": False, "ratingsComplete": False, "percentages": []}
|
||||
|
||||
|
||||
async def check(args):
|
||||
assertions = unittest.TestCase()
|
||||
results = []
|
||||
# Even a supported-but-missing report must not execute the rendered-text fallback.
|
||||
probe = survey_probe("() => { throw new Error('unexpected fallback'); }")
|
||||
async with browser_session(args.binary, args.engine, args.output) as (page, _):
|
||||
for name, site, html, fingerprint, expected in fixtures():
|
||||
record = {"fixture": name, "passed": False}
|
||||
try:
|
||||
await page.set_content(html, wait_until="domcontentloaded", timeout=10000)
|
||||
await page.evaluate("""fingerprint => {
|
||||
window.Fingerprint = fingerprint;
|
||||
const forbidden = () => { throw new Error('layout-dependent read'); };
|
||||
Object.defineProperty(HTMLElement.prototype, 'innerText', {get: forbidden, configurable: true});
|
||||
Element.prototype.getBoundingClientRect = forbidden;
|
||||
window.getComputedStyle = forbidden;
|
||||
}""", fingerprint)
|
||||
actual = await asyncio.wait_for(page.evaluate(probe, {"site": site}), 10)
|
||||
for key, value in expected.items():
|
||||
assertions.assertEqual(actual[key], value, f"{name}: {key}")
|
||||
record["passed"] = True
|
||||
except Exception as error:
|
||||
record.update(error_type=type(error).__name__, detail=str(error))
|
||||
results.append(record)
|
||||
# Only unrelated sites may use the supplied legacy collector.
|
||||
actual = await page.evaluate(survey_probe("arg => ({fallback: arg.site})"), {"site": "other"})
|
||||
assertions.assertEqual(actual, {"fallback": "other"})
|
||||
save(args.output / "fixtures.json", results)
|
||||
failures = [item for item in results if not item["passed"]]
|
||||
print(f"{args.engine}: {len(results) - len(failures)}/{len(results)} DOM fixtures passed; fallback dispatch passed")
|
||||
if failures:
|
||||
raise SystemExit(f"Fixture failures: {failures}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--binary", type=Path, required=True)
|
||||
parser.add_argument("--engine", choices=["moli", "chromium"], required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
asyncio.run(check(parser.parse_args()))
|
||||
@@ -0,0 +1,102 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import unittest
|
||||
|
||||
from moli_benchmark.fingerprint.cases import CASES, select_cases
|
||||
from moli_benchmark.fingerprint.observations import Observer, complete_fp_result, is_fp_result, select_fp_result
|
||||
from moli_benchmark.fingerprint.runner import build_summary, classify
|
||||
|
||||
|
||||
class FingerprintContractTests(unittest.TestCase):
|
||||
def test_canonical_matrix_and_selection(self):
|
||||
self.assertEqual(len(CASES), 12)
|
||||
self.assertEqual(len({case.id for case in CASES}), 12)
|
||||
self.assertEqual(select_cases(["browserscan-bot"])[0].wait_seconds, 10)
|
||||
for ids in [[], ["unknown"], ["creepjs", "creepjs"]]:
|
||||
with self.assertRaises(ValueError):
|
||||
select_cases(ids)
|
||||
|
||||
def test_fp_requires_all_fields_and_retains_zero_and_false(self):
|
||||
raw = {"bot": "not_detected", "tampering": False, "virtual_machine": False, "suspect_score": 0,
|
||||
"visitorId": "secret", "requestId": "secret", "ip": "secret", "raw_device_attributes": {"ip": "secret"}}
|
||||
value = select_fp_result(raw)
|
||||
self.assertEqual(len(value), 4)
|
||||
self.assertTrue(complete_fp_result(value))
|
||||
for field in value:
|
||||
self.assertFalse(complete_fp_result({key: v for key, v in value.items() if key != field}))
|
||||
self.assertEqual(select_fp_result({"bot": {}, "suspect_score": True, "tampering": "false"}), {})
|
||||
self.assertNotIn("suspect_score", select_fp_result({"suspect_score": float("nan")}))
|
||||
|
||||
def test_result_origin_is_exact(self):
|
||||
self.assertTrue(is_fp_result("https://demo.fingerprint.com/api/event/id?token=secret"))
|
||||
self.assertFalse(is_fp_result("https://demo.fingerprint.com.attacker.test/api/event/id"))
|
||||
self.assertFalse(is_fp_result("http://demo.fingerprint.com/api/event/id"))
|
||||
|
||||
def test_negative_verdict_is_complete_not_a_collection_error(self):
|
||||
row = {"engine": "moli", "report": {"valid": True, "verdict": "Robot"}, "process_cleaned_up": True}
|
||||
row["status"] = classify(row)
|
||||
self.assertEqual(row["status"], "complete")
|
||||
summary = build_summary([row], select_cases(["browserscan-bot"]), ["moli", "chromium"])
|
||||
self.assertEqual(summary["coverage"]["moli"]["complete"], 1)
|
||||
self.assertEqual(summary["coverage"]["chromium"]["attempted"], 0)
|
||||
self.assertNotIn("pass_count", summary)
|
||||
|
||||
def test_missing_does_not_become_pass_and_errors_do_not_disappear(self):
|
||||
row = {"report": {"valid": False}, "process_cleaned_up": True}
|
||||
self.assertEqual(classify(row), "missing")
|
||||
row["report"]["fields"] = {"value": None}
|
||||
self.assertEqual(classify(row), "partial")
|
||||
row["errors"] = [{"type": "TimeoutError"}]
|
||||
self.assertEqual(classify(row), "timeout")
|
||||
row["errors"] = [{"type": "Error", "network_codes": ["net::ERR_CERT_AUTHORITY_INVALID"]}]
|
||||
self.assertEqual(classify(row), "network_error")
|
||||
row["process_cleaned_up"] = False
|
||||
self.assertEqual(classify(row), "cleanup_error")
|
||||
|
||||
|
||||
class FakeCDP:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def on(self, *_args):
|
||||
pass
|
||||
|
||||
async def send(self, method, params):
|
||||
self.calls.append((method, params))
|
||||
return {"body": json.dumps({"bot": "not_detected", "tampering": False,
|
||||
"virtual_machine": False, "suspect_score": 6, "ip": "private"})}
|
||||
|
||||
|
||||
class ObservationWindowTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_headers_before_but_body_after_cutoff_does_not_fill_the_sample(self):
|
||||
cdp = FakeCDP()
|
||||
now = [1]
|
||||
observer = Observer(cdp, lambda: now[0])
|
||||
observer.requested({"requestId": "r", "request": {"url": "https://demo.fingerprint.com/api/event/secret"}})
|
||||
observer.received({"requestId": "r", "response": {"status": 200}})
|
||||
frozen = observer.snapshot(2)
|
||||
now[0] = 3
|
||||
observer.finished({"requestId": "r"})
|
||||
result = await observer.resolve(frozen)
|
||||
self.assertFalse(result["responses"][0]["within_window"])
|
||||
self.assertNotIn("result", result["responses"][0])
|
||||
self.assertEqual(cdp.calls, [])
|
||||
completed = await observer.resolve(observer.snapshot(4))
|
||||
self.assertTrue(complete_fp_result(completed["responses"][0]["result"]))
|
||||
encoded = json.dumps(completed)
|
||||
for forbidden in ["private", "secret", "requestId"]:
|
||||
self.assertNotIn(forbidden, encoded)
|
||||
|
||||
async def test_redirect_outside_result_origin_cannot_supply_a_verdict(self):
|
||||
observer = Observer(FakeCDP(), lambda: 1)
|
||||
for url in ["https://demo.fingerprint.com/api/event/id", "https://example.com/redirected"]:
|
||||
observer.requested({"requestId": "r", "request": {"url": url}})
|
||||
observer.received({"requestId": "r", "response": {"status": 200}})
|
||||
observer.finished({"requestId": "r"})
|
||||
self.assertEqual((await observer.resolve(observer.snapshot(2)))["responses"], [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
@@ -128,6 +129,30 @@ class TargetServeTests(unittest.TestCase):
|
||||
[{"elapsed_ms": 1.0, "rss_bytes": 42}],
|
||||
)
|
||||
|
||||
def test_native_chromium_preserves_identity_and_does_not_sample_resources(self) -> None:
|
||||
with (
|
||||
patch("moli_benchmark.target_serve.subprocess.Popen", return_value=_FakeProcess()),
|
||||
patch("moli_benchmark.target_serve.ResourceSampler") as sampler,
|
||||
patch("moli_benchmark.target_serve.probe_url", return_value=True),
|
||||
patch("moli_benchmark.target_serve._kill_process_group") as kill,
|
||||
):
|
||||
handle = start_target_serve("chrome-cdp", Path("/bin/chromium"), 1, native=True)
|
||||
self.assertIn("--remote-debugging-address=127.0.0.1", handle.command)
|
||||
self.assertIn("--no-proxy-server", handle.command)
|
||||
for forbidden in ["--headless", "--disable-gpu", "--no-sandbox", "--user-agent"]:
|
||||
self.assertFalse(any(flag.startswith(forbidden) for flag in handle.command))
|
||||
sampler.assert_not_called()
|
||||
stopped = stop_target_serve(handle)
|
||||
self.assertTrue(stopped["process_exited"])
|
||||
self.assertEqual(stopped["resources"], {})
|
||||
if os.name == "posix":
|
||||
kill.assert_called_once()
|
||||
|
||||
def test_native_profile_rejects_custom_flags_and_unrelated_engines(self) -> None:
|
||||
for target, flags in [("lightpanda-cdp", ()), ("chrome-cdp", ("--user-agent=Fake",))]:
|
||||
with self.assertRaises(ValueError):
|
||||
start_target_serve(target, Path("/unused"), 1, flags, native=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Generated
+124
@@ -2,6 +2,83 @@ version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.11"
|
||||
|
||||
[[package]]
|
||||
name = "greenlet"
|
||||
version = "3.5.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3e/6e/0091f175ccd02b02bc8811bbcbcc6ac2e980be116e3b2f7a736ca322bf84/greenlet-3.5.6.tar.gz", hash = "sha256:8e67c43bdfc88d5fee6db0d3e40175b362fc95fb85f0412d233b9b203c53a575", size = 207653, upload-time = "2026-09-14T15:42:51.806Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/d7/41511ee2696f14be4200b524d9553dc4295e2bdeb20aa8962c3cb25e71c6/greenlet-3.5.6-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a6a4b98a9132e0f45c9fc245a63894cfd8c45fb7a0d6bffc5eab3ec327cf7324", size = 294075, upload-time = "2026-09-14T14:25:16.922Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/7b/b509624970909294cd064ff7346148ca9941c21bec9026d7873dd254e9fa/greenlet-3.5.6-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45bfd2b51e38aaa5f9849f114d9c7c1d75f69187c849b3549cd64c465283abfa", size = 613429, upload-time = "2026-09-14T15:12:00.454Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/5c/d2eb503067f9ba20875ef8c87681f29a64f53bbbbe4059a5d7c53179d442/greenlet-3.5.6-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3c6dede9133e1da41d561bc3fb14e92b47e2ce39ae60edefaad145658ea7c5e2", size = 625405, upload-time = "2026-09-14T15:20:41.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/24/9b071d11c8bb9f5f38cccacc38fcc234d91997a4c395cc2bf43ecae89642/greenlet-3.5.6-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4fb8e59f68845d56c23c031dcd79c329f345e4a9d2ffac91c3d1ab366bdc457b", size = 633270, upload-time = "2026-09-14T15:25:04.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/d3/63d4477ce31dff2fd802a9a20240f6606aac85977e0fb18443aae33de3f6/greenlet-3.5.6-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c20ea32a73d17b9b60e3371240e17b0068120c98a5ec01a224a7dd8c89733ba", size = 624428, upload-time = "2026-09-14T14:35:56.895Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/17/ac11883ecc9da19c681c8b763ee39e6f7dca2aa81874eb11a075d3cbeb00/greenlet-3.5.6-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:d701eab36200c36224833d07dbdb709adb7fd4253429548ddb5e547b8ed40586", size = 428068, upload-time = "2026-09-14T15:28:35.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/aa/9cde4e00688eaa2a03b91d12e4681439a87e6aad860399e0847af6a014ca/greenlet-3.5.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5a0b2791239c99992a86c1b635b787fe2a877d9eaaa26f8891ce943832b585ae", size = 1588385, upload-time = "2026-09-14T15:10:05.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/01/24632b5ec186b64e21e07a8f53ce5e15a7e9cb33eddee99a5fe16379afa5/greenlet-3.5.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:188bf333769b7145e2b0b4a7f09615ec550ed44d3a2a8395fb7b36f0e9901e13", size = 1653119, upload-time = "2026-09-14T14:35:48.275Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/6c/019d2ef898f4b9ac845167f1c6f73229e9a4e2439362a5e2ce50205a19b0/greenlet-3.5.6-cp311-cp311-win_amd64.whl", hash = "sha256:a6b4ff33f7e011bbaa148238d131c4fd4f8afbab3c104ddfbdb2b12b74ff7016", size = 323317, upload-time = "2026-09-14T14:22:38.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/7a/439df999455e3bdf02b1c68f3848d4020385ef0a01f89f706b07bf148a65/greenlet-3.5.6-cp311-cp311-win_arm64.whl", hash = "sha256:59deccd347735a7774223b05a93773fddbb298aba3cea21be4337fb4752dbe32", size = 307739, upload-time = "2026-09-14T14:23:40.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/18/3fc6d951466ae9a2a688edcddde3b2e388da0a8244e0caf7117bbeb0eb95/greenlet-3.5.6-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:a5876d0a60355af98d535c47f6cd6eb0f8a432396dab26845d380b92f8412422", size = 295668, upload-time = "2026-09-14T14:22:33.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/89/366d2af5061eeefa5012f510d95a99c8620dcc457609838db4d538820318/greenlet-3.5.6-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e85880b538e59a59f55117b81f208a6660ad5ac328aad9305f812d9b8bc67a0f", size = 611700, upload-time = "2026-09-14T15:12:01.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/1c/07f133f865fd58ae593dd2bbec3144acaee9b04ffe2eb48c6e121747ceef/greenlet-3.5.6-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f0ba7c2a329d650628f4c8572fd1db29f0a59dd70a3e3e0710dcf18a35cce9d8", size = 624223, upload-time = "2026-09-14T15:20:42.459Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/f2/844dc823ff2752ad049caa6b59d57e4572f9c445934b02d3518f4c67197c/greenlet-3.5.6-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee7d9da3bf493909cf811a3f038840cb34fab5ae2956b8a263919f6e289ab188", size = 629529, upload-time = "2026-09-14T15:25:06.354Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/6a/1594f3869c57c149abdb380492529e04d4c0229b5e4d79572c5bd0aaa673/greenlet-3.5.6-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:975736b002ed080d124cf81a79cb7e05cb26d6b3f5c7a7b651c0fcce70353aa1", size = 621404, upload-time = "2026-09-14T14:35:59.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/42/b1f8dbc89a53b9e77859fc1ad1627d106fc361daa3ea4bdf43a91ebb4338/greenlet-3.5.6-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:71890d5247020c25c21a6b65202782bfc281d4e6e244842419d30e3492bb6dcc", size = 432385, upload-time = "2026-09-14T15:28:37.369Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/f5/33e5c9e48178b9259fd000f8f45caa4a65036f65d3d0c06a602f570f025d/greenlet-3.5.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0616b8f878098c5681fd8f0dc92d887551717402342a70f0abcbfea5f5ad8a44", size = 1584998, upload-time = "2026-09-14T15:10:06.653Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/31/9b4e140bc24d0ad7927ebd651f5608b0acc2334d061748c3b6ad19085cfa/greenlet-3.5.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3dbb4596a6a4e5d47121a33ff20533a81e60f302d9e67b69909a8bc21a43f0a7", size = 1647568, upload-time = "2026-09-14T14:35:49.787Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/71/d79f1791f824f8ff15c2978746640467ae932a2365e0201069f7f272395f/greenlet-3.5.6-cp312-cp312-win_amd64.whl", hash = "sha256:7ac4abb3877c43af320392c664774eef6fa2cc063c79a55fc02d844a3cbe7395", size = 324203, upload-time = "2026-09-14T14:22:54.504Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/af/42aca4d56e8cb321912203069d8d34734cb288222f10ad2ae102718cc577/greenlet-3.5.6-cp312-cp312-win_arm64.whl", hash = "sha256:301102a49120b095e72a7838792b41233975fc1c155daec6d98f81c00c9280e0", size = 308310, upload-time = "2026-09-14T14:24:03.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/a1/e720a38852366c589e1a46cf570b886507ad2cf591050c203365638baab0/greenlet-3.5.6-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:f96f0e30b5a95c7631b12bfe214cbc90ec8fe8cfa36920596c10514a65743519", size = 294627, upload-time = "2026-09-14T14:24:40.102Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/c3/58187858df41354a11e6a55b421e7af9059798abdab3a384cc51b8567c38/greenlet-3.5.6-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c75116c9de79949de23006e2d9b35ee82874c594fcf5c0311b439acaa14b8441", size = 614356, upload-time = "2026-09-14T15:12:03.399Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/b9/3a7e67d5f05c9760b1ad411fa52264bd69cc08e22a2ebfb4018b90628ced/greenlet-3.5.6-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cad5782f93f7f738b62c6527b6f32a60694d924029f299a8b524758cfa53d815", size = 626756, upload-time = "2026-09-14T15:20:44.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/7c/40400455f5b5a65bb83e94fde66d1be9e5ec518638113f8083ace746c309/greenlet-3.5.6-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a93ee7c6e8fd0f8a83525a51bd777be57ee17787e91d805bd8d6faf9dcada18e", size = 632632, upload-time = "2026-09-14T15:25:07.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/cb/ab0c123c514ed4e94c0dc9ee2e86362633e6b998cfc05de7fc9ac2eb9690/greenlet-3.5.6-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f98e8215e172f567ce80eeaed9107fb4d32b6c44f26983d9b8334658136a205a", size = 623779, upload-time = "2026-09-14T14:36:01.104Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/67/1f35cff30a6c51c3f23b63d4afcc7313ab4f97490ba3676fa78178984b27/greenlet-3.5.6-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:7f731ebac68ea06d628658295cb2d217b10186329fcf9a3b6a149045059bf92e", size = 434933, upload-time = "2026-09-14T15:28:38.858Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/26/fda8a5a06e7073333ccb038133c5893b9e0c4fe29d5992a17e83c241bc6e/greenlet-3.5.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df19e2d0b1620039af5102563fbd96e8938c7f5c3f5828528d641d9fc585525e", size = 1584930, upload-time = "2026-09-14T15:10:08.234Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/37/50f8813163148d6234e08b23dcad6a9e37f01d148c8ec976e4c44ea2d918/greenlet-3.5.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:06c0e933290fba8ffe53ead4ae1b8044b0e9754b75cebf381aa2bc3e50d82fac", size = 1647590, upload-time = "2026-09-14T14:35:51.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/da/b7669b09586365654083a62bd0724cf06cb74bd5085a15cdd161271f992f/greenlet-3.5.6-cp313-cp313-win_amd64.whl", hash = "sha256:5b602b4201b965a8354d74e232364a66ff243dd142e350d035f46169bb36e13d", size = 324086, upload-time = "2026-09-14T14:23:48.428Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/5d/c9663cfe84a2a9e0aa96f066f5b0594c227ea4c647511e087e2e11d4ac0a/greenlet-3.5.6-cp313-cp313-win_arm64.whl", hash = "sha256:876077e7ebb8c84ed068e2b23d4c62ebb010d60df84b9591af1be2f39010ffb2", size = 308211, upload-time = "2026-09-14T14:28:01.634Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/c0/d254544ae2b8bdd311aef000fafc02828c2771b17d994b3075620ea7cc6e/greenlet-3.5.6-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8cddea1b8339451c2fb3388e138347b6126744f33b611bdb55b7357361cfef46", size = 295221, upload-time = "2026-09-14T14:25:11.583Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/18/eb54be16b9cc3971e09ca5b73334e1b8c804a4630d9addaaf218a4fe300f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c59acfa8eb73a1e0d484392dc002bdf001fd4ce73394e0132df3d1ab6093d7cb", size = 660992, upload-time = "2026-09-14T15:12:04.876Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/b4/e193efe65671dcf294bc51fcc59efb52d154adf8612c4ea016da0d2c486c/greenlet-3.5.6-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3b4a01c6da07ef9f80d4fe8933b994bc99747bcea3eab0330a9c34d3c12655b", size = 673428, upload-time = "2026-09-14T15:20:45.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/21/631bb45fafde1dca782152377c0676d182ec924820064047f533a3627b28/greenlet-3.5.6-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dd0b83bed3405b586a3133629f1d1a5bc7bfd64822a3b7ab342bdc68e6dbc61b", size = 677688, upload-time = "2026-09-14T15:25:09.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/ac/28fa7a9e50f2859466214c4ac584d776db52c1604ad4dd158960a5af2a1f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a09d59bef1db94f384b5bcc2d523694d338f3df6b757aeeaf7baca5d0c0be88", size = 670773, upload-time = "2026-09-14T14:36:02.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/30/2b0a73e68e1e18e30b601d0d183cfdfc2beca4de5a6843c630f0fc9fb90c/greenlet-3.5.6-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:fdacf26402389bdd89857ad3c045a26fe8f3314f9a8b28226f82f88463a65b77", size = 480475, upload-time = "2026-09-14T15:28:40.741Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/cd/fb7d6cdd86ff3427c1494854f0e35437eba05142be91f530f6da75e09e19/greenlet-3.5.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8b7c73d1cef3d9ae963e9ff03f6222df43efbb9054ffd2f1969c935b7fc84c02", size = 1631900, upload-time = "2026-09-14T15:10:09.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/40/143bdbb20a516628cb15074ae52ed17d850b450292609c7a6fccac6dbece/greenlet-3.5.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8b27df301f56e3b3d2298095c8f7d6b68f2521f6b1693e901fa039bdbae34424", size = 1693740, upload-time = "2026-09-14T14:35:52.959Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/9e/019642432e6ae283301df1361227d47610709d2dc69a38f95edef266d713/greenlet-3.5.6-cp314-cp314-win_amd64.whl", hash = "sha256:f8f0bd690e1a41294ac87905e8121c81a3761ec2583c768f13467428606c8c7a", size = 327473, upload-time = "2026-09-14T14:28:12.948Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/7f/8aafc7bf70c948786dba7221d0dc0838e5329bebc6d434ef2208b4f0e760/greenlet-3.5.6-cp314-cp314-win_arm64.whl", hash = "sha256:8cda13494d86a4f12429641117cb6ac4bbbc9c30a33f711f7d3a2e5fbe4b0b7e", size = 311095, upload-time = "2026-09-14T14:28:00.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/7e/7a205688a5b3074933b18a906608d46d106e9a79d776bdab5a4abf4b4feb/greenlet-3.5.6-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:97c5a53e8c1754df58e73f047a99e287d4da1bdfe64b0072fb25c87000897951", size = 305352, upload-time = "2026-09-14T14:21:31.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/cb/9c4a57a9d9dd0256e20b8f7f4f06554c2c92badebf0ab73ce344321b78b9/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fea4427d1ffdb3b523d7daa6712038428a4c16c450b9777bdd1221cfee0eab49", size = 672671, upload-time = "2026-09-14T15:12:06.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/52/c6729681ebbd298f4decd28746815acc8a0b0a0fde21d2df33776fd4d042/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73a29b5ba642e35433166a03a3e02935e7238c4b3467fbd77523b99edea23e5b", size = 679489, upload-time = "2026-09-14T15:20:47.291Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/76/3c11c21e0716b1f1dc7c1a4b3d690abb1d3b448c69a9d32049fecb64010a/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:61a61b4a95a4f97922c3a6f5606d3e360851584bd47e500a5161373c53810e3d", size = 681303, upload-time = "2026-09-14T15:25:11.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/c5/2b6c721ba8b8963da42d5a0f57f25b8aaeb1fe9bdd156875e57f3be648a2/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:460e70b033aba8ed47e2ac9b5d0d2157b05a34fbfa30a241400aef4118902cdc", size = 676608, upload-time = "2026-09-14T14:36:03.959Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/26/3ae402202452cd5941bbbd483e5a74297e2397e7aa3182c2a5e3ab7d5666/greenlet-3.5.6-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:fe3170a69fe039b18ad18171e66faa9a75f6fe9d78f968fd9b54e09fbd714d81", size = 510112, upload-time = "2026-09-14T15:28:42.112Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/04/0d018e0d05bcdde19a0fcb907834155f1fc853a9bedd3f3f5e6acadcae19/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca80a49b53ed1d22f7282da7255f7bb2fd1935fd0f623d8613fda38745f18961", size = 1641479, upload-time = "2026-09-14T15:10:11.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/bb/f02ef9073919158f6403fe3701d4ed4403d646720e7201dfc6e9d264bac3/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:916f92f2a8db10508f739d0b5e00b83defe5d1115a997c54532a6d7cf8c95404", size = 1698758, upload-time = "2026-09-14T14:35:54.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/a5/1f48fe647473a2dcccfd1839b2ff2c78eb57009be776b4da071e901c9bff/greenlet-3.5.6-cp314-cp314t-win_amd64.whl", hash = "sha256:886bcf1870af74c32bc310fd00a6b803445e17e51b7d5a107c7b35c0f362cc16", size = 331574, upload-time = "2026-09-14T14:27:18.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/72/3882855a75838faeb54a58aeef4fd77d20b2a86d4bad570c70d41b565dcf/greenlet-3.5.6-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:3ac3494c381dab876cad7d0b22f3a722f3e0c8deb3a65b9e7f35ad7f58b8fcb3", size = 295926, upload-time = "2026-09-14T14:27:21.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/1f/be4d957d8a9b90bcbe8db206548a42134d96222d43e5ed3fc4708fb6e24b/greenlet-3.5.6-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:602024dae6d77e161f4b89491b62ca1d4f19949d79d47b2db057e476d21179d6", size = 666910, upload-time = "2026-09-14T15:12:07.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/af/60d62571a7d6de961e4ce7625d6c2faf359345659fc782d2cdf517c34577/greenlet-3.5.6-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8e63209c3e1e828ee6a457529b4a6d8b05d050fe0ae03a7ae49e967c5d312e0", size = 677415, upload-time = "2026-09-14T15:20:48.817Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/41/b3114c97c10e796010f00a30f51c81470072bca4b53e396ccca87484fcf7/greenlet-3.5.6-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9133d68624b1f2e89ec2f554d56aea8a5b0d7168cd9320200ba58d4d794845a4", size = 681337, upload-time = "2026-09-14T15:25:12.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/16/ac9e547b611539aaed1870eb1d6ddc57abdd5924b3a99bb9b5f0b44176b8/greenlet-3.5.6-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccadce0130fd813ec86ebfe969a6c58b42acc1d0fe55a47525375b740e07b605", size = 675927, upload-time = "2026-09-14T14:36:05.34Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/1b/d41861c2fa00968e39e467a495ca8db9ce9b6310a5d9b57561b3d0dc48fa/greenlet-3.5.6-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:5adcbbfe78bdc242c71740a02e0991cc1b2f34d33c8bb15ca45eee8fd1140942", size = 487339, upload-time = "2026-09-14T15:28:43.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/b1/b7ba08d6431121741f1d30be0d5d292e76873325179a63586cd9217b62f6/greenlet-3.5.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9297fb9c39b9a2c039dbcd306c410bd6906b95244dec3bba4318d36c718c164c", size = 1637236, upload-time = "2026-09-14T15:10:12.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/c5/3b1cbc68f0c082022fc8717f7fe4b8b13b8d583c52352be37f4e9f55bcd2/greenlet-3.5.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b374e79ffa7511afc11773aef40a4ccea6191fba1c856ea2f9c56738dca69d7a", size = 1698526, upload-time = "2026-09-14T14:35:56.039Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/56/12941ed2711400451c89d544e10f831800a2770f19dd55eac8f0f7f2003b/greenlet-3.5.6-cp315-cp315-win_amd64.whl", hash = "sha256:7969bffa322c097bd46ae595ada6a931cefda613f18ba64587e9cff4cb320756", size = 327739, upload-time = "2026-09-14T14:23:55.768Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/3b/576b9ed5ac929252e340cf60b4bcb6a8515350dc20797064b1922dc4ea75/greenlet-3.5.6-cp315-cp315-win_arm64.whl", hash = "sha256:8dba0129b93e7091dfefaf4cf7000172741bff7f47bf6326fcf17f32fbb54d6b", size = 311715, upload-time = "2026-09-14T14:28:25.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/c2/86cfc5555a98e12b86966ddbd24fd39af32f71f2f785c6595b7feb2db156/greenlet-3.5.6-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:de3de000d459402cda015068fd135aa50c0bf6f2477a80d4da1e646f123b4e78", size = 306244, upload-time = "2026-09-14T14:27:57.565Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/6d/83ffc9d05a75a80ab3a7595dbb1d9604e5d4fc2996d73a8ae2dbd1284900/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45663c01a4de48b9a64a2ee1509d92d1dfd3afb02b2ccfc9333029d11aef996a", size = 676578, upload-time = "2026-09-14T15:12:09.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/d6/c2cf684810e5caded075970aaadea654ecb58b8382b9aecf1d231b936894/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3deccbb57a481e3a408fe61cdfd5c13e0678fc0a30fdd09597917ca87b4be877", size = 684203, upload-time = "2026-09-14T15:20:50.261Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/d1/039c353d5593a97a89699e989324c9bc86af499e6c6152fe0180f5742204/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:63aff70fe5aac59c72215f42ec39fcb59ff46774fa966e717f8ecb6ee2273577", size = 686023, upload-time = "2026-09-14T15:25:14.528Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/19/00e1bee5d2af890dc8f400b54d0b0f9b489965f92bc12b407ff72cc6f469/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:311018b46472fb26ee85870847fb89eb64cc8aaddb617400789d87076f7cfeec", size = 681253, upload-time = "2026-09-14T14:36:06.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/62/97ceb8e0b2ea96046cdf8e95b042715020ebb12d83ea0690db80a8f03d23/greenlet-3.5.6-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:520648db8fb92eef7b3e6013f5a6f901cdf0d6685f639c2f7a245879f865bef7", size = 517058, upload-time = "2026-09-14T15:28:44.924Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/58/c9275fd0ca195d1d3402931bcce8cfcc74726ff76efb1883d229e6e1a3d7/greenlet-3.5.6-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7f924a5a9d5890649566f2f6682e0d8ad8ca23028bacffbbac36dbd7fd680176", size = 1645988, upload-time = "2026-09-14T15:10:13.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/36/b35747582fa4f1a5453f8f3002405dbac788e450cec7674dc2d204b6ccb5/greenlet-3.5.6-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:de9923832f2d8c1a5ecd8d7260465a6ca5a86888a0d129e3bd5cf0406d2fc5bf", size = 1702383, upload-time = "2026-09-14T14:35:58.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/69/6ec22ac9351e474d2a134d0ff9400dc80362d1c20f0721088ffffdfc205b/greenlet-3.5.6-cp315-cp315t-win_amd64.whl", hash = "sha256:2ab5f42ac6c238eb71770715e6e909ad9a1a92b6c681ccb64cd5a0f07edb953f", size = 331845, upload-time = "2026-09-14T14:27:41.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/cf/697c051fd534e223461fb8b523890e21a24eeca229cd50624cff6f02fabd/greenlet-3.5.6-cp315-cp315t-win_arm64.whl", hash = "sha256:f9fe868463ec7e1363733af77e38a5fda3e9b63940337048c945d69e0c80ff24", size = 315070, upload-time = "2026-09-14T14:22:21.476Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moli-benchmark"
|
||||
version = "0.1.0"
|
||||
@@ -11,11 +88,18 @@ dependencies = [
|
||||
{ name = "websockets" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
fingerprint = [
|
||||
{ name = "playwright" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "pillow", specifier = ">=10,<13" },
|
||||
{ name = "playwright", marker = "extra == 'fingerprint'", specifier = ">=1.48,<2" },
|
||||
{ name = "websockets", specifier = ">=13,<16" },
|
||||
]
|
||||
provides-extras = ["fingerprint"]
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
@@ -102,6 +186,46 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "playwright"
|
||||
version = "1.63.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "greenlet" },
|
||||
{ name = "pyee" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/19/dd/fbb3d34228ad753bc14464d5f2252585b73367539775a88d0a3949cf5a45/playwright-1.63.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:84c540759e8e7f01e690e197e04060f48899d37cea322299f255843273d3385d", size = 44278407, upload-time = "2026-09-15T16:49:06.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/9a/948b930b1a8c4ee869e5a139a2b7747caa06aab56a3f09a2f0abdcbda221/playwright-1.63.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fd1aa00631d44d55e56e0975bf3f3f285fac4a9fd2813183f0c12a488f1a1b24", size = 42944679, upload-time = "2026-09-15T16:49:10.039Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/11/dc5c13fa1602371603acd461be47529c1b3513815d3a0dc98f642c291a10/playwright-1.63.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:c89fc4736502a1f0fac2c8ca5d10c0cbc1c669f1f4774a2d8507a43140e4d53f", size = 44278410, upload-time = "2026-09-15T16:49:13.861Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/9c/103a5037789062bdab27c7dca53f3ca6b075b572ab2cd96eec825b3aec4e/playwright-1.63.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:ad21bc07516b187965a7521c5cf0df0bd657b17482eaad74335272d35a2b07de", size = 48217159, upload-time = "2026-09-15T16:49:17.404Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/82/3d85505284c5a210f2da6c07b8f757524e79d1fba9cfdafe1eafb766ae59/playwright-1.63.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:354e15b29503565fc598b89f16fbe070459343bef9d7498a93e304864000c6a7", size = 47902708, upload-time = "2026-09-15T16:49:21.055Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/8d/f74ff6b52751859f4b69caf66aa7d4a3c8d6c1f0c7dc9920da103612ee39/playwright-1.63.0-py3-none-win32.whl", hash = "sha256:660c00c62639e31b16700ba5456b351ddba55bb766b7ce8261223aa34928e482", size = 38606075, upload-time = "2026-09-15T16:49:29.546Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/eb/d6b8d92658038e260dbc7dd69fb3fdbe245aac283953de8b50b02bfe5f61/playwright-1.63.0-py3-none-win_amd64.whl", hash = "sha256:2f9a707a6c6c91157ed77bff2b8caeb04b3c8d46e70d585fc134298cbe4b5cc6", size = 38606082, upload-time = "2026-09-15T16:49:32.838Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/75/2432b3e3c7c62103b72d4c4cc8b16a56383ada372bbb0d1278591e988f71/playwright-1.63.0-py3-none-win_arm64.whl", hash = "sha256:1e4a3a838ce22fb68ad17193fcd142a19610d9d70fb9966d2239f5dddc0cc05b", size = 34523554, upload-time = "2026-09-15T16:49:36.455Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyee"
|
||||
version = "13.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "websockets"
|
||||
version = "15.0.1"
|
||||
|
||||
Reference in New Issue
Block a user