Krixter commited on
Commit
5099643
ยท
verified ยท
1 Parent(s): 8c7e421

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +225 -102
app.py CHANGED
@@ -1,125 +1,248 @@
1
  """
2
- WebNovel link scraper
3
- ----------------------
4
- Crawls WebNovel's genre/tag/search listing pages and collects every unique
5
- novel ("/book/...") link it finds, then writes them all to a .txt file.
6
-
7
- Run on Hugging Face (Space / Notebook / Colab-style env) or locally:
8
- pip install requests beautifulsoup4
9
- python webnovel_link_scraper.py
10
-
11
- Notes:
12
- - WebNovel loads a lot of its catalog dynamically via JS/AJAX. This script
13
- only sees server-rendered HTML, so it will pick up a large subset of
14
- novels (everything reachable from listing/search pages) rather than
15
- literally every title on the site.
16
- - Be a good citizen: there's a delay between requests, and you should check
17
- https://www.webnovel.com/robots.txt and their Terms of Service before
18
- running this at scale or pointing it at many pages.
19
  """
20
 
 
21
  import re
 
22
  import time
 
23
  import random
24
- from urllib.parse import urljoin
 
25
 
26
  import requests
27
  from bs4 import BeautifulSoup
 
28
 
29
- BASE_URL = "https://www.webnovel.com"
30
- OUTPUT_FILE = "webnovel_links.txt"
 
31
 
32
- HEADERS = {
33
- "User-Agent": (
34
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
35
- "(KHTML, like Gecko) Chrome/124.0 Safari/537.36"
36
- )
37
- }
38
-
39
- # A handful of starting points that list lots of novels.
40
- # Add/remove genre or tag slugs as you like.
41
- SEED_PATHS = [
42
- "/category/all",
43
- "/category/all?orderBy=0", # popularity sort, if supported
44
- "/category/all?orderBy=1", # other sort options
45
- "/tags/fantasy",
46
- "/tags/romance",
47
- "/tags/action",
48
- "/tags/adventure",
49
- "/tags/r18",
50
- "/tags/system",
51
- ]
52
-
53
- # Matches links like /book/some-novel-title_12345678905012345
54
- BOOK_LINK_RE = re.compile(r"^/book/[\w\-]+$")
55
-
56
- MAX_PAGES_PER_SEED = 20 # how many "page=" pages to walk per seed URL
57
- REQUEST_DELAY_RANGE = (1.0, 2.5) # polite random delay between requests
58
-
59
-
60
- def fetch(url: str) -> str | None:
61
- try:
62
- resp = requests.get(url, headers=HEADERS, timeout=15)
63
- if resp.status_code == 200:
64
- return resp.text
65
- print(f" [!] {resp.status_code} for {url}")
66
- except requests.RequestException as e:
67
- print(f" [!] request failed for {url}: {e}")
68
- return None
69
-
70
-
71
- def extract_book_links(html: str) -> set[str]:
72
- soup = BeautifulSoup(html, "html.parser")
73
- found = set()
74
- for a in soup.find_all("a", href=True):
75
- href = a["href"]
76
- # Normalize relative/absolute links pointing at /book/...
77
- if href.startswith(BASE_URL):
78
- href = href[len(BASE_URL):]
79
- if BOOK_LINK_RE.match(href):
80
- found.add(urljoin(BASE_URL, href))
81
- return found
82
-
83
-
84
- def crawl():
85
- all_links: set[str] = set()
86
 
87
- for seed in SEED_PATHS:
88
- print(f"\n=== Seed: {seed} ===")
89
- for page in range(1, MAX_PAGES_PER_SEED + 1):
90
- sep = "&" if "?" in seed else "?"
91
- url = f"{BASE_URL}{seed}{sep}pageIndex={page}"
92
- print(f"Fetching {url}")
93
 
94
- html = fetch(url)
95
- if not html:
96
- break
97
 
98
- new_links = extract_book_links(html)
99
- before = len(all_links)
100
- all_links.update(new_links)
101
- added = len(all_links) - before
102
- print(f" found {len(new_links)} links on page, {added} new")
 
 
 
 
103
 
104
- # Stop walking this seed once a page yields nothing new
105
- if added == 0 and page > 1:
106
- break
107
 
108
- time.sleep(random.uniform(*REQUEST_DELAY_RANGE))
109
-
110
- return all_links
111
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
 
113
- def main():
114
- links = crawl()
115
- print(f"\nTotal unique novel links collected: {len(links)}")
116
 
117
- with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
118
- for link in sorted(links):
119
- f.write(link + "\n")
120
 
121
- print(f"Saved to {OUTPUT_FILE}")
 
 
 
 
 
 
122
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
 
124
  if __name__ == "__main__":
125
- main()
 
1
  """
2
+ Link Scraper โ€” Hugging Face Space
3
+ ----------------------------------
4
+ Upload a .txt file containing one URL per line.
5
+ The app will:
6
+ 1. Visit each URL one by one (or with a few parallel workers)
7
+ 2. Save the raw webpage as an .html file
8
+ 3. Extract the page <title> and meta description
9
+ 4. Produce one combined .txt file with: URL | Title | Description
10
+ 5. Zip all the saved .html files for download
11
+
12
+ Built for Gradio on Hugging Face Spaces.
 
 
 
 
 
 
13
  """
14
 
15
+ import os
16
  import re
17
+ import io
18
  import time
19
+ import zipfile
20
  import random
21
+ import concurrent.futures
22
+ from urllib.parse import urlparse
23
 
24
  import requests
25
  from bs4 import BeautifulSoup
26
+ import gradio as gr
27
 
28
+ # ---------------------------------------------------------------------------
29
+ # Config
30
+ # ---------------------------------------------------------------------------
31
 
32
+ USER_AGENT = (
33
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
34
+ "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
35
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
 
37
+ WORK_DIR = "/tmp/link_scraper"
38
+ HTML_DIR = os.path.join(WORK_DIR, "html_pages")
39
+ os.makedirs(HTML_DIR, exist_ok=True)
 
 
 
40
 
 
 
 
41
 
42
+ def slugify_for_filename(url: str, index: int) -> str:
43
+ """Turn a URL into a safe, short filename."""
44
+ parsed = urlparse(url)
45
+ path = (parsed.netloc + parsed.path).strip("/")
46
+ path = re.sub(r"[^a-zA-Z0-9._-]+", "_", path)
47
+ path = path[:120] # keep filenames from getting too long
48
+ if not path:
49
+ path = "page"
50
+ return f"{index:05d}_{path}.html"
51
 
 
 
 
52
 
53
+ def extract_title_and_description(html: str) -> tuple[str, str]:
54
+ """Pull the best available title and description from a page's HTML."""
55
+ soup = BeautifulSoup(html, "html.parser")
56
 
57
+ title = ""
58
+ og_title = soup.find("meta", property="og:title")
59
+ if og_title and og_title.get("content"):
60
+ title = og_title["content"].strip()
61
+ if not title and soup.title and soup.title.string:
62
+ title = soup.title.string.strip()
63
+
64
+ description = ""
65
+ meta_desc = soup.find("meta", attrs={"name": "description"})
66
+ if meta_desc and meta_desc.get("content"):
67
+ description = meta_desc["content"].strip()
68
+ if not description:
69
+ og_desc = soup.find("meta", property="og:description")
70
+ if og_desc and og_desc.get("content"):
71
+ description = og_desc["content"].strip()
72
+
73
+ # Keep everything on a single line in the combined output
74
+ title = re.sub(r"\s+", " ", title)
75
+ description = re.sub(r"\s+", " ", description)
76
+ return title, description
77
+
78
+
79
+ def fetch_one(index: int, url: str, save_html: bool, timeout: int):
80
+ """Fetch a single URL. Returns a result dict; never raises."""
81
+ result = {
82
+ "index": index,
83
+ "url": url,
84
+ "title": "",
85
+ "description": "",
86
+ "status": "",
87
+ "html_filename": "",
88
+ }
89
+ try:
90
+ resp = requests.get(
91
+ url,
92
+ headers={"User-Agent": USER_AGENT},
93
+ timeout=timeout,
94
+ )
95
+ result["status"] = f"HTTP {resp.status_code}"
96
+ resp.raise_for_status()
97
+ html = resp.text
98
+
99
+ if save_html:
100
+ filename = slugify_for_filename(url, index)
101
+ filepath = os.path.join(HTML_DIR, filename)
102
+ with open(filepath, "w", encoding="utf-8") as f:
103
+ f.write(html)
104
+ result["html_filename"] = filename
105
+
106
+ title, description = extract_title_and_description(html)
107
+ result["title"] = title
108
+ result["description"] = description
109
+
110
+ except Exception as e: # noqa: BLE001
111
+ result["status"] = f"ERROR: {e}"
112
+
113
+ return result
114
+
115
+
116
+ def zip_html_folder(zip_path: str):
117
+ with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
118
+ for fname in os.listdir(HTML_DIR):
119
+ zf.write(os.path.join(HTML_DIR, fname), arcname=fname)
120
+
121
+
122
+ def run_scraper(
123
+ file_obj,
124
+ save_html: bool,
125
+ workers: int,
126
+ delay_seconds: float,
127
+ timeout_seconds: int,
128
+ progress=gr.Progress(track_tqdm=False),
129
+ ):
130
+ """Main generator driving the Gradio UI: yields (log_text, combined_file, zip_file)."""
131
+
132
+ if file_obj is None:
133
+ yield "Please upload a .txt file with one URL per line.", None, None
134
+ return
135
+
136
+ # Reset working folders for a clean run
137
+ if os.path.exists(HTML_DIR):
138
+ for f in os.listdir(HTML_DIR):
139
+ try:
140
+ os.remove(os.path.join(HTML_DIR, f))
141
+ except OSError:
142
+ pass
143
+ os.makedirs(HTML_DIR, exist_ok=True)
144
+
145
+ with open(file_obj, "r", encoding="utf-8", errors="ignore") as f:
146
+ urls = [line.strip() for line in f if line.strip()]
147
+
148
+ total = len(urls)
149
+ if total == 0:
150
+ yield "The uploaded file has no URLs in it.", None, None
151
+ return
152
+
153
+ log_lines = [f"Found {total} URLs. Starting...\n"]
154
+ yield "".join(log_lines), None, None
155
+
156
+ results = [None] * total
157
+ workers = max(1, min(int(workers), 16))
158
+
159
+ def worker_task(i, url):
160
+ if delay_seconds > 0:
161
+ time.sleep(delay_seconds * random.uniform(0.5, 1.5))
162
+ return fetch_one(i, url, save_html, int(timeout_seconds))
163
+
164
+ completed = 0
165
+ with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
166
+ futures = {
167
+ executor.submit(worker_task, i, url): i for i, url in enumerate(urls)
168
+ }
169
+ for future in concurrent.futures.as_completed(futures):
170
+ i = futures[future]
171
+ res = future.result()
172
+ results[i] = res
173
+ completed += 1
174
+
175
+ short_url = res["url"][:70]
176
+ log_lines.append(f"[{completed}/{total}] {res['status']} - {short_url}")
177
+ progress(completed / total, desc=f"{completed}/{total} pages done")
178
+
179
+ # Stream log updates periodically (every 5 items or near the end)
180
+ if completed % 5 == 0 or completed == total:
181
+ yield "\n".join(log_lines[-200:]), None, None
182
+
183
+ # Build combined output file
184
+ combined_path = os.path.join(WORK_DIR, "combined_results.txt")
185
+ with open(combined_path, "w", encoding="utf-8") as f:
186
+ for res in results:
187
+ f.write(f"URL: {res['url']}\n")
188
+ f.write(f"Title: {res['title']}\n")
189
+ f.write(f"Description: {res['description']}\n")
190
+ f.write(f"Status: {res['status']}\n")
191
+ f.write("-" * 60 + "\n")
192
+
193
+ zip_path = None
194
+ if save_html:
195
+ zip_path = os.path.join(WORK_DIR, "saved_html_pages.zip")
196
+ zip_html_folder(zip_path)
197
+
198
+ ok_count = sum(1 for r in results if r["status"].startswith("HTTP 2"))
199
+ log_lines.append(
200
+ f"\nDone. {ok_count}/{total} pages fetched successfully. "
201
+ f"Combined file and {'zip ' if save_html else ''}ready for download below."
202
+ )
203
 
204
+ yield "\n".join(log_lines[-200:]), combined_path, zip_path
 
 
205
 
 
 
 
206
 
207
+ with gr.Blocks(title="Link Scraper") as demo:
208
+ gr.Markdown(
209
+ "# Link Scraper\n"
210
+ "Upload a `.txt` file with one URL per line. The app visits each link, "
211
+ "saves the page as HTML, pulls out the title + description, and gives you "
212
+ "back one combined `.txt` file (plus a `.zip` of all the saved HTML pages)."
213
+ )
214
 
215
+ with gr.Row():
216
+ file_input = gr.File(label="Upload .txt file of URLs", file_types=[".txt"])
217
+
218
+ with gr.Row():
219
+ save_html_cb = gr.Checkbox(label="Save raw HTML files (zipped)", value=True)
220
+ workers_slider = gr.Slider(
221
+ label="Parallel workers (lower = safer, less likely to get blocked)",
222
+ minimum=1, maximum=16, step=1, value=4,
223
+ )
224
+ delay_slider = gr.Slider(
225
+ label="Delay between requests per worker (seconds)",
226
+ minimum=0, maximum=10, step=0.5, value=1,
227
+ )
228
+ timeout_slider = gr.Slider(
229
+ label="Timeout per request (seconds)",
230
+ minimum=5, maximum=60, step=5, value=20,
231
+ )
232
+
233
+ start_btn = gr.Button("Start Scraping", variant="primary")
234
+
235
+ log_box = gr.Textbox(label="Progress / Log", lines=20, max_lines=20)
236
+
237
+ with gr.Row():
238
+ combined_output = gr.File(label="Combined results (.txt)")
239
+ zip_output = gr.File(label="All saved pages (.zip)")
240
+
241
+ start_btn.click(
242
+ fn=run_scraper,
243
+ inputs=[file_input, save_html_cb, workers_slider, delay_slider, timeout_slider],
244
+ outputs=[log_box, combined_output, zip_output],
245
+ )
246
 
247
  if __name__ == "__main__":
248
+ demo.queue().launch()