Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Changelog

## 0.1.6 - 2026-07-23

Version jumps from 0.1.2 to 0.1.6 so all four official SDKs (Python, Node.js, Ruby, PHP) share one version number from here on. Nothing was skipped - 0.1.3 to 0.1.5 were never released for PHP.

- Added `PaymentRequiredException` for HTTP 402, which previously surfaced as a bare `ApiException` with no explanation. The three billing blocks now each get their own subclass, picked from the response body: `QuotaExceededException` (`Quota exceeded`), `CreditLimitExceededException` (`Credit limit exceeded`) and `PaymentFailedException` (`Payment failed - update payment method`). Catch `PaymentRequiredException` to handle all three.
- Added `NoSubscriptionException`, a subclass of `AuthenticationException`, for the 401 that means "the key is fine, the account has no active plan" (`No valid subscription`) as opposed to an unrecognised key.
- Added typed exceptions for the remaining documented status codes: `NotFoundException` (404), `BrowserTimeoutException` (408), `UnsupportedContentException` (415) and `ValidationException` (422). All previously threw a bare `ApiException`.
- Error messages now describe every documented status code accurately - notably 400, which also covers a missing `x-scrapeunblocker-key` header, not just a bad URL.
- Documented the full exception hierarchy in the README, including which errors are retried, which are billed, and how each 402 clears.
- Fixed the README and `oopbuySearch()` docblock claim that Oopbuy brand keywords return HTTP 422. They return a successful `200` with `keywordRejected: true` and an empty `results` array.

No breaking changes: every new class extends `ApiException`, so existing `catch (ApiException)` / `catch (ScrapeUnblockerException)` handlers keep working unchanged.

## 0.1.2 - 2026-07-22

- Added `oopbuySearch()` for the Oopbuy goods search plugin (`/goods/oopbuy-search`) - search 1688/Taobao/official channels and get products with USD and CNY prices, images and monthly sales.
Expand Down
50 changes: 44 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ foreach ($goods['results'] as $item) {
}
```

Channels: `1688` (default), `taobao`, `official`. Sort: `default`, `price_asc`, `price_desc`, `best_selling`. `page_size` up to 60. Brand keywords are rejected with HTTP 422.
Channels: `1688` (default), `taobao`, `official`. Sort: `default`, `price_asc`, `price_desc`, `best_selling`. `page_size` up to 60. Oopbuy trademark-blocks brand keywords at its own backend: those come back as a successful `200` with `keywordRejected: true` and an empty `results` array, not an error.

## Cookies and the serving proxy

Expand Down Expand Up @@ -130,17 +130,20 @@ $cars = $su->skyscanner->carhire(['pickup' => 'Madrid', 'pickup_datetime' => '20

## Error handling

Non-2xx responses throw typed exceptions, all subclasses of `ScrapeUnblockerException`. Transient failures (429, 502, 503, 504 and network errors) are retried automatically with exponential backoff.
Non-2xx responses throw typed exceptions, all subclasses of `ScrapeUnblockerException`.

```php
use ScrapeUnblocker\Exception\BlockedException;
use ScrapeUnblocker\Exception\PaymentRequiredException;
use ScrapeUnblocker\Exception\RateLimitException;
use ScrapeUnblocker\Exception\UpstreamOutageException;

try {
$html = $su->getPageSource('https://example.com');
} catch (BlockedException $e) {
// 403: the target blocked every bypass path (not billed)
} catch (PaymentRequiredException $e) {
// 402: quota, credit limit, or a failed payment - fix billing
} catch (RateLimitException $e) {
// 429: slow down
} catch (UpstreamOutageException $e) {
Expand All @@ -150,15 +153,50 @@ try {

| Exception | Status | Meaning |
|---|---|---|
| `InvalidRequestException` | 400 | Bad URL or unsupported scheme |
| `AuthenticationException` | 401 | Missing or invalid API key |
| `InvalidRequestException` | 400 | Bad URL, unsupported scheme, or the API key header was not sent |
| `AuthenticationException` | 401 | Key not recognised - typo, stray whitespace, or a rotated key |
| `NoSubscriptionException` | 401 | Key is fine, but the account has no active plan |
| `PaymentRequiredException` | 402 | Billing block - base class for the three below |
| `QuotaExceededException` | 402 | The plan's requests for this period are used up |
| `CreditLimitExceededException` | 402 | Unpaid balance is past the account's credit limit |
| `PaymentFailedException` | 402 | A card payment was declined three times |
| `BlockedException` | 403 | Blocked by bot protection on every path |
| `NotFoundException` | 404 | Page loaded but held no image (`getImage` only) |
| `BrowserTimeoutException` | 408 | Our browser run timed out before the page was ready |
| `UnsupportedContentException` | 415 | The URL serves something other than HTML |
| `ValidationException` | 422 | Missing or wrong-typed parameter; `$body` holds the `detail` array |
| `RateLimitException` | 429 | Too many requests |
| `UpstreamOutageException` | 503 | The target origin is down |
| `ServerException` | 5xx | Unexpected server error |
| `TimeoutException` | - | Request exceeded the timeout |
| `ServerException` | 5xx | Unexpected server error, including a 504 upstream timeout |
| `TimeoutException` | - | This client gave up locally before the API answered |
| `ConnectionException` | - | Could not reach the API |

Transient failures (429, 502, 503, 504 and network errors) are retried automatically with exponential backoff. A 401 or 402 is never retried - it clears when the key or the billing state changes, not on another attempt. Neither is billed or counted against your quota, because the request is refused before anything is scraped.

### Billing errors (402)

The three billing blocks share a status code and differ only in their message, so the client throws a dedicated exception for each:

```php
use ScrapeUnblocker\Exception\CreditLimitExceededException;
use ScrapeUnblocker\Exception\PaymentFailedException;
use ScrapeUnblocker\Exception\QuotaExceededException;

try {
$html = $su->getPageSource('https://example.com');
} catch (QuotaExceededException $e) {
// plan quota (plus any overage allowance) is used up for this period
} catch (CreditLimitExceededException $e) {
// unpaid balance passed the account credit limit
} catch (PaymentFailedException $e) {
// card declined three times - update the payment method
}
```

When more than one applies, the most serious wins: failed payment outranks credit limit, which outranks quota. All three lift by themselves once the billing state changes - access returns within about a minute, and the API key stays the same. One catch worth knowing: subscribing to a new plan does **not** clear `PaymentFailedException`, because the old unpaid invoice stays open until it is paid.

Full details for every status code: [developers.scrapeunblocker.com/errors](https://developers.scrapeunblocker.com/errors).

## Configuration

```php
Expand Down
62 changes: 57 additions & 5 deletions src/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,22 @@
use ScrapeUnblocker\Exception\ApiException;
use ScrapeUnblocker\Exception\AuthenticationException;
use ScrapeUnblocker\Exception\BlockedException;
use ScrapeUnblocker\Exception\BrowserTimeoutException;
use ScrapeUnblocker\Exception\ConnectionException;
use ScrapeUnblocker\Exception\CreditLimitExceededException;
use ScrapeUnblocker\Exception\InvalidRequestException;
use ScrapeUnblocker\Exception\NoSubscriptionException;
use ScrapeUnblocker\Exception\NotFoundException;
use ScrapeUnblocker\Exception\PaymentFailedException;
use ScrapeUnblocker\Exception\PaymentRequiredException;
use ScrapeUnblocker\Exception\QuotaExceededException;
use ScrapeUnblocker\Exception\RateLimitException;
use ScrapeUnblocker\Exception\ScrapeUnblockerException;
use ScrapeUnblocker\Exception\ServerException;
use ScrapeUnblocker\Exception\TimeoutException;
use ScrapeUnblocker\Exception\UnsupportedContentException;
use ScrapeUnblocker\Exception\UpstreamOutageException;
use ScrapeUnblocker\Exception\ValidationException;

/**
* Client for the ScrapeUnblocker API.
Expand All @@ -26,7 +35,7 @@
final class Client
{
private const DEFAULT_BASE_URL = 'https://api.scrapeunblocker.com';
private const VERSION = '0.1.2';
private const VERSION = '0.1.6';
private const API_KEY_HEADER = 'x-scrapeunblocker-key';
private const RETRYABLE = [429, 502, 503, 504];

Expand Down Expand Up @@ -137,7 +146,9 @@ public function googleLocal(string $keyword, array $options = []): array
*
* Searches the "1688" channel by default ("taobao" and "official" are
* also supported) and returns products with USD and CNY prices, images
* and monthly sales. Brand keywords are rejected with HTTP 422.
* and monthly sales. Oopbuy trademark-blocks brand keywords at its own
* backend: those return a successful 200 with keywordRejected = true and
* an empty results array, not an error.
*/
public function oopbuySearch(string $keyword, array $options = []): array
{
Expand Down Expand Up @@ -238,26 +249,67 @@ private function errorForStatus(int $status, string $body): ApiException
$snippet = substr($snippet, 0, 200) . '...';
}
$base = match ($status) {
400 => 'Invalid request (bad URL or unsupported scheme)',
401 => 'Authentication failed - check your API key',
400 => 'Invalid request (bad URL, unsupported scheme, or missing API key header)',
401 => 'Authentication failed - key not recognised, or account has no active plan',
402 => 'Billing block - quota exceeded, credit limit exceeded, or a failed payment',
403 => 'Target blocked by bot protection on every bypass path',
404 => 'Requested element not found on the page',
408 => 'Browser run timed out before the page was ready',
415 => 'URL does not serve HTML',
422 => 'Validation error - see the detail array in the response body',
429 => 'Rate limited - too many requests',
503 => 'Upstream origin returned a server-side outage page',
504 => 'Fetch timed out upstream',
default => "API returned HTTP {$status}",
};
$message = $snippet !== '' ? "{$base}: {$snippet}" : $base;

return match (true) {
$status === 400 => new InvalidRequestException($message, $status, $body),
$status === 401 => new AuthenticationException($message, $status, $body),
$status === 401 => $this->authError($message, $status, $body),
$status === 402 => $this->billingError($message, $status, $body),
$status === 403 => new BlockedException($message, $status, $body),
$status === 404 => new NotFoundException($message, $status, $body),
$status === 408 => new BrowserTimeoutException($message, $status, $body),
$status === 415 => new UnsupportedContentException($message, $status, $body),
$status === 422 => new ValidationException($message, $status, $body),
$status === 429 => new RateLimitException($message, $status, $body),
$status === 503 => new UpstreamOutageException($message, $status, $body),
$status >= 500 => new ServerException($message, $status, $body),
default => new ApiException($message, $status, $body),
};
}

/**
* A 401 is either an unknown key or a recognised key on an account without a plan,
* and only the body tells them apart. Anything unrecognised stays on the general
* AuthenticationException rather than guessing.
*/
private function authError(string $message, int $status, string $body): AuthenticationException
{
if (str_contains(strtolower($body), 'no valid subscription')) {
return new NoSubscriptionException($message, $status, $body);
}

return new AuthenticationException($message, $status, $body);
}

/**
* The three billing blocks share a status code and differ only in their plain-text
* body. An unrecognised body falls back to PaymentRequiredException.
*/
private function billingError(string $message, int $status, string $body): PaymentRequiredException
{
$text = strtolower($body);

return match (true) {
str_contains($text, 'quota exceeded') => new QuotaExceededException($message, $status, $body),
str_contains($text, 'credit limit exceeded') => new CreditLimitExceededException($message, $status, $body),
str_contains($text, 'payment failed') => new PaymentFailedException($message, $status, $body),
default => new PaymentRequiredException($message, $status, $body),
};
}

/**
* @param list<string> $headers
* @return array{status:int,body:string}
Expand Down
11 changes: 10 additions & 1 deletion src/Exception/AuthenticationException.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,16 @@

namespace ScrapeUnblocker\Exception;

/** The API key is missing, malformed, or not recognised (HTTP 401). */
/**
* The API key was rejected (HTTP 401).
*
* Two cases produce a 401: an unrecognised key ("Unauthorized" - a typo, trailing
* whitespace, an empty value, or a key rotated in the dashboard), and a valid key on an
* account with no plan, which throws the NoSubscriptionException subclass.
*
* Omitting the API key header entirely is a 400, not a 401. Nothing is scraped for a 401,
* so it is not billed and does not count against your quota.
*/
class AuthenticationException extends ApiException
{
}
15 changes: 15 additions & 0 deletions src/Exception/BrowserTimeoutException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace ScrapeUnblocker\Exception;

/**
* The browser run did not finish in time on our side (HTTP 408).
*
* Distinct from TimeoutException, which is this client giving up locally. Here the API
* answered - it just could not render the page in the time allowed. Retrying usually helps.
*/
class BrowserTimeoutException extends ApiException
{
}
16 changes: 16 additions & 0 deletions src/Exception/CreditLimitExceededException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace ScrapeUnblocker\Exception;

/**
* The unpaid balance has passed the account's credit limit (HTTP 402).
*
* The balance counted here is the amount remaining on open invoices plus metered usage
* already consumed but not yet invoiced. Outstanding invoices are charged automatically
* when this triggers, so with a working card it usually clears itself within about a minute.
*/
class CreditLimitExceededException extends PaymentRequiredException
{
}
8 changes: 7 additions & 1 deletion src/Exception/InvalidRequestException.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@

namespace ScrapeUnblocker\Exception;

/** The request was rejected as invalid, e.g. a malformed URL (HTTP 400). */
/**
* The request was rejected as invalid (HTTP 400).
*
* Thrown for a malformed URL or unsupported scheme, for a missing x-scrapeunblocker-key
* header ("Missing x-scrapeunblocker-key"), and for a URL that belongs to a dedicated
* plugin - the response names the endpoint to use instead.
*/
class InvalidRequestException extends ApiException
{
}
16 changes: 16 additions & 0 deletions src/Exception/NoSubscriptionException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace ScrapeUnblocker\Exception;

/**
* The key is valid but the account has no active plan (HTTP 401).
*
* Thrown when the API answers a 401 with "No valid subscription". Pick a plan at
* https://app.scrapeunblocker.com - access resumes within about a minute, and the
* key does not change.
*/
class NoSubscriptionException extends AuthenticationException
{
}
14 changes: 14 additions & 0 deletions src/Exception/NotFoundException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

declare(strict_types=1);

namespace ScrapeUnblocker\Exception;

/**
* The page loaded but the requested element was absent (HTTP 404).
*
* Only getImage() throws this: the page rendered fine and contained no <img> tag.
*/
class NotFoundException extends ApiException
{
}
16 changes: 16 additions & 0 deletions src/Exception/PaymentFailedException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace ScrapeUnblocker\Exception;

/**
* A card payment has been declined three times (HTTP 402).
*
* Those attempts are the payment provider's automatic retries spread over several days,
* so a card has been failing for a while. Subscribing to a new plan does NOT clear this:
* the old unpaid invoice stays open, and the block stays until that specific invoice is paid.
*/
class PaymentFailedException extends PaymentRequiredException
{
}
22 changes: 22 additions & 0 deletions src/Exception/PaymentRequiredException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace ScrapeUnblocker\Exception;

/**
* The account has a billing problem (HTTP 402).
*
* Credentials are fine - the request was stopped for a billing reason. There are three,
* each thrown as a dedicated subclass: QuotaExceededException, CreditLimitExceededException
* and PaymentFailedException. Catch this base class to handle all three.
*
* When more than one applies, the most serious wins: failed payment outranks credit limit,
* which outranks quota. All three lift by themselves once the billing state changes - access
* returns within roughly a minute, with no key change needed. Like a 401, a 402 is refused
* before anything is scraped, so it is never billed. Retrying is pointless; fix the billing
* state first.
*/
class PaymentRequiredException extends ApiException
{
}
17 changes: 17 additions & 0 deletions src/Exception/QuotaExceededException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace ScrapeUnblocker\Exception;

/**
* Every request the plan allows this period has been used (HTTP 402).
*
* On plans that permit overages this only fires past the quota *plus* the overage
* allowance; inside that band requests still succeed and the extra usage is invoiced.
* Active coupon credit is spent before plan quota. The counter resets on the
* subscription's anniversary day, not the first of the month.
*/
class QuotaExceededException extends PaymentRequiredException
{
}
15 changes: 15 additions & 0 deletions src/Exception/UnsupportedContentException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace ScrapeUnblocker\Exception;

/**
* The URL serves something other than HTML (HTTP 415).
*
* The message names the content type that was found. For images, use getImage()
* instead of getPageSource().
*/
class UnsupportedContentException extends ApiException
{
}
Loading
Loading