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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +422 -512
app.py CHANGED
@@ -1,542 +1,452 @@
1
  """
2
- Novel Scraper β€” novelbin.com + webnovel.com
3
- Runs on HuggingFace Spaces (Gradio UI).
4
- Saves title + description of every novel to CSV and JSONL.
5
- Fully resumable β€” safe to restart if interrupted.
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  """
7
 
8
- # ── audioop stub ──────────────────────────────────────────────────────────────
9
- # Python 3.13 removed the `audioop` stdlib module.
10
- # Gradio 4.x pulls in pydub which tries to import it β€” and crashes.
11
- # We don't use any audio features, so a no-op stub fixes the crash entirely.
12
- import sys as _sys
13
- import types as _types
14
- if "audioop" not in _sys.modules:
15
- _audioop_stub = _types.ModuleType("audioop")
16
- _sys.modules["audioop"] = _audioop_stub
17
- _sys.modules["pyaudioop"] = _audioop_stub # pydub tries this name too
18
- # ─────────────────────────────────────────────────────────────────────────────
19
-
20
- import os
21
- import csv
22
  import json
 
23
  import time
24
- import random
25
  import logging
26
- import threading
27
- import traceback
28
- from datetime import datetime
29
- from pathlib import Path
30
-
31
- import requests
32
- from bs4 import BeautifulSoup
33
- import gradio as gr
34
-
35
- # ─────────────────────────────────────────────
36
- # CONFIG
37
- # ─────────────────────────────────────────────
38
- OUTPUT_DIR = Path("output")
39
- OUTPUT_DIR.mkdir(exist_ok=True)
40
-
41
- LOG_FILE = OUTPUT_DIR / "scraper.log"
42
- NOVELBIN_CSV = OUTPUT_DIR / "novelbin_novels.csv"
43
- NOVELBIN_JSONL = OUTPUT_DIR / "novelbin_novels.jsonl"
44
- WEBNOVEL_CSV = OUTPUT_DIR / "webnovel_novels.csv"
45
- WEBNOVEL_JSONL = OUTPUT_DIR / "webnovel_novels.jsonl"
46
- PROGRESS_FILE = OUTPUT_DIR / "progress.json"
47
-
48
- NOVELBIN_BASE = "https://novelbin.com"
49
- NOVELBIN_LIST = "https://novelbin.com/sort/updated?page={page}"
50
-
51
- # webnovel public category API β€” returns JSON, no JS needed
52
- WEBNOVEL_API = (
53
- "https://www.webnovel.com/go/pcm/category/categoryAjax"
54
- "?_csrfToken=&categoryId=0&pageIndex={page}&orderBy=1&bookType=1"
55
- )
56
 
57
- REQUEST_TIMEOUT = 30 # seconds per request
58
- RETRY_MAX = 5 # retries per failed request
59
- RETRY_BACKOFF = [2, 5, 10, 20, 40] # seconds between retries
60
- PAGE_DELAY = (1.0, 2.5) # random sleep between pages (min, max)
61
-
62
- HEADERS = {
63
- "User-Agent": (
64
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
65
- "AppleWebKit/537.36 (KHTML, like Gecko) "
66
- "Chrome/124.0.0.0 Safari/537.36"
67
- ),
68
- "Accept-Language": "en-US,en;q=0.9",
69
- "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
70
- }
71
-
72
- # ─────────────────────────────────────────────
73
- # LOGGING
74
- # ────────────────��────────────────────────────
75
  logging.basicConfig(
76
  level=logging.INFO,
77
  format="%(asctime)s [%(levelname)s] %(message)s",
78
- handlers=[
79
- logging.FileHandler(LOG_FILE, encoding="utf-8"),
80
- logging.StreamHandler(),
81
- ],
82
  )
83
  log = logging.getLogger(__name__)
84
 
85
- # ─────────────────────────────────────────────
86
- # SHARED STATE (thread-safe via GIL for simple types)
87
- # ─────────────────────────────────────────────
88
- state = {
89
- "running": False,
90
- "status": "Idle",
91
- "novelbin_done": 0,
92
- "webnovel_done": 0,
93
- "errors": 0,
94
- "log_lines": [], # last N lines shown in UI
95
- "stop_flag": False,
96
- }
97
-
98
- def _log(msg: str, level: str = "info"):
99
- getattr(log, level)(msg)
100
- line = f"[{datetime.now().strftime('%H:%M:%S')}] {msg}"
101
- state["log_lines"].append(line)
102
- if len(state["log_lines"]) > 300:
103
- state["log_lines"] = state["log_lines"][-300:]
104
-
105
- # ─────────────────────────────────────────────
106
- # PROGRESS PERSISTENCE
107
- # ─────────────────────────────────────────────
108
- def load_progress() -> dict:
109
- if PROGRESS_FILE.exists():
110
- try:
111
- return json.loads(PROGRESS_FILE.read_text())
112
- except Exception:
113
- pass
114
- return {"novelbin_page": 1, "webnovel_page": 1,
115
- "novelbin_seen": [], "webnovel_seen": []}
116
-
117
- def save_progress(prog: dict):
118
- PROGRESS_FILE.write_text(json.dumps(prog, indent=2))
119
-
120
- # ─────────────────────────────────────────────
121
- # CSV / JSONL HELPERS
122
- # ─────────────────────────────────────────────
123
- def ensure_csv(path: Path, fieldnames: list):
124
- if not path.exists():
125
- with open(path, "w", newline="", encoding="utf-8") as f:
126
- writer = csv.DictWriter(f, fieldnames=fieldnames)
127
- writer.writeheader()
128
-
129
- def append_record(csv_path: Path, jsonl_path: Path, record: dict, fieldnames: list):
130
- with open(csv_path, "a", newline="", encoding="utf-8") as f:
131
- writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore")
132
- writer.writerow(record)
133
- with open(jsonl_path, "a", encoding="utf-8") as f:
134
- f.write(json.dumps(record, ensure_ascii=False) + "\n")
135
-
136
- def load_seen_ids(jsonl_path: Path, id_key: str) -> set:
137
- """Load already-scraped IDs from JSONL so we never duplicate."""
138
- seen = set()
139
- if jsonl_path.exists():
140
- for line in jsonl_path.read_text(encoding="utf-8").splitlines():
141
- try:
142
- obj = json.loads(line)
143
- seen.add(obj[id_key])
144
- except Exception:
145
- pass
146
- return seen
147
-
148
- # ─────────────────────────────────────────────
149
- # HTTP WITH RETRY
150
- # ─────────────────────────────────────────────
151
- def get(url: str, session: requests.Session, *, is_json=False, extra_headers=None):
152
- """GET with exponential-backoff retry. Returns (content, ok)."""
153
- hdrs = dict(HEADERS)
154
- if extra_headers:
155
- hdrs.update(extra_headers)
156
- for attempt in range(RETRY_MAX):
157
- if state["stop_flag"]:
158
- return None, False
159
- try:
160
- r = session.get(url, headers=hdrs, timeout=REQUEST_TIMEOUT)
161
- if r.status_code == 429:
162
- wait = RETRY_BACKOFF[min(attempt, len(RETRY_BACKOFF)-1)] * 2
163
- _log(f"Rate-limited on {url} β€” waiting {wait}s", "warning")
164
- time.sleep(wait)
165
- continue
166
- if r.status_code != 200:
167
- _log(f"HTTP {r.status_code} on {url} (attempt {attempt+1})", "warning")
168
- time.sleep(RETRY_BACKOFF[min(attempt, len(RETRY_BACKOFF)-1)])
169
- continue
170
- return (r.json() if is_json else r.text), True
171
- except requests.exceptions.RequestException as e:
172
- wait = RETRY_BACKOFF[min(attempt, len(RETRY_BACKOFF)-1)]
173
- _log(f"Request error on {url}: {e} β€” retry in {wait}s", "warning")
174
- time.sleep(wait)
175
- _log(f"FAILED after {RETRY_MAX} attempts: {url}", "error")
176
- state["errors"] += 1
177
- return None, False
178
-
179
- # ─────────────────────────────────────────────
180
- # NOVELBIN SCRAPER
181
- # ─────────────────────────────��───────────────
182
- NB_FIELDS = ["id", "title", "url", "description", "author", "genres", "status", "scraped_at"]
183
-
184
- def scrape_novelbin_page(page: int, session: requests.Session, seen: set) -> tuple[list, bool]:
185
- """Scrape one listing page. Returns (records, has_more)."""
186
- url = NOVELBIN_LIST.format(page=page)
187
- html, ok = get(url, session)
188
- if not ok or not html:
189
- return [], True # treat as transient error, not end-of-pages
190
-
191
- soup = BeautifulSoup(html, "html.parser")
192
-
193
- # Check if page has any novels at all
194
- novel_items = soup.select("div.col-novel-main .row.novel-item, li.novel-item, .list-novel .row")
195
- if not novel_items:
196
- # Try alternate selectors
197
- novel_items = soup.select(".col-novel-main .col-xs-12")
198
-
199
- if not novel_items:
200
- _log(f"novelbin page {page}: no items found β€” assuming end of listing", "warning")
201
- return [], False # signal end
202
-
203
- records = []
204
- for item in novel_items:
205
- try:
206
- # Title + link
207
- a_tag = item.select_one("h3.novel-title a, .novel-title a, a.novel-title")
208
- if not a_tag:
209
- continue
210
- title = a_tag.get_text(strip=True)
211
- href = a_tag.get("href", "")
212
- if not href:
213
- continue
214
- novel_url = href if href.startswith("http") else NOVELBIN_BASE + href
215
-
216
- novel_id = href.rstrip("/").split("/")[-1]
217
- if novel_id in seen:
218
- continue
219
-
220
- # Fetch detail page for description
221
- detail_html, dok = get(novel_url, session)
222
- desc = author = genres = status = ""
223
- if dok and detail_html:
224
- dsoup = BeautifulSoup(detail_html, "html.parser")
225
-
226
- # Description β€” multiple possible selectors
227
- for sel in ["div.desc-text", "div#tab-description", ".summary__content", ".description"]:
228
- el = dsoup.select_one(sel)
229
- if el:
230
- desc = el.get_text(" ", strip=True)[:4000]
231
- break
232
-
233
- # Author
234
- for sel in ["a[href*='author']", "span.author", ".author a"]:
235
- el = dsoup.select_one(sel)
236
- if el:
237
- author = el.get_text(strip=True)
238
- break
239
-
240
- # Genres
241
- genre_els = dsoup.select("div.categories a, .genres a, a.genre")
242
- genres = ", ".join(g.get_text(strip=True) for g in genre_els)
243
-
244
- # Status
245
- for sel in [".header-stats span.ongoing, .header-stats span.completed", ".info-item .completed, .info-item .ongoing"]:
246
- el = dsoup.select_one(sel)
247
- if el:
248
- status = el.get_text(strip=True)
249
- break
250
-
251
- record = {
252
- "id": novel_id,
253
- "title": title,
254
- "url": novel_url,
255
- "description": desc,
256
- "author": author,
257
- "genres": genres,
258
- "status": status,
259
- "scraped_at": datetime.utcnow().isoformat(),
260
- }
261
- records.append(record)
262
- seen.add(novel_id)
263
-
264
- time.sleep(random.uniform(0.3, 0.8)) # polite delay per detail page
265
-
266
- except Exception as e:
267
- _log(f"novelbin item parse error: {e}", "error")
268
- state["errors"] += 1
269
-
270
- return records, True
271
-
272
- def run_novelbin(prog: dict, session: requests.Session):
273
- _log("═══ Starting novelbin.com scrape ═══")
274
- state["status"] = "Scraping novelbin.com..."
275
- seen = load_seen_ids(NOVELBIN_JSONL, "id")
276
- _log(f"novelbin: {len(seen)} novels already saved, resuming from page {prog['novelbin_page']}")
277
- ensure_csv(NOVELBIN_CSV, NB_FIELDS)
278
-
279
- page = prog["novelbin_page"]
280
- consecutive_empty = 0
281
-
282
- while not state["stop_flag"]:
283
- _log(f"novelbin page {page}...")
284
- records, has_more = scrape_novelbin_page(page, session, seen)
285
-
286
- for r in records:
287
- append_record(NOVELBIN_CSV, NOVELBIN_JSONL, r, NB_FIELDS)
288
- state["novelbin_done"] += 1
289
-
290
- _log(f"novelbin page {page}: +{len(records)} novels (total {state['novelbin_done']})")
291
-
292
- if not records:
293
- consecutive_empty += 1
294
- if consecutive_empty >= 3:
295
- _log("novelbin: 3 consecutive empty pages β€” scrape complete.")
296
- break
297
- else:
298
- consecutive_empty = 0
299
-
300
- if not has_more:
301
- _log("novelbin: reached end of listing.")
302
- break
303
-
304
- page += 1
305
- prog["novelbin_page"] = page
306
- save_progress(prog)
307
-
308
- time.sleep(random.uniform(*PAGE_DELAY))
309
-
310
- _log(f"═══ novelbin done β€” {state['novelbin_done']} novels saved ═══")
311
 
312
- # ─────────────────────────────────────────────
313
- # WEBNOVEL SCRAPER (uses public JSON API)
314
- # ─────────────────────────────────────────────
315
- WN_FIELDS = ["id", "title", "url", "description", "author", "genres", "status",
316
- "total_chapters", "rating", "scraped_at"]
317
 
318
- def scrape_webnovel_page(page: int, session: requests.Session, seen: set) -> tuple[list, bool]:
319
- """One page of webnovel category API. Returns (records, has_more)."""
320
- url = WEBNOVEL_API.format(page=page)
321
- extra = {
322
- "Referer": "https://www.webnovel.com/",
323
- "Accept": "application/json, text/javascript, */*; q=0.01",
324
- "X-Requested-With": "XMLHttpRequest",
325
- }
326
- data, ok = get(url, session, is_json=True, extra_headers=extra)
327
- if not ok or not data:
328
- return [], True
329
-
330
- try:
331
- code = data.get("code", -1)
332
- if code != 0:
333
- _log(f"webnovel API non-zero code {code} on page {page}", "warning")
334
- return [], True
335
-
336
- items = (data.get("data") or {}).get("items") or []
337
- if not items:
338
- return [], False # end of pages
339
-
340
- except Exception as e:
341
- _log(f"webnovel page {page} parse error: {e}", "error")
342
- return [], True
343
-
344
- records = []
345
- for item in items:
346
  try:
347
- novel_id = str(item.get("bookId") or item.get("id") or "")
348
- if not novel_id or novel_id in seen:
349
- continue
350
-
351
- title = item.get("bookName") or item.get("name") or ""
352
- author = item.get("authorName") or ""
353
- status = "Completed" if item.get("isComplete") else "Ongoing"
354
- chapters = str(item.get("totalChapterNum") or item.get("chapterNum") or "")
355
- rating = str(item.get("totalScore") or item.get("reviewTotal") or "")
356
- cat_list = item.get("categoryItems") or item.get("categoryList") or []
357
- genres = ", ".join(c.get("categoryName", "") for c in cat_list if isinstance(c, dict))
358
- novel_url = f"https://www.webnovel.com/book/{novel_id}"
359
-
360
- # Description is sometimes in the listing, sometimes needs a detail call
361
- desc = item.get("description") or item.get("intro") or ""
362
- if not desc:
363
- desc = fetch_webnovel_description(novel_id, session)
364
-
365
- record = {
366
- "id": novel_id,
367
- "title": title,
368
- "url": novel_url,
369
- "description": desc[:4000],
370
- "author": author,
371
- "genres": genres,
372
- "status": status,
373
- "total_chapters": chapters,
374
- "rating": rating,
375
- "scraped_at": datetime.utcnow().isoformat(),
376
- }
377
- records.append(record)
378
- seen.add(novel_id)
379
-
380
- except Exception as e:
381
- _log(f"webnovel item parse error: {e}", "error")
382
- state["errors"] += 1
383
-
384
- return records, True
385
-
386
- def fetch_webnovel_description(novel_id: str, session: requests.Session) -> str:
387
- """Fallback: fetch book detail API for description."""
388
- url = (
389
- f"https://www.webnovel.com/go/pcm/book/getBookDetail"
390
- f"?_csrfToken=&bookId={novel_id}"
391
- )
392
- extra = {
393
- "Referer": f"https://www.webnovel.com/book/{novel_id}",
394
- "Accept": "application/json, */*; q=0.01",
395
- "X-Requested-With": "XMLHttpRequest",
396
- }
397
- data, ok = get(url, session, is_json=True, extra_headers=extra)
398
- if not ok or not data:
399
- return ""
400
- try:
401
- return (data.get("data") or {}).get("bookInfo", {}).get("description", "")
402
- except Exception:
403
- return ""
404
-
405
- def run_webnovel(prog: dict, session: requests.Session):
406
- _log("═══ Starting webnovel.com scrape ═══")
407
- state["status"] = "Scraping webnovel.com..."
408
- seen = load_seen_ids(WEBNOVEL_JSONL, "id")
409
- _log(f"webnovel: {len(seen)} novels already saved, resuming from page {prog['webnovel_page']}")
410
- ensure_csv(WEBNOVEL_CSV, WN_FIELDS)
411
-
412
- page = prog["webnovel_page"]
413
  consecutive_empty = 0
414
 
415
- while not state["stop_flag"]:
416
- _log(f"webnovel page {page}...")
417
- records, has_more = scrape_webnovel_page(page, session, seen)
 
 
 
 
 
 
418
 
419
- for r in records:
420
- append_record(WEBNOVEL_CSV, WEBNOVEL_JSONL, r, WN_FIELDS)
421
- state["webnovel_done"] += 1
422
 
423
- _log(f"webnovel page {page}: +{len(records)} novels (total {state['webnovel_done']})")
 
424
 
425
- if not records:
426
  consecutive_empty += 1
427
- if consecutive_empty >= 3:
428
- _log("webnovel: 3 consecutive empty pages β€” scrape complete.")
429
  break
430
  else:
431
  consecutive_empty = 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
432
 
433
- if not has_more:
434
- _log("webnovel: reached end of listing.")
435
- break
436
-
437
- page += 1
438
- prog["webnovel_page"] = page
439
- save_progress(prog)
440
-
441
- time.sleep(random.uniform(*PAGE_DELAY))
442
-
443
- _log(f"═══ webnovel done β€” {state['webnovel_done']} novels saved ═══")
444
-
445
- # ────────────────────────��────────────────────
446
- # MASTER RUNNER
447
- # ─────────────────────────────────────────────
448
- def master_run():
449
- state["running"] = True
450
- state["stop_flag"] = False
451
- state["errors"] = 0
452
-
453
- # Restore saved counts from existing JSONL files
454
- state["novelbin_done"] = sum(1 for _ in NOVELBIN_JSONL.open(encoding="utf-8")) if NOVELBIN_JSONL.exists() else 0
455
- state["webnovel_done"] = sum(1 for _ in WEBNOVEL_JSONL.open(encoding="utf-8")) if WEBNOVEL_JSONL.exists() else 0
456
-
457
- prog = load_progress()
458
-
459
- session = requests.Session()
460
- session.headers.update(HEADERS)
461
-
462
- try:
463
- run_novelbin(prog, session)
464
- if not state["stop_flag"]:
465
- run_webnovel(prog, session)
466
- except Exception as e:
467
- _log(f"FATAL: {e}\n{traceback.format_exc()}", "error")
468
- finally:
469
- state["running"] = False
470
- state["status"] = "Finished βœ…" if not state["stop_flag"] else "Stopped β›”"
471
- _log(f"Run complete. novelbin={state['novelbin_done']} webnovel={state['webnovel_done']} errors={state['errors']}")
472
- session.close()
473
-
474
- # ─────────────────────────────────────────────
475
- # GRADIO UI
476
- # ─────────────────────────────────────────────
477
- def ui_start():
478
- if state["running"]:
479
- return "⚠️ Already running!"
480
- state["log_lines"] = []
481
- thread = threading.Thread(target=master_run, daemon=True)
482
- thread.start()
483
- return "πŸš€ Scraper started!"
484
-
485
- def ui_stop():
486
- state["stop_flag"] = True
487
- return "β›” Stop signal sent β€” finishing current page..."
488
-
489
- def ui_status():
490
- lines = "\n".join(state["log_lines"][-80:])
491
- nb_size = f"{NOVELBIN_JSONL.stat().st_size/1024:.1f} KB" if NOVELBIN_JSONL.exists() else "β€”"
492
- wn_size = f"{WEBNOVEL_JSONL.stat().st_size/1024:.1f} KB" if WEBNOVEL_JSONL.exists() else "β€”"
493
- info = (
494
- f"Status : {state['status']}\n"
495
- f"novelbin: {state['novelbin_done']} novels ({nb_size})\n"
496
- f"webnovel: {state['webnovel_done']} novels ({wn_size})\n"
497
- f"Errors : {state['errors']}\n"
498
- f"{'─'*40}\n"
499
- f"{lines}"
500
- )
501
- return info
502
-
503
- def ui_files():
504
- files = []
505
- for p in [NOVELBIN_CSV, NOVELBIN_JSONL, WEBNOVEL_CSV, WEBNOVEL_JSONL, LOG_FILE, PROGRESS_FILE]:
506
- if p.exists():
507
- files.append(str(p))
508
- return files if files else None
509
-
510
- with gr.Blocks(title="Novel Scraper") as demo:
511
- gr.Markdown(
512
- "# πŸ“š Novel Scraper\n"
513
- "Scrapes **title + description** of every novel on **novelbin.com** and **webnovel.com**.\n"
514
- "Output is saved as **CSV + JSONL** in the `output/` folder. Fully resumable."
515
- )
516
-
517
- with gr.Row():
518
- btn_start = gr.Button("β–Ά Start Scraper", variant="primary")
519
- btn_stop = gr.Button("⏹ Stop", variant="stop")
520
- btn_refresh = gr.Button("πŸ”„ Refresh Status")
521
-
522
- status_box = gr.Textbox(
523
- label="Live Log",
524
- lines=25,
525
- max_lines=25,
526
- interactive=False,
527
- value="Press β–Ά Start Scraper to begin.",
528
- )
529
-
530
- file_out = gr.File(label="Download Output Files", file_count="multiple")
531
 
532
- btn_start.click(ui_start, outputs=status_box)
533
- btn_stop.click(ui_stop, outputs=status_box)
534
- btn_refresh.click(ui_status, outputs=status_box)
535
- btn_refresh.click(ui_files, outputs=file_out)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
536
 
537
- # Gradio 4.x auto-poll β€” fires every N seconds while the tab is open
538
- demo.load(ui_status, outputs=status_box, every=5)
539
- demo.load(ui_files, outputs=file_out, every=10)
540
 
541
  if __name__ == "__main__":
542
- demo.launch()
 
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()