import gradio as gr import requests from bs4 import BeautifulSoup import re import time import os import tempfile import json from urllib.parse import urlparse, urljoin import urllib.robotparser # ───────────────────────────────────────────── # HEADERS & SESSION # ───────────────────────────────────────────── HEADERS = { "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/124.0.0.0 Safari/537.36" ), "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "en-US,en;q=0.5", } SESSION = requests.Session() SESSION.headers.update(HEADERS) # ───────────────────────────────────────────── # SITE PROFILES # Each profile tells the scraper HOW to parse # a specific site's HTML structure. # ───────────────────────────────────────────── SITE_PROFILES = { # ── RoyalRoad ──────────────────────────── "royalroad.com": { "name": "Royal Road", "novel_title": ("h1", {"class": "font-white"}), "novel_author": ("span", {"property": "name"}), "chapter_list": ("table", {"id": "chapters"}), "chapter_links": ("a", {"href": re.compile(r"/fiction/\d+/chapter/")}), "chapter_title": ("h1", {"class": "font-white"}), "content_sel": ("div", {"class": "chapter-content"}), "next_page": None, "delay": 1.2, }, # ── ScribbleHub ────────────────────────── "scribblehub.com": { "name": "ScribbleHub", "novel_title": ("div", {"class": "fic_title"}), "novel_author": ("span", {"class": "auth_name_fic"}), "chapter_list": None, # uses API "api_toc": "https://www.scribblehub.com/wp-admin/admin-ajax.php", "chapter_links": ("a", {"class": "toc_a"}), "chapter_title": ("div", {"class": "chapter-title"}), "content_sel": ("div", {"class": "chp_raw"}), "next_page": ("a", {"class": "btn-chapter-next"}), "delay": 1.5, }, # ── WuxiaWorld ─────────────────────────── "wuxiaworld.com": { "name": "WuxiaWorld", "novel_title": ("h3", {"class": "novel-title"}), "novel_author": ("span", {"class": "author"}), "chapter_list": ("div", {"class": "chapter-list"}), "chapter_links": ("a", {"href": re.compile(r"/novel/[^/]+/[^/]+-chapter")}), "chapter_title": ("h4", {}), "content_sel": ("div", {"class": "chapter-content"}), "next_page": None, "delay": 1.5, }, # ── NovelFull / NovelFull.com ───────────── "novelfull.com": { "name": "NovelFull", "novel_title": ("h3", {"class": "title"}), "novel_author": ("a", {"itemprop": "author"}), "chapter_list": ("ul", {"class": "list-chapter"}), "chapter_links": ("a", {"href": re.compile(r"\.html")}), "chapter_title": ("a", {"class": "chr-title"}), "content_sel": ("div", {"id": "chapter-content"}), "next_page": ("a", {"id": "next_chap"}), "delay": 1.0, }, # ── LightNovelPub ──────────────────────── "lightnovelpub.com": { "name": "LightNovelPub", "novel_title": ("h1", {"class": "novel-title"}), "novel_author": ("span", {"itemprop": "author"}), "chapter_list": ("ul", {"class": "chapter-list"}), "chapter_links": ("a", {"href": re.compile(r"/novel/.+/chapter-\d+")}), "chapter_title": ("span", {"class": "chapter-title"}), "content_sel": ("div", {"class": "chapter-content"}), "next_page": ("a", {"class": "btn-next"}), "delay": 1.2, }, # ── FreeWebNovel ───────────────────────── "freewebnovel.com": { "name": "FreeWebNovel", "novel_title": ("h1", {"class": "pc_title"}), "novel_author": ("a", {"class": "a1"}), "chapter_list": ("ul", {"class": "ul-list5"}), "chapter_links": ("a", {"href": re.compile(r"\.html")}), "chapter_title": ("h1", {"class": "chapter-title"}), "content_sel": ("div", {"class": "txt"}), "next_page": ("a", {"id": "idnext"}), "delay": 1.0, }, # ── NovelUpdates (info page, links out) ── "novelupdates.com": { "name": "NovelUpdates", "novel_title": ("div", {"class": "seriestitlenu"}), "novel_author": ("div", {"id": "authtag"}), "chapter_list": ("table", {"id": "myTable"}), "chapter_links": ("a", {"class": "chp-release"}), "chapter_title": None, "content_sel": None, "next_page": None, "delay": 1.5, "_external_links": True, # chapters hosted elsewhere }, # ── WebNovel.com ───────────────────────── "webnovel.com": { "name": "WebNovel", "novel_title": ("h2", {"class": "dib"}), "novel_author": ("address", {}), "chapter_list": ("ol", {"class": "c_list"}), "chapter_links": ("a", {"href": re.compile(r"/book/\d+/\d+")}), "chapter_title": ("h3", {"class": "cha-tit"}), "content_sel": ("div", {"class": "cha-words"}), "next_page": ("a", {"class": "_nextChapter"}), "delay": 2.0, }, # ── Wattpad ────────────────────────────── "wattpad.com": { "name": "Wattpad", "novel_title": ("h1", {"class": "story-title"}), "novel_author": ("strong", {"class": "author-info__username"}), "chapter_list": ("ul", {"class": "table-of-contents"}), "chapter_links": ("a", {"href": re.compile(r"/story/\d+.+")}), "chapter_title": ("h2", {"class": "part-title"}), "content_sel": ("pre", {"class": ""}), "next_page": ("a", {"class": "next-part-link"}), "delay": 1.5, }, # ── Moonquill ──────────────────────────── "moonquill.com": { "name": "Moonquill", "novel_title": ("h1", {"class": "title"}), "novel_author": ("a", {"class": "author"}), "chapter_list": ("div", {"class": "table-of-contents"}), "chapter_links": ("a", {"href": re.compile(r"/book/.+")}), "chapter_title": ("h1", {"class": "title"}), "content_sel": ("div", {"class": "chapter-body"}), "next_page": None, "delay": 1.0, }, } # ───────────────────────────────────────────── # GENERIC FALLBACK HEURISTICS # ───────────────────────────────────────────── GENERIC_CONTENT_SELECTORS = [ ("div", {"id": "chapter-content"}), ("div", {"class": "chapter-content"}), ("div", {"class": "content"}), ("div", {"class": "entry-content"}), ("div", {"id": "content"}), ("article", {}), ("div", {"class": "post-content"}), ("div", {"class": "text-left"}), ("div", {"class": "novel-content"}), ("div", {"class": "read-content"}), ] GENERIC_TITLE_SELECTORS = [ ("h1", {}), ("h2", {}), ("h3", {"class": "chapter-title"}), ("title", {}), ] # ───────────────────────────────────────────── # HELPER UTILITIES # ───────────────────────────────────────────── def get_domain(url: str) -> str: parsed = urlparse(url) domain = parsed.netloc.lower().replace("www.", "") return domain def safe_get(url: str, retries: int = 3, delay: float = 1.0): for attempt in range(retries): try: resp = SESSION.get(url, timeout=20) if resp.status_code == 200: return resp if resp.status_code == 429: time.sleep(delay * (attempt + 1) * 3) else: time.sleep(delay) except Exception as e: time.sleep(delay * (attempt + 1)) return None def extract_text_blocks(soup_element) -> str: """Extract clean paragraphs from a BS4 element.""" if soup_element is None: return "" # Remove script/style/ads for tag in soup_element(["script", "style", "noscript", "iframe", "ins", "aside"]): tag.decompose() # Build paragraphs paras = [] for p in soup_element.find_all(["p", "br"]): text = p.get_text(separator=" ", strip=True) if text: paras.append(text) if not paras: # fallback: just grab all text raw = soup_element.get_text(separator="\n", strip=True) paras = [line for line in raw.splitlines() if line.strip()] return "\n\n".join(paras) def clean_title(title: str) -> str: return re.sub(r'[<>:"/\\|?*\x00-\x1F]', '', title).strip() # ───────────────────────────────────────────── # CORE SCRAPER # ───────────────────────────────────────────── def detect_profile(domain: str): for key, profile in SITE_PROFILES.items(): if key in domain: return profile return None def get_toc_links(main_url: str, soup: BeautifulSoup, profile, log_fn) -> list: """Return list of (chapter_title, chapter_url).""" domain = get_domain(main_url) base = f"{urlparse(main_url).scheme}://{urlparse(main_url).netloc}" links = [] # ── ScribbleHub uses AJAX for TOC ──────── if profile and "api_toc" in profile and "scribblehub" in domain: fic_id_match = re.search(r"scribblehub\.com/series/(\d+)/", main_url) if fic_id_match: fic_id = fic_id_match.group(1) data = {"action": "wi_gettocchp", "strSID": fic_id, "strmypostid": "0", "strFic": "yes"} resp = SESSION.post(profile["api_toc"], data=data, timeout=20) if resp.ok: toc_soup = BeautifulSoup(resp.text, "html.parser") for a in reversed(toc_soup.find_all("a")): href = a.get("href", "") title = a.get_text(strip=True) if href and title: links.append((title, href)) log_fn(f" ✅ ScribbleHub AJAX TOC: {len(links)} chapters") return links # ── Standard: find chapter list container ─ container = None if profile and profile.get("chapter_list"): tag, attrs = profile["chapter_list"] container = soup.find(tag, attrs) if attrs else soup.find(tag) search_root = container if container else soup link_sel = (profile.get("chapter_links") if profile else None) or \ ("a", {"href": re.compile(r"chapter|chap|ch-\d+", re.I)}) tag, attrs = link_sel found = search_root.find_all(tag, attrs) seen = set() for a in found: href = a.get("href", "") if not href or href in seen: continue seen.add(href) full_url = href if href.startswith("http") else urljoin(base, href) title = a.get_text(strip=True) or f"Chapter {len(links)+1}" links.append((title, full_url)) log_fn(f" 📋 Found {len(links)} chapter links") return links def scrape_chapter(url: str, profile, log_fn) -> tuple: """Returns (chapter_title, chapter_text).""" resp = safe_get(url, delay=profile.get("delay", 1.2) if profile else 1.2) if not resp: log_fn(f" ⚠️ Failed to fetch: {url}") return None, None soup = BeautifulSoup(resp.text, "html.parser") # ── Title ── chapter_title = "" if profile and profile.get("chapter_title"): t, a = profile["chapter_title"] el = soup.find(t, a) if el: chapter_title = el.get_text(strip=True) if not chapter_title: for t, a in GENERIC_TITLE_SELECTORS: el = soup.find(t, a) if el: chapter_title = el.get_text(strip=True) break # ── Content ── content_el = None if profile and profile.get("content_sel"): t, a = profile["content_sel"] content_el = soup.find(t, a) if not content_el: for t, a in GENERIC_CONTENT_SELECTORS: content_el = soup.find(t, a) if content_el: break if not content_el: # Last resort: largest
Paste any novel URL · Get a clean .txt file · Works on 10+ sites