Repository navigation
Releases: 2scraper/foodpanda-scraper
Release list
v0.3.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:
--pages 0,--concurrency 0, and a negative
--pages,--concurrency,--delay,--retry-delayor
--proxy-block-retriesare now usage errors (exit 2) in all three engines,
and a negative--retries/--retry-delayinscraper_api_client.py.
Behaviour change: a page that is served WITH vendor tiles and parses to
zero rows now fails the page withstop_reason: parser_found_nothing:
exit 5 when nothing was gathered, exit 6 after earlier pages, where it used
to be exit 4 ("empty listing") or acompleterun. The Playwright and
pyppeteer engines now also pass the navigation's HTTP status to the
classifier, so a refusal with no vendor marker on it under 401/403/429/5xx
is exit 3 rather than a 25-second wait and an exit-4 "empty listing".
Behaviour change: CSV cells that begin with
=,+,-,@, tab, CR
or LF are prefixed with an apostrophe so a spreadsheet reads them as text.
JSON is unchanged; the sidecar recordscsv_cells_escaped.
Behaviour change: the canary FAILS when neither
FOODPANDA_PROXYnor
FOODPANDA_CDP_ENDPOINTis set, and on exit 5; it used to skip green.
Fixed
- Output files are replaced atomically. JSON, CSV and the
<out>.meta.jsonsidecar are written to a temporary file in the same
directory, flushed, fsynced and renamed over the old one. A write that
failed half-way used to truncate the previous good file into invalid
JSON. A new file gets the modeopen()would have given it under the
current umask, and an existing file keeps its own mode. --allow-emptyon a complete, genuinely empty listing writes
status: "complete", not"failed", sodiff_runs.pycan compare it.- The credential scan reads what it used to skip. Exclusions are judged
on the path inside the repository, so a clone under a directory called
venvno longer scans 0 files and reports clean; a scan that read nothing
now fails. Untracked.jsondumps and.env.*backups are scanned
(.envitself, the user's own file, is not). - pyppeteer no longer re-runs a plain load failure for every block
retry. Only an unusable proxy exit, with a pool to rotate to, earns
another round — the same rule as the other two engines. - The Scraping Browser credentials claim is corrected. README,
TROUBLESHOOTING,.env.exampleand the canary said a profile "lives about
a day". That was never a measured constant (one endpoint was still
accepted ten days after it was set); they now say the credentials expire
and that HTTP 401deny_no_useris the signal. - The Turnstile log no longer calls a user-agent mismatch the likeliest
cause of a refused token; on this site a token minted under a different
user agent was accepted. .gitignorecovers--dump-htmlpage dumps of any name (*.page[0-9]*),
a root-level HTML dump and scratch directories (live/,out/, …).- Removed the unused
single_page_modestop reason. - Comments, docs and release notes no longer cite section numbers of an
internal document readers cannot open; a suite check keeps them out. --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 challenge that never clears no longer buys a solve per retry.
SOLVES_PER_PAGEwas enforced on only one of the two
handle_captcha_if_presentcalls in a fetch attempt, so with no proxy pool
a persistent challenge bought one solve on every block-retry: 6 solver
invocations against a limit of 1, reproduced with the solver stubbed. The
budget (page_flow.SolveBudget) is now spent inside the handler, right
before the purchase, shared by both call sites and not reset by a rotation.
All three engines. --delaynow applies between page 1 and page 2. The pause sat at the
tail of the loop, behind a "more pages remain" test, so it only ran between
pages that had both been fetched already and a two-page run never paused.
It now runs before every navigation after page 1, and once before the
--concurrencyworkers start.diff_runs.pyrefuses two runs that read different listings. Both
runs could becompletewith the same countrysourceand a different
city or area, and the diff then reported the AREA change as vendors
appearing and disappearing. The sidecar'sstart_url(host, path, query
withoutpage) must now agree, and a run that stopped at its own--pages
limit is refused against a deeper run.--forcestill overrides, and a
run with nostart_urlin its sidecar is let through as before.- Scraper API:
waitForis now sent as a JSON object. Measured
2026-09-23 againstscraper.2captcha.com/tasks/sync: the JSON-encoded
string this client sent (the form older docs described) is refused with
HTTP 422 "params.waitFor must be an object" -- and the task is still
billed ($0.0005) -- so every run with--wait-textor its sibling wait flags
failed with exit 5. The object form answers HTTP 200. - Scraper API: the target page's status is read from
http_code. The
response'sstatusfield is the API's own verdict string ("success"),
not the target site's HTTP code, so a target 403/503 was never seen by
this client.http_code(an int) is read now, withstatuskept as a
fallback only when it is an int. scraper_api_client.py --helpdescribed two other sites. Its
description said the pages need JavaScript and a single fetch returns
about 5 products, and--urltold you to wait for a
lodging-card-responsiveelement that arrives over GraphQL. Neither is
true here: foodpanda server-renders its grid, and the docstring's own
measurement is 48 of 48 vendors from one request.--categoryno longer
claims to default to a URL segment — this client does not use it.- Issue templates described an auction site. The bug-report hints talked
about lots, reserve prices, a page-100 cap and/en/locales, and the
site-change template's anchors were__NEXT_DATA__and lot cards.
Rewritten from this repo's README, CONTRIBUTING and TROUBLESHOOTING:
headless refusal, PerimeterX retries, directory URLs, the(100+)and
Up toflags, home-page-only delivery columns. - The pyppeteer and Selenium engines' docstrings listed a
--mode product
and gave a Tokopedia path as their--urlexample; both now describe the
onelistingmode and a real area listing. Their unreachable
--mode productsidecar branch (a shop's id and slug, which nothing here
produces) is gone; the listing branch is unchanged. - Comments that told another site's story as this one's:
/p/<slug>hubs,
"hub category", a search grid over GraphQL, akeyword=kopitracking tail
and a detail page's buy box in the engines; an Akamai 394-byte refusal and
auction lots incaptcha_solver.py; a Tokopedia no-results string, an
HTTP/2 stream reset, an Orlando search andshop_ratingin
output_writer.py. Replaced with what this repo measured, or attributed
to the sibling where it happened.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 now says what the README measures: no account needed,
from a residential IP. - The README was missing its canary badge. The workflow has been
running all along; the badge that reports it was never added, so the
one signal that says whether this scraper still works against the live
site was invisible above the fold. - Fifteen lines of unreachable code removed from
playwright_scraper.py.
A function'sdefline had been lost at some point before this repo's
first commit, leaving its docstring and itstry: return page.content()
body indented into the end of_mask_credentials, where the control flow
can never arrive. Nothing called it —_content_when_settledbelow it
does the job — so no behaviour changes. The same fifteen lines, byte for
byte, were in six repos of this family.
Added
- A check for a statement the control flow can never reach. The
undefined-name walk beside it cannot see this class by design: it pools
every binding in a file rather than tracking scopes, so a name used inside
dead code passes as long as anything else in the module binds it. The new
one is narrow — a statement after areturn/raise/break/continuein
the SAME block — and measured across the eighteen repos of this family it
found six real problems and zero false positives. Verified by control:
appendingreturn 1followed by a statement turns the suite red.
v0.2.1 — the help text was still lying
--helpwas still lying. 0.2.0 implemented both of this site's
captchas and corrected the README — but not the engines' own text, which is
what a user reads first.
Two engines were still telling readers "NO challenge has ever been observed
on this site … neither setting has anything to act on today, and neither
helps with a refusal". Playwright's said Cloudflare's challenge "is reported
as blocked instead, so no solve is attempted or billed for it". Both describe
a repo that stopped existing in 0.2.0.
Fixed
-
Every user-facing claim that a captcha cannot be solved is gone — from
--help, from four docstrings and frompage_flow.block_advice. What
replaced them is what was measured: reCAPTCHA Enterprise ~55s /
$0.00299, Turnstile 11s / $0.00145, and PerimeterX's widget-less stub
as the one refusal nothing can buy. -
--helpnamed a host the parser refuses..co.thwas dropped in 0.1.0
— it redirects to robinhood.co.th, a different company — andHOSTShas
held ten entries ever since, while three engines andoutput_writer.py
went on saying "eleven country sites" and playwright's--urlhelp listed
.co.thamong the supported ones. -
The README contradicted itself about Cloudflare. One section said a
managed challenge is what a non-browser client gets and that a browser
engine seeing one means "something has stripped the context" — three
sections above the Turnstile section explaining thatfoodpanda.comserves
one to a real browser, which is how this repo solved one. Same
contradiction in TROUBLESHOOTING.md.
Added
-
A flag reference in the README. There was none: sixteen real flags were
documented nowhere but--help. Four tables — core, proxies, 2Captcha, and
the engine differences — with defaults and what each flag actually does. -
Three checks, because prose does not fail a build on its own: a
banned-phrase check over every shipped.pyand.md; a check that the
documented host count equalslen(HOSTS)and that no user-facing text
advertises a refused host; and a check that every flag an engine accepts
appears in the README, that every in-page link resolves, and that each exit
code is documented.Each was verified to fail when its fix is reverted. A check that cannot
fail is not a check.
532 offline checks, up from 493.
v0.2.0 — the captchas this site serves are solved now
If you read 0.1.0's notes, two claims in them are now false. A 2Captcha
key does clear this site's challenges — both of them — and the canary is
dispatch-only rather than daily.
Everything in this release is about the same thing: what this scraper does
when foodpanda refuses it. 0.1.0 shipped saying a key would not help. That
was wrong in the way that matters most — it reported a limit of this code as
a limit of the product.
Both of this site's challenges are solved now
| task type | measured | |
|---|---|---|
| reCAPTCHA Enterprise on PerimeterX's denial page | RecaptchaV2EnterpriseTaskProxyless |
~55s, $0.00299, page came back with its full grid |
| Cloudflare Turnstile, Challenge page included | TurnstileTaskProxyless |
11s, $0.00145, 837-char token, page became the real site |
The enterprise solve has a control, because a solve that coincides with a
block expiring proves nothing: a plain same-session reload cleared the same
block 0 of 8 times. The token is what got in.
The Turnstile half is harder than it looks. A Challenge page publishes no
sitekey at all — Cloudflare calls turnstile.render(container, params) once
and keeps nothing, so cData, chlPageData and action exist only inside
that call. Every engine now installs an interception script on the context
before any page script runs: add_init_script in Playwright,
evaluateOnNewDocument in pyppeteer, CDP
Page.addScriptToEvaluateOnNewDocument in Selenium — the one thing the three
cannot share.
Still unsolvable, and honestly so: PerimeterX's widget-less stub. 4.7 KB,
24 px-captcha references, no widget at all. Nothing there for any solver at
any price, so nothing is attempted or billed for it.
Changed
- A page with a solvable widget is
challenge, notblocked. Both of
this site's refusals carry one now. - The canary is dispatch-only: no schedule, no badge. This repo runs
nothing live on a timer, and a green badge for a check that is not
happening is as unreadable as one that is always red.
Fixed
- A wrong captcha variant, found by spending real money on the first
attempt — no rung for "challenge frame present, nosize, norender",
so it fell through to v3 and gotERROR_CAPTCHA_UNSOLVABLEafter 87
seconds. v3 renders no challenge frame at all, so a frame present now
settles the variant. - The static detector could not see this site's widget — it bailed unless
the markup containedgrecaptcha, and the denial page carries only the
hyphenated classg-recaptcha. Anyone debugging from a--dump-html
capture was told there was no captcha at all. cf-turnstilewas tried as a marker and thrown away. 2Captcha's
Scraping Browser injects acf-turnstile-responsehunter into every page it
loads, so the string appears once on a served listing and zero times
on the real challenge — a marker that fires on good pages and misses the bad
one.challenges.cloudflare.comis the one that works: 0 on every served
page, 5 on the challenge.- A sitekey-less Turnstile detection raises instead of building a task.
Paying for a request 2Captcha will reject is worse than reporting the page
unsolved.
Tests
493 offline checks, up from 470, none of which touch the site or a
credential.
v0.1.0 — rewritten on the family architecture
If you used the April 2026 prototype, nothing carries over. Not the
command line, not the row schema, not the exit codes. The three monolithic
scripts are replaced by a parser, a page policy, a shared output contract
and three engines that must agree. Oldfoodpanda_*.jsonfiles are not
comparable with this one's output.
Scrapes foodpanda vendor listings — every restaurant and shop tile on a
country home page, a /city/{city} listing or a /city/{city}/area/{area}
listing — with rating, review count, cuisines, deal labels, delivery data and
whether the vendor is open right now. Ten country sites, one schema, JSON and
CSV.
A real run, from an ordinary residential address with no proxy and no key:
python3 playwright_scraper.py \
--url "https://www.foodpanda.pk/city/lahore/area/gulberg" \
--pages 3 --delay 45 --retry-delay 30 --out gulberg
[+] Saved 143 products -> gulberg.json
[+] Wrote run metadata -> gulberg.meta.json (status=complete)
Things worth knowing before you run it
- You do not need to buy anything. Nine of the ten country sites served a
plain headful Chromium on a residential address. What you need is patience:
10 of 30 cold navigations were served across a 20-minute sweep, and the
refusal is per-session — a fresh browser and a slower rate is what clears
it, which is what--retriesdoes here. --headfulis the default, unusually for this family. Headless was
served 0 of 4 times against headful's 4 of 4.- Do not use
--fingerprinton this site. Measured harmful: both
fingerprinted arms were refused 4 of 4 while both plain arms were served on
the first attempt — including a fingerprint whose country matched the exit.
The engine warns at runtime. - A 2Captcha solver will not get you past the block. foodpanda's refusal
renders reCAPTCHA Enterprise, which this project does not implement, so
no solve is attempted and your key is never charged. The other three
2Captcha paths were each run end to end and all work — the Scraping Browser
took three pages, 144 vendors, zero refusals, in 29 seconds. foodpanda.co.this not a foodpanda site. It redirects to
robinhood.co.th, a different company. foodpanda has left Thailand. The
scraper refuses that host with that reason rather than reporting a block.
Output
One row per vendor. Five family columns are absent because a foodpanda tile
has no price — 0 price nodes across 1,952 tiles on two country sites — and
each absence is a measurement written down in output_writer.py.
Three columns are populated only on the HOME page, which is the one listing
foodpanda renders with a delivery address in play: delivery_time,
delivery_fee and price_level. page_kind says which listing you have.
Read the flags beside the numbers: review_count_is_floor (the site caps at
100+, 500+, 1000+) and discount_is_upper_bound (Up to 35% off is a
ceiling, and 21% of measured labels are that shape).
Verification
457 offline checks, fixtures cut from real captures across three country
sites, all three engines run live, and a Docker image built and run in CI.
Six defects were found by running it rather than by reading it — including a
refused page being reported as an exhausted listing with status: complete
and exit 0. See CHANGELOG.md for the full list.