mirror of
https://github.com/ThePhaseless/Byparr.git
synced 2026-09-24 14:20:08 +01:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab1d4fdf93 | ||
|
|
98e1668721 | ||
|
|
beb281267a | ||
|
|
b46614fb55 | ||
|
|
cdfd73785a | ||
|
|
080b0f9141 | ||
|
|
c611afb865 | ||
|
|
cb2a862386 | ||
|
|
2852dc2527 | ||
|
|
12ef77177a | ||
|
|
11d7e59263 | ||
|
|
78b3f314c3 | ||
|
|
d0b013029c | ||
|
|
426aae4310 | ||
|
|
3dcf529609 | ||
|
|
61db8d82ee | ||
|
|
9067ca37df | ||
|
|
cb8fb58fa8 | ||
|
|
fc64fe05d5 | ||
|
|
24c339b085 | ||
|
|
c7633b4525 | ||
|
|
691aaa6f3f | ||
|
|
e0b1efa560 | ||
|
|
362b07e55a | ||
|
|
1a2cf32e1e |
@@ -0,0 +1,3 @@
|
||||
# Supported funding model platforms
|
||||
|
||||
github: [ThePhaseless]
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
## Project overview
|
||||
|
||||
- FastAPI service that mimics FlareSolverr-style API for bypassing anti-bot pages using Camoufox + Playwright captcha solver.
|
||||
- Entry point: main app in main.py; routes and request flow defined in src/endpoints.py and src/models.py.
|
||||
- Browser lifecycle is owned by get_camoufox() in src/utils.py, which yields a page, solver, and context for each request.
|
||||
- FastAPI service that mimics FlareSolverr-style API for bypassing anti-bot pages using invisible_playwright.
|
||||
- Entry point: main app in main.py; routes and request flow in src/endpoints.py, challenge handling in src/challenge.py, response bodies in src/content.py, models in src/models.py.
|
||||
- Browser lifecycle is owned by get_browser() in src/utils.py, which yields a page and context for each request.
|
||||
|
||||
## Architecture and data flow
|
||||
|
||||
- Request flow: POST /v1 -> read_item() -> browser.goto() -> wait for load states -> detect challenge title -> solve captcha -> return LinkResponse.
|
||||
- Challenge detection uses CHALLENGE_TITLES from src/consts.py; update this map when adding providers.
|
||||
- Request flow: POST /v1 -> read_item() -> page.goto() -> wait for load states -> detect the interstitial -> click its checkbox until it clears -> return LinkResponse.
|
||||
- Challenge detection uses detect_cloudflare_challenge() from playwright_captcha; the challenge is over when its markup goes, not when a solver says so.
|
||||
- Health check hits /v1 internally with <https://google.com> and fails if status is not OK.
|
||||
- Logging: LogRequest middleware logs only POST /v1 timing and outcome; other paths pass through.
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
- Local run: uv sync && uv run main.py
|
||||
- Init mode: uv run main.py --init (pre-warms health check via browser setup)
|
||||
- Tests: uv sync --group test && uv run pytest --retries 3
|
||||
- Tests: uv sync --group test && uv run pytest --retries 5
|
||||
- Docker troubleshooting: docker build --target test .
|
||||
|
||||
## Tests and external dependencies
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
# Ubuntu is required by playwright.
|
||||
# Pin to 24.04 LTS: ubuntu:latest floats to 26.04, which Playwright 1.58
|
||||
# cannot install firefox deps for (no libgtk-3 -> camoufox fails to launch).
|
||||
FROM ubuntu:24.04 AS base
|
||||
FROM ubuntu:26.04 AS base
|
||||
|
||||
ARG GITHUB_BUILD=false
|
||||
|
||||
@@ -51,7 +51,7 @@ RUN mkdir -p /home/byparr &&\
|
||||
FROM app AS test
|
||||
RUN \
|
||||
uv sync --group test &&\
|
||||
uv run pytest -rs --retries 3
|
||||
uv run pytest -rs --retries 5
|
||||
|
||||
FROM app
|
||||
ARG VERSION
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Byparr
|
||||
# Byparr [](https://github.com/sponsors/ThePhaseless)
|
||||
|
||||
<p align="center">
|
||||
<img src="icon/logo-byparr.svg" alt="Byparr logo" width="120" />
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
"""Find a click Cloudflare's checkbox actually accepts.
|
||||
|
||||
The widget is reachable now, but `Checkbox clicked successfully` leaves it
|
||||
`checked=False`, so the click is not registering as a user gesture. This tries
|
||||
each candidate in turn against a fresh page and reports which one ticks the box
|
||||
and which one clears the challenge.
|
||||
|
||||
docker run --rm -e PYTHONPATH=/app -e TRACE_URL=https://extratorrent.st/ \\
|
||||
-v "$PWD/out:/out" byparr-test uv run python debug/click_modes.py
|
||||
|
||||
MODES defaults to every strategy; set MODES=force,label to narrow it.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import pathlib
|
||||
import time
|
||||
|
||||
from src.utils import get_browser
|
||||
|
||||
URL = os.environ.get("TRACE_URL", "https://extratorrent.st/")
|
||||
MODES = os.environ.get(
|
||||
"MODES", "locator,force,check,label,mouse,widget_centre"
|
||||
).split(",")
|
||||
SETTLE = int(os.environ.get("SETTLE", "45"))
|
||||
OUT = pathlib.Path(os.environ.get("DIAG_OUT", "/out"))
|
||||
|
||||
|
||||
def log(*a: object) -> None:
|
||||
"""Print immediately."""
|
||||
print(*a, flush=True)
|
||||
|
||||
|
||||
def cf_frame(page):
|
||||
"""The turnstile widget frame."""
|
||||
for frame in page.frames:
|
||||
if "challenges.cloudflare.com" in frame.url and not frame.is_detached():
|
||||
return frame
|
||||
return None
|
||||
|
||||
|
||||
async def wait_for_widget(page, seconds: int = 30):
|
||||
"""Wait until the checkbox is visible, returning (frame, locator)."""
|
||||
for _ in range(seconds * 2):
|
||||
frame = cf_frame(page)
|
||||
if frame is not None:
|
||||
try:
|
||||
box = frame.locator('input[type="checkbox"]')
|
||||
if await box.count() and await box.first.is_visible():
|
||||
return frame, box.first
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
await asyncio.sleep(0.5)
|
||||
return None, None
|
||||
|
||||
|
||||
async def do_click(page, frame, box, mode: str) -> str:
|
||||
"""Perform one click strategy."""
|
||||
if mode == "locator":
|
||||
await box.click(timeout=10_000)
|
||||
return "locator.click()"
|
||||
if mode == "force":
|
||||
await box.click(timeout=10_000, force=True)
|
||||
return "locator.click(force=True)"
|
||||
if mode == "check":
|
||||
await box.check(timeout=10_000)
|
||||
return "locator.check()"
|
||||
if mode == "label":
|
||||
label = frame.locator("label")
|
||||
if await label.count():
|
||||
await label.first.click(timeout=10_000)
|
||||
return "label.click()"
|
||||
return "no label present"
|
||||
if mode in {"mouse", "widget_centre"}:
|
||||
rect = await box.bounding_box()
|
||||
if rect is None:
|
||||
return "no bounding box"
|
||||
x = rect["x"] + rect["width"] / 2
|
||||
y = rect["y"] + rect["height"] / 2
|
||||
# Approach first: a cursor that teleports is itself a signal.
|
||||
await page.mouse.move(x - 180, y - 120)
|
||||
await asyncio.sleep(0.3)
|
||||
await page.mouse.move(x - 40, y - 20, steps=18)
|
||||
await asyncio.sleep(0.2)
|
||||
await page.mouse.move(x, y, steps=10)
|
||||
await asyncio.sleep(0.35)
|
||||
await page.mouse.down()
|
||||
await asyncio.sleep(0.07)
|
||||
await page.mouse.up()
|
||||
return f"page.mouse at ({x:.0f}, {y:.0f})"
|
||||
return f"unknown mode {mode}"
|
||||
|
||||
|
||||
async def try_mode(mode: str) -> None:
|
||||
"""One fresh browser, one strategy, one verdict."""
|
||||
log(f"\n===== mode={mode} =====")
|
||||
async for dep in get_browser():
|
||||
page = dep.page
|
||||
await page.goto(URL, timeout=60_000)
|
||||
await page.wait_for_load_state("domcontentloaded", timeout=30_000)
|
||||
|
||||
# Is our shadow-root patch even surviving the page's CSP?
|
||||
try:
|
||||
src = await page.evaluate("() => Element.prototype.attachShadow.toString()")
|
||||
flag = await page.evaluate("() => '_shadowRootPatched' in window")
|
||||
log(f" on challenge page: native={'[native code]' in src} flag={flag}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log(f" tamper probe blocked: {str(exc)[:70]}")
|
||||
|
||||
frame, box = await wait_for_widget(page)
|
||||
if box is None:
|
||||
log(" checkbox never became visible")
|
||||
return
|
||||
|
||||
log(f" before: checked={await box.is_checked()}")
|
||||
try:
|
||||
what = await do_click(page, frame, box, mode)
|
||||
log(f" clicked via {what}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log(f" click raised: {str(exc)[:120]}")
|
||||
return
|
||||
|
||||
start = time.perf_counter()
|
||||
for _ in range(SETTLE // 3):
|
||||
await asyncio.sleep(3)
|
||||
elapsed = time.perf_counter() - start
|
||||
title = await page.title()
|
||||
checked = None
|
||||
frame_now = cf_frame(page)
|
||||
if frame_now is not None:
|
||||
try:
|
||||
b = frame_now.locator('input[type="checkbox"]')
|
||||
checked = await b.first.is_checked() if await b.count() else None
|
||||
except Exception: # noqa: BLE001
|
||||
checked = "unreadable"
|
||||
log(f" +{elapsed:3.0f}s checked={checked} title={title!r}")
|
||||
if "oment" not in title and "ierpliwo" not in title:
|
||||
log(f" >>> CLEARED by {mode}")
|
||||
await page.screenshot(path=str(OUT / f"cleared-{mode}.png"))
|
||||
return
|
||||
await page.screenshot(path=str(OUT / f"stuck-{mode}.png"))
|
||||
log(f" {mode}: still challenged")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Try each strategy on its own fresh browser."""
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
log(f"### {URL} modes={MODES}")
|
||||
for mode in MODES:
|
||||
try:
|
||||
await try_mode(mode.strip())
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log(f" mode {mode} blew up: {str(exc)[:150]}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
-119
@@ -1,119 +0,0 @@
|
||||
"""Watch Byparr work a Cloudflare challenge, with screenshots.
|
||||
|
||||
Runs the real /v1 handler against one URL and narrates the page every few
|
||||
seconds: what Cloudflare is showing, whether the challenge markers are still
|
||||
there, whether the widget frame is reachable, and whether the checkbox is
|
||||
clickable. Screenshots land in /out.
|
||||
|
||||
docker run --rm -e PYTHONPATH=/app -e TRACE_URL=https://extratorrent.st/ \\
|
||||
-e BUDGET=240 -v "$PWD/out:/out" byparr-test \\
|
||||
uv run python debug/probe.py
|
||||
|
||||
Set FRAMEWORK=patchright to launch the solver the other way for comparison.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
import time
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(relativeCreated)8.0fms %(name)s %(levelname)s %(message)s",
|
||||
stream=sys.stdout,
|
||||
)
|
||||
for noisy in ("asyncio", "httpx", "httpcore", "urllib3"):
|
||||
logging.getLogger(noisy).setLevel(logging.WARNING)
|
||||
|
||||
from src.endpoints import CHALLENGE_MARKERS, read_item # noqa: E402
|
||||
from src.models import LinkRequest # noqa: E402
|
||||
from src.utils import get_browser # noqa: E402
|
||||
|
||||
URL = os.environ.get("TRACE_URL", "https://extratorrent.st/")
|
||||
BUDGET = int(os.environ.get("BUDGET", "240"))
|
||||
OUT = pathlib.Path(os.environ.get("DIAG_OUT", "/out"))
|
||||
|
||||
|
||||
def log(*a: object) -> None:
|
||||
"""Print immediately so a hung step is still visible."""
|
||||
print(*a, flush=True)
|
||||
|
||||
|
||||
def cf_frame(page):
|
||||
"""The turnstile widget frame, if the browser exposes it."""
|
||||
for frame in page.frames:
|
||||
if "challenges.cloudflare.com" in frame.url and not frame.is_detached():
|
||||
return frame
|
||||
return None
|
||||
|
||||
|
||||
async def watch(page, seconds: int) -> None:
|
||||
"""Narrate the page while the handler works."""
|
||||
start = time.perf_counter()
|
||||
for i in range(seconds // 5):
|
||||
await asyncio.sleep(5)
|
||||
elapsed = time.perf_counter() - start
|
||||
try:
|
||||
title = await page.title()
|
||||
markers = await page.locator(CHALLENGE_MARKERS).count()
|
||||
body = (await page.locator("body").inner_text())[:70]
|
||||
body = body.replace("\n", " | ")
|
||||
|
||||
frame = cf_frame(page)
|
||||
widget = "no frame"
|
||||
if frame is not None:
|
||||
try:
|
||||
box = frame.locator('input[type="checkbox"]')
|
||||
count = await box.count()
|
||||
visible = count and await box.first.is_visible()
|
||||
checked = await box.first.is_checked() if count else None
|
||||
widget = f"checkbox={count} visible={bool(visible)} checked={checked}"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
widget = f"frame unreadable: {str(exc)[:50]}"
|
||||
|
||||
log(f" +{elapsed:5.0f}s markers={markers} {widget} | {title!r} {body!r}")
|
||||
if i % 3 == 0:
|
||||
await page.screenshot(path=str(OUT / f"probe-{elapsed:04.0f}s.png"))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log(f" +{elapsed:5.0f}s <{str(exc)[:80]}>")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Call read_item and report what came back."""
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
log(f"### {URL} budget={BUDGET}s")
|
||||
async for dep in get_browser():
|
||||
try:
|
||||
native = await dep.page.evaluate(
|
||||
"() => Element.prototype.attachShadow.toString()"
|
||||
)
|
||||
flagged = await dep.page.evaluate("() => '_shadowRootPatched' in window")
|
||||
log(f" attachShadow native: {'[native code]' in native}")
|
||||
log(f" _shadowRootPatched flag on window: {flagged}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log(f" tamper probe failed: {str(exc)[:80]}")
|
||||
|
||||
watcher = asyncio.create_task(watch(dep.page, BUDGET))
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
response = await read_item(LinkRequest(url=URL, max_timeout=BUDGET), dep)
|
||||
took = time.perf_counter() - started
|
||||
cookies = [c["name"] for c in response.solution.cookies]
|
||||
log(f"\nRESULT ok in {took:.0f}s")
|
||||
log(f" solution.status = {response.solution.status}")
|
||||
log(f" bytes = {len(response.solution.response)}")
|
||||
log(f" cf_clearance = {'cf_clearance' in cookies}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log(f"\nRESULT failed in {time.perf_counter() - started:.0f}s: {exc!r}"[:250])
|
||||
watcher.cancel()
|
||||
try:
|
||||
await dep.page.screenshot(path=str(OUT / "probe-final.png"))
|
||||
log(f"final title = {await dep.page.title()!r}")
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -1,222 +0,0 @@
|
||||
"""Two variables at once: hide the patch, and click where a human would.
|
||||
|
||||
Findings this is built on, both measured on a residential connection:
|
||||
|
||||
* On the challenge page the library's unlockShadowRoot.js has run --
|
||||
`_shadowRootPatched` is a global and attachShadow is visibly patched. Any
|
||||
anti-bot script can read that in one line.
|
||||
* Turnstile's <input type="checkbox"> is invisible. Playwright reports a
|
||||
successful click on it and `checked` never flips, because the real target
|
||||
is the overlay drawn on top.
|
||||
|
||||
So: MODE=stealth patches shadow roots without leaving a global or a
|
||||
non-native toString; MODE=library keeps the current behaviour. Either way the
|
||||
click is a real mouse press at the widget's visible position, not a synthetic
|
||||
click on a hidden input.
|
||||
|
||||
docker run --rm -e PYTHONPATH=/app -e MODE=stealth \\
|
||||
-e TRACE_URL=https://extratorrent.st/ -v "$PWD/out:/out" \\
|
||||
byparr-test uv run python debug/stealth_click.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import pathlib
|
||||
|
||||
from invisible_playwright.async_api import InvisiblePlaywright
|
||||
from playwright_captcha import ClickSolver, FrameworkType
|
||||
|
||||
URL = os.environ.get("TRACE_URL", "https://extratorrent.st/")
|
||||
MODE = os.environ.get("MODE", "stealth")
|
||||
WATCH = int(os.environ.get("WATCH", "60"))
|
||||
OUT = pathlib.Path(os.environ.get("DIAG_OUT", "/out"))
|
||||
|
||||
PREFS = {
|
||||
"devtools.jsonview.enabled": False,
|
||||
"browser.tabs.remote.useCrossOriginOpenerPolicy": False,
|
||||
"browser.tabs.remote.useCrossOriginEmbedderPolicy": False,
|
||||
}
|
||||
|
||||
STEALTH_UNLOCK = """
|
||||
(() => {
|
||||
const nativeToString = Function.prototype.toString;
|
||||
const spoofed = new WeakMap();
|
||||
const asNative = (fake, real) => { spoofed.set(fake, real); return fake; };
|
||||
|
||||
Function.prototype.toString = asNative(function toString() {
|
||||
const real = spoofed.get(this);
|
||||
return nativeToString.call(real === undefined ? this : real);
|
||||
}, nativeToString);
|
||||
|
||||
const hidden = new WeakMap();
|
||||
const realAttach = Element.prototype.attachShadow;
|
||||
Element.prototype.attachShadow = asNative(function attachShadow(init) {
|
||||
const root = realAttach.call(this, Object.assign({}, init, {mode: 'open'}));
|
||||
hidden.set(this, root);
|
||||
return root;
|
||||
}, realAttach);
|
||||
|
||||
const desc = Object.getOwnPropertyDescriptor(Element.prototype, 'shadowRoot');
|
||||
if (desc && desc.get) {
|
||||
const realGet = desc.get;
|
||||
Object.defineProperty(Element.prototype, 'shadowRoot', {
|
||||
get: asNative(function shadowRoot() {
|
||||
return realGet.call(this) || hidden.get(this);
|
||||
}, realGet),
|
||||
configurable: desc.configurable,
|
||||
enumerable: desc.enumerable,
|
||||
});
|
||||
}
|
||||
})();
|
||||
"""
|
||||
|
||||
PROBE = """
|
||||
() => ({
|
||||
flag: '_shadowRootPatched' in window,
|
||||
attachNative: /\\[native code\\]/.test(Element.prototype.attachShadow.toString()),
|
||||
})
|
||||
"""
|
||||
|
||||
|
||||
def log(*a: object) -> None:
|
||||
"""Print immediately."""
|
||||
print(*a, flush=True)
|
||||
|
||||
|
||||
def cf_frame(page):
|
||||
"""The turnstile widget frame."""
|
||||
for frame in page.frames:
|
||||
if "challenges.cloudflare.com" in frame.url and not frame.is_detached():
|
||||
return frame
|
||||
return None
|
||||
|
||||
|
||||
async def widget_box(page):
|
||||
"""Where the widget is drawn, in main-page coordinates."""
|
||||
try:
|
||||
el = page.locator('iframe[src*="challenges.cloudflare.com"]').first
|
||||
if await el.count():
|
||||
return await el.bounding_box()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
async def checkbox_state(page) -> str:
|
||||
"""What the hidden input currently reports."""
|
||||
frame = cf_frame(page)
|
||||
if frame is None:
|
||||
return "no frame"
|
||||
try:
|
||||
box = frame.locator('input[type="checkbox"]')
|
||||
if not await box.count():
|
||||
return "no input"
|
||||
return f"checked={await box.first.is_checked()}"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return f"unreadable ({str(exc)[:40]})"
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Load the challenge, press the widget like a person, watch the verdict."""
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
log(f"### mode={MODE} {URL}")
|
||||
|
||||
async with InvisiblePlaywright(
|
||||
headless=True, humanize=True, locale="auto", extra_prefs=PREFS
|
||||
) as browser:
|
||||
context = await browser.new_context()
|
||||
page = await context.new_page()
|
||||
|
||||
solver = None
|
||||
if MODE == "stealth":
|
||||
await page.add_init_script(STEALTH_UNLOCK)
|
||||
else:
|
||||
solver = ClickSolver(
|
||||
framework=FrameworkType.PLAYWRIGHT, page=page, max_attempts=1
|
||||
)
|
||||
await solver.__aenter__()
|
||||
|
||||
goto_timeout = int(os.environ.get("GOTO_TIMEOUT", "120")) * 1000
|
||||
try:
|
||||
response = await page.goto(URL, timeout=goto_timeout)
|
||||
log(f" goto status: {response.status if response else None}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log(f" goto FAILED: {type(exc).__name__}: {str(exc)[:150]}")
|
||||
return
|
||||
try:
|
||||
await page.wait_for_load_state("domcontentloaded", timeout=60_000)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log(f" domcontentloaded wait failed: {str(exc)[:100]}")
|
||||
log(f" on challenge page: {await page.evaluate(PROBE)}")
|
||||
log(f" title: {await page.title()!r}")
|
||||
|
||||
# Wait for the checkbox to actually be presented, not merely for the
|
||||
# iframe to exist. Cloudflare cycles: the widget frame appears first
|
||||
# while it is still "checking if you are human" with no input in it,
|
||||
# and only then renders the checkbox. Pressing during that first phase
|
||||
# is a click into nothing, which is what every earlier probe did.
|
||||
ready = False
|
||||
for i in range(90):
|
||||
frame = cf_frame(page)
|
||||
if frame is not None:
|
||||
try:
|
||||
count = await frame.locator('input[type="checkbox"]').count()
|
||||
except Exception: # noqa: BLE001
|
||||
count = 0
|
||||
if count:
|
||||
log(f" checkbox presented after {i}s")
|
||||
ready = True
|
||||
break
|
||||
await asyncio.sleep(1)
|
||||
if not ready:
|
||||
log(" checkbox never appeared")
|
||||
await page.screenshot(path=str(OUT / f"{MODE}-no-checkbox.png"))
|
||||
return
|
||||
|
||||
# Let it settle, then confirm it is still presented before pressing.
|
||||
await asyncio.sleep(2)
|
||||
frame = cf_frame(page)
|
||||
if frame is None or not await frame.locator('input[type="checkbox"]').count():
|
||||
log(" checkbox vanished while settling -- Cloudflare moved on")
|
||||
return
|
||||
|
||||
box = await widget_box(page)
|
||||
if not box:
|
||||
log(" widget has no bounding box")
|
||||
return
|
||||
log(f" widget box: {box}")
|
||||
log(f" before: {await checkbox_state(page)}")
|
||||
await page.screenshot(path=str(OUT / f"{MODE}-before-click.png"))
|
||||
|
||||
# The checkbox sits at the left of the widget, vertically centred.
|
||||
x = box["x"] + 30
|
||||
y = box["y"] + box["height"] / 2
|
||||
await page.mouse.move(x - 200, y - 130)
|
||||
await asyncio.sleep(0.4)
|
||||
await page.mouse.move(x - 50, y - 25, steps=22)
|
||||
await asyncio.sleep(0.25)
|
||||
await page.mouse.move(x, y, steps=12)
|
||||
await asyncio.sleep(0.4)
|
||||
await page.mouse.down()
|
||||
await asyncio.sleep(0.08)
|
||||
await page.mouse.up()
|
||||
log(f" pressed at ({x:.0f}, {y:.0f})")
|
||||
|
||||
for i in range(WATCH // 3):
|
||||
await asyncio.sleep(3)
|
||||
title = await page.title()
|
||||
log(f" +{(i + 1) * 3:3d}s {await checkbox_state(page)} title={title!r}")
|
||||
if "oment" not in title and "ierpliwo" not in title:
|
||||
log(f" >>> CLEARED by mode={MODE}")
|
||||
await page.screenshot(path=str(OUT / f"{MODE}-cleared.png"))
|
||||
log(f" cookies={[c['name'] for c in await context.cookies()]}")
|
||||
return
|
||||
await page.screenshot(path=str(OUT / f"{MODE}-end.png"))
|
||||
log(f" mode={MODE}: still challenged")
|
||||
|
||||
if solver:
|
||||
await solver.__aexit__(None, None, None)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+2
-2
@@ -10,7 +10,7 @@ readme = "README.md"
|
||||
dependencies = [
|
||||
"fastapi[standard]==0.141.*",
|
||||
"invisible-playwright>=0.6.1",
|
||||
"playwright==1.60.*",
|
||||
"playwright==1.63.*",
|
||||
"playwright-captcha==0.1.*",
|
||||
"pydantic==2.*",
|
||||
"pydantic-settings==2.*",
|
||||
@@ -20,7 +20,7 @@ urls = { repository = "https://github.com/ThePhaseless/Byparr" }
|
||||
[dependency-groups]
|
||||
|
||||
test = [
|
||||
"httpx2==2.10.*",
|
||||
"httpx2==2.13.*",
|
||||
"pytest==9.1.*",
|
||||
"pytest-asyncio==1.4.*",
|
||||
"pytest-retry==1.7.*",
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import time
|
||||
from asyncio import sleep
|
||||
from contextlib import suppress
|
||||
|
||||
from playwright.async_api import Error as PlaywrightError
|
||||
from playwright.async_api import FloatRect, Page
|
||||
from playwright.async_api import TimeoutError as PlaywrightTimeoutError
|
||||
from playwright_captcha.solvers.click.cloudflare.utils.detection import (
|
||||
CF_INTERSTITIAL_INDICATORS_SELECTORS,
|
||||
detect_cloudflare_challenge,
|
||||
)
|
||||
|
||||
from src.utils import TimeoutTimer, logger
|
||||
|
||||
__all__ = [
|
||||
"CF_INTERSTITIAL_INDICATORS_SELECTORS",
|
||||
"challenge_present",
|
||||
"solve_challenge",
|
||||
]
|
||||
|
||||
POLL_INTERVAL = 0.25
|
||||
CLICK_SETTLE = 1.5
|
||||
CLICK_COOLDOWN = 4
|
||||
PROBE_INTERVAL = 0.5
|
||||
TOKEN_READ_TIMEOUT = 1000
|
||||
BOX_READ_TIMEOUT = 1000
|
||||
CHECKBOX_INSET = 25
|
||||
TURNSTILE_INPUT = 'input[name="cf-turnstile-response"]'
|
||||
WIDGET_ANCESTOR_DEPTHS = (1, 2, 3, 4)
|
||||
WIDGET_MIN_WIDTH = 40
|
||||
WIDGET_MIN_HEIGHT = 20
|
||||
WIDGET_MAX_HEIGHT = 120
|
||||
|
||||
|
||||
async def challenge_present(page: Page) -> bool:
|
||||
"""Report whether the Cloudflare interstitial is up."""
|
||||
return await detect_cloudflare_challenge(page, "interstitial")
|
||||
|
||||
|
||||
async def widget_box(page: Page) -> FloatRect | None:
|
||||
"""Measure the widget container with locators; running page scripts resets the challenge."""
|
||||
for depth in WIDGET_ANCESTOR_DEPTHS:
|
||||
widget = page.locator(f"{TURNSTILE_INPUT} >> xpath=ancestor::div[{depth}]")
|
||||
with suppress(PlaywrightError, PlaywrightTimeoutError):
|
||||
if await widget.count() == 0:
|
||||
continue
|
||||
box = await widget.first.bounding_box(timeout=BOX_READ_TIMEOUT)
|
||||
if (
|
||||
box
|
||||
and box["width"] > WIDGET_MIN_WIDTH
|
||||
and WIDGET_MIN_HEIGHT < box["height"] < WIDGET_MAX_HEIGHT
|
||||
):
|
||||
return box
|
||||
return None
|
||||
|
||||
|
||||
async def click_checkbox(page: Page) -> bool:
|
||||
"""Click the checkbox through its container, leaving its closed shadow root alone."""
|
||||
box = await widget_box(page)
|
||||
if box is None:
|
||||
return False
|
||||
await page.mouse.move(box["x"] + CHECKBOX_INSET, box["y"] + box["height"] / 2)
|
||||
try:
|
||||
await page.mouse.down()
|
||||
finally:
|
||||
await page.mouse.up()
|
||||
return True
|
||||
|
||||
|
||||
async def checkbox_already_answered(page: Page) -> bool:
|
||||
"""Report whether Turnstile has already filled in its response token."""
|
||||
token = page.locator(TURNSTILE_INPUT)
|
||||
with suppress(PlaywrightError, PlaywrightTimeoutError):
|
||||
if await token.count() > 0:
|
||||
return bool(await token.first.input_value(timeout=TOKEN_READ_TIMEOUT))
|
||||
return False
|
||||
|
||||
|
||||
async def challenge_is_gone(page: Page) -> bool:
|
||||
"""Confirm the interstitial is really gone and not just between navigations."""
|
||||
if await challenge_present(page):
|
||||
return False
|
||||
await sleep(POLL_INTERVAL)
|
||||
return not await challenge_present(page)
|
||||
|
||||
|
||||
async def solve_challenge(page: Page, timer: TimeoutTimer) -> None:
|
||||
"""Wait out the interstitial, clicking its checkbox whenever one is offered."""
|
||||
logger.info("Challenge detected, waiting for it to clear...")
|
||||
clicks = 0
|
||||
next_click = 0.0
|
||||
while True:
|
||||
if await challenge_is_gone(page):
|
||||
logger.debug("Challenge cleared.")
|
||||
if clicks:
|
||||
await sleep(min(CLICK_SETTLE, timer.remaining()))
|
||||
return
|
||||
|
||||
if time.perf_counter() >= next_click:
|
||||
landed = False
|
||||
with suppress(PlaywrightError, PlaywrightTimeoutError):
|
||||
landed = not await checkbox_already_answered(
|
||||
page
|
||||
) and await click_checkbox(page)
|
||||
if landed:
|
||||
clicks += 1
|
||||
logger.info("Clicked the challenge checkbox (attempt %d).", clicks)
|
||||
next_click = time.perf_counter() + (
|
||||
CLICK_COOLDOWN if landed else PROBE_INTERVAL
|
||||
)
|
||||
|
||||
if timer.remaining() <= 0:
|
||||
break
|
||||
await sleep(POLL_INTERVAL)
|
||||
|
||||
message = "Challenge still present when the request budget ran out"
|
||||
raise TimeoutError(message)
|
||||
@@ -9,13 +9,6 @@ class Settings(BaseSettings):
|
||||
log_level: str = "INFO"
|
||||
version: str = "unknown"
|
||||
|
||||
# The solver retries whenever it cannot reach the challenge widget, and
|
||||
# that failure is usually structural rather than transient -- an
|
||||
# unreachable widget stays unreachable. sys.maxsize meant a single request
|
||||
# burned its whole max_timeout on ~1300 identical failed attempts before
|
||||
# reporting a 408. Give it a handful of tries, then let the caller know.
|
||||
max_attempts: int = 5
|
||||
|
||||
proxy_server: str | None = None
|
||||
proxy_username: str | None = None
|
||||
proxy_password: str | None = None
|
||||
@@ -34,8 +27,6 @@ settings = Settings()
|
||||
LOG_LEVEL = logging.getLevelNamesMapping()[settings.log_level.upper()]
|
||||
VERSION = settings.version.removeprefix("v")
|
||||
|
||||
MAX_ATTEMPTS = settings.max_attempts
|
||||
|
||||
PROXY_SERVER = settings.proxy_server
|
||||
PROXY_USERNAME = settings.proxy_username
|
||||
PROXY_PASSWORD = settings.proxy_password
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import base64
|
||||
|
||||
from playwright.async_api import Page
|
||||
|
||||
from src.models import LinkRequest
|
||||
from src.utils import logger
|
||||
|
||||
|
||||
async def build_response_content(
|
||||
page: Page,
|
||||
request: LinkRequest,
|
||||
page_request: object,
|
||||
*,
|
||||
challenge_detected: bool,
|
||||
page_html: str | None,
|
||||
) -> tuple[str, str]:
|
||||
"""Build (content_type, response_content) from the settled page."""
|
||||
if request.return_only_cookies:
|
||||
return "text/html", ""
|
||||
|
||||
if page_request and page_request.headers.get("content-type", "").startswith(
|
||||
"application/pdf"
|
||||
):
|
||||
return await fetch_pdf_content(page)
|
||||
|
||||
response_content = (
|
||||
page_html
|
||||
if page_html is not None and not challenge_detected
|
||||
else await page.content()
|
||||
)
|
||||
return "text/html", response_content
|
||||
|
||||
|
||||
async def fetch_pdf_content(page: Page) -> tuple[str, str]:
|
||||
"""Fetch raw PDF bytes as base64, falling back to viewer HTML on failure."""
|
||||
try:
|
||||
fetch_response = await page.request.fetch(page.url)
|
||||
response_content = base64.b64encode(await fetch_response.body()).decode("ascii")
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch PDF bytes, falling back to viewer HTML")
|
||||
return "text/html", await page.content()
|
||||
return "application/pdf", response_content
|
||||
+31
-209
@@ -1,25 +1,28 @@
|
||||
import base64
|
||||
import time
|
||||
import warnings
|
||||
from asyncio import sleep
|
||||
from http import HTTPStatus
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import RedirectResponse
|
||||
from playwright.async_api import Page
|
||||
from playwright.async_api import Error as PlaywrightError
|
||||
from playwright.async_api import TimeoutError as PlaywrightTimeoutError
|
||||
from playwright_captcha.solvers.click.cloudflare.utils.detection import (
|
||||
detect_cloudflare_challenge,
|
||||
)
|
||||
|
||||
from src.challenge import challenge_present, solve_challenge
|
||||
from src.content import build_response_content
|
||||
from src.models import (
|
||||
HealthcheckResponse,
|
||||
LinkRequest,
|
||||
LinkResponse,
|
||||
Solution,
|
||||
)
|
||||
from src.utils import BrowserDepClass, TimeoutTimer, get_browser, logger
|
||||
from src.utils import (
|
||||
BrowserDepClass,
|
||||
TimeoutTimer,
|
||||
get_browser,
|
||||
logger,
|
||||
remaining_ms,
|
||||
)
|
||||
|
||||
warnings.filterwarnings("ignore", category=SyntaxWarning)
|
||||
|
||||
@@ -28,32 +31,6 @@ router = APIRouter()
|
||||
|
||||
BrowserDep = Annotated[BrowserDepClass, Depends(get_browser)]
|
||||
|
||||
# Markup only an unsolved challenge has. Two near misses to avoid:
|
||||
#
|
||||
# script[src*="/cdn-cgi/challenge-platform/"] on its own also matches the jsd
|
||||
# bot-scoring beacon Cloudflare serves from that path on ordinary pages, so it
|
||||
# has to be narrowed to the challenge orchestrator (chl_page).
|
||||
#
|
||||
# iframe[src*="challenges.cloudflare.com"] looks like the widget but outlives
|
||||
# it: a cleared nowsecure.nl carries two of them with no challenge in sight.
|
||||
CHALLENGE_MARKERS = (
|
||||
'script[src*="/cdn-cgi/challenge-platform/"][src*="chl_page"], '
|
||||
"#challenge-error-text, #challenge-running, #challenge-stage"
|
||||
)
|
||||
|
||||
# The widget lives in an iframe served from here; the checkbox is an invisible
|
||||
# input inside it, so it is pressed by position rather than by locator. 30px in
|
||||
# from the widget's left edge is the middle of the box Cloudflare draws.
|
||||
CF_WIDGET_HOST = "challenges.cloudflare.com"
|
||||
CHECKBOX_SELECTOR = 'input[type="checkbox"]'
|
||||
CHECKBOX_OFFSET_X = 30
|
||||
|
||||
# How often to look, and how long to leave a press alone before trying again.
|
||||
# Verification takes 5-15s, and pressing over the top of it just restarts the
|
||||
# cycle.
|
||||
CHALLENGE_POLL_SECONDS = 1.0
|
||||
PRESS_INTERVAL_SECONDS = 12.0
|
||||
|
||||
|
||||
@router.get("/", include_in_schema=False)
|
||||
def read_root():
|
||||
@@ -89,7 +66,7 @@ async def read_item(request: LinkRequest, dep: BrowserDep) -> LinkResponse:
|
||||
await setup_routes(request, dep)
|
||||
|
||||
try:
|
||||
challenge_detected, page_html, page_request, status = await _navigate_and_solve(
|
||||
challenge_detected, page_html, page_request = await _navigate_and_solve(
|
||||
dep, request, timer
|
||||
)
|
||||
except (TimeoutError, PlaywrightTimeoutError) as e:
|
||||
@@ -98,24 +75,32 @@ async def read_item(request: LinkRequest, dep: BrowserDep) -> LinkResponse:
|
||||
status_code=408,
|
||||
detail="Timed out while loading the page or solving the challenge",
|
||||
) from e
|
||||
except PlaywrightError as e:
|
||||
logger.error("Could not reach the target: %s", e)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"Could not reach the target: {e}",
|
||||
) from e
|
||||
|
||||
cookies = await dep.context.cookies()
|
||||
content_type, response_content = await build_response_content(
|
||||
dep,
|
||||
dep.page,
|
||||
request,
|
||||
page_request,
|
||||
challenge_detected=challenge_detected,
|
||||
page_html=page_html,
|
||||
)
|
||||
|
||||
user_agent = page_request.request.headers.get("user-agent") if page_request else ""
|
||||
user_agent = (
|
||||
page_request.request.headers.get("user-agent") or "" if page_request else ""
|
||||
)
|
||||
|
||||
return LinkResponse(
|
||||
message="Success",
|
||||
solution=Solution(
|
||||
user_agent=user_agent,
|
||||
url=dep.page.url,
|
||||
status=status,
|
||||
status=HTTPStatus.OK,
|
||||
cookies=cookies,
|
||||
headers=page_request.headers if page_request else {},
|
||||
response=response_content,
|
||||
@@ -142,192 +127,29 @@ async def _navigate_and_solve(
|
||||
dep: BrowserDep,
|
||||
request: LinkRequest,
|
||||
timer: TimeoutTimer,
|
||||
) -> tuple[bool, str | None, object, HTTPStatus]:
|
||||
) -> tuple[bool, str | None, object]:
|
||||
"""Navigate to the URL, then solve a challenge or wait for network idle."""
|
||||
page_html: str | None = None
|
||||
page_request = await dep.page.goto(request.url, timeout=timer.remaining() * 1000)
|
||||
status = page_request.status if page_request else HTTPStatus.OK
|
||||
page_request = await dep.page.goto(request.url, timeout=remaining_ms(timer))
|
||||
await dep.page.wait_for_load_state(
|
||||
state="domcontentloaded", timeout=timer.remaining() * 1000
|
||||
state="domcontentloaded", timeout=remaining_ms(timer)
|
||||
)
|
||||
|
||||
challenge_active = await detect_cloudflare_challenge(
|
||||
dep.page, "interstitial"
|
||||
) or await detect_cloudflare_challenge(dep.page, "turnstile")
|
||||
if not challenge_active:
|
||||
if not await challenge_present(dep.page):
|
||||
page_html = await dep.page.content()
|
||||
await _wait_for_networkidle(dep, timer)
|
||||
return False, page_html, page_request, status
|
||||
return False, page_html, page_request
|
||||
|
||||
await _solve_challenge(dep, timer)
|
||||
status = HTTPStatus.OK
|
||||
return True, page_html, page_request, status
|
||||
|
||||
|
||||
async def _solve_challenge(dep: BrowserDep, timer: TimeoutTimer) -> None:
|
||||
"""
|
||||
Attempt to solve a detected Cloudflare interstitial challenge.
|
||||
|
||||
Handles both shapes Cloudflare serves: the non-interactive challenge, which
|
||||
clears itself given a few seconds, and the interactive one, which needs the
|
||||
checkbox pressed. Both are covered by the same loop -- watch for the
|
||||
challenge markup to disappear, and press whenever a checkbox is on offer.
|
||||
|
||||
playwright-captcha's solver is deliberately not used here. It clicks the
|
||||
checkbox input directly, and that input is invisible, so the click reports
|
||||
success while `checked` never flips. It also judges the result by waiting
|
||||
for networkidle, which returned 9ms after the click while Cloudflare was
|
||||
still verifying, so it reported failure on challenges that were about to
|
||||
pass.
|
||||
"""
|
||||
logger.info("Challenge detected, attempting to solve...")
|
||||
last_press = -PRESS_INTERVAL_SECONDS
|
||||
while timer.remaining() > 0:
|
||||
if not await _challenge_visible(dep.page):
|
||||
logger.info("Challenge cleared")
|
||||
return
|
||||
|
||||
elapsed = timer.duration - timer.remaining()
|
||||
if elapsed - last_press >= PRESS_INTERVAL_SECONDS and await _press_checkbox(
|
||||
dep.page
|
||||
):
|
||||
last_press = elapsed
|
||||
|
||||
await sleep(CHALLENGE_POLL_SECONDS)
|
||||
|
||||
message = "Challenge still present when the request budget ran out"
|
||||
raise TimeoutError(message)
|
||||
|
||||
|
||||
def _cloudflare_frame(page: Page) -> object | None:
|
||||
"""Find the turnstile widget's frame; None while Cloudflare is between states."""
|
||||
for frame in page.frames:
|
||||
if CF_WIDGET_HOST in frame.url and not frame.is_detached():
|
||||
return frame
|
||||
return None
|
||||
|
||||
|
||||
async def _press_checkbox(page: Page) -> bool:
|
||||
"""
|
||||
Press the checkbox, if one is currently on offer. True when a press happened.
|
||||
|
||||
Two things make this harder than locator.click():
|
||||
|
||||
Cloudflare cycles between "checking if you are human", where the widget
|
||||
frame holds no input at all, and the state where the checkbox is offered.
|
||||
Pressing during the first phase clicks nothing, so wait for the input to
|
||||
exist before reaching for the mouse.
|
||||
|
||||
And the input is invisible -- it sits under a styled overlay. Playwright
|
||||
reports a successful click on it and `checked` never flips, which is why
|
||||
playwright-captcha's own click has never solved one of these. Pressing the
|
||||
widget's visible pixels does work: measured against ext.to, this clears the
|
||||
challenge and returns a cf_clearance cookie.
|
||||
"""
|
||||
frame = _cloudflare_frame(page)
|
||||
if frame is None:
|
||||
return False
|
||||
try:
|
||||
checkbox = frame.locator(CHECKBOX_SELECTOR)
|
||||
if not await checkbox.count():
|
||||
return False
|
||||
if await checkbox.first.is_checked():
|
||||
# A press has already landed and Cloudflare is verifying it.
|
||||
# Pressing over the top restarts that verification, which is how
|
||||
# ext.to and speed.cd sat on "performing security verification" for
|
||||
# a full 300s budget while being pressed a dozen times.
|
||||
return False
|
||||
element = await frame.frame_element()
|
||||
box = await element.bounding_box()
|
||||
except Exception:
|
||||
# The widget is mid-swap; try again on the next poll.
|
||||
return False
|
||||
if not box:
|
||||
return False
|
||||
|
||||
x = box["x"] + CHECKBOX_OFFSET_X
|
||||
y = box["y"] + box["height"] / 2
|
||||
try:
|
||||
# Approach before pressing: a cursor that teleports onto the target is
|
||||
# itself a signal.
|
||||
await page.mouse.move(x - 180, y - 120)
|
||||
await sleep(0.3)
|
||||
await page.mouse.move(x - 45, y - 20, steps=18)
|
||||
await sleep(0.2)
|
||||
await page.mouse.move(x, y, steps=10)
|
||||
await sleep(0.35)
|
||||
await page.mouse.down()
|
||||
await sleep(0.08)
|
||||
await page.mouse.up()
|
||||
except Exception as exc:
|
||||
logger.debug(f"Checkbox press failed: {exc}")
|
||||
return False
|
||||
logger.info(f"Pressed the Cloudflare checkbox at ({x:.0f}, {y:.0f}) in {box}")
|
||||
return True
|
||||
|
||||
|
||||
async def _challenge_visible(page: Page) -> bool:
|
||||
"""
|
||||
Report whether an unsolved challenge is still on the page.
|
||||
|
||||
Cloudflare serves two different scripts from /cdn-cgi/challenge-platform/:
|
||||
the challenge orchestrator on an interstitial, and the jsd bot-scoring
|
||||
beacon on ordinary pages once a visitor is cleared. detect_cloudflare_
|
||||
challenge() matches both, so on its own it never reports success. Match the
|
||||
orchestrator and the widget instead.
|
||||
"""
|
||||
try:
|
||||
return await page.locator(CHALLENGE_MARKERS).count() > 0
|
||||
except Exception:
|
||||
# A navigation tore down the execution context mid-check, which only
|
||||
# happens once Cloudflare has moved us on.
|
||||
logger.debug("Challenge lookup interrupted by a navigation")
|
||||
return False
|
||||
await solve_challenge(dep.page, timer)
|
||||
await _wait_for_networkidle(dep, timer)
|
||||
return True, page_html, page_request
|
||||
|
||||
|
||||
async def _wait_for_networkidle(dep: BrowserDep, timer: TimeoutTimer) -> None:
|
||||
"""Wait for network idle, tolerating post-DOM-load stalls."""
|
||||
try:
|
||||
await dep.page.wait_for_load_state(
|
||||
"networkidle", timeout=timer.remaining() * 1000
|
||||
)
|
||||
await dep.page.wait_for_load_state("networkidle", timeout=remaining_ms(timer))
|
||||
except PlaywrightTimeoutError:
|
||||
logger.info(
|
||||
"networkidle timed out after domcontentloaded; continuing with loaded page"
|
||||
)
|
||||
|
||||
|
||||
async def build_response_content(
|
||||
dep: BrowserDep,
|
||||
request: LinkRequest,
|
||||
page_request: object,
|
||||
*,
|
||||
challenge_detected: bool,
|
||||
page_html: str | None,
|
||||
) -> tuple[str, str]:
|
||||
"""Build (content_type, response_content) from the settled page."""
|
||||
if request.return_only_cookies:
|
||||
return "text/html", ""
|
||||
|
||||
if page_request and page_request.headers.get("content-type", "").startswith(
|
||||
"application/pdf"
|
||||
):
|
||||
return await _fetch_pdf_content(dep)
|
||||
|
||||
response_content = (
|
||||
page_html
|
||||
if page_html is not None and not challenge_detected
|
||||
else await dep.page.content()
|
||||
)
|
||||
return "text/html", response_content
|
||||
|
||||
|
||||
async def _fetch_pdf_content(dep: BrowserDep) -> tuple[str, str]:
|
||||
"""Fetch raw PDF bytes as base64, falling back to viewer HTML on failure."""
|
||||
try:
|
||||
fetch_response = await dep.page.request.fetch(dep.page.url)
|
||||
response_content = base64.b64encode(await fetch_response.body()).decode("ascii")
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch PDF bytes, falling back to viewer HTML")
|
||||
return "text/html", await dep.page.content()
|
||||
return "application/pdf", response_content
|
||||
|
||||
+14
-45
@@ -6,16 +6,11 @@ from typing import Annotated, NamedTuple, cast
|
||||
from fastapi import Header
|
||||
from invisible_playwright.async_api import InvisiblePlaywright
|
||||
from playwright.async_api import Browser, BrowserContext, Page
|
||||
from playwright_captcha import (
|
||||
ClickSolver,
|
||||
FrameworkType,
|
||||
)
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from src.consts import (
|
||||
BROWSER_LOCALE,
|
||||
LOG_LEVEL,
|
||||
MAX_ATTEMPTS,
|
||||
PROXY_PASSWORD,
|
||||
PROXY_SERVER,
|
||||
PROXY_USERNAME,
|
||||
@@ -35,31 +30,6 @@ if len(logger.handlers) == 0:
|
||||
logger.addHandler(logging.StreamHandler())
|
||||
|
||||
|
||||
# Cloudflare embeds its challenge widget in an iframe carrying
|
||||
# allow="cross-origin-isolated". Firefox honours that by moving the iframe into
|
||||
# a cross-origin-isolated content process, where Juggler sees a frame with no
|
||||
# docShell and no URL, so content_frame() raises "Permission denied to access
|
||||
# property docShell on cross-origin object" and the solver never reaches the
|
||||
# checkbox.
|
||||
#
|
||||
# Turning the two policies off (as upstream Playwright's Firefox does, and as
|
||||
# v2.1.0 did via camoufox's disable_coop=True) restores that access, and the
|
||||
# solver then clicks the checkbox successfully.
|
||||
#
|
||||
# It is not a demonstrated win: from a datacenter IP Cloudflare rejects the
|
||||
# click regardless -- measured across eight sites, nine clicks, and an
|
||||
# undetectable shadow-root patch -- so no outcome changed here. It is kept for
|
||||
# parity with v2, which users report worked on these sites, because reaching
|
||||
# the checkbox is a precondition for ever passing an interactive challenge and
|
||||
# Byparr mostly runs from residential addresses that Cloudflare treats far
|
||||
# better than CI does.
|
||||
BROWSER_PREFS = {
|
||||
"devtools.jsonview.enabled": False,
|
||||
"browser.tabs.remote.useCrossOriginOpenerPolicy": False,
|
||||
"browser.tabs.remote.useCrossOriginEmbedderPolicy": False,
|
||||
}
|
||||
|
||||
|
||||
class TimeoutTimer(BaseModel):
|
||||
duration: int # in seconds
|
||||
start_time: float = Field(default_factory=time.perf_counter)
|
||||
@@ -69,9 +39,16 @@ class TimeoutTimer(BaseModel):
|
||||
return max(0, self.duration - (time.perf_counter() - self.start_time))
|
||||
|
||||
|
||||
MIN_WAIT_MS = 1.0
|
||||
|
||||
|
||||
def remaining_ms(timer: TimeoutTimer) -> float:
|
||||
"""Milliseconds left, never 0 - Playwright reads that as no timeout at all."""
|
||||
return max(MIN_WAIT_MS, timer.remaining() * 1000)
|
||||
|
||||
|
||||
class BrowserDepClass(NamedTuple):
|
||||
page: Page
|
||||
solver: ClickSolver
|
||||
context: BrowserContext
|
||||
|
||||
|
||||
@@ -121,22 +98,14 @@ async def get_browser(
|
||||
proxy=proxy_config,
|
||||
humanize=True,
|
||||
locale=BROWSER_LOCALE or "auto",
|
||||
extra_prefs=BROWSER_PREFS,
|
||||
extra_prefs={
|
||||
"devtools.jsonview.enabled": False,
|
||||
"browser.tabs.remote.useCrossOriginOpenerPolicy": False,
|
||||
"browser.tabs.remote.useCrossOriginEmbedderPolicy": False,
|
||||
},
|
||||
) as browser_raw:
|
||||
# InvisiblePlaywright yields a Browser instance
|
||||
browser = cast("Browser", browser_raw)
|
||||
context = await browser.new_context()
|
||||
page = await context.new_page()
|
||||
async with ClickSolver(
|
||||
# Not PATCHRIGHT: that path skips the unlockShadowRoot init script
|
||||
# and injects it over CDP instead, which Firefox has no session for
|
||||
# ("CDP session is only available in Chromium"). Cloudflare builds
|
||||
# its widget inside a closed shadow root, so without that script
|
||||
# nothing -- not the solver, not page.locator -- can see the
|
||||
# challenge iframe, and every solve attempt fails outright.
|
||||
framework=FrameworkType.PLAYWRIGHT,
|
||||
page=page,
|
||||
max_attempts=MAX_ATTEMPTS,
|
||||
attempt_delay=1,
|
||||
) as solver:
|
||||
yield BrowserDepClass(page, solver, context)
|
||||
yield BrowserDepClass(page, context)
|
||||
|
||||
+110
-128
@@ -1,6 +1,4 @@
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
from http import HTTPStatus
|
||||
from json import JSONDecodeError
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
@@ -8,53 +6,35 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
import httpx2
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from playwright.async_api import Error as PlaywrightError
|
||||
from playwright.async_api import TimeoutError as PlaywrightTimeoutError
|
||||
from playwright_captcha.utils.exceptions import (
|
||||
CaptchaDetectionError,
|
||||
CaptchaSolvingError,
|
||||
)
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from main import app
|
||||
from src.endpoints import CHALLENGE_MARKERS, read_item
|
||||
from src.challenge import CF_INTERSTITIAL_INDICATORS_SELECTORS
|
||||
from src.endpoints import read_item
|
||||
from src.models import LinkRequest
|
||||
from src.utils import BrowserDepClass
|
||||
from src.utils import BrowserDepClass, TimeoutTimer, remaining_ms
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
# Real Firefox advertises 16 cipher suites; Playwright's HTTP client advertised
|
||||
# 52. A small margin absorbs Firefox version drift without letting 52 through.
|
||||
FIREFOX_CIPHER_SUITE_CEILING = 20
|
||||
|
||||
# Sites Byparr clears from any network, datacenter ranges included. These carry
|
||||
# the hard assertion: if the bypass breaks, one of these goes red.
|
||||
test_websites = [
|
||||
# Purpose-built Cloudflare challenge target. Serves a real interstitial and
|
||||
# hands back a cf_clearance cookie once it is passed, so a pass here means
|
||||
# the challenge was solved rather than never presented.
|
||||
"https://nowsecure.nl/",
|
||||
'https://www.yggtorrent.top/engine/search?do=search&order=desc&sort=publish_date&name="UNESCAPED"+"DOUBLEQUOTES"&category=2145',
|
||||
]
|
||||
|
||||
# Cloudflare hands these its interactive checkbox challenge and then refuses the
|
||||
# click from datacenter ranges: the widget goes to "verifying you are human" and
|
||||
# comes back as a fresh unchecked box, indefinitely. Measured over four fresh
|
||||
# navigations and nine clicks, and reproduced from two unrelated hosting
|
||||
# providers on two architectures -- it is the visitor's IP being judged, not our
|
||||
# code. They still run rather than being skipped, so a real regression is
|
||||
# visible in the report and a pass is recorded as xpass, but the runner's luck
|
||||
# with Cloudflare cannot turn the build red.
|
||||
datacenter_hostile_websites = [
|
||||
"https://ext.to/",
|
||||
# "https://www.ygg.re/",
|
||||
"https://extratorrent.st/",
|
||||
"https://speed.cd/login",
|
||||
'https://www.yggtorrent.top/engine/search?do=search&order=desc&sort=publish_date&name="UNESCAPED"+"DOUBLEQUOTES"&category=2145',
|
||||
"https://1337x.to/home/",
|
||||
]
|
||||
|
||||
|
||||
def _bypass(website: str) -> None:
|
||||
"""Ask Byparr for the page and require a clean answer."""
|
||||
@pytest.mark.parametrize("website", test_websites)
|
||||
def test_bypass(website: str):
|
||||
"""
|
||||
Tests if the service can bypass cloudflare/DDOS-GUARD on given websites.
|
||||
|
||||
This test is skipped if the website is not reachable or does not have cloudflare/DDOS-GUARD.
|
||||
"""
|
||||
test_request = httpx2.get(
|
||||
website,
|
||||
)
|
||||
@@ -72,30 +52,20 @@ def _bypass(website: str) -> None:
|
||||
|
||||
response = client.post(
|
||||
"/v1",
|
||||
json=LinkRequest.model_construct(url=website, cmd="request.get").model_dump(),
|
||||
json=LinkRequest.model_construct(
|
||||
url=website, cmd="request.get", max_timeout=60
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
|
||||
@pytest.mark.parametrize("website", test_websites)
|
||||
def test_bypass(website: str):
|
||||
"""Tests if the service can bypass cloudflare/DDOS-GUARD on given websites."""
|
||||
_bypass(website)
|
||||
|
||||
|
||||
@pytest.mark.xfail(
|
||||
reason="Cloudflare refuses the checkbox click from datacenter IPs",
|
||||
strict=False,
|
||||
)
|
||||
@pytest.mark.parametrize("website", datacenter_hostile_websites)
|
||||
def test_bypass_datacenter_hostile(website: str):
|
||||
"""Same check against sites Cloudflare guards hardest, outcome permitting."""
|
||||
_bypass(website)
|
||||
solution = response.json()["solution"]
|
||||
assert "_cf_chl_opt" not in solution["response"]
|
||||
assert "__cf_chl" not in solution["url"]
|
||||
|
||||
|
||||
def test_json_api():
|
||||
"""JSON APIs must return 200, not crash on the UA evaluate.
|
||||
"""
|
||||
JSON APIs must return 200, not crash on the UA evaluate.
|
||||
|
||||
Firefox renders application/json in a built-in viewer whose CSP blocks
|
||||
Playwright's eval-based evaluate() (issue #394). The browser must be
|
||||
@@ -122,38 +92,6 @@ def test_json_api():
|
||||
assert '"ip"' in solution["response"]
|
||||
|
||||
|
||||
def test_tls_handshake_looks_like_firefox():
|
||||
"""
|
||||
The handshake must be Firefox's, not the HTTP client's (#398).
|
||||
|
||||
route.fetch() re-issued navigations through Playwright's own HTTP stack, so
|
||||
the ClientHello advertised 52 cipher suites where Firefox offers 16 -- a
|
||||
fingerprint no amount of header spoofing hides. Unlike a Cloudflare verdict
|
||||
this is deterministic, so it pins the regression that motivated this branch.
|
||||
"""
|
||||
url = "https://www.howsmyssl.com/a/check"
|
||||
if httpx2.get(url).status_code >= HTTPStatus.INTERNAL_SERVER_ERROR:
|
||||
pytest.skip("Skipping TLS check - howsmyssl is down")
|
||||
|
||||
response = client.post(
|
||||
"/v1",
|
||||
json=LinkRequest.model_construct(url=url, cmd="request.get").model_dump(),
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
body = response.json()["solution"]["response"]
|
||||
report = json.loads(
|
||||
re.sub(r"<[^>]+>", "", re.search(r"\{.*\}", body, re.DOTALL).group(0))
|
||||
)
|
||||
suites = len(report["given_cipher_suites"])
|
||||
|
||||
# Firefox offers 16; Playwright's client offered 52. Anything in between
|
||||
# means the navigation is no longer going through the browser.
|
||||
assert suites <= FIREFOX_CIPHER_SUITE_CEILING, (
|
||||
f"{suites} cipher suites offered - the handshake is not Firefox's"
|
||||
)
|
||||
|
||||
|
||||
def test_health_check():
|
||||
"""
|
||||
Tests the health check endpoint.
|
||||
@@ -208,37 +146,32 @@ def fake_dep(
|
||||
fail_states: set[str] | None = None,
|
||||
challenged: bool = False,
|
||||
marker_counts: list[int] | None = None,
|
||||
widget_box: dict[str, float] | None = None,
|
||||
user_agent: str | None = "UnitTestBrowser/1.0",
|
||||
) -> BrowserDepClass:
|
||||
"""
|
||||
Build a browser dependency triple backed by mocks.
|
||||
|
||||
`challenged` makes the detector report a Cloudflare challenge.
|
||||
`marker_counts` drives the "is it still up?" check that runs after each
|
||||
solve attempt: one entry per look, the last one repeating forever.
|
||||
"""
|
||||
"""Build a browser dependency pair backed by mocks."""
|
||||
page = AsyncMock()
|
||||
page.url = "https://example.test/login"
|
||||
page.goto.return_value = MagicMock(
|
||||
status=HTTPStatus.OK,
|
||||
headers={"content-type": "text/html"},
|
||||
request=MagicMock(headers={"user-agent": "UnitTestBrowser/1.0"}),
|
||||
request=MagicMock(headers={"user-agent": user_agent} if user_agent else {}),
|
||||
)
|
||||
page.title.return_value = "Login"
|
||||
page.evaluate.return_value = "UnitTestBrowser/1.0"
|
||||
page.content.return_value = "<html><title>Login</title></html>"
|
||||
|
||||
remaining = list(marker_counts or [])
|
||||
|
||||
def count_for(selector: str) -> int:
|
||||
"""Answer the marker check from the script, everything else from `challenged`."""
|
||||
if selector != CHALLENGE_MARKERS or not remaining:
|
||||
"""Answer the marker check from the script, else from `challenged`."""
|
||||
if selector not in CF_INTERSTITIAL_INDICATORS_SELECTORS or not remaining:
|
||||
return 1 if challenged else 0
|
||||
return remaining.pop(0) if len(remaining) > 1 else remaining[0]
|
||||
|
||||
def locator(selector: str) -> MagicMock:
|
||||
handle = MagicMock()
|
||||
handle.count = AsyncMock(return_value=None)
|
||||
handle.count.side_effect = lambda: count_for(selector)
|
||||
handle.count = AsyncMock(side_effect=lambda: count_for(selector))
|
||||
handle.first.bounding_box = AsyncMock(return_value=widget_box)
|
||||
handle.first.input_value = AsyncMock(return_value="")
|
||||
return handle
|
||||
|
||||
page.locator = MagicMock(side_effect=locator)
|
||||
@@ -253,7 +186,7 @@ def fake_dep(
|
||||
|
||||
context = AsyncMock()
|
||||
context.cookies.return_value = []
|
||||
return BrowserDepClass(page=page, solver=AsyncMock(), context=context)
|
||||
return BrowserDepClass(page=page, context=context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -266,9 +199,7 @@ async def test_networkidle_timeout_after_domcontentloaded_returns_content():
|
||||
)
|
||||
|
||||
assert response.status == "ok"
|
||||
assert response.solution.status == HTTPStatus.OK
|
||||
assert response.solution.response == "<html><title>Login</title></html>"
|
||||
dep.solver.solve_captcha.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -284,18 +215,59 @@ async def test_domcontentloaded_timeout_returns_408():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_challenge_that_clears_after_the_click_succeeds():
|
||||
"""
|
||||
The solver's own "challenge still present" verdict must not end the request.
|
||||
async def test_unreachable_host_is_a_502_not_a_500():
|
||||
"""An upstream we cannot reach is a gateway failure, never a Byparr crash."""
|
||||
dep = fake_dep()
|
||||
dep.page.goto.side_effect = PlaywrightError("Page.goto: NS_ERROR_UNKNOWN_HOST")
|
||||
|
||||
It judges its click by waiting for networkidle, which returns as soon as the
|
||||
network happens to be quiet -- 9ms after the click, in practice -- while
|
||||
Cloudflare is still showing "verifying you are human". Byparr has to wait
|
||||
for the challenge markup itself to go away.
|
||||
"""
|
||||
dep = fake_dep(challenged=True, marker_counts=[1, 0])
|
||||
dep.solver.solve_captcha.side_effect = CaptchaSolvingError(
|
||||
"challenge still present or expected content not detected"
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await read_item(LinkRequest(url="https://nope.invalid/"), dep)
|
||||
|
||||
assert exc.value.status_code == HTTPStatus.BAD_GATEWAY
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_is_always_ok_like_flaresolverr():
|
||||
"""FlareSolverr hardcodes 200 because Selenium cannot report the real code."""
|
||||
dep = fake_dep()
|
||||
dep.page.goto.return_value = MagicMock(
|
||||
status=HTTPStatus.FORBIDDEN,
|
||||
headers={"content-type": "text/html"},
|
||||
request=MagicMock(headers={"user-agent": "UnitTestBrowser/1.0"}),
|
||||
)
|
||||
|
||||
response = await read_item(LinkRequest(url="https://example.test/login"), dep)
|
||||
|
||||
assert response.solution.status == HTTPStatus.OK
|
||||
|
||||
|
||||
def test_exhausted_budget_never_disables_playwright_timeouts():
|
||||
"""Playwright reads timeout=0 as no timeout at all, so the floor must hold."""
|
||||
spent = TimeoutTimer(duration=0)
|
||||
|
||||
assert spent.remaining() == 0
|
||||
assert remaining_ms(spent) > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_user_agent_header_is_not_a_500():
|
||||
"""A request without a user-agent header degrades to empty, never a 500 (#394)."""
|
||||
response = await read_item(
|
||||
LinkRequest(url="https://example.test/login"),
|
||||
fake_dep(user_agent=None),
|
||||
)
|
||||
|
||||
assert response.status == "ok"
|
||||
assert response.solution.user_agent == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_checkbox_is_clicked_while_the_challenge_is_up():
|
||||
"""A measurable widget gets a humanised press, not a raw synthetic click."""
|
||||
dep = fake_dep(
|
||||
challenged=True,
|
||||
marker_counts=[1, 1, 0],
|
||||
widget_box={"x": 100.0, "y": 200.0, "width": 300.0, "height": 60.0},
|
||||
)
|
||||
|
||||
response = await read_item(
|
||||
@@ -303,16 +275,32 @@ async def test_challenge_that_clears_after_the_click_succeeds():
|
||||
)
|
||||
|
||||
assert response.status == "ok"
|
||||
assert response.solution.status == HTTPStatus.OK
|
||||
dep.page.mouse.move.assert_awaited_once_with(125.0, 230.0)
|
||||
dep.page.mouse.down.assert_awaited_once()
|
||||
dep.page.mouse.up.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_challenge_that_clears_on_its_own_is_never_clicked():
|
||||
"""A challenge is over when its markup goes, and until then we keep our hands off."""
|
||||
dep = fake_dep(
|
||||
challenged=True,
|
||||
marker_counts=[1, 0],
|
||||
widget_box={"x": 100.0, "y": 200.0, "width": 300.0, "height": 60.0},
|
||||
)
|
||||
|
||||
response = await read_item(
|
||||
LinkRequest(url="https://example.test/login", max_timeout=5), dep
|
||||
)
|
||||
|
||||
assert response.status == "ok"
|
||||
dep.page.mouse.down.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_challenge_that_never_clears_returns_408():
|
||||
"""A challenge still up when the budget runs out is a timeout, not a 500."""
|
||||
dep = fake_dep(challenged=True, marker_counts=[1])
|
||||
dep.solver.solve_captcha.side_effect = CaptchaDetectionError(
|
||||
"Cloudflare iframes not found"
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await read_item(
|
||||
@@ -323,19 +311,13 @@ async def test_challenge_that_never_clears_returns_408():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_agent_survives_csp_blocked_evaluate():
|
||||
"""UA comes from request headers when page CSP blocks evaluate (#394).
|
||||
async def test_marker_vanishing_mid_navigation_is_not_a_solved_challenge():
|
||||
"""The marker drops out between challenge rounds; one clear read proves nothing."""
|
||||
dep = fake_dep(challenged=True, marker_counts=[1, 0, 1])
|
||||
|
||||
No CSP configuration (header, meta tag, or internal viewer document) may
|
||||
turn /v1 into a 500.
|
||||
"""
|
||||
dep = fake_dep()
|
||||
dep.page.evaluate.side_effect = Exception("call to eval() blocked by CSP")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await read_item(
|
||||
LinkRequest(url="https://example.test/login", max_timeout=2), dep
|
||||
)
|
||||
|
||||
response = await read_item(
|
||||
LinkRequest(url="https://example.test/login"),
|
||||
dep,
|
||||
)
|
||||
|
||||
assert response.status == "ok"
|
||||
assert response.solution.user_agent == "UnitTestBrowser/1.0"
|
||||
assert exc.value.status_code == HTTPStatus.REQUEST_TIMEOUT
|
||||
|
||||
+1
-1
@@ -89,7 +89,7 @@ def fake_dep(*, html: str = ARTICLE_HTML) -> BrowserDepClass:
|
||||
raise PlaywrightTimeoutError(message)
|
||||
|
||||
page.wait_for_load_state.side_effect = wait_for_load_state
|
||||
return BrowserDepClass(page=page, solver=AsyncMock(), context=AsyncMock())
|
||||
return BrowserDepClass(page=page, context=AsyncMock())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -96,7 +96,7 @@ test = [
|
||||
requires-dist = [
|
||||
{ name = "fastapi", extras = ["standard"], specifier = "==0.141.*" },
|
||||
{ name = "invisible-playwright", specifier = ">=0.6.1" },
|
||||
{ name = "playwright", specifier = "==1.60.*" },
|
||||
{ name = "playwright", specifier = "==1.63.*" },
|
||||
{ name = "playwright-captcha", specifier = "==0.1.*" },
|
||||
{ name = "pydantic", specifier = "==2.*" },
|
||||
{ name = "pydantic-settings", specifier = "==2.*" },
|
||||
@@ -109,7 +109,7 @@ dev = [
|
||||
{ name = "ruff", specifier = "==0.16.*" },
|
||||
]
|
||||
test = [
|
||||
{ name = "httpx2", specifier = "==2.10.*" },
|
||||
{ name = "httpx2", specifier = "==2.13.*" },
|
||||
{ name = "pytest", specifier = "==9.1.*" },
|
||||
{ name = "pytest-asyncio", specifier = "==1.4.*" },
|
||||
{ name = "pytest-retry", specifier = "==1.7.*" },
|
||||
@@ -128,64 +128,64 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.5.0"
|
||||
version = "3.5.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cb/31/4971872b3ed8715346231fb6eb4da8fcba65a4143c189db151ee28a2812b/charset_normalizer-3.5.0.tar.gz", hash = "sha256:49bd5feb59b0bf3cbf6ebcf4352e371c95b9da9bacd4449f8b64d0ad2c10a26e", size = 169295, upload-time = "2026-08-12T14:35:31.624Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/43/14/d098868dac5ff27e0258f548b1c74c6484be528384965d8fcf8fc6a4011d/charset_normalizer-3.5.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:d95244906ed69d0f79f190893c65e336c15959003e21449256dc05c001b52ea2", size = 211664, upload-time = "2026-08-12T14:33:14.153Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/da/a944b32a46601ae5a4c3499e8d64ecd14fe82313f00da74dcdf00273a0b4/charset_normalizer-3.5.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:d788e2ded0c4c47efa4d73cfe59eaf975ee32f425219873d2cb3e3fbaa00f636", size = 224375, upload-time = "2026-08-12T14:33:15.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/db/eabb5996be2f529744755e7b2fc9396eff4a64961f034e7fd49d54b9afb2/charset_normalizer-3.5.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:f9f91d3e8382900f3a68fa0ce94294479de9cd2de6bc0c70acd0f0dfd511836b", size = 194364, upload-time = "2026-08-12T14:33:16.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/65/4ad3c5be108930310d8003f5602861d5b89f728293b9f09c3a4837f7ba10/charset_normalizer-3.5.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d54625cbf4e6b60bf0639728cb8b4cb541e340f6d7cafae5806051a40ddf4c45", size = 197643, upload-time = "2026-08-12T14:33:17.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/39/8fee3201b98d52289be60a775797d69be05a04fb6cfb48c1587dad33e649/charset_normalizer-3.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:1f99a8c3a1da5d955edbad18208b3d627bdd54c48a6e739fa877bdca98c686d6", size = 341384, upload-time = "2026-08-12T14:33:19.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/dd/9e757101d1f76c35c0643684ba499ac3a181fb2b264c68174bf727d627e8/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf1e75dc07a3850b53d1e5f75e04d3ae12afe56284be7821771eaa2466350c73", size = 241637, upload-time = "2026-08-12T14:33:20.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/e4/7857023015400bc4aa0a82fbcca29fa2dc7ec25f971a130764cb2dc7a589/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ac5a9cc079c67d75f4ddf343276031879eadbb333d1bb231cce297b8d7b9aae8", size = 226170, upload-time = "2026-08-12T14:33:21.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/ae/8b52935b304f7b6bbf33151ed2b75266b09aa4b6f8f04230d948885b2577/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0dfe83c1b4d00abbf433998117a14f56a5c2bc68226c0d331709eed0d1ce539b", size = 265093, upload-time = "2026-08-12T14:33:22.999Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/78/6e838f6bb059f2c0afc60a4e7f294252f043c254656ad4114c50302cae4d/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e4e8fa586df2208ef040684751345f10f503834a757c9a74ecd19c1a2f9b1ccd", size = 262789, upload-time = "2026-08-12T14:33:24.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/08/189b27e51fddc9d6b3695331da0e31792c1d88b953ad854e57f06e9b2cc8/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:82cc5835997ec78afe293a192e385099355770a7db94b2fb1239d36b32796f1c", size = 250580, upload-time = "2026-08-12T14:33:25.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/55/64854e99b25841f83e8e37d9df2f3d1f96f693439f80e5fabd542a7e47ab/charset_normalizer-3.5.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:19e52bda45086df8a4be4bb5910af6f5d9d3b538c78712c8ae09ef10b85bf458", size = 245008, upload-time = "2026-08-12T14:33:26.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/01/7720c904fa635d4260b4dced6029cf3d298c57b26741365d5a8d28c54043/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3418edd0ecb72a0a3861cf72f31be0ad9b7fe338ce2b58fb5cc80b9aeb792700", size = 243892, upload-time = "2026-08-12T14:33:28.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/50/7bfcb327631d4870c720872b548745f6ec8baa044d51c21b5d1d32ac4e3a/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:125ee619611019471b177c70bc3e9d4cda9fad7e01d93523501d3b188df0193a", size = 230996, upload-time = "2026-08-12T14:33:29.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/51/40c45d6d940c04005ed721aa54bdebf1ebb2930f8a2ae537e8d60484fb27/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a3ad0e3da22852533858663848608f3f24c0d35e5cde415a4903476f2b4c88ec", size = 265834, upload-time = "2026-08-12T14:33:30.689Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/d4/ef7a227ef89d215b47f9df79c3966610b17faa13bb2f236989207a631622/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:4ebebb410bc517e1d284c52a123e82704b21e4e7e26a21ebecf7439d0647b8a3", size = 245544, upload-time = "2026-08-12T14:33:31.859Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/a9/a4ca9156964ded61c7718eba410ce11be2fd2b263fda4bcf08367b6578cd/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:91f9f7c151e772acebe489eaec96e96a2877202d7dd144e3f96b8676881715a0", size = 264110, upload-time = "2026-08-12T14:33:33.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/6a/838364bb8702229c6e5f8b23f80ff0f052a12dfaf3113a12fd6acbe92a44/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:401ea6e7af9e7852ed818f64714b579c1935482049670847ca3bd7ba45dc63fb", size = 252303, upload-time = "2026-08-12T14:33:34.98Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/5f/d88032edce951f499a2321cf7ae0d35a043c74be12bc22d81084cc7afbcc/charset_normalizer-3.5.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f7496aed56b06325a1ad419c5bf23c6dd042558e874f71dd1b958f3e255f3053", size = 139964, upload-time = "2026-08-12T14:33:36.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/ae/1c4a46b6b00d1c34d2ee355ef99ad6173674166800d1af0f05f85028d513/charset_normalizer-3.5.0-cp314-cp314-win32.whl", hash = "sha256:606a86c1c3196f3738de39a67a7490bbd61cb31c0e0436070bd0c6a48170b38e", size = 179790, upload-time = "2026-08-12T14:33:37.356Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/51/f94dcf34fa8eba48c1fb89b6490a5f1426e19488fe5f38aac6c648c99057/charset_normalizer-3.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:ec6c464cf45867f66a2273e2214d9199a8fbad5cb95ca0fd45f6a2fe1d9d2cf4", size = 203723, upload-time = "2026-08-12T14:33:38.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/ba/91d386870b5d9e4b0d8c4034f63877cc2e47b99c81ef05f3e6d42bf9a53f/charset_normalizer-3.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:dc28949de1bb5f7f30a46f15d74ce7ac5aaa63e03c5de04d68f571c7423af834", size = 183423, upload-time = "2026-08-12T14:33:39.899Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/c9/534ecb17b7fb95f9052c4a44cf316316a27d4a8f73e8475ff55e778dcdd7/charset_normalizer-3.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:68b7e84ae8239a94f8d2c8f3f3a3a81bcde54805ec8f42a34de927d155688ec6", size = 368967, upload-time = "2026-08-12T14:33:41.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/b2/ad7c3242d7fe55cd55126c22c65cb1b49779782cdf8932fd01d12232d86a/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:58ca5dc0a0ef99f2801ec0574214c978e9574055bc783830bbb6e7433218609f", size = 239478, upload-time = "2026-08-12T14:33:42.428Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/62/77f0b850048e430fc350ec58876b0c020f5c8d0d3956fd1a4d6ae2fa292f/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6c57af4084c10cb3286688d65e4c654190ff5edcbc2411d08cdca0a8a44c59a1", size = 227036, upload-time = "2026-08-12T14:33:43.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/ba/47d951e1a51dddbaad0a1410baf49fb1d897ceb00281568f1183b79bce9a/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1619a3cc174a7e3963dd34348e6fceb6e50db0ddeb0031bd7c73a58286454fa", size = 260772, upload-time = "2026-08-12T14:33:44.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/61/8c7ff4c81b2a88271126acf4b83ab3e31f6d63868b0f01d331eaa0f9cb67/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1328cc57dd4372be1265f68232cee890e087416e3e6e93e6ffb32c2bad4d36a4", size = 259273, upload-time = "2026-08-12T14:33:46.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/fd/36129689be08dc287b951306946657ff70d76e287dd57018861f86d0e474/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:06f4fb62a9139bef056b8b2da6773c94c2f259f90e4b8e53b166f3d0372d7cf6", size = 248086, upload-time = "2026-08-12T14:33:47.54Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/f8/bcae67f994c8fd31dda445e5ebf84045823c31443fe46f0e9ee6aca99aa0/charset_normalizer-3.5.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:478650a70a750d75d5add401606c77f77069c32e4ba2c9131dc6cee566962ca0", size = 242671, upload-time = "2026-08-12T14:33:48.746Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/92/0472cdad1061c2f0e4d3aee29973eb6e81bb8fe256ff2860cf115b15f1c9/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:48920bf6fe83eb2226756ac623fa54940487154eb18f80889d5735cf234965c0", size = 241311, upload-time = "2026-08-12T14:33:50.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/89/04a03de5d27c77c624d9fcf6287073754bd438df1b58cb7d030c57c2824d/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:168a0cb536b5123a77bc42ecf5e0bf6f923d0d9ae43c42a14eb0677c19ac6c19", size = 229898, upload-time = "2026-08-12T14:33:51.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/a2/639c4278adcb7ed1f4db608dd9ac19b6774fa2285a96b1c0bdb9c124ccbd/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f278e131afa96a3622cef9211c406ea2ad1b68eb06f8837cd443684a40e0ae50", size = 262852, upload-time = "2026-08-12T14:33:52.924Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/95/02e34c97bedfd0c5574efb9179c850591acc7f967ba039ed8dd29d332b73/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d6100f877d2ed95f0856a3fde25334153add94bf2224c43f45f88e7039262aaa", size = 242913, upload-time = "2026-08-12T14:33:54.18Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/64/0946aeab6462dad9f160a50dfb4704d3f58a5ee708f085abc2105fbbff0c/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:4c440122e1ea68b1f8b44a631ebf49c39180f6869b1da22d76e8a724208ec6e9", size = 257938, upload-time = "2026-08-12T14:33:55.802Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/77/36787d41ead124746506a4425c729f4f17c68280af8a6a5baa0a598cae86/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e5f834965c2fe589837bac1002e07e25734ff70381903ccd95b3d649e22bfa40", size = 249467, upload-time = "2026-08-12T14:33:57.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/10/d9f6c5589cd24198d4ce6cd2948191c18e657272f433e5a00d258d9f5c22/charset_normalizer-3.5.0-cp314-cp314t-win32.whl", hash = "sha256:076cf9d3f3c7e410295c09d96355cf3b1bcae74990034d80e4371e20fe1ba4c6", size = 190624, upload-time = "2026-08-12T14:33:58.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/81/43e0584a802051a22c725795ebe1df78263abc7de858eef6cdc9b36637e9/charset_normalizer-3.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3288a560dc3114d5d2ebe309b1ef43f8af355eafe25856832415c2a8196c9db3", size = 215902, upload-time = "2026-08-12T14:33:59.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/f3/af6a1160fef0eac4510d035241e11eccf78e5350e4cd4de79e79fe02a5e5/charset_normalizer-3.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a284c36b9c6616bf0a8aa4aabba668a0c75ba65ccf40a79868aeaa69ad996897", size = 193452, upload-time = "2026-08-12T14:34:01.017Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/f3/7b523d807cb5e73562ef8acf21d39cdb9d704955327362c781bc3478a73d/charset_normalizer-3.5.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:5a4ee37248dfac25107c758bda99d545ce73e60b44d2dd39e4a2bb9f2831e9f5", size = 330840, upload-time = "2026-08-12T14:34:45.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/de/fc68978fe78ca97063c96d764e41ff92ca639948f319271e0ff450e577a2/charset_normalizer-3.5.0-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a864bdcacd8bff58bb4845304e031f821a3ec64b2b7259f2d409cd49c9e59ca3", size = 251862, upload-time = "2026-08-12T14:34:46.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/cb/82b41a0ab7fb1a88065f1d78ad32696ad88ea3fe8e25b8189d08833938de/charset_normalizer-3.5.0-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:84b736e3b391601bc47b86da381c749c0f894e9191aaca9f31f30c2632206df3", size = 239484, upload-time = "2026-08-12T14:34:47.869Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/0c/19608b631f4538f908098d4a2d56a8f79a665e27cc58e9d90479761a9227/charset_normalizer-3.5.0-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6abb1f356fb865baeb6ebc3fadd843e9a96fbf49b9adcca55037f3cceccb7438", size = 230602, upload-time = "2026-08-12T14:34:49.265Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/db/f648eb30e14eba301aed61e11672156f137905c1bdbb530151abe8065943/charset_normalizer-3.5.0-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d366548d2ee28a8cfdcc4296363978cc644a728333be9824d2de4652e83df0a", size = 259208, upload-time = "2026-08-12T14:34:50.632Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/e0/ed2c8bdbac484d69614d6993143aeb6cb0f4dd1561c883402517b623c8ef/charset_normalizer-3.5.0-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d672f329ae504ee240eb39b6effb3318aa8e7e8924c0ce8eee5760b3fad98539", size = 253659, upload-time = "2026-08-12T14:34:52.11Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/08/b4907cb9ec5b521d9d024ced13611240b86ef065c2eb15b3ad2334dc9940/charset_normalizer-3.5.0-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c54036a518748b6c02e666f6d46c3817561998fb904c3be25b56fb4fe3dc5706", size = 248821, upload-time = "2026-08-12T14:34:53.399Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/b2/e2d1abcfbc05822f0030869efb4e9f8a3658e13b4821796d4b62da917327/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:22a1889f1c9b752c63c36758a0c2145458e3cadb20fced7a0790002e9dd12b26", size = 240271, upload-time = "2026-08-12T14:34:55.09Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/f9/4ba127ad610542fa3eabfa41c45bf12d357860a815b3566374ec0188e213/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:b7eb3eab5c646d3de7dcb14a7c9caebace5249c5767da39e1761cb1576e521a3", size = 232155, upload-time = "2026-08-12T14:34:56.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/68/40613182366d00bd6dbd5f6c84a926cbd120960e038a8269e9ae7d782762/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:2080aa129a28267984cdc902898993d788c995c384e285d0d19199f56760d52e", size = 259674, upload-time = "2026-08-12T14:34:57.815Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/53/4574a14fa4c9de4a6c9f31725354bfa40b67f653e6d594ce1654f9a41b32/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:fd68c825548a611158230e2f9222e210ceb2e3391995c0aa5865cbdf3ab4bd49", size = 246122, upload-time = "2026-08-12T14:34:59.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/02/bd8030d13d92c058ca7b2b9615bbb3169569e144db64d65c149cd45abf5e/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:d8a9316f4da85e937242642b537c6d55d7e9287dd38e5634732f8233932aff45", size = 255221, upload-time = "2026-08-12T14:35:00.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/9d/10ecd3bcbe2666b3d4d4026c97b48f73990682815db516052a1e8f4a31c5/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d90254c8f609338c53ec180fcd4c4f9c16502e238e3fc88ca7fd4c2f38d445b8", size = 253450, upload-time = "2026-08-12T14:35:02.217Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/0f/d044c4872c0938a84f87b5027a698c0e61bacfc5c3551a4e749ca9b7bc5c/charset_normalizer-3.5.0-cp37-abi3-win32.whl", hash = "sha256:8b3e9e29b8b07cc461b9ce7768db7693a93979d0dadf22046f6f3555ded2f516", size = 173594, upload-time = "2026-08-12T14:35:03.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/6b/6046773901f1944b9a89436351529811ee958afc7b774563be9d74a6f0c3/charset_normalizer-3.5.0-cp37-abi3-win_amd64.whl", hash = "sha256:0c8953d9d1617794cfc40d81179571c9ba3805dd029623a15c93f1fb70e60a74", size = 198959, upload-time = "2026-08-12T14:35:05.187Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/a6/b57708ac92aefc8e8389d51d5178129b81f03196da61ee2c23e687b8178a/charset_normalizer-3.5.0-cp37-abi3-win_arm64.whl", hash = "sha256:562d24ca7797c1af8852994950c2e623a907b201fc4b0ed29e92af173d3828ca", size = 267055, upload-time = "2026-08-12T14:35:06.533Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/c7/754d09943a616937df61e4ba367c409ded2a987e872972098d51a6fcf73b/charset_normalizer-3.5.0-py3-none-any.whl", hash = "sha256:993dfcbe75a85a3784abb5084f2c41b915767c90546fcc92803cffa28611baea", size = 67943, upload-time = "2026-08-12T14:35:30.363Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -477,15 +477,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "httpcore2"
|
||||
version = "2.10.0"
|
||||
version = "2.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "h11" },
|
||||
{ name = "truststore" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a9/83/a896fc59940fc5a6e2aff3a4be1d92fa890112936803b331cae75a993c34/httpcore2-2.10.0.tar.gz", hash = "sha256:13c0cc3d1919d4f28457f60cd2c2abe04113a8af184ccf1142811beba936f9dc", size = 67427, upload-time = "2026-08-09T09:11:32.123Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/15/8c/e925b1c92018abb3a1863ce1549d76d2381e334d21d65d4ac8f65dabd78a/httpcore2-2.13.0.tar.gz", hash = "sha256:2adc8be4fb285fbcd6d894298db3b52c177e74b6674eda3a76bd36be3292a3db", size = 67740, upload-time = "2026-09-14T14:18:04.717Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/4f/d149104195a35e2853a2fc203a8e3477747e58c80e17dda686dace174383/httpcore2-2.10.0-py3-none-any.whl", hash = "sha256:7df06cfb34070cae4f7c89be69dc1095eca138e9704ceffb98d25c1912ab6f01", size = 83000, upload-time = "2026-08-09T09:11:29.555Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/0d/117a771a2bb91df334b66bf4da14cd02f21aefbcfe53180f336ce55e8f90/httpcore2-2.13.0-py3-none-any.whl", hash = "sha256:35ae5be347aa40467b4a5dc032ac67ebb6d27189fc97e8cebcf99616f6a1bb9e", size = 83162, upload-time = "2026-09-14T14:18:02.529Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -527,7 +527,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "httpx2"
|
||||
version = "2.10.0"
|
||||
version = "2.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio", marker = "sys_platform != 'emscripten'" },
|
||||
@@ -536,9 +536,9 @@ dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "truststore", marker = "sys_platform != 'emscripten'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bd/3d/f9a8c07a3884f3e5b26205e8436a18b3af61c5d53192c3bea235574dbbec/httpx2-2.10.0.tar.gz", hash = "sha256:8741d7329fe2c7885fc9ceb61c8217acfb87a85f75723714b89ebf7ad7196338", size = 98749, upload-time = "2026-08-09T09:11:33.24Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b9/a0/e9deef4654132857b5a5dbe4eddd0ac59c2814500e11f2f5044cd81103ee/httpx2-2.13.0.tar.gz", hash = "sha256:81bd07dc67a3701729ef1f777a3c00c915d4539604fdb5afd327f8682f6b7b44", size = 100290, upload-time = "2026-09-14T14:18:05.486Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/6d/a637d52449d98a6892d9a4dc0262587afdb6a66f201871842dce5a97b1c1/httpx2-2.10.0-py3-none-any.whl", hash = "sha256:5e3194a432701e1cc6f69a8b1b2fa199ef907013fede8d9a09a2c5b7b8141a18", size = 94355, upload-time = "2026-08-09T09:11:30.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/d1/a0c72b0e006df654709fbc366cc5bcb53e5aee13e1e3395152c6dd293376/httpx2-2.13.0-py3-none-any.whl", hash = "sha256:fc12720cedf72faa26cca6b4ca394e05c894e7d7933fc45cafe767960804e49a", size = 95565, upload-time = "2026-09-14T14:18:03.553Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -552,11 +552,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.18"
|
||||
version = "3.19"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -570,7 +570,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "invisible-core"
|
||||
version = "19.14.0"
|
||||
version = "20.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "maxminddb" },
|
||||
@@ -579,23 +579,24 @@ dependencies = [
|
||||
{ name = "requests", extra = ["socks"] },
|
||||
{ name = "tzdata" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f0/d3/fdbfe18b081964c8d893923181e2f890a0a6f05082ee09221c978dc49f06/invisible_core-19.14.0.tar.gz", hash = "sha256:6014106e3ac256135deaa3abdb6b9a2e7890a7cb30641b304386e840dab28f1b", size = 380808, upload-time = "2026-08-11T15:35:04.756Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e2/2e/8d6398c901ade1494807f0ce0a44f179d58744b57053554978d8b1584704/invisible_core-20.16.0.tar.gz", hash = "sha256:042b555f77bc4e87637930360492dc7c73f3e8ec0c5d05da3d180dff9c73254e", size = 439290, upload-time = "2026-08-26T12:51:48.662Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/e9/1e150e6f446a7ad1f9c290f8b1fbb291bc87660e12b21bd77ec92106dc26/invisible_core-19.14.0-py3-none-any.whl", hash = "sha256:b927c9c28c26fe5765300e105d4e5408a4fd1167b96910109934df902992e3b0", size = 207862, upload-time = "2026-08-11T15:35:03.757Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/39/8f8f8455bd78b72a0edbc18f8c2155fa787e795e60ef0b7d1aeff287f3d9/invisible_core-20.16.0-py3-none-any.whl", hash = "sha256:b897ca335921e762871f89c7340c823b963e25326caa2e5ff07324ae4d3b590b", size = 238740, upload-time = "2026-08-26T12:51:47.512Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "invisible-playwright"
|
||||
version = "0.7.0"
|
||||
version = "0.7.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "greenlet" },
|
||||
{ name = "invisible-core" },
|
||||
{ name = "playwright" },
|
||||
{ name = "psutil" },
|
||||
{ name = "pyee" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d0/a9/3cb1c3c4edea5ce670a368df1de88e332370679b532a8f377daa98a96e59/invisible_playwright-0.7.0.tar.gz", hash = "sha256:b0ad0c3e4fd479ffcf57dcec2b051f292b7326e44123cc338281e8d910d2ec26", size = 422491, upload-time = "2026-08-11T15:55:05.977Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e8/d4/43101ac5d04b9a0bf9eadafb09fb0c895081e9076d95c6e3ccb9dc48b885/invisible_playwright-0.7.3.tar.gz", hash = "sha256:08c128ed11a721778663cf3f62f3e30d3702e6817ce59f7629dd77e8f2820090", size = 1965807, upload-time = "2026-08-26T14:58:18.4Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/84/969a3303401831a53fdbf06d7fb12cf565d6fe199b390b01dd984f56e553/invisible_playwright-0.7.0-py3-none-any.whl", hash = "sha256:20312d50d5250ec24311690e62eb395261b295e70cfa0b57b8f800e2d5565e15", size = 95905, upload-time = "2026-08-11T15:55:04.77Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/67/d46b834d75ada8c479bcd1d2fe41783eed9b5da84eaca16666c7bd77201c/invisible_playwright-0.7.3-py3-none-any.whl", hash = "sha256:2f0db5dcc18ba30f99dd3f8966af4c209fdbc7c9396a58ad0013e10af5fb1420", size = 1682912, upload-time = "2026-08-26T14:58:16.355Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -786,21 +787,21 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "playwright"
|
||||
version = "1.60.0"
|
||||
version = "1.63.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "greenlet" },
|
||||
{ name = "pyee" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/21/f0/832bd9677194908da118064eef20082f2791e3d18215cc6d9391ee2c5a67/playwright-1.60.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:6a8cd0fec171fb3089e95e898c8bc8a6f35dea0b78b399e12fcc19427e91b1d7", size = 43474635, upload-time = "2026-05-18T12:00:31.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/7b/e1d32ae8a3ed937ec2be3721c5f728b13d731a0b7c6442e0b3bec5094ac0/playwright-1.60.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:39b5420ba6145045b69ced4c5c47d4d9fe5bddfc8ff816c518913afcb25ec7a5", size = 42261327, upload-time = "2026-05-18T12:00:35.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/bc/23de499ded6411c188a20c5a0dea6f0cd4ed5d2b3cc6042a5dbd3ed609aa/playwright-1.60.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:2581d0e6a3392c71f91b27460c7fd093356818dc430f48153896c8aeeaef7705", size = 43474636, upload-time = "2026-05-18T12:00:39.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/7b/1d679f4fced4ea94efadd17103856d8c565384f68382a1681264e46f5925/playwright-1.60.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:1c2bfae7884fb3fb05b853290eab8f343d524e5016f2f1def702acbbdf14c93e", size = 47467220, upload-time = "2026-05-18T12:00:43.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/c2/1528d267d4442bd2c6b8eaeab819dd52c2030bf80e89293f0ba1f687473b/playwright-1.60.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43e66564125ee31b07a58cefb21e256d62d67d8d1713e6858df7a3019d8ed353", size = 47154856, upload-time = "2026-05-18T12:00:46.715Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/4e/b008b6440a7a1624378041da94829956d4b8f7ab9ef5aad22d0dc3f2e26d/playwright-1.60.0-py3-none-win32.whl", hash = "sha256:ec94e416ea320711e0ad4bf185dcbf41833672961e90773e1885255d7db7b7e7", size = 37902157, upload-time = "2026-05-18T12:00:50.374Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/f0/0541524133104f9cc20bf900870ff4a736b76a23483f3a55295ddfa58409/playwright-1.60.0-py3-none-win_amd64.whl", hash = "sha256:9566821ce6030a1f9e7146a24e19355ab0d98805fd0f9be50bb3d8fef1750c02", size = 37902159, upload-time = "2026-05-18T12:00:53.728Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/c8/210f282d278e4709cdd71b12a31af45a30a22ab3207b387e29b37e478713/playwright-1.60.0-py3-none-win_arm64.whl", hash = "sha256:6e4f6700a4c2250efff8e690a81d66e3855754fb587b6b87cf5c784014f91537", size = 34037981, upload-time = "2026-05-18T12:00:57.584Z" },
|
||||
{ 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]]
|
||||
@@ -948,11 +949,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.20.0"
|
||||
version = "2.21.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1031,11 +1032,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.2.2"
|
||||
version = "1.2.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user