Repository navigation
Expand file tree
/
Copy pathplaywright_scraper.py
More file actions
1765 lines (1588 loc) · 90.5 KB
/
Copy pathplaywright_scraper.py
File metadata and controls
1765 lines (1588 loc) · 90.5 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
"""medium-scraper — Playwright edition (primary engine)
Scrapes Medium stories out of one of four views:
--mode tag (default) /tag/{slug} — a tag's story feed
--mode archive /tag/{slug}/archive/{yyyy}/{mm}/{dd} —
one whole day of that tag
--mode author /@{username} — one writer's stories
--mode post /p/{id} — one story, with its body
All four yield the same row, because all four are views of the same thing;
see `output_writer.Post`. The mode is inferred from the URL, so passing it is
only ever a way to be explicit or to be told you are wrong.
There is deliberately no `--language` flag. Medium runs one catalogue in
every language — `/tag/日本語` is a live tag on the same host as
`/tag/python` — so a flag could only disagree with the URL it was given.
Medium's own `detectedLanguage` lands in the `language` column instead.
WHAT IS DIFFERENT ABOUT THIS SITE
---------------------------------
* **The `HeadlessChrome` UA token is what Cloudflare hard-blocks — not
headless itself.** Measured 2026-09-16 from one datacentre address: a bare
headless Chromium sending its default User-Agent got HTTP 403 and a 5 KB
WAF block page on 11 of 11 attempts. The same browser, same address, same
minute, with an ordinary Chrome UA — which is what this engine builds, from
the browser's OWN version — was never WAF-blocked once. This is the
family's "the user agent comes from the browser, not a literal" rule
turning out to be the difference between data and a block page, and it is
why `curl` and every naive headless script get nothing from this site.
Headful is still the DEFAULT, because it is still better: interleaved over
8 rounds, headful was served 8/8 and headless 6/8, the misses being the
transient managed challenge rather than the WAF.
* **Two different renderers, and the URL picks which.** The modern site ships
`window.__APOLLO_STATE__`; a tag's DAY ARCHIVE ships the legacy
`window["obvInit"]({…})` and no Apollo state at all. The legacy payload is
by far the richer of the two — 84 fields per story against 21 on a tag
feed, and the only one carrying reading time, word count and language on a
listing row. `data_source` on every row says which one built it.
* **It IS server-rendered, which makes `content` the normal first state.**
A tag feed's first response already carries 34 stories in its state and a
day archive carries 128, before anything paints. So the readiness wait and
the scroll loop are bounded safety nets rather than the main event.
* **Scrolling adds nothing from a refused address.** Every one of the 11
POSTs to `medium.com/_/graphql` came back 403 while the HTML came back 200,
so a tag feed scrolled twelve times to a stable height held exactly the 34
stories its first response shipped. What the page ships is what the run
gets, and `--mode archive` is how you get more.
* **One mode has real per-page addresses; three do not.**
`/tag/{slug}/archive/{yyyy}/{mm}/{dd}` is independent and complete — 128
stories, `paging` empty — so `--pages N` walks N days backwards and
`--concurrency` is meaningful. For the other three there is no address for
batch 2, and `--concurrency` above 1 is refused with that reason.
A day with no stories does not 404: it REDIRECTS up to the month view,
which is a different renderer holding a different set of stories, so the
landed URL is compared against the requested one.
* **The block has two shapes and only one of them is worth retrying.** The
WAF refusal (5 KB, "Sorry, you have been blocked") is what headless gets.
The managed challenge (28 KB, "Just a moment...", `cType: 'managed'`, no
sitekey and no iframe) is transient: 9 of 27 first attempts got it and all
9 were served in full on the next attempt IN A FRESH CONTEXT — waiting
inside the same context never cleared one. Neither is solvable, so nothing
is ever charged to a solver for them.
* **Medium ships reCAPTCHA markup on every page it serves**, for its own
own Enterprise integration: 3-4 `g-recaptcha` and 4-17 `recaptcha` occurrences on
pages known to be good, against 0 on the challenge page. The family's
usual reCAPTCHA marker is a false positive here and is deliberately not in
the marker set.
* **A bundled Chromium is enough.** Headful, Playwright's own Chromium was
served exactly as well as system Chrome (364 KB against 363 KB on the same
URL). So no browser channel is forced, and `--browser-channel chrome` is
available rather than assumed.
Examples
--------
python3 playwright_scraper.py \\
--url "https://medium.com/tag/python"
# the volume path: five days of a tag, three workers
python3 playwright_scraper.py \\
--url "https://medium.com/tag/python/archive/2026/09/10" \\
--pages 5 --concurrency 3
python3 playwright_scraper.py \\
--url "https://medium.com/@quincylarson"
python3 playwright_scraper.py \\
--url "https://medium.com/p/d373fe2c96b7" --format json
"""
import argparse
import logging
import queue
import re
import sys
import threading
import time
from dataclasses import dataclass, field
from typing import List, Optional
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)
from product_parser import (parse_posts, SELECTORS, PAGE_CAP,
detect_bot_challenge, listing_kind, normalize_url,
page_url, paginates_by_url, redirected_away,
served_by_medium, site_host, is_supported_host,
source_of, unsupported_reason)
from output_writer import dedupe_by_key, finish_run, EXIT_API_ERROR
import page_flow
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")
# No browser channel is forced here, and that is a measurement rather than an
# omission. A sibling site reads the CLIENT before the address and refuses a
# bundled Chromium outright; Medium does not. Playwright's own Chromium was
# served HTTP 200 and the full feed on every fetch that was not challenged —
# eight of the first fourteen, from one datacentre exit on 2026-09-15 — and
# the refusals were Cloudflare's managed challenge, which tracks the ADDRESS's
# recent request rate rather than the browser build. Named here rather than at
# the call site so the smoke suite can assert the three engines agree on it;
# `--browser-channel chrome` is still available for a reader who wants it.
DEFAULT_BROWSER_CHANNEL = None
# How long to wait for a remote browser to accept the CDP connection.
#
# 150s, not the 30s this family shipped. What is MEASURED is narrow and
# arithmetic: against a live Scraping Browser endpoint the WebSocket upgrade
# hung for **121 seconds** before the SERVER hung up, so a 30s client timeout
# gives up while the server is still working. Sitting above the server's own
# give-up point means the client is never the one that walks away first.
#
# What is NOT established, and was claimed here for one commit before the
# evidence contradicted it: that giving up early is what leaves a profile
# stuck at `profile_locked`. Two profiles were observed locked and not
# clearing (one for over forty minutes, one after four minutes of complete
# silence), and the second locked INSTANTLY on its first WebSocket attempt —
# with no timed-out connect anywhere in its history. So the lock has some
# other cause, and this timeout is not a fix for it. Raising it is still
# right; expecting it to unwedge anything is not.
CDP_CONNECT_TIMEOUT_MS = 150_000
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 Chrome
ships, and a UA claiming an older Chrome than what the JS engine, WebGL
strings and TLS ClientHello all 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: dedupe that mutates a running set inside the loop
makes the OUTPUT depend on the order pages happened to arrive in. Pages
are strictly sequential on this site, which is exactly why keeping the
merge order-independent costs nothing and keeps the family's contract.
"""
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
state: Optional[str] = None
# How many stories the TAG holds in total, where the site states one.
# NOT a per-page counter and never used as one: a tag feed renders a
# first batch of a much larger catalogue, so reading it as a gap would
# claim missing cards on a page that rendered everything it was going to.
# It goes in the sidecar beside what the run actually read, which is what
# makes the difference between the two visible rather than assumed.
tag_total_posts: Optional[int] = None
# None, always, on this site: Medium publishes no per-page counter, and an
# unknown gap must not read the same as a gap of zero.
gap: Optional[int] = None
# What the scroll did: how many cards it reached and whether it SETTLED.
# A batch whose feed was still growing when the round budget ran out is
# a floor rather than the whole feed, and a run that reported it as
# complete would read as a shrinking catalogue.
scroll: Optional[dict] = None
@property
def ok(self) -> bool:
return not self.load_failed and self.blocked_by is None
# The lowest share of rows that must carry a title and an author before the
# read is suspect. Every measured run in the README carried both on 100% of
# rows, in every mode, so the floor sits high; 90% leaves room for a deleted
# author without hiding a broken read.
FIELD_FLOOR = 90
# ---------------------------------------------------------------------------
# page_flow, bound to Playwright
# ---------------------------------------------------------------------------
# Every decision about WHAT to do with a page — how long to wait, when to
# scroll, when a page has turned over — lives in page_flow.py so all three
# engines make it identically. What lives here is only HOW to ask this
# particular driver.
#
# The primitives are NAMED OPERATIONS rather than JavaScript. Selenium's
# execute_script takes a function BODY with an explicit `return` while
# Playwright and pyppeteer take `() => expr`, so a shared module handing JS
# across this boundary would quietly acquire one driver's dialect.
def _count(page, selector: str) -> int:
"""How many elements match, or 0 if the page moved under us.
GUARDED, like its twins in the other two engines, and the crash that
taught us why came from the canary's first dispatch: a scroll batch was
polling the card count when the page navigated — Cloudflare's challenge
can arrive at any moment on this site — and Playwright raised
`Execution context was destroyed, most likely because of a navigation`.
That left the run with exit 1, a CRASH, where the correct answer was
"blocked".
0 is the safe reading rather than a lie: every caller treats it as "no
cards seen this poll", which makes a readiness wait keep waiting and a
scroll batch report no growth — both of which are what actually happened.
The alternative, letting it propagate, turns a routine mid-poll
navigation into a traceback.
"""
try:
return len(page.query_selector_all(selector))
except (PWError, PWTimeout) as e:
logger.debug("count(%s) failed: %s", selector, e)
return 0
def _scroll_to_bottom(page) -> None:
"""Scroll the WINDOW to the end of the document.
The opposite of a sibling repo, where the body never scrolled and the
results lived in an inner container. On Medium the window itself is what
scrolls, and the document grows with the feed.
To `document.body.scrollHeight` rather than by a fixed wheel distance: a
fixed wheel stopped three rounds short of the bottom on a sibling site's
7,600px grid, so the lazy-load trigger was never reached and a run took
30 of 50 cards while looking settled.
Guarded for the same reason `_count` is: the page can navigate mid-loop
on this site, and a scroll that raises turns a routine challenge into a
traceback. A failed scroll needs no report of its own — the next poll
sees the card count unchanged and the loop draws the right conclusion.
"""
try:
page.evaluate("() => window.scrollTo(0, document.body.scrollHeight)")
except (PWError, PWTimeout) as e:
logger.debug("scroll failed: %s", e)
def _page_height(page) -> Optional[int]:
"""The document's scroll height, or None if the page is mid-navigation."""
try:
return page.evaluate("() => document.body.scrollHeight")
except (PWError, PWTimeout):
return None
# Each scroll batch after the first arrives over POST /graphql, and that
# endpoint can be refused while the HTML keeps answering 200. Counting the
# refusals is what separates a feed that ran out (COMPLETE) from one whose
# next batch was refused (PARTIAL) — without it the two are the same
# observation and a throttled run reports "complete" holding its first batch.
#
# Any status at or above 400, rather than one specific code, and the SAME
# threshold in all three engines. A threshold that differed between them
# would mean one engine reporting `complete` where its twins report `partial`
# on the identical run, which is precisely the drift the shared modules exist
# to prevent — and this file carried a sibling repo's single-code check
# for several commits before the smoke suite was taught to compare the three.
_GRAPHQL_PATH = "/graphql/"
def _watch_graphql(session) -> None:
"""Start counting refused GraphQL responses on this session's page."""
session._graphql_refused = 0
def _on_response(response):
try:
if _GRAPHQL_PATH in response.url and response.status >= 400:
session._graphql_refused += 1
except Exception: # noqa: BLE001 — a listener must never break a run
pass
session.page.on("response", _on_response)
def _graphql_refused_count(session) -> int:
return getattr(session, "_graphql_refused", 0)
def _ready_selector(args) -> str:
return page_flow.ready_selector(args.mode)
def _min_matches(args, html: str = "") -> int:
"""The readiness threshold, lowered to what THIS page actually holds.
Passing the counter's own range is what keeps a short last page from
spending the whole timeout and then reporting itself unpainted.
"""
return page_flow.min_matches(args.mode, page_flow.tag_total_posts(html))
def _classify(page, html: str, status=None) -> str:
return page_flow.classify(html, status, page.url)
def _same_url(a: str, b: str) -> bool:
from product_parser import strip_tracking
return strip_tracking(a or "") == strip_tracking(b or "")
# 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",
"ERR_TUNNEL_CONNECTION_FAILED",
"ERR_PROXY_AUTH_UNSUPPORTED",
"ERR_PROXY_AUTH_REQUESTED",
"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 one — retrying it unchanged just
spends the 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 a browser on `pool`'s current exit; return (browser, context, page).
Uses Playwright's own Chromium by default, and on this site that is a
measurement rather than a shrug: it was served HTTP 200 and the full feed
on every fetch that was not challenged, and the challenges it did meet
were Cloudflare's managed one, which tracks the ADDRESS's recent request
rate rather than the browser build. `--browser-channel chrome` is offered
for a reader who wants it and is not needed.
Factored out so a proxy rotation can tear the whole browser down and call
it 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.
"""
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))
channel = args.browser_channel
if channel:
try:
browser = pw.chromium.launch(channel=channel, **launch_kwargs)
except (PWError, PWTimeout) as e:
# A fallback, not a downgrade. Unlike a sibling site, this one
# was measured serving the bundled Chromium the full feed, so
# losing the requested channel costs nothing that is known.
logger.info(
"Could not launch the %r channel (%s) — using Playwright's "
"own Chromium instead, which this site was measured serving "
"normally. Install the channel with `playwright install %s` "
"if you want it.", channel, str(e)[:160], channel)
browser = pw.chromium.launch(**launch_kwargs)
else:
browser = pw.chromium.launch(**launch_kwargs)
ctx_kwargs = {"user_agent": _chrome_ua(browser.version),
"locale": args.locale,
"viewport": {"width": 1440, "height": 900}}
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:
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.
"""
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)
_watch_graphql(self)
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))
try:
browser = pw.chromium.connect_over_cdp(
args.cdp_endpoint, timeout=args.cdp_connect_timeout * 1000)
except (PWError, PWTimeout) as e:
# Playwright puts the endpoint it tried into the exception text, and
# that endpoint is a URL with a password in it — repeated five times,
# in the message plus a four-line call log. Unmasked it lands in the
# terminal, in CI output and in any log the run is piped to, which is
# the one thing this project promises does not happen. The host and
# port are KEPT: which endpoint failed is the useful half and is not
# the secret.
raise PWError(
f"could not connect to --cdp-endpoint "
f"{_mask_credentials(args.cdp_endpoint)}: "
f"{_mask_credentials(str(e))}\n"
f"{page_flow.cdp_connect_advice(_mask_credentials(str(e)))}"
) from None
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. Tried first when --cdp-endpoint is set;
# this script's own detect+solve logic still runs as a fallback.
#
# Worth knowing what it can and cannot do here: the refusal this site
# actually serves is Cloudflare's MANAGED challenge, which publishes no
# sitekey at all — 0 `data-sitekey` attributes and 0 Turnstile iframes on
# the one measured. An auto-solver has nothing to answer either. What it
# WOULD cover is a rendered reCAPTCHA or Turnstile widget, which Medium
# has wired into every page (an empty `cf-turnstile-response` input in a
# 0x0 div, and Turnstile's own loader in the head) and has never been
# observed rendering to an anonymous reader.
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.")
except Exception as e: # noqa: BLE001
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
# Every `scheme://user:pass@` in a string, however many times it occurs.
# Matching GLOBALLY rather than once is the point: a Playwright connection
# error repeats the endpoint five times, so a masker that handled only the
# first occurrence would print the password four times and look like it was
# working.
_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."""
return _CREDENTIALS_IN_URL_RE.sub(r"\1***:***@", text or "")
def _content_when_settled(page, attempts: int = 4, pause_ms: int = 700):
"""page.content() that tolerates a page mid-navigation.
Playwright RAISES rather than returning empty while a navigation is in
flight ("Unable to retrieve content because the page is navigating"), and
this site's challenge handler resolves by navigating — so the one moment
this is called is the one moment it can fail. Returns None if the page
will not hold still, so a 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 — 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.
Runs after EVERY navigation, for ANY page. The static-HTML and runtime
reCAPTCHA detectors are run and RECONCILED against each other rather than
short-circuited, because they can disagree about the variant and the
parameters for one are rejected for the other.
NOTE what this cannot help with, because on this site it is the normal
case. Medium's refusal is Cloudflare's MANAGED challenge (`cType:
'managed'`), and a managed challenge has no widget and no sitekey: 0
`data-sitekey` attributes and 0 Turnstile iframes on the one measured.
There is nothing to hand a solver, so `page_flow.STATE_POLICY` marks
`challenge` as retry-but-do-not-solve and nothing is ever charged for it.
The detector is still run on every navigation, and that asymmetry is
deliberate. Medium HAS a captcha configured — Turnstile's loader is
in the head of every page it serves and an empty
`cf-turnstile-response` input sits in a 0x0 div — it simply does not
render one to an anonymous reader. Which challenge a visitor meets
depends on the exit country and on what the address has been doing, and a
narrow detector is how a rendered challenge gets reported as an empty
tag months later.
"""
html = _content_when_settled(page)
if html is None:
return False
# Detected is not the same as blocking. A challenge on a page whose
# stories are already rendered guards nothing, and counting the anchors is
# instant — which is why this check sits here rather than after the
# readiness wait. The other way round would cost 25 wasted seconds on a
# page the challenge genuinely gates, where solving FIRST is what makes
# the content appear.
already_rendered = _count(page, _ready_selector(args))
when_blocked = getattr(args, "solve_captcha", "when-blocked") == "when-blocked"
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 > 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
if not budget.spend():
# Checked BEFORE the purchase, and shared with the other call site:
# see page_flow.SolveBudget.
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
def _parse_for_mode(html: str, url: str, args, page_num: int = 1) -> List:
"""Rows for this mode, always as a list even when the mode yields one.
`page_num` is threaded through rather than defaulted, because `position`
restarts at 1 on every page: without the page number beside it a row from
page 2 claims the same position as one from page 1 and the two are
indistinguishable in the output.
"""
return parse_posts(html, url, page=page_num, mode=args.mode)
def _scroll_the_feed(session, args, html: str, page_num: int) -> dict:
"""Scroll to the bottom until the feed stops growing.
A BOUNDED SAFETY NET on this site rather than the main event. Medium
server-renders its payload — a tag feed's first response already carries
34 stories and a day archive 128 — and the XHR that would extend the feed
is a POST to `medium.com/_/graphql` which came back 403 on every one of
the 11 attempts measured, while the HTML itself came back 200. So the
expected outcome here is "scrolled, nothing arrived", and that is
reported rather than fought: more rounds against a feed the site will not
extend only spends time.
Deliberately no `target`. The only total Medium publishes is
`tag.postCount` — 257,497 for `python` — which is the size of the tag's
whole catalogue since 2003, not of this page. Using it to decide when to
stop scrolling would scroll forever.
"""
before = _count(session.page, page_flow.READY_SELECTOR)
trace = page_flow.scroll_until_settled(
lambda sel: _count(session.page, sel),
lambda: _scroll_to_bottom(session.page),
lambda: _page_height(session.page),
session.page.wait_for_timeout,
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"])
warning = page_flow.feed_not_extended_warning(args.mode, trace["cards_after"],
trace)
if warning:
logger.info("%s", warning)
return {"first_paint": before, "reached": trace["cards_after"],
"rounds": trace["rounds"], "settled": True}
# ===========================================================================
# Workers — archive mode only
# ===========================================================================
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:
the concurrency is safe by construction rather than by discipline.
"""
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.
Only ever called for a day-archive walk. Those URLs are independent by
construction — page 5 is "five days earlier", knowable without fetching
page 4 — which is exactly what the other three modes do not have.
"""
work = queue.Queue()
for spec in specs:
work.put(spec)
results = []
results_lock = threading.Lock()
# Set when a day comes back with no rows at all. Without it, asking for
# 40 days of a tag that only published on three of them would fetch 37
# empty ones. Workers check it before taking more work, so at most
# (concurrency - 1) extra fetches 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()
except Exception: # noqa: BLE001 — a dead worker must not hang the run
logger.exception("[%s] died; its pages will be reported as "
"unattempted rather than 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 _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 to navigate to for page 1, and None for every page
after it: pages 2..N on this site are not addresses at all, they are the
result of pressing the site's own button. Passing None is what makes that
explicit rather than leaving a stale URL to look like it was fetched.
Returns a PageOutcome and never raises for an EXPECTED failure — a
timeout, a refusal, a challenge page, a dead exit are all recorded on
the outcome instead.
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 or session.page.url)
has_pool = bool(pool and len(pool) > 1)
# `RETRY_ON_BLOCKED` is CONSULTED, not merely documented — a policy
# constant nothing reads is the same defect as dead code. It is
# True on this site because a RESTED address recovers: the same URL that
# was challenged was served in full on the next attempt about a minute
# later. The budget is deliberately small, because a BUSY address does
# not: every attempt after about the thirtieth was challenged, and a
# 45-minute rest did not clear it.
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)
# ONE allowance for the whole block-retry loop and for BOTH call sites
# below: a page that keeps coming back gated would otherwise buy one
# solve per attempt, which is how a run quietly turns into a bill.
budget = page_flow.SolveBudget()
html, state, load_failed = None, "ok", False
for block_attempt in range(block_retries + 1):
load_failed, exit_failed = False, None
# The navigation's HTTP status, handed to the classifier so a refusal
# with no recognisable body (a bare 403, 429 or 5xx) is read as one.
# None for a scroll batch, which is not a navigation.
nav_status = None
if url is not None:
logger.info("Fetching page %d/%d: %s", page_num, args.pages, url)
for attempt in range(1, args.retries + 1):
try:
response = session.page.goto(
url, wait_until="domcontentloaded", timeout=60000)
nav_status = (response.status if response is not None
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 = _proxy_failure(e)
if reason:
exit_failed, load_failed = reason, 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)
else:
logger.info("Extending the feed to batch %d/%d by scrolling "
"(this listing has no batch-%d address).",
page_num, args.pages, page_num)
refused_before = _graphql_refused_count(session)
turn = page_flow.advance_feed(
lambda: _scroll_to_bottom(session.page),
lambda sel: _count(session.page, sel),
lambda: _page_height(session.page),
session.page.wait_for_timeout)
if turn == page_flow.NO_GROWTH:
refused = _graphql_refused_count(session) - refused_before
if refused:
# The feed stopped growing AND the endpoint behind it was
# refusing. Those are two different endings and must not
# collapse into one: reporting this as the end of the
# listing would make a throttled run say "complete" while
# holding its first batch.
outcome.state = "no_turnover"
outcome.load_failed = True
outcome.final_url = session.page.url
logger.error(
"The feed did not grow within %.0fs and %d GraphQL "
"response(s) were refused while waiting. This is NOT "
"the end of the listing — the run is reported as "
"PARTIAL (exit 6) rather than complete. Each batch "
"after the first is fetched over POST /graphql, "
"which is limited separately from the HTML: the page "
"itself still answers 200 while the batch is "
"refused. Raise --delay (it is %.1fs now), or spread "
"the load with --proxy-file.",
page_flow.NEXT_BATCH_TIMEOUT_MS / 1000, refused,
args.delay)
return outcome
# Nothing refused and nothing new. Before calling that the
# end of the listing, ASK WHAT PAGE WE ARE ON: Cloudflare's
# challenge can arrive mid-scroll — it is what crashed this
# repo's first canary dispatch — and a feed that stopped
# growing because the page was replaced has not run out. The
# engines used to report that as `exhausted`, which is a
# COMPLETE stop reason, so a run that was blocked halfway
# would have claimed the feed ended.
current = _content_when_settled(session.page) or ""
state_now = _classify(session.page, current)
if page_flow.counts_as_blocked(state_now):
outcome.state = "blocked_mid_scroll"
outcome.blocked_by = (detect_bot_challenge(current)
or "bot-challenge")
outcome.final_url = session.page.url
logger.error(
"The feed stopped growing because the page was "
"replaced: it is now %s. This is NOT the end of the "
"listing — the batches already gathered are kept and "
"the run is reported as partial rather than "
"complete.", state_now)
return outcome
# Ours, still served, and no more stories came. On an
# infinite scroll that is the only ending there is.
outcome.state = "exhausted"
outcome.final_url = session.page.url
return outcome
if exit_failed and has_pool 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
if handle_captcha_if_present(session.page, args, budget):
# The page was reloaded after the solve, so the status belongs
# to a document that is no longer there.
nav_status = None
session.page.wait_for_timeout(1000)
html = _content_when_settled(session.page) or ""
state = _classify(session.page, html, nav_status)
# "Not painted yet" is not a fault, and telling it apart from one is
# the distinction that matters here. A search page's first response is a
# shell — the grid arrives over client-side GraphQL — so classified
# naively it reads as something to retry, and retrying a shell buys
# another shell. Wait for the anchor and re-classify BEFORE the retry
# decision.
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)
found = page_flow.wait_for_count(
lambda sel: _count(session.page, sel),
session.page.wait_for_timeout,
_ready_selector(args), _min_matches(args, html), wait_timeout)
if found < _min_matches(args, html):
logger.info("The grid still had not painted after %.0fs "
"(%d match(es)).", wait_timeout / 1000, found)
html = _content_when_settled(session.page) or html
state = _classify(session.page, html, nav_status)
if page_flow.should_solve(state) and budget.available():
if handle_captcha_if_present(session.page, args, budget):
session.page.wait_for_timeout(1000)
html = _content_when_settled(session.page) or html
state = _classify(session.page, html)
# The VERIFIED outcome, and the only one worth reporting: a
# "ready" task result is not evidence the token works. This
# line is what says whether the money bought anything.
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 not page_flow.should_retry(state):
# "content" and "empty" are both final answers. An empty search
# is a CORRECT one, so retrying it would spend the budget
# re-confirming the same right answer and rotating the exit would
# blame an address for the URL it was given.
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 DOES clear it here is
# a fresh browser context: measured 9 of 27 first attempts