Skip to content

Latest commit

 

History

36 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

farfetch-scraper

release tests canary Python 3.9+ License: MIT Playwright · Selenium · Puppeteer Runs without an account

A working scraper for farfetch.com listing pages — category, hub, search and brand. Not a client for a hosted API: the code in this repo opens the site, handles the page itself, and writes JSON and CSV. Run it on your own machine, on your own IP, and read every line of what it does.

Clone and run. No account, no API key, no signup — a browser it launches itself clears Farfetch's bot management on a clean residential IP, and a filtered category page yields 96 products. Optional 2Captcha integrations (solving, managed browser, proxies, fingerprints — all paid, each billed separately) are there for when you outgrow that; see do you need a paid service for this.

git clone https://github.com/2scraper/farfetch-scraper && cd farfetch-scraper
pip install -r requirements.txt
pip install -r requirements-playwright.txt && playwright install chromium

python3 smoke_test.py          # offline checks, ~1s, no network, no key

python3 playwright_scraper.py \
  --url "https://www.farfetch.com/shopping/kids/girls-clothing-4/items.aspx" \
  --pages 1 --format both --out girls_clothing

Output shape: sample_output.json / sample_output.csv — three rows cut from a real run, so you can see the exact fields before installing anything.

If a run returns 0 products, the usual cause is a hub URL rather than a filtered category URL — see Troubleshooting.


Contents


What people use it for

  • Price and discount monitoringprice, original_price and discount_pct per SKU. Re-run on a schedule and diff on sku — see diff_runs.py.
  • Assortment tracking for a brand or category — what is listed, and what disappeared since the last run, via the same diff.
  • Availabilityin_stock per product.
  • Resale premium checks — Farfetch retail price against a marketplace price for the same item.
  • Currency and market comparison — the same category priced from a different country, by changing the exit IP rather than the URL. A fresh, cookie-less visit is geo-redirected on exit IP; verify the market in the output rather than assuming it — see Geo-redirect.

Listing-level fields only. This repo does not open product pages, so no sizes, materials, colour variants or descriptions — those live one level deeper.


Do you need a paid service for this?

Straight answer, because it decides whether you should keep reading:

For one category page, once — no. An ordinary local Chromium on a clean residential IP cleared Akamai Bot Manager silently and returned all 96 products, with no key and no proxy. That is measured, not assumed. Anyone telling you the first page is impassable is selling something.

At volume, or from a specific country, or when a challenge does appear — yes, and the honest list is short. What a paid service actually buys:

You need Why your own setup runs out What covers it
Many requests per hour Behavioural scoring degrades as one address repeats. Your own IP is the one you cannot rotate. Proxies
A specific country's prices A fresh visit is geo-redirected on exit IP, and your address gives you exactly one market. Proxies or the Scraping Browser API country- segment
A challenge that does not clear A real browser usually clears it; when it does not, something has to answer. Captcha solving, or the browser solving it in-session
No browser infrastructure to run Managing Chromium, versions and concurrency is its own job. Scraping Browser API
Consistent device identity A default automation fingerprint is uniform and therefore distinctive. Fingerprints

All four are 2Captcha products and all four are paid, each billed separately; one API key covers them. Nothing in this repo requires any of them, and every integration is a flag you can leave unset.

Compared with a managed scraping API

Different tools, and the honest trade is not "open source is better":

This repo A hosted scraping API
Cost to start Nothing A subscription, typically per-record or per-month
Runs offline / on your own machine Yes No
You can read and change the extraction Yes — one parser file, ~500 lines No
Someone else fixes it when the site changes No, you or a PR Yes, that is what you pay for
Concurrency and IP pool Yours to build Included
Vendor lock-in None, MIT The output schema is theirs

If your team does not want to own a scraper, buy one. If you want to know exactly what is being requested and how the data is derived, this is that.



Engines

Same CLI, same parsing core, same output. Pick by how you want to reach a browser.

Script Engine Own browser Remote browser over CDP
playwright_scraper.py Playwright ✅ recommended
puppeteer_scraper.py pyppeteer
selenium_scraper.py Selenium ❌ see below
scraper_api_client.py HTTP API, no local browser ✅ via --cdp-url

Selenium cannot use an authenticated remote CDP endpoint. Playwright's connect_over_cdp and Puppeteer's browserWSEndpoint take a full ws://user:pass@host:port and authenticate on the WebSocket upgrade. chromedriver's debuggerAddress takes a bare host:port and has nowhere to put a password — it is not a generic "connect to CDP" option. Use Playwright or pyppeteer for a remote endpoint; Selenium is fine against a browser it launches itself, or a local --remote-debugging-port.

Farfetch listing pages need a rendered browser. The product data is in JSON-LD the page builds client-side, and the product tiles render client-side too. A plain HTTP fetch of the HTML — no browser — is not enough on this site, whichever client performs it. Use a browser engine, or the Scraper API client with --cdp-url pointed at a browser.

# your own browser
python3 playwright_scraper.py --url "$URL" --pages 1 --out girls

# a remote browser over CDP
python3 playwright_scraper.py --url "$URL" --pages 1 --out girls \
  --cdp-endpoint "ws://USER:PASS@HOST:9222"

pip install .[playwright] (or .[puppeteer] / .[selenium]) is equivalent to pip install -r requirements.txt -r requirements-<engine>.txt, if you'd rather install this as a package than clone-and-run. A Dockerfile builds the Playwright engine with Chromium already installed, for a container-based schedule or CI job:

docker build -t farfetch-scraper .
docker run --rm -v "$PWD/out:/out" farfetch-scraper \
  --url "$URL" --pages 1 --out /out/girls_clothing

Configuration

Credentials go in a .env file next to the scripts, not on the command line:

cp .env.example .env
$EDITOR .env
python3 env_config.py     # reports what got picked up, without printing secrets
Variable Backs Needed for
TWOCAPTCHA_KEY --twocaptcha-key, --key Solving, fingerprints, scraper_api_client.py
FARFETCH_CDP_ENDPOINT --cdp-endpoint, --cdp-url Reaching a remote browser
FARFETCH_PROXY --proxy A proxy for a locally launched browser
FARFETCH_URL --url Convenience only

Precedence, highest first: explicit flag → exported environment variable → .env → default. An already-exported variable is never clobbered by the file, so a CI secret keeps working.

.env is in .gitignore. There is no new dependency for this — env_config.py parses the file itself, and defers to python-dotenv only if you already have it.

Prefer this over --twocaptcha-key on the command line. A secret in argv is readable by anything on the machine that can run ps, and it lands in your shell history.


Flags

The three browser engines share these:

Flag Default Description
--url (required) Listing URL
--pages 1 Listing pages to paginate through
--category derived from the URL Free-text label written into every row
--format both json, csv, both
--out farfetch_products Output prefix, also used for debug dumps
--delay 2.0 Seconds between pages
--cdp-endpoint Attach to a running browser instead of launching one
--proxy Proxy for a self-launched browser; ignored with --cdp-endpoint
--retries 3 Attempts per page load, pause doubling each time
--concurrency 1 Playwright only — fetch pages through N parallel workers (details)
--twocaptcha-key 2Captcha API key (or set TWOCAPTCHA_KEY)
--captcha-api v2 v2 (current JSON API) or v1 (legacy in.php)
--min-score 0.7 reCAPTCHA v3 score to request — 0.3, 0.7 or 0.9 only
--allow-empty off Write output even when 0 products were found
--dump-html Save the exact HTML the parser was given, on success too
--headless / --headful headless Local browser only

Playwright also takes --fingerprint, --fp-tags, --fp-country (fingerprints) and the proxy-pool flags --proxy-file, --proxy-rotate, --proxy-shuffle, --proxy-block-retries (proxies). Selenium also takes --chrome-binary, --chromedriver, --disable-build-check and --driver-timeout — see Troubleshooting if a local Selenium run will not start.

scraper_api_client.py takes --url, --key, --cdp-url, --wait-text, --timeout, --retries, --dump-html, --out, --format.

Exit codes

A contract, not decoration — the harness and any pipeline can branch on these.

Code Meaning
0 Products written
1 Unhandled error
2 Bad usage
3 Blocked before parsing — a bot-check or challenge page
4 Ran fine, parsed 0 products
5 Remote API returned an error
6 Partial run — products written, but the page loop stopped early
124 Self-imposed timeout expired

Exit 4 writes nothing. A run that finds nothing leaves the previous output file intact rather than replacing it with [], because a consumer cannot tell an empty category from a failed run. Pass --allow-empty when empty is the expected answer; it writes the file and still exits 4.

Exit 6 writes what it got. A timeout or a challenge on page 3 of 10 still saves the first two pages — discarding good data would be worse — but the result is not a complete view of the category, and a consumer that cannot tell the difference will read the pages that were never fetched as products that disappeared from the catalogue. That is what the run metadata sidecar is for. The site's own pagination simply running out is not a partial run: there was nothing more to fetch, so that still exits 0.

Concurrency

--concurrency N (Playwright only) fetches pages through N parallel workers. It defaults to 1, so the default run is exactly the sequential one.

python3 playwright_scraper.py --url "$URL" --pages 20 \
  --concurrency 4 --proxy-file exits.txt

Measured on a live 4-page run: 57s at --concurrency 3 against 98s sequential, with byte-identical output — same 333 products, in the same order, with no field differing.

Three things worth knowing before raising it:

Each worker owns a browser and one exit for its lifetime. Not one browser shared between workers: with Playwright's sync API a browser belongs to the thread that created it. And not an exit that changes per page either — the invariant from proxies is that a session must not change address mid-flight, and a worker is one session. Workers start on different exits from the pool and can walk the rest of it if one gets blocked.

--concurrency 4 without --proxy-file sends four times the traffic from one address, which is a faster way to get that address scored than to gather data. The run warns and continues rather than refusing, because it is occasionally what you want on a small job.

Page 1 is always fetched on its own, because its content is what decides whether pages 2..N can be addressed independently at all — see Pagination. A listing paginated with a cursor or token falls back to one page at a time and says so.

Ignored with --cdp-endpoint: the Scraping Browser API allows one live connection per profile, so several workers would collide on it (profile_locked). Use several pids, one run each.

Run metadata

Every run that writes output also writes <out>.meta.json beside it:

{
  "source": "farfetch.com",
  "status": "partial",
  "stop_reason": "page_load_timeout",
  "pages_requested": 10,
  "pages_completed": 2,
  "pages_failed": [3],
  "products": 192,
  "start_url": "https://www.farfetch.com/shopping/kids/girls-clothing-4/items.aspx",
  "final_url": "https://www.farfetch.com/de/shopping/kids/girls-clothing-4/items.aspx?page=2",
  "finished_at": "2026-09-07T12:45:31.199634+00:00"
}

status is the field to branch on: complete (everything requested was fetched, or the site's pagination ran out), partial (stopped early), failed (nothing gathered). It describes the run, not the products, which is why it is a sidecar rather than sixteen more identical columns on every row.

pages_failed names the pages that produced nothing, by number. pages_completed alone was a sufficient description only while pages were fetched strictly in order, where "3 of 10" could only mean 1-2-3 — a count stops describing anything once a page can fail while later ones succeed.

A failed run writes no sidecar, deliberately: save leaves the previous run's good output in place, and a "status": "failed" file sitting next to perfectly good data would contradict it.

diff_runs.py reads it and refuses an assortment comparison unless both runs are complete — see Diffing two runs.


Output

{
  "source": "farfetch.com",
  "scraped_at": "2026-08-26T22:40:31.652535+00:00",
  "url": "https://www.farfetch.com/de/shopping/kids/polo-ralph-lauren-kids-t-shirt-mit-polo-bear-print-item-35132321.aspx",
  "sku": "35132321",
  "title": "T-Shirt mit Polo Bear-Print",
  "brand": "POLO RALPH LAUREN KIDS",
  "price": 59.0,
  "currency": "EUR",
  "original_price": null,
  "discount_pct": null,
  "rating": null,
  "review_count": null,
  "in_stock": true,
  "image_url": "https://cdn-images.farfetch-contents.com/35/13/23/21/35132321_69402936_480.jpg",
  "category": "girls-clothing-4",
  "price_source": "jsonld+dom"
}

Sixteen columns, in that order, in both formats. Real rows are in sample_output.json and sample_output.csv — three products cut from an actual run, not hand-written, so the field names there are the field names you get.

price_source says how much to trust price, because the same column can hold two figures with different confidence:

Value Meaning
jsonld+dom The rendered tile was found and reconciled with the JSON-LD figure — either it corrected the price to what a customer pays, or a single-price tile confirmed there is no discount. Trustworthy.
jsonld Structured data only: the tile was missing (this site paints a variable fraction of them) or disagreed. On a discounted item this may be the pre-promo price.
dom The CSS/URL fallback path — read from the tile's own text, with no JSON-LD to cross-check.

Without it, two runs that differed only in how much had rendered produced a false "price changed" in diff_runs.py, which now reports that case separately instead.

Three things about that row, because each looks like a bug and is not:

  • currency is EUR and the title is German. That run exited in Europe. A fresh visit is geo-redirected on exit IP — currency, language and the URL locale follow the address, not the URL you request — see Geo-redirect. The same category through a US address returns USD and English.

  • original_price and discount_pct are null here because that product was not discounted. On a sale item all three price columns populate. Worth knowing how: Farfetch shows three prices per discounted tile — original 245 €, sale 135 €, final 108 € — and its JSON-LD publishes only the middle one. The parser reads the tile as well, so price is what a customer pays (108), original_price is the list price (245), and discount_pct is computed from those two rather than read from the "-45%" the page prints, which is only the first of two compounding discounts. Pass tile_prices_overlay=False to parse_products() for the raw JSON-LD figures instead.

    On a sale page this corrects nearly every row (95 of 96 on the run this was built against). A row is deliberately left alone when the JSON-LD price is not one of the numbers in its tile — the two views then disagree about which product it is, and overwriting a correct row is worse than leaving one uncorrected. It logs a warning naming the SKU when that happens.

  • brand casing is inconsistent — 16 of 96 brands in that run were all-caps (POLO RALPH LAUREN KIDS), the rest mixed-case (Bonpoint). That is how the site stores them. No brand appears in two different casings, so grouping by brand is safe; normalising is left to you, since it would damage names like DSQUARED2.

rating and review_count are null on every row: the listing JSON-LD carries no aggregateRating at all.

A run that finds a sku already written by an earlier page of the same run drops it, rather than duplicating the row. Pagination advances by following NEXT_PAGE_SELECTOR on the page just fetched, and a stale or repeating link would otherwise re-parse a page you already have; this is automatic, on every engine, not a flag.


Diffing two runs

diff_runs.py compares two JSON outputs by sku — the tool the price- and assortment-monitoring use cases above actually depend on:

python3 playwright_scraper.py --url "$URL" --out "girls_$(date +%F)"
python3 diff_runs.py --old girls_2026-08-31.json --new girls_2026-09-07.json --out diff.json

Four buckets, all keyed on sku: added (new since the last run), removed (delisted, or just off this run's page/category), changed (price, original_price, discount_pct, currency or in_stock differs, reported as old value → new value), and source_changed — a price that differs while price_source also differs, meaning one run got the DOM-corrected figure and the other the raw JSON-LD one. That says something about our own two snapshots, not about the site, so it is reported separately and --fail-on-change deliberately ignores it. A row with no sku — or a second row sharing one already seen in the same file — can't be matched across runs at all, so it's counted separately as unmatchable_old/unmatchable_new rather than silently folded into "added" or "removed".

It refuses to run if either side was a partial run, reading the .meta.json sidecar beside each file. A run cut short on page 3 of 10 never saw the products on pages 4–10, and diffing it against a full run reports every one of them as removed — which reads as "delisted" when they were simply never fetched. Re-run the short side, or pass --force to compare anyway. Output written before run metadata existed (or by scraper_api_client.py, which fetches one page and has no pagination to cut short) has no sidecar and is compared without complaint.

--fail-on-change exits 1 when anything changed, for a cron job that should only alert on a real diff:

0 * * * * cd /path/to/farfetch-scraper && \
  python3 playwright_scraper.py --url "$FARFETCH_URL" --out "run_$(date +\%F_\%H)" && \
  python3 diff_runs.py --old "$(ls -t run_*.json | sed -n 2p)" \
                        --new "run_$(date +%F_%H).json" \
                        --out diff.json --fail-on-change || \
  echo "products changed — see diff.json" # replace with a real notification

Using 2Captcha

Four products, each optional and independently useful.

1. Captcha solving

API docs · --twocaptcha-key or TWOCAPTCHA_KEY

Runs after every navigation, on any page — not scoped to one URL. Both detectors always run: one over the static HTML, one in the live page over ___grecaptcha_cfg, and the results are reconciled.

Detected is not the same as blocking, and that distinction costs money. This site carries a reCAPTCHA in its sign-up modal that has nothing to do with the catalogue, so a detection on a page whose products are already rendered is guarding nothing you want. --solve-captcha decides what to do about it:

Value Behaviour
when-blocked (default) Solve only when the catalogue is not already readable. Product links are counted on the spot — no waiting — so this costs nothing to check.
always Solve whenever one is detected. Choose this if you would rather spend a solve than risk missing content that only appears afterwards.

The check deliberately does not work by running the readiness wait first: on a page the captcha genuinely gates, that would burn 20 seconds before solving, and solving first is what makes the products appear.

A challenge this run cannot solve never takes the run down. No key, or a solver error, is a warning — the products may well be readable anyway, and a traceback in their place is strictly worse. If the challenge really was blocking, the run reports that as exit 3 rather than as a crash.

That reconciliation matters more than it sounds. Farfetch's own wrapper element declares data-version="v3", while the Google loader the page actually ships is api.js?render=explicit with size: "invisible" — the v2-invisible signature. The parameters are not interchangeable:

Variant 2Captcha task Parameters
v3 RecaptchaV3TaskProxyless minScore (0.3/0.7/0.9 only) + optional pageAction
v2 invisible RecaptchaV2TaskProxyless isInvisible: true
v2 checkbox RecaptchaV2TaskProxyless

v3 parameters sent for a v2-invisible widget buy a token the site rejects, so the loader wins over the site's own label.

2. Scraping Browser API

Product · --cdp-endpoint

Managed Chrome reached over a CDP WebSocket, with proxies and fingerprints included:

ws://{login}-zone-scraping_browser-country-{cc}-pid-{profileId}:{password}@cb.2captcha.com:9222
Segment Meaning
zone-scraping_browser Product zone
country-us Exit country for the session. A fresh visit's currency and language follow the exit IP, so this also decides those — as does a country-targeted --proxy.
pid-p1 Profile id: cookies and storage persist per profile

One live CDP connection per profile. A second connection to the same pid is rejected (profile_locked); use different pids for parallel sessions. Each distinct pid creates a profile server-side and profiles are capped per account (ERROR_MAX_PROFILES), so reuse them rather than minting one per run.

The browser can also solve challenges itself, before this project's own solver gets a turn:

cdp = context.new_cdp_session(page)
cdp.send("Captcha.setAutoSolve", {"autoSolve": True, "options": [{"type": "*"}]})
cdp.on("Captcha.detected",      lambda *_: ...)
cdp.on("Captcha.waitForSolve",  lambda *_: ...)
cdp.on("Captcha.solveFinished", lambda *_: ...)
cdp.on("Captcha.solveFailed",   lambda *_: ...)

Equivalents: page.target.createCDPSession() in pyppeteer, driver.execute_cdp_cmd(...) in Selenium. All three engines enable it right after connecting and fall back silently if the endpoint does not implement the domain. Treat solveFinished as the success signal and keep the fallback path — do not assume every detection completes.

Never set a user agent over --cdp-endpoint: it contradicts the fingerprint the remote browser already presents, which is worse than not setting one.

3. Proxies

Product · --proxy · --proxy-file

Residential, premium, datacenter, ISP, mobile and SOCKS5, with country/state/city targeting and configurable IP lifetime. Also sold as 2prx.com — the same product, not a second service.

One exit for the whole run:

python3 playwright_scraper.py --url "$URL" \
  --proxy "http://ACCOUNT:PASSWORD@HOST:9999"

A pool to spread the run across, which is the reason to hold more than one:

cat > exits.txt <<'EOF'
# one proxy URL per line; blanks and # comments ignored
http://ACCOUNT:PASSWORD@HOST:9999
http://ACCOUNT:PASSWORD@HOST:10000
http://ACCOUNT:PASSWORD@HOST:10001
EOF

python3 playwright_scraper.py --url "$URL" --pages 20 \
  --proxy-file exits.txt --proxy-rotate per-page --proxy-shuffle
Flag Default Description
--proxy-file One proxy URL per line. Wins over --proxy, and says so rather than silently picking one.
--proxy-rotate per-run per-run: one exit for the whole run. per-page: a new exit for every page.
--proxy-shuffle off Shuffle the pool at startup, so two runs started at once don't both begin on the first line.
--proxy-block-retries 2 When a page comes back as a bot-challenge, retry it from this many other exits before giving up.

A rotation relaunches the browser, and that is deliberate rather than incidental. Swapping the proxy under a live session would be cheaper and wrong: cookies a bot manager issued against one exit, replayed from another, are a stronger signal than either address on its own. So each exit gets a genuinely fresh browser — new cookie jar, new storage — which is what an ordinary user on a different network looks like. per-page therefore costs a browser start per page; per-run is the default because a session that changes address mid-flight is more suspicious than one that does not.

An unusable exit rotates instead of burning retries. Chromium reports a dead or misconfigured proxy as ERR_PROXY_CONNECTION_FAILED / ERR_TUNNEL_CONNECTION_FAILED, distinct from a timeout — the first wants a different exit, the second wants another try at the same one. Retrying a proxy that will not answer just spends the budget.

Credentials go into Playwright's own username/password fields, never into server: that string becomes a Chromium command-line switch, so a user:pass left in it would land in the browser's argv for anything on the machine that can run ps. Log lines mask credentials but keep host and port — which exit a run used is the point of the log, and is not the secret.

Three caveats. Rotation is Playwright-only for now (--proxy still works on every engine); Selenium's --proxy-server flag cannot carry credentials at all (use Selenium-Wire or an extension); and both flags are ignored with --cdp-endpoint, where the remote browser brings its own exit.

Match the proxy's country to your fingerprint's. A US fingerprint arriving on a German IP is a contradiction that is cheap to detect.

4. Fingerprints

Product · --fingerprint

python3 playwright_scraper.py --url "$URL" \
  --fingerprint --fp-tags "Windows,Chrome,Desktop" --fp-country us

fingerprint_client.py fetches one and applies it to a local Playwright context: user agent, screen, locale, navigator.platform / hardwareConcurrency / deviceMemory, and WebGL vendor/renderer on both WebGLRenderingContext and WebGL2RenderingContext — patching only one leaves a mismatch easier to spot than the original values. Responses are cached on disk, keyed on the filter set, because the endpoint is billed per successful response with a per-minute cap.

Local browsers only. With --cdp-endpoint it is ignored: the remote browser brings its own, and stacking two creates a contradiction.

It is a JS-level patch. A fingerprinter that cross-checks a claimed GPU against real rendering output still wins — this raises the floor, it is not a disguise.


How the parser works

product_parser.py does all extraction; output_writer.py holds the row model and the JSON/CSV writers. Two paths, in order:

1. JSON-LD (primary). Reads <script type="application/ld+json">, walks ItemList / itemListElement and pulls each Product. Handles both flat Products and ListItem-wrapped entries.

2. CSS + URL pattern (fallback). Only if the first path yields nothing. Anchors on href matching -item-<digits>.aspx — chosen because a URL pattern outlives CSS class churn — then reads title, brand and prices from around the matched link.

Three details that are easy to get wrong on this site:

  • The product URL is in offers.url, not node.url. No product carries node.url. Read the wrong field and every row points at the category page while title, brand and price all look correct — which is what makes it hard to notice.
  • There is no sku field. The product id is in the URL, so both paths recover it from -item-(\d+)\.aspx.
  • Widen to the parent only when it holds exactly one item link. More than one means the search escaped into a shared grid wrapper, where a product can inherit its neighbour's data.

Prices parse $ € £ ¥, prefixed dollars (HK$, A$, NT$, …) and 3-letter ISO codes (AED 100, 100 CHF) in either position, in all three grouping conventions: 1,234.56, 1.234,56 and 1 234,56 — including the no-break and narrow-no-break spaces a rendered page actually uses. When both a dot and a comma appear, whichever comes last is the decimal point; when only one does, three trailing digits means a thousands grouping ($1,234 is 1234, not 1.234 — none of the currencies here have a 3-digit subunit). Space grouping requires full three-digit groups, so a size list beside a price (5 yrs, 6 yrs 200 €) cannot merge into one number.

A prefixed dollar names its currency (HK$ is HKD, not USD); a bare $ is a guess and reads as USD, which is what it means on the US site. The JSON-LD path supplies the real currency whenever the site publishes one, and the DOM overlay never overwrites it.

One assumption worth knowing, because it is the overlay's load-bearing one: every price in a tile is taken to belong to the same discount chain, so the lowest is what a customer pays. An installment price inside a tile would break that. Checked against a live 106-tile capture — none carried one, and the page's Klarna/Raten text sits in the footer, outside any tile — so it is pinned as a known limitation in the test suite rather than guarded against with locale-chasing word lists or a ratio threshold that would reject this site's real 60%+ discounts.

Codes are matched against an allowlist of real ISO 4217 codes rather than a bare [A-Z]{3}, so a size chart (XXL 100) doesn't become a phantom price. A written code sets currency outright; a bare symbol can only ever be mapped to its most likely code, which is why $ alone yields USD and the DOM overlay never overwrites a currency that JSON-LD stated explicitly.


Site-specific behaviour

Geo-redirect. Farfetch redirects a fresh visit on exit IP, and the URL you request has no say in it. Measured repeatedly: a European address gives /de/ URLs, 125 € with the symbol after the number, and localised product names; a US address gives $125 and English.

That is a measurement of what a first, cookie-less visit does — not the whole mechanism. Farfetch's own help pages describe a shopping location the customer can set, with currency following the shipping destination, and that choice is remembered per session. So the exit IP is what decides the default for a scraper arriving with no state, which is exactly the case here (every rotation starts a fresh browser — see proxies); it is not a claim that IP is the only input the site has. If you need a specific market guaranteed rather than inferred, verify it in the output — currency on every row, and the locale in the sidecar's final_url — instead of assuming the IP settled it.

So pin the exit IP, whichever way you reach the site:

  • A proxy — set the country (and state or city) in the proxy's own targeting, then pass it with --proxy. Works for any local browser, and for the remote one too if you override its proxy there. See 2captcha.com/proxy.
  • The Scraping Browser URL — the country- segment picks the exit country for that session, so country-us gives USD and English without touching anything else.

Both routes do the same job: they decide which country Farfetch thinks you are in. Pick one per run rather than setting both to different countries.

Use filtered category URLs, not the bare hub. /shopping/kids/items.aspx has one JSON-LD block of type Organization and zero products; /shopping/kids/girls-clothing-4/items.aspx has all 96. A hub URL from a European IP would have returned zero products with nothing obviously wrong.

Bot management. Category pages sit behind Akamai Bot Manager (_abck, bm_sz). A real browser clears it silently — including an ordinary local Chromium on a clean residential IP. That is worth knowing before reaching for anything heavier: what a managed browser and proxies buy you is running at volume from many addresses without burning your own, not access to the first page.

Pagination is not in the markup you would expect. Measured 2026-09-07: the page serves no anchor matching a[data-testid='pagination-next'], a[rel='next'] or li.pagination-next a — the visible pager is built client-side without any of them. What it does serve is <link rel="next" href="...?page=2"> in <head>, a standards-based signal that costs nothing to read and long outlives a build-generated attribute. So that selector leads, and product_parser.page_url() reconstructs ?page=N behind it if even that disappears.

This mattered more than it sounds. Before the fallback existed, --pages 3 returned page 1 and exited 0 — a complete, successful-looking run holding a third of the data. That is why the loop now stops on a page that contributes no new sku (a property of the data) rather than on a missing link (a property of a selector), and why the canary requests three pages: with one page, pagination is never exercised at all.

How much of the page has rendered varies, and it affects the prices. Two measurements, both real: an early capture of a filtered category page had 96 products in the JSON-LD but only 18 product anchors in the DOM, while two captures of a sale page had all of them — 108 anchors for 96 products, the extra few being recommendation tiles.

This is why the JSON-LD path is primary: it carries every product regardless of what has painted, and a DOM-only scraper would under-report on the first kind of page. But the discount correction described in Output reads the rendered tiles, so on a page that has only partly painted, some rows keep the JSON-LD price. If a sale page comes back with far fewer discounted rows than it should, that is the reason — raise the wait or re-run, and use --dump-html to see what the parser was actually given.

The sign-up modal has its own captcha, unrelated to Akamai — a Google reCAPTCHA inside the modal. Nothing in this repo submits that form.


Legal

MIT licensed — see LICENSE.

Scrape responsibly: public catalogue data only, at a rate that does not degrade the site. Read Farfetch's terms of service and the law in your jurisdiction before running at volume. This project deliberately never submits the registration form, and you should not either.

About

Farfetch listing-page scraper (Playwright, Selenium, Puppeteer, or a cloud browser via CDP) — JSON-LD parsing, reCAPTCHA solving, proxies, fingerprints

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

4 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages