Spaces:
Running
Running
Enforce monthly request cap (12000/mo plan) via RapidAPI quota headers; optional MONTHLY_DOWNLOAD_LIMIT self-cap; show quota in health check
822d3cc verified | """Gradio Space: YouTube topic -> captioned .docx tutorial (API-based acquisition). | |
| Pipeline: | |
| search top videos -> rank by YouTube Data API comment sentiment -> transcript via | |
| youtube-transcript-api -> DeepSeek-V3 tutorial -> real screenshots via yt-dlp stream URL | |
| + ffmpeg -ss (weighted timestamps) -> VLM captions -> .docx. | |
| No video download and no cookies/proxy/PO-token UI. A thin fallback remains via the | |
| optional Space secrets YT_COOKIES / YT_PROXY (used only to resolve the stream URL and the | |
| transcript when the Space's datacenter IP is blocked). | |
| """ | |
| from __future__ import annotations | |
| import base64 | |
| import binascii | |
| import os | |
| import re | |
| import shutil | |
| import tempfile | |
| import time | |
| def _install_proxy_ca() -> None: | |
| """Trust a self-signed proxy CA (secret ``YT_PROXY_CA``) alongside the system | |
| roots, so an HTTPS (TLS-wrapped) ``YT_PROXY`` validates. TLS-wrapping hides the | |
| target host (``CONNECT www.youtube.com``) from the Space's egress DPI, which | |
| otherwise resets YouTube-bound connections. Covers requests / youtube-transcript-api | |
| (``REQUESTS_CA_BUNDLE``) and stdlib ssl / yt-dlp (``SSL_CERT_FILE``).""" | |
| ca = os.environ.get("YT_PROXY_CA", "").strip() | |
| if not ca: | |
| return | |
| try: | |
| import certifi | |
| bundle = os.path.join(tempfile.gettempdir(), "yt_proxy_ca_bundle.pem") | |
| with open(certifi.where(), encoding="utf-8") as fh: | |
| roots = fh.read() | |
| with open(bundle, "w", encoding="utf-8") as fh: | |
| fh.write(roots.rstrip() + "\n" + ca + "\n") | |
| os.environ["REQUESTS_CA_BUNDLE"] = bundle # requests / youtube-transcript-api | |
| os.environ["SSL_CERT_FILE"] = bundle # stdlib ssl + yt-dlp (see frames.py) | |
| except Exception: | |
| pass | |
| _install_proxy_ca() | |
| import gradio as gr | |
| from pipeline import ( | |
| asr as asr_mod, | |
| captions as captions_mod, | |
| docx_builder, | |
| downloader as downloader_mod, | |
| frames as frames_mod, | |
| search as search_mod, | |
| sentiment as sentiment_mod, | |
| transcribe as transcribe_mod, | |
| tutorial as tutorial_mod, | |
| ) | |
| LLM_CHOICES = [ | |
| "deepseek-ai/DeepSeek-V3", | |
| "meta-llama/Llama-3.3-70B-Instruct", | |
| "openai/gpt-oss-120b", | |
| ] | |
| VLM_CHOICES = [ | |
| "Qwen/Qwen2.5-VL-72B-Instruct", | |
| "Qwen/Qwen2.5-VL-7B-Instruct", | |
| "meta-llama/Llama-3.2-90B-Vision-Instruct", | |
| ] | |
| # --------------------------------------------------------------- thin access fallback | |
| def _looks_like_netscape(text: str) -> bool: | |
| head = text.lstrip() | |
| return head.startswith("#") or "\tTRUE\t" in text or "\tFALSE\t" in text | |
| def _maybe_b64_decode(text: str) -> str | None: | |
| compact = "".join(text.split()) | |
| if len(compact) < 16 or re.search(r"[^A-Za-z0-9+/=]", compact): | |
| return None | |
| try: | |
| decoded = base64.b64decode(compact, validate=True).decode("utf-8", "replace") | |
| except (binascii.Error, ValueError): | |
| return None | |
| return decoded if _looks_like_netscape(decoded) else None | |
| def _cookiefile(workdir: str) -> str | None: | |
| """Materialize the optional YT_COOKIES secret to a Netscape file; return path or None.""" | |
| data = os.environ.get("YT_COOKIES") | |
| if not data or not data.strip(): | |
| return None | |
| if not _looks_like_netscape(data): | |
| decoded = _maybe_b64_decode(data) | |
| if decoded: | |
| data = decoded | |
| if not data.lstrip().startswith(("# Netscape", "# HTTP")): | |
| data = "# Netscape HTTP Cookie File\n" + data.lstrip("\n") | |
| if not data.endswith("\n"): | |
| data += "\n" | |
| path = os.path.join(workdir, "cookies.txt") | |
| with open(path, "w", encoding="utf-8", newline="\n") as fh: | |
| fh.write(data) | |
| return path | |
| def _resolve_proxy() -> str | None: | |
| proxy = os.environ.get("YT_PROXY", "").strip() | |
| return proxy or None | |
| def _resolve_media_proxy() -> str | None: | |
| """Plain HTTP proxy for large media (googlevideo.com) downloads. The TLS YT_PROXY | |
| can't sustain multi-MB transfers, so screenshots use this instead; falls back to | |
| YT_PROXY when unset.""" | |
| mp = os.environ.get("YT_MEDIA_PROXY", "").strip() | |
| return mp or None | |
| def _resolve_api_key(ui_key: str | None) -> str | None: | |
| key = (ui_key or "").strip() or os.environ.get("YOUTUBE_API_KEY", "").strip() | |
| return key or None | |
| def check_access(): | |
| """Health check: are the required secrets set and is the video-download API live?""" | |
| import requests | |
| rk = os.environ.get("RAPIDAPI_KEY", "").strip() | |
| lines = [ | |
| f"- **Video download** (`RAPIDAPI_KEY`): {'set ✅' if rk else 'not set ❌ — required'}", | |
| f"- **Comment sentiment** (`YOUTUBE_API_KEY`): " | |
| f"{'set ✅' if os.environ.get('YOUTUBE_API_KEY') else 'not set ⚪ (sentiment skipped)'}", | |
| "- **Transcript**: faster-whisper, local (no key/proxy needed) ✅", | |
| "- **Search**: Piped API (no key needed) ✅", | |
| ] | |
| api_ok = None | |
| if rk: | |
| host = "youtube-video-fast-downloader-24-7.p.rapidapi.com" | |
| try: | |
| r = requests.get(f"https://{host}/get-video-info/dQw4w9WgXcQ", | |
| headers={"X-RapidAPI-Key": rk, "X-RapidAPI-Host": host}, | |
| timeout=25) | |
| if r.status_code == 200: | |
| api_ok = True | |
| lines.append("- ✅ Download API reachable and key valid (HTTP 200)") | |
| downloader_mod._update_quota(r.headers) # prime the monthly-cap tracker | |
| q = downloader_mod.quota_status() | |
| if q.get("limit") is not None and q.get("remaining") is not None: | |
| days = (q["reset"] // 86400) if q.get("reset") else "?" | |
| cap = f" · self-cap {q['self_cap']}" if q.get("self_cap") else "" | |
| lines.append(f"- 📊 Monthly requests: **{q['remaining']} / {q['limit']} left** " | |
| f"({q.get('used', 0)} used, resets in ~{days} days){cap}") | |
| elif r.status_code in (401, 403): | |
| api_ok = False | |
| lines.append(f"- ❌ Download API rejected the key (HTTP {r.status_code}) — " | |
| "check RAPIDAPI_KEY and that you're subscribed") | |
| else: | |
| api_ok = False | |
| lines.append(f"- ⚠️ Download API HTTP {r.status_code}: {r.text[:120]}") | |
| except Exception as exc: | |
| api_ok = False | |
| lines.append(f"- ❌ Download API unreachable: {type(exc).__name__}") | |
| if not rk: | |
| verdict = "### 🔴 Set `RAPIDAPI_KEY` — it's required to download the source video." | |
| elif api_ok: | |
| verdict = ("### 🟢 Ready. Video downloads via the API; the transcript is produced " | |
| "locally by faster-whisper (first run downloads the model, ~1 min).") | |
| else: | |
| verdict = "### 🔴 The video-download API isn't working — see the details below." | |
| return verdict + "\n\n" + "\n".join(lines) | |
| # ----------------------------------------------------------------------------- helpers | |
| def _ranking_rows(scored: list[dict]) -> list[list]: | |
| rows = [] | |
| for rank, v in enumerate(scored, start=1): | |
| rows.append([ | |
| rank, | |
| v.get("title", v["video_id"]), | |
| f"{v.get('positive_share', 0) * 100:.0f}%", | |
| v.get("n_comments", 0), | |
| v.get("note", "") or "ok", | |
| v["url"], | |
| ]) | |
| return rows | |
| def _safe_name(text: str) -> str: | |
| return re.sub(r"[^A-Za-z0-9._-]+", "_", text).strip("_")[:60] or "tutorial" | |
| def _collect_keywords(primary_kw, secondary_kw) -> dict: | |
| primary = (primary_kw or "").strip() | |
| secondary, seen = [], {primary.lower()} | |
| for part in (secondary_kw or "").split(","): | |
| kw = part.strip() | |
| if kw and kw.lower() not in seen: | |
| seen.add(kw.lower()) | |
| secondary.append(kw) | |
| return {"primary": primary, "secondary": secondary} | |
| def run_pipeline(topic, hf_token, yt_api_key, llm_model, vlm_model, | |
| w_llm, w_whisper, lead, max_shots, primary_kw, secondary_kw, | |
| content_brief="", progress=gr.Progress()): | |
| """Generator yielding (status_md, ranking_df, transcript, docx_file).""" | |
| log: list[str] = [] | |
| def status(msg: str): | |
| log.append(msg) | |
| return "\n\n".join(log) | |
| topic = (topic or "").strip() | |
| if not topic: | |
| raise gr.Error("Please enter a topic.") | |
| if not (hf_token or "").strip(): | |
| raise gr.Error("Please paste your Hugging Face token (used for the LLM + vision model).") | |
| workdir = tempfile.mkdtemp(prefix="ytt_") | |
| frames_dir = os.path.join(workdir, "frames") | |
| video_path = os.path.join(workdir, "source.mp4") | |
| try: | |
| api_key = _resolve_api_key(yt_api_key) | |
| # 1. Search ------------------------------------------------------------------ | |
| progress(0.03, desc="Searching") | |
| yield status(f"🔍 Searching top videos for **{topic}** (engagement-ranked)…"), gr.update(), gr.update(), gr.update() | |
| videos = search_mod.search_top5(topic, api_key=api_key) | |
| eng_note = "" | |
| if videos and "engagement" in videos[0]: | |
| eng_note = " • ranked by likes+comments+subscribers−dislikes (normalized)" | |
| yield status(f"Found {len(videos)} candidate videos{eng_note}."), gr.update(), gr.update(), gr.update() | |
| # 2. Sentiment ranking (YouTube Data API comments) --------------------------- | |
| if api_key: | |
| yield status("💬 Fetching comments (YouTube Data API) and scoring sentiment…"), gr.update(), gr.update(), gr.update() | |
| best, scored = sentiment_mod.rank_by_sentiment(videos, api_key, progress) | |
| picked_msg = f"({best['positive_share'] * 100:.0f}% positive comments)" | |
| else: | |
| best = videos[0] | |
| scored = [{**v, "positive_share": 0.0, "n_comments": 0, | |
| "note": "sentiment skipped (no API key)", "search_rank": i} | |
| for i, v in enumerate(videos)] | |
| picked_msg = "(no YouTube Data API key → used top search result)" | |
| ranking = gr.update(value=_ranking_rows(scored)) | |
| yield (status(f"🏆 Picked **{best.get('title', best['video_id'])}** {picked_msg}."), | |
| ranking, gr.update(), gr.update()) | |
| # 3. Download the source video once (RapidAPI, own-CDN — no proxy) ----------- | |
| progress(0.25, desc="Downloading video") | |
| yield status("⬇️ Downloading the video (server-side prep can take up to ~5 min)…"), ranking, gr.update(), gr.update() | |
| downloader_mod.download_video(best["video_id"], video_path, progress=progress) | |
| size_mb = os.path.getsize(video_path) / 1024 / 1024 | |
| yield (status(f"Video downloaded ({size_mb:.0f} MB)."), | |
| ranking, gr.update(), gr.update()) | |
| # 4. Transcript via faster-whisper on the local video ------------------------ | |
| progress(0.4, desc="Transcribing (faster-whisper)") | |
| yield status("📝 Transcribing the audio with faster-whisper (can take a while on CPU)…"), ranking, gr.update(), gr.update() | |
| segs = asr_mod.transcribe_file(video_path, progress=progress) | |
| transcript = transcribe_mod.transcript_text(segs) | |
| yield (status(f"Transcript ready ({len(segs)} segments)."), | |
| ranking, gr.update(value=transcript), gr.update()) | |
| # 5. Tutorial text ----------------------------------------------------------- | |
| progress(0.6, desc="Writing tutorial") | |
| keywords = _collect_keywords(primary_kw, secondary_kw) | |
| kw_note = f" • primary: '{keywords['primary']}'" if keywords["primary"] else "" | |
| if keywords["secondary"]: | |
| kw_note += f" • secondary: {', '.join(keywords['secondary'])}" | |
| if (content_brief or "").strip(): | |
| kw_note += " • honoring your content brief" | |
| yield status(f"🤖 Generating tutorial with `{llm_model}`{kw_note}…"), ranking, gr.update(value=transcript), gr.update() | |
| tut = tutorial_mod.generate_tutorial(transcript, hf_token.strip(), llm_model, | |
| keywords, brief=content_brief) | |
| if keywords["primary"]: | |
| n = tutorial_mod.count_keyword(tut, keywords["primary"]) | |
| yield (status(f"🔑 Primary keyword '{keywords['primary']}' appears {n}× in the post."), | |
| ranking, gr.update(value=transcript), gr.update()) | |
| # 6. Screenshots from the local video (ffmpeg, no network) ------------------- | |
| selected, caps = {}, {} | |
| times = frames_mod.compute_shot_times( | |
| tut["steps"], segs, w_llm=float(w_llm), w_whisper=float(w_whisper), | |
| lead=float(lead), max_shots=int(max_shots)) | |
| if times: | |
| progress(0.8, desc="Screenshots") | |
| yield status(f"🎞️ Capturing {len(times)} screenshots from the video…"), ranking, gr.update(value=transcript), gr.update() | |
| try: | |
| selected = frames_mod.capture_from_file(times, video_path, frames_dir, progress) | |
| except Exception as exc: | |
| yield (status(f"⚠️ Couldn't extract screenshots — text-only tutorial. " | |
| f"`{type(exc).__name__}: {str(exc)[:300]}`"), | |
| ranking, gr.update(value=transcript), gr.update()) | |
| if selected: | |
| progress(0.9, desc="Captioning") | |
| yield status(f"✍️ Captioning {len(selected)} screenshots with `{vlm_model}`…"), ranking, gr.update(value=transcript), gr.update() | |
| caps = captions_mod.caption_frames(selected, tut["steps"], hf_token.strip(), vlm_model, progress) | |
| # Done with the video — delete it (keep only the .docx for download). | |
| try: | |
| os.remove(video_path) | |
| except OSError: | |
| pass | |
| # 7. DOCX -------------------------------------------------------------------- | |
| progress(0.96, desc="Building document") | |
| out_path = os.path.join(workdir, f"{_safe_name(tut['title'])}.docx") | |
| docx_builder.build_docx(tut, selected, caps, out_path, source_url=best["url"]) | |
| progress(1.0, desc="Done") | |
| shots_msg = f"{len(selected)} screenshots" if selected else "text-only" | |
| yield (status(f"✅ Done ({shots_msg}). Download your tutorial below."), | |
| ranking, gr.update(value=transcript), gr.update(value=out_path)) | |
| except gr.Error: | |
| raise | |
| except (transcribe_mod.TranscriptError, sentiment_mod.SentimentError, | |
| RuntimeError, ValueError) as exc: | |
| raise gr.Error(str(exc)) | |
| finally: | |
| # Always delete the downloaded video (keep only the .docx for download). | |
| try: | |
| os.remove(video_path) | |
| except OSError: | |
| pass | |
| def build_ui(): | |
| with gr.Blocks(title="YouTube → Tutorial Post") as demo: | |
| gr.Markdown( | |
| "# 📝 YouTube → Tutorial Post Generator\n" | |
| "Enter a topic, your **Hugging Face token** (LLM + vision model, billed to you) " | |
| "and a **YouTube Data API key** (for comments). The Space picks the best video by " | |
| "comment sentiment, pulls its transcript, writes an AEO-friendly tutorial, grabs " | |
| "real screenshots at the right moments, and builds a **.docx**." | |
| ) | |
| with gr.Row(): | |
| with gr.Column(scale=2): | |
| topic = gr.Textbox(label="Topic", placeholder="e.g. Excel pivot tables for beginners") | |
| hf_token = gr.Textbox(label="Hugging Face token", type="password", | |
| placeholder="hf_… (Inference Providers permission)") | |
| yt_api_key = gr.Textbox(label="YouTube Data API key", type="password", | |
| placeholder="for comments (or set the YOUTUBE_API_KEY secret)") | |
| with gr.Column(scale=1): | |
| llm_model = gr.Dropdown(LLM_CHOICES, value=LLM_CHOICES[0], | |
| label="Tutorial LLM", allow_custom_value=True) | |
| vlm_model = gr.Dropdown(VLM_CHOICES, value=VLM_CHOICES[0], | |
| label="Vision model (captions)", allow_custom_value=True) | |
| content_brief = gr.Textbox( | |
| label="What the content must cover (optional)", | |
| lines=4, | |
| placeholder=("List the points, steps, or questions the tutorial must address — " | |
| "one per line or comma-separated.\n" | |
| "e.g. How to create a pivot table\nHow to refresh data\n" | |
| "Common pivot table errors"), | |
| info=("Required coverage for the tutorial writer. Leave blank to just follow " | |
| "the video. Screenshots stay optional — if none are suitable the post is " | |
| "produced without them."), | |
| ) | |
| with gr.Accordion("SEO / AEO keywords (optional)", open=False): | |
| gr.Markdown( | |
| "The **primary keyword** is used naturally ~3× in the body and placed in " | |
| "the title, URL slug, meta description, the first 100 words, and one or two " | |
| "H2 headings. Each **secondary keyword** is used once. The post also follows " | |
| "answer-engine best practices (direct answer up top, FAQ, last-updated date, " | |
| "source citation)." | |
| ) | |
| primary_kw = gr.Textbox(label="Primary keyword", placeholder="e.g. excel pivot tables") | |
| secondary_kw = gr.Textbox(label="Secondary keywords (comma-separated)", | |
| placeholder="e.g. pivot chart, data summary") | |
| with gr.Accordion("Advanced settings", open=False): | |
| with gr.Row(): | |
| w_llm = gr.Slider(0.0, 1.0, value=0.4, step=0.05, label="Weight: LLM timestamp") | |
| w_whisper = gr.Slider(0.0, 1.0, value=0.6, step=0.05, label="Weight: transcript timing") | |
| lead = gr.Slider(0.0, 5.0, value=1.0, step=0.5, label="Lead offset (s)") | |
| max_shots = gr.Slider(1, 15, value=8, step=1, label="Max screenshots") | |
| with gr.Row(): | |
| health_btn = gr.Button("🩺 Check YouTube access (proxy + reachability)") | |
| health_md = gr.Markdown() | |
| run_btn = gr.Button("Generate tutorial", variant="primary") | |
| status_md = gr.Markdown(label="Status") | |
| ranking_df = gr.Dataframe( | |
| headers=["#", "Title", "Positive", "Comments", "Note", "URL"], | |
| label="Sentiment ranking", interactive=False, wrap=True, | |
| ) | |
| transcript_box = gr.Textbox(label="Transcript preview", lines=10, max_lines=20) | |
| docx_file = gr.File(label="Download tutorial (.docx)") | |
| health_btn.click(check_access, outputs=health_md) | |
| run_btn.click( | |
| run_pipeline, | |
| inputs=[topic, hf_token, yt_api_key, llm_model, vlm_model, | |
| w_llm, w_whisper, lead, max_shots, primary_kw, secondary_kw, | |
| content_brief], | |
| outputs=[status_md, ranking_df, transcript_box, docx_file], | |
| ) | |
| return demo | |
| # Expose a module-level `demo` so HF Spaces' SSR launcher finds it (avoids the | |
| # "Launching demo not found in __main__" fallback warning). | |
| demo = build_ui() | |
| demo.queue() | |
| if __name__ == "__main__": | |
| demo.launch() | |