Skip to content

Update scrapingbee-cli package to support Header based Authorization - #32

Merged
sahilsunny merged 3 commits into
mainfrom
sahil/scr-585-update-scrapingbee-cli-package-to-support-header-based
Sep 7, 2026
Merged

Update scrapingbee-cli package to support Header based Authorization#32
sahilsunny merged 3 commits into
mainfrom
sahil/scr-585-update-scrapingbee-cli-package-to-support-header-based

Conversation

@sahilsunny

@sahilsunny sahilsunny commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Switches API auth from the deprecated api_key query param to the Authorization: Bearer header, so the key no longer shows up in URLs. The only exception is crawl, whose scrapy-scrapingbee middleware will be migrated separately.

Also adds two params that are missing from the docs but confirmed working against the live API (it rejects unknown params with 400, these return 200):

  • google --nb-results
  • amazon-product --autoselect-variant

Stays on 1.6.0 since it isn't published yet; release date set to 2026-08-24.

Tested live on a pipx install (usage, GET, PUT, both new flags) plus unit tests for the new behavior. Based on #31 — merge that first, with a merge commit (not squash).

…SCR-585)

- All Client requests now authenticate via 'Authorization: Bearer' instead
  of the deprecated api_key query parameter, so the key no longer appears
  in request URLs. Applies to every command including auth validation and
  usage; crawl's scrapy-scrapingbee middleware still builds api_key URLs
  and migrates separately.
- google --nb-results: requested results per page. Absent from llms.txt
  but verified live: the API strictly rejects unknown params (400 'Unknown
  field' control) yet accepts nb_results with 200.
- amazon-product --autoselect-variant: matches the existing amazon-search
  flag; same live verification (200 vs 400 bogus-param control).
- Skill docs: --custom-google price corrected 15 -> 20 credits per the
  API's own error message ('Each request will cost 20 credits!').

Live-verified via pipx build: usage/GET/PUT all 200 under Bearer auth,
nb-results and autoselect-variant accepted. 863 unit tests green (new:
Bearer session header, no api_key in GET/POST/usage params, param
forwarding for both new options); ruff + ty clean.
sahilsunny added a commit that referenced this pull request Aug 20, 2026
Stacked PRs (based on another PR's branch) silently got zero checks:
on.pull_request was filtered to branches [main], so PRs #32 and #33
never ran CI. Drop the filter so every PR runs the suite; the push
trigger stays main-only.
@kostas-jakeliunas-sb

Copy link
Copy Markdown
Contributor

Regression: -X post/-X put with a user Authorization header now fails API auth (400/401)

tl;dr — with header-based auth, any -H "Authorization: …" that the user wants forwarded to the target site now overwrites our own Authorization: Bearer <api key> on POST/PUT, so the API rejects the request. On main the api_key query param kept auth working. Fix is a 4-line change (always Spb--prefix custom headers, not only on GET) — verified live + with a regression test below.

Where

req_headers: dict[str, str] | None = None
if custom_headers:
if method_upper == "GET":
req_headers = {f"Spb-{k}": v for k, v in custom_headers.items()}
else:
req_headers = dict(custom_headers)

# src/scrapingbee_cli/client.py @ 52c8403
233        req_headers: dict[str, str] | None = None
234        if custom_headers:
235            if method_upper == "GET":
236                req_headers = {f"Spb-{k}": v for k, v in custom_headers.items()}
237            else:
238                req_headers = dict(custom_headers)          # <-- raw, unprefixed

Those raw headers are passed straight to aiohttp as per-request headers:

# src/scrapingbee_cli/client.py @ 52c8403
121        req_kwargs: dict[str, Any] = {"params": params}
122        if headers:
123            req_kwargs["headers"] = headers
...
133        async with session.request(method, url, **req_kwargs) as resp:

and the session-level default now carries our key (this PR, line 48):

 48        session_headers = user_agent_headers() | {"Authorization": f"Bearer {self.api_key}"}

Why it breaks

aiohttp merges session default headers with per-request headers, and the per-request value wins on the same (case-insensitive) key. So on POST/PUT a user -H "Authorization: Basic …" replaces Authorization: Bearer <api key> before the request leaves the client. The GET branch is unaffected because it rewrites the key to Spb-Authorization. Before this PR params_clean["api_key"] = self.api_key (removed in d360217) meant the backend still found the key in the query string.

Reproduce (live API, 2026-09-04)

export SCRAPINGBEE_API_KEY=...   # any valid key
scrapingbee scrape https://httpbin.org/post -X post -d "x=1" \
  -H "Authorization: Basic Zm9vOmJhcg==" --forward-headers-pure true --verbose

main (1.5.2, 3874781):

HTTP Status: 200
Credit Cost: 1

this branch (52c8403):

Error: HTTP 400
{"errors": {"api_key": {"api_key": ["Missing data for required field."]}}}

Same with a user Bearer token for the target site (-H "Authorization: Bearer target-site-token"):

Error: HTTP 401
{"message": "Invalid api key: target-site-token"}

(the backend prefers the header over the query param, so that Bearer variant also fails on main — the Basic/Token/Digest case is the actual regression.)

Offline repro — pytest against a local fake API (fails on this branch: 2 failed, 1 passed)
# tests/unit/test_post_auth_header.py
import asyncio

from aiohttp import web

from scrapingbee_cli.client import Client


def _run_against_fake_api(method, custom_headers):
    """Return (headers, query) that the fake ScrapingBee endpoint received."""
    seen = {}

    async def endpoint(request):
        seen["headers"] = dict(request.headers)
        seen["query"] = dict(request.query)
        return web.json_response({"ok": True})

    async def run():
        app = web.Application()
        app.router.add_route("*", "/", endpoint)
        runner = web.AppRunner(app)
        await runner.setup()
        site = web.TCPSite(runner, "127.0.0.1", 0)
        await site.start()
        port = runner.addresses[0][1]
        try:
            async with Client("fake-key", base_url=f"http://127.0.0.1:{port}") as client:
                await client.scrape(
                    "https://example.com",
                    method=method,
                    body="x=1" if method != "get" else None,
                    custom_headers=custom_headers,
                    forward_headers_pure=True,
                    retries=0,
                )
        finally:
            await runner.cleanup()

    asyncio.run(run())
    return seen["headers"], seen["query"]


class TestUserAuthorizationHeaderDoesNotClobberApiAuth:
    def test_post_with_user_basic_auth_header_still_authenticates(self):
        headers, query = _run_against_fake_api("post", {"Authorization": "Basic Zm9vOmJhcg=="})
        assert headers.get("Authorization") == "Bearer fake-key", headers.get("Authorization")

    def test_put_with_user_token_header_still_authenticates(self):
        headers, query = _run_against_fake_api("put", {"Authorization": "Token abc"})
        assert headers.get("Authorization") == "Bearer fake-key", headers.get("Authorization")

    def test_get_with_user_auth_header_is_prefixed_and_keeps_bearer(self):
        headers, _ = _run_against_fake_api("get", {"Authorization": "Basic Zm9vOmJhcg=="})
        assert headers.get("Authorization") == "Bearer fake-key"
        assert headers.get("Spb-Authorization") == "Basic Zm9vOmJhcg=="

Output on this branch:

E   AssertionError: 'Basic Zm9vOmJhcg=='   (post)
E   AssertionError: 'Token abc'            (put)
2 failed, 1 passed

Suggested fix — prefix on every method, not only GET

--- a/src/scrapingbee_cli/client.py
+++ b/src/scrapingbee_cli/client.py
@@ -232,10 +232,10 @@ class Client:
         method_upper = (method or "GET").upper()
         req_headers: dict[str, str] | None = None
         if custom_headers:
-            if method_upper == "GET":
-                req_headers = {f"Spb-{k}": v for k, v in custom_headers.items()}
-            else:
-                req_headers = dict(custom_headers)
+            # Always Spb-prefix: the API only forwards prefixed headers (it
+            # strips the prefix in both forward modes), and a raw user
+            # Authorization header would replace the session Bearer key.
+            req_headers = {f"Spb-{k}": v for k, v in custom_headers.items()}

Why prefixing (rather than re-adding api_key to the query on POST) is the right call: the backend never forwarded raw custom headers on POST/PUT in the first place — only Spb- ones, and it strips the prefix in both --forward-headers and --forward-headers-pure mode. Measured on main against httpbin.org/post:

-X post with --forward-headers true → target sees --forward-headers-pure true → target sees
-H "X-Custom: 1" (nothing) (nothing)
-H "Spb-X-Custom: 1" X-Custom: 1 X-Custom: 1
-H "Authorization: Basic …" (nothing) (nothing)
-H "Spb-Authorization: Basic …" Authorization: Basic … Authorization: Basic …

So the raw branch at line 238 was silently dropping every header anyway; prefixing fixes the auth clash and makes -H on POST/PUT actually work. With the patch applied: the three tests above pass, the existing tests/unit/test_client.py suite passes (74), and the live command returns HTTP 200 with httpbin showing "Authorization": "Basic Zm9vOmJhcg==" (i.e. the header now reaches the target; on main it never did).

Related, same code path: the --forward-headers help says "Use -H with Spb- prefix for GET", but the CLI already prefixes on GET, so a user who follows that hint gets Spb-Spb-X-Custom and the target receives Spb-X-Custom: 1 (verified on main). With the fix above the hint can simply go — or make the prefixing idempotent (if not k.lower().startswith("spb-")).

@kostas-jakeliunas-sb kostas-jakeliunas-sb left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See comment, would be nice to fix this, apart from that otherwise looks good i think :)

Base automatically changed from feat/api-parity-1.6.0/SCR-577 to main September 4, 2026 14:40
- Custom -H headers are now Spb--prefixed on every method (idempotently),
  not only GET. On POST/PUT raw headers were silently dropped by the API,
  and after the Bearer migration a user Authorization header replaced the
  session's API key (aiohttp per-request beats session headers), failing
  auth with 400/401. Red->green verified live with the reviewer's repro;
  fake-API regression tests added (3 fail without the fix).
- Stale --forward-headers help ('use Spb- prefix') removed — following it
  produced Spb-Spb- double prefixes; prefixing is now automatic.
- google --pages billing corrected in CHANGELOG and references: flat 10/15
  per request regardless of page count (measured: --pages 1/2/2-from-2 all
  cost 10). Window semantics (--page start, N consecutive) re-verified by
  URL-overlap comparison and were already documented correctly.
- youtube-subtitles: a language with no subtitles returns HTTP 200 with an
  empty object (not 404 as the API docs claim) and still charges 5 credits
  (verified live) — the CLI now warns instead of printing silent empty
  JSON; docs corrected; unit tests added.
- youtube-subtitles added to the error-response, integration, and e2e test
  registries.
@sahilsunny
sahilsunny merged commit 069a148 into main Sep 7, 2026
14 checks passed
@sahilsunny
sahilsunny deleted the sahil/scr-585-update-scrapingbee-cli-package-to-support-header-based branch September 7, 2026 07:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants