Krixter commited on
Commit
c403b12
ยท
verified ยท
1 Parent(s): c59b932

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +181 -382
app.py CHANGED
@@ -1,399 +1,198 @@
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()
 
 
 
 
 
 
 
 
1
+ """
2
+ Mixed-Alphabet Word Scanner
3
+ ----------------------------
4
+ Scans a .txt file word-by-word and flags "mixup" words โ€” words that mix
5
+ letters with digits and/or special characters (e.g. M0M, G@ng, f1ght).
6
+
7
+ Rules:
8
+ - Pure numbers (e.g. "12345") -> SKIPPED
9
+ - Pure special characters (e.g. "***")-> SKIPPED
10
+ - Normal words, even with a trailing
11
+ comma/period (e.g. "hello.", "world,") -> SKIPPED
12
+ - Pure alphabet words (e.g. "hello") -> SKIPPED
13
+ - Words mixing letters with digits/symbols
14
+ ANYWHERE in the word (e.g. M0M, G@ng, f1ght) -> FLAGGED
15
+ - Duplicate flagged words are only listed once.
16
+
17
+ Works two ways:
18
+ 1. As a command-line script:
19
+ python app.py your_file.txt
20
+ 2. As a Gradio web app (this is what runs when deployed to a
21
+ Hugging Face Space โ€” just upload this file + requirements.txt).
22
+ """
23
+
24
  import re
25
+ import sys
26
+ import io
27
+ import csv
28
+
29
+
30
+ def rstrip_trailing_punct(word: str) -> str:
31
+ """Strip trailing commas/periods only (e.g. 'hello.,' -> 'hello')."""
32
+ return word.rstrip(".,")
33
+
34
+
35
+ def is_mixup_word(word: str):
36
+ """
37
+ Returns (is_mixup: bool, cleaned_word: str)
38
+
39
+ A word is a "mixup" if, after stripping trailing , or . :
40
+ - it is NOT empty
41
+ - it is NOT purely digits
42
+ - it is NOT purely special characters (no letters, no digits)
43
+ - it is NOT purely alphabetic
44
+ - it DOES contain at least one letter AND at least one
45
+ digit/special character somewhere in it
46
+ """
47
+ cleaned = rstrip_trailing_punct(word)
48
+
49
+ if cleaned == "":
50
+ return False, cleaned
51
+
52
+ if cleaned.isdigit(): # pure number -> skip
53
+ return False, cleaned
54
 
55
+ if cleaned.isalpha(): # pure alphabet -> skip
56
+ return False, cleaned
57
 
58
+ has_letter = any(c.isalpha() for c in cleaned)
59
+ has_other = any((not c.isalpha()) for c in cleaned) # digit or special
 
 
 
 
 
 
60
 
61
+ if not has_letter: # pure digits/specials -> skip
62
+ return False, cleaned
63
 
64
+ if has_letter and has_other: # genuine mixup
65
+ return True, cleaned
66
 
67
+ return False, cleaned
 
 
 
 
 
68
 
69
 
70
+ def scan_text(text: str):
71
  """
72
+ Scans text, returns (results, stats)
73
+ results: list of dicts [{"word": original_token, "cleaned": cleaned}]
74
+ in order of first appearance, duplicates removed (case-sensitive).
75
+ stats: dict with counts.
76
  """
77
+ tokens = re.findall(r"\S+", text)
78
+
79
+ seen = set()
80
+ results = []
81
+ total_flagged_occurrences = 0
82
+
83
+ for token in tokens:
84
+ flagged, cleaned = is_mixup_word(token)
85
+ if flagged:
86
+ total_flagged_occurrences += 1
87
+ key = cleaned # dedupe on the cleaned form, case-sensitive
88
+ if key not in seen:
89
+ seen.add(key)
90
+ results.append({"word": token, "cleaned": cleaned})
91
+
92
+ stats = {
93
+ "total_words_scanned": len(tokens),
94
+ "total_flagged_occurrences": total_flagged_occurrences,
95
+ "unique_flagged_words": len(results),
96
+ }
97
+ return results, stats
98
+
99
+
100
+ def results_to_txt(results):
101
+ return "\n".join(r["cleaned"] for r in results)
102
+
103
+
104
+ def results_to_csv(results):
105
+ buf = io.StringIO()
106
+ writer = csv.writer(buf)
107
+ writer.writerow(["word"])
108
+ for r in results:
109
+ writer.writerow([r["cleaned"]])
110
+ return buf.getvalue()
111
+
112
+
113
+ # --------------------------------------------------------------------------
114
+ # CLI mode
115
+ # --------------------------------------------------------------------------
116
+ def run_cli(path: str):
117
+ with open(path, "r", encoding="utf-8", errors="ignore") as f:
118
+ text = f.read()
119
+
120
+ results, stats = scan_text(text)
121
+
122
+ print(f"Scanned {stats['total_words_scanned']} words.")
123
+ print(f"Found {stats['unique_flagged_words']} unique mixed-alphabet words "
124
+ f"({stats['total_flagged_occurrences']} total occurrences):\n")
125
+
126
+ for r in results:
127
+ print(r["cleaned"])
128
+
129
+ out_path = "flagged_words.txt"
130
+ with open(out_path, "w", encoding="utf-8") as f:
131
+ f.write(results_to_txt(results))
132
+ print(f"\nSaved list to {out_path}")
133
+
134
+
135
+ # --------------------------------------------------------------------------
136
+ # Gradio web app (used on Hugging Face Spaces)
137
+ # --------------------------------------------------------------------------
138
+ def build_gradio_app():
139
+ import gradio as gr
140
+
141
+ def process(file_obj):
142
+ if file_obj is None:
143
+ return "Please upload a .txt file.", None, None
144
+
145
+ # gr.File gives a filepath (str) in recent Gradio versions
146
+ path = file_obj if isinstance(file_obj, str) else file_obj.name
147
+ with open(path, "r", encoding="utf-8", errors="ignore") as f:
148
+ text = f.read()
149
+
150
+ results, stats = scan_text(text)
151
+
152
+ summary = (
153
+ f"Scanned {stats['total_words_scanned']} words.\n"
154
+ f"Unique flagged words: {stats['unique_flagged_words']}\n"
155
+ f"Total flagged occurrences: {stats['total_flagged_occurrences']}"
156
  )
 
157
 
158
+ list_display = "\n".join(r["cleaned"] for r in results) or "(none found)"
159
+
160
+ # Write downloadable output file
161
+ out_path = "/tmp/flagged_words.txt"
162
+ with open(out_path, "w", encoding="utf-8") as f:
163
+ f.write(results_to_txt(results))
164
 
165
+ return summary, list_display, out_path
 
 
 
166
 
167
+ with gr.Blocks(title="Mixed-Alphabet Word Scanner") as demo:
168
+ gr.Markdown(
169
+ "## Mixed-Alphabet Word Scanner\n"
170
+ "Upload a `.txt` file. This tool flags words that mix letters "
171
+ "with digits/symbols (e.g. `M0M`, `G@ng`, `f1ght`), while "
172
+ "skipping pure numbers, pure symbols, normal words, and words "
173
+ "with only a trailing comma/period."
174
  )
175
+ file_input = gr.File(label="Upload .txt file", file_types=[".txt"])
176
+ run_btn = gr.Button("Scan File", variant="primary")
177
+ summary_out = gr.Textbox(label="Summary", interactive=False)
178
+ list_out = gr.Textbox(label="Flagged Words", lines=15, interactive=False)
179
+ download_out = gr.File(label="Download flagged_words.txt")
180
+
181
+ run_btn.click(
182
+ fn=process,
183
+ inputs=file_input,
184
+ outputs=[summary_out, list_out, download_out],
185
+ )
186
+
187
+ return demo
188
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
 
190
  if __name__ == "__main__":
191
+ # If a filepath is passed as an argument, run in CLI mode.
192
+ if len(sys.argv) > 1:
193
+ run_cli(sys.argv[1])
194
+ else:
195
+ # Otherwise launch the Gradio web app (Hugging Face Spaces will
196
+ # call this file directly with no args).
197
+ app = build_gradio_app()
198
+ app.launch()