NullGaze 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nullgaze/__init__.py +36 -0
- nullgaze/downloader.py +392 -0
- nullgaze/exceptions.py +11 -0
- nullgaze/utils.py +52 -0
- nullgaze-1.0.0.dist-info/METADATA +215 -0
- nullgaze-1.0.0.dist-info/RECORD +9 -0
- nullgaze-1.0.0.dist-info/WHEEL +5 -0
- nullgaze-1.0.0.dist-info/licenses/LICENSE +21 -0
- nullgaze-1.0.0.dist-info/top_level.txt +1 -0
nullgaze/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from .downloader import ImageDownloader
|
|
2
|
+
from .exceptions import NullGazeError, DownloadFailedError, InvalidURLError
|
|
3
|
+
|
|
4
|
+
__version__ = "1.0.0"
|
|
5
|
+
__all__ = [
|
|
6
|
+
"ImageDownloader",
|
|
7
|
+
"download_image",
|
|
8
|
+
"NullGazeError",
|
|
9
|
+
"DownloadFailedError",
|
|
10
|
+
"InvalidURLError",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
def download_image(
|
|
14
|
+
url: str,
|
|
15
|
+
output_path: str,
|
|
16
|
+
verbose: bool = False,
|
|
17
|
+
headers: dict = None
|
|
18
|
+
) -> str:
|
|
19
|
+
"""
|
|
20
|
+
Convenience wrapper to download an image using the NullGaze engine.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
url (str): The direct URL or link of the image to retrieve.
|
|
24
|
+
output_path (str): The local path where the image should be saved.
|
|
25
|
+
verbose (bool): If True, prints verbose debugging output.
|
|
26
|
+
headers (dict, optional): Custom headers to merge into the request.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
str: The path to the downloaded image.
|
|
30
|
+
|
|
31
|
+
Raises:
|
|
32
|
+
DownloadFailedError: If all bypass strategies fail.
|
|
33
|
+
InvalidURLError: If the provided URL is malformed.
|
|
34
|
+
"""
|
|
35
|
+
downloader = ImageDownloader(verbose=verbose)
|
|
36
|
+
return downloader.download(url, output_path, headers=headers)
|
nullgaze/downloader.py
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import ssl
|
|
3
|
+
import select
|
|
4
|
+
import socket
|
|
5
|
+
import base64
|
|
6
|
+
import logging
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import urllib.request
|
|
11
|
+
from typing import Optional, Dict
|
|
12
|
+
|
|
13
|
+
from .utils import get_default_headers
|
|
14
|
+
from .exceptions import DownloadFailedError, InvalidURLError
|
|
15
|
+
|
|
16
|
+
# Configure logging
|
|
17
|
+
logger = logging.getLogger("NullGaze")
|
|
18
|
+
|
|
19
|
+
# Try to import requests for standard download
|
|
20
|
+
try:
|
|
21
|
+
import requests
|
|
22
|
+
HAS_REQUESTS = True
|
|
23
|
+
except ImportError:
|
|
24
|
+
HAS_REQUESTS = False
|
|
25
|
+
|
|
26
|
+
# Try to import curl_cffi for advanced TLS impersonation
|
|
27
|
+
try:
|
|
28
|
+
from curl_cffi import requests as curl_requests
|
|
29
|
+
HAS_CURL_CFFI = True
|
|
30
|
+
except ImportError:
|
|
31
|
+
HAS_CURL_CFFI = False
|
|
32
|
+
logger.warning("curl_cffi is not installed. TLS impersonation strategy will be limited.")
|
|
33
|
+
|
|
34
|
+
# Try to import DrissionPage for advanced Chromium stealth bypassing
|
|
35
|
+
try:
|
|
36
|
+
from DrissionPage import ChromiumPage, ChromiumOptions
|
|
37
|
+
HAS_DRISSION = True
|
|
38
|
+
except ImportError:
|
|
39
|
+
HAS_DRISSION = False
|
|
40
|
+
logger.warning("DrissionPage is not installed. Browser rendering strategy will be skipped.")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class LocalSplitProxyServer:
|
|
44
|
+
"""A lightweight HTTP CONNECT proxy server that splits the TLS ClientHello to bypass DPI SNI blocks."""
|
|
45
|
+
|
|
46
|
+
def __init__(self):
|
|
47
|
+
self.server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
48
|
+
self.server_sock.bind(("127.0.0.1", 0)) # Bind to a random free port
|
|
49
|
+
self.port = self.server_sock.getsockname()[1]
|
|
50
|
+
self.running = False
|
|
51
|
+
self.thread = None
|
|
52
|
+
|
|
53
|
+
def start(self):
|
|
54
|
+
self.running = True
|
|
55
|
+
self.server_sock.listen(5)
|
|
56
|
+
self.thread = threading.Thread(target=self._listen_loop)
|
|
57
|
+
self.thread.daemon = True
|
|
58
|
+
self.thread.start()
|
|
59
|
+
logger.info(f"Local split proxy started on port {self.port}")
|
|
60
|
+
|
|
61
|
+
def stop(self):
|
|
62
|
+
self.running = False
|
|
63
|
+
try:
|
|
64
|
+
self.server_sock.close()
|
|
65
|
+
except Exception:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
def _listen_loop(self):
|
|
69
|
+
while self.running:
|
|
70
|
+
try:
|
|
71
|
+
conn, addr = self.server_sock.accept()
|
|
72
|
+
t = threading.Thread(target=self._handle_client, args=(conn,))
|
|
73
|
+
t.daemon = True
|
|
74
|
+
t.start()
|
|
75
|
+
except Exception:
|
|
76
|
+
break
|
|
77
|
+
|
|
78
|
+
def _handle_client(self, local_conn):
|
|
79
|
+
# 1. Read HTTP proxy CONNECT request
|
|
80
|
+
req_data = b""
|
|
81
|
+
try:
|
|
82
|
+
while b"\r\n\r\n" not in req_data:
|
|
83
|
+
chunk = local_conn.recv(4096)
|
|
84
|
+
if not chunk:
|
|
85
|
+
break
|
|
86
|
+
req_data += chunk
|
|
87
|
+
except Exception as e:
|
|
88
|
+
logger.info(f"Failed to read CONNECT header: {e}")
|
|
89
|
+
local_conn.close()
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
if not req_data.startswith(b"CONNECT"):
|
|
93
|
+
local_conn.close()
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
# Parse target host and port
|
|
97
|
+
try:
|
|
98
|
+
first_line = req_data.split(b"\r\n")[0].decode('utf-8')
|
|
99
|
+
_, target, _ = first_line.split(" ")
|
|
100
|
+
host, port = target.split(":")
|
|
101
|
+
port = int(port)
|
|
102
|
+
except Exception as e:
|
|
103
|
+
logger.info(f"Failed to parse CONNECT targets: {e}")
|
|
104
|
+
local_conn.close()
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
# 2. Establish connection to remote server
|
|
108
|
+
try:
|
|
109
|
+
remote_sock = socket.create_connection((host, port), timeout=6)
|
|
110
|
+
except Exception as e:
|
|
111
|
+
logger.info(f"Proxy failed to connect to remote {host}:{port}: {e}")
|
|
112
|
+
local_conn.close()
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
# 3. Confirm connection established to local client
|
|
116
|
+
try:
|
|
117
|
+
local_conn.sendall(b"HTTP/1.1 200 Connection Established\r\n\r\n")
|
|
118
|
+
except Exception as e:
|
|
119
|
+
logger.info(f"Proxy failed to write established confirmation: {e}")
|
|
120
|
+
local_conn.close()
|
|
121
|
+
remote_sock.close()
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
# 4. Bidirectional socket forwarding with TLS ClientHello splitting
|
|
125
|
+
self._forward_sockets(local_conn, remote_sock)
|
|
126
|
+
|
|
127
|
+
def _forward_sockets(self, local_conn, remote_sock):
|
|
128
|
+
# Keep sockets in blocking mode for reliable writes/sendall
|
|
129
|
+
first_client_packet = True
|
|
130
|
+
socks = [local_conn, remote_sock]
|
|
131
|
+
closed = False
|
|
132
|
+
|
|
133
|
+
while self.running and not closed:
|
|
134
|
+
try:
|
|
135
|
+
# Use select to wait until data is readable on either socket (with a 1.0 second timeout)
|
|
136
|
+
r, _, x = select.select(socks, [], socks, 1.0)
|
|
137
|
+
if x:
|
|
138
|
+
break
|
|
139
|
+
for s in r:
|
|
140
|
+
if s is local_conn:
|
|
141
|
+
data = local_conn.recv(16384)
|
|
142
|
+
if not data:
|
|
143
|
+
closed = True
|
|
144
|
+
break
|
|
145
|
+
|
|
146
|
+
# Check if first packet is TLS ClientHello (starts with 0x16 0x03)
|
|
147
|
+
if first_client_packet and data.startswith(b"\x16\x03"):
|
|
148
|
+
first_client_packet = False
|
|
149
|
+
logger.info(f"DPI Bypass: Splitting ClientHello ({len(data)} bytes)...")
|
|
150
|
+
# Split into two parts (first 100 bytes and the rest)
|
|
151
|
+
split_idx = min(100, len(data) - 1)
|
|
152
|
+
part1 = data[:split_idx]
|
|
153
|
+
part2 = data[split_idx:]
|
|
154
|
+
|
|
155
|
+
remote_sock.sendall(part1)
|
|
156
|
+
time.sleep(0.01) # Delay of 10ms to force separate TCP segments
|
|
157
|
+
remote_sock.sendall(part2)
|
|
158
|
+
else:
|
|
159
|
+
remote_sock.sendall(data)
|
|
160
|
+
|
|
161
|
+
elif s is remote_sock:
|
|
162
|
+
data = remote_sock.recv(16384)
|
|
163
|
+
if not data:
|
|
164
|
+
closed = True
|
|
165
|
+
break
|
|
166
|
+
local_conn.sendall(data)
|
|
167
|
+
except Exception as e:
|
|
168
|
+
logger.info(f"Socket forwarding error: {e}")
|
|
169
|
+
closed = True
|
|
170
|
+
break
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
local_conn.close()
|
|
174
|
+
except Exception:
|
|
175
|
+
pass
|
|
176
|
+
try:
|
|
177
|
+
remote_sock.close()
|
|
178
|
+
except Exception:
|
|
179
|
+
pass
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class ImageDownloader:
|
|
183
|
+
"""A highly resilient image downloading engine with multi-tier fallback strategies."""
|
|
184
|
+
|
|
185
|
+
def __init__(self, verbose: bool = False):
|
|
186
|
+
self.verbose = verbose
|
|
187
|
+
if verbose:
|
|
188
|
+
logging.basicConfig(level=logging.INFO)
|
|
189
|
+
logger.setLevel(logging.INFO)
|
|
190
|
+
else:
|
|
191
|
+
logger.setLevel(logging.WARNING)
|
|
192
|
+
|
|
193
|
+
def download(self, url: str, output_path: str, headers: Optional[Dict[str, str]] = None) -> str:
|
|
194
|
+
"""
|
|
195
|
+
Downloads an image from the specified URL and saves it to output_path.
|
|
196
|
+
Tries multiple bypass strategies sequentially until one succeeds.
|
|
197
|
+
"""
|
|
198
|
+
if not url.startswith("http://") and not url.startswith("https://"):
|
|
199
|
+
raise InvalidURLError("The URL must start with http:// or https://")
|
|
200
|
+
|
|
201
|
+
# Resolve output directory
|
|
202
|
+
out_dir = os.path.dirname(os.path.abspath(output_path))
|
|
203
|
+
if out_dir:
|
|
204
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
205
|
+
|
|
206
|
+
# Merge or generate headers
|
|
207
|
+
req_headers = get_default_headers(url)
|
|
208
|
+
if headers:
|
|
209
|
+
req_headers.update(headers)
|
|
210
|
+
|
|
211
|
+
strategies = [
|
|
212
|
+
("Direct HTTP Request", lambda: self._download_direct(url, output_path, req_headers)),
|
|
213
|
+
("DPI-Bypass TLS Impersonation (Split Proxy)", lambda: self._download_dpi_bypass(url, output_path, req_headers)),
|
|
214
|
+
("Stealth Browser Rendering (Chromium)", lambda: self._download_browser(url, output_path)),
|
|
215
|
+
("Weserv Image Proxy Fallback", lambda: self._download_via_proxy(url, output_path)),
|
|
216
|
+
]
|
|
217
|
+
|
|
218
|
+
errors = []
|
|
219
|
+
for name, strategy_fn in strategies:
|
|
220
|
+
logger.info(f"Trying strategy: {name}")
|
|
221
|
+
try:
|
|
222
|
+
if strategy_fn():
|
|
223
|
+
logger.info(f"Successfully downloaded image using strategy: {name}")
|
|
224
|
+
return output_path
|
|
225
|
+
except Exception as e:
|
|
226
|
+
err_msg = f"Strategy '{name}' failed: {e}"
|
|
227
|
+
logger.info(err_msg)
|
|
228
|
+
errors.append(err_msg)
|
|
229
|
+
|
|
230
|
+
# If all strategies failed, raise an exception
|
|
231
|
+
detailed_error = "\n".join(errors)
|
|
232
|
+
raise DownloadFailedError(
|
|
233
|
+
f"Failed to download image from {url} after trying all strategies.\nDetails:\n{detailed_error}"
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
def _download_direct(self, url: str, output_path: str, headers: dict) -> bool:
|
|
237
|
+
"""Strategy 1: Direct HTTP GET using requests (or urllib if requests is missing)."""
|
|
238
|
+
if HAS_REQUESTS:
|
|
239
|
+
response = requests.get(url, headers=headers, timeout=1.5, stream=True)
|
|
240
|
+
response.raise_for_status()
|
|
241
|
+
with open(output_path, "wb") as f:
|
|
242
|
+
for chunk in response.iter_content(chunk_size=8192):
|
|
243
|
+
if chunk:
|
|
244
|
+
f.write(chunk)
|
|
245
|
+
return True
|
|
246
|
+
else:
|
|
247
|
+
req = urllib.request.Request(url, headers=headers)
|
|
248
|
+
with urllib.request.urlopen(req, timeout=1.5) as response:
|
|
249
|
+
with open(output_path, "wb") as f:
|
|
250
|
+
f.write(response.read())
|
|
251
|
+
return True
|
|
252
|
+
|
|
253
|
+
def _download_dpi_bypass(self, url: str, output_path: str, headers: dict) -> bool:
|
|
254
|
+
"""
|
|
255
|
+
Strategy 2: Insane Speed DPI-Bypass TLS Impersonation.
|
|
256
|
+
Launches a local split proxy in a background thread, routes curl_cffi through it,
|
|
257
|
+
which bypasses SNI blocks in fractions of a second, downloading the original file.
|
|
258
|
+
"""
|
|
259
|
+
if not HAS_CURL_CFFI:
|
|
260
|
+
raise NotImplementedError("curl_cffi is not installed.")
|
|
261
|
+
|
|
262
|
+
# Start the local split proxy
|
|
263
|
+
proxy = LocalSplitProxyServer()
|
|
264
|
+
proxy.start()
|
|
265
|
+
|
|
266
|
+
time.sleep(0.05) # Let proxy bind and start listening
|
|
267
|
+
|
|
268
|
+
proxies = {
|
|
269
|
+
"http": f"http://127.0.0.1:{proxy.port}",
|
|
270
|
+
"https": f"http://127.0.0.1:{proxy.port}"
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
try:
|
|
274
|
+
logger.info("Routing curl_cffi Chrome impersonation through local split proxy...")
|
|
275
|
+
response = curl_requests.get(
|
|
276
|
+
url,
|
|
277
|
+
headers=headers,
|
|
278
|
+
impersonate="chrome",
|
|
279
|
+
proxies=proxies,
|
|
280
|
+
timeout=12
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
if response.status_code >= 400:
|
|
284
|
+
raise Exception(f"HTTP status code {response.status_code}")
|
|
285
|
+
|
|
286
|
+
with open(output_path, "wb") as f:
|
|
287
|
+
f.write(response.content)
|
|
288
|
+
return True
|
|
289
|
+
finally:
|
|
290
|
+
proxy.stop()
|
|
291
|
+
|
|
292
|
+
def _download_browser(self, url: str, output_path: str) -> bool:
|
|
293
|
+
"""
|
|
294
|
+
Strategy 3: Stealth Browser Rendering using DrissionPage (Chromium).
|
|
295
|
+
Bypasses extreme active JS challenges (Turnstile) by rendering the page.
|
|
296
|
+
Extracts image data directly from memory/canvas or element screenshotting.
|
|
297
|
+
"""
|
|
298
|
+
if not HAS_DRISSION:
|
|
299
|
+
raise NotImplementedError("DrissionPage is not installed.")
|
|
300
|
+
|
|
301
|
+
logger.info("Initializing headless Chromium browser...")
|
|
302
|
+
co = ChromiumOptions()
|
|
303
|
+
co.headless(True)
|
|
304
|
+
co.set_argument('--no-sandbox')
|
|
305
|
+
co.set_argument('--disable-gpu')
|
|
306
|
+
|
|
307
|
+
page = ChromiumPage(co)
|
|
308
|
+
try:
|
|
309
|
+
logger.info(f"Navigating to URL: {url}")
|
|
310
|
+
page.get(url)
|
|
311
|
+
|
|
312
|
+
# Wait for browser to render and decode the image
|
|
313
|
+
page.wait(3)
|
|
314
|
+
|
|
315
|
+
# --- Attempt 1: Canvas Base64 Pixel Extraction ---
|
|
316
|
+
js_canvas_extract = """
|
|
317
|
+
const img = document.querySelector('img');
|
|
318
|
+
if (!img || img.naturalWidth === 0) {
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
const canvas = document.createElement('canvas');
|
|
322
|
+
canvas.width = img.naturalWidth;
|
|
323
|
+
canvas.height = img.naturalHeight;
|
|
324
|
+
const ctx = canvas.getContext('2d');
|
|
325
|
+
try {
|
|
326
|
+
ctx.drawImage(img, 0, 0);
|
|
327
|
+
return canvas.toDataURL('image/jpeg', 0.95);
|
|
328
|
+
} catch (err) {
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
"""
|
|
332
|
+
|
|
333
|
+
logger.info("Attempting Canvas pixel extraction...")
|
|
334
|
+
data_url = page.run_js(js_canvas_extract)
|
|
335
|
+
|
|
336
|
+
if data_url and data_url.startswith("data:image/jpeg;base64,"):
|
|
337
|
+
header, base64_data = data_url.split(",", 1)
|
|
338
|
+
image_data = base64.b64decode(base64_data)
|
|
339
|
+
with open(output_path, "wb") as f:
|
|
340
|
+
f.write(image_data)
|
|
341
|
+
logger.info("Canvas extraction successful!")
|
|
342
|
+
return True
|
|
343
|
+
|
|
344
|
+
# --- Attempt 2: Direct Element Screenshot ---
|
|
345
|
+
logger.info("Canvas extraction failed. Attempting element screenshot...")
|
|
346
|
+
img_element = page.ele('tag:img')
|
|
347
|
+
if img_element:
|
|
348
|
+
img_element.get_screenshot(path=output_path)
|
|
349
|
+
if os.path.exists(output_path) and os.path.getsize(output_path) > 0:
|
|
350
|
+
logger.info("Element screenshot successful!")
|
|
351
|
+
return True
|
|
352
|
+
|
|
353
|
+
# --- Attempt 3: Browser Session Download API ---
|
|
354
|
+
logger.info("Screenshot failed. Attempting browser download API...")
|
|
355
|
+
if img_element:
|
|
356
|
+
src = img_element.attr('src')
|
|
357
|
+
if src:
|
|
358
|
+
out_dir = os.path.dirname(os.path.abspath(output_path))
|
|
359
|
+
filename = os.path.basename(output_path)
|
|
360
|
+
res = page.download(src, out_dir, rename=filename)
|
|
361
|
+
if res and os.path.exists(output_path) and os.path.getsize(output_path) > 0:
|
|
362
|
+
logger.info("Browser session download API successful!")
|
|
363
|
+
return True
|
|
364
|
+
|
|
365
|
+
raise Exception("Failed to extract image via Canvas, Screenshot, or Download API.")
|
|
366
|
+
finally:
|
|
367
|
+
page.quit()
|
|
368
|
+
|
|
369
|
+
def _download_via_proxy(self, url: str, output_path: str) -> bool:
|
|
370
|
+
"""Strategy 4: Download via high-availability free caching proxy (Weserv)."""
|
|
371
|
+
encoded_url = urllib.parse.quote(url)
|
|
372
|
+
proxy_url = f"https://images.weserv.nl/?url={encoded_url}"
|
|
373
|
+
|
|
374
|
+
headers = {
|
|
375
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
|
376
|
+
"Accept": "image/*,*/*"
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if HAS_REQUESTS:
|
|
380
|
+
response = requests.get(proxy_url, headers=headers, timeout=10, stream=True)
|
|
381
|
+
response.raise_for_status()
|
|
382
|
+
with open(output_path, "wb") as f:
|
|
383
|
+
for chunk in response.iter_content(chunk_size=8192):
|
|
384
|
+
if chunk:
|
|
385
|
+
f.write(chunk)
|
|
386
|
+
return True
|
|
387
|
+
else:
|
|
388
|
+
req = urllib.request.Request(proxy_url, headers=headers)
|
|
389
|
+
with urllib.request.urlopen(req, timeout=10) as response:
|
|
390
|
+
with open(output_path, "wb") as f:
|
|
391
|
+
f.write(response.read())
|
|
392
|
+
return True
|
nullgaze/exceptions.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
class NullGazeError(Exception):
|
|
2
|
+
"""Base exception for all NullGaze library errors."""
|
|
3
|
+
pass
|
|
4
|
+
|
|
5
|
+
class DownloadFailedError(NullGazeError):
|
|
6
|
+
"""Raised when all download strategies fail to retrieve the image."""
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
class InvalidURLError(NullGazeError):
|
|
10
|
+
"""Raised when the provided image URL is invalid or malformed."""
|
|
11
|
+
pass
|
nullgaze/utils.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import random
|
|
2
|
+
from urllib.parse import urlparse
|
|
3
|
+
|
|
4
|
+
USER_AGENTS = [
|
|
5
|
+
# Chrome on Windows
|
|
6
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
7
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
|
|
8
|
+
# Chrome on macOS
|
|
9
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
10
|
+
# Firefox on Windows
|
|
11
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0",
|
|
12
|
+
# Safari on macOS
|
|
13
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15",
|
|
14
|
+
# Edge on Windows
|
|
15
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0"
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
def get_random_user_agent() -> str:
|
|
19
|
+
"""Returns a random modern User-Agent string."""
|
|
20
|
+
return random.choice(USER_AGENTS)
|
|
21
|
+
|
|
22
|
+
def get_base_domain(url: str) -> str:
|
|
23
|
+
"""Extracts the base domain (scheme + netloc) from a URL."""
|
|
24
|
+
parsed = urlparse(url)
|
|
25
|
+
if not parsed.netloc:
|
|
26
|
+
return ""
|
|
27
|
+
# E.g. cdn4.webtoonscan.com -> webtoonscan.com or keep as scheme + netloc
|
|
28
|
+
return f"{parsed.scheme}://{parsed.netloc}"
|
|
29
|
+
|
|
30
|
+
def get_default_headers(url: str) -> dict:
|
|
31
|
+
"""Generates standard browser-like headers for a given URL, matching the Referer."""
|
|
32
|
+
base_domain = get_base_domain(url)
|
|
33
|
+
parsed = urlparse(url)
|
|
34
|
+
|
|
35
|
+
# We can try to guess the referer domain: e.g. webtoonscan.com instead of cdn4.webtoonscan.com
|
|
36
|
+
# if it starts with cdn/static/assets etc.
|
|
37
|
+
referer = base_domain
|
|
38
|
+
netloc_parts = parsed.netloc.split('.')
|
|
39
|
+
if len(netloc_parts) > 2:
|
|
40
|
+
# If it's a subdomain like cdn4.webtoonscan.com, we can also use the parent webtoonscan.com as a fallback candidate
|
|
41
|
+
parent_domain = '.'.join(netloc_parts[-2:])
|
|
42
|
+
# For Referer, we can use the main site domain, which is often what CDNs expect for hotlink bypass
|
|
43
|
+
referer = f"{parsed.scheme}://{parent_domain}/"
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
"User-Agent": get_random_user_agent(),
|
|
47
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
|
|
48
|
+
"Accept-Language": "en-US,en;q=0.9,ar;q=0.8",
|
|
49
|
+
"Referer": referer,
|
|
50
|
+
"Connection": "keep-alive",
|
|
51
|
+
"Upgrade-Insecure-Requests": "1"
|
|
52
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: NullGaze
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A highly resilient image downloading library that bypasses DPI/SNI blocks, Cloudflare, TLS fingerprinting, and hotlinking protections.
|
|
5
|
+
Home-page: https://github.com/batal/NullGaze
|
|
6
|
+
Author: Batal
|
|
7
|
+
Author-email: batal@example.com
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
12
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
13
|
+
Requires-Python: >=3.8
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: requests>=2.25.0
|
|
17
|
+
Requires-Dist: curl_cffi>=0.5.0
|
|
18
|
+
Provides-Extra: browser
|
|
19
|
+
Requires-Dist: DrissionPage>=4.0.0; extra == "browser"
|
|
20
|
+
Provides-Extra: full
|
|
21
|
+
Requires-Dist: curl_cffi>=0.5.0; extra == "full"
|
|
22
|
+
Requires-Dist: DrissionPage>=4.0.0; extra == "full"
|
|
23
|
+
Dynamic: author
|
|
24
|
+
Dynamic: author-email
|
|
25
|
+
Dynamic: classifier
|
|
26
|
+
Dynamic: description
|
|
27
|
+
Dynamic: description-content-type
|
|
28
|
+
Dynamic: home-page
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
Dynamic: provides-extra
|
|
31
|
+
Dynamic: requires-dist
|
|
32
|
+
Dynamic: requires-python
|
|
33
|
+
Dynamic: summary
|
|
34
|
+
|
|
35
|
+
# NullGaze 👁️🗨️
|
|
36
|
+
|
|
37
|
+
**مكتبة بايثون ثورية لتحميل الصور من أي رابط مهما كانت الحمايات.**
|
|
38
|
+
|
|
39
|
+
تتخطى حجب مزودي الإنترنت (ISP SNI Blocks)، وحمايات Cloudflare، ومنع الربط المباشر (Hotlinking)، وبصمات TLS — بسرعة مجنونة تصل إلى أجزاء من الثانية.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## ⚡ التثبيت السريع
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install NullGaze
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
أو للتثبيت الكامل مع دعم المتصفح الخفي:
|
|
50
|
+
```bash
|
|
51
|
+
pip install NullGaze[full]
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
أو التثبيت المحلي من المصدر:
|
|
55
|
+
```bash
|
|
56
|
+
cd NullGaze
|
|
57
|
+
pip install -e .
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## 🚀 طريقة الاستخدام
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from nullgaze import download_image
|
|
66
|
+
|
|
67
|
+
# سطر واحد فقط لتحميل أي صورة محمية
|
|
68
|
+
download_image(
|
|
69
|
+
url="https://cdn4.webtoonscan.com/erotic-manga-cafe-girls-raw-15264/chapter-20/17.jpg",
|
|
70
|
+
output_path="my_image.jpg",
|
|
71
|
+
verbose=True
|
|
72
|
+
)
|
|
73
|
+
print("تم التحميل بنجاح!")
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### استخدام متقدم مع كلاس ImageDownloader
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from nullgaze import ImageDownloader
|
|
80
|
+
|
|
81
|
+
downloader = ImageDownloader(verbose=True)
|
|
82
|
+
|
|
83
|
+
# تحميل صورة واحدة
|
|
84
|
+
downloader.download(
|
|
85
|
+
url="https://example.com/protected/image.jpg",
|
|
86
|
+
output_path="output/image.jpg"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# تحميل عدة صور
|
|
90
|
+
urls = [
|
|
91
|
+
"https://cdn.example.com/img1.jpg",
|
|
92
|
+
"https://cdn.example.com/img2.jpg",
|
|
93
|
+
"https://cdn.example.com/img3.jpg",
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
for i, url in enumerate(urls):
|
|
97
|
+
try:
|
|
98
|
+
downloader.download(url, f"output/image_{i+1}.jpg")
|
|
99
|
+
print(f"✅ Image {i+1} downloaded!")
|
|
100
|
+
except Exception as e:
|
|
101
|
+
print(f"❌ Image {i+1} failed: {e}")
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### إضافة ترويسات مخصصة
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from nullgaze import download_image
|
|
108
|
+
|
|
109
|
+
custom_headers = {
|
|
110
|
+
"Referer": "https://mywebsite.com/",
|
|
111
|
+
"Cookie": "session_id=abc123"
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
download_image(
|
|
115
|
+
url="https://protected-cdn.example.com/image.png",
|
|
116
|
+
output_path="result.png",
|
|
117
|
+
headers=custom_headers
|
|
118
|
+
)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## 🧠 كيف تعمل المكتبة (نظام التخطي المتعدد المستويات)
|
|
124
|
+
|
|
125
|
+
| المستوى | الاسم | السرعة | الوصف |
|
|
126
|
+
|---------|-------|--------|-------|
|
|
127
|
+
| 1 | Direct HTTP Request | ⚡⚡⚡ | طلب مباشر بترويسات متصفح ذكية |
|
|
128
|
+
| 2 | **DPI-Bypass TLS Impersonation** | ⚡⚡⚡ | **الطريقة الثورية**: تقسيم حزمة ClientHello + انتحال بصمة Chrome |
|
|
129
|
+
| 3 | Stealth Browser Rendering | ⚡ | متصفح Chromium خفي مع استخراج بكسلات Canvas |
|
|
130
|
+
| 4 | Weserv Image Proxy | ⚡⚡ | توجيه عبر بروكسي تخزين مؤقت |
|
|
131
|
+
|
|
132
|
+
> **ملاحظة**: المستوى الثاني هو الابتكار الثوري الأساسي. يقوم بتشغيل بروكسي محلي خفيف على جهازك يقسم حزمة مصافحة TLS إلى جزأين، مما يخدع جدار حماية مزود الإنترنت ويمنعه من قراءة اسم النطاق المحجوب (SNI). اقرأ [التوثيق التقني الكامل](nullgaze/DPI_BYPASS_DOC.md) لفهم الآلية.
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## 📦 دليل نشر المكتبة على PyPI (للمبتدئين خطوة بخطوة)
|
|
137
|
+
|
|
138
|
+
### الخطوة 1: إنشاء حساب على PyPI
|
|
139
|
+
1. افتح الموقع: https://pypi.org/account/register/
|
|
140
|
+
2. سجّل بإيميلك وأنشئ كلمة مرور.
|
|
141
|
+
3. فعّل الحساب من الإيميل.
|
|
142
|
+
4. ادخل على **Account Settings** → **API tokens** → **Add API token**.
|
|
143
|
+
5. اختر الاسم مثلاً `nullgaze-token` والنطاق `Entire account`.
|
|
144
|
+
6. **انسخ الرمز** الذي يبدأ بـ `pypi-` واحفظه في مكان آمن. هذا هو كلمة مرورك للرفع.
|
|
145
|
+
|
|
146
|
+
### الخطوة 2: تثبيت أدوات البناء والرفع
|
|
147
|
+
```bash
|
|
148
|
+
pip install --upgrade build twine
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### الخطوة 3: بناء الحزمة
|
|
152
|
+
تأكد أنك داخل مجلد `NullGaze` (الذي فيه `setup.py`):
|
|
153
|
+
```bash
|
|
154
|
+
cd C:\Users\batal\Desktop\cwewd21\NullGaze
|
|
155
|
+
python -m build
|
|
156
|
+
```
|
|
157
|
+
سيظهر مجلد جديد اسمه `dist/` فيه ملفات `.whl` و `.tar.gz`.
|
|
158
|
+
|
|
159
|
+
### الخطوة 4: رفع المكتبة إلى PyPI
|
|
160
|
+
```bash
|
|
161
|
+
python -m twine upload dist/*
|
|
162
|
+
```
|
|
163
|
+
- **Username**: اكتب `__token__`
|
|
164
|
+
- **Password**: الصق الرمز (API Token) الذي يبدأ بـ `pypi-`
|
|
165
|
+
|
|
166
|
+
🎉 **مبروك!** مكتبتك الآن متاحة للعالم كله:
|
|
167
|
+
```bash
|
|
168
|
+
pip install NullGaze
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## 🔄 كيف تحدّث المكتبة (بعد إضافة ميزات جديدة)
|
|
174
|
+
|
|
175
|
+
### الخطوة 1: غيّر رقم الإصدار
|
|
176
|
+
افتح ملف `setup.py` وغيّر الرقم:
|
|
177
|
+
```python
|
|
178
|
+
version="1.0.1" # كان 1.0.0، الآن 1.0.1
|
|
179
|
+
```
|
|
180
|
+
وافتح ملف `nullgaze/__init__.py` وغيّر:
|
|
181
|
+
```python
|
|
182
|
+
__version__ = "1.0.1"
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### الخطوة 2: احذف الملفات القديمة
|
|
186
|
+
```bash
|
|
187
|
+
rmdir /s /q dist build NullGaze.egg-info
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### الخطوة 3: أعد البناء والرفع
|
|
191
|
+
```bash
|
|
192
|
+
python -m build
|
|
193
|
+
python -m twine upload dist/*
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
## 📥 كيف يثبّت المستخدمون مكتبتك
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
# التثبيت الأساسي (يكفي لمعظم الحالات)
|
|
202
|
+
pip install NullGaze
|
|
203
|
+
|
|
204
|
+
# التثبيت الكامل مع دعم المتصفح الخفي
|
|
205
|
+
pip install NullGaze[full]
|
|
206
|
+
|
|
207
|
+
# التحديث لآخر إصدار
|
|
208
|
+
pip install --upgrade NullGaze
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
---
|
|
212
|
+
|
|
213
|
+
## 📄 الترخيص
|
|
214
|
+
|
|
215
|
+
MIT License — مكتبة مفتوحة المصدر، استخدمها كما تشاء.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
nullgaze/__init__.py,sha256=wBf93gO3N0D2dA_EWLJJ4pKkTUs8XVidi595IezdY_w,1104
|
|
2
|
+
nullgaze/downloader.py,sha256=LtpxAmP4RZVSnlmQSjQPW3xqwJ3kIybANasueRcI5ZU,15107
|
|
3
|
+
nullgaze/exceptions.py,sha256=3DIJYVW3PUPW3rvvXpXWrQe-EscNSBz2GRyJKq8ft9E,343
|
|
4
|
+
nullgaze/utils.py,sha256=T70xaIACFbii8brvrCak62dFqTim1YTrA9McKC9Y0vg,2451
|
|
5
|
+
nullgaze-1.0.0.dist-info/licenses/LICENSE,sha256=XQgFexabq7mDn_MJ7wpv8IzxjNUP3Tlu-CFTQRj3Kx8,1062
|
|
6
|
+
nullgaze-1.0.0.dist-info/METADATA,sha256=BskDrZktQnBBKFr_u94WRx3tPuE_4ZtkGb7bmZeOA_8,6812
|
|
7
|
+
nullgaze-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
8
|
+
nullgaze-1.0.0.dist-info/top_level.txt,sha256=T7AOkdcbKHoS_Gct_jOYP5yjhYuvYHt6wj6bfeshEuo,9
|
|
9
|
+
nullgaze-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Batal
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
nullgaze
|