File size: 14,031 Bytes
01bff57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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

# =========================
# COLAB CELL 2: APP
# =========================
import re
import io
import hashlib
import tempfile
import urllib.parse
import ipaddress
import socket
import time
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path

import requests
from bs4 import BeautifulSoup
from PIL import Image as PILImage
import gradio as gr


TMP_DIR = Path(tempfile.gettempdir()) / "image_stream_scraper"
TMP_DIR.mkdir(parents=True, exist_ok=True)

HEADERS = {
    "User-Agent": (
        "Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 "
        "(KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"
    ),
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
    "Accept-Language": "en-US,en;q=0.9,vi;q=0.8",
}

AD_URL_PATTERNS = [
    "doubleclick.net", "googlesyndication", "googleadservices",
    "adservice.google", "amazon-adsystem", "taboola", "outbrain",
    "scorecardresearch", "/ads/", "adserver", "advert", "banner",
    "promo", "tracking", "pixel",
]

AD_TEXT_PATTERNS = [
    r"\bads?\b", r"advert", r"banner", r"promo", r"sponsor",
    r"tracking", r"pixel", r"logo", r"icon", r"avatar", r"favicon",
]

MIN_DIMENSION = 20
MIN_AREA = 300

stop_event = threading.Event()
session = requests.Session()
session.headers.update(HEADERS)


def is_private_host(hostname: str) -> bool:
    if not hostname:
        return True
    if hostname.lower() in {"localhost", "127.0.0.1", "::1"}:
        return True
    try:
        infos = socket.getaddrinfo(hostname, None)
        for info in infos:
            ip = info[4][0]
            ip_obj = ipaddress.ip_address(ip)
            if (ip_obj.is_private or ip_obj.is_loopback or
                ip_obj.is_link_local or ip_obj.is_multicast or ip_obj.is_reserved):
                return True
    except Exception:
        return True
    return False


def safe_join_url(base_url: str, maybe_url: str) -> str | None:
    if not maybe_url:
        return None
    maybe_url = maybe_url.strip()
    if maybe_url.startswith("data:"):
        return None
    abs_url = urllib.parse.urljoin(base_url, maybe_url)
    parsed = urllib.parse.urlparse(abs_url)
    if parsed.scheme not in {"http", "https"}:
        return None
    if not parsed.hostname or is_private_host(parsed.hostname):
        return None
    return abs_url


def url_relevant_for_ads(url: str) -> bool:
    return any(p in url.lower() for p in AD_URL_PATTERNS)


def text_relevant_for_ads(*texts) -> bool:
    joined = " ".join([t for t in texts if t]).lower()
    return any(re.search(p, joined) for p in AD_TEXT_PATTERNS)


def extract_best_from_srcset(srcset: str) -> str | None:
    if not srcset:
        return None
    best_url = None
    best_score = -1
    for part in srcset.split(","):
        part = part.strip()
        if not part:
            continue
        pieces = part.split()
        url = pieces[0].strip()
        score = 0
        if len(pieces) > 1:
            desc = pieces[1].strip().lower()
            if desc.endswith("w"):
                try: score = int(desc[:-1])
                except: pass
            elif desc.endswith("x"):
                try: score = int(float(desc[:-1]) * 1000)
                except: pass
        if score > best_score:
            best_score = score
            best_url = url
    return best_url


def fetch_html(url: str, referer: str | None = None) -> str:
    headers = {}
    if referer:
        headers["Referer"] = referer
    r = session.get(url, headers=headers, timeout=25)
    r.raise_for_status()
    return r.text


def is_ehentai_gallery(url: str) -> bool:
    parsed = urllib.parse.urlparse(url)
    host = (parsed.hostname or "").lower()
    return host in {"e-hentai.org", "exhentai.org"} and "/g/" in parsed.path


def get_ehentai_image_page_links(gallery_url: str):
    links = []
    page = 0
    while True:
        if stop_event.is_set():
            break
        page_url = gallery_url if page == 0 else f"{gallery_url}?p={page}"
        try:
            html = fetch_html(page_url)
        except Exception:
            break
        soup = BeautifulSoup(html, "lxml")
        found = False
        for a in soup.select("#gdt a, .gdtm a, .gdtl a"):
            href = a.get("href")
            if href and "/s/" in href:
                abs_link = safe_join_url(gallery_url, href)
                if abs_link and abs_link not in links:
                    links.append(abs_link)
                    found = True
        if not found:
            break
        page += 1
        time.sleep(0.25)
        if page > 60:
            break
    return links


def extract_full_image_from_eh_page(page_url: str) -> str | None:
    try:
        html = fetch_html(page_url, referer=page_url)
        soup = BeautifulSoup(html, "lxml")
        img = soup.select_one("#img") or soup.select_one("#i3 img")
        if img and img.get("src"):
            return img["src"]
    except Exception:
        pass
    return None


def download_image(url: str, referer: str | None = None) -> str | None:
    try:
        headers = {"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"}
        if referer:
            headers["Referer"] = referer
        r = session.get(url, headers=headers, timeout=25, stream=True)
        r.raise_for_status()

        content_type = r.headers.get("content-type", "").lower()
        if "image" not in content_type and not url.lower().split("?")[0].endswith(
            (".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".avif")
        ):
            return None

        raw = r.content
        if len(raw) < 200:
            return None

        try:
            img = PILImage.open(io.BytesIO(raw))
            img.load()
        except Exception:
            return None

        h = hashlib.sha1(url.encode()).hexdigest()[:16]
        ext = {
            "jpeg": ".jpg", "jpg": ".jpg", "png": ".png",
            "webp": ".webp", "gif": ".gif", "bmp": ".bmp", "avif": ".avif"
        }.get((img.format or "").lower(), ".jpg")

        path = TMP_DIR / f"{h}{ext}"
        with open(path, "wb") as f:
            f.write(raw)
        return str(path)
    except Exception:
        return None


def process_one_eh_image(page_link: str):
    """Hàm dùng cho ThreadPool"""
    if stop_event.is_set():
        return None
    img_url = extract_full_image_from_eh_page(page_link)
    if not img_url:
        return None
    img_url = img_url.split("#")[0]
    return download_image(img_url, referer=page_link)


def stream_images(page_url: str):
    stop_event.clear()
    page_url = (page_url or "").strip()
    if not page_url:
        yield [], "Vui lòng nhập URL."
        return

    if not page_url.startswith(("http://", "https://")):
        page_url = "https://" + page_url

    parsed = urllib.parse.urlparse(page_url)
    if not parsed.hostname or is_private_host(parsed.hostname):
        yield [], "URL không hợp lệ."
        return

    gallery = []
    seen = set()

    yield gallery, "Đang tải trang..."

    # ========== E-HENTAI ==========
    if is_ehentai_gallery(page_url):
        yield gallery, "Đang lấy danh sách trang ảnh..."
        try:
            image_pages = get_ehentai_image_page_links(page_url)
        except Exception as e:
            yield gallery, f"Lỗi: {e}"
            return

        if stop_event.is_set():
            yield gallery, "Đã dừng."
            return

        total = len(image_pages)
        if total == 0:
            yield gallery, "Không tìm thấy trang ảnh."
            return

        yield gallery, f"Tìm thấy {total} ảnh → bắt đầu tải nhanh hơn..."

        with ThreadPoolExecutor(max_workers=2) as executor:
            futures = {executor.submit(process_one_eh_image, link): link for link in image_pages}

            done = 0
            for future in as_completed(futures):
                if stop_event.is_set():
                    for f in futures:
                        f.cancel()
                    yield gallery, f"Đã dừng. Đã tải {len(gallery)} ảnh."
                    return

                result = future.result()
                done += 1
                if result and result not in seen:
                    seen.add(result)
                    gallery.append(result)
                    yield gallery, f"Đã tải {len(gallery)}/{total} ảnh..."

                time.sleep(0.15# nhẹ để tránh limit

        yield gallery, f"Hoàn tất! Tổng: {len(gallery)} ảnh"
        return

    # ========== TRANG THƯỜNG ==========
    try:
        html = fetch_html(page_url)
    except Exception as e:
        yield gallery, f"Không tải được trang: {e}"
        return

    items = []
    soup = BeautifulSoup(html, "lxml")
    for img in soup.find_all("img"):
        src = (img.get("src") or img.get("data-src") or img.get("data-lazy-src")
               or img.get("data-original") or img.get("data-url"))
        srcset = img.get("srcset") or img.get("data-srcset")
        if not src and srcset:
            src = extract_best_from_srcset(srcset)
        abs_url = safe_join_url(page_url, src)
        if abs_url:
            items.append({
                "url": abs_url,
                "alt": img.get("alt", ""),
                "cls": " ".join(img.get("class", []) or []),
                "width": img.get("width"),
                "height": img.get("height"),
            })

    # lọc tracker
    clean_items = []
    for item in items:
        url = item["url"]
        if url in seen:
            continue
        if url_relevant_for_ads(url) or text_relevant_for_ads(url, item["alt"], item["cls"]):
            continue
        w, h = item.get("width"), item.get("height")
        try:
            w = int(w) if w and str(w).isdigit() else None
            h = int(h) if h and str(h).isdigit() else None
        except:
            w = h = None
        if w and h and (w < MIN_DIMENSION or h < MIN_DIMENSION or w*h <= MIN_AREA):
            continue
        clean_items.append(item)
        seen.add(url)

    if not clean_items:
        yield gallery, "Không tìm thấy ảnh."
        return

    yield gallery, f"Tìm thấy {len(clean_items)} ảnh → đang tải..."

    with ThreadPoolExecutor(max_workers=2) as executor:
        futures = {
            executor.submit(download_image, item["url"], page_url): item["url"]
            for item in clean_items
        }
        for future in as_completed(futures):
            if stop_event.is_set():
                yield gallery, f"Đã dừng. Đã tải {len(gallery)} ảnh."
                return
            path = future.result()
            if path:
                gallery.append(path)
                yield gallery, f"Đã tải {len(gallery)} ảnh..."

    yield gallery, f"Xong! Tổng: {len(gallery)} ảnh"


def stop_scraping():
    stop_event.set()
    return "Đang dừng..."


with gr.Blocks(title="Image Stream Scraper") as demo:
    gr.Markdown("### Quét ảnh nhanh (tối ưu điện thoại)")

    url_in = gr.Textbox(
        label="URL",
        placeholder="https://e-hentai.org/g/xxxxx/yyyyy/",
        lines=1
    )

    with gr.Row():
        go_btn = gr.Button("Bắt đầu", variant="primary", size="lg")
        stop_btn = gr.Button("Dừng", variant="stop", size="lg")

    gallery = gr.Gallery(
        label="Ảnh",
        columns=2,          # 2 cột cho điện thoại
        height=500,
        object_fit="contain",
        show_label=True
    )
    status = gr.Textbox(label="Trạng thái", interactive=False, lines=1)

    go_btn.click(fn=stream_images, inputs=url_in, outputs=[gallery, status], show_progress="hidden")
    url_in.submit(fn=stream_images, inputs=url_in, outputs=[gallery, status], show_progress="hidden")
    stop_btn.click(fn=stop_scraping, outputs=status)

demo.queue()
demo.launch()