Repository navigation
Expand file tree
/
Copy pathplaywright_scraper.py
More file actions
1459 lines (1315 loc) · 74.4 KB
/
Copy pathplaywright_scraper.py
File metadata and controls
1459 lines (1315 loc) · 74.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""
farfetch-scraper — Playwright edition (primary engine)
==========================================================
Scrapes Farfetch (farfetch.com) kids category/hub pages: title, price,
original price, discount %, rating (best-effort), image, URL.
Works against ANY Farfetch category/hub page (Kids, Women, Men, Sale,
brand pages, etc.) — no category-specific logic. Product tiles are found
by URL pattern (`-item-<digits>.aspx`), not by CSS class, since Farfetch's
exact class names weren't available to reverse-engineer at write time (see
product_parser.py's module docstring for why that's the more durable choice
anyway).
Features
--------
* Playwright (Chromium) with a real browser context.
* Optional rotating proxy support via 2Captcha proxies (2captcha.com/proxy,
also sold under the 2prx.com name — same product, same gateways).
* Connect to an existing Scraping Browser API session via --cdp-endpoint
instead of launching a bundled Chromium.
* If a reCAPTCHA challenge is detected on ANY page (not just one specific
URL — this check runs after every navigation), it is classified (v3 /
v2-invisible / v2-checkbox) and solved through 2captcha (--twocaptcha-key).
Over the Scraping Browser API you often need none of this: Captcha.setAutoSolve
can clear the challenge inside the browser before this code gets a turn.
* Output: JSON, CSV, or both.
Usage
-----
python playwright_scraper.py \\
--url "https://www.farfetch.com/shopping/kids/items.aspx" \\
--pages 1 \\
--format both \\
--cdp-endpoint "ws://user:pass@cb.2captcha.com:9222"
Requires: pip install -r requirements.txt -r requirements-playwright.txt
then: playwright install chromium
&& playwright install chromium (only if NOT using --cdp-endpoint)
"""
import argparse
import logging
import os
import queue
import sys
import threading
import time
from dataclasses import dataclass, field
from typing import List, Optional
from urllib.parse import urlparse, urljoin, parse_qsl
from playwright.sync_api import (sync_playwright, Error as PWError,
TimeoutError as PWTimeout)
from captcha_solver import (detect_recaptcha_v3, detect_recaptcha_in_page,
reconcile_detections, solve_recaptcha,
SolveBudget,
INJECT_TOKEN_JS)
from product_parser import (parse_products, SELECTORS, detect_bot_challenge,
describe_block, page_url,
count_product_links)
from product_detail_parser import parse_product_detail
from output_writer import (dedupe_by_sku, finish_run, stop_reason_for,
COMPLETE_STOP_REASONS, new_run_id,
ProductVariant, EXIT_FETCH_FAILED,
write_failed_attempt)
from run_state import Checkpoint
from proxy_pool import (from_args as proxy_pool_from_args, to_playwright, mask,
redact_url_credentials, describe_cdp_failure,
cdp_failure_is_transient, CDP_CONNECT_ATTEMPTS,
CDP_CONNECT_PAUSE_S,
ROTATE_MODES, ProxyError, ProxyPool,
precheck_proxy, robots_url, precheck_message,
ProxyPrecheckError, PROXY_PRECHECK_STOP_REASON,
should_block_media, playwright_media_route)
import env_config
import proxy_meter
from arg_types import positive_int, nonneg_int, nonneg_float
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("playwright_scraper")
def _chrome_ua(chromium_version: str) -> str:
"""Build a desktop-Chrome UA naming the browser's OWN real version.
Not a hardcoded version number: that drifts the moment a newer Chromium
ships, and a UA claiming an older Chrome than what the JS engine, WebGL
strings and TLS ClientHello all actually report is itself a mismatch a
fingerprinter can key on.
"""
return (f"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
f"(KHTML, like Gecko) Chrome/{chromium_version} Safari/537.36")
@dataclass
class PageOutcome:
"""What one page of a listing produced.
Collected per page and merged afterwards rather than folded into shared
state as the loop goes. Two reasons, and the second is the point:
dedupe that mutates a running set inside the loop makes the OUTPUT depend
on the order pages happen to arrive in — fine while that order is fixed,
wrong the moment pages are fetched concurrently, because which page
"claims" a duplicate sku (and so which `scraped_at` the row carries)
would vary between runs of the same command. Merging afterwards in page
order is deterministic regardless of arrival order.
"""
page_num: int
url: str
final_url: Optional[str] = None
products: List = field(default_factory=list)
blocked_by: Optional[str] = None
load_failed: bool = False
# The response status for the document itself, where the driver exposes
# one (Playwright and pyppeteer return a Response from goto(); Selenium
# has no equivalent without a CDP session, so it stays None there and the
# HTML markers carry the whole job — which is why a STRUCTURAL
# secondary signal is needed for exactly that case).
http_status: Optional[int] = None
# Chromium's own error text when the EXIT was unusable
# (ERR_PROXY_CONNECTION_FAILED and friends), as opposed to the site being
# slow. Recorded rather than folded into load_failed because the two want
# opposite responses — another try at the same exit vs. a different exit —
# and the run metadata should say which one happened.
proxy_failure: Optional[str] = None
# The page was served, carried product links, and the parser still
# returned nothing. That is not an empty category — it is this repo's
# bug, and the two deserve opposite responses from whoever reads the run.
parse_drift: bool = False
@property
def http_error(self) -> bool:
"""The edge answered, with an error. Whatever came back is not the
listing, even when no vendor marker names who refused us — an
unrecognised 403 body is still a 403."""
return self.http_status is not None and self.http_status >= 400
@property
def ok(self) -> bool:
return (not self.load_failed and self.blocked_by is None
and not self.http_error)
def _plan_page_urls(page, args, page_one_url: str) -> Optional[List[str]]:
"""URLs for pages 2..N, decided once from page 1, or None to chain.
Following the site's own next-link one page at a time is correct but
strictly sequential: the address of page 5 is not knowable until page 4
has been fetched. Constructing `?page=N` up front removes that chain —
which is what makes fetching pages independently (and later,
concurrently) possible at all.
It is only safe when the site's own link AGREES with the convention, so
that is checked rather than assumed: if page 1's next-link is not what
`page_url()` would build for page 2, pagination is carrying something the
convention cannot reproduce (a cursor, a token, a filter id) and the
caller must keep chaining link to link. Returns None in that case.
"""
if args.pages < 2:
return None
constructed = page_url(page_one_url, 2)
next_link = page.query_selector(NEXT_PAGE_SELECTOR)
href = next_link.get_attribute("href") if next_link else None
if href:
advertised = _resolve_pagination_url(page_one_url, href)
if _same_url(advertised, constructed):
logger.info("Pagination follows the ?page=N convention (page 2 "
"link matches the constructed URL) — planning pages "
"2-%d up front.", args.pages)
else:
logger.info("The site's own next-page link (%s) is not what the "
"?page= convention would build (%s) — following its "
"links one page at a time instead. Pages cannot be "
"fetched independently for this listing.",
advertised, constructed)
return None
else:
logger.warning(
"No pagination link matched %s on page 1 — falling back to the "
"?page= URL convention. If this repeats, the site's markup has "
"probably changed and NEXT_PAGE_SELECTOR needs updating.",
NEXT_PAGE_SELECTOR)
return [page_url(page_one_url, n) for n in range(2, args.pages + 1)]
def _next_url_from_page(page, args, page_num: int) -> str:
"""Next page's URL from the site's own link, falling back to ?page=N.
Only used when pagination could not be planned up front. A missing link
must not end the run: pagination resting entirely on DOM selectors is a
silent-success failure waiting to happen (see #11), so the convention
backs it up and the DATA decides when to stop.
"""
next_link = page.query_selector(NEXT_PAGE_SELECTOR)
href = next_link.get_attribute("href") if next_link else None
if href:
return _resolve_pagination_url(page.url, href)
return page_url(page.url, page_num + 1)
def _same_url(a: str, b: str) -> bool:
"""URL equality that ignores query-parameter ORDER, which carries no meaning."""
pa, pb = urlparse(a), urlparse(b)
return (pa.scheme, pa.netloc, pa.path.rstrip("/")) == \
(pb.scheme, pb.netloc, pb.path.rstrip("/")) and \
sorted(parse_qsl(pa.query)) == sorted(parse_qsl(pb.query))
# Chromium's own names for "the proxy is the problem, not the site". Matched
# on the error text because Playwright surfaces them as a generic Error.
_PROXY_ERROR_MARKERS = (
"ERR_PROXY_CONNECTION_FAILED", # nothing listening / refused
"ERR_TUNNEL_CONNECTION_FAILED", # CONNECT rejected by the proxy
"ERR_PROXY_AUTH_UNSUPPORTED", # auth scheme we cannot satisfy
"ERR_PROXY_AUTH_REQUESTED", # credentials missing or wrong
"ERR_UNEXPECTED_PROXY_AUTH",
"ERR_PROXY_CERTIFICATE_INVALID",
)
def _proxy_failure(exc) -> str:
"""The Chromium proxy-error name in `exc`, or "" if it is not one.
Distinguishing this from an ordinary timeout matters because the two want
opposite responses: a timeout deserves a retry from the same exit, while
an unusable exit deserves a different exit — retrying it unchanged just
spends the retry budget on a proxy that is not going to answer.
"""
text = str(exc)
for marker in _PROXY_ERROR_MARKERS:
if marker in text:
return marker
return ""
def _launch_local(pw, args, pool):
"""Launch our own Chromium on `pool`'s current exit; return (browser, context, page).
Factored out of scrape() so a proxy rotation can tear the whole browser
down and call this again. 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 alone.
A rotation therefore means a genuinely fresh browser — new cookie jar,
new storage — which is what an ordinary user on a different network
looks like.
"""
launch_kwargs = {"headless": args.headless}
proxy = to_playwright(pool.current) if pool else None
if proxy:
launch_kwargs["proxy"] = proxy
logger.info("Using proxy exit %s", mask(pool.current))
browser = pw.chromium.launch(**launch_kwargs)
# Only override the UA when we launched our own bundled Chromium.
# Forcing a UA on a page reached via --cdp-endpoint mismatches
# the remote browser's real TLS/JS fingerprint on purpose-
# matched values — a mistake that broke a previous run in this
# family with an Akamai "Access Denied."
ctx_kwargs = {"user_agent": _chrome_ua(browser.version), "locale": "en-US"}
init_script = None
if args.fingerprint:
# Only meaningful on this branch. Over --cdp-endpoint the Scraping Browser
# browser already has its own fingerprint, and layering a second
# one on top produces a mismatch rather than better cover.
from fingerprint_client import (get_fingerprint,
playwright_context_kwargs,
playwright_init_script)
fp = get_fingerprint(args.twocaptcha_key,
tags=args.fp_tags, country=args.fp_country)
ctx_kwargs.update(playwright_context_kwargs(fp))
init_script = playwright_init_script(fp)
logger.info("Using 2captcha fingerprint %s (%s)", fp.get("id"), fp.get("country"))
context = browser.new_context(**ctx_kwargs)
if init_script:
# Must be installed on the context, before any page script runs.
context.add_init_script(init_script)
if should_block_media(args, proxy_in_use=bool(proxy)):
# Behind a metered proxy: abort images, video/audio and web fonts.
# No parser reads them (image_url comes from the markup, not from a
# loaded image). --load-media keeps them.
context.route("**/*", playwright_media_route)
logger.info("Blocking images/media/fonts "
"(--load-media keeps them).")
if proxy:
# Counted for --proxy-budget-mb. Registered after the media route, so
# it is asked first and aborts every request once the budget is spent.
proxy_meter.current().attach_playwright(context)
return browser, context, context.new_page()
class _BrowserSession:
"""One browser + context + page, relaunchable onto a different exit.
Exists because a rotation replaces all three handles at once, and passing
three mutable locals through every helper is how one of them ends up
stale. It also gives a worker thread a single object to own: with
Playwright's sync API, a browser and everything reachable from it belong
to the thread that created them, so each worker builds its own.
"""
def __init__(self, pw, args, pool, remote: bool = False):
self.pw, self.args, self.pool, self.remote = pw, args, pool, remote
self.browser = self.context = self.page = None
def open(self):
if self.remote:
self.browser, self.context, self.page = _connect_remote(self.pw, self.args)
else:
self.browser, self.context, self.page = _launch_local(
self.pw, self.args, self.pool)
return self
def relaunch(self):
"""Tear the browser down and come back on the pool's current exit.
A rotation must not reuse the session: see _launch_local for why
carrying cookies across exits is worse than either exit alone. On a
remote browser this is a no-op — its exit is not ours to change.
"""
if self.remote:
return
try:
self.browser.close()
except Exception as e: # noqa: BLE001 — teardown must not mask the reason we're here
logger.debug("Ignoring error while closing browser for rotation: %s", e)
self.open()
def close(self):
try:
if self.remote:
self.page.close() # leave the remote browser app running
else:
self.browser.close()
except Exception as e: # noqa: BLE001
logger.debug("Ignoring error during browser teardown: %s", e)
class CdpConnectError(RuntimeError):
"""A --cdp-endpoint that could not be attached to. The message is already
free of credentials; the run reports it as exit 5 (content never
obtained), not as a traceback."""
def _connect_remote(pw, args):
"""Attach to an already-running browser over CDP; return (browser, context, page)."""
logger.info("Connecting to existing browser over CDP: %s",
_mask_credentials(args.cdp_endpoint))
# Explicit timeout. Playwright defaults to 30s here, but stating it
# makes the contract visible next to the pyppeteer twin, which has
# no connect timeout at all and had to grow one by hand after a
# ten-minute hang. A Scraping Browser session that is still held
# answers with HTTP 500 rather than stalling, so this mostly guards
# against the endpoint going quiet.
browser = None
for attempt in range(1, CDP_CONNECT_ATTEMPTS + 1):
try:
browser = pw.chromium.connect_over_cdp(args.cdp_endpoint,
timeout=30000)
break
except (PWTimeout, PWError) as e:
# Playwright's message repeats the endpoint, `user:pass` and all,
# in the message and again in its call log, so nothing from it
# is printed or re-raised unredacted.
text = str(e)
if cdp_failure_is_transient(text) and attempt < CDP_CONNECT_ATTEMPTS:
logger.warning("CDP connect refused (%s) — retrying in %.0fs "
"(%d/%d).", redact_url_credentials(text).splitlines()[0]
if text else "no message", CDP_CONNECT_PAUSE_S,
attempt, CDP_CONNECT_ATTEMPTS)
time.sleep(CDP_CONNECT_PAUSE_S)
continue
raise CdpConnectError(describe_cdp_failure(text)) from None
# Reuse the remote browser's existing context so its
# fingerprint/session/proxy settings stay intact.
context = browser.contexts[0] if browser.contexts else browser.new_context()
page = context.new_page()
# RETROACTIVE ADDITION (added after this project was originally
# delivered — found via 2captcha's own Scraping Browser API docs while
# building a later project in this family:
# https://2captcha.com/scraper/browser-api/api). A real,
# documented CDP domain (`Captcha.setAutoSolve` / `Captcha.solve`)
# that solves reCAPTCHA, Cloudflare Turnstile, and more,
# automatically — but must be explicitly enabled per session.
# Tried first when --cdp-endpoint is set; this script's own
# regex-based detect+solve logic still runs as a fallback if this CDP
# domain isn't supported by whatever --cdp-endpoint actually points at.
try:
cdp_session = context.new_cdp_session(page)
cdp_session.send("Captcha.setAutoSolve", {"autoSolve": True, "options": [{"type": "*"}]})
cdp_session.on("Captcha.detected", lambda *_: logger.info("[Scraping Browser] CAPTCHA detected on page."))
cdp_session.on("Captcha.waitForSolve", lambda *_: logger.info("[Scraping Browser] CAPTCHA sent to 2captcha for solving."))
cdp_session.on("Captcha.solveFinished", lambda *_: logger.info("[Scraping Browser] CAPTCHA solved automatically."))
cdp_session.on("Captcha.solveFailed", lambda *_: logger.warning("[Scraping Browser] CAPTCHA auto-solve failed."))
logger.info("2captcha Scraping Browser API Captcha.setAutoSolve enabled — reCAPTCHA, "
"Turnstile, and other supported types will be solved automatically "
"if this --cdp-endpoint is a 2captcha Scraping Browser API session.")
except Exception as e:
logger.info("Captcha.setAutoSolve not available on this --cdp-endpoint (%s) — "
"relying on this script's own detect+solve logic instead.", e)
return browser, context, page
def _resolve_pagination_url(base_url: str, href: str) -> str:
"""Resolve a pagination link's raw href against the page it came from.
Playwright's get_attribute("href") returns the raw HTML attribute,
unresolved — unlike the DOM .href property Puppeteer/Selenium read for
the same purpose in this project, which the browser resolves for you.
A hand-rolled "startswith('http') else base + href" got this wrong for
an absolute-path href once the base URL's own query string was
stripped first: base ".../items.aspx?page=1" + href
"/shopping/kids/.../items.aspx?page=2" pasted into one broken, doubled
URL. urljoin handles every shape correctly instead — absolute,
protocol-relative, absolute-path, and page-relative hrefs alike.
"""
return urljoin(base_url, href)
ITEM_LINK_SELECTOR = SELECTORS["item_link"]
# Ordered most-durable first. `<link rel="next">` in <head> is a W3C/SEO
# convention rather than a build artefact, and on 2026-09-07 it was the ONLY
# one of these that farfetch.com actually served: the three anchor selectors
# below (which this project shipped with) matched nothing at all, so a
# multi-page run silently returned page 1 and reported success. Kept anyway —
# they cost nothing and the visible UI may come back — but the standards-based
# one leads, and product_parser.page_url() backs all of them up.
NEXT_PAGE_SELECTOR = ("link[rel='next'], a[data-testid='pagination-next'], "
"a[rel='next'], li.pagination-next a")
# How many product-link matches must appear before we treat the page as
# "actually loaded" rather than a lucky single match on an unrelated link
# that happens to share the URL shape (e.g. a "sizing guide" link). Learned
# the hard way on a previous single-site scraper in this family: waiting
# for just ONE match resolves in ~2s on an unrelated element, long before
# the real grid renders, giving a fast false-positive "ready" state.
MIN_CARD_MATCHES = 5
def _mask_credentials(url: str) -> str:
"""Never print a username:password embedded in a ws://... or http://... URL."""
if "@" not in url:
return url
scheme_sep = url.find("://")
if scheme_sep == -1:
return url
scheme, rest = url[:scheme_sep + 3], url[scheme_sep + 3:]
_, _, host_part = rest.partition("@")
return f"{scheme}***:***@{host_part}"
def _content_when_settled(page, attempts: int = 4, pause_ms: int = 700):
"""page.content() that tolerates a page mid-navigation.
Playwright raises `Page.content: Unable to retrieve content because the
page is navigating and changing the content` if the document swaps under
it. On farfetch.com that is not an edge case: the site geo-redirects
client-side, so a snapshot taken right after goto() can land exactly on
the swap. A live run lost an entire test to it — an unhandled exception out
of the captcha check, before a single product was parsed.
Retries briefly and returns None if the page won't hold still, so the
caller can skip captcha detection instead of failing the run.
"""
for attempt in range(1, attempts + 1):
try:
return page.content()
except PWError as e:
if "navigating" not in str(e).lower():
raise
if attempt == attempts:
logger.warning("Page kept navigating through %d attempts — skipping "
"captcha detection for this navigation.", attempts)
return None
logger.info("Page is navigating (client-side redirect?) — retrying "
"content() in %dms (%d/%d).", pause_ms, attempt, attempts)
page.wait_for_timeout(pause_ms)
return None
def handle_captcha_if_present(page, args, budget=None) -> None:
"""Runs after EVERY navigation, for ANY page — not scoped to one URL.
Two detectors, in order:
1. the static-HTML one, which is cheap and catches the old
`<captcha-widget data-sitekey=...>` / inline-`grecaptcha.execute`
markup;
2. the runtime one, which reads the live page's reCAPTCHA client
config. Needed because as of 2026-08-24 farfetch.com configures the
widget purely in JS — no sitekey appears in the served HTML at all,
so detector 1 finds nothing on a page that definitely has a
reCAPTCHA. See captcha_solver.py for the full write-up.
"""
html = _content_when_settled(page)
if html is None:
# Couldn't get a stable snapshot — skip captcha detection for this
# navigation rather than taking the whole run down. The next
# navigation gets another chance, and the product parse below reads
# its own copy of the DOM.
return
# BOTH detectors run, always — not static-then-fallback. On farfetch.com
# they disagree on the same page (see reconcile_detections), and the
# static one is the less trustworthy of the two, so short-circuiting on
# it would send the wrong parameters to 2captcha.
html_challenge = detect_recaptcha_v3(html, page.url)
runtime_challenge = detect_recaptcha_in_page(
lambda js: page.evaluate(js), page_url=page.url)
challenge = reconcile_detections(html_challenge, runtime_challenge)
if not challenge:
return
# Detected is not the same as blocking. 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 present means the
# challenge is not standing between us and anything. Counting the links
# is instant — no wait_for_function, no 20s — which is why the check can
# sit here rather than forcing the readiness wait to run first (doing
# that would cost 20 wasted seconds on a page the captcha genuinely
# gates, where solving first is what makes the products appear).
if getattr(args, "solve_captcha", "when-blocked") == "when-blocked":
visible = len(page.query_selector_all(ITEM_LINK_SELECTOR))
if visible > MIN_CARD_MATCHES:
logger.info("%s detected via %s, but %d product links are already "
"on the page — not solving it. The catalogue is not "
"what this challenge is guarding. Pass "
"--solve-captcha always to solve it anyway.",
challenge.kind, challenge.source, visible)
return
logger.warning("%s detected via %s (sitekey=%s, action=%s) — attempting to solve.",
challenge.kind, challenge.source, challenge.sitekey, challenge.action)
# A captcha this run cannot solve must not take the run down with it. The
# products may well be readable anyway, and a traceback in place of them
# is strictly worse than a warning: the missing key raised RuntimeError
# out of solve_recaptcha, through here, and out of scrape().
if not args.twocaptcha_key:
logger.warning("No 2captcha API key, so this challenge cannot be "
"solved — continuing with whatever the page already "
"holds. Pass --twocaptcha-key or set TWOCAPTCHA_KEY if "
"the run comes back blocked (exit 3).")
return
# Checked BEFORE the purchase, against an allowance the caller holds
# for the whole page, so retries and exit rotations cannot each buy
# another token. Without a caller's budget, this call alone may buy one.
if not (budget if budget is not None else SolveBudget()).take():
return
try:
token = solve_recaptcha(challenge, args.twocaptcha_key,
api_version=args.captcha_api,
min_score=args.min_score)
except Exception as e: # noqa: BLE001 — a solver failure is not a crash
logger.error("Solving the challenge failed (%s) — continuing with "
"whatever the page holds. If it was in fact blocking, "
"the run will report that as exit 3.", e)
return
page.evaluate(INJECT_TOKEN_JS, token)
logger.info("Token injected. Reloading page to continue.")
page.wait_for_timeout(1500)
page.reload(wait_until="domcontentloaded", timeout=60000)
def _fetch_one_page(session, args, pool, page_num: int, url: str) -> PageOutcome:
"""Fetch and parse one page. Retries, rotations and debug dumps live here.
Returns a PageOutcome and never raises for an EXPECTED failure — a
timeout, a challenge page, a dead exit are all recorded on the outcome
instead. What the run should do about them differs between the sequential
and concurrent paths, so that decision belongs to the caller rather than
to a raised exception unwinding through it.
Always goes through `session.page`, never a captured local: a rotation
replaces the browser, context and page together, and a stale handle is
exactly the bug _BrowserSession exists to prevent.
"""
outcome = PageOutcome(page_num=page_num, url=url)
# How many times a blocked page may be retried from a DIFFERENT exit.
# Zero without a pool: there is nowhere else to go, and a bare retry from
# the same address just burns it further.
block_retries = args.proxy_block_retries if (pool and len(pool) > 1) else 0
html, vendor, load_failed = None, None, False
http_status = None
# One paid-solve allowance for this PAGE, created outside the rotation
# loop so a fresh exit does not bring a fresh allowance with it.
budget = SolveBudget()
for block_attempt in range(block_retries + 1):
if proxy_meter.exceeded():
# --proxy-budget-mb is spent: no new navigation through the proxy.
outcome.load_failed = True
return outcome
logger.info("Fetching page %d/%d: %s", page_num, args.pages, url)
# Retry a navigation timeout rather than ending the run on it.
# One network flap on page 12 of 50 used to break the loop, and
# scraper_api_client.py has had --retries all along — the same
# transient deserved the same treatment here.
load_failed, exit_failed = False, None
for attempt in range(1, args.retries + 1):
try:
# goto() hands back the Response for the document itself.
# It was being discarded, and with it the single most
# reliable signal a refusing edge gives us: Farfetch's
# "Access Denied" page arrives under HTTP 403 (measured
# 2026-09-14), while every marker on it was unknown to the
# parser. So the status is taken first.
resp = session.page.goto(url, wait_until="domcontentloaded",
timeout=60000)
http_status = resp.status if resp else None
load_failed = False
break
except (PWTimeout, PWError) as e:
# A dead or misconfigured proxy raises PWError
# (net::ERR_PROXY_CONNECTION_FAILED), not PWTimeout —
# catching only the latter let it escape as a traceback,
# which is the likeliest failure the first time anyone points
# --proxy-file at a real list.
reason = _proxy_failure(e)
if reason:
exit_failed = reason
load_failed = True
break # a different exit is the only thing that helps
load_failed = True
if attempt < args.retries:
pause = args.retry_delay * (2 ** (attempt - 1))
logger.warning("Timeout loading %s (attempt %d/%d) — "
"retrying in %.1fs.", url, attempt,
args.retries, pause)
time.sleep(pause)
if exit_failed and block_attempt < block_retries:
logger.warning("Exit %s is unusable (%s) — rotating to another "
"one (%d/%d).", mask(pool.current), exit_failed,
block_attempt + 1, block_retries)
pool.advance(f"unusable exit: {exit_failed}")
session.relaunch()
continue
if load_failed:
break
handle_captcha_if_present(session.page, args, budget=budget)
if http_status is not None and http_status >= 400:
# An error status is not a page that has yet to paint, so the
# readiness wait below can only expire. Skipping it is worth
# doing rather than tidy: the wait is 20s, and it was being
# spent on every attempt of every page of a blocked run —
# 20s x --retries x --proxy-block-retries x --pages of nothing.
logger.warning("HTTP %d for %s — the edge answered with an error, "
"so this is not the listing. Not waiting for "
"products to paint.", http_status, url)
else:
# Don't wait for network idle (retail sites never go fully
# quiet) and don't accept a single selector match as "ready"
# (see MIN_CARD_MATCHES comment above).
try:
session.page.wait_for_function(
f"document.querySelectorAll({ITEM_LINK_SELECTOR!r}).length > {MIN_CARD_MATCHES}",
timeout=20000,
)
session.page.wait_for_timeout(1000)
except PWTimeout:
logger.warning("No product markers appeared within 20s — "
"parsing whatever loaded (may be a bot-check/consent page).")
html = session.page.content()
vendor = detect_bot_challenge(html)
if not vendor:
break
# Blocked. A different exit is the one thing that plausibly changes
# the outcome — the address is what was scored, so retrying from it
# unchanged would only confirm the block.
if block_attempt < block_retries:
logger.warning("Blocked by %s on page %d from %s — retrying "
"from another exit (%d/%d).", vendor, page_num,
mask(pool.current), block_attempt + 1, block_retries)
pool.advance(f"blocked by {vendor} on page {page_num}")
session.relaunch()
outcome.http_status = http_status
outcome.proxy_failure = exit_failed
if load_failed:
if exit_failed:
logger.error("Gave up loading %s: the exit is unusable (%s). That "
"is a dead proxy, not a slow site — another try at "
"the same address cannot help.", url, exit_failed)
else:
logger.error("Gave up loading %s after %d attempt(s).", url, args.retries)
outcome.load_failed = True
return outcome
# Dumping on success, not only on failure: a run can return the
# right NUMBER of products with a field silently unpopulated, and
# then the only way to tell a parsing bug from a too-early snapshot
# is to inspect the exact bytes the parser was given.
if args.dump_html:
dump_path = (args.dump_html if args.pages == 1
else f"{args.dump_html}.page{page_num}")
with open(dump_path, "w", encoding="utf-8") as f:
f.write(html)
logger.info("Saved the snapshot the parser sees to %s (%d bytes).",
dump_path, len(html))
if vendor:
debug_html = f"{args.out}_page{page_num}_debug.html"
with open(debug_html, "w", encoding="utf-8") as f:
f.write(html)
try:
session.page.screenshot(path=f"{args.out}_page{page_num}_debug.png",
full_page=True)
except Exception as e:
logger.warning("Could not capture screenshot: %s", e)
logger.error("Blocked by %s before parsing (%d bytes) — "
"saved to %s%s. This is exit 3, distinct from a genuinely "
"empty category (exit 4).", describe_block(html, vendor), len(html), debug_html,
f" (tried {block_retries + 1} exit(s))" if block_retries else "")
outcome.blocked_by = vendor
return outcome
if outcome.http_error:
# An error status whose body carries no marker we recognise. We still
# know this is not the listing, and saying "0 products" about it
# would be a claim about the catalogue that this run cannot support.
# Reported separately from `blocked` precisely because we CANNOT name
# who refused us — a guess is never presented as a fact.
debug_html = f"{args.out}_page{page_num}_debug.html"
with open(debug_html, "w", encoding="utf-8") as f:
f.write(html)
logger.error("HTTP %d for %s and nothing on the page names a known "
"bot-check vendor — saved %d bytes to %s. Reported as a "
"fetch failure, not an empty category.",
outcome.http_status, url, len(html), debug_html)
return outcome
products = parse_products(html, session.page.url, category=args.category)
logger.info("Parsed %d products from page %d.", len(products), page_num)
# A discounted listing where nothing came back discounted is the
# signature of a snapshot taken before prices render. Say so at the
# point it happens rather than leaving a column quietly empty.
if products and not any(p.original_price for p in products):
logger.debug("No product on page %d carried an original_price. "
"Expected on a full-price category; on a sale page "
"it means the prices had not rendered yet — re-run "
"with --dump-html to check.", page_num)
if not products:
debug_html = f"{args.out}_page{page_num}_debug.html"
debug_png = f"{args.out}_page{page_num}_debug.png"
with open(debug_html, "w", encoding="utf-8") as f:
f.write(html)
try:
session.page.screenshot(path=debug_png, full_page=True)
except Exception as e:
logger.warning("Could not capture screenshot: %s", e)
linked = count_product_links(html)
if linked >= MIN_CARD_MATCHES:
# The distinction the audit asked for. A page that LINKS to 18
# products and parses to 0 is a broken parser, not an empty
# category, and reporting it as "no products" sends the reader to
# check the URL instead of the JSON-LD.
outcome.parse_drift = True
logger.error("PARSER DRIFT: the page links to %d product(s) and "
"the parser extracted 0. This is not an empty "
"category — the markup or the JSON-LD shape has "
"changed. Saved what the browser saw to %s and %s.",
linked, debug_html, debug_png)
else:
logger.warning("0 products parsed, and the page links to %d "
"product(s) — consistent with an empty or filtered "
"category, or a hub URL. Saved what the browser "
"actually saw to %s and %s. Open the .png to see "
"it.", linked, debug_html, debug_png)
outcome.products = products
outcome.final_url = session.page.url
return outcome
def _worker_pool(pool, worker_index: int):
"""A private ProxyPool for one worker, starting at a different exit.
Each worker gets its OWN pool object holding the same exits rotated to a
different offset. Two things fall out of that, both wanted:
* Workers start on distinct exits, which is the point of running
several — N workers all leaving from one address is just a faster
way to burn that address.
* No shared mutable state between threads, so rotation needs no lock.
A worker that gets blocked can still walk the rest of the pool on
its own.
Its exit stays put for the worker's lifetime otherwise: the invariant
from _launch_local is that a SESSION must not change address mid-flight,
and a worker is one session.
"""
if not pool:
return None
proxies = pool.proxies
offset = worker_index % len(proxies)
return ProxyPool(proxies[offset:] + proxies[:offset], rotate="per-run")
def _fetch_pages_concurrently(args, pool, specs, concurrency: int,
checkpoint=None):
"""Fetch `specs` [(page_num, url), ...] across `concurrency` workers.
Each worker owns its own Playwright instance, browser and exit: with the
sync API a browser belongs to the thread that made it, so sharing one
across threads is not an option even if it were desirable.
"""
work = queue.Queue()
for spec in specs:
work.put(spec)
results = []
results_lock = threading.Lock()
# Set when a page comes back with no products at all — the end of the
# listing. Without it, asking for 50 pages of a 5-page category would
# fetch 45 empty ones. Workers check it before taking more work, so at
# most (concurrency - 1) extra pages are in flight when it trips.
exhausted = threading.Event()
def worker(index: int):
name = f"worker-{index + 1}"
try:
with sync_playwright() as pw:
session = _BrowserSession(pw, args, _worker_pool(pool, index)).open()
try:
first = True
while not exhausted.is_set() and not proxy_meter.exceeded():
try:
page_num, url = work.get_nowait()
except queue.Empty:
break
if not first:
time.sleep(args.delay)
first = False
outcome = _fetch_one_page(session, args, session.pool,
page_num, url)
with results_lock:
results.append(outcome)
# Written from the COLLECTING side, inside the
# lock that already serialises results — not from
# each worker, which would have several threads
# rewriting one file.
if checkpoint is not None and outcome.ok:
checkpoint.record(page_num, outcome.products,
outcome.final_url)
if outcome.ok and not outcome.products:
logger.info("[%s] page %d returned no products — "
"treating that as the end of the listing "
"and stopping dispatch.", name, page_num)
exhausted.set()
finally:
session.close()
except Exception: # noqa: BLE001 — a dead worker must not hang the run
logger.exception("[%s] died; its pages will be reported as failed.", name)
threads = [threading.Thread(target=worker, args=(i,), name=f"page-worker-{i + 1}")
for i in range(concurrency)]
for t in threads:
t.start()
for t in threads:
t.join()
# Anything still queued was never attempted (a worker died, or dispatch
# stopped at the end of the listing). Not reported as failed pages: they
# were not tried, and claiming otherwise would overstate the damage.
unattempted = []
while True:
try:
unattempted.append(work.get_nowait()[0])
except queue.Empty:
break
return results, sorted(unattempted), exhausted.is_set()
def _crawl_details(session, args, listing_rows, checkpoint):
"""Open each listed product and return one row per size.
Sequential, deliberately, for a first cut: detail mode multiplies a run's
requests by the number of products on a page (~18 here), and raising that
again with workers is a decision that wants a live measurement behind it
rather than a default.
The URLs come from the LISTING ROWS rather than from the page's HTML, so
the crawl and the listing parser cannot disagree about what counts as a
product — and the listing rows are already de-duplicated, which matters
because a tile links to its product twice.
Returns (rows, failures, attempted).
"""
urls, seen = [], set()
for row in listing_rows:
if row.url and row.url not in seen:
seen.add(row.url)
urls.append(row.url)
total = len(urls)
if args.max_products and total > args.max_products:
logger.warning("Listing has %d products; --max-products %d caps this "
"run. The cap is recorded in the run metadata, so a "
"truncated crawl does not read as a complete one.",
total, args.max_products)
urls = urls[:args.max_products]
logger.info("Detail mode: opening %d product page(s)%s.", len(urls),
f" of {total}" if len(urls) != total else "")
rows, failures = [], []
for i, url in enumerate(urls, start=1):
if checkpoint.has(i):
continue
if proxy_meter.exceeded():
break # --proxy-budget-mb: no further product page through the proxy
if i > 1:
time.sleep(args.delay)
try:
resp = session.page.goto(url, wait_until="domcontentloaded",
timeout=60000)
status = resp.status if resp else None
if status is not None and status >= 400:
logger.warning("Product %d/%d: HTTP %d for %s — skipping.",
i, len(urls), status, url)
failures.append(url)
continue
session.page.wait_for_timeout(1500)
html = session.page.content()
except (PWTimeout, PWError) as e:
reason = _proxy_failure(e)
logger.warning("Product %d/%d failed (%s) — skipping.", i,
len(urls), reason or type(e).__name__)
failures.append(url)
continue
vendor = detect_bot_challenge(html)
if vendor:
logger.error("Product %d/%d: blocked by %s — skipping.", i,
len(urls), describe_block(html, vendor))
failures.append(url)
continue
variants = parse_product_detail(html, session.page.url,
category=args.category)
if not variants:
# The parser already said why. Recorded as a failure rather than
# as "this product has no sizes", which is what an empty list
# would otherwise quietly mean.
failures.append(url)
continue
logger.info("Product %d/%d: %d size(s) — %s", i, len(urls),
len(variants), (variants[0].title or "")[:60])
rows.extend(variants)
checkpoint.record(i, variants, session.page.url)
return rows, failures, len(urls)
def scrape(args) -> int:
# One id per run, logged here and written into the sidecar, so a
# log line and an artefact can be tied together. "the run that
# failed" is not identifying for a scraper on a schedule.
run_id = new_run_id()
started_at = time.time()
logger.info("Run %s starting: %s", run_id, args.url)
# One entry per page attempted, merged after the loop rather than folded
# into shared state during it — see PageOutcome for why that ordering
# matters more than it looks.
outcomes: List[PageOutcome] = []
seen_skus = set()
blocked = False
# Written after every page, always — see run_state.py for why this is not
# behind a flag. --resume reads it; a completed run deletes it.
checkpoint = Checkpoint(args.out, args)
if args.resume:
for line in checkpoint.resume():
logger.info("%s", line)
elif checkpoint.enabled and os.path.exists(checkpoint.path):
logger.info("A checkpoint from an earlier run is at %s. It will be "
"overwritten as this run progresses; pass --resume to "
"continue that run instead of restarting it.",
checkpoint.path)
# Why the loop ended. "completed" means every requested page was
# fetched; "pagination_exhausted" means the site itself ran out of pages
# (also a complete result — there was nothing more to get). Anything else
# is an early stop, and the run is only a partial view of the category.
stop_reason = "completed"