Krixter commited on
Commit
94e8f4d
Β·
verified Β·
1 Parent(s): d5118b1

Update app.py

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