Krixter commited on
Commit
45cb56f
Β·
verified Β·
1 Parent(s): b094e4a

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +111 -287
app.py CHANGED
@@ -1,296 +1,120 @@
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_synopsis(description: str) -> str:
54
- """WebNovel-style descriptions read like:
55
- 'Read X Online for Free, written by ... and the synopsis is: <actual synopsis>'
56
- Strip everything before the marker so only the real synopsis remains.
57
- If the marker isn't found (non-WebNovel page), just return the description as-is.
58
- """
59
- marker = "the synopsis is:"
60
- idx = description.lower().find(marker)
61
- if idx != -1:
62
- return description[idx + len(marker):].strip()
63
- return description.strip()
64
-
65
-
66
- def extract_title_and_description(html: str) -> tuple[str, str]:
67
- """Pull the best available title and synopsis/description from a page's HTML.
68
-
69
- For WebNovel pages the meta description is deliberately truncated with '...'.
70
- The full synopsis lives inside a <div class="... j_synopsis ..."> in the body.
71
- We prefer that element; the meta tags are only a fallback for other sites.
72
- """
73
- soup = BeautifulSoup(html, "html.parser")
74
-
75
- # --- Title ---
76
- title = ""
77
- og_title = soup.find("meta", property="og:title")
78
- if og_title and og_title.get("content"):
79
- title = og_title["content"].strip()
80
- if not title and soup.title and soup.title.string:
81
- title = soup.title.string.strip()
82
-
83
- # --- Synopsis: try full body element first (WebNovel and similar sites) ---
84
- synopsis = ""
85
-
86
- # 1. WebNovel: <div class="... j_synopsis ...">
87
- j_syn = soup.find(class_=lambda c: c and "j_synopsis" in c.split())
88
- if j_syn:
89
- # Replace <br> tags with newlines before extracting text
90
- for br in j_syn.find_all("br"):
91
- br.replace_with("\n")
92
- # Remove the "read more" toggle span that WebNovel appends
93
- for rm in j_syn.find_all("span", class_="_readmore"):
94
- rm.decompose()
95
- synopsis = j_syn.get_text(separator=" ").strip()
96
-
97
- # 2. Fallback: any element whose class contains common synopsis keywords
98
- if not synopsis:
99
- for cls_hint in ("book-intro", "book_intro", "bookIntro",
100
- "detail-desc", "book-desc", "bookDesc",
101
- "synopsis", "j_intro"):
102
- el = soup.find(class_=lambda c: c and cls_hint in c)
103
- if el:
104
- for br in el.find_all("br"):
105
- br.replace_with("\n")
106
- synopsis = el.get_text(separator=" ").strip()
107
- if synopsis:
108
- break
109
-
110
- # 3. Last resort: meta tags (always truncated on WebNovel, but better than nothing)
111
- if not synopsis:
112
- meta_desc = soup.find("meta", attrs={"name": "description"})
113
- if meta_desc and meta_desc.get("content"):
114
- synopsis = extract_synopsis(meta_desc["content"].strip())
115
- if not synopsis:
116
- og_desc = soup.find("meta", property="og:description")
117
- if og_desc and og_desc.get("content"):
118
- synopsis = extract_synopsis(og_desc["content"].strip())
119
-
120
- # Normalise whitespace: collapse runs of spaces/tabs but keep newlines
121
- title = re.sub(r"[ \t]+", " ", title).strip()
122
- synopsis = re.sub(r"[ \t]+", " ", synopsis).strip()
123
-
124
- return title, synopsis
125
-
126
-
127
- def fetch_one(index: int, url: str, save_html: bool, timeout: int):
128
- """Fetch a single URL. Returns a result dict; never raises."""
129
- result = {
130
- "index": index,
131
- "url": url,
132
- "title": "",
133
- "description": "",
134
- "status": "",
135
- "html_filename": "",
136
- }
137
- try:
138
- resp = requests.get(
139
- url,
140
- headers={"User-Agent": USER_AGENT},
141
- timeout=timeout,
142
- )
143
- result["status"] = f"HTTP {resp.status_code}"
144
- resp.raise_for_status()
145
- html = resp.text
146
-
147
- if save_html:
148
- filename = slugify_for_filename(url, index)
149
- filepath = os.path.join(HTML_DIR, filename)
150
- with open(filepath, "w", encoding="utf-8") as f:
151
- f.write(html)
152
- result["html_filename"] = filename
153
-
154
- title, description = extract_title_and_description(html)
155
- result["title"] = title
156
- result["description"] = description
157
-
158
- except Exception as e: # noqa: BLE001
159
- result["status"] = f"ERROR: {e}"
160
-
161
- return result
162
-
163
-
164
- def zip_html_folder(zip_path: str):
165
- with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
166
- for fname in os.listdir(HTML_DIR):
167
- zf.write(os.path.join(HTML_DIR, fname), arcname=fname)
168
-
169
-
170
- def run_scraper(
171
- file_obj,
172
- save_html: bool,
173
- workers: int,
174
- delay_seconds: float,
175
- timeout_seconds: int,
176
- progress=gr.Progress(track_tqdm=False),
177
- ):
178
- """Main generator driving the Gradio UI: yields (log_text, combined_file, zip_file)."""
179
-
180
- if file_obj is None:
181
- yield "Please upload a .txt file with one URL per line.", None, None
182
- return
183
-
184
- # Reset working folders for a clean run
185
- if os.path.exists(HTML_DIR):
186
- for f in os.listdir(HTML_DIR):
187
  try:
188
- os.remove(os.path.join(HTML_DIR, f))
189
- except OSError:
190
- pass
191
- os.makedirs(HTML_DIR, exist_ok=True)
192
-
193
- with open(file_obj, "r", encoding="utf-8", errors="ignore") as f:
194
- urls = [line.strip() for line in f if line.strip()]
195
-
196
- total = len(urls)
197
- if total == 0:
198
- yield "The uploaded file has no URLs in it.", None, None
199
- return
200
-
201
- log_lines = [f"Found {total} URLs. Starting...\n"]
202
- yield "".join(log_lines), None, None
203
-
204
- results = [None] * total
205
- workers = max(1, min(int(workers), 16))
206
-
207
- def worker_task(i, url):
208
- if delay_seconds > 0:
209
- time.sleep(delay_seconds * random.uniform(0.5, 1.5))
210
- return fetch_one(i, url, save_html, int(timeout_seconds))
211
-
212
- completed = 0
213
- with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
214
- futures = {
215
- executor.submit(worker_task, i, url): i for i, url in enumerate(urls)
216
- }
217
- for future in concurrent.futures.as_completed(futures):
218
- i = futures[future]
219
- res = future.result()
220
- results[i] = res
221
- completed += 1
222
-
223
- short_url = res["url"][:70]
224
- log_lines.append(f"[{completed}/{total}] {res['status']} - {short_url}")
225
- progress(completed / total, desc=f"{completed}/{total} pages done")
226
-
227
- # Stream log updates periodically (every 5 items or near the end)
228
- if completed % 5 == 0 or completed == total:
229
- yield "\n".join(log_lines[-200:]), None, None
230
-
231
- # Build combined output file
232
- combined_path = os.path.join(WORK_DIR, "combined_results.txt")
233
- with open(combined_path, "w", encoding="utf-8") as f:
234
- for res in results:
235
- f.write(f"URL: {res['url']}\n")
236
- f.write(f"Title: {res['title']}\n")
237
- f.write(f"Synopsis: {res['description']}\n")
238
- f.write(f"Status: {res['status']}\n")
239
- f.write("-" * 60 + "\n")
240
-
241
- zip_path = None
242
- if save_html:
243
- zip_path = os.path.join(WORK_DIR, "saved_html_pages.zip")
244
- zip_html_folder(zip_path)
245
-
246
- ok_count = sum(1 for r in results if r["status"].startswith("HTTP 2"))
247
- log_lines.append(
248
- f"\nDone. {ok_count}/{total} pages fetched successfully. "
249
- f"Combined file and {'zip ' if save_html else ''}ready for download below."
250
- )
251
-
252
- yield "\n".join(log_lines[-200:]), combined_path, zip_path
253
-
254
-
255
- with gr.Blocks(title="Link Scraper") as demo:
256
- gr.Markdown(
257
- "# Link Scraper\n"
258
- "Upload a `.txt` file with one URL per line. The app visits each link, "
259
- "saves the page as HTML, pulls out the title + description, and gives you "
260
- "back one combined `.txt` file (plus a `.zip` of all the saved HTML pages)."
261
- )
262
-
263
- with gr.Row():
264
- file_input = gr.File(label="Upload .txt file of URLs", file_types=[".txt"])
265
-
266
- with gr.Row():
267
- save_html_cb = gr.Checkbox(label="Save raw HTML files (zipped)", value=True)
268
- workers_slider = gr.Slider(
269
- label="Parallel workers (lower = safer, less likely to get blocked)",
270
- minimum=1, maximum=16, step=1, value=4,
271
- )
272
- delay_slider = gr.Slider(
273
- label="Delay between requests per worker (seconds)",
274
- minimum=0, maximum=10, step=0.5, value=1,
275
- )
276
- timeout_slider = gr.Slider(
277
- label="Timeout per request (seconds)",
278
- minimum=5, maximum=60, step=5, value=20,
279
- )
280
-
281
- start_btn = gr.Button("Start Scraping", variant="primary")
282
-
283
- log_box = gr.Textbox(label="Progress / Log", lines=20, max_lines=20)
284
-
285
  with gr.Row():
286
- combined_output = gr.File(label="Combined results (.txt)")
287
- zip_output = gr.File(label="All saved pages (.zip)")
288
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
289
  start_btn.click(
290
- fn=run_scraper,
291
- inputs=[file_input, save_html_cb, workers_slider, delay_slider, timeout_slider],
292
- outputs=[log_box, combined_output, zip_output],
293
  )
294
 
295
- if __name__ == "__main__":
296
- demo.queue().launch()
 
1
+ import gradio as gr
2
+ import requests
3
+ from bs4 import BeautifulSoup
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  import time
5
+ import os
6
  import zipfile
 
 
7
  from urllib.parse import urlparse
8
+ import re
9
 
10
+ def scrape_kemono(links_text, delay_sec, max_retries):
11
+ # Smart URL extraction: find all kemono.cr links even if wrapped in HTML tags
12
+ raw_urls = re.findall(r'(https://kemono\.cr/[^\s"\'<>]+)', links_text)
13
+
14
+ # Deduplicate while preserving order
15
+ seen = set()
16
+ links = [url for url in raw_urls if not (url in seen or seen.add(url))]
17
+
18
+ if not links:
19
+ return None, "No valid kemono.cr links found in the input."
20
+
21
+ combined_text_path = "combined_extracted_text.txt"
22
+ html_zip_path = "saved_raw_htmls.zip"
23
+
24
+ extracted_data = []
25
+
26
+ # Create a zip file to store all the individual HTML pages
27
+ with zipfile.ZipFile(html_zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
28
+ for i, link in enumerate(links):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  try:
30
+ # Request Delay Controller
31
+ if i > 0:
32
+ time.sleep(delay_sec)
33
+
34
+ headers = {
35
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
36
+ }
37
+
38
+ # Fetch page with retry mechanism
39
+ response = None
40
+ for attempt in range(int(max_retries)):
41
+ response = requests.get(link, headers=headers, timeout=15)
42
+ if response.status_code == 200:
43
+ break
44
+ time.sleep(2) # brief pause before retry
45
+
46
+ if response is None or response.status_code != 200:
47
+ extracted_data.append(f"--- FAILED TO FETCH: {link} (Status: {response.status_code if response else 'Timeout'}) ---")
48
+ continue
49
+
50
+ html_content = response.text
51
+
52
+ # Save Raw HTML to the ZIP file
53
+ parsed_url = urlparse(link)
54
+ path_parts = [p for p in parsed_url.path.strip('/').split('/') if p]
55
+ # Create a safe filename using the last two parts of the URL (e.g., user ID and post ID)
56
+ safe_name = f"post_{i+1}_" + "_".join(path_parts[-2:]) + ".html"
57
+ zipf.writestr(safe_name, html_content)
58
+
59
+ # Parse HTML for Text and Title extraction
60
+ soup = BeautifulSoup(html_content, 'html.parser')
61
+
62
+ # Extract Title (Kemono specifically uses h1 with class 'post__title')
63
+ title_tag = soup.find('h1', class_='post__title')
64
+ title = title_tag.get_text(strip=True) if title_tag else f"Untitled_Post_{i+1}"
65
+
66
+ # Extract Content Text (Kemono uses 'post__content' or 'post__body')
67
+ content_tag = soup.find('div', class_='post__content')
68
+ if not content_tag:
69
+ content_tag = soup.find('div', class_='post__body')
70
+
71
+ text_content = content_tag.get_text(separator='\n', strip=True) if content_tag else "[No text content found in this post.]"
72
+
73
+ # Append formatted data to our combined text string
74
+ post_text = f"========================================================================\n"
75
+ post_text += f"TITLE: {title}\n"
76
+ post_text += f"URL: {link}\n"
77
+ post_text += f"========================================================================\n\n"
78
+ post_text += f"{text_content}\n\n\n"
79
+
80
+ extracted_data.append(post_text)
81
+
82
+ except Exception as e:
83
+ extracted_data.append(f"--- ERROR PROCESSING {link}: {str(e)} ---\n\n")
84
+
85
+ # Write the combined text file
86
+ with open(combined_text_path, 'w', encoding='utf-8') as f:
87
+ f.write("\n".join(extracted_data))
88
+
89
+ return [combined_text_path, html_zip_path], f"Successfully processed {len(links)} links."
90
+
91
+ # --- Gradio UI Layout ---
92
+ with gr.Blocks(theme=gr.themes.Soft()) as app:
93
+ gr.Markdown("# 🦊 Kemono.cr Batch Scraper & Extractor")
94
+ gr.Markdown("Paste a list of links (or raw HTML tags from your `links.txt` file). The scraper will fetch each link, extract the title and text, save the raw HTMLs in a ZIP, and give you a combined TXT document.")
95
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  with gr.Row():
97
+ with gr.Column(scale=2):
98
+ links_input = gr.Textbox(
99
+ lines=12,
100
+ label="Paste Links Here",
101
+ placeholder="https://kemono.cr/patreon/user/.../post/...\n(You can also paste raw <a href=...> tags)"
102
+ )
103
+
104
+ with gr.Row():
105
+ delay_input = gr.Slider(minimum=0, maximum=15, value=2, step=0.5, label="Delay Between Requests (Seconds)")
106
+ retry_input = gr.Slider(minimum=1, maximum=5, value=3, step=1, label="Max Retries per Link")
107
+
108
+ start_btn = gr.Button("πŸš€ Start Extraction", variant="primary")
109
+
110
+ with gr.Column(scale=1):
111
+ status_output = gr.Textbox(label="Status Logging")
112
+ files_output = gr.File(label="πŸ“₯ Download Extracted Files")
113
+
114
  start_btn.click(
115
+ fn=scrape_kemono,
116
+ inputs=[links_input, delay_input, retry_input],
117
+ outputs=[files_output, status_output]
118
  )
119
 
120
+ app.launch()