Repository navigation
Expand file tree
/
Copy pathselenium_scraper.py
More file actions
1250 lines (1111 loc) · 59.7 KB
/
Copy pathselenium_scraper.py
File metadata and controls
1250 lines (1111 loc) · 59.7 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
"""polymarket-scraper — Selenium edition
A parity engine. It must agree with the other two on exit codes, run status,
and whether a run crashes or spends money — the shared modules
(`product_parser`, `page_flow`, `output_writer`, `proxy_pool`,
`captcha_solver`) are what keep it honest, and this file holds only "how to
ask Selenium".
Read playwright_scraper.py's docstring for what is different about this SITE.
The short version: a listing URL is ONE page of twenty events with no address
for the twenty-first, everything is server-rendered into the page's own
payload, and nothing refused us from a datacentre address — a plain `curl`
was served the full 791,003 bytes.
Three things are different about this ENGINE:
* **It drives the installed Chrome, and there is nothing to choose.**
chromedriver has no bundled browser, so there is no browser-channel flag
here. On this site that costs nothing either way: the page was served to
every client tried, including `curl`.
* **It cannot authenticate a proxy, and it cannot use an authenticated CDP
endpoint.** `--proxy-server` takes an address with nowhere to put a
password, and chromedriver's `debuggerAddress` takes a bare `host:port`.
Both are reported loudly rather than silently half-working. Use the
Playwright or pyppeteer engine for either.
* **`--concurrency` above 1 is not implemented here.** The worker pool lives
in playwright_scraper.py. This engine walks the same event pages one at a
time and produces identical rows, exit codes and metadata — it just takes
longer. Said out loud rather than run as one worker quietly, which would
look like the flag did something.
Examples
--------
python3 selenium_scraper.py --url "https://polymarket.com/predictions"
python3 selenium_scraper.py --category politics --mode events --pages 21
python3 selenium_scraper.py \\
--url "https://polymarket.com/event/fed-decision-in-september-762"
"""
import argparse
import logging
import re
import sys
import time
from dataclasses import dataclass, field
from typing import List, Optional
from urllib.parse import urlsplit
from selenium import webdriver
from selenium.common.exceptions import (TimeoutException, WebDriverException)
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.by import By
from captcha_solver import (detect_recaptcha_v3, detect_recaptcha_in_page,
reconcile_detections, solve_recaptcha,
INJECT_TOKEN_JS, detect_turnstile,
wait_for_turnstile, TURNSTILE_INTERCEPT_JS,
TURNSTILE_INJECT_JS)
from product_parser import (parse_markets, SELECTORS, PAGE_CAP,
detect_bot_challenge, page_kind, normalize_url,
page_url, paginates_by_url, redirected_away,
served_by_polymarket, source_of,
unsupported_reason, event_urls_from,
listing_url_for, category_from_url, locale_of)
from output_writer import dedupe_by_key, merge_pages, finish_run
import page_flow
from proxy_pool import (from_args as proxy_pool_from_args, split_credentials,
mask, ROTATE_MODES, ProxyError)
import env_config
logging.basicConfig(level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("selenium_scraper")
# Explicit, because a driver that stops answering otherwise hangs the run:
# "every remote call is bounded" applies to this engine too.
PAGE_LOAD_TIMEOUT = 60
SCRIPT_TIMEOUT = 30
# Kept identical to the Playwright engine's, and the smoke suite asserts it:
# a floor that differed between engines would mean one of them warning about
# a page its twin called healthy.
FIELD_FLOOR = 95
_CREDENTIALS_IN_URL_RE = re.compile(r"([a-z][a-z0-9+.\-]*://)[^\s/@]+:[^\s/@]+@",
re.IGNORECASE)
def _mask_credentials(text: str) -> str:
"""`text` with any username:password in an embedded URL replaced.
Global, not first-match: an error can repeat an endpoint several times,
and a masker that handles one occurrence prints the password for the
rest while looking like it works.
"""
return _CREDENTIALS_IN_URL_RE.sub(r"\1***:***@", text or "")
def _chrome_ua(version: str) -> str:
"""A desktop-Chrome UA naming the browser's OWN real version."""
return (f"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
f"(KHTML, like Gecko) Chrome/{version} Safari/537.36")
def _cdp_host_port(endpoint: str) -> str:
"""`host:port` out of a CDP endpoint, refusing one with credentials.
Selenium cannot use an authenticated remote CDP endpoint at all, and this
is the one place to say so. 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` with nowhere to put a
password. Silently stripping the credentials would produce a connection
refusal a long way from its cause.
"""
parts = urlsplit(endpoint)
if parts.username or parts.password:
logger.error(
"This --cdp-endpoint carries credentials, and Selenium cannot "
"send them: chromedriver's debuggerAddress is a bare host:port. "
"The 2Captcha Scraping Browser API endpoint is authenticated, so "
"it cannot be used from this engine — run playwright_scraper.py "
"or puppeteer_scraper.py for it. Endpoint: %s",
_mask_credentials(endpoint))
sys.exit(2)
host = parts.hostname or endpoint
port = f":{parts.port}" if parts.port else ""
return f"{host}{port}"
@dataclass
class PageOutcome:
"""What one page produced. Same shape as the other two engines'."""
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 the BROWSER failed (a crashed page, a closed connection)
# rather than the site: such a page is a fetch failure, not a block.
browser_failure: Optional[str] = None
state: Optional[str] = None
# See the Playwright engine for why this is NOT a per-page counter.
total_events: Optional[int] = None
gap: Optional[int] = None
scroll: Optional[dict] = None
@property
def ok(self) -> bool:
return not self.load_failed and self.blocked_by is None
class _Session:
"""One Chrome driver, relaunchable onto a different exit.
Same contract as the Playwright engine's _BrowserSession, including the
rule that a rotation means a genuinely FRESH browser: cookies a bot
manager issued against one exit, replayed from another, are a stronger
signal than either address alone.
"""
def __init__(self, args, pool):
self.args, self.pool = args, pool
self.remote = bool(args.cdp_endpoint)
self.driver = None
self.browser_failure: Optional[str] = None
def open(self):
self.browser_failure = None
options = Options()
if self.remote:
options.debugger_address = _cdp_host_port(self.args.cdp_endpoint)
logger.info("Attaching to an existing browser at %s.",
options.debugger_address)
# No UA, no proxy, no fingerprint on this path: the remote browser
# brings its own, and stacking a second creates a contradiction
# rather than better cover.
self.driver = webdriver.Chrome(options=options)
self._apply_timeouts()
self._install_turnstile_intercept()
return self
if self.args.headless:
options.add_argument("--headless=new")
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage")
# 1440x900 matches what the captures were taken at. The viewport
# decides how the grid is laid out, and the captures this repo's
# numbers come from were taken at this size.
options.add_argument("--window-size=1440,900")
# Not a fingerprint measure, a correctness one: without it Chrome
# advertises "HeadlessChrome", which is a giveaway on any site with a
# bot manager in front of it.
options.add_argument("--disable-blink-features=AutomationControlled")
if self.pool:
scrubbed, credentials = split_credentials(self.pool.current)
options.add_argument(f"--proxy-server={scrubbed}")
logger.info("Using proxy exit %s", mask(self.pool.current))
if credentials:
logger.warning(
"This proxy has credentials and SELENIUM CANNOT SEND "
"THEM: --proxy-server accepts an address only, and there "
"is no Selenium equivalent of pyppeteer's "
"page.authenticate. They have been stripped, so requests "
"will go out unauthenticated and the exit will most "
"likely refuse them. Use playwright_scraper.py or "
"puppeteer_scraper.py for an authenticated proxy.")
# Chrome's performance log, which is how this engine counts refused
# refused responses — see `_refused_count`. It has to be
# asked for at driver creation; there is no way to turn it on later.
# Without it this engine would report `complete` where its twins
# report `partial` on the identical run.
options.set_capability("goog:loggingPrefs", {"performance": "ALL"})
self.driver = webdriver.Chrome(options=options)
self._apply_timeouts()
self._install_turnstile_intercept()
_watch_refusals(self)
version = self.driver.capabilities.get("browserVersion", "")
if version:
try:
self.driver.execute_cdp_cmd(
"Network.setUserAgentOverride",
{"userAgent": _chrome_ua(version)})
except WebDriverException as e:
logger.debug("Could not override the user agent: %s", e)
if self.args.fingerprint:
self._apply_fingerprint()
return self
def _apply_timeouts(self):
self.driver.set_page_load_timeout(PAGE_LOAD_TIMEOUT)
self.driver.set_script_timeout(SCRIPT_TIMEOUT)
def _apply_fingerprint(self):
# THROUGH THE SHARED HELPER, never by reaching into the response
# shape here. This line dug the UA out of the response itself until a
# live call to the API showed what it actually returns: the UA is at
# `userAgent.userAgent` in the chromium format and at `data.ua` in
# the raw one, and the key this engine asked for exists in NEITHER.
# So `--fingerprint`
# silently set no user agent at all and the browser kept its own —
# which defeats the flag rather than breaking it, because the run
# then presents a Windows fingerprint's screen, locale and timezone
# over a local Chromium's UA. That is the identity MISMATCH the flag
# exists to avoid (where the same defect was live in four
# sibling repos at once).
from fingerprint_client import (get_fingerprint, fingerprint_user_agent,
playwright_init_script)
fp = get_fingerprint(self.args.twocaptcha_key, tags=self.args.fp_tags,
country=self.args.fp_country)
ua = fingerprint_user_agent(fp)
script = playwright_init_script(fp)
try:
if ua:
self.driver.execute_cdp_cmd("Network.setUserAgentOverride",
{"userAgent": ua})
# The same patch script the Playwright engine installs on its
# context. Shared deliberately: two engines applying different
# halves of one fingerprint would be a contradiction of exactly
# the kind a fingerprint is meant to avoid.
self.driver.execute_cdp_cmd(
"Page.addScriptToEvaluateOnNewDocument", {"source": script})
logger.info("Using 2captcha fingerprint %s (%s)", fp.get("id"),
fp.get("country"))
except WebDriverException as e:
logger.warning("Could not apply the fingerprint over CDP (%s) — "
"continuing without it.", e)
def _install_turnstile_intercept(self):
"""Hook `turnstile.render` before any page script can run.
THE ONLY MOMENT a Cloudflare Turnstile's parameters can be captured:
a Challenge page calls `turnstile.render(container, params)` once and
keeps nothing, while `TurnstileTaskProxyless` needs the sitekey,
action, cData and chlPageData that live only inside that call.
Selenium spells it `Page.addScriptToEvaluateOnNewDocument` over CDP;
the other two engines spell the same thing `add_init_script` and
`evaluateOnNewDocument`, which is why this cannot live in the shared
module.
Harmless where there is no Turnstile — every page measured on this
site so far — because it wraps a function that never appears and
stops watching after 30 seconds.
"""
try:
self.driver.execute_cdp_cmd(
"Page.addScriptToEvaluateOnNewDocument",
{"source": TURNSTILE_INTERCEPT_JS})
except WebDriverException as e:
logger.debug("Could not install the Turnstile interception: %s", e)
def relaunch(self):
if self.remote:
return
self.close()
self.open()
def close(self):
try:
if self.driver is not None:
# quit(), not close(): close() ends one window and leaves the
# driver process running, which on a per-page rotation would
# leak a chromedriver per page.
self.driver.quit()
except Exception as e: # noqa: BLE001
logger.debug("Ignoring error during driver teardown: %s", e)
# ---------------------------------------------------------------------------
# page_flow, bound to Selenium
# ---------------------------------------------------------------------------
# Only "how to ask this driver" lives here. Note the JS dialect: Selenium's
# execute_script runs a function BODY and needs an explicit `return`, unlike
# the `() => expr` both other engines take — which is exactly why page_flow
# names operations instead of passing JavaScript across the boundary.
def _count(session, selector: str) -> int:
try:
return len(session.driver.find_elements(By.CSS_SELECTOR, selector))
except WebDriverException as e:
logger.debug("count(%s) failed: %s", selector, e)
return 0
def _sleep(ms: int) -> None:
time.sleep(ms / 1000.0)
# What chromedriver says when the BROWSER, not the site, has failed.
_BROWSER_DEAD_MARKERS = ("tab crashed", "invalid session id",
"disconnected", "chrome not reachable")
def _content(session) -> Optional[str]:
try:
return session.driver.page_source
except WebDriverException as e:
message = str(e).lower()
if any(m in message for m in _BROWSER_DEAD_MARKERS):
# Recorded for the fetch loop, which reports a dead browser as a
# fetch failure (exit 5) rather than classifying the missing
# document as a site block (exit 3).
session.browser_failure = str(e).splitlines()[0][:160]
logger.debug("page_source unavailable (page navigating?): %s", e)
return None
def _scroll_to_bottom(session) -> None:
"""Scroll the WINDOW to the end of the document. See page_flow.
Note the dialect: Selenium's `execute_script` takes a function BODY with
an explicit `return`, where Playwright and pyppeteer take `() => expr`.
That difference is exactly why `page_flow` names the OPERATION and each
engine spells it itself.
"""
try:
session.driver.execute_script(
"window.scrollTo(0, document.body.scrollHeight); return null;")
except WebDriverException as e:
logger.debug("scroll failed: %s", e)
def _page_height(session) -> Optional[int]:
try:
return session.driver.execute_script(
"return document.body.scrollHeight;")
except WebDriverException:
return None
# Counting the responses the site refused. The other two engines get this
# from a response listener, which Selenium has no equivalent of — so it is
# read out of Chrome's performance log instead.
#
# This is NOT a cosmetic difference. If this engine always answered 0, it
# would report `complete` where its twins report `partial` on the identical
# run, and that is precisely the drift the shared modules exist to prevent
#. The log has to be ENABLED at driver creation; see `_Session.open`.
_SITE_HOST_FRAGMENT = "polymarket.com"
def _watch_refusals(session) -> None:
"""Reset the running count. The log itself is enabled on the driver."""
session._refused = 0
_refused_count(session) # drain anything already buffered
def _refused_count(session) -> int:
"""How many of the site's responses have come back >= 400 this session.
Chrome's performance log is drained on read — each `get_log` call returns
only entries since the last one — so this accumulates rather than
recounting, and callers take a difference across the window they care
about.
"""
import json as _json
total = getattr(session, "_refused", 0)
try:
entries = session.driver.get_log("performance")
except Exception: # noqa: BLE001 — an absent log must never break a run
return total
for entry in entries:
try:
message = _json.loads(entry.get("message", "{}"))["message"]
if message.get("method") != "Network.responseReceived":
continue
response = message["params"]["response"]
if _SITE_HOST_FRAGMENT in response.get("url", "") \
and int(response.get("status", 0)) >= 400:
total += 1
except Exception: # noqa: BLE001
continue
session._refused = total
return total
def _ready_selector(args) -> str:
return page_flow.ready_selector(args.mode)
def _min_matches(args, html: str = "") -> int:
return page_flow.min_matches(args.mode)
def _current_url(session) -> str:
try:
return session.driver.current_url
except WebDriverException:
return ""
def _classify(session, html: str, status=None) -> str:
# `status` is POSITIONAL and second. Two engines in a sibling repo passed
# it as a keyword and both crashed on their first fetch; this
# repo's smoke suite binds every shared-module call in every engine
# against the callee's real signature for that reason.
return page_flow.classify(html, status, _current_url(session))
def _parse_for_mode(html: str, url: str, args, page_num: int = 1) -> List:
return parse_markets(html, url, page=page_num, mode=args.mode)
def handle_captcha_if_present(session, args,
budget: "page_flow.SolveBudget") -> bool:
"""Detect and solve a challenge. True if something was solved.
Same contract and same reconciliation as the Playwright engine. No
challenge was met on polymarket.com while this repo was built, so the
path is UNEXERCISED here — which is a fact about this repo's testing and
never a claim that a captcha on this site could not be solved.
"""
driver = session.driver
html = _content(session)
if html is None:
return False
already_rendered = _count(session, _ready_selector(args))
when_blocked = getattr(args, "solve_captcha", "when-blocked") == "when-blocked"
url = _current_url(session)
html_challenge = detect_recaptcha_v3(html, url)
runtime_challenge = detect_recaptcha_in_page(
lambda js: driver.execute_script(f"return ({js})();"),
page_url=url)
challenge = reconcile_detections(html_challenge, runtime_challenge)
if not challenge:
# No reCAPTCHA. Turnstile is the other thing 2Captcha can solve, and
# the RUNTIME reading is the one that matters: a Cloudflare Challenge
# page publishes no sitekey in its markup, so only the interception
# installed through `Page.addScriptToEvaluateOnNewDocument` before
# any page script ran can produce a solvable challenge. The static
# read is kept as the fallback for a standalone widget, whose sitekey
# IS in the markup.
challenge = (wait_for_turnstile(
lambda js: driver.execute_script(f"return ({js})();"),
lambda sec: _sleep(int(sec * 1000)),
page_url=url)
or detect_turnstile(html, url))
if challenge and not challenge.sitekey:
logger.warning(
"A Cloudflare Turnstile is on this page but no sitekey was "
"captured, so it cannot be solved and nothing will be "
"charged for it.")
return False
if not challenge:
return False
if when_blocked and already_rendered > page_flow.MIN_CARD_MATCHES:
logger.info("%s detected via %s, but %d cards 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 holds.")
return False
# The page's allowance is checked HERE, immediately before the money is
# spent, so both call sites in _fetch_one_page and every block retry draw
# on the same one. See page_flow.SolveBudget.
if not budget.spend():
logger.warning("This page's solve allowance (%d) is spent — not "
"buying another; continuing with whatever the page "
"holds.", budget.limit)
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
if challenge.is_turnstile:
# Turnstile hands its token back through `cf-turnstile-response` and
# the page's own callback, not through `g-recaptcha-response`.
driver.execute_script(f"return ({TURNSTILE_INJECT_JS})(arguments[0]);",
token)
else:
driver.execute_script(f"return ({INJECT_TOKEN_JS})(arguments[0]);",
token)
logger.info("Token injected. Reloading page to continue.")
_sleep(1500)
try:
driver.refresh()
except WebDriverException as e:
logger.warning("Reload after the solve failed: %s", e)
return True
def _scroll_the_feed(session, args, html: str, page_num: int) -> dict:
"""Scroll to the bottom until the feed stops growing.
RUN ONLY WHEN THE PAYLOAD DID NOT ANSWER — see the Playwright engine's
copy for the measurements. Short version: this site ships its markets in
the page's own payload and twelve scroll rounds added zero events, so
this is the fallback path's safety net and not the main event.
Deliberately no `target`. The only total this site publishes is
`totalCount` — 21,511 for `/predictions` — which is the size of the whole
catalogue rather than of this page. Using it to decide when to stop
scrolling would scroll forever.
"""
before = _count(session, page_flow.READY_SELECTOR)
trace = page_flow.scroll_until_settled(
lambda sel: _count(session, sel),
lambda: _scroll_to_bottom(session),
lambda: _page_height(session),
_sleep,
selector=page_flow.READY_SELECTOR)
logger.info("Scrolled batch %d: %d card(s) at first paint, %d after "
"%d round(s) of scrolling.", page_num, trace["cards_before"],
trace["cards_after"], trace["rounds"])
if trace["cards_after"] <= trace["cards_before"]:
logger.info(
"%d round(s) of scrolling added no events (%d -> %d). That is "
"the SITE and not the scroll: a Polymarket listing is twenty "
"events long and has no address for the twenty-first, measured "
"three ways on 2026-09-16.",
trace["rounds"], trace["cards_before"], trace["cards_after"])
return {"first_paint": before, "reached": trace["cards_after"],
"rounds": trace["rounds"], "settled": True}
def _fetch_one_page(session, args, pool, page_num: int,
url: Optional[str]) -> PageOutcome:
"""Fetch (or turn to) one page and parse it.
`url` is the address for page 1 and None for every page after it: pages
2..N on this site are not addresses, they are the result of pressing the
site's own button.
"""
outcome = PageOutcome(page_num=page_num, url=url or _current_url(session))
has_pool = bool(pool and len(pool) > 1)
block_retries = 0 if not page_flow.RETRY_ON_BLOCKED else (
args.proxy_block_retries if has_pool
else page_flow.BLOCK_RETRIES_WITHOUT_POOL)
budget = page_flow.SolveBudget()
html, state, load_failed = None, "ok", False
for block_attempt in range(block_retries + 1):
load_failed = False
if url is None:
# Never expected: every page of every mode on this site has an
# address. Reported rather than swallowed, because the sibling
# engine this was adapted from used None to mean "scroll for it"
# and a silent fallthrough here would look like a fetch.
logger.error("Page %d has no URL to fetch. That is a bug in this "
"engine's page planning, not a site behaviour: "
"every page here is an address.", page_num)
outcome.load_failed = True
return outcome
if True:
logger.info("Fetching page %d/%d: %s", page_num, args.pages, url)
for attempt in range(1, args.retries + 1):
try:
session.driver.get(url)
load_failed = False
break
except (TimeoutException, WebDriverException) as e:
load_failed = True
if attempt < args.retries:
pause = args.retry_delay * (2 ** (attempt - 1))
logger.warning("Timeout loading %s (attempt %d/%d: "
"%s) — retrying in %.1fs.", url,
attempt, args.retries,
str(e)[:120], pause)
time.sleep(pause)
if load_failed:
break
if handle_captcha_if_present(session, args, budget):
_sleep(1000)
html = _content(session) or ""
state = _classify(session, html)
if page_flow.is_unpainted(state, html):
wait_timeout = page_flow.content_timeout_ms(args.mode)
logger.info("Page %d is a shell the site served but has not "
"filled in (%d bytes, no cards) — waiting up to "
"%.0fs for the grid rather than spending a retry.",
page_num, len(html), wait_timeout / 1000)
page_flow.wait_for_count(
lambda sel: _count(session, sel), _sleep,
_ready_selector(args), _min_matches(args, html), wait_timeout)
html = _content(session) or html
state = _classify(session, html)
if (page_flow.should_solve(state)
and budget.allows()):
if handle_captcha_if_present(session, args, budget):
_sleep(1000)
html = _content(session) or html
state = _classify(session, html)
if state == "content":
logger.info("The solve was accepted — page %d is content "
"now.", page_num)
else:
logger.warning("The solve was NOT accepted: page %d is "
"still %s. The purchase is spent.",
page_num, state)
if session.browser_failure:
# The browser died under the fetch. What is left is not an
# answer from the site, so it must not be classified as a block.
outcome.browser_failure = session.browser_failure
load_failed = True
if not session.remote and block_attempt < block_retries:
logger.warning(
"Page %d: %s. That is this engine's browser failing, not "
"the site refusing — relaunching it and fetching again "
"(%d/%d).", page_num, session.browser_failure,
block_attempt + 1, block_retries)
session.relaunch()
continue
break
outcome.browser_failure = None
if not page_flow.should_retry(state):
break
if block_attempt < block_retries:
pause = args.retry_delay * (block_attempt + 1)
if has_pool:
logger.warning("Page %d came back as %s from %s — retrying "
"from another exit in %.1fs (%d/%d).",
page_num, state, mask(pool.current), pause,
block_attempt + 1, block_retries)
pool.advance(f"{state} on page {page_num}")
session.relaunch()
time.sleep(pause)
else:
# No pool, so nowhere else to go. What has cleared a
# challenge on sibling sites is a FRESH browser context rather
# than a reload; nothing was refused on this site while it
# was measured, so that is the family's rule, not a figure
# from here. `page_flow.RETRY_NEEDS_FRESH_CONTEXT` says so, and a
# constant no engine consulted would be dead policy dressed
# up as enforcement.
#
# NOT relaunched over --cdp-endpoint: a Scraping Browser
# profile allows one live connection, so reconnecting risks
# `profile_locked`.
fresh = (page_flow.RETRY_NEEDS_FRESH_CONTEXT
and not args.cdp_endpoint)
logger.warning("Page %d came back as %s — waiting %.1fs and "
"re-fetching %s (%d/%d).",
page_num, state, pause,
"in a FRESH browser context, which is what "
"clears a challenge on sibling sites (not measured on this one)" if fresh
else "through the same access path",
block_attempt + 1, block_retries)
time.sleep(pause)
if fresh:
session.relaunch()
if url is None:
logger.warning("Page %d was reached by a button press, so "
"there is no address to re-fetch — stopping "
"here rather than silently restarting the "
"listing at page 1.", page_num)
break
if load_failed:
if outcome.browser_failure:
logger.error(
"Gave up on %s: %s, and a fresh browser did not get past it. "
"Nothing was read from the site, so this is a fetch failure "
"(exit 5), not a block.", url, outcome.browser_failure)
else:
logger.error("Gave up loading %s after %d attempt(s).", url,
args.retries)
outcome.load_failed = True
return outcome
outcome.state = state
if page_flow.counts_as_blocked(state):
debug_html = f"{args.out}_page{page_num}_debug.html"
with open(debug_html, "w", encoding="utf-8") as f:
f.write(html or "")
try:
session.driver.save_screenshot(f"{args.out}_page{page_num}_debug.png")
except WebDriverException as e:
logger.warning("Could not capture screenshot: %s", e)
served = served_by_polymarket(html or "")
vendor = detect_bot_challenge(html or "")
logger.error(
"The site did not serve this request — %d bytes, %s the site's "
"own asset hosts, saved to %s. This is exit 3, distinct from a "
"genuinely empty result (exit 4).", len(html or ""),
"which references" if served else "with no reference to",
debug_html)
logger.error("%s", page_flow.block_advice(
html, headless=bool(getattr(args, "headless", False)),
has_pool=has_pool))
outcome.blocked_by = vendor or ("no-response" if not html else "not-served")
outcome.final_url = _current_url(session)
return outcome
if page_flow.should_parse(state) and page_flow.payload_arrived(html):
# THE FAST PATH, and the normal one: the page's own payload has
# arrived, so there is nothing to wait for and nothing to scroll.
# Identical to the other two engines by construction — the test for
# it lives in page_flow.
#
# Gated on the payload ARRIVING rather than on it naming markets: a
# payload that arrived with none is an answer, and spending the
# readiness timeout and four scroll rounds on it reached the same
# exit 4 sixty seconds later (see page_flow.payload_arrived).
logger.info("Page %d shipped its payload — parsing it directly (no "
"readiness wait, no scroll)%s.", page_num,
"" if page_flow.payload_answered(html)
else ", and it named no markets")
elif page_flow.should_parse(state):
selector, threshold = _ready_selector(args), _min_matches(args, html)
content_timeout = page_flow.content_timeout_ms(args.mode)
found = page_flow.wait_for_count(
lambda sel: _count(session, sel), _sleep, selector, threshold,
content_timeout)
_sleep(500)
if found < threshold:
logger.info("No event tiles appeared within %.0fs. If this "
"listing genuinely holds nothing, that is the "
"expected answer and the run will report 0 rows "
"(exit 4).", content_timeout / 1000)
outcome.scroll = _scroll_the_feed(session, args, html, page_num)
html = _content(session) or html
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 or "")
logger.info("Saved the snapshot the parser sees to %s (%d bytes).",
dump_path, len(html or ""))
products = _parse_for_mode(html or "", _current_url(session), args, page_num)
logger.info("Parsed %d row(s) from page %d.", len(products), page_num)
# How many events the LISTING holds in total, where the site states one
# — 21,511 for /predictions. Recorded beside what this run read, and
# never used as a gap: see PageOutcome.total_events.
outcome.total_events = page_flow.total_events_on_page(html or "")
outcome.gap = page_flow.page_gap(html or "", len(products))
if outcome.total_events:
logger.info("Polymarket states this listing could show %d event(s) "
"in total; this page carried %d event(s) worth of "
"markets, which is all it renders. The difference is the "
"site's own cap and not a missing read.",
outcome.total_events,
len({p.event_slug for p in products if p.event_slug}))
if products:
with_title = sum(1 for p in products if p.title)
with_price = sum(1 for p in products if p.price is not None)
worst = min(with_title, with_price)
share = 100.0 * worst / len(products)
logger.info("Title/price coverage on page %d: %d and %d of %d "
"(%.0f%% at worst); the measured floor is %d%%.",
page_num, with_title, with_price, len(products), share,
FIELD_FLOOR)
if share < FIELD_FLOOR:
logger.warning(
"Only %.0f%% of page %d carries both a question and a price, "
"against a measured floor of %d%%. Every market the payload "
"named had both on all five captures this repo was built "
"from, so this is the read breaking rather than the listing "
"being unusual. Re-run with --dump-html; the data_source "
"column says whether the rows came from the DOM alone.",
share, page_num, FIELD_FLOOR)
from collections import Counter
views = Counter(p.data_source for p in products)
logger.info("Rows by view on page %d: %s.", page_num,
", ".join(f"{k}={v}" for k, v in sorted(views.items())))
if not products:
debug_html = f"{args.out}_page{page_num}_debug.html"
with open(debug_html, "w", encoding="utf-8") as f:
f.write(html or "")
try:
session.driver.save_screenshot(f"{args.out}_page{page_num}_debug.png")
except WebDriverException as e:
logger.warning("Could not capture screenshot: %s", e)
if page_flow.parsed_nothing_from_a_served_page(html, len(products)):
# A page the site SERVED, linking to events, that parses to zero
# rows is this parser's failure and not an empty listing; the
# state is what scrape() turns into its stop reason.
outcome.state = page_flow.PARSER_FOUND_NOTHING
logger.error(
"0 rows parsed from a page the site SERVED, which links to "
"%d event(s). That is a parser regression rather than an "
"empty listing: the payload shape or the tile markup has "
"changed. Saved what the browser saw to %s — please open an "
"issue with it attached.",
page_flow.count_cards(html or ""), debug_html)
else:
logger.warning("0 rows parsed — saved what the browser actually "
"saw to %s.", debug_html)
outcome.products = products
outcome.final_url = _current_url(session)
return outcome
def scrape(args) -> int:
outcomes: List[PageOutcome] = []
seen_keys = set()
# Event pages the walk fetched that held no readable markets. A
# fact about those events, recorded for the consumer rather than
# acted on as an end-of-walk signal (see the walk below).
empty_pages: List[int] = []
blocked = False
stop_reason = "completed"
pool = proxy_pool_from_args(args)
if pool and args.cdp_endpoint:
logger.warning("Ignoring --proxy/--proxy-file: with --cdp-endpoint "
"the remote browser has its own exit.")
pool = None
# Two shapes of pagination, and the MODE picks which. In
# --mode events page 1 is the LISTING and pages 2..N are the event pages
# page 1 named — real independent addresses, but knowable only from page
# 1's own data, which is why they are planned from `event_urls_from`
# rather than from a URL convention. The other two modes are ONE page and
# there is no second one: ?page=2, ?_p=2 and ?offset=20 all answer with
# page 1 and scrolling adds nothing (measured 2026-09-16).
plans_pages = args.mode == "events"
if args.concurrency > 1:
if not plans_pages:
logger.warning("--concurrency %d is refused: %s.",
args.concurrency,
page_flow.concurrency_refusal(args.url, args.mode))
else:
# A DOCUMENTED ENGINE LIMIT, in the same class as this family's
# "Selenium cannot authenticate a remote CDP endpoint": the
# worker pool is implemented in the Playwright engine only. Said
# out loud rather than run as one worker quietly, which would
# look like the flag did something.
logger.warning("--concurrency %d is not implemented in this "
"engine — the worker pool lives in "
"playwright_scraper.py. Walking the days one at a "
"time instead. Exit codes, status and output are "
"identical either way.", args.concurrency)
session = _Session(args, pool).open()
try:
first = _fetch_one_page(session, args, pool, 1, args.url)
outcomes.append(first)
if not first.ok:
stop_reason = page_flow.failure_stop_reason(first)
blocked = first.blocked_by is not None
elif first.state == page_flow.PARSER_FOUND_NOTHING:
# Served, links to events, and nothing parsed: a parser failure,
# reported as one (exit 5) rather than as an empty listing (exit 4).
stop_reason = page_flow.PARSER_FOUND_NOTHING
elif plans_pages:
seen_keys.update(p.sku for p in first.products if p.sku is not None)
# VERIFY before planning. A search `?q=x` can land on the
# TAG listing `/predictions/x`, which is a different set of
# events — and page 1's event URLs would then belong to that
# other listing rather than to the one this run asked for.
drift = redirected_away(args.url, first.final_url or args.url)
planned = []
if drift:
logger.warning(
"Not walking the event pages: %s. Point --url at the "
"address the site settled on.", drift)
stop_reason = "pagination_redirected"
elif args.pages > 1:
planned = event_urls_from(first.products, limit=args.pages - 1)
if not planned:
logger.warning("Page 1 named no event pages to walk.")
stop_reason = "no_event_pages"
for index, url in enumerate(planned):
page_num = index + 2
if page_flow.page_cap_reached(page_num):
logger.warning("Stopping at the %d-page cap.", PAGE_CAP)
stop_reason = "page_cap"
break
if pool and pool.rotates_per_page():
pool.advance(f"per-page rotation, page {page_num}")
session.relaunch()
time.sleep(args.delay)
outcome = _fetch_one_page(session, args, pool, page_num, url)
outcomes.append(outcome)
if not outcome.ok:
stop_reason = page_flow.failure_stop_reason(outcome)
blocked = outcome.blocked_by is not None
break
seen_keys.update(p.sku for p in outcome.products
if p.sku is not None)
if not outcome.products:
# NOT the end of the walk. This used to `break` here, and
# that was wrong for a reason specific to this mode: the
# event URLs are not addresses this run is guessing at,
# they are a FINITE LIST page 1 already named, capped at
# `--pages - 1`. There is no catalogue left to exhaust
# and nothing to save by stopping — the queue is exactly
# as long as the listing's own event list — so one event
# with no readable markets says nothing whatever about
# the nineteen after it.
#
# Reproduced on the code this replaces: a single empty
# page at position 4 left 17 of 20 event pages unfetched
# and the run reported `status: complete`, exit 0, with
# `pages_completed: 4 of 21` in the same sidecar.
empty_pages.append(page_num)
logger.info("Event page %s produced no rows. Recording "
"that and carrying on: the remaining event "
"pages are independent URLs page 1 already "
"named.", url)
else:
seen_keys.update(p.sku for p in first.products if p.sku is not None)
# ONE page, and the run is COMPLETE holding it. the family's warning read
# from the other side: the single page is DELIBERATE, stated in
# the log with the measurement behind it and recorded in the
# sidecar as its own stop reason, so it can never be confused
# with a dead next-page selector quietly returning a third of the
# data.
note = page_flow.one_page_note(args.url, args.pages)
if note:
logger.info("%s.", note)
stop_reason = "listing_has_one_page"
finally:
session.close()
# Merged in PAGE order, with a DEEPER reading of a market replacing a