Than / app.py
Jxjsnsn's picture
Create app.py
01bff57 verified
Raw
History Blame Contribute Delete
14 kB
# =========================
# 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()