forked from LeyckerS/moondownloader
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmoon_extract.py
More file actions
1520 lines (1308 loc) · 64.9 KB
/
Copy pathmoon_extract.py
File metadata and controls
1520 lines (1308 loc) · 64.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
"""
moon_extract.py — MoonDownloader extraction layer, rebuilt 2026-07-29.
The extraction layer, imported by moon_engine.py and moon_cli.py.
Both providers changed; the pre-14.4 code fails on every link for different reasons.
fuckingfast.co
The landing page is Alpine + htmx now and the /dl/ URL is NOT in the HTML at
all. It is only returned in the `hx-redirect` RESPONSE HEADER of
POST /f/{id}/go. On top of that, Cloudflare fingerprints TLS: aiohttp's
ClientHello scores as a bot and gets `cf-mitigated: challenge` -> 403 no matter
which headers you send, while a Chrome-impersonating ClientHello sails through.
Extraction therefore goes through curl_cffi; the download engine keeps aiohttp
because dl.fuckingfast.co serves the file with full Range support.
datanodes.to
1. The share URL 302s to /download and drops a `file_code` cookie.
2. Step 1's form is present from the first byte but sits inside a collapsed
`#downloadReveal` with a `disabled` submit; the Vue scan arms it at ~6s
(site's own failsafe at 8s). Submitting at t=0 gets you nowhere.
3. The submit button carries `name="method_free" value="Free Download >>"`.
The server re-serves step 1 when that pair is missing from the POST body,
and a synthetic click does not reliably register as the form's submitter.
4. Exactly ONE POST /download is allowed. A second one re-runs SecSave server
side and invalidates the token step 2 is holding, after which download2
fails SecCheck and the server answers with HTML. The old extractor tripped
this by calling form.submit() and then click-hunting "free download" text.
5. Step 2 is a <download-countdown> Vue component with Cloudflare Turnstile
(`:has-captcha="true"`) and adblock detection (`:detect-adblock="true"`).
The old BLOCKED_DOMS list contains "challenges.cloudflare", so Turnstile
could never load, and BLOCKED_RES contains "stylesheet", which collapses
every getBoundingClientRect() to 0x0 and makes the button finder blind.
Turnstile is the load-bearing change: it does not issue a token to a headless
Chromium. Measured on chromium 131 (headless shell, --headless=new, and a
persistent profile) the challenge platform answers 401 on
/cdn-cgi/challenge-platform/h/b/pat/... every time and cf-turnstile-response
stays empty indefinitely. datanodes therefore needs a non-headless browser —
see dn_launch_kwargs() / prepare_datanodes_context() below.
"""
import asyncio
import os
import random
import re
import time
from contextlib import asynccontextmanager
from urllib.parse import urlparse, unquote
DEBUG = bool(os.environ.get("MOON_DEBUG"))
DATANODES_HOST = "datanodes.to"
FUCKINGFAST_HOST = "fuckingfast.co"
SUPPORTED_HOSTS = (DATANODES_HOST, FUCKINGFAST_HOST)
def _d(*a):
if DEBUG:
print(" [extract]", *a, flush=True)
# ══ fuckingfast.co ════════════════════════════════════════════════════════════
FF_HOST = f"https://{FUCKINGFAST_HOST}"
FF_ID_RE = re.compile(r"^[A-Za-z0-9]{6,32}$")
FF_DL_RE = re.compile(r"https://(?:dl\.)?fuckingfast\.co/dl/[A-Za-z0-9_\-]{16,}")
FF_RETRIES = 3
FF_IMPERSONATE = "chrome"
FALLBACK_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36")
try:
from curl_cffi import AsyncSession as _CurlSession
HAVE_CURL_CFFI = True
except ImportError: # pragma: no cover
_CurlSession = None
HAVE_CURL_CFFI = False
_FF_SESSION = None
_FF_LOCK = asyncio.Lock()
async def ff_session():
"""Shared TLS-impersonating session; one instance keeps Cloudflare clearance warm.
*curl_cffi binds its libcurl multi handle to the loop it is constructed on, so
this must be built inside the running loop — never at import time.*
"""
global _FF_SESSION
if not HAVE_CURL_CFFI:
return None
async with _FF_LOCK:
if _FF_SESSION is None:
_FF_SESSION = _CurlSession(impersonate=FF_IMPERSONATE, timeout=30,
max_clients=64)
return _FF_SESSION
async def close_ff_session():
"""Call from the same shutdown path that runs _close_sess()."""
global _FF_SESSION
if _FF_SESSION is not None:
try:
await _FF_SESSION.close()
except Exception:
pass # best-effort cleanup during shutdown
_FF_SESSION = None
def ff_file_id(url: str) -> str | None:
"""Pull the file id out of any fuckingfast link shape.
FitGirl links carry the filename as a URL FRAGMENT
(`https://fuckingfast.co/smeekt12mped#Game.part01.rar`). urlparse drops the
fragment; a naive rsplit on the raw string does not and yields a bogus id.
"""
path = urlparse(url).path.strip("/")
if not path:
return None
for part in reversed(path.split("/")):
if FF_ID_RE.match(part):
return part
return None
def _ff_headers(file_id: str) -> dict[str, str]:
page = f"{FF_HOST}/{file_id}"
return {
"Accept": "*/*",
"Accept-Language": "en-US,en;q=0.9",
"HX-Request": "true",
"HX-Current-URL": page,
"Origin": FF_HOST,
"Referer": page,
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin",
}
FF_CAPTCHA_MARK = "captcha verification failed"
FF_BROWSER_TIMEOUT = 75.0
FF_CLICK_TRIES = 4
FF_READY_WAIT = 6.0
# The button is gated on `click[!!window.turnstileToken || !!window.dlCleared]`.
# Note the OR: most of the time Turnstile clears itself here and never hands out
# a visible token, so insisting on cf-turnstile-response waits for something that
# is not coming. Either flag being set means the trigger is armed.
FF_READY_JS = """() => !!(window.turnstileToken || window.dlCleared)"""
# No <form> on the page any more -- htmx owns the submit, so the hx-post element
# is what has to be clicked.
FF_CLICK_JS = """() => {
const el = document.querySelector('[hx-post*="/go"]');
if (!el) return false;
el.click();
return true;
}"""
async def _ff_browser_extract(get_browser, url: str, file_id: str) -> str | None:
"""Resolve one fuckingfast link in the shared browser, after /go returned 403.
fuckingfast put Cloudflare Turnstile in front of POST /f/<id>/go in August
2026 and the server answers `captcha verification failed` without a token,
which plain HTTPS cannot mint at any TLS fingerprint.
Two things this has to copy from a human rather than from the datanodes flow:
the challenge usually clears itself, so the wait is on the trigger being
armed and not on a token appearing; and the first click opens an ad tab
instead of submitting, so the tab is closed and the button pressed again.
"""
browser = await get_browser()
found: list[str] = []
async def _on_response(resp):
if "/go" in resp.url:
target = resp.headers.get("hx-redirect") or ""
if "/dl/" in target:
found.append(target.strip())
async def _block_dl(route):
# The URL is what we want, not the bytes: the engine downloads it with
# range support. Following the redirect here would start a second,
# untracked copy of the whole file inside Chrome.
await route.abort()
# Through the SAME pooled context datanodes uses. Giving each link its own
# browser.new_context() is the v14.6 mistake recorded above acquire_lane:
# several identities from one IP in quick succession read as a bot farm.
async with acquire_lane(browser) as context:
page = await context.new_page()
page.on("response", _on_response)
async def _close_ad(popup):
# The interstitial the first click opens. Closing it is what lets the
# second click reach the trigger, exactly as a person does it.
try:
await popup.close()
_d(f"ff {file_id}: closed an ad tab")
except Exception:
pass # popup already gone
# Both of these are bound to the PAGE, not to the context. The context is
# shared and lives for the whole session, so a handler or a route added
# per link would pile up on it: after a few links every popup fires N
# stale closures and every request walks N route handlers. Page-scoped,
# they die with the page.
page.on("popup", lambda pg: asyncio.ensure_future(_close_ad(pg)))
await page.route("**/dl/*", _block_dl)
try:
await page.goto(f"{FF_HOST}/{file_id}", wait_until="domcontentloaded",
timeout=45000)
# Give the challenge a chance to clear on its own; only reach for the
# widget if it is actually sitting there interactive.
t_ready = time.monotonic()
while time.monotonic() - t_ready < FF_READY_WAIT:
if await _dn_eval(page, FF_READY_JS, default=False):
break
if await _dn_eval(page, DN_WIDGET_BOX_JS):
await dn_solve_turnstile(page, DN_HEADLESS)
break
await asyncio.sleep(0.4)
# Measured on the live page: this flag never arms, and the click
# submits regardless -- htmx evaluates the trigger against its own
# state, not against what we can read. Logged, never gated on.
armed = await _dn_eval(page, FF_READY_JS, default=False)
_d(f"ff {file_id}: trigger {'armed' if armed else 'not armed'} "
f"after {time.monotonic() - t_ready:.1f}s")
deadline = time.monotonic() + FF_BROWSER_TIMEOUT
for attempt in range(FF_CLICK_TRIES):
if found or time.monotonic() > deadline:
break
if not await _dn_eval(page, FF_CLICK_JS, default=False):
_d(f"ff {file_id}: no hx-post trigger on the page")
return None
_d(f"ff {file_id}: download click {attempt + 1}")
t_click = time.monotonic()
while not found and time.monotonic() - t_click < 8.0 and time.monotonic() < deadline:
await asyncio.sleep(0.2)
if found:
_d(f"ff {file_id}: resolved in the browser")
return found[0]
_d(f"ff {file_id}: no hx-redirect after {FF_CLICK_TRIES} clicks")
return None
finally:
try:
await page.close()
except Exception: # page already gone with a crashed browser
pass
async def extract_fuckingfast(url: str, get_browser=None) -> str | None:
"""Resolve a fuckingfast.co share link to its direct dl.fuckingfast.co URL.
No browser. Returns the direct URL, or None for an unparseable id, a dead
file, or a challenge that survives FF_RETRIES. Measured 0.23-0.33s per link
against live FitGirl parts; the returned URL answers 206 with a correct
Content-Range and `Content-Disposition: attachment` under plain aiohttp.
Depends on the host module for `_sess` and `USER_AGENTS` only in the degraded
no-curl_cffi path.
"""
file_id = ff_file_id(url)
if not file_id:
return None
go_url = f"{FF_HOST}/f/{file_id}/go"
hdrs = _ff_headers(file_id)
sess = await ff_session()
needs_captcha = False
for attempt in range(FF_RETRIES):
try:
if sess is not None:
r = await sess.post(go_url, headers=hdrs, data=b"",
allow_redirects=False)
status = r.status_code
target = r.headers.get("hx-redirect")
body = "" if target else r.text
else:
# Degraded: only reachable if the host ever drops the TLS challenge.
# _sess / USER_AGENTS are provided by the host module.
host_sess = globals().get("_sess")
host_uas = globals().get("USER_AGENTS") or [FALLBACK_UA]
if host_sess is None:
return None
async with host_sess().post(
go_url,
headers={**hdrs, "User-Agent": random.choice(host_uas)},
data=b"", allow_redirects=False) as r:
status = r.status
target = r.headers.get("hx-redirect")
body = "" if target else await r.text()
if target and "/dl/" in target:
return target.strip()
if status == 404 or "not found" in body[:400].lower():
_d("ff dead file", file_id)
return None
m = FF_DL_RE.search(body) # legacy shape, if they ever revert
if m:
return m.group()
if status == 403 and FF_CAPTCHA_MARK in body[:200].lower():
# Retrying this over HTTP cannot help: the token is the point.
needs_captcha = True
break
_d(f"ff {file_id}: status={status} no hx-redirect")
except Exception as e:
# network or parsing error triggers a retry
_d(f"ff {file_id}: {type(e).__name__} {e}")
if attempt + 1 < FF_RETRIES:
await asyncio.sleep(0.6 * (attempt + 1))
if needs_captcha and get_browser is not None:
_d(f"ff {file_id}: captcha required - falling back to the browser")
return await _ff_browser_extract(get_browser, url, file_id)
if needs_captcha:
_d(f"ff {file_id}: captcha required and no browser available")
return None
# ══ datanodes.to ══════════════════════════════════════════════════════════════
# Only heavy media is dropped. Stylesheets MUST load: the button finder measures
# getBoundingClientRect(), and with CSS blocked every element collapses to 0x0.
# Ad hosts must load too — step 2 runs `:detect-adblock="true"`.
DN_BLOCKED_RES = {"image", "media", "font"}
DN_ALWAYS_ALLOW = (
"challenges.cloudflare.com", # Turnstile — step 2 cannot pass without it
"cdn-cgi/challenge-platform", # Cloudflare JS detections
DATANODES_HOST,
)
# Pure telemetry only. Nothing ad-shaped, on purpose.
DN_BLOCKED_DOMS = {
"google-analytics.com", "analytics.google.com", "stats.g.doubleclick.net",
"hotjar", "clarity.ms", "facebook.com/tr",
}
DN_FILE_EXT = re.compile(
r"\.(?:r(?:ar|\d{2})|zip|7z|tar|gz|bin|iso|exe|mkv|mp4|part\d+)(?:$|[?#])", re.I)
DN_STEP1_GATE_TIMEOUT = 22.0
DN_STEP2_TIMEOUT = 420.0
# Step 2 is a chain of buttons, not one button: each click can reveal the next.
# The cap stops a page that cycles labels from being clicked until the deadline.
DN_STEP2_MAX_CLICKS = 4
# Auto-click budget. After this the widget is left alone so a human sitting at the
# headful window can tick it; DN_MANUAL_CAPTCHA_TIMEOUT is that grace period.
DN_CAPTCHA_AUTO_TIMEOUT = 45.0
DN_MANUAL_CAPTCHA_TIMEOUT = float(os.environ.get("MOON_DN_CAPTCHA_WAIT", "240"))
DN_CAPTCHA_RECLICK = 13.0
# ── datanodes official API (no captcha, no countdown, no browser) ─────────────
# One free click on https://datanodes.to/account mints a key. With MOON_DN_API_KEY
# set, extraction is a single JSON GET and the whole two-step Turnstile flow is
# skipped, which is the only sane way to pull a 40-part repack.
DN_API_KEY = os.environ.get("MOON_DN_API_KEY", "").strip()
DN_API_ENDPOINT = "https://datanodes.to/api/file/direct_link"
DN_CODE_RE = re.compile(r"^[A-Za-z0-9]{8,20}$")
DN_STEALTH_JS = """
Object.defineProperty(navigator, 'webdriver', {get: () => undefined});
window.chrome = window.chrome || {runtime: {}, loadTimes: () => {}, csi: () => {},
app: {isInstalled: false}};
Object.defineProperty(navigator, 'plugins',
{get: () => ({length: 5, 0: {}, 1: {}, 2: {}, 3: {}, 4: {}})});
Object.defineProperty(navigator, 'languages', {get: () => ['en-US', 'en']});
Object.defineProperty(navigator, 'hardwareConcurrency', {get: () => 8});
Object.defineProperty(navigator, 'maxTouchPoints', {get: () => 0});
const _gp = WebGLRenderingContext.prototype.getParameter;
WebGLRenderingContext.prototype.getParameter = function (p) {
if (p === 37445) return 'Intel Inc.';
if (p === 37446) return 'Intel Iris OpenGL Engine';
return _gp.apply(this, arguments);
};
"""
DN_DEAD_JS = """() => {
const t = (document.body?.innerText || '').toLowerCase();
return t.includes('file not found') || t.includes('could not be found')
|| t.includes('file was deleted') || t.includes('has been removed')
|| t.includes('file expired') || t.includes('no such file');
}"""
DN_SHAPE_JS = """() => {
// Which of the two shapes the server sent. datanodes dropped step 1 in
// August 2026 and now serves step 2 on the first response, but only
// fills it in once Cloudflare has been cleared -- before that the page
// is a stub with no controls at all, which is why this cannot key off
// 'no step 1 found' and has to wait for positive evidence of either.
if (document.getElementById('downloadReveal') &&
document.getElementById('method_free')) return 'step1';
const txt = document.body ? (document.body.innerText || '') : '';
if (/step\\s*2\\s*of\\s*2/i.test(txt)) return 'step2';
// The method chooser itself: 'Free Download / Standard speed'. Matching
// the label rather than a tag, because Vue consumes <download-countdown>
// when it mounts and the attributes go with it.
for (const e of document.querySelectorAll('button, a, [role=button]')) {
if (/free\\s*download/i.test(e.innerText || '')) return 'step2';
}
return 'unknown';
}"""
DN_GATE_JS = """(force) => {
const w = document.getElementById('downloadReveal');
const b = document.getElementById('method_free');
if (!w || !b) return {present: false, ready: false};
const cs = getComputedStyle(w);
let ready = !b.disabled && cs.pointerEvents !== 'none' && cs.opacity !== '0';
if (!ready && force) {
w.style.maxHeight = 'none'; w.style.opacity = '1';
w.style.transform = 'none'; w.style.pointerEvents = 'auto';
b.disabled = false;
ready = true;
}
return {present: true, ready: ready};
}"""
# One POST /download, ever — a second invalidates the token step 2 holds.
DN_LATCH_JS = """() => {
if (window.__moonLatched) return false;
window.__moonLatched = true;
const f = document.getElementById('downloadForm');
if (f) {
const native = f.submit.bind(f);
let used = false;
f.submit = function () { if (used) return; used = true; return native(); };
}
return true;
}"""
# `method_free` must be in the body or the server just re-serves step 1, and a
# synthetic click is not a reliable submitter here, so materialise the pair.
DN_SUBMIT_JS = """() => {
const f = document.getElementById('downloadForm');
if (!f) return false;
if (!f.querySelector('input[type=hidden][name=method_free]')) {
const i = document.createElement('input');
i.type = 'hidden'; i.name = 'method_free'; i.value = 'Free Download >>';
f.appendChild(i);
}
f.submit();
return true;
}"""
DN_STEP2_JS = """() => {
const t = (document.body?.innerText || '').toLowerCase();
const tok = document.querySelector('[name="cf-turnstile-response"]');
const captcha = document.querySelectorAll('.cf-turnstile').length > 0
|| document.querySelectorAll('iframe[src*="challenges.cloudflare"]').length > 0;
let trigger = null;
for (const el of document.querySelectorAll('button, a, input[type=submit]')) {
const label = (el.innerText || el.value || '').trim().toLowerCase();
if (!label || label.length > 60 || el.disabled) continue;
if (/start download|download now|get link|proceed to download|download file|free download/.test(label)) {
const r = el.getBoundingClientRect();
if (r.width * r.height > 0) { trigger = label; break; }
}
}
return {
step2: t.includes('step 2 of 2') || t.includes('unlock your download'),
captcha: captcha,
solved: !!(tok && tok.value),
// Turnstile's OWN error state -- distinct from "not solved yet". Waiting
// out the normal captcha budget here just burns minutes on a widget that
// will never recover on its own; the caller bails immediately instead.
hardFail: t.includes('verification failed') || t.includes('challenge failed'),
adblock: t.includes('adblock') || t.includes('ad blocker'),
starting: t.includes('starting download') || t.includes('preparing your download'),
trigger: trigger,
};
}"""
DN_CLICK_JS = """(label) => {
for (const el of document.querySelectorAll('button, a, input[type=submit]')) {
const l = (el.innerText || el.value || '').trim().toLowerCase();
if (l === label) { el.click(); return true; }
}
return false;
}"""
# Ad overlays hijack z-index on bare <div>s directly under body; the real UI is
# inside #app, so dropping unnamed body-level layers is safe.
DN_OVERLAY_JS = """() => {
for (const el of document.querySelectorAll('body > div')) {
if (el.id || el.className) continue;
const s = el.getAttribute('style') || '';
if (s.includes('z-index') || s.includes('position: fixed')) el.remove();
}
for (const el of document.querySelectorAll('body > iframe, body > ins')) el.remove();
}"""
DN_HEADLESS = False # updated by dn_launch_kwargs(); read by the captcha step
def dn_launch_kwargs(launch_args: list[str], headless: bool | None = None) -> dict:
"""Launch kwargs for a datanodes-capable browser.
Turnstile hands out no token to a headless Chromium — the challenge platform
answers 401 on its /pat/ endpoint and cf-turnstile-response stays empty — so
headless defaults to False here. Override with MOON_DN_HEADLESS=1 only if you
have wired in a captcha solver.
*`--disable-blink-features=AutomationControlled` alone is not enough; the flag
hides one signal, and the init script in prepare_datanodes_context() covers
navigator.webdriver / plugins / WebGL vendor.*
"""
global DN_HEADLESS
if headless is None:
headless = os.environ.get("MOON_DN_HEADLESS") == "1"
DN_HEADLESS = headless
args = [a for a in launch_args if a not in ("--disable-gpu", "--no-zygote")]
if "--disable-blink-features=AutomationControlled" not in args:
args.append("--disable-blink-features=AutomationControlled")
return {"headless": headless, "args": args}
async def prepare_datanodes_context(context) -> None:
"""Install the stealth init script on a context before any datanodes page loads."""
from playwright.async_api import Error as PlaywrightError
try:
await context.add_init_script(DN_STEALTH_JS)
except PlaywrightError:
pass
async def _dn_eval(page, js, arg=None, default=None, tries: int = 4):
"""page.evaluate() that survives a navigation landing mid-call.
*Step 1 is a form-POST navigation, so Playwright will raise "Execution context
was destroyed" on any probe that overlaps it. Retry instead of trusting.*
"""
from playwright.async_api import Error as PlaywrightError
for i in range(tries):
try:
return await (page.evaluate(js, arg) if arg is not None else page.evaluate(js))
except PlaywrightError as e:
msg = str(e)
if ("Execution context was destroyed" not in msg
and "Target closed" not in msg and "navigation" not in msg):
return default
await asyncio.sleep(0.35 * (i + 1))
return default
def dn_is_file_url(candidate: str, landing_host: str, want_name: str | None,
self_urls: frozenset) -> bool:
"""True when a request looks like the final file handoff rather than page chrome.
Deliberately not keyed on the literal string "dlproxy" — that token is what the
old extractor waited on and it is the churn-prone part of the flow. The share
URL ends in the filename too, so anything on the landing host needs an explicit
handoff marker; without that guard the very first navigation matches its own
filename, gets aborted by the route handler, and goto() dies with ERR_FAILED.
"""
if len(candidate) < 40 or candidate in self_urls:
return False
low = candidate.lower()
p = urlparse(candidate)
host = p.netloc.lower()
path = unquote(p.path)
if path in ("", "/") or path.rstrip("/") in ("/download", "/premium", "/login"):
return False
base = landing_host[4:] if landing_host.startswith("www.") else landing_host
if host in (landing_host, base, f"www.{base}"):
return "dlproxy" in low or "/dl/" in path or path.startswith("/d/")
if re.match(r"^(?:s\d+|dl\d*|cdn\d*|node\d*|fs\d+|files?)\.", host) and \
(DN_FILE_EXT.search(path) or "/d/" in path or "/dl/" in path):
return True
if "dlproxy" in low or DN_FILE_EXT.search(path):
return True
if want_name and want_name.lower() in low:
return True
return False
def dn_file_code(url: str) -> str | None:
"""Extract the datanodes file_code from a share URL (`/{code}/{filename}`)."""
for part in urlparse(url).path.strip("/").split("/"):
if DN_CODE_RE.match(part):
return part
return None
async def extract_datanodes_api(url: str, api_key: str | None = None) -> str | None:
"""Resolve a datanodes link through the official API — no browser, no captcha.
GET /api/file/direct_link?file_code=..&key=.. -> {"status":200,
"result":{"url":"https://sN.datanodes.to/d/.../file.rar","size":N}}
Returns None when no key is configured or the API refuses, so the caller can
fall back to the browser flow.
*The endpoint answers HTTP 200 even for failures — the real outcome is the JSON
`status` field, so never branch on the HTTP code here.*
"""
key = (api_key or DN_API_KEY).strip()
code = dn_file_code(url)
if not key or not code:
return None
target = f"{DN_API_ENDPOINT}?file_code={code}&key={key}"
try:
sess = await ff_session()
if sess is not None:
r = await sess.get(target)
payload = r.json()
else:
host_sess = globals().get("_sess")
if host_sess is None:
return None
async with host_sess().get(target) as r:
payload = await r.json(content_type=None)
except Exception as e:
# swallow network/JSON errors and fall back to scraping
_d("dn api error:", type(e).__name__, str(e)[:90])
return None
if not isinstance(payload, dict):
return None
if payload.get("status") != 200:
msg = payload.get("msg") or "unknown error"
if "not allowed" in str(msg).lower():
_d("dn api: direct_link is premium-only on this account "
"(account/info shows premium_expire: null) — falling back to the browser")
else:
_d(f"dn api rejected {code}: {msg}")
return None
direct = (payload.get("result") or {}).get("url")
if direct:
_d(f"dn api ok {code} -> {direct[:70]}")
return direct
return None
DN_WIDGET_BOX_JS = """() => {
// datanodes renders the widget with class="cf-turnstile"; fuckingfast uses
// id="cf-turnstile", and its challenge iframe carries no src at all, so
// neither of the two original lookups matched there and the auto-click was
// left without a target -- the box simply sat there, never ticked.
const d = document.querySelector('.cf-turnstile, #cf-turnstile, [data-sitekey]')
|| document.querySelector('iframe[src*="challenges.cloudflare"]')?.parentElement;
if (!d) return null;
d.scrollIntoView({block: 'center', behavior: 'instant'});
const r = d.getBoundingClientRect();
if (r.width < 40 || r.height < 20) return null;
return {x: r.x, y: r.y, w: r.width, h: r.height};
}"""
DN_TOKEN_JS = """() => {
const i = document.querySelector('[name="cf-turnstile-response"]');
return i && i.value ? i.value.length : 0;
}"""
DN_HARD_FAIL_JS = """() => {
const t = (document.body?.innerText || '').toLowerCase();
return t.includes('verification failed') || t.includes('challenge failed');
}"""
async def _dn_click_turnstile(page) -> str:
"""Tick the Turnstile checkbox with a trusted input event.
Turnstile is served in interactive mode here — it renders a 'Verify you are
human' checkbox that must be clicked, and it will not solve on its own.
*A JS `element.click()` produces an untrusted event that Turnstile ignores.
Playwright's locator click and page.mouse.* both go through CDP
Input.dispatchMouseEvent, which the widget accepts.*
"""
from playwright.async_api import Error as PlaywrightError, TimeoutError as PlaywrightTimeoutError
# Preferred: let Playwright reach into the cross-origin challenge frame. Guard
# on the iframe existing first — frame_locator waits out its full timeout per
# selector otherwise, which burns 12s an attempt for nothing.
has_frame = await _dn_eval(
page,
"""() => document.querySelectorAll('iframe[src*="challenges.cloudflare"]').length""",
default=0)
if has_frame:
for sel in ('input[type="checkbox"]', 'label', 'body'):
try:
frame = page.frame_locator('iframe[src*="challenges.cloudflare"]')
target = frame.locator(sel).first
await target.click(timeout=4000)
return f"frame:{sel}"
except (PlaywrightError, PlaywrightTimeoutError):
continue
# Fallback: real mouse at the widget's checkbox, with some pointer entropy
# first — Turnstile scores mouse movement, a teleporting cursor looks synthetic.
box = await _dn_eval(page, DN_WIDGET_BOX_JS)
if not box:
return ""
cx = box["x"] + 30
cy = box["y"] + box["h"] / 2
try:
await page.mouse.move(cx - 140, cy - 70, steps=14)
await asyncio.sleep(0.2)
await page.mouse.move(cx - 40, cy - 12, steps=10)
await asyncio.sleep(0.15)
await page.mouse.move(cx, cy, steps=8)
await asyncio.sleep(0.2)
await page.mouse.click(cx, cy, delay=95)
return f"mouse:({cx:.0f},{cy:.0f})"
except (PlaywrightError, PlaywrightTimeoutError) as e:
_d("turnstile click failed:", str(e)[:70])
return ""
async def dn_solve_turnstile(page, headless: bool) -> bool:
"""Get a Turnstile token: auto-click first, then leave it to the human.
Returns True once cf-turnstile-response is populated. On a headful window the
user can always tick the box themselves, so a failed auto-click degrades to a
prompt instead of a dead link. Bails immediately (does not burn the rest of
its budget) the moment the widget shows its own "Verification failed" state —
that is not a click problem, clicking it more will not help.
"""
t0 = time.monotonic()
attempts = 0
last_click = 0.0
prompted = False
budget = DN_CAPTCHA_AUTO_TIMEOUT + (0.0 if headless else DN_MANUAL_CAPTCHA_TIMEOUT)
while time.monotonic() - t0 < budget:
if await _dn_eval(page, DN_TOKEN_JS, default=0):
_d(f"turnstile solved after {time.monotonic() - t0:.1f}s "
f"({attempts} auto-click attempt(s))")
return True
if await _dn_eval(page, DN_HARD_FAIL_JS, default=False):
_d(f"turnstile hard-failed after {time.monotonic() - t0:.1f}s "
f"({attempts} auto-click attempt(s)) — stopping, not retrying clicks")
return False
elapsed = time.monotonic() - t0
if elapsed < DN_CAPTCHA_AUTO_TIMEOUT:
if time.monotonic() - last_click > DN_CAPTCHA_RECLICK:
last_click = time.monotonic()
attempts += 1
how = await _dn_click_turnstile(page)
_d(f"turnstile click attempt {attempts}: {how or 'no target'}")
elif not prompted:
prompted = True
if headless:
_d("turnstile unsolved and browser is headless — no human to ask")
return False
print("\n >>> Tick the 'Verify you are human' checkbox in the browser "
f"window (waiting {int(DN_MANUAL_CAPTCHA_TIMEOUT)}s) <<<\n",
flush=True)
await asyncio.sleep(0.6)
_d("turnstile never solved")
return False
async def _extract_datanodes_on_context(context, url: str,
headless: bool) -> tuple[str | None, str | None]:
"""Drive the two-step browser flow on an already-open context.
302 -> /download, ~6s scan reveal, one POST carrying method_free, then
Turnstile + a ~15s countdown. Returns (None, None) for dead files, an
unopened gate, or an unsolved Turnstile. Called by extract_datanodes(),
which owns lane acquisition — this half owns only the page-level flow.
"""
from playwright.async_api import Error as PlaywrightError, TimeoutError as PlaywrightTimeoutError
page = await context.new_page()
captured = asyncio.Event()
holder: list[str] = []
landing_host = urlparse(url).netloc.lower()
want_name = unquote(urlparse(url).path).rsplit("/", 1)[-1] or None
if want_name and not DN_FILE_EXT.search(want_name):
want_name = None
self_urls = frozenset({url, url.split("#")[0],
f"https://{landing_host}/download",
f"https://{landing_host}/download/"})
def _take(candidate: str) -> bool:
if captured.is_set() or not candidate:
return False
if dn_is_file_url(candidate, landing_host, want_name, self_urls):
holder.append(candidate)
captured.set()
_d("captured", candidate[:110])
return True
return False
async def on_route(route):
req = route.request
u, rt = req.url, req.resource_type
try:
if _take(u):
await route.abort() # URL is all we need; aiohttp transfers
return
if any(a in u for a in DN_ALWAYS_ALLOW):
await route.continue_()
return
if rt in DN_BLOCKED_RES or any(d in u for d in DN_BLOCKED_DOMS):
await route.abort()
return
await route.continue_()
except PlaywrightError:
pass # Ignore route abort/continue race conditions during request interception
await page.route("**/*", on_route)
# A same-tab navigation, a popup, or a real download event also carries the
# URL, and none of those reliably reach the route handler.
my_popups: list = []
def _on_popup(pop):
my_popups.append(pop)
_take(pop.url)
page.on("download", lambda d: _take(d.url))
page.on("framenavigated", lambda f: _take(f.url))
# page-scoped, not context-scoped: the context may be shared with other
# workers, and context.on("page") both leaks a listener per call and would
# hand us their tabs to close.
page.on("popup", _on_popup)
file_url = cookies_str = None
try:
resp = await page.goto(url, wait_until="domcontentloaded", timeout=30000)
if not resp or resp.status >= 400:
_d("goto failed", resp.status if resp else "no response")
return None, None
if await _dn_eval(page, DN_DEAD_JS, default=False):
_d("dead link")
return None, None
# ── which step did the server actually send? ───────────────────────────
# It used to be step 1 every time. Since August 2026 datanodes serves
# step 2 straight away, and posting step 1 on top of it re-runs SecSave
# and invalidates the token step 2 is holding. Both shapes are still
# handled: the page decides, not a flag in here.
shape = "unknown"
gate = {"present": False, "ready": False}
t_gate = time.monotonic()
# The controls only appear once Cloudflare has been answered, and on the
# new shape that answer is the operator's to give -- so this wait has to
# cover their reaction time, not just the site's own ~6s scan. The step-1
# budget stays the floor for the old shape.
deadline = t_gate + max(DN_STEP1_GATE_TIMEOUT, DN_MANUAL_CAPTCHA_TIMEOUT)
while time.monotonic() < deadline:
shape = await _dn_eval(page, DN_SHAPE_JS, default="unknown")
if shape == "step2":
break
if shape == "step1":
gate = await _dn_eval(page, DN_GATE_JS, False,
default={"present": False, "ready": False})
if gate["ready"]:
break
elif await _dn_eval(page, DN_DEAD_JS, default=False):
return None, None
await asyncio.sleep(0.3)
if shape == "step2":
_d(f"step 2 served directly in {time.monotonic() - t_gate:.1f}s "
f"- no step-1 POST")
await _dn_eval(page, DN_OVERLAY_JS)
if shape != "step2":
if not gate["ready"]:
# Same escape hatch the site ships for its own broken-bundle case.
gate = await _dn_eval(page, DN_GATE_JS, True,
default={"present": False, "ready": False})
if not gate["ready"]:
_d("neither step-1 nor step-2 controls appeared - if a "
"Cloudflare challenge is still on screen, answer it")
return None, None
_d(f"gate armed in {time.monotonic() - t_gate:.1f}s")
await _dn_eval(page, DN_OVERLAY_JS)
if not await _dn_eval(page, DN_LATCH_JS, default=False):
return None, None
if not await _dn_eval(page, DN_SUBMIT_JS, default=False):
_d("step-1 form missing")
return None, None
_d("step 1 submitted (single POST, method_free present)")
try:
await page.wait_for_load_state("domcontentloaded", timeout=25000)
except (PlaywrightError, PlaywrightTimeoutError):
pass # Ignore page navigation/load timeout; proceed with DOM evaluation.
if await _dn_eval(page, DN_DEAD_JS, default=False):
return None, None
# ── step 2: Turnstile + countdown, then the trigger chain ─────────────
for hard_fail_retry in range(2): # one reload if Turnstile hard-fails
# Not a boolean. datanodes serves the trigger as a *chain*: the first
# button ("Free Download / Standard Speed") only reveals the second
# ("Start Download / Your file is ready"), and only the second starts
# the transfer. A one-shot latch clicked the first, then watched the
# second sit there for the whole 420s budget -- the page was never
# unreadable, we simply refused to press it. Remember which labels
# were used instead, so each new one gets a click and a repeat does not.
clicked_labels : set[str] = set()
solved_captcha = False
last_sweep = 0.0
hard_failed = False
AD_SWEEP_EVERY = 1.0 # was 3.0s — ad popups on a shared window cost
# real CPU/network while alive; close them sooner.
deadline = time.monotonic() + DN_STEP2_TIMEOUT
while time.monotonic() < deadline and not captured.is_set():
now = time.monotonic()
if now - last_sweep > AD_SWEEP_EVERY:
await _dn_eval(page, DN_OVERLAY_JS)
while my_popups:
ad = my_popups.pop()
try:
await ad.close()
except PlaywrightError:
pass # Ignore error if ad popup page is already closed or destroyed.
last_sweep = now
st = await _dn_eval(page, DN_STEP2_JS)
if st is None:
await asyncio.sleep(0.4)
continue
if st["adblock"]:
_d("site reports adblock — unblock the ad hosts")
return None, None
if st["hardFail"]:
_d("Turnstile returned 'Verification failed' — not a click "
"problem, the widget itself gave up")
hard_failed = True
break
if st["captcha"] and not st["solved"]:
if solved_captcha:
# Widget reset or the token expired before the countdown ended.
solved_captcha = False
if not await dn_solve_turnstile(page, headless):
_d("TURNSTILE UNSOLVED — use real Chrome (MOON_CHROME_PATH) so "
"Cloudflare stops scoring the browser itself, then tick the box")
return None, None
solved_captcha = True
continue
if st["trigger"] and st["trigger"] not in clicked_labels:
if len(clicked_labels) >= DN_STEP2_MAX_CLICKS:
# A page cycling labels would otherwise be clicked until the
# deadline. Stop and let the caller retry from a clean page.
_d(f"step-2 trigger chain exceeded {DN_STEP2_MAX_CLICKS} "
f"distinct labels, last: {st['trigger']!r} — giving up")
break
await _dn_eval(page, DN_OVERLAY_JS)
if await _dn_eval(page, DN_CLICK_JS, st["trigger"], default=False):
_d("clicked step-2 trigger:", st["trigger"])
clicked_labels.add(st["trigger"])
await asyncio.sleep(0.4)
if not hard_failed:
break
if hard_fail_retry == 0:
# One reload: a fresh Turnstile widget instance sometimes clears a
# transient hard-fail without needing a whole new context/cookie
# jar. Second failure gives up and lets the caller's own
# retry-with-backoff take over instead.
_d("reloading the page once after a Turnstile hard-fail")
try:
await page.reload(wait_until="domcontentloaded", timeout=20000)
await asyncio.sleep(1.0)
except (PlaywrightError, PlaywrightTimeoutError):
# Return None on failure to reload page after Turnstile hard-fail.
return None, None
else:
return None, None
if not captured.is_set():
try:
await asyncio.wait_for(captured.wait(), 12.0)
except asyncio.TimeoutError:
pass
if holder:
file_url = holder[0]
cookies_str = "; ".join(f"{c['name']}={c['value']}"
for c in await context.cookies())
except Exception as e:
# Catch and log unexpected extraction errors during datanodes flow.
_d("datanodes:", type(e).__name__, str(e)[:120])
finally:
for ad in my_popups:
try:
await ad.close()