diff --git a/.dockerignore b/.dockerignore
deleted file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000
diff --git a/.gitattributes b/.gitattributes
deleted file mode 100644
index a6344aac8c09253b3b630fb776ae94478aa0275b..0000000000000000000000000000000000000000
--- a/.gitattributes
+++ /dev/null
@@ -1,35 +0,0 @@
-*.7z filter=lfs diff=lfs merge=lfs -text
-*.arrow filter=lfs diff=lfs merge=lfs -text
-*.bin filter=lfs diff=lfs merge=lfs -text
-*.bz2 filter=lfs diff=lfs merge=lfs -text
-*.ckpt filter=lfs diff=lfs merge=lfs -text
-*.ftz filter=lfs diff=lfs merge=lfs -text
-*.gz filter=lfs diff=lfs merge=lfs -text
-*.h5 filter=lfs diff=lfs merge=lfs -text
-*.joblib filter=lfs diff=lfs merge=lfs -text
-*.lfs.* filter=lfs diff=lfs merge=lfs -text
-*.mlmodel filter=lfs diff=lfs merge=lfs -text
-*.model filter=lfs diff=lfs merge=lfs -text
-*.msgpack filter=lfs diff=lfs merge=lfs -text
-*.npy filter=lfs diff=lfs merge=lfs -text
-*.npz filter=lfs diff=lfs merge=lfs -text
-*.onnx filter=lfs diff=lfs merge=lfs -text
-*.ot filter=lfs diff=lfs merge=lfs -text
-*.parquet filter=lfs diff=lfs merge=lfs -text
-*.pb filter=lfs diff=lfs merge=lfs -text
-*.pickle filter=lfs diff=lfs merge=lfs -text
-*.pkl filter=lfs diff=lfs merge=lfs -text
-*.pt filter=lfs diff=lfs merge=lfs -text
-*.pth filter=lfs diff=lfs merge=lfs -text
-*.rar filter=lfs diff=lfs merge=lfs -text
-*.safetensors filter=lfs diff=lfs merge=lfs -text
-saved_model/**/* filter=lfs diff=lfs merge=lfs -text
-*.tar.* filter=lfs diff=lfs merge=lfs -text
-*.tar filter=lfs diff=lfs merge=lfs -text
-*.tflite filter=lfs diff=lfs merge=lfs -text
-*.tgz filter=lfs diff=lfs merge=lfs -text
-*.wasm filter=lfs diff=lfs merge=lfs -text
-*.xz filter=lfs diff=lfs merge=lfs -text
-*.zip filter=lfs diff=lfs merge=lfs -text
-*.zst filter=lfs diff=lfs merge=lfs -text
-*tfevents* filter=lfs diff=lfs merge=lfs -text
diff --git a/.rebuild b/.rebuild
deleted file mode 100644
index 1d41f2f463be7e2787f613dad97ef3d46d1d10a3..0000000000000000000000000000000000000000
--- a/.rebuild
+++ /dev/null
@@ -1 +0,0 @@
-Rebuild triggered $(date +%s)
\ No newline at end of file
diff --git a/.restart_trigger b/.restart_trigger
deleted file mode 100644
index 73c311ef5e3fac883fe410c694450c53ba0feeee..0000000000000000000000000000000000000000
--- a/.restart_trigger
+++ /dev/null
@@ -1 +0,0 @@
-Restart to apply AI scraper changes: RSS-based sources + random shuffle
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
deleted file mode 100644
index d7e8f05818627317960375f70db3aec4f04d7968..0000000000000000000000000000000000000000
--- a/CHANGELOG.md
+++ /dev/null
@@ -1,74 +0,0 @@
-# VNEWS v2.8 - Icon Change
-- Changed Short AI feed video share button icon from 📤 (upload/share) to 📥 (download) to distinguish from article share
-
-# VNEWS v6.5 - Resilient Shorts Auto-Updater
-
-## Changes
-
-### Critical Fix: Shorts timeout and homepage load stability
-**Root cause**: YouTube shorts fetching in `main.py` using `scrape_shorts()` and `_yt_channel_shorts_requests()` could hang indefinitely when YouTube blocks requests or yt-dlp times out, causing:
-- Homepage `/api/shorts` endpoint to time out (30s limit)
-- Space to appear unresponsive on first load
-- No fallback when sources fail
-
-**Fix applied**:
-1. **shorts_updater.py** (NEW) — Resilient background updater:
- - Hard timeout (25s) per channel using subprocess isolation
- - Stale-while-revalidate pattern: returns cached data immediately, updates in background
- - Automatic fallback to hardcoded short URLs when all sources fail
- - Persistent storage in `/data/shorts_cache.json` for cache across restarts
- - Background scheduler runs every 10 minutes automatically
- - No blocking on first homepage load
-
-2. **_run.py** — Integrated resilient shorts endpoint:
- - Overrides `/api/shorts` with non-blocking version
- - Returns cached/fallback data in <100ms guaranteed
- - Triggers background update if cache is stale or empty
- - Never hangs - always returns valid JSON response
-
-3. **FALLBACK_SHORTS** — 6 hardcoded viral shorts as emergency fallback:
- - baodantri7941 (Dân trí) headlines
- - baosuckhoedoisongboyte (Sức khỏe & đời sống) stories
- - vtvnambo (VTV Nam Bộ) news
-
-### Benefits
-- Homepage loads in <2 seconds always
-- Shorts data auto-updates every 10 minutes
-- Never times out - graceful degradation to fallback
-- Persistent cache survives Space restarts
-- Uses bucket `bep40/VNEWS-storage` for cache storage
-
-### Channels monitored
-- baodantri7941 (Dân trí)
-- baosuckhoedoisongboyte (Sức khỏe & đời sống)
-- vtvnambo (VTV Nam Bộ)
-
----
-
-# VNEWS v5.1 - Rewrite Fix
-
-## Changes
-
-### Critical Fix: Rewrite button not creating posts on Tường AI
-**Root cause**: `_run.py` imports from `app_v2_entry.py`, but the `/api/rewrite_share` endpoint was only defined in `ai_runtime_patch_fast.py` (loaded through `app_entry.py` which is NOT used). The frontend called a non-existent endpoint → 404 → silent failure.
-
-**Fix applied**:
-1. **app_v2_entry.py** — Added 3 new endpoints:
- - `POST /api/rewrite_slide` — Fast extractive summary (no AI needed), creates slides from article key points + images, saves to wall
- - `POST /api/rewrite_share` — AI-powered rewrite with extractive fallback, saves to wall
- - `POST /api/url_wall` — URL submission endpoint (alias for rewrite_share)
- - All endpoints use the same `_load_wall_posts()` / `_save_wall_posts()` and `WALL_FILE` path as the existing `/api/wall` endpoint
-
-2. **static/index_v2.html** — Added `` to load the rewrite fix
-
-3. **static/rewrite_fix_v2.js** — New file that overrides `rewriteArticle()` to:
- - Call `/api/rewrite_slide` first (fast, no AI needed)
- - Fallback to `/api/rewrite_share` if slide fails
- - Show slide preview overlay after successful post
- - Use `prependWallPost()` to add the new post to Tường AI
-
-### Previous changes (v5)
-- Rewrote match_detail_v2.py with correct event parsing
-- 2-tab layout for match detail (stats + timeline)
-- Fixed _run.py import
-- Dockerfile cache busting
diff --git a/Dockerfile b/Dockerfile
deleted file mode 100644
index 946afd0fec756241b13ea1bc15c0b6bc6367bad2..0000000000000000000000000000000000000000
--- a/Dockerfile
+++ /dev/null
@@ -1,48 +0,0 @@
-FROM python:3.12-slim
-
-WORKDIR /app
-
-RUN echo "[BUILD] step1: apt-get update+install ffmpeg + Vietnamese fonts" && \
- apt-get update && apt-get install -y --no-install-recommends \
- ffmpeg \
- fonts-dejavu-core \
- fonts-noto \
- fonts-noto-cjk \
- fonts-noto-color-emoji \
- fonts-liberation \
- fonts-freefont-ttf \
- libfreetype6 \
- && rm -rf /var/lib/apt/lists/* && \
- echo "[BUILD] step1 done"
-
-RUN echo "[BUILD] step2: pip base pkgs (bs4/lxml)" && \
- pip install --no-cache-dir "beautifulsoup4>=4.12" lxml && \
- echo "[BUILD] step2 done"
-
-RUN echo "[BUILD] step3: pip main pkgs" && \
- pip install --no-cache-dir fastapi uvicorn requests beautifulsoup4 jinja2 yt-dlp huggingface_hub gTTS pillow edge-tts python-dateutil httpx && \
- echo "[BUILD] step3 done"
-
-COPY requirements.txt .
-RUN echo "[BUILD] step4: pip requirements.txt" && \
- pip install --no-cache-dir -r requirements.txt || true && \
- echo "[BUILD] step4 done"
-
-COPY . .
-EXPOSE 7860
-
-RUN echo "[BUILD] step5: setup Vietnamese font symlink" && \
- mkdir -p /usr/share/fonts/truetype/vn && \
- # Prefer Noto Sans for Vietnamese - it has full diacritic support
- if [ -f /usr/share/fonts/truetype/noto/NotoSans-Regular.ttf ]; then \
- ln -sf /usr/share/fonts/truetype/noto/NotoSans-Regular.ttf /usr/share/fonts/truetype/vn/VNFont.ttf; \
- elif [ -f /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf ]; then \
- ln -sf /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf /usr/share/fonts/truetype/vn/VNFont.ttf; \
- ln -sf /usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf /usr/share/fonts/truetype/vn/VNFont-Bold.ttf; \
- fi; \
- fc-cache -f -v || true; \
- date > /app/.build_done && \
- echo "[BUILD] step5 done"
-
-CMD ["uvicorn", "_run:app", "--host", "0.0.0.0", "--port", "7860"]
-# v3.0-vn-font-fix-short-video-2026-07-19
diff --git a/README.md b/README.md
deleted file mode 100644
index 8f9526538a80ace404f12e119c3bd2d0e940b957..0000000000000000000000000000000000000000
--- a/README.md
+++ /dev/null
@@ -1,33 +0,0 @@
----
-title: VNEWS
-emoji: 📰
-colorFrom: green
-colorTo: yellow
-sdk: docker
-pinned: false
-tags:
-- ml-intern
----
-
-# VNEWS - Tin Tức Việt Nam
-
-**v18 - FIXED VTV2/VTV3/VTV6/VTV9 stream hanging**
-
-## 🔧 Changes in v18 (2026-07-06)
-- **VTV2, VTV3, VTV6, VTV9**: Skip expired ssaimh CDN token → immediately fall through to sv2.xemtivitop.com
-- **15+ extraction patterns** for m3u8 URL (up from 5), including: file:, src=, source:, player.src(), hls.loadSource(), href=, ``, url:, window.location, iframe follow (3 levels deep), base64 decode
-- **Backup CDN** `tv.mediacdn.vn` for VTV2/VTV3/VTV6/VTV9
-- **Fast timeout** 5s for CDN, 12s for PHP endpoints (was 15s each = 60s+ total)
-- **sv2.xemtivitop.com** re-prioritized to check BEFORE xemtv.us
-- **Iframe chain following**: if a PHP page returns an iframe → follow it up to 3 levels to find the m3u8
-
-## Features:
-- 📰 News from VnExpress (10 categories) + GenK AI
-- ⚽ Livescore from bongda.com.vn (live, today, upcoming, results, standings)
-- 🎬 Football highlights from xemlaibongda.top (8 leagues)
-- 📺 VTV live channels (VTV1→VTV10, VTV Prime)
- - Priority: ssaimh CDN → sv2.xemtivitop.com → xemtv.us → xemtivitop blogspot → FPTPlay → VTVGo → mediacdn → xemtv.net
-- 🏆 World Cup 2026 (news, fixtures, standings, stats, highlights)
-- 🤖 AI article writing + TTS (multilingual, emotion-aware)
-- 🔍 Topic search (8 news sources)
-- 🎤 TTS: voice selector + emotion selector + speed control
\ No newline at end of file
diff --git a/RESTART_TRIGGER.md b/RESTART_TRIGGER.md
deleted file mode 100644
index 4891fbeb87e48b5252d4f1f919f2cba0d2bd0702..0000000000000000000000000000000000000000
--- a/RESTART_TRIGGER.md
+++ /dev/null
@@ -1,6 +0,0 @@
-trigger rebuild 2026-07-18T10:35 +0700 - add missing ai/short/ and ai/short-file/ endpoints
-
-- Added POST /api/ai/short/{post_id} endpoint (was lost during route cleanup)
-- Added GET /api/ai/short-file/{file_id} endpoint (file serving)
-- Both were supposed to be in ai_patch.py but never existed there
-- Also added FileResponse import
\ No newline at end of file
diff --git a/TEMP_REBUILD_TRIGGER.txt b/TEMP_REBUILD_TRIGGER.txt
deleted file mode 100644
index 5b52fc9f825e7d7997f32173b23d1b1f0aed0156..0000000000000000000000000000000000000000
--- a/TEMP_REBUILD_TRIGGER.txt
+++ /dev/null
@@ -1 +0,0 @@
-rebuild
\ No newline at end of file
diff --git a/TRIGGER_REBUILD b/TRIGGER_REBUILD
deleted file mode 100644
index 9a548eafd2535025386dd7ddc800e6d8a17ac2c1..0000000000000000000000000000000000000000
--- a/TRIGGER_REBUILD
+++ /dev/null
@@ -1,2 +0,0 @@
-FIX: ai_patch.py root route killer removed + main.py stale root route removed
-$(date +%s)
\ No newline at end of file
diff --git a/_run.py b/_run.py
deleted file mode 100644
index de72380c3006dba490ec49c1fe421c87723a0490..0000000000000000000000000000000000000000
--- a/_run.py
+++ /dev/null
@@ -1 +0,0 @@
-from app_v2_entry import app # v5-stable inline bongda proxy
\ No newline at end of file
diff --git a/ai_ext.py b/ai_ext.py
index ed8e098a4fc0a8dccc5aa9ac5f5efafbdeac50be..cf4b1d0ff3215baa8ed3f05887a252c47d56bc90 100644
--- a/ai_ext.py
+++ b/ai_ext.py
@@ -174,9 +174,39 @@ async def qwen_generate(prompt: str, image_url: str = None, max_tokens: int = 12
def _fallback_summary_from_prompt(prompt: str, max_units: int = 6) -> str:
- """Generate a simple fallback summary when AI is unavailable.
- Returns empty string so callers can provide their own fallback."""
- return ""
+ """Generate a simple fallback summary when AI is unavailable."""
+ text = prompt or ""
+ for marker in ["Nội dung nguồn:", "Nội dung bài:", "Nội dung gốc:", "Nội dung:", "Nguồn/bối cảnh internet:"]:
+ if marker in text:
+ text = text.split(marker, 1)[1]
+ break
+ text = re.sub(r"https?://\S+", "", text)
+ text = re.sub(r"\s+", " ", text).strip()
+
+ # Split into sentences - extract ALL valid sentences, not just first few
+ sentences = re.split(r"(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])", text)
+ units = []
+ for s in sentences:
+ s = _clean_text(s)
+ if len(s) >= 30: # Lower threshold to capture more content
+ units.append(s)
+
+ if units:
+ # Take up to max_units valid sentences
+ result_units = units[:max_units]
+ return "\n".join("• " + u for u in result_units)
+ if text:
+ # Fallback: take chunks if no sentence boundaries found
+ chunks = []
+ for i in range(0, min(len(text), max_units * 300), 280):
+ chunk = _clean_text(text[i:i+300])
+ if chunk and chunk not in chunks:
+ chunks.append(chunk)
+ if len(chunks) >= max_units:
+ break
+ if chunks:
+ return "\n".join("• " + c for c in chunks)
+ return "• Không có đủ nội dung để tóm tắt."
HF_TOKEN = _hf_token()
diff --git a/ai_fix2.py b/ai_fix2.py
deleted file mode 100644
index 895be1d7505ed5fedaec5b8f023a52434101989f..0000000000000000000000000000000000000000
--- a/ai_fix2.py
+++ /dev/null
@@ -1,366 +0,0 @@
-import os, re, subprocess, html as html_lib, json
-from urllib.parse import quote_plus, urlparse, parse_qs, unquote
-import requests
-import ai_patch as prev
-from ai_patch import app
-from fastapi import Request
-from fastapi.responses import JSONResponse, HTMLResponse, FileResponse
-
-base = prev.base
-
-
-def clean(s):
- return re.sub(r"\s+", " ", html_lib.unescape(s or "")).strip()
-
-
-def _is_real_article_text(raw):
- raw = clean(raw)
- if len(raw) < 500:
- return False
- # Reject search-result/title-only pages: need several real sentences.
- sentences = re.split(r"(?<=[\.\!\?])\s+", raw)
- long_sentences = [s for s in sentences if len(s) > 45]
- return len(long_sentences) >= 5
-
-
-def _extract_ddg_url(href):
- if not href:
- return ""
- if href.startswith("//"):
- href = "https:" + href
- if "duckduckgo.com/l/" in href:
- try:
- qs = parse_qs(urlparse(href).query)
- if qs.get("uddg"):
- return unquote(qs["uddg"][0])
- except Exception:
- pass
- return href
-
-
-def _ddg_article_urls(topic, limit=12):
- urls = []
- try:
- q = quote_plus(topic + " tin tức bài viết phân tích")
- r = requests.get("https://html.duckduckgo.com/html/?q=" + q, headers=base.HEADERS, timeout=18)
- r.encoding = "utf-8"
- from bs4 import BeautifulSoup
- soup = BeautifulSoup(r.text, "lxml")
- for a in soup.select("a.result__a"):
- u = _extract_ddg_url(a.get("href", ""))
- if not u.startswith("http"):
- continue
- if any(bad in u for bad in ["google.com", "youtube.com", "facebook.com", "x.com", "twitter.com"]):
- continue
- if u not in urls:
- urls.append(u)
- if len(urls) >= limit:
- break
- except Exception:
- pass
- return urls
-
-
-def _rss_article_urls(topic, limit=10):
- out = []
- try:
- url = "https://news.google.com/rss/search?q=" + quote_plus(topic) + "&hl=vi&gl=VN&ceid=VN:vi"
- r = requests.get(url, headers=base.HEADERS, timeout=15)
- r.encoding = "utf-8"
- from bs4 import BeautifulSoup
- soup = BeautifulSoup(r.text, "xml")
- for it in soup.find_all("item")[:limit]:
- title = it.find("title").get_text(" ", strip=True) if it.find("title") else ""
- link = it.find("link").get_text(strip=True) if it.find("link") else ""
- src = it.find("source").get_text(" ", strip=True) if it.find("source") else base._domain(link)
- if title and link:
- out.append({"title": title, "url": link, "via": src, "excerpt": title})
- except Exception:
- pass
- return out
-
-
-def _topic_source_articles(topic, limit=5):
- """Scrape actual article bodies. Do not accept title-only sources."""
- candidates = []
- seen = set()
-
- # 1) DuckDuckGo actual result URLs are usually more directly scrapable.
- for u in _ddg_article_urls(topic, limit=14):
- if u not in seen:
- seen.add(u)
- candidates.append({"url": u, "title": "", "via": base._domain(u)})
-
- # 2) Add base web_context sources.
- try:
- _ctx, srcs = base.web_context(topic, limit=8)
- for s in srcs or []:
- u = s.get("url") or ""
- if u.startswith("http") and u not in seen:
- seen.add(u)
- candidates.append(s)
- except Exception:
- pass
-
- # 3) Google News RSS fallback last.
- for s in _rss_article_urls(topic, limit=10):
- u = s.get("url") or ""
- if u.startswith("http") and u not in seen:
- seen.add(u)
- candidates.append(s)
-
- out = []
- for s in candidates[:24]:
- url = s.get("url") or ""
- try:
- page = base.scrape_any_url(url)
- raw = (page.get("summary", "") + "\n" + page.get("text", "")).strip()
- if not _is_real_article_text(raw):
- continue
- title = page.get("title") or s.get("title") or url
- via = page.get("via") or s.get("via") or base._domain(url)
- out.append({
- "title": title,
- "url": url,
- "raw": raw,
- "image": page.get("image") or "",
- "via": via,
- "source": {"title": title, "url": url, "excerpt": raw[:700], "via": via}
- })
- if len(out) >= limit:
- break
- except Exception:
- continue
- return out[:limit]
-
-
-def sentence_split(text):
- text = re.sub(r"^[•\-\*]\s*", "", text or "", flags=re.M)
- text = re.sub(r"\n+", ". ", text)
- parts = []
- for s in re.split(r"(?<=[\.\!\?])\s+", text):
- s = clean(s)
- if len(s) >= 8:
- parts.append(s)
- return parts
-
-
-def srt_time(sec):
- ms = int((sec - int(sec)) * 1000)
- sec = int(sec)
- return f"{sec//3600:02d}:{(sec%3600)//60:02d}:{sec%60:02d},{ms:03d}"
-
-
-def parse_timecode(t):
- # 00:00:01.234 or 00:00:01,234
- t = t.replace(',', '.')
- parts = t.split(':')
- if len(parts) == 3:
- return int(parts[0])*3600 + int(parts[1])*60 + float(parts[2])
- if len(parts) == 2:
- return int(parts[0])*60 + float(parts[1])
- return float(parts[0])
-
-
-def convert_vtt_to_scaled_srt(vtt_path, srt_path, speed=1.2):
- try:
- txt = open(vtt_path, 'r', encoding='utf-8').read().splitlines()
- cues = []
- i = 0
- while i < len(txt):
- line = txt[i].strip()
- if '-->' in line:
- a, b = [x.strip().split()[0] for x in line.split('-->')[:2]]
- start = parse_timecode(a) / speed
- end = parse_timecode(b) / speed
- i += 1
- texts = []
- while i < len(txt) and txt[i].strip():
- texts.append(txt[i].strip())
- i += 1
- s = clean(' '.join(texts))
- if s:
- cues.append((start, end, s))
- i += 1
- if not cues:
- return False
- with open(srt_path, 'w', encoding='utf-8') as f:
- for idx, (st, en, s) in enumerate(cues, 1):
- if en <= st:
- en = st + 1.2
- f.write(f"{idx}\n{srt_time(st)} --> {srt_time(en)}\n{s}\n\n")
- return True
- except Exception:
- return False
-
-
-def write_weighted_srt(script, path, total_duration):
- subs = sentence_split(script)
- if not subs:
- subs = [clean(script)[:140] or "VNEWS"]
- total_chars = max(1, sum(len(x) for x in subs))
- usable = max(2.0, float(total_duration) - 1.0)
- cur = 0.5
- with open(path, "w", encoding="utf-8") as f:
- for i, s in enumerate(subs, 1):
- dur = max(1.8, min(7.0, usable * len(s) / total_chars))
- start = cur
- end = min(total_duration - 0.15, cur + dur)
- cur = end + 0.18
- f.write(f"{i}\n{srt_time(start)} --> {srt_time(end)}\n{s}\n\n")
- if cur >= total_duration - 0.2:
- break
-
-
-def tts_script_full(post, emotion):
- title = clean(post.get("title", ""))
- text = clean(post.get("text", ""))
- text = re.sub(r"Nguồn tham khảo:.*", "", text, flags=re.S).strip()
- prefix = {
- "urgent": "Tin nhanh.",
- "warm": "Câu chuyện đáng chú ý.",
- "serious": "Bản tin nghiêm túc.",
- "energetic": "Cập nhật nổi bật.",
- }.get(emotion, "")
- script = f"{prefix} {title}. {text}".strip()
- # Keep complete wall summary. Only trim pathological payloads, on sentence boundary.
- if len(script) > 3600:
- tmp = script[:3600]
- cut = max(tmp.rfind("."), tmp.rfind("!"), tmp.rfind("?"))
- script = tmp[:cut + 1] if cut > 1600 else tmp
- script = re.sub(r"([\.\!\?])\s*", r"\1\n", script)
- script = re.sub(r"\n{2,}", "\n", script).strip()
- return script
-
-
-_PATCH = {('/api/topic_post','POST'),('/api/ai/short/{post_id}','POST'),('/api/ai/short-file/{file_id}','GET'),('/','GET')}
-app.router.routes = [r for r in app.router.routes if not any(getattr(r,'path',None)==p and m in getattr(r,'methods',set()) for p,m in _PATCH)]
-
-
-@app.post('/api/topic_post')
-async def topic_post_aggregate(request: Request):
- body = await request.json()
- topic = base._clean_text(body.get('topic',''))
- if not topic:
- return JSONResponse({'error':'missing topic'}, status_code=400)
- articles = _topic_source_articles(topic, limit=5)
- if not articles:
- return JSONResponse({'error':'Không scrape được nội dung bài viết thật cho chủ đề này. Hãy thử chủ đề cụ thể hơn hoặc dán URL trực tiếp.'}, status_code=422)
- source_blocks = []
- sources = []
- image = ""
- for i, art in enumerate(articles, 1):
- raw = art.get('raw','')
- source_blocks.append(f"[Nguồn {i}] {art.get('title','')} ({art.get('via','')})\n{raw[:3000]}")
- sources.append(art.get('source') or {'title': art.get('title'), 'url': art.get('url'), 'via': art.get('via'), 'excerpt': raw[:600]})
- if not image and art.get('image'):
- image = art.get('image')
- ctx = "\n\n".join(source_blocks)
- prompt = f"""Bạn là biên tập viên tổng hợp tin tức tiếng Việt.
-
-Chủ đề: {topic}
-
-NHIỆM VỤ:
-- Đọc nội dung của TẤT CẢ các bài nguồn bên dưới.
-- Tổng hợp thành 1 bản tóm tắt chung duy nhất, giống cách tóm tắt qua URL.
-- Không tạo mỗi tiêu đề thành một bài riêng.
-- Không chỉ liệt kê tiêu đề; phải dựa vào nội dung trong từng bài.
-- Không lặp ý giữa các nguồn.
-- Tối đa 6 gạch đầu dòng, mỗi dòng 1 câu rõ ràng.
-- Nếu các nguồn có góc nhìn khác nhau, gộp lại thành ý tổng hợp.
-- Cuối cùng thêm dòng: Nguồn tham khảo: tên website.
-
-Nội dung nguồn:
-{ctx[:16000]}"""
- text = await prev.base.qwen_generate(prompt, image_url=image or None, max_tokens=1100)
- text = prev._postprocess_ai_text(text, max_units=7)
- if 'Nguồn tham khảo:' not in text:
- text += '\n\n' + prev._source_line(sources)
- post = base.make_post('Tổng hợp: ' + topic, text, image or base.pollinations_image_url(topic), '', 'topic_aggregate', sources=sources[:5])
- posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts)
- return JSONResponse({'post': post, 'count_sources': len(sources)})
-
-
-@app.post('/api/ai/short/{post_id}')
-async def ai_short_full(post_id: str, request: Request):
- try:
- body = await request.json()
- except Exception:
- body = {}
- voice = str(body.get('voice','nu')).lower().strip()
- emotion = str(body.get('emotion','neutral')).lower().strip()
- speed = max(0.85, min(1.35, float(body.get('speed', 1.2) or 1.2)))
- posts = base._load_ai_wall()
- post = next((p for p in posts if str(p.get('id')) == str(post_id)), None)
- if not post:
- return JSONResponse({'error':'post not found'}, status_code=404)
- os.makedirs(base.SHORTS_DIR, exist_ok=True)
- suffix = f"_{voice}_{emotion}_{str(speed).replace('.', 'p')}_fullv2"
- out_mp4 = os.path.join(base.SHORTS_DIR, base._safe_name(post_id + suffix) + '.mp4')
- if os.path.exists(out_mp4):
- post['video'] = '/api/ai/short-file/' + post_id + suffix
- base._save_ai_wall(posts)
- return JSONResponse({'video': post['video'], 'speed': speed, 'subtitles': True})
- work = os.path.join(base.SHORTS_DIR, base._safe_name(post_id + suffix)); os.makedirs(work, exist_ok=True)
- img = os.path.join(work,'image.jpg'); frame = os.path.join(work,'frame.jpg'); audio = os.path.join(work,'voice.mp3'); audio_fast=os.path.join(work,'voice_fast.mp3'); srt=os.path.join(work,'subtitles.srt'); vtt=os.path.join(work,'subtitles.vtt')
- try:
- base._download_image(post.get('img'), post.get('title','AI news'), img)
- prev._make_short_frame_full(post, img, frame)
- script = tts_script_full(post, emotion)
- edge_voice = {'nam':'vi-VN-NamMinhNeural','male':'vi-VN-NamMinhNeural','nu':'vi-VN-HoaiMyNeural','female':'vi-VN-HoaiMyNeural','mien-nam':'vi-VN-HoaiMyNeural'}.get(voice,'vi-VN-HoaiMyNeural')
- used_edge = False
- try:
- subprocess.run(['python','-m','edge_tts','--voice',edge_voice,'--text',script,'--write-media',audio,'--write-subtitles',vtt], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=260)
- used_edge = True
- except Exception:
- tld = 'com.vn' if voice in ('nu','female','mien-nam') else 'com'
- try:
- base.gTTS(script, lang='vi', tld=tld, slow=False).save(audio)
- except TypeError:
- base.gTTS(script, lang='vi', slow=False).save(audio)
- subprocess.run(['ffmpeg','-y','-i',audio,'-filter:a',f'atempo={speed}','-vn',audio_fast], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=220)
- duration = 45.0
- try:
- pr = subprocess.run(['ffprobe','-v','error','-show_entries','format=duration','-of','default=noprint_wrappers=1:no_key=1',audio_fast], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20)
- duration = float((pr.stdout or b'45').decode().strip() or 45)
- except Exception:
- pass
- if used_edge and os.path.exists(vtt):
- ok = convert_vtt_to_scaled_srt(vtt, srt, speed=speed)
- if not ok:
- write_weighted_srt(script, srt, duration)
- else:
- write_weighted_srt(script, srt, duration)
- vf = "scale=1080:1920,subtitles='{}':force_style='FontName=DejaVu Sans,FontSize=16,PrimaryColour=&H00FFFFFF,OutlineColour=&HAA000000,BorderStyle=1,Outline=1.5,Shadow=0,Alignment=2,MarginV=42'".format(srt.replace("'", "\\'"))
- cmd = ['ffmpeg','-y','-loop','1','-i',frame,'-i',audio_fast,'-shortest','-c:v','libx264','-tune','stillimage','-pix_fmt','yuv420p','-c:a','aac','-b:a','128k','-vf',vf,out_mp4]
- subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=420)
- post['video'] = '/api/ai/short-file/' + post_id + suffix
- post['short_voice'] = voice; post['short_emotion'] = emotion; post['short_speed'] = speed; post['short_subtitles'] = True
- base._save_ai_wall(posts)
- return JSONResponse({'video': post['video'], 'voice': voice, 'emotion': emotion, 'speed': speed, 'subtitles': True, 'duration': duration})
- except Exception as e:
- return JSONResponse({'error':'Không tạo được shorts: '+str(e)[:180]}, status_code=500)
-
-
-@app.get('/api/ai/short-file/{file_id}')
-def ai_short_file_full(file_id: str):
- path = os.path.join(base.SHORTS_DIR, base._safe_name(file_id) + '.mp4')
- if not os.path.exists(path):
- return JSONResponse({'error':'not found'}, status_code=404)
- return FileResponse(path, media_type='video/mp4', filename=f'vnews-ai-{file_id}.mp4')
-
-
-app.router.routes = [r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
-
-@app.get('/')
-async def index_fix2():
- with open('/app/static/index.html','r',encoding='utf-8') as f:
- html = f.read()
- inject = prev.PATCH_INJECT + r'''
-
-'''
- return HTMLResponse(html.replace('