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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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