Krixter commited on
Commit
c59b932
Β·
verified Β·
1 Parent(s): 58f1338

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +387 -139
app.py CHANGED
@@ -1,151 +1,399 @@
1
  import gradio as gr
2
  import requests
3
- from bs4 import BeautifulSoup
4
- import time
5
  import zipfile
6
- from urllib.parse import urlparse
7
  import re
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
- def scrape_kemono(links_text, delay_sec, max_retries):
10
- # Smart URL extraction: find all kemono.cr links even if wrapped in HTML tags
11
- raw_urls = re.findall(r'(https://kemono\.cr/[^\s"\'<>]+)', links_text)
12
-
13
- # Deduplicate while preserving order
14
- seen = set()
15
- links = [url for url in raw_urls if not (url in seen or seen.add(url))]
16
-
17
- if not links:
18
- return None, "No valid kemono.cr links found in the input."
19
-
20
- combined_text_path = "combined_extracted_text.txt"
21
- html_zip_path = "saved_raw_htmls.zip"
22
-
23
- extracted_data = []
24
-
25
- # Create a zip file to store all the individual HTML pages
26
- with zipfile.ZipFile(html_zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
27
- for i, link in enumerate(links):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  try:
29
- # Request Delay Controller
30
- if i > 0:
31
- time.sleep(delay_sec)
32
-
33
- headers = {
34
- "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"
35
- }
36
-
37
- # Fetch page with retry mechanism
38
- response = None
39
- for attempt in range(int(max_retries)):
40
- response = requests.get(link, headers=headers, timeout=15)
41
- if response.status_code == 200:
42
- break
43
- time.sleep(2) # brief pause before retry
44
-
45
- if response is None or response.status_code != 200:
46
- extracted_data.append(f"--- FAILED TO FETCH: {link} (Status: {response.status_code if response else 'Timeout'}) ---")
47
- continue
48
-
49
- html_content = response.text
50
-
51
- # Save Raw HTML to the ZIP file
52
- parsed_url = urlparse(link)
53
- path_parts = [p for p in parsed_url.path.strip('/').split('/') if p]
54
- # Create a safe filename using the last two parts of the URL (e.g., user ID and post ID)
55
- safe_name = f"post_{i+1}_" + "_".join(path_parts[-2:]) + ".html"
56
- zipf.writestr(safe_name, html_content)
57
-
58
- # Parse HTML for Text and Title extraction
59
- soup = BeautifulSoup(html_content, 'html.parser')
60
-
61
- # Check for Cloudflare block
62
- page_title = soup.find('title')
63
- if page_title and "Just a moment" in page_title.get_text():
64
- extracted_data.append(f"--- BLOCKED BY CLOUDFLARE: {link} (Increase your delay!) ---\n")
65
- continue
66
-
67
- # 1. EXTRACT TITLE (Better Fallbacks)
68
- title_tag = soup.find('h1', class_='post__title')
69
- if title_tag:
70
- title = title_tag.get_text(strip=True)
71
- elif page_title:
72
- title = page_title.get_text(strip=True).replace(" | Kemono", "")
73
- else:
74
- title = f"Untitled_Post_{i+1}"
75
-
76
- # 2. EXTRACT TEXT CONTENT
77
- content_tag = soup.find('div', class_='post__content') or soup.find('div', class_='post__body')
78
- text_content = content_tag.get_text(separator='\n', strip=True) if content_tag else ""
79
-
80
- # 3. EXTRACT ATTACHMENTS & IMAGES
81
- media_links = []
82
- # Look for attachments
83
- attachments = soup.find_all('a', class_='post__attachment-link')
84
- for atk in attachments:
85
- href = atk.get('href')
86
- if href:
87
- media_links.append("Attachment: " + (href if href.startswith('http') else f"https://kemono.cr{href}"))
88
-
89
- # Look for images inside the post
90
- images = soup.find_all('img', class_='post__image')
91
- for img in images:
92
- src = img.get('src')
93
- if src:
94
- media_links.append("Image: " + (src if src.startswith('http') else f"https://kemono.cr{src}"))
95
-
96
- # Build the final text block
97
- post_text = f"========================================================================\n"
98
- post_text += f"TITLE: {title}\n"
99
- post_text += f"URL: {link}\n"
100
- post_text += f"========================================================================\n\n"
101
-
102
- if not text_content and not media_links:
103
- post_text += "[No text or media content found. The post might be empty or locked.]\n\n"
104
- else:
105
- if text_content:
106
- post_text += f"--- TEXT ---\n{text_content}\n\n"
107
- if media_links:
108
- post_text += f"--- MEDIA LINKS ---\n" + "\n".join(media_links) + "\n\n"
109
-
110
- post_text += "\n"
111
- extracted_data.append(post_text)
112
-
113
- except Exception as e:
114
- extracted_data.append(f"--- ERROR PROCESSING {link}: {str(e)} ---\n\n")
115
-
116
- # Write the combined text file
117
- with open(combined_text_path, 'w', encoding='utf-8') as f:
118
- f.write("\n".join(extracted_data))
119
-
120
- return [combined_text_path, html_zip_path], f"Successfully processed {len(links)} links."
121
-
122
- # --- Gradio UI Layout ---
123
- with gr.Blocks(theme=gr.themes.Soft()) as app:
124
- gr.Markdown("# 🦊 Kemono.cr Batch Scraper & Extractor")
125
- 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, text, and media links, save the raw HTMLs in a ZIP, and give you a combined TXT document.")
126
-
127
  with gr.Row():
128
  with gr.Column(scale=2):
129
- links_input = gr.Textbox(
130
- lines=12,
131
- label="Paste Links Here",
132
- placeholder="https://kemono.cr/patreon/user/.../post/...\n(You can also paste raw <a href=...> tags)"
 
133
  )
134
-
135
- with gr.Row():
136
- delay_input = gr.Slider(minimum=0, maximum=15, value=2.5, step=0.5, label="Delay Between Requests (Seconds)")
137
- retry_input = gr.Slider(minimum=1, maximum=5, value=3, step=1, label="Max Retries per Link")
138
-
139
- start_btn = gr.Button("πŸš€ Start Extraction", variant="primary")
140
-
 
 
141
  with gr.Column(scale=1):
142
- status_output = gr.Textbox(label="Status Logging")
143
- files_output = gr.File(label="πŸ“₯ Download Extracted Files")
144
-
145
- start_btn.click(
146
- fn=scrape_kemono,
147
- inputs=[links_input, delay_input, retry_input],
148
- outputs=[files_output, status_output]
 
 
 
 
 
 
 
 
149
  )
150
 
151
- app.launch()
 
 
1
  import gradio as gr
2
  import requests
 
 
3
  import zipfile
4
+ import os
5
  import re
6
+ import time
7
+ from datetime import datetime
8
+ from urllib.parse import urlparse
9
+ from bs4 import BeautifulSoup
10
+
11
+ # ── helpers ──────────────────────────────────────────────────────────────────
12
+
13
+ HEADERS = {
14
+ "User-Agent": (
15
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
16
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
17
+ "Chrome/124.0 Safari/537.36"
18
+ ),
19
+ "Accept-Language": "en-US,en;q=0.9",
20
+ }
21
+
22
+ OUTPUT_DIR = "fetched_pages"
23
+ os.makedirs(OUTPUT_DIR, exist_ok=True)
24
+
25
+
26
+ def safe_filename(url: str, index: int) -> str:
27
+ """Turn a URL into a safe filename like 001_example_com_path.html"""
28
+ parsed = urlparse(url)
29
+ slug = re.sub(r"[^\w\-]", "_", parsed.netloc + parsed.path)
30
+ slug = re.sub(r"_+", "_", slug).strip("_")[:80]
31
+ return f"{index:03d}_{slug}.html"
32
+
33
+
34
+ def fetch_url(url: str, timeout: int = 15) -> tuple[str, str]:
35
+ """
36
+ Fetch a URL and return (html_content, error_message).
37
+ error_message is empty string on success.
38
+ """
39
+ try:
40
+ r = requests.get(url, headers=HEADERS, timeout=timeout)
41
+ r.raise_for_status()
42
+ # normalise encoding
43
+ r.encoding = r.apparent_encoding or "utf-8"
44
+ return r.text, ""
45
+ except requests.exceptions.Timeout:
46
+ return "", f"Timed out after {timeout}s"
47
+ except requests.exceptions.HTTPError as e:
48
+ return "", f"HTTP {e.response.status_code}"
49
+ except Exception as e:
50
+ return "", str(e)
51
+
52
+
53
+ def inject_base_tag(html: str, url: str) -> str:
54
+ """Inject a <base> tag so relative links resolve correctly in previews."""
55
+ parsed = urlparse(url)
56
+ base = f"{parsed.scheme}://{parsed.netloc}"
57
+ if "<head" in html.lower():
58
+ return re.sub(
59
+ r"(<head[^>]*>)",
60
+ rf'\1<base href="{base}">',
61
+ html,
62
+ count=1,
63
+ flags=re.IGNORECASE,
64
+ )
65
+ return f'<base href="{base}">' + html
66
+
67
+
68
+ def make_combined(pages: list[dict], title: str = "Combined Pages") -> str:
69
+ """Build one big HTML file with a sidebar TOC and all pages in iframes."""
70
+ nav_items = ""
71
+ sections = ""
72
+
73
+ for i, page in enumerate(pages):
74
+ anchor = f"page_{i}"
75
+ label = page["title"] or page["url"]
76
+ status_badge = (
77
+ '<span style="color:#22c55e">βœ“</span>'
78
+ if page["ok"]
79
+ else '<span style="color:#ef4444">βœ—</span>'
80
+ )
81
+ nav_items += f"""
82
+ <li>
83
+ <a href="#{anchor}" class="nav-link" onclick="showSection('{anchor}')">
84
+ {status_badge} {i+1}. {label[:60]}
85
+ </a>
86
+ </li>"""
87
+
88
+ if page["ok"]:
89
+ # embed raw HTML in a sandboxed iframe via srcdoc
90
+ escaped = page["html"].replace("&", "&amp;").replace('"', "&quot;")
91
+ content_block = f'<iframe srcdoc="{escaped}" class="page-frame" sandbox="allow-same-origin allow-scripts"></iframe>'
92
+ else:
93
+ content_block = f'<div class="error-box">⚠ Could not fetch this page: {page["error"]}</div>'
94
+
95
+ sections += f"""
96
+ <section id="{anchor}" class="page-section">
97
+ <div class="page-header">
98
+ <span class="page-index">#{i+1}</span>
99
+ <a href="{page['url']}" target="_blank" class="page-url">{page['url']}</a>
100
+ </div>
101
+ {content_block}
102
+ </section>"""
103
+
104
+ fetched_at = datetime.now().strftime("%Y-%m-%d %H:%M")
105
+ total = len(pages)
106
+ ok_count = sum(1 for p in pages if p["ok"])
107
+
108
+ return f"""<!DOCTYPE html>
109
+ <html lang="en">
110
+ <head>
111
+ <meta charset="utf-8">
112
+ <meta name="viewport" content="width=device-width, initial-scale=1">
113
+ <title>{title}</title>
114
+ <style>
115
+ *, *::before, *::after {{ box-sizing: border-box; margin: 0; padding: 0; }}
116
+ :root {{
117
+ --bg: #0f1117;
118
+ --sidebar-bg: #161b22;
119
+ --surface: #1c2230;
120
+ --accent: #58a6ff;
121
+ --accent2: #3fb950;
122
+ --text: #e6edf3;
123
+ --muted: #8b949e;
124
+ --border: #30363d;
125
+ --radius: 8px;
126
+ --sidebar-w: 300px;
127
+ }}
128
+ body {{
129
+ font-family: 'Segoe UI', system-ui, sans-serif;
130
+ background: var(--bg);
131
+ color: var(--text);
132
+ display: flex;
133
+ height: 100vh;
134
+ overflow: hidden;
135
+ }}
136
+
137
+ /* ── Sidebar ── */
138
+ #sidebar {{
139
+ width: var(--sidebar-w);
140
+ min-width: var(--sidebar-w);
141
+ background: var(--sidebar-bg);
142
+ border-right: 1px solid var(--border);
143
+ display: flex;
144
+ flex-direction: column;
145
+ overflow: hidden;
146
+ }}
147
+ .sidebar-header {{
148
+ padding: 18px 16px 14px;
149
+ border-bottom: 1px solid var(--border);
150
+ }}
151
+ .sidebar-header h1 {{
152
+ font-size: 15px;
153
+ font-weight: 700;
154
+ color: var(--accent);
155
+ letter-spacing: .02em;
156
+ }}
157
+ .sidebar-meta {{
158
+ font-size: 11px;
159
+ color: var(--muted);
160
+ margin-top: 4px;
161
+ }}
162
+ #nav-list {{
163
+ list-style: none;
164
+ overflow-y: auto;
165
+ flex: 1;
166
+ padding: 8px 0;
167
+ }}
168
+ #nav-list li {{ padding: 2px 8px; }}
169
+ .nav-link {{
170
+ display: block;
171
+ padding: 7px 10px;
172
+ border-radius: var(--radius);
173
+ color: var(--muted);
174
+ text-decoration: none;
175
+ font-size: 12px;
176
+ line-height: 1.4;
177
+ transition: background .15s, color .15s;
178
+ word-break: break-all;
179
+ }}
180
+ .nav-link:hover, .nav-link.active {{
181
+ background: rgba(88,166,255,.12);
182
+ color: var(--text);
183
+ }}
184
+
185
+ /* ── Main ── */
186
+ #main {{
187
+ flex: 1;
188
+ overflow-y: auto;
189
+ padding: 24px 28px;
190
+ }}
191
+ .page-section {{
192
+ margin-bottom: 48px;
193
+ }}
194
+ .page-header {{
195
+ display: flex;
196
+ align-items: center;
197
+ gap: 10px;
198
+ margin-bottom: 10px;
199
+ padding-bottom: 10px;
200
+ border-bottom: 1px solid var(--border);
201
+ }}
202
+ .page-index {{
203
+ background: var(--accent);
204
+ color: #000;
205
+ font-size: 11px;
206
+ font-weight: 700;
207
+ padding: 2px 7px;
208
+ border-radius: 20px;
209
+ flex-shrink: 0;
210
+ }}
211
+ .page-url {{
212
+ color: var(--muted);
213
+ font-size: 12px;
214
+ word-break: break-all;
215
+ text-decoration: none;
216
+ }}
217
+ .page-url:hover {{ color: var(--accent); text-decoration: underline; }}
218
+ .page-frame {{
219
+ width: 100%;
220
+ height: 600px;
221
+ border: 1px solid var(--border);
222
+ border-radius: var(--radius);
223
+ background: #fff;
224
+ }}
225
+ .error-box {{
226
+ padding: 20px;
227
+ border: 1px solid #f8514933;
228
+ background: #f8514911;
229
+ border-radius: var(--radius);
230
+ color: #ff7b72;
231
+ font-size: 13px;
232
+ }}
233
+ </style>
234
+ </head>
235
+ <body>
236
+
237
+ <aside id="sidebar">
238
+ <div class="sidebar-header">
239
+ <h1>πŸ“„ {title}</h1>
240
+ <div class="sidebar-meta">
241
+ {ok_count}/{total} pages fetched &nbsp;Β·&nbsp; {fetched_at}
242
+ </div>
243
+ </div>
244
+ <ul id="nav-list">
245
+ {nav_items}
246
+ </ul>
247
+ </aside>
248
+
249
+ <main id="main">
250
+ {sections}
251
+ </main>
252
 
253
+ <script>
254
+ function showSection(id) {{
255
+ document.querySelectorAll('.nav-link').forEach(l => l.classList.remove('active'));
256
+ const link = document.querySelector(`a[href="#${{id}}"]`);
257
+ if (link) link.classList.add('active');
258
+ }}
259
+
260
+ // Highlight nav item on scroll
261
+ const observer = new IntersectionObserver(entries => {{
262
+ entries.forEach(e => {{
263
+ if (e.isIntersecting) {{
264
+ document.querySelectorAll('.nav-link').forEach(l => l.classList.remove('active'));
265
+ const link = document.querySelector(`a[href="#${{e.target.id}}"]`);
266
+ if (link) link.classList.add('active');
267
+ }}
268
+ }});
269
+ }}, {{ threshold: 0.3 }});
270
+ document.querySelectorAll('.page-section').forEach(s => observer.observe(s));
271
+ </script>
272
+ </body>
273
+ </html>"""
274
+
275
+
276
+ # ── Gradio logic ─────────────────────────────────────────────────────────────
277
+
278
+ def process_urls(raw_input: str, delay: float, progress=gr.Progress(track_tqdm=True)):
279
+ """
280
+ Main processing function called by Gradio.
281
+ Returns (status_text, combined_html_path, zip_path)
282
+ """
283
+ lines = [l.strip() for l in raw_input.strip().splitlines() if l.strip()]
284
+ urls = [l for l in lines if l.startswith("http://") or l.startswith("https://")]
285
+
286
+ if not urls:
287
+ return "❌ No valid URLs found. Make sure each URL starts with http:// or https://", None, None
288
+
289
+ pages = []
290
+ log_lines = []
291
+
292
+ for i, url in enumerate(progress.tqdm(urls, desc="Fetching pages")):
293
+ progress((i + 1) / len(urls), desc=f"Fetching {i+1}/{len(urls)}: {url[:50]}…")
294
+ html, err = fetch_url(url)
295
+
296
+ if html:
297
+ html = inject_base_tag(html, url)
298
+ # try to extract a page title
299
  try:
300
+ soup = BeautifulSoup(html, "html.parser")
301
+ title = soup.title.string.strip() if soup.title else ""
302
+ except Exception:
303
+ title = ""
304
+
305
+ fname = safe_filename(url, i + 1)
306
+ fpath = os.path.join(OUTPUT_DIR, fname)
307
+ with open(fpath, "w", encoding="utf-8", errors="replace") as f:
308
+ f.write(html)
309
+
310
+ pages.append({"url": url, "html": html, "title": title, "ok": True, "file": fpath, "error": ""})
311
+ log_lines.append(f"βœ… [{i+1}/{len(urls)}] {url}")
312
+ else:
313
+ pages.append({"url": url, "html": "", "title": "", "ok": False, "file": None, "error": err})
314
+ log_lines.append(f"❌ [{i+1}/{len(urls)}] {url} β†’ {err}")
315
+
316
+ if delay > 0 and i < len(urls) - 1:
317
+ time.sleep(delay)
318
+
319
+ # Build combined HTML
320
+ combined_html = make_combined(pages, title="Fetched Pages")
321
+ combined_path = os.path.join(OUTPUT_DIR, "combined.html")
322
+ with open(combined_path, "w", encoding="utf-8", errors="replace") as f:
323
+ f.write(combined_html)
324
+
325
+ # Build ZIP of individual files
326
+ zip_path = os.path.join(OUTPUT_DIR, "all_pages.zip")
327
+ with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
328
+ for p in pages:
329
+ if p["file"]:
330
+ zf.write(p["file"], os.path.basename(p["file"]))
331
+ zf.write(combined_path, "combined.html")
332
+
333
+ ok_count = sum(1 for p in pages if p["ok"])
334
+ status = "\n".join(log_lines)
335
+ status += f"\n\nπŸŽ‰ Done! {ok_count}/{len(urls)} pages saved."
336
+ return status, combined_path, zip_path
337
+
338
+
339
+ # ── UI ───────────────────────────────────────────────────────────────────────
340
+
341
+ with gr.Blocks(
342
+ title="URL β†’ HTML Fetcher",
343
+ theme=gr.themes.Base(
344
+ primary_hue="blue",
345
+ neutral_hue="slate",
346
+ ),
347
+ css="""
348
+ #url-box textarea { font-family: monospace; font-size: 13px; }
349
+ #log-box textarea { font-family: monospace; font-size: 12px; }
350
+ """
351
+ ) as demo:
352
+
353
+ gr.Markdown(
354
+ """
355
+ # 🌐 URL β†’ HTML Fetcher
356
+ Paste URLs (one per line), click **Fetch**, and get:
357
+ - Individual `.html` files for each page
358
+ - One `combined.html` with a sidebar viewer
359
+ - A `.zip` with everything bundled
360
+ """
361
+ )
362
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
363
  with gr.Row():
364
  with gr.Column(scale=2):
365
+ url_input = gr.Textbox(
366
+ label="URLs (one per line)",
367
+ placeholder="https://example.com\nhttps://another-site.com/page",
368
+ lines=12,
369
+ elem_id="url-box",
370
  )
371
+ delay_slider = gr.Slider(
372
+ minimum=0,
373
+ maximum=5,
374
+ value=1,
375
+ step=0.5,
376
+ label="Delay between requests (seconds) β€” be polite to servers!",
377
+ )
378
+ fetch_btn = gr.Button("πŸš€ Fetch Pages", variant="primary")
379
+
380
  with gr.Column(scale=1):
381
+ log_output = gr.Textbox(
382
+ label="Progress log",
383
+ lines=12,
384
+ interactive=False,
385
+ elem_id="log-box",
386
+ )
387
+
388
+ with gr.Row():
389
+ combined_file = gr.File(label="πŸ“„ Combined HTML (download)")
390
+ zip_file = gr.File(label="πŸ“¦ All pages ZIP (download)")
391
+
392
+ fetch_btn.click(
393
+ fn=process_urls,
394
+ inputs=[url_input, delay_slider],
395
+ outputs=[log_output, combined_file, zip_file],
396
  )
397
 
398
+ if __name__ == "__main__":
399
+ demo.launch()