Compare commits

...
7 Commits
Author SHA1 Message Date
CaliBrain fae6140c6a fix(bypass): scope browser cleanup to the calling session (#1232)
The orphan sweep ran a container-wide 'pkill -9 -f
chrome|chromium|Xvfb|ffmpeg', so it also matched browsers another bypass
was still driving. Scope it by process group: kill only our own group
and groups whose leader has died. Spawn the helper with
start_new_session so its browser tree is identifiable, tear the whole
group down after every run (a timed-out helper used to leak its Chrome
and Xvfb), and have an orphaned helper take its browser down with it.
Fixes #1231.
2026-08-18 23:24:43 -04:00
dependabot[bot] 63133097e4 build(deps): update httpx[http2] requirement from >=0.27 to >=0.28.1 (#1227)
Updates the requirements on
[httpx[http2]](https://github.com/encode/httpx) to permit the latest
version.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/encode/httpx/releases">httpx[http2]'s
releases</a>.</em></p>
<blockquote>
<h2>Version 0.28.1</h2>
<h2>0.28.1 (6th December, 2024)</h2>
<ul>
<li>Fix SSL case where <code>verify=False</code> together with client
side certificates.</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/encode/httpx/blob/master/CHANGELOG.md">httpx[http2]'s
changelog</a>.</em></p>
<blockquote>
<h2>0.28.1 (6th December, 2024)</h2>
<ul>
<li>Fix SSL case where <code>verify=False</code> together with client
side certificates.</li>
</ul>
<h2>0.28.0 (28th November, 2024)</h2>
<p>Be aware that the default <em>JSON request bodies now use a more
compact representation</em>. This is generally considered a prefered
style, tho may require updates to test suites.</p>
<p>The 0.28 release includes a limited set of deprecations...</p>
<p><strong>Deprecations</strong>:</p>
<p>We are working towards a simplified SSL configuration API.</p>
<p><em>For users of the standard <code>verify=True</code> or
<code>verify=False</code> cases, or
<code>verify=&lt;ssl_context&gt;</code> case this should require no
changes. The following cases have been deprecated...</em></p>
<ul>
<li>The <code>verify</code> argument as a string argument is now
deprecated and will raise warnings.</li>
<li>The <code>cert</code> argument is now deprecated and will raise
warnings.</li>
</ul>
<p>Our revised <a
href="https://github.com/encode/httpx/blob/master/docs/advanced/ssl.md">SSL
documentation</a> covers how to implement the same behaviour with a more
constrained API.</p>
<p><strong>The following changes are also included</strong>:</p>
<ul>
<li>The deprecated <code>proxies</code> argument has now been
removed.</li>
<li>The deprecated <code>app</code> argument has now been removed.</li>
<li>JSON request bodies use a compact representation. (<a
href="https://redirect.github.com/encode/httpx/issues/3363">#3363</a>)</li>
<li>Review URL percent escape sets, based on WHATWG spec. (<a
href="https://redirect.github.com/encode/httpx/issues/3371">#3371</a>,
<a
href="https://redirect.github.com/encode/httpx/issues/3373">#3373</a>)</li>
<li>Ensure <code>certifi</code> and <code>httpcore</code> are only
imported if required. (<a
href="https://redirect.github.com/encode/httpx/issues/3377">#3377</a>)</li>
<li>Treat <code>socks5h</code> as a valid proxy scheme. (<a
href="https://redirect.github.com/encode/httpx/issues/3178">#3178</a>)</li>
<li>Cleanup <code>Request()</code> method signature in line with
<code>client.request()</code> and <code>httpx.request()</code>. (<a
href="https://redirect.github.com/encode/httpx/issues/3378">#3378</a>)</li>
<li>Bugfix: When passing <code>params={}</code>, always strictly update
rather than merge with an existing querystring. (<a
href="https://redirect.github.com/encode/httpx/issues/3364">#3364</a>)</li>
</ul>
<h2>0.27.2 (27th August, 2024)</h2>
<h3>Fixed</h3>
<ul>
<li>Reintroduced supposedly-private <code>URLTypes</code> shortcut. (<a
href="https://redirect.github.com/encode/httpx/issues/2673">#2673</a>)</li>
</ul>
<h2>0.27.1 (27th August, 2024)</h2>
<h3>Added</h3>
<ul>
<li>Support for <code>zstd</code> content decoding using the python
<code>zstandard</code> package is added. Installable using
<code>httpx[zstd]</code>. (<a
href="https://redirect.github.com/encode/httpx/issues/3139">#3139</a>)</li>
</ul>
<h3>Fixed</h3>
<ul>
<li>Improved error messaging for <code>InvalidURL</code> exceptions. (<a
href="https://redirect.github.com/encode/httpx/issues/3250">#3250</a>)</li>
<li>Fix <code>app</code> type signature in <code>ASGITransport</code>.
(<a
href="https://redirect.github.com/encode/httpx/issues/3109">#3109</a>)</li>
</ul>
<h2>0.27.0 (21st February, 2024)</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/encode/httpx/commit/26d48e0634e6ee9cdc0533996db289ce4b430177"><code>26d48e0</code></a>
Version 0.28.1 (<a
href="https://redirect.github.com/encode/httpx/issues/3445">#3445</a>)</li>
<li><a
href="https://github.com/encode/httpx/commit/89599a9541af14bcf906fc4ed58ccbdf403802ba"><code>89599a9</code></a>
Fix <code>verify=False</code>, <code>cert=...</code> case. (<a
href="https://redirect.github.com/encode/httpx/issues/3442">#3442</a>)</li>
<li><a
href="https://github.com/encode/httpx/commit/8ecb86f0d74ffc52d4663214fae9526bee89358d"><code>8ecb86f</code></a>
Add test for request params behavior changes (<a
href="https://redirect.github.com/encode/httpx/issues/3364">#3364</a>)
(<a
href="https://redirect.github.com/encode/httpx/issues/3440">#3440</a>)</li>
<li><a
href="https://github.com/encode/httpx/commit/0cb7e5a2e736628e2f506d259fcf0d48cd2bde82"><code>0cb7e5a</code></a>
Bump the python-packages group with 11 updates (<a
href="https://redirect.github.com/encode/httpx/issues/3434">#3434</a>)</li>
<li><a
href="https://github.com/encode/httpx/commit/15e21e9ea3cad4f06e22a7e704aabefdf43d2e29"><code>15e21e9</code></a>
Updating deprecated docstring Client() class (<a
href="https://redirect.github.com/encode/httpx/issues/3426">#3426</a>)</li>
<li><a
href="https://github.com/encode/httpx/commit/80960fa31918d7663c3f4c3ad61661cf0e80628f"><code>80960fa</code></a>
Version 0.28.0. (<a
href="https://redirect.github.com/encode/httpx/issues/3419">#3419</a>)</li>
<li><a
href="https://github.com/encode/httpx/commit/a33c87852b8a0dddc65e5f739af1e0a6fca4b91f"><code>a33c878</code></a>
Fix <code>extensions</code> type annotation. (<a
href="https://redirect.github.com/encode/httpx/issues/3380">#3380</a>)</li>
<li><a
href="https://github.com/encode/httpx/commit/ce7e14da27abba6574be9b3ea7cd5990556a9343"><code>ce7e14d</code></a>
Error on verify as str. (<a
href="https://redirect.github.com/encode/httpx/issues/3418">#3418</a>)</li>
<li><a
href="https://github.com/encode/httpx/commit/47f4a96ffaaaa07dca1614409549b5d7a6e7af49"><code>47f4a96</code></a>
Handle empty zstd responses (<a
href="https://redirect.github.com/encode/httpx/issues/3412">#3412</a>)</li>
<li><a
href="https://github.com/encode/httpx/commit/189fc4bcbe5f314128775dec66a616ac9a31ad48"><code>189fc4b</code></a>
Update CHANGELOG.md, fix typo(s) (<a
href="https://redirect.github.com/encode/httpx/issues/3406">#3406</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/encode/httpx/compare/0.27.0...0.28.1">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-18 22:22:06 -04:00
dependabot[bot] 82aeee387e build(deps-dev): bump the python-deps group with 2 updates (#1226)
Bumps the python-deps group with 2 updates:
[basedpyright](https://github.com/detachhead/basedpyright) and
[ruff](https://github.com/astral-sh/ruff).

Updates `basedpyright` from 1.39.9 to 1.39.10
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/detachhead/basedpyright/releases">basedpyright's
releases</a>.</em></p>
<blockquote>
<h2>v1.39.10 (pyright 1.1.412)</h2>
<h2>What's Changed</h2>
<ul>
<li>add <code>allowedUntypedLibraries</code> and
<code>failOnWarnings</code> to <code>pyrightconfig.schema.json</code> by
<a href="https://github.com/DetachHead"><code>@​DetachHead</code></a> in
<a
href="https://redirect.github.com/DetachHead/basedpyright/pull/1851">DetachHead/basedpyright#1851</a></li>
<li>Update pycharm setup instructions by <a
href="https://github.com/charliecloudberry"><code>@​charliecloudberry</code></a>
in <a
href="https://redirect.github.com/DetachHead/basedpyright/pull/1862">DetachHead/basedpyright#1862</a></li>
<li>Merge 1.1.412 by <a
href="https://github.com/DetachHead"><code>@​DetachHead</code></a> in <a
href="https://redirect.github.com/DetachHead/basedpyright/pull/1869">DetachHead/basedpyright#1869</a></li>
<li>fix redundant <code>workspace/configuration</code> request by <a
href="https://github.com/DetachHead"><code>@​DetachHead</code></a> in <a
href="https://redirect.github.com/DetachHead/basedpyright/pull/1847">DetachHead/basedpyright#1847</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/vmphase"><code>@​vmphase</code></a> made
their first contribution in <a
href="https://redirect.github.com/DetachHead/basedpyright/pull/1866">DetachHead/basedpyright#1866</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/DetachHead/basedpyright/compare/v1.39.9...v1.39.10">https://github.com/DetachHead/basedpyright/compare/v1.39.9...v1.39.10</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/DetachHead/basedpyright/commit/6d830bac284253dc6587d35eb026a9a30aee7771"><code>6d830ba</code></a>
1.39.10</li>
<li><a
href="https://github.com/DetachHead/basedpyright/commit/a34496c27eb0805e4ac39976361526c92d6b3f59"><code>a34496c</code></a>
fix redundant <code>workspace/configuration</code> request</li>
<li><a
href="https://github.com/DetachHead/basedpyright/commit/b3074fe4dfa981a1928aebc4d9694ae4e5c03fc9"><code>b3074fe</code></a>
fix links in tsp docs</li>
<li><a
href="https://github.com/DetachHead/basedpyright/commit/979a3fc4d9a86c018a11aeebf17822c468b71ab2"><code>979a3fc</code></a>
add <code>nodejs-wheel</code> back as a dev dependency</li>
<li><a
href="https://github.com/DetachHead/basedpyright/commit/285225059bae1e3d41b7ff198d8f1d3e07b7141a"><code>2852250</code></a>
ignore <code>mypy_primer/build</code> in bpr</li>
<li><a
href="https://github.com/DetachHead/basedpyright/commit/5c4427f639c26a4917747ebd5d133ca425e807cc"><code>5c4427f</code></a>
baseline type errors from new upstream python file</li>
<li><a
href="https://github.com/DetachHead/basedpyright/commit/ae420b5e0cbcad508d93f603282c5e2614d58182"><code>ae420b5</code></a>
try to fix primer</li>
<li><a
href="https://github.com/DetachHead/basedpyright/commit/0e5c88e47a8ee57ab73b37702a72997976706405"><code>0e5c88e</code></a>
fixes from merge</li>
<li><a
href="https://github.com/DetachHead/basedpyright/commit/78adf4f989315b4821d30c3b79f0a0aa3732bb07"><code>78adf4f</code></a>
don't support TSP</li>
<li><a
href="https://github.com/DetachHead/basedpyright/commit/eff463de301edaf268c816a2be618de4c40b6b62"><code>eff463d</code></a>
Merge tag '1.1.412' into merge-1.1.412</li>
<li>Additional commits viewable in <a
href="https://github.com/detachhead/basedpyright/compare/v1.39.9...v1.39.10">compare
view</a></li>
</ul>
</details>
<br />

Updates `ruff` from 0.16.2 to 0.16.3
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/astral-sh/ruff/releases">ruff's
releases</a>.</em></p>
<blockquote>
<h2>0.16.3</h2>
<h2>Release Notes</h2>
<p>Released on 2026-08-13.</p>
<h3>Preview features</h3>
<ul>
<li>[<code>pylint</code>] Fix false negatives on negative numbers
(<code>PLR6104</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27251">#27251</a>)</li>
<li>[<code>pyupgrade</code>] Add rule to replace <code>while 1</code>
with <code>while True</code> (<code>UP048</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27190">#27190</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>[<code>flake8-bandit</code>] Also check keyword arguments
(<code>S602</code>, <code>S603</code>, <code>S607</code>,
<code>S609</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27687">#27687</a>)</li>
<li>[<code>pylint</code>] Allow <code>continue</code> in
<code>finally</code> on Python 3.8 (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27626">#27626</a>)</li>
<li>[<code>pylint</code>] Fix <code>PLE1307</code> false positive with
bools (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27651">#27651</a>)</li>
<li>[<code>pylint</code>] Fix false positives and negatives with
<code>%b</code> format character (<code>PLE1300</code>,
<code>PLE1307</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27560">#27560</a>)</li>
<li>[<code>pylint</code>] Improve handling of concatenated strings
(<code>PLE1300</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27659">#27659</a>)</li>
</ul>
<h3>Rule changes</h3>
<ul>
<li>[<code>numpy</code>] Make <code>np.chararray</code> autofix
backwards-compatible (<code>NPY201</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27527">#27527</a>)</li>
</ul>
<h3>Performance</h3>
<ul>
<li>Enable PGO for Linux x86-64 Ruff releases (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27570">#27570</a>)</li>
<li>Enable PGO for Linux ARM64 Ruff releases (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27574">#27574</a>)</li>
<li>Enable PGO for Windows x86-64 Ruff releases (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27573">#27573</a>)</li>
<li>Enable PGO for macOS ARM64 Ruff releases (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27572">#27572</a>)</li>
<li>Reduce <code>Expr</code> size to 64 bytes (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27591">#27591</a>)</li>
</ul>
<h3>CLI</h3>
<ul>
<li>Hyperlink rule codes in <code>ruff check --statistics</code> output
(<a
href="https://redirect.github.com/astral-sh/ruff/pull/27646">#27646</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>[<code>ruff</code>] Also suggest <code>asyncio.TaskGroup</code>
(<code>RUF006</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27461">#27461</a>)</li>
</ul>
<h3>Other changes</h3>
<ul>
<li>Use mimalloc v3 (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27586">#27586</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a
href="https://github.com/Andrej730"><code>@​Andrej730</code></a></li>
<li><a
href="https://github.com/alonfaraj"><code>@​alonfaraj</code></a></li>
<li><a
href="https://github.com/romero-deshaw"><code>@​romero-deshaw</code></a></li>
<li><a href="https://github.com/Avasam"><code>@​Avasam</code></a></li>
<li><a href="https://github.com/tjkuson"><code>@​tjkuson</code></a></li>
<li><a
href="https://github.com/charliermarsh"><code>@​charliermarsh</code></a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md">ruff's
changelog</a>.</em></p>
<blockquote>
<h2>0.16.3</h2>
<p>Released on 2026-08-13.</p>
<h3>Preview features</h3>
<ul>
<li>[<code>pylint</code>] Fix false negatives on negative numbers
(<code>PLR6104</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27251">#27251</a>)</li>
<li>[<code>pyupgrade</code>] Add rule to replace <code>while 1</code>
with <code>while True</code> (<code>UP048</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27190">#27190</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>[<code>flake8-bandit</code>] Also check keyword arguments
(<code>S602</code>, <code>S603</code>, <code>S607</code>,
<code>S609</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27687">#27687</a>)</li>
<li>[<code>pylint</code>] Allow <code>continue</code> in
<code>finally</code> on Python 3.8 (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27626">#27626</a>)</li>
<li>[<code>pylint</code>] Fix <code>PLE1307</code> false positive with
bools (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27651">#27651</a>)</li>
<li>[<code>pylint</code>] Fix false positives and negatives with
<code>%b</code> format character (<code>PLE1300</code>,
<code>PLE1307</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27560">#27560</a>)</li>
<li>[<code>pylint</code>] Improve handling of concatenated strings
(<code>PLE1300</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27659">#27659</a>)</li>
</ul>
<h3>Rule changes</h3>
<ul>
<li>[<code>numpy</code>] Make <code>np.chararray</code> autofix
backwards-compatible (<code>NPY201</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27527">#27527</a>)</li>
</ul>
<h3>Performance</h3>
<ul>
<li>Enable PGO for Linux x86-64 Ruff releases (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27570">#27570</a>)</li>
<li>Enable PGO for Linux ARM64 Ruff releases (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27574">#27574</a>)</li>
<li>Enable PGO for Windows x86-64 Ruff releases (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27573">#27573</a>)</li>
<li>Enable PGO for macOS ARM64 Ruff releases (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27572">#27572</a>)</li>
<li>Reduce <code>Expr</code> size to 64 bytes (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27591">#27591</a>)</li>
</ul>
<h3>CLI</h3>
<ul>
<li>Hyperlink rule codes in <code>ruff check --statistics</code> output
(<a
href="https://redirect.github.com/astral-sh/ruff/pull/27646">#27646</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>[<code>ruff</code>] Also suggest <code>asyncio.TaskGroup</code>
(<code>RUF006</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27461">#27461</a>)</li>
</ul>
<h3>Other changes</h3>
<ul>
<li>Use mimalloc v3 (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27586">#27586</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a
href="https://github.com/Andrej730"><code>@​Andrej730</code></a></li>
<li><a
href="https://github.com/alonfaraj"><code>@​alonfaraj</code></a></li>
<li><a
href="https://github.com/romero-deshaw"><code>@​romero-deshaw</code></a></li>
<li><a href="https://github.com/Avasam"><code>@​Avasam</code></a></li>
<li><a href="https://github.com/tjkuson"><code>@​tjkuson</code></a></li>
<li><a
href="https://github.com/charliermarsh"><code>@​charliermarsh</code></a></li>
<li><a
href="https://github.com/chirizxc"><code>@​chirizxc</code></a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/astral-sh/ruff/commit/b0e47022cfce4f3594aa26d15ea792681430b6f6"><code>b0e4702</code></a>
Bump 0.16.3 (<a
href="https://redirect.github.com/astral-sh/ruff/issues/27723">#27723</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/ecdd401fdbc5b0b22e18759c8bd25cda452e8b32"><code>ecdd401</code></a>
[ty] Separate script and uv modules from project metadata (<a
href="https://redirect.github.com/astral-sh/ruff/issues/27720">#27720</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/126352467217bebfa4cb86fd3c4d20820322d9e3"><code>1263524</code></a>
[ty] Simplify display implementations with std::fmt::from_fn (<a
href="https://redirect.github.com/astral-sh/ruff/issues/27718">#27718</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/59196baedf23c9876d1fcf1fa2ae78f80d306f94"><code>59196ba</code></a>
[ty] Unify polarity-aware relation construction (<a
href="https://redirect.github.com/astral-sh/ruff/issues/27707">#27707</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/b8c5e73abe5b15a74fb066e474d30397d1421cfe"><code>b8c5e73</code></a>
[ty] Disable CodSpeed cycle estimation for instrumented benchmarks (<a
href="https://redirect.github.com/astral-sh/ruff/issues/27706">#27706</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/2b0d21094e2a55491bff60c07fd6f8803876cae5"><code>2b0d210</code></a>
[ty] Centralize matched argument relations (<a
href="https://redirect.github.com/astral-sh/ruff/issues/27705">#27705</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/a9130f3381fe137626d22288c0d45f996541ca7e"><code>a9130f3</code></a>
[<code>pyupgrade</code>] Add rule to replace <code>while 1</code> with
<code>while True</code> (<code>while-one</code>, `...</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/c64c7d6dad1e0a4966ce578b2c03af1e8e7673e1"><code>c64c7d6</code></a>
[ty] Model try exception flow with operation checkpoints (<a
href="https://redirect.github.com/astral-sh/ruff/issues/27471">#27471</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/9dea5ef180b3de748b5fe45787056716f235d11a"><code>9dea5ef</code></a>
[ty] Avoid deriving sequents for typevars with concrete bounds (<a
href="https://redirect.github.com/astral-sh/ruff/issues/27587">#27587</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/9798e88de673ec73051980ebd9aeb681161f3c27"><code>9798e88</code></a>
[ty] Preserve enum exhaustiveness with custom <em>missing</em> methods
(<a
href="https://redirect.github.com/astral-sh/ruff/issues/27700">#27700</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/astral-sh/ruff/compare/0.16.2...0.16.3">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-18 22:21:52 -04:00
CaliBrain 4cd1091d16 fix(hardcover): send the field count Hardcover's Book search requires (#1224)
Advanced title search, advanced title+author search, and the title
typeahead returned zero results every time, and the sort fallback added
in #1183 blamed the sort value for it.

Hardcover turns the `fields` search parameter into Typesense's
`query_by`
but keeps `num_typos` and `query_by_weights` as fixed-length presets per
query_type. For query_type=Book the preset expects exactly five fields,
so a shorter list is not searched loosely - the whole search is rejected
with a null results body. Confirmed against the live API: 1, 2, 3, 4 and
6 fields are all rejected, only 5 works, and weights must match
one-for-one when sent. Every Book-type list we sent was the wrong length
- the title typeahead and advanced title search sent 2, title+author
sent 3.

- Send BOOK_SEARCH_FIELDS (the full five) for every narrowed Book search
  and express the intent through weights instead. Weights only bias
  ranking - a field weighted 0 still matches - so a title search now
  ranks titles first rather than restricting to them. That is the
  closest behaviour Hardcover still allows, and there is no client-side
  filter to restore the old precision.
- Pin the field and weight counts in tests, since the failure mode is a
  silent zero results rather than an error.

The sort fallback from #1183 also misread these rejections:

- Select the `error` field on every search and log Hardcover's own
  explanation. The reason is only ever in that sibling field, so a
  rejection surfaced as "returned no result body" with nothing to act
  on. Reading it is what made the field-count rule findable.
- Drop `sort` entirely on the retry instead of sending an empty string.
  An empty sort is a value like any other and can be rejected too.
- Arm the 900s sticky window only after the sortless retry succeeds. It
  was armed before the retry and never rolled back, so one rejected
  typeahead disabled sorting process-wide for 15 minutes whatever the
  actual cause.

Verified against the live Hardcover API: advanced title search 0 -> 84
results, title+author 0 -> 139, title typeahead 0 -> 84 with the exact
title top. 2566 unit tests pass; ruff, basedpyright and vulture clean.

Refs #1183. The sort_by regression #1183 was written for is gone from
Hardcover's side - every sort value it rejected, including the one in
the report, is accepted again today. Two plain-search rejections in that
report (fields=None) remain unexplained: they could not be reproduced
under any per_page, page depth, sort value or query shape, and are most
likely transient upstream. They now self-report the reason if they
recur.
2026-08-16 20:55:08 -04:00
CaliBrain 651096ed7b fix(bypass): reuse external bypasser clearance instead of re-solving (#1223)
Direct download was unusable behind an external bypasser (FlareSolverr /
Byparr): every request paid a 403 plus a full solve, and a search that
never ran was reported to the user as "No books found".

Clearance was discarded on the external path. get_cf_cookies_for_domain
and get_cf_user_agent_for_domain returned {} / None whenever
USING_EXTERNAL_BYPASSER was set, and _fetch_via_bypasser read only
solution.response - dropping solution.cookies and solution.userAgent,
which FlareSolverr-compatible services do return. A solve therefore
cleared the one request that paid for it and nothing else, and a file
download - which the solver cannot proxy, being binary - presented no
clearance at all. Diagnosed from a v1.3.9 debug bundle: ~35s in the
bypasser per search, on every search.

- Move the cookie jar out of internal_bypasser into bypass/cookie_store.
  internal_bypasser imports seleniumbase at module scope, which is the
  dependency an external-bypasser deployment is entitled not to have, so
  it cannot host a store the external path depends on.
- Harvest solution.cookies and solution.userAgent after a successful
  solve. The existing filtering applies unchanged, so the per-check
  __ddg8_/__ddg9_/__ddg10_ trio is still dropped and the external path
  cannot reintroduce the ?check=1 loop fixed in ebb833a. The UA matters
  as much as the cookies: Cloudflare ties cf_clearance to the UA that
  solved the challenge.
- Read cookie fields from either shape - CDP objects or JSON mappings.
  Both use the same field names, expires included.
- Point http.py's getters and _purge_clearance at the shared store, so
  either bypasser fills and drains the same jar.
- Give the Docker helper-subprocess handoff explicit export_store /
  import_store rather than reaching into module globals.

An unsolved challenge was also indistinguishable from an empty result.
_looks_like_aa_page() counted the challenge markers as "recognisably
AA", so _fetch_search_table handed a DDoS-Guard interstitial back as a
legitimate no-table response and the user was told their query found
nothing when the search never ran. Split challenge detection out and
raise SearchUnavailableError with the reason instead. The mirror is
still not quarantined - every mirror shares the same protection, so it
is not the mirror's fault.

Verified: 2531 unit tests pass; ruff, basedpyright and vulture clean;
e2e bypasser-external profile passes (5). Its mock FlareSolverr already
returned cookies and userAgent from /v1 - the contract was there,
shelfmark was not reading it.

Refs #1220. Deliberately not "Fixes": this removes the re-solve and
makes a failed solve legible, but if Byparr genuinely cannot clear AA's
current DDoS-Guard, the reporter now gets that as an error rather than a
silent "no books found". The download path may swallow interstitials the
same way; not audited here.
2026-08-16 12:08:45 -04:00
CaliBrain ebb833a82c fix(bypass): discard rejected DDoS-Guard cookies instead of replaying them (#1221)
A cookie that has been rejected was kept and presented again on every
later
request, so a single bad clearance could re-arm the challenge
indefinitely.

Cookie storage:
- Enforce expiry for every stored cookie, not just cf_clearance.
DDoS-Guard
domains have no cf_clearance, so the existing check never fired for them
and
  expired cookies were replayed forever.
- Stop storing the per-check cookies __ddg8_/__ddg9_/__ddg10_ and
ddg_last_challenge. Captured live from Anna's Archive, these carry the
client
IP and the timestamp the check was issued (~40 min), versus ~1 year for
the
  __ddg1_/__ddg2_/__ddgid_ clearance. Replaying an IP-bound token stops
describing the caller as soon as the egress IP changes, which is routine
  behind a VPN.

Failure handling — every path that is rejected while carrying cookies
now
purges them, not just the redirect loop:
- 403 returned while presenting cookies.
- Cached-cookie attempt rejected, whether by status or by redirect loop.
- Factored the purge into _purge_clearance, guarded on a non-empty
hostname
since clear_cf_cookies("") means "every host" and would wipe clearance
for
  sites that are working fine.

Also fix the search warm-up switches shipped inert in v1.3.8:
SEARCH_WARMUP_ENABLED and SEARCH_WARMUP_QUERY are not in the settings
registry, and config.get only consults the environment for keys it
knows, so
both always returned their defaults — the warm-up could not be turned
off or
retargeted. Read os.environ first.

Refs #1220. Deliberately not "Fixes": the reported failure could not be
reproduced on v1.3.8 from a stable IP (the reporter's own queries all
returned
200 on both the pre- and post-change builds), and the new purge paths
did not
fire in live testing because the failures arrive as redirect loops,
which were
already purged. These are correctness fixes with no measured effect on
that
issue. The underlying problem remains that Chrome-obtained cookies never
satisfy DDoS-Guard when replayed by requests, so every search still
re-solves.

Verified: 2542 unit tests pass; ruff, basedpyright and vulture clean;
e2e
platform baseline (10), full (6) and bypasser-external (5) all pass;
five
sequential live searches against Anna's Archive all returned 200 with
zero
"Exceeded 30 redirects".
2026-08-15 17:08:11 -04:00
CaliBrain b7093f4594 Fix log to debug DNS (#1219) 2026-08-15 15:30:29 -04:00
20 changed files with 1549 additions and 321 deletions
+3 -3
View File
@@ -25,7 +25,7 @@ dependencies = [
"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",
]
+5 -3
View File
@@ -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.
@@ -238,9 +238,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
+260
View File
@@ -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()
+37 -1
View File
@@ -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)",
+175 -192
View File
@@ -5,7 +5,6 @@ import asyncio
import json
import os
import random
import shutil
import signal
import socket
import stat
@@ -28,6 +27,14 @@ from seleniumbase import cdp_driver
from seleniumbase.undetected.cdp_driver.connection import ProtocolException
from shelfmark.bypass import BypassCancelledError
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
@@ -54,6 +61,7 @@ _BYPASS_SUBPROCESS_TIMEOUT_SECONDS = 420.0
# branches of get() are bounded the same way.
_IN_PROCESS_BYPASS_TIMEOUT_SECONDS = _BYPASS_SUBPROCESS_TIMEOUT_SECONDS
_BYPASS_CHILD_ENV = "SHELFMARK_INTERNAL_BYPASSER_CHILD"
_PARENT_WATCHDOG_INTERVAL_SECONDS = 5.0
# Challenge detection indicators
CLOUDFLARE_INDICATORS = [
@@ -90,8 +98,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 = (
@@ -231,96 +239,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",
}
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."""
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."""
@@ -336,117 +254,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 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:
expiry = cf_clearance.get("expiry")
if expiry is None:
expiry = cf_clearance.get("expires")
if expiry and expiry > 0 and time.time() > expiry:
logger.debug("CF cookies expired for %s", base_domain)
_cf_cookies.pop(base_domain, None)
return {}
return {name: c["value"] for name, c in cookies.items()}
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 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
@@ -913,17 +830,7 @@ def _run_bypass_in_current_process(url: str, retry: int, cancel_flag: Event | No
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]:
@@ -946,6 +853,21 @@ 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 _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")
@@ -972,14 +894,25 @@ def _get_via_subprocess(url: str, retry: int, cancel_flag: Event | None = None)
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 bypass's browsers apart
# from a concurrent worker's (#1231) and lets us kill the whole tree below.
start_new_session=True,
)
timed_out = False
try:
proc.communicate(json.dumps(payload), timeout=_BYPASS_SUBPROCESS_TIMEOUT_SECONDS)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
timed_out = True
finally:
# Always tear the session down, not just on timeout: killing the helper alone
# leaves its Chrome and Xvfb running, and those leftovers are what made the next
# worker's browser fail to start in the first place.
_terminate_helper_session(proc)
if timed_out:
msg = "Internal bypasser helper process timed out"
raise TimeoutError(msg) from None
raise TimeoutError(msg)
try:
result = json.loads(result_path.read_text())
@@ -1263,9 +1196,23 @@ def _try_with_cached_cookies(url: str, hostname: str) -> str | None:
if response.status_code == HTTPStatus.OK:
logger.debug("Cached cookies worked, skipped Chrome bypass")
return response.text
logger.debug(
"Cached cookies rejected (%s) for %s; discarding them",
response.status_code,
url,
)
except _REQUEST_OPERATION_ERRORS as exc:
# A redirect loop lands here too: DDoS-Guard answers a dead clearance cookie
# with an endless ?check=1 bounce rather than a status we can read.
logger.debug("Cached cookie retry failed for %s: %s", url, exc)
# Reached only when the cached cookies did not produce a page, so they are no
# longer clearance. Dropping them now means the imminent Chrome solve starts from
# a clean slate and later requests cannot re-present the same rejected cookie.
# Guarded because clear_cf_cookies("") means "every host", which would wipe
# clearance for sites that are working fine.
if hostname:
clear_cf_cookies(hostname)
return None
@@ -1335,6 +1282,38 @@ def _apply_parent_dns_config(dns_config: dict[str, Any]) -> None:
logger.warning("Could not apply parent DNS config (%s): %s", provider, exc)
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 _run_child_process() -> int:
"""CLI entrypoint used by the Docker helper subprocess."""
request = json.loads(sys.stdin.read() or "{}")
@@ -1350,11 +1329,12 @@ def _run_child_process() -> int:
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")
except Exception as exc: # noqa: BLE001 - helper boundary must serialize failures.
@@ -1370,4 +1350,7 @@ def _run_child_process() -> int:
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())
+35 -21
View File
@@ -10,7 +10,7 @@ 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.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 +145,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:
@@ -369,20 +363,28 @@ def html_get_page(
"""
return allow_bypasser_fallback and _is_cf_bypass_enabled() and not use_bypasser_now
def _purge_clearance(target_url: str) -> None:
"""Drop the host's stored clearance cookies.
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. 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 store, so skip the purge
# rather than wipe clearance for sites that are working fine.
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`.
A `?check=1` loop is how DDoS-Guard answers a clearance cookie that has gone
stale, so the dead cookie has to go before the solve — otherwise it is merged
back over the fresh one on the next request and the loop simply resumes. Purging
is internal-bypasser only; with an external one get_cf_cookies_for_domain()
already returns {}.
back over the fresh one on the next request and the loop simply resumes.
"""
hostname = urlparse(bypass_url).hostname or ""
# An empty domain means "clear every host" to the bypasser, 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)
_purge_clearance(bypass_url)
return _run_bypasser(bypass_url)
configured_retry = normalize_positive_int(app_config.MAX_RETRY)
@@ -567,13 +569,25 @@ 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,
)
continue
if cookies:
# Challenged *while presenting* clearance: those cookies are
# dead. Without this they survive the solve and get merged back
# over the fresh ones, so every later request re-presents a
# known-rejected cookie and is challenged again - the stale
# retry that never ends.
logger.debug("403 with cookies presented; purging: %s", current_url)
_purge_clearance(current_url)
logger.info("403 detected; switching to bypasser: %s", current_url)
# Invoke it here rather than setting use_bypasser_now and continuing.
# The branch that acts on that flag runs at the top of the *next* retry
+2 -9
View File
@@ -683,8 +683,6 @@ def create_custom_getaddrinfo(
source: str,
provider_label: str,
res: Sequence[tuple[AddressFamily, SocketKind, int, str, tuple[Any, ...]]],
*,
is_bypass: bool = False,
) -> None:
"""Emit a unified resolver log with the IPs returned.
@@ -692,7 +690,6 @@ def create_custom_getaddrinfo(
source: Description of resolver source
provider_label: Label for the DNS provider
res: Resolution results
is_bypass: If True, log at DEBUG level (for local/IP addresses)
"""
# Skip logging entirely for localhost to reduce noise
@@ -708,11 +705,7 @@ def create_custom_getaddrinfo(
ip = sockaddr[0]
if isinstance(ip, str):
ips.append(ip)
msg = f"Resolved {host_str} via {source} [{provider_label}]: {ips}"
if is_bypass:
logger.debug(msg)
else:
logger.info(msg)
logger.debug("Resolved %s via %s [%s]: %s", host_str, source, provider_label, ips)
# Skip custom resolution for IP addresses, local addresses, or if skip check passes
if (
@@ -722,7 +715,7 @@ def create_custom_getaddrinfo(
):
# Quietly bypass custom resolution for IP/local targets
res = original_getaddrinfo(host, port, family, socket_type, proto, flags)
_log_results("system resolver (bypass)", "system", res, is_bypass=True)
_log_results("system resolver (bypass)", "system", res)
return res
results: list[tuple[AddressFamily, SocketKind, int, str, tuple[Any, ...]]] = []
+18 -3
View File
@@ -15,6 +15,7 @@ source that is down at boot must not affect startup or health.
from __future__ import annotations
import os
import threading
from shelfmark.core.config import config
@@ -43,13 +44,27 @@ def _as_bool(value: object, *, default: bool) -> bool:
return bool(value)
def _setting(key: str, default: object) -> object:
"""Read a warm-up setting, preferring the deployment environment.
These keys are not in the settings registry, and ``config.get`` only consults the
environment for keys it knows about - so reading config alone silently ignored
SEARCH_WARMUP_ENABLED and always returned the default. Check os.environ first so
the documented switches actually work.
"""
raw = os.environ.get(key)
if raw is not None and raw.strip():
return raw
return config.get(key, default)
def is_enabled() -> bool:
"""Whether the boot-time warm-up search should run."""
if not _as_bool(config.get("SEARCH_WARMUP_ENABLED", True), default=True):
if not _as_bool(_setting("SEARCH_WARMUP_ENABLED", True), default=True):
return False
# Nothing to warm if the source is off, and no challenge to pre-solve without
# the bypasser - a plain search is fast enough not to need this.
if not _as_bool(config.get("DIRECT_DOWNLOAD_ENABLED", True), default=True):
if not _as_bool(_setting("DIRECT_DOWNLOAD_ENABLED", True), default=True):
logger.debug("Search warm-up skipped: direct download disabled")
return False
return True
@@ -57,7 +72,7 @@ def is_enabled() -> bool:
def warmup_query() -> str:
"""The query used to warm the source."""
raw = config.get("SEARCH_WARMUP_QUERY", _DEFAULT_QUERY)
raw = _setting("SEARCH_WARMUP_QUERY", _DEFAULT_QUERY)
query = str(raw).strip() if raw else ""
return query or _DEFAULT_QUERY
+61 -24
View File
@@ -318,6 +318,7 @@ query SearchFieldOptions(
fields: $fields,
weights: $weights
) {
error
results
}
}
@@ -536,13 +537,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 +557,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 +593,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:
@@ -1012,13 +1036,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 +2410,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 +2419,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 +2718,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:
+24 -5
View File
@@ -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(
+222
View File
@@ -0,0 +1,222 @@
"""DDoS-Guard cookie reuse between requests.
Anna's Archive issues nine cookies after a solve, and they are not equivalent:
__ddg1_/__ddg2_/__ddgid_ ~1 year clearance
__ddgmark_ ~1 day
__ddg5_ session
__ddg8_/__ddg9_/__ddg10_ ~40 min one check: token, CLIENT IP, TIMESTAMP
Replaying the last three is what produces the ?check=1 redirect loop. They describe a
single check, so once the timestamp ages out - or the egress IP changes, routine
behind a VPN - DDoS-Guard stops recognising the caller and re-arms the challenge on
every request. Storing an expired cookie and sending it forever has the same effect.
"""
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(cs, "_cf_cookies", {})
monkeypatch.setattr(cs, "_cf_user_agents", {})
class _Cookie:
"""Stand-in for the CDP cookie objects the bypasser extracts."""
def __init__(self, name, value="v", expires=None, domain="annas-archive.gl"):
self.name = name
self.value = value
self.expires = expires
self.domain = domain
self.path = "/"
self.secure = True
def _store(cookies, url="https://annas-archive.gl/search"):
cs.store_extracted_cookies(url=url, cookies=cookies, user_agent="UA/1.0")
# --------------------------------------------------------------------------- #
# Per-check cookies must not be persisted for replay
# --------------------------------------------------------------------------- #
def test_per_check_cookies_are_not_stored():
"""The IP/timestamp trio describes one check and must not outlive it."""
_store(
[
_Cookie("__ddg1_", "clearance"),
_Cookie("__ddg2_", "clearance2"),
_Cookie("__ddg8_", "opaque"),
_Cookie("__ddg9_", "203.0.113.7"),
_Cookie("__ddg10_", "1786826304"),
_Cookie("ddg_last_challenge", "1786826304"),
]
)
stored = ib.get_cf_cookies_for_domain("annas-archive.gl")
assert set(stored) == {"__ddg1_", "__ddg2_"}
for ephemeral in ("__ddg8_", "__ddg9_", "__ddg10_", "ddg_last_challenge"):
assert ephemeral not in stored
def test_clearance_cookies_survive():
_store([_Cookie("__ddg1_", "a"), _Cookie("__ddg2_", "b"), _Cookie("__ddgid_", "c")])
stored = ib.get_cf_cookies_for_domain("annas-archive.gl")
assert stored == {"__ddg1_": "a", "__ddg2_": "b", "__ddgid_": "c"}
def test_cloudflare_cookies_are_unaffected():
_store([_Cookie("cf_clearance", "token"), _Cookie("__cf_bm", "bm")])
stored = ib.get_cf_cookies_for_domain("annas-archive.gl")
assert stored == {"cf_clearance": "token", "__cf_bm": "bm"}
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(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")
assert "sessionid" in stored
assert "__ddg9_" not in stored
# --------------------------------------------------------------------------- #
# Expiry must be honoured for every cookie, not only cf_clearance
# --------------------------------------------------------------------------- #
def test_expired_ddg_cookies_are_dropped():
"""The old code only expiry-checked cf_clearance, so DDoS-Guard domains - which
have none - replayed dead cookies forever."""
past = int(time.time()) - 60
_store([_Cookie("__ddg1_", "live"), _Cookie("__ddgmark_", "dead", expires=past)])
stored = ib.get_cf_cookies_for_domain("annas-archive.gl")
assert stored == {"__ddg1_": "live"}
def test_all_cookies_expired_returns_empty_so_caller_re_solves():
past = int(time.time()) - 60
_store([_Cookie("__ddg1_", "dead", expires=past)])
assert ib.get_cf_cookies_for_domain("annas-archive.gl") == {}
assert cs.has_valid_cf_cookies("annas-archive.gl") is False
def test_unexpired_cookies_are_kept():
future = int(time.time()) + 3600
_store([_Cookie("__ddg1_", "live", expires=future)])
assert ib.get_cf_cookies_for_domain("annas-archive.gl") == {"__ddg1_": "live"}
def test_session_cookies_never_expire():
"""expires<=0 means a session cookie, not an already-expired one."""
_store([_Cookie("__ddg5_", "s", expires=0), _Cookie("__ddg1_", "a", expires=None)])
assert ib.get_cf_cookies_for_domain("annas-archive.gl") == {"__ddg5_": "s", "__ddg1_": "a"}
def test_expired_cf_clearance_still_drops_the_whole_domain():
"""Pre-existing Cloudflare behaviour must not regress."""
past = int(time.time()) - 60
_store([_Cookie("cf_clearance", "dead", expires=past), _Cookie("__cf_bm", "bm")])
assert ib.get_cf_cookies_for_domain("annas-archive.gl") == {}
def test_expired_cookies_are_pruned_from_the_store():
"""A dropped cookie must not linger and be re-evaluated on every request."""
past = int(time.time()) - 60
_store([_Cookie("__ddg1_", "live"), _Cookie("__ddgmark_", "dead", expires=past)])
ib.get_cf_cookies_for_domain("annas-archive.gl")
assert set(cs._cf_cookies["annas-archive.gl"]) == {"__ddg1_"}
def test_solve_that_yields_only_per_check_cookies_stores_nothing():
"""No clearance means no reuse - the caller must go back to the bypasser rather
than believe it holds a valid session."""
_store([_Cookie("__ddg9_", "203.0.113.7"), _Cookie("__ddg10_", "1786826304")])
assert ib.get_cf_cookies_for_domain("annas-archive.gl") == {}
# --------------------------------------------------------------------------- #
# Rejected cookies are discarded, never retried forever
# --------------------------------------------------------------------------- #
class _Resp:
def __init__(self, status_code, text="page"):
self.status_code = status_code
self.text = text
def _seed(monkeypatch):
_store([_Cookie("__ddg1_", "clearance"), _Cookie("__ddg2_", "c2")])
monkeypatch.setattr(ib, "get_proxies", lambda _url: None)
monkeypatch.setattr(ib, "get_ssl_verify", lambda _url: True)
assert ib.get_cf_cookies_for_domain("annas-archive.gl")
def test_rejected_cached_cookies_are_discarded(monkeypatch):
"""A 403 while presenting cookies proves they are dead - keep them and every
later request re-presents a known-rejected cookie."""
_seed(monkeypatch)
monkeypatch.setattr(ib.requests, "get", lambda *a, **k: _Resp(403))
assert (
ib._try_with_cached_cookies("https://annas-archive.gl/search", "annas-archive.gl") is None
)
assert ib.get_cf_cookies_for_domain("annas-archive.gl") == {}
def test_redirect_loop_on_cached_cookies_discards_them(monkeypatch):
"""DDoS-Guard answers dead clearance with an endless ?check=1 bounce, which
surfaces as an exception rather than a status code."""
_seed(monkeypatch)
def boom(*_a, **_k):
raise ib.requests.exceptions.TooManyRedirects("Exceeded 30 redirects")
monkeypatch.setattr(ib.requests, "get", boom)
assert (
ib._try_with_cached_cookies("https://annas-archive.gl/search", "annas-archive.gl") is None
)
assert ib.get_cf_cookies_for_domain("annas-archive.gl") == {}
def test_working_cookies_are_kept(monkeypatch):
_seed(monkeypatch)
monkeypatch.setattr(ib.requests, "get", lambda *a, **k: _Resp(200, "the page"))
result = ib._try_with_cached_cookies("https://annas-archive.gl/search", "annas-archive.gl")
assert result == "the page"
assert ib.get_cf_cookies_for_domain("annas-archive.gl") != {}
def test_failure_only_clears_the_failing_host(monkeypatch):
"""clear_cf_cookies('') means every host - a blank hostname must not wipe
clearance for sites that are working fine."""
_seed(monkeypatch)
_store([_Cookie("__ddg1_", "other")], url="https://other-site.test/x")
monkeypatch.setattr(ib.requests, "get", lambda *a, **k: _Resp(403))
ib._try_with_cached_cookies("https://annas-archive.gl/search", "annas-archive.gl")
assert ib.get_cf_cookies_for_domain("annas-archive.gl") == {}
assert ib.get_cf_cookies_for_domain("other-site.test") == {"__ddg1_": "other"}
+161
View File
@@ -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
+180 -4
View File
@@ -1,5 +1,8 @@
import asyncio
import json
import subprocess
import threading
from pathlib import Path
import pytest
@@ -113,7 +116,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 +170,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 +381,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 +491,170 @@ 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 _FakeHelperProcess:
"""Stand-in for the bypass helper subprocess."""
def __init__(self, *_args, **kwargs):
self.kwargs = kwargs
self.pid = 4242
self.returncode = 0
self.timed_out = False
self.killed = False
self.waited = False
def communicate(self, payload, timeout=None):
if self.timed_out:
raise subprocess.TimeoutExpired(cmd="helper", timeout=timeout)
request = json.loads(payload)
result = {"ok": True, "html": "<html>solved</html>", "cookies": {}, "user_agents": {}}
Path(request["result_path"]).write_text(json.dumps(result), encoding="utf-8")
return "", ""
def kill(self):
self.killed = True
def wait(self, timeout=None):
self.waited = True
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)
)
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."""
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)
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 == [processes[0].pid]
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.timed_out = True
processes.append(process)
return process
_patch_helper_subprocess(monkeypatch, internal_bypasser, _make_process, killed_groups)
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]
@@ -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):
+137 -17
View File
@@ -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:
+24
View File
@@ -144,3 +144,27 @@ def test_start_does_not_run_the_search_inline(monkeypatch, warmup):
if warmup._warmup_thread:
warmup._warmup_thread.cancel()
def test_env_var_can_disable_the_warmup(monkeypatch, warmup):
"""SEARCH_WARMUP_ENABLED is not in the settings registry, so config.get never
sees it - the documented off-switch only works if os.environ is consulted."""
_patch_config(monkeypatch, warmup, {}) # config knows nothing about the key
monkeypatch.setenv("SEARCH_WARMUP_ENABLED", "false")
assert warmup.is_enabled() is False
assert warmup.start() is False
def test_env_var_can_set_the_query(monkeypatch, warmup):
_patch_config(monkeypatch, warmup, {})
monkeypatch.setenv("SEARCH_WARMUP_QUERY", "Moby Dick")
assert warmup.warmup_query() == "Moby Dick"
def test_env_var_absent_falls_back_to_config(monkeypatch, warmup):
monkeypatch.delenv("SEARCH_WARMUP_QUERY", raising=False)
_patch_config(monkeypatch, warmup, {"SEARCH_WARMUP_QUERY": "From Config"})
assert warmup.warmup_query() == "From Config"
@@ -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
+79 -6
View File
@@ -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}}) == ""
Generated
+26 -26
View File
@@ -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]]
@@ -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,7 +1510,7 @@ 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" },
@@ -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" },
]