Krixter commited on
Commit
42c4dea
Β·
verified Β·
1 Parent(s): f7e2a48

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +288 -419
app.py CHANGED
@@ -1,452 +1,321 @@
1
- """
2
- novelhall_scraper.py
3
- ====================
4
- Scrapes EVERY novel title + description from https://www.novelhall.com
5
- and saves them to novelhall_novels.json + novelhall_novels.csv
6
-
7
- HOW TO RUN ON HUGGINGFACE SPACES
8
- ---------------------------------
9
- 1. Create a new Space β†’ "Blank" template (SDK: Gradio or Static β€” doesn't matter)
10
- 2. Upload this file as `app.py` (rename it) OR add it to your repo and run via terminal
11
- 3. In your Space's "Files" tab, open a terminal (or use the Jupyter-style cell runner)
12
- and execute: python novelhall_scraper.py
13
- 4. Output files will appear in the repo root:
14
- novelhall_novels.json
15
- novelhall_novels.csv
16
-
17
- DEPENDENCIES (all pre-installed on HuggingFace Spaces):
18
- requests, beautifulsoup4, lxml, tqdm
19
- """
20
-
21
  import requests
22
  from bs4 import BeautifulSoup
23
- import json
24
- import csv
25
  import time
26
- import sys
27
- import logging
28
- import random
29
- from urllib.parse import urljoin, urlparse
30
- from tqdm import tqdm
31
-
32
- # ─────────────────────────── CONFIGURATION ──────────────────────────────────
33
- BASE_URL = "https://www.novelhall.com"
34
- LIST_URL = "https://www.novelhall.com/all.html" # full catalogue page
35
- OUTPUT_JSON = "novelhall_novels.json"
36
- OUTPUT_CSV = "novelhall_novels.csv"
37
- REQUEST_TIMEOUT = 30 # seconds per request
38
- DELAY_MIN = 0.4 # minimum sleep between requests (seconds)
39
- DELAY_MAX = 1.0 # maximum sleep between requests (seconds)
40
- MAX_RETRIES = 5 # retries per failed request
41
- RETRY_BACKOFF = 2.0 # exponential-back-off multiplier
42
- LOG_EVERY = 50 # print progress every N novels
43
- # ─────────────────────────────────────────────────────────────────────────────
44
-
45
- logging.basicConfig(
46
- level=logging.INFO,
47
- format="%(asctime)s [%(levelname)s] %(message)s",
48
- handlers=[logging.StreamHandler(sys.stdout)],
49
- )
50
- log = logging.getLogger(__name__)
51
-
52
- # Rotate between a handful of realistic user-agent strings so the server
53
- # does not treat every request as a bot.
54
- USER_AGENTS = [
55
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
56
- "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
57
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
58
- "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
59
- "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
60
- "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
61
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:125.0) Gecko/20100101 Firefox/125.0",
62
- ]
63
-
64
-
65
- def make_session() -> requests.Session:
66
- """Return a requests.Session with shared headers."""
67
- session = requests.Session()
68
- session.headers.update(
69
- {
70
- "User-Agent": random.choice(USER_AGENTS),
71
- "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
72
- "Accept-Language": "en-US,en;q=0.9",
73
- "Accept-Encoding": "gzip, deflate, br",
74
- "Connection": "keep-alive",
75
- "Referer": BASE_URL,
76
- }
77
- )
78
- return session
79
-
80
-
81
- def fetch(session: requests.Session, url: str) -> str | None:
82
- """
83
- GET a URL with retry + exponential back-off.
84
- Returns the response text, or None if all retries fail.
85
- """
86
- delay = RETRY_BACKOFF
87
- for attempt in range(1, MAX_RETRIES + 1):
88
  try:
89
- # Rotate user-agent on every retry
90
- session.headers["User-Agent"] = random.choice(USER_AGENTS)
91
- resp = session.get(url, timeout=REQUEST_TIMEOUT)
92
  resp.raise_for_status()
93
  return resp.text
94
- except requests.exceptions.HTTPError as exc:
95
- status = exc.response.status_code if exc.response is not None else "?"
96
- if status == 404:
97
- log.warning("404 for %s – skipping", url)
98
  return None
99
- log.warning("HTTP %s on %s (attempt %d/%d)", status, url, attempt, MAX_RETRIES)
100
- except requests.exceptions.RequestException as exc:
101
- log.warning("Request error on %s (attempt %d/%d): %s", url, attempt, MAX_RETRIES, exc)
102
-
103
- if attempt < MAX_RETRIES:
104
- sleep_time = delay + random.uniform(0, 0.5)
105
- log.info(" β†’ Retrying in %.1f s …", sleep_time)
106
- time.sleep(sleep_time)
107
- delay *= RETRY_BACKOFF
108
-
109
- log.error("All retries exhausted for %s", url)
110
- return None
111
-
112
-
113
- # ──────────────────────── PHASE 1: collect novel links ───────────────────────
114
 
115
- def discover_novel_links(session: requests.Session) -> list[str]:
116
- """
117
- Crawl the novelhall catalogue and return a de-duplicated list of
118
- novel detail-page URLs.
119
-
120
- Strategy
121
- --------
122
- novelhall.com typically exposes novels through:
123
- β€’ /all.html (or paginated /all-1.html, /all-2.html …)
124
- β€’ Genre / tag listing pages
125
-
126
- We start from /all.html and follow pagination. If that yields nothing
127
- (site structure may vary) we fall back to the sitemap or the home page.
128
- """
129
- log.info("=== PHASE 1: Discovering novel links ===")
130
 
131
- novel_links: set[str] = set()
132
-
133
- # ── Try paginated /all.html first ─────────────────────────────────────
134
- page_num = 1
135
- consecutive_empty = 0
136
-
137
- while True:
138
- if page_num == 1:
139
- url = LIST_URL
140
- else:
141
- # Common patterns: /all-2.html or /all.html?page=2
142
- url = f"https://www.novelhall.com/all-{page_num}.html"
143
-
144
- log.info("Fetching catalogue page %d: %s", page_num, url)
145
- html = fetch(session, url)
146
-
147
- if html is None:
148
- log.info(" Page %d returned nothing – stopping pagination.", page_num)
149
- break
150
-
151
- soup = BeautifulSoup(html, "lxml")
152
- found_on_page = _extract_novel_links_from_soup(soup)
153
-
154
- if not found_on_page:
155
- consecutive_empty += 1
156
- log.info(" No new novel links on page %d (empty streak: %d)", page_num, consecutive_empty)
157
- if consecutive_empty >= 2:
158
- break
159
- else:
160
- consecutive_empty = 0
161
- novel_links.update(found_on_page)
162
- log.info(" Found %d novel links on page %d (total so far: %d)",
163
- len(found_on_page), page_num, len(novel_links))
164
-
165
- page_num += 1
166
- time.sleep(random.uniform(DELAY_MIN, DELAY_MAX))
167
-
168
- # ── Fallback: scrape home page for genre/tag links, then crawl those ──
169
- if len(novel_links) == 0:
170
- log.info("Catalogue pages yielded nothing – falling back to home-page crawl")
171
- novel_links.update(_crawl_home_categories(session))
172
-
173
- # ── Second fallback: /sitemap.xml ─────────────────────────────────────
174
- if len(novel_links) == 0:
175
- log.info("Home-page crawl also empty – trying sitemap.xml")
176
- novel_links.update(_crawl_sitemap(session))
177
-
178
- result = sorted(novel_links)
179
- log.info("=== Phase 1 complete: %d unique novel links discovered ===", len(result))
180
- return result
181
-
182
-
183
- def _extract_novel_links_from_soup(soup: BeautifulSoup) -> list[str]:
184
- """
185
- Extract novel detail-page hrefs from any listing page soup.
186
- Returns absolute URLs.
187
- """
188
  links = []
189
- for a in soup.find_all("a", href=True):
190
- href = a["href"].strip()
191
- # novelhall detail pages look like /Novel-Title-123/ or /123/
192
- # We accept any path that is NOT a category/genre/page index link
193
- if not href or href in ("#", "/"):
194
- continue
195
- abs_url = urljoin(BASE_URL, href)
196
- parsed = urlparse(abs_url)
197
- # Must be on the same domain
198
- if parsed.netloc not in ("www.novelhall.com", "novelhall.com"):
199
- continue
200
- # Filter out obvious non-novel pages
201
- path = parsed.path.rstrip("/")
202
- skip_patterns = (
203
- "/all", "/genre", "/tag", "/category", "/search",
204
- "/login", "/register", "/user", "/sitemap", "/cdn-cgi",
205
- )
206
- if any(path.startswith(p) for p in skip_patterns):
207
- continue
208
- # Novel pages typically have at least one path segment of substance
209
- segments = [s for s in path.split("/") if s]
210
- if len(segments) == 1 and segments[0].isdigit():
211
- # Pattern like /12345 β†’ probably a novel
212
- links.append(abs_url)
213
- elif len(segments) == 1 and "-" in segments[0]:
214
- # Pattern like /novel-title β†’ probably a novel
215
- links.append(abs_url)
216
- elif len(segments) >= 1 and not segments[0].startswith("all"):
217
- links.append(abs_url)
218
- return links
219
-
220
-
221
- def _crawl_home_categories(session: requests.Session) -> list[str]:
222
- """Grab the home page, find genre/category links, crawl each."""
223
- log.info("Crawling home page for category links …")
224
- html = fetch(session, BASE_URL)
225
- if not html:
226
- return []
227
- soup = BeautifulSoup(html, "lxml")
228
- links: set[str] = set()
229
-
230
- # Collect category links first
231
- cat_links = set()
232
- for a in soup.find_all("a", href=True):
233
- href = a["href"].strip()
234
- abs_url = urljoin(BASE_URL, href)
235
- parsed = urlparse(abs_url)
236
- if parsed.netloc in ("www.novelhall.com", "novelhall.com"):
237
- path = parsed.path.rstrip("/")
238
- if any(kw in path for kw in ("/genre", "/tag", "/category", "/type", "/list")):
239
- cat_links.add(abs_url)
240
-
241
- log.info(" Found %d category links", len(cat_links))
242
-
243
- for cat_url in sorted(cat_links):
244
- log.info(" Crawling category: %s", cat_url)
245
- cat_html = fetch(session, cat_url)
246
- if not cat_html:
247
- continue
248
- cat_soup = BeautifulSoup(cat_html, "lxml")
249
- found = _extract_novel_links_from_soup(cat_soup)
250
- links.update(found)
251
- time.sleep(random.uniform(DELAY_MIN, DELAY_MAX))
252
-
253
- return list(links)
254
-
255
-
256
- def _crawl_sitemap(session: requests.Session) -> list[str]:
257
- """Try to parse /sitemap.xml for novel URLs."""
258
- sitemap_url = f"{BASE_URL}/sitemap.xml"
259
- log.info("Fetching %s", sitemap_url)
260
- xml = fetch(session, sitemap_url)
261
- if not xml:
262
- return []
263
- soup = BeautifulSoup(xml, "lxml-xml")
264
- links = []
265
- for loc in soup.find_all("loc"):
266
- url = loc.get_text(strip=True)
267
- parsed = urlparse(url)
268
- if parsed.netloc in ("www.novelhall.com", "novelhall.com"):
269
- path = parsed.path.rstrip("/")
270
- segments = [s for s in path.split("/") if s]
271
- if segments:
272
- links.append(url)
273
- log.info(" Sitemap gave %d candidate links", len(links))
274
  return links
275
 
276
 
277
- # ──────────────────────── PHASE 2: scrape each detail page ───────────────────
 
 
278
 
279
- def scrape_novel_detail(session: requests.Session, url: str) -> dict | None:
280
- """
281
- Fetch a novel's detail page and extract:
282
- - title
283
- - description / synopsis
284
- - url (canonical)
285
- Returns None if the page cannot be fetched or is not a novel page.
286
- """
287
- html = fetch(session, url)
288
- if not html:
289
- return None
290
-
291
- soup = BeautifulSoup(html, "lxml")
292
-
293
- # ── Title ──────────────────────────────────────────────────────────────
294
- title = _extract_title(soup)
295
  if not title:
296
- # Not a real novel page (could be a category/error page)
297
- return None
298
-
299
- # ── Description ────────────────────────────────────────────────────────
300
- description = _extract_description(soup)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
301
 
302
  return {
 
303
  "title": title,
304
  "description": description,
305
- "url": url,
306
  }
307
 
308
 
309
- def _extract_title(soup: BeautifulSoup) -> str:
310
- """Try multiple selectors to find the novel title."""
311
- # 1. <h1> inside common novel-info wrappers
312
- for selector in (
313
- "div.book-info h1",
314
- "div.novel-info h1",
315
- "div.info h1",
316
- "div.header-wrapper h1",
317
- "div.title h1",
318
- "article h1",
319
- "h1.novel-title",
320
- "h1",
321
- ):
322
- tag = soup.select_one(selector)
323
- if tag:
324
- text = tag.get_text(strip=True)
325
- if text:
326
- return text
327
-
328
- # 2. <title> tag, strip " - Novelhall" suffix
329
- title_tag = soup.find("title")
330
- if title_tag:
331
- text = title_tag.get_text(strip=True)
332
- for suffix in (" - Novelhall", " - Novel Hall", " | Novelhall", " | Novel Hall"):
333
- if text.lower().endswith(suffix.lower()):
334
- text = text[: -len(suffix)].strip()
335
- if text:
336
- return text
337
-
338
- return ""
339
-
340
-
341
- def _extract_description(soup: BeautifulSoup) -> str:
342
- """Try multiple selectors to find the synopsis / description."""
343
- # 1. Common class names used by novel sites
344
- for selector in (
345
- "div.book-intro",
346
- "div.novel-intro",
347
- "div.synopsis",
348
- "div.description",
349
- "div.intro",
350
- "div.summary",
351
- "div.novel-desc",
352
- "div.book-desc",
353
- "section.synopsis",
354
- "div#bookIntro",
355
- "div#intro",
356
- "div#synopsis",
357
- "div#description",
358
- "div.content > p", # generic fallback
 
 
 
 
 
 
 
359
  ):
360
- tag = soup.select_one(selector)
361
- if tag:
362
- text = tag.get_text(separator=" ", strip=True)
363
- if len(text) > 20: # ignore tiny/empty hits
364
- return text
365
-
366
- # 2. meta description
367
- meta = soup.find("meta", attrs={"name": "description"})
368
- if meta and meta.get("content"):
369
- return meta["content"].strip()
370
-
371
- return ""
372
-
373
-
374
- # ───────────────────────────── PHASE 3: save ─────────────────────────────────
375
-
376
- def save_results(novels: list[dict]) -> None:
377
- """Write results to JSON and CSV."""
378
- # JSON
379
- with open(OUTPUT_JSON, "w", encoding="utf-8") as f:
380
- json.dump(novels, f, ensure_ascii=False, indent=2)
381
- log.info("Saved %d novels to %s", len(novels), OUTPUT_JSON)
382
-
383
- # CSV
384
- fieldnames = ["title", "description", "url"]
385
- with open(OUTPUT_CSV, "w", encoding="utf-8", newline="") as f:
386
- writer = csv.DictWriter(f, fieldnames=fieldnames)
387
- writer.writeheader()
388
- writer.writerows(novels)
389
- log.info("Saved %d novels to %s", len(novels), OUTPUT_CSV)
390
 
 
 
 
 
 
391
 
392
- # ─────────────────────────────── MAIN ────────────────────────────────────────
393
-
394
- def main() -> None:
395
- log.info("╔══════════════════════════════════════════╗")
396
- log.info("β•‘ Novelhall.com Full Catalogue Scraper β•‘")
397
- log.info("β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•")
398
-
399
- session = make_session()
400
-
401
- # ── Phase 1: discover all novel URLs ──────────────────────────────────
402
- novel_urls = discover_novel_links(session)
403
-
404
- if not novel_urls:
405
- log.error("No novel links found at all – aborting.")
406
- sys.exit(1)
407
-
408
- # ── Phase 2: scrape each novel's detail page ───────────────────────────
409
- log.info("=== PHASE 2: Scraping %d novel detail pages ===", len(novel_urls))
410
- novels: list[dict] = []
411
- failed: list[str] = []
412
-
413
- for idx, url in enumerate(tqdm(novel_urls, desc="Scraping novels", unit="novel"), start=1):
414
- result = scrape_novel_detail(session, url)
415
-
416
- if result:
417
- novels.append(result)
418
- else:
419
- failed.append(url)
420
- log.warning(" Skipped (no data): %s", url)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
421
 
422
- if idx % LOG_EVERY == 0:
423
- log.info(" Progress: %d / %d scraped, %d ok, %d failed",
424
- idx, len(novel_urls), len(novels), len(failed))
 
 
 
 
 
 
 
 
 
 
425
 
426
- # Polite crawl delay
427
- time.sleep(random.uniform(DELAY_MIN, DELAY_MAX))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
428
 
429
- # ── Phase 3: save ─────────────────────────────────────────────────────
430
- log.info("=== PHASE 3: Saving results ===")
431
- save_results(novels)
432
 
433
- # ── Summary ────────────────────────────────────────────────────────────
434
- log.info("╔══════════════════════════════════════════╗")
435
- log.info("β•‘ DONE β•‘")
436
- log.info("β•‘ Total discovered : %-5d β•‘", len(novel_urls))
437
- log.info("β•‘ Successfully saved: %-5d β•‘", len(novels))
438
- log.info("β•‘ Failed / skipped : %-5d β•‘", len(failed))
439
- log.info("β•‘ Output files: β•‘")
440
- log.info("β•‘ β€’ %-36s β•‘", OUTPUT_JSON)
441
- log.info("β•‘ β€’ %-36s β•‘", OUTPUT_CSV)
442
- log.info("β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•")
443
 
444
- if failed:
445
- failed_path = "novelhall_failed.txt"
446
- with open(failed_path, "w", encoding="utf-8") as f:
447
- f.write("\n".join(failed))
448
- log.info("URLs that could not be scraped written to: %s", failed_path)
449
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
450
 
451
  if __name__ == "__main__":
452
- main()
 
1
+ import gradio as gr
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  import requests
3
  from bs4 import BeautifulSoup
4
+ import re
 
5
  import time
6
+ import os
7
+ import json
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+
11
+ # ─────────────────────────────────────────────
12
+ # CONFIG
13
+ # ─────────────────────────────────────────────
14
+ BASE_URL = "https://www.novelhall.com"
15
+ NOVEL_RE = re.compile(r"https://www\.novelhall\.com/[a-z0-9-]+-\d+/$")
16
+ OUTPUT_DIR = Path("outputs")
17
+ OUTPUT_DIR.mkdir(exist_ok=True)
18
+
19
+ HEADERS = {
20
+ "User-Agent": (
21
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
22
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
23
+ "Chrome/120.0.0.0 Safari/537.36"
24
+ ),
25
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
26
+ "Accept-Language": "en-US,en;q=0.5",
27
+ }
28
+
29
+ SESSION = requests.Session()
30
+ SESSION.headers.update(HEADERS)
31
+
32
+ # ─────────────────────────────────────────────
33
+ # HELPERS
34
+ # ─────────────────────────────────────────────
35
+ def fetch_page(url: str, retries: int = 3, delay: float = 2.0) -> str | None:
36
+ """Fetch HTML from a URL with retries."""
37
+ for attempt in range(1, retries + 1):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  try:
39
+ resp = SESSION.get(url, timeout=20)
 
 
40
  resp.raise_for_status()
41
  return resp.text
42
+ except Exception as exc:
43
+ if attempt < retries:
44
+ time.sleep(delay * attempt)
45
+ else:
46
  return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
 
49
+ def extract_novel_links(html: str) -> list[str]:
50
+ """Extract all novel detail-page links from a listing page."""
51
+ soup = BeautifulSoup(html, "html.parser")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  links = []
53
+ seen = set()
54
+ for a in soup.find_all("a", href=NOVEL_RE):
55
+ href = a["href"]
56
+ if href not in seen:
57
+ seen.add(href)
58
+ links.append(href)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  return links
60
 
61
 
62
+ def extract_novel_info(html: str, url: str) -> dict:
63
+ """Extract title and description from a novel detail page."""
64
+ soup = BeautifulSoup(html, "html.parser")
65
 
66
+ # Title – prefer h1 inside book-info, fall back to <title>
67
+ title = ""
68
+ h1 = soup.find("h1")
69
+ if h1:
70
+ title = h1.get_text(strip=True)
 
 
 
 
 
 
 
 
 
 
 
71
  if not title:
72
+ t = soup.find("title")
73
+ if t:
74
+ title = t.get_text(strip=True)
75
+
76
+ # Description – div.intro contains synopsis text
77
+ description = ""
78
+ intro_div = soup.find("div", class_="intro")
79
+ if intro_div:
80
+ # Remove the "...more>>" button text and clean duplicates
81
+ for tag in intro_div.find_all(["a", "span", "button"]):
82
+ tag.decompose()
83
+ raw = intro_div.get_text(separator=" ", strip=True)
84
+ # The site sometimes duplicates the synopsis; take the first half
85
+ half = len(raw) // 2
86
+ if half > 50 and raw[:half].strip() == raw[half:].strip():
87
+ raw = raw[:half].strip()
88
+ # Strip leading "Synopsis" label if present (may be fused: "SynopsisAt")
89
+ raw = re.sub(r"^Synopsis", "", raw, flags=re.IGNORECASE).strip()
90
+ description = raw
91
+
92
+ # Fall back to meta description
93
+ if not description:
94
+ meta = soup.find("meta", {"name": "description"})
95
+ if meta:
96
+ description = meta.get("content", "").strip()
97
+ description = re.sub(r"\.\.\.$", "", description).strip()
98
 
99
  return {
100
+ "url": url,
101
  "title": title,
102
  "description": description,
 
103
  }
104
 
105
 
106
+ def build_list_url(base_pattern: str, page_num: int) -> str:
107
+ """
108
+ Construct a page URL given a pattern string.
109
+ Detects 'all2022-N' style URLs and replaces the number.
110
+ Falls back to appending ?page=N.
111
+ """
112
+ # Pattern: ends with -N.html or -N/ or -N
113
+ m = re.search(r"(-\d+)(\.html)?$", base_pattern)
114
+ if m:
115
+ prefix = base_pattern[: m.start()]
116
+ suffix = m.group(2) or ""
117
+ return f"{prefix}-{page_num}{suffix}"
118
+ # Fallback: append ?page=N
119
+ sep = "&" if "?" in base_pattern else "?"
120
+ return f"{base_pattern}{sep}page={page_num}"
121
+
122
+
123
+ # ─────────────────────────────────────────────
124
+ # MAIN SCRAPER
125
+ # ─────────────────────────────────────────────
126
+ def scrape(
127
+ start_url: str,
128
+ end_page: int,
129
+ delay_sec: float,
130
+ save_html: bool,
131
+ progress=gr.Progress(track_tqdm=True),
132
+ ):
133
+ """
134
+ Core scraping function called by Gradio.
135
+ Returns (log_text, output_file_path).
136
+ """
137
+ start_url = start_url.strip().rstrip("/")
138
+ logs = []
139
+ all_novels = []
140
+ html_dir = OUTPUT_DIR / "html_pages"
141
+ if save_html:
142
+ html_dir.mkdir(exist_ok=True)
143
+
144
+ def log(msg: str):
145
+ ts = datetime.now().strftime("%H:%M:%S")
146
+ line = f"[{ts}] {msg}"
147
+ logs.append(line)
148
+ return line
149
+
150
+ # Detect starting page number
151
+ m = re.search(r"-(\d+)(\.html)?$", start_url)
152
+ start_page = int(m.group(1)) if m else 1
153
+
154
+ log(f"Starting from: {start_url}")
155
+ log(f"Page range : {start_page} β†’ {end_page}")
156
+ log("─" * 60)
157
+
158
+ total_pages = end_page - start_page + 1
159
+ novels_done = 0
160
+
161
+ for page_idx, page_num in enumerate(
162
+ range(start_page, end_page + 1), start=1
163
  ):
164
+ list_url = build_list_url(start_url, page_num)
165
+ yield "\n".join(logs + [log(f"πŸ“„ LIST PAGE {page_num}/{end_page} β†’ {list_url}")]), None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
 
167
+ html = fetch_page(list_url)
168
+ if html is None:
169
+ log(f" ⚠️ Could not fetch listing page {page_num} – skipping")
170
+ yield "\n".join(logs), None
171
+ continue
172
 
173
+ novel_urls = extract_novel_links(html)
174
+ log(f" Found {len(novel_urls)} novels on this page")
175
+
176
+ for idx, novel_url in enumerate(novel_urls, start=1):
177
+ yield "\n".join(
178
+ logs + [f" [{idx}/{len(novel_urls)}] Fetching: {novel_url}"]
179
+ ), None
180
+
181
+ novel_html = fetch_page(novel_url)
182
+ if novel_html is None:
183
+ log(f" ⚠️ Failed to fetch {novel_url}")
184
+ all_novels.append({
185
+ "url": novel_url,
186
+ "title": "FETCH_ERROR",
187
+ "description": "",
188
+ })
189
+ yield "\n".join(logs), None
190
+ continue
191
+
192
+ info = extract_novel_info(novel_html, novel_url)
193
+ all_novels.append(info)
194
+ log(
195
+ f" βœ… [{novels_done + 1}] {info['title'][:60]}"
196
+ )
197
+ novels_done += 1
198
+
199
+ # Optionally save the raw HTML
200
+ if save_html:
201
+ slug = re.sub(r"[^a-z0-9]+", "_", novel_url.split("/")[-2].lower())
202
+ html_path = html_dir / f"{slug}.html"
203
+ html_path.write_text(novel_html, encoding="utf-8")
204
+
205
+ time.sleep(delay_sec)
206
+ yield "\n".join(logs), None
207
+
208
+ time.sleep(delay_sec) # polite pause between listing pages
209
+
210
+ # ── Write combined TXT output ──────────────────────────────────────
211
+ out_path = OUTPUT_DIR / f"novels_{start_page}_to_{end_page}.txt"
212
+ with out_path.open("w", encoding="utf-8") as fout:
213
+ fout.write(
214
+ f"Novelhall Novel Scrape β€” Pages {start_page}–{end_page}\n"
215
+ f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"
216
+ f"Total novels: {len(all_novels)}\n"
217
+ f"{'=' * 70}\n\n"
218
+ )
219
+ for i, novel in enumerate(all_novels, start=1):
220
+ fout.write(f"[{i}]\n")
221
+ fout.write(f"TITLE : {novel['title']}\n")
222
+ fout.write(f"URL : {novel['url']}\n")
223
+ fout.write(f"DESCRIPTION:\n{novel['description']}\n")
224
+ fout.write(f"{'─' * 70}\n\n")
225
+
226
+ log("─" * 60)
227
+ log(f"βœ… Done! {len(all_novels)} novels scraped.")
228
+ log(f"πŸ“ Output saved β†’ {out_path}")
229
+
230
+ yield "\n".join(logs), str(out_path)
231
+
232
+
233
+ # ─────────────────────────────────────────────
234
+ # GRADIO UI
235
+ # ─────────────────────────────────────────────
236
+ CSS = """
237
+ #log_box textarea { font-family: monospace; font-size: 12px; }
238
+ .status-bar { font-size: 13px; color: #555; }
239
+ """
240
 
241
+ with gr.Blocks(title="Novelhall Scraper", css=CSS) as demo:
242
+ gr.Markdown(
243
+ """
244
+ # πŸ“š Novelhall Novel Scraper
245
+ Scrapes every novel on Novelhall listing pages, extracts **title** and **description**,
246
+ and saves a combined `.txt` file.
247
+
248
+ **How it works:**
249
+ 1. Paste a listing-page URL like `https://www.novelhall.com/all2022-1.html`
250
+ 2. Set the **End Page** number (e.g. `337` for pages 1–337)
251
+ 3. Click **Start Scraping**
252
+ """
253
+ )
254
 
255
+ with gr.Row():
256
+ with gr.Column(scale=3):
257
+ start_url_box = gr.Textbox(
258
+ label="Start URL (first listing page)",
259
+ value="https://www.novelhall.com/all2022-1.html",
260
+ placeholder="https://www.novelhall.com/all2022-1.html",
261
+ )
262
+ with gr.Column(scale=1):
263
+ end_page_box = gr.Number(
264
+ label="End Page Number",
265
+ value=5,
266
+ minimum=1,
267
+ maximum=500,
268
+ step=1,
269
+ )
270
+
271
+ with gr.Row():
272
+ delay_slider = gr.Slider(
273
+ label="Delay between requests (seconds)",
274
+ minimum=0.5,
275
+ maximum=5.0,
276
+ value=1.5,
277
+ step=0.5,
278
+ )
279
+ save_html_cb = gr.Checkbox(
280
+ label="Also save raw HTML of each novel page",
281
+ value=False,
282
+ )
283
 
284
+ with gr.Row():
285
+ run_btn = gr.Button("πŸš€ Start Scraping", variant="primary", scale=3)
286
+ stop_btn = gr.Button("β›” Stop", variant="stop", scale=1)
287
 
288
+ log_box = gr.Textbox(
289
+ label="Live Log",
290
+ lines=20,
291
+ max_lines=40,
292
+ interactive=False,
293
+ elem_id="log_box",
294
+ )
 
 
 
295
 
296
+ out_file = gr.File(label="πŸ“₯ Download Combined TXT", interactive=False)
 
 
 
 
297
 
298
+ # Wire up
299
+ scrape_event = run_btn.click(
300
+ fn=scrape,
301
+ inputs=[start_url_box, end_page_box, delay_slider, save_html_cb],
302
+ outputs=[log_box, out_file],
303
+ )
304
+ stop_btn.click(fn=None, cancels=[scrape_event])
305
+
306
+ gr.Markdown(
307
+ """
308
+ ---
309
+ ### πŸ“Œ Notes
310
+ - **URL pattern**: The scraper auto-detects `all2022-N` style pagination.
311
+ Pages `all2022-1.html` through `all2022-337.html` are all supported.
312
+ - **Rate limiting**: Default 1.5 s delay is polite and avoids blocks.
313
+ Increase it if you get 429 errors.
314
+ - **Output**: One `.txt` file with title, URL, and full description per novel.
315
+ - **HTML saving**: Optional β€” saves each novel's main page as `<slug>.html`
316
+ in `outputs/html_pages/`.
317
+ """
318
+ )
319
 
320
  if __name__ == "__main__":
321
+ demo.launch()