Update app.py
Browse files
app.py
CHANGED
|
@@ -1,121 +1,125 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
import requests
|
| 3 |
from bs4 import BeautifulSoup
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 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 |
-
|
|
|
|
|
|
| 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()
|