Repository navigation
Releases: 2scraper/bbb-scraper
Release list
v1.1.0
Behaviour change:
--retries 0is now a usage error (exit 2). It used
to be accepted and made the page-load loop unreachable, so nothing was
fetched and the engine reported the browser's own blank page as a result:
exit 3 or 4 with a 39-byte "capture", in tens of milliseconds. If a
script of yours passes 0, pass 1: the flag counts ATTEMPTS, so 1 means
"try once and do not retry".
Behaviour change for existing users: a page BBB serves that cannot be
read now fails the run instead of ending it. A run that used to finish
completewith exit 0 on such a page now finishespartialwith exit 6
(rows were gathered) or exit 5 (none were), and its sidecar names the page
inpages_failedwithstop_reason: "unreadable_response". A pipeline
that branched on exit 0 was reading an unread page as the end of a listing.
Behaviour change: a URL BBB answers with its own 404 page now stops at
once withstop_reason: "not_found"(exit 5 when nothing was gathered).
It used to wait 30 s, re-fetch, and end asunreadable_response.
Behaviour change: negative values for
--pages,--delay,
--retry-delay,--proxy-block-retriesand--concurrency(and 0 for
--pagesand--concurrency) are now usage errors (exit 2) in every
engine, and so is a negative--retries/--retry-delayin
scraper_api_client.py.
Behaviour change: CSV cells that begin with
=,+,-,@, a tab
or a line break are now prefixed with an apostrophe so a spreadsheet does
not run them as formulas. The JSON output is unchanged; the sidecar says
how many cells differ incsv_cells_escaped. Output files are now written
through a temporary file and renamed, keeping the modeopen()would give
a new file and the existing mode of a file being replaced.
Correction to v1.0.1. Its README billed the Managed Challenge solve to
the wrong product. It read "Captcha solving (--twocaptcha-key) — for
the Managed Challenge only" with a table marking that challenge
solvable. The challenge is indeed solvable, but not by anything a
--twocaptcha-keybuys here:captcha_solver.pybuildsRecaptchaV2Task,
RecaptchaV2TaskProxylessandRecaptchaV3TaskProxylessand no Turnstile
task at all. What actually clears it isCaptcha.setAutoSolveover
--cdp-endpoint, which is the Scraping Browser API — a separately billed
product. A reader following the old README would have set a key, met the
challenge anyway, and had no way to tell why.
Fixed
-
The Selenium and pyppeteer engines crashed on a block with no proxy.
With no--proxy, a blocked page (and, in pyppeteer, a dead exit) reached
pool.current/pool.advanceon None: a traceback, exit 1, and every row
from earlier pages lost. Both now re-fetch on the same path, as the
Playwright engine always did, and report exit 3. -
An interrupted write destroyed the previous good output.
out.json,
out.csvandout.meta.jsonwere truncated before being written, so a
failure halfway left a short or empty file. They are now written to a
temporary file beside the target, synced and renamed over it. -
The HTTP status never reached the classifier. Playwright and
pyppeteer now pass the response status, so BBB's 404 is terminal and a
marker-less 403 is a block. Selenium has no status to pass; BBB's own 404
title is recognised in the body for it. -
A
BBB_URLfrom.envblocked--text/--location. A URL from the
environment is now a default that the query flags replace; a TYPED
--urlwith query flags is still refused..env.exampleshipsBBB_URL
commented out. -
The credential scan judged exclusions on the absolute path, so a
clone under a directory namedvenvscanned 0 files and reported clean.
Exclusions are now relative to the repository, a scan of 0 files fails,
and.env*copies plus.json,.csvand.htmlfiles are read too. -
Dump and capture files were not ignored:
X.page3from
--dump-html, a raw.htmlat the repository root, andlive/,
out/-style working directories. -
The profile canary went green without testing anything. With no
BBB_CDP_ENDPOINTsecret it skipped with a notice, and with one it
passed on exit 3 or 5. It now fails with an error naming the missing
secret, and every non-zero exit or empty result is red. The listing
canary needs no secret and stays scheduled; it now also fails explicitly
on zero rows. -
--solve-captchahelp, a docstring and the README's exit-code table
described refusals and exit 5 inaccurately; corrected. Comments no longer
point at documents a reader of this repository cannot open. -
--retries 0fetched nothing and called it a result. The load loop is
range(1, retries + 1), so 0 never runs it and the engine judged
about:blank. Three third-party audits of this family ran exactly that and
reported a working scraper as broken.--retriesnow goes through
page_flow.attemptsin every entry point; a check reads the source of each
one and calls the validator, so a new engine declaring the flag with a
plain int fails the suite. -
A served page that could not be read was taken for the end of the
listing.unknown(BBB's page, neither the listing payload nor its own
empty-result marker) left the page outcomeokwith no rows, so the loop
read "added nothing new" as the end of the results. Reproduced with the
real fetch and finish code and only the browser replaced: page 1 good,
page 2 with the row container renamed, page 3 good gavecomplete,
no_new_products, exit 0, page 3 never requested. The same state on page 1
was worse: pages 2 and 3 were still fetched and the run reportedcomplete
over 14 rows instead of 21, the first page lost without a trace. An
unreadable page is now a failed page in all three engines. A CONFIRMED
empty result set (totalResults: 0) is unchanged: it is still a finished
page and still ends a run as complete. -
The captcha budget was advisory.
handle_captcha_if_presentruns once
before a page is classified and once after, and only the second call was
counted, while the first repeats on every block-retry: three solver calls
againstSOLVES_PER_PAGE = 1, reproduced with the solver stubbed and no
proxy pool. The allowance is now onepage_flow.SolveBudgetper page,
spent at the call to the solver itself in all three engines (counting calls
would have spent it on a call that found no captcha). It does not reset on
rotation. There is deliberately no run-wide cap on top of it: a run of N
pages that each need a solve has bought what it needed. -
diff_runs.pycompared two different queries. The sort guard caught two
orderings of one query and nothing caught two queries, sorestaurantsin
New York againstrestaurantsin Chicago, both a-z, both complete, was
accepted and every line of the diff was the change of city. The scope is
now read from each sidecar'sfinal_url(query text, location,--state,
category; page, sort, host andwww.ignored) and a mismatch is refused,
with--forceas before. A run the user cut at--pages 3is refused
against one that read 15 for the same reason; a listing that merely ran
out of pages is not.diff_runs.pyhad no behavioural check at all
(CI ran--help), so its sort and completeness guards are pinned too. -
The Scraper API path (
scraper_api_client.py) failed whenever a wait flag
was given, and never saw the target's status. Measured 2026-09-23 against
the live/tasks/syncendpoint:waitForsent as a JSON-encoded string
(what this client sent) is answered HTTP 422 "params.waitFor must be an
object" and is still billed ($0.0005); sent as an object it is answered
HTTP 200. It is now an object. And the response'sstatusis the API's own
verdict ("success"), while the target site's HTTP code ishttp_code— the
client handedstatusonward, so a target 403/503 never reached the page
classifier. It now readshttp_code, falling back tostatusonly if that
is an integer. After the fix, one live call (--mode profile --wait-text BBB, the README's profile URL, no--cdp-url) answered HTTP 200, upstream 200, 1 business row. -
Issue templates described Etsy, not BBB. The bug-report hints talked
about DataDome,t=bv,shop_ratingand exit-IP storefronts, the example
command scraped an Etsy search, and the site-change template pointed at a
JSON-LD parser this repo does not have. Rewritten from this repo's README:
the Cloudflare-gated profile mode, the 15-pages-of-15 cap, unrated
businesses written as null, and the__PRELOADED_STATE__anchors. The
bug-report and question templates no longer link aTROUBLESHOOTING.md
this repo never had. -
--retrieshelp said an empty "hub category" is a correct answer in
all three engines. BBB has no hub categories; the empty case here is a
query that matched nothing, and the help now says so. The matching engine
comments were corrected the same way. -
Comments that described another site's pages as this one's: the
akamaihd.nethub-page incident is now attributed to the sibling
tokopedia-scraper where it happened, the pyppeteer engine's single-page
note no longer talks about a--mode shopthis repo does not have, and
captcha_solver.pyno longer points at a "No DataDome solver" section
that does not exist. -
SECURITY.mdsaid this project has no releases or tags; it has both. -
The access badge's alt text now says what its label already did: the
listing modes run without an account (profile mode needs a residential
exit). -
The README now names the product that clears each refusal, and says in
those words that this repo does not implementTurnstileTaskProxyless
— nor the init script that capturessitekey,action,cDataand
chlPageDatafrom Cloudfla...
v1.0.1 — a marker that fired on good pages
A challenge marker was wrong, and it was one that could have cost money.
cf-turnstilefired on five of five pages BBB actually served. Only the signal ordering indetect_page_state— the listing payload is checked before any marker — kept a good page from being reported as a challenge, andchallengeis the state that spends.
What was measured
Counted across every capture taken 2026-09-16:
| served (6) | challenge (2) | hard block | |
|---|---|---|---|
cf_chl_opt |
0 | 7, 7 | 0 |
__cf_chl |
0 | 3, 3 | 0 |
cf-chl- |
0 | 1, 0 | 0 |
challenges.cloudflare.com |
0 | 1, 0 | 0 |
cf-turnstile |
1 on 5! | 1, 0 | 0 |
/turnstile/v0/api.js |
0 | 0, 0 | 0 |
The cause is not BBB. 2Captcha's Scraping Browser auto-solve extension injects chrome-extension://kjmkgkdkpedkejedfhmfcenooemhbpbo/…/turnstile/hunter.js with a cf-turnstile-response input into every page it loads, and every capture in this repo was taken that way.
Fixed
cf-turnstileand/turnstile/v0/api.jsremoved fromBOT_CHALLENGE_MARKERS;challenges.cloudflare.comadded.- The marker guard in the suite had been passing for the wrong reason — it ran only against BBB's 404, fetched with plain curl, which carries no extension injection. It now also runs against a listing fetched through the Scraping Browser, and asserts in both directions: no marker on a served page, every marker firing on a real challenge, and each excluded string really present on a served page.
352 offline checks, up from 340.
Which captcha this site actually uses
BBB sits behind Cloudflare and refuses in two shapes — both HTTP 403, both titled … | Better Business Bureau®:
| what it is | solvable | |
|---|---|---|
Just a moment... |
a Cloudflare Managed Challenge (Turnstile) | yes — a real test |
You have been blocked |
a flat refusal: no widget, no sitekey | no — a solve buys nothing |
Separately, BBB has its own reCAPTCHA Enterprise on every page (render=<sitekey>, so v3 rather than a v2 checkbox), guarding its review and complaint forms. This scraper never touches those forms — so no captcha was ever solved, and nothing was ever spent on one.
v1.0.0 — BBB scraper, rebuilt
First release of the rebuilt scraper. It replaces four standalone scripts that predated the template — no shared row model, no exit-code contract, no sidecar, no tests.
Read this first
--mode search and --mode category need no account, no key and no proxy.
BBB renders its listings out of its own JSON endpoint, and that endpoint is not behind Cloudflare. Measured 2026-09-16 from a datacenter VPS — an address that gets HTTP 403 on every HTML page on the site — the endpoint answered HTTP 200 with 15 complete business records. A two-page run from that machine, with no credentials configured at all, returned 30 rows and status: complete.
--mode profile is the one that needs a residential exit, and it is what the paid products are for here: a profile page is gated, and there is no endpoint behind it.
What's in it
- Three engines — Playwright (primary, the only one with
--concurrency), Selenium, pyppeteer — plus a browserless client for 2Captcha's Scraper API. All produce identical rows and exit codes. - Three modes: a keyword search, a category listing, a business profile. A profile row joins a listing row on the same key.
Business, a 53-column schema, JSON + CSV + a.meta.jsonsidecar carrying BBB's own result arithmetic.- 340 offline checks that run with no network and no engine library installed.
- A canary that scrapes 3 real pages daily with no secrets — which is also what keeps the claim above honest.
Three things about BBB that will look like bugs
"Best Match" is not a relevance ordering. BBB's own default returned 15 of 15 BBB Accredited businesses on every page tested and, for find_text=restaurants, not one restaurant. The same query under A-Z returned 0 of 15 accredited and real matches. --sort therefore defaults to a-z — the one place this repo deliberately disagrees with the site — and sort is a column, because it changes which businesses are in the file rather than only their order.
A complete run can be a 1.2% sample. Every query is capped at 15 pages of 15 — 225 rows — however many it matched; one measured search reported 19,016. The sidecar records total_results, pages_available, capped_by_site and reachable_max. Page 16 answers HTTP 500, so runs plan against the site's own number.
An unrated business is not rated zero. rating: "" with ratingScore: 0.0 means not graded — 7 of 105 measured rows — and both columns come back null.
Engine limits, stated rather than discovered
selenium_scraper.pycannot authenticate a remote CDP endpoint or a proxy, so it refuses a credentialled--cdp-endpointwith exit 2. Wrong engine for--mode profile; perfectly good for the two listing modes.puppeteer_scraper.pydownloads its own Chromium, which failed to launch during development —--chromium-pathpoints it elsewhere.scraper_api_client.pyrequires--cdp-urlon this site: its own exits are datacenter addresses and BBB refuses them.
Not collected, deliberately
BBB names a business's officers as individuals. There is no column for them, and the committed test fixtures carry placeholders: republishing a named person's details is a separate act from the site showing them on its own page.