Repository navigation
Expand file tree
/
Copy pathplaywright_scraper.py
More file actions
1414 lines (1254 loc) · 71.9 KB
/
Copy pathplaywright_scraper.py
File metadata and controls
1414 lines (1254 loc) · 71.9 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
"""
amazon-scraper — Playwright edition (primary engine)
====================================================
Scrapes Amazon listing pages, product detail pages and the reviews rendered
on a detail page, from any of the 21 marketplaces. Which marketplace is
decided by the URL you pass — there is no --marketplace flag, so a flag and a
URL cannot disagree about which site a run is reading.
--mode listing (default) search results (/s?k=...) and best-seller
grids (/zgbs/...), paginated
--mode product one /dp/{ASIN} page, with brand, seller,
bullets, images and availability
--mode reviews the ~13 reviews Amazon renders to an
anonymous visitor on /dp/{ASIN}
Three engines ship in this repo and they must agree on exit codes, run
status, and whether a run crashes or spends money; the shared decisions live
in output_writer.finish_run() so they cannot drift apart.
What is different about Amazon
------------------------------
* **No JSON-LD anywhere.** Rows come from the site's own `data-asin`
attributes. See product_parser.py's docstring for the measurements.
* **AWS WAF, not Akamai.** Amazon's front door answers a suspicious request
with HTTP 202 and a self-solving JavaScript challenge. A real browser
clears it in a few seconds without help and without a captcha solve, so
this engine WAITS for it rather than reporting a block. Treating it as a
block is how a run gives up on a page it would have got; treating it as a
captcha is how it pays for one it did not need.
* **503 is a throttle, not a failure.** "Sorry! Something went wrong" comes
back on a first request and 200 on a retry seconds later. Retried, then
answered with a different exit.
* **The image captcha is the paid path.** "Enter the characters you see
below" is Amazon's own captcha and the only state here worth a 2Captcha
solve. See captcha_solver.detect_amazon_captcha.
* **Best-seller grids lazy-load.** A captured grid held 30 cards before
scrolling and 50 after, so the readiness wait scrolls until the count stops
growing instead of trusting the first render.
Usage
-----
python playwright_scraper.py \\
--url "https://www.amazon.com/s?k=wireless+headphones" \\
--pages 3 \\
--format both
python playwright_scraper.py --mode product --url "https://www.amazon.com/dp/B07K5214NZ"
python playwright_scraper.py --mode reviews --url "https://www.amazon.com/dp/B07K5214NZ"
Requires: pip install -r requirements.txt -r requirements-playwright.txt
then: playwright install chromium (only if NOT using --cdp-endpoint)
"""
import argparse
import logging
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,
INJECT_TOKEN_JS, RECAPTCHA_DISCOVERY_JS,
detect_amazon_captcha, solve_amazon_captcha,
amazon_captcha_submit_url)
from product_parser import (parse_products, parse_product_detail, parse_reviews,
SELECTORS, detect_bot_challenge, detect_page_state,
page_url, listing_kind, marketplace_host)
from output_writer import dedupe_by_key, finish_run
import page_flow
from page_flow import NEXT_PAGE_SELECTOR, MIN_CARD_MATCHES
from proxy_pool import (from_args as proxy_pool_from_args, to_playwright, mask,
ROTATE_MODES, ProxyError, ProxyPool)
import env_config
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 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 ASIN (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
# Set when Amazon answered with a signin redirect. Neither a block nor a
# failure: the run asked for something an anonymous visitor cannot have.
needs_signin: bool = False
# Set when Amazon answered "this page does not exist" (HTTP 404/410 or its
# own not-found page). Not a block: no exit, proxy or solve changes it.
not_found: bool = False
@property
def ok(self) -> bool:
return (not self.load_failed and self.blocked_by is None
and not self.needs_signin and not self.not_found)
ITEM_LINK_SELECTOR = SELECTORS["item_link"]
# ---------------------------------------------------------------------------
# page_flow, bound to Playwright
# ---------------------------------------------------------------------------
# Every decision about WHAT to do with a page — how long to wait, when to
# scroll, when a fresh session is the only fix — lives in page_flow.py so all
# three engines make it identically. What lives here is only HOW to ask this
# particular driver. See page_flow's docstring for why that split exists.
def _driver(page):
def scroll_into_view(selector):
try:
element = page.query_selector(selector)
if element is not None:
element.scroll_into_view_if_needed(timeout=5000)
except PWError as e:
logger.debug("Could not scroll %s into view: %s", selector, e)
return {
"count": lambda selector: len(page.query_selector_all(selector)),
"page_height": lambda: page.evaluate("() => document.body.scrollHeight"),
"scroll_to_bottom": lambda: page.evaluate(
"() => window.scrollTo(0, document.body.scrollHeight)"),
"sleep": page.wait_for_timeout,
"scroll_into_view": scroll_into_view,
"content": lambda: _content_when_settled(page),
"current_url": lambda: page.url,
}
def _ready_selector(args) -> str:
return page_flow.ready_selector(args.mode)
def _min_matches(args) -> int:
return page_flow.min_matches(args.mode)
def _needs_scrolling(args, url: str) -> bool:
return page_flow.needs_scrolling(args.mode, url)
def _wait_out_waf_challenge(page, args) -> bool:
d = _driver(page)
return page_flow.wait_out_waf_challenge(d["content"], d["current_url"], d["sleep"])
def _scroll_until_stable(page, selector: str, **kwargs) -> int:
d = _driver(page)
return page_flow.scroll_until_stable(d["count"], d["page_height"],
d["scroll_to_bottom"], d["sleep"],
selector, **kwargs)
def _hydrate(page, args, selector: str, threshold: int,
attempts: Optional[int] = None) -> int:
d = _driver(page)
if attempts is None:
attempts = page_flow.hydrate_attempts(args.mode)
return page_flow.hydrate(d["count"], d["page_height"],
d["scroll_to_bottom"], d["sleep"],
d["scroll_into_view"], args.mode, selector,
threshold, attempts)
# What "the page has painted" means, per mode. The listing form deliberately
# matches the PRIMARY anchors and not `a[href*="/dp/"]`: a captured search
# page carries 143 such links for 22 organic tiles, most of them in carousels
# that render before the grid does, so counting them would call the page
# ready while the results were still empty.
# Ordered most-durable first, per the family rule: a standards-based signal
# before a build artefact. `<link rel="next">` is FIRST and is known to be
# ABSENT — checked on both listing kinds on 2026-09-08, zero matches. It is
# kept because it costs one query and is the form that would survive a
# redesign, but it is explicitly not what makes pagination work here:
# `a.s-pagination-next` is a class Amazon can rename any day, and
# product_parser.page_url() is what actually backs pagination up.
# How many primary anchors must appear before the page counts as loaded
# rather than as a lucky single match. Must be > 1: waiting for one resolves
# on an unrelated element long before the grid paints. Five, against measured
# floors of 16 organic tiles on amazon.com, 16 on amazon.de, 60 on
# amazon.co.jp and 30-before-scroll on a best-seller grid — low enough that a
# short result page still passes, high enough that a single stray match does
# not.
# How long to let the AWS WAF interstitial clear itself. Measured at 3.9s on
# amazon.co.uk; 25s is generous enough for a slow exit and still bounded,
# because "every remote call is bounded" and an unbounded wait here would
# hang a run on a challenge that is never going to resolve.
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 the page parameter 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.
On Amazon the check passes: page 1's next link is `?...&page=2` plus
tracking parameters (`xpid`, `qid`, `ref`) that _same_url ignores.
"""
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 ?%s=N convention (page 2 link "
"matches the constructed URL) — planning pages 2-%d up "
"front.", "pg" if listing_kind(page_one_url) == "bestsellers"
else "page", 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 URL "
"convention. Amazon serves no link[rel=next], so this is the "
"expected path once the anchor classes change; if the run comes "
"back with one page of data, that is what to check first.",
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, 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 tracking parameters and parameter ORDER.
Amazon's own next-page link carries `xpid`, `qid` and `ref` alongside
`page=2`. None of them select content — they are search-session and
click-attribution ids — so a strict comparison would decide the site's
link disagrees with the convention on every single run, and every run
would fall back to sequential chaining and refuse --concurrency.
"""
tracking = page_flow.TRACKING_PARAMS
pa, pb = urlparse(a), urlparse(b)
def significant(query):
return sorted((k, v) for k, v in parse_qsl(query) if k not in tracking)
return (pa.scheme, pa.netloc, pa.path.rstrip("/")) == \
(pb.scheme, pb.netloc, pb.path.rstrip("/")) and \
significant(pa.query) == significant(pb.query)
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. On Amazon it also resets the AWS WAF token, which is bound to
the address that earned it.
"""
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.
ctx_kwargs = {"user_agent": _chrome_ua(browser.version), "locale": args.locale}
init_script = None
if args.fingerprint:
# Only meaningful on this branch. Over --cdp-endpoint the Scraping
# 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)
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.
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)
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. 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 = pw.chromium.connect_over_cdp(args.cdp_endpoint, timeout=30000)
# 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()
# The Scraping Browser API exposes a documented CDP domain
# (`Captcha.setAutoSolve` / `Captcha.solve`) that clears supported
# challenges inside the browser: https://2captcha.com/scraper/browser-api/api
# Tried first when --cdp-endpoint is set; this script's own detect+solve
# logic still runs as a fallback if the endpoint does not support it.
# Note it does NOT cover the AWS WAF interstitial, which needs no solving
# anyway — see _settle_page.
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("Scraping Browser API Captcha.setAutoSolve enabled — supported "
"challenge types will be solved automatically if this "
"--cdp-endpoint is a 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.
urljoin handles every shape correctly — absolute, protocol-relative,
absolute-path, and page-relative hrefs alike.
"""
return urljoin(base_url, href)
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 Amazon that is not an edge case: the AWS WAF challenge reloads the
page from JavaScript the moment it has its token, so a snapshot taken
right after goto() can land exactly on the swap.
Retries briefly and returns None if the page won't hold still, so the
caller can skip a check 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 — "
"continuing without a snapshot.", attempts)
return None
logger.info("Page is navigating (the WAF challenge reloading?) — "
"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) -> bool:
"""Detect and solve a challenge. True if something was solved.
`budget` is the page's page_flow.SolveBudget: every purchase below asks
it first, so retries and rotations cannot buy a second solve for the
same page.
Runs after EVERY navigation, for ANY page — not scoped to one URL. Two
families are checked, because Amazon serves one on the paths this scraper
reads and the other elsewhere:
1. Amazon's own image captcha ("Enter the characters you see below"),
which is what a blocked listing page actually looks like;
2. reCAPTCHA, via the static-HTML and runtime detectors reconciled
against each other. Amazon uses it on account and payment flows, and
the family's rule is that detection stays broad — different geos and
scenarios surface different challenges.
"""
html = _content_when_settled(page)
if html is None:
# Couldn't get a stable snapshot — skip detection for this navigation
# rather than taking the whole run down. The next navigation gets
# another chance, and the parse below reads its own copy of the DOM.
return False
# Detected is not the same as blocking. A challenge on a page whose
# products are already rendered guards nothing, and counting the anchors
# is instant — no wait_for_function, no 20s — which is why this check
# sits here rather than after the readiness wait. Doing it the other way
# round would cost 20 wasted seconds on a page the captcha genuinely
# gates, where solving FIRST is what makes the content appear.
already_rendered = len(page.query_selector_all(_ready_selector(args)))
when_blocked = getattr(args, "solve_captcha", "when-blocked") == "when-blocked"
amazon_challenge = detect_amazon_captcha(html, page.url)
if amazon_challenge:
if when_blocked and already_rendered > MIN_CARD_MATCHES:
logger.info("Amazon image captcha detected, but %d anchors are "
"already on the page — not solving it. Pass "
"--solve-captcha always to solve it anyway.",
already_rendered)
return False
logger.warning("Amazon image captcha (%s) — attempting to solve.",
amazon_challenge.image_url)
if not args.twocaptcha_key:
logger.warning("No 2captcha API key, so this captcha cannot be "
"solved — continuing with whatever the page holds. "
"Pass --twocaptcha-key or set TWOCAPTCHA_KEY if the "
"run comes back blocked (exit 3).")
return False
if not budget.spend():
return False
try:
# Fetched through the BROWSER's request context, not this
# process: the image is bound to the session that was served the
# captcha, and a correct answer to an image fetched from another
# address is rejected.
def fetch_image(url):
return page.request.get(url, timeout=30000).body()
solution = solve_amazon_captcha(amazon_challenge, args.twocaptcha_key,
fetch_image, api_version=args.captcha_api)
except Exception as e: # noqa: BLE001 — a solver failure is not a crash
logger.error("Solving the captcha failed (%s) — continuing with "
"whatever the page holds. If it was in fact blocking, "
"the run will report that as exit 3.", e)
return False
submit = amazon_captcha_submit_url(amazon_challenge, solution)
logger.info("Submitting the solution and returning to the page.")
try:
page.goto(submit, wait_until="domcontentloaded", timeout=60000)
except (PWTimeout, PWError) as e:
logger.error("Submitting the captcha solution failed (%s).", e)
return False
return True
# BOTH reCAPTCHA detectors run, always — not static-then-fallback. They
# can disagree on the same page about the widget's version, and the
# runtime one is authoritative when they do, so short-circuiting on the
# static one 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 False
if when_blocked and already_rendered > MIN_CARD_MATCHES:
logger.info("%s detected via %s, but %d anchors are already on the "
"page — not solving it. Pass --solve-captcha always to "
"solve it anyway.", challenge.kind, challenge.source,
already_rendered)
return False
logger.warning("%s detected via %s (sitekey=%s, action=%s) — attempting to solve.",
challenge.kind, challenge.source, challenge.sitekey, challenge.action)
if not args.twocaptcha_key:
logger.warning("No 2captcha API key, so this challenge cannot be "
"solved — continuing with whatever the page already "
"holds.")
return False
if not budget.spend():
return False
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.", e)
return False
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)
return True
# The container each mode's content hangs off, used to pull the lazy-load
# trigger precisely instead of jumping past it. Amazon hydrates the review
# widget from an intersection observer on #reviewsMedley: scrolling straight
# to the end of the document flies past that element without ever
# intersecting it, which is why "scroll to the bottom" got 13 reviews on two
# runs out of three and zero on the other.
def _parse_for_mode(html: str, url: str, args) -> List:
if args.mode == "product":
return parse_product_detail(html, url, category=args.category)
if args.mode == "reviews":
return parse_reviews(html, url)
return parse_products(html, url, category=args.category)
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 throttle, a captcha 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
# One allowance for the whole page, across every exit and retry.
budget = page_flow.SolveBudget()
html, state, load_failed = None, "ok", False
# The HTTP status of the navigation that produced the current
# document, or None when the driver cannot say (Selenium) or the
# document has since been replaced (a captcha solve navigates).
status = None
for block_attempt in range(block_retries + 1):
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 should not break the loop.
load_failed, exit_failed = False, None
for attempt in range(1, args.retries + 1):
try:
response = session.page.goto(url, wait_until="domcontentloaded",
timeout=60000)
status = response.status if response 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 lets it escape as a traceback,
# which is the likeliest failure the first time anyone points
# --proxy-file at a real list.
reason = page_flow.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
# The WAF interstitial comes first: until it clears, every other
# check would be looking at a challenge page rather than at Amazon.
_wait_out_waf_challenge(session.page, args)
if handle_captcha_if_present(session.page, args, budget):
# A solve navigated the page. Give the destination a moment
# before judging what came back.
session.page.wait_for_timeout(1000)
status = None # the solve replaced the document
html = _content_when_settled(session.page) or ""
state = detect_page_state(html, status=status, url=session.page.url)
# A 503 throttle is not a block and not a load failure: Amazon
# answered, it just declined to serve content this second. A fresh
# request seconds later gets 200 — measured on amazon.de, which
# returned 503 on a first navigation and 200 on a fresh context. So
# it is reloaded a few times before an exit is blamed.
if state == "throttled":
for attempt in range(1, args.retries + 1):
pause = args.retry_delay * (2 ** (attempt - 1))
logger.warning("Amazon returned its 'Sorry, something went "
"wrong' throttle page — reloading in %.1fs "
"(%d/%d).", pause, attempt, args.retries)
time.sleep(pause)
try:
response = session.page.goto(url, wait_until="domcontentloaded",
timeout=60000)
status = response.status if response else None
except (PWTimeout, PWError):
continue
_wait_out_waf_challenge(session.page, args)
html = _content_when_settled(session.page) or ""
state = detect_page_state(html, status=status, url=session.page.url)
if state != "throttled":
break
if state in ("ok", "signin", "not_found"):
break
# Still throttled or still behind a captcha. A different exit is the
# one thing that plausibly changes the outcome — the address is what
# was scored, so retrying it unchanged would only confirm it.
if block_attempt < block_retries:
logger.warning("Page %d came back as %s from %s — retrying from "
"another exit (%d/%d).", page_num, state,
mask(pool.current), block_attempt + 1, block_retries)
pool.advance(f"{state} on page {page_num}")
session.relaunch()
if load_failed:
logger.error("Gave up loading %s after %d attempt(s).", url, args.retries)
outcome.load_failed = True
return outcome
if state == "not_found":
# The URL names a page Amazon does not have — a delisted or mistyped
# ASIN, a dead category. Not retried and not rotated: a different
# exit gets the same answer. Before this state existed, Amazon's 404
# page was read as its 503 throttle page (both carry the same
# "automated access" comment), retried, and reported as blocked —
# exit 3, sending the reader after a proxy for a wrong URL.
logger.error("Amazon says this page does not exist (%s). Check the "
"URL; no proxy or captcha solve changes this.", url)
outcome.not_found = True
return outcome
if state == "signin":
# Not a block and not solvable. The run asked for a page Amazon does
# not serve anonymously — /product-reviews/ does this on page 1. Kept
# distinct so the message can say what to do about it instead of
# suggesting a proxy or a captcha key would help.
logger.error("Amazon redirected to sign-in (%s). This page needs an "
"account; no proxy or captcha solve changes that. For "
"reviews use --mode reviews, which reads the ones "
"rendered on the product page itself.", session.page.url)
outcome.needs_signin = True
outcome.final_url = session.page.url
return outcome
if state == "ok":
# Don't wait for network idle (retail sites never go fully quiet) and
# don't accept a single selector match as "ready".
selector, threshold = _ready_selector(args), _min_matches(args)
scrolls = _needs_scrolling(args, session.page.url)
# A FRESH SESSION, not a scroll and not a reload, is what fixes an
# empty review widget. Three measurements, in the order they were
# taken, because the first two conclusions were wrong:
#
# 1. Amazon serves two variants of the same /dp/ URL — one with the
# reviews in the initial markup (13 containers, ~466 [data-hook]
# elements, 17.5k page height), one where the widget is present
# but empty (5 [data-hook] elements, 10.8k height). Six scroll
# passes left the second at zero, so it is not a lazy load.
# 2. Reloading does not help either: a review-less context stayed
# review-less across six consecutive navigations (0,0,0,0,0,0).
# The variant is sticky to the SESSION.
# 3. A fresh context does help: 3 of 5 new contexts returned the
# full page, and 4 of 8 in an earlier sample. So the variant is
# assigned per cookie jar, and a new browser is the only thing
# that re-rolls it.
#
# Hence session.relaunch() — the same tool a proxy rotation uses, for
# the same reason: a session cannot be talked out of what it has
# already been assigned. Note this is a no-op over --cdp-endpoint,
# where the profile's cookies are not ours to discard; there, a
# review-less profile stays review-less and a different `pid` is the
# answer.
session_budget = page_flow.session_attempts(args.mode, args.retries)
for attempt in range(1, session_budget + 1):
if scrolls:
_hydrate(session.page, args, selector, threshold)
content_timeout = page_flow.content_timeout_ms(args.mode)
try:
session.page.wait_for_function(
f"document.querySelectorAll({selector!r}).length > {threshold}",
timeout=content_timeout)
session.page.wait_for_timeout(500)
except PWTimeout:
logger.warning("No content markers (%s) appeared within %.0fs.",
selector, content_timeout / 1000)
if scrolls:
# A second pass, because the wait above may have been what let
# the first batch appear, and the batch after it needs another
# scroll. Cheap when there is nothing more to load.
loaded = _scroll_until_stable(session.page, selector)
logger.info("Scrolled until the count stopped growing: %d "
"match(es).", loaded)
if len(session.page.query_selector_all(selector)) > threshold:
break
if attempt < session_budget:
logger.info("Nothing matched %s. %s (attempt %d/%d).",
selector, page_flow.VARIANT_RETRY_NOTE, attempt,
session_budget)
if session.remote:
logger.warning("Cannot start a fresh session over "
"--cdp-endpoint: the remote profile's "
"cookies are not ours to discard. Try a "
"different `pid` in the endpoint URL.")
break
try:
session.relaunch()
session.page.goto(url, wait_until="domcontentloaded",
timeout=60000)
_wait_out_waf_challenge(session.page, args)
except (PWTimeout, PWError) as e:
logger.warning("The fresh session could not load the page "
"(%s) — parsing what is there.", e)
break
else:
logger.warning("Still nothing after %d session(s). Amazon kept "
"serving the variant without this content; the "
"run will report what it has.", session_budget)
html = _content_when_settled(session.page) or html
# Dumping on success, not only on failure: a run can return the right
# NUMBER of rows 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))
vendor = detect_bot_challenge(html, url=session.page.url)
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 result "
"(exit 4).", vendor, len(html), debug_html,
f" (tried {block_retries + 1} exit(s))" if block_retries else "")
outcome.blocked_by = vendor
return outcome
products = _parse_for_mode(html, session.page.url, args)
logger.info("Parsed %d row(s) from page %d.", len(products), page_num)
# A best-seller grid publishes contiguous ranks, so a gap is arithmetic
# rather than a guess: 30 rows spanning ranks 1-50 means 20 cards never
# loaded. Without this the run reports a complete page and a consumer
# sees 20 products "delisted" from the chart.
if products and args.mode == "listing":
ranks = [p.position for p in products if p.position is not None]
if ranks and listing_kind(session.page.url) == "bestsellers":
span = max(ranks) - min(ranks) + 1
if span > len(ranks):
logger.warning("Best-seller page %d returned %d rows spanning "
"ranks %d-%d — %d card(s) never loaded. The "
"lazy-load did not finish; the output is short "
"by that many products.", page_num, len(ranks),
min(ranks), max(ranks), span - len(ranks))
if products and args.mode == "listing":
priced = sum(1 for p in products if p.price is not None)
# Reported every time, not only when it looks wrong: on Amazon a
# quarter of a page legitimately carries no price (16 of 22 priced on
# a measured live page), so a consumer needs the number rather than a
# threshold someone guessed. Below half, something is more likely
# wrong with the snapshot than with the catalogue.
logger.info("Price coverage on page %d: %d/%d (%.0f%%).", page_num,
priced, len(products), 100.0 * priced / len(products))
if priced * 2 < len(products):
logger.warning("Fewer than half the rows on page %d carry a price. "
"Amazon does withhold some prices, but this is low "
"— re-run with --dump-html to check the snapshot.",
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)
logger.warning("0 rows parsed — saved what the browser actually saw to "
"%s and %s. Open the .png to see it.", 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: 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):
"""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 rows at all — the end of the
# listing. Without it, asking for 50 pages of a 5-page result 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():
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)
if outcome.ok and not outcome.products:
logger.info("[%s] page %d returned no rows — "
"treating that as the end of the listing "
"and stopping dispatch.", name, page_num)
exhausted.set()
finally:
session.close()