Krixter commited on
Commit
d126a36
ยท
verified ยท
1 Parent(s): d003e2a

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +114 -314
app.py CHANGED
@@ -1,321 +1,121 @@
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()
 
1
  import gradio as gr
2
  import requests
3
  from bs4 import BeautifulSoup
 
 
4
  import os
5
+ import zipfile
6
+ import shutil
7
+
8
+ def scrape_novels(start_page, end_page, progress=gr.Progress()):
9
+ base_url = "https://www.novelhall.com/all2022-{}.html"
10
+
11
+ # Reset and prepare the HTML directory for a fresh scrape
12
+ html_dir = "html_pages"
13
+ if os.path.exists(html_dir):
14
+ shutil.rmtree(html_dir)
15
+ os.makedirs(html_dir, exist_ok=True)
16
+
17
+ combined_txt_path = "combined_novels.txt"
18
+ headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'}
19
+
20
+ with open(combined_txt_path, 'w', encoding='utf-8') as combined_file:
21
+
22
+ # Iterate over the specified page range
23
+ for page in progress.tqdm(range(int(start_page), int(end_page) + 1), desc="Scraping List Pages"):
24
+ page_url = base_url.format(page)
25
+ try:
26
+ response = requests.get(page_url, headers=headers, timeout=15)
27
+ soup = BeautifulSoup(response.content, 'html.parser')
28
+
29
+ # Extract all novel links from the current list page
30
+ links = []
31
+ for li in soup.find_all('li', class_='btm'):
32
+ a_tag = li.find('a')
33
+ if a_tag and 'href' in a_tag.attrs:
34
+ href = a_tag['href']
35
+ if href.startswith('/'):
36
+ href = "https://www.novelhall.com" + href
37
+ links.append(href)
38
+
39
+ # Visit each novel link individually
40
+ for link in links:
41
+ try:
42
+ novel_response = requests.get(link, headers=headers, timeout=15)
43
+ novel_soup = BeautifulSoup(novel_response.content, 'html.parser')
44
+
45
+ # 1. Extract Title
46
+ title = "Unknown Title"
47
+ info_div = novel_soup.find('div', class_='book-info')
48
+ if info_div and info_div.find('h1'):
49
+ title = info_div.find('h1').text.strip()
50
+ elif novel_soup.find('h1'):
51
+ title = novel_soup.find('h1').text.strip()
52
+
53
+ # 2. Extract Description (Ensuring we don't scrape the truncated version)
54
+ description = "Unknown Description"
55
+ intro_div = novel_soup.find('div', class_='intro')
56
+ if intro_div:
57
+ close_wrap = intro_div.find('span', class_='js-close-wrap')
58
+ if close_wrap:
59
+ # Remove the "back<<" span text
60
+ back_span = close_wrap.find('span', class_='blue')
61
+ if back_span:
62
+ back_span.decompose()
63
+ description = close_wrap.text.strip()
64
+ else:
65
+ description = intro_div.text.strip()
66
+
67
+ # 3. Save the HTML file locally
68
+ # Sanitize title to ensure the filename is valid
69
+ safe_title = "".join(c for c in title if c.isalnum() or c in " -_").rstrip()
70
+ if not safe_title:
71
+ safe_title = link.strip('/').split('/')[-1]
72
+
73
+ html_filename = f"{html_dir}/{safe_title}.html"
74
+ with open(html_filename, 'w', encoding='utf-8') as html_file:
75
+ html_file.write(novel_response.text)
76
+
77
+ # 4. Write data to the Combined TXT File
78
+ combined_file.write(f"Title: {title}\n")
79
+ combined_file.write(f"Link: {link}\n")
80
+ combined_file.write(f"Description:\n{description}\n")
81
+ combined_file.write("-" * 80 + "\n\n")
82
+
83
+ except Exception as e:
84
+ combined_file.write(f"Error scraping novel {link}: {e}\n\n")
85
+
86
+ except Exception as e:
87
+ combined_file.write(f"Error scraping list page {page_url}: {e}\n\n")
88
+
89
+ # Compress the text file and HTML directories into a downloadable ZIP
90
+ zip_path = "scraped_data.zip"
91
+ with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
92
+ zipf.write(combined_txt_path, arcname=combined_txt_path)
93
+ for root, _, files in os.walk(html_dir):
94
+ for file in files:
95
+ file_path = os.path.join(root, file)
96
+ zipf.write(file_path, arcname=os.path.join(html_dir, file))
97
+
98
+ return zip_path, f"Successfully processed pages {start_page} to {end_page}!"
99
+
100
+ # Set up the UI logic
101
+ with gr.Blocks(title="Novelhall Web Scraper") as demo:
102
+ gr.Markdown("# ๐Ÿ“š Novelhall Scraper Application")
103
+ gr.Markdown("Extracts individual novel links, saves their HTML structure, and compiles titles and full descriptions into a combined text file. **Note:** Large ranges take a long time to run; scrape in batches to prevent server timeouts.")
104
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  with gr.Row():
106
+ start_pg = gr.Number(label="Start Page", value=1, precision=0)
107
+ end_pg = gr.Number(label="End Page", value=2, precision=0)
108
+
109
+ scrape_btn = gr.Button("Start Scraping", variant="primary")
110
+
111
+ output_msg = gr.Textbox(label="Status Tracker")
112
+ output_file = gr.File(label="Download Archive (Contains HTMLs & Combined TXT)")
113
+
114
+ # Link the function
115
+ scrape_btn.click(
116
+ fn=scrape_novels,
117
+ inputs=[start_pg, end_pg],
118
+ outputs=[output_file, output_msg]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  )
120
 
121
+ demo.launch()