mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-25 07:00:24 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b3df2a463 | ||
|
|
5247ec6124 | ||
|
|
bd21ec1257 | ||
|
|
7b9c416df8 | ||
|
|
646b531669 | ||
|
|
eafb965662 | ||
|
|
7193036626 | ||
|
|
12d554a92f | ||
|
|
fae6140c6a | ||
|
|
63133097e4 | ||
|
|
82aeee387e | ||
|
|
4cd1091d16 | ||
|
|
651096ed7b |
@@ -1977,7 +1977,7 @@ Move deletes the job from your usenet client after import; Copy keeps it in the
|
||||
| Variable | Description | Type | Default |
|
||||
|----------|-------------|------|---------|
|
||||
| `HARDCOVER_ENABLED` | Enable Hardcover as a metadata provider for book searches | boolean | `false` |
|
||||
| `HARDCOVER_API_KEY` | Get your API key from hardcover.app/account/api | string (secret) | _none_ |
|
||||
| `HARDCOVER_API_KEY` | Get your API key from hardcover.app/account/api (starts with hc_pat_) | string (secret) | _none_ |
|
||||
| `HARDCOVER_DEFAULT_SORT` | Default sort order for Hardcover search results. | string (choice) | `relevance` |
|
||||
| `HARDCOVER_EXCLUDE_COMPILATIONS` | Filter out compilations, anthologies, and omnibus editions from search results | boolean | `false` |
|
||||
| `HARDCOVER_EXCLUDE_UNRELEASED` | Filter out books with a release year in the future | boolean | `false` |
|
||||
@@ -1999,7 +1999,7 @@ Enable Hardcover as a metadata provider for book searches
|
||||
|
||||
**API Key**
|
||||
|
||||
Get your API key from hardcover.app/account/api
|
||||
Get your API key from hardcover.app/account/api (starts with hc_pat_)
|
||||
|
||||
- **Type:** string (secret)
|
||||
- **Default:** _none_
|
||||
@@ -2304,6 +2304,7 @@ Override destination based on content type metadata.
|
||||
| `EXT_BYPASSER_URL` | URL of the external bypasser service (e.g., FlareSolverr). | string | `http://flaresolverr:8191` |
|
||||
| `EXT_BYPASSER_PATH` | API path for the external bypasser. | string | `/v1` |
|
||||
| `EXT_BYPASSER_TIMEOUT` | Timeout for external bypasser requests in milliseconds. | number | `60000` |
|
||||
| `BYPASS_BROWSER_IDLE_TIMEOUT` | How long the bypass helper process may sit unused before it is shut down. Higher keeps more searches fast, lower frees memory sooner. | number | `180` |
|
||||
|
||||
<details>
|
||||
<summary>Detailed descriptions</summary>
|
||||
@@ -2359,6 +2360,17 @@ Timeout for external bypasser requests in milliseconds.
|
||||
- **Requires restart:** Yes
|
||||
- **Constraints:** min: 10000, max: 300000
|
||||
|
||||
#### `BYPASS_BROWSER_IDLE_TIMEOUT`
|
||||
|
||||
**Bypasser Idle Timeout (seconds)**
|
||||
|
||||
How long the bypass helper process may sit unused before it is shut down. Higher keeps more searches fast, lower frees memory sooner.
|
||||
|
||||
- **Type:** number
|
||||
- **Default:** `180`
|
||||
- **Requires restart:** Yes
|
||||
- **Constraints:** min: 30, max: 3600
|
||||
|
||||
</details>
|
||||
|
||||
### Direct Download: Mirrors
|
||||
|
||||
+4
-4
@@ -19,13 +19,13 @@ dependencies = [
|
||||
"psutil",
|
||||
"emoji",
|
||||
"rarfile",
|
||||
"qbittorrent-api>=2026.8.0",
|
||||
"qbittorrent-api>=2026.8.1",
|
||||
"transmission-rpc",
|
||||
"authlib>=1.7.2,<1.8",
|
||||
"apprise>=1.12.0",
|
||||
# HTTP/2 client for RFC 8484 DoH: quad9 rejects HTTP/1.1 outright (505), which
|
||||
# requests cannot speak. See shelfmark/download/doh_wireformat.py.
|
||||
"httpx[http2]>=0.27",
|
||||
"httpx[http2]>=0.28.1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -38,12 +38,12 @@ browser = [
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"basedpyright>=1.39.9",
|
||||
"basedpyright>=1.39.10",
|
||||
"prek",
|
||||
"pytest",
|
||||
"pytest-cov",
|
||||
"pytest-xdist>=3.8.0",
|
||||
"ruff==0.16.2",
|
||||
"ruff==0.16.3",
|
||||
"vulture>=2.14",
|
||||
]
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<img src="src/frontend/public/logo.png" alt="Shelfmark" width="200">
|
||||
|
||||
> [!NOTE]
|
||||
> This project is in a stable state as of May 2026 but is not under active maintenance.
|
||||
> Shelfmark is feature stable and maintained on a best-effort basis. Bug fixes, security updates, and small quality-of-life improvements are still shipped, and pull requests are reviewed — including new features. There is no roadmap for new features for now.
|
||||
|
||||
Shelfmark is a self-hosted web interface for searching and requesting books and audiobooks across multiple sources. Bring your own sources, metadata providers, and download clients to build a single hub for your digital library. Supports multiple users with a built-in request system, so you can share your instance with others and let them browse and request books on their own.
|
||||
|
||||
@@ -95,6 +95,30 @@ volumes:
|
||||
- Aggregates releases from multiple configured sources
|
||||
- Full audiobook support
|
||||
|
||||
### Hardcover API Key
|
||||
|
||||
Hardcover powers metadata search in Universal mode. Create a token at
|
||||
[hardcover.app/account/api](https://hardcover.app/account/api) — current keys start with `hc_pat_`
|
||||
and are far shorter than the JWTs Hardcover issued before August 2026.
|
||||
|
||||
Tick these seven scopes on the token screen:
|
||||
|
||||
| Scope | Used for |
|
||||
|-------|----------|
|
||||
| `read:catalog` | Metadata search, plus book, edition, author and series lookups |
|
||||
| `read:library` | Your reading status and shelf counts |
|
||||
| `read:lists` | Your lists and the books on them |
|
||||
| `read:me:content` | Test Connection and the "Connected as" label |
|
||||
| `read:users` | Usernames shown alongside lists |
|
||||
| `write:library` | Setting a book's reading status from Shelfmark |
|
||||
| `write:lists` | Adding and removing books from lists, including auto-remove on download |
|
||||
|
||||
The two `write:` scopes matter only if you set reading status from Shelfmark or leave
|
||||
**Auto-Remove from List on Download** enabled (it is on by default) — without them those actions
|
||||
fail silently. Everything else Hardcover offers (journal, goals, reviews, prompts, notifications,
|
||||
account) can stay unticked. The `all` scope works too, but it grants full account access including
|
||||
deletion, so prefer the list above.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Environment variables work for initial setup and Docker deployments. They serve as defaults that can be overridden in the web interface.
|
||||
@@ -238,9 +262,11 @@ These are non-goals, not missing features.
|
||||
|
||||
## Contributing
|
||||
|
||||
Shelfmark's core feature set is complete. Development focuses on stability, bug fixes, quality-of-life improvements, and refining the search experience. Contributions in these areas are welcome, please file issues or submit pull requests on GitHub.
|
||||
Shelfmark's core feature set is complete.
|
||||
|
||||
Feature requests that fall outside the project scope (library integration, automation, collection management) will be closed. If you're unsure whether something fits, open a discussion first.
|
||||
Pull requests are welcome and all of them get reviewed, new features included. If you want a feature, the fastest path is to send a PR for it rather than to file a request.
|
||||
|
||||
Feature requests that fall outside the project scope (library integration, automation, collection management) will be closed, and PRs implementing them won't be merged. If you're unsure whether something fits, open a discussion first.
|
||||
|
||||
## Health Monitoring
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Challenge-page detection shared by the bypassers and the HTTP retry path.
|
||||
|
||||
Kept out of `internal_bypasser` so the HTTP layer can recognise an interstitial
|
||||
without importing SeleniumBase: that module is imported lazily precisely because its
|
||||
browser dependencies are optional, and external-bypasser setups run without them.
|
||||
"""
|
||||
|
||||
# Matched against lowercased text, so every entry must be lowercase.
|
||||
CLOUDFLARE_INDICATORS = [
|
||||
"just a moment",
|
||||
"verify you are human",
|
||||
"verifying you are human",
|
||||
"cloudflare.com/products/turnstile",
|
||||
]
|
||||
|
||||
DDOS_GUARD_INDICATORS = [
|
||||
"ddos-guard",
|
||||
"ddos guard",
|
||||
"checking your browser before accessing",
|
||||
"complete the manual check to continue",
|
||||
"could not verify your browser automatically",
|
||||
]
|
||||
|
||||
# Markers that exist only in raw markup: the bypassers scan rendered innerText, where
|
||||
# a script src or a <title> never appears. The title match is scoped to the tag on
|
||||
# purpose - hosts word the rest of that sentence differently, and matching "checking
|
||||
# your browser" as free text would trip on any page that merely discusses a challenge.
|
||||
_RAW_HTML_MARKERS = (
|
||||
"<title>checking your browser",
|
||||
"/cdn-cgi/challenge-platform",
|
||||
"/.well-known/ddos-guard/",
|
||||
)
|
||||
|
||||
# An interstitial is a few KB of markup. Past that it is a real page that happens to
|
||||
# mention a marker - a protected site links its own DDoS-Guard endpoints on every page.
|
||||
MAX_CHALLENGE_HTML_CHARS = 64 * 1024
|
||||
|
||||
|
||||
def challenge_marker(html: str) -> str | None:
|
||||
"""Return the marker proving `html` is an unsolved challenge page, or None.
|
||||
|
||||
Only meaningful for a response that already carries a challenge status: the
|
||||
markers appear on protected sites' real pages too, so the status is what
|
||||
separates "blocked" from "served".
|
||||
"""
|
||||
if not html or len(html) > MAX_CHALLENGE_HTML_CHARS:
|
||||
return None
|
||||
lowered = html.lower()
|
||||
for marker in (*_RAW_HTML_MARKERS, *DDOS_GUARD_INDICATORS, *CLOUDFLARE_INDICATORS):
|
||||
if marker in lowered:
|
||||
return marker
|
||||
return None
|
||||
@@ -0,0 +1,260 @@
|
||||
"""Clearance cookies won by a bypass, shared by every bypasser implementation.
|
||||
|
||||
Kept in its own module rather than inside a bypasser because both of them feed it and
|
||||
both read from it. The internal bypasser cannot host it: it imports seleniumbase at
|
||||
module scope, which is exactly the dependency an external-bypasser deployment is
|
||||
entitled not to have installed.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
# Cookie storage - shared with requests library for Cloudflare bypass
|
||||
# Nested mapping of domain to cookie name to cookie metadata.
|
||||
_cf_cookies: dict[str, dict] = {}
|
||||
_cf_cookies_lock = threading.Lock()
|
||||
|
||||
# User-Agent storage - Cloudflare ties cf_clearance to the UA that solved the challenge
|
||||
_cf_user_agents: dict[str, str] = {}
|
||||
|
||||
# Protection cookie names we care about (Cloudflare and DDoS-Guard)
|
||||
CF_COOKIE_NAMES = {"cf_clearance", "__cf_bm", "cf_chl_2", "cf_chl_prog"}
|
||||
DDG_COOKIE_NAMES = {
|
||||
"__ddg1_",
|
||||
"__ddg2_",
|
||||
"__ddg5_",
|
||||
"__ddg8_",
|
||||
"__ddg9_",
|
||||
"__ddg10_",
|
||||
"__ddgid_",
|
||||
"__ddgmark_",
|
||||
"ddg_last_challenge",
|
||||
}
|
||||
|
||||
# DDoS-Guard cookies that describe *one* check rather than granting clearance, and so
|
||||
# must never be replayed on a later request. Observed live on Anna's Archive:
|
||||
#
|
||||
# __ddg9_ the client IP address
|
||||
# __ddg10_ the unix timestamp the check was issued
|
||||
# __ddg8_ an opaque token issued with them, same ~40 minute expiry
|
||||
#
|
||||
# Clearance itself lives in __ddg1_/__ddg2_/__ddgid_ (roughly a year) and __ddg5_.
|
||||
# Replaying the trio is actively harmful: once the timestamp ages out - or the egress
|
||||
# IP changes, which happens routinely behind a VPN - the values no longer describe the
|
||||
# caller, DDoS-Guard re-arms its check and answers every request with a ?check=1
|
||||
# redirect. That is the redirect loop, and it is self-inflicted. Dropping them simply
|
||||
# lets DDoS-Guard issue a fresh set, exactly as it does for a browser.
|
||||
DDG_EPHEMERAL_COOKIE_NAMES = {
|
||||
"__ddg8_",
|
||||
"__ddg9_",
|
||||
"__ddg10_",
|
||||
"ddg_last_challenge",
|
||||
}
|
||||
|
||||
|
||||
def _get_base_domain(domain: str) -> str:
|
||||
"""Extract base domain from hostname (e.g., 'www.example.com' -> 'example.com')."""
|
||||
return ".".join(domain.split(".")[-2:]) if "." in domain else domain
|
||||
|
||||
|
||||
def _get_full_cookie_domains() -> set[str]:
|
||||
"""Return mirror domains that need full-session cookie extraction."""
|
||||
from shelfmark.core.mirrors import get_zlib_cookie_domains
|
||||
|
||||
return {_get_base_domain(domain) for domain in get_zlib_cookie_domains()}
|
||||
|
||||
|
||||
def _should_extract_cookie(name: str, *, extract_all: bool) -> bool:
|
||||
"""Determine if a cookie should be extracted based on its name."""
|
||||
# Checked before extract_all: a per-check token is wrong to replay for every
|
||||
# domain, including the full-session ones.
|
||||
if name in DDG_EPHEMERAL_COOKIE_NAMES:
|
||||
return False
|
||||
if extract_all:
|
||||
return True
|
||||
is_cf = name in CF_COOKIE_NAMES or name.startswith("cf_")
|
||||
is_ddg = name in DDG_COOKIE_NAMES or name.startswith("__ddg")
|
||||
return is_cf or is_ddg
|
||||
|
||||
|
||||
def _cookie_field(cookie: Any, name: str) -> Any:
|
||||
"""Read one field from a cookie in either shape we are handed.
|
||||
|
||||
The internal bypasser extracts CDP cookie objects; an external bypasser returns
|
||||
the same fields as JSON objects, so the difference is attribute versus key access.
|
||||
"""
|
||||
if isinstance(cookie, Mapping):
|
||||
return cookie.get(name)
|
||||
return getattr(cookie, name, None)
|
||||
|
||||
|
||||
def _cookie_expiry(cookie: Any) -> float | None:
|
||||
"""A cookie's absolute expiry, or None when it is a session cookie.
|
||||
|
||||
The two spellings are not interchangeable and both reach this store. CDP and
|
||||
Playwright cookies carry `expires`; the WebDriver cookie object - what a
|
||||
Selenium-based solver such as FlareSolverr returns - carries `expiry`. Reading
|
||||
only one silently turns every cookie from the other into a never-expiring one,
|
||||
which is exactly how dead clearance ends up replayed forever (see
|
||||
get_cf_cookies_for_domain).
|
||||
|
||||
The value is coerced rather than trusted: it arrives as JSON from a service we
|
||||
do not control, and a string here used to raise straight out of the store.
|
||||
"""
|
||||
for field in ("expires", "expiry"):
|
||||
raw = _cookie_field(cookie, field)
|
||||
if raw is None:
|
||||
continue
|
||||
try:
|
||||
expiry = float(raw)
|
||||
except TypeError, ValueError:
|
||||
logger.debug("Unreadable cookie expiry %r; treating as a session cookie", raw)
|
||||
return None
|
||||
# <= 0 is how both shapes spell "session cookie", not "expired in 1970".
|
||||
return expiry if expiry > 0 else None
|
||||
return None
|
||||
|
||||
|
||||
def store_extracted_cookies(
|
||||
*,
|
||||
url: str,
|
||||
cookies: list[Any],
|
||||
user_agent: str | None = None,
|
||||
) -> None:
|
||||
"""Store filtered bypass cookies (and optional UA) for a URL domain."""
|
||||
parsed = urlparse(url)
|
||||
domain = parsed.hostname or ""
|
||||
if not domain:
|
||||
return
|
||||
|
||||
base_domain = _get_base_domain(domain)
|
||||
extract_all = base_domain in _get_full_cookie_domains()
|
||||
|
||||
cookies_found: dict[str, dict[str, Any]] = {}
|
||||
for cookie in cookies:
|
||||
name = _cookie_field(cookie, "name") or ""
|
||||
if not _should_extract_cookie(name, extract_all=extract_all):
|
||||
continue
|
||||
secure = _cookie_field(cookie, "secure")
|
||||
cookies_found[name] = {
|
||||
"value": _cookie_field(cookie, "value") or "",
|
||||
"domain": _cookie_field(cookie, "domain") or domain,
|
||||
"path": _cookie_field(cookie, "path") or "/",
|
||||
"expiry": _cookie_expiry(cookie),
|
||||
"secure": True if secure is None else bool(secure),
|
||||
"httpOnly": True,
|
||||
}
|
||||
|
||||
if not cookies_found:
|
||||
return
|
||||
|
||||
with _cf_cookies_lock:
|
||||
_cf_cookies[base_domain] = cookies_found
|
||||
if user_agent:
|
||||
_cf_user_agents[base_domain] = user_agent
|
||||
logger.debug("Stored UA for %s: %s...", base_domain, str(user_agent)[:60])
|
||||
else:
|
||||
logger.debug("No UA captured for %s", base_domain)
|
||||
|
||||
cookie_type = "all" if extract_all else "protection"
|
||||
logger.debug("Extracted %s %s cookies for %s", len(cookies_found), cookie_type, base_domain)
|
||||
|
||||
|
||||
def _is_cookie_expired(cookie: dict[str, Any]) -> bool:
|
||||
"""Whether a stored cookie's expiry has passed. Session cookies never expire here."""
|
||||
expiry = cookie.get("expiry")
|
||||
if expiry is None:
|
||||
expiry = cookie.get("expires")
|
||||
if not expiry or expiry <= 0:
|
||||
return False
|
||||
return time.time() > expiry
|
||||
|
||||
|
||||
def get_cf_cookies_for_domain(domain: str) -> dict[str, str]:
|
||||
"""Get stored cookies for a domain. Returns empty dict if none available."""
|
||||
if not domain:
|
||||
return {}
|
||||
|
||||
base_domain = _get_base_domain(domain)
|
||||
|
||||
with _cf_cookies_lock:
|
||||
cookies = _cf_cookies.get(base_domain, {})
|
||||
if not cookies:
|
||||
return {}
|
||||
|
||||
cf_clearance = cookies.get("cf_clearance", {})
|
||||
if cf_clearance and _is_cookie_expired(cf_clearance):
|
||||
logger.debug("CF cookies expired for %s", base_domain)
|
||||
_cf_cookies.pop(base_domain, None)
|
||||
return {}
|
||||
|
||||
# Expiry applies to every cookie, not just Cloudflare's. DDoS-Guard domains
|
||||
# have no cf_clearance, so the check above never fired for them and dead
|
||||
# cookies were replayed indefinitely - the server answers those with a
|
||||
# challenge, which is indistinguishable from having sent nothing at all.
|
||||
live = {name: c for name, c in cookies.items() if not _is_cookie_expired(c)}
|
||||
if len(live) != len(cookies):
|
||||
expired = sorted(set(cookies) - set(live))
|
||||
logger.debug("Dropping expired cookies for %s: %s", base_domain, expired)
|
||||
if live:
|
||||
_cf_cookies[base_domain] = live
|
||||
else:
|
||||
_cf_cookies.pop(base_domain, None)
|
||||
|
||||
return {name: c["value"] for name, c in live.items()}
|
||||
|
||||
|
||||
def has_valid_cf_cookies(domain: str) -> bool:
|
||||
"""Check if we have valid Cloudflare cookies for a domain."""
|
||||
return bool(get_cf_cookies_for_domain(domain))
|
||||
|
||||
|
||||
def get_cf_user_agent_for_domain(domain: str) -> str | None:
|
||||
"""Get the User-Agent that was used during bypass for a domain."""
|
||||
if not domain:
|
||||
return None
|
||||
with _cf_cookies_lock:
|
||||
return _cf_user_agents.get(_get_base_domain(domain))
|
||||
|
||||
|
||||
def export_store() -> tuple[dict[str, dict], dict[str, str]]:
|
||||
"""Snapshot the whole store, for handing to another process.
|
||||
|
||||
The internal bypasser's Docker helper solves in a subprocess, so the clearance it
|
||||
wins has to be serialized back to the parent or the solve is lost with the child.
|
||||
"""
|
||||
with _cf_cookies_lock:
|
||||
return (
|
||||
{domain: dict(cookies) for domain, cookies in _cf_cookies.items()},
|
||||
dict(_cf_user_agents),
|
||||
)
|
||||
|
||||
|
||||
def import_store(cookies: object, user_agents: object) -> None:
|
||||
"""Merge a snapshot produced by :func:`export_store` into this process's store."""
|
||||
with _cf_cookies_lock:
|
||||
if isinstance(cookies, dict):
|
||||
_cf_cookies.update(cookies)
|
||||
if isinstance(user_agents, dict):
|
||||
_cf_user_agents.update(
|
||||
{str(domain): str(agent) for domain, agent in user_agents.items()}
|
||||
)
|
||||
|
||||
|
||||
def clear_cf_cookies(domain: str | None = None) -> None:
|
||||
"""Clear stored Cloudflare cookies and User-Agent. If domain is None, clear all."""
|
||||
with _cf_cookies_lock:
|
||||
if domain:
|
||||
base_domain = _get_base_domain(domain)
|
||||
_cf_cookies.pop(base_domain, None)
|
||||
_cf_user_agents.pop(base_domain, None)
|
||||
else:
|
||||
_cf_cookies.clear()
|
||||
_cf_user_agents.clear()
|
||||
@@ -2,17 +2,19 @@
|
||||
|
||||
import random
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import requests
|
||||
|
||||
from shelfmark.bypass import BypassCancelledError
|
||||
from shelfmark.bypass.cookie_store import store_extracted_cookies
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.utils import normalize_http_url
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
from threading import Event
|
||||
|
||||
from shelfmark.download import network
|
||||
@@ -63,6 +65,31 @@ def max_duration_seconds() -> float:
|
||||
return MAX_RETRY * read_timeout + backoff_total
|
||||
|
||||
|
||||
def _store_solution_clearance(target_url: str, solution: Mapping[str, Any]) -> None:
|
||||
"""Keep the clearance the solver won, so later requests do not re-solve.
|
||||
|
||||
A solve is the expensive part of an external bypass - tens of seconds of real
|
||||
browser - and FlareSolverr-compatible services hand back the cookies and the
|
||||
User-Agent that earned it. Dropping them meant every single request paid a 403
|
||||
plus a full solve, and a file download (which the solver cannot proxy, being
|
||||
binary) never presented clearance at all.
|
||||
|
||||
The UA matters as much as the cookies: Cloudflare ties cf_clearance to the UA
|
||||
that solved the challenge, so replaying the cookie under our own UA is rejected.
|
||||
"""
|
||||
cookies = solution.get("cookies") or []
|
||||
if not isinstance(cookies, list):
|
||||
logger.debug("External bypasser returned no usable cookie list for '%s'", target_url)
|
||||
return
|
||||
|
||||
user_agent = solution.get("userAgent")
|
||||
store_extracted_cookies(
|
||||
url=target_url,
|
||||
cookies=cookies,
|
||||
user_agent=user_agent if isinstance(user_agent, str) else None,
|
||||
)
|
||||
|
||||
|
||||
def _fetch_via_bypasser(target_url: str) -> str | None:
|
||||
"""Make a single request to the external bypasser service. Returns HTML or None."""
|
||||
raw_bypasser_url = _coerce_config_str(
|
||||
@@ -116,6 +143,15 @@ def _fetch_via_bypasser(target_url: str) -> str | None:
|
||||
logger.warning("External bypasser returned empty response for '%s'", target_url)
|
||||
return None
|
||||
|
||||
try:
|
||||
_store_solution_clearance(target_url, solution)
|
||||
except AttributeError, KeyError, TypeError, ValueError:
|
||||
# Storing clearance is an optimisation; the page is the product. The
|
||||
# solution JSON comes from a service we do not control, so a surprise in
|
||||
# its cookie shape must not discard HTML that already cost a ~30s solve
|
||||
# and send the caller round for up to MAX_RETRY more of them.
|
||||
logger.debug("Could not store bypass clearance for '%s'", target_url, exc_info=True)
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
logger.warning(
|
||||
"External bypasser timed out for '%s' (connect: %ss, read: %.0fs)",
|
||||
|
||||
@@ -5,7 +5,6 @@ import asyncio
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import stat
|
||||
@@ -28,6 +27,15 @@ from seleniumbase import cdp_driver
|
||||
from seleniumbase.undetected.cdp_driver.connection import ProtocolException
|
||||
|
||||
from shelfmark.bypass import BypassCancelledError
|
||||
from shelfmark.bypass.challenge import CLOUDFLARE_INDICATORS, DDOS_GUARD_INDICATORS
|
||||
from shelfmark.bypass.cookie_store import (
|
||||
clear_cf_cookies,
|
||||
export_store,
|
||||
get_cf_cookies_for_domain,
|
||||
get_cf_user_agent_for_domain,
|
||||
import_store,
|
||||
store_extracted_cookies,
|
||||
)
|
||||
from shelfmark.bypass.fingerprint import get_screen_size
|
||||
from shelfmark.config import env
|
||||
from shelfmark.config.env import LOG_DIR
|
||||
@@ -50,26 +58,31 @@ _LOADING_BODY_LENGTH_MAX = 50
|
||||
_PAGE_BODY_PREVIEW_CHARS = 500
|
||||
_BROWSER_START_TIMEOUT_SECONDS = 45.0
|
||||
_BYPASS_SUBPROCESS_TIMEOUT_SECONDS = 420.0
|
||||
# Same budget as the Docker helper process, applied to the in-process CDP path so both
|
||||
# branches of get() are bounded the same way.
|
||||
_IN_PROCESS_BYPASS_TIMEOUT_SECONDS = _BYPASS_SUBPROCESS_TIMEOUT_SECONDS
|
||||
# How long a cancelled bypass may take to close its browser before the calling thread
|
||||
# stops waiting for it. Counted on top of the bypass deadline, so every budget below is
|
||||
# set to leave room for it.
|
||||
_CDP_UNWIND_GRACE_SECONDS = 15.0
|
||||
# Same wall-clock budget as the Docker helper process, applied to the in-process CDP path
|
||||
# so both branches of get() are bounded the same way: the deadline plus the unwind grace
|
||||
# comes to _BYPASS_SUBPROCESS_TIMEOUT_SECONDS either way.
|
||||
_IN_PROCESS_BYPASS_TIMEOUT_SECONDS = _BYPASS_SUBPROCESS_TIMEOUT_SECONDS - _CDP_UNWIND_GRACE_SECONDS
|
||||
_BYPASS_CHILD_ENV = "SHELFMARK_INTERNAL_BYPASSER_CHILD"
|
||||
|
||||
# Challenge detection indicators
|
||||
CLOUDFLARE_INDICATORS = [
|
||||
"just a moment",
|
||||
"verify you are human",
|
||||
"verifying you are human",
|
||||
"cloudflare.com/products/turnstile",
|
||||
]
|
||||
|
||||
DDOS_GUARD_INDICATORS = [
|
||||
"ddos-guard",
|
||||
"ddos guard",
|
||||
"checking your browser before accessing",
|
||||
"complete the manual check to continue",
|
||||
"could not verify your browser automatically",
|
||||
]
|
||||
# The helper bounds each bypass below the parent's deadline, so it is the side that gives
|
||||
# up first: it still gets to report the timeout and close its browser, and stays available
|
||||
# for the next request. A parent that hit its deadline first could only kill the helper,
|
||||
# throwing away a process the next request would have to start again. The 30s covers the
|
||||
# unwind grace as well, so a helper that times out and closes its browser as slowly as it
|
||||
# is allowed to still answers with 15s to spare.
|
||||
_CHILD_BYPASS_TIMEOUT_SECONDS = _BYPASS_SUBPROCESS_TIMEOUT_SECONDS - 30.0
|
||||
# The helper publishes its answer by writing the result file the request named, so the
|
||||
# parent waits by watching for that file rather than by reading a stream it would have to
|
||||
# demultiplex from the helper's log output.
|
||||
_HELPER_RESULT_POLL_SECONDS = 0.05
|
||||
# Closing the helper's stdin asks it to shut down; this is how long it may take to finish
|
||||
# what it is doing and exit before its session is killed instead.
|
||||
_HELPER_SHUTDOWN_GRACE_SECONDS = 15.0
|
||||
_HELPER_IDLE_TIMEOUT_DEFAULT = 180.0
|
||||
_PARENT_WATCHDOG_INTERVAL_SECONDS = 5.0
|
||||
|
||||
|
||||
class _DisplayState(TypedDict):
|
||||
@@ -90,8 +103,8 @@ DISPLAY: _DisplayState = {
|
||||
"ffmpeg_output": None,
|
||||
}
|
||||
LOCKED = threading.Lock()
|
||||
_PGREP_PATH = shutil.which("pgrep")
|
||||
_PKILL_PATH = shutil.which("pkill")
|
||||
_PROC_ROOT = Path("/proc")
|
||||
_BROWSER_PROCESS_PATTERNS = ("chrome", "chromium", "Xvfb", "ffmpeg")
|
||||
_RNG = random.SystemRandom()
|
||||
|
||||
_CDP_OPERATION_ERRORS = (
|
||||
@@ -214,14 +227,33 @@ class _CdpWorker:
|
||||
msg = "CDP worker loop failed to start"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
@staticmethod
|
||||
async def _bounded(coro: Any, timeout: float | None) -> Any:
|
||||
"""Run the coroutine under its deadline, on the loop that owns it.
|
||||
|
||||
The deadline has to be enforced from inside the loop rather than by the calling
|
||||
thread: asyncio.wait_for() cancels the bypass and then *waits for it to unwind*,
|
||||
so `finally: await _close_cdp_driver(driver)` has finished by the time this
|
||||
raises. Cancelling from outside returns the moment the cancellation is scheduled,
|
||||
which in a helper serving many requests let the abandoned bypass close its browser
|
||||
while the next one was already opening its own - on the same loop, sharing the
|
||||
DISPLAY globals and one process group.
|
||||
"""
|
||||
if timeout is None:
|
||||
return await coro
|
||||
return await asyncio.wait_for(coro, timeout)
|
||||
|
||||
def run(self, coro: Any, timeout: float | None = None) -> Any:
|
||||
self.start()
|
||||
if not self._loop or self._loop.is_closed():
|
||||
msg = "CDP worker loop not available"
|
||||
raise RuntimeError(msg)
|
||||
future = asyncio.run_coroutine_threadsafe(coro, self._loop)
|
||||
future = asyncio.run_coroutine_threadsafe(self._bounded(coro, timeout), self._loop)
|
||||
# Backstop for an unwind that wedges too: _close_cdp_driver awaits websockets that
|
||||
# a dead browser may never answer, and _bounded cannot outlive its own cleanup.
|
||||
wait_for = None if timeout is None else timeout + _CDP_UNWIND_GRACE_SECONDS
|
||||
try:
|
||||
return future.result(timeout=timeout)
|
||||
return future.result(timeout=wait_for)
|
||||
except TimeoutError:
|
||||
# Otherwise the coroutine keeps running in the worker loop after we stop
|
||||
# waiting, holding the browser and racing the next bypass.
|
||||
@@ -231,120 +263,6 @@ class _CdpWorker:
|
||||
|
||||
_CDP_WORKER = _CdpWorker()
|
||||
|
||||
# Cookie storage - shared with requests library for Cloudflare bypass
|
||||
# Nested mapping of domain to cookie name to cookie metadata.
|
||||
_cf_cookies: dict[str, dict] = {}
|
||||
_cf_cookies_lock = threading.Lock()
|
||||
|
||||
# User-Agent storage - Cloudflare ties cf_clearance to the UA that solved the challenge
|
||||
_cf_user_agents: dict[str, str] = {}
|
||||
|
||||
# Protection cookie names we care about (Cloudflare and DDoS-Guard)
|
||||
CF_COOKIE_NAMES = {"cf_clearance", "__cf_bm", "cf_chl_2", "cf_chl_prog"}
|
||||
DDG_COOKIE_NAMES = {
|
||||
"__ddg1_",
|
||||
"__ddg2_",
|
||||
"__ddg5_",
|
||||
"__ddg8_",
|
||||
"__ddg9_",
|
||||
"__ddg10_",
|
||||
"__ddgid_",
|
||||
"__ddgmark_",
|
||||
"ddg_last_challenge",
|
||||
}
|
||||
|
||||
# DDoS-Guard cookies that describe *one* check rather than granting clearance, and so
|
||||
# must never be replayed on a later request. Observed live on Anna's Archive:
|
||||
#
|
||||
# __ddg9_ the client IP address
|
||||
# __ddg10_ the unix timestamp the check was issued
|
||||
# __ddg8_ an opaque token issued with them, same ~40 minute expiry
|
||||
#
|
||||
# Clearance itself lives in __ddg1_/__ddg2_/__ddgid_ (roughly a year) and __ddg5_.
|
||||
# Replaying the trio is actively harmful: once the timestamp ages out - or the egress
|
||||
# IP changes, which happens routinely behind a VPN - the values no longer describe the
|
||||
# caller, DDoS-Guard re-arms its check and answers every request with a ?check=1
|
||||
# redirect. That is the redirect loop, and it is self-inflicted. Dropping them simply
|
||||
# lets DDoS-Guard issue a fresh set, exactly as it does for a browser.
|
||||
DDG_EPHEMERAL_COOKIE_NAMES = {
|
||||
"__ddg8_",
|
||||
"__ddg9_",
|
||||
"__ddg10_",
|
||||
"ddg_last_challenge",
|
||||
}
|
||||
|
||||
|
||||
def _get_base_domain(domain: str) -> str:
|
||||
"""Extract base domain from hostname (e.g., 'www.example.com' -> 'example.com')."""
|
||||
return ".".join(domain.split(".")[-2:]) if "." in domain else domain
|
||||
|
||||
|
||||
def _get_full_cookie_domains() -> set[str]:
|
||||
"""Return mirror domains that need full-session cookie extraction."""
|
||||
from shelfmark.core.mirrors import get_zlib_cookie_domains
|
||||
|
||||
return {_get_base_domain(domain) for domain in get_zlib_cookie_domains()}
|
||||
|
||||
|
||||
def _should_extract_cookie(name: str, *, extract_all: bool) -> bool:
|
||||
"""Determine if a cookie should be extracted based on its name."""
|
||||
# Checked before extract_all: a per-check token is wrong to replay for every
|
||||
# domain, including the full-session ones.
|
||||
if name in DDG_EPHEMERAL_COOKIE_NAMES:
|
||||
return False
|
||||
if extract_all:
|
||||
return True
|
||||
is_cf = name in CF_COOKIE_NAMES or name.startswith("cf_")
|
||||
is_ddg = name in DDG_COOKIE_NAMES or name.startswith("__ddg")
|
||||
return is_cf or is_ddg
|
||||
|
||||
|
||||
def _store_extracted_cookies(
|
||||
*,
|
||||
url: str,
|
||||
cookies: list[Any],
|
||||
user_agent: str | None = None,
|
||||
) -> None:
|
||||
"""Store filtered bypass cookies (and optional UA) for a URL domain."""
|
||||
parsed = urlparse(url)
|
||||
domain = parsed.hostname or ""
|
||||
if not domain:
|
||||
return
|
||||
|
||||
base_domain = _get_base_domain(domain)
|
||||
extract_all = base_domain in _get_full_cookie_domains()
|
||||
|
||||
cookies_found: dict[str, dict[str, Any]] = {}
|
||||
for cookie in cookies:
|
||||
name = getattr(cookie, "name", "") or ""
|
||||
if not _should_extract_cookie(name, extract_all=extract_all):
|
||||
continue
|
||||
expires = getattr(cookie, "expires", None)
|
||||
if expires is not None and expires <= 0:
|
||||
expires = None
|
||||
cookies_found[name] = {
|
||||
"value": getattr(cookie, "value", ""),
|
||||
"domain": getattr(cookie, "domain", None) or domain,
|
||||
"path": getattr(cookie, "path", None) or "/",
|
||||
"expiry": expires,
|
||||
"secure": bool(getattr(cookie, "secure", True)),
|
||||
"httpOnly": True,
|
||||
}
|
||||
|
||||
if not cookies_found:
|
||||
return
|
||||
|
||||
with _cf_cookies_lock:
|
||||
_cf_cookies[base_domain] = cookies_found
|
||||
if user_agent:
|
||||
_cf_user_agents[base_domain] = user_agent
|
||||
logger.debug("Stored UA for %s: %s...", base_domain, str(user_agent)[:60])
|
||||
else:
|
||||
logger.debug("No UA captured for %s", base_domain)
|
||||
|
||||
cookie_type = "all" if extract_all else "protection"
|
||||
logger.debug("Extracted %s %s cookies for %s", len(cookies_found), cookie_type, base_domain)
|
||||
|
||||
|
||||
async def _extract_cookies_from_cdp(driver: Any, page: Any, url: str) -> None:
|
||||
"""Extract cookies from a CDP browser after successful bypass."""
|
||||
@@ -360,136 +278,116 @@ async def _extract_cookies_from_cdp(driver: Any, page: Any, url: str) -> None:
|
||||
except _CDP_OPERATION_ERRORS:
|
||||
user_agent = None
|
||||
|
||||
_store_extracted_cookies(url=url, cookies=all_cookies, user_agent=user_agent)
|
||||
store_extracted_cookies(url=url, cookies=all_cookies, user_agent=user_agent)
|
||||
|
||||
except _CDP_OPERATION_ERRORS as e:
|
||||
logger.debug("Failed to extract cookies: %s", e)
|
||||
|
||||
|
||||
def _is_cookie_expired(cookie: dict[str, Any]) -> bool:
|
||||
"""Whether a stored cookie's expiry has passed. Session cookies never expire here."""
|
||||
expiry = cookie.get("expiry")
|
||||
if expiry is None:
|
||||
expiry = cookie.get("expires")
|
||||
if not expiry or expiry <= 0:
|
||||
return False
|
||||
return time.time() > expiry
|
||||
def _read_process_cmdline(proc_dir: Path) -> str:
|
||||
"""Return a process's full command line, or "" when it cannot be read."""
|
||||
try:
|
||||
raw = (proc_dir / "cmdline").read_bytes()
|
||||
except OSError:
|
||||
return ""
|
||||
return raw.replace(b"\x00", b" ").decode("utf-8", "replace").strip()
|
||||
|
||||
|
||||
def get_cf_cookies_for_domain(domain: str) -> dict[str, str]:
|
||||
"""Get stored cookies for a domain. Returns empty dict if none available."""
|
||||
if not domain:
|
||||
return {}
|
||||
|
||||
base_domain = _get_base_domain(domain)
|
||||
|
||||
with _cf_cookies_lock:
|
||||
cookies = _cf_cookies.get(base_domain, {})
|
||||
if not cookies:
|
||||
return {}
|
||||
|
||||
cf_clearance = cookies.get("cf_clearance", {})
|
||||
if cf_clearance and _is_cookie_expired(cf_clearance):
|
||||
logger.debug("CF cookies expired for %s", base_domain)
|
||||
_cf_cookies.pop(base_domain, None)
|
||||
return {}
|
||||
|
||||
# Expiry applies to every cookie, not just Cloudflare's. DDoS-Guard domains
|
||||
# have no cf_clearance, so the check above never fired for them and dead
|
||||
# cookies were replayed indefinitely - the server answers those with a
|
||||
# challenge, which is indistinguishable from having sent nothing at all.
|
||||
live = {name: c for name, c in cookies.items() if not _is_cookie_expired(c)}
|
||||
if len(live) != len(cookies):
|
||||
expired = sorted(set(cookies) - set(live))
|
||||
logger.debug("Dropping expired cookies for %s: %s", base_domain, expired)
|
||||
if live:
|
||||
_cf_cookies[base_domain] = live
|
||||
else:
|
||||
_cf_cookies.pop(base_domain, None)
|
||||
|
||||
return {name: c["value"] for name, c in live.items()}
|
||||
|
||||
|
||||
def has_valid_cf_cookies(domain: str) -> bool:
|
||||
"""Check if we have valid Cloudflare cookies for a domain."""
|
||||
return bool(get_cf_cookies_for_domain(domain))
|
||||
|
||||
|
||||
def get_cf_user_agent_for_domain(domain: str) -> str | None:
|
||||
"""Get the User-Agent that was used during bypass for a domain."""
|
||||
if not domain:
|
||||
def _read_process_pgid(proc_dir: Path) -> int | None:
|
||||
"""Return a process's group id from /proc/<pid>/stat, or None when unreadable."""
|
||||
try:
|
||||
stat_line = (proc_dir / "stat").read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
return None
|
||||
# Field 2 (comm) is parenthesised and may itself contain spaces and parens, so the
|
||||
# fields are only unambiguous after the last ')': state, ppid, pgrp, ...
|
||||
fields = stat_line.rpartition(")")[2].split()
|
||||
pgrp_index = 2
|
||||
if len(fields) <= pgrp_index:
|
||||
return None
|
||||
try:
|
||||
return int(fields[pgrp_index])
|
||||
except ValueError:
|
||||
return None
|
||||
with _cf_cookies_lock:
|
||||
return _cf_user_agents.get(_get_base_domain(domain))
|
||||
|
||||
|
||||
def clear_cf_cookies(domain: str | None = None) -> None:
|
||||
"""Clear stored Cloudflare cookies and User-Agent. If domain is None, clear all."""
|
||||
with _cf_cookies_lock:
|
||||
if domain:
|
||||
base_domain = _get_base_domain(domain)
|
||||
_cf_cookies.pop(base_domain, None)
|
||||
_cf_user_agents.pop(base_domain, None)
|
||||
else:
|
||||
_cf_cookies.clear()
|
||||
_cf_user_agents.clear()
|
||||
def _find_browser_processes() -> list[tuple[int, int, str]]:
|
||||
"""Return (pid, pgid, cmdline) for every browser-ish process visible in /proc."""
|
||||
found: list[tuple[int, int, str]] = []
|
||||
try:
|
||||
entries = list(_PROC_ROOT.iterdir())
|
||||
except OSError as e:
|
||||
logger.debug("Could not list %s: %s", _PROC_ROOT, e)
|
||||
return found
|
||||
|
||||
for entry in entries:
|
||||
if not entry.name.isdigit():
|
||||
continue
|
||||
cmdline = _read_process_cmdline(entry)
|
||||
if not cmdline or not any(name in cmdline for name in _BROWSER_PROCESS_PATTERNS):
|
||||
continue
|
||||
pgid = _read_process_pgid(entry)
|
||||
if pgid is None:
|
||||
continue
|
||||
found.append((int(entry.name), pgid, cmdline))
|
||||
return found
|
||||
|
||||
|
||||
def _kill_process(pid: int, cmdline: str) -> bool:
|
||||
"""SIGKILL one process, reporting whether it was actually signalled."""
|
||||
try:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except OSError as e:
|
||||
logger.warning("Failed to kill pid %s: %s", pid, e)
|
||||
return False
|
||||
logger.debug("Killed leftover process %s: %s", pid, cmdline[:120])
|
||||
return True
|
||||
|
||||
|
||||
def _cleanup_orphan_processes() -> int:
|
||||
"""Kill orphan Chrome/Xvfb/ffmpeg processes. Only runs in Docker mode."""
|
||||
"""Kill leftover Chrome/Xvfb/ffmpeg processes. Only runs in Docker mode.
|
||||
|
||||
Scoped to this bypass session's process group plus groups whose leader has died.
|
||||
A container-wide sweep (the old `pkill -9 -f chrome`) also matched the browsers a
|
||||
concurrently running bypass was still driving, so with MAX_CONCURRENT_DOWNLOADS > 1
|
||||
every worker that started a solve killed the others' browsers (#1231).
|
||||
"""
|
||||
if not env.DOCKERMODE:
|
||||
return 0
|
||||
|
||||
_stop_ffmpeg_recording()
|
||||
|
||||
processes_to_kill = ["chrome", "chromium", "Xvfb", "ffmpeg"]
|
||||
total_killed = 0
|
||||
|
||||
logger.debug("Checking for orphan processes...")
|
||||
logger.debug("Checking for leftover browser processes...")
|
||||
logger.log_resource_usage()
|
||||
|
||||
if _PGREP_PATH is None or _PKILL_PATH is None:
|
||||
logger.warning("Skipping orphan-process cleanup because pgrep/pkill are unavailable")
|
||||
if not _PROC_ROOT.is_dir():
|
||||
logger.warning("Skipping browser-process cleanup because %s is unavailable", _PROC_ROOT)
|
||||
return 0
|
||||
|
||||
for proc_name in processes_to_kill:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[_PGREP_PATH, "-f", proc_name],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode != 0 or not result.stdout.strip():
|
||||
continue
|
||||
own_pid = os.getpid()
|
||||
own_pgid = os.getpgrp()
|
||||
total_killed = 0
|
||||
|
||||
pids = result.stdout.strip().split("\n")
|
||||
count = len(pids)
|
||||
logger.info("Found %s orphan %s process(es), killing...", count, proc_name)
|
||||
|
||||
kill_result = subprocess.run(
|
||||
[_PKILL_PATH, "-9", "-f", proc_name],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=5,
|
||||
)
|
||||
if kill_result.returncode == 0:
|
||||
total_killed += count
|
||||
else:
|
||||
logger.warning("pkill for %s returned %s", proc_name, kill_result.returncode)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("Timeout while checking for %s processes", proc_name)
|
||||
except _SUBPROCESS_OPERATION_ERRORS as e:
|
||||
logger.debug("Error checking for %s processes: %s", proc_name, e)
|
||||
for pid, pgid, cmdline in _find_browser_processes():
|
||||
if pid == own_pid:
|
||||
continue
|
||||
# Another live process group means another bypass session: its browsers are in
|
||||
# use, not orphans. Only our own group and groups whose leader is gone (a helper
|
||||
# that died or was killed, leaving its browser behind) are ours to clean up.
|
||||
if pgid != own_pgid and (_PROC_ROOT / str(pgid)).exists():
|
||||
logger.debug("Leaving pid %s to its live bypass session (pgid %s)", pid, pgid)
|
||||
continue
|
||||
if _kill_process(pid, cmdline):
|
||||
total_killed += 1
|
||||
|
||||
if total_killed > 0:
|
||||
time.sleep(1)
|
||||
logger.info("Cleaned up %s orphan process(es)", total_killed)
|
||||
logger.info("Cleaned up %s leftover browser process(es)", total_killed)
|
||||
logger.log_resource_usage()
|
||||
else:
|
||||
logger.debug("No orphan processes found")
|
||||
logger.debug("No leftover browser processes found")
|
||||
|
||||
return total_killed
|
||||
|
||||
@@ -946,27 +844,25 @@ def _run_bypass_in_current_process(url: str, retry: int, cancel_flag: Event | No
|
||||
if driver:
|
||||
await _close_cdp_driver(driver)
|
||||
|
||||
if os.environ.get(_BYPASS_CHILD_ENV) == "1":
|
||||
return asyncio.run(_run_bypass())
|
||||
# Bound the wait: this path runs in-process (non-Docker installs), holds the module-wide
|
||||
# LOCKED for its whole duration, and neither page.get() nor page.wait() has a timeout of
|
||||
# its own. Without a deadline here a single wedged CDP session blocks every subsequent
|
||||
# bypass in the process forever.
|
||||
return _CDP_WORKER.run(_run_bypass(), timeout=_IN_PROCESS_BYPASS_TIMEOUT_SECONDS)
|
||||
# Bound the wait: this holds the module-wide LOCKED for its whole duration, and neither
|
||||
# page.get() nor page.wait() has a timeout of its own. Without a deadline here a single
|
||||
# wedged CDP session blocks every subsequent bypass in the process forever.
|
||||
#
|
||||
# The helper goes through the worker too, rather than asyncio.run: that owns a loop for
|
||||
# one call and closes it on the way out, so a helper serving many requests would build
|
||||
# and tear down a loop per bypass and would carry no deadline of its own. The worker's
|
||||
# loop lives in a thread, outlives any single bypass, and cancels the coroutine when the
|
||||
# deadline passes.
|
||||
timeout = (
|
||||
_CHILD_BYPASS_TIMEOUT_SECONDS
|
||||
if os.environ.get(_BYPASS_CHILD_ENV) == "1"
|
||||
else _IN_PROCESS_BYPASS_TIMEOUT_SECONDS
|
||||
)
|
||||
return _CDP_WORKER.run(_run_bypass(), timeout=timeout)
|
||||
|
||||
|
||||
def _store_child_bypass_state(payload: dict[str, Any]) -> None:
|
||||
cookies = payload.get("cookies")
|
||||
if isinstance(cookies, dict):
|
||||
with _cf_cookies_lock:
|
||||
_cf_cookies.update(cookies)
|
||||
|
||||
user_agents = payload.get("user_agents")
|
||||
if isinstance(user_agents, dict):
|
||||
with _cf_cookies_lock:
|
||||
_cf_user_agents.update(
|
||||
{str(domain): str(agent) for domain, agent in user_agents.items()}
|
||||
)
|
||||
import_store(payload.get("cookies"), payload.get("user_agents"))
|
||||
|
||||
|
||||
def _prepare_child_browser_env(env_vars: dict[str, str]) -> dict[str, str]:
|
||||
@@ -989,6 +885,228 @@ def _prepare_child_browser_env(env_vars: dict[str, str]) -> dict[str, str]:
|
||||
return env_vars
|
||||
|
||||
|
||||
def _terminate_helper_session(proc: subprocess.Popen[str]) -> None:
|
||||
"""Kill the bypass helper and every process it spawned.
|
||||
|
||||
start_new_session makes the helper a session leader, so its pid doubles as the
|
||||
process-group id of the browser tree underneath it and one killpg reaches all of it.
|
||||
"""
|
||||
if hasattr(os, "killpg"):
|
||||
with suppress(OSError):
|
||||
os.killpg(proc.pid, signal.SIGKILL)
|
||||
with suppress(OSError):
|
||||
proc.kill()
|
||||
with suppress(OSError, subprocess.SubprocessError):
|
||||
proc.wait(timeout=5)
|
||||
|
||||
|
||||
def _part_path(result_path: Path) -> Path:
|
||||
"""Where the helper stages a result before renaming it into place."""
|
||||
return result_path.with_name(result_path.name + ".part")
|
||||
|
||||
|
||||
class _BypassHelper:
|
||||
"""The helper subprocess that runs the bypasses, kept alive across them.
|
||||
|
||||
Spawning it costs about 4.5 seconds of interpreter start and imports before any work
|
||||
begins, paid on every protected request - and a single search issues several. What it
|
||||
keeps is the process, not the browser: each bypass still starts and closes its own
|
||||
Chrome, so nothing accumulates between requests.
|
||||
|
||||
Protocol: one JSON request per line on stdin, answered by writing the result file that
|
||||
request named. stdout and stderr stay attached to the parent's, so helper logs keep
|
||||
showing up in `docker logs` as before.
|
||||
|
||||
Only one request is ever in flight - get() serializes every bypass behind LOCKED. The
|
||||
lock here is for the idle reaper, which runs on a timer thread.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.RLock()
|
||||
self._proc: subprocess.Popen[str] | None = None
|
||||
self._last_used = 0.0
|
||||
self._idle_timer: threading.Timer | None = None
|
||||
|
||||
def _idle_timeout(self) -> float:
|
||||
return _coerce_non_negative_float(
|
||||
app_config.get("BYPASS_BROWSER_IDLE_TIMEOUT", _HELPER_IDLE_TIMEOUT_DEFAULT),
|
||||
_HELPER_IDLE_TIMEOUT_DEFAULT,
|
||||
)
|
||||
|
||||
def _spawn(self) -> subprocess.Popen[str]:
|
||||
env_vars = os.environ.copy()
|
||||
env_vars[_BYPASS_CHILD_ENV] = "1"
|
||||
env_vars = _prepare_child_browser_env(env_vars)
|
||||
return subprocess.Popen(
|
||||
[sys.executable, "-m", "shelfmark.bypass.internal_bypasser"],
|
||||
stdin=subprocess.PIPE,
|
||||
text=True,
|
||||
env=env_vars,
|
||||
# Give the helper its own session: Chrome, Xvfb and ffmpeg inherit its process
|
||||
# group, which is what lets the cleanup sweep tell this helper's browsers apart
|
||||
# from a concurrent worker's (#1231) and lets us kill the whole tree below.
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
def _running(self) -> subprocess.Popen[str] | None:
|
||||
proc = self._proc
|
||||
if proc is None:
|
||||
return None
|
||||
if proc.poll() is not None or proc.stdin is None or proc.stdin.closed:
|
||||
return None
|
||||
return proc
|
||||
|
||||
def _ensure_running(self) -> subprocess.Popen[str]:
|
||||
proc = self._running()
|
||||
if proc is not None:
|
||||
return proc
|
||||
if self._proc is not None:
|
||||
logger.info("Bypass helper exited (code %s), starting a new one", self._proc.returncode)
|
||||
self._discard()
|
||||
self._proc = self._spawn()
|
||||
return self._proc
|
||||
|
||||
def _discard(self, *, wait_for_exit: bool = True) -> None:
|
||||
"""Stop the helper and forget it.
|
||||
|
||||
`wait_for_exit` belongs to a helper that could still act on the closed pipe: an
|
||||
idle one is sitting in its stdin read, notices EOF and exits on its own. A helper
|
||||
dropped mid-bypass is blocked inside the solve and will not return to that read,
|
||||
so the grace cannot end in anything but the kill below - and the caller waiting it
|
||||
out is a user cancelling a download, holding LOCKED while every other bypass in
|
||||
the worker queues behind them.
|
||||
"""
|
||||
proc = self._proc
|
||||
self._proc = None
|
||||
if proc is None:
|
||||
return
|
||||
|
||||
# Closing stdin ends the helper's request loop, so an idle helper gets to exit on
|
||||
# its own. One mid-bypass cannot answer, and is killed below.
|
||||
with suppress(OSError):
|
||||
if proc.stdin is not None and not proc.stdin.closed:
|
||||
proc.stdin.close()
|
||||
if wait_for_exit:
|
||||
try:
|
||||
proc.wait(timeout=_HELPER_SHUTDOWN_GRACE_SECONDS)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("Bypass helper did not exit on request, killing its session")
|
||||
|
||||
# Tear the session down either way: a helper killed mid-bypass leaves its Chrome
|
||||
# and Xvfb running, and those leftovers are what made the next worker's browser
|
||||
# fail to start. Harmless once it has already exited.
|
||||
_terminate_helper_session(proc)
|
||||
|
||||
def _cancel_idle_timer(self) -> None:
|
||||
if self._idle_timer is not None:
|
||||
self._idle_timer.cancel()
|
||||
self._idle_timer = None
|
||||
|
||||
def _arm_idle_timer(self) -> None:
|
||||
self._cancel_idle_timer()
|
||||
timeout = self._idle_timeout()
|
||||
if self._proc is None or timeout <= 0:
|
||||
return
|
||||
timer = threading.Timer(timeout, self._reap_if_idle)
|
||||
timer.daemon = True
|
||||
self._idle_timer = timer
|
||||
timer.start()
|
||||
|
||||
def _reap_if_idle(self) -> None:
|
||||
with self._lock:
|
||||
if self._proc is None:
|
||||
return
|
||||
idle_for = time.monotonic() - self._last_used
|
||||
timeout = self._idle_timeout()
|
||||
if idle_for < timeout:
|
||||
# A bypass started while this timer was waiting for the lock.
|
||||
self._arm_idle_timer()
|
||||
return
|
||||
logger.info("Closing idle bypass helper after %.0fs without work", idle_for)
|
||||
self._discard()
|
||||
|
||||
def run(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
timeout: float,
|
||||
cancel_flag: Event | None,
|
||||
) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
self._cancel_idle_timer()
|
||||
try:
|
||||
return self._exchange(payload, timeout, cancel_flag)
|
||||
finally:
|
||||
self._last_used = time.monotonic()
|
||||
self._arm_idle_timer()
|
||||
|
||||
def _exchange(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
timeout: float,
|
||||
cancel_flag: Event | None,
|
||||
) -> dict[str, Any]:
|
||||
request_line = json.dumps(payload) + "\n"
|
||||
proc = self._ensure_running()
|
||||
try:
|
||||
self._write(proc, request_line)
|
||||
except OSError as exc:
|
||||
# A live helper can die between the liveness check and the write, so one retry
|
||||
# on a fresh process. A fresh one failing here is a real failure.
|
||||
logger.info("Bypass helper closed its pipe (%s), retrying on a new one", exc)
|
||||
# Nothing to ask of a helper we cannot write to: its read end is already gone.
|
||||
self._discard(wait_for_exit=False)
|
||||
proc = self._ensure_running()
|
||||
self._write(proc, request_line)
|
||||
|
||||
return self._await_result(proc, Path(str(payload["result_path"])), timeout, cancel_flag)
|
||||
|
||||
def _write(self, proc: subprocess.Popen[str], request_line: str) -> None:
|
||||
if proc.stdin is None:
|
||||
msg = "Bypass helper has no stdin pipe"
|
||||
raise OSError(msg)
|
||||
proc.stdin.write(request_line)
|
||||
proc.stdin.flush()
|
||||
|
||||
def _await_result(
|
||||
self,
|
||||
proc: subprocess.Popen[str],
|
||||
result_path: Path,
|
||||
timeout: float,
|
||||
cancel_flag: Event | None,
|
||||
) -> dict[str, Any]:
|
||||
deadline = time.monotonic() + timeout
|
||||
try:
|
||||
while not result_path.exists():
|
||||
if proc.poll() is not None:
|
||||
returncode = proc.returncode
|
||||
self._discard(wait_for_exit=False)
|
||||
msg = f"Internal bypasser helper exited without a result (code {returncode})"
|
||||
raise RuntimeError(msg)
|
||||
if cancel_flag is not None and cancel_flag.is_set():
|
||||
# The helper is mid-bypass and cannot be told to stop, so it goes.
|
||||
self._discard(wait_for_exit=False)
|
||||
_check_cancellation(cancel_flag, "Bypass cancelled while waiting for helper")
|
||||
if time.monotonic() >= deadline:
|
||||
self._discard(wait_for_exit=False)
|
||||
msg = "Internal bypasser helper process timed out"
|
||||
raise TimeoutError(msg)
|
||||
time.sleep(_HELPER_RESULT_POLL_SECONDS)
|
||||
|
||||
return json.loads(result_path.read_text(encoding="utf-8"))
|
||||
finally:
|
||||
# Every way out of here is final for this request: either the answer has been
|
||||
# read, or the helper that would have written it has just been killed. Nothing
|
||||
# will write these paths afterwards and nothing will come looking for them, so
|
||||
# they are cleaned on the failure paths too - otherwise every cancelled
|
||||
# download and every wedged solve leaves one behind for the container's life.
|
||||
for path in (result_path, _part_path(result_path)):
|
||||
with suppress(OSError):
|
||||
path.unlink()
|
||||
|
||||
|
||||
_BYPASS_HELPER = _BypassHelper()
|
||||
|
||||
|
||||
def _get_via_subprocess(url: str, retry: int, cancel_flag: Event | None = None) -> str:
|
||||
"""Run the browser bypass in a helper process isolated from gunicorn/gevent."""
|
||||
_check_cancellation(cancel_flag, "Bypass cancelled before helper process")
|
||||
@@ -999,39 +1117,15 @@ def _get_via_subprocess(url: str, retry: int, cancel_flag: Event | None = None)
|
||||
# freshly spawned helper would otherwise pre-resolve AA hostnames against the system
|
||||
# resolver - which may be blocked or hijacked by the user's ISP. Pass the parent's
|
||||
# active DNS config so the helper mirrors it (e.g. DoH) when building Chrome's host
|
||||
# resolver rules.
|
||||
# resolver rules. Sent with every request, not just at spawn, because a helper outlives
|
||||
# changes the parent makes to its DNS provider.
|
||||
payload = {
|
||||
"url": url,
|
||||
"retry": retry,
|
||||
"result_path": str(result_path),
|
||||
"dns_config": network.get_dns_config(),
|
||||
}
|
||||
env_vars = os.environ.copy()
|
||||
env_vars[_BYPASS_CHILD_ENV] = "1"
|
||||
env_vars = _prepare_child_browser_env(env_vars)
|
||||
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "shelfmark.bypass.internal_bypasser"],
|
||||
stdin=subprocess.PIPE,
|
||||
text=True,
|
||||
env=env_vars,
|
||||
)
|
||||
try:
|
||||
proc.communicate(json.dumps(payload), timeout=_BYPASS_SUBPROCESS_TIMEOUT_SECONDS)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
msg = "Internal bypasser helper process timed out"
|
||||
raise TimeoutError(msg) from None
|
||||
|
||||
try:
|
||||
result = json.loads(result_path.read_text())
|
||||
except FileNotFoundError as exc:
|
||||
msg = f"Internal bypasser helper exited without a result (code {proc.returncode})"
|
||||
raise RuntimeError(msg) from exc
|
||||
finally:
|
||||
with suppress(OSError):
|
||||
result_path.unlink()
|
||||
result = _BYPASS_HELPER.run(payload, _BYPASS_SUBPROCESS_TIMEOUT_SECONDS, cancel_flag)
|
||||
|
||||
if not isinstance(result, dict):
|
||||
msg = "Internal bypasser helper returned an invalid result"
|
||||
@@ -1368,33 +1462,88 @@ def get_bypassed_page(
|
||||
return response_html
|
||||
|
||||
|
||||
def _dns_fingerprint(dns_config: dict[str, Any]) -> tuple[str, tuple[str, ...], bool]:
|
||||
"""Reduce a DNS config to what has to match for two of them to be the same one."""
|
||||
provider = str(dns_config.get("provider") or "").strip().lower()
|
||||
servers = dns_config.get("servers") if provider == "manual" else None
|
||||
server_list = tuple(str(server) for server in servers) if isinstance(servers, list) else ()
|
||||
return (provider, server_list, bool(dns_config.get("doh_enabled")))
|
||||
|
||||
|
||||
def _apply_parent_dns_config(dns_config: dict[str, Any]) -> None:
|
||||
"""Mirror the parent process's active DNS provider in this helper subprocess.
|
||||
|
||||
DNS state is in-memory only, so a fresh helper defaults to system DNS and would
|
||||
pre-resolve AA hostnames (for Chrome's --host-resolver-rules) against a resolver
|
||||
that may be blocked/hijacked. Re-applying the parent's provider keeps the helper on
|
||||
the same DoH/custom resolver the parent already validated.
|
||||
DNS state is in-memory only, so a helper left to itself would pre-resolve AA hostnames
|
||||
(for Chrome's --host-resolver-rules) against a resolver that may be blocked or
|
||||
hijacked. Re-applying the parent's provider keeps the helper on the same DoH/custom
|
||||
resolver the parent already validated.
|
||||
|
||||
Compared against what this process is *actually* resolving through, rather than
|
||||
against the last config it happened to be handed. The helper now outlives the request,
|
||||
so it has to be able to travel back to auto as well as away from it - which a user
|
||||
flipping CUSTOM_DNS in settings does live, without a restart - and asking the network
|
||||
module what it is doing beats keeping a second, drifting copy of that answer here.
|
||||
"""
|
||||
provider = str(dns_config.get("provider") or "").strip().lower()
|
||||
# "auto" means the parent has not rotated off system DNS yet, so the helper's own
|
||||
# default initialization already matches it - nothing to override.
|
||||
if not provider or provider == "auto":
|
||||
wanted = _dns_fingerprint(dns_config)
|
||||
provider, servers, use_doh = wanted
|
||||
if not provider:
|
||||
return
|
||||
manual_servers = dns_config.get("servers") if provider == "manual" else None
|
||||
# set_dns_provider() rebuilds the resolvers, so it is worth doing only on a real change.
|
||||
if wanted == _dns_fingerprint(network.get_dns_config()):
|
||||
return
|
||||
|
||||
try:
|
||||
network.set_dns_provider(
|
||||
provider,
|
||||
manual_servers,
|
||||
use_doh=bool(dns_config.get("doh_enabled")),
|
||||
)
|
||||
network.set_dns_provider(provider, list(servers) or None, use_doh=use_doh)
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
logger.warning("Could not apply parent DNS config (%s): %s", provider, exc)
|
||||
|
||||
|
||||
def _run_child_process() -> int:
|
||||
"""CLI entrypoint used by the Docker helper subprocess."""
|
||||
request = json.loads(sys.stdin.read() or "{}")
|
||||
def _terminate_own_session() -> None:
|
||||
"""SIGKILL this process and every process it spawned, browser included."""
|
||||
if hasattr(os, "killpg") and os.getpgrp() == os.getpid():
|
||||
with suppress(OSError):
|
||||
os.killpg(os.getpgrp(), signal.SIGKILL)
|
||||
# A thread cannot end the process any other way; sys.exit would only end itself.
|
||||
os._exit(1)
|
||||
|
||||
|
||||
def _watch_parent_process(original_ppid: int, interval: float) -> None:
|
||||
"""Take the browser down with us once the app process that spawned us is gone.
|
||||
|
||||
Cleanup only reclaims process groups whose leader has died, so a helper that outlives
|
||||
its parent (worker restart, OOM kill) would sit there holding a browser that no later
|
||||
bypass is allowed to touch.
|
||||
"""
|
||||
while os.getppid() == original_ppid:
|
||||
time.sleep(interval)
|
||||
logger.warning("Bypass helper lost its parent process; taking the browser down")
|
||||
_terminate_own_session()
|
||||
|
||||
|
||||
def _start_parent_watchdog() -> None:
|
||||
"""Watch the spawning process in the background for the life of this helper."""
|
||||
threading.Thread(
|
||||
target=_watch_parent_process,
|
||||
args=(os.getppid(), _PARENT_WATCHDOG_INTERVAL_SECONDS),
|
||||
daemon=True,
|
||||
name="BypassParentWatchdog",
|
||||
).start()
|
||||
|
||||
|
||||
def _publish_result(result_path: Path, payload: dict[str, Any]) -> None:
|
||||
"""Write the result file atomically.
|
||||
|
||||
The parent decides the request is answered the moment this path exists, so it must
|
||||
never observe a half-written file. Rename within the same directory is atomic.
|
||||
"""
|
||||
tmp_path = _part_path(result_path)
|
||||
tmp_path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
tmp_path.replace(result_path)
|
||||
|
||||
|
||||
def _handle_child_request(request_line: str) -> int:
|
||||
"""Answer one request from the parent."""
|
||||
request = json.loads(request_line or "{}")
|
||||
result_path = Path(str(request["result_path"]))
|
||||
url = str(request["url"])
|
||||
retry = _coerce_positive_int(
|
||||
@@ -1405,15 +1554,25 @@ def _run_child_process() -> int:
|
||||
if isinstance(dns_config, dict):
|
||||
_apply_parent_dns_config(dns_config)
|
||||
|
||||
# The parent owns the cookie store; this process only solves. Starting each request
|
||||
# from an empty store is what a helper spawned per request gave for free, and losing
|
||||
# it is what let clearance the parent had deliberately purged for some *other* host
|
||||
# survive here and get merged back over the parent's copy by the export below - the
|
||||
# dead-cookie resurrection that http.py's _redirect_loop_handoff purges to avoid.
|
||||
# Nothing is lost by dropping it: get() below re-checks cached cookies, and the
|
||||
# parent already ran that same check against a store that is a superset of this one.
|
||||
clear_cf_cookies()
|
||||
|
||||
try:
|
||||
html = get(url, retry=retry)
|
||||
cookies, user_agents = export_store()
|
||||
payload = {
|
||||
"ok": True,
|
||||
"html": html,
|
||||
"cookies": _cf_cookies,
|
||||
"user_agents": _cf_user_agents,
|
||||
"cookies": cookies,
|
||||
"user_agents": user_agents,
|
||||
}
|
||||
result_path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
_publish_result(result_path, payload)
|
||||
except Exception as exc: # noqa: BLE001 - helper boundary must serialize failures.
|
||||
payload = {
|
||||
"ok": False,
|
||||
@@ -1421,10 +1580,30 @@ def _run_child_process() -> int:
|
||||
"error": str(exc),
|
||||
"traceback": traceback.format_exc(),
|
||||
}
|
||||
result_path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
_publish_result(result_path, payload)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def _run_child_process() -> int:
|
||||
"""CLI entrypoint used by the Docker helper subprocess.
|
||||
|
||||
Serves one request per line of stdin until the parent closes the pipe, so a burst of
|
||||
protected requests - a single search is several - pays the interpreter start and imports
|
||||
once instead of per request. Each bypass still gets its own browser, closed before the
|
||||
answer is published.
|
||||
"""
|
||||
exit_code = 0
|
||||
for line in sys.stdin:
|
||||
request_line = line.strip()
|
||||
if not request_line:
|
||||
continue
|
||||
exit_code = _handle_child_request(request_line)
|
||||
return exit_code
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Started here rather than in _run_child_process() so it only ever watches a real
|
||||
# spawned helper, never a test or an embedded call.
|
||||
_start_parent_watchdog()
|
||||
raise SystemExit(_run_child_process())
|
||||
|
||||
@@ -1655,6 +1655,19 @@ def cloudflare_bypass_settings() -> list[SettingsField]:
|
||||
requires_restart=True,
|
||||
show_when={"field": "USING_EXTERNAL_BYPASSER", "value": True},
|
||||
),
|
||||
NumberField(
|
||||
key="BYPASS_BROWSER_IDLE_TIMEOUT",
|
||||
label="Bypasser Idle Timeout (seconds)",
|
||||
description=(
|
||||
"How long the bypass helper process may sit unused before it is shut down. "
|
||||
"Higher keeps more searches fast, lower frees memory sooner."
|
||||
),
|
||||
default=180,
|
||||
min_value=30,
|
||||
max_value=3600,
|
||||
requires_restart=True,
|
||||
show_when={"field": "USING_EXTERNAL_BYPASSER", "value": False},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -843,6 +843,9 @@ class ExternalClientHandler(DownloadHandler, ABC):
|
||||
expected_hash=request.expected_hash,
|
||||
seeding_time_limit=request.seeding_time_limit,
|
||||
ratio_limit=request.ratio_limit,
|
||||
# rTorrent has no category concept, so its audiobook label
|
||||
# can only be chosen from the content type (#1235).
|
||||
content_type=task.content_type,
|
||||
)
|
||||
except Exception as e:
|
||||
if not refresh_attempted:
|
||||
|
||||
@@ -11,7 +11,12 @@ from urllib.parse import urlparse
|
||||
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.utils import get_hardened_xmlrpc_client
|
||||
from shelfmark.core.utils import (
|
||||
get_hardened_xmlrpc_client,
|
||||
)
|
||||
from shelfmark.core.utils import (
|
||||
is_audiobook as check_audiobook,
|
||||
)
|
||||
from shelfmark.download.clients import (
|
||||
DownloadClient,
|
||||
DownloadStatus,
|
||||
@@ -173,7 +178,8 @@ class RTorrentClient(DownloadClient):
|
||||
|
||||
commands = []
|
||||
|
||||
is_audiobook = kwargs.get("content_type") == "audiobook"
|
||||
content_type = kwargs.get("content_type")
|
||||
is_audiobook = check_audiobook(content_type if isinstance(content_type, str) else None)
|
||||
default_label = (
|
||||
self._audiobook_label if is_audiobook and self._audiobook_label else self._label
|
||||
)
|
||||
|
||||
+62
-19
@@ -10,7 +10,8 @@ from urllib.parse import urljoin, urlparse
|
||||
import requests
|
||||
from tqdm import tqdm
|
||||
|
||||
from shelfmark.bypass import BypassCancelledError
|
||||
from shelfmark.bypass import BypassCancelledError, cookie_store
|
||||
from shelfmark.bypass.challenge import challenge_marker
|
||||
from shelfmark.core.config import config as app_config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.request_helpers import coerce_bool, normalize_positive_int
|
||||
@@ -145,19 +146,13 @@ def get_bypassed_page(
|
||||
|
||||
|
||||
def get_cf_cookies_for_domain(domain: str) -> dict[str, str]:
|
||||
"""Get CF cookies - only available with internal bypasser."""
|
||||
if _is_using_external_bypasser():
|
||||
logger.debug("External bypasser in use, CF cookies not available for %s", domain)
|
||||
return {}
|
||||
return _get_internal_bypasser().get_cf_cookies_for_domain(domain)
|
||||
"""Get the clearance cookies won by whichever bypasser solved this domain."""
|
||||
return cookie_store.get_cf_cookies_for_domain(domain)
|
||||
|
||||
|
||||
def get_cf_user_agent_for_domain(domain: str) -> str | None:
|
||||
"""Get CF user agent - only available with internal bypasser."""
|
||||
if _is_using_external_bypasser():
|
||||
logger.debug("External bypasser in use, CF user agent not available for %s", domain)
|
||||
return None
|
||||
return _get_internal_bypasser().get_cf_user_agent_for_domain(domain)
|
||||
"""Get the User-Agent that solved this domain's challenge, if one is stored."""
|
||||
return cookie_store.get_cf_user_agent_for_domain(domain)
|
||||
|
||||
|
||||
def _apply_cf_bypass(url: str, headers: dict) -> dict:
|
||||
@@ -239,6 +234,22 @@ def _is_retryable_error(e: Exception) -> bool:
|
||||
_DEAD_MIRROR_CODES = (410, 451)
|
||||
|
||||
|
||||
def _response_challenge_marker(response: requests.Response) -> str | None:
|
||||
"""The challenge marker in a response body, or None if it carries no challenge.
|
||||
|
||||
Content type is checked first so a JSON or octet-stream error body is never
|
||||
decoded just to be scanned; a missing header is scanned anyway, since an
|
||||
interstitial served without one is still an interstitial.
|
||||
"""
|
||||
content_type = response.headers.get("Content-Type", "")
|
||||
if content_type and "html" not in content_type.lower():
|
||||
return None
|
||||
try:
|
||||
return challenge_marker(response.text)
|
||||
except UnicodeDecodeError, ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _fatal_mirror_reason(e: Exception) -> str | None:
|
||||
"""Return why ``e`` proves the mirror is unusable, or None if it may recover.
|
||||
|
||||
@@ -374,15 +385,14 @@ def html_get_page(
|
||||
|
||||
Called whenever the protection answered a request that *carried* cookies:
|
||||
being challenged while presenting them proves they no longer work, so keeping
|
||||
them only guarantees the same rejection on every later request. Purging is
|
||||
internal-bypasser only; with an external one get_cf_cookies_for_domain()
|
||||
already returns {}.
|
||||
them only guarantees the same rejection on every later request. Applies to
|
||||
either bypasser, since both fill the same store.
|
||||
"""
|
||||
hostname = urlparse(target_url).hostname or ""
|
||||
# An empty domain means "clear every host" to the bypasser, so skip the purge
|
||||
# An empty domain means "clear every host" to the store, so skip the purge
|
||||
# rather than wipe clearance for sites that are working fine.
|
||||
if hostname and not _is_using_external_bypasser():
|
||||
_get_internal_bypasser().clear_cf_cookies(hostname)
|
||||
if hostname:
|
||||
cookie_store.clear_cf_cookies(hostname)
|
||||
|
||||
def _redirect_loop_handoff(bypass_url: str) -> str | tuple[str, str]:
|
||||
"""Drop the host's stale clearance cookies, then bypass `bypass_url`.
|
||||
@@ -462,6 +472,35 @@ def html_get_page(
|
||||
)
|
||||
continue
|
||||
|
||||
# A 503 still serving a challenge is protection, not a busy origin. The
|
||||
# handshake above has nothing left to echo back, and 503 is in
|
||||
# RETRYABLE_CODES, so without this the request spends every attempt on
|
||||
# the same wall: the bypasser is only ever reached from the 403 branch
|
||||
# and the AA redirect rescues. Gate on the body, not the status, so a
|
||||
# genuine overloaded-origin 503 keeps its retry path.
|
||||
if response.status_code == _HTTP_STATUS_SERVICE_UNAVAILABLE:
|
||||
marker = _response_challenge_marker(response)
|
||||
if marker and _bypass_handoff_allowed():
|
||||
if cookies:
|
||||
# Challenged while presenting clearance means those cookies
|
||||
# are dead; same reasoning as the 403 branch below.
|
||||
logger.debug(
|
||||
"503 challenge with cookies presented; purging: %s", current_url
|
||||
)
|
||||
_purge_clearance(current_url)
|
||||
logger.info(
|
||||
"503 challenge detected (%s); switching to bypasser: %s",
|
||||
marker,
|
||||
current_url,
|
||||
)
|
||||
return _run_bypasser(current_url)
|
||||
if marker:
|
||||
logger.debug(
|
||||
"503 challenge (%s) but no bypasser handoff available: %s",
|
||||
marker,
|
||||
current_url,
|
||||
)
|
||||
|
||||
if is_aa_url and response.is_redirect:
|
||||
location = response.headers.get("Location", "")
|
||||
if not location:
|
||||
@@ -576,8 +615,12 @@ def html_get_page(
|
||||
# (another concurrent download may have completed bypass and extracted cookies)
|
||||
parsed = urlparse(current_url)
|
||||
fresh_cookies = get_cf_cookies_for_domain(parsed.hostname or "")
|
||||
if fresh_cookies and not cookies:
|
||||
# Cookies are now available - retry with cookies before using bypasser
|
||||
if fresh_cookies and not cookies and attempt < retry_limit:
|
||||
# Cookies are now available - retry with cookies before using bypasser.
|
||||
# Guarded on there being a next attempt: `continue` on the last one
|
||||
# ends the retry loop and abandons the request without ever offering
|
||||
# the URL to the bypasser, and MAX_RETRY=1 is the supported setting.
|
||||
# Same reasoning as the bypasser invocation below.
|
||||
logger.debug(
|
||||
"403 but cookies now available - retrying with cookies: %s",
|
||||
current_url,
|
||||
|
||||
@@ -48,6 +48,10 @@ HARDCOVER_PAGE_SIZE = 25 # Hardcover API returns max 25 results per page
|
||||
HARDCOVER_MIN_AUTHOR_PARTS = 2
|
||||
HARDCOVER_MIN_TYPEAHEAD_QUERY_LENGTH = 2
|
||||
HARDCOVER_MAX_SERIES_OPTIONS = 7
|
||||
# Hardcover hands out short opaque tokens now ("hc_pat_...") instead of the ~500 char
|
||||
# JWTs it used to, so the length floor only applies to keys without that prefix.
|
||||
HARDCOVER_API_KEY_PREFIX = "hc_pat_"
|
||||
HARDCOVER_BEARER_PREFIX_PATTERN = re.compile(r"^bearer\s+", re.IGNORECASE)
|
||||
HARDCOVER_API_KEY_MIN_LENGTH = 100
|
||||
HARDCOVER_LIST_URL_PATTERN = re.compile(
|
||||
r"^/(?:@([\w.-]+)/)?lists?/([\w-]+)/?$",
|
||||
@@ -318,6 +322,7 @@ query SearchFieldOptions(
|
||||
fields: $fields,
|
||||
weights: $weights
|
||||
) {
|
||||
error
|
||||
results
|
||||
}
|
||||
}
|
||||
@@ -536,13 +541,19 @@ SORT_MAPPING: dict[SortOrder, str] = {
|
||||
SortOrder.OLDEST: "release_year:asc",
|
||||
}
|
||||
|
||||
# Mapping from abstract search type to Hardcover fields parameter
|
||||
SEARCH_TYPE_FIELDS: dict[SearchType, str] = {
|
||||
SearchType.GENERAL: "title,isbns,series_names,author_names,alternative_titles",
|
||||
SearchType.TITLE: "title,alternative_titles",
|
||||
SearchType.AUTHOR: "author_names",
|
||||
# ISBN is handled separately via search_by_isbn()
|
||||
}
|
||||
# `fields` becomes Typesense's `query_by`, but Hardcover keeps `num_typos` and
|
||||
# `query_by_weights` as fixed-length presets per query_type. Passing a different
|
||||
# number of fields than the preset expects makes Typesense reject the whole search,
|
||||
# complaining that the number of num_typos values does not match the number of
|
||||
# query_by fields. So a Book search may only ever narrow to *these five* names --
|
||||
# a shorter list is rejected outright rather than searched, and any weights sent
|
||||
# alongside must match one-for-one.
|
||||
# Weights only bias ranking: a field weighted 0 still matches, so `fields` can no
|
||||
# longer restrict which fields a Book query looks at.
|
||||
BOOK_SEARCH_FIELDS = "title,alternative_titles,author_names,series_names,isbns"
|
||||
BOOK_SEARCH_FIELD_COUNT = 5
|
||||
BOOK_TITLE_WEIGHTS = "5,1,0,0,0"
|
||||
BOOK_TITLE_AUTHOR_WEIGHTS = "5,1,3,0,0"
|
||||
|
||||
SERIES_SEARCH_FIELDS = "name,books,author_name"
|
||||
SERIES_SEARCH_WEIGHTS = "2,1,1"
|
||||
@@ -550,22 +561,28 @@ SERIES_SEARCH_SORT = "_text_match:desc,readers_count:desc"
|
||||
AUTHOR_SUGGESTION_FIELDS = "name,name_personal,alternate_names"
|
||||
AUTHOR_SUGGESTION_WEIGHTS = "4,3,2"
|
||||
AUTHOR_SUGGESTION_SORT = "_text_match:desc,books_count:desc"
|
||||
TITLE_SUGGESTION_FIELDS = "title,alternative_titles"
|
||||
TITLE_SUGGESTION_WEIGHTS = "5,2"
|
||||
TITLE_SUGGESTION_FIELDS = BOOK_SEARCH_FIELDS
|
||||
TITLE_SUGGESTION_WEIGHTS = "5,2,0,0,0"
|
||||
TITLE_SUGGESTION_SORT = "_text_match:desc,users_count:desc"
|
||||
|
||||
# Hardcover forwards `sort` to Typesense's `sort_by` and rejects the whole search
|
||||
# if it does not like the value -- an unknown field, a bare field name with no
|
||||
# direction, more than three keys. A rejected search comes back as HTTP 200 with
|
||||
# no GraphQL errors and a null `results` body, which is otherwise indistinguishable
|
||||
# from "nothing matched". An empty sort is always accepted, so fall back to it and
|
||||
# keep the fallback sticky for a while rather than paying for a doomed request on
|
||||
# every search.
|
||||
SORT_FALLBACK = ""
|
||||
# from "nothing matched"; the reason only shows up in the sibling `error` field,
|
||||
# so every search asks for it. Dropping `sort` from the request is the one shape
|
||||
# Hardcover always accepts -- an empty string is a value like any other and has
|
||||
# been rejected too -- so retry that way and keep the fallback sticky for a while
|
||||
# rather than paying for a doomed request on every search.
|
||||
SORT_FALLBACK_TTL = 900.0
|
||||
_sort_fallback_until = 0.0
|
||||
|
||||
|
||||
def _without_sort(variables: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Drop `sort` entirely so Hardcover applies its own default ordering."""
|
||||
return {key: value for key, value in variables.items() if key != "sort"}
|
||||
|
||||
|
||||
def _search_payload_rejected(result: dict[str, Any] | None) -> bool:
|
||||
"""Report whether Hardcover answered a search with a null results body.
|
||||
|
||||
@@ -580,6 +597,17 @@ def _search_payload_rejected(result: dict[str, Any] | None) -> bool:
|
||||
return root["results"] is None
|
||||
|
||||
|
||||
def _search_rejection_reason(result: dict[str, Any] | None) -> str:
|
||||
"""Return Hardcover's explanation for a rejected search, if it sent one."""
|
||||
if not isinstance(result, dict):
|
||||
return ""
|
||||
root = result.get("search", result)
|
||||
if not isinstance(root, dict):
|
||||
return ""
|
||||
error = root.get("error")
|
||||
return error.strip() if isinstance(error, str) else ""
|
||||
|
||||
|
||||
def _combine_headline_description(headline: str | None, description: str | None) -> str | None:
|
||||
"""Combine headline (tagline) and description into a single description."""
|
||||
if headline and description:
|
||||
@@ -646,7 +674,7 @@ def _normalize_series_position(value: Any) -> float | None:
|
||||
def _normalize_hardcover_api_key(value: object) -> str:
|
||||
"""Normalize Hardcover API keys, stripping copied auth-header prefixes."""
|
||||
normalized_value = normalize_optional_text(value) or ""
|
||||
return normalized_value.removeprefix("Bearer ").strip()
|
||||
return HARDCOVER_BEARER_PREFIX_PATTERN.sub("", normalized_value.strip()).strip()
|
||||
|
||||
|
||||
def _normalize_search_text(value: str) -> str:
|
||||
@@ -1012,13 +1040,15 @@ class HardcoverProvider(MetadataProvider):
|
||||
"""Build search query, fields, and weights based on provided values.
|
||||
|
||||
Returns (query, fields, weights) tuple. Fields/weights are None for general search.
|
||||
A narrowed search still sends all of BOOK_SEARCH_FIELDS -- Hardcover rejects a
|
||||
shorter list outright -- and leans on the weights to rank the wanted field first.
|
||||
"""
|
||||
if author and not title and not series:
|
||||
return author, None, None
|
||||
if title and not author and not series:
|
||||
return title, "title,alternative_titles", "5,1"
|
||||
return title, BOOK_SEARCH_FIELDS, BOOK_TITLE_WEIGHTS
|
||||
if author and title and not series:
|
||||
return f"{title} {author}", "title,alternative_titles,author_names", "5,1,3"
|
||||
return f"{title} {author}", BOOK_SEARCH_FIELDS, BOOK_TITLE_AUTHOR_WEIGHTS
|
||||
return default_query, None, None
|
||||
|
||||
def _detect_list_url(self, query: str) -> tuple[str | None, str] | None:
|
||||
@@ -2384,6 +2414,7 @@ class HardcoverProvider(MetadataProvider):
|
||||
graphql_query = """
|
||||
query SearchBooks($query: String!, $limit: Int!, $page: Int!, $sort: String, $fields: String, $weights: String) {
|
||||
search(query: $query, query_type: "Book", per_page: $limit, page: $page, sort: $sort, fields: $fields, weights: $weights) {
|
||||
error
|
||||
results
|
||||
}
|
||||
}
|
||||
@@ -2392,6 +2423,7 @@ class HardcoverProvider(MetadataProvider):
|
||||
graphql_query = """
|
||||
query SearchBooks($query: String!, $limit: Int!, $page: Int!, $sort: String) {
|
||||
search(query: $query, query_type: "Book", per_page: $limit, page: $page, sort: $sort) {
|
||||
error
|
||||
results
|
||||
}
|
||||
}
|
||||
@@ -2690,33 +2722,42 @@ class HardcoverProvider(MetadataProvider):
|
||||
|
||||
sort = variables.get("sort")
|
||||
if sort and time.monotonic() < _sort_fallback_until:
|
||||
variables = {**variables, "sort": SORT_FALLBACK}
|
||||
variables = _without_sort(variables)
|
||||
sort = None
|
||||
|
||||
result = self._execute_query(query, variables)
|
||||
if not _search_payload_rejected(result):
|
||||
return result
|
||||
|
||||
reason = _search_rejection_reason(result)
|
||||
if not sort:
|
||||
logger.error(
|
||||
"Hardcover rejected this search (query_type=%s, fields=%s) and returned "
|
||||
"no result body",
|
||||
"Hardcover rejected this search (query_type=%s, fields=%s): %s",
|
||||
variables.get("queryType", "Book"),
|
||||
variables.get("fields"),
|
||||
reason or "no error message",
|
||||
)
|
||||
return None
|
||||
|
||||
retry = self._execute_query(query, _without_sort(variables))
|
||||
if _search_payload_rejected(retry):
|
||||
# The sort was not the culprit, so leave sorting alone for other searches.
|
||||
logger.error(
|
||||
"Hardcover rejected this search (query_type=%s, fields=%s) with and without "
|
||||
"a sort order: %s",
|
||||
variables.get("queryType", "Book"),
|
||||
variables.get("fields"),
|
||||
_search_rejection_reason(retry) or reason or "no error message",
|
||||
)
|
||||
return None
|
||||
|
||||
logger.warning(
|
||||
"Hardcover rejected sort '%s'; retrying searches without a sort order for %ss",
|
||||
"Hardcover rejected sort '%s' (%s); dropping the sort order from searches for %ss",
|
||||
sort,
|
||||
reason or "no error message",
|
||||
int(SORT_FALLBACK_TTL),
|
||||
)
|
||||
_sort_fallback_until = time.monotonic() + SORT_FALLBACK_TTL
|
||||
|
||||
retry = self._execute_query(query, {**variables, "sort": SORT_FALLBACK})
|
||||
if _search_payload_rejected(retry):
|
||||
logger.error("Hardcover rejected this search even without a sort order")
|
||||
return None
|
||||
return retry
|
||||
|
||||
def _parse_search_result(self, item: dict) -> BookMetadata | None:
|
||||
@@ -2982,12 +3023,13 @@ def _test_hardcover_connection(current_values: dict[str, Any] | None = None) ->
|
||||
_save_connected_user(None, None)
|
||||
return {"success": False, "message": "API key is required"}
|
||||
|
||||
if key_len < HARDCOVER_API_KEY_MIN_LENGTH:
|
||||
is_prefixed_key = api_key.startswith(HARDCOVER_API_KEY_PREFIX)
|
||||
if not is_prefixed_key and key_len < HARDCOVER_API_KEY_MIN_LENGTH:
|
||||
return {
|
||||
"success": False,
|
||||
"message": (
|
||||
f"API key seems too short ({key_len} chars). "
|
||||
f"Expected {HARDCOVER_API_KEY_MIN_LENGTH}+ chars."
|
||||
f"API key seems too short ({key_len} chars). Expected a key starting "
|
||||
f"with {HARDCOVER_API_KEY_PREFIX} or {HARDCOVER_API_KEY_MIN_LENGTH}+ chars."
|
||||
),
|
||||
}
|
||||
|
||||
@@ -3094,7 +3136,7 @@ def hardcover_settings() -> list[SettingsField]:
|
||||
PasswordField(
|
||||
key="HARDCOVER_API_KEY",
|
||||
label="API Key",
|
||||
description="Get your API key from hardcover.app/account/api",
|
||||
description="Get your API key from hardcover.app/account/api (starts with hc_pat_)",
|
||||
required=True,
|
||||
),
|
||||
ActionButton(
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import re
|
||||
import time
|
||||
from urllib.parse import quote
|
||||
from urllib.parse import quote, quote_plus
|
||||
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
@@ -10,6 +10,7 @@ from bs4 import BeautifulSoup
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.download import http as downloader
|
||||
from shelfmark.release_sources.audiobookbay.utils import normalize_search_punctuation
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
@@ -98,8 +99,10 @@ def _encode_search_query(query: str, *, exact_phrase: bool) -> str:
|
||||
and not (search_query.startswith('"') and search_query.endswith('"'))
|
||||
):
|
||||
search_query = f'"{search_query}"'
|
||||
# Keep ABB-friendly encoding style (spaces as '+') while percent-encoding quotes.
|
||||
return search_query.replace('"', "%22").replace(" ", "+")
|
||||
# Keep ABB's space-as-'+' style, but percent-encode everything else: a bare
|
||||
# '&' would otherwise start a new query parameter, '%' would open an invalid
|
||||
# escape, and a literal '+' would arrive as a space.
|
||||
return quote_plus(search_query)
|
||||
|
||||
|
||||
def _normalize_result_url(url: str, hostname: str) -> str:
|
||||
@@ -153,6 +156,9 @@ def search_audiobookbay(
|
||||
|
||||
"""
|
||||
results = []
|
||||
# ABB matches the stored, untexturized title, so a curly apostrophe reaching
|
||||
# the search returns nothing at all rather than merely ranking worse.
|
||||
query = normalize_search_punctuation(query)
|
||||
rate_limit_delay = _coerce_non_negative_float(config.get("ABB_RATE_LIMIT_DELAY", 1.0), 1.0)
|
||||
session = requests.Session()
|
||||
|
||||
|
||||
@@ -23,7 +23,11 @@ from shelfmark.release_sources import (
|
||||
register_source,
|
||||
)
|
||||
from shelfmark.release_sources.audiobookbay import scraper
|
||||
from shelfmark.release_sources.audiobookbay.utils import normalize_hostname, parse_size
|
||||
from shelfmark.release_sources.audiobookbay.utils import (
|
||||
normalize_hostname,
|
||||
normalize_search_punctuation,
|
||||
parse_size,
|
||||
)
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
MIN_RELEVANCE_QUERY_WORD_LENGTH = 2
|
||||
@@ -227,10 +231,12 @@ class AudiobookBaySource(ReleaseSource):
|
||||
deduped_queries[index + 1].lower(),
|
||||
)
|
||||
|
||||
# Extract query words for relevance checking
|
||||
# Extract query words for relevance checking. Both sides of the
|
||||
# comparison are punctuation-normalized: scraped titles carry the
|
||||
# typographic forms WordPress renders, queries carry the ASCII ones.
|
||||
query_words = {
|
||||
word.lower()
|
||||
for word in query_lower.split()
|
||||
for word in normalize_search_punctuation(query_lower).split()
|
||||
if len(word) > MIN_RELEVANCE_QUERY_WORD_LENGTH
|
||||
}
|
||||
|
||||
@@ -239,7 +245,7 @@ class AudiobookBaySource(ReleaseSource):
|
||||
try:
|
||||
raw_title = result["title"]
|
||||
title, author = _split_title_and_author(raw_title)
|
||||
title_for_filter = raw_title.lower()
|
||||
title_for_filter = normalize_search_punctuation(raw_title).lower()
|
||||
|
||||
# Basic relevance check: ensure title contains at least one query word
|
||||
# This filters out homepage "Latest" feed items that may leak through
|
||||
|
||||
@@ -2,6 +2,63 @@
|
||||
|
||||
import re
|
||||
|
||||
# WordPress texturizes punctuation on output only: a post stored as "The
|
||||
# Stranger's Wife" is rendered as "The Stranger’s Wife". ABB's search matches the
|
||||
# stored value, so a query carrying the typographic form matches nothing -- and
|
||||
# because ABB ANDs its search terms, one such term empties the entire result set.
|
||||
# Book metadata and phone keyboards both hand us the typographic forms, so map
|
||||
# them back before they reach a search or a title comparison.
|
||||
_ASCII_PUNCTUATION = str.maketrans(
|
||||
{
|
||||
# Single quotes
|
||||
"‘": "'", # left single quotation mark
|
||||
"’": "'", # right single quotation mark
|
||||
"‚": "'", # single low-9 quotation mark
|
||||
"‛": "'", # single high-reversed-9 quotation mark
|
||||
"′": "'", # prime
|
||||
"´": "'", # acute accent
|
||||
"`": "'", # grave accent
|
||||
# Double quotes
|
||||
"“": '"', # left double quotation mark
|
||||
"”": '"', # right double quotation mark
|
||||
"„": '"', # double low-9 quotation mark
|
||||
"‟": '"', # double high-reversed-9 quotation mark
|
||||
"″": '"', # double prime
|
||||
# Dashes
|
||||
"‐": "-", # hyphen
|
||||
"‑": "-", # non-breaking hyphen
|
||||
"‒": "-", # figure dash
|
||||
"–": "-", # en dash
|
||||
"—": "-", # em dash
|
||||
"―": "-", # horizontal bar
|
||||
"−": "-", # minus sign
|
||||
"﹘": "-", # small em dash
|
||||
"﹣": "-", # small hyphen-minus
|
||||
"-": "-", # fullwidth hyphen-minus
|
||||
# Ellipsis
|
||||
"…": "...", # horizontal ellipsis
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def normalize_search_punctuation(text: str) -> str:
|
||||
"""Replace typographic punctuation with the ASCII forms ABB stores.
|
||||
|
||||
Each character is mapped individually rather than collapsing runs, so an
|
||||
ASCII "--" is left alone: only characters ABB cannot have stored are
|
||||
rewritten.
|
||||
|
||||
Args:
|
||||
text: A search query, or a scraped title being compared against one.
|
||||
|
||||
Returns:
|
||||
The text with curly quotes, dashes and ellipses mapped to ASCII.
|
||||
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
return text.translate(_ASCII_PUNCTUATION)
|
||||
|
||||
|
||||
def normalize_hostname(raw: str | None) -> str:
|
||||
"""Normalize a user-supplied hostname for URL construction.
|
||||
|
||||
@@ -565,9 +565,15 @@ _CHALLENGE_MARKERS = (
|
||||
|
||||
|
||||
def _looks_like_aa_page(html: str) -> bool:
|
||||
"""Whether ``html`` is recognisably Anna's Archive, or a challenge in front of it."""
|
||||
"""Whether ``html`` is recognisably Anna's Archive itself."""
|
||||
lowered = html.lower()
|
||||
return any(marker in lowered for marker in (*_AA_PAGE_MARKERS, *_CHALLENGE_MARKERS))
|
||||
return any(marker in lowered for marker in _AA_PAGE_MARKERS)
|
||||
|
||||
|
||||
def _looks_like_challenge_page(html: str) -> bool:
|
||||
"""Whether ``html`` is a protection interstitial rather than the site behind it."""
|
||||
lowered = html.lower()
|
||||
return any(marker in lowered for marker in _CHALLENGE_MARKERS)
|
||||
|
||||
|
||||
def _fetch_search_table(url: str, selector: network.AAMirrorSelector) -> tuple[str, Tag | None]:
|
||||
@@ -596,9 +602,22 @@ def _fetch_search_table(url: str, selector: network.AAMirrorSelector) -> tuple[s
|
||||
if table is not None:
|
||||
msg = f"Expected results table tag, got {type(table).__name__}"
|
||||
raise TypeError(msg)
|
||||
if "No files found." in html or _looks_like_aa_page(html):
|
||||
# A real AA response - either genuinely empty, or a shape the caller
|
||||
# should report as drift. Not the mirror's fault.
|
||||
if "No files found." in html:
|
||||
# A real, genuinely empty answer from a healthy mirror.
|
||||
return html, None
|
||||
if _looks_like_challenge_page(html):
|
||||
# The bypass did not actually clear the protection - the interstitial is
|
||||
# what came back. Rotating is pointless (every mirror shares the same
|
||||
# protection) and reporting it as an empty result is worse: the user is
|
||||
# told their query found nothing when the search never ran.
|
||||
msg = (
|
||||
"Anna's Archive answered with an unsolved protection challenge. "
|
||||
"Check that the bypasser is reachable and working."
|
||||
)
|
||||
raise SearchUnavailableError(msg)
|
||||
if _looks_like_aa_page(html):
|
||||
# A real AA response in a shape the caller should report as drift.
|
||||
# Not the mirror's fault.
|
||||
return html, None
|
||||
|
||||
new_base, action = selector.next_mirror_or_rotate_dns(
|
||||
|
||||
@@ -317,6 +317,44 @@ class TestSearchAudiobookbay:
|
||||
assert "s=%22test+query%22" in requested_url
|
||||
assert "cat=undefined%2Cundefined" in requested_url
|
||||
|
||||
@patch("shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page")
|
||||
@patch("shelfmark.release_sources.audiobookbay.scraper.config.get")
|
||||
def test_search_audiobookbay_normalizes_curly_apostrophe(self, mock_config_get, mock_html_get):
|
||||
"""Test curly apostrophes are searched as the ASCII form ABB stores."""
|
||||
mock_config_get.return_value = 0.0
|
||||
mock_html_get.return_value = (SAMPLE_SEARCH_HTML, "https://audiobookbay.lu/?s=x")
|
||||
|
||||
scraper.search_audiobookbay(
|
||||
"the stranger’s wife",
|
||||
max_pages=1,
|
||||
hostname="audiobookbay.lu",
|
||||
)
|
||||
|
||||
requested_url = mock_html_get.call_args.args[0]
|
||||
assert "s=the+stranger%27s+wife" in requested_url
|
||||
assert "%E2%80%99" not in requested_url
|
||||
|
||||
@patch("shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page")
|
||||
@patch("shelfmark.release_sources.audiobookbay.scraper.config.get")
|
||||
def test_search_audiobookbay_percent_encodes_reserved_characters(
|
||||
self, mock_config_get, mock_html_get
|
||||
):
|
||||
"""Test reserved characters cannot break out of the search parameter."""
|
||||
mock_config_get.return_value = 0.0
|
||||
mock_html_get.return_value = (SAMPLE_SEARCH_HTML, "https://audiobookbay.lu/?s=x")
|
||||
|
||||
scraper.search_audiobookbay(
|
||||
"sense & sensibility 100% c++",
|
||||
max_pages=1,
|
||||
hostname="audiobookbay.lu",
|
||||
)
|
||||
|
||||
requested_url = mock_html_get.call_args.args[0]
|
||||
assert "s=sense+%26+sensibility+100%25+c%2B%2B" in requested_url
|
||||
# The only surviving '&' introduces the legacy category parameter.
|
||||
assert requested_url.count("&") == 1
|
||||
assert requested_url.endswith("&cat=undefined%2Cundefined")
|
||||
|
||||
@patch("shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page")
|
||||
@patch("shelfmark.release_sources.audiobookbay.scraper.config.get")
|
||||
def test_search_audiobookbay_always_uses_legacy_category_query(
|
||||
|
||||
@@ -306,6 +306,42 @@ class TestAudiobookBaySource:
|
||||
assert len(results) == 1
|
||||
assert results[0].title == "Test Book by Test Author"
|
||||
|
||||
@patch("shelfmark.release_sources.audiobookbay.source.scraper.search_audiobookbay")
|
||||
def test_search_relevance_filtering_spans_typographic_punctuation(self, mock_search):
|
||||
"""Test an ASCII query still matches the typographic title ABB renders."""
|
||||
mock_search.return_value = [
|
||||
{
|
||||
"title": "The Stranger’s Wife — Anna‑Lou Weatherley",
|
||||
"link": "https://audiobookbay.lu/abss/the-strangers-wife/",
|
||||
"format": "M4B",
|
||||
"size": "259 MB",
|
||||
"language": "English",
|
||||
},
|
||||
]
|
||||
|
||||
source = AudiobookBaySource()
|
||||
book = BookMetadata(
|
||||
provider="test",
|
||||
provider_id="123",
|
||||
title="Stranger's",
|
||||
authors=["Anna-Lou Weatherley"],
|
||||
)
|
||||
# Every query word carries punctuation, so the result survives only when
|
||||
# both sides of the comparison are normalized.
|
||||
plan = ReleaseSearchPlan(
|
||||
languages=["en"],
|
||||
isbn_candidates=[],
|
||||
author="",
|
||||
title_variants=[ReleaseSearchVariant(title="Stranger's", author="")],
|
||||
grouped_title_variants=[],
|
||||
)
|
||||
|
||||
results = source.search(book, plan, content_type="audiobook")
|
||||
|
||||
assert len(results) == 1
|
||||
# The release keeps the title as ABB rendered it; only matching normalizes.
|
||||
assert results[0].title == "The Stranger’s Wife — Anna‑Lou Weatherley"
|
||||
|
||||
@patch("shelfmark.release_sources.audiobookbay.source.scraper.search_audiobookbay")
|
||||
def test_search_result_mapping(self, mock_search):
|
||||
"""Test conversion of scraper results to Release objects."""
|
||||
|
||||
@@ -2,7 +2,52 @@
|
||||
Tests for AudiobookBay utility functions.
|
||||
"""
|
||||
|
||||
from shelfmark.release_sources.audiobookbay.utils import parse_size
|
||||
from shelfmark.release_sources.audiobookbay.utils import normalize_search_punctuation, parse_size
|
||||
|
||||
|
||||
class TestNormalizeSearchPunctuation:
|
||||
"""Tests for the normalize_search_punctuation function."""
|
||||
|
||||
def test_curly_apostrophe_becomes_ascii(self):
|
||||
"""ABB matches the stored ASCII apostrophe, not the rendered curly one."""
|
||||
assert normalize_search_punctuation("The Stranger’s Wife") == "The Stranger's Wife"
|
||||
|
||||
def test_all_single_quote_variants(self):
|
||||
"""Every single-quote lookalike collapses to the ASCII apostrophe."""
|
||||
for variant in ("‘", "’", "‚", "‛", "′", "´", "`"):
|
||||
assert normalize_search_punctuation(f"don{variant}t") == "don't"
|
||||
|
||||
def test_all_double_quote_variants(self):
|
||||
"""Every double-quote lookalike collapses to the ASCII double quote."""
|
||||
for variant in ("“", "”", "„", "‟", "″"):
|
||||
assert normalize_search_punctuation(f"{variant}quoted{variant}") == '"quoted"'
|
||||
|
||||
def test_all_dash_variants(self):
|
||||
"""Every dash lookalike collapses to the ASCII hyphen."""
|
||||
for variant in ("‐", "‑", "‒", "–", "—", "―", "−", "﹘", "﹣", "-"):
|
||||
assert normalize_search_punctuation(f"anna{variant}lou") == "anna-lou"
|
||||
|
||||
def test_ellipsis_expands_to_three_dots(self):
|
||||
"""WordPress renders '...' as a single ellipsis character."""
|
||||
assert normalize_search_punctuation("And Then…") == "And Then..."
|
||||
|
||||
def test_ascii_query_is_unchanged(self):
|
||||
"""A query that is already ASCII passes through untouched."""
|
||||
assert normalize_search_punctuation("The Stranger's Wife") == "The Stranger's Wife"
|
||||
|
||||
def test_ascii_dash_runs_are_not_collapsed(self):
|
||||
"""Only characters ABB cannot have stored are rewritten."""
|
||||
assert normalize_search_punctuation("Book -- Subtitle") == "Book -- Subtitle"
|
||||
|
||||
def test_other_punctuation_is_preserved(self):
|
||||
"""Colons and commas carry search signal and are left alone."""
|
||||
assert normalize_search_punctuation("Weatherley: Book 3, Part 1") == (
|
||||
"Weatherley: Book 3, Part 1"
|
||||
)
|
||||
|
||||
def test_empty_query(self):
|
||||
"""An empty query is returned as-is."""
|
||||
assert normalize_search_punctuation("") == ""
|
||||
|
||||
|
||||
class TestParseSize:
|
||||
|
||||
@@ -17,13 +17,14 @@ import time
|
||||
|
||||
import pytest
|
||||
|
||||
import shelfmark.bypass.cookie_store as cs
|
||||
import shelfmark.bypass.internal_bypasser as ib
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_cookie_store(monkeypatch):
|
||||
monkeypatch.setattr(ib, "_cf_cookies", {})
|
||||
monkeypatch.setattr(ib, "_cf_user_agents", {})
|
||||
monkeypatch.setattr(cs, "_cf_cookies", {})
|
||||
monkeypatch.setattr(cs, "_cf_user_agents", {})
|
||||
|
||||
|
||||
class _Cookie:
|
||||
@@ -39,7 +40,7 @@ class _Cookie:
|
||||
|
||||
|
||||
def _store(cookies, url="https://annas-archive.gl/search"):
|
||||
ib._store_extracted_cookies(url=url, cookies=cookies, user_agent="UA/1.0")
|
||||
cs.store_extracted_cookies(url=url, cookies=cookies, user_agent="UA/1.0")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -83,7 +84,7 @@ def test_cloudflare_cookies_are_unaffected():
|
||||
|
||||
def test_per_check_cookies_are_excluded_even_for_full_session_domains(monkeypatch):
|
||||
"""extract_all exists for Z-Library sessions; it must not resurrect the trio."""
|
||||
monkeypatch.setattr(ib, "_get_full_cookie_domains", lambda: {"annas-archive.gl"})
|
||||
monkeypatch.setattr(cs, "_get_full_cookie_domains", lambda: {"annas-archive.gl"})
|
||||
_store([_Cookie("sessionid", "s"), _Cookie("__ddg9_", "203.0.113.7")])
|
||||
|
||||
stored = ib.get_cf_cookies_for_domain("annas-archive.gl")
|
||||
@@ -111,7 +112,7 @@ def test_all_cookies_expired_returns_empty_so_caller_re_solves():
|
||||
_store([_Cookie("__ddg1_", "dead", expires=past)])
|
||||
|
||||
assert ib.get_cf_cookies_for_domain("annas-archive.gl") == {}
|
||||
assert ib.has_valid_cf_cookies("annas-archive.gl") is False
|
||||
assert cs.has_valid_cf_cookies("annas-archive.gl") is False
|
||||
|
||||
|
||||
def test_unexpired_cookies_are_kept():
|
||||
@@ -143,7 +144,7 @@ def test_expired_cookies_are_pruned_from_the_store():
|
||||
|
||||
ib.get_cf_cookies_for_domain("annas-archive.gl")
|
||||
|
||||
assert set(ib._cf_cookies["annas-archive.gl"]) == {"__ddg1_"}
|
||||
assert set(cs._cf_cookies["annas-archive.gl"]) == {"__ddg1_"}
|
||||
|
||||
|
||||
def test_solve_that_yields_only_per_check_cookies_stores_nothing():
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Tests for the external bypasser flow."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, payload: dict) -> None:
|
||||
@@ -55,6 +57,165 @@ def test_fetch_via_bypasser_posts_expected_payload_and_uses_ssl_verify(monkeypat
|
||||
]
|
||||
|
||||
|
||||
def _stub_solution(monkeypatch, external_bypasser, solution: dict) -> None:
|
||||
"""Answer one bypass with `solution`, with config and SSL stubbed out."""
|
||||
|
||||
def fake_get(key, default=""):
|
||||
values = {
|
||||
"EXT_BYPASSER_URL": "https://bypass.example",
|
||||
"EXT_BYPASSER_PATH": "/v1",
|
||||
"EXT_BYPASSER_TIMEOUT": 60000,
|
||||
}
|
||||
return values.get(key, default)
|
||||
|
||||
monkeypatch.setattr(external_bypasser.config, "get", fake_get)
|
||||
monkeypatch.setattr(
|
||||
external_bypasser.requests,
|
||||
"post",
|
||||
lambda *_a, **_k: _FakeResponse({"status": "ok", "solution": solution}),
|
||||
)
|
||||
monkeypatch.setattr(external_bypasser, "get_ssl_verify", lambda _url: False)
|
||||
|
||||
|
||||
def test_solved_clearance_is_stored_for_reuse(monkeypatch):
|
||||
"""A solve costs tens of seconds of real browser; its clearance must be kept.
|
||||
|
||||
Without this every request paid a 403 plus a full solve, and the file download -
|
||||
which the solver cannot proxy - presented no clearance at all.
|
||||
"""
|
||||
import shelfmark.bypass.cookie_store as cookie_store
|
||||
import shelfmark.bypass.external_bypasser as external_bypasser
|
||||
|
||||
monkeypatch.setattr(cookie_store, "_cf_cookies", {})
|
||||
monkeypatch.setattr(cookie_store, "_cf_user_agents", {})
|
||||
_stub_solution(
|
||||
monkeypatch,
|
||||
external_bypasser,
|
||||
{
|
||||
"response": "<html>ok</html>",
|
||||
"userAgent": "Mozilla/5.0 (solver)",
|
||||
"cookies": [
|
||||
{"name": "__ddg1_", "value": "clearance", "domain": ".annas-archive.gl"},
|
||||
{"name": "__ddg2_", "value": "c2", "domain": ".annas-archive.gl"},
|
||||
# Per-check cookies: kept out of the store, same as the internal path.
|
||||
{"name": "__ddg9_", "value": "203.0.113.7", "domain": ".annas-archive.gl"},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
external_bypasser._fetch_via_bypasser("https://annas-archive.gl/search?q=dune")
|
||||
|
||||
assert cookie_store.get_cf_cookies_for_domain("annas-archive.gl") == {
|
||||
"__ddg1_": "clearance",
|
||||
"__ddg2_": "c2",
|
||||
}
|
||||
# Cloudflare ties clearance to the solving UA, so replaying one without the other fails.
|
||||
assert cookie_store.get_cf_user_agent_for_domain("annas-archive.gl") == "Mozilla/5.0 (solver)"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "shape"),
|
||||
[
|
||||
# Byparr drives Playwright/camoufox, whose cookies spell it "expires".
|
||||
("expires", "playwright"),
|
||||
# FlareSolverr assigns driver.get_cookies() - the WebDriver cookie object,
|
||||
# which spells it "expiry". Reading only "expires" made every FlareSolverr
|
||||
# cookie immortal, so dead clearance was replayed forever.
|
||||
("expiry", "webdriver"),
|
||||
],
|
||||
)
|
||||
def test_expired_solution_cookie_is_not_replayed(monkeypatch, field, shape):
|
||||
import time
|
||||
|
||||
import shelfmark.bypass.cookie_store as cookie_store
|
||||
import shelfmark.bypass.external_bypasser as external_bypasser
|
||||
|
||||
monkeypatch.setattr(cookie_store, "_cf_cookies", {})
|
||||
monkeypatch.setattr(cookie_store, "_cf_user_agents", {})
|
||||
_stub_solution(
|
||||
monkeypatch,
|
||||
external_bypasser,
|
||||
{
|
||||
"response": "<html>ok</html>",
|
||||
"cookies": [{"name": "__ddg1_", "value": "dead", field: int(time.time()) - 60}],
|
||||
},
|
||||
)
|
||||
|
||||
external_bypasser._fetch_via_bypasser("https://annas-archive.gl/search?q=dune")
|
||||
|
||||
assert cookie_store.get_cf_cookies_for_domain("annas-archive.gl") == {}, (
|
||||
f"a dead {shape} cookie was kept for replay"
|
||||
)
|
||||
|
||||
|
||||
def test_solution_cookie_expiry_is_coerced_not_trusted(monkeypatch):
|
||||
"""The solver is not ours; a stringified expiry must be read, not raised on."""
|
||||
import time
|
||||
|
||||
import shelfmark.bypass.cookie_store as cookie_store
|
||||
import shelfmark.bypass.external_bypasser as external_bypasser
|
||||
|
||||
monkeypatch.setattr(cookie_store, "_cf_cookies", {})
|
||||
monkeypatch.setattr(cookie_store, "_cf_user_agents", {})
|
||||
_stub_solution(
|
||||
monkeypatch,
|
||||
external_bypasser,
|
||||
{
|
||||
"response": "<html>ok</html>",
|
||||
"cookies": [
|
||||
{"name": "__ddg1_", "value": "live", "expires": str(int(time.time()) + 3600)},
|
||||
{"name": "__ddg2_", "value": "dead", "expires": str(int(time.time()) - 60)},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
result = external_bypasser._fetch_via_bypasser("https://annas-archive.gl/search?q=dune")
|
||||
|
||||
assert result == "<html>ok</html>"
|
||||
assert cookie_store.get_cf_cookies_for_domain("annas-archive.gl") == {"__ddg1_": "live"}
|
||||
|
||||
|
||||
def test_storing_clearance_can_never_discard_the_solved_page(monkeypatch):
|
||||
"""A solve costs ~30s; a surprise in the cookie shape must not throw it away.
|
||||
|
||||
The store call sits inside the request try/except, whose handler returns None -
|
||||
so without its own guard a raising store turned a good page into a failed fetch
|
||||
and sent the caller round for up to MAX_RETRY more solves.
|
||||
"""
|
||||
import shelfmark.bypass.external_bypasser as external_bypasser
|
||||
|
||||
_stub_solution(
|
||||
monkeypatch,
|
||||
external_bypasser,
|
||||
{"response": "<html>ok</html>", "cookies": [{"name": "__ddg1_", "value": "v"}]},
|
||||
)
|
||||
|
||||
def boom(*_args, **_kwargs):
|
||||
raise TypeError("unexpected cookie shape")
|
||||
|
||||
monkeypatch.setattr(external_bypasser, "store_extracted_cookies", boom)
|
||||
|
||||
assert (
|
||||
external_bypasser._fetch_via_bypasser("https://annas-archive.gl/search?q=dune")
|
||||
== "<html>ok</html>"
|
||||
)
|
||||
|
||||
|
||||
def test_solution_without_cookies_is_still_returned(monkeypatch):
|
||||
"""A solver that returns no cookie list must not break the page fetch."""
|
||||
import shelfmark.bypass.cookie_store as cookie_store
|
||||
import shelfmark.bypass.external_bypasser as external_bypasser
|
||||
|
||||
monkeypatch.setattr(cookie_store, "_cf_cookies", {})
|
||||
monkeypatch.setattr(cookie_store, "_cf_user_agents", {})
|
||||
_stub_solution(monkeypatch, external_bypasser, {"response": "<html>ok</html>"})
|
||||
|
||||
result = external_bypasser._fetch_via_bypasser("https://annas-archive.gl/search?q=dune")
|
||||
|
||||
assert result == "<html>ok</html>"
|
||||
assert cookie_store.get_cf_cookies_for_domain("annas-archive.gl") == {}
|
||||
|
||||
|
||||
def test_get_bypassed_page_retries_and_rotates_selector_between_attempts(monkeypatch):
|
||||
import shelfmark.bypass.external_bypasser as external_bypasser
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import asyncio
|
||||
import json
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -113,7 +115,9 @@ def test_extract_cookies_from_cdp_keeps_full_session_cookies_for_configured_zlib
|
||||
async def evaluate(self, _expr):
|
||||
return "TestUA/1.0"
|
||||
|
||||
monkeypatch.setattr(internal_bypasser, "_get_full_cookie_domains", lambda: {"z-lib.fm"})
|
||||
from shelfmark.bypass import cookie_store
|
||||
|
||||
monkeypatch.setattr(cookie_store, "_get_full_cookie_domains", lambda: {"z-lib.fm"})
|
||||
|
||||
internal_bypasser.clear_cf_cookies()
|
||||
asyncio.run(
|
||||
@@ -165,12 +169,14 @@ def test_extract_cookies_from_cdp_normalizes_session_expiry():
|
||||
)
|
||||
)
|
||||
|
||||
stored = internal_bypasser._cf_cookies.get("example.com", {})
|
||||
from shelfmark.bypass import cookie_store
|
||||
|
||||
stored = cookie_store._cf_cookies.get("example.com", {})
|
||||
assert stored["cf_clearance"]["expiry"] is None
|
||||
assert internal_bypasser.get_cf_cookies_for_domain("example.com") == {"cf_clearance": "abc"}
|
||||
|
||||
# Verify fallback to "expires" key for expiry checks
|
||||
internal_bypasser._cf_cookies["example.com"]["cf_clearance"]["expires"] = int(time.time()) - 10
|
||||
cookie_store._cf_cookies["example.com"]["cf_clearance"]["expires"] = int(time.time()) - 10
|
||||
assert internal_bypasser.get_cf_cookies_for_domain("example.com") == {}
|
||||
|
||||
|
||||
@@ -374,7 +380,9 @@ def test_try_with_cached_cookies_returns_none_on_request_exception(monkeypatch):
|
||||
import shelfmark.bypass.internal_bypasser as internal_bypasser
|
||||
|
||||
internal_bypasser.clear_cf_cookies()
|
||||
internal_bypasser._cf_cookies["example.com"] = {
|
||||
from shelfmark.bypass import cookie_store
|
||||
|
||||
cookie_store._cf_cookies["example.com"] = {
|
||||
"cf_clearance": {
|
||||
"value": "abc",
|
||||
"domain": "example.com",
|
||||
@@ -482,3 +490,239 @@ def test_run_bypass_in_current_process_bounds_its_wait(monkeypatch):
|
||||
|
||||
assert result == "html"
|
||||
assert observed["timeout"] == internal_bypasser._IN_PROCESS_BYPASS_TIMEOUT_SECONDS
|
||||
|
||||
|
||||
def _write_fake_proc_entry(proc_root, pid: int, pgid: int, argv: list[str]) -> None:
|
||||
"""Create a /proc-shaped entry for a fake process."""
|
||||
entry = proc_root / str(pid)
|
||||
entry.mkdir()
|
||||
(entry / "cmdline").write_bytes(b"\0".join(arg.encode() for arg in argv) + b"\0")
|
||||
# pid (comm) state ppid pgrp ... - comm is parenthesised and may contain spaces.
|
||||
(entry / "stat").write_text(f"{pid} (some (odd) name) S 1 {pgid} {pgid} 0 -1 4194304 0 0")
|
||||
|
||||
|
||||
def test_cleanup_only_kills_own_and_abandoned_browser_sessions(monkeypatch, tmp_path):
|
||||
"""Regression test for issue #1231: the sweep used a container-wide `pkill -f chrome`,
|
||||
so every worker that started a bypass killed the browsers the other workers were
|
||||
still driving. Only our own process group and groups whose leader is gone are ours."""
|
||||
import shelfmark.bypass.internal_bypasser as internal_bypasser
|
||||
|
||||
proc_root = tmp_path / "proc"
|
||||
proc_root.mkdir()
|
||||
_write_fake_proc_entry(proc_root, 1000, 1000, ["python", "-m", "shelfmark.bypass"])
|
||||
_write_fake_proc_entry(proc_root, 1001, 1000, ["/usr/bin/chromium", "--headless"])
|
||||
_write_fake_proc_entry(proc_root, 1002, 1000, ["Xvfb", ":99"])
|
||||
# Live sibling session: another worker is solving a challenge with these right now.
|
||||
_write_fake_proc_entry(proc_root, 2000, 2000, ["python", "-m", "shelfmark.bypass"])
|
||||
_write_fake_proc_entry(proc_root, 2001, 2000, ["/usr/bin/chromium", "--headless"])
|
||||
# Abandoned session: its leader (pid 3000) is gone, so its browser really is an orphan.
|
||||
_write_fake_proc_entry(proc_root, 3001, 3000, ["/usr/bin/chromium", "--headless"])
|
||||
|
||||
killed: list[int] = []
|
||||
|
||||
monkeypatch.setattr(internal_bypasser.env, "DOCKERMODE", True)
|
||||
monkeypatch.setattr(internal_bypasser, "_PROC_ROOT", proc_root)
|
||||
monkeypatch.setattr(internal_bypasser.os, "getpid", lambda: 1000)
|
||||
monkeypatch.setattr(internal_bypasser.os, "getpgrp", lambda: 1000)
|
||||
monkeypatch.setattr(internal_bypasser.os, "kill", lambda pid, _sig: killed.append(pid))
|
||||
monkeypatch.setattr(internal_bypasser.time, "sleep", lambda _seconds: None)
|
||||
|
||||
assert internal_bypasser._cleanup_orphan_processes() == 3
|
||||
assert sorted(killed) == [1001, 1002, 3001]
|
||||
|
||||
|
||||
def test_cleanup_is_skipped_without_proc(monkeypatch, tmp_path):
|
||||
"""Without /proc there is no way to tell sessions apart, so kill nothing."""
|
||||
import shelfmark.bypass.internal_bypasser as internal_bypasser
|
||||
|
||||
monkeypatch.setattr(internal_bypasser.env, "DOCKERMODE", True)
|
||||
monkeypatch.setattr(internal_bypasser, "_PROC_ROOT", tmp_path / "missing")
|
||||
monkeypatch.setattr(
|
||||
internal_bypasser.os, "kill", lambda *_args: pytest.fail("must not kill anything")
|
||||
)
|
||||
|
||||
assert internal_bypasser._cleanup_orphan_processes() == 0
|
||||
|
||||
|
||||
class _FakeHelperStdin:
|
||||
"""The request pipe: a write is how the helper receives one request."""
|
||||
|
||||
def __init__(self, process):
|
||||
self._process = process
|
||||
self.closed = False
|
||||
|
||||
def write(self, data):
|
||||
self._process.serve(data)
|
||||
|
||||
def flush(self):
|
||||
return None
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
class _FakeHelperProcess:
|
||||
"""Stand-in for the bypass helper subprocess.
|
||||
|
||||
The helper serves one request per line of stdin and answers by writing the result file
|
||||
the request named, so that is what this fakes: a write produces an answer.
|
||||
"""
|
||||
|
||||
def __init__(self, *_args, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
self.pid = 4242
|
||||
self.returncode = None
|
||||
self.answers = True
|
||||
self.killed = False
|
||||
self.waited = False
|
||||
self.stdin = _FakeHelperStdin(self)
|
||||
self.requests: list[dict] = []
|
||||
|
||||
def serve(self, payload):
|
||||
request = json.loads(payload)
|
||||
self.requests.append(request)
|
||||
if not self.answers:
|
||||
return
|
||||
result = {"ok": True, "html": "<html>solved</html>", "cookies": {}, "user_agents": {}}
|
||||
Path(request["result_path"]).write_text(json.dumps(result), encoding="utf-8")
|
||||
|
||||
def poll(self):
|
||||
return self.returncode
|
||||
|
||||
def kill(self):
|
||||
self.killed = True
|
||||
self.returncode = -9
|
||||
|
||||
def wait(self, timeout=None):
|
||||
self.waited = True
|
||||
if self.returncode is None:
|
||||
self.returncode = 0
|
||||
return self.returncode
|
||||
|
||||
|
||||
def _patch_helper_subprocess(monkeypatch, internal_bypasser, process, killed_groups):
|
||||
monkeypatch.setattr(internal_bypasser.subprocess, "Popen", lambda *a, **kw: process(*a, **kw))
|
||||
monkeypatch.setattr(internal_bypasser.network, "get_dns_config", dict)
|
||||
monkeypatch.setattr(
|
||||
internal_bypasser.os, "killpg", lambda pgid, _sig: killed_groups.append(pgid)
|
||||
)
|
||||
# A fresh helper per test: the module-level one is shared, and a process parked by one
|
||||
# test would be handed to the next.
|
||||
helper = internal_bypasser._BypassHelper()
|
||||
monkeypatch.setattr(internal_bypasser._BypassHelper, "_idle_timeout", lambda _self: 0.0)
|
||||
monkeypatch.setattr(internal_bypasser, "_BYPASS_HELPER", helper)
|
||||
return helper
|
||||
|
||||
|
||||
def test_helper_runs_in_its_own_session_and_is_torn_down(monkeypatch):
|
||||
"""Regression test for issue #1231: the helper's Chrome and Xvfb must belong to the
|
||||
helper's own process group, and the whole group must die with it - otherwise the
|
||||
leftovers break the next worker's browser and can only be cleared by a sweep broad
|
||||
enough to kill a concurrent worker's browser too.
|
||||
|
||||
The helper outlives a single request, so the teardown happens when it is dropped rather
|
||||
than after every solve. Each bypass still closes its own browser, so what survives in
|
||||
between is the process, not a Chrome.
|
||||
"""
|
||||
import shelfmark.bypass.internal_bypasser as internal_bypasser
|
||||
|
||||
processes: list[_FakeHelperProcess] = []
|
||||
killed_groups: list[int] = []
|
||||
|
||||
def _make_process(*args, **kwargs):
|
||||
process = _FakeHelperProcess(*args, **kwargs)
|
||||
processes.append(process)
|
||||
return process
|
||||
|
||||
helper = _patch_helper_subprocess(monkeypatch, internal_bypasser, _make_process, killed_groups)
|
||||
|
||||
assert internal_bypasser._get_via_subprocess("https://example.com", 1) == "<html>solved</html>"
|
||||
assert processes[0].kwargs["start_new_session"] is True
|
||||
assert killed_groups == [], "the helper was torn down after a single request"
|
||||
|
||||
helper._discard()
|
||||
|
||||
assert killed_groups == [processes[0].pid]
|
||||
|
||||
|
||||
def test_helper_serves_a_second_request_without_respawning(monkeypatch):
|
||||
"""The interpreter start and imports are paid once, not per protected request."""
|
||||
import shelfmark.bypass.internal_bypasser as internal_bypasser
|
||||
|
||||
processes: list[_FakeHelperProcess] = []
|
||||
killed_groups: list[int] = []
|
||||
|
||||
def _make_process(*args, **kwargs):
|
||||
process = _FakeHelperProcess(*args, **kwargs)
|
||||
processes.append(process)
|
||||
return process
|
||||
|
||||
_patch_helper_subprocess(monkeypatch, internal_bypasser, _make_process, killed_groups)
|
||||
|
||||
internal_bypasser._get_via_subprocess("https://example.com/one", 1)
|
||||
internal_bypasser._get_via_subprocess("https://example.com/two", 1)
|
||||
|
||||
assert len(processes) == 1
|
||||
assert [request["url"] for request in processes[0].requests] == [
|
||||
"https://example.com/one",
|
||||
"https://example.com/two",
|
||||
]
|
||||
|
||||
|
||||
def test_helper_timeout_kills_the_whole_session(monkeypatch):
|
||||
"""A timed-out solve must not leave a live browser behind for the next worker."""
|
||||
import shelfmark.bypass.internal_bypasser as internal_bypasser
|
||||
|
||||
processes: list[_FakeHelperProcess] = []
|
||||
killed_groups: list[int] = []
|
||||
|
||||
def _make_process(*args, **kwargs):
|
||||
process = _FakeHelperProcess(*args, **kwargs)
|
||||
process.answers = False # accepts the request, never writes a result
|
||||
processes.append(process)
|
||||
return process
|
||||
|
||||
_patch_helper_subprocess(monkeypatch, internal_bypasser, _make_process, killed_groups)
|
||||
monkeypatch.setattr(internal_bypasser, "_BYPASS_SUBPROCESS_TIMEOUT_SECONDS", 0.1)
|
||||
|
||||
with pytest.raises(TimeoutError):
|
||||
internal_bypasser._get_via_subprocess("https://example.com", 1)
|
||||
|
||||
assert killed_groups == [processes[0].pid]
|
||||
assert processes[0].killed is True
|
||||
|
||||
|
||||
def test_helper_takes_the_browser_down_when_its_parent_dies(monkeypatch):
|
||||
"""Cleanup only reclaims process groups whose leader is gone (#1231), so an orphaned
|
||||
helper must not sit there holding a browser no later bypass is allowed to touch."""
|
||||
import shelfmark.bypass.internal_bypasser as internal_bypasser
|
||||
|
||||
terminated: list[str] = []
|
||||
|
||||
monkeypatch.setattr(internal_bypasser.os, "getppid", lambda: 1)
|
||||
monkeypatch.setattr(
|
||||
internal_bypasser, "_terminate_own_session", lambda: terminated.append("terminated")
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
internal_bypasser.time, "sleep", lambda _seconds: pytest.fail("should not wait")
|
||||
)
|
||||
|
||||
internal_bypasser._watch_parent_process(999, interval=0.0)
|
||||
|
||||
assert terminated == ["terminated"]
|
||||
|
||||
|
||||
def test_helper_watchdog_waits_while_its_parent_is_alive(monkeypatch):
|
||||
"""The watchdog must only fire on a changed ppid, not on every poll."""
|
||||
import shelfmark.bypass.internal_bypasser as internal_bypasser
|
||||
|
||||
ppids = iter([999, 999, 1])
|
||||
sleeps: list[float] = []
|
||||
|
||||
monkeypatch.setattr(internal_bypasser.os, "getppid", lambda: next(ppids))
|
||||
monkeypatch.setattr(internal_bypasser, "_terminate_own_session", lambda: None)
|
||||
monkeypatch.setattr(internal_bypasser.time, "sleep", sleeps.append)
|
||||
|
||||
internal_bypasser._watch_parent_process(999, interval=0.5)
|
||||
|
||||
assert sleeps == [0.5, 0.5]
|
||||
|
||||
@@ -0,0 +1,658 @@
|
||||
"""Tests for keeping the bypass helper process alive between requests.
|
||||
|
||||
The browser is deliberately not kept: every bypass starts and closes its own Chrome. What
|
||||
survives is the helper process, whose interpreter start and imports are pure overhead.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class _FakeStdin:
|
||||
def __init__(self) -> None:
|
||||
self.closed = False
|
||||
self.written: list[str] = []
|
||||
|
||||
def write(self, data: str) -> None:
|
||||
if self.closed:
|
||||
raise BrokenPipeError("stdin is closed")
|
||||
self.written.append(data)
|
||||
|
||||
def flush(self) -> None:
|
||||
return None
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
class _FakeProc:
|
||||
"""Enough of subprocess.Popen for the helper's process bookkeeping."""
|
||||
|
||||
_next_pid = 90001
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.stdin = _FakeStdin()
|
||||
self.returncode: int | None = None
|
||||
self.waited = False
|
||||
# A pid nothing may actually be signalled by: _terminate_helper_session is patched
|
||||
# out in these tests, and a stray killpg on a live pid would take out the test run.
|
||||
type(self)._next_pid += 1
|
||||
self.pid = type(self)._next_pid
|
||||
|
||||
def poll(self) -> int | None:
|
||||
return self.returncode
|
||||
|
||||
def wait(self, timeout: float | None = None) -> int:
|
||||
self.waited = True
|
||||
if self.returncode is None:
|
||||
self.returncode = 0
|
||||
return self.returncode
|
||||
|
||||
def kill(self) -> None:
|
||||
self.returncode = -9
|
||||
|
||||
|
||||
def _helper_with_fake_spawn(monkeypatch, procs: list[_FakeProc], terminated=None):
|
||||
"""Build a helper that hands out fake processes and never arms a real timer."""
|
||||
import shelfmark.bypass.internal_bypasser as internal_bypasser
|
||||
|
||||
def _spawn(_self) -> _FakeProc:
|
||||
proc = _FakeProc()
|
||||
procs.append(proc)
|
||||
return proc
|
||||
|
||||
def _terminate(proc) -> None:
|
||||
if terminated is not None:
|
||||
terminated.append(proc)
|
||||
|
||||
monkeypatch.setattr(internal_bypasser._BypassHelper, "_spawn", _spawn)
|
||||
monkeypatch.setattr(internal_bypasser._BypassHelper, "_idle_timeout", lambda _self: 0.0)
|
||||
monkeypatch.setattr(internal_bypasser, "_terminate_helper_session", _terminate)
|
||||
return internal_bypasser._BypassHelper()
|
||||
|
||||
|
||||
def _answered_payload(tmp_path, name: str = "result.json") -> dict:
|
||||
"""A request whose result file already exists, so the helper resolves immediately."""
|
||||
result_path = tmp_path / name
|
||||
result_path.write_text(json.dumps({"ok": True, "html": "<html/>"}), encoding="utf-8")
|
||||
return {"url": "https://example.com", "retry": 1, "result_path": str(result_path)}
|
||||
|
||||
|
||||
def test_helper_serves_consecutive_requests_from_one_process(monkeypatch, tmp_path):
|
||||
"""The point of the whole thing: request two and three must not re-pay the spawn."""
|
||||
procs: list[_FakeProc] = []
|
||||
helper = _helper_with_fake_spawn(monkeypatch, procs)
|
||||
|
||||
for i in range(3):
|
||||
result = helper.run(_answered_payload(tmp_path, f"r{i}.json"), timeout=5, cancel_flag=None)
|
||||
assert result["ok"] is True
|
||||
|
||||
assert len(procs) == 1, "each request spawned its own helper"
|
||||
assert len(procs[0].stdin.written) == 3
|
||||
assert all(line.endswith("\n") for line in procs[0].stdin.written), (
|
||||
"requests must be newline-delimited or the helper's loop cannot split them"
|
||||
)
|
||||
|
||||
|
||||
def test_helper_respawns_after_the_previous_one_died(monkeypatch, tmp_path):
|
||||
"""A helper can be reaped while idle; the next request must not fail on it."""
|
||||
procs: list[_FakeProc] = []
|
||||
helper = _helper_with_fake_spawn(monkeypatch, procs)
|
||||
|
||||
helper.run(_answered_payload(tmp_path, "a.json"), timeout=5, cancel_flag=None)
|
||||
procs[0].returncode = 1 # died between requests
|
||||
|
||||
result = helper.run(_answered_payload(tmp_path, "b.json"), timeout=5, cancel_flag=None)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert len(procs) == 2
|
||||
|
||||
|
||||
def test_helper_retries_once_when_the_pipe_breaks_on_write(monkeypatch, tmp_path):
|
||||
"""poll() can still say alive when the far end is already gone."""
|
||||
procs: list[_FakeProc] = []
|
||||
helper = _helper_with_fake_spawn(monkeypatch, procs)
|
||||
|
||||
helper.run(_answered_payload(tmp_path, "a.json"), timeout=5, cancel_flag=None)
|
||||
procs[0].stdin.closed = True # pipe gone, but poll() still reports running
|
||||
|
||||
result = helper.run(_answered_payload(tmp_path, "b.json"), timeout=5, cancel_flag=None)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert len(procs) == 2
|
||||
|
||||
|
||||
def test_helper_reports_a_helper_that_exits_without_answering(monkeypatch, tmp_path):
|
||||
import shelfmark.bypass.internal_bypasser as internal_bypasser
|
||||
|
||||
procs: list[_FakeProc] = []
|
||||
helper = _helper_with_fake_spawn(monkeypatch, procs)
|
||||
|
||||
payload = {
|
||||
"url": "https://example.com",
|
||||
"retry": 1,
|
||||
"result_path": str(tmp_path / "never-written.json"),
|
||||
}
|
||||
|
||||
def _die_on_write(_self, proc, _line) -> None:
|
||||
proc.returncode = 3
|
||||
|
||||
monkeypatch.setattr(internal_bypasser._BypassHelper, "_write", _die_on_write)
|
||||
|
||||
with pytest.raises(RuntimeError, match="exited without a result"):
|
||||
helper.run(payload, timeout=5, cancel_flag=None)
|
||||
|
||||
|
||||
def test_helper_times_out_and_discards_the_wedged_process(monkeypatch, tmp_path):
|
||||
procs: list[_FakeProc] = []
|
||||
helper = _helper_with_fake_spawn(monkeypatch, procs)
|
||||
|
||||
payload = {
|
||||
"url": "https://example.com",
|
||||
"retry": 1,
|
||||
"result_path": str(tmp_path / "never-written.json"),
|
||||
}
|
||||
|
||||
with pytest.raises(TimeoutError):
|
||||
helper.run(payload, timeout=0.05, cancel_flag=None)
|
||||
|
||||
assert helper._proc is None, "a wedged helper must not be handed to the next request"
|
||||
|
||||
|
||||
def test_idle_reaper_rearms_when_work_arrived_while_it_waited(monkeypatch, tmp_path):
|
||||
"""The timer fires on its own thread and can lose the race against a new request."""
|
||||
import time
|
||||
|
||||
import shelfmark.bypass.internal_bypasser as internal_bypasser
|
||||
|
||||
procs: list[_FakeProc] = []
|
||||
helper = _helper_with_fake_spawn(monkeypatch, procs)
|
||||
helper.run(_answered_payload(tmp_path), timeout=5, cancel_flag=None)
|
||||
|
||||
rearmed: list[bool] = []
|
||||
monkeypatch.setattr(internal_bypasser._BypassHelper, "_idle_timeout", lambda _self: 3600.0)
|
||||
monkeypatch.setattr(
|
||||
internal_bypasser._BypassHelper, "_arm_idle_timer", lambda _self: rearmed.append(True)
|
||||
)
|
||||
helper._last_used = time.monotonic()
|
||||
|
||||
helper._reap_if_idle()
|
||||
|
||||
assert rearmed == [True]
|
||||
assert helper._proc is not None, "helper was killed despite recent work"
|
||||
|
||||
|
||||
def test_idle_reaper_closes_a_genuinely_idle_helper(monkeypatch, tmp_path):
|
||||
import time
|
||||
|
||||
import shelfmark.bypass.internal_bypasser as internal_bypasser
|
||||
|
||||
procs: list[_FakeProc] = []
|
||||
helper = _helper_with_fake_spawn(monkeypatch, procs)
|
||||
helper.run(_answered_payload(tmp_path), timeout=5, cancel_flag=None)
|
||||
|
||||
monkeypatch.setattr(internal_bypasser._BypassHelper, "_idle_timeout", lambda _self: 60.0)
|
||||
helper._last_used = time.monotonic() - 120
|
||||
|
||||
helper._reap_if_idle()
|
||||
|
||||
assert helper._proc is None
|
||||
assert procs[0].stdin.closed
|
||||
|
||||
|
||||
def test_discard_tears_down_the_whole_session(monkeypatch, tmp_path):
|
||||
"""Dropping the helper must reach its browser tree, not just the helper itself.
|
||||
|
||||
The helper is a session leader (start_new_session), so a Chrome left behind by one
|
||||
killed mid-bypass would keep a process group alive that the cleanup sweep is then not
|
||||
allowed to reclaim - the leak #1231 was about.
|
||||
"""
|
||||
procs: list[_FakeProc] = []
|
||||
terminated: list[_FakeProc] = []
|
||||
helper = _helper_with_fake_spawn(monkeypatch, procs, terminated)
|
||||
|
||||
helper.run(_answered_payload(tmp_path), timeout=5, cancel_flag=None)
|
||||
helper._discard()
|
||||
|
||||
assert terminated == [procs[0]]
|
||||
|
||||
|
||||
def test_helper_asks_before_it_kills(monkeypatch, tmp_path):
|
||||
"""An idle helper should get to exit on its own; the kill is the fallback."""
|
||||
procs: list[_FakeProc] = []
|
||||
helper = _helper_with_fake_spawn(monkeypatch, procs)
|
||||
|
||||
helper.run(_answered_payload(tmp_path), timeout=5, cancel_flag=None)
|
||||
helper._discard()
|
||||
|
||||
assert procs[0].stdin.closed, "stdin must be closed to end the helper's request loop"
|
||||
assert procs[0].returncode == 0, "an idle helper should have exited on its own"
|
||||
|
||||
|
||||
def _bypass_with_recorded_driver(monkeypatch, get_impl):
|
||||
"""Wire up a bypass whose browser creation and closing are observable."""
|
||||
import shelfmark.bypass.internal_bypasser as internal_bypasser
|
||||
|
||||
driver = object()
|
||||
closed: list[object] = []
|
||||
|
||||
async def _create(_url):
|
||||
return driver
|
||||
|
||||
async def _close(drv):
|
||||
closed.append(drv)
|
||||
|
||||
monkeypatch.setattr(internal_bypasser, "_create_cdp_browser", _create)
|
||||
monkeypatch.setattr(internal_bypasser, "_get", get_impl)
|
||||
monkeypatch.setattr(internal_bypasser, "_close_cdp_driver", _close)
|
||||
return driver, closed
|
||||
|
||||
|
||||
def test_successful_bypass_closes_its_browser(monkeypatch):
|
||||
"""A living helper must not accumulate browsers: each bypass ends with Chrome gone."""
|
||||
import shelfmark.bypass.internal_bypasser as internal_bypasser
|
||||
|
||||
async def _get(_url, _driver, _cancel=None):
|
||||
return "<html>ok</html>"
|
||||
|
||||
driver, closed = _bypass_with_recorded_driver(monkeypatch, _get)
|
||||
|
||||
result = internal_bypasser._run_bypass_in_current_process("https://example.com", 1)
|
||||
|
||||
assert result == "<html>ok</html>"
|
||||
assert closed == [driver]
|
||||
|
||||
|
||||
def test_failed_bypass_closes_its_browser(monkeypatch):
|
||||
"""The same has to hold when the bypass raises on its way out."""
|
||||
import shelfmark.bypass.internal_bypasser as internal_bypasser
|
||||
|
||||
async def _get(_url, _driver, _cancel=None):
|
||||
raise internal_bypasser.BypassCancelledError("cancelled")
|
||||
|
||||
driver, closed = _bypass_with_recorded_driver(monkeypatch, _get)
|
||||
|
||||
with pytest.raises(internal_bypasser.BypassCancelledError):
|
||||
internal_bypasser._run_bypass_in_current_process("https://example.com", 1)
|
||||
|
||||
assert closed == [driver]
|
||||
|
||||
|
||||
def test_child_process_serves_every_line_it_is_given(monkeypatch, tmp_path):
|
||||
"""One helper, several requests: the loop is what saves the repeated process start."""
|
||||
import io
|
||||
|
||||
import shelfmark.bypass.internal_bypasser as internal_bypasser
|
||||
|
||||
urls: list[str] = []
|
||||
|
||||
def _fake_get(url, retry=None, cancel_flag=None):
|
||||
urls.append(url)
|
||||
return f"<html>{url}</html>"
|
||||
|
||||
requests = [
|
||||
{"url": "https://example.com/one", "retry": 1, "result_path": str(tmp_path / "1.json")},
|
||||
{"url": "https://example.com/two", "retry": 1, "result_path": str(tmp_path / "2.json")},
|
||||
]
|
||||
stdin = io.StringIO("\n".join(json.dumps(request) for request in requests) + "\n")
|
||||
|
||||
monkeypatch.setattr(internal_bypasser, "get", _fake_get)
|
||||
monkeypatch.setattr(internal_bypasser.sys, "stdin", stdin)
|
||||
|
||||
assert internal_bypasser._run_child_process() == 0
|
||||
assert urls == ["https://example.com/one", "https://example.com/two"]
|
||||
|
||||
for index, request in enumerate(requests, start=1):
|
||||
result = json.loads((tmp_path / f"{index}.json").read_text(encoding="utf-8"))
|
||||
assert result["ok"] is True
|
||||
assert result["html"] == f"<html>{request['url']}</html>"
|
||||
|
||||
|
||||
def test_child_process_keeps_serving_after_a_failed_request(monkeypatch, tmp_path):
|
||||
"""One failing URL must not take the helper - and everything queued - down."""
|
||||
import io
|
||||
|
||||
import shelfmark.bypass.internal_bypasser as internal_bypasser
|
||||
|
||||
def _fake_get(url, retry=None, cancel_flag=None):
|
||||
if url.endswith("boom"):
|
||||
raise RuntimeError("bypass exploded")
|
||||
return "<html>ok</html>"
|
||||
|
||||
requests = [
|
||||
{"url": "https://example.com/boom", "retry": 1, "result_path": str(tmp_path / "1.json")},
|
||||
{"url": "https://example.com/fine", "retry": 1, "result_path": str(tmp_path / "2.json")},
|
||||
]
|
||||
stdin = io.StringIO("\n".join(json.dumps(request) for request in requests) + "\n")
|
||||
|
||||
monkeypatch.setattr(internal_bypasser, "get", _fake_get)
|
||||
monkeypatch.setattr(internal_bypasser.sys, "stdin", stdin)
|
||||
|
||||
assert internal_bypasser._run_child_process() == 0
|
||||
|
||||
failed = json.loads((tmp_path / "1.json").read_text(encoding="utf-8"))
|
||||
assert failed["ok"] is False
|
||||
assert failed["error"] == "bypass exploded"
|
||||
|
||||
served = json.loads((tmp_path / "2.json").read_text(encoding="utf-8"))
|
||||
assert served["ok"] is True
|
||||
|
||||
|
||||
def test_result_file_becomes_visible_only_when_complete(tmp_path):
|
||||
"""The parent treats the file's existence as the answer, so no partial writes."""
|
||||
import shelfmark.bypass.internal_bypasser as internal_bypasser
|
||||
|
||||
result_path = tmp_path / "result.json"
|
||||
internal_bypasser._publish_result(result_path, {"ok": True, "html": "<html/>"})
|
||||
|
||||
assert json.loads(result_path.read_text(encoding="utf-8"))["ok"] is True
|
||||
assert list(tmp_path.iterdir()) == [result_path], "temporary file was left behind"
|
||||
|
||||
|
||||
def test_child_bypass_runs_on_the_long_lived_worker_loop(monkeypatch):
|
||||
"""A helper serving many requests must not build and close a loop per bypass.
|
||||
|
||||
asyncio.run() owns the loop for one call and closes it on the way out, which is why the
|
||||
child goes through the worker unconditionally: one loop for the process's lifetime.
|
||||
"""
|
||||
import shelfmark.bypass.internal_bypasser as internal_bypasser
|
||||
|
||||
monkeypatch.setenv("SHELFMARK_INTERNAL_BYPASSER_CHILD", "1")
|
||||
|
||||
loops: list[asyncio.AbstractEventLoop] = []
|
||||
|
||||
async def _record_loop(_url, _driver, _cancel=None):
|
||||
loops.append(asyncio.get_running_loop())
|
||||
return "<html>ok</html>"
|
||||
|
||||
_bypass_with_recorded_driver(monkeypatch, _record_loop)
|
||||
|
||||
internal_bypasser._run_bypass_in_current_process("https://example.com", 1)
|
||||
internal_bypasser._run_bypass_in_current_process("https://example.com", 1)
|
||||
|
||||
assert len(loops) == 2
|
||||
assert loops[0] is loops[1], "second bypass ran on a different loop than the first"
|
||||
assert not loops[0].is_closed()
|
||||
|
||||
|
||||
def test_child_bypass_carries_its_own_deadline(monkeypatch):
|
||||
"""The child bounds itself, rather than relying only on the parent's deadline."""
|
||||
import shelfmark.bypass.internal_bypasser as internal_bypasser
|
||||
|
||||
monkeypatch.setenv("SHELFMARK_INTERNAL_BYPASSER_CHILD", "1")
|
||||
|
||||
timeouts: list[float | None] = []
|
||||
real_run = internal_bypasser._CDP_WORKER.run
|
||||
|
||||
def _record_timeout(coro, timeout=None):
|
||||
timeouts.append(timeout)
|
||||
return real_run(coro, timeout=timeout)
|
||||
|
||||
async def _get(_url, _driver, _cancel=None):
|
||||
return "<html>ok</html>"
|
||||
|
||||
_bypass_with_recorded_driver(monkeypatch, _get)
|
||||
monkeypatch.setattr(internal_bypasser._CDP_WORKER, "run", _record_timeout)
|
||||
|
||||
internal_bypasser._run_bypass_in_current_process("https://example.com", 1)
|
||||
|
||||
assert timeouts == [internal_bypasser._CHILD_BYPASS_TIMEOUT_SECONDS]
|
||||
|
||||
|
||||
def test_child_deadline_leaves_the_parent_room_to_hear_the_answer(monkeypatch):
|
||||
"""If the parent gave up first it could only kill the helper, losing a warm process."""
|
||||
import shelfmark.bypass.internal_bypasser as internal_bypasser
|
||||
|
||||
# The child's worst case is its deadline plus the grace it is given to close the
|
||||
# browser after that deadline cancels the bypass, and all of it has to fit inside the
|
||||
# parent's wait - otherwise the parent gives up first and kills a helper that was
|
||||
# about to answer.
|
||||
assert (
|
||||
internal_bypasser._CHILD_BYPASS_TIMEOUT_SECONDS
|
||||
+ internal_bypasser._CDP_UNWIND_GRACE_SECONDS
|
||||
< internal_bypasser._BYPASS_SUBPROCESS_TIMEOUT_SECONDS
|
||||
)
|
||||
|
||||
|
||||
def test_in_process_bypass_keeps_the_parents_budget(monkeypatch):
|
||||
"""Non-Docker installs run in-process, where there is no helper to outlive anything."""
|
||||
import shelfmark.bypass.internal_bypasser as internal_bypasser
|
||||
|
||||
monkeypatch.delenv("SHELFMARK_INTERNAL_BYPASSER_CHILD", raising=False)
|
||||
|
||||
timeouts: list[float | None] = []
|
||||
real_run = internal_bypasser._CDP_WORKER.run
|
||||
|
||||
def _record_timeout(coro, timeout=None):
|
||||
timeouts.append(timeout)
|
||||
return real_run(coro, timeout=timeout)
|
||||
|
||||
async def _get(_url, _driver, _cancel=None):
|
||||
return "<html>ok</html>"
|
||||
|
||||
_bypass_with_recorded_driver(monkeypatch, _get)
|
||||
monkeypatch.setattr(internal_bypasser._CDP_WORKER, "run", _record_timeout)
|
||||
|
||||
internal_bypasser._run_bypass_in_current_process("https://example.com", 1)
|
||||
|
||||
assert timeouts == [internal_bypasser._IN_PROCESS_BYPASS_TIMEOUT_SECONDS]
|
||||
|
||||
|
||||
def test_timed_out_bypass_finishes_unwinding_before_the_call_returns():
|
||||
"""A helper serving the next request must not race the browser teardown of the last.
|
||||
|
||||
The deadline cancels the bypass, but cancelling from the calling thread only schedules
|
||||
that - it returns while `finally: await _close_cdp_driver(driver)` is still running.
|
||||
In a helper that now outlives the request, the next bypass would open its Chrome on the
|
||||
same loop while the abandoned one was still closing its own, sharing the DISPLAY
|
||||
globals and one process group.
|
||||
"""
|
||||
import shelfmark.bypass.internal_bypasser as internal_bypasser
|
||||
|
||||
events: list[str] = []
|
||||
|
||||
async def _wedged():
|
||||
try:
|
||||
await asyncio.sleep(30)
|
||||
finally:
|
||||
# Teardown that yields, the way closing websockets and Chrome does.
|
||||
await asyncio.sleep(0.05)
|
||||
events.append("browser closed")
|
||||
|
||||
with pytest.raises(TimeoutError):
|
||||
internal_bypasser._CDP_WORKER.run(_wedged(), timeout=0.1)
|
||||
|
||||
assert events == ["browser closed"], "run() returned before the bypass had unwound"
|
||||
|
||||
|
||||
def test_unwind_that_wedges_does_not_hold_the_caller_forever(monkeypatch):
|
||||
"""The grace is a bound, not a promise: cleanup can hang on a dead browser too."""
|
||||
import shelfmark.bypass.internal_bypasser as internal_bypasser
|
||||
|
||||
monkeypatch.setattr(internal_bypasser, "_CDP_UNWIND_GRACE_SECONDS", 0.1)
|
||||
|
||||
async def _wedged_on_both_ends():
|
||||
try:
|
||||
await asyncio.sleep(30)
|
||||
finally:
|
||||
await asyncio.sleep(30)
|
||||
|
||||
with pytest.raises(TimeoutError):
|
||||
internal_bypasser._CDP_WORKER.run(_wedged_on_both_ends(), timeout=0.1)
|
||||
|
||||
|
||||
def test_cancelling_does_not_wait_out_the_shutdown_grace(monkeypatch, tmp_path):
|
||||
"""The grace only helps a helper that can still read its stdin.
|
||||
|
||||
One dropped mid-bypass is blocked inside the solve and will never reach its read loop,
|
||||
so waiting it out cannot end in anything but the kill - while the user who asked to
|
||||
cancel, and every bypass queued behind them on LOCKED, waits for it.
|
||||
"""
|
||||
import threading
|
||||
|
||||
import shelfmark.bypass.internal_bypasser as internal_bypasser
|
||||
|
||||
procs: list[_FakeProc] = []
|
||||
helper = _helper_with_fake_spawn(monkeypatch, procs)
|
||||
|
||||
cancel_flag = threading.Event()
|
||||
cancel_flag.set()
|
||||
payload = {
|
||||
"url": "https://example.com",
|
||||
"retry": 1,
|
||||
"result_path": str(tmp_path / "never-written.json"),
|
||||
}
|
||||
|
||||
with pytest.raises(internal_bypasser.BypassCancelledError):
|
||||
helper.run(payload, timeout=5, cancel_flag=cancel_flag)
|
||||
|
||||
assert not procs[0].waited, "a helper wedged mid-bypass was given the full exit grace"
|
||||
assert procs[0].stdin.closed
|
||||
|
||||
|
||||
def test_idle_helper_still_gets_its_grace(monkeypatch, tmp_path):
|
||||
"""The reaper drops a helper that *is* in its read loop, and that one gets to exit."""
|
||||
procs: list[_FakeProc] = []
|
||||
helper = _helper_with_fake_spawn(monkeypatch, procs)
|
||||
|
||||
helper.run(_answered_payload(tmp_path), timeout=5, cancel_flag=None)
|
||||
helper._discard()
|
||||
|
||||
assert procs[0].waited, "an idle helper should be asked to exit before being killed"
|
||||
|
||||
|
||||
def test_failed_request_leaves_no_result_files_behind(monkeypatch, tmp_path):
|
||||
"""Result paths are unique per request, so anything left is left for good."""
|
||||
procs: list[_FakeProc] = []
|
||||
helper = _helper_with_fake_spawn(monkeypatch, procs)
|
||||
|
||||
result_path = tmp_path / "result.json"
|
||||
# A helper killed part-way through _publish_result leaves the staging file.
|
||||
(tmp_path / "result.json.part").write_text('{"ok": tr', encoding="utf-8")
|
||||
payload = {"url": "https://example.com", "retry": 1, "result_path": str(result_path)}
|
||||
|
||||
with pytest.raises(TimeoutError):
|
||||
helper.run(payload, timeout=0.05, cancel_flag=None)
|
||||
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
|
||||
|
||||
def test_child_does_not_export_cookies_left_by_an_earlier_request(monkeypatch, tmp_path):
|
||||
"""The parent owns the store; a warm helper must not push its own history back over it.
|
||||
|
||||
http.py purges a host's clearance the moment that host challenges a request carrying
|
||||
it. A helper that kept its store across requests would still be holding the purged
|
||||
cookies, and the next solve - for some entirely different host - would export them and
|
||||
the parent would merge them straight back in.
|
||||
"""
|
||||
import shelfmark.bypass.internal_bypasser as internal_bypasser
|
||||
|
||||
def _solve_host(url, retry=None, cancel_flag=None):
|
||||
# A solve fills the store for the host it solved, which is all it should report.
|
||||
host = url.rsplit("/", 1)[-1]
|
||||
internal_bypasser.import_store({host: {"cf_clearance": "fresh"}}, {host: "UA"})
|
||||
return "<html>ok</html>"
|
||||
|
||||
monkeypatch.setattr(internal_bypasser, "get", _solve_host)
|
||||
internal_bypasser.clear_cf_cookies()
|
||||
|
||||
for index, host in enumerate(("first.example", "second.example")):
|
||||
internal_bypasser._handle_child_request(
|
||||
json.dumps(
|
||||
{
|
||||
"url": f"https://example.com/{host}",
|
||||
"retry": 1,
|
||||
"result_path": str(tmp_path / f"{index}.json"),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
second = json.loads((tmp_path / "1.json").read_text(encoding="utf-8"))
|
||||
assert list(second["cookies"]) == ["second.example"], (
|
||||
"the helper exported clearance won by an earlier request"
|
||||
)
|
||||
assert list(second["user_agents"]) == ["second.example"]
|
||||
|
||||
internal_bypasser.clear_cf_cookies()
|
||||
|
||||
|
||||
def _record_dns_calls(monkeypatch):
|
||||
"""Stand in for the network module: report a resolver state, record changes to it.
|
||||
|
||||
A helper starts on system DNS, which is what the parent reports as "auto".
|
||||
"""
|
||||
import shelfmark.bypass.internal_bypasser as internal_bypasser
|
||||
|
||||
calls: list[tuple] = []
|
||||
state = {"provider": "auto", "servers": [], "doh_enabled": False}
|
||||
|
||||
def _set(provider, servers=None, use_doh=None):
|
||||
calls.append((provider, servers, use_doh))
|
||||
state.update({"provider": provider, "servers": servers or [], "doh_enabled": bool(use_doh)})
|
||||
|
||||
monkeypatch.setattr(internal_bypasser.network, "set_dns_provider", _set)
|
||||
monkeypatch.setattr(internal_bypasser.network, "get_dns_config", lambda: dict(state))
|
||||
return internal_bypasser, calls
|
||||
|
||||
|
||||
def test_helper_follows_the_parent_back_to_auto_dns(monkeypatch):
|
||||
"""A user flipping CUSTOM_DNS back to auto applies live - the helper has to hear it.
|
||||
|
||||
The old early-return on "auto" was correct only because a fresh helper had never been
|
||||
told anything else. One that outlives the request has, and would go on resolving AA
|
||||
through a resolver the parent has already abandoned.
|
||||
"""
|
||||
internal_bypasser, calls = _record_dns_calls(monkeypatch)
|
||||
|
||||
internal_bypasser._apply_parent_dns_config(
|
||||
{"provider": "cloudflare", "servers": [], "doh_enabled": True}
|
||||
)
|
||||
internal_bypasser._apply_parent_dns_config(
|
||||
{"provider": "auto", "servers": [], "doh_enabled": False}
|
||||
)
|
||||
|
||||
assert calls == [("cloudflare", None, True), ("auto", None, False)]
|
||||
|
||||
|
||||
def test_helper_does_not_reinitialize_dns_for_an_unchanged_config(monkeypatch):
|
||||
"""set_dns_provider() rebuilds resolvers; every request would pay for it otherwise."""
|
||||
internal_bypasser, calls = _record_dns_calls(monkeypatch)
|
||||
|
||||
for _ in range(3):
|
||||
internal_bypasser._apply_parent_dns_config(
|
||||
{"provider": "quad9", "servers": [], "doh_enabled": True}
|
||||
)
|
||||
|
||||
assert calls == [("quad9", None, True)]
|
||||
|
||||
|
||||
def test_fresh_helper_leaves_auto_dns_alone(monkeypatch):
|
||||
"""A helper starts on system DNS, which is what the parent reports as auto."""
|
||||
internal_bypasser, calls = _record_dns_calls(monkeypatch)
|
||||
|
||||
internal_bypasser._apply_parent_dns_config(
|
||||
{"provider": "auto", "servers": [], "doh_enabled": False}
|
||||
)
|
||||
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_failed_dns_apply_is_retried_on_the_next_request(monkeypatch):
|
||||
"""A provider that did not land leaves the resolver where it was, so the next request
|
||||
sees the same mismatch and tries again."""
|
||||
internal_bypasser, calls = _record_dns_calls(monkeypatch)
|
||||
|
||||
def _explode(provider, servers=None, use_doh=None):
|
||||
calls.append((provider, servers, use_doh))
|
||||
msg = "resolver unreachable"
|
||||
raise OSError(msg)
|
||||
|
||||
monkeypatch.setattr(internal_bypasser.network, "set_dns_provider", _explode)
|
||||
|
||||
config = {"provider": "google", "servers": [], "doh_enabled": True}
|
||||
internal_bypasser._apply_parent_dns_config(config)
|
||||
internal_bypasser._apply_parent_dns_config(config)
|
||||
|
||||
assert calls == [("google", None, True), ("google", None, True)]
|
||||
@@ -5,6 +5,7 @@ parking page is indistinguishable from a broken search, so the mirror stays in
|
||||
rotation and every later search pays for it again.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from bs4 import Tag
|
||||
|
||||
PARKED_PAGE = """<!doctype html><html><head><title>annas-archive.li</title></head>
|
||||
@@ -85,15 +86,19 @@ def test_genuinely_empty_aa_result_does_not_quarantine(monkeypatch):
|
||||
assert "No files found." in html
|
||||
|
||||
|
||||
def test_challenge_page_does_not_quarantine(monkeypatch):
|
||||
"""A DDoS-Guard interstitial means the mirror is alive and holds our clearance."""
|
||||
def test_challenge_page_is_reported_not_passed_off_as_an_empty_result(monkeypatch):
|
||||
"""An unsolved interstitial means the search never ran.
|
||||
|
||||
The mirror is alive and holds our clearance, so it must not be quarantined - but
|
||||
returning it as "no table" made the caller tell the user their query found nothing.
|
||||
"""
|
||||
dd, _calls = _patch_pages(monkeypatch, [DDOS_GUARD_PAGE])
|
||||
selector = _Selector(["https://real.test", "https://other.test"])
|
||||
|
||||
_html, table = dd._fetch_search_table("https://real.test/search?q=dune", selector)
|
||||
with pytest.raises(dd.SearchUnavailableError, match="protection challenge"):
|
||||
dd._fetch_search_table("https://real.test/search?q=dune", selector)
|
||||
|
||||
assert selector.quarantined == []
|
||||
assert table is None
|
||||
|
||||
|
||||
def test_unreachable_mirror_raises_search_unavailable(monkeypatch):
|
||||
|
||||
@@ -11,6 +11,134 @@ class _FakeResponse:
|
||||
self.url = url
|
||||
|
||||
|
||||
def test_external_bypasser_clearance_is_presented_on_the_next_request(monkeypatch):
|
||||
"""Clearance is read from the shared store whichever bypasser filled it.
|
||||
|
||||
Guards the regression where the external path returned {} unconditionally: every
|
||||
request re-paid a 403 plus a full solve, and a download - which the solver cannot
|
||||
proxy - presented no clearance at all.
|
||||
"""
|
||||
import shelfmark.bypass.cookie_store as cookie_store
|
||||
import shelfmark.download.http as http
|
||||
|
||||
monkeypatch.setattr(cookie_store, "_cf_cookies", {})
|
||||
monkeypatch.setattr(cookie_store, "_cf_user_agents", {})
|
||||
monkeypatch.setattr(http, "_is_cf_bypass_enabled", lambda: True)
|
||||
monkeypatch.setattr(http, "_is_using_external_bypasser", lambda: True)
|
||||
|
||||
cookie_store.store_extracted_cookies(
|
||||
url="https://annas-archive.gl/search",
|
||||
cookies=[{"name": "__ddg1_", "value": "clearance"}],
|
||||
user_agent="Mozilla/5.0 (solver)",
|
||||
)
|
||||
|
||||
headers: dict[str, str] = {}
|
||||
cookies = http._apply_cf_bypass("https://annas-archive.gl/md5/abc", headers)
|
||||
|
||||
assert cookies == {"__ddg1_": "clearance"}
|
||||
assert headers["User-Agent"] == "Mozilla/5.0 (solver)"
|
||||
|
||||
|
||||
def test_external_bypasser_solve_is_reused_instead_of_re_solved(monkeypatch):
|
||||
"""One solve should clear the following requests, not just the one that paid for it.
|
||||
|
||||
A solve is tens of seconds of real browser, so re-running it per request is what
|
||||
made direct download unusable behind an external bypasser.
|
||||
"""
|
||||
import shelfmark.bypass.cookie_store as cookie_store
|
||||
import shelfmark.download.http as http
|
||||
|
||||
monkeypatch.setattr(cookie_store, "_cf_cookies", {})
|
||||
monkeypatch.setattr(cookie_store, "_cf_user_agents", {})
|
||||
monkeypatch.setattr(http, "_is_cf_bypass_enabled", lambda: True)
|
||||
monkeypatch.setattr(http, "_is_using_external_bypasser", lambda: True)
|
||||
monkeypatch.setattr(http, "_bypass_grace_seconds", lambda: 100.0)
|
||||
monkeypatch.setattr(http, "get_proxies", lambda _url: {})
|
||||
monkeypatch.setattr(http, "get_ssl_verify", lambda _url: True)
|
||||
monkeypatch.setattr(http.network, "should_rotate_dns_for_url", lambda _url: False)
|
||||
monkeypatch.setattr(http.time, "sleep", lambda _seconds: None)
|
||||
|
||||
class _Cleared:
|
||||
is_redirect = False
|
||||
status_code = 200
|
||||
cookies: dict[str, str] = {}
|
||||
text = "<table>results</table>"
|
||||
url = "https://annas-archive.gl/search?q=dune"
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
return None
|
||||
|
||||
def gated_get(url: str, **kwargs):
|
||||
if kwargs.get("cookies", {}).get("cf_clearance") != "token":
|
||||
error = requests.exceptions.HTTPError("forbidden")
|
||||
error.response = _FakeResponse(403, url=url)
|
||||
raise error
|
||||
return _Cleared()
|
||||
|
||||
solves: list[str] = []
|
||||
|
||||
def fake_solve(url: str, *_args, **_kwargs):
|
||||
solves.append(url)
|
||||
cookie_store.store_extracted_cookies(
|
||||
url=url,
|
||||
cookies=[{"name": "cf_clearance", "value": "token"}],
|
||||
user_agent="Mozilla/5.0 (solver)",
|
||||
)
|
||||
return "<table>results</table>"
|
||||
|
||||
monkeypatch.setattr(http.requests, "get", gated_get)
|
||||
monkeypatch.setattr(http, "get_bypassed_page", fake_solve)
|
||||
|
||||
url = "https://annas-archive.gl/search?q=dune"
|
||||
first = http.html_get_page(url, retry=2, allow_bypasser_fallback=True, success_delay=0)
|
||||
second = http.html_get_page(url, retry=2, allow_bypasser_fallback=True, success_delay=0)
|
||||
|
||||
assert first == "<table>results</table>"
|
||||
assert second == "<table>results</table>"
|
||||
# The second request rode the stored clearance instead of paying for another solve.
|
||||
assert solves == [url]
|
||||
|
||||
|
||||
def test_403_with_a_concurrently_won_clearance_still_reaches_the_bypasser(monkeypatch):
|
||||
"""The last attempt must hand off, not `continue` into the end of the loop.
|
||||
|
||||
Another worker's solve can land between our request and its 403, which used to
|
||||
send this branch back round the retry loop - but on the final attempt (and
|
||||
MAX_RETRY=1 is the supported setting) `continue` just ends it, abandoning the
|
||||
request without ever offering the URL to the bypasser.
|
||||
"""
|
||||
import shelfmark.download.http as http
|
||||
|
||||
monkeypatch.setattr(http, "_is_cf_bypass_enabled", lambda: True)
|
||||
monkeypatch.setattr(http, "_bypass_grace_seconds", lambda: 100.0)
|
||||
# A concurrent solve has filled the store, but this request went out before it did.
|
||||
monkeypatch.setattr(http, "get_cf_cookies_for_domain", lambda _hostname: {"__ddg1_": "fresh"})
|
||||
monkeypatch.setattr(http, "_apply_cf_bypass", lambda _url, _headers: {})
|
||||
monkeypatch.setattr(http, "get_proxies", lambda _url: {})
|
||||
monkeypatch.setattr(http, "get_ssl_verify", lambda _url: True)
|
||||
monkeypatch.setattr(http.network, "should_rotate_dns_for_url", lambda _url: False)
|
||||
monkeypatch.setattr(http.time, "sleep", lambda _seconds: None)
|
||||
|
||||
def gated(url: str, **_kwargs):
|
||||
error = requests.exceptions.HTTPError("forbidden")
|
||||
error.response = _FakeResponse(403, url=url)
|
||||
raise error
|
||||
|
||||
bypassed: list[str] = []
|
||||
monkeypatch.setattr(http.requests, "get", gated)
|
||||
monkeypatch.setattr(
|
||||
http,
|
||||
"get_bypassed_page",
|
||||
lambda url, *_a, **_k: bypassed.append(url) or "<table>results</table>",
|
||||
)
|
||||
|
||||
url = "https://annas-archive.gl/search?q=dune"
|
||||
html = http.html_get_page(url, retry=1, allow_bypasser_fallback=True, success_delay=0)
|
||||
|
||||
assert html == "<table>results</table>"
|
||||
assert bypassed == [url]
|
||||
|
||||
|
||||
def test_html_get_page_ignores_status_callback_failure(monkeypatch):
|
||||
"""A raising status_callback must not break the bypass it was reporting on."""
|
||||
import shelfmark.download.http as http
|
||||
@@ -190,15 +318,13 @@ def test_redirect_loop_purges_stale_cookies_and_switches_to_bypasser(monkeypatch
|
||||
stale = {"__ddg8_": "stale"}
|
||||
cleared: list[str] = []
|
||||
|
||||
class _FakeInternalBypasser:
|
||||
@staticmethod
|
||||
def clear_cf_cookies(domain: str) -> None:
|
||||
cleared.append(domain)
|
||||
stale.clear()
|
||||
def fake_clear(domain: str) -> None:
|
||||
cleared.append(domain)
|
||||
stale.clear()
|
||||
|
||||
monkeypatch.setattr(http, "_is_cf_bypass_enabled", lambda: True)
|
||||
monkeypatch.setattr(http, "_is_using_external_bypasser", lambda: False)
|
||||
monkeypatch.setattr(http, "_get_internal_bypasser", lambda: _FakeInternalBypasser)
|
||||
monkeypatch.setattr(http.cookie_store, "clear_cf_cookies", fake_clear)
|
||||
monkeypatch.setattr(http, "_bypass_grace_seconds", lambda: 100.0)
|
||||
monkeypatch.setattr(http, "_apply_cf_bypass", lambda _url, _headers: dict(stale))
|
||||
monkeypatch.setattr(http, "get_proxies", lambda _url: {})
|
||||
@@ -349,17 +475,11 @@ def test_html_get_page_redirect_loop_purges_cookies_and_bypasses(monkeypatch):
|
||||
|
||||
cleared: list[str] = []
|
||||
|
||||
class FakeInternalBypasser:
|
||||
def clear_cf_cookies(self, domain: str) -> None:
|
||||
cleared.append(domain)
|
||||
|
||||
def get_cf_cookies_for_domain(self, _domain: str) -> dict[str, str]:
|
||||
return {"__ddg2_": "stale"}
|
||||
|
||||
def get_cf_user_agent_for_domain(self, _domain: str) -> str | None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(http, "_get_internal_bypasser", lambda: FakeInternalBypasser())
|
||||
monkeypatch.setattr(http.cookie_store, "clear_cf_cookies", cleared.append)
|
||||
monkeypatch.setattr(
|
||||
http.cookie_store, "get_cf_cookies_for_domain", lambda _domain: {"__ddg2_": "stale"}
|
||||
)
|
||||
monkeypatch.setattr(http.cookie_store, "get_cf_user_agent_for_domain", lambda _domain: None)
|
||||
monkeypatch.setattr(http, "get_bypassed_page", lambda *_args, **_kwargs: "SOLVED")
|
||||
|
||||
class _FakeRedirect:
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Tests for handing a 503 that carries a browser challenge to the bypasser."""
|
||||
|
||||
import requests
|
||||
|
||||
_CHALLENGE_HTML = (
|
||||
"<html><head><title>Checking your browser before accessing z-lib.gd</title>"
|
||||
"<script src='/.well-known/ddos-guard/check.js'></script></head>"
|
||||
"<body>Please wait...</body></html>"
|
||||
)
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
"""Minimal stand-in for requests.Response covering what html_get_page touches."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
status_code: int,
|
||||
*,
|
||||
url: str = "https://z-lib.gd/md5/abc",
|
||||
text: str = "",
|
||||
cookies: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
self.status_code = status_code
|
||||
self.url = url
|
||||
self.text = text
|
||||
self.cookies = cookies or {}
|
||||
self.headers = {"Content-Type": "text/html;charset=utf-8"}
|
||||
self.is_redirect = False
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
if self.status_code >= 400:
|
||||
error = requests.exceptions.HTTPError(f"{self.status_code} Error")
|
||||
error.response = self
|
||||
raise error
|
||||
|
||||
|
||||
def _neutralize_network(monkeypatch, http):
|
||||
monkeypatch.setattr(http, "_apply_cf_bypass", lambda _url, _headers: {})
|
||||
monkeypatch.setattr(http, "get_proxies", lambda _url: {})
|
||||
monkeypatch.setattr(http, "get_ssl_verify", lambda _url: True)
|
||||
monkeypatch.setattr(http.network, "should_rotate_dns_for_url", lambda _url: False)
|
||||
monkeypatch.setattr(http.time, "sleep", lambda _seconds: None)
|
||||
|
||||
|
||||
def test_503_challenge_is_handed_to_the_bypasser(monkeypatch):
|
||||
"""The reissued-cookie 503 from #1233 reaches the bypasser instead of retrying."""
|
||||
import shelfmark.download.http as http
|
||||
|
||||
_neutralize_network(monkeypatch, http)
|
||||
monkeypatch.setattr(http, "_is_cf_bypass_enabled", lambda: True)
|
||||
|
||||
attempts: list[dict[str, str]] = []
|
||||
bypassed: list[str] = []
|
||||
|
||||
def fake_get(_url: str, **kwargs):
|
||||
attempts.append(dict(kwargs["cookies"]))
|
||||
# Hit 1 issues the cookie; every later hit re-serves the challenge unchanged,
|
||||
# which is what leaves the handshake with nothing to echo back.
|
||||
if len(attempts) == 1:
|
||||
return _FakeResponse(503, cookies={"bsrv": "1"})
|
||||
return _FakeResponse(503, text=_CHALLENGE_HTML, cookies={"bsrv": "1"})
|
||||
|
||||
def fake_bypass(url: str, _selector=None, _cancel_flag=None):
|
||||
bypassed.append(url)
|
||||
return "<html>real page</html>"
|
||||
|
||||
monkeypatch.setattr(http.requests, "get", fake_get)
|
||||
monkeypatch.setattr(http, "get_bypassed_page", fake_bypass)
|
||||
|
||||
html = http.html_get_page("https://z-lib.gd/md5/abc", retry=10, success_delay=0)
|
||||
|
||||
assert html == "<html>real page</html>"
|
||||
assert bypassed == ["https://z-lib.gd/md5/abc"]
|
||||
# The handshake still gets its echo; the challenge ends the loop on the second hit
|
||||
# rather than burning all ten attempts.
|
||||
assert attempts == [{}, {"bsrv": "1"}]
|
||||
|
||||
|
||||
def test_plain_503_still_retries_without_bypassing(monkeypatch):
|
||||
"""An overloaded origin has no challenge marker, so its retry path is untouched."""
|
||||
import shelfmark.download.http as http
|
||||
|
||||
_neutralize_network(monkeypatch, http)
|
||||
monkeypatch.setattr(http, "_is_cf_bypass_enabled", lambda: True)
|
||||
|
||||
attempts: list[dict[str, str]] = []
|
||||
bypassed: list[str] = []
|
||||
|
||||
def fake_get(_url: str, **kwargs):
|
||||
attempts.append(dict(kwargs["cookies"]))
|
||||
return _FakeResponse(503, text="<html><body>Service Unavailable</body></html>")
|
||||
|
||||
monkeypatch.setattr(http.requests, "get", fake_get)
|
||||
monkeypatch.setattr(
|
||||
http, "get_bypassed_page", lambda url, *_a, **_k: bypassed.append(url) or ""
|
||||
)
|
||||
|
||||
html = http.html_get_page("https://z-lib.gd/md5/abc", retry=3, success_delay=0)
|
||||
|
||||
assert html == ""
|
||||
assert bypassed == []
|
||||
assert attempts == [{}, {}, {}]
|
||||
|
||||
|
||||
def test_503_challenge_respects_disabled_bypasser_fallback(monkeypatch):
|
||||
"""Best-effort fetches must not stall on a minutes-long solve."""
|
||||
import shelfmark.download.http as http
|
||||
|
||||
_neutralize_network(monkeypatch, http)
|
||||
monkeypatch.setattr(http, "_is_cf_bypass_enabled", lambda: True)
|
||||
|
||||
bypassed: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
http.requests,
|
||||
"get",
|
||||
lambda _url, **_kwargs: _FakeResponse(503, text=_CHALLENGE_HTML),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
http, "get_bypassed_page", lambda url, *_a, **_k: bypassed.append(url) or ""
|
||||
)
|
||||
|
||||
html = http.html_get_page(
|
||||
"https://z-lib.gd/md5/abc", retry=1, success_delay=0, allow_bypasser_fallback=False
|
||||
)
|
||||
|
||||
assert html == ""
|
||||
assert bypassed == []
|
||||
@@ -0,0 +1,53 @@
|
||||
import pytest
|
||||
|
||||
from shelfmark.metadata_providers import hardcover
|
||||
from shelfmark.metadata_providers.hardcover import (
|
||||
HardcoverProvider,
|
||||
_test_hardcover_connection,
|
||||
)
|
||||
|
||||
# Hardcover replaced its ~500 char JWTs with short opaque tokens.
|
||||
PAT = "hc_pat_" + "a" * 32
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_config_writes(monkeypatch):
|
||||
"""Keep the connection test from touching the on-disk provider config."""
|
||||
monkeypatch.setattr(hardcover, "_save_connected_user", lambda user_id, username: None)
|
||||
|
||||
|
||||
class TestHardcoverApiKey:
|
||||
def test_personal_access_token_is_accepted(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
HardcoverProvider,
|
||||
"_execute_query",
|
||||
lambda self, query, variables: {"me": [{"id": 1, "username": "alex"}]},
|
||||
)
|
||||
|
||||
result = _test_hardcover_connection({"HARDCOVER_API_KEY": PAT})
|
||||
|
||||
assert result == {"success": True, "message": "Connected as: alex"}
|
||||
|
||||
def test_short_key_without_the_prefix_is_rejected(self):
|
||||
result = _test_hardcover_connection({"HARDCOVER_API_KEY": "eyJhbGciOiJIUzI1NiJ9.short"})
|
||||
|
||||
assert result["success"] is False
|
||||
assert "too short" in result["message"]
|
||||
|
||||
def test_short_prefixed_key_still_reaches_the_api(self, monkeypatch):
|
||||
"""A key wearing the hc_pat_ prefix is Hardcover's to accept or reject."""
|
||||
monkeypatch.setattr(
|
||||
HardcoverProvider,
|
||||
"_execute_query",
|
||||
lambda self, query, variables: None,
|
||||
)
|
||||
|
||||
result = _test_hardcover_connection({"HARDCOVER_API_KEY": "hc_pat_ab"})
|
||||
|
||||
assert result == {"success": False, "message": "API request failed - check your API key"}
|
||||
|
||||
@pytest.mark.parametrize("pasted", [f"Bearer {PAT}", f"bearer {PAT}", f" {PAT} "])
|
||||
def test_pasted_auth_header_noise_is_stripped(self, pasted):
|
||||
provider = HardcoverProvider(api_key=pasted)
|
||||
|
||||
assert provider.session.headers["Authorization"] == f"Bearer {PAT}"
|
||||
@@ -1,4 +1,8 @@
|
||||
from shelfmark.metadata_providers.hardcover import HardcoverProvider
|
||||
from shelfmark.metadata_providers.hardcover import (
|
||||
TITLE_SUGGESTION_FIELDS,
|
||||
TITLE_SUGGESTION_WEIGHTS,
|
||||
HardcoverProvider,
|
||||
)
|
||||
|
||||
|
||||
class TestHardcoverFieldOptions:
|
||||
@@ -130,8 +134,10 @@ class TestHardcoverFieldOptions:
|
||||
"limit": 7,
|
||||
"page": 1,
|
||||
"sort": "_text_match:desc,users_count:desc",
|
||||
"fields": "title,alternative_titles",
|
||||
"weights": "5,2",
|
||||
# Hardcover rejects a Book search that narrows to fewer fields than its
|
||||
# preset expects, so the typeahead sends the full list and leans on weights.
|
||||
"fields": TITLE_SUGGESTION_FIELDS,
|
||||
"weights": TITLE_SUGGESTION_WEIGHTS,
|
||||
}
|
||||
|
||||
def test_get_search_field_options_skips_short_text_queries(self):
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Guards on the shape of Hardcover's `fields`/`weights` search parameters.
|
||||
|
||||
Hardcover turns `fields` into Typesense's `query_by` but keeps `num_typos` and
|
||||
`query_by_weights` as fixed-length presets per query_type. A field list of the
|
||||
wrong length is not searched loosely -- the whole search is rejected with a null
|
||||
results body, which used to surface as "0 results". These tests pin the counts
|
||||
so a narrower field list cannot silently ship again.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from shelfmark.metadata_providers.hardcover import (
|
||||
AUTHOR_SUGGESTION_FIELDS,
|
||||
AUTHOR_SUGGESTION_WEIGHTS,
|
||||
BOOK_SEARCH_FIELD_COUNT,
|
||||
BOOK_SEARCH_FIELDS,
|
||||
BOOK_TITLE_AUTHOR_WEIGHTS,
|
||||
BOOK_TITLE_WEIGHTS,
|
||||
SERIES_SEARCH_FIELDS,
|
||||
SERIES_SEARCH_WEIGHTS,
|
||||
TITLE_SUGGESTION_FIELDS,
|
||||
TITLE_SUGGESTION_WEIGHTS,
|
||||
HardcoverProvider,
|
||||
)
|
||||
|
||||
|
||||
def _count(value: str) -> int:
|
||||
return len([part for part in value.split(",") if part.strip()])
|
||||
|
||||
|
||||
class TestBookSearchFieldCounts:
|
||||
def test_book_field_list_matches_hardcovers_preset_length(self):
|
||||
assert _count(BOOK_SEARCH_FIELDS) == BOOK_SEARCH_FIELD_COUNT
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("label", "weights"),
|
||||
[
|
||||
("title", BOOK_TITLE_WEIGHTS),
|
||||
("title+author", BOOK_TITLE_AUTHOR_WEIGHTS),
|
||||
("title typeahead", TITLE_SUGGESTION_WEIGHTS),
|
||||
],
|
||||
)
|
||||
def test_book_weights_line_up_with_the_field_list(self, label, weights):
|
||||
assert _count(weights) == BOOK_SEARCH_FIELD_COUNT, label
|
||||
|
||||
def test_title_typeahead_uses_the_full_book_field_list(self):
|
||||
assert TITLE_SUGGESTION_FIELDS == BOOK_SEARCH_FIELDS
|
||||
|
||||
|
||||
class TestNonBookSearchFieldCounts:
|
||||
@pytest.mark.parametrize(
|
||||
("fields", "weights"),
|
||||
[
|
||||
(AUTHOR_SUGGESTION_FIELDS, AUTHOR_SUGGESTION_WEIGHTS),
|
||||
(SERIES_SEARCH_FIELDS, SERIES_SEARCH_WEIGHTS),
|
||||
],
|
||||
)
|
||||
def test_weights_line_up_with_their_field_list(self, fields, weights):
|
||||
assert _count(fields) == _count(weights)
|
||||
|
||||
|
||||
class TestBuildSearchParams:
|
||||
@pytest.mark.parametrize(
|
||||
("author", "title", "series"),
|
||||
[
|
||||
("", "Dune", ""),
|
||||
("Herbert", "Dune", ""),
|
||||
("Herbert", "", ""),
|
||||
("", "", ""),
|
||||
],
|
||||
)
|
||||
def test_every_branch_sends_a_usable_field_weight_pair(self, author, title, series):
|
||||
provider = HardcoverProvider(api_key="test-token")
|
||||
|
||||
_query, fields, weights = provider._build_search_params("dune", author, title, series)
|
||||
|
||||
if fields is None:
|
||||
# No override: Hardcover applies its own preset, so weights must be absent too.
|
||||
assert weights is None
|
||||
return
|
||||
assert _count(fields) == BOOK_SEARCH_FIELD_COUNT
|
||||
assert _count(weights) == BOOK_SEARCH_FIELD_COUNT
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
@@ -6,9 +7,10 @@ from shelfmark.metadata_providers import MetadataSearchOptions
|
||||
from shelfmark.metadata_providers.hardcover import HardcoverProvider
|
||||
|
||||
# Hardcover answers a rejected search with HTTP 200, no GraphQL errors, and a
|
||||
# null results body. A search that genuinely matched nothing still returns a
|
||||
# results object with found: 0.
|
||||
REJECTED = {"search": {"results": None}}
|
||||
# null results body; the reason shows up in the sibling error field. A search
|
||||
# that genuinely matched nothing still returns a results object with found: 0.
|
||||
REJECTED = {"search": {"error": "Parameter `sort_by` is malformed.", "results": None}}
|
||||
REJECTED_SILENTLY = {"search": {"results": None}}
|
||||
EMPTY = {"search": {"results": {"hits": [], "found": 0}}}
|
||||
ONE_HIT = {"search": {"results": {"hits": [{"document": {"id": 7, "title": "Dune"}}], "found": 1}}}
|
||||
|
||||
@@ -19,6 +21,29 @@ def _reset_sort_fallback(monkeypatch):
|
||||
monkeypatch.setattr("shelfmark.metadata_providers.hardcover._sort_fallback_until", 0.0)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hardcover_logs():
|
||||
"""Collect Hardcover log messages.
|
||||
|
||||
The provider's logger is built outside the standard hierarchy, so its
|
||||
records never reach the root handler that caplog installs.
|
||||
"""
|
||||
from shelfmark.metadata_providers import hardcover
|
||||
|
||||
messages: list[str] = []
|
||||
|
||||
class _Capture(logging.Handler):
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
messages.append(record.getMessage())
|
||||
|
||||
handler = _Capture()
|
||||
hardcover.logger.addHandler(handler)
|
||||
try:
|
||||
yield messages
|
||||
finally:
|
||||
hardcover.logger.removeHandler(handler)
|
||||
|
||||
|
||||
def _reject_sorted(calls: list[dict[str, Any]], *, success=ONE_HIT):
|
||||
"""Build an _execute_query stand-in that rejects any request carrying a sort."""
|
||||
|
||||
@@ -38,7 +63,9 @@ class TestHardcoverSortFallback:
|
||||
result = provider._execute_search_query("query", {"query": "dune", "sort": "relevance"})
|
||||
|
||||
assert result == ONE_HIT
|
||||
assert [call["sort"] for call in calls] == ["relevance", ""]
|
||||
# The retry drops sort entirely -- an empty sort is a value Hardcover can reject too.
|
||||
assert [call.get("sort") for call in calls] == ["relevance", None]
|
||||
assert "sort" not in calls[1]
|
||||
|
||||
def test_treats_an_empty_result_set_as_success(self, monkeypatch):
|
||||
provider = HardcoverProvider(api_key="test-token")
|
||||
@@ -64,6 +91,24 @@ class TestHardcoverSortFallback:
|
||||
assert result is None
|
||||
assert len(calls) == 2
|
||||
|
||||
def test_keeps_sorting_when_the_sort_was_not_the_culprit(self, monkeypatch):
|
||||
"""A rejection that survives dropping the sort must not disable sorting globally."""
|
||||
provider = HardcoverProvider(api_key="test-token")
|
||||
calls: list[dict[str, Any]] = []
|
||||
monkeypatch.setattr(
|
||||
provider, "_execute_query", lambda query, variables: calls.append(variables) or REJECTED
|
||||
)
|
||||
|
||||
provider._execute_search_query("query", {"query": "dune", "sort": "rating:desc"})
|
||||
provider._execute_search_query("query", {"query": "hyperion", "sort": "rating:desc"})
|
||||
|
||||
assert [call.get("sort") for call in calls] == [
|
||||
"rating:desc",
|
||||
None,
|
||||
"rating:desc",
|
||||
None,
|
||||
]
|
||||
|
||||
def test_reports_failure_for_an_unsorted_rejection(self, monkeypatch):
|
||||
provider = HardcoverProvider(api_key="test-token")
|
||||
calls: list[dict[str, Any]] = []
|
||||
@@ -76,6 +121,24 @@ class TestHardcoverSortFallback:
|
||||
assert result is None
|
||||
assert len(calls) == 1
|
||||
|
||||
def test_logs_the_reason_hardcover_gave(self, monkeypatch, hardcover_logs):
|
||||
provider = HardcoverProvider(api_key="test-token")
|
||||
monkeypatch.setattr(provider, "_execute_query", lambda query, variables: REJECTED)
|
||||
|
||||
provider._execute_search_query("query", {"query": "dune", "sort": ""})
|
||||
|
||||
assert any("Parameter `sort_by` is malformed." in message for message in hardcover_logs)
|
||||
|
||||
def test_falls_back_to_a_placeholder_when_hardcover_says_nothing(
|
||||
self, monkeypatch, hardcover_logs
|
||||
):
|
||||
provider = HardcoverProvider(api_key="test-token")
|
||||
monkeypatch.setattr(provider, "_execute_query", lambda query, variables: REJECTED_SILENTLY)
|
||||
|
||||
provider._execute_search_query("query", {"query": "dune", "sort": ""})
|
||||
|
||||
assert any("no error message" in message for message in hardcover_logs)
|
||||
|
||||
def test_skips_the_doomed_request_on_later_searches(self, monkeypatch):
|
||||
provider = HardcoverProvider(api_key="test-token")
|
||||
calls: list[dict[str, Any]] = []
|
||||
@@ -84,7 +147,7 @@ class TestHardcoverSortFallback:
|
||||
provider._execute_search_query("query", {"query": "dune", "sort": "relevance"})
|
||||
provider._execute_search_query("query", {"query": "hyperion", "sort": "relevance"})
|
||||
|
||||
assert [call["sort"] for call in calls] == ["relevance", "", ""]
|
||||
assert [call.get("sort") for call in calls] == ["relevance", None, None]
|
||||
|
||||
def test_search_returns_results_despite_a_rejected_sort(self, monkeypatch):
|
||||
provider = HardcoverProvider(api_key="test-token")
|
||||
@@ -97,7 +160,7 @@ class TestHardcoverSortFallback:
|
||||
|
||||
assert result.total_found == 1
|
||||
assert [book.title for book in result.books] == ["Dune"]
|
||||
assert [call["sort"] for call in calls] == ["_text_match:desc,users_count:desc", ""]
|
||||
assert [call.get("sort") for call in calls] == ["_text_match:desc,users_count:desc", None]
|
||||
|
||||
|
||||
class TestSearchPayloadRejection:
|
||||
@@ -105,9 +168,19 @@ class TestSearchPayloadRejection:
|
||||
from shelfmark.metadata_providers.hardcover import _search_payload_rejected
|
||||
|
||||
assert _search_payload_rejected(REJECTED) is True
|
||||
assert _search_payload_rejected(REJECTED_SILENTLY) is True
|
||||
assert _search_payload_rejected(EMPTY) is False
|
||||
assert _search_payload_rejected(ONE_HIT) is False
|
||||
assert _search_payload_rejected(None) is False
|
||||
assert _search_payload_rejected({}) is False
|
||||
# Non-search payloads (list lookups, book fetches) must pass through.
|
||||
assert _search_payload_rejected({"series": [{"id": 1}]}) is False
|
||||
|
||||
def test_reads_the_error_hardcover_attached(self):
|
||||
from shelfmark.metadata_providers.hardcover import _search_rejection_reason
|
||||
|
||||
assert _search_rejection_reason(REJECTED) == "Parameter `sort_by` is malformed."
|
||||
assert _search_rejection_reason(REJECTED_SILENTLY) == ""
|
||||
assert _search_rejection_reason(EMPTY) == ""
|
||||
assert _search_rejection_reason(None) == ""
|
||||
assert _search_rejection_reason({"search": {"error": None, "results": None}}) == ""
|
||||
|
||||
@@ -727,6 +727,62 @@ class TestProwlarrHandlerSeedCriteria:
|
||||
assert call_kwargs["ratio_limit"] == 1.25
|
||||
|
||||
|
||||
class TestProwlarrHandlerContentType:
|
||||
"""Regression tests for issue #1235 — content type must reach the client."""
|
||||
|
||||
def test_download_passes_content_type_to_client(self):
|
||||
"""rTorrent picks its audiobook label from content_type, not category."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.name = "rtorrent"
|
||||
mock_client.find_existing.return_value = None
|
||||
mock_client.add_download.return_value = "download_id"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"shelfmark.release_sources.prowlarr.handler.get_release",
|
||||
return_value={
|
||||
"protocol": "torrent",
|
||||
"title": "Test Release",
|
||||
"magnetUrl": "magnet:?xt=urn:btih:abc123",
|
||||
},
|
||||
),
|
||||
patch(
|
||||
"shelfmark.release_sources.prowlarr.handler.get_client",
|
||||
return_value=mock_client,
|
||||
),
|
||||
patch(
|
||||
"shelfmark.release_sources.prowlarr.handler.remove_release",
|
||||
),
|
||||
patch("shelfmark.release_sources.prowlarr.handler.config.get", return_value=True),
|
||||
patch.object(
|
||||
ProwlarrHandler,
|
||||
"_poll_and_complete",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
handler = ProwlarrHandler()
|
||||
task = DownloadTask(
|
||||
task_id="content-type-pass-through",
|
||||
source="prowlarr",
|
||||
title="Test Audiobook",
|
||||
content_type="audiobook",
|
||||
)
|
||||
cancel_flag = Event()
|
||||
recorder = ProgressRecorder()
|
||||
|
||||
handler.download(
|
||||
task=task,
|
||||
cancel_flag=cancel_flag,
|
||||
progress_callback=recorder.progress_callback,
|
||||
status_callback=recorder.status_callback,
|
||||
)
|
||||
|
||||
call_kwargs = mock_client.add_download.call_args.kwargs
|
||||
assert call_kwargs["content_type"] == "audiobook"
|
||||
# rTorrent gets no category, so content_type is its only audiobook signal.
|
||||
assert call_kwargs["category"] is None
|
||||
|
||||
|
||||
class TestProwlarrHandlerExistingDownload:
|
||||
"""Tests for handling existing downloads."""
|
||||
|
||||
|
||||
@@ -563,6 +563,36 @@ class TestRTorrentClientAudiobookLabel:
|
||||
assert "d.custom1.set=books" in args[2]
|
||||
assert "d.custom1.set=audiobooks" not in args[2]
|
||||
|
||||
def test_uses_audiobook_label_for_compound_content_type(self, monkeypatch):
|
||||
"""Issue #1235 — an exact "audiobook" match missed forms like "book (audiobook)"."""
|
||||
config_values = {
|
||||
"RTORRENT_URL": "http://localhost:8080/RPC2",
|
||||
"RTORRENT_LABEL": "books",
|
||||
"RTORRENT_AUDIOBOOK_LABEL": "audiobooks",
|
||||
"RTORRENT_DOWNLOAD_DIR": "/downloads",
|
||||
}
|
||||
mock_rpc, mock_xmlrpc, mock_torrent_info = self._make_client(monkeypatch, config_values)
|
||||
|
||||
with patch.dict("sys.modules", {"xmlrpc.client": mock_xmlrpc}):
|
||||
with patch(
|
||||
"shelfmark.download.clients.torrent_utils.extract_torrent_info",
|
||||
return_value=mock_torrent_info,
|
||||
):
|
||||
if "shelfmark.download.clients.rtorrent" in sys.modules:
|
||||
del sys.modules["shelfmark.download.clients.rtorrent"]
|
||||
from shelfmark.download.clients.rtorrent import RTorrentClient
|
||||
|
||||
client = RTorrentClient()
|
||||
client.add_download(
|
||||
"magnet:?xt=urn:btih:abc123",
|
||||
"Test Audiobook",
|
||||
content_type="Book (Audiobook)",
|
||||
)
|
||||
|
||||
args = mock_rpc.load.start.call_args[0]
|
||||
assert "d.custom1.set=audiobooks" in args[2]
|
||||
assert "d.custom1.set=books" not in args[2]
|
||||
|
||||
|
||||
class TestRTorrentClientGetStatus:
|
||||
"""Tests for RTorrentClient.get_status()."""
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
version = 1
|
||||
revision = 2
|
||||
revision = 3
|
||||
requires-python = ">=3.14"
|
||||
|
||||
[[package]]
|
||||
@@ -56,14 +56,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "basedpyright"
|
||||
version = "1.39.9"
|
||||
version = "1.39.10"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nodejs-wheel-binaries" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7a/4b/c1f4e211e50389304d6af32b9280e026a7133e3ad59bbdf8f7a3250f8bee/basedpyright-1.39.9.tar.gz", hash = "sha256:32cbea5fc8273e89df3db20daea56cb7286e419ccdfdc479c64759d2dc071901", size = 24412216, upload-time = "2026-06-27T02:19:49.834Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/68/43/ad2999f3b09eb2b1e59931d88fac0f7bcc9c17fc18c903268779bd10cc97/basedpyright-1.39.10.tar.gz", hash = "sha256:c8eaf5302f3265e275c7df4fba194d7afa7c1cb53fbfd448e90098360aca2c2e", size = 24740347, upload-time = "2026-08-13T17:09:02.51Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/d4/e1fa108710d0498a18c77b1e13897f31eab47c69aa8cfe2d2a4df746541e/basedpyright-1.39.9-py3-none-any.whl", hash = "sha256:6b0837b9eba972c71895167ab9b127e6afdbc17abc92312e3f8d15ca82a5611c", size = 13374276, upload-time = "2026-06-27T02:19:54.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/2a/a224054d75a58786c482f63b8ff2a09fc3362268bd1ebd0a61fb3f982153/basedpyright-1.39.10-py3-none-any.whl", hash = "sha256:cbd75d83c0be841329bcfef2d2f1182f152a6d975b8eb199e75cf5b8e9a3de78", size = 13482322, upload-time = "2026-08-13T17:08:59.074Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1243,16 +1243,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "qbittorrent-api"
|
||||
version = "2026.8.0"
|
||||
version = "2026.8.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "packaging" },
|
||||
{ name = "requests" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/eb/d2/4a6d6ae6aaca1ba83039d27f28e5bf30bacef27ff4eb635c729d2d582363/qbittorrent_api-2026.8.0.tar.gz", hash = "sha256:4127a9c1d5c7ad9cd4e8de16f83daeea2e8b2ed962f99f284d238f927479391a", size = 1430678, upload-time = "2026-08-01T21:05:55.88Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/00/b7f41dfe8af6c3991f7182f9c14c46851379b8e66ac74c819f0952e9923d/qbittorrent_api-2026.8.1.tar.gz", hash = "sha256:9642c4528baee67216eab87022ce297048ac96df2f8c9f1cf920de078eb50b62", size = 1436760, upload-time = "2026-08-16T20:30:28.35Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/81/b456688c20e75cae5964700b58712630930b9dfaf32ce7c6a8881658d0d0/qbittorrent_api-2026.8.0-py3-none-any.whl", hash = "sha256:03b3db598d351194fd3c65f1d3e53843f65f85bef1d5077791efaa2e26869d2b", size = 68196, upload-time = "2026-08-01T21:05:54.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/60/77c7b63675f12ba492dee9f8aabab0fc61ced5d2131a83bd7e92dcc335c1/qbittorrent_api-2026.8.1-py3-none-any.whl", hash = "sha256:c17260e416ec5832c3ed7aff46fb0e2b47e70b90de182e89c34133b9d4ce5a6e", size = 71970, upload-time = "2026-08-16T20:30:26.67Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1321,27 +1321,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.16.2"
|
||||
version = "0.16.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/73/e1/4508a569211b35599016e84ba65c1a992b7a4004b4b6c4bea02a851cba1b/ruff-0.16.2.tar.gz", hash = "sha256:c3d7828d12e8927a6fc65fe38e2c2541b9e762d360a1786d752cb1b8883b3c9c", size = 4885811, upload-time = "2026-08-07T13:31:01.432Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/61/b3/3213589383f8f1b3938781bd1278713f6d18621a14992b3e81fefb8a5ef9/ruff-0.16.3.tar.gz", hash = "sha256:e76d33a347661a84b5be6d043d0347fdc745dfdcf825a8f4fed64b5e26eebdf2", size = 4891904, upload-time = "2026-08-13T15:17:13.381Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/14/57/db19951540f98859c956b50bdb4d31089b4d91e9f15e2968e7d5193806d5/ruff-0.16.2-py3-none-linux_armv6l.whl", hash = "sha256:3c8de4cf2181f01d57946d87d777aa52916976fc09942aed89938fab5e013318", size = 10847925, upload-time = "2026-08-07T13:30:14.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/5a/995fe85a8470d3e391ac0f7fa8054bb454eaf33ee138196d6172ed1079c0/ruff-0.16.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9a48cc05c6fbc811ca81b5d7ba95375affea6582d1b8024e455e41afbbf55344", size = 11072662, upload-time = "2026-08-07T13:30:18.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/53/370d767c61c71a971a4ace36703a7ecd8c393956349a7325d7fab2b56827/ruff-0.16.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a2c0d14fcbb26c91f0f867a6dc9bd71bbc30b1b6151829c884f23faeab2e5700", size = 10566771, upload-time = "2026-08-07T13:30:20.899Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/d6/9d96948caf5a632be62d62202d5ec914d6856f204fd79eb036e5915e79ea/ruff-0.16.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:335c621622c4650330be50842561c6586ac6971bb8ab5407fe34dcc9efb16bbe", size = 10975825, upload-time = "2026-08-07T13:30:23.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/92/ea87129b3414acb0b5770563779c51804d37ac67675c7ba35447ddb14773/ruff-0.16.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20e66910f2c37cc753f9ef6580c914a621b80c4fa3549d3e3521e29d0f5bfc3f", size = 10649437, upload-time = "2026-08-07T13:30:26.097Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/43/f8f291dcd4af5bb7872b74fdfa41a7cd7c856ca1d4069670971cf1b9f5cb/ruff-0.16.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7e36fbfba65510548156902bcf1350a979a958ce0347ce0f90d73894036b39f", size = 11446761, upload-time = "2026-08-07T13:30:28.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/4a/ef991fb2fcf516ab71f0808adcdd8da5e18c8cde447f4ceaf5f47a5132a5/ruff-0.16.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f0eab35f80df8f134aae5d1630e751901321d317cc8e50dc39e36fa3ed34cd12", size = 12336364, upload-time = "2026-08-07T13:30:31.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/24/f615e74f307e6ca0e56a482872477b856c70d530aa356abfb6dfe5ca8a80/ruff-0.16.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ea8c0594feb894e89c8c61ab9c103d38b0ea72dfde6c594107147ca31b1140", size = 11630720, upload-time = "2026-08-07T13:30:34.426Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/d3/8ef50149e8412a77f7ab409efdef0e2b23803707a3863da4fc64cb23d459/ruff-0.16.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab3d62dde0b19facdd632008cc4827fc28ada7736c6bd35ab6f1050f0bfed53f", size = 11466130, upload-time = "2026-08-07T13:30:36.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/a7/a19334985c4dea8c381981fa252cd854c7ee52dc4b1686dc16f4a911c702/ruff-0.16.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e43e1f5b8388da9eca1b9e88328d47a5cec794633ccf6f7484ac2dd15eee92c0", size = 11523634, upload-time = "2026-08-07T13:30:39.822Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/6c/96d192b0e742412ceda08c0a50f9669b253dde9fd6a60ea1a10c9fa79a63/ruff-0.16.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c24788a980581e1d7ea3a0cbe4344c4fbeb0a6a9b1f4713aa46bb104f8294690", size = 10949807, upload-time = "2026-08-07T13:30:42.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/51/e26599ceca11e79ee255c7df515995561edf87e9ca1893284e44d98f5a86/ruff-0.16.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:81806b08329130005dd4a8a8394a0c9da8c6f4cafb16ba438d2a2ee6a18bedf1", size = 10646891, upload-time = "2026-08-07T13:30:45.522Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/01/800c4b1f97bc8d7c6029e06b1f20473a3cf1e13c4933d8f3342add83fc55/ruff-0.16.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4ce4e02bad779bef557f541a1b31f20d6abeae1cc05ed1b1ac019d4ffd1044c8", size = 11162063, upload-time = "2026-08-07T13:30:48.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/d0/1477ea50fc5a0d4b0b71d1d63d50770bdd794d90b43e37a7618e63ec9894/ruff-0.16.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e0422abdf70070255fc4073ce9dfc814cc03db577013761ddd09bc1e4a9a4fbd", size = 11556038, upload-time = "2026-08-07T13:30:50.686Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/76/a7776f32048d991e16d4fa8ff91790b877342d3596cc3ed04acdbf1aaedc/ruff-0.16.2-py3-none-win32.whl", hash = "sha256:bf3a63d78fb39f4bf5ac8ae52051c5520505301abe19ba4e204c453b3f09bb0b", size = 10872850, upload-time = "2026-08-07T13:30:53.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/0d/929c800d920e61397d82a01b60bffc68da3052c17d31de59efaad2e4ed75/ruff-0.16.2-py3-none-win_amd64.whl", hash = "sha256:bcabe2f6d0fc7819f1431793005af4e4de7371927d037345bf941252b195b9fa", size = 12023338, upload-time = "2026-08-07T13:30:56.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/6c/93e26c22c5f78ff87363e07da49c84955affbeb1098bd1936bf3b3f293bf/ruff-0.16.2-py3-none-win_arm64.whl", hash = "sha256:d614e95cedf38a2053fd351c55b103ba30d017d61688fdbfd40ee0412852a99f", size = 11374065, upload-time = "2026-08-07T13:30:58.775Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/96/493770daebd68c0a67f1549fdf519f53be51fc435186c0585bcc272fd76c/ruff-0.16.3-py3-none-linux_armv6l.whl", hash = "sha256:0c5710e247a58a4521e66e124ba9a74655b414f61ba3a2e9e3811e11098f48f7", size = 10902799, upload-time = "2026-08-13T15:16:27.382Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/e6/2becf3942fddc29a29b8df47691d456fb1085391a694f74d84513251418c/ruff-0.16.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fe155130631a2471fd2e14a7a664a4dfbd7194b8229c3d7b2a40b21178639081", size = 11135539, upload-time = "2026-08-13T15:16:30.87Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/1e/4b8b72f0d006dbf19326aa99f9ca0ee2ff374187c4d301cf529a51aa06fe/ruff-0.16.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e2ed719e14aa64d895c2ee922594a90a43c861a93f0575a95ff8c47cdbd13eb9", size = 10475095, upload-time = "2026-08-13T15:16:33.259Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/32/2201fa49ba1f6c101ee321e83f051ac7a4b8d07b0ef6b4d3f2772b302275/ruff-0.16.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e0b1da805eb043654645d74d5de1e5ce2edc686e40790d2b86f56d71cc06a84", size = 10668771, upload-time = "2026-08-13T15:16:35.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/66/4afc5c8363bd04d45effce1b7c8713ca037d7a6740b7451a2403a6e3a972/ruff-0.16.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a37bdea0bbe21780f590bf437d6412c8c4e1b6cd010f91a65c2c40c5e5f5f870", size = 10699568, upload-time = "2026-08-13T15:16:38.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/fd/c67d246bf36bf1698551c56de39e95cd07f70e64433e0098e6267d77061b/ruff-0.16.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09571e6d1288ed9be475207a3ac04ada404f1cd898104be0f6ab8d7df438575b", size = 11499365, upload-time = "2026-08-13T15:16:40.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/0b/00ecbceb99a263af7b12f6f05ac3c92bc47b905e91adc3f207a836e3bc01/ruff-0.16.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c18c5a101eb540010638cc1ff3c84944d3adb3df62b8d98ca8f22ba484d3413", size = 12311728, upload-time = "2026-08-13T15:16:43.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/b2/b7b3bb54f4d3f7db504e476ad4ab8de530dceebe2c061384b2757ee419e8/ruff-0.16.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8457c44f15033c85ddbb77b15d451df9e24e4bd03b628396dd3610cedc3b8f82", size = 11699896, upload-time = "2026-08-13T15:16:46.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/30/4c468429ac195addc5ee1b717b6ab1b66632786737ca3b2ed3443fb0c26a/ruff-0.16.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:294b95c4ae0cda9388525c2047778aa758d6b8d4bb876fd4e9eaa3ebc92343eb", size = 11058736, upload-time = "2026-08-13T15:16:48.823Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/67/7a113cdaddf24b64d7f75b1242a99d04c82fcef4f6921fdbb832beaffb5f/ruff-0.16.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3d0c7c40c87c2a820509c31ba007968da6e1306468c067b2d82fbfdbcd0e8474", size = 11586911, upload-time = "2026-08-13T15:16:51.913Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/c1/2e66f24c0f3ead25a5e660111778685e505e5da353c82802bf49f0cbe7b9/ruff-0.16.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9f738c0fdfa8eed0b2ce7fb27ee7258208a92a68d7949e62aa15164bc7b389da", size = 10954265, upload-time = "2026-08-13T15:16:54.763Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/ba/4cee23bf52cba9a058d3726de623624daf50ef9638868edd86f4126157f6/ruff-0.16.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fb785f0be25abe69d320415cd4f833b59e17ba7613d9ba6a958023b6bceb0a50", size = 10709886, upload-time = "2026-08-13T15:16:57.339Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/df/7da7194fa5d9dc0a285f7e6fa5a4722e7c63faac0b45b614ded9314363a1/ruff-0.16.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c5536e3acfbf9563085aa2be7b13c629c3077e902afc5b941ac44024dbb9f506", size = 11210392, upload-time = "2026-08-13T15:17:00.171Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/85/7795f6e817af050e7517bf3e7aa9b061cce70ef33d280aad902c956c1ecf/ruff-0.16.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a2d85c02f9b8e165d85e6779184d38c4132de12603dab59c51c28e22584f9e4d", size = 11626910, upload-time = "2026-08-13T15:17:03.299Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/9b/475b927cf27a5cbbda3c7bafb69ed6ff77e1d7923d5d85f17c2749d7ae32/ruff-0.16.3-py3-none-win32.whl", hash = "sha256:388cdf2166642bd9b13d52b5932d3170f34f8abed7e8d9a855f1d84b83645a0a", size = 10931415, upload-time = "2026-08-13T15:17:05.726Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/99/e2a2bfc4fbf0a1e8a916bc9ebe6fe6c58cc34c28e0ffc6ce281d572d1c2e/ruff-0.16.3-py3-none-win_amd64.whl", hash = "sha256:e80a7d69ca2a6d1c4d352ec91458cdca6e56c83cdbcabd93e4abe1e53591d948", size = 11445993, upload-time = "2026-08-13T15:17:08.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/3e/4132e539aed78c148854d4997a2685b0ed4dc4e87110b59ce528564e184e/ruff-0.16.3-py3-none-win_arm64.whl", hash = "sha256:b8ca152da82c1acc1fa8d5874b15951935f0eef46f10e6954c83859011b6178a", size = 11399302, upload-time = "2026-08-13T15:17:10.908Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1510,13 +1510,13 @@ requires-dist = [
|
||||
{ name = "gevent" },
|
||||
{ name = "gevent-websocket" },
|
||||
{ name = "gunicorn" },
|
||||
{ name = "httpx", extras = ["http2"], specifier = ">=0.27" },
|
||||
{ name = "httpx", extras = ["http2"], specifier = ">=0.28.1" },
|
||||
{ name = "psutil" },
|
||||
{ name = "pyautogui", marker = "extra == 'browser'" },
|
||||
{ name = "python-socketio" },
|
||||
{ name = "python-xlib", marker = "extra == 'browser'" },
|
||||
{ name = "pyvirtualdisplay", marker = "extra == 'browser'" },
|
||||
{ name = "qbittorrent-api", specifier = ">=2026.8.0" },
|
||||
{ name = "qbittorrent-api", specifier = ">=2026.8.1" },
|
||||
{ name = "rarfile" },
|
||||
{ name = "requests", extras = ["socks"] },
|
||||
{ name = "seleniumbase", marker = "extra == 'browser'", specifier = "==4.51.12" },
|
||||
@@ -1527,12 +1527,12 @@ provides-extras = ["browser"]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "basedpyright", specifier = ">=1.39.9" },
|
||||
{ name = "basedpyright", specifier = ">=1.39.10" },
|
||||
{ name = "prek" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "pytest-xdist", specifier = ">=3.8.0" },
|
||||
{ name = "ruff", specifier = "==0.16.2" },
|
||||
{ name = "ruff", specifier = "==0.16.3" },
|
||||
{ name = "vulture", specifier = ">=2.14" },
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user