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('', inject+'\n')) diff --git a/ai_patch.py b/ai_patch.py index 1fafaa84cd7827a92c5c21fc173e3f2eb811a9cf..41aeba3d810744429c14e473c32c3a9fb8e3a605 100644 --- a/ai_patch.py +++ b/ai_patch.py @@ -913,4 +913,5 @@ def api_ai_shorts(): posts = [p for p in base._load_ai_wall() if p.get('video')] return JSONResponse({'posts': posts[:80]}) -# Root route is managed by app_v2_entry.py (serve_index) - do NOT remove it here \ No newline at end of file + +app.router.routes = [r for r in app.router.routes if not (getattr(r, 'path', None) == '/' and 'GET' in getattr(r, 'methods', set()))] diff --git a/ai_runtime.py b/ai_runtime.py deleted file mode 100644 index b644d317bcc589fefc470ceb871cab0b3149a717..0000000000000000000000000000000000000000 --- a/ai_runtime.py +++ /dev/null @@ -1,357 +0,0 @@ -import os, re, subprocess, json, time, hashlib -import ai_patch as old -from ai_patch import app -import ai_ext as base -from fastapi import Request -from fastapi.responses import JSONResponse, HTMLResponse, FileResponse -try: - from PIL import Image, ImageDraw, ImageFont -except Exception: - Image = ImageDraw = ImageFont = None - - -def clean(s): - import html as html_lib - return re.sub(r"\s+", " ", html_lib.unescape(s or "")).strip() - - -def _domain(url): - try: - from urllib.parse import urlparse - return urlparse(url or '').netloc.replace('www.','') - except Exception: - return '' - - -def _strip_bullet_prefix(s): - # remove bullets, numbered prefixes, leading dots commonly produced by AI summaries - return clean(re.sub(r'^[\s•\-\*·▪▫●○\d\.\)\(]+', '', s or '')) - - -def source_line(sources): - names=[] - for s in (sources or [])[:5]: - via=s.get('via') or _domain(s.get('url','')) or s.get('title','') - if via and via not in names:names.append(via) - return 'Nguồn tham khảo: '+', '.join(names[:5]) if names else 'Nguồn tham khảo: tổng hợp internet' - - -def _source_badge(post): - sources=post.get('sources') or [] - for s in sources: - via=s.get('via') or _domain(s.get('url','')) - if via:return via - return _domain(post.get('url','')) or post.get('source') or 'VNEWS' - - -def _collect_all_images(data): - imgs=[] - def add(u): - u=(u or '').strip() - if not u or u.startswith('data:') or 'base64' in u:return - if u.startswith('//'):u='https:'+u - if u not in imgs:imgs.append(u) - add(data.get('image') or data.get('og_image') or data.get('img')) - for u in data.get('images') or []:add(u) - for b in data.get('body') or []: - if isinstance(b,dict) and b.get('type')=='img':add(b.get('src')) - return imgs[:20] - - -def _scrape_url_with_images(url): - data=base.scrape_any_url(url) - # extra pass: collect every useful image from original HTML, because some readers only return one image - try: - import requests - from bs4 import BeautifulSoup - r=requests.get(url,headers=base.HEADERS,timeout=18);r.encoding='utf-8' - soup=BeautifulSoup(r.text,'lxml') - extra=[] - for im in soup.find_all('img'): - src=im.get('data-src') or im.get('data-original') or im.get('data-lazy-src') or im.get('src') or '' - if src.startswith('//'):src='https:'+src - if src and 'base64' not in src and src not in extra: - # skip tiny icons/logos as much as possible - low=src.lower() - if any(x in low for x in ['logo','icon','avatar','sprite']): - continue - extra.append(src) - if len(extra)>=20:break - data['images']=_collect_all_images(data)+[u for u in extra if u not in _collect_all_images(data)] - except Exception: - data['images']=_collect_all_images(data) - data['images']=_collect_all_images(data) - if data['images'] and not data.get('image'): - data['image']=data['images'][0] - return data - - -def rich_context(topic, limit=5): - try: ctx,sources=base.web_context(topic, limit=limit) - except Exception: ctx,sources='',[] - rich=[];rs=[];seen=set() - for s in (sources or [])[:limit*2]: - url=s.get('url') or '' - if not url.startswith('http') or url in seen:continue - seen.add(url) - try: - data=base.scrape_any_url(url) - raw=(data.get('summary','')+'\n'+data.get('text','')).strip() - if len(raw)<180:continue - title=data.get('title') or s.get('title') or url - via=data.get('via') or s.get('via') or _domain(url) - rich.append(f"### {title} ({via})\n{raw[:2600]}") - rs.append({'title':title,'url':url,'excerpt':raw[:700],'via':via}) - if len(rich)>=limit:break - except Exception:continue - if rich:return '\n\n'.join(rich),rs - return ctx or f'Chủ đề: {topic}', sources or [] - - -def postprocess(text): - if hasattr(old,'_postprocess_ai_text'): - out=old._postprocess_ai_text(text, max_units=7) - else: - out=clean(text) - # keep wall text readable, but ensure short generation later won't show bullets - return out - - -# Remove old routes we must override. -_PATCH={('/api/topic_post','POST'),('/api/url_wall','POST'),('/api/rewrite_share','POST'),('/api/ai/url','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/url_wall') -async def url_wall_only(request:Request): - body=await request.json();url=base._clean_text(body.get('url','')) - if not url.startswith('http'):return JSONResponse({'error':'missing url'},status_code=400) - try:data=_scrape_url_with_images(url) - except Exception as e:return JSONResponse({'error':'Không scrape được URL: '+str(e)[:180]},status_code=422) - raw=(data.get('summary','')+'\n'+data.get('text','')).strip() - if len(raw)<120:return JSONResponse({'error':'URL không có đủ nội dung để tóm tắt'},status_code=422) - prompt=f"""Tóm tắt bài viết nguồn dưới đây để đăng lên Tường AI VNEWS. - -Yêu cầu bắt buộc: -- Chỉ tóm tắt nội dung chính, không viết lại toàn bộ bài. -- Ngắn gọn, cụ thể, dễ hiểu. -- Không lặp lại ý và không thêm chi tiết ngoài nguồn. -- Tối đa 5 ý chính hoặc 2 đoạn ngắn. -- Tránh dùng dấu đầu dòng nếu không thật cần thiết. - -Tiêu đề gốc: {data.get('title','')} -Nguồn: {data.get('via','') or _domain(url)} -Nội dung gốc: -{raw[:16000]}""" - text=await base.qwen_generate(prompt,image_url=(data.get('image') or None),max_tokens=900) - if not text:text=old._fallback_summary_from_prompt(prompt,max_units=5) if hasattr(old,'_fallback_summary_from_prompt') else raw[:900] - text=postprocess(text) - src=[{'title':data.get('title'), 'url':url, 'excerpt':raw[:500], 'via':data.get('via') or _domain(url)}] - if 'Nguồn tham khảo:' not in text:text+='\n\n'+source_line(src) - images=_collect_all_images(data) - post=base.make_post(data.get('title') or 'Bài viết',text,images[0] if images else (data.get('image') or ''),url,'url',sources=src) - post['images']=images - posts=base._load_ai_wall();posts.insert(0,post);base._save_ai_wall(posts) - return JSONResponse({'post':post}) - - -@app.post('/api/rewrite_share') -async def rewrite_share_url_only(request:Request): - return await url_wall_only(request) - - -@app.post('/api/ai/url') -async def ai_url_compat(request:Request): - return await url_wall_only(request) - - -@app.post('/api/topic_post') -async def topic_disabled(request:Request): - return JSONResponse({'error':'Đã tắt tạo bài theo chủ đề. Vui lòng dán URL bài viết để AI tóm tắt.'},status_code=410) - - -def split_segments(post,max_segments=8): - text=clean(post.get('text') or post.get('title') or '') - text=re.sub(r'Nguồn tham khảo:.*$','',text,flags=re.I|re.S).strip() - lines=[] - for ln in text.splitlines(): - ln=_strip_bullet_prefix(ln) - if len(ln)>=18:lines.append(ln) - if len(lines)<2: - lines=[_strip_bullet_prefix(s) for s in re.split(r'(?<=[\.\!\?])\s+',text) if len(_strip_bullet_prefix(s))>=25] - segs=[];cur='' - for ln in lines: - ln=_strip_bullet_prefix(ln) - if not ln:continue - if len(cur)+len(ln)<180:cur=(cur+' '+ln).strip() - else: - if cur:segs.append(_strip_bullet_prefix(cur)) - cur=ln - if cur:segs.append(_strip_bullet_prefix(cur)) - return segs[:max_segments] or [_strip_bullet_prefix(post.get('title','VNEWS'))] - - -def wrap_text(draw,text,font,maxw,max_lines): - words=clean(text).split();lines=[];cur='' - for w in words: - test=(cur+' '+w).strip() - try:width=draw.textbbox((0,0),test,font=font)[2] - except Exception:width=len(test)*20 - if width<=maxw:cur=test - else: - if cur:lines.append(cur) - cur=w - if len(lines)>=max_lines:break - if cur and len(lines)tr:nh=target[1];nw=int(nh*ratio) - else:nw=target[0];nh=int(nw/ratio) - im=im.resize((nw,nh));left=(nw-target[0])//2;top=(nh-target[1])//2 - bg.paste(im.crop((left,top,left+target[0],top+target[1])),(0,0)) - except Exception:pass - draw=ImageDraw.Draw(bg) - try: - fb=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',58) - ft=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',38) - fs=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',30) - fsmall=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',28) - except Exception:fb=ft=fs=fsmall=None - # source badge on top image corner - badge='Nguồn: '+_source_badge(post) - try: - b=draw.textbbox((0,0),badge,font=fsmall);bw=b[2]-b[0];bh=b[3]-b[1] - except Exception: - bw=len(badge)*16;bh=34 - bx=W-bw-42;by=24 - draw.rounded_rectangle((bx-16,by-8,W-24,by+bh+14),radius=18,fill=(0,0,0,170)) - draw.text((bx,by),badge,fill=(255,255,255),font=fsmall) - # bottom text area - draw.rectangle((0,hero_h-20,W,H),fill=(12,12,12)) - # progress bars centered - total_w=total*38-14;start=(W-total_w)//2 - for i in range(total): - fill=(92,184,122) if i==idx else (70,70,70) - draw.rounded_rectangle((start+i*38,820,start+i*38+24,832),radius=6,fill=fill) - brand='VNEWS AI SHORT' - try: - bb=draw.textbbox((0,0),brand,font=ft);tx=(W-(bb[2]-bb[0]))//2 - except Exception:tx=360 - draw.text((tx,870),brand,fill=(110,231,143),font=ft) - clean_seg=_strip_bullet_prefix(seg) - lines=wrap_text(draw,clean_seg,fb,W-120,8) - block_h=len(lines)*74 - y=max(980, 1250-block_h//2) - _draw_center(draw,lines,fb,y,(255,255,255),W,74) - # small title centered near bottom - title_lines=wrap_text(draw,_strip_bullet_prefix(post.get('title','')),fs,W-120,3) - y2=1640 - draw.line((80,y2-26,W-80,y2-26),fill=(70,70,70),width=2) - _draw_center(draw,title_lines,fs,y2,(220,220,220),W,42) - bg.save(out_path,quality=92) - - -def make_tts(text,voice,out_path): - v={'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') - text=_strip_bullet_prefix(text) - try:subprocess.run(['python','-m','edge_tts','--voice',v,'--text',text,'--write-media',out_path],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=160) - except Exception: - tld='com.vn' if voice in ('nu','female','mien-nam') else 'com' - try:base.gTTS(text,lang='vi',tld=tld,slow=False).save(out_path) - except TypeError:base.gTTS(text,lang='vi',slow=False).save(out_path) - - -@app.post('/api/ai/short/{post_id}') -async def short_segments(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) - segs=split_segments(post,8) - os.makedirs(base.SHORTS_DIR,exist_ok=True);suffix=f'_{voice}_{emotion}_{str(speed).replace(".","p")}_centered_source_nobullet' - out=os.path.join(base.SHORTS_DIR,base._safe_name(post_id+suffix)+'.mp4') - if os.path.exists(out):post['video']='/api/ai/short-file/'+post_id+suffix;base._save_ai_wall(posts);return JSONResponse({'video':post['video'],'segments':len(segs),'subtitles':False}) - 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');base._download_image(post.get('img'),post.get('title','AI news'),img) - clips=[] - try: - for i,seg in enumerate(segs): - frame=os.path.join(work,f'f{i}.jpg');aud=os.path.join(work,f'a{i}.mp3');aud2=os.path.join(work,f'a{i}_fast.mp3');clip=os.path.join(work,f'c{i}.mp4') - seg=_strip_bullet_prefix(seg) - make_frame(post,seg,i,len(segs),img,frame) - 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,'') - spoken=(prefix+' '+seg).strip() if i==0 and prefix else seg - make_tts(spoken,voice,aud) - subprocess.run(['ffmpeg','-y','-i',aud,'-filter:a',f'atempo={speed}','-vn',aud2],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=120) - subprocess.run(['ffmpeg','-y','-loop','1','-i',frame,'-i',aud2,'-shortest','-c:v','libx264','-tune','stillimage','-pix_fmt','yuv420p','-c:a','aac','-b:a','128k','-vf','scale=1080:1920',clip],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=180) - clips.append(clip) - lf=os.path.join(work,'list.txt') - with open(lf,'w',encoding='utf-8') as f: - for c in clips:f.write("file '{}".format(c.replace("'","'\\''"))+"'\n") - subprocess.run(['ffmpeg','-y','-f','concat','-safe','0','-i',lf,'-c','copy',out],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=240) - post['video']='/api/ai/short-file/'+post_id+suffix;post['short_subtitles']=False;post['short_segments']=segs;post['short_speed']=speed;base._save_ai_wall(posts) - return JSONResponse({'video':post['video'],'segments':len(segs),'subtitles':False}) - except Exception as e:return JSONResponse({'error':'Không tạo được shorts: '+str(e)[:200]},status_code=500) - - -@app.get('/api/ai/short-file/{file_id}') -def short_file(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') - - -# Rebuild / with old UI injection plus final UI overrides. -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_runtime(): - with open('/app/static/index.html','r',encoding='utf-8') as f:html=f.read() - inject=getattr(old,'PATCH_INJECT','')+r''' - - -''' - return HTMLResponse(html.replace('',inject+'\n') if '' in html else html+inject) diff --git a/ai_runtime_final.py b/ai_runtime_final.py deleted file mode 100644 index 8dce363f4414b76f00d41b1e4cccc2c2c3809d78..0000000000000000000000000000000000000000 --- a/ai_runtime_final.py +++ /dev/null @@ -1,315 +0,0 @@ -"""Final runtime overrides for VNEWS AI UI, article-only images, shareable AI wall, and robust Vietnamese shorts.""" -import os, re, requests, subprocess, time -from urllib.parse import urlparse, quote -import ai_runtime as rt -from ai_runtime import app -import ai_ext as base -from fastapi import Request, Query -from fastapi.responses import HTMLResponse, JSONResponse, FileResponse -try: - from PIL import Image, ImageDraw, ImageFont -except Exception: - Image = ImageDraw = ImageFont = None - -RESTORE_INDEX_URL = "https://huggingface.co/spaces/bep40/vnews/raw/restore-33c3dda/static/index.html" -SPACE_URL = "https://bep40-vnews.hf.space" -DEFAULT_IMG = "https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg" - -# Only voices that support Vietnamese reliably. Extra labels map to these Vietnamese neural voices. -VN_VOICES = { - "nu": "vi-VN-HoaiMyNeural", "female": "vi-VN-HoaiMyNeural", "hoaimy": "vi-VN-HoaiMyNeural", - "nu-tre": "vi-VN-HoaiMyNeural", "nu-truyen-cam": "vi-VN-HoaiMyNeural", "nu-tin-nhanh": "vi-VN-HoaiMyNeural", - "nam": "vi-VN-NamMinhNeural", "male": "vi-VN-NamMinhNeural", "namminh": "vi-VN-NamMinhNeural", - "nam-tram": "vi-VN-NamMinhNeural", "nam-ban-tin": "vi-VN-NamMinhNeural", "nam-nang-dong": "vi-VN-NamMinhNeural", -} - - -def clean(s): - import html as html_lib - return re.sub(r"\s+", " ", html_lib.unescape(s or "")).strip() - - -def _domain(url): - try:return urlparse(url or '').netloc.replace('www.','') - except Exception:return '' - - -def _strip_bullet_prefix(s): - return clean(re.sub(r'^[\s•\-\*·▪▫●○\d\.\)\(]+', '', s or '')) - - -def _source_badge_url_first(post): - d=_domain(post.get('url','')) - if d:return d - for s in post.get('sources') or []: - d=_domain(s.get('url','')) - if d:return d - return 'VNEWS' - - -def _abs_url(src, base_url): - if not src:return '' - src=src.strip() - if src.startswith('//'):return 'https:'+src - if src.startswith('/'): - try: - p=urlparse(base_url);return f'{p.scheme}://{p.netloc}{src}' - except Exception:return src - return src - - -def _article_content_block(soup): - for tag in soup.find_all(['script','style','nav','footer','aside','form','noscript','iframe']):tag.decompose() - # Aggressively remove related/ad/recommend containers before image collection. - bad_re=re.compile(r'(related|relate|recommend|suggest|sidebar|ads|advert|popular|more|xem-them|xemthem|tin-lien-quan|tinlienquan|doc-them|docthem|other-news|news-other|article-related|box-tin|box_related|story-related|recommend-news|same-category|cate-list|news-list|most-view|banner|qc|quang-cao|sponsor)',re.I) - for el in list(soup.find_all(True)): - cls=' '.join(el.get('class',[])); eid=el.get('id',''); role=el.get('role','') - if bad_re.search(cls) or bad_re.search(eid) or bad_re.search(role): - el.decompose() - selectors=['article','main article','.article-content','.article__body','.article-body','.article-detail','.detail-content','.content-detail','.singular-content','.news-content','.post-content','.entry-content','.knc-content','.fck_detail','.cms-body','.story-body','[class*=article-content]','[class*=detail-content]','[class*=singular-content]'] - for sel in selectors: - el=soup.select_one(sel) - if el and (len(el.find_all('p'))>=2 or len(el.find_all(['figure','picture','img']))>=1):return el - best=None;score=0 - for el in soup.find_all(['article','main','section','div']): - ps=el.find_all('p');imgs=el.find_all('img');txt=' '.join(p.get_text(' ',strip=True) for p in ps) - sc=len(ps)*120+len(imgs)*10+min(len(txt),4500) - cls=' '.join(el.get('class',[])).lower() - if any(k in cls for k in ['article','content','detail','post','entry','story']):sc+=800 - if sc>score:best=el;score=sc - return best or soup - - -def _image_is_likely_article(im, src): - low=(src or '').lower() - if not src or src.startswith('data:') or 'base64' in low:return False - if any(x in low for x in ['logo','icon','avatar','sprite','banner','ads','advert','tracking','pixel','social','share','author','thumb-related']):return False - alt=(im.get('alt') or im.get('title') or '').lower() - if any(x in alt for x in ['logo','avatar','quảng cáo','advertisement','banner']):return False - try: - w=int(re.sub(r'\D','',str(im.get('width') or '0')) or 0);h=int(re.sub(r'\D','',str(im.get('height') or '0')) or 0) - if (w and w<220) or (h and h<140):return False - except Exception:pass - return True - - -def _article_only_images(url): - """Collect images only inside main article content. If uncertain, return fewer/no images rather than related/ad images.""" - imgs=[] - try: - from bs4 import BeautifulSoup - r=requests.get(url,headers=getattr(base,'HEADERS',{}),timeout=18);r.encoding='utf-8' - soup=BeautifulSoup(r.text,'lxml') - block=_article_content_block(soup) - candidates=[] - # Prefer figure/picture under article body; then direct img in body. - for el in block.find_all(['figure','picture'],recursive=True): - im=el.find('img') - if im:candidates.append(im) - for im in block.find_all('img',recursive=True): - if im not in candidates:candidates.append(im) - seen=set() - for im in candidates: - src=(im.get('data-src') or im.get('data-original') or im.get('data-lazy-src') or im.get('data-srcset') or im.get('srcset') or im.get('src') or '') - if ',' in src:src=src.split(',')[0].strip().split(' ')[0] - else:src=src.strip().split(' ')[0] - src=_abs_url(src,url) - if src in seen or not _image_is_likely_article(im,src):continue - # parent text guard: skip images from any remaining related block - parent_txt=' '.join((im.parent.get('class',[]) if im.parent else []))+' '+(im.parent.get('id','') if im.parent else '') - if re.search(r'(related|recommend|tin-lien-quan|doc-them|xem-them|popular|ads|banner)',parent_txt,re.I):continue - seen.add(src);imgs.append(src) - if len(imgs)>=20:break - # Use og:image ONLY as article main image fallback when no body image found. - if not imgs: - og=soup.find('meta',property='og:image') or soup.find('meta',attrs={'name':'twitter:image'}) - if og: - src=_abs_url(og.get('content',''),url) - if src and 'logo' not in src.lower() and 'banner' not in src.lower():imgs.append(src) - except Exception:pass - return imgs[:20] - - -def _scrape_url_article_only(url): - data=base.scrape_any_url(url) - imgs=_article_only_images(url) - data['images']=imgs - if imgs:data['image']=imgs[0] - else:data['image']='' - return data - - -def _blank_image(path, title='VNEWS'): - if Image is None:return None - im=Image.new('RGB',(1080,760),(24,48,36));draw=ImageDraw.Draw(im) - try:f=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',48) - except Exception:f=None - draw.text((60,330),clean(title)[:40] or 'VNEWS',fill=(255,255,255),font=f) - im.save(path,quality=90);return path - - -def _download_image_safe(url, fallback_title, out_path): - if url: - try: - r=requests.get(url,headers=getattr(base,'HEADERS',{}),timeout=18) - if r.status_code==200 and len(r.content)>1200: - with open(out_path,'wb') as f:f.write(r.content) - # verify PIL opens it - if Image: - Image.open(out_path).verify() - return out_path - except Exception:pass - try: - return base._download_image('',fallback_title,out_path) - except Exception: - return _blank_image(out_path,fallback_title) - - -def final_make_tts(text,voice,out_path): - text=_strip_bullet_prefix(text) or 'Bản tin VNEWS.' - # Only Vietnamese voices. Unknown choices fall back to Vietnamese female. - edge_voice=VN_VOICES.get(str(voice or '').lower().strip(), 'vi-VN-HoaiMyNeural') - for ev in [edge_voice, 'vi-VN-HoaiMyNeural', 'vi-VN-NamMinhNeural']: - try: - subprocess.run(['python','-m','edge_tts','--voice',ev,'--text',text,'--write-media',out_path],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=180) - if os.path.exists(out_path) and os.path.getsize(out_path)>1000:return out_path - except Exception:pass - try: - base.gTTS(text,lang='vi',tld='com.vn',slow=False).save(out_path) - if os.path.exists(out_path) and os.path.getsize(out_path)>1000:return out_path - except Exception:pass - # Last-resort silent audio guarantees short generation succeeds. - subprocess.run(['ffmpeg','-y','-f','lavfi','-i','anullsrc=channel_layout=stereo:sample_rate=44100','-t','3','-q:a','9','-acodec','libmp3lame',out_path],stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=30) - return out_path - - -def _draw_center(draw, lines, font, y, fill, W, line_h): - for ln in lines: - try:box=draw.textbbox((0,0),ln,font=font);tw=box[2]-box[0] - except Exception:tw=len(ln)*24 - draw.text((max(30,(W-tw)//2),y),ln,fill=fill,font=font);y+=line_h - return y - - -def final_make_frame(post,seg,idx,total,img_path,out_path): - if Image is None:return rt.make_frame(post,seg,idx,total,img_path,out_path) - W,H=1080,1920;hero_h=760;bg=Image.new('RGB',(W,H),(12,12,12)) - try: - im=Image.open(img_path).convert('RGB');ratio=im.width/max(1,im.height);tr=W/hero_h - if ratio>tr:nh=hero_h;nw=int(nh*ratio) - else:nw=W;nh=int(nw/ratio) - im=im.resize((nw,nh));left=(nw-W)//2;top=(nh-hero_h)//2;bg.paste(im.crop((left,top,left+W,top+hero_h)),(0,0)) - except Exception:pass - draw=ImageDraw.Draw(bg) - try: - fb=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',58);ft=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',38);fs=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',30);fsmall=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',28) - except Exception:fb=ft=fs=fsmall=None - badge='Nguồn: '+_source_badge_url_first(post) - try:b=draw.textbbox((0,0),badge,font=fsmall);bw=b[2]-b[0];bh=b[3]-b[1] - except Exception:bw=len(badge)*16;bh=34 - bx=W-bw-42;by=24;draw.rounded_rectangle((bx-16,by-8,W-24,by+bh+14),radius=18,fill=(0,0,0));draw.text((bx,by),badge,fill=(255,255,255),font=fsmall) - draw.rectangle((0,hero_h-20,W,H),fill=(12,12,12)) - total=max(1,total);total_w=total*38-14;start=(W-total_w)//2 - for i in range(total):draw.rounded_rectangle((start+i*38,820,start+i*38+24,832),radius=6,fill=(92,184,122) if i==idx else (70,70,70)) - brand='VNEWS AI SHORT' - try:bb=draw.textbbox((0,0),brand,font=ft);tx=(W-(bb[2]-bb[0]))//2 - except Exception:tx=360 - draw.text((tx,870),brand,fill=(110,231,143),font=ft) - seg=_strip_bullet_prefix(seg);lines=rt.wrap_text(draw,seg,fb,W-120,8);y=max(980,1250-(len(lines)*74)//2);_draw_center(draw,lines,fb,y,(255,255,255),W,74) - title_lines=rt.wrap_text(draw,_strip_bullet_prefix(post.get('title','')),fs,W-120,3);y2=1640;draw.line((80,y2-26,W-80,y2-26),fill=(70,70,70),width=2);_draw_center(draw,title_lines,fs,y2,(220,220,220),W,42) - bg.save(out_path,quality=92) - -# Monkey patches for old functions. -rt.make_frame=final_make_frame;rt.make_tts=final_make_tts;rt._source_badge=_source_badge_url_first - -# Override endpoints. -_PATCH={('/api/url_wall','POST'),('/api/rewrite_share','POST'),('/api/ai/url','POST'),('/api/ai/short/{post_id}','POST'),('/','GET'),('/aw','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/url_wall') -async def final_url_wall(request:Request): - body=await request.json();url=base._clean_text(body.get('url','')) - if not url.startswith('http'):return JSONResponse({'error':'missing url'},status_code=400) - try:data=_scrape_url_article_only(url) - except Exception as e:return JSONResponse({'error':'Không scrape được URL: '+str(e)[:180]},status_code=422) - raw=(data.get('summary','')+'\n'+data.get('text','')).strip() - if len(raw)<120:return JSONResponse({'error':'URL không có đủ nội dung để tóm tắt'},status_code=422) - prompt=f"""Tóm tắt bài viết nguồn dưới đây để đăng lên Tường AI VNEWS. - -Yêu cầu: -- Chỉ tóm tắt nội dung chính, không viết lại toàn bộ bài. -- Ngắn gọn, cụ thể, dễ hiểu. -- Không lặp ý, không thêm chi tiết ngoài nguồn. -- Tối đa 5 ý chính hoặc 2 đoạn ngắn. -- Hạn chế dùng dấu đầu dòng. - -Tiêu đề gốc: {data.get('title','')} -Nguồn: {_domain(url)} -Nội dung gốc: -{raw[:16000]}""" - text=await base.qwen_generate(prompt,image_url=(data.get('image') or None),max_tokens=900) - if not text:text=rt.old._fallback_summary_from_prompt(prompt,max_units=5) if hasattr(rt.old,'_fallback_summary_from_prompt') else raw[:900] - text=rt.postprocess(text) if hasattr(rt,'postprocess') else text - src=[{'title':data.get('title'), 'url':url, 'excerpt':raw[:500], 'via':_domain(url)}] - if 'Nguồn tham khảo:' not in text:text+='\n\n'+rt.source_line(src) - imgs=data.get('images') or [] - post=base.make_post(data.get('title') or 'Bài viết',text,imgs[0] if imgs else '',url,'url',sources=src) - post['images']=imgs - posts=base._load_ai_wall();posts.insert(0,post);base._save_ai_wall(posts) - return JSONResponse({'post':post}) - -@app.post('/api/rewrite_share') -async def final_rewrite_share(request:Request):return await final_url_wall(request) -@app.post('/api/ai/url') -async def final_ai_url(request:Request):return await final_url_wall(request) - -@app.post('/api/ai/short/{post_id}') -async def final_short(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) - segs=rt.split_segments(post,8) if hasattr(rt,'split_segments') else [_strip_bullet_prefix(post.get('text') or post.get('title') or 'VNEWS')] - imgs=[u for u in (post.get('images') or []) if u] or ([post.get('img')] if post.get('img') else []) - os.makedirs(base.SHORTS_DIR,exist_ok=True);suffix=f'_{voice}_{emotion}_{str(speed).replace(".","p")}_articleimgs_vivoice' - out=os.path.join(base.SHORTS_DIR,base._safe_name(post_id+suffix)+'.mp4') - if os.path.exists(out): - post['video']='/api/ai/short-file/'+post_id+suffix;base._save_ai_wall(posts);return JSONResponse({'video':post['video'],'segments':len(segs),'subtitles':False}) - work=os.path.join(base.SHORTS_DIR,base._safe_name(post_id+suffix));os.makedirs(work,exist_ok=True) - clips=[] - try: - for i,seg in enumerate(segs): - img_url=imgs[i % len(imgs)] if imgs else '' - img=os.path.join(work,f'image_{i}.jpg');frame=os.path.join(work,f'f{i}.jpg');aud=os.path.join(work,f'a{i}.mp3');aud2=os.path.join(work,f'a{i}_fast.mp3');clip=os.path.join(work,f'c{i}.mp4') - _download_image_safe(img_url,post.get('title','AI news'),img) - seg=_strip_bullet_prefix(seg);final_make_frame(post,seg,i,len(segs),img,frame) - 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,'') - spoken=(prefix+' '+seg).strip() if i==0 and prefix else seg - final_make_tts(spoken,voice,aud) - try:subprocess.run(['ffmpeg','-y','-i',aud,'-filter:a',f'atempo={speed}','-vn',aud2],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=120) - except Exception:aud2=aud - try: - subprocess.run(['ffmpeg','-y','-loop','1','-i',frame,'-i',aud2,'-shortest','-c:v','libx264','-tune','stillimage','-pix_fmt','yuv420p','-c:a','aac','-b:a','128k','-vf','scale=1080:1920',clip],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=180) - except Exception: - # last-resort visual-only 4s clip - subprocess.run(['ffmpeg','-y','-loop','1','-t','4','-i',frame,'-f','lavfi','-i','anullsrc=channel_layout=stereo:sample_rate=44100','-shortest','-c:v','libx264','-pix_fmt','yuv420p','-c:a','aac','-vf','scale=1080:1920',clip],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=120) - clips.append(clip) - lf=os.path.join(work,'list.txt') - with open(lf,'w',encoding='utf-8') as f: - for c in clips:f.write("file '"+c.replace("","'\\''"))+"'\n") - subprocess.run(['ffmpeg','-y','-f','concat','-safe','0','-i',lf,'-c','copy',out],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=240) - post['video']='/api/ai/short-file/'+post_id+suffix;post['short_subtitles']=False;post['short_segments']=segs;post['short_speed']=speed;base._save_ai_wall(posts) - return JSONResponse({'video':post['video'],'segments':len(segs),'subtitles':False}) - except Exception as e:return JSONResponse({'error':'Không tạo được shorts: '+str(e)[:220]},status_code=500) - -@app.get('/aw') -def ai_wall_share(post:str=Query(default=''), short:int=Query(default=0)): - posts=base._load_ai_wall();p=next((x for x in posts if str(x.get('id'))==str(post)),None) - if not p:return HTMLResponse(f'') - title=p.get('title') or 'VNEWS AI';img=p.get('img') or DEFAULT_IMG - desc=(p.get('text') or '')[:220] - return HTMLResponse(f'{title}') - -FINAL_INJECT = r''' - -
- -''' - -@app.get('/') -async def index_final3(): - html=f2.f1._load_index_html();body=getattr(rt.old,'PATCH_INJECT','') + f2.f1.FINAL_INJECT + FINAL3_INJECT - return HTMLResponse(html.replace('',body+'\n') if '' in html else html+body) diff --git a/ai_runtime_final4.py b/ai_runtime_final4.py deleted file mode 100644 index e3ae1b8a86a071ca71e71bf8adcc76ed2ede8369..0000000000000000000000000000000000000000 --- a/ai_runtime_final4.py +++ /dev/null @@ -1,185 +0,0 @@ -"""Final4 runtime: fix topic button visibility, shorts home feed, AI asking for videos/articles.""" -import re, time, json, os, requests -from urllib.parse import urlparse -import ai_runtime_final3 as f3 -from ai_runtime_final3 import app, base, rt, HTMLResponse, JSONResponse, Request, Query -try: - import main as main_mod -except Exception: - main_mod=None - -AI_INTERACTIONS_FILE=f3.AI_INTERACTIONS_FILE -_SHORTS_CACHE={"t":0,"d":[]} -SHORT_CHANNELS=f3.SHORT_CHANNELS - - -def clean(s): - import html as html_lib - return re.sub(r"\s+"," ",html_lib.unescape(s or "")).strip() - - -def _domain(u): - try:return urlparse(u or '').netloc.replace('www.','') - except Exception:return '' - - -def _load_json(path,default): - try: - if os.path.exists(path): - with open(path,'r',encoding='utf-8') as f:return json.load(f) - except Exception:pass - return default - - -def _save_json(path,data): - try: - os.makedirs(os.path.dirname(path),exist_ok=True);tmp=path+'.tmp' - with open(tmp,'w',encoding='utf-8') as f:json.dump(data,f,ensure_ascii=False) - os.replace(tmp,path) - except Exception:pass - - -def _fallback_shorts(): - out=[];seen=set() - candidates=[] - try:candidates+=(getattr(main_mod,'SHORTS_FALLBACK',[]) or []) - except Exception:pass - try:candidates+=(getattr(rt,'SHORTS_FALLBACK',[]) or []) - except Exception:pass - # hard fallback if imports fail - hard=[('Lu_iCQ5YwNM','Công an lập hồ sơ xử lý người phụ nữ chửi bới, tát tài xế ô tô | Dân trí','baodantri7941'),('CwWvijF8BOA','Chú rể bật khóc nhận món quà bí mật người cha quá cố gửi 26 năm trước | Dân trí','baodantri7941'),('7Pd6vZ2Lz1M','Hành động ấm lòng trong tìm kiếm học sinh tử vong ở sông Lô | SKĐS','baosuckhoedoisongboyte'),('SlHLt_ZyPiE','Xử phạt người đàn ông xóa số điện thoại cứu hộ trên cao tốc Bắc - Nam | SKĐS','baosuckhoedoisongboyte')] - for vid,title,ch in hard: - candidates.append({'id':vid,'title':title,'channel':ch,'link':'https://www.youtube.com/watch?v='+vid,'img':'https://i.ytimg.com/vi/'+vid+'/hqdefault.jpg','source':'yt'}) - for v in candidates: - vid=v.get('id') or '' - if vid and vid not in seen: - seen.add(vid) - if not v.get('link'):v['link']='https://www.youtube.com/watch?v='+vid - if not v.get('img'):v['img']='https://i.ytimg.com/vi/'+vid+'/hqdefault.jpg' - v['source']='yt';out.append(v) - return out - - -def _fresh_shorts(): - items=[];seen=set() - for ch in SHORT_CHANNELS: - got=f3._youtube_shorts_ytdlp(ch,24) or f3._youtube_shorts_html(ch,24) - for v in got: - vid=v.get('id') - if vid and vid not in seen: - seen.add(vid);items.append(v) - for v in _fallback_shorts(): - vid=v.get('id') - if vid and vid not in seen: - seen.add(vid);items.append(v) - return items[:60] - -# Remove endpoints/root to override. -_PATCH={('/api/shorts','GET'),('/api/ai/interact','POST'),('/api/article/ask','POST'),('/','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.get('/api/shorts') -def api_shorts_final4(refresh:int=Query(default=0)): - now=time.time() - if not refresh and _SHORTS_CACHE['d'] and now-_SHORTS_CACHE['t']<900:return JSONResponse(_SHORTS_CACHE['d']) - data=_fresh_shorts() - _SHORTS_CACHE.update({'t':now,'d':data}) - return JSONResponse(data) - -@app.post('/api/ai/interact') -async def ai_interact_final4(request:Request): - body=await request.json();pid=str(body.get('id','')).strip();kind=str(body.get('kind','wall')).strip();action=str(body.get('action','')).strip();text=clean(body.get('text',''));context=clean(body.get('context',''));title=clean(body.get('title','')) - if not pid:return JSONResponse({'error':'missing id'},status_code=400) - db=_load_json(AI_INTERACTIONS_FILE,{}) - key=kind+':'+pid - st=db.get(key) or {'views':0,'likes':0,'comments':[],'asks':[]} - if action=='view':st['views']=int(st.get('views',0))+1 - elif action=='like':st['likes']=int(st.get('likes',0))+1 - elif action=='comment' and text: - st.setdefault('comments',[]).insert(0,{'text':text[:240],'ts':int(time.time())});st['comments']=st['comments'][:80] - elif action=='ask' and text: - if kind in ('ai','short','wall'): - posts=base._load_ai_wall();p=next((x for x in posts if str(x.get('id'))==pid),{}) - title=title or p.get('title','');context=context or (p.get('text') or '') - # For YouTube shorts, frontend sends title/context because AI cannot watch video. - if not context:context=title or pid - prompt=f"""Bạn là trợ lý VNEWS. Trả lời chi tiết bằng tiếng Việt dựa trên thông tin có sẵn về video/bài viết. - -Tiêu đề/ngữ cảnh: {title} -Nội dung mô tả: {context[:5000]} - -Câu hỏi người dùng: {text} - -Yêu cầu: -- Nếu là video YouTube/Shorts và chỉ có tiêu đề, hãy nói rõ rằng bạn suy luận từ tiêu đề/mô tả, không khẳng định đã xem video. -- Trả lời cụ thể, có giải thích, không quá ngắn. -""" - ans=await base.qwen_generate(prompt,max_tokens=900) - if not ans:ans='AI chưa trả lời được lúc này. Bạn thử hỏi lại cụ thể hơn.' - st.setdefault('asks',[]).insert(0,{'q':text[:240],'a':ans[:1500],'ts':int(time.time())});st['asks']=st['asks'][:50] - db[key]=st;_save_json(AI_INTERACTIONS_FILE,db) - return JSONResponse({'stats':st}) - -@app.post('/api/article/ask') -async def article_ask(request:Request): - body=await request.json();url=clean(body.get('url',''));question=clean(body.get('question','')) - if not question:return JSONResponse({'error':'missing question'},status_code=400) - title='';raw='' - try: - data=None - if url and hasattr(f3.f2.f1,'_scrape_url_article_only'): - data=f3.f2.f1._scrape_url_article_only(url) - if not data and url:data=base.scrape_any_url(url) - if data: - title=data.get('title','');raw=(data.get('summary','')+'\n'+data.get('text','')).strip() - except Exception:pass - context=raw[:12000] if raw else clean(body.get('context',''))[:12000] - prompt=f"""Bạn là trợ lý đọc hiểu bài viết của VNEWS. Hãy trả lời chi tiết câu hỏi của người dùng dựa trên bài viết. - -Tiêu đề bài: {title} -Nội dung bài: -{context} - -Câu hỏi: {question} - -Yêu cầu: -- Trả lời bằng tiếng Việt. -- Dựa sát nội dung bài, nếu bài không có thông tin thì nói rõ. -- Giải thích chi tiết, có gạch đầu dòng khi hữu ích. -""" - ans=await base.qwen_generate(prompt,max_tokens=1200) - if not ans:ans='AI chưa trả lời được lúc này. Bạn thử hỏi lại hoặc rút gọn câu hỏi.' - return JSONResponse({'answer':ans,'title':title}) - -FINAL4_INJECT = r''' - - -''' - -@app.get('/') -async def index_final4(): - html=f3.f2.f1._load_index_html();body=getattr(rt.old,'PATCH_INJECT','')+f3.f2.f1.FINAL_INJECT+f3.FINAL3_INJECT+FINAL4_INJECT - return HTMLResponse(html.replace('',body+'\n') if '' in html else html+body) diff --git a/ai_runtime_final5.py b/ai_runtime_final5.py deleted file mode 100644 index cab91a90e41225b2d3a32db9c034dc05fafb4258..0000000000000000000000000000000000000000 --- a/ai_runtime_final5.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Final5 runtime: remove duplicate topic box, improve Qwen topic knowledge output, fix Shorts direct playback.""" -import re, time -from urllib.parse import quote -import ai_runtime_final4 as f4 -from ai_runtime_final4 import app, base, rt, HTMLResponse, JSONResponse, Request, Query - -# Remove topic/root endpoints to override. -_PATCH={('/api/topic_post','POST'),('/','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)] - -def clean(s): - import html as html_lib - return re.sub(r"\s+"," ",html_lib.unescape(s or "")).strip() - -def _topic_image(topic): - try:return base.pollinations_image_url(topic) - except Exception:return "https://image.pollinations.ai/prompt/"+quote("Vietnamese educational editorial illustration "+topic)+"?width=1024&height=576&nologo=true" - -@app.post('/api/topic_post') -async def topic_post_knowledge(request:Request): - body=await request.json();topic=clean(body.get('topic','')) - if not topic:return JSONResponse({'error':'missing topic'},status_code=400) - img=_topic_image(topic) - prompt=f"""Người dùng muốn đăng một bài trên Tường AI về chủ đề: "{topic}". - -Hãy viết NGAY nội dung kiến thức/thông tin hữu ích về chủ đề đó, không lập dàn ý chung chung, không nói "có thể viết", không hướng dẫn cách viết. - -Yêu cầu đầu ra: -- Tiêu đề hấp dẫn, cụ thể. -- 1 đoạn mở đầu giải thích trực tiếp chủ đề là gì/vì sao đáng chú ý. -- 5-7 đoạn hoặc ý chính cung cấp kiến thức thực chất, ví dụ, bối cảnh, tác động, hiểu lầm thường gặp, điểm cần lưu ý. -- Nếu chủ đề là thể thao, hãy nói về bối cảnh, nhân vật/đội bóng, ý nghĩa chiến thuật hoặc lịch sử liên quan. -- Nếu chủ đề là công nghệ/khoa học/xã hội, hãy giải thích khái niệm, ứng dụng, rủi ro/lợi ích, ví dụ thực tế. -- Không bịa số liệu thời sự mới; nếu không chắc, dùng cách nói thận trọng. -- Viết như bài đăng hoàn chỉnh để đọc được ngay. -- Cuối bài thêm: Nguồn tham khảo: Qwen2.5-VL / kiến thức tổng hợp. -""" - text=await base.qwen_generate(prompt,image_url=img,max_tokens=1400) - if not text: - text=f"{topic}\n\n{topic} là một chủ đề có nhiều khía cạnh cần nhìn từ bối cảnh, ý nghĩa thực tế và tác động đối với người quan tâm. Bài viết này tóm lược các điểm quan trọng nhất để người đọc hiểu nhanh vấn đề, thay vì chỉ liệt kê tiêu đề hoặc dàn ý.\n\nNguồn tham khảo: Qwen2.5-VL / kiến thức tổng hợp." - post=base.make_post(topic,text,img,'','topic_qwen',sources=[{'title':'Qwen2.5-VL / kiến thức tổng hợp','url':'','via':'Qwen2.5-VL'}]) - post['images']=[img] - posts=base._load_ai_wall();posts.insert(0,post);base._save_ai_wall(posts) - return JSONResponse({'post':post}) - -FINAL5_INJECT=r''' - - -''' - -@app.get('/') -async def index_final5(): - html=f4.f3.f2.f1._load_index_html();body=getattr(rt.old,'PATCH_INJECT','')+f4.f3.f2.f1.FINAL_INJECT+f4.f3.FINAL3_INJECT+f4.FINAL4_INJECT+FINAL5_INJECT - return HTMLResponse(html.replace('',body+'\n') if '' in html else html+body) diff --git a/ai_runtime_final6.py b/ai_runtime_final6.py deleted file mode 100644 index 4efc9c899924f859f2e644ac3e18c98919615c20..0000000000000000000000000000000000000000 --- a/ai_runtime_final6.py +++ /dev/null @@ -1,849 +0,0 @@ -"""Final6: robust topic synthesis, stable shorts, hot topic hashtags. - -This runtime intentionally overrides only the topic/shorts/root endpoints from the restored app. -""" -import re, time, json, os, threading, html as html_lib -from urllib.parse import quote, urlparse, parse_qs, unquote -import requests -from bs4 import BeautifulSoup -import ai_runtime_final5 as f5 -from ai_runtime_final5 import app, rt, HTMLResponse, JSONResponse, Request, Query - -_PATCH={('/api/topic_post','POST'),('/api/shorts','GET'),('/api/hot_topics','GET'),('/api/topic_sources','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)] - -_TOPIC_CACHE={} -_HOT_CACHE={"t":0,"d":[]} -_SHORTS_CACHE_FINAL6={"t":0,"d":[]} -_TRANSLATE_CACHE_PATH="/data/title_vi_cache.json" if os.path.isdir('/data') else "/app/data/title_vi_cache.json" -_translate_lock=threading.Lock() -YOUTUBE_HANDLES=["baodantri7941","baosuckhoedoisongboyte"] -UA={"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","Accept-Language":"vi,en;q=0.8"} -STOP_WORDS=set('và của các những một được trong với cho tại sau trước khi không người việt nam hôm nay mới nhất nóng tin tức cập nhật'.split()) -TRUSTED_SITES=['vnexpress.net','dantri.com.vn','vietnamnet.vn','tuoitre.vn','thanhnien.vn','laodong.vn','vov.vn','vtv.vn','genk.vn','cafef.vn','thethaovanhoa.vn'] - -def clean(s):return re.sub(r"\s+"," ",html_lib.unescape(str(s or ""))).strip() -def _domain(u): - try:return urlparse(u or '').netloc.replace('www.','') - except Exception:return '' - -def _load_title_cache(): - try: - if os.path.exists(_TRANSLATE_CACHE_PATH): - with open(_TRANSLATE_CACHE_PATH,'r',encoding='utf-8') as f:return json.load(f) - except Exception:pass - return {} -def _save_title_cache(db): - try: - os.makedirs(os.path.dirname(_TRANSLATE_CACHE_PATH),exist_ok=True);tmp=_TRANSLATE_CACHE_PATH+'.tmp' - with open(tmp,'w',encoding='utf-8') as f:json.dump(db,f,ensure_ascii=False) - os.replace(tmp,_TRANSLATE_CACHE_PATH) - except Exception:pass - -def _looks_vietnamese(s): - s=s or '' - if re.search(r'[àáạảãâầấậẩẫăằắặẳẵèéẹẻẽêềếệểễìíịỉĩòóọỏõôồốộổỗơờớợởỡùúụủũưừứựửữỳýỵỷỹđ]',s,re.I):return True - low=' '+s.lower()+' ' - return any(w in low for w in [' và ',' của ',' người ',' tại ',' trong ',' với ',' không ',' được ',' công an ',' bệnh viện ',' học sinh ',' tài xế ',' bóng đá ',' tin tức ',' sức khỏe ']) -def _translate_title_vi(title): - title=clean(title) - if not title or _looks_vietnamese(title):return title - with _translate_lock: - db=_load_title_cache() - if title in db:return db[title] - vi=title - try: - r=requests.get('https://translate.googleapis.com/translate_a/single',params={'client':'gtx','sl':'auto','tl':'vi','dt':'t','q':title},headers=UA,timeout=8) - if r.status_code==200: - data=r.json();vi=''.join(part[0] for part in data[0] if part and part[0]).strip() or title - except Exception:pass - vi=clean(vi) - with _translate_lock: - db=_load_title_cache();db[title]=vi;_save_title_cache(db) - return vi - -# ===== Hot topics / hashtags ===== -def _keywords_from_title(title): - title=clean(re.sub(r'\s+-\s+.*$','',title)) - words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',title) if len(w)>2 and w.lower() not in STOP_WORDS] - phrases=[] - for n in (4,3,2): - for i in range(0,max(0,len(words)-n+1)): - ph=' '.join(words[i:i+n]).strip() - if len(ph)>=8:phrases.append(ph) - if words:phrases.append(' '.join(words[:5])) - return phrases[:4] - -def _hot_topics(): - now=time.time() - if _HOT_CACHE['d'] and now-_HOT_CACHE['t']<900:return _HOT_CACHE['d'] - topics=[];seen=set() - feeds=[ - 'https://news.google.com/rss?hl=vi&gl=VN&ceid=VN:vi', - 'https://news.google.com/rss/headlines/section/topic/NATION?hl=vi&gl=VN&ceid=VN:vi', - 'https://news.google.com/rss/headlines/section/topic/BUSINESS?hl=vi&gl=VN&ceid=VN:vi', - 'https://news.google.com/rss/headlines/section/topic/SPORTS?hl=vi&gl=VN&ceid=VN:vi', - 'https://news.google.com/rss/headlines/section/topic/TECHNOLOGY?hl=vi&gl=VN&ceid=VN:vi' - ] - for feed in feeds: - try: - r=requests.get(feed,headers=UA,timeout=10);r.encoding='utf-8' - soup=BeautifulSoup(r.text,'xml') - for it in soup.find_all('item')[:15]: - title=clean(it.find('title').get_text(' ',strip=True) if it.find('title') else '') - for kw in _keywords_from_title(title): - key=kw.lower() - if key not in seen and len(kw)<=60: - seen.add(key);topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw}) - if len(topics)>=24:break - if len(topics)>=24:break - except Exception:pass - if len(topics)>=24:break - for kw in ['AI trong giáo dục','World Cup 2026','kinh tế Việt Nam','biến đổi khí hậu','giá vàng','bóng đá Việt Nam','an ninh mạng','xe điện','sức khỏe tinh thần','thị trường chứng khoán']: - if kw.lower() not in seen:topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw}) - _HOT_CACHE.update({'t':now,'d':topics[:24]}) - return _HOT_CACHE['d'] -@app.get('/api/hot_topics') -def api_hot_topics():return JSONResponse({'topics':_hot_topics()}) - -# ===== Topic web research ===== -def _unwrap_ddg_href(href): - if not href:return '' - if href.startswith('//duckduckgo.com/l/?') or 'duckduckgo.com/l/?' in href: - qs=parse_qs(urlparse('https:'+href if href.startswith('//') else href).query) - return unquote(qs.get('uddg',[''])[0]) - return href - -def _ddg_search(query, limit=10): - items=[];seen=set() - try: - url='https://html.duckduckgo.com/html/?q='+quote(query) - r=requests.get(url,headers=UA,timeout=14);r.encoding='utf-8' - soup=BeautifulSoup(r.text,'lxml') - for res in soup.select('.result'): - a=res.select_one('.result__title a') or res.find('a',href=True) - if not a:continue - link=_unwrap_ddg_href(a.get('href',''));title=clean(a.get_text(' ',strip=True));snippet=clean((res.select_one('.result__snippet') or res).get_text(' ',strip=True)) - if not link.startswith('http') or link in seen:continue - if any(bad in link for bad in ['duckduckgo.com','youtube.com','facebook.com','tiktok.com','twitter.com','x.com']):continue - seen.add(link);items.append({'title':title,'url':link,'source':_domain(link),'snippet':snippet}) - if len(items)>=limit:break - except Exception:pass - return items - -def _google_news_items(topic, limit=8): - items=[];seen=set() - try: - rss='https://news.google.com/rss/search?q='+quote(topic)+'&hl=vi&gl=VN&ceid=VN:vi' - r=requests.get(rss,headers=UA,timeout=12);r.encoding='utf-8' - soup=BeautifulSoup(r.text,'xml') - for it in soup.find_all('item')[:limit*2]: - title=clean(it.find('title').get_text(' ',strip=True) if it.find('title') else '') - link=clean(it.find('link').get_text(strip=True) if it.find('link') else '') - src=clean(it.find('source').get_text(' ',strip=True) if it.find('source') else _domain(link)) - if title and link and link not in seen: - seen.add(link);items.append({'title':title,'url':link,'source':src,'snippet':''}) - if len(items)>=limit:break - except Exception:pass - return items - -def _candidate_urls(topic): - seen=set();items=[] - queries=[topic+' tin tức Việt Nam', topic+' phân tích bối cảnh', topic+' site:vnexpress.net OR site:dantri.com.vn OR site:vietnamnet.vn'] - for q in queries: - for it in _ddg_search(q,8): - if it['url'] not in seen: - seen.add(it['url']);items.append(it) - if len(items)>=12:break - for site in TRUSTED_SITES[:8]: - for it in _ddg_search(f'{topic} site:{site}',3): - if it['url'] not in seen: - seen.add(it['url']);items.append(it) - for it in _google_news_items(topic,8): - if it['url'] not in seen: - seen.add(it['url']);items.append(it) - return items[:24] - -def _extract_article_text_bs(url, max_chars=9000): - try: - r=requests.get(url,headers=UA,timeout=16,allow_redirects=True) - if r.status_code>=400:return '' - r.encoding='utf-8';soup=BeautifulSoup(r.text,'lxml') - for tag in soup.find_all(['script','style','nav','footer','aside','form','noscript','iframe','svg']):tag.decompose() - candidates=[] - for sel in ['article','main','.article-content','.detail-content','.singular-content','.fck_detail','.content-detail','.entry-content','.story-body','.knc-content']: - el=soup.select_one(sel) - if el:candidates.append(el) - if not candidates:candidates=[soup.body or soup] - best=max(candidates,key=lambda el:len(el.find_all('p')) if el else 0) - ps=[] - for el in best.find_all(['p','h2','h3'],recursive=True): - t=clean(el.get_text(' ',strip=True)) - if len(t)>45 and not any(x in t.lower() for x in ['đăng ký nhận tin','theo dõi chúng tôi','chuyên mục','xem thêm','tin liên quan','advertisement']):ps.append(t) - if sum(len(x) for x in ps)>max_chars:break - return '\n'.join(ps)[:max_chars] - except Exception:return '' - -def _jina_read_text(url, max_chars=9000): - try: - ju='https://r.jina.ai/http://'+url - r=requests.get(ju,headers=UA,timeout=28);r.encoding='utf-8' - if r.status_code!=200 or not r.text:return '' - lines=[] - for ln in r.text.splitlines(): - t=clean(ln) - if not t or t.startswith(('Title:','URL Source:','Published Time:','Markdown Content:','Image:','Description:')):continue - if len(t)>45:lines.append(t) - if sum(len(x) for x in lines)>max_chars:break - return '\n'.join(lines)[:max_chars] - except Exception:return '' - -def _scrape_article_text(url, max_chars=9000): - text=_extract_article_text_bs(url,max_chars) - if len(text)<350:text=_jina_read_text(url,max_chars) - return text - -def _score_relevance(topic, title, text, snippet=''): - keys=[w.lower() for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',topic) if len(w)>2 and w.lower() not in STOP_WORDS] - hay=(title+' '+snippet+' '+text[:2500]).lower() - if not keys:return 1 - return sum(1 for k in keys if k in hay) - -def _web_research_context(topic): - now=time.time();key=topic.lower().strip() - if key in _TOPIC_CACHE and now-_TOPIC_CACHE[key]['t']<900:return _TOPIC_CACHE[key]['d'] - items=_candidate_urls(topic) - crawled=[] - for it in items: - text=_scrape_article_text(it['url'],9000) - rel=_score_relevance(topic,it.get('title',''),text,it.get('snippet','')) - if text and len(text)>300 and rel>0: - crawled.append({**it,'text':text,'rel':rel}) - elif it.get('snippet') and rel>0: - crawled.append({**it,'text':it['snippet'],'rel':rel,'snippet_only':True}) - crawled=sorted(crawled,key=lambda x:(x.get('rel',0),len(x.get('text',''))),reverse=True)[:6] - blocks=[];sources=[] - for it in crawled: - label='ĐOẠN MÔ TẢ TỪ KẾT QUẢ TÌM KIẾM' if it.get('snippet_only') else 'NỘI DUNG BÀI VIẾT ĐÃ CRAWL' - blocks.append(f"NGUỒN: {it['source']}\nTIÊU ĐỀ: {it['title']}\n{label}:\n{it['text'][:8500]}") - sources.append({'title':it['title'],'url':it['url'],'via':it['source']}) - data={'context':'\n\n---\n\n'.join(blocks),'sources':sources[:8],'count':len(blocks)} - _TOPIC_CACHE[key]={'t':now,'d':data} - return data - -def _topic_image(topic): - try:return f5.base.pollinations_image_url(topic) - except Exception:return 'https://image.pollinations.ai/prompt/'+quote('Vietnamese editorial illustration, '+topic)+'?width=1024&height=576&nologo=true' - -@app.get('/api/topic_sources') -def api_topic_sources(topic:str=Query(...)): - data=_web_research_context(clean(topic)) - return JSONResponse({'count':data.get('count',0),'sources':data.get('sources',[]),'has_context':bool(data.get('context'))}) - -@app.post('/api/topic_post') -async def topic_post_synthesis(request:Request): - body=await request.json();topic=clean(body.get('topic','')) - if not topic:return JSONResponse({'error':'missing topic'},status_code=400) - img=_topic_image(topic);research=_web_research_context(topic);context=research.get('context','');sources=research.get('sources',[]) - if not context or research.get('count',0)==0: - return JSONResponse({'error':'Không tìm/crawl được đủ nội dung về chủ đề này. Hãy thử chủ đề cụ thể hơn hoặc dùng hashtag gợi ý.'},status_code=422) - prompt=f"""Bạn là biên tập viên VNEWS. Người dùng chọn chủ đề: "{topic}". - -Dưới đây là NỘI DUNG các bài viết/đoạn mô tả đã crawl từ internet. Hãy đọc hiểu và TỔNG HỢP thành MỘT BÀI VIẾT HOÀN CHỈNH. Tuyệt đối không bê nguyên văn, không xếp danh sách tiêu đề thành bài viết, không viết kiểu trả lời chat. - -DỮ LIỆU CRAWL: -{context[:30000]} - -Yêu cầu bắt buộc: -- Viết bằng tiếng Việt, văn phong báo điện tử/tạp chí. -- Tiêu đề mới, rõ, hấp dẫn. -- Sapo 2-3 câu nêu vấn đề chính. -- 5-8 đoạn nội dung tổng hợp: bối cảnh, diễn biến/khái niệm, phân tích, tác động, điểm cần lưu ý. -- Dùng thông tin từ nội dung đã crawl để tổng hợp ý; nếu chỉ có mô tả tìm kiếm thì viết thận trọng. -- KHÔNG liệt kê các tiêu đề nguồn. KHÔNG mở đầu bằng "Dưới đây là" hay "Tôi sẽ". -- Cuối bài thêm mục "Nguồn tham khảo" gồm tên nguồn ngắn gọn. -""" - text=await f5.base.qwen_generate(prompt,image_url=img,max_tokens=2800) - if not text or len(text)<500: - parts=[] - for block in context.split('---'): - body=block.split('NỘI DUNG BÀI VIẾT ĐÃ CRAWL:')[-1].split('ĐOẠN MÔ TẢ TỪ KẾT QUẢ TÌM KIẾM:')[-1].strip() - if len(body)>120:parts.append(body) - joined='\n\n'.join(parts)[:8500] - text=(f"{topic}: những điểm chính cần biết\n\n{topic} đang thu hút sự chú ý vì liên quan đến nhiều khía cạnh thực tế. Tổng hợp từ các nội dung thu thập được, có thể nhìn vấn đề qua bối cảnh, tác động và những điểm cần theo dõi.\n\n"+joined+"\n\nNguồn tham khảo: "+', '.join(sorted({s.get('via','') for s in sources if s.get('via')}))) - post=f5.base.make_post(topic,text,img,'','topic_web_synthesis',sources=[s for s in sources if s.get('url')]);post['images']=[img] - posts=f5.base._load_ai_wall();posts.insert(0,post);f5.base._save_ai_wall(posts) - return JSONResponse({'post':post}) - -# ===== Stable newest Dantri/SKDS Shorts ===== -def _yt_ytdlp(handle,count=30): - try: - import yt_dlp - urls=[f'https://www.youtube.com/@{handle}/shorts',f'https://www.youtube.com/@{handle}/videos'] - out=[];seen=set();opts={'quiet':True,'extract_flat':True,'skip_download':True,'playlistend':count,'ignoreerrors':True,'no_warnings':True,'extractor_args':{'youtube':{'player_client':['web']}}} - for url in urls: - with yt_dlp.YoutubeDL(opts) as ydl:info=ydl.extract_info(url,download=False) - for e in (info or {}).get('entries') or []: - vid=e.get('id') or '' - if not re.match(r'^[A-Za-z0-9_-]{11}$',vid) or vid in seen:continue - title=e.get('title') or 'YouTube Short' - if url.endswith('/videos') and '#short' not in title.lower() and 'shorts' not in title.lower():continue - seen.add(vid);out.append({'title':title,'link':'https://www.youtube.com/watch?v='+vid,'img':'https://i.ytimg.com/vi/'+vid+'/hqdefault.jpg','source':'yt','id':vid,'channel':handle}) - if len(out)>=count:break - if len(out)>=count:break - return out - except Exception:return [] -def _yt_html(handle,count=30): - out=[];seen=set() - for suffix in ['shorts','videos']: - try: - r=requests.get(f'https://www.youtube.com/@{handle}/{suffix}',headers=UA,timeout=15);html=r.text - for m in re.finditer(r'"videoId":"([A-Za-z0-9_-]{11})"',html): - vid=m.group(1) - if vid in seen:continue - snip=html[max(0,m.start()-1200):m.start()+2200];title='YouTube Short' - mt=re.search(r'"title":\{"runs":\[\{"text":"([^"]+)"',snip) or re.search(r'"accessibilityText":"([^"]+)"',snip) - if mt:title=clean(mt.group(1).replace('\\n',' ')) - if suffix=='videos' and '#short' not in title.lower() and 'shorts' not in title.lower():continue - seen.add(vid);out.append({'title':title,'link':'https://www.youtube.com/watch?v='+vid,'img':'https://i.ytimg.com/vi/'+vid+'/hqdefault.jpg','source':'yt','id':vid,'channel':handle}) - if len(out)>=count:break - except Exception:pass - if len(out)>=count:break - return out[:count] -def _fallback_shorts(): - try:return f5._fallback_shorts() - except Exception:return [] -@app.get('/api/shorts') -def api_shorts_final6(refresh:int=Query(default=0)): - now=time.time() - if not refresh and _SHORTS_CACHE_FINAL6['d'] and now-_SHORTS_CACHE_FINAL6['t']<600:return JSONResponse(_SHORTS_CACHE_FINAL6['d']) - raw=[] - for h in YOUTUBE_HANDLES:raw.extend(_yt_ytdlp(h,30) or _yt_html(h,30)) - raw.extend(_fallback_shorts()) - seen=set();out=[] - for v in raw: - vid=v.get('id') or '' - if not vid: - m=re.search(r'(?:v=|shorts/|youtu\.be/)([A-Za-z0-9_-]{11})',v.get('link',''));vid=m.group(1) if m else '' - title=_translate_title_vi(v.get('title') or 'YouTube Short');key=vid or re.sub(r'\W+','',title.lower())[:80] - if not key or key in seen:continue - seen.add(key);item=dict(v);item['id']=vid;item['title']=title - if vid:item['link']='https://www.youtube.com/watch?v='+vid;item['img']='https://i.ytimg.com/vi/'+vid+'/hqdefault.jpg' - item['source']='yt';out.append(item) - if len(out)>=40:break - _SHORTS_CACHE_FINAL6.update({'t':now,'d':out}) - return JSONResponse(out) - -FINAL6_INJECT=r''' - - -''' - -@app.get('/') -async def index_final6(): - html=f5.f4.f3.f2.f1._load_index_html() - body=getattr(rt.old,'PATCH_INJECT','')+f5.f4.f3.f2.f1.FINAL_INJECT+f5.f4.f3.FINAL3_INJECT+f5.f4.FINAL4_INJECT+f5.FINAL5_INJECT+FINAL6_INJECT - return HTMLResponse(html.replace('',body+'\n') if '' in html else html+body) - - -# ===== FINAL6B: Vietnam hot hashtags + reliable VN RSS/source retrieval ===== -VN_RSS_FEEDS = [ - ('VnExpress Thời sự','https://vnexpress.net/rss/thoi-su.rss'), - ('VnExpress Thế giới','https://vnexpress.net/rss/the-gioi.rss'), - ('VnExpress Kinh doanh','https://vnexpress.net/rss/kinh-doanh.rss'), - ('VnExpress Công nghệ','https://vnexpress.net/rss/so-hoa.rss'), - ('VnExpress Thể thao','https://vnexpress.net/rss/the-thao.rss'), - ('VnExpress Giải trí','https://vnexpress.net/rss/giai-tri.rss'), - ('VnExpress Sức khỏe','https://vnexpress.net/rss/suc-khoe.rss'), - ('VnExpress Giáo dục','https://vnexpress.net/rss/giao-duc.rss'), - ('Dân trí Xã hội','https://dantri.com.vn/rss/xa-hoi.rss'), - ('Dân trí Thế giới','https://dantri.com.vn/rss/the-gioi.rss'), - ('Dân trí Kinh doanh','https://dantri.com.vn/rss/kinh-doanh.rss'), - ('Dân trí Sức khỏe','https://dantri.com.vn/rss/suc-khoe.rss'), - ('Dân trí Thể thao','https://dantri.com.vn/rss/the-thao.rss'), - ('Dân trí Công nghệ','https://dantri.com.vn/rss/suc-manh-so.rss'), - ('Vietnamnet Thời sự','https://vietnamnet.vn/thoi-su.rss'), - ('Vietnamnet Kinh doanh','https://vietnamnet.vn/kinh-doanh.rss'), - ('Vietnamnet Công nghệ','https://vietnamnet.vn/cong-nghe.rss'), - ('Vietnamnet Thể thao','https://vietnamnet.vn/the-thao.rss'), -] - -def _fetch_rss_items(feed_name, feed_url, max_items=15): - items=[] - try: - r=requests.get(feed_url,headers=UA,timeout=10);r.encoding='utf-8' - soup=BeautifulSoup(r.text,'xml') - for it in soup.find_all('item')[:max_items]: - title=clean(it.find('title').get_text(' ',strip=True) if it.find('title') else '') - link=clean(it.find('link').get_text(strip=True) if it.find('link') else '') - desc=it.find('description').get_text(' ',strip=True) if it.find('description') else '' - desc_txt=clean(BeautifulSoup(desc,'lxml').get_text(' ',strip=True)) - if title and link: - items.append({'title':title,'url':link,'source':feed_name,'snippet':desc_txt}) - except Exception:pass - return items - -def _vn_rss_pool(): - now=time.time();key='vn_rss_pool' - if key in _TOPIC_CACHE and now-_TOPIC_CACHE[key]['t']<600:return _TOPIC_CACHE[key]['d'] - pool=[];seen=set() - for name,url in VN_RSS_FEEDS: - for it in _fetch_rss_items(name,url,12): - if it['url'] not in seen: - seen.add(it['url']);pool.append(it) - _TOPIC_CACHE[key]={'t':now,'d':pool} - return pool - -def _topic_tokens(topic): - toks=[w.lower() for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',topic or '') if len(w)>1] - return [t for t in toks if t not in STOP_WORDS] - -def _score_topic_item(topic,item): - toks=_topic_tokens(topic) - hay=(item.get('title','')+' '+item.get('snippet','')+' '+item.get('source','')).lower() - if not toks:return 0 - score=0 - for t in toks: - if t in hay:score+=2 if len(t)>3 else 1 - phrase=topic.lower().strip() - if phrase and phrase in hay:score+=8 - return score - -# Override: hashtags must be Việt Nam-focused, using VN news RSS directly. -def _hot_topics(): - now=time.time() - if _HOT_CACHE['d'] and now-_HOT_CACHE['t']<600:return _HOT_CACHE['d'] - pool=_vn_rss_pool() - freq={};display={} - for it in pool[:180]: - title=re.sub(r'\s+-\s+.*$','',it.get('title','')) - # Extract compact Vietnamese hot phrases from current VN headlines. - kws=[] - # quoted/name phrases first - for m in re.findall(r'([A-ZĐÀ-Ỹ][A-Za-zÀ-ỹ0-9]+(?:\s+[A-ZĐÀ-ỸA-Za-zÀ-ỹ0-9][A-Za-zÀ-ỹ0-9]+){1,4})',title): - if len(m)>=6:kws.append(m) - kws += _keywords_from_title(title) - for kw in kws[:5]: - kw=clean(kw) - words=[w for w in kw.split() if w.lower() not in STOP_WORDS] - if len(words)<2:continue - kw=' '.join(words[:5]) - if len(kw)<6 or len(kw)>55:continue - key=kw.lower() - freq[key]=freq.get(key,0)+1 - display[key]=kw - ranked=sorted(freq.items(),key=lambda x:x[1],reverse=True) - topics=[];seen=set() - for key,_ in ranked: - kw=display[key] - if key in seen:continue - seen.add(key) - label='#'+re.sub(r'\s+','',kw.title()) - topics.append({'label':label,'topic':kw}) - if len(topics)>=24:break - # VN fallback, not generic global. - for kw in ['Giá vàng trong nước','Bão và mưa lũ','Bóng đá Việt Nam','Kinh tế Việt Nam','AI tại Việt Nam','Giá xăng dầu','Thị trường chứng khoán Việt Nam','Tuyển Việt Nam','Sức khỏe cộng đồng','An ninh mạng Việt Nam']: - if kw.lower() not in seen:topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw}) - _HOT_CACHE.update({'t':now,'d':topics[:24]}) - return _HOT_CACHE['d'] - -def _candidate_urls(topic): - seen=set();items=[] - # 1) VN RSS pool relevance is most reliable and has direct URLs. - scored=[] - for it in _vn_rss_pool(): - sc=_score_topic_item(topic,it) - if sc>0:scored.append((sc,it)) - for sc,it in sorted(scored,key=lambda x:x[0],reverse=True)[:12]: - if it['url'] not in seen: - seen.add(it['url']);items.append(it) - # 2) Search trusted web if RSS not enough. - queries=[topic+' Việt Nam tin tức',topic+' phân tích Việt Nam',topic+' mới nhất'] - for q in queries: - for it in _ddg_search(q,8): - if it['url'] not in seen: - seen.add(it['url']);items.append(it) - if len(items)>=14:break - # 3) Google News as supplemental titles/direct links. - for it in _google_news_items(topic,10): - if it['url'] not in seen: - seen.add(it['url']);items.append(it) - return items[:24] - -def _web_research_context(topic): - now=time.time();key='ctx2:'+topic.lower().strip() - if key in _TOPIC_CACHE and now-_TOPIC_CACHE[key]['t']<900:return _TOPIC_CACHE[key]['d'] - items=_candidate_urls(topic) - crawled=[] - for it in items: - text=_scrape_article_text(it['url'],9000) - rel=_score_relevance(topic,it.get('title',''),text,it.get('snippet','')) or _score_topic_item(topic,it) - # If RSS item has good snippet, keep it even when full text blocks. - if text and len(text)>300 and rel>0: - crawled.append({**it,'text':text,'rel':rel}) - elif it.get('snippet') and len(it['snippet'])>120 and rel>0: - crawled.append({**it,'text':it['snippet'],'rel':rel,'snippet_only':True}) - crawled=sorted(crawled,key=lambda x:(x.get('rel',0),len(x.get('text',''))),reverse=True)[:7] - blocks=[];sources=[] - for it in crawled: - label='ĐOẠN MÔ TẢ TỪ RSS/TÌM KIẾM' if it.get('snippet_only') else 'NỘI DUNG BÀI VIẾT ĐÃ CRAWL' - blocks.append(f"NGUỒN: {it['source']}\nTIÊU ĐỀ: {it['title']}\n{label}:\n{it['text'][:8500]}") - sources.append({'title':it['title'],'url':it['url'],'via':it['source']}) - data={'context':'\n\n---\n\n'.join(blocks),'sources':sources[:8],'count':len(blocks)} - _TOPIC_CACHE[key]={'t':now,'d':data} - return data - - -# ===== FINAL6C: FAST topic generation (RSS cache first, no slow full-page crawling) ===== -import asyncio -_FAST_TOPIC_CACHE={} -FAST_RSS_FEEDS=[ - ('VnExpress','https://vnexpress.net/rss/tin-moi-nhat.rss'), - ('VnExpress Thời sự','https://vnexpress.net/rss/thoi-su.rss'), - ('VnExpress Thế giới','https://vnexpress.net/rss/the-gioi.rss'), - ('VnExpress Kinh doanh','https://vnexpress.net/rss/kinh-doanh.rss'), - ('VnExpress Công nghệ','https://vnexpress.net/rss/so-hoa.rss'), - ('VnExpress Thể thao','https://vnexpress.net/rss/the-thao.rss'), - ('Dân trí','https://dantri.com.vn/rss/home.rss'), - ('Dân trí Xã hội','https://dantri.com.vn/rss/xa-hoi.rss'), - ('Dân trí Kinh doanh','https://dantri.com.vn/rss/kinh-doanh.rss'), - ('Dân trí Thể thao','https://dantri.com.vn/rss/the-thao.rss'), - ('Dân trí Công nghệ','https://dantri.com.vn/rss/suc-manh-so.rss'), - ('Vietnamnet','https://vietnamnet.vn/rss/tin-moi-nhat.rss'), - ('Vietnamnet Thời sự','https://vietnamnet.vn/thoi-su.rss'), - ('Vietnamnet Kinh doanh','https://vietnamnet.vn/kinh-doanh.rss'), - ('Vietnamnet Công nghệ','https://vietnamnet.vn/cong-nghe.rss'), - ('Vietnamnet Thể thao','https://vietnamnet.vn/the-thao.rss'), -] - -def _fast_fetch_rss(feed_name, feed_url, max_items=20): - items=[] - try: - r=requests.get(feed_url,headers=UA,timeout=6);r.encoding='utf-8' - soup=BeautifulSoup(r.text,'xml') - for it in soup.find_all('item')[:max_items]: - title=clean(it.find('title').get_text(' ',strip=True) if it.find('title') else '') - link=clean(it.find('link').get_text(strip=True) if it.find('link') else '') - desc_raw=it.find('description').get_text(' ',strip=True) if it.find('description') else '' - desc=clean(BeautifulSoup(desc_raw,'lxml').get_text(' ',strip=True)) - if title and link: - items.append({'title':title,'url':link,'source':feed_name,'snippet':desc}) - except Exception:pass - return items - -def _fast_rss_pool(): - now=time.time();key='fast_rss_pool' - if key in _FAST_TOPIC_CACHE and now-_FAST_TOPIC_CACHE[key]['t']<600:return _FAST_TOPIC_CACHE[key]['d'] - pool=[];seen=set() - # Sequential with short timeouts is predictable; RSS is small. - for name,url in FAST_RSS_FEEDS: - for it in _fast_fetch_rss(name,url,16): - if it['url'] not in seen: - seen.add(it['url']);pool.append(it) - _FAST_TOPIC_CACHE[key]={'t':now,'d':pool} - return pool - -def _fast_topic_tokens(topic): - toks=[w.lower() for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',topic or '') if len(w)>1] - return [t for t in toks if t not in STOP_WORDS] - -def _fast_score(topic,item): - toks=_fast_topic_tokens(topic) - hay=(item.get('title','')+' '+item.get('snippet','')+' '+item.get('source','')).lower() - if not toks:return 0 - score=0 - for t in toks: - if t in hay:score+=3 if len(t)>3 else 1 - phrase=topic.lower().strip() - if phrase and phrase in hay:score+=12 - return score - -def _fast_sources(topic, limit=8): - pool=_fast_rss_pool() - scored=[] - for it in pool: - sc=_fast_score(topic,it) - if sc>0:scored.append((sc,it)) - scored=sorted(scored,key=lambda x:(x[0],len(x[1].get('snippet',''))),reverse=True) - out=[];seen=set() - for sc,it in scored: - if it['url'] in seen:continue - seen.add(it['url']);out.append({**it,'score':sc}) - if len(out)>=limit:break - # If topic too narrow and no match, use top latest from VN RSS as weak context instead of slow crawling. - if not out: - out=pool[:min(limit,8)] - return out - -def _fast_context(topic): - now=time.time();key='fast_ctx:'+topic.lower().strip() - if key in _FAST_TOPIC_CACHE and now-_FAST_TOPIC_CACHE[key]['t']<600:return _FAST_TOPIC_CACHE[key]['d'] - sources=_fast_sources(topic,8) - blocks=[];src=[] - for it in sources: - text=(it.get('snippet') or '').strip() - # Use title + RSS description only: fast and reliable. - blocks.append(f"NGUỒN: {it.get('source','')}\nTIÊU ĐỀ: {it.get('title','')}\nTÓM TẮT RSS:\n{text}") - src.append({'title':it.get('title',''),'url':it.get('url',''),'via':it.get('source','')}) - data={'context':'\n\n---\n\n'.join(blocks),'sources':src,'count':len(blocks)} - _FAST_TOPIC_CACHE[key]={'t':now,'d':data} - return data - -def _fallback_fast_article(topic, sources): - lines=[] - for s in sources[:7]: - title=s.get('title','') - if title:lines.append(title) - body='\n'.join('• '+x for x in lines[:7]) - vias=', '.join(sorted({s.get('via','') for s in sources if s.get('via')})) - return (f"{topic}: những điểm đáng chú ý\n\n" - f"{topic} đang là chủ đề được quan tâm trong dòng tin tức hiện nay. Dựa trên các nguồn tin mới nhất, có thể tổng hợp nhanh một số điểm nổi bật để người đọc nắm bối cảnh và theo dõi tiếp diễn biến.\n\n" - f"Các nguồn tin liên quan cho thấy chủ đề này gắn với những diễn biến sau:\n{body}\n\n" - f"Nhìn chung, đây là vấn đề cần được theo dõi theo nhiều góc độ: bối cảnh, tác động thực tế, phản ứng của các bên liên quan và những thông tin cập nhật tiếp theo. Người đọc nên đối chiếu thêm các nguồn chính thống khi cần quyết định hoặc đánh giá chi tiết.\n\n" - f"Nguồn tham khảo: {vias}") - -# Remove previous slow topic routes and register fast versions last. -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 {('/api/topic_post','POST'),('/api/topic_sources','GET')})] - -@app.get('/api/topic_sources') -def api_topic_sources_fast(topic:str=Query(...)): - data=_fast_context(clean(topic)) - return JSONResponse({'count':data.get('count',0),'sources':data.get('sources',[]),'has_context':bool(data.get('context')),'mode':'fast_rss'}) - -@app.post('/api/topic_post') -async def topic_post_fast(request:Request): - body=await request.json();topic=clean(body.get('topic','')) - if not topic:return JSONResponse({'error':'missing topic'},status_code=400) - img=_topic_image(topic) - research=_fast_context(topic);context=research.get('context','');sources=research.get('sources',[]) - prompt=f"""Bạn là biên tập viên VNEWS. Hãy viết MỘT BÀI VIẾT HOÀN CHỈNH bằng tiếng Việt về chủ đề: {topic} - -Dữ liệu nhanh từ RSS nguồn Việt Nam: -{context[:12000]} - -Yêu cầu: -- Không liệt kê tiêu đề nguồn thành bài viết. -- Tổng hợp thành bài báo/tạp chí hoàn chỉnh. -- Có tiêu đề mới, sapo 2-3 câu, 4-6 đoạn phân tích/bối cảnh/tác động. -- Diễn đạt lại, không sao chép nguyên văn. -- Nếu dữ liệu ít, viết thận trọng và nêu các điểm cần theo dõi. -- Cuối bài có mục Nguồn tham khảo. -""" - text=None - try: - text=await asyncio.wait_for(f5.base.qwen_generate(prompt,image_url=img,max_tokens=1300),timeout=28) - except Exception: - text=None - if not text or len(text)<350: - text=_fallback_fast_article(topic,sources) - post=f5.base.make_post(topic,text,img,'','topic_fast_rss',sources=[s for s in sources if s.get('url')]) - post['images']=[img] - posts=f5.base._load_ai_wall();posts.insert(0,post);f5.base._save_ai_wall(posts) - return JSONResponse({'post':post,'mode':'fast_rss','sources_count':len(sources)}) - - -# ===== FINAL6D: FAST HOME LOAD ===== -_FAST_HOME_CACHE={"t":0,"d":[]} -_FAST_DT_CACHE={"t":0,"d":[]} -_FAST_VNEGO_CACHE={"t":0,"d":[]} -_FAST_HL_CACHE={"t":0,"d":[]} - -def _rss_articles_fast(feed_url, group, source='vne', limit=6): - out=[] - try: - r=requests.get(feed_url,headers=UA,timeout=4);r.encoding='utf-8' - soup=BeautifulSoup(r.text,'xml') - for it in soup.find_all('item')[:limit*2]: - title=clean(it.find('title').get_text(' ',strip=True) if it.find('title') else '') - link=clean(it.find('link').get_text(strip=True) if it.find('link') else '') - desc_raw=it.find('description').get_text(' ',strip=True) if it.find('description') else '' - ds=BeautifulSoup(desc_raw,'lxml') - im=ds.find('img'); img=im.get('src','') if im else '' - desc=clean(ds.get_text(' ',strip=True))[:160] - if title and link: - out.append({'title':title,'link':link,'img':img,'summary':desc,'source':source,'group':group}) - if len(out)>=limit:break - except Exception:pass - return out - -def _fast_homepage(): - now=time.time() - if _FAST_HOME_CACHE['d'] and now-_FAST_HOME_CACHE['t']<600:return _FAST_HOME_CACHE['d'] - feeds=[('Thời Sự','https://vnexpress.net/rss/thoi-su.rss'),('Thế Giới','https://vnexpress.net/rss/the-gioi.rss'),('Kinh Doanh','https://vnexpress.net/rss/kinh-doanh.rss'),('Công Nghệ','https://vnexpress.net/rss/so-hoa.rss'),('Thể Thao','https://vnexpress.net/rss/the-thao.rss'),('Giải Trí','https://vnexpress.net/rss/giai-tri.rss'),('Sức Khỏe','https://vnexpress.net/rss/suc-khoe.rss'),('Giáo Dục','https://vnexpress.net/rss/giao-duc.rss'),('Pháp Luật','https://vnexpress.net/rss/phap-luat.rss'),('Du Lịch','https://vnexpress.net/rss/du-lich.rss')] - arts=[] - try: - from concurrent.futures import ThreadPoolExecutor, as_completed - with ThreadPoolExecutor(max_workers=6) as ex: - futs=[ex.submit(_rss_articles_fast,u,g,'vne',6) for g,u in feeds] - for f in as_completed(futs,timeout=7): - try:arts.extend(f.result() or []) - except Exception:pass - except Exception: - for g,u in feeds[:5]:arts.extend(_rss_articles_fast(u,g,'vne',4)) - if arts:_FAST_HOME_CACHE.update({'t':now,'d':arts}) - return _FAST_HOME_CACHE['d'] or arts - -def _fast_dantri_hot(): - now=time.time() - if _FAST_DT_CACHE['d'] and now-_FAST_DT_CACHE['t']<900:return _FAST_DT_CACHE['d'] - data=_rss_articles_fast('https://dantri.com.vn/rss/home.rss','Tin Nổi Bật','dantri',12) - if data:_FAST_DT_CACHE.update({'t':now,'d':data}) - return data - -def _fast_vnego(): - now=time.time() - if _FAST_VNEGO_CACHE['d'] and now-_FAST_VNEGO_CACHE['t']<900:return _FAST_VNEGO_CACHE['d'] - out=[] - try: - r=requests.get('https://vnexpress.net/vne-go',headers=UA,timeout=4);r.encoding='utf-8' - soup=BeautifulSoup(r.text,'lxml');seen=set() - for a in soup.find_all('a',href=True): - href=a.get('href','');title=clean(a.get('title','') or a.get_text(' ',strip=True)) - if not title or len(title)<8 or not href.startswith('http') or href in seen:continue - if '/vne-go' not in href and '/video/' not in href:continue - seen.add(href);img='';im=a.find('img') or (a.parent.find('img') if a.parent else None) - if im:img=im.get('data-src') or im.get('src','') - out.append({'title':title,'link':href,'img':img,'source':'vne-video'}) - if len(out)>=10:break - except Exception:pass - _FAST_VNEGO_CACHE.update({'t':now,'d':out}) - return out - -def _fast_highlights(): - now=time.time() - if _FAST_HL_CACHE['d'] and now-_FAST_HL_CACHE['t']<900:return _FAST_HL_CACHE['d'] - _FAST_HL_CACHE.update({'t':now,'d':[]}) - return [] - -for _p in ['/api/homepage','/api/dantri_hot','/api/vne_video','/api/highlights']: - app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)==_p and 'GET' in getattr(r,'methods',set()))] -@app.get('/api/homepage') -def api_homepage_fast():return JSONResponse(_fast_homepage()) -@app.get('/api/dantri_hot') -def api_dantri_hot_fast():return JSONResponse(_fast_dantri_hot()) -@app.get('/api/vne_video') -def api_vne_video_fast():return JSONResponse(_fast_vnego()) -@app.get('/api/highlights') -def api_highlights_fast():return JSONResponse(_fast_highlights()) - -FINAL6_FAST_HOME_INJECT = """ - -""" -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_final6_fast_home(): - html=f5.f4.f3.f2.f1._load_index_html() - body=getattr(rt.old,'PATCH_INJECT','')+f5.f4.f3.f2.f1.FINAL_INJECT+f5.f4.f3.FINAL3_INJECT+f5.f4.FINAL4_INJECT+f5.FINAL5_INJECT+FINAL6_INJECT+FINAL6_FAST_HOME_INJECT - return HTMLResponse(html.replace('',body+'\n') if '' in html else html+body) - - -# ===== FINAL6E: SHOW SOURCE CONTENTS IN TOPIC ARTICLE ===== -def _extract_source_details_from_context(context, sources): - details=[] - # Map source urls by title for URL/via enrichment - src_by_title={clean(s.get('title','')):s for s in (sources or [])} - for block in (context or '').split('---'): - block=block.strip() - if not block:continue - via='';title='';content='' - m=re.search(r'NGUỒN:\s*(.*)',block) - if m:via=clean(m.group(1)) - m=re.search(r'TIÊU ĐỀ:\s*(.*)',block) - if m:title=clean(m.group(1)) - if 'NỘI DUNG BÀI VIẾT ĐÃ CRAWL:' in block: - content=block.split('NỘI DUNG BÀI VIẾT ĐÃ CRAWL:',1)[1] - elif 'TÓM TẮT RSS:' in block: - content=block.split('TÓM TẮT RSS:',1)[1] - elif 'ĐOẠN MÔ TẢ' in block: - content=re.split(r'ĐOẠN MÔ TẢ[^:]*:',block,1)[-1] - content=clean(content) - if not title and not content:continue - s=src_by_title.get(title,{}) - details.append({'title':title or s.get('title','Nguồn tham khảo'),'url':s.get('url',''),'via':via or s.get('via',''),'content':content[:1800]}) - if len(details)>=8:break - return details - -# Remove prior topic endpoint and register one that stores source_details in post. -app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)=='/api/topic_post' and 'POST' in getattr(r,'methods',set()))] - -@app.post('/api/topic_post') -async def topic_post_with_source_contents(request:Request): - body=await request.json();topic=clean(body.get('topic','')) - if not topic:return JSONResponse({'error':'missing topic'},status_code=400) - img=_topic_image(topic) - research=_fast_context(topic) if '_fast_context' in globals() else _web_research_context(topic) - context=research.get('context','');sources=research.get('sources',[]) - details=_extract_source_details_from_context(context,sources) - if not context or not details: - return JSONResponse({'error':'Không tìm/crawl được đủ nội dung về chủ đề này. Hãy thử chủ đề cụ thể hơn hoặc dùng hashtag gợi ý.'},status_code=422) - source_brief='\n\n'.join([f"[{i+1}] {d.get('title','')} ({d.get('via','')})\n{d.get('content','')[:1400]}" for i,d in enumerate(details)]) - prompt=f"""Bạn là biên tập viên VNEWS. Hãy viết MỘT BÀI VIẾT HOÀN CHỈNH bằng tiếng Việt về chủ đề: {topic} - -Dưới đây là nội dung từng nguồn đã thu thập. Hãy tổng hợp ý chính, không sao chép nguyên văn, không biến các tiêu đề thành danh sách. - -NỘI DUNG NGUỒN: -{source_brief[:18000]} - -Yêu cầu: -- Tiêu đề mới, rõ, hấp dẫn. -- Sapo 2-3 câu. -- 5-8 đoạn phân tích/bối cảnh/tác động/điểm cần lưu ý. -- Không dùng câu "Dưới đây là" hoặc "Tôi sẽ". -- Cuối bài có mục "Nguồn tham khảo" nêu tên nguồn. -""" - text=None - try: - import asyncio - text=await asyncio.wait_for(f5.base.qwen_generate(prompt,image_url=img,max_tokens=1700),timeout=35) - except Exception: - text=None - if not text or len(text)<350: - bullets='\n'.join([f"• {d['title']}: {d.get('content','')[:320]}" for d in details[:6]]) - vias=', '.join(sorted({d.get('via','') for d in details if d.get('via')})) - text=(f"{topic}: tổng hợp những điểm đáng chú ý\n\n" - f"{topic} đang được nhiều nguồn tin đề cập với các góc nhìn khác nhau. Dưới đây là phần tổng hợp nhanh từ những nội dung đã thu thập được.\n\n" - f"{bullets}\n\n" - f"Nhìn chung, chủ đề này cần được theo dõi thêm ở các khía cạnh: bối cảnh, tác động thực tế, phản ứng của các bên liên quan và các diễn biến mới trong thời gian tới.\n\n" - f"Nguồn tham khảo: {vias}") - post=f5.base.make_post(topic,text,img,'','topic_fast_rss_with_sources',sources=[s for s in sources if s.get('url')]) - post['images']=[img] - post['source_details']=details - posts=f5.base._load_ai_wall();posts.insert(0,post);f5.base._save_ai_wall(posts) - return JSONResponse({'post':post,'mode':'fast_rss_with_source_details','sources_count':len(details)}) - -FINAL6E_INJECT = """ - - -''' diff --git a/ai_runtime_fix.py b/ai_runtime_fix.py deleted file mode 100644 index 0ec96f7c3c57d95b682765689d6ed0964058cd85..0000000000000000000000000000000000000000 --- a/ai_runtime_fix.py +++ /dev/null @@ -1,394 +0,0 @@ -"""VNEWS Short Video Fix - standalone module with clean registration. -This module MUST be imported LAST to register /api/ai/short endpoints. -FIX v1: No route filtering issues - registers endpoints unconditionally. -FIX v2: SSE inline endpoint for auto homepage updates -""" -import os -import re -import time -import json -import sys -import logging -import asyncio -import hashlib -import subprocess -import requests -from datetime import datetime, timezone, timedelta -from urllib.parse import urlparse -from fastapi import Request, Query -from fastapi.responses import JSONResponse, FileResponse - -# Import dependencies -try: - import ai_ext as base -except ImportError: - import ai_runtime_final6 as base - -# Try to import app from various sources -try: - from app_v2_entry import app -except ImportError: - try: - from main import app - except ImportError: - from ai_runtime_final6 import app - -_log = logging.getLogger("short_fix") -_log.setLevel(logging.INFO) -if not _log.handlers: - _log.addHandler(logging.StreamHandler(sys.stderr)) - -DATA_DIR = "/data" if os.path.isdir("/data") else "/app/data" -os.makedirs(DATA_DIR, exist_ok=True) -SHORTS_DIR = os.path.join(DATA_DIR, "ai_shorts") -os.makedirs(SHORTS_DIR, exist_ok=True) - -# ===== VIETNAMESE FONT DETECTION ===== -_VN_FONT_REG = None -_VN_FONT_BOLD = None - -def _get_vn_fonts(): - """Find Vietnamese-supporting fonts.""" - global _VN_FONT_REG, _VN_FONT_BOLD - if _VN_FONT_REG is not None: - return _VN_FONT_REG, _VN_FONT_BOLD - - try: - from PIL import ImageFont - except Exception: - _log.error("PIL not available!") - return None, None - - # Priority: Noto > DejaVu > Liberation - reg_paths = [ - "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", - "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", - "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", - "/usr/share/fonts/truetype/freefont/FreeSans.ttf", - ] - bold_paths = [ - "/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf", - "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", - "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", - "/usr/share/fonts/truetype/freefont/FreeSans.ttf", - ] - - for path in reg_paths: - if os.path.exists(path): - try: - _VN_FONT_REG = ImageFont.truetype(path, 40) - _log.info(f"Found regular font: {path}") - break - except: - continue - - for path in bold_paths: - if os.path.exists(path): - try: - _VN_FONT_BOLD = ImageFont.truetype(path, 52) - _log.info(f"Found bold font: {path}") - break - except: - continue - - if _VN_FONT_REG is None: - _VN_FONT_REG = ImageFont.load_default() - if _VN_FONT_BOLD is None: - _VN_FONT_BOLD = _VN_FONT_REG - - return _VN_FONT_REG, _VN_FONT_BOLD - - -def _clean(s): - import html as html_lib - return re.sub(r"\s+", " ", html_lib.unescape(str(s or ""))).strip() - - -# ===== ROBUST TEXT SEGMENTATION ===== -def _split_into_segments(text, max_segments=10, min_len=30): - """Split text into segments - multi strategy.""" - text = _clean(text) - if not text: - return [] - - # Strategy 1: bullet points - lines = text.split('\n') - segmented = [] - for line in lines: - line = _clean(line) - line_bare = re.sub(r'^[•\-\*\d\.\)\s]+', '', line).strip() - if len(line_bare) > min_len: - segmented.append(line_bare) - elif len(line) > min_len: - segmented.append(line) - - # Strategy 2: sentences (Vietnamese) - if len(segmented) < 2: - sents = re.split(r'(?<=[.!?])\s+(?=[A-Z0-9À-ỸĐ])', text) - segmented = [s for s in sents if len(_clean(s)) > min_len] - - # Strategy 3: character chunks - if not segmented: - words = text.split() - for i in range(0, min(len(words), max_segments * 20), 20): - chunk = ' '.join(words[i:i+20]) - if len(chunk) > min_len: - segmented.append(chunk) - - # Strategy 4: fallback - if not segmented: - segmented = [text[:300]] - - return segmented[:max_segments] - - -# ===== SHORT VIDEO GENERATOR ===== -def _gen_short_core(post, work_dir): - """Core short generation - returns video path or None.""" - post_id = post.get('id', '') - text = post.get('text', '') or post.get('title', '') - - if not post_id or len(text) < 100: - _log.error(f"Invalid post: id={post_id}, text_len={len(text)}") - return None - - segments = _split_into_segments(text, max_segments=10, min_len=30) - if not segments: - _log.error("No segments generated") - return None - - _log.info(f"Generating short: {len(segments)} segments") - - seg_hash = hashlib.md5(('|'.join(segments) + 'nu').encode()).hexdigest()[:8] - suffix = f"_nu_{seg_hash}" - out_mp4 = os.path.join(work_dir, f"{post_id}{suffix}.mp4") - - if os.path.exists(out_mp4): - _log.info(f"Already exists: {out_mp4}") - return out_mp4 - - # Check dependencies - try: - subprocess.run(['ffmpeg', '-version'], capture_output=True, timeout=5) - except Exception as e: - _log.error(f"ffmpeg missing: {e}") - return None - - # Download image - img_path = os.path.join(work_dir, 'bg.jpg') - downloaded = False - try: - img_url = post.get('img', '') - if img_url and img_url.startswith('http'): - r = requests.get(img_url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=12) - if r.status_code == 200: - with open(img_path, 'wb') as f: - f.write(r.content) - downloaded = True - except Exception as e: - _log.warning(f"Image download: {e}") - - try: - from PIL import Image, ImageDraw - has_pil = True - except: - has_pil = False - _log.warning("PIL not available") - - try: - from gtts import gTTS - has_tts = True - except: - has_tts = False - _log.warning("gTTS not available") - - parts = [] - - for i, seg in enumerate(segments[:10]): - frame = os.path.join(work_dir, f'frame_{i}.jpg') - audio = os.path.join(work_dir, f'audio_{i}.mp3') - part = os.path.join(work_dir, f'part_{i}.mp4') - - # Create frame - try: - if has_pil: - _make_frame(post, seg, img_path, downloaded, frame) - else: - subprocess.run(['ffmpeg', '-y', '-f', 'lavfi', '-i', - 'color=c=black:s=1080x1920:d=1', '-frames:v', '1', frame], - capture_output=True, timeout=20) - except Exception as e: - _log.error(f"Frame error: {e}") - continue - - # Create audio - if has_tts: - try: - tts = _clean(seg)[:300] - gTTS(tts, lang='vi', slow=False).save(audio) - except Exception as e: - _log.warning(f"TTS error: {e}") - audio = None - - # Combine - dur = 10 - try: - cmd = ['ffmpeg', '-y', '-loop', '1', '-t', str(dur), '-i', frame] - if has_tts and os.path.exists(audio): - cmd += ['-i', audio, '-shortest'] - else: - cmd += ['-f', 'lavfi', '-i', 'anullsrc', '-shortest'] - cmd += ['-c:v', 'libx264', '-tune', 'stillimage', '-pix_fmt', 'yuv420p', - '-c:a', 'aac', '-b:a', '128k', part] - subprocess.run(cmd, capture_output=True, timeout=120) - if os.path.exists(part) and os.path.getsize(part) > 5000: - parts.append(part) - except Exception as e: - _log.error(f"Part combine error: {e}") - - if not parts: - _log.error("No video parts created!") - return None - - # Concatenate - try: - concat = os.path.join(work_dir, 'list.txt') - with open(concat, 'w') as f: - for p in parts: - f.write(f"file '{p}'\n") - subprocess.run(['ffmpeg', '-y', '-f', 'concat', '-safe', '0', '-i', concat, '-c', 'copy', out_mp4], - capture_output=True, timeout=180) - _log.info(f"Short created: {out_mp4}") - return out_mp4 - except Exception as e: - _log.error(f"Concat error: {e}") - return None - - -def _make_frame(post, text, img_path, downloaded, out_path): - """Create video frame with Vietnamese font.""" - from PIL import Image, ImageDraw - _get_vn_fonts() - - W, H = 1080, 1920 - bg = Image.new('RGB', (W, H), (15, 23, 38)) - d = ImageDraw.Draw(bg) - - # Background image - if downloaded and os.path.exists(img_path): - try: - im = Image.open(img_path).convert('RGB') - im = im.resize((W, 760)) - bg.paste(im, (0, 0)) - except: - pass - - # Title - d.rectangle([0, 0, W, 100], fill=(25, 118, 210)) - ttl = post.get('title', '')[:50] - if _VN_FONT_BOLD: - d.text((W//2, 50), ttl, fill='white', font=_VN_FONT_BOLD, anchor='mm') - - # Content - y = 150 - for ln in _wrap_text(d, text[:200], _VN_FONT_REG, 80, 920, 10): - d.text((80, y), ln, fill='white', font=_VN_FONT_REG) - y += 55 - - bg.save(out_path, quality=85) - - -def _wrap_text(draw, text, font, x, max_w, max_lines): - """Word wrap text.""" - words = text.split() - lines = [] - cur = [] - for w in words: - test = ' '.join(cur + [w]) - try: - w_px = draw.textbbox((0, 0), test, font=font)[2] - except: - w_px = len(test) * 22 - if w_px <= max_w: - cur.append(w) - else: - if cur: - lines.append(' '.join(cur)) - cur = [w] - if len(lines) >= max_lines: - break - if cur and len(lines) < max_lines: - lines.append(' '.join(cur)) - return lines - - -def _gen_short_sync(post) -> str: - """Sync wrapper - returns video URL.""" - work = os.path.join(SHORTS_DIR, f"work_{post.get('id', int(time.time()))}") - os.makedirs(work, exist_ok=True) - result = _gen_short_core(post, work) - if result: - # Update wall - try: - wall = base._load_ai_wall() - for i, p in enumerate(wall): - if str(p.get('id')) == str(post.get('id')): - p['video'] = f'/api/ai/short-file/{post.get("id")}_nu_{hashlib.md5(str(post).encode()).hexdigest()[:8]}' - wall[i] = p - break - base._save_ai_wall(wall) - # Notify SSE for auto-update - try: - from auto_update_sse import notify_new_short - notify_new_short(post) - except: - pass - except Exception as e: - _log.warning(f"Wall update: {e}") - return result - return '' - - -# ===== REGISTER ENDPOINTS - MUST BE AT MODULE LEVEL ===== -@app.post('/api/ai/short/{post_id}') -async def api_short_generate(post_id: str, request: Request): - _log.info(f"POST /api/ai/short/{post_id}") - wall = base._load_ai_wall() - post = next((p for p in wall if str(p.get('id')) == str(post_id)), None) - if not post: - return JSONResponse({'error': 'Post not found in wall'}, status_code=404) - - if post.get('video'): - return JSONResponse({'post': post, 'video': post['video'], 'status': 'done'}) - - loop = asyncio.get_event_loop() - result = await loop.run_in_executor(None, _gen_short_sync, post) - - if result: - # Get the video URL from wall (updated in _gen_short_sync) - wall = base._load_ai_wall() - post = next((p for p in wall if str(p.get('id')) == str(post_id)), post) - return JSONResponse({'post': post, 'video': post.get('video'), 'status': 'done'}) - return JSONResponse({'error': 'Video generation failed'}, status_code=500) - - -@app.get('/api/ai/short-file/{file_id:path}') -async def api_short_file(file_id: str): - safe = re.sub(r'[^\w\-.]', '_', file_id)[:100] - for fname in os.listdir(SHORTS_DIR) if os.path.isdir(SHORTS_DIR) else []: - if fname.endswith('.mp4') and safe in fname: - return FileResponse(os.path.join(SHORTS_DIR, fname), media_type='video/mp4') - return JSONResponse({'error': 'Not found'}, status_code=404) - - -# ===== SSE ENDPOINT FOR AUTO-UPDATE ===== -try: - from auto_update_sse import sse_events as _sse_handler - app.add_api_route('/api/events', _sse_handler, methods=['GET']) - _log.info("SSE endpoint registered at /api/events") -except Exception as e: - _log.warning(f"SSE route not loaded: {e}") - - -# Log startup -_log.info("Short video endpoints registered") \ No newline at end of file diff --git a/ai_runtime_patch_fast.py b/ai_runtime_patch_fast.py deleted file mode 100644 index 126cc9a14012fabb8b4f72064810a56a1c663802..0000000000000000000000000000000000000000 --- a/ai_runtime_patch_fast.py +++ /dev/null @@ -1,188 +0,0 @@ -"""Final patch v2: fix topic rewrite, remove duplicate short slide, full short interaction buttons.""" -import re, threading, time, json, os, asyncio -import ai_runtime_final6 as f6 -from ai_runtime_final6 import app, rt, f5, HTMLResponse, JSONResponse, Request, Query -import html as html_lib -from urllib.parse import urlparse - -def clean(s):return re.sub(r"\s+"," ",html_lib.unescape(str(s or ""))).strip() -def _domain(u): - try:return urlparse(u or '').netloc.replace('www.','') - except:return '' -DATA_DIR="/data" if os.path.isdir('/data') else "/app/data" -os.makedirs(DATA_DIR,exist_ok=True) -SHORT_COMMENTS_FILE=os.path.join(DATA_DIR,'short_comments.json') -TTL_24H=86400;HAS_PERSISTENT=os.path.isdir('/data') -def _lj(p,d): - try: - if os.path.exists(p):return json.load(open(p,'r',encoding='utf-8')) - except:pass - return d -def _sj(p,d): - try:os.makedirs(os.path.dirname(p),exist_ok=True);open(p+'.tmp','w',encoding='utf-8').write(json.dumps(d,ensure_ascii=False));os.replace(p+'.tmp',p) - except:pass -def _cleanup(): - n=int(time.time());ps=f5.base._load_ai_wall();f=[p for p in ps if n-int(p.get('ts') or 0)300 else None);return JSONResponse(_bg_home['d']) - if hasattr(f6,'_fast_homepage'):d=f6._fast_homepage();_bg_home.update({"t":n,"d":d or []});return JSONResponse(d or []) - return JSONResponse([]) -@app.get('/api/shorts') -def _sh(refresh:int=Query(default=0)): - n=time.time() - if _bg_shorts['d'] and (not refresh or n-_bg_shorts['t']<120):(threading.Thread(target=_bg,daemon=True).start() if n-_bg_shorts['t']>600 else None);return JSONResponse(_bg_shorts['d']) - return f6.api_shorts_final6(refresh=refresh) if hasattr(f6,'api_shorts_final6') else JSONResponse([]) -@app.get('/api/ai_wall') -def _w():n=int(time.time());return JSONResponse({'posts':[p for p in f5.base._load_ai_wall() if n-int(p.get('ts') or 0)150 else None) - ac='\n---\n'.join(parts) if parts else (p.get('text') or '') - title=p.get('title','') - text=None - try:text=await asyncio.wait_for(f5.base.qwen_generate(f'Viết lại:\nChủ đề: {title}\n{ac[:16000]}\n\nTiêu đề mới + 4-6 ý + nguồn.',image_url=p.get('img'),max_tokens=1200),timeout=35) - except:pass - if not text or len(text)<100:text=f"Tóm tắt: {title}\n\n{ac[:1500]}\n\nNguồn: VNEWS AI" - np=f5.base.make_post('Rewrite: '+title,text,p.get('img',''),'','rewrite_topic',sources=p.get('sources',[]));np['images']=p.get('images',[]) - all_p=f5.base._load_ai_wall();all_p.insert(0,np);f5.base._save_ai_wall(all_p);return JSONResponse({'post':np}) -@app.post('/api/topic_post') -async def _tp(request:Request): - b=await request.json();topic=clean(b.get('topic','')) - if not topic:return JSONResponse({'error':'missing topic'},status_code=400) - img=f6._topic_image(topic);research=f6._fast_context(topic) if hasattr(f6,'_fast_context') else f6._web_research_context(topic) - ctx=research.get('context','');src=research.get('sources',[]);det=f6._extract_source_details_from_context(ctx,src) if hasattr(f6,'_extract_source_details_from_context') else [] - if not ctx or not src:return JSONResponse({'error':'Không tìm được nội dung.'},status_code=422) - sb='\n\n'.join([f"[{i+1}] {d.get('title','')} ({d.get('via','')})\n{d.get('content','')[:1400]}" for i,d in enumerate(det)]) if det else ctx[:18000] - text=None - try:text=await asyncio.wait_for(f5.base.qwen_generate(f'Viết bài tiếng Việt VỀ: "{topic}"\nNGUỒN:\n{sb[:18000]}\nCHỈ viết về "{topic}". 5-8 đoạn. Cuối có nguồn.',image_url=img,max_tokens=1700),timeout=35) - except:pass - if not text or len(text)<300:text=f"{topic}: tổng hợp\n\n"+'\n'.join([f"• {d['title']}: {d.get('content','')[:300]}" for d in (det or [])[:6]])+"\n\nNguồn: "+', '.join(sorted({d.get('via','') for d in (det or []) if d.get('via')})) - post=f5.base.make_post(topic,text,img,'','topic_focused',sources=[s for s in src if s.get('url')]);post['images']=[img];post['source_details']=det - ps=f5.base._load_ai_wall();ps.insert(0,post);f5.base._save_ai_wall(ps);return JSONResponse({'post':post}) - -PATCH_INJECT=r''' - -
- -''' - -@app.get('/') -async def _index(): - html=f5.f4.f3.f2.f1._load_index_html() - body=getattr(rt.old,'PATCH_INJECT','')+f5.f4.f3.f2.f1.FINAL_INJECT+f5.f4.f3.FINAL3_INJECT+f5.f4.FINAL4_INJECT+f5.FINAL5_INJECT - body+=getattr(f6,'FINAL6_INJECT','');body+=getattr(f6,'FINAL6_FAST_HOME_INJECT','');body+=getattr(f6,'FINAL6E_INJECT','') - body+=PATCH_INJECT - return HTMLResponse(html.replace('',body+'\n') if '' in html else html+body) diff --git a/ai_runtime_patch_final.py b/ai_runtime_patch_final.py deleted file mode 100644 index 0512ad0ef58446a2f791f8f85e0457950cdd54d3..0000000000000000000000000000000000000000 --- a/ai_runtime_patch_final.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Final patch: homepage fix + AI topics at top + SSE auto-update""" -import re, json, time -from fastapi.responses import HTMLResponse, JSONResponse -from fastapi import Query - -# Import chain - must be after ai_runtime_final6 -try: - import ai_runtime_final6 as f6 - from ai_runtime_final6 import app, f5 - from main import rt -except Exception as e: - print(f"[ERROR] f6 import: {e}") - f6 = None - f5 = None - rt = None - -PATCH_CSS_JS = r''' - -
- -''' - -# Register route if possible -if f6 and app: - try: - # Remove duplicate / route to avoid conflict - original_routes = [r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))] - app.router.routes = original_routes - - @app.get('/') - async def patch_homepage(): - html = f5.f4.f3.f2.f1._load_index_html() if f5 else "" - body = "" - if hasattr(rt,'old') and hasattr(rt.old,'PATCH_INJECT'): - body += getattr(rt.old,'PATCH_INJECT','') - if f5: - body += getattr(f5.f4.f3.f2.f1,'FINAL_INJECT','') if hasattr(f5,'f4') else '' - body += getattr(f5.f4.f3,'FINAL3_INJECT','') if hasattr(f5,'f4') else '' - body += getattr(f5.f4,'FINAL4_INJECT','') if hasattr(f5,'f4') else '' - body += getattr(f5,'FINAL5_INJECT','') if hasattr(f5,'f4') else '' - body += getattr(f6,'FINAL6_INJECT','') if f6 else '' - body += getattr(f6,'FINAL6_FAST_HOME_INJECT','') if f6 else '' - body += getattr(f6,'FINAL6E_INJECT','') if f6 else '' - body += PATCH_CSS_JS - if '' in html: - html = html.replace('', body + '\n') - else: - html = html + body - return HTMLResponse(html) - except Exception as e: - print(f"[ERROR] register route: {e}") \ No newline at end of file diff --git a/app_clean.py b/app_clean.py deleted file mode 100644 index e535dd3f83d11e1ce93ea6cf32ab1889c72a8ace..0000000000000000000000000000000000000000 --- a/app_clean.py +++ /dev/null @@ -1,69 +0,0 @@ -""" -VNEWS Clean Backend - serves static/index_v2.html directly. -No injection layers. All APIs from existing modules preserved. -Comments feature REMOVED per user request. -""" -import sys, os - -# Import the full chain which registers all API endpoints on the FastAPI app -from app_main import app, _search_all, _clean - -# Now override the root '/' to serve our clean frontend -from fastapi import Query, Request -from fastapi.responses import HTMLResponse, FileResponse, JSONResponse -from fastapi.staticfiles import StaticFiles -import os - -# Remove old '/' route -app.router.routes = [r for r in app.router.routes if not ( - getattr(r, 'path', None) == '/' and 'GET' in getattr(r, 'methods', set()) -)] - -# Remove comment endpoints (user requested removal) -app.router.routes = [r for r in app.router.routes if not ( - getattr(r, 'path', None) in ('/api/short/comments', '/api/short/comment') -)] - -# Mount static files -STATIC_DIR = os.path.join(os.path.dirname(__file__), 'static') -app.mount('/static', StaticFiles(directory=STATIC_DIR), name='static') - -@app.get('/') -async def serve_index(): - """Serve the clean v2 frontend - single HTML file, no injection.""" - index_path = os.path.join(STATIC_DIR, 'index_v2.html') - if os.path.exists(index_path): - return FileResponse(index_path, media_type='text/html') - return HTMLResponse('

VNEWS

index_v2.html not found

', status_code=500) - -# Keep /api/hashtag/sources using direct search (not Google News) -# This was already overridden in app_main.py with _search_all -# Just make sure it's accessible - -# Storage status endpoint -@app.get('/api/storage_status') -def storage_status(): - """Check if persistent storage is enabled.""" - data_dir = '/data' - persistent = os.path.isdir(data_dir) and os.access(data_dir, os.W_OK) - return JSONResponse({'persistent': persistent, 'path': data_dir}) - -# Categories for the tab bar -@app.get('/api/categories') -def get_categories(): - """Return category list for frontend tab bar.""" - return JSONResponse([]) # Categories moved into News tab, homepage shows media content - -# Share page -@app.get('/s') -async def share_page(url: str = '', title: str = '', img: str = ''): - """OG share page for social media.""" - html = f''' - - - - - - -Redirecting...''' - return HTMLResponse(html) diff --git a/app_entry.py b/app_entry.py deleted file mode 100644 index 497d3439fa32c545f5a97810e80553931f6b13a1..0000000000000000000000000000000000000000 --- a/app_entry.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Wrapper: load main patch then inject extra fixes for tiktok-right position, kill duplicate slides, progress toast.""" -from ai_runtime_patch_fast import * -from ai_runtime_patch_fast import app, f5, f6, rt, PATCH_INJECT -from patch_extra import EXTRA_FIX -from fastapi.responses import HTMLResponse - -# Remove old root and re-register with EXTRA_FIX appended. -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_final(): - html=f5.f4.f3.f2.f1._load_index_html() - body=getattr(rt.old,'PATCH_INJECT','')+f5.f4.f3.f2.f1.FINAL_INJECT+f5.f4.f3.FINAL3_INJECT+f5.f4.FINAL4_INJECT+f5.FINAL5_INJECT - body+=getattr(f6,'FINAL6_INJECT','');body+=getattr(f6,'FINAL6_FAST_HOME_INJECT','');body+=getattr(f6,'FINAL6E_INJECT','') - body+=PATCH_INJECT - body+=EXTRA_FIX - return HTMLResponse(html.replace('',body+'\n') if '' in html else html+body) diff --git a/app_final.py b/app_final.py deleted file mode 100644 index 0fc7e772f6e19d57bd33770eaf082412f7e00717..0000000000000000000000000000000000000000 --- a/app_final.py +++ /dev/null @@ -1,213 +0,0 @@ -"""Final wrapper with complete highlight override including interaction buttons. -PLUS: Hashtag inline sources on homepage with rewrite button.""" -import json, os, time -from app_patch_unified import * -from app_patch_unified import app, UNIFIED_INJECT, f5, f6, rt, PATCH_INJECT -from fastapi.responses import HTMLResponse, JSONResponse -from fastapi import Request, Query - -DATA_DIR="/data" if os.path.isdir('/data') else "/app/data" -os.makedirs(DATA_DIR,exist_ok=True) -HL_STATS_FILE=os.path.join(DATA_DIR,'highlight_stats.json') - -def _load_hl(): - try: - if os.path.exists(HL_STATS_FILE):return json.load(open(HL_STATS_FILE,'r',encoding='utf-8')) - except:pass - return {} -def _save_hl(db): - try:open(HL_STATS_FILE+'.tmp','w',encoding='utf-8').write(json.dumps(db,ensure_ascii=False));os.replace(HL_STATS_FILE+'.tmp',HL_STATS_FILE) - except:pass - -app.router.routes=[r for r in app.router.routes if not ( - (getattr(r,'path',None)=='/api/highlight/interact' and 'POST' in getattr(r,'methods',set())) or - (getattr(r,'path',None)=='/api/highlight/stats' and 'GET' in getattr(r,'methods',set())) or - (getattr(r,'path',None)=='/api/hashtag/sources' and 'GET' in getattr(r,'methods',set())) or - (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set())) -)] - -@app.post('/api/highlight/interact') -async def _hl_act(request:Request): - b=await request.json();vid=str(b.get('id','')).strip();action=str(b.get('action','')).strip() - if not vid or action not in ('view','like','share'):return JSONResponse({'error':'invalid'},status_code=400) - db=_load_hl();st=db.get(vid,{'views':0,'likes':0,'shares':0}) - st[action+'s']=st.get(action+'s',0)+1 - db[vid]=st;_save_hl(db);return JSONResponse({'stats':st}) - -@app.get('/api/highlight/stats') -def _hl_stats(ids:str=Query(default='')): - db=_load_hl();out={} - for vid in ids.split(','): - vid=vid.strip() - if vid:out[vid]=db.get(vid,{'views':0,'likes':0,'shares':0}) - return JSONResponse({'stats':out}) - -@app.get('/api/hashtag/sources') -def _hashtag_sources(topic:str=Query(...)): - """Return sources for a hashtag topic to display inline on homepage.""" - research=f6._fast_context(topic) if hasattr(f6,'_fast_context') else f6._web_research_context(topic) - sources=research.get('sources',[]) - # Add og:image for each source - from ai_runtime_patch_fast import _scrape - for s in sources[:6]: - if s.get('url') and not s.get('img'): - try:_,_,img=_scrape(s['url'],500) - except:img='' - s['img']=img if img and len(img)>20 else '' - return JSONResponse({'sources':sources[:6],'topic':topic}) - -# PRE_KILL fix -UNIFIED_INJECT_FIXED = UNIFIED_INJECT.replace( - """Object.defineProperty(window,'renderAIShorts7',{get:function(){return function(){}},set:function(){},configurable:true});""", - """Object.defineProperty(window,'renderAIShorts7',{get:function(){return function(){}},set:function(){},configurable:true}); -Object.defineProperty(window,'renderPatchedWall',{get:function(){return function(){}},set:function(){},configurable:true}); -Object.defineProperty(window,'renderAiShorts',{get:function(){return function(){}},set:function(){},configurable:true}); -Object.defineProperty(window,'renderWall',{get:function(){return function(){}},set:function(){},configurable:true}); -Object.defineProperty(window,'renderAIShorts',{get:function(){return function(){}},set:function(){},configurable:true}); -Object.defineProperty(window,'loadPatchedWall',{get:function(){return function(){}},set:function(){},configurable:true}); -Object.defineProperty(window,'refreshFinalWall3',{get:function(){return function(){}},set:function(){},configurable:true});""" -) - -# Fix highlight fetch -UNIFIED_INJECT_FIXED = UNIFIED_INJECT_FIXED.replace( - "var articles=(window._hlLeagueData||{})[league]||[];\n if(!articles.length){el.innerHTML=", - "var articles=(window._hlLeagueData||{})[league]||[];\n if(!articles.length){try{var _r=await fetch('/api/highlights/'+league);articles=await _r.json();if(!Array.isArray(articles))articles=[];}catch(e){articles=[];}}\n if(!articles.length){el.innerHTML=" -) - -# Highlight full override (same as 5a5b626) -HIGHLIGHT_FULL_OVERRIDE = r''' - -
- -''' - -EXTRA_WALL_FIX = r''' - - -''' - -@app.get('/') -async def _index_fixed(): - html=f5.f4.f3.f2.f1._load_index_html() - body='' - body+=getattr(rt.old,'PATCH_INJECT','') - body+=f5.f4.f3.f2.f1.FINAL_INJECT+f5.f4.f3.FINAL3_INJECT+f5.f4.FINAL4_INJECT+f5.FINAL5_INJECT - body+=getattr(f6,'FINAL6_INJECT','') - body+=getattr(f6,'FINAL6_FAST_HOME_INJECT','') - body+=getattr(f6,'FINAL6E_INJECT','') - body+=PATCH_INJECT - body+=UNIFIED_INJECT_FIXED - body+=HIGHLIGHT_FULL_OVERRIDE - body+=EXTRA_WALL_FIX - return HTMLResponse(html.replace('',body+'\n') if '' in html else html+body) diff --git a/app_main.py b/app_main.py deleted file mode 100644 index e6e3ac68305763476ac9a11084015e1f7a4878e9..0000000000000000000000000000000000000000 --- a/app_main.py +++ /dev/null @@ -1,283 +0,0 @@ -"""VNEWS v2 - Clean frontend. CRITICAL: removes ALL old routes before registering new ones.""" -from app_run import * -from app_run import app, f5, f6, rt, PATCH_INJECT, UNIFIED_INJECT_FIXED, HIGHLIGHT_FULL_OVERRIDE, EXTRA_WALL_FIX, FAST_HASHTAG_JS -from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, Response -from fastapi.staticfiles import StaticFiles -from fastapi import Query, Request -import requests as req -from urllib.parse import quote -from bs4 import BeautifulSoup -import re, html as html_lib, os, json, threading, time -from concurrent.futures import ThreadPoolExecutor, as_completed - -def _clean(s):return re.sub(r"\s+"," ",html_lib.unescape(str(s or ""))).strip() -_STOP_WORDS=set('và của các những một được trong với cho tại sau trước khi không người việt nam hôm nay mới nhất nóng tin tức cập nhật theo từ đến là có thì'.split()) - -def _relevance_score(topic, title): - topic_lower = topic.lower().strip();title_lower = (title or '').lower() - if topic_lower in title_lower: return 10 - topic_words = [w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+', topic_lower) if len(w) > 1 and w not in _STOP_WORDS] - if not topic_words: return 0 - matched = sum(1 for w in topic_words if w in title_lower) - ratio = matched / len(topic_words) if topic_words else 0 - return int(ratio * 8) if ratio >= 0.6 else 0 - -def _search_vnexpress(topic,limit=8): - items=[] - try: - r=req.get(f"https://timkiem.vnexpress.net/?q={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=10);soup=BeautifulSoup(r.text,'lxml') - for art in soup.select('article.item-news')[:limit]: - a=art.select_one('h2 a, h3 a') - if a and a.get('href'):items.append({'title':_clean(a.get('title','') or a.get_text(strip=True)),'url':a['href'],'via':'VnExpress'}) - except:pass - return items -def _search_dantri(topic,limit=8): - items=[] - try: - r=req.get(f"https://dantri.com.vn/tim-kiem/{quote(topic)}.htm",headers={'User-Agent':'Mozilla/5.0'},timeout=10);soup=BeautifulSoup(r.text,'lxml') - for a in soup.select('h3 a[href], .article-title a[href]')[:limit*2]: - t=_clean(a.get_text(strip=True));href=a.get('href','') - if t and len(t)>15: - if not href.startswith('http'):href='https://dantri.com.vn'+href - if 'dantri.com.vn' in href:items.append({'title':t,'url':href,'via':'Dân Trí'}) - if len(items)>=limit:break - except:pass - return items -def _search_vietnamnet(topic,limit=6): - items=[] - try: - r=req.get(f"https://vietnamnet.vn/tim-kiem?q={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=10);soup=BeautifulSoup(r.text,'lxml') - for a in soup.select('h3 a[href], .horizontalPost__main-title a')[:limit*2]: - t=_clean(a.get_text(strip=True));href=a.get('href','') - if t and len(t)>15: - if not href.startswith('http'):href='https://vietnamnet.vn'+href - if 'vietnamnet.vn' in href:items.append({'title':t,'url':href,'via':'VietNamNet'}) - if len(items)>=limit:break - except:pass - return items -def _search_all(topic, limit=40): - all_items=[] - with ThreadPoolExecutor(5) as ex: - futs=[ex.submit(_search_vnexpress,topic,10),ex.submit(_search_dantri,topic,10),ex.submit(_search_vietnamnet,topic,8)] - for f in as_completed(futs,timeout=12): - try:all_items.extend(f.result()) - except:pass - seen=set();unique=[] - for i in all_items: - if i.get('url') and i['url'] not in seen:seen.add(i['url']);unique.append(i) - return unique[:limit] - -# Remove old routes -app.router.routes = [r for r in app.router.routes if not ( - (getattr(r, 'path', None) == '/' and 'GET' in getattr(r, 'methods', set())) or - (getattr(r, 'path', None) == '/api/hashtag/sources' and 'GET' in getattr(r, 'methods', set())) or - (getattr(r, 'path', None) in ('/api/short/comments', '/api/short/comment')) -)] -app.routes[:] = [r for r in app.routes if not ( - hasattr(r, 'path') and getattr(r, 'path', None) == '/' and - hasattr(r, 'methods') and 'GET' in getattr(r, 'methods', set()) -)] - -STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static') - -@app.get('/api/hashtag/sources') -def _ht(topic:str=Query(...), page:int=Query(default=0)): - all_items=_search_all(topic, 40) - scored = [(s,item) for item in all_items if (s:=_relevance_score(topic, item.get('title','')))>0] - scored.sort(key=lambda x: x[0], reverse=True) - filtered = [item for _, item in scored] - if len(filtered) < 3: filtered = all_items - per_page=6;start=page*per_page;end=start+per_page - return JSONResponse({'sources':filtered[start:end],'topic':topic,'page':page,'has_more':endRedirecting...') - -@app.get('/api/proxy/page') -def proxy_page(url: str = Query(...)): - try: - r = req.get(url, headers={'User-Agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36','Accept-Language':'vi-VN,vi;q=0.9','Referer':'https://hd.xemtv.net/'}, timeout=15) - return HTMLResponse(content=r.text) - except: - return HTMLResponse(content='', status_code=502) - -@app.get('/api/proxy/hls') -def proxy_hls(url: str = Query(...)): - try: - headers = { - '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', - 'Accept': '*/*', - 'Accept-Language': 'vi-VN,vi;q=0.9', - 'Referer': 'https://fptplay.vn/', - 'Origin': 'https://fptplay.vn', - } - r = req.get(url, headers=headers, timeout=15) - content_type = r.headers.get('Content-Type', 'application/vnd.apple.mpegurl') - text = r.text - base_url = url.rsplit('/', 1)[0] + '/' - def _rewrite_url(m): - seg_url = m.group(0) - if seg_url.startswith('http'): - return '/api/proxy/seg?url=' + quote(seg_url, safe='') - elif seg_url.startswith('/'): - return '/api/proxy/seg?url=' + quote(base_url.rsplit('/', 2)[0] + seg_url, safe='') - else: - return '/api/proxy/seg?url=' + quote(base_url + seg_url, safe='') - text = re.sub(r'https?://[^\s"\'<>]+\.(ts|m3u8)[^\s"\'<>]*', _rewrite_url, text) - return HTMLResponse(content=text, media_type=content_type) - except: - return HTMLResponse(content='', status_code=502) - -@app.get('/api/proxy/seg') -def proxy_seg(url: str = Query(...)): - try: - headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', - 'Referer': 'https://fptplay.vn/', - 'Origin': 'https://fptplay.vn', - } - r = req.get(url, headers=headers, timeout=15) - content_type = r.headers.get('Content-Type', 'video/MP2T') - return Response(content=r.content, media_type=content_type) - except: - return Response(content=b'', status_code=502) - -# Interactions -DATA_DIR = '/data' if os.path.isdir('/data') else os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data') -os.makedirs(DATA_DIR, exist_ok=True) -INTERACTIONS_FILE = os.path.join(DATA_DIR, 'interactions_v2.json') -COMMENTS_FILE = os.path.join(DATA_DIR, 'comments_v2.json') -_interact_lock = threading.Lock() -_comment_lock = threading.Lock() -def _load_json(path): - try: - if os.path.exists(path): - with open(path,'r',encoding='utf-8') as f:return json.load(f) - except:pass - return {} -def _save_json(path, data): - try: - tmp=path+'.tmp' - with open(tmp,'w',encoding='utf-8') as f:json.dump(data,f,ensure_ascii=False) - os.replace(tmp,path) - except:pass - -@app.post('/api/v2/interact') -async def api_interact(request:Request): - body=await request.json();vid=str(body.get('id','')).strip();itype=str(body.get('type','')).strip() - if not vid or itype not in('view','like'):return JSONResponse({'error':'invalid'},status_code=400) - with _interact_lock: - db=_load_json(INTERACTIONS_FILE) - if vid not in db:db[vid]={'views':0,'likes':0,'comments':0} - db[vid][itype+'s']=db[vid].get(itype+'s',0)+1 - _save_json(INTERACTIONS_FILE,db);return JSONResponse(db[vid]) -@app.get('/api/v2/interactions') -def api_get_interactions(id:str=Query(...)): - with _interact_lock:return JSONResponse(_load_json(INTERACTIONS_FILE).get(id.strip(),{'views':0,'likes':0,'comments':0})) -@app.get('/api/v2/comments') -def api_get_comments(id:str=Query(...)): - with _comment_lock:return JSONResponse({'comments':_load_json(COMMENTS_FILE).get(id.strip(),[])}) -@app.post('/api/v2/comment') -async def api_post_comment(request:Request): - body=await request.json();vid=str(body.get('id','')).strip();text=str(body.get('text','')).strip()[:500] - if not vid or not text:return JSONResponse({'error':'invalid'},status_code=400) - comment={'text':text,'time':time.strftime('%H:%M %d/%m',time.localtime()),'ts':int(time.time())} - with _comment_lock: - db=_load_json(COMMENTS_FILE) - if vid not in db:db[vid]=[] - db[vid].append(comment) - if len(db[vid])>200:db[vid]=db[vid][-200:] - _save_json(COMMENTS_FILE,db);comments=db[vid] - with _interact_lock: - idb=_load_json(INTERACTIONS_FILE) - if vid not in idb:idb[vid]={'views':0,'likes':0,'comments':0} - idb[vid]['comments']=len(comments);_save_json(INTERACTIONS_FILE,idb) - return JSONResponse({'comments':comments}) - -# World Cup 2026 API -from wc2026_scraper import ( - scrape_summary, scrape_fixtures, scrape_standings, scrape_stats, - scrape_wc_news, scrape_road_to_wc, get_wc2026_all, - scrape_history, scrape_h2h, scrape_lineups, scrape_match_detail -) - -@app.get('/api/wc2026') -def api_wc2026_all():return JSONResponse(get_wc2026_all()) -@app.get('/api/wc2026/summary') -def api_wc2026_summary():return JSONResponse(scrape_summary()) -@app.get('/api/wc2026/fixtures') -def api_wc2026_fixtures():return JSONResponse(scrape_fixtures()) -@app.get('/api/wc2026/standings') -def api_wc2026_standings():return JSONResponse(scrape_standings()) -@app.get('/api/wc2026/stats') -def api_wc2026_stats():return JSONResponse(scrape_stats()) -@app.get('/api/wc2026/history') -def api_wc2026_history():return JSONResponse(scrape_history()) -@app.get('/api/wc2026/news') -def api_wc2026_news():return JSONResponse(scrape_wc_news()) -@app.get('/api/wc2026/road') -def api_wc2026_road():return JSONResponse(scrape_road_to_wc()) -@app.get('/api/wc2026/h2h/{event_id}') -def api_wc2026_h2h(event_id:int):return JSONResponse(scrape_h2h(event_id)) -@app.get('/api/wc2026/lineups/{event_id}') -def api_wc2026_lineups(event_id:int):return JSONResponse(scrape_lineups(event_id)) -@app.get('/api/wc2026/match/{event_id}') -def api_wc2026_match(event_id:int):return JSONResponse(scrape_match_detail(event_id)) - -# Match Detail API (for any match from bongda.com.vn) -from match_detail import fetch_match_detail, fetch_match_detail_by_url, _bongda_api - -@app.get('/api/match/{event_id}/detail') -def api_match_detail(event_id: int, url: str = Query(default=None)): - """Get complete match detail. Optional 'url' param with full bongda URL (with slug) for HTML scraping.""" - if url: - return JSONResponse(fetch_match_detail_by_url(url)) - return JSONResponse(fetch_match_detail(event_id)) - -@app.get('/api/match/{event_id}/commentaries') -def api_match_commentaries(event_id: int): - """Get match commentaries from bongda API.""" - comm = _bongda_api("/api/fixtures/commentaries", {"event_id": event_id}) - if comm and comm.get("status") == "success": - html = comm.get("html", "") - if html and len(html.strip()) > 10: - return JSONResponse({"html": html}) - return JSONResponse({"html": ""}) - -@app.get('/api/match/{event_id}/stats') -def api_match_stats(event_id: int): - """Get match player performance stats from bongda API.""" - perf = _bongda_api("/api/event-standing/player-performance", {"event_id": event_id}) - if perf and perf.get("status") == "success": - html = perf.get("html", "") - if html and len(html.strip()) > 10: - return JSONResponse({"html": html}) - return JSONResponse({"html": ""}) - -@app.get('/api/match/detail') -def api_match_detail_by_url(url: str = Query(...)): - """Get match detail by full bongda.com.vn URL.""" - return JSONResponse(fetch_match_detail_by_url(url)) - -def _wc2026_bg_refresh(): - time.sleep(10) - while True: - try:get_wc2026_all() - except:pass - time.sleep(90) -threading.Thread(target=_wc2026_bg_refresh,daemon=True).start() - -# Serve frontend -@app.get('/') -async def _index_v2(): - index_path = os.path.join(STATIC_DIR, 'index_v2.html') - if os.path.exists(index_path): - return FileResponse(index_path, media_type='text/html') - return HTMLResponse('

VNEWS v2

index_v2.html not found

') - -app.mount('/static', StaticFiles(directory=STATIC_DIR), name='vnews_static') diff --git a/app_patch_unified.py b/app_patch_unified.py deleted file mode 100644 index 74b609d7ed36f7d11262955f8a5cfe91c5228c64..0000000000000000000000000000000000000000 --- a/app_patch_unified.py +++ /dev/null @@ -1,273 +0,0 @@ -""" -VNEWS Unified Patch v2 -====================== -Single file replacing app_entry.py + patch_extra.py functionality. -No conflicts, no duplicate slides, no DOM destruction. - -Features: -1. Tường AI persistent (fix FINAL6E destroying DOM) -2. Source details with image + description + "Xem trên VNEWS" -3. Highlight = TikTok fullheight 1:1 crop center with interaction buttons -4. Rewrite auto-title, no "xem trên VNEWS" junk -5. Topic post uses source og:image instead of AI image -6. Fast homepage load (non-blocking) -""" -from ai_runtime_patch_fast import * -from ai_runtime_patch_fast import app, f5, f6, rt, PATCH_INJECT, _scrape, _domain, clean, _bg, _bg_home, _bg_shorts -from fastapi.responses import HTMLResponse, JSONResponse -from fastapi import Request, Query -import asyncio, re, threading, time - -DEFAULT_IMG = "https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg" - -# ============================================================ -# REMOVE ALL CONFLICTING ROUTES — we redefine them cleanly -# ============================================================ -_OVERRIDE_PATHS = {'/api/homepage','/api/shorts','/api/topic_post','/api/topic/rewrite','/api/rewrite_share','/api/url_wall','/'} -app.router.routes = [r for r in app.router.routes if not (getattr(r,'path',None) in _OVERRIDE_PATHS and any(m in getattr(r,'methods',set()) for m in ('GET','POST')))] - -# ============================================================ -# FAST HOMEPAGE + SHORTS (non-blocking) -# ============================================================ -@app.get('/api/homepage') -def _homepage(): - if _bg_home['d']: - if time.time()-_bg_home['t']>300:threading.Thread(target=_bg,daemon=True).start() - return JSONResponse(_bg_home['d']) - threading.Thread(target=_bg,daemon=True).start() - return JSONResponse([]) - -@app.get('/api/shorts') -def _shorts(refresh:int=Query(default=0)): - if _bg_shorts['d']: - if time.time()-_bg_shorts['t']>600:threading.Thread(target=_bg,daemon=True).start() - return JSONResponse(_bg_shorts['d']) - threading.Thread(target=_bg,daemon=True).start() - return JSONResponse([]) - -# ============================================================ -# HELPERS -# ============================================================ -def _extract_title(text): - if not text:return 'Bài viết AI' - lines=[l.strip() for l in text.strip().split('\n') if l.strip()] - if lines: - first=re.sub(r'^[#*\-•\d\.\)\s]+','',lines[0]).strip() - if 10<=len(first)<=120:return first - return lines[0][:100] if lines else 'Bài viết AI' - -def _clean_text(text): - if not text:return text - for junk in ['xem trên VNEWS','Xem trên VNEWS','📖 Xem trên VNEWS','đọc trên VNEWS','Đọc trên VNEWS','Mở nguồn gốc','mở nguồn gốc','📖 Đọc trên']: - text=text.replace(junk,'') - return re.sub(r'\n{3,}','\n\n',text).strip() - -def _source_image(sources, details): - for s in (details or [])+(sources or []): - url=s.get('url','') - if not url:continue - try:_,_,img=_scrape(url,500) - except:img='' - if img and 'pollinations' not in img and len(img)>20:return img - return '' - -def _ensure_img(img): - return img if (img and len(img)>20 and img.startswith('http')) else DEFAULT_IMG - -# ============================================================ -# TOPIC POST (source image instead of AI image) -# ============================================================ -@app.post('/api/topic_post') -async def _topic(request:Request): - b=await request.json();topic=clean(b.get('topic','')) - if not topic:return JSONResponse({'error':'missing topic'},status_code=400) - research=f6._fast_context(topic) if hasattr(f6,'_fast_context') else f6._web_research_context(topic) - ctx=research.get('context','');src=research.get('sources',[]) - det=f6._extract_source_details_from_context(ctx,src) if hasattr(f6,'_extract_source_details_from_context') else [] - if not ctx or not src:return JSONResponse({'error':'Không tìm được nội dung.'},status_code=422) - img=_ensure_img(_source_image(src,det) or f6._topic_image(topic)) - sb='\n\n'.join([f"[{i+1}] {d.get('title','')} ({d.get('via','')})\n{d.get('content','')[:1400]}" for i,d in enumerate(det)]) if det else ctx[:18000] - text=None - try:text=await asyncio.wait_for(f5.base.qwen_generate(f'Viết bài tiếng Việt VỀ: "{topic}"\nNGUỒN:\n{sb[:18000]}\nCHỈ viết về "{topic}". 5-8 đoạn. Cuối có nguồn.',image_url=img,max_tokens=1700),timeout=35) - except:pass - if not text or len(text)<300: - text=f"{topic}: tổng hợp\n\n"+'\n'.join([f"• {d['title']}: {d.get('content','')[:300]}" for d in (det or [])[:6]])+"\n\nNguồn: "+', '.join(sorted({d.get('via','') for d in (det or []) if d.get('via')})) - text=_clean_text(text) - post=f5.base.make_post(topic,text,img,'','topic_focused',sources=[s for s in src if s.get('url')]) - post['images']=[img];post['source_details']=det - ps=f5.base._load_ai_wall();ps.insert(0,post);f5.base._save_ai_wall(ps) - return JSONResponse({'post':post}) - -# ============================================================ -# REWRITE (auto-title, clean text) -# ============================================================ -@app.post('/api/rewrite_share') -@app.post('/api/url_wall') -async def _rewrite(request:Request): - b=await request.json();url=clean(b.get('url',''));ctx=clean(b.get('context','')) - if not url.startswith('http'):return JSONResponse({'error':'URL không hợp lệ'},status_code=400) - title,raw,img=_scrape(url,14000) - if len(raw)<50:raw=ctx[:14000] - if len(raw)<50:return JSONResponse({'error':'Không đọc được bài'},status_code=422) - img=_ensure_img(img) - prompt=f"""Tóm tắt bài viết thành bản tin ngắn. Dòng đầu tiên là tiêu đề mới hấp dẫn (tự đặt, không copy gốc). - -Tiêu đề gốc: {title} -Nội dung: -{raw[:14000]} - -Yêu cầu: -- Dòng 1: Tiêu đề MỚI ngắn gọn hấp dẫn. -- Tiếp: 4-6 ý chính. -- Cuối: nguồn. -- KHÔNG viết bất kỳ cụm điều hướng nào.""" - text=None - try:text=await asyncio.wait_for(f5.base.qwen_generate(prompt,image_url=img,max_tokens=1000),timeout=30) - except:pass - if not text or len(text)<80:text=f"{title}\n\n{raw[:1200]}\n\nNguồn: {_domain(url)}" - text=_clean_text(text) - ai_title=_extract_title(text) - lines=text.strip().split('\n') - body='\n'.join(lines[1:]).strip() if lines and lines[0].strip()==ai_title else text - post=f5.base.make_post(ai_title,_clean_text(body),img,url,'rewrite',sources=[{'title':title,'url':url,'via':_domain(url)}]) - ps=f5.base._load_ai_wall();ps.insert(0,post);f5.base._save_ai_wall(ps) - return JSONResponse({'post':post}) - -@app.post('/api/topic/rewrite') -async def _topic_rewrite(request:Request): - b=await request.json();pid=str(b.get('post_id','')).strip() - if not pid:return JSONResponse({'error':'missing post_id'},status_code=400) - ps=f5.base._load_ai_wall();p=next((x for x in ps if str(x.get('id'))==pid),None) - if not p:return JSONResponse({'error':'Bài không tồn tại'},status_code=404) - urls=list(dict.fromkeys([s['url'] for s in (p.get('source_details') or []) if s.get('url')]+[s['url'] for s in (p.get('sources') or []) if s.get('url')]))[:5] - parts=[];best_img='' - for u in urls: - t,r,uimg=_scrape(u,6000) - if r and len(r)>150:parts.append(f"[{_domain(u)}] {t}\n{r}") - if not best_img and uimg and len(uimg)>20:best_img=uimg - ac='\n---\n'.join(parts) if parts else (p.get('text') or '') - img=_ensure_img(best_img or p.get('img','')) - prompt=f"""Viết lại thành bản tóm tắt mới. Dòng đầu là tiêu đề mới hấp dẫn. - -Chủ đề: {p.get('title','')} -Nguồn: -{ac[:16000]} - -Yêu cầu: Dòng 1 = tiêu đề mới. Tiếp: 4-6 ý. Cuối: nguồn. KHÔNG viết cụm điều hướng.""" - text=None - try:text=await asyncio.wait_for(f5.base.qwen_generate(prompt,image_url=img,max_tokens=1200),timeout=35) - except:pass - if not text or len(text)<100:text=f"Tóm tắt: {p.get('title','')}\n\n{ac[:1500]}\n\nNguồn: VNEWS AI" - text=_clean_text(text) - ai_title=_extract_title(text) - lines=text.strip().split('\n') - body='\n'.join(lines[1:]).strip() if lines and lines[0].strip()==ai_title else text - np=f5.base.make_post(ai_title,_clean_text(body),img,'','rewrite_topic',sources=p.get('sources',[]));np['images']=[img] - all_p=f5.base._load_ai_wall();all_p.insert(0,np);f5.base._save_ai_wall(all_p) - return JSONResponse({'post':np}) - -# ============================================================ -# UNIFIED INJECT: everything in one clean block -# ============================================================ -UNIFIED_INJECT = r''' - - -
- -''' - -# ============================================================ -# ROOT ROUTE: inject order matters -# ============================================================ -@app.get('/') -async def _index(): - html = f5.f4.f3.f2.f1._load_index_html() - # Inject order: PRE_KILL (in UNIFIED) → old injects → PATCH_INJECT → UNIFIED - body = '' - body += getattr(rt.old,'PATCH_INJECT','') - body += f5.f4.f3.f2.f1.FINAL_INJECT + f5.f4.f3.FINAL3_INJECT + f5.f4.FINAL4_INJECT + f5.FINAL5_INJECT - body += getattr(f6,'FINAL6_INJECT','') - body += getattr(f6,'FINAL6_FAST_HOME_INJECT','') - body += getattr(f6,'FINAL6E_INJECT','') # Keep it — our PRE_KILL in UNIFIED neutralizes its destructive parts - body += PATCH_INJECT - body += UNIFIED_INJECT # This goes LAST and contains PRE_KILL at the TOP (runs first in browser) - return HTMLResponse(html.replace('', body + '\n') if '' in html else html + body) diff --git a/app_run.py b/app_run.py deleted file mode 100644 index 39efbfda49e08d8caf317ac058dfffdb289670a7..0000000000000000000000000000000000000000 --- a/app_run.py +++ /dev/null @@ -1,221 +0,0 @@ -"""Wrapper: hashtag via Google News with pagination, strict relevance, load more.""" -from app_final import * -from app_final import app, f6, f5, rt, PATCH_INJECT, UNIFIED_INJECT_FIXED, HIGHLIGHT_FULL_OVERRIDE, EXTRA_WALL_FIX -from fastapi.responses import HTMLResponse, JSONResponse -from fastapi import Query, Request -import requests as req -from urllib.parse import quote -from bs4 import BeautifulSoup -import re, html as html_lib - -def _clean(s):return re.sub(r"\s+"," ",html_lib.unescape(str(s or ""))).strip() - -def _follow_redirect(url): - try: - r=req.head(url,allow_redirects=True,timeout=10,headers={'User-Agent':'Mozilla/5.0'}) - return r.url - except: - try:r=req.get(url,allow_redirects=True,timeout=10,headers={'User-Agent':'Mozilla/5.0'},stream=True);u=r.url;r.close();return u - except:return url - -def _scrape_any_article(url): - if 'news.google.com' in url or 'google.com/rss' in url:url=_follow_redirect(url) - try: - r=req.get(url,headers={'User-Agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36','Accept-Language':'vi-VN,vi;q=0.9,en;q=0.8'},timeout=15,allow_redirects=True) - r.encoding='utf-8';soup=BeautifulSoup(r.text,'lxml') - for tag in soup.find_all(['script','style','nav','footer','aside','form','noscript','iframe']):tag.decompose() - h1=soup.find('h1');ogt=soup.find('meta',property='og:title') - title=(h1.get_text(' ',strip=True) if h1 else '') or (ogt.get('content','') if ogt else '') or (soup.title.get_text(strip=True) if soup.title else '') - ogd=soup.find('meta',property='og:description') or soup.find('meta',attrs={'name':'description'}) - summary=ogd.get('content','') if ogd else '' - ogi=soup.find('meta',property='og:image') or soup.find('meta',attrs={'name':'twitter:image'}) - og_image=ogi.get('content','') if ogi else '' - if og_image and og_image.startswith('//'):og_image='https:'+og_image - selectors=['article','main','.article-content','.detail-content','.singular-content','.fck_detail','.content-detail','.entry-content','.story-body','.knc-content','.cms-body'] - block=None - for sel in selectors: - el=soup.select_one(sel) - if el and len(el.find_all('p'))>=2:block=el;break - if not block: - best=None;best_score=0 - for el in soup.find_all(['article','main','section','div']): - ps=el.find_all('p');score=len(ps)*100+sum(len(p.get_text())for p in ps[:10]) - if score>best_score:best=el;best_score=score - block=best or soup.body or soup - body=[] - for el in block.find_all(['p','h2','h3','figure','img'],recursive=True): - if el.name=='p': - t=_clean(el.get_text(' ',strip=True)) - if len(t)>30:body.append({'type':'p','text':t}) - elif el.name in ('h2','h3'): - t=_clean(el.get_text(' ',strip=True)) - if t:body.append({'type':'heading','text':t}) - elif el.name in ('figure','img'): - im=el if el.name=='img' else el.find('img') - if im: - src=im.get('data-src') or im.get('data-original') or im.get('src') or '' - if src and 'base64' not in src: - if src.startswith('//'):src='https:'+src - body.append({'type':'img','src':src}) - if not body and summary:body=[{'type':'p','text':summary}] - return {'title':_clean(title),'summary':_clean(summary),'og_image':og_image,'body':body[:50],'source':'generic','url':url} - except:return None - -def _google_news_search_all(topic, limit=30): - """Get ALL results from Google News RSS for a topic — no filtering here, filter in endpoint.""" - items=[] - try: - url='https://news.google.com/rss/search?q='+quote(topic)+'&hl=vi&gl=VN&ceid=VN:vi' - r=req.get(url,headers={'User-Agent':'Mozilla/5.0'},timeout=10);r.encoding='utf-8' - soup=BeautifulSoup(r.text,'xml') - for it in soup.find_all('item')[:limit]: - title=_clean(it.find('title').get_text(' ',strip=True) if it.find('title') else '') - link=_clean(it.find('link').get_text(strip=True) if it.find('link') else '') - src=_clean(it.find('source').get_text(' ',strip=True) if it.find('source') else '') - pub=_clean(it.find('pubDate').get_text(strip=True) if it.find('pubDate') else '') - if not title or not link:continue - items.append({'title':title,'url':link,'via':src,'snippet':'','pubDate':pub}) - except:pass - return items - -def _filter_relevant(items, topic): - """Strict filter: topic keywords MUST appear in title.""" - topic_lower=topic.lower() - topic_words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',topic_lower) if len(w)>2] - filtered=[] - for s in items: - title_lower=s.get('title','').lower() - # Whole phrase match OR majority of words match - if topic_lower in title_lower: - filtered.append(s);continue - if topic_words: - match=sum(1 for w in topic_words if w in title_lower) - if match>=len(topic_words)*0.6: - filtered.append(s) - return filtered - -# Override endpoints -app.router.routes=[r for r in app.router.routes if not ( - (getattr(r,'path',None)=='/api/hashtag/sources' and 'GET' in getattr(r,'methods',set())) or - (getattr(r,'path',None)=='/api/article' and 'GET' in getattr(r,'methods',set())) or - (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set())) -)] - -@app.get('/api/article') -def _article_universal(url:str=Query(...)): - data=_scrape_any_article(url) - if data and data.get('body'):return JSONResponse(data) - from main import scrape_vne_article,scrape_bbc_article,scrape_dantri_article,scrape_genk_article,scrape_ttvh_article - if 'vnexpress.net' in url:d=scrape_vne_article(url) - elif 'bbc.com' in url:d=scrape_bbc_article(url) - elif 'dantri.com.vn' in url:d=scrape_dantri_article(url) - elif 'genk.vn' in url:d=scrape_genk_article(url) - elif 'thethaovanhoa.vn' in url:d=scrape_ttvh_article(url) - else:d=None - if d and d.get('body'):return JSONResponse(d) - return JSONResponse({'error':'Không đọc được bài viết','url':url}) - -@app.get('/api/hashtag/sources') -def _hashtag_paged(topic:str=Query(...),page:int=Query(default=0)): - """Google News search with pagination. page=0 returns first 6, page=1 returns next 6, etc.""" - all_items=_google_news_search_all(topic,30) - filtered=_filter_relevant(all_items,topic) - # If strict filter too harsh, fallback to all - if len(filtered)<3:filtered=all_items - per_page=6;start=page*per_page;end=start+per_page - page_items=filtered[start:end] - has_more=end -.hashtag-loading{display:flex;align-items:center;gap:8px;padding:12px;color:#888;font-size:12px} -.hashtag-spinner{width:16px;height:16px;border:2px solid #333;border-top-color:#5cb87a;border-radius:50%;animation:ht-spin .8s linear infinite} -@keyframes ht-spin{to{transform:rotate(360deg)}} -.hashtag-load-more{width:100%;margin-top:8px;background:#222;border:1px solid #333;color:#ccc;padding:9px;border-radius:10px;font-size:12px;cursor:pointer}.hashtag-load-more:active{opacity:.7} - - -''' - -@app.get('/') -async def _index_run(): - html=f5.f4.f3.f2.f1._load_index_html() - body='' - body+=getattr(rt.old,'PATCH_INJECT','') - body+=f5.f4.f3.f2.f1.FINAL_INJECT+f5.f4.f3.FINAL3_INJECT+f5.f4.FINAL4_INJECT+f5.FINAL5_INJECT - body+=getattr(f6,'FINAL6_INJECT','') - body+=getattr(f6,'FINAL6_FAST_HOME_INJECT','') - body+=getattr(f6,'FINAL6E_INJECT','') - body+=PATCH_INJECT - body+=UNIFIED_INJECT_FIXED - body+=HIGHLIGHT_FULL_OVERRIDE - body+=EXTRA_WALL_FIX - body+=FAST_HASHTAG_JS - return HTMLResponse(html.replace('',body+'\n') if '' in html else html+body) diff --git a/app_v2_entry.py b/app_v2_entry.py index 2004de58fd9cdc21e12dbca3b586edda6d79e92a..454dad5986ebe5037a7b65ad8d1de09c3f12439b 100644 --- a/app_v2_entry.py +++ b/app_v2_entry.py @@ -1,4 +1,4 @@ -"""VNEWS v2 Entry Point - with fast bongda proxy + rewrite endpoints + multilingual TTS + opinion v3""" +"""VNEWS v2 Entry Point - with fast bongda proxy + rewrite endpoints + multilingual TTS""" import sys, os from main import app, HEADERS, BONGDA_HEADERS, fetch_bongda_api, HL_LEAGUES @@ -12,37 +12,40 @@ try: except Exception as e: print(f"[WARN] ai_patch import failed: {e}") -# PERSONAL OPINION POST v3 - AI synthesis from user opinion + hot news sources -try: - import opinion_v3_patch - print("[app_v2_entry] opinion_v3_patch loaded: POST /api/opinion/post + GET /api/opinion/hot_context") -except Exception as e: - print(f"[WARN] opinion_v3_patch import failed: {e}") - from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, Response +from fastapi.staticfiles import StaticFiles from starlette.routing import Mount from fastapi import Query, Request, UploadFile, File, Form import requests as req from bs4 import BeautifulSoup import re, html as html_lib, json, threading, time, uuid from concurrent.futures import ThreadPoolExecutor, as_completed -from urllib.parse import quote, unquote +from urllib.parse import quote import asyncio +HL_LEAGUES['friendly'] = {"path": "giai-khac/friendly", "name": "Giao hữu", "emoji": "🤝"} + STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static') -SPACE = "https://bep40-vnews.hf.space" +SPACE = "https://bep40-vnews.hf.space" # SEO URL base for share links app.router.routes=[r for r in app.router.routes if not(getattr(r,'path',None)=='/' and hasattr(r,'methods') and 'GET' in getattr(r,'methods',set()))] app.routes[:]=[r for r in app.routes if not isinstance(r, Mount)] app.router.routes=[r for r in app.router.routes if not isinstance(r, Mount)] def _clean(s): return re.sub(r"\s+"," ",html_lib.unescape(str(s or""))).strip() + +# Cache for match details (5 min TTL) _match_cache = {} +# === FAST BONGDA PROXY ENDPOINT === def _get_match_detail(event_id, slug=None): headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "text/html", "Referer": "https://bongda.com.vn/"} - url = f"https://bongda.com.vn/tran-dau/{event_id}/centre/{slug}" if slug else f"https://bongda.com.vn/tran-dau/{event_id}" + if slug: + url = f"https://bongda.com.vn/tran-dau/{event_id}/centre/{slug}" + else: + url = f"https://bongda.com.vn/tran-dau/{event_id}" resp = req.get(url, headers=headers, timeout=15, allow_redirects=True) - if resp.status_code != 200: return None + if resp.status_code != 200: + return None soup = BeautifulSoup(resp.text, 'html.parser') result = {"event_id": event_id, "found": False, "sections": []} info = {} @@ -68,7 +71,9 @@ def _get_match_detail(event_id, slug=None): lb = sc.select_one('.label') if lb: info['status_label'] = _clean(lb.get_text()) if info.get('home_team') and info.get('away_team'): - result['info'] = info; result['found'] = True; result['sections'].append('info') + result['info'] = info + result['found'] = True + result['sections'].append('info') events = [] for ev in soup.select('.events .period .event'): ev_cls = ' '.join(ev.get('class', [])) @@ -85,10 +90,15 @@ def _get_match_detail(event_id, slug=None): if players_el: pl_text = _clean(players_el.get_text(' ', strip=True)) m = re.match(r"(\d+)'(.*)", pl_text) - if m: ev_data['time'] = f"{m.group(1)}'"; ev_data['players'] = m.group(2) - else: ev_data['players'] = pl_text + if m: + ev_data['time'] = f"{m.group(1)}'" + ev_data['players'] = m.group(2) + else: + ev_data['players'] = pl_text events.append(ev_data) - if events: result['events'] = events; result['sections'].append('events') + if events: + result['events'] = events + result['sections'].append('events') pred = soup.select_one('.prediction-card') if pred: team_info = pred.select_one('.team-info') @@ -114,7 +124,9 @@ def _get_match_detail(event_id, slug=None): se = item.select_one('.score, .result') if he_item or ae_item: recent.append({'date': _clean(de.get_text()) if de else '', 'league': _clean(le.get_text()) if le else '', 'home': _clean(he_item.get_text()) if he_item else '', 'away': _clean(ae_item.get_text()) if ae_item else '', 'score': _clean(se.get_text()) if se else 'vs'}) - if recent: result['recent_matches'] = recent; result['sections'].append('recent') + if recent: + result['recent_matches'] = recent + result['sections'].append('recent') try: api_h = {"User-Agent": "Mozilla/5.0", "Accept": "application/json", "X-Requested-With": "XMLHttpRequest", "Referer": "https://bongda.com.vn/"} ar = req.get(f"https://bongda.com.vn/api/fixtures/h2h-stats?event_id={event_id}", headers=api_h, timeout=10) @@ -128,22 +140,31 @@ def _get_match_detail(event_id, slug=None): if len(cells) >= 3: lb = _clean(cells[0].get_text()) if lb: ast[lb] = {'home': _clean(cells[1].get_text()), 'away': _clean(cells[2].get_text())} - if ast: result['h2h_stats_parsed'] = ast; result['sections'].append('h2h_stats') + if ast: + result['h2h_stats_parsed'] = ast + result['sections'].append('h2h_stats') except: pass return result @app.get('/api/proxy/bongda') def proxy_bongda(event_id: int = Query(default=None), slug: str = Query(default=None)): - if event_id is None: return JSONResponse({'error': 'event_id required'}, status_code=400) - cache_key = f"{event_id}_{slug}"; now = time.time() + if event_id is None: + return JSONResponse({'error': 'event_id required'}, status_code=400) + cache_key = f"{event_id}_{slug}" + now = time.time() cached = _match_cache.get(cache_key) - if cached and now - cached.get('_ts', 0) < 300: return JSONResponse(cached) + if cached and now - cached.get('_ts', 0) < 300: + return JSONResponse(cached) try: result = _get_match_detail(event_id, slug) - if result: result['_ts'] = now; _match_cache[cache_key] = result; return JSONResponse(result) + if result: + result['_ts'] = now + _match_cache[cache_key] = result + return JSONResponse(result) except Exception as e: err = {"event_id": event_id, "found": False, "error": str(e), "_ts": now} - _match_cache[cache_key] = err; return JSONResponse(err) + _match_cache[cache_key] = err + return JSONResponse(err) return JSONResponse({"event_id": event_id, "found": False}) @app.get('/api/match/{event_id}/detail') @@ -151,10 +172,13 @@ def api_match_detail(event_id: int, url: str = Query(default=None)): slug = None if url: m = re.match(r'.+/tran-dau/\d+/(?:centre|preview)/(.+)', url) - if m: slug = m.group(1) - cache_key = f"{event_id}_{slug or ''}"; now = time.time() + if m: + slug = m.group(1) + cache_key = f"{event_id}_{slug or ''}" + now = time.time() cached = _match_cache.get(cache_key) - if cached and now - cached.get('_ts', 0) < 300: return JSONResponse(cached) + if cached and now - cached.get('_ts', 0) < 300: + return JSONResponse(cached) try: if not slug: try: @@ -164,13 +188,20 @@ def api_match_detail(event_id: int, url: str = Query(default=None)): for a in home_soup.select(f'a[href*="/tran-dau/{event_id}/"]'): href = a.get('href', '') m = re.match(r'/tran-dau/\d+/(?:centre|preview)/(.+)', href) - if m: slug = m.group(1); cache_key = f"{event_id}_{slug}"; break + if m: + slug = m.group(1) + cache_key = f"{event_id}_{slug}" + break except: pass result = _get_match_detail(event_id, slug) - if result: result['_ts'] = now; _match_cache[cache_key] = result; return JSONResponse(result) + if result: + result['_ts'] = now + _match_cache[cache_key] = result + return JSONResponse(result) except Exception as e: err = {"event_id": event_id, "found": False, "error": str(e), "_ts": now} - _match_cache[cache_key] = err; return JSONResponse(err) + _match_cache[cache_key] = err + return JSONResponse(err) return JSONResponse({"event_id": event_id, "found": False}) _STOP=set('và của các những một được trong với cho tại sau trước khi không người việt nam hôm nay mới nhất nóng tin tức cập nhật theo từ đến là có thì này đã để'.split()) @@ -193,6 +224,7 @@ def _s_vnexpress(topic,limit=8): if _has_kw(topic,t):items.append({'title':t,'url':a['href'],'via':'VnExpress'}) except:pass return items + def _s_dantri(topic,limit=8): items=[] try: @@ -205,6 +237,7 @@ def _s_dantri(topic,limit=8): if len(items)>=limit:break except:pass return items + def _s_vietnamnet(topic,limit=6): items=[] try: @@ -217,6 +250,7 @@ def _s_vietnamnet(topic,limit=6): if len(items)>=limit:break except:pass return items + def _s_bongda(topic,limit=5): items=[] try: @@ -229,6 +263,7 @@ def _s_bongda(topic,limit=5): if len(items)>=limit:break except:pass return items + def _s_genk(topic,limit=5): items=[] try: @@ -241,6 +276,7 @@ def _s_genk(topic,limit=5): if len(items)>=limit:break except:pass return items + def _s_thanhnien(topic,limit=6): items=[] try: @@ -253,6 +289,7 @@ def _s_thanhnien(topic,limit=6): if len(items)>=limit:break except:pass return items + def _s_tuoitre(topic,limit=6): items=[] try: @@ -265,6 +302,7 @@ def _s_tuoitre(topic,limit=6): if len(items)>=limit:break except:pass return items + def _s_thethaovanhoa(topic,limit=5): items=[] try: @@ -294,26 +332,37 @@ def _search_all(topic,limit=36): for _path in ['/api/article', '/api/hot_topics', '/api/categories', '/api/storage_status']: app.router.routes=[r for r in app.router.routes if not(getattr(r,'path',None)==_path and 'GET' in getattr(r,'methods',set()))] -_article_cache = {}; _article_cache_ttl = 1800 -_art_session = None; _art_lock = threading.Lock() +_article_cache = {} +_article_cache_ttl = 1800 + +_art_session = None +_art_lock = threading.Lock() def _get_art_session(): global _art_session if _art_session is None: with _art_lock: if _art_session is None: _art_session = req.Session() - _art_session.headers.update({"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","Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8","Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"}) + _art_session.headers.update({ + "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", + "Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + }) return _art_session def _scrape_article_fast(url): from urllib.parse import urlparse domain = urlparse(url).netloc sess = _get_art_session() - uas = [{"User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"},{"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"}] + uas = [ + {"User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"}, + {"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"}, + ] for ua in uas: try: r = sess.get(url, headers=ua, timeout=6, allow_redirects=True) - if not r or r.status_code != 200: continue + if not r or r.status_code != 200: + continue r.encoding = 'utf-8' soup = BeautifulSoup(r.text, 'lxml') for tag in soup.find_all(['script','style','nav','footer','aside','form','noscript','iframe','.ads','.ad','.banner-ads','.fb-comments','.fb-root','.social-share','.related-news','.tag','.breadcrumb']): @@ -330,7 +379,15 @@ def _scrape_article_fast(url): h1 = soup.find('h1') if not title and h1: title = h1.get_text(strip=True)[:200] body = [] - selectors = ['.fck_detail','.sidebar-1','.singular-content','.dt__content','.article-content','.content-detail','#divNewsContent','.content-detail','.main-content-detail','.box-content','.knc-content','.article-body','.detail-body','.article-detail','.detail-content','article','main','.cms-body','.article__body','.post-content','.entry-content','#content','.article-text','.story-body'] + selectors = [ + '.fck_detail', '.sidebar-1', + '.singular-content', '.dt__content', '.article-content', '.content-detail', '#divNewsContent', + '.content-detail', '.main-content-detail', '.box-content', + '.knc-content', '.article-body', '.detail-body', + '.article-detail', '.detail-content', + 'article', 'main', '.cms-body', '.article__body', '.post-content', + '.entry-content', '#content', '.article-text', '.story-body', + ] for sel in selectors: el = soup.select_one(sel) if el and len(el.find_all('p')) >= 2: @@ -338,10 +395,12 @@ def _scrape_article_fast(url): for child in el.find_all(['p','h2','h3','figure','img'], recursive=True): if child.name == 'p': t = child.get_text(strip=True) - if t and len(t) > 15: body.append({'type': 'p', 'text': t}) + if t and len(t) > 15: + body.append({'type': 'p', 'text': t}) elif child.name in ('h2','h3'): t = child.get_text(strip=True) - if t: body.append({'type': 'heading', 'text': t}) + if t: + body.append({'type': 'heading', 'text': t}) elif child.name in ('figure','img'): im = child if child.name == 'img' else child.find('img') if im: @@ -356,32 +415,45 @@ def _scrape_article_fast(url): ct = cap.get_text(strip=True) if ct: body.append({'type': 'p', 'text': ct}) if len(body) >= 2: - return {'title': _clean(title), 'summary': _clean(summary), 'og_image': og_img, 'body': body[:50], 'source': domain, 'url': url} + return {'title': _clean(title), 'summary': _clean(summary), 'og_image': og_img, + 'body': body[:50], 'source': domain, 'url': url} if title and (summary or og_img): - fb = [] - if og_img: fb.append({'type': 'img', 'src': og_img}) - if summary: fb.append({'type': 'p', 'text': summary}) - if fb: return {'title': _clean(title), 'summary': _clean(summary), 'og_image': og_img, 'body': fb, 'source': domain, 'url': url, 'fallback': True} + fallback = [] + if og_img: fallback.append({'type': 'img', 'src': og_img}) + if summary: fallback.append({'type': 'p', 'text': summary}) + if fallback: + return {'title': _clean(title), 'summary': _clean(summary), 'og_image': og_img, + 'body': fallback, 'source': domain, 'url': url, 'fallback': True} if title: - return {'title': _clean(title), 'summary': '', 'og_image': '', 'body': [{'type': 'p', 'text': 'Nội dung đang được tải...'}], 'source': domain, 'url': url, 'fallback': True} + return {'title': _clean(title), 'summary': '', 'og_image': '', + 'body': [{'type': 'p', 'text': 'Nội dung đang được tải...'}], + 'source': domain, 'url': url, 'fallback': True} break - except Exception: continue + except Exception: + continue return None @app.get('/api/article') def api_article_v2(url: str = Query(...)): + from urllib.parse import unquote safe_url = unquote(url) try: now = time.time() cached = _article_cache.get(safe_url) if cached and now - cached['t'] < _article_cache_ttl: - resp = JSONResponse(cached['d']); resp.headers["Cache-Control"] = "public, max-age=1800"; return resp + resp = JSONResponse(cached['d']) + resp.headers["Cache-Control"] = "public, max-age=1800" + return resp data = _scrape_article_fast(safe_url) if data and data.get('body'): _article_cache[safe_url] = {'d': data, 't': now} - resp = JSONResponse(data); resp.headers["Cache-Control"] = "public, max-age=1800"; return resp + resp = JSONResponse(data) + resp.headers["Cache-Control"] = "public, max-age=1800" + return resp result = {'error': 'Không đọc được', 'url': safe_url} - resp = JSONResponse(result); resp.headers["Cache-Control"] = "public, max-age=60"; return resp + resp = JSONResponse(result) + resp.headers["Cache-Control"] = "public, max-age=60" + return resp except Exception as e: return JSONResponse({'error': f'Server error: {str(e)[:100]}', 'url': safe_url}, status_code=200) @@ -420,32 +492,54 @@ def api_hot_topics(): resp = JSONResponse({'topics':_get_hot_topics()}) resp.headers["Cache-Control"] = "public, max-age=120" return resp - @app.get('/') async def serve_index(): p=os.path.join(STATIC_DIR,'index_v2.html') - if os.path.exists(p): return FileResponse(p,media_type='text/html') + if os.path.exists(p):return FileResponse(p,media_type='text/html') return HTMLResponse('

VNEWS

') - @app.get('/api/hashtag/sources') def _ht(topic:str=Query(...),page:int=Query(default=0)): items=_search_all(topic,36);per_page=8;start=page*per_page;end=start+per_page return JSONResponse({'sources':items[start:end],'topic':topic,'page':page,'has_more':end{_clean(safe_title)}''' + + # Build canonical URL preserving original query format if url was provided + if safe_url and safe_url != '/': + canonical_url = f"{SPACE}/s?url={quote(safe_url)}&title={quote(safe_title[:100])}" + else: + canonical_url = f"{SPACE}/s?post_id={post.get('id') or ''}" + + h = f''' + + + + +{_clean(safe_title)} + + + + + + + +''' for s in slides: img_src = s.get('image', '') if img_src and ('cdnphoto.dantri' in img_src or 'refooty' in img_src or 'vnexpress' in img_src or 'vcdn' in img_src): @@ -458,101 +552,225 @@ def _render_slides_page(post, safe_title, safe_img, safe_url): def _render_video_page(post, safe_title, safe_img, safe_url): video_url = post.get('video', '') + # Use text for description if available description = _clean((post.get('text') or '')[:200]) or "Tin tức tóm tắt, AI rewrite, World Cup 2026" - if safe_url and safe_url != '/': canonical_url = f"{SPACE}/s?url={quote(safe_url)}&title={quote(safe_title[:100])}" - else: canonical_url = f"{SPACE}/s?post_id={post.get('id') or ''}" - h = f'''{_clean(safe_title)}
{_clean(safe_title)}
''' + + # Build canonical URL preserving original query format if url was provided + if safe_url and safe_url != '/': + canonical_url = f"{SPACE}/s?url={quote(safe_url)}&title={quote(safe_title[:100])}" + else: + canonical_url = f"{SPACE}/s?post_id={post.get('id') or ''}" + + h = f''' + + + + +{_clean(safe_title)} + + + + + + + + + + +
+ +
{_clean(safe_title)}
+
+''' return HTMLResponse(h) @app.get('/s/{slug}') async def _sh_slug(slug: str, request: Request, url: str = '', title: str = '', img: str = ''): + """SEO-friendly share endpoint with slug in URL path. + Shows slide content when slug matches a wall post ID, otherwise redirects. + """ safe_title = _clean(title) if title else 'VNEWS - Tin tức' - safe_img = _clean(img) if img else ''; safe_url = _clean(url) if url else '/' + safe_img = _clean(img) if img else '' + safe_url = _clean(url) if url else '/' + + # Try to find post by slug (post ID) post = None try: - if slug and len(slug) > 5: - for p in _load_wall_posts(): + if slug and len(slug) > 5: # Likely a post ID + posts = _load_wall_posts() + for p in posts: if p.get('id') == slug: - post = p; safe_title = p.get('title', safe_title) or safe_title; safe_img = p.get('img', safe_img) or safe_img; safe_url = p.get('url', safe_url) or safe_url; break - except: pass - if post and post.get('slides'): return _render_slides_page(post, safe_title, safe_img, safe_url) - if post and post.get('video'): return _render_video_page(post, safe_title, safe_img, safe_url) - return HTMLResponse(f'''{_clean(safe_title)}''') + post = p + safe_title = p.get('title', safe_title) or safe_title + safe_img = p.get('img', safe_img) or safe_img + safe_url = p.get('url', safe_url) or safe_url + break + except: + pass + + if post and post.get('slides'): + return _render_slides_page(post, safe_title, safe_img, safe_url) + + if post and post.get('video'): + return _render_video_page(post, safe_title, safe_img, safe_url) + + # Otherwise redirect + return HTMLResponse(f''' + + + + +{_clean(safe_title)} + + + + + + +''') @app.get('/s') async def _sh(url:str='',title:str='',img:str='',post_id:str=''): safe_title = _clean(title) if title else 'VNEWS - Tin tức' - safe_img = _clean(img) if img else ''; safe_url = _clean(url) if url else '/' + safe_img = _clean(img) if img else '' + safe_url = _clean(url) if url else '/' + + # Try to find wall post by post_id or URL (prioritize posts with slides/video) post = None try: posts = _load_wall_posts() if post_id: for p in posts: - if p.get('id') == post_id: post = p; safe_title = p.get('title', safe_title) or safe_title; safe_img = p.get('img', safe_img) or safe_img; safe_url = p.get('url', safe_url) or safe_url; break + if p.get('id') == post_id: + post = p + safe_title = p.get('title', safe_title) or safe_title + safe_img = p.get('img', safe_img) or safe_img + safe_url = p.get('url', safe_url) or safe_url + break elif url: + # Find matching URL - prioritize posts with slides or video for p in posts: - if p.get('url') == url and p.get('slides'): post = p; safe_title = p.get('title', safe_title) or safe_title; safe_img = p.get('img', safe_img) or safe_img; safe_url = p.get('url', safe_url) or safe_url; break + if p.get('url') == url and p.get('slides'): + post = p + safe_title = p.get('title', safe_title) or safe_title + safe_img = p.get('img', safe_img) or safe_img + safe_url = p.get('url', safe_url) or safe_url + break if not post: + # Fallback: find any matching URL for p in posts: - if p.get('url') == url: post = p; safe_title = p.get('title', safe_title) or safe_title; safe_img = p.get('img', safe_img) or safe_img; safe_url = p.get('url', safe_url) or safe_url; break - except: pass - if post and post.get('slides'): return _render_slides_page(post, safe_title, safe_img, safe_url) - if post and post.get('video'): return _render_video_page(post, safe_title, safe_img, safe_url) - return HTMLResponse(f'''{safe_title}''') + if p.get('url') == url: + post = p + safe_title = p.get('title', safe_title) or safe_title + safe_img = p.get('img', safe_img) or safe_img + safe_url = p.get('url', safe_url) or safe_url + break + except: + pass + + if post and post.get('slides'): + return _render_slides_page(post, safe_title, safe_img, safe_url) + + if post and post.get('video'): + return _render_video_page(post, safe_title, safe_img, safe_url) + + # Fallback: redirect to original URL + return HTMLResponse(f''' + + + + +{safe_title} + + + + + + +''') from wc2026_scraper import scrape_summary,scrape_fixtures,scrape_standings,scrape_stats,scrape_wc_news,scrape_road_to_wc,get_wc2026_all,scrape_history,scrape_h2h,scrape_lineups,scrape_match_detail -_xlb_cache = {}; _xlb_lock = threading.Lock() +_xlb_cache = {} +_xlb_lock = threading.Lock() + def _xlb_scrape(path): url = f"https://xemlaibongda.top/{path}" r = req.get(url, headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}, timeout=15, allow_redirects=True) - if r.status_code != 200: return [] + if r.status_code != 200: + return [] soup = BeautifulSoup(r.text, 'lxml') - vids = []; seen = set() + vids = [] + seen = set() for a in soup.select('a[href*="/video/"]'): href = a.get('href', '') - if not href or href in seen: continue + if not href or href in seen: + continue seen.add(href) - if not href.startswith('http'): href = 'https://xemlaibongda.top' + href + if not href.startswith('http'): + href = 'https://xemlaibongda.top' + href img = a.select_one('img') p = a.parent for _ in range(4): - if img: break - if p: img = p.select_one('img'); p = p.parent + if img: + break + if p: + img = p.select_one('img') + p = p.parent img_src = '' if img: img_src = img.get('data-src','') or img.get('src','') or img.get('data-lazy','') or img.get('data-original','') - if img_src.startswith('//'): img_src = 'https:' + img_src - elif img_src.startswith('/'): img_src = 'https://xemlaibongda.top' + img_src + if img_src.startswith('//'): + img_src = 'https:' + img_src + elif img_src.startswith('/'): + img_src = 'https://xemlaibongda.top' + img_src title = '' for sel in ['.title', 'h3', 'h2', '.name', '.post-title', '.entry-title', '.video-title']: t = a.select_one(sel) - if t: title = _clean(t.get_text()); break - if not title: title = _clean(a.get('title','')) + if t: + title = _clean(t.get_text()) + break + if not title: + title = _clean(a.get('title','')) if not title: img_alt = a.select_one('img') - if img_alt: title = _clean(img_alt.get('alt','')) + if img_alt: + title = _clean(img_alt.get('alt','')) if not title: parent = a.parent if parent: pt = _clean(parent.get_text(' ',strip=True)) - if 5 < len(pt) < 120: title = pt - if not title or len(title) < 3: continue + if 5 < len(pt) < 120: + title = pt + if not title or len(title) < 3: + continue vids.append({"link": href, "img": img_src, "title": title}) - if len(vids) >= 30: break + if len(vids) >= 30: + break return vids @app.get('/api/proxy/xlb') def proxy_xlb(path: str = Query(default="")): - now = time.time(); cache_key = f"xlb:{path}" + now = time.time() + cache_key = f"xlb:{path}" with _xlb_lock: cached = _xlb_cache.get(cache_key) - if cached and now - cached['t'] < 120: return JSONResponse(cached['d']) + if cached and now - cached['t'] < 120: + return JSONResponse(cached['d']) try: - vids = _xlb_scrape(path); result = {"videos": vids, "count": len(vids)} - with _xlb_lock: _xlb_cache[cache_key] = {'t': now, 'd': result} + vids = _xlb_scrape(path) + result = {"videos": vids, "count": len(vids)} + with _xlb_lock: + _xlb_cache[cache_key] = {'t': now, 'd': result} return JSONResponse(result) - except Exception as e: return JSONResponse({"videos": [], "count": 0, "error": str(e)}, status_code=500) + except Exception as e: + return JSONResponse({"videos": [], "count": 0, "error": str(e)}, status_code=500) @app.get('/api/wc2026') def _w():return JSONResponse(get_wc2026_all()) @@ -593,88 +811,6 @@ def _sj(p,d): try:open(p+'.tmp','w',encoding='utf-8').write(json.dumps(d,ensure_ascii=False));os.replace(p+'.tmp',p) except:pass -# ===== REWRITE ENDPOINTS (extractive summary, no AI needed) ===== -_rewrite_cache = {} -_rewrite_cache_lock = threading.Lock() - -@app.post('/api/rewrite_slide') -async def api_rewrite_slide(request: Request): - try: body = await request.json() - except: body = {} - url = body.get('url','') - title = body.get('title','') or 'Bài viết' - if not url: return JSONResponse({"error":"No URL"}, status_code=400) - now = time.time() - safe_url = unquote(url) - with _rewrite_cache_lock: - cached = _rewrite_cache.get(safe_url) - if cached and now - cached['t'] < 3600: return JSONResponse(cached['d']) - try: - data = _scrape_article_fast(safe_url) - if not data or not data.get('body'): - return JSONResponse({"error":"Không đọc được nội dung từ URL này"}) - title = data.get('title', '') or title - summary = data.get('summary', '') - body_parts = [b for b in data.get('body', []) if b.get('type') == 'p'] - images = [b.get('src') for b in data.get('body', []) if b.get('type') == 'img'] - heading = data.get('title', '') or title - summary_text = summary or (body_parts[0].get('text','') if body_parts else '') - # Build slides - slides = [{"index": 1, "image": images[0] if images else data.get('og_image',''), "text": f"**{heading}**\n\n{summary_text}"}] if summary_text else [] - key_points = [] - for i, p in enumerate(body_parts[:5]): - t = p.get('text','') - if len(t) > 30: - from_idx = len(key_points) - key_points.append({"index": from_idx+2, "image": images[from_idx+1] if from_idx+1 < len(images) else (images[-1] if images else ''), "text": t}) - slides.extend(key_points) - # Save to wall - post_id = str(uuid.uuid4())[:12] - post = {"id": post_id, "title": title[:200], "text": summary_text[:2000] or f"Tóm tắt: {title[:200]}", "source": "rewrite", "url": safe_url, "slides": slides[:10], "img": images[0] if images else data.get('og_image',''), "images": images[:5], "video": None, "created": int(time.time()), "created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime())} - posts = _load_wall_posts() - if not isinstance(posts, list): posts = [] - posts.insert(0, post) - posts = posts[:200] - _save_wall_posts(posts) - result = {"post": post, "slides": slides[:10], "ok": True} - with _rewrite_cache_lock: _rewrite_cache[safe_url] = {'d': result, 't': now} - return JSONResponse(result) - except Exception as e: - return JSONResponse({"error": str(e)[:200]}, status_code=200) - -@app.post('/api/rewrite_share') -async def api_rewrite_share(request: Request): - try: body = await request.json() - except: body = {} - url = body.get('url','') - title = body.get('title','') or '' - if not url: return JSONResponse({"error":"No URL"}, status_code=400) - try: - data = _scrape_article_fast(unquote(url)) - if not data or not data.get('body'): - return JSONResponse({"error":"Không đọc được nội dung"}) - title = data.get('title', '') or title or 'Bài viết' - summary = data.get('summary', '') - body_parts = [b.get('text','') for b in data.get('body', []) if b.get('type') == 'p'] - images = [b.get('src') for b in data.get('body', []) if b.get('type') == 'img'] - img = data.get('og_image', '') or (images[0] if images else '') - text = (summary + '\n\n' + '\n'.join(body_parts[:5]))[:2000] - post_id = str(uuid.uuid4())[:12] - post = {"id": post_id, "title": title[:200], "text": text, "source": "rewrite", "url": url, "img": img, "images": images[:5], "video": None, "created": int(time.time()), "created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime())} - posts = _load_wall_posts() - if not isinstance(posts, list): posts = [] - posts.insert(0, post) - posts = posts[:200] - _save_wall_posts(posts) - return JSONResponse({"post": post, "ok": True}) - except Exception as e: - return JSONResponse({"error": str(e)[:200]}, status_code=200) - -@app.post('/api/url_wall') -async def api_url_wall(request: Request): - return await api_rewrite_share(request) - -# ===== INTERACTIONS & COMMENTS ===== @app.post('/api/v2/interact') async def _int(request:Request): b=await request.json();v=str(b.get('id','')).strip();t=str(b.get('type','')).strip() @@ -698,24 +834,29 @@ async def _pc(request:Request): with _il:idb=_lj(IF);idb.setdefault(v,{'views':0,'likes':0,'comments':0});idb[v]['comments']=len(cms);_sj(IF,idb) return JSONResponse({'comments':cms}) -# ===== WALL ENDPOINTS ===== def _load_wall_posts(): - with _wl_lock: return _lj(WALL_FILE) + with _wl_lock: + return _lj(WALL_FILE) + def _save_wall_posts(posts): - with _wl_lock: _sj(WALL_FILE, posts) + with _wl_lock: + _sj(WALL_FILE, posts) @app.get('/api/wall') def api_wall(): posts = _load_wall_posts() - if not posts: return JSONResponse({"posts": []}) + if not posts: + return JSONResponse({"posts": []}) return JSONResponse({"posts": posts}) @app.post('/api/wall') async def api_wall_post(request: Request): content_type = request.headers.get('content-type', '') if 'multipart/form-data' in content_type: - try: form = await request.form() - except: return JSONResponse({"error": "Form parse error"}, status_code=400) + try: + form = await request.form() + except Exception as e: + return JSONResponse({"error": f"Form parse error: {str(e)}"}, status_code=400) title = form.get('title', 'Video mới') or 'Video mới' text = form.get('text', '') or '' source = form.get('source', 'vtv_recorder') or 'vtv_recorder' @@ -724,52 +865,1302 @@ async def api_wall_post(request: Request): video_url = None if video_file and hasattr(video_file, 'filename') and video_file.filename: fname = video_file.filename.lower() - ext = '.mp4' if fname.endswith('.mp4') else '.webm' + if fname.endswith('.mp4'): + ext = '.mp4' + elif fname.endswith('.webm'): + ext = '.webm' + else: + ext = '.webm' video_filename = f"wall_{post_id}{ext}" video_path = os.path.join(WALL_VIDEO_DIR, video_filename) try: content = await video_file.read() - if not content: return JSONResponse({"error": "Empty video file"}, status_code=400) - with open(video_path, 'wb') as f: f.write(content) + if not content: + return JSONResponse({"error": "Empty video file"}, status_code=400) + with open(video_path, 'wb') as f: + f.write(content) file_size_mb = len(content) / 1024 / 1024 - if file_size_mb > 50: os.remove(video_path); return JSONResponse({"error": f"Video quá lớn ({file_size_mb:.1f}MB). Tối đa 50MB."}, status_code=400) + if file_size_mb > 50: + os.remove(video_path) + return JSONResponse({"error": f"Video quá lớn ({file_size_mb:.1f}MB). Tối đa 50MB."}, status_code=400) video_url = f"/api/wall/video/{video_filename}" - except Exception as e: return JSONResponse({"error": f"Lỗi lưu video: {str(e)}"}, status_code=500) - post = {"id": post_id, "title": title[:200], "text": text[:2000], "source": source, "video": video_url, "img": None, "images": [], "created": int(time.time()), "created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime())} + except Exception as e: + return JSONResponse({"error": f"Lỗi lưu video: {str(e)}"}, status_code=500) + post = { + "id": post_id, + "title": title[:200], + "text": text[:2000], + "source": source, + "video": video_url, + "img": None, + "images": [], + "created": int(time.time()), + "created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()), + } posts = _load_wall_posts() - if not isinstance(posts, list): posts = [] - posts.insert(0, post); posts = posts[:200]; _save_wall_posts(posts) + if not isinstance(posts, list): + posts = [] + posts.insert(0, post) + posts = posts[:200] + _save_wall_posts(posts) return JSONResponse({"post": post, "ok": True}) - try: body = await request.json() - except: body = {} + try: + body = await request.json() + except: + body = {} title = body.get('title', 'Bài mới') or 'Bài mới' text = body.get('text', '') or '' img = body.get('img', None) source = body.get('source', 'user') or 'user' post_id = str(uuid.uuid4())[:12] - post = {"id": post_id, "title": title[:200], "text": text[:2000], "source": source, "video": None, "img": img, "images": [], "created": int(time.time()), "created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime())} + post = { + "id": post_id, + "title": title[:200], + "text": text[:2000], + "source": source, + "video": None, + "img": img, + "images": [], + "created": int(time.time()), + "created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()), + } posts = _load_wall_posts() - if not isinstance(posts, list): posts = [] - posts.insert(0, post); posts = posts[:200]; _save_wall_posts(posts) + if not isinstance(posts, list): + posts = [] + posts.insert(0, post) + posts = posts[:200] + _save_wall_posts(posts) return JSONResponse({"post": post, "ok": True}) @app.get('/api/wall/video/{filename}') def api_wall_video(filename: str): - if '..' in filename or '/' in filename: return Response(status_code=403) + if '..' in filename or '/' in filename: + return Response(status_code=403) video_path = os.path.join(WALL_VIDEO_DIR, filename) - if not os.path.exists(video_path): return Response(status_code=404) + if not os.path.exists(video_path): + return Response(status_code=404) ext = os.path.splitext(filename)[1].lower() - return FileResponse(video_path, media_type='video/mp4' if ext == '.mp4' else 'video/webm') + media_type = 'video/mp4' if ext == '.mp4' else 'video/webm' + return FileResponse(video_path, media_type=media_type) @app.delete('/api/wall/{post_id}') def api_wall_delete(post_id: str): posts = _load_wall_posts() - if not isinstance(posts, list): return JSONResponse({"error": "No posts"}, status_code=404) + if not isinstance(posts, list): + return JSONResponse({"error": "No posts"}, status_code=404) for i, p in enumerate(posts): if p.get('id') == post_id: if p.get('video'): video_name = p['video'].split('/')[-1] video_path = os.path.join(WALL_VIDEO_DIR, video_name) - if os.path.exists(video_path): os.remove(video_path) - posts.pop(i); _save_wall_posts(posts); return JSONResponse({"ok": True}) - return JSONResponse({"error": "Post not found"}, status_code=404) \ No newline at end of file + if os.path.exists(video_path): + os.remove(video_path) + posts.pop(i) + _save_wall_posts(posts) + return JSONResponse({"ok": True}) + return JSONResponse({"error": "Post not found"}, status_code=404) + +# ===== LANGUAGE & EMOTION DETECTION ===== +import random as _random2 +from urllib.parse import quote as _quote2 + +_UA_RW = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept-Language': 'vi-VN,vi;q=0.9'} + +# Unique character markers for language detection +_UNIQUE_CHARS = { + 'vietnamese': set('đăâêôơưàảãạáằẳẵặắầẩẫậấèẻẽẹéềễểệếìỉĩịíòỏõọóồổỗộốờởỡợớùủũụúừửữựứỳỷỹỵý'), + 'spanish': set('ñáéíóúü¿¡'), + 'portuguese': set('ãõçáéíóúâêôà'), +} + +_STOPWORDS = { + 'english': {'the', 'is', 'at', 'which', 'on', 'a', 'an', 'and', 'or', 'but', 'in', 'with', 'to', 'for', 'of', 'not', 'no', 'can', 'had', 'have', 'has', 'was', 'were', 'are', 'be', 'been', 'this', 'that', 'it', 'he', 'she', 'they', 'his', 'her', 'my', 'your', 'our', 'we', 'you', 'i'}, + 'vietnamese': {'là', 'của', 'và', 'có', 'được', 'cho', 'không', 'với', 'này', 'đó', 'từ', 'trong', 'đã', 'sẽ', 'một', 'các', 'những', 'về', 'tại', 'người', 'năm', 'đến', 'ra', 'lại', 'như', 'khi', 'để', 'rất', 'cũng', 'mà', 'nếu', 'sau', 'trên', 'theo', 'vì', 'do', 'nên', 'thì', 'mình', 'tôi', 'bạn', 'anh', 'chị', 'em'}, + 'portuguese': {'de', 'um', 'que', 'e', 'do', 'da', 'em', 'para', 'com', 'não', 'uma', 'os', 'no', 'se', 'na', 'por', 'mais', 'as', 'dos', 'como', 'mas', 'ao', 'ele', 'das', 'tem', 'seu', 'sua', 'ou', 'quando', 'muito', 'nos', 'já', 'eu', 'também', 'só', 'pelo', 'pela', 'até', 'isso', 'ela', 'entre', 'depois', 'sem', 'mesmo', 'aos', 'são', 'está', 'ter', 'ser', 'foi', 'era', 'há', 'estão', 'você', 'nós', 'eles', 'elas'}, + 'spanish': {'de', 'que', 'el', 'en', 'y', 'a', 'los', 'del', 'se', 'las', 'por', 'un', 'para', 'con', 'no', 'una', 'su', 'al', 'es', 'lo', 'como', 'más', 'pero', 'sus', 'le', 'ya', 'o', 'fue', 'este', 'ha', 'si', 'porque', 'esta', 'son', 'entre', 'está', 'cuando', 'muy', 'sin', 'sobre', 'ser', 'también', 'me', 'hasta', 'hay', 'donde', 'han', 'quien', 'están', 'desde', 'todo', 'nos', 'durante', 'todos', 'uno', 'les', 'ni', 'contra', 'otros', 'fueron', 'ese', 'eso', 'ante', 'ellos', 'yo', 'tú', 'él', 'ella', 'nosotros', 'usted', 'ustedes'}, +} + +def detect_language(text): + """Detect language from text content using stopword + character analysis.""" + if not text: + return 'vietnamese' + text_lower = text.lower() + text_chars = set(text_lower) + + # Strong signal: Vietnamese unique characters + vn_chars = len(text_chars & _UNIQUE_CHARS['vietnamese']) + if vn_chars >= 2: + return 'vietnamese' + + # Spanish unique chars (ñ, ¿, ¡) + es_chars = len(text_chars & _UNIQUE_CHARS['spanish']) + pt_chars = len(text_chars & _UNIQUE_CHARS['portuguese']) + + # Stopword scoring + words = set(re.findall(r'\b\w+\b', text_lower)) + scores = {} + for lang, stops in _STOPWORDS.items(): + scores[lang] = len(words & stops) / max(len(stops), 1) + + # Disambiguate Portuguese vs Spanish + pt_markers = {'não', 'pelo', 'pela', 'isso', 'há', 'estão', 'num', 'numa', 'tenho', 'posso', 'você', 'nós', 'eles', 'elas', 'também', 'muito', 'já', 'só', 'até', 'entre', 'depois', 'sem', 'mesmo', 'aos', 'serão'} + es_markers = {'pero', 'está', 'están', 'porque', 'también', 'hasta', 'donde', 'quien', 'fue', 'son', 'fueron', 'ese', 'eso', 'ante', 'ellos', 'ella', 'nosotros', 'usted', 'ustedes', 'tú', 'él', 'desde', 'todo', 'durante', 'todos', 'uno', 'les', 'ni', 'contra', 'otros', 'fueron'} + + pt_overlap = len(words & pt_markers) + es_overlap = len(words & es_markers) + + if scores.get('portuguese', 0) > 0 and pt_overlap > es_overlap: + return 'portuguese' + if scores.get('spanish', 0) > 0 and es_overlap > pt_overlap: + return 'spanish' + if scores.get('english', 0) > 0.15: + return 'english' + + best = max(scores, key=scores.get) + return best if scores[best] > 0.05 else 'vietnamese' + +# Emotion keyword-based detection +_EMOTION_KEYWORDS = { + 'happy': { + 'en': ['happy', 'joy', 'wonderful', 'great', 'amazing', 'fantastic', 'love', 'excellent', 'beautiful', 'glad', 'delighted', 'pleased', 'cheerful', 'celebrate', 'victory', 'win', 'success'], + 'pt': ['feliz', 'alegria', 'maravilhoso', 'ótimo', 'incrível', 'fantástico', 'amor', 'excelente', 'lindo', 'contente', 'encantado', 'vitória', 'sucesso'], + 'es': ['feliz', 'alegria', 'maravilloso', 'genial', 'increíble', 'fantástico', 'amor', 'excelente', 'hermoso', 'contento', 'encantado', 'victoria', 'éxito'], + 'vi': ['vui', 'hạnh phúc', 'tuyệt vời', 'tuyệt', 'ý nghĩa', 'đẹp', 'thích', 'yêu', 'vui vẻ', 'hân hoan', 'phấn khích', 'chiến thắng', 'thành công'], + }, + 'sad': { + 'en': ['sad', 'unhappy', 'terrible', 'awful', 'horrible', 'miserable', 'depressed', 'grief', 'sorrow', 'tragic', 'unfortunate', 'painful', 'death', 'die', 'kill'], + 'pt': ['triste', 'infeliz', 'terrível', 'horrível', 'miserável', 'deprimido', 'dor', 'trágico', 'infelizmente', 'penoso', 'morte', 'morrer'], + 'es': ['triste', 'infeliz', 'terrible', 'horrible', 'miserable', 'deprimido', 'dolor', 'trágico', 'desafortunado', 'penoso', 'muerte', 'morir'], + 'vi': ['buồn', 'không vui', 'tồi tệ', 'kinh khủng', 'đau khổ', 'đau buồn', 'bi thương', 'khốn nạn', 'đau đớn', 'thảm họa', 'chết', 'mất'], + }, + 'excited': { + 'en': ['excited', 'thrilling', 'amazing', 'wow', 'incredible', 'unbelievable', 'awesome', 'exhilarating', 'electrifying', 'breathtaking', 'breakthrough', 'record'], + 'pt': ['animado', 'emocionante', 'incrível', 'impressionante', 'sensacional', 'eletrizante', 'empolgante', 'recorde'], + 'es': ['emocionante', 'increíble', 'impresionante', 'sensacional', 'electrizante', 'emocionado', 'entusiasmado', 'récord'], + 'vi': ['hào hứng', 'phấn khích', 'thú vị', 'tuyệt cú mèo', 'đỉnh cao', 'ngoạn mục', 'sục sôi', 'kỷ lục', 'đột phá'], + }, + 'humorous': { + 'en': ['funny', 'hilarious', 'joke', 'laugh', 'comedy', 'humor', 'amusing', 'witty', 'sarcastic', 'ironic', 'ridiculous', 'absurd', 'lol', 'haha'], + 'pt': ['engraçado', 'hilário', 'piada', 'rir', 'comédia', 'humor', 'divertido', 'irônico', 'ridículo', 'absurdo', 'kkk'], + 'es': ['gracioso', 'hilarante', 'broma', 'risa', 'comedia', 'humor', 'divertido', 'irónico', 'ridículo', 'absurdo', 'jaja'], + 'vi': ['hài hước', 'buồn cười', 'đùa', 'cười', 'hài', 'vui nhộn', 'hóm hỉnh', 'mỉa mai', 'lố bịch', 'vô lý', 'haha'], + }, + 'serious': { + 'en': ['serious', 'critical', 'important', 'urgent', 'severe', 'grave', 'significant', 'crucial', 'vital', 'essential', 'alarming', 'concerning', 'crisis', 'war', 'conflict'], + 'pt': ['sério', 'crítico', 'importante', 'urgente', 'grave', 'significativo', 'crucial', 'vital', 'essencial', 'preocupante', 'crise', 'guerra', 'conflito'], + 'es': ['serio', 'crítico', 'importante', 'urgente', 'grave', 'significativo', 'crucial', 'vital', 'esencial', 'preocupante', 'crisis', 'guerra', 'conflicto'], + 'vi': ['nghiêm trọng', 'quan trọng', 'khẩn cấp', 'nghiêm túc', 'đáng kể', 'thiết yếu', 'cần thiết', 'báo động', 'lo ngại', 'khủng hoảng', 'chiến tranh', 'xung đột'], + }, +} + +def detect_emotion(text, language='vietnamese'): + """Detect emotion from text using keyword matching.""" + if not text: + return 'neutral' + text_lower = text.lower() + + scores = {} + for emotion, lang_keywords in _EMOTION_KEYWORDS.items(): + keywords = lang_keywords.get(language, lang_keywords.get('en', [])) + score = sum(1 for kw in keywords if kw in text_lower) + scores[emotion] = score + + if max(scores.values()) == 0: + return 'neutral' + + return max(scores, key=scores.get) + +def detect_language_and_emotion(title, text): + """Detect both language and emotion from article content.""" + combined = f"{title} {text}" + lang = detect_language(combined) + emotion = detect_emotion(combined, lang) + return lang, emotion + +# Voice selection based on language and emotion (using MultilingualNeural voices) +VOICE_BY_LANG_EMOTION = { + 'vietnamese': { + 'happy': ('vi-VN-HoaiMyNeural', 'vui'), + 'sad': ('vi-VN-NamMinhNeural', 'buồn'), + 'excited': ('vi-VN-HoaiMyNeural', 'hào hứng'), + 'humorous': ('vi-VN-HoaiMyNeural', 'vui'), + 'serious': ('vi-VN-NamMinhNeural', 'nghiêm túc'), + 'neutral': ('vi-VN-HoaiMyNeural', 'trung_tinh'), + }, + 'portuguese': { + 'happy': ('pt-BR-ThalitaMultilingualNeural', 'feliz'), + 'sad': ('pt-BR-ThalitaMultilingualNeural', 'triste'), + 'excited': ('pt-BR-ThalitaMultilingualNeural', 'animado'), + 'humorous': ('pt-BR-ThalitaMultilingualNeural', 'engraçado'), + 'serious': ('pt-BR-ThalitaMultilingualNeural', 'sério'), + 'neutral': ('pt-BR-ThalitaMultilingualNeural', 'neutro'), + }, + 'english': { + 'happy': ('en-US-AndrewMultilingualNeural', 'happy'), + 'sad': ('en-AU-WilliamMultilingualNeural', 'sad'), + 'excited': ('en-US-AndrewMultilingualNeural', 'excited'), + 'humorous': ('en-US-AndrewMultilingualNeural', 'funny'), + 'serious': ('en-AU-WilliamMultilingualNeural', 'serious'), + 'neutral': ('en-US-AndrewMultilingualNeural', 'neutral'), + }, + 'french': { + 'happy': ('fr-FR-VivienneMultilingualNeural', 'heureux'), + 'sad': ('fr-FR-RemyMultilingualNeural', 'triste'), + 'excited': ('fr-FR-VivienneMultilingualNeural', 'excité'), + 'humorous': ('fr-FR-VivienneMultilingualNeural', 'drôle'), + 'serious': ('fr-FR-RemyMultilingualNeural', 'sérieux'), + 'neutral': ('fr-FR-VivienneMultilingualNeural', 'neutre'), + }, + 'german': { + 'happy': ('de-DE-SeraphinaMultilingualNeural', 'glücklich'), + 'sad': ('de-DE-FlorianMultilingualNeural', 'traurig'), + 'excited': ('de-DE-SeraphinaMultilingualNeural', 'aufgeregt'), + 'humorous': ('de-DE-SeraphinaMultilingualNeural', 'lustig'), + 'serious': ('de-DE-FlorianMultilingualNeural', 'ernst'), + 'neutral': ('de-DE-SeraphinaMultilingualNeural', 'neutral'), + }, + 'korean': { + 'happy': ('ko-KR-HyunsuMultilingualNeural', '행복'), + 'sad': ('ko-KR-HyunsuMultilingualNeural', '슬픔'), + 'excited': ('ko-KR-HyunsuMultilingualNeural', '흥분'), + 'humorous': ('ko-KR-HyunsuMultilingualNeural', '유쾌'), + 'serious': ('ko-KR-HyunsuMultilingualNeural', '진지'), + 'neutral': ('ko-KR-HyunsuMultilingualNeural', '중립'), + }, + 'italian': { + 'happy': ('it-IT-GiuseppeMultilingualNeural', 'felice'), + 'sad': ('it-IT-GiuseppeMultilingualNeural', 'triste'), + 'excited': ('it-IT-GiuseppeMultilingualNeural', 'emozionato'), + 'humorous': ('it-IT-GiuseppeMultilingualNeural', 'divertente'), + 'serious': ('it-IT-GiuseppeMultilingualNeural', 'serio'), + 'neutral': ('it-IT-GiuseppeMultilingualNeural', 'neutro'), + }, +} + +# All valid voice IDs (new MultilingualNeural format) +VALID_VOICES = { + 'vi-VN-HoaiMyNeural', 'vi-VN-NamMinhNeural', + 'en-US-AndrewMultilingualNeural', 'en-AU-WilliamMultilingualNeural', + 'pt-BR-ThalitaMultilingualNeural', + 'fr-FR-VivienneMultilingualNeural', 'fr-FR-RemyMultilingualNeural', + 'de-DE-SeraphinaMultilingualNeural', 'de-DE-FlorianMultilingualNeural', + 'ko-KR-HyunsuMultilingualNeural', + 'it-IT-GiuseppeMultilingualNeural', +} + +def get_voice_for_content(title, text, preferred_voice=None): + """Get appropriate voice based on content language and emotion.""" + # Accept the new MultilingualNeural voices directly + if preferred_voice and preferred_voice in VALID_VOICES: + return preferred_voice + + # Also accept old shorthand voice IDs and map them to new format + old_voice_map = { + 'hoaimy': 'vi-VN-HoaiMyNeural', + 'namminh': 'vi-VN-NamMinhNeural', + 'andrew': 'en-US-AndrewMultilingualNeural', + 'jenny': 'en-US-AndrewMultilingualNeural', + 'thalita': 'pt-BR-ThalitaMultilingualNeural', + 'pt_thalita': 'pt-BR-ThalitaMultilingualNeural', + 'pt_francisco': 'pt-BR-ThalitaMultilingualNeural', + 'ela': 'en-US-AndrewMultilingualNeural', + 'es_carlos': 'en-US-AndrewMultilingualNeural', + 'denise': 'fr-FR-VivienneMultilingualNeural', + 'katja': 'de-DE-SeraphinaMultilingualNeural', + 'nanami': 'en-US-AndrewMultilingualNeural', + 'sunhee': 'ko-KR-HyunsuMultilingualNeural', + 'xiaochen': 'en-US-AndrewMultilingualNeural', + } + if preferred_voice and preferred_voice in old_voice_map: + return old_voice_map[preferred_voice] + + lang, emotion = detect_language_and_emotion(title, text) + lang_map = VOICE_BY_LANG_EMOTION.get(lang, VOICE_BY_LANG_EMOTION['vietnamese']) + voice, _ = lang_map.get(emotion, lang_map['neutral']) + return voice + + +def _is_relevant_image(img_url, title, text): + """Check if an image is relevant to the article content.""" + if not img_url: + return False + skip_patterns = ['pixel', 'analytics', 'tracking', '1x1.gif', 'spacer.gif', + 'logo', 'icon', 'avatar', 'emoji', 'smiley', 'sprite', + 'advertisement', 'ad-banner', 'sponsored', 'banner-ads'] + img_lower = img_url.lower() + for p in skip_patterns: + if p in img_lower: + return False + if not any(img_lower.endswith(ext) for ext in ['.jpg', '.jpeg', '.png', '.webp', '.gif']): + return False + return True + + +def _filter_relevant_images(images, title, text, max_images=8): + """Filter and rank images by relevance to article content.""" + if not images: + return [] + seen = set() + relevant = [] + for img in images: + if img in seen: + continue + seen.add(img) + if _is_relevant_image(img, title, text): + relevant.append(img) + return relevant[:max_images] + + +def _scrape_article_for_rewrite(url): + """Scrape article: extract title, paragraphs, RELEVANT images, OG image.""" + try: + r = req.get(url, headers=_UA_RW, timeout=15, allow_redirects=True) + r.encoding = 'utf-8' + soup = BeautifulSoup(r.text, 'lxml') + for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form']): + tag.decompose() + h1 = soup.find('h1') + ogt = soup.find('meta', property='og:title') + title = (h1.get_text(strip=True) if h1 else '') or (ogt.get('content', '') if ogt else '') + ogi = soup.find('meta', property='og:image') + og_img = ogi.get('content', '') if ogi else '' + if og_img and og_img.startswith('//'): + og_img = 'https:' + og_img + block = None + for sel in ['article', '.singular-content', '.detail-content', '.fck_detail', '.content-detail', '.knc-content', 'main', '.cms-body', '.article__body']: + el = soup.select_one(sel) + if el and len(el.find_all('p')) >= 2: + block = el + break + if not block: + block = soup.body or soup + paragraphs = [] + all_images = [] + seen_imgs = set() + if og_img and og_img not in seen_imgs: + all_images.append(og_img) + seen_imgs.add(og_img) + for el in block.find_all(['p', 'h2', 'h3', 'figure', 'img'], recursive=True): + if el.name == 'p': + t = _clean(el.get_text(strip=True)) + if t and len(t) > 40: + paragraphs.append(t) + elif el.name in ('figure', 'img'): + im = el if el.name == 'img' else el.find('img') + if im: + src = im.get('data-src') or im.get('src') or im.get('data-original') or '' + if src and 'base64' not in src: + if src.startswith('//'): + src = 'https:' + src + if src not in seen_imgs: + all_images.append(src) + seen_imgs.add(src) + # Filter to relevant images only + relevant_images = _filter_relevant_images(all_images, title, ' '.join(paragraphs[:5])) + return {'title': _clean(title), 'paragraphs': paragraphs, 'images': relevant_images, 'og_img': og_img} + except Exception: + return None + + +def _extract_key_points_rw(paragraphs, max_points=5): + r"""Extract key points from paragraphs - extracts ALL sentences, not just first one. + + Fixes: Original regex `^(.+?[.!?])\s` only captured first sentence per paragraph. + Now splits on all sentence boundaries and takes valid sentences until max_points. + """ + points = [] + + for p in paragraphs: + if len(points) >= max_points: + break + + p = _clean(p) + if not p: + continue + + # Split paragraph into sentences using Vietnamese + English punctuation + sentences = re.split(r'(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])', p) + sentences = [s.strip() for s in sentences if s.strip()] + + for sentence in sentences: + if len(points) >= max_points: + break + + # Clean sentence - remove extra whitespace + sentence = _clean(sentence) + + if len(sentence) < 30: + continue + + # Check for duplicates + if any(sentence[:60] in existing for existing in points): + continue + + # Ensure sentence ends with punctuation + if not sentence.endswith(('.', '!', '?')): + sentence = sentence + '.' + + points.append(sentence) + + # If no valid sentences found, take chunks from raw text + if not points: + raw = '\n'.join(paragraphs) + for i in range(0, min(len(raw), max_points * 300), 280): + chunk = _clean(raw[i:i+280]) + if len(chunk) >= 30 and chunk not in points: + points.append(chunk + ('.' if not chunk.endswith('.') else '')) + if len(points) >= max_points: + break + + return points + + +@app.post("/api/rewrite_slide") +async def api_rewrite_slide(request: Request): + """Fast rewrite as SLIDES - no AI needed, instant response.""" + body = await request.json() + url = _clean(body.get("url", "")) + context = body.get("context", "") + preferred_voice = body.get("voice", "") # Accept custom voice selection + if not url and not context: + return JSONResponse({"error": "Cần URL hoặc nội dung"}, status_code=400) + data = None + if url and url.startswith("http"): + data = _scrape_article_for_rewrite(url) + if not data and context: + paragraphs = [_clean(p) for p in context.split('\n') if len(_clean(p)) > 40] + data = {'title': paragraphs[0][:80] if paragraphs else 'Bài viết', 'paragraphs': paragraphs, 'images': [], 'og_img': ''} + if not data or not data.get('paragraphs'): + return JSONResponse({"error": "Không đọc được bài viết"}, status_code=422) + points = _extract_key_points_rw(data['paragraphs'], max_points=12) + if not points: + return JSONResponse({"error": "Không tìm được ý chính"}, status_code=422) + images = data.get('images', []) + slides = [] + for i, point in enumerate(points): + img = images[i] if i < len(images) else (images[-1] if images else '') + if img and 'cdnphoto.dantri' in img: + img = '/api/proxy/img?url=' + _quote2(img, safe='') + slides.append({'text': point, 'image': img, 'index': i + 1}) + summary_text = '\n\n'.join([f"• {s['text']}" for s in slides]) + + # Auto-detect language and emotion + lang, emotion = detect_language_and_emotion(data['title'], summary_text) + # Use preferred voice if provided, otherwise auto-detect + voice = preferred_voice if preferred_voice else get_voice_for_content(data['title'], summary_text) + + post = { + "id": str(int(time.time() * 1000)) + str(_random2.randint(100, 999)), + "title": data['title'], + "text": summary_text, + "img": images[0] if images else '', + "url": url, + "kind": "slide_summary", + "slides": slides, + "images": images[:10], + "video": "", + "voice": voice, + "emotion": emotion, + "language": lang, + "ts": int(time.time()) + } + posts = _load_wall_posts() + posts.insert(0, post) + _save_wall_posts(posts) + return JSONResponse({"post": post, "slides": slides}) + + +@app.post("/api/rewrite_share") +async def api_rewrite_share(request: Request): + """Rewrite article and post to Tường AI with SLIDES + AI text.""" + body = await request.json() + url = _clean(body.get("url", "")) + ctx = _clean(body.get("context", "")) + preferred_voice = body.get("voice", "") # Accept custom voice selection + if not url and not ctx: + return JSONResponse({"error": "Cần URL hoặc nội dung"}, status_code=400) + data = None + if url and url.startswith("http"): + data = _scrape_article_for_rewrite(url) + if not data and ctx: + paragraphs = [_clean(p) for p in ctx.split('\n') if len(_clean(p)) > 40] + data = {'title': paragraphs[0][:80] if paragraphs else 'Bài viết', 'paragraphs': paragraphs, 'images': [], 'og_img': ''} + if not data or not data.get('paragraphs'): + return JSONResponse({"error": "Không đọc được bài viết"}, status_code=422) + raw_text = '\n'.join(data['paragraphs']) + if len(raw_text) < 50: + raw_text = ctx[:14000] + if len(raw_text) < 50: + return JSONResponse({"error": "Bài viết quá ngắn"}, status_code=422) + domain = '' + try: + from urllib.parse import urlparse + domain = urlparse(url).netloc.replace('www.', '') + except: + pass + + # Generate AI summary text + ai_text = None + try: + import ai_ext + if hasattr(ai_ext, 'qwen_generate'): + prompt = f'Tóm tắt đăng Tường AI:\nTiêu đề: {data["title"]}\n{raw_text[:14000]}\n\n4-6 ý chính. Cuối ghi nguồn.' + ai_text = await ai_ext.qwen_generate(prompt, max_tokens=1000) + except Exception: + pass + if not ai_text or len(ai_text) < 80: + key_pts = _extract_key_points_rw(data['paragraphs'], max_points=12) + if key_pts: + ai_text = '\n\n'.join([f"• {p}" for p in key_pts]) + else: + ai_text = f"Tóm tắt: {data['title']}\n\n{raw_text[:1200]}\n\nNguồn: {domain}" + + # Build slides from key points (FIX: include slides in rewrite_share too!) + points = _extract_key_points_rw(data['paragraphs'], max_points=12) + images = data.get('images', []) + slides = [] + for i, point in enumerate(points): + img = images[i] if i < len(images) else (images[-1] if images else '') + if img and 'cdnphoto.dantri' in img: + img = '/api/proxy/img?url=' + _quote2(img, safe='') + slides.append({'text': point, 'image': img, 'index': i + 1}) + + # Auto-detect language and emotion + lang, emotion = detect_language_and_emotion(data['title'], ai_text) + # Use preferred voice if provided, otherwise auto-detect + voice = preferred_voice if preferred_voice else get_voice_for_content(data['title'], ai_text) + + post = { + "id": str(int(time.time() * 1000)) + str(_random2.randint(100, 999)), + "title": data['title'], + "text": ai_text, + "img": images[0] if images else '', + "url": url, + "kind": "rewrite", + "slides": slides, + "images": images[:10], + "video": "", + "voice": voice, + "emotion": emotion, + "language": lang, + "ts": int(time.time()) + } + posts = _load_wall_posts() + posts.insert(0, post) + _save_wall_posts(posts) + return JSONResponse({"post": post, "slides": slides}) + + +@app.post("/api/url_wall") +async def api_url_wall(request: Request): + """Submit URL to add to Tường AI.""" + body = await request.json() + url = _clean(body.get("url", "")) + if not url or not url.startswith('http'): + return JSONResponse({"error": "URL không hợp lệ"}, status_code=400) + # Reuse rewrite_share logic + req._body = json.dumps({"url": url}).encode() + return await api_rewrite_share(request) + + +# ===== PERSONAL OPINION POST v2: AI tổng hợp bài viết từ quan điểm + nguồn tin HOT ===== +@app.post("/api/personal_post/preview") +async def api_personal_post_preview(request: Request): + """Preview personal post: fetch full articles, let AI compose logical article with images.""" + body = await request.json() + opinion = _clean(body.get("opinion", "")) + selected_topics = body.get("selected_topics", []) or [] + selected_sources = body.get("selected_sources", []) or [] + + if not opinion or len(opinion) < 10: + return JSONResponse({"error": "Quan điểm cá nhân quá ngắn (cần ít nhất 10 ký tự)"}, status_code=400) + + # Lấy HOT topics nếu ko có selected + if not selected_topics: + hot = _get_hot_topics() + selected_topics = [t.get("topic", "") for t in hot[:3] if t.get("topic")] + + # Tìm nguồn tin + all_sources = [] + seen_urls = set() + for topic in selected_topics[:3]: + sources = _search_all(topic, limit=5) + for s in sources: + if s.get("url") and s["url"] not in seen_urls: + seen_urls.add(s["url"]) + all_sources.append(s) + if len(all_sources) >= 6: + break + if len(all_sources) >= 6: + break + + for src in selected_sources: + if src.get("url") and src["url"] not in seen_urls: + all_sources.insert(0, src) + + # Scrape nội dung đầy đủ từng nguồn (paragraphs + images) + source_details = [] + source_images = [] + for src in all_sources[:5]: + url = src.get("url", "") + if not url: + continue + try: + art = _scrape_article_for_rewrite(url) + if art: + src_detail = { + "title": art.get("title", src.get("title", "")), + "url": url, + "via": src.get("via", ""), + "paragraphs": art.get("paragraphs", [])[:8], + "images": art.get("images", [])[:3], + "og_image": art.get("og_img", "") + } + source_details.append(src_detail) + # Collect images for proxy + for img in art.get("images", [])[:2]: + if any(x in img for x in ["cdnphoto.dantri", "vnexpress", "vcdn", "refooty"]): + img = "/api/proxy/img?url=" + _quote2(img, safe="") + source_images.append(img) + except: + pass + if len(source_details) >= 5: + break + + # Tạo title từ opinion + opinion_words = re.findall(r"[A-Za-zÀ-ỹ0-9]+", opinion) + title_words = opinion_words[:8] if len(opinion_words) >= 8 else opinion_words[:4] + title = " ".join([w[0].upper() + w[1:] for w in title_words]) if title_words else "Quan điểm cá nhân" + title = title[:80] + + # AI sinh bài viết hoàn chỉnh + ai_text = None + try: + import ai_ext + if hasattr(ai_ext, 'qwen_generate'): + # Build detailed context from source articles + source_context = "" + for i, sd in enumerate(source_details[:5]): + src_title = sd.get("title", "") + src_via = sd.get("via", "") + src_paras = sd.get("paragraphs", []) + source_context += f"\n=== Nguồn {i+1}: {src_title} ({src_via}) ===\n" + for j, p in enumerate(src_paras[:4]): + source_context += f" - {p[:300]}\n" + + prompt = ( + "Bạn là nhà báo chuyên nghiệp tiếng Việt. Hãy viết một bài phân tích dựa trên quan điểm cá nhân và các nguồn tin sau đây.\n\n" + "=== QUAN ĐIỂM CÁ NHÂN ===\n" + opinion[:2000] + "\n\n" + "=== NGUỒN TIN THAM KHẢO (kèm nội dung chi tiết) ===\n" + source_context + "\n\n" + "=== YÊU CẦU BÀI VIẾT ===\n" + "1. Mở đầu: Giới thiệu chủ đề và nêu quan điểm cá nhân (1-2 câu)\n" + "2. Thân bài: Phân tích luận điểm, dùng dẫn chứng CỤ THỂ từ nguồn tin (trích dẫn nguồn kèm tên báo)\n" + "3. Mỗi luận điểm là 1 đoạn ngắn 2-4 câu, có ghi nguồn rõ ràng (VD: Theo VnExpress, ...)\n" + "4. Kết luận: Tổng kết quan điểm, gợi mở suy nghĩ\n" + "5. Cuối bài: Ghi danh sách nguồn tham khảo\n\n" + "Viết tự nhiên, mạch lạc, giọng văn báo chí - phân tích. Độ dài: 300-600 từ." + ) + ai_text = await ai_ext.qwen_generate(prompt, max_tokens=2000) + except: + pass + + if not ai_text or len(ai_text) < 100: + # Fallback: build article manually + ai_text = "## " + title + "\n\n" + ai_text += opinion + "\n\n" + for i, sd in enumerate(source_details[:5]): + ai_text += "### " + sd.get("title", f"Nguồn {i+1}") + "\n" + for p in sd.get("paragraphs", [])[:3]: + ai_text += p[:250] + "\n" + ai_text += "*Nguồn: " + sd.get("via", "") + "*\n\n" + ai_text += "\n---\n*Bài viết tổng hợp từ quan điểm cá nhân và các nguồn tin liên quan*" + + # Tạo slides từ bài viết + slide_parts = [] + if ai_text: + paragraphs = [p.strip() for p in ai_text.split("\n") if p.strip() and len(p.strip()) > 40] + current_para = "" + para_count = 0 + for p in paragraphs: + if p.startswith("## ") or p.startswith("### ") or p.startswith("---"): + if current_para and para_count < 6: + slide_parts.append(current_para) + para_count += 1 + current_para = "" + elif not p.startswith("*") and not p.startswith("- "): + if len(p) > 80: + if current_para: + current_para += "\n\n" + p + else: + current_para = p + if current_para and para_count < 6: + slide_parts.append(current_para) + + if len(slide_parts) < 2: + # Create slides from opinion + sources + slide_parts = [opinion[:300]] + for sd in source_details[:4]: + slide_parts.append(sd.get("title", "")[:200] + "\n\n" + (sd.get("paragraphs", [""])[0][:200] if sd.get("paragraphs") else "")) + + slides = [] + total = min(len(slide_parts), 6) + for i in range(total): + img = source_images[i] if i < len(source_images) else "" + slides.append({ + "text": slide_parts[i], + "image": img, + "index": i + 1 + }) + + preview = { + "title": title, + "text": ai_text, + "opinion": opinion, + "images": source_images[:10], + "sources": source_details[:5], + "slides": slides + } + + return JSONResponse({"preview": preview}) + + +@app.post("/api/personal_post") +async def api_personal_post(request: Request): + """Create and save personal opinion post.""" + body = await request.json() + opinion = _clean(body.get("opinion", "")) + selected_topics = body.get("selected_topics", []) or [] + selected_sources = body.get("selected_sources", []) or [] + custom_title = body.get("custom_title", "") + custom_slides = body.get("custom_slides", []) + + if not opinion or len(opinion) < 10: + return JSONResponse({"error": "Quan điểm cá nhân quá ngắn (cần ít nhất 10 ký tự)"}, status_code=400) + + if not selected_topics: + hot = _get_hot_topics() + selected_topics = [t.get("topic", "") for t in hot[:3] if t.get("topic")] + + all_sources = [] + seen_urls = set() + for topic in selected_topics[:3]: + sources = _search_all(topic, limit=5) + for s in sources: + if s.get("url") and s["url"] not in seen_urls: + seen_urls.add(s["url"]) + all_sources.append(s) + if len(all_sources) >= 6: + break + if len(all_sources) >= 6: + break + + for src in selected_sources: + if src.get("url") and src["url"] not in seen_urls: + all_sources.insert(0, src) + + source_details = [] + source_images = [] + for src in all_sources[:5]: + url = src.get("url", "") + if not url: + continue + try: + art = _scrape_article_for_rewrite(url) + if art: + src_detail = { + "title": art.get("title", src.get("title", "")), + "url": url, + "via": src.get("via", ""), + "paragraphs": art.get("paragraphs", [])[:6], + "images": art.get("images", [])[:2], + "og_image": art.get("og_img", "") + } + source_details.append(src_detail) + for img in art.get("images", [])[:2]: + if any(x in img for x in ["cdnphoto.dantri", "vnexpress", "vcdn", "refooty"]): + img = "/api/proxy/img?url=" + _quote2(img, safe="") + source_images.append(img) + except: + pass + + # Title + if custom_title: + title = custom_title[:80] + else: + opinion_words = re.findall(r"[A-Za-zÀ-ỹ0-9]+", opinion) + title_words = opinion_words[:8] if len(opinion_words) >= 8 else opinion_words[:4] + title = " ".join([w[0].upper() + w[1:] for w in title_words]) if title_words else "Quan điểm cá nhân" + title = title[:80] + + # AI sinh bài + ai_text = None + try: + import ai_ext + if hasattr(ai_ext, 'qwen_generate'): + source_context = "" + for i, sd in enumerate(source_details[:5]): + src_title = sd.get("title", "") + src_via = sd.get("via", "") + src_paras = sd.get("paragraphs", []) + source_context += f"\nNguồn {i+1}: {src_title} ({src_via})\n" + for j, p in enumerate(src_paras[:3]): + source_context += f" - {p[:300]}\n" + prompt = ( + "Bạn là nhà báo tiếng Việt. Viết bài phân tích dựa trên quan điểm cá nhân và các nguồn tin sau.\n\n" + "=== QUAN ĐIỂM ===\n" + opinion[:2000] + "\n\n" + "=== NGUỒN TIN ===\n" + source_context + "\n\n" + "Yêu cầu:\n- Mở đầu: giới thiệu chủ đề + nêu quan điểm\n- Thân bài: phân tích có dẫn chứng từ nguồn (ghi rõ nguồn)\n- Mỗi đoạn 2-4 câu\n- Kết luận: tổng kết\n- Cuối: danh sách nguồn\n\nViết tự nhiên, 300-600 từ." + ) + ai_text = await ai_ext.qwen_generate(prompt, max_tokens=2000) + except: + pass + + if not ai_text or len(ai_text) < 100: + ai_text = "## " + title + "\n\n" + opinion + "\n\n" + for i, sd in enumerate(source_details[:5]): + ai_text += "### " + sd.get("title", "") + "\n" + for p in sd.get("paragraphs", [])[:2]: + ai_text += p[:250] + "\n" + ai_text += "\n---\n*Nguồn: " + sd.get("via", "") + "*\n\n" + + # Tạo slides + if custom_slides and len(custom_slides) > 0: + slides = [] + for i, slide in enumerate(custom_slides): + slides.append({ + "text": slide.get("text", ""), + "image": slide.get("image", ""), + "index": i + 1 + }) + else: + slide_parts = [] + if ai_text: + paragraphs = [p.strip() for p in ai_text.split("\n") if p.strip() and len(p.strip()) > 40] + current = "" + count = 0 + for p in paragraphs: + if p.startswith("## ") or p.startswith("### ") or p.startswith("---"): + if current and count < 6: + slide_parts.append(current) + count += 1 + current = "" + elif len(p) > 80: + if current: + current += "\n\n" + p + else: + current = p + if current and count < 6: + slide_parts.append(current) + + if len(slide_parts) < 2: + slide_parts = [opinion[:300]] + for sd in source_details[:5]: + slide_parts.append(sd.get("title", "")[:200] + "\n" + (sd.get("paragraphs", [""])[0][:200] if sd.get("paragraphs") else "")) + + slides = [] + total = min(len(slide_parts), 6) + for i in range(total): + img = source_images[i] if i < len(source_images) else "" + slides.append({ + "text": slide_parts[i], + "image": img, + "index": i + 1 + }) + + lang, emotion = detect_language_and_emotion(title, ai_text) + voice = get_voice_for_content(title, ai_text) + + post = { + "id": str(int(time.time() * 1000)) + str(_random2.randint(100, 999)), + "title": title, + "text": ai_text, + "img": source_images[0] if source_images else "", + "url": "", + "kind": "personal_opinion", + "slides": slides, + "images": source_images[:10], + "video": "", + "voice": voice, + "emotion": emotion, + "language": lang, + "ts": int(time.time()), + "sources": source_details[:5] + } + + posts = _load_wall_posts() + posts.insert(0, post) + _save_wall_posts(posts) + + return JSONResponse({"post": post, "slides": slides}) + + +# ===== END PERSONAL OPINION POST v2 ===== + +def _bg(): + time.sleep(15) + while True: + try:get_wc2026_all() + except:pass + time.sleep(90) +threading.Thread(target=_bg,daemon=True).start() + +# ===== AUTO SCHEDULER: rewrite AI + short at 7/13/19 VN time ===== +_AUTO_SCHEDULE_TIMES = [(7, '07:00'), (13, '13:00'), (19, '19:00')] +_AUTO_LOG = os.path.join(DATA_DIR, 'auto_rewrite_log.json') + +def _load_auto_log(): + try: + if os.path.exists(_AUTO_LOG): + with open(_AUTO_LOG, 'r') as f: + return json.load(f) + except: pass + return {} + +def _save_auto_log(log): + try: + tmp = _AUTO_LOG + '.tmp' + with open(tmp, 'w') as f: + json.dump(log, f) + os.replace(tmp, _AUTO_LOG) + except: pass + +async def _auto_fetch_short(post_id): + """Try to auto-generate a short for a post.""" + try: + import httpx + async with httpx.AsyncClient(timeout=180) as cl: + r = await cl.post( + f"http://localhost:7860/api/ai/short/{post_id}", + json={"voice":"vi-VN-HoaiMyNeural","emotion":"neutral","speed":1.2}, + headers={"Content-Type":"application/json"} + ) + if r.status_code < 300: + sj = r.json() + if sj.get('video'): + posts = _load_wall_posts() + for p in posts: + if p.get('id') == post_id: + p['video'] = sj['video'] + break + _save_wall_posts(posts) + return True + except: pass + return False + +async def _auto_rewrite_one(topic, slot_label, used_urls=None, post_index=0): + """Rewrite one topic: find articles, summarize, post to wall, trigger short. + used_urls: shared set to avoid duplicate articles across topics. + post_index: 0-based index to create multiple posts per topic (0,1,2 = up to 3 posts).""" + from urllib.parse import quote as _q + # Get MORE items to support 1-3 posts per topic + items = _search_all(topic, limit=12) + # Skip URLs already used by another topic + if used_urls is not None: + filtered = [it for it in items if it.get('url') not in used_urls] + if filtered: + items = filtered + if not items or post_index >= len(items): + return False + + # Get article at post_index (0,1,2 for multiple posts) + item = items[post_index] # post_index allows multiple articles per topic + url = item.get('url', '') + title = item.get('title', topic) + if url and used_urls is not None: + used_urls.add(url) + if not url.startswith('http'): + return False + + data = _scrape_article_for_rewrite(url) + if not data or not data.get('paragraphs'): + return False + + raw_text = '\n'.join(data['paragraphs']) + ai_text = None + + # Try AI generation + try: + import ai_ext + prompt = f"Tóm tắt tin tức (tự động {slot_label}):\nTiêu đề: {data['title']}\n{raw_text[:10000]}\n\n4-6 ý chính dạng bullet. Cuối ghi nguồn." + ai_text = await ai_ext.qwen_generate(prompt, max_tokens=1000) + except: pass + + if not ai_text or len(ai_text) < 80: + pts = data['paragraphs'][:6] + ai_text = '\n\n'.join([f"• {p[:300]}" for p in pts]) + via = item.get('via', '') or urlparse(url).netloc.replace('www.', '') + ai_text += f"\n\nNguồn tham khảo: {via}" + + # Build slides + images = data.get('images', []) + pts = data['paragraphs'][:10] + slides = [] + for i, p in enumerate(pts[:8]): + img = images[i] if i < len(images) else (images[-1] if images else data.get('og_img', '')) + slides.append({'text': p[:300], 'image': img, 'index': i + 1}) + + post_id = str(int(time.time() * 1000)) + str(_random2.randint(100, 999)) + post = { + "id": post_id, "title": data.get('title', title)[:200], + "text": ai_text, "img": images[0] if images else data.get('og_img', ''), + "url": url, "kind": "auto_rewrite", "slides": slides, + "images": images[:10], "video": "", + "voice": "vi-VN-HoaiMyNeural", "emotion": "neutral", + "language": "vietnamese", "ts": int(time.time()), + "auto_scheduled": True, "slot": slot_label, + } + + posts = _load_wall_posts() + posts.insert(0, post) + _save_wall_posts(posts) + + # Trigger short generation async + threading.Thread(target=lambda: asyncio.run(_auto_fetch_short(post_id)), daemon=True).start() + return True + +async def _do_scheduled_run(slot_label): + """Main scheduled run: 1-3 posts from 3 different HOT topics (3-9 total), no duplicates.""" + print(f"[auto] Starting scheduled rewrite for {slot_label}") + + # Get top hot topics, skip duplicates + all_topics = _get_hot_topics() + seen_topics = set() + unique_topics = [] + for t in all_topics: + kw = t.get('topic', '').lower().strip() + if kw and len(kw) > 5 and kw not in seen_topics: + is_dup = False + for s in seen_topics: + # Check if one topic is substring of another + if kw in s or s in kw: + is_dup = True + break + if not is_dup: + seen_topics.add(kw) + unique_topics.append(t) + if len(unique_topics) >= 3: + break + + job_topics = [t['topic'] for t in unique_topics[:3] if t.get('topic')] + if not job_topics: + print(f"[auto] No hot topics found, skipping") + return + + print(f"[auto] Running 3 topics: {job_topics}") + + # Track used URLs to avoid cross-topic duplicates + _used_urls = set() + results = [] + + # Process each topic, create 1-3 posts per topic + for jt in job_topics: + for post_idx in range(3): # Try up to 3 posts per topic + try: + ok = await asyncio.wait_for(_auto_rewrite_one(jt, slot_label, _used_urls, post_idx), timeout=120) + if ok: + results.append((jt, post_idx, True)) + print(f"[auto] Created post {post_idx+1} for '{jt}'") + else: + # No more articles for this topic + break + except Exception as e: + print(f"[auto] Error on '{jt}' post {post_idx}: {e}") + results.append((jt, post_idx, False)) + await asyncio.sleep(1) # Small delay between posts + + # Ensure at least 3 posts total (fallback if needed) + successful_posts = sum(1 for _, _, ok in results if ok) + print(f"[auto] Done {slot_label}: {successful_posts} posts created") + + # Log + from datetime import datetime, timezone, timedelta + VN_TZ_SCHED = timezone(timedelta(hours=7)) + today_str = datetime.now(VN_TZ_SCHED).strftime('%Y-%m-%d') + log = _load_auto_log() + if today_str not in log: log[today_str] = {} + log[today_str][slot_label] = { + 'time': datetime.now(VN_TZ_SCHED).strftime('%H:%M:%S'), + 'count': successful_posts, + 'total': len(job_topics), + } + _save_auto_log(log) + +def _scheduler_loop(): + """Check every 60s; trigger at 7:00, 13:00, 19:00 VN time. + On startup, check for any missed slots today and run them immediately.""" + time.sleep(35) + from datetime import datetime, timezone, timedelta + VN_TZ_SCHED = timezone(timedelta(hours=7)) + + _last_run_date = "" + _last_run_slots = set() + + # On startup: check log for missed slots today + try: + start_now = datetime.now(VN_TZ_SCHED) + today_str = start_now.strftime('%Y-%m-%d') + current_hour = start_now.hour + current_minute = start_now.minute + log = _load_auto_log() + today_log = log.get(today_str, {}) + for h, label in _AUTO_SCHEDULE_TIMES: + # Run if slot is past (either strictly earlier hour, or same hour but window has passed) + should_run = False + if h < current_hour: + should_run = True + elif h == current_hour and current_minute > 10: + should_run = True + if should_run and label not in today_log: + print(f"[auto] Detected missed slot {label} (h={h} < now={current_hour}:{current_minute}), running catch-up now") + _run_scheduled_sync(label) + _last_run_slots.add(label) + except Exception as e: + print(f"[auto] Catch-up check error: {e}") + + while True: + try: + now = datetime.now(VN_TZ_SCHED) + today = now.strftime('%Y-%m-%d') + hour = now.hour + minute = now.minute + + if today != _last_run_date: + _last_run_date = today + _last_run_slots = set() + + slot = None + for h, label in _AUTO_SCHEDULE_TIMES: + if hour == h and 0 <= minute < 5: + slot = label + break + + if slot and slot not in _last_run_slots: + _last_run_slots.add(slot) + _run_scheduled_sync(slot) + except Exception as e: + print(f"[auto] Loop error: {e}") + + time.sleep(60) + +threading.Thread(target=_scheduler_loop, daemon=True, name='auto-rewrite-scheduler').start() + +@app.get('/api/debug/auto_schedule') +async def debug_auto_schedule(slot: str = '07:00'): + """Manually trigger auto scheduler for debugging.""" + try: + # Check if we can access the data directory + log = _load_auto_log() + topics = _get_hot_topics()[:3] + job_topics = [t['topic'] for t in topics if t.get('topic')] + return JSONResponse({ + "slot": slot, + "log": log, + "hot_topics": job_topics, + "wall_posts_count": len(_load_wall_posts()), + "data_dir_writable": os.access(DATA_DIR, os.W_OK) if os.path.isdir(DATA_DIR) else False, + "data_dir_exists": os.path.isdir(DATA_DIR), + }) + except Exception as e: + return JSONResponse({"error": str(e)}, status_code=500) + +def _run_scheduled_sync(slot): + """Run _do_scheduled_run in a separate event loop (for background thread).""" + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + loop.run_until_complete(_do_scheduled_run(slot)) + except Exception as e: + print(f"[auto] Background run error: {e}") + finally: + loop.close() + +@app.get('/api/debug/trigger_auto') +async def debug_trigger_auto(slot: str = '19:00'): + """Trigger _do_scheduled_run in background thread (non-blocking).""" + threading.Thread(target=_run_scheduled_sync, args=(slot,), daemon=True).start() + return JSONResponse({"status": "started", "slot": slot}) + +# ===== SHORTS RSS PROXY ENDPOINT ===== +@app.get("/api/shorts/rss") +def shorts_rss(): + """Get shorts from YouTube RSS feeds server-side""" + import xml.etree.ElementTree as ET + import html as html_lib2 + import re as re2 + + YOUTUBE_CHANNELS = { + "baodantri7941": "UC_x5TKhOgd6GhYvv5z4I3jg", + "baosuckhoedoisongboyte": "UCBsY5fXTQLkF_JnH9kLkL4g", + } + + shorts = [] + seen = set() + + for handle, channel_id in YOUTUBE_CHANNELS.items(): + try: + rss_url = f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}" + r = req.get(rss_url, headers=HEADERS, timeout=15) + if r.status_code != 200: + continue + + root = ET.fromstring(r.text) + ns = { + 'atom': 'http://www.w3.org/2005/Atom', + 'yt': 'http://www.youtube.com/xml/schemas/2015', + 'media': 'http://search.yahoo.com/mrss/' + } + + for entry in root.findall('atom:entry', ns)[:30]: + title_el = entry.find('atom:title', ns) + title = html_lib2.unescape(title_el.text) if title_el is not None and title_el.text else '' + + link_el = entry.find('atom:link', ns) + link = link_el.get('href', '') if link_el is not None else '' + + vid_el = entry.find('yt:videoId', ns) + vid = vid_el.text if vid_el is not None else '' + + if not vid or vid in seen: + continue + + # Check if it's a short + is_short = '#shorts' in title.lower() or '#short' in title.lower() or '/shorts/' in link + + if not is_short: + desc_el = entry.find('media:description', ns) + if desc_el is not None and desc_el.text: + if '#shorts' in desc_el.text.lower(): + is_short = True + + if not is_short: + continue + + seen.add(vid) + + # Get thumbnail + thumb = f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg" + media_group = entry.find('media:group', ns) + if media_group is not None: + thumb_el = media_group.find('media:thumbnail', ns) + if thumb_el is not None: + thumb = thumb_el.get('url', thumb) + + shorts.append({ + 'id': vid, + 'title': title.replace('#shorts', '').replace('#short', '').strip()[:120], + 'img': thumb, + 'link': f'https://www.youtube.com/shorts/{vid}', + 'channel': handle, + 'source': 'yt' + }) + + if len(shorts) >= 40: + break + + except Exception as e: + print(f"RSS error for {handle}: {e}") + continue + + return {"shorts": shorts, "count": len(shorts)} + +app.mount('/static',StaticFiles(directory=STATIC_DIR),name='vnews_static') \ No newline at end of file diff --git a/app_v2_entry.py.gitigignore b/app_v2_entry.py.gitigignore deleted file mode 100644 index b0ce4bb73e0138d7e48114fc6bb45471e71096cd..0000000000000000000000000000000000000000 --- a/app_v2_entry.py.gitigignore +++ /dev/null @@ -1,3 +0,0 @@ -.pyc -__pycache__/ -*.pyc diff --git a/app_v2_entry_hot.py b/app_v2_entry_hot.py deleted file mode 100644 index 18b903b8b3b409d6a16a38cb8c6723515a58f296..0000000000000000000000000000000000000000 --- a/app_v2_entry_hot.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Hot topics patch - makes AI topics always visible at top of HOT list.""" -# This file is imported by app_v2_entry.py - -# AI topics to prepend to hot topics -AI_HOT_TOPICS = [ - {'label': '#Công nghệ AI', 'topic': 'Công nghệ AI', 'count': 0}, - {'label': '#World Cup 2026', 'topic': 'World Cup 2026', 'count': 0}, - {'label': '#Kinh tế Việt Nam', 'topic': 'Kinh tế Việt Nam', 'count': 0}, - {'label': '#Bóng đá châu Âu', 'topic': 'Bóng đá châu Âu', 'count': 0}, - {'label': '#Giá vàng', 'topic': 'Giá vàng', 'count': 0}, - {'label': '#Thời tiết', 'topic': 'Thời tiết', 'count': 0}, -] - -def prepend_ai_hot_topics(topics): - """Prepend AI topics to hot topics list, ensuring they're always visible.""" - if not topics: - return AI_HOT_TOPICS[:] - # Remove duplicates that already exist - existing_topics = [t.get('topic', '').lower() for t in topics] - result = [] - for ai_topic in AI_HOT_TOPICS: - if ai_topic.get('topic', '').lower() not in existing_topics: - result.append(ai_topic) - return result + topics \ No newline at end of file diff --git a/app_v2_entry_test.py b/app_v2_entry_test.py deleted file mode 100644 index b06732f0aef340c8a62a87762b8d738b225c1f45..0000000000000000000000000000000000000000 --- a/app_v2_entry_test.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -VNEWS App v2 - Main application with match detail API -""" -import os, json, re, time, asyncio, hashlib, logging, threading, importlib, sys -from datetime import datetime, timezone, timedelta -from pathlib import Path -from typing import Optional - -import httpx -import requests -from fastapi import FastAPI, HTTPException, Query -from fastapi.responses import JSONResponse, FileResponse, HTMLResponse -from fastapi.staticfiles import StaticFiles -from fastapi.templating import Jinja2Templates - -# ... (rest of app_v2_entry.py content) diff --git a/app_v2_entry_v2.py b/app_v2_entry_v2.py deleted file mode 100644 index f8093e26f974eb852f11f2ec736c0483934ac353..0000000000000000000000000000000000000000 --- a/app_v2_entry_v2.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -VNEWS App v2 - Main application with match detail API -""" -import os, json, re, time, asyncio, hashlib, logging, threading, importlib -from datetime import datetime, timezone, timedelta -from pathlib import Path -from typing import Optional - -import httpx -import requests -from fastapi import FastAPI, HTTPException, Query -from fastapi.responses import JSONResponse, FileResponse, HTMLResponse -from fastapi.staticfiles import StaticFiles -from fastapi.templating import Jinja2Templates - -# ... (rest of app_v2_entry.py content) diff --git a/app_v2_patch.py b/app_v2_patch.py deleted file mode 100644 index 8813192d6506e09ea86871b4e95bb1121810bb3e..0000000000000000000000000000000000000000 --- a/app_v2_patch.py +++ /dev/null @@ -1,111 +0,0 @@ -"""VNEWS v2 Patch - auto scheduler + status endpoints + keep-alive. -This is imported by app_v2_entry.py to add auto posting functionality. -FIX v2: Catch-up scheduler + keep-alive to prevent Space sleep -""" -import sys, os, threading, json, time, logging -from datetime import datetime, timezone, timedelta -from fastapi import Request -from fastapi.responses import JSONResponse -import requests as _req - -VN_TZ = timezone(timedelta(hours=7)) -LOG = logging.getLogger("app_v2_patch") -LOG.setLevel(logging.INFO) -if not LOG.handlers: - ch = logging.StreamHandler() - ch.setFormatter(logging.Formatter('%(asctime)s [app_v2_patch] %(levelname)s: %(message)s')) - LOG.addHandler(ch) - -# ===== Keep-alive: prevent Space from sleeping ===== -# HF Spaces sleep after ~30 min of inactivity on free tier -# This thread pings the Space every 10 minutes to keep it alive -SPACE_URL = "https://bep40-vnews.hf.space" - -def _keep_alive_loop(): - """Ping the Space every 10 minutes to prevent sleep.""" - LOG.info(f"🔄 Keep-alive thread started - ping {SPACE_URL} every 10 min") - while True: - try: - time.sleep(600) # 10 minutes - _req.get(f"{SPACE_URL}/api/scheduler/status", - headers={"User-Agent": "VNEWS-KeepAlive/1.0"}, - timeout=15) - LOG.debug("Keep-alive ping OK") - except Exception as e: - LOG.warning(f"Keep-alive ping failed (Space may be sleeping): {e}") - -# Start keep-alive in background -try: - _ka_thread = threading.Thread(target=_keep_alive_loop, daemon=True, name="keep-alive") - _ka_thread.start() - LOG.info("🔄 Keep-alive started - Space will stay awake") -except Exception as e: - LOG.warning(f"Keep-alive start failed: {e}") - -# ===== Start auto scheduler ===== -try: - import auto_scheduler as _as - _as.start_auto_scheduler() - LOG.info("[auto_scheduler] Started successfully - will post at 7:00, 13:00, 19:00 VN time (with catch-up)") -except Exception as e: - LOG.error(f"[auto_scheduler] Start failed: {e}") - -def register_scheduler_endpoints(app): - """Register scheduler status/trigger endpoints on the FastAPI app.""" - - @app.get('/api/scheduler/status') - def scheduler_status(): - running = any(t.name == 'auto-scheduler' and t.is_alive() for t in threading.enumerate()) - keep_alive = any(t.name == 'keep-alive' and t.is_alive() for t in threading.enumerate()) - - # Load state to show which slots ran today - today_str = datetime.now(VN_TZ).strftime('%Y-%m-%d') - state = {} - try: - state_file = '/data/scheduler_state.json' if os.path.isdir('/data') else None - if state_file and os.path.exists(state_file): - state = json.load(open(state_file, 'r')) - except: - pass - - ran_today = state.get(today_str, {}) if state else {} - - return JSONResponse({ - "running": running, - "keep_alive": keep_alive, - "schedule": "7:00, 13:00, 19:00 VN time", - "today": today_str, - "slots_ran_today": ran_today, - "catch_up_enabled": True, - "next_run": "7:00, 13:00, or 19:00 VN time (whichever is next)" - }) - - @app.post('/api/scheduler/trigger') - async def scheduler_trigger(): - try: - import auto_scheduler as _as2 - _as2._run_scheduled_posting() - return JSONResponse({"ok": True, "message": "Scheduled posting triggered manually"}) - except Exception as e: - return JSONResponse({"ok": False, "error": str(e)}, status_code=500) - - @app.get('/api/scheduler/force') - def scheduler_force(): - """Force-run all missed slots immediately. Useful after deploy.""" - try: - import auto_scheduler as _as2 - _as2._check_missed_slots() - return JSONResponse({"ok": True, "message": "Missed slots check triggered"}) - except Exception as e: - return JSONResponse({"ok": False, "error": str(e)}, status_code=500) - - return app - - -# Auto-register on the main app from app_v2_entry -try: - from main import app - register_scheduler_endpoints(app) - LOG.info("[app_v2_patch] Scheduler endpoints registered: /api/scheduler/status, /api/scheduler/trigger, /api/scheduler/force") -except Exception as e: - LOG.error(f"[app_v2_patch] Could not register endpoints: {e}") diff --git a/auto_scheduler.py b/auto_scheduler.py deleted file mode 100644 index 0681dc5b881e7a7a254a55b095f05ded955c8159..0000000000000000000000000000000000000000 --- a/auto_scheduler.py +++ /dev/null @@ -1,396 +0,0 @@ -"""VNEWS Auto Scheduler - tự động đăng 3 bài rewrite AI + shorts từ 3 chủ đề HOT -Vào các khung giờ: 7:00, 13:00, 19:00 (giờ Việt Nam) -Mỗi bài: Rewrite AI từ nguồn báo + short video tự động -FIX v7: Giữ nguyên tiêu đề gốc từng bài viết + thêm "Tin tóm tắt VNEWS 7h sáng/13h trưa/19h tối" ở đầu text -""" -import os, re, json, time, threading, asyncio, logging, random, hashlib, html as html_lib -from datetime import datetime, timezone, timedelta, date -from urllib.parse import quote -import requests -from bs4 import BeautifulSoup - -# Import storage for persistent data -from storage import load_wall_posts, save_wall_posts, DATA_DIR - -VN_TZ = timezone(timedelta(hours=7)) -LOG = logging.getLogger("auto_scheduler") -LOG.setLevel(logging.INFO) -if not LOG.handlers: - ch = logging.StreamHandler() - ch.setFormatter(logging.Formatter('%(asctime)s [%(name)s] %(levelname)s: %(message)s')) - LOG.addHandler(ch) - -SCHEDULE_TIMES = [(7, 0), (13, 0), (19, 0)] -SCHEDULE_LABELS = {t: f"{t[0]:02d}:{t[1]:02d}" for t in SCHEDULE_TIMES} - -os.makedirs(DATA_DIR, exist_ok=True) -SCHEDULE_STATE_FILE = os.path.join(DATA_DIR, 'scheduler_state.json') - -def _load_state(): - try: - if os.path.exists(SCHEDULE_STATE_FILE): - with open(SCHEDULE_STATE_FILE, 'r') as f: return json.load(f) - except: pass - return {} - -def _save_state(state): - try: - tmp = SCHEDULE_STATE_FILE + '.tmp' - with open(tmp, 'w') as f: json.dump(state, f, ensure_ascii=False) - os.replace(tmp, SCHEDULE_STATE_FILE) - except Exception as e: LOG.warning(f"Cannot save state: {e}") - -_STOP = set('và của các những một được trong với cho tại sau trước khi không người vietnam hôm nay mới nhất nóng tin tức cập nhật theo từ đến là có thì này đã để'.split()) - -def _clean(s): - s = html_lib.unescape(s or "") - # FIX: Remove malformed HTML artifacts (truncated tags without closing >) - s = s.replace(']+>', '', s) # Remove all HTML tags - return re.sub(r"\s+", " ", s).strip() - -def _get_hot_topics(): - freq = {}; display = {} - feeds = [ - 'https://vnexpress.net/rss/tin-moi-nhat.rss', - 'https://dantri.com.vn/rss/home.rss', - 'https://vietnamnet.vn/rss/tin-moi-nhat.rss', - 'https://thanhnien.vn/rss/home.rss', - 'https://tuoitre.vn/rss/tin-moi-nhat.rss', - 'https://genk.vn/rss', - 'https://vnexpress.net/rss/the-thao.rss', - 'https://thethaovanhoa.vn/rss/tin-nong.rss', - 'https://vnexpress.net/rss/kinh-doanh.rss', - 'https://dantri.com.vn/rss/the-gioi.rss', - ] - for feed_url in feeds: - try: - r = requests.get(feed_url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=6) - r.encoding = 'utf-8' - soup = BeautifulSoup(r.text, 'xml') - for item in soup.find_all('item')[:12]: - title = _clean(item.find('title').get_text() if item.find('title') else '') - if not title: continue - title = re.sub(r'\s*[-|].*$', '', title) - words = [w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+', title) if len(w) > 2 and w.lower() not in _STOP] - if len(words) < 2: continue - for n in (3, 4, 2): - for i in range(max(0, len(words) - n + 1)): - phrase = ' '.join(words[i:i + n]) - if 8 <= len(phrase) <= 45: - key = phrase.lower() - freq[key] = freq.get(key, 0) + 1 - display[key] = phrase - except: continue - ranked = sorted(freq.items(), key=lambda x: x[1], reverse=True) - topics = []; seen = set() - for key, count in ranked: - kw = display[key] - is_dup = any(len(set(e.split()) & set(key.split())) / max(len(set(e.split())), len(set(key.split())), 1) > 0.6 for e in seen) - if is_dup: continue - seen.add(key) - topics.append({'label': '#' + re.sub(r'\s+', '', kw.title()), 'topic': kw, 'count': count}) - if len(topics) >= 20: break - for kw in ['World Cup 2026', 'Kinh tế Việt Nam', 'Bóng đá châu Âu', 'Công nghệ AI', 'Giá vàng', 'Thời tiết']: - if len(topics) >= 24: break - if not any(kw.lower() in s for s in seen): - topics.append({'label': '#' + re.sub(r'\s+', '', kw.title()), 'topic': kw, 'count': 0}) - return topics[:24] - -_ai_ext = None; _ai_patch = None -def _get_ai_ext(): - global _ai_ext - if _ai_ext is None: import ai_ext as m; _ai_ext = m - return _ai_ext -def _get_ai_patch(): - global _ai_patch - if _ai_patch is None: import ai_patch as m; _ai_patch = m - return _ai_patch - -_RSS_FEEDS = [ - ('https://vnexpress.net/rss/tin-moi-nhat.rss', 'VnExpress'), - ('https://dantri.com.vn/rss/home.rss', 'Dân Trí'), - ('https://vietnamnet.vn/rss/tin-moi-nhat.rss', 'VietNamNet'), - ('https://thanhnien.vn/rss/home.rss', 'Thanh Niên'), - ('https://tuoitre.vn/rss/tin-moi-nhat.rss', 'Tuổi Trẻ'), - ('https://genk.vn/rss', 'GenK'), - ('https://vnexpress.net/rss/the-thao.rss', 'VnExpress'), - ('https://thethaovanhoa.vn/rss/tin-nong.rss', 'TT&VH'), - ('https://vnexpress.net/rss/kinh-doanh.rss', 'VnExpress'), - ('https://dantri.com.vn/rss/the-gioi.rss', 'Dân Trí'), -] - -def _search_articles_by_topic(topic, limit=4): - all_articles = []; seen_urls = set() - topic_lower = topic.lower() - topic_words = set(re.findall(r'[A-Za-zÀ-ỹ0-9]+', topic_lower)) - for feed_url, source in _RSS_FEEDS: - try: - r = requests.get(feed_url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=6) - r.encoding = 'utf-8' - soup = BeautifulSoup(r.text, 'xml') - for item in soup.find_all('item')[:8]: - title = _clean(item.find('title').get_text() if item.find('title') else '') - link = _clean(item.find('link').get_text() if item.find('link') else '') - desc = _clean(item.find('description').get_text() if item.find('description') else '') - if not title or not link or link in seen_urls: continue - seen_urls.add(link) - title_words = set(re.findall(r'[A-Za-zÀ-ỹ0-9]+', title.lower())) - overlap = len(topic_words & title_words) if topic_words else 0 - exact_match = topic_lower in title.lower() or topic_lower in desc.lower() - if exact_match or overlap >= 2: - img = '' - encl = item.find('enclosure') - if encl: img = encl.get('url', '') - if not img: - try: - art_r = requests.get(link, headers={'User-Agent': 'Mozilla/5.0'}, timeout=4) - art_r.encoding = 'utf-8' - art_soup = BeautifulSoup(art_r.text, 'lxml') - ogi = art_soup.find('meta', property='og:image') - if ogi: img = ogi.get('content', '') - except: pass - all_articles.append({'title': title, 'url': link, 'raw': desc or title, 'image': img, 'via': source, 'source': {'title': title, 'url': link, 'excerpt': (desc or title)[:700], 'via': source}}) - if len(all_articles) >= limit: break - except: continue - return all_articles[:limit] - -async def _create_ai_post(topic): - ai_ext = _get_ai_ext(); ai_patch = _get_ai_patch() - articles = _search_articles_by_topic(topic, limit=4) - if not articles: - LOG.warning(f"No articles for topic: {topic}. Fallback.") - return await _create_fallback_post(topic, ai_ext, ai_patch) - posts = [] - # Get schedule time label for text intro (7h sáng, 13h trưa, 19h tối) - now = datetime.now(VN_TZ) - hour = now.hour - time_label = "7h sáng" if hour == 7 else ("13h trưa" if hour == 13 else "19h tối") - text_intro = f"Tin tóm tắt VNEWS {time_label}" - wall = ai_ext._load_ai_wall() - if not isinstance(wall, list): wall = [] - for art in articles: - try: - prompt = ai_patch._make_summary_prompt(art.get('title', topic), art.get('raw', ''), art.get('via', '')) - text = await ai_ext.qwen_generate(prompt, image_url=art.get('image'), max_tokens=1500) - text = ai_patch._postprocess_ai_text(text, max_units=20) - src = [art.get('source', {'title': art.get('title', topic), 'url': art.get('url', ''), 'via': art.get('via', '')})] - # Prepend time label intro to text (giữ nguyên title là tiêu đề gốc của bài báo) - if text and not text.startswith(text_intro): - text = f"{text_intro}\n\n{text}" - if 'Nguồn tham khảo:' not in (text or ''): - text = (text or '') + "\n\n" + ai_patch._source_line(src) - img = art.get('image') or ai_ext.pollination_image_url(art.get('title', topic)) - # Dùng art.get('title') GIỮ NGUYÊN tiêu đề gốc từ bài báo - post = ai_ext.make_post(art.get('title', topic), text, img, art.get('url', ''), 'auto_scheduled', sources=src) - try: - page_data = ai_patch._scrape_article_images(art.get('url', '')) - if page_data and page_data.get('paragraphs'): - kp = ai_patch._extract_key_points_for_slides(page_data['paragraphs'], max_points=8) - if kp: - imgs = page_data.get('images', []) - if not imgs and page_data.get('og_img'): imgs = [page_data['og_img']] - slides = [] - for i, pt in enumerate(kp): - slides.append({'text': pt, 'image': imgs[i] if i < len(imgs) else (imgs[-1] if imgs else ''), 'index': i + 1}) - post['slides'] = slides - except: pass - posts.append(post) - except Exception as e: - LOG.error(f"Error post: {e}") - if not posts: return await _create_fallback_post(topic, ai_ext, ai_patch) - wall = posts + wall - ai_ext._save_ai_wall(wall) - for post in posts: - try: _try_generate_short(post) - except: pass - return posts - -async def _create_fallback_post(topic, ai_ext, ai_patch): - LOG.info(f"Fallback: {topic}") - try: - # Still add time label to fallback posts - now = datetime.now(VN_TZ) - hour = now.hour - time_label = "7h sáng" if hour == 7 else ("13h trưa" if hour == 13 else "19h tối") - text_intro = f"Tin tóm tắt VNEWS {time_label}" - text = f"{text_intro}\n\n• {topic} đang là chủ đề nóng hôm nay.\n• Theo dõi VNEWS để cập nhật tin tức mới nhất." - img = ai_ext.pollination_image_url(topic) - post = ai_ext.make_post(topic, text, img, '', 'auto_scheduled', sources=[]) - wall = ai_ext._load_ai_wall() - if not isinstance(wall, list): wall = [] - wall = [post] + wall - ai_ext._save_ai_wall(wall) - LOG.info(f"Fallback saved: {topic}") - return [post] - except Exception as e: - LOG.error(f"Fallback failed: {e}") - return [] - -def _try_generate_short(post): - post_id = post.get('id', '') - if not post_id: return - try: - ai_ext = _get_ai_ext(); ai_patch = _get_ai_patch() - if ai_ext.gTTS is None: return - segments = ai_patch._summary_segments_from_post(post, max_segments=15) - if not segments: return - seg_hash = hashlib.md5(('|'.join(segments) + 'nu' + 'neutral' + '1.0').encode('utf-8')).hexdigest()[:8] - suffix = f"_nu_neutral_1p0_{seg_hash}_scenes_nosub" - out_mp4 = os.path.join(ai_ext.SHORTS_DIR, ai_ext._safe_name(post_id + suffix) + '.mp4') - if os.path.exists(out_mp4): - post['video'] = '/api/ai/short-file/' + post_id + suffix - wall = ai_ext._load_ai_wall() - for i, p in enumerate(wall): - if p.get('id') == post_id: wall[i] = post; break - ai_ext._save_ai_wall(wall); return - threading.Thread(target=lambda: _generate_short_worker(post, segments, post_id, suffix, out_mp4), daemon=True).start() - except Exception as e: LOG.warning(f"Short init: {e}") - -def _generate_short_worker(post, segments, post_id, suffix, out_mp4): - import subprocess - try: - ai_ext = _get_ai_ext(); ai_patch = _get_ai_patch() - work = os.path.join(ai_ext.SHORTS_DIR, ai_ext._safe_name(post_id + suffix)) - os.makedirs(work, exist_ok=True) - img = os.path.join(work, 'image.jpg') - ai_ext._download_image(post.get('img'), post.get('title', 'AI news'), img) - part_files = [] - for idx, seg in enumerate(segments[:10]): - frame = os.path.join(work, f'frame_{idx:02d}.jpg') - aud = os.path.join(work, f'voice_{idx:02d}.mp3') - aud_fast = os.path.join(work, f'voice_{idx:02d}_fast.mp3') - part = os.path.join(work, f'part_{idx:02d}.mp4') - try: ai_patch._make_scene_frame(post, seg, idx, min(len(segments), 10), img, frame, emotion='neutral') - except: - if not os.path.exists(img): continue - from PIL import Image - Image.new('RGB', (1080, 1920), (14, 14, 14)).save(frame, quality=85) - tts_text = re.sub(r'^[•\-\*\d\.\)\s]+', '', seg).strip() - try: ai_ext.gTTS(tts_text, lang='vi', slow=False).save(aud) - except: - try: ai_ext.gTTS(tts_text, lang='vi', tld='com.vn', slow=False).save(aud) - except: continue - subprocess.run(['ffmpeg', '-y', '-i', aud, '-filter:a', 'atempo=1.0', '-vn', aud_fast], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=90) - dur = 12.0 - try: - pr = subprocess.run(['ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:no_key=1', aud_fast], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20) - dur = max(8.0, float((pr.stdout or b'').decode().strip() or 12.0)) + 0.5 - except: pass - subprocess.run(['ffmpeg', '-y', '-loop', '1', '-t', str(dur), '-i', frame, '-i', aud_fast, '-shortest', '-c:v', 'libx264', '-tune', 'stillimage', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '128k', part], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=150) - part_files.append(part) - if part_files: - concat = os.path.join(work, 'concat.txt') - with open(concat, 'w', encoding='utf-8') as f: - for p in part_files: f.write("file '" + p.replace("'", "'\\''") + "'\n") - subprocess.run(['ffmpeg', '-y', '-f', 'concat', '-safe', '0', '-i', concat, '-c', 'copy', out_mp4], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180) - post['video'] = '/api/ai/short-file/' + post_id + suffix - post['short_voice'] = 'nu'; post['short_emotion'] = 'neutral'; post['short_speed'] = 1.0 - post['short_segments'] = segments; post['short_subtitles'] = False - wall = ai_ext._load_ai_wall() - for i, p in enumerate(wall): - if p.get('id') == post_id: wall[i] = post; break - ai_ext._save_ai_wall(wall) - LOG.info(f"Short: {post_id}") - except Exception as e: LOG.warning(f"Short fail: {e}") - -def _run_async(coro): - """Run async coroutine safely regardless of current event loop state.""" - try: - loop = asyncio.get_running_loop() - except RuntimeError: - return asyncio.run(coro) - import concurrent.futures - with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: - return pool.submit(asyncio.run, coro).result(timeout=300) - -def _run_scheduled_posting(): - LOG.info("=" * 50) - LOG.info("Scheduler triggered at %s", datetime.now(VN_TZ).strftime('%H:%M %d/%m/%Y')) - LOG.info("=" * 50) - try: - hot_topics = _get_hot_topics() - if not hot_topics: - LOG.warning("No hot topics"); return - selected = []; seen_labels = set() - for t in hot_topics: - label = t.get('label', '') - if label and label not in seen_labels: - seen_labels.add(label); selected.append(t['topic']) - if len(selected) >= 3: break - if len(selected) < 3: - selected = ['Thời sự Việt Nam', 'Kinh tế Việt Nam', 'Thể thao'] - LOG.info(f"Topics: {selected}") - async def _do_all(): - results = [] - for topic in selected: - try: - posts = await _create_ai_post(topic) - results.append({'topic': topic, 'posts': len(posts) if posts else 0}) - LOG.info(f"{'✓' if posts else '✗'} {topic}: {len(posts) if posts else 0} posts") - except Exception as e: - LOG.error(f"Error {topic}: {e}") - results.append({'topic': topic, 'posts': 0}) - return results - results = _run_async(_do_all()) - LOG.info(f"Done: {len(results)} topics") - for r in results: LOG.info(f" • {r['topic']}: {r['posts']} bài") - except Exception as e: - LOG.error(f"Scheduler error: {e}", exc_info=True) - -def _check_missed_slots(): - try: - state = _load_state() - today_str = datetime.now(VN_TZ).strftime('%Y-%m-%d') - now = datetime.now(VN_TZ); cur_mins = now.hour * 60 + now.minute - ran = state.get(today_str, {}) - for s in SCHEDULE_TIMES: - lbl = SCHEDULE_LABELS[s]; sm = s[0] * 60 + s[1] - if ran.get(lbl): continue - if cur_mins >= sm: - LOG.info(f"Catch-up: {lbl}") - _run_scheduled_posting() - if today_str not in state: state[today_str] = {} - state[today_str][lbl] = True; _save_state(state) - except Exception as e: LOG.error(f"Catch-up: {e}") - -def _scheduler_loop(): - LOG.info("Scheduler started") - LOG.info(f"Schedule: {', '.join(f'{h:02d}:{m:02d}' for h,m in SCHEDULE_TIMES)} VN") - state = _load_state(); today_str = datetime.now(VN_TZ).strftime('%Y-%m-%d') - ran = state.get(today_str, {}) - now = datetime.now(VN_TZ); cur_mins = now.hour * 60 + now.minute - for s in SCHEDULE_TIMES: - lbl = SCHEDULE_LABELS[s]; sm = s[0] * 60 + s[1] - if ran.get(lbl): LOG.info(f" ✓ {lbl} done"); continue - if cur_mins >= sm: - LOG.info(f" → {lbl} missed! Catch-up") - _run_scheduled_posting() - if today_str not in state: state[today_str] = {} - state[today_str][lbl] = True; _save_state(state) - else: LOG.info(f" ⏩ {lbl} upcoming") - while True: - try: - now = datetime.now(VN_TZ) - ck = (now.hour, now.minute) - state = _load_state(); today_str = now.strftime('%Y-%m-%d') - ran = state.get(today_str, {}) - for s in SCHEDULE_TIMES: - lbl = SCHEDULE_LABELS[s] - if ck == s and not ran.get(lbl): - LOG.info(f"On-time: {lbl}") - _run_scheduled_posting() - if today_str not in state: state[today_str] = {} - state[today_str][lbl] = True; _save_state(state) - break - time.sleep(60) - except Exception as e: - LOG.error(f"Loop: {e}") - time.sleep(60) - -def start_auto_scheduler(): - t = threading.Thread(target=_scheduler_loop, daemon=True, name="auto-scheduler") - t.start() - LOG.info("Auto scheduler started") - return t diff --git a/auto_update_sse.py b/auto_update_sse.py deleted file mode 100644 index 965d8911b230df411dffb1bd4aa992adeb6a5e90..0000000000000000000000000000000000000000 --- a/auto_update_sse.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Auto-update SSE endpoint for VNEWS - pushes updates when new posts/shorts published.""" -import asyncio -import json -import time -from fastapi import Request -from fastapi.responses import StreamingResponse - -# Connected clients queue -_clients = [] -_lock = asyncio.Lock() - -async def _notify_clients(event_type: str, data: dict): - """Send notification to all SSE clients.""" - if not _clients: - return - msg = f"data: {json.dumps({'type': event_type, 'data': data, 'ts': int(time.time())})}\n\n" - async with _lock: - dead = [] - for q in _clients: - try: - await q.put_nowait(msg) - except asyncio.QueueFull: - pass - except: - dead.append(q) - for q in dead: - if q in _clients: - _clients.remove(q) - -# Public functions to call from other modules -notify_new_post = lambda post: asyncio.create_task(_notify_clients("new_post", post)) if post else None -notify_new_short = lambda post: asyncio.create_task(_notify_clients("new_short", post)) if post else None - -async def sse_events(request: Request): - """SSE endpoint for real-time updates on homepage.""" - q = asyncio.Queue(maxsize=10) - _clients.append(q) - - async def event_generator(): - try: - # Send initial connection message - yield "data: {\"type\":\"connected\",\"ts\":null}\n\n" - while not await request.is_disconnected(): - try: - msg = await asyncio.wait_for(q.get(), timeout=25.0) - yield msg - except asyncio.TimeoutError: - yield ":keepalive\n\n" - except: - pass - finally: - if q in _clients: - _clients.remove(q) - - return StreamingResponse(event_generator(), media_type="text/event-stream") \ No newline at end of file diff --git a/bongda_proxy.py b/bongda_proxy.py deleted file mode 100644 index 826427d4fc358fbf512c7230cf218fd794d180cd..0000000000000000000000000000000000000000 --- a/bongda_proxy.py +++ /dev/null @@ -1,113 +0,0 @@ -"""VNEWS — Bongda Proxy Endpoint (for fast match detail loading)""" -import requests -from bs4 import BeautifulSoup -import re -import json - -def _cl(s): - return re.sub(r'\s+', ' ', str(s or '')).strip() - -def _normalize_time(raw): - t = _cl(raw) - t = re.sub(r"(\d+)'\s*\+(\d+)", r"\1+\2'", t) - t = t.replace("''", "'") - return t - -def scrape_match_html(event_id, url=None): - result = {"event_id": event_id, "found": False, "sections": []} - headers = { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", - "Referer": "https://bongda.com.vn/", - } - html = None - urls_to_try = [url] if url else [] - urls_to_try += [ - f"https://bongda.com.vn/tran-dau/{event_id}/centre/", - f"https://bongda.com.vn/tran-dau/{event_id}/preview/", - ] - for u in urls_to_try: - if not u: - continue - try: - resp = requests.get(u, headers=headers, timeout=15, allow_redirects=True) - if resp.status_code == 200 and len(resp.text) > 1000: - html = resp.text - break - except Exception: - continue - if not html: - return result - try: - soup = BeautifulSoup(html, 'html.parser') - info = {} - tel = soup.select_one('.teams') - if tel: - he = tel.select_one('.team.home') - if he: - ne = he.select_one('p:not(.logo)') or he.find('p') - if ne: info['home_team'] = _cl(ne.get_text()) - lo = he.select_one('img') - if lo: info['home_logo'] = lo.get('src', '') - ae = tel.select_one('.team.away') - if ae: - ne = ae.select_one('p:not(.logo)') or ae.find('p') - if ne: info['away_team'] = _cl(ne.get_text()) - lo = ae.select_one('img') - if lo: info['away_logo'] = lo.get('src', '') - sc = tel.select_one('.score') - if sc: - parts = [_cl(p.get_text()) for p in sc.select('p')] - if len(parts) >= 2: info['score'] = f"{parts[0]} - {parts[1]}" - lb = sc.select_one('.label') - if lb: info['status_label'] = _cl(lb.get_text()) - if info.get('home_team') and info.get('away_team'): - result['info'] = info - result['found'] = True - result['sections'].append('info') - else: - return result - events = [] - events_div = soup.select_one('.events') - if events_div: - period = '' - for child in events_div.children: - if not hasattr(child, 'name') or not child.name: continue - cls = ' '.join(child.get('class', [])) - if 'period' in cls: - h2 = child.find('h2') - if h2: period = _cl(h2.get_text()) - for ev in child.children: - if not hasattr(ev, 'name') or not ev.name: continue - ev_cls = ' '.join(ev.get('class', [])) - if 'event' not in ev_cls: continue - ev_data = {'team': 'home' if 'home' in ev_cls else 'away', 'period': period, 'type': 'unknown', 'time': ''} - type_el = ev.select_one('.event-type') - if type_el: - if type_el.select_one('[class*="redcard"]'): ev_data['type'] = 'redcard' - elif type_el.select_one('[class*="yellowcard"]'): ev_data['type'] = 'yellowcard' - elif type_el.select_one('[class*="goal"]'): ev_data['type'] = 'goal' - elif type_el.select_one('[class*="substitution"]'): ev_data['type'] = 'substitution' - players_el = ev.select_one('.players') - if players_el: - time_el = players_el.select_one('.event-time') - if time_el: ev_data['time'] = _normalize_time(time_el.get_text()) - text = _cl(players_el.get_text(' ', strip=True).replace(ev_data['time'], '').strip()) - ev_data['players'] = text - events.append(ev_data) - if events: - result['events'] = events - result['sections'].append('events') - except Exception as e: - result['error'] = str(e) - return result - -from fastapi import Query -from fastapi.responses import JSONResponse - -def add_bongda_proxy_endpoint(app): - @app.get('/api/proxy/bongda') - def proxy_bongda(event_id: int = Query(default=None), url: str = Query(default=None)): - if event_id is None: - return JSONResponse({'error': 'event_id required'}, status_code=400) - return JSONResponse(scrape_match_html(event_id, url)) diff --git a/logs_route.py b/logs_route.py deleted file mode 100644 index f703d40d12d08f49b5dbc361c0d7ef70da5a6a67..0000000000000000000000000000000000000000 --- a/logs_route.py +++ /dev/null @@ -1,106 +0,0 @@ -"""Independent logs page for VNEWS Space. -Serves /logs (HTML) and /logs.txt (raw) so build/runtime errors are visible -even when the Hugging Face build-logs tab is stuck/unavailable. -Mounted from _run.py. -""" -import os -import time -import json -import subprocess -from fastapi import Request -from fastapi.responses import HTMLResponse, PlainTextResponse - -try: - from app_v2_entry import app -except Exception: - from main import app - -BUILD_DONE = "/app/.build_done" -DATA_DIR = '/data' if os.path.isdir('/data') else os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data') - - -def _collect(): - lines = [] - lines.append("=== VNEWS LOGS ===") - lines.append("generated: " + time.strftime('%Y-%m-%d %H:%M:%S', time.localtime())) - lines.append("") - # Build marker - if os.path.exists(BUILD_DONE): - lines.append("[BUILD] .build_done exists -> container started OK") - try: - lines.append("[BUILD] built at: " + open(BUILD_DONE).read().strip()) - except Exception: - pass - else: - lines.append("[BUILD] WARNING: .build_done MISSING -> uvicorn started before build finished?") - lines.append("") - - # Space status from HF runtime file - try: - import json as _j - mj = os.path.join(os.path.dirname(os.path.abspath(__file__)), '.huggingface', 'main.json') - if os.path.exists(mj): - lines.append("[RUNTIME] .huggingface/main.json present") - else: - lines.append("[RUNTIME] .huggingface/main.json NOT found") - except Exception as e: - lines.append("[RUNTIME] error: " + str(e)) - lines.append("") - - # Data dir contents - lines.append("[DATA] dir=" + DATA_DIR) - try: - if os.path.isdir(DATA_DIR): - for f in sorted(os.listdir(DATA_DIR)): - p = os.path.join(DATA_DIR, f) - lines.append(" - %s (%d bytes)" % (f, os.path.getsize(p))) - else: - lines.append(" (data dir missing)") - except Exception as e: - lines.append(" error: " + str(e)) - lines.append("") - - # Recent container logs (stdout) if captured - log_paths = ["/tmp/vnews_stdout.log", os.path.join(DATA_DIR, "app.log")] - for lp in log_paths: - if os.path.exists(lp): - lines.append("[STDOUT] tail of " + lp + ":") - try: - with open(lp, "r", errors="replace") as fh: - tail = fh.read().splitlines()[-50:] - for l in tail: - lines.append(" " + l) - except Exception as e: - lines.append(" read error: " + str(e)) - lines.append("") - - # Environment hints - lines.append("[ENV] HF_SPACE: " + os.environ.get("HF_SPACE", "?")) - lines.append("[ENV] SPACE_ID: " + os.environ.get("SPACE_ID", "?")) - lines.append("[ENV] CUDA/CPU: " + ("gpu" if os.environ.get("CUDA_VISIBLE_DEVICES") else "cpu")) - lines.append("") - lines.append("=== END ===") - return "\n".join(lines) - - -@app.get("/logs") -def logs_page(request: Request): - txt = _collect() - html = ( - "" - "" - "VNEWS Logs" - "" - "

VNEWS — Build & Runtime Logs

" - "

📄 raw text · refresh để cập nhật

" - "
" + txt.replace("&", "&").replace("<", "<").replace(">", ">") + "
" - "" - ) - return HTMLResponse(html) - - -@app.get("/logs.txt") -def logs_raw(request: Request): - return PlainTextResponse(_collect()) diff --git a/main.py b/main.py index 4d0e3bb2a1140e49069cb50d87e34f8835d21c6e..71fc8aa3142ed2a3e8c1eba2e92c594bc646eca2 100644 --- a/main.py +++ b/main.py @@ -272,10 +272,12 @@ def api_livescore_date(date:str):return JSONResponse({"html":fetch_bongda_api(f" @app.get("/api/livescore/updates7d") def api_livescore_updates7d(): + """Aggregate results + incoming matches from past 7 days and next 7 days.""" def _f(): from datetime import date as _date today = _date.today() all_html = [] + # Past 7 days (results) for i in range(7, 0, -1): d = (today - timedelta(days=i)).strftime("%Y-%m-%d") html = fetch_bongda_api(f"/api/fixtures/get-by-date?date={d}&status=finished") @@ -287,6 +289,7 @@ def api_livescore_updates7d(): dt.string = f"📅 {day_label}" match.insert(0, dt) all_html.append(str(soup)) + # Next 7 days (upcoming) for i in range(7): d = (today + timedelta(days=i)).strftime("%Y-%m-%d") html = fetch_bongda_api(f"/api/fixtures/get-by-date?date={d}") @@ -548,5 +551,6 @@ def api_storage_status(): def api_hot_topics(): return JSONResponse({"topics":[]}) -# IMPORTANT: No root GET route here - app_v2_entry.py handles the homepage via serve_index() -# This file only defines API routes and utilities \ No newline at end of file +@app.get("/", response_class=HTMLResponse) +async def root(): + return HTMLResponse("

VNEWS v17

VTV Digital CDN ssaimh · No shorts Dantri/SKDS · Homepage full content

") \ No newline at end of file diff --git a/main_patch.py b/main_patch.py deleted file mode 100644 index ac0921a0db85a125a039effe4380b4394fa2d16d..0000000000000000000000000000000000000000 --- a/main_patch.py +++ /dev/null @@ -1,8 +0,0 @@ -# PATCH: Add these 2 lines to main.py right after "app = FastAPI()" -# Line 1: from vtv_api import router as vtv_router -# Line 2: app.include_router(vtv_router) -# -# This enables the VTV1-VTV10 + VTVPrime stream endpoints: -# GET /api/vtv/streams - Get all channel streams -# GET /api/vtv/stream/{id} - Get specific channel stream -# GET /api/proxy/page?url=... - Proxy web pages (for xemtv PHP scraping) diff --git a/match_detail.py b/match_detail.py deleted file mode 100644 index 32d439d20eca4a7dfe643364acdfe4eb1f283d32..0000000000000000000000000000000000000000 --- a/match_detail.py +++ /dev/null @@ -1,309 +0,0 @@ -""" -Match Detail Scraper for bongda.com.vn -""" -import requests, re, json, time, threading -from bs4 import BeautifulSoup - -def _sp(html): - try: - return BeautifulSoup(html, 'lxml') - except: - return BeautifulSoup(html, 'html.parser') - -BH = { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", - "Accept": "application/json, text/javascript, */*; q=0.01", - "Referer": "https://bongda.com.vn/", - "X-Requested-With": "XMLHttpRequest", -} -HH = { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", - "Referer": "https://bongda.com.vn/", -} - -def _cl(s): - return re.sub(r'\s+', ' ', str(s or '')).strip() - -def _api(ep, params=None): - try: - url = f"https://bongda.com.vn{ep}" - if params: - url += "?" + "&".join(f"{k}={v}" for k, v in params.items()) - r = requests.get(url, headers=BH, timeout=15) - if r.status_code == 200: - try: return r.json() - except: pass - except: pass - return None - -def _get_teams(soup): - info = {} - tel = soup.select_one('.teams') - if not tel: - return info - he = tel.select_one('.team.home, .home-team') - if he: - ne = he.select_one('p:not(.logo)') or he.find('p') - if ne: info['home_team'] = _cl(ne.get_text()) - lo = he.select_one('img') - if lo: info['home_logo'] = lo.get('src', '') - le = he if he.name == 'a' else he.find('a') - if le and le.get('href'): - m = re.search(r'/doi-bong/(\d+)/', le['href']) - if m: info['home_team_id'] = m.group(1) - ae = tel.select_one('.team.away, .away-team') - if ae: - ne = ae.select_one('p:not(.logo)') or ae.find('p') - if ne: info['away_team'] = _cl(ne.get_text()) - lo = ae.select_one('img') - if lo: info['away_logo'] = lo.get('src', '') - le = ae if ae.name == 'a' else ae.find('a') - if le and le.get('href'): - m = re.search(r'/doi-bong/(\d+)/', le['href']) - if m: info['away_team_id'] = m.group(1) - sc = tel.select_one('.score') - if sc: - parts = [_cl(p.get_text()) for p in sc.select('p')] - if len(parts) >= 2: info['score'] = f"{parts[0]} - {parts[1]}" - lb = sc.select_one('.label') - if lb: info['status_label'] = _cl(lb.get_text()) - return info - -def _get_timeline(soup): - tl = [] - el = soup.select_one('.timeline') - if not el: return tl - half = '' - for c in el.children: - if not hasattr(c, 'name') or not c.name: continue - t = _cl(c.get_text()) - if not t: continue - if t in ['H1','H2','Hiệp 1','Hiệp 2']: - half = t; continue - m = re.match(r"(\d+'\+?\d*)", t) - if m: - tl.append({'time': m.group(1), 'text': t[m.end():].strip(), 'half': half}) - elif len(t) > 5: - tl.append({'time': '', 'text': t, 'half': half}) - return tl - -def _get_events(soup): - evts = [] - for el in soup.select('.event'): - e = {} - cl = ' '.join(el.get('class', [])) - e['team'] = 'home' if 'home' in cl else ('away' if 'away' in cl else '') - ps = [_cl(p.get_text()) for p in el.select('p')] - ps = [p for p in ps if p] - if ps: e['players'] = ps - tl = el.select_one('.time, .minute, span') - if tl: e['time'] = _cl(tl.get_text()) - evts.append(e) - return evts - -def _get_stats(soup): - st = {} - for sel in ['.match-stats','[class*="stats"]']: - el = soup.select_one(sel) - if el and len(str(el)) > 50: - for row in el.select('li,tr,.stat-row'): - cells = row.select('td,span,p') - if len(cells) >= 3: - lb = _cl(cells[0].get_text()) - if lb: st[lb] = {'home': _cl(cells[1].get_text()), 'away': _cl(cells[2].get_text())} - if st: break - return st - -def _get_h2h(soup): - h2h = {'matches': [], 'stats': {}} - for sel in ['.head-to-head','[class*="h2h"]']: - el = soup.select_one(sel) - if el and len(str(el)) > 50: - for it in el.select('li,tr,.match-item'): - m = {} - cells = it.select('td,span,p') - if len(cells) >= 3: - m['date'] = _cl(cells[0].get_text()) - m['home'] = _cl(cells[1].get_text()) - m['score'] = _cl(cells[2].get_text()) - if m.get('home'): - if len(cells) > 3: m['away'] = _cl(cells[3].get_text()) - h2h['matches'].append(m) - if h2h['matches']: break - return h2h - -def _get_form(soup): - f = {'home': [], 'away': []} - for sel in ['.form-guide','[class*="form"]']: - el = soup.select_one(sel) - if el and len(str(el)) > 50: - items = el.select('li,.form-item,tr') - for it in items[:10]: - t = _cl(it.get_text()) - if t: f['home'].append({'text': t}) - for it in items[10:20]: - t = _cl(it.get_text()) - if t: f['away'].append({'text': t}) - break - return f - -def _get_info(soup): - info = {} - mi = soup.select_one('.match-info') - if mi: - te = mi.select_one('.times,li') - if te: info['datetime'] = _cl(te.get_text()) - le = soup.select_one('.league,.tournament,[class*="league"]') - if le: info['league'] = _cl(le.get_text()) - return info - -def _scrape(url): - print(f"[DEBUG] _scrape: {url[:80]}", flush=True) - try: - r = requests.get(url, headers=HH, timeout=15, allow_redirects=True) - print(f"[DEBUG] HTTP={r.status_code}", flush=True) - if r.status_code != 200: - return False, {} - sp = _sp(r.text) - d = {} - - teams = _get_teams(sp) - print(f"[DEBUG] teams={teams}", flush=True) - if teams: d['info'] = teams - - mi = _get_info(sp) - if mi: - d.setdefault('info', {}).update(mi) - - tl = _get_timeline(sp) - if tl: - d['timeline'] = tl - d['commentaries_html'] = '\n'.join([f"{t.get('time','')} {t.get('text','')}" for t in tl]) - - ev = _get_events(sp) - if ev: d['events'] = ev - - st = _get_stats(sp) - if st: - d['stats_parsed'] = st - d['stats_html'] = str(st) - - h2h = _get_h2h(sp) - if h2h.get('matches'): d['h2h_matches'] = h2h['matches'] - if h2h.get('stats'): d['h2h_stats'] = h2h['stats'] - - if '/preview/' in url: - fm = _get_form(sp) - if fm.get('home'): d['home_form'] = fm['home'] - if fm.get('away'): d['away_form'] = fm['away'] - - print(f"[DEBUG] success keys={list(d.keys())}", flush=True) - return True, d - except Exception as e: - import traceback - print(f"[DEBUG] error: {e}", flush=True) - traceback.print_exc() - return False, {} - -def fetch_match_detail_by_url(url): - m = re.search(r'/tran-dau/(\d+)/', url) - if not m: return {"error": "Could not extract event_id", "found": False} - event_id = int(m.group(1)) - res = {"event_id": event_id, "found": False, "sections": []} - _fetch_api(event_id, res) - ok, d = _scrape(url) - print(f"[DEBUG] by_url: ok={ok} d_keys={list(d.keys())}", flush=True) - if ok: _merge(res, d) - return res - -def fetch_match_detail(event_id): - print(f"[DEBUG] fetch_match_detail({event_id})", flush=True) - res = {"event_id": event_id, "found": False, "sections": []} - _fetch_api(event_id, res) - - for pt in ["centre", "preview"]: - url = f"https://bongda.com.vn/tran-dau/{event_id}/{pt}/" - ok, d = _scrape(url) - print(f"[DEBUG] {pt}: ok={ok}", flush=True) - if ok: - _merge(res, d) - if res.get("found"): break - - print(f"[DEBUG] final: found={res['found']} sections={res['sections']}", flush=True) - return res - -def _fetch_api(eid, res): - pm = _api("/api/event-standing/pre-match", {"event_id": eid}) - res["pre_match"] = pm - res["pre_match_html"] = pm.get("html","") if pm and pm.get("status")=="success" and len(pm.get("html","").strip())>10 else "" - - hm = _api("/api/fixtures/h2h-match", {"event_id": eid}) - res["h2h_match"] = hm - if hm and hm.get("status")=="success": - h = hm.get("html","") - if len(h.strip())>10: - res["h2h_html"] = h - res["sections"].append("h2h") - else: res["h2h_html"] = "" - - hs = _api("/api/fixtures/h2h-stats", {"event_id": eid}) - res["h2h_stats"] = hs - if hs and hs.get("status")=="success": - h = hs.get("html","") - if len(h.strip())>10: - res["h2h_stats_html"] = h - res["sections"].append("h2h_stats") - try: - sp = _sp(h) - stats = {} - for row in sp.select('li,tr,.stat-row'): - cells = row.select('td,span,p') - if len(cells)>=3: - lb = _cl(cells[0].get_text()) - if lb: stats[lb] = {'home': _cl(cells[1].get_text()), 'away': _cl(cells[2].get_text())} - if stats: res["h2h_stats_parsed"] = stats - except: pass - else: res["h2h_stats_html"] = "" - - pf = _api("/api/event-standing/player-performance", {"event_id": eid}) - res["performance"] = pf - if pf and pf.get("status")=="success" and len(pf.get("html","").strip())>10: - res["stats_html"] = pf["html"] - res["sections"].append("stats") - else: res["stats_html"] = "" - - cm = _api("/api/fixtures/commentaries", {"event_id": eid}) - if cm and cm.get("status")=="success" and len(cm.get("html","").strip())>10: - res["commentaries_html"] = cm["html"] - res["sections"].append("commentaries") - elif not res.get("commentaries_html"): res["commentaries_html"] = "" - -def _merge(res, d): - if d.get("info"): - res.setdefault("info", {}).update(d["info"]) - res["found"] = True - if "info" not in res["sections"]: res["sections"].append("info") - if d.get("timeline"): - res["timeline"] = d["timeline"] - if not res.get("commentaries_html"): res["commentaries_html"] = d.get("commentaries_html","") - res["sections"].append("commentaries") - if d.get("events"): - res["events"] = d["events"] - res["sections"].append("events") - if d.get("stats_parsed"): - res["stats_parsed"] = d["stats_parsed"] - if not res.get("stats_html"): res["stats_html"] = d.get("stats_html","") - res["sections"].append("stats") - if d.get("h2h_matches"): - res["h2h"] = d["h2h_matches"] - res["sections"].append("h2h") - if d.get("h2h_stats"): - res["h2h_stats_parsed"] = d["h2h_stats"] - res["sections"].append("h2h_stats") - if d.get("home_form"): - res["home_form"] = d["home_form"] - res["sections"].append("home_form") - if d.get("away_form"): - res["away_form"] = d["away_form"] - res["sections"].append("away_form") diff --git a/match_detail_v2.py b/match_detail_v2.py deleted file mode 100644 index 825a2ab3c4841ef46ef8714467a787e7a4fecc2b..0000000000000000000000000000000000000000 --- a/match_detail_v2.py +++ /dev/null @@ -1,418 +0,0 @@ -"""VNEWS — Match Detail Parser v2 (html.parser only, no lxml dependency)""" -import re -import requests -from bs4 import BeautifulSoup - -HEADERS = { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", - "Accept-Language": "vi-VN,vi;q=0.9", - "Referer": "https://bongda.com.vn/", -} - -API_HEADERS = { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", - "Accept": "application/json, text/javascript, */*; q=0.01", - "X-Requested-With": "XMLHttpRequest", - "Referer": "https://bongda.com.vn/", -} - - -def _cl(s): - return re.sub(r'\s+', ' ', str(s or '')).strip() - - -def _normalize_time(raw): - t = _cl(raw) - if not t: - return t - t = re.sub(r"(\d+)'\s*\+(\d+)", r"\1+\2'", t) - t = t.replace("''", "'") - return t - - -def _mk(html): - """Parse HTML using html.parser (lxml may not be available).""" - return BeautifulSoup(html, 'html.parser') - - -def fetch_html(url, timeout=8): - resp = requests.get(url, headers=HEADERS, timeout=timeout, allow_redirects=True) - resp.raise_for_status() - return resp.text - - -def parse_events(sp): - """Parse .events > .period > .event structure.""" - events = [] - events_div = sp.select_one('.events') - if not events_div: - return events - - current_period = '' - for child in events_div.children: - if not hasattr(child, 'name') or not child.name: - continue - cls_str = ' '.join(child.get('class', []) if child.get('class') else []) - - if 'period' in cls_str: - h2 = child.find('h2') - if h2: - current_period = _cl(h2.get_text()) - - for ev in child.children: - if not hasattr(ev, 'name') or not ev.name: - continue - ev_cls_str = ' '.join(ev.get('class', []) if ev.get('class') else []) - if 'event' not in ev_cls_str: - continue - - team = 'home' if 'home' in ev_cls_str else 'away' - ev_data = { - 'team': team, 'period': current_period, 'type': 'unknown', - 'time': '', 'players': '', 'player_in': '', 'player_out': '', - 'scorer': '', 'assist': '', 'card_type': '', 'player': '', - } - - type_el = ev.select_one('.event-type') - if type_el: - if type_el.select_one('[class*="redcard"]'): - ev_data['type'] = 'redcard'; ev_data['card_type'] = 'red' - elif type_el.select_one('[class*="yellowcard"]'): - ev_data['type'] = 'yellowcard'; ev_data['card_type'] = 'yellow' - elif type_el.select_one('[class*="goal"]'): - ev_data['type'] = 'goal' - elif type_el.select_one('[class*="substitution"]'): - ev_data['type'] = 'substitution' - else: - for rect in type_el.select('svg rect'): - if rect.get('fill') == '#E20007': - ev_data['type'] = 'redcard'; ev_data['card_type'] = 'red'; break - if ev_data['type'] == 'unknown': - for circle in type_el.select('svg circle'): - if circle.get('fill') == 'white' and circle.get('r') == '8': - ev_data['type'] = 'goal'; break - if ev_data['type'] == 'unknown' and ev.select_one('.players.subst'): - ev_data['type'] = 'substitution' - - players_el = ev.select_one('.players') - if players_el and ev_data['type'] == 'unknown': - pcls = ' '.join(players_el.get('class', []) if players_el.get('class') else []) - if 'goal' in pcls: ev_data['type'] = 'goal' - elif 'card' in pcls: ev_data['type'] = 'redcard'; ev_data['card_type'] = 'red' - elif 'subst' in pcls: ev_data['type'] = 'substitution' - - if players_el: - time_el = players_el.select_one('.event-time') - if time_el: - ev_data['time'] = _normalize_time(time_el.get_text()) - ev_data['players'] = _cl(players_el.get_text(' ', strip=True)) - - texts = [] - for d in players_el.find_all('div', recursive=False): - t = _cl(d.get_text()) - if t and t != ev_data['time']: - texts.append(t) - for p in players_el.find_all('p', recursive=False): - t = _cl(p.get_text()) - if t and t not in texts: - texts.append(t) - - if ev_data['type'] == 'substitution': - if len(texts) >= 2: - ev_data['player_out'] = texts[0]; ev_data['player_in'] = texts[1] - elif len(texts) == 1: - ev_data['player_in'] = texts[0] - elif ev_data['type'] == 'goal': - if len(texts) >= 1: ev_data['scorer'] = texts[0] - if len(texts) >= 2: ev_data['assist'] = texts[1] - elif ev_data['type'] in ('redcard', 'yellowcard'): - if texts: ev_data['player'] = ' '.join(texts) - - events.append(ev_data) - return events - - -def fetch_match_detail(event_id: int) -> dict: - import concurrent.futures - result = {"event_id": event_id, "found": False, "sections": []} - - html = None - base = f"https://bongda.com.vn/tran-dau/{event_id}" - urls = [base + suffix for suffix in ['/centre/', '/preview/', '/bao-cao-nhanh/']] - - # Try all URLs in parallel, take first success - with concurrent.futures.ThreadPoolExecutor(max_workers=3) as ex: - futures = {ex.submit(requests.get, url, headers=HEADERS, timeout=8, allow_redirects=True): url for url in urls} - for future in concurrent.futures.as_completed(futures, timeout=12): - try: - resp = future.result() - if resp.status_code == 200 and len(resp.text) > 1000: - html = resp.text - for f in futures: - f.cancel() - break - except Exception: - continue - - if not html: - return result - - sp = _mk(html) - info = {} - - tel = sp.select_one('.teams') - if tel: - he = tel.select_one('.team.home') or tel.select_one('[class*="home"]') - if he: - ne = he.select_one('p:not(.logo)') or he.find('p') - if ne: info['home_team'] = _cl(ne.get_text()) - lo = he.select_one('img') - if lo: info['home_logo'] = lo.get('src', '') - - ae = tel.select_one('.team.away') or tel.select_one('[class*="away"]') - if ae: - ne = ae.select_one('p:not(.logo)') or ae.find('p') - if ne: info['away_team'] = _cl(ne.get_text()) - lo = ae.select_one('img') - if lo: info['away_logo'] = lo.get('src', '') - - sc = tel.select_one('.score') - if sc: - parts = [_cl(p.get_text()) for p in sc.select('p')] - if len(parts) >= 2: info['score'] = f"{parts[0]} - {parts[1]}" - lb = sc.select_one('.label') - if lb: info['status_label'] = _cl(lb.get_text()) - - if info.get('home_team') and info.get('away_team'): - result['info'] = info - result['found'] = True - result['sections'].append('info') - else: - return result - - mi = sp.select_one('.match-info') - if mi: - for sel in ['.times', 'li']: - el = mi.select_one(sel) - if el: - t = _cl(el.get_text()) - if t: info.setdefault('datetime', t); break - - events = parse_events(sp) - if events: - result['events'] = events - result['sections'].append('events') - - pred = sp.select_one('.prediction-card') - if pred: - pred_data = {} - team_info = pred.select_one('.team-info') - if team_info: - teams = team_info.select('.team') - if len(teams) >= 2: - pred_data['home_name'] = _cl(teams[0].select_one('.team-name').get_text()) if teams[0].select_one('.team-name') else '' - pred_data['away_name'] = _cl(teams[1].select_one('.team-name').get_text()) if teams[1].select_one('.team-name') else '' - divider = team_info.select_one('.divider') - if divider: pred_data['result'] = _cl(divider.get_text()) - vote_count = pred.select_one('.vote-count') - if vote_count: pred_data['vote_count'] = _cl(vote_count.get_text()) - result['prediction'] = pred_data - - try: - ar = requests.get( - f"https://bongda.com.vn/api/fixtures/h2h-stats?event_id={event_id}", - headers=API_HEADERS, timeout=6 - ) - if ar.status_code == 200: - ad = ar.json() - if ad.get('status') == 'success' and ad.get('html'): - asp = _mk(ad['html']) - ast = {} - for row in asp.select('li, tr, .stat-row'): - cells = row.select('td, span, p') - if len(cells) >= 3: - lb = _cl(cells[0].get_text()) - if lb: ast[lb] = {'home': _cl(cells[1].get_text()), 'away': _cl(cells[2].get_text())} - if ast: result['h2h_stats_parsed'] = ast; result['sections'].append('h2h_stats') - except Exception: - pass - - h2h_data = [] - h2h_el = sp.select_one('.h2h-standings') - if h2h_el: - rows = h2h_el.select('.ranking-table tbody tr, .leaderboard tr') - for row in rows: - cells = row.select('td') - if len(cells) >= 4: - logo = row.select_one('img') - name_el = row.select_one('.team-name, p.link, .name') - h2h_data.append({ - 'pos': _cl(cells[0].get_text()), - 'logo': logo.get('src', '') if logo else '', - 'name': _cl(name_el.get_text()) if name_el else '', - 'played': _cl(cells[1].get_text()) if len(cells) > 1 else '', - 'wins': _cl(cells[2].get_text()) if len(cells) > 2 else '', - 'draws': _cl(cells[3].get_text()) if len(cells) > 3 else '', - 'losses': _cl(cells[4].get_text()) if len(cells) > 4 else '', - 'gf': _cl(cells[5].get_text()) if len(cells) > 5 else '', - 'ga': _cl(cells[6].get_text()) if len(cells) > 6 else '', - 'points': _cl(cells[8].get_text()) if len(cells) > 8 else '', - }) - if h2h_data: result['h2h_standings'] = h2h_data; result['sections'].append('h2h_standings') - - recent_matches = [] - matches_list = sp.select_one('.matches-list') - if matches_list: - for item in matches_list.select('.match-detail, .match-item, li'): - date_el = item.select_one('.date, .time, .match-time') - league_el = item.select_one('.league') - home_el = item.select_one('.home, .team-home') - away_el = item.select_one('.away, .team-away') - score_el = item.select_one('.score, .result') - if home_el or away_el: - recent_matches.append({ - 'date': _cl(date_el.get_text()) if date_el else '', - 'league': _cl(league_el.get_text()) if league_el else '', - 'home': _cl(home_el.get_text()) if home_el else '', - 'away': _cl(away_el.get_text()) if away_el else '', - 'score': _cl(score_el.get_text()) if score_el else 'vs', - }) - if recent_matches: result['recent_matches'] = recent_matches; result['sections'].append('recent') - - return result - - -def fetch_match_detail_by_url(url: str) -> dict: - import concurrent.futures - eid_match = re.search(r'/tran-dau/(\d+)/', url) - if not eid_match: - return {"event_id": 0, "found": False, "error": "Cannot extract event_id from URL"} - event_id = int(eid_match.group(1)) - result = {"event_id": event_id, "found": False, "sections": []} - - html = None - try: - resp = requests.get(url, headers=HEADERS, timeout=8, allow_redirects=True) - if resp.status_code == 200 and len(resp.text) > 1000: - html = resp.text - except Exception: - pass - - if not html: - return fetch_match_detail(event_id) - - sp = _mk(html) - info = {} - - tel = sp.select_one('.teams') - if tel: - he = tel.select_one('.team.home') or tel.select_one('[class*="home"]') - if he: - ne = he.select_one('p:not(.logo)') or he.find('p') - if ne: info['home_team'] = _cl(ne.get_text()) - lo = he.select_one('img') - if lo: info['home_logo'] = lo.get('src', '') - ae = tel.select_one('.team.away') or tel.select_one('[class*="away"]') - if ae: - ne = ae.select_one('p:not(.logo)') or ae.find('p') - if ne: info['away_team'] = _cl(ne.get_text()) - lo = ae.select_one('img') - if lo: info['away_logo'] = lo.get('src', '') - sc = tel.select_one('.score') - if sc: - parts = [_cl(p.get_text()) for p in sc.select('p')] - if len(parts) >= 2: info['score'] = f"{parts[0]} - {parts[1]}" - lb = sc.select_one('.label') - if lb: info['status_label'] = _cl(lb.get_text()) - - if info.get('home_team') and info.get('away_team'): - result['info'] = info; result['found'] = True; result['sections'].append('info') - else: - return fetch_match_detail(event_id) - - mi = sp.select_one('.match-info') - if mi: - te = mi.select_one('.times, li') - if te: info.setdefault('datetime', _cl(te.get_text())) - - events = parse_events(sp) - if events: result['events'] = events; result['sections'].append('events') - - pred = sp.select_one('.prediction-card') - if pred: - pred_data = {} - team_info = pred.select_one('.team-info') - if team_info: - teams = team_info.select('.team') - if len(teams) >= 2: - pred_data['home_name'] = _cl(teams[0].select_one('.team-name').get_text()) if teams[0].select_one('.team-name') else '' - pred_data['away_name'] = _cl(teams[1].select_one('.team-name').get_text()) if teams[1].select_one('.team-name') else '' - divider = team_info.select_one('.divider') - if divider: pred_data['result'] = _cl(divider.get_text()) - vote_count = pred.select_one('.vote-count') - if vote_count: pred_data['vote_count'] = _cl(vote_count.get_text()) - result['prediction'] = pred_data - - try: - ar = requests.get( - f"https://bongda.com.vn/api/fixtures/h2h-stats?event_id={event_id}", - headers=API_HEADERS, timeout=6 - ) - if ar.status_code == 200: - ad = ar.json() - if ad.get('status') == 'success' and ad.get('html'): - asp = _mk(ad['html']) - ast = {} - for row in asp.select('li, tr, .stat-row'): - cells = row.select('td, span, p') - if len(cells) >= 3: - lb = _cl(cells[0].get_text()) - if lb: ast[lb] = {'home': _cl(cells[1].get_text()), 'away': _cl(cells[2].get_text())} - if ast: result['h2h_stats_parsed'] = ast; result['sections'].append('h2h_stats') - except Exception: - pass - - h2h_data = [] - h2h_el = sp.select_one('.h2h-standings') - if h2h_el: - rows = h2h_el.select('.ranking-table tbody tr, .leaderboard tr') - for row in rows: - cells = row.select('td') - if len(cells) >= 4: - logo = row.select_one('img') - name_el = row.select_one('.team-name, p.link, .name') - h2h_data.append({ - 'pos': _cl(cells[0].get_text()), - 'logo': logo.get('src', '') if logo else '', - 'name': _cl(name_el.get_text()) if name_el else '', - 'played': _cl(cells[1].get_text()) if len(cells) > 1 else '', - 'wins': _cl(cells[2].get_text()) if len(cells) > 2 else '', - 'draws': _cl(cells[3].get_text()) if len(cells) > 3 else '', - 'losses': _cl(cells[4].get_text()) if len(cells) > 4 else '', - 'gf': _cl(cells[5].get_text()) if len(cells) > 5 else '', - 'ga': _cl(cells[6].get_text()) if len(cells) > 6 else '', - 'points': _cl(cells[8].get_text()) if len(cells) > 8 else '', - }) - if h2h_data: result['h2h_standings'] = h2h_data; result['sections'].append('h2h_standings') - - recent_matches = [] - matches_list = sp.select_one('.matches-list') - if matches_list: - for item in matches_list.select('.match-detail, .match-item, li'): - date_el = item.select_one('.date, .time, .match-time') - league_el = item.select_one('.league') - home_el = item.select_one('.home, .team-home') - away_el = item.select_one('.away, .team-away') - score_el = item.select_one('.score, .result') - if home_el or away_el: - recent_matches.append({ - 'date': _cl(date_el.get_text()) if date_el else '', - 'league': _cl(league_el.get_text()) if league_el else '', - 'home': _cl(home_el.get_text()) if home_el else '', - 'away': _cl(away_el.get_text()) if away_el else '', - 'score': _cl(score_el.get_text()) if score_el else 'vs', - }) - if recent_matches: result['recent_matches'] = recent_matches; result['sections'].append('recent') - - return result diff --git a/opinion_v3_patch.py b/opinion_v3_patch.py deleted file mode 100644 index c61e06bd33baedd80ca6b8433078547ee2bb306f..0000000000000000000000000000000000000000 --- a/opinion_v3_patch.py +++ /dev/null @@ -1,179 +0,0 @@ -""" -PERSONAL OPINION POST v3 - AI synthesis from opinion + hot sources -Standalone module. Uses lazy imports to avoid circular dependency with app_v2_entry. -""" -import os, re, json, time, uuid, threading -from fastapi import Request, Query -from fastapi.responses import JSONResponse -from urllib.parse import quote_plus, quote - -try: - from main import app -except: - from fastapi import FastAPI - app = FastAPI() - -# Lazy imports to avoid circular deps -def _get_helpers(): - """Get helper functions from app_v2_entry (lazy, after startup).""" - import sys - mod = sys.modules.get('app_v2_entry') - if mod is None: - # Fallback: define minimal versions - return { - '_clean': lambda s: re.sub(r"\s+", " ", str(s or "")).strip(), - '_has_kw': lambda topic, title: (topic or '').lower() in (title or '').lower(), - '_search_all': lambda topic, limit=12: [], - '_get_hot_topics': lambda: [], - '_load_wall_posts': lambda: [], - '_save_wall_posts': lambda posts: None, - } - return mod.__dict__ - -async def _get_qwen(): - """Get qwen_generate lazily.""" - import sys - for mod_name in ['app_v2_entry', 'ai_ext']: - mod = sys.modules.get(mod_name) - if mod and hasattr(mod, 'qwen_generate'): - return mod.qwen_generate - return None - - -async def _ai_synthesize(topic, opinion_text, hot_sources, max_tokens=1200): - """Generate an article using AI by combining user opinion + news context.""" - sources_str = "\n".join([f"- {s.get('title','')} ({s.get('via','')})" for s in hot_sources[:8]]) - - prompt = f'''Dưới đây là quan điểm của người dùng và các tin tức liên quan. - -QUAN ĐIỂM CÁ NHÂN: -{opinion_text} - -TIN TỨC LIÊN QUAN (tham khảo): -{sources_str} - -YÊU CẦU: Hãy viết một bài viết tổng hợp dựa trên quan điểm trên và các nguồn tin liên quan. -Bài viết cần: -1. Có tiêu đề hấp dẫn (bắt đầu bằng "## ") -2. Trình bày quan điểm của người dùng làm nòng cốt -3. Lồng ghép thông tin từ các nguồn tin để hỗ trợ/đối chiếu -4. Kết luận ở cuối -5. Viết bằng tiếng Việt tự nhiên, dài 300-500 từ -6. Định dạng Markdown rõ ràng với heading, bullet points nếu cần''' - - qwen = await _get_qwen() - if qwen: - try: - result = await qwen(prompt, max_tokens=max_tokens) - if result: - return result - except Exception: - pass - return f"**{topic}**\n\n{opinion_text}\n\n*Bài viết đang được cập nhật...*" - - -@app.post('/api/opinion/post') -async def api_opinion_post(request: Request): - """POST /api/opinion/post - Body: {"topic", "opinion", "sources": [{"title","url","via"}]} - Returns: {"article": "markdown", "title": "...", "post": {...}, "ok": true} - """ - h = _get_helpers() - _search_all = h.get('_search_all', lambda t,l=12: []) - _get_hot_topics = h.get('_get_hot_topics', lambda: []) - _has_kw = h.get('_has_kw', lambda t,tt: (t or '').lower() in (tt or '').lower()) - _load_wall_posts = h.get('_load_wall_posts', lambda: []) - _save_wall_posts = h.get('_save_wall_posts', lambda p: None) - _clean = h.get('_clean', lambda s: re.sub(r"\s+", " ", str(s or "")).strip()) - - try: - body = await request.json() - except Exception: - return JSONResponse({"error": "Invalid JSON"}, status_code=400) - - topic = (body.get('topic') or '').strip() - opinion = (body.get('opinion') or '').strip() - sources = body.get('sources', []) - - if not opinion: - return JSONResponse({"error": "Vui lòng nhập quan điểm cá nhân"}, status_code=400) - if not topic: - words = [w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+', opinion) if len(w) > 3] - topic = ' '.join(words[:5]) if words else 'Bài viết quan điểm' - - # Step 1: Get hot topics as additional context if sources empty - if not sources or len(sources) < 2: - hot = _get_hot_topics() - if isinstance(hot, dict): - hot = hot.get('topics', []) if isinstance(hot, dict) else list(hot) - related = [t for t in (hot or []) if _has_kw(topic, t.get('topic',''))] - if related: - for rtopic in related[:3]: - more = _search_all(rtopic.get('topic',''), 6) - seen_urls = set(s.get('url') for s in sources) - for s in more: - if s.get('url') not in seen_urls: - seen_urls.add(s.get('url')) - sources.append(s) - if len(sources) >= 10: - break - - # Step 2: Generate article via AI - article = await _ai_synthesize(topic, opinion, sources) - - # Step 3: Extract title from article - title_match = re.search(r'^##\s+(.+)$', article, re.MULTILINE) - title = title_match.group(1).strip() if title_match else f'Quan điểm: {topic}' - - # Step 4: Save to wall - post_id = str(uuid.uuid4())[:12] - post = { - "id": post_id, - "title": title[:200], - "text": article[:2000], - "source": "opinion_v3", - "opinion": opinion, - "topic": topic, - "sources": sources[:10], - "img": None, - "video": None, - "created": int(time.time()), - "created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()), - } - posts = _load_wall_posts() - if not isinstance(posts, list): - posts = [] - posts.insert(0, post) - posts = posts[:200] - _save_wall_posts(posts) - - return JSONResponse({ - "post": post, - "article": article, - "title": title, - "ok": True - }) - - -@app.get('/api/opinion/hot_context') -async def api_opinion_hot_context(topic: str = Query(...)): - """GET /api/opinion/hot_context?topic=X - Returns: {"sources": [...], "hot_topics": [...]} - """ - h = _get_helpers() - _search_all = h.get('_search_all', lambda t,l=12: []) - _get_hot_topics = h.get('_get_hot_topics', lambda: []) - _has_kw = h.get('_has_kw', lambda t,tt: (t or '').lower() in (tt or '').lower()) - - sources = _search_all(topic, 12) - hot = _get_hot_topics() - if isinstance(hot, dict): - hot = hot.get('topics', []) if isinstance(hot, dict) else list(hot) - related_hot = [t for t in (hot or []) if _has_kw(topic, t.get('topic',''))] - return JSONResponse({ - "sources": sources, - "hot_topics": related_hot[:5], - }) - - -print("[opinion_v3_patch] Endpoints: POST /api/opinion/post, GET /api/opinion/hot_context") \ No newline at end of file diff --git a/patch_ai_hot.py b/patch_ai_hot.py deleted file mode 100644 index b5e7e0b6d8708e76b41075e33ebdd206afa72773..0000000000000000000000000000000000000000 --- a/patch_ai_hot.py +++ /dev/null @@ -1,55 +0,0 @@ -"""PATCH AI: prepend AI topics to hot list + homepage route fix""" -import re, json, time -from fastapi.responses import HTMLResponse - -# Import at runtime to avoid circular -try: - from main import app, rt - import ai_runtime_final6 as f6 - from ai_runtime_final6 import f5 -except: - f6, f5, rt = None, None, None - -# Patch hot_topics to prepend AI topics -if f6 and hasattr(f6, '_HOT_CACHE') and hasattr(f6, '_hot_topics'): - _orig_hot = f6._hot_topics - def _hot_topics_patched(): - topics = _orig_hot() - # Prepend AI topics to front - for ai in ['Công nghệ AI', 'World Cup 2026', 'Kinh tế Việt Nam']: - if not any(ai.lower() == t.get('topic','').lower() for t in topics): - topics.insert(0, {'label': f'#{ai.replace(" ", "")}', 'topic': ai, 'count': 0}) - return topics[:24] - f6._hot_topics = f6._HOT_CACHE['d'] = _hot_topics_patched() - f6._HOT_CACHE['t'] = time.time() - -PATCH_INJECT = r''' - -''' - -# Register homepage route -if app and f5 and f6: - # Remove old / route - 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 patch_homepage(): - html = f5.f4.f3.f2.f1._load_index_html() if f5 else "" - body = (getattr(rt.old,'PATCH_INJECT','') if hasattr(rt,'old') else '') + \ - (getattr(f5.f4.f3.f2.f1,'FINAL_INJECT','') if f5 else '') + \ - (getattr(f5.f4.f3,'FINAL3_INJECT','') if f5 else '') + \ - (getattr(f5.f4,'FINAL4_INJECT','') if f5 else '') + \ - (getattr(f5,'FINAL5_INJECT','') if f5 else '') + \ - (getattr(f6,'FINAL6_INJECT','') or '') + \ - (getattr(f6,'FINAL6_FAST_HOME_INJECT','') or '') + \ - (getattr(f6,'FINAL6E_INJECT','') or '') + \ - PATCH_INJECT - if '' in html: - html = html.replace('', body + '\n') - else: - html += body - return HTMLResponse(html) \ No newline at end of file diff --git a/patch_extra.py b/patch_extra.py deleted file mode 100644 index c3e248bccababe223379d74821445217275dc3db..0000000000000000000000000000000000000000 --- a/patch_extra.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Extra CSS/JS fixes injected AFTER main PATCH_INJECT.""" -EXTRA_FIX = r''' - -
- -''' diff --git a/patch_runtime.py b/patch_runtime.py deleted file mode 100644 index 7409a162ff5a7444093b19ed16d379cfb9cd5bda..0000000000000000000000000000000000000000 --- a/patch_runtime.py +++ /dev/null @@ -1,274 +0,0 @@ -"""Runtime patch layer for VNEWS. -Keeps the current large app intact, but replaces fragile AI wall endpoints with -stable JSON endpoints and injects frontend safeJson wrappers. -""" -import hashlib -import time -import os -from urllib.parse import quote - -import requests -from bs4 import BeautifulSoup -from fastapi import Request -from fastapi.responses import JSONResponse, HTMLResponse - -import main as _main - -app = _main.app -DEFAULT_IMG = "https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg" - - -def _remove_routes(paths): - app.router.routes = [r for r in app.router.routes if getattr(r, "path", None) not in set(paths)] - - -def _safe_text(v): - return (v or "").strip() - - -def _ensure_article(url: str): - data = None - try: - if hasattr(_main, "_article_by_url"): - data = _main._article_by_url(url) - except Exception: - data = None - if not data: - try: - data = _main._scrape_generic_article(url) if hasattr(_main, "_scrape_generic_article") else None - except Exception: - data = None - if not data: - data = {"title": "", "summary": "", "og_image": "", "body": [], "url": url, "source": "generic"} - title = _safe_text(data.get("title")) - summary = _safe_text(data.get("summary")) - img = _safe_text(data.get("og_image")) - body = data.get("body") or [] - if not title or not summary or not img or not body: - try: - r = requests.get(url, headers=getattr(_main, "HEADERS", {}), timeout=15) - r.encoding = "utf-8" - soup = BeautifulSoup(r.text, "lxml") - if not title: - tag = soup.find("meta", property="og:title") or soup.find("title") - title = tag.get("content", "").strip() if tag and tag.name == "meta" else (tag.get_text(strip=True) if tag else "") - if not summary: - tag = soup.find("meta", property="og:description") or soup.find("meta", attrs={"name": "description"}) - summary = tag.get("content", "").strip() if tag else "" - if not img: - tag = soup.find("meta", property="og:image") or soup.find("meta", attrs={"name": "twitter:image"}) - img = tag.get("content", "").strip() if tag else "" - if not body: - ps = [] - for p in soup.find_all("p"): - t = p.get_text(" ", strip=True) - if len(t) > 40: - ps.append({"type": "p", "text": t}) - if len(ps) >= 30: - break - body = ps - except Exception: - pass - if not summary and body: - first = next((b.get("text", "") for b in body if b.get("type") == "p" and b.get("text")), "") - summary = first[:360] - if not title: - title = url - if not img: - img = DEFAULT_IMG - if not body and summary: - body = [{"type": "p", "text": summary}] - data.update({"title": title, "summary": summary, "og_image": img, "body": body, "url": url}) - return data - - -def _rewrite(data, tone="tu-nhien"): - try: - if hasattr(_main, "_ai_rewrite_article"): - text = _main._ai_rewrite_article(data, tone=tone) - if text and len(text.strip()) > 50: - return text.strip() - except Exception: - pass - title = data.get("title", "") - summary = data.get("summary", "") - ps = [b.get("text", "") for b in data.get("body", []) if b.get("type") == "p" and b.get("text")] - lead = summary or (ps[0] if ps else "") - points = "\n".join(["• " + p[:220] + ("..." if len(p) > 220 else "") for p in ps[:5]]) - body = "\n\n".join(ps[:10]) - return (f"Bản tin AI viết lại: {title}\n\n{lead}\n\n{body}\n\nĐiểm chính:\n{points}").strip() - - -def _topic_image(topic): - try: - if hasattr(_main, "_image_for_topic"): - return _main._image_for_topic(topic) - except Exception: - pass - return "https://image.pollinations.ai/prompt/" + quote("editorial illustration Vietnamese news " + topic, safe="") + "?width=1024&height=576&nologo=true" - - -def _save_post(post): - try: - posts = _main._load_wall() if hasattr(_main, "_load_wall") else [] - except Exception: - posts = [] - posts.insert(0, post) - try: - if hasattr(_main, "_save_wall"): - _main._save_wall(posts) - except Exception: - pass - return post - - -_remove_routes(["/api/url_wall", "/api/topic_post", "/api/rewrite_share", "/"]) - - -@app.post("/api/url_wall") -async def patched_url_wall(request: Request): - try: - body = await request.json() - except Exception: - body = {} - url = _safe_text(body.get("url")) - tone = _safe_text(body.get("tone")) or "tu-nhien" - if not url: - return JSONResponse({"error": "missing url"}, status_code=400) - try: - data = _ensure_article(url) - text = _rewrite(data, tone=tone) - post = { - "id": hashlib.md5((url + str(time.time())).encode()).hexdigest()[:12], - "url": url, - "title": data.get("title") or url, - "summary": data.get("summary") or "", - "img": data.get("og_image") or DEFAULT_IMG, - "text": text or (data.get("summary") or data.get("title") or url), - "source": data.get("source", "url"), - "ts": int(time.time()), - } - _save_post(post) - return JSONResponse({"post": post}) - except Exception as e: - return JSONResponse({"error": "Không tạo được tóm tắt URL", "detail": str(e)[:300]}, status_code=500) - - -@app.post("/api/rewrite_share") -async def patched_rewrite_share(request: Request): - return await patched_url_wall(request) - - -@app.post("/api/topic_post") -async def patched_topic_post(request: Request): - try: - body = await request.json() - except Exception: - body = {} - topic = _safe_text(body.get("topic")) - tone = _safe_text(body.get("tone")) or "tu-nhien" - if not topic: - return JSONResponse({"error": "missing topic"}, status_code=400) - try: - context = "" - try: - if hasattr(_main, "_topic_article_context"): - context = _main._topic_article_context(topic) - if not context and hasattr(_main, "_web_context"): - context = _main._web_context(topic) - except Exception: - context = "" - if not context: - context = f"Chủ đề: {topic}" - data = {"title": topic, "summary": context[:420], "og_image": _topic_image(topic), "body": [{"type": "p", "text": context}], "source": "topic", "url": ""} - text = _rewrite(data, tone=tone) - post = { - "id": hashlib.md5((topic + str(time.time())).encode()).hexdigest()[:12], - "url": "", - "title": topic, - "summary": data["summary"], - "img": data["og_image"] or DEFAULT_IMG, - "text": text or context, - "source": "topic", - "ts": int(time.time()), - } - _save_post(post) - return JSONResponse({"post": post}) - except Exception as e: - return JSONResponse({"error": "Không tạo được bài theo chủ đề", "detail": str(e)[:300]}, status_code=500) - - -_FRONTEND_PATCH = r''' - -''' - - -@app.get("/") -async def patched_index(): - try: - with open("/app/static/index.html", "r", encoding="utf-8") as f: - html = f.read() - if "window.safeJson" not in html: - html = html.replace("", _FRONTEND_PATCH + "") - return HTMLResponse(content=html) - except Exception as e: - return HTMLResponse(content=f"
Index error: {str(e)}
", status_code=500) diff --git a/piped_client.py b/piped_client.py deleted file mode 100644 index 4ca3b72dcf611e08c0fe4da0ee55ea0f4a1e0a10..0000000000000000000000000000000000000000 --- a/piped_client.py +++ /dev/null @@ -1,258 +0,0 @@ -""" -YouTube Shorts Scraper using Piped API -Piped is a privacy-friendly YouTube proxy that works without JS -""" -import requests -import json -import time -import threading - -_cache = {} -_lock = threading.Lock() -CACHE_TTL = 900 # 15 min - -# Piped API instances (public) -PIPED_INSTANCES = [ - "https://pipedapi.kavin.rocks", - "https://pipedapi.adminforge.de", - "https://api.piped.projectsegfau.lt", -] - -UA = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"} - -def _cached(key): - with _lock: - if key in _cache and time.time() - _cache[key]['t'] < CACHE_TTL: - return _cache[key]['d'] - return None - -def _set_cache(key, data): - with _lock: - _cache[key] = {'t': time.time(), 'd': data} - -def _piped_request(path, params=None): - """Try multiple Piped instances""" - last_err = None - for base in PIPED_INSTANCES: - try: - url = f"{base}{path}" - r = requests.get(url, params=params, headers=UA, timeout=15) - if r.status_code == 200: - return r.json() - except Exception as e: - last_err = e - continue - raise Exception(f"All Piped instances failed: {last_err}") - -def get_channel_videos(channel_id, max_videos=200): - """Get all videos from a channel using Piped API with pagination""" - cached = _cached(f'ch_vids_{channel_id}') - if cached is not None: - return cached - - all_videos = [] - page = None - - while len(all_videos) < max_videos: - try: - if page: - data = _piped_request(f"/channels/{channel_id}/videos", {"nextpage": page}) - else: - data = _piped_request(f"/channels/{channel_id}/videos") - - videos = data.get('relatedStreams', []) - if not videos: - break - - all_videos.extend(videos) - - # Check for next page - next_page = data.get('nextpage') - if not next_page or next_page == page: - break - page = next_page - - # Small delay to be polite - time.sleep(0.3) - - if len(all_videos) >= max_videos: - break - - except Exception as e: - print(f"Piped pagination error: {e}") - break - - result = all_videos[:max_videos] - _set_cache(f'ch_vids_{channel_id}', result) - return result - -def get_vtvnambo_shorts_piped(max_count=50): - """Get shorts from VTV Nam Bộ using Piped API""" - # VTV Nam Bộ channel ID - channel_id = "UCJ0btJV8qh7J7R2aXb9GmGA" - - try: - videos = get_channel_videos(channel_id, 200) - - shorts = [] - for v in videos: - title = v.get('title', '') - vid = v.get('url', '').replace('/watch?v=', '') - if not vid: - continue - - # Filter for shorts: title has #shorts, or duration <= 60s - duration = v.get('duration', 0) - is_short = ( - '#shorts' in title.lower() or - '#short' in title.lower() or - (duration > 0 and duration <= 60) - ) - - if is_short: - shorts.append({ - 'id': vid, - 'title': title, - 'img': f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg", - 'channel': 'vtvnambo', - }) - - if shorts: - return shorts[:max_count] - except Exception as e: - print(f"Piped shorts error: {e}") - - return [] - -def get_vtvnambo_shorts_rss(max_count=50): - """Get shorts from YouTube RSS feed""" - cached = _cached('vtvnambo_rss') - if cached is not None: - return cached - - from xml.etree import ElementTree as ET - - # First get channel ID from page - channel_id = None - try: - r = requests.get("https://www.youtube.com/@vtvnambo", headers=UA, timeout=15) - if r.status_code == 200: - m = re.search(r'"channelId":"(UC[^"]+)"', r.text) - if m: - channel_id = m.group(1) - except: - pass - - if not channel_id: - channel_id = "UCJ0btJV8qh7J7R2aXb9GmGA" # fallback - - try: - url = f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}" - r = requests.get(url, headers=UA, timeout=15) - if r.status_code != 200: - return [] - - root = ET.fromstring(r.text) - ns = {'atom': 'http://www.w3.org/2005/Atom', 'yt': 'http://www.youtube.com/xml/schemas/2015'} - - shorts = [] - for entry in root.findall('atom:entry', ns)[:max_count * 2]: - title_el = entry.find('atom:title', ns) - title = title_el.text if title_el is not None and title_el.text else '' - - vid_el = entry.find('yt:videoId', ns) - vid = vid_el.text if vid_el is not None else '' - if not vid: - continue - - is_short = '#shorts' in title.lower() or '#short' in title.lower() - link_el = entry.find('atom:link', ns) - link = link_el.get('href', '') if link_el is not None else '' - if '/shorts/' in link: - is_short = True - - if is_short: - shorts.append({ - 'id': vid, - 'title': title, - 'img': f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg", - 'channel': 'vtvnambo', - }) - - _set_cache('vtvnambo_rss', shorts[:max_count]) - return shorts[:max_count] - except Exception as e: - print(f"RSS error: {e}") - - return [] - -def get_vtvnambo_shorts(max_count=50): - """Get all shorts from VTV Nam Bộ. Tries Piped API first, then RSS.""" - cached = _cached('vtvnambo_shorts_v3') - if cached is not None: - return cached - - all_shorts = [] - seen_ids = set() - - # Method 1: Piped API (most reliable) - try: - piped_shorts = get_vtvnambo_shorts_piped(max_count) - for s in piped_shorts: - if s['id'] not in seen_ids: - seen_ids.add(s['id']) - all_shorts.append(s) - print(f"Piped API found {len(piped_shorts)} shorts") - except Exception as e: - print(f"Piped method failed: {e}") - - # Method 2: RSS feed - if len(all_shorts) < 3: - try: - rss_shorts = get_vtvnambo_shorts_rss(max_count) - for s in rss_shorts: - if s['id'] not in seen_ids: - seen_ids.add(s['id']) - all_shorts.append(s) - print(f"RSS found {len(rss_shorts)} shorts") - except Exception as e: - print(f"RSS method failed: {e}") - - result = all_shorts[:max_count] - _set_cache('vtvnambo_shorts_v3', result) - return result - -def get_wc_related_shorts(max_count=30): - """Get World Cup / football related shorts.""" - all_shorts = get_vtvnambo_shorts(max_count * 3) - - wc_kws = [ - 'world cup', 'wc 2026', 'worldcup', 'fifa', 'bóng đá', - 'trận đấu', 'đội tuyển', 'tuyển', 'vòng loại', - 'khoảnh khắc', 'highlights', 'bàn thắng', 'goal', - 'kết quả', 'tỉ số', 'việt nam', 'vn', - 'ngoại hạng', 'premier league', 'champions league', - 'laliga', 'serie a', 'bundesliga', 'ligue 1', - 'copa', 'europa', 'c1', 'c2', - 'messi', 'ronaldo', 'neymar', 'mbappe', 'haaland', - 'v-league', 'vleague', 'bóng đá việt', - 'đội bóng', 'hlv', 'huấn luyện viên', - 'chuyển nhượng', 'transfer', - 'asian cup', 'aff cup', 'sea games', - 'olympic', 'u23', 'u20', 'u17', - ] - - wc_shorts = [] - for s in all_shorts: - tl = s.get('title', '').lower() - if any(k in tl for k in wc_kws): - wc_shorts.append(s) - - if not wc_shorts: - wc_shorts = all_shorts - - return wc_shorts[:max_count] - -import re -# Alias for backward compatibility -get_vtvnamo_shorts = get_vtvnambo_shorts diff --git a/rebuild3.md b/rebuild3.md deleted file mode 100644 index 5b52fc9f825e7d7997f32173b23d1b1f0aed0156..0000000000000000000000000000000000000000 --- a/rebuild3.md +++ /dev/null @@ -1 +0,0 @@ -rebuild \ No newline at end of file diff --git a/rebuild_trigger.txt b/rebuild_trigger.txt deleted file mode 100644 index d20f2a1414523268ea2934830b50c340c225fa1b..0000000000000000000000000000000000000000 --- a/rebuild_trigger.txt +++ /dev/null @@ -1 +0,0 @@ -TRIGGER_REBUILD=20260719 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 33a0c0a4662327715df3b69f0cb57016d139681e..0000000000000000000000000000000000000000 --- a/requirements.txt +++ /dev/null @@ -1,14 +0,0 @@ -fastapi -uvicorn -requests -beautifulsoup4>=4.12.0 -lxml -jinja2 -yt-dlp -huggingface_hub -gTTS -pillow -edge-tts -python-dateutil -httpx -# trigger rebuild 1784359129 diff --git a/restart.txt b/restart.txt deleted file mode 100644 index b7dc7207f672632d645ad1ffd59f24b1f69865a8..0000000000000000000000000000000000000000 --- a/restart.txt +++ /dev/null @@ -1 +0,0 @@ -restart \ No newline at end of file diff --git a/restart2.md b/restart2.md deleted file mode 100644 index 1ad28c637f7615a09045aa79531232266712cc28..0000000000000000000000000000000000000000 --- a/restart2.md +++ /dev/null @@ -1 +0,0 @@ -restart with _run.py fix \ No newline at end of file diff --git a/restore_runner.py b/restore_runner.py deleted file mode 100644 index 7c1677995de2a077cc9b1db2692fc5878e043f59..0000000000000000000000000000000000000000 --- a/restore_runner.py +++ /dev/null @@ -1,31 +0,0 @@ -import os -import sys -import subprocess -from huggingface_hub import snapshot_download - -REVISION = os.environ.get("VNEWS_RESTORE_REVISION", "bcaa2dc") -REPO_ID = os.environ.get("VNEWS_REPO_ID", "bep40/vnews") - -# Download exact Space snapshot from Hugging Face Hub. -# This avoids manually copying huge files from an old commit. -snapshot_dir = snapshot_download( - repo_id=REPO_ID, - repo_type="space", - revision=REVISION, - local_dir="/tmp/vnews_restore", - local_dir_use_symlinks=False, -) - -os.chdir(snapshot_dir) -sys.path.insert(0, snapshot_dir) - -# Commit bcaa2dc Dockerfile ran ai_patch:app. -cmd = [ - "uvicorn", - "ai_patch:app", - "--host", - "0.0.0.0", - "--port", - "7860", -] -os.execvp(cmd[0], cmd) diff --git a/rewrite_fix_v2.js b/rewrite_fix_v2.js deleted file mode 100644 index 488520ab2edc21927c2ef5b49f0839b01c13ff63..0000000000000000000000000000000000000000 --- a/rewrite_fix_v2.js +++ /dev/null @@ -1,2 +0,0 @@ -// No-op - all functionality built into app_v2.js -(function(){})(); diff --git a/rewrite_slide.py b/rewrite_slide.py deleted file mode 100644 index 263ab8e7bbf5929c39eb16ff0183efa6ab4b896b..0000000000000000000000000000000000000000 --- a/rewrite_slide.py +++ /dev/null @@ -1,185 +0,0 @@ -"""Fast rewrite as slides - no AI needed, extracts key points + images from article.""" -from main import app -from fastapi import Request -from fastapi.responses import JSONResponse -import requests, re, time, random, json, os -from bs4 import BeautifulSoup -from urllib.parse import quote - -UA = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept-Language': 'vi-VN,vi;q=0.9'} - -try: - from main import _load_wall, _save_wall -except: - _data_dir = "/data" if os.path.isdir("/data") else "/app/data" - _wall_file = os.path.join(_data_dir, "wall_posts.json") - def _load_wall(): - try: - if os.path.exists(_wall_file): - with open(_wall_file, 'r', encoding='utf-8') as f: return json.load(f) - except: pass - return [] - def _save_wall(posts): - try: - os.makedirs(os.path.dirname(_wall_file), exist_ok=True) - with open(_wall_file+'.tmp', 'w', encoding='utf-8') as f: json.dump(posts[:100], f, ensure_ascii=False) - os.replace(_wall_file+'.tmp', _wall_file) - except: pass - - -def _clean(s): return re.sub(r'\s+', ' ', str(s or '')).strip() - - -def _scrape_article_full(url): - """Scrape article: extract paragraphs + ALL images.""" - try: - r = requests.get(url, headers=UA, timeout=15, allow_redirects=True) - r.encoding = 'utf-8' - soup = BeautifulSoup(r.text, 'lxml') - for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form']): tag.decompose() - - # Title - h1 = soup.find('h1') - ogt = soup.find('meta', property='og:title') - title = (h1.get_text(strip=True) if h1 else '') or (ogt.get('content', '') if ogt else '') - - # OG image - ogi = soup.find('meta', property='og:image') - og_img = ogi.get('content', '') if ogi else '' - if og_img and og_img.startswith('//'): og_img = 'https:' + og_img - - # Find content block - block = None - for sel in ['article', '.singular-content', '.detail-content', '.fck_detail', '.content-detail', '.knc-content', 'main', '.cms-body', '.article__body']: - el = soup.select_one(sel) - if el and len(el.find_all('p')) >= 2: block = el; break - if not block: block = soup.body or soup - - # Extract paragraphs and images IN ORDER - paragraphs = [] - images = [] - seen_imgs = set() - - if og_img and og_img not in seen_imgs: - images.append(og_img) - seen_imgs.add(og_img) - - for el in block.find_all(['p', 'h2', 'h3', 'figure', 'img'], recursive=True): - if el.name == 'p': - t = _clean(el.get_text(strip=True)) - if t and len(t) > 40: - paragraphs.append(t) - elif el.name in ('figure', 'img'): - im = el if el.name == 'img' else el.find('img') - if im: - src = im.get('data-src') or im.get('src') or im.get('data-original') or '' - if src and 'base64' not in src: - if src.startswith('//'): src = 'https:' + src - if src not in seen_imgs: - images.append(src) - seen_imgs.add(src) - - return {'title': _clean(title), 'paragraphs': paragraphs, 'images': images, 'og_img': og_img} - except Exception as e: - return None - - -def _extract_key_points(paragraphs, max_points=5): - """Extract key points: take first sentence of each significant paragraph.""" - points = [] - for p in paragraphs: - if len(points) >= max_points: break - # Take first complete sentence (ends with . ! ?) - m = re.match(r'^(.+?[.!?])\s', p) - if m: - sentence = m.group(1) - else: - sentence = p[:150] + ('.' if not p.endswith('.') else '') - - # Skip if too short or duplicate - if len(sentence) < 30: continue - if any(sentence[:50] in existing for existing in points): continue - - points.append(sentence) - - return points - - -@app.post("/api/rewrite_slide") -async def api_rewrite_slide(request: Request): - """ - Fast rewrite as SLIDES: - - Extract key points from article (1 sentence each, full and complete) - - Pair each point with an image from the article - - Return as slides array for frontend to display - - Save to Tường AI - NO AI NEEDED - instant response. - """ - body = await request.json() - url = _clean(body.get("url", "")) - context = body.get("context", "") - - if not url and not context: - return JSONResponse({"error": "Cần URL hoặc nội dung"}, status_code=400) - - # Scrape article - data = None - if url and url.startswith("http"): - data = _scrape_article_full(url) - - if not data and context: - # Use context passed from frontend - paragraphs = [_clean(p) for p in context.split('\n') if len(_clean(p)) > 40] - data = {'title': paragraphs[0][:80] if paragraphs else 'Bài viết', 'paragraphs': paragraphs, 'images': [], 'og_img': ''} - - if not data or not data.get('paragraphs'): - return JSONResponse({"error": "Không đọc được bài viết"}, status_code=422) - - # Extract key points - points = _extract_key_points(data['paragraphs'], max_points=6) - if not points: - return JSONResponse({"error": "Không tìm được ý chính"}, status_code=422) - - # Build slides: pair each point with an image - images = data.get('images', []) - slides = [] - for i, point in enumerate(points): - img = images[i] if i < len(images) else (images[-1] if images else '') - # Proxy dantri images - if img and 'cdnphoto.dantri' in img: - img = '/api/proxy/img?url=' + quote(img, safe='') - slides.append({ - 'text': point, - 'image': img, - 'index': i + 1 - }) - - # Create post for Tường AI - summary_text = '\n\n'.join([f"• {s['text']}" for s in slides]) - # Auto voice + emotion based on topic (reuse ai_ext detector if available) - try: - from ai_ext import _detect_voice_emotion - _voice, _emotion = _detect_voice_emotion(data['title'], summary_text) - except Exception: - _voice, _emotion = "hoaimy", "trung_tinh" - post = { - "id": str(int(time.time() * 1000)) + str(random.randint(100, 999)), - "title": data['title'], - "text": summary_text, - "img": images[0] if images else '', - "url": url, - "kind": "slide_summary", - "slides": slides, - "images": images[:10], - "video": "", - "voice": _voice, - "emotion": _emotion, - "ts": int(time.time()) - } - - # Save to wall - posts = _load_wall() - posts.insert(0, post) - _save_wall(posts) - - return JSONResponse({"post": post, "slides": slides}) diff --git a/runtime.txt b/runtime.txt deleted file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 diff --git a/shorts_cache.py b/shorts_cache.py deleted file mode 100644 index 6ed6bd22884712258a650fe68133d40d74628875..0000000000000000000000000000000000000000 --- a/shorts_cache.py +++ /dev/null @@ -1,86 +0,0 @@ -""" -VNEWS Shorts Runtime Cache - External Updater Module -GitHub Actions fetches YouTube shorts via yt-dlp -> POST to /api/shorts/update -Space saves to RAM cache + persistent file if /data available -""" -import os -import json -import time -import threading - -# Runtime cache (RAM) -_shorts_runtime_cache = None -_shorts_cache_ts = 0 -_shorts_cache_lock = threading.Lock() - -# Secret for authenticating update requests -SHORTS_UPDATE_SECRET = os.environ.get("SHORTS_UPDATE_SECRET", "vnews-shorts-2026") - -# Paths -SHORTS_CACHE_FILE = "/data/shorts_runtime_cache.json" if os.path.isdir("/data") else "/app/shorts_runtime_cache.json" - - -def get_runtime_cache(): - """Get cached shorts (from RAM or file fallback)""" - global _shorts_runtime_cache, _shorts_cache_ts - with _shorts_cache_lock: - if _shorts_runtime_cache is not None: - age = time.time() - _shorts_cache_ts - if age < 7200: # 2h fresh - return _shorts_runtime_cache - - # Try file fallback - try: - if os.path.exists(SHORTS_CACHE_FILE): - with open(SHORTS_CACHE_FILE, "r", encoding="utf-8") as f: - data = json.load(f) - age = time.time() - data.get("ts", 0) - if age < 86400: # 24h stale limit - items = data.get("items", []) - with _shorts_cache_lock: - _shorts_runtime_cache = items - _shorts_cache_ts = data.get("ts", time.time()) - return items - except Exception as e: - print(f"[cache] read error: {e}") - - return None - - -def set_runtime_cache(items): - """Update runtime cache from external data""" - global _shorts_runtime_cache, _shorts_cache_ts - ts = time.time() - with _shorts_cache_lock: - _shorts_runtime_cache = items - _shorts_cache_ts = ts - - # Also write to file (persistent if /data mounted) - try: - os.makedirs(os.path.dirname(SHORTS_CACHE_FILE), exist_ok=True) - payload = {"items": items, "ts": ts, "count": len(items)} - with open(SHORTS_CACHE_FILE, "w", encoding="utf-8") as f: - json.dump(payload, f, ensure_ascii=False, indent=2) - print(f"[cache] saved {len(items)} shorts to {SHORTS_CACHE_FILE}") - except Exception as e: - print(f"[cache] write skipped: {e}") - - return len(items) - - -def get_cache_status(): - """Return status dict for the cache""" - cache = None - with _shorts_cache_lock: - if _shorts_runtime_cache is not None: - cache = _shorts_runtime_cache - age = int(time.time() - _shorts_cache_ts) - else: - age = -1 - return { - "cached": cache is not None, - "count": len(cache) if cache else 0, - "age_seconds": age, - "has_persistent": os.path.isdir("/data"), - "cache_file_exists": os.path.exists(SHORTS_CACHE_FILE), - } diff --git a/shorts_rss_proxy.py b/shorts_rss_proxy.py deleted file mode 100644 index 657d7c8a1c4a792448440632e132d3691f3dabea..0000000000000000000000000000000000000000 --- a/shorts_rss_proxy.py +++ /dev/null @@ -1,114 +0,0 @@ -""" -YouTube RSS Proxy - Fetches YouTube channel RSS feeds server-side -Avoids CORS issues when client tries to fetch YouTube directly -""" -import requests as req -from fastapi import Query -from fastapi.responses import Response - -HEADERS = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"} - -YOUTUBE_CHANNELS = { - "baodantri7941": "UC_x5TKhOgd6GhYvv5z4I3jg", - "baosuckhoedoisongboyte": "UCBsY5fXTQLkF_JnH9kLkL4g", -} - -def setup_rss_proxy(app): - """Add RSS proxy endpoints to the FastAPI app""" - - @app.get("/api/proxy/rss") - def proxy_rss(url: str = Query(...)): - """Proxy YouTube RSS feed to avoid CORS""" - try: - r = req.get(url, headers=HEADERS, timeout=15) - if r.status_code == 200: - return Response( - content=r.content, - media_type="application/xml", - headers={"Access-Control-Allow-Origin": "*"} - ) - return Response(status_code=r.status_code) - except Exception as e: - return Response(status_code=502, content=str(e)) - - @app.get("/api/shorts/rss") - def shorts_via_rss(): - """Get shorts from YouTube RSS feeds server-side""" - import xml.etree.ElementTree as ET - import html as html_lib - import re - - shorts = [] - seen = set() - - for handle, channel_id in YOUTUBE_CHANNELS.items(): - try: - rss_url = f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}" - r = req.get(rss_url, headers=HEADERS, timeout=15) - if r.status_code != 200: - continue - - root = ET.fromstring(r.text) - ns = { - 'atom': 'http://www.w3.org/2005/Atom', - 'yt': 'http://www.youtube.com/xml/schemas/2015', - 'media': 'http://search.yahoo.com/mrss/' - } - - for entry in root.findall('atom:entry', ns)[:30]: - title_el = entry.find('atom:title', ns) - title = html_lib.unescape(title_el.text) if title_el is not None and title_el.text else '' - - link_el = entry.find('atom:link', ns) - link = link_el.get('href', '') if link_el is not None else '' - - vid_el = entry.find('yt:videoId', ns) - vid = vid_el.text if vid_el is not None else '' - - if not vid: - m = re.search(r'(?:v=|shorts/)([A-Za-z0-9_-]{11})', link) - if m: - vid = m.group(1) - - if not vid or vid in seen: - continue - - # Check if it's a short - is_short = '#shorts' in title.lower() or '#short' in title.lower() or '/shorts/' in link - - if not is_short: - desc_el = entry.find('media:description', ns) - if desc_el is not None and desc_el.text: - if '#shorts' in desc_el.text.lower(): - is_short = True - - if not is_short: - continue - - seen.add(vid) - - # Get thumbnail - thumb = f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg" - media_group = entry.find('media:group', ns) - if media_group is not None: - thumb_el = media_group.find('media:thumbnail', ns) - if thumb_el is not None: - thumb = thumb_el.get('url', thumb) - - shorts.append({ - 'id': vid, - 'title': title.replace('#shorts', '').replace('#short', '').strip()[:120], - 'img': thumb, - 'link': f'https://www.youtube.com/shorts/{vid}', - 'channel': handle, - 'source': 'yt' - }) - - if len(shorts) >= 40: - break - - except Exception as e: - print(f"RSS error for {handle}: {e}") - continue - - return {"shorts": shorts, "count": len(shorts)} diff --git a/storage.py b/storage.py deleted file mode 100644 index 9f968e484faae118657dcd55ec580e200ca992e6..0000000000000000000000000000000000000000 --- a/storage.py +++ /dev/null @@ -1,150 +0,0 @@ -"""Persistent storage for VNEWS using Hugging Face Dataset as backend.""" -import os, json, time, threading -from pathlib import Path -from huggingface_hub import HfApi, hf_hub_download, upload_file -from huggingface_hub.utils import RepositoryNotFoundError - -DATASET_REPO = "bep40/VNEWS-data" -DATA_DIR = "/data" if os.path.isdir("/data") else "/app/data" -os.makedirs(DATA_DIR, exist_ok=True) - -LOCAL_WALL_FILE = os.path.join(DATA_DIR, "wall_posts.json") -LOCAL_INTERACTIONS_FILE = os.path.join(DATA_DIR, "interactions_v2.json") -LOCAL_COMMENTS_FILE = os.path.join(DATA_DIR, "comments_v2.json") -LOCAL_SHORTS_CACHE = os.path.join(DATA_DIR, "shorts_cache.json") - -_lock = threading.Lock() -_api = HfApi() - -def _download_file(repo_path: str, local_path: str) -> bool: - """Download a file from HF dataset to local path.""" - try: - downloaded = hf_hub_download( - repo_id=DATASET_REPO, - filename=repo_path, - repo_type="dataset", - local_dir=DATA_DIR, - local_dir_use_symlinks=False, - ) - if downloaded and downloaded != local_path: - import shutil - shutil.copy2(downloaded, local_path) - return True - except Exception as e: - print(f"[storage] Download {repo_path} failed: {e}") - return False - -def _upload_file(local_path: str, repo_path: str) -> bool: - """Upload a local file to HF dataset.""" - try: - upload_file( - path_or_fileobj=local_path, - path_in_repo=repo_path, - repo_id=DATASET_REPO, - repo_type="dataset", - ) - return True - except Exception as e: - print(f"[storage] Upload {repo_path} failed: {e}") - return False - -def init_storage(): - """Initialize storage by downloading existing data from HF dataset.""" - print("[storage] Initializing from HF dataset...") - for local, remote in [ - (LOCAL_WALL_FILE, "wall_posts.json"), - (LOCAL_INTERACTIONS_FILE, "interactions_v2.json"), - (LOCAL_COMMENTS_FILE, "comments_v2.json"), - (LOCAL_SHORTS_CACHE, "shorts_cache.json"), - ]: - if not os.path.exists(local): - _download_file(remote, local) - print("[storage] Storage initialized") - -def _load_json(local_path: str, default): - """Load JSON from local file with default fallback.""" - try: - if os.path.exists(local_path): - with open(local_path, 'r', encoding='utf-8') as f: - return json.load(f) - except Exception: - pass - return default - -def _save_json(local_path: str, data): - """Save JSON to local file atomically.""" - try: - tmp = local_path + ".tmp" - with open(tmp, 'w', encoding='utf-8') as f: - json.dump(data, f, ensure_ascii=False) - os.replace(tmp, local_path) - return True - except Exception as e: - print(f"[storage] Save {local_path} failed: {e}") - return False - -# ===== Wall Posts ===== -def load_wall_posts() -> list: - """Load wall posts from local file (synced from HF dataset).""" - with _lock: - return _load_json(LOCAL_WALL_FILE, []) - -def save_wall_posts(posts: list) -> bool: - """Save wall posts to local file and async sync to HF dataset.""" - with _lock: - ok = _save_json(LOCAL_WALL_FILE, posts[:200]) # Keep max 200 - if ok: - # Async upload to HF dataset - threading.Thread( - target=lambda: _upload_file(LOCAL_WALL_FILE, "wall_posts.json"), - daemon=True - ).start() - return ok - -# ===== Interactions ===== -def load_interactions() -> dict: - with _lock: - return _load_json(LOCAL_INTERACTIONS_FILE, {}) - -def save_interactions(data: dict) -> bool: - with _lock: - ok = _save_json(LOCAL_INTERACTIONS_FILE, data) - if ok: - threading.Thread( - target=lambda: _upload_file(LOCAL_INTERACTIONS_FILE, "interactions_v2.json"), - daemon=True - ).start() - return ok - -# ===== Comments ===== -def load_comments() -> dict: - with _lock: - return _load_json(LOCAL_COMMENTS_FILE, {}) - -def save_comments(data: dict) -> bool: - with _lock: - ok = _save_json(LOCAL_COMMENTS_FILE, data) - if ok: - threading.Thread( - target=lambda: _upload_file(LOCAL_COMMENTS_FILE, "comments_v2.json"), - daemon=True - ).start() - return ok - -# ===== Shorts Cache ===== -def load_shorts_cache() -> dict: - with _lock: - return _load_json(LOCAL_SHORTS_CACHE, {}) - -def save_shorts_cache(data: dict) -> bool: - with _lock: - ok = _save_json(LOCAL_SHORTS_CACHE, data) - if ok: - threading.Thread( - target=lambda: _upload_file(LOCAL_SHORTS_CACHE, "shorts_cache.json"), - daemon=True - ).start() - return ok - -# Auto-sync on startup -init_storage() \ No newline at end of file diff --git a/vtv_api.py b/vtv_api.py deleted file mode 100644 index 6fe1fa3c4ffc999cf51e1391d3f7fca085bee2e2..0000000000000000000000000000000000000000 --- a/vtv_api.py +++ /dev/null @@ -1,227 +0,0 @@ -# VTV Stream — SV2/sv.xemtivitop iframe URLs + M3U fallback + EPG -import re, time, threading, json, requests -from fastapi import APIRouter, Query -from fastapi.responses import JSONResponse, Response, StreamingResponse -from bs4 import BeautifulSoup -from datetime import datetime, timedelta, timezone -from urllib.parse import quote - -VN_TZ = timezone(timedelta(hours=7)) -router = APIRouter() - -UA = { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", - "Accept-Language": "vi-VN,vi;q=0.9", -} - -HARDCODED_URLS = { - "vtv6": "https://freem3u.xyz/api/live/play.m3u8?vid=10043", - "vtv10": "https://live.fptplay53.net/live/media/vtv10/live247-hls-avc/index.m3u8", -} -CHANNEL_URLS = {k: (HARDCODED_URLS[k] if k in HARDCODED_URLS else None) for k in ["vtv1","vtv2","vtv3","vtv4","vtv5","vtv6","vtv7","vtv8","vtv9","vtv10"]} -CHANNEL_NAMES = {"vtv1":"VTV1","vtv2":"VTV2","vtv3":"VTV3","vtv4":"VTV4","vtv5":"VTV5","vtv6":"VTV6","vtv7":"VTV7","vtv8":"VTV8","vtv9":"VTV9","vtv10":"VTV10","vtvprime":"VTVPrime"} - -# Iframe URLs cho từng kênh — lấy từ xemtivitop.com -# Với VTV7: LINK 1 = blogspot (lỗi), LINK 2 = sv.xemtivitop.com (JWPlayer, hoạt động) -IFRAME_URLS = { - "vtv1": "https://sv2.xemtivitop.com/live/hot/vtv1.php", - "vtv2": "https://sv2.xemtivitop.com/live/hot/vtv2.php", - "vtv3": "https://sv2.xemtivitop.com/live/hot/vtv3.php", - "vtv4": "https://sv2.xemtivitop.com/live/hot/vtv4.php", - "vtv5": "https://sv2.xemtivitop.com/live/hot/vtv5.php", - "vtv6": "https://sv2.xemtivitop.com/live/hot/vtv6.php", - # VTV7: LINK 2 trên xemtivitop.com — sv.xemtivitop.com (ko phải sv2), JWPlayer, cho phép iframe - "vtv7": "https://sv.xemtivitop.com/live/vtv/vtv7.php", - "vtv8": "https://sv2.xemtivitop.com/live/hot/vtv8.php", - "vtv9": "https://sv2.xemtivitop.com/live/hot/vtv9.php", - "vtv10": "https://sv2.xemtivitop.com/live/hot/vtv10.php", -} - -M3U_URLS = [ - "https://raw.githubusercontent.com/Love4vn/Test/refs/heads/main/IPTV.m3u", - "https://raw.githubusercontent.com/Love4vn/love4vn/main/Out_Iptv_CXT.m3u", - "https://raw.githubusercontent.com/konanda-sg/Test_Love4vn/main/IPTV.m3u", - "https://raw.githubusercontent.com/iptv-org/iptv/master/streams/vn.m3u", -] - -_channel_urls_lock = threading.Lock() -_last_m3u_fetch = 0 -_M3U_TTL = 900 - -def _fetch_m3u(): - global _last_m3u_fetch - now = time.time() - if now - _last_m3u_fetch < _M3U_TTL: return - _last_m3u_fetch = now - all_urls = {} - for m3u_url in M3U_URLS: - try: - r = requests.get(m3u_url, headers=UA, timeout=10) - if r.status_code != 200: continue - text = r.text - lines = text.strip().split("\n") - current_channel = None - for line in lines: - s = line.strip() - if s.startswith("#EXTINF"): - m = re.search(r'tvg-id="?(\w+)"?', s) - if m: current_channel = m.group(1).lower() - else: - m2 = re.search(r'(VTV\d+|vtv\d+)', s, re.IGNORECASE) - if m2: current_channel = m2.group(1).lower() - else: current_channel = None - elif s.startswith("http") and current_channel: - ch_id = current_channel - ch_map = {"vtv1":"vtv1","vtv2":"vtv2","vtv3":"vtv3","vtv4":"vtv4","vtv5":"vtv5","vtv6":"vtv6","vtv7":"vtv7","vtv8":"vtv8","vtv9":"vtv9","vtv10":"vtv10","vtvcầnthơ":"vtv10","vtvcantho":"vtv10","vtvcan-tho":"vtv10"} - if ch_id in ch_map: ch_id = ch_map[ch_id] - if ch_id in CHANNEL_URLS and ch_id not in all_urls: - if CHANNEL_URLS[ch_id] and HARDCODED_URLS.get(ch_id): current_channel = None; continue - if ".flv" in s: all_urls[ch_id] = s - elif ch_id not in all_urls or ".flv" not in all_urls.get(ch_id,""): - if ch_id not in all_urls: all_urls[ch_id] = s - current_channel = None - if len(all_urls) >= 10: break - except: pass - with _channel_urls_lock: - found = 0 - for ch_id in CHANNEL_URLS: - if CHANNEL_URLS[ch_id] and HARDCODED_URLS.get(ch_id): continue - if ch_id in all_urls: CHANNEL_URLS[ch_id] = all_urls[ch_id]; found += 1 - print(f"[M3U] Found {found}/10 channels (hardcoded: vtv6, vtv10)") - -def get_channel_url(channel_id): - _fetch_m3u() - with _channel_urls_lock: return CHANNEL_URLS.get(channel_id) - -# ===================== API ENDPOINTS ===================== - -@router.get("/api/vtv/streams") -def api_vtv_streams(): - urls = dict(CHANNEL_URLS) - result = {} - for ch_id in CHANNEL_NAMES: - stream_url = urls.get(ch_id) - iframe_url = IFRAME_URLS.get(ch_id) - is_flv = stream_url and ".flv" in stream_url - result[ch_id] = { - "name": CHANNEL_NAMES[ch_id], - "stream_url": stream_url, - "proxy_url": f"/api/proxy/flv?url={quote(stream_url, safe='')}" if stream_url and is_flv else "", - "proxy_url_hls": f"/api/proxy/stream?url={quote(stream_url, safe='')}" if stream_url and not is_flv else "", - "is_flv": is_flv, - "iframe_url": iframe_url, - "status": "ok" - } - return JSONResponse(result) - -@router.get("/api/vtv/stream/{channel_id}") -def api_vtv_stream(channel_id: str): - channel_id = channel_id.lower().strip() - if channel_id not in CHANNEL_NAMES: return JSONResponse({"error":"not found"}, status_code=404) - stream_url = get_channel_url(channel_id) - iframe_url = IFRAME_URLS.get(channel_id) - is_flv = stream_url and ".flv" in stream_url - m3u8_proxy = "" - if stream_url and not is_flv and stream_url.startswith("http"): - m3u8_proxy = f"/api/proxy/m3u8?url={quote(stream_url, safe='')}" - return JSONResponse({ - "name": CHANNEL_NAMES[channel_id], - "stream_url": stream_url, - "m3u8_proxy_url": m3u8_proxy, - "proxy_url": f"/api/proxy/flv?url={quote(stream_url, safe='')}" if stream_url and is_flv else "", - "proxy_url_hls": f"/api/proxy/stream?url={quote(stream_url, safe='')}" if stream_url and not is_flv else "", - "is_flv": is_flv, - "iframe_url": iframe_url, - "status": "ok" - }) - -@router.get("/api/vtv/m3u/refresh") -def api_vtv_m3u_refresh(): - global _last_m3u_fetch - _last_m3u_fetch = 0 - _fetch_m3u() - urls = dict(CHANNEL_URLS) - found = sum(1 for v in urls.values() if v) - return JSONResponse({"status":"refreshed","channels_found":found,"channels":{k:v for k,v in urls.items() if v}}) - -# ===================== EPG ===================== -_epg_cache = {}; _epg_cache_time = 0; _EPG_CACHE_TTL = 600 -VTV_CHANNEL_MAP = {"vtv1":"vtv1","vtv2":"vtv2","vtv3":"vtv3","vtv4":"vtv4","vtv5":"vtv5","vtv5-tay-nam-bo":"vtv5","vtv5-tay-nguyen":"vtv5","vtv6":"vtv6","vtv7":"vtv7","vtv8":"vtv8","vtv9":"vtv9","vtv-can-tho":"vtv10"} - -def _parse_time(time_str, reference_date=None): - if not time_str: return None - time_str = time_str.strip().replace("h",":").replace("H",":") - m = re.search(r'(\d{1,2}):(\d{2})', time_str) - if m: - try: - hour, minute = int(m.group(1)), int(m.group(2)) - base = reference_date or datetime.now(VN_TZ) - if hour < 5: base = base - timedelta(days=1) if base.hour >= 5 else base - dt = base.replace(hour=hour, minute=minute, second=0, microsecond=0) - if dt.tzinfo is None: dt = dt.replace(tzinfo=VN_TZ) - return dt - except: pass - return None - -def _fetch_epg_from_vtv(): - global _epg_cache, _epg_cache_time - now_ts = time.time() - if _epg_cache and now_ts - _epg_cache_time < _EPG_CACHE_TTL: return _epg_cache - epg_data = {} - now_vn = datetime.now(VN_TZ) - try: - h = {"User-Agent": UA["User-Agent"], "Accept-Language": "vi-VN,vi;q=0.9", "Referer": "https://vtv.vn/"} - r = requests.get("https://vtv.vn/lich-phat-song.htm", headers=h, timeout=20) - if r.status_code != 200: return epg_data - r.encoding = "utf-8" - soup = BeautifulSoup(r.text, "lxml") - channel_order = [] - for link in soup.find_all('a', href=re.compile(r'truyen-hinh-truc-tuyen/([^.]+)\.htm')): - ch_id = re.search(r'truyen-hinh-truc-tuyen/([^.]+)\.htm', link.get('href','')) - if ch_id and ch_id.group(1) not in channel_order: channel_order.append(ch_id.group(1)) - containers = soup.find_all('ul', class_=re.compile(r'\bprograms\b')) - for i, container in enumerate(containers): - if i >= len(channel_order): break - vtv_id = channel_order[i] - our_id = VTV_CHANNEL_MAP.get(vtv_id, vtv_id) - if our_id not in epg_data: epg_data[our_id] = [] - for li in container.find_all('li', class_=re.compile(r'\bprogram\b')): - t = li.find('span', class_=re.compile(r'\btime\b')) - title_el = li.find('span', class_=re.compile(r'\btitle\b')) - genre = li.find('a', class_=re.compile(r'\bgenre\b')) - time_str = t.get_text(strip=True) if t else "" - title = genre.get_text(strip=True) if genre else "" - if not title and title_el: title = title_el.get_text(strip=True) - if not time_str or not title: continue - start_dt = _parse_time(time_str, reference_date=now_vn) - if not start_dt: continue - epg_data[our_id].append({"time":time_str[:5],"title":title[:80],"start_dt":start_dt,"date":start_dt.strftime("%d/%m/%Y")}) - for ch_id in epg_data: epg_data[ch_id].sort(key=lambda x: x.get("start_dt") or datetime.min) - except Exception as e: print(f"EPG error: {e}") - _epg_cache = epg_data; _epg_cache_time = now_ts - return epg_data - -@router.get("/api/vtv/epg/{channel_id}") -def api_vtv_epg(channel_id: str): - channel_id = channel_id.lower().strip() - if channel_id not in CHANNEL_NAMES: return JSONResponse({"error":"channel not found"}, status_code=404) - epg_data = _fetch_epg_from_vtv() - programmes = epg_data.get(channel_id, []) - now = datetime.now(VN_TZ) - today = now.date() - result = [] - today_progs = [p for p in programmes if p.get("start_dt") and p["start_dt"].date() == today] or programmes - for i, p in enumerate(today_progs): - start_dt = p.get("start_dt") - stop_dt = today_progs[i+1].get("start_dt") if i+1 < len(today_progs) else None - is_now = bool(start_dt and ((stop_dt and start_dt <= now < stop_dt) or start_dt <= now)) - end_time = stop_dt.strftime("%H:%M") if stop_dt else "" - result.append({"time":p["time"],"title":p["title"],"end_time":end_time,"now":is_now,"date":p.get("date","")}) - return JSONResponse({"channel":channel_id,"channel_name":CHANNEL_NAMES.get(channel_id,channel_id),"date":now.strftime("%Y-%m-%d"),"programs":result}) - -@router.get("/api/vtv/epg") -def api_vtv_epg_refresh(): - global _epg_cache, _epg_cache_time - _epg_cache = {}; _epg_cache_time = 0 - epg_data = _fetch_epg_from_vtv() - return JSONResponse({"status":"refreshed","channels":len(epg_data),"total":sum(len(v) for v in epg_data.values())}) \ No newline at end of file diff --git a/vtv_epg_data.json b/vtv_epg_data.json deleted file mode 100644 index 4772c6fcc5dccf5f56686855928e565d33758744..0000000000000000000000000000000000000000 --- a/vtv_epg_data.json +++ /dev/null @@ -1 +0,0 @@ -{"vtv1":[{"time":"00:00","title":"PHIM TRUYỆN: MẸ BIỂN - TẬP 24"},{"time":"00:00","title":"MẸ BIỂN - TẬP 26"},{"time":"00:00","title":"PHIM TRUYỆN: MẸ BIỂN - TẬP 25"},{"time":"00:30","title":"ÁNH SÁNG TRI THỨC: KHƠI NGUỒN SÁNG TẠO"},{"time":"00:30","title":"HỌC VÀ LÀM THEO BÁC: CÓ CHÍ THÌ NÊN"},{"time":"00:30","title":"CHỐNG GIAN LẬN-BẢO VỆ NGƯỜI DÙNG"},{"time":"00:45","title":"VĂN HỌC NGHỆ THUẬT: NSND DƯƠNG MINH ĐỨC"},{"time":"00:45","title":"SỰ LỰA CHỌN"},{"time":"00:45","title":"SỰ KIỆN VÀ BÌNH LUẬN"},{"time":"01:10","title":"PHIM TÀI LIỆU: KHI VỸ HÁT"},{"time":"01:10","title":"TƯƠNG LAI XANH: CHẤN CHỈNH KHAI THÁC KHOÁNG SẢN"},{"time":"01:10","title":"GIAI ĐIỆU KẾT NỐI"},{"time":"01:40","title":"KHÁM PHÁ VIỆT NAM: DẤU ẤN ĐÀO XÁ"},{"time":"01:40","title":"KHÁM PHÁ VIỆT NAM: NGƯỜI BAHNAR BÊN DÒNG ĐẮK BLA"},{"time":"02:00","title":"THỂ THAO: KẾT NỐI THỂ THAO"},{"time":"02:00","title":"THỂ THAO: GIỜ VÀNG THỂ THAO"},{"time":"02:00","title":"THỂ THAO"},{"time":"02:30","title":"TỪ NHỮNG MIỀN QUÊ: BÌNH YÊN BẢO LỘC"},{"time":"02:30","title":"TỪ NHỮNG MIỀN QUÊ: SẮC MÀU TRÊN ĐẤT MƯỜNG VÀ"},{"time":"02:30","title":"TỪ NHỮNG MIỀN QUÊ - VÙNG ĐẤT CẨM NAM"},{"time":"02:45","title":"VTV SỐNG KHỎE: KHI TRÁI TIM LOẠN NHỊP"},{"time":"02:45","title":"VTV SỐNG KHỎE: VIÊM TAI GIỮA, NHỮNG NGUY CƠ NGÀY HÈ"},{"time":"02:45","title":"VTV SỐNG KHỎE"},{"time":"03:30","title":"PHIM TRUYỆN: GIA ĐÌNH MÌNH VUI BẤT THÌNH LÌNH - TẬP 39"},{"time":"03:30","title":"PHIM TRUYỆN: GIA ĐÌNH MÌNH VUI BẤT THÌNH LÌNH - TẬP 41"},{"time":"03:30","title":"GIA ĐÌNH MÌNH VUI BẤT THÌNH LÌNH - TẬP 43"},{"time":"04:15","title":"PHIM TRUYỆN: GIA ĐÌNH MÌNH VUI BẤT THÌNH LÌNH - TẬP 40"},{"time":"04:15","title":"PHIM TRUYỆN: GIA ĐÌNH MÌNH VUI BẤT THÌNH LÌNH - TẬP 42"},{"time":"04:15","title":"GIA ĐÌNH MÌNH VUI BẤT THÌNH LÌNH - TẬP 44"},{"time":"05:05","title":"S - VIỆT NAM: BÀ NÀ TINH HOA ẨM THỰC CHÂU ÂU"},{"time":"05:05","title":"S - VIỆT NAM: GIA LAI - MÙA LỄ HỘI CẦU NGƯ"},{"time":"05:05","title":"S - VIỆT NAM"},{"time":"05:10","title":"KINH TẾ BẠC: NGÔI NHÀ THỨ 2"},{"time":"05:10","title":"VÌ CỘNG ĐỒNG: DỆT HOA SẮC MÀU TRUYỀN THỐNG"},{"time":"05:10","title":"HÀNH TRÌNH HY VỌNG"},{"time":"05:25","title":"HẢI QUAN VIỆT NAM"},{"time":"05:30","title":"CHÀO BUỔI SÁNG"},{"time":"07:00","title":"TIÊU ĐIỂM CHÍNH SÁCH"},{"time":"07:00","title":"BÁO CHÍ TOÀN CẢNH"},{"time":"07:00","title":"TÀI CHÍNH - KINH DOANH"},{"time":"07:15","title":"KHÔNG GIAN VĂN HÓA NGHỆ THUẬT"},{"time":"07:25","title":"VIỆT NAM ĐA SẮC"},{"time":"07:30","title":"PHÓNG SỰ: CHUYỆN LÀNG TRONG VẬN NƯỚC"},{"time":"07:30","title":"NẺO VỀ NGUỒN CỘI - ÂM VANG TRỐNG ĐỒNG"},{"time":"07:45","title":"DÁM SỐNG: RỰC RỠ GIỮA BÓNG TỐI"},{"time":"07:45","title":"KHÁM PHÁ VIỆT NAM - NGHỀ XƯA GIỮA NHỊP SỐNG MỚI"},{"time":"08:00","title":"VTV KẾT NỐI"},{"time":"08:00","title":"SỐNG MỚI"},{"time":"08:00","title":"HÀNH TRÌNH HY VỌNG"},{"time":"08:15","title":"SÁCH HAY THAY ĐỔI CUỘC ĐỜI"},{"time":"08:15","title":"SỐNG XANH"},{"time":"08:30","title":"TẠP CHÍ KINH TẾ CUỐI TUẦN"},{"time":"08:40","title":"ĐI CÙNG CHÚNG TÔI"},{"time":"08:45","title":"CHỐNG GIAN LẬN-BẢO VỆ NGƯỜI DÙNG"},{"time":"09:00","title":"THỜI SỰ"},{"time":"09:05","title":"DU LỊCH VIỆT NAM"},{"time":"09:15","title":"SỰ KIỆN VÀ BÌNH LUẬN"},{"time":"09:15","title":"TOÀN CẢNH THẾ GIỚI"},{"time":"09:15","title":"TẠP CHÍ KINH TẾ CUỐI TUẦN"},{"time":"09:45","title":"DÁM SỐNG: KIẾN TRÚC SƯ MAI HƯNG TRUNG"},{"time":"09:45","title":"VIETNAM 360: QUY HOẠCH HÀ NỘI TẦM NHÌN 100 NĂM"},{"time":"09:45","title":"VTV KẾT NỐI"},{"time":"10:00","title":"ĐIỂM TỰA CUỘC SỐNG: BẢO VỆ TRẺ EM TRÊN KHÔNG GIAN MẠNG"},{"time":"10:00","title":"THỂ THAO"},{"time":"10:15","title":"VTV KẾT NỐI"},{"time":"10:25","title":"TOÀN CẢNH THẾ GIỚI"},{"time":"10:30","title":"TƯƠNG LAI XANH: CHẤN CHỈNH KHAI THÁC KHOÁNG SẢN"},{"time":"10:30","title":"CẶP LÁ YÊU THƯƠNG"},{"time":"11:00","title":"SỐNG AN TOÀN"},{"time":"11:00","title":"SỐNG XANH"},{"time":"11:00","title":"TÀI CHÍNH - KINH DOANH"},{"time":"11:30","title":"CUỘC SỐNG SỐ"},{"time":"11:45","title":"GÓC NHÌN VĂN HÓA"},{"time":"12:00","title":"THỜI SỰ"},{"time":"12:40","title":"SỰ LỰA CHỌN"},{"time":"12:40","title":"NÔNG NGHIỆP XANH: CANH TÁC SỐ"},{"time":"12:45","title":"NHỊP SỐNG TUỔI BẠC"},{"time":"12:55","title":"CẶP LÁ YÊU THƯƠNG"},{"time":"13:00","title":"VTV SỐNG KHỎE - DINH DƯỠNG CHO NGƯỜI VIỆT: ĐẢM BẢO DINH DƯỠNG CHO TRẺ TRONG THỜI BÃO GIÁ"},{"time":"13:00","title":"VTV SỐNG KHỎE: ĐIỀU TRỊ HIỆU QUẢ UNG THƯ VÚ"},{"time":"13:00","title":"VTV SỐNG KHỎE"},{"time":"13:45","title":"PHỤ NỮ VÀ CUỘC SỐNG: NỮ ĐẠI SỨ DU LỊCH LÀNG NGHỀ"},{"time":"13:45","title":"80 NĂM QUỐC HỘI VIỆT NAM"},{"time":"13:45","title":"TỪ NHỮNG MIỀN QUÊ - VÙNG ĐẤT CẨM NAM"},{"time":"14:00","title":"GÓC NHÌN VĂN HÓA"},{"time":"14:15","title":"HỌC VÀ LÀM THEO BÁC: CÓ CHÍ THÌ NÊN"},{"time":"14:15","title":"HÀNH TRÌNH DI SẢN: SỨC SỐNG BÊN DÒNG SUỐI MƯỜNG HOA"},{"time":"14:15","title":"SỐNG MỚI"},{"time":"14:30","title":"VĂN HỌC NGHỆ THUẬT: NHỮNG CÂY CẦU CỦA VĂN CHƯƠNG"},{"time":"14:55","title":"ÁNH SÁNG TRI THỨC - AI TRONG GIÁO DỤC"},{"time":"15:00","title":"DOANH NGHIỆP - DOANH NHÂN: CHUYỂN DỊCH"},{"time":"15:00","title":"GIAI ĐIỆU KẾT NỐI"},{"time":"15:10","title":"DOANH NGHIỆP - DOANH NHÂN - CHUYỂN DỊCH"},{"time":"15:25","title":"VTV KẾT NỐI"},{"time":"15:30","title":"TRÁI TIM CHO EM: ƯỚC MƠ TỪ NHỊP TIM NHỎ"},{"time":"15:30","title":"CẶP LÁ YÊU THƯƠNG"},{"time":"15:40","title":"THƯƠNG HIỆU QUỐC GIA VIỆT NAM: THƯƠNG HIỆU QUỐC GIA VÀ SỞ HỮU TRÍ TUỆ"},{"time":"15:45","title":"KHUYẾN HỌC - HÀNH TRÌNH TRI THỨC: DÒNG HỌ KHOA BẢNG NGUYỄN VŨ"},{"time":"15:55","title":"VỀ QUÊ: LIÊN KẾT BỀN VỮNG NƠI VÙNG CAO"},{"time":"15:55","title":"CÙNG EM ĐẾN TRƯỜNG: NHỮNG MÓN QUÀ TIẾP SỨC"},{"time":"16:00","title":"THỜI SỰ"},{"time":"16:15","title":"VĂN HOÁ CAND: BẢN LĨNH VÀ KHÁT VỌNG"},{"time":"16:15","title":"VĂN HOÁ QĐND"},{"time":"16:15","title":"NHÂN ĐẠO - ĐIỂM TỰA AN SINH XÃ HỘI"},{"time":"16:30","title":"SỰ KIỆN VÀ BÌNH LUẬN"},{"time":"16:45","title":"VÌ CỘNG ĐỒNG: DỆT HOA SẮC MÀU TRUYỀN THỐNG"},{"time":"16:45","title":"KINH TẾ BẠC: NHỮNG NGƯỜI TIÊN PHONG"},{"time":"17:00","title":"NHẬT KÝ NGƯỜI VIỆT: LÚA LAI HAI DÒNG Ở VIỆT NAM"},{"time":"17:00","title":"NHẬT KÝ NGƯỜI VIỆT: GIỮ HỒN NHẠC CỤ ĐÀO XÁ"},{"time":"17:00","title":"VIỆT NAM ĐA SẮC"},{"time":"17:05","title":"KHÁM PHÁ VIỆT NAM: NGƯỜI BAHNAR BÊN DÒNG ĐẮK BLA"},{"time":"17:05","title":"KHÁM PHÁ VIỆT NAM: THẠNH HÓA -BẢN HÒA CA TỪ BIỂN"},{"time":"17:10","title":"CẢI CÁCH HÀNH CHÍNH"},{"time":"17:20","title":"HÀNH TRÌNH VẺ ĐẸP: ĐỜN CA TÀI TỬ MIỀN TÂY XỨ DỪA"},{"time":"17:20","title":"HÀNH TRÌNH VẺ ĐẸP"},{"time":"17:30","title":"CHUYỂN ĐỘNG 24H"},{"time":"18:00","title":"VIỆT NAM HÔM NAY"},{"time":"18:25","title":"VÌ TẦM VÓC VIỆT"},{"time":"19:00","title":"THỜI SỰ"},{"time":"19:40","title":"THỜI TIẾT + THỂ THAO 24/7"},{"time":"19:40","title":"THỂ THAO 24/7"},{"time":"19:55","title":"ĐIỂM TIN"},{"time":"20:00","title":"S - VIỆT NAM: GIA LAI - MÙA LỄ HỘI CẦU NGƯ"},{"time":"20:00","title":"S - VIỆT NAM: DẤU ẤN ĐÔNG DƯƠNG GIỮA LÒNG HẢI PHÒNG"},{"time":"20:00","title":"VIỆT NAM - ĐIỂM HẸN"},{"time":"20:05","title":"VIỆT NAM VUI KHỎE"},{"time":"20:10","title":"TRUYỀN HÌNH TRỰC TIẾP: KHAI MẠC LỄ HỘI VÌ HÒA BÌNH NĂM 2026"},{"time":"20:10","title":"TRUYỀN HÌNH QUÂN ĐỘI NHÂN DÂN"},{"time":"20:10","title":"PHÓNG SỰ"},{"time":"20:30","title":"QUỐC DÂN HIỂU THUẾ"},{"time":"20:30","title":"PHIM TÀI LIỆU"},{"time":"20:45","title":"TIÊU ĐIỂM"},{"time":"20:55","title":"THUẾ VÀ ĐỜI SỐNG"},{"time":"21:00","title":"PHIM TÀI LIỆU: HÀNH TRÌNH CỦA NHỮNG GIÁ TRỊ SỐNG"},{"time":"21:00","title":"PHÍA BÊN KIA THÀNH PHỐ - TẬP 27"},{"time":"21:30","title":"ÁNH SÁNG TRI THỨC: AI TRONG GIÁO DỤC"},{"time":"21:30","title":"TÀI CHÍNH - KINH DOANH"},{"time":"21:45","title":"CÂU CHUYỆN QUỐC TẾ: RANH GIỚI MÀN HÌNH"},{"time":"21:45","title":"DÁM SỐNG: KIẾN TRÚC SƯ MAI HƯNG TRUNG"},{"time":"21:55","title":"VIỆC TỬ TẾ"},{"time":"22:00","title":"CHUYỂN ĐỘNG CUỐI NGÀY"},{"time":"22:30","title":"NHẬT KÝ FIFA WORLD CUP 2026"},{"time":"22:45","title":"TÁC PHẨM MỚI: NGÀY ĐẤT NƯỚC CHUYỂN MÌNH"},{"time":"22:45","title":"HÒA NHẠC THÍNH PHÒNG: HÒA NHẠC VIỄN PHƯƠNG - PHẦN 2"},{"time":"22:45","title":"VTV KẾT NỐI"},{"time":"23:00","title":"GIỜ VÀNG THỂ THAO"},{"time":"23:00","title":"SỐNG MỚI"},{"time":"23:15","title":"TỔ QUỐC TRONG TIM"},{"time":"23:15","title":"GIỜ VÀNG THỂ THAO"},{"time":"23:45","title":"KHÁM PHÁ VIỆT NAM: NGƯỜI BAHNAR BÊN DÒNG ĐẮK BLA"},{"time":"23:45","title":"KHÁM PHÁ VIỆT NAM: THẠNH HÓA -BẢN HÒA CA TỪ BIỂN"}],"vtv2":[{"time":"00:00","title":"PHIM TRUYỆN: GIÓ NGANG KHOẢNG TRỜI XANH - TẬP 39"},{"time":"00:00","title":"PHIM TRUYỆN: GIÓ NGANG KHOẢNG TRỜI XANH - TẬP 40"},{"time":"00:00","title":"PHIM TRUYỆN: GIÓ NGANG KHOẢNG TRỜI XANH - TẬP 41"},{"time":"00:45","title":"KHÁM PHÁ VIỆT NAM: DẤU ẤN THIÊNG HAI BÀ TRƯNG"},{"time":"00:45","title":"KHÁM PHÁ VIỆT NAM: NHỊP SỐNG MIỀN CHIÊM TRŨNG"},{"time":"00:45","title":"KHÁM PHÁ VIỆT NAM: HƯƠNG VỊ GIỮ HỒN QUÊ"},{"time":"01:00","title":"BẠN CỦA NHÀ NÔNG"},{"time":"01:45","title":"DÁM SỐNG: HOÀNG VẼ CUỘC ĐỜI MÌNH"},{"time":"01:45","title":"DÁM SỐNG: KÌNH NGƯ"},{"time":"01:45","title":"DÁM SỐNG: CHÀNG ĐAM - SAN THỜI HIỆN ĐẠI"},{"time":"02:00","title":"KHÁM PHÁ THẾ GIỚI: SỰ SỐNG TRONG ĐẠI DƯƠNG - TẬP 5"},{"time":"02:00","title":"KHÁM PHÁ THẾ GIỚI: SỰ SỐNG TRONG ĐẠI DƯƠNG - TẬP 6"},{"time":"02:00","title":"KHÁM PHÁ THẾ GIỚI: CUỘC SỐNG 2.0 - TẬP 1"},{"time":"02:30","title":"HIỂU SÂU - SỐNG CHẤT: KHI MÂU THUẪN VƯỢT NGƯỠNG"},{"time":"02:30","title":"HIỂU SÂU - SỐNG CHẤT: ÔNG BÀ THỜI CHUYỂN ĐỔI SỐ"},{"time":"03:00","title":"S - TECH"},{"time":"03:30","title":"CHUYỆN NHÀ THỜI NAY"},{"time":"04:00","title":"PHIM TRUYỆN: GIÓ NGANG KHOẢNG TRỜI XANH - TẬP 40"},{"time":"04:00","title":"PHIM TRUYỆN: GIÓ NGANG KHOẢNG TRỜI XANH - TẬP 41"},{"time":"04:00","title":"PHIM TRUYỆN: GIÓ NGANG KHOẢNG TRỜI XANH - TẬP 42"},{"time":"04:45","title":"DÁM SỐNG: KÌNH NGƯ"},{"time":"04:45","title":"DÁM SỐNG: CHÀNG ĐAM - SAN THỜI HIỆN ĐẠI"},{"time":"04:45","title":"DÁM SỐNG: ĐỂ MẠCH NGUỒN TUỒNG CHẢY MÃI"},{"time":"05:00","title":"EDUTALK - BÀN LUẬN GIÁO DỤC: TRƯỜNG CHUYÊN TIỆM CẬN CHUẨN QUỐC TẾ"},{"time":"05:00","title":"BÍ ẨN TỰ NHIÊN: BÍ ẨN NÚI LỬA ĐĂK NÔNG"},{"time":"05:00","title":"ĐƯỜNG TỚI NÔNG TRẠI: DU LỊCH NÔNG NGHIỆP TRẢI NGHIỆM"},{"time":"05:30","title":"BẠN CỦA NHÀ NÔNG"},{"time":"06:15","title":"VTV SỐNG KHỎE: SỐNG CHUNG VỚI TRÁI TIM SUY"},{"time":"06:15","title":"VTV SỐNG KHỎE: YOGA VỚI SỨC KHỎE NGƯỜI CAO TUỔI"},{"time":"06:15","title":"VTV SỐNG KHỎE: RỐI LOẠN TIC - HIỂU ĐỂ CHỮA LÀNH"},{"time":"06:45","title":"KHÁM PHÁ VIỆT NAM: NHỊP SỐNG MIỀN CHIÊM TRŨNG"},{"time":"06:45","title":"VTV KẾT NỐI"},{"time":"06:45","title":"KHÁM PHÁ VIỆT NAM: DẤU ẤN THIÊNG HAI BÀ TRƯNG"},{"time":"07:00","title":"CHUYỆN NHÀ THỜI NAY"},{"time":"07:30","title":"PHIM TÀI LIỆU: THIÊN NHIÊN TÀ KÓU - PHẦN 1"},{"time":"07:30","title":"PHIM TÀI LIỆU: THIÊN NHIÊN TÀ KÓU - PHẦN 2"},{"time":"07:30","title":"PHIM TÀI LIỆU: THIÊN NHIÊN TÀ KÓU - PHẦN 3"},{"time":"08:00","title":"VTV SỐNG KHỎE: VIÊM TAI GIỮA, NHỮNG NGUY CƠ NGÀY HÈ"},{"time":"08:00","title":"TƯ VẤN TUYỂN SINH"},{"time":"08:00","title":"VTV SỐNG KHỎE: UNG THƯ TRỰC TRÀNG"},{"time":"08:45","title":"DÁM SỐNG: CHÀNG ĐAM - SAN THỜI HIỆN ĐẠI"},{"time":"08:45","title":"DÁM SỐNG: HÀNH TRÌNH TỎA SÁNG THẾ GIỚI"},{"time":"09:00","title":"BÍ ẨN TỰ NHIÊN: BÍ ẨN NÚI LỬA ĐĂK NÔNG"},{"time":"09:00","title":"ĐƯỜNG TỚI NÔNG TRẠI: DU LỊCH NÔNG NGHIỆP TRẢI NGHIỆM"},{"time":"09:25","title":"TRÁI TIM CHO EM: ƯỚC MƠ TỪ NHỊP TIM NHỎ"},{"time":"09:30","title":"S - TECH"},{"time":"10:00","title":"KHÁM PHÁ VIỆT NAM: NHỊP SỐNG MIỀN CHIÊM TRŨNG"},{"time":"10:00","title":"KHỞI NGHIỆP KIẾN QUỐC: CHỮ TÍN - NỀN MÓNG CỦA CÔNG TRÌNH"},{"time":"10:00","title":"KHÁM PHÁ VIỆT NAM: DẤU ẤN THIÊNG HAI BÀ TRƯNG"},{"time":"10:15","title":"VTV SỐNG KHỎE: RỐI LOẠN TIC - HIỂU ĐỂ CHỮA LÀNH"},{"time":"10:15","title":"VTV SỐNG KHỎE: SỐNG CHUNG VỚI TRÁI TIM SUY"},{"time":"10:15","title":"VTV SỐNG KHỎE: YOGA VỚI SỨC KHỎE NGƯỜI CAO TUỔI"},{"time":"10:45","title":"KHÁT VỌNG XANH: THÀNH PHỐ TÍCH NHIỆT"},{"time":"10:45","title":"KHÁT VỌNG XANH: HỆ LỤY TỪ MỘT THÚ CHƠI"},{"time":"10:45","title":"KHÁM PHÁ THẾ GIỚI: KHÁM PHÁ THÁI BÌNH DƯƠNG - TẬP 2"},{"time":"11:00","title":"PHIM HOẠT HÌNH: POKÉMON CHÂN TRỜI MỚI - TẬP 11"},{"time":"11:00","title":"PHIM HOẠT HÌNH: POKÉMON CHÂN TRỜI MỚI - TẬP 12"},{"time":"11:30","title":"PHIM TRUYỆN: GIÓ NGANG KHOẢNG TRỜI XANH - TẬP 41"},{"time":"11:30","title":"PHIM TRUYỆN: GIÓ NGANG KHOẢNG TRỜI XANH - TẬP 42"},{"time":"11:30","title":"PHIM TRUYỆN: GIÓ NGANG KHOẢNG TRỜI XANH - TẬP 43"},{"time":"12:15","title":"TỪ NHỮNG MIỀN QUÊ: NGƯỜI THÁI Ở SƠN LA"},{"time":"12:15","title":"TỪ NHỮNG MIỀN QUÊ: NGHỀ NGÓI ÂM DƯƠNG BẮC SƠN"},{"time":"12:15","title":"TỪ NHỮNG MIỀN QUÊ: SẮC MÀU TRÊN ĐẤT MƯỜNG VÀ"},{"time":"12:30","title":"HIỂU SÂU - SỐNG CHẤT: VỈA HÈ CHUNG - LỢI ÍCH RIÊNG"},{"time":"12:30","title":"HIỂU SÂU - SỐNG CHẤT: HỘI NHÓM VÀ BÓC PHỐT TRÊN MẠNG"},{"time":"12:30","title":"HIỂU SÂU - SỐNG CHẤT: KHI MÂU THUẪN VƯỢT NGƯỠNG"},{"time":"13:00","title":"KHÁM PHÁ THẾ GIỚI: CUỘC SỐNG 2.0 - TẬP 1"},{"time":"13:00","title":"KHÁM PHÁ THẾ GIỚI: CUỘC SỐNG 2.0 - TẬP 2"},{"time":"13:00","title":"KHÁM PHÁ THẾ GIỚI: CUỘC SỐNG 2.0 - TẬP 3"},{"time":"13:30","title":"PHIM TÀI LIỆU: THIÊN NHIÊN TÀ KÓU - PHẦN 2"},{"time":"13:30","title":"PHIM TÀI LIỆU: THIÊN NHIÊN TÀ KÓU - PHẦN 3"},{"time":"13:30","title":"PHIM TÀI LIỆU: THIÊN NHIÊN TÀ KÓU - PHẦN 4"},{"time":"14:00","title":"SẮC MÀU CÁC DÂN TỘC: CHUYỆN TỪ BẢN VẶT"},{"time":"14:00","title":"PHỤ NỮ LÀ ĐỂ YÊU THƯƠNG: BẠN ĐƯỢC QUYỀN HẠNH PHÚC"},{"time":"14:30","title":"CHUYỆN NHÀ THỜI NAY"},{"time":"15:00","title":"TƯ VẤN TUYỂN SINH"},{"time":"15:00","title":"PHIM TÀI LIỆU: THIÊN NHIÊN TÀ KÓU - PHẦN 4"},{"time":"15:00","title":"NHỮNG MẢNH GHÉP CỦA CUỘC SỐNG: BỆNH DA HIẾM GẶP - TẬP 1"},{"time":"15:30","title":"EDUTALK - BÀN LUẬN GIÁO DỤC: CHIẾN THUẬT CHỌN NGUYỆN VỌNG"},{"time":"15:30","title":"ĐƯỜNG TỚI NÔNG TRẠI: DU LỊCH NÔNG NGHIỆP TRẢI NGHIỆM"},{"time":"16:00","title":"CẢNH GIÁC 247: NGUY CƠ RỬA TIỀN TỪ VÍ ĐIỆN TỬ"},{"time":"16:15","title":"VTV KẾT NỐI"},{"time":"16:20","title":"CHECK IN VIỆT NAM: CÓ HẸN VỚI XỨ MƯỜNG"},{"time":"16:30","title":"KHÁM PHÁ VIỆT NAM: HƯƠNG VỊ GIỮ HỒN QUÊ"},{"time":"16:30","title":"KHÁM PHÁ VIỆT NAM: DẤU ẤN THIÊNG HAI BÀ TRƯNG"},{"time":"16:30","title":"KHÁM PHÁ VIỆT NAM: NHỊP SỐNG MIỀN CHIÊM TRŨNG"},{"time":"16:43","title":"THÔNG BÁO - GHI ƠN"},{"time":"16:45","title":"BẠN CỦA NHÀ NÔNG"},{"time":"17:25","title":"NHỊP ĐẬP VIỆT NAM: LÀNG CHÀI XUÂN HẢI"},{"time":"17:25","title":"NHỊP ĐẬP VIỆT NAM: NÉT ĐẸP CỔ KÍNH CHÙA TRÔNG"},{"time":"17:25","title":"NHỊP ĐẬP VIỆT NAM: CUỘC SỐNG LÀNG CHÀI CỬA NHƯỢNG"},{"time":"17:30","title":"ĐƯỜNG TỚI NÔNG TRẠI: DU LỊCH NÔNG NGHIỆP TRẢI NGHIỆM"},{"time":"17:30","title":"BÍ ẨN TỰ NHIÊN: BÍ ẨN NÚI LỬA ĐĂK NÔNG"},{"time":"17:30","title":"EDUTALK - BÀN LUẬN GIÁO DỤC: CHIẾN THUẬT CHỌN NGUYỆN VỌNG"},{"time":"18:00","title":"KIẾN THỨC CỘNG ĐỒNG: LIVESTREAM VÀ QUYỀN RIÊNG TƯ CÁ NHÂN"},{"time":"18:00","title":"KIẾN THỨC CỘNG ĐỒNG: HIỂU LUẬT MỖI NGÀY"},{"time":"18:00","title":"KIẾN THỨC CỘNG ĐỒNG: MIỄN GIẤY PHÉP XÂY DỰNG ÁP DỤNG RA SAO?"},{"time":"18:15","title":"NHỮNG BÔNG HOA NHỎ"},{"time":"18:30","title":"CHUYỆN NHÀ THỜI NAY"},{"time":"18:30","title":"CHUYỆN NHÀ THỜI NAY: CHUYỆN NHÀ THỜI NAY - TẬP 33"},{"time":"19:00","title":"CẬN CẢNH FIFA WORLD CUP 2026"},{"time":"19:30","title":"KHÁM PHÁ THẾ GIỚI: CUỘC SỐNG 2.0 - TẬP 1"},{"time":"19:30","title":"KHÁM PHÁ THẾ GIỚI: CUỘC SỐNG 2.0 - TẬP 2"},{"time":"19:30","title":"KHÁM PHÁ THẾ GIỚI: CUỘC SỐNG 2.0 - TẬP 3"},{"time":"20:00","title":"LÁT CẮT FIFA WORLD CUP 2026"},{"time":"20:05","title":"HIỂU SÂU - SỐNG CHẤT: VỈA HÈ CHUNG - LỢI ÍCH RIÊNG"},{"time":"20:10","title":"CẢNH GIÁC 247: NGUY CƠ RỬA TIỀN TỪ VÍ ĐIỆN TỬ"},{"time":"20:10","title":"EDUTALK - BÀN LUẬN GIÁO DỤC: CHIẾN THUẬT CHỌN NGUYỆN VỌNG"},{"time":"20:30","title":"SÁCH HAY THAY ĐỔI CUỘC ĐỜI"},{"time":"20:30","title":"S - TECH"},{"time":"20:35","title":"PHỤ NỮ LÀ ĐỂ YÊU THƯƠNG: BẠN ĐƯỢC QUYỀN HẠNH PHÚC"},{"time":"20:40","title":"BÍ ẨN TỰ NHIÊN: BÃO - CỖ MÁY CUỒNG PHONG - TẬP 1"},{"time":"21:00","title":"CHECK IN VIỆT NAM: CÓ HẸN VỚI XỨ MƯỜNG"},{"time":"21:00","title":"PHIM TÀI LIỆU NƯỚC NGOÀI: NHỮNG BÍ MẬT CHƯA KHÉP LẠI CỦA THỜI APARTHEID - TẬP 2"},{"time":"21:00","title":"KHÁT VỌNG XANH: GIA TĂNG LŨ CỰC ĐOAN"},{"time":"21:15","title":"VTV SỐNG KHỎE: RỐI LOẠN TIC - HIỂU ĐỂ CHỮA LÀNH"},{"time":"21:15","title":"VTV SỐNG KHỎE: SỐNG CHUNG VỚI TRÁI TIM SUY"},{"time":"21:45","title":"PHIM TÀI LIỆU: THIÊN NHIÊN TÀ KÓU - PHẦN 4"},{"time":"21:45","title":"NHỮNG MẢNH GHÉP CỦA CUỘC SỐNG: BỆNH DA HIẾM GẶP - TẬP 1"},{"time":"21:45","title":"PHIM TÀI LIỆU: THIÊN NHIÊN TÀ KÓU - PHẦN 5"},{"time":"22:15","title":"CHUYỆN NHÀ THỜI NAY"},{"time":"22:15","title":"CHUYỆN NHÀ THỜI NAY: CHUYỆN NHÀ THỜI NAY - TẬP 33"},{"time":"22:45","title":"KHÁM PHÁ VIỆT NAM: HƯƠNG VỊ GIỮ HỒN QUÊ"},{"time":"22:45","title":"KHÁM PHÁ VIỆT NAM: DẤU ẤN THIÊNG HAI BÀ TRƯNG"},{"time":"22:45","title":"KHÁM PHÁ VIỆT NAM: NHỊP SỐNG MIỀN CHIÊM TRŨNG"},{"time":"23:00","title":"KHÁM PHÁ THẾ GIỚI: CUỘC SỐNG 2.0 - TẬP 1"},{"time":"23:00","title":"KHÁM PHÁ THẾ GIỚI: CUỘC SỐNG 2.0 - TẬP 2"},{"time":"23:00","title":"KHÁM PHÁ THẾ GIỚI: CUỘC SỐNG 2.0 - TẬP 3"},{"time":"23:30","title":"BÍ ẨN TỰ NHIÊN: BÃO - CỖ MÁY CUỒNG PHONG - TẬP 1"},{"time":"23:30","title":"S - TECH"}],"vtv3":[{"time":"00:00","title":"THANH ÂM TỪ ĐẤT VIỆT: ĐẤT HÁT"},{"time":"00:00","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 VÒNG 16 ĐỘI: CANADA - MA RỐC"},{"time":"00:10","title":"PHIM TRUYỆN: TÌNH YÊU CỦA ĐỜI TÔI - TẬP 39"},{"time":"00:20","title":"BÌNH LUẬN THỂ THAO"},{"time":"01:00","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 VÒNG 32 ĐỘI: AUSTRALIA - AI CẬP"},{"time":"01:00","title":"ĐƯỜNG LÊN ĐỈNH OLYMPIA"},{"time":"01:50","title":"SAO CHECK: CA SĨ VŨ THẢO MY"},{"time":"02:20","title":"BÌNH LUẬN THỂ THAO"},{"time":"03:00","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 VÒNG 16 ĐỘI: BRAZIL - NA UY"},{"time":"03:20","title":"BÌNH LUẬN THỂ THAO"},{"time":"04:00","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 VÒNG 16 ĐỘI: PARAGUAY - PHÁP"},{"time":"04:20","title":"BÌNH LUẬN THỂ THAO"},{"time":"05:00","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 VÒNG 32 ĐỘI: ARGENTINA - CAPE VERDE"},{"time":"06:20","title":"BÌNH LUẬN THỂ THAO"},{"time":"07:00","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 VÒNG 16 ĐỘI: MEXICO - ANH"},{"time":"07:20","title":"BÌNH LUẬN THỂ THAO"},{"time":"07:30","title":"CHUYỆN NHÀ THỜI NAY"},{"time":"07:55","title":"VIỆT NAM XANH: NGÀY HỘI ĐỔI RÁC LẤY QUÀ"},{"time":"08:00","title":"VTV KẾT NỐI"},{"time":"08:10","title":"THỂ THAO"},{"time":"08:30","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 VÒNG 32 ĐỘI: COLOMBIA - GHANA"},{"time":"08:40","title":"CẨM NANG VÀNG CHO SỨC KHỎE"},{"time":"09:00","title":"BỘ BA TRANH TÀI: HỒNG LUYẾN, THANH HUYỀN, ĐỨC THÀNH"},{"time":"10:00","title":"PHỤ NỮ SỐ"},{"time":"10:30","title":"NHẬT KÝ TRÊN KHÓA SOL: CA SĨ HOÀNG ANH"},{"time":"10:30","title":"HIỂU SÂU - SỐNG CHẤT: CHUYỆN ĐỌC SÁCH THỜI ĐẠI SỐ"},{"time":"10:55","title":"THANH ÂM TỪ ĐẤT VIỆT: NHỊP ĐIỆU ĐÔNG HỒ"},{"time":"11:00","title":"NHÀ MÌNH QUÁ ĐỈNH"},{"time":"11:10","title":"THÔNG TIN 260"},{"time":"11:15","title":"ĐIỀU NHỎ BÉ KỲ DIỆU"},{"time":"11:30","title":"GÌ THẾ NHỈ"},{"time":"11:45","title":"GIA ĐÌNH VUI VẺ"},{"time":"11:50","title":"QUÀ TẶNG CUỘC SỐNG: SỰ TÍCH CÁI DÂY LƯNG"},{"time":"12:00","title":"ÚM BA LA RA CHỮ GÌ?"},{"time":"12:00","title":"KHÁCH SẠN 5 SAO"},{"time":"12:00","title":"PHIM TRUYỆN: SỚM TỐI CÓ NHAU - TẬP 7"},{"time":"12:45","title":"ALO! ĐÂY LÀ..."},{"time":"12:50","title":"HẠNH PHÚC LÀ GÌ?: VÕ CAO ĐỈNH"},{"time":"12:50","title":"HẠNH PHÚC LÀ GÌ?: TỐNG THỊ LÂM"},{"time":"13:00","title":"VUA TIẾNG VIỆT"},{"time":"13:00","title":"ĐƯỜNG LÊN ĐỈNH OLYMPIA"},{"time":"13:00","title":"PHIM TRUYỆN: CÂU CHUYỆN HOA HỒNG - TẬP 22"},{"time":"13:50","title":"ĐƯỜNG LÊN ĐỈNH OLYMPIA"},{"time":"14:00","title":"PHIM TRUYỆN: DỊU DÀNG MÀU NẮNG - TẬP 11"},{"time":"14:00","title":"PHIM TRUYỆN: DỊU DÀNG MÀU NẮNG - TẬP 12"},{"time":"14:30","title":"ÚM BA LA RA CHỮ GÌ?"},{"time":"14:50","title":"CẢ NHÀ CÙNG VUI"},{"time":"15:00","title":"BẬT MÍ BÍ MẬT"},{"time":"15:20","title":"THỂ THAO: GIỜ VÀNG THỂ THAO"},{"time":"15:40","title":"ALO! ĐÂY LÀ..."},{"time":"15:50","title":"ALO! ĐÂY LÀ..."},{"time":"15:50","title":"GÌ THẾ NHỈ"},{"time":"16:00","title":"SÂN CỎ FIFA WORLD CUP 2026"},{"time":"16:10","title":"TALK VIETNAM: HẦU ĐỒNG - DI SẢN KHÔNG BIÊN GIỚI"},{"time":"16:10","title":"PHIM TRUYỆN: CUỘC ĐỜI VẪN ĐẸP SAO - TẬP 41"},{"time":"16:30","title":"SAO CHECK: DIỄN VIÊN NGỌC THANH TÂM"},{"time":"16:55","title":"THỜI TIẾT"},{"time":"16:55","title":"PHÚT GIÂY THƯ GIÃN: THẦN DƯỢC CHANH MUỐI"},{"time":"17:00","title":"S - VIỆT NAM (15)"},{"time":"17:00","title":"ĐIỀU NHỎ BÉ KỲ DIỆU"},{"time":"17:14","title":"THỜI TIẾT"},{"time":"17:15","title":"SÂN CỎ FIFA WORLD CUP 2026"},{"time":"17:15","title":"TALK VIETNAM: HẦU ĐỒNG - DI SẢN KHÔNG BIÊN GIỚI"},{"time":"17:20","title":"THÔNG TIN 260"},{"time":"17:29","title":"THỜI TIẾT"},{"time":"17:30","title":"SÂN CỎ FIFA WORLD CUP 2026"},{"time":"17:45","title":"VTV KẾT NỐI"},{"time":"18:00","title":"PHIM TRUYỆN: TRÁI TIM KHÔNG THỂ NGỪNG YÊU - TẬP 10"},{"time":"18:00","title":"PHIM TRUYỆN: TRÁI TIM KHÔNG THỂ NGỪNG YÊU - TẬP 11"},{"time":"18:00","title":"PHIM TRUYỆN: TRÁI TIM KHÔNG THỂ NGỪNG YÊU - TẬP 12"},{"time":"18:35","title":"CẢM HỨNG FIFA WORL CUP 2026"},{"time":"19:00","title":"THỜI SỰ"},{"time":"19:55","title":"SAO 24H"},{"time":"20:00","title":"ANH TRAI VƯỢT NGÀN CHÔNG GAI"},{"time":"20:00","title":"STUDIO 3"},{"time":"20:00","title":"PHIM TRUYỆN (20H00): DƯỚI Ô CỬA SÁNG ĐÈN - TẬP 22"},{"time":"20:50","title":"ĐIỀU NHỎ BÉ KỲ DIỆU"},{"time":"20:55","title":"FANZONE FIFA WORLD CUP 2026"},{"time":"21:00","title":"GAMEBOX - HỘP TRÒ CHƠI"},{"time":"21:00","title":"FANZONE FIFA WORLD CUP 2026"},{"time":"21:05","title":"ĐẦU BẾP THƯỢNG ĐỈNH"},{"time":"21:25","title":"FANZONE FIFA WORLD CUP 2026"},{"time":"21:30","title":"PHIM TRUYỆN: TIỂU TAM KHÔNG CÓ LỖI? - TẬP 18"},{"time":"21:55","title":"QUÀ TẶNG CUỘC SỐNG: CON VOI TRẮNG"},{"time":"22:00","title":"NÓNG CÙNG FIFA WORLD CUP 2026"},{"time":"22:35","title":"VTV KẾT NỐI"},{"time":"22:40","title":"PHIM TRUYỆN: NƠI TUYẾN LỬA - TẬP 9"},{"time":"22:50","title":"NÓNG CÙNG FIFA WORLD CUP 2026"},{"time":"23:00","title":"NÓNG CÙNG FIFA WORLD CUP 2026"},{"time":"23:20","title":"BÌNH LUẬN THỂ THAO"},{"time":"23:30","title":"THỂ THAO"}],"vtv4":[{"time":"00:00","title":"BẢN TIN THỜI SỰ"},{"time":"00:20","title":"SỨC SỐNG THỂ THAO"},{"time":"00:25","title":"THỜI TIẾT DU LỊCH"},{"time":"00:30","title":"PHIM TRUYỆN: HOA HỒNG TRÊN NGỰC TRÁI - TẬP 46"},{"time":"00:30","title":"PHIM TRUYỆN: HƯỚNG DƯƠNG NGƯỢC NẮNG - TẬP 1 - PHẦN 1"},{"time":"00:30","title":"PHIM TRUYỆN: HƯỚNG DƯƠNG NGƯỢC NẮNG - TẬP 2 - PHẦN 1"},{"time":"01:15","title":"NGƯỜI VIỆT BỐN PHƯƠNG"},{"time":"01:15","title":"S - VIỆT NAM"},{"time":"01:15","title":"CHECK IN VIỆT NAM: HƠI THỞ CỦA BIỂN"},{"time":"01:30","title":"NHÀ HÁT TRUYỀN HÌNH: VỞ CẢI LƯƠNG: HÀO KIỆT VỚI GIANG SƠN"},{"time":"01:30","title":"STUDIO 3"},{"time":"01:30","title":"PHIM TRUYỆN: PHỐ TRONG LÀNG - TẬP 31"},{"time":"02:00","title":"GIAI ĐIỆU CUỘC SỐNG: NEO ĐẬU BẾN QUÊ"},{"time":"02:15","title":"MIỀN ĐẤT VÕ: VOVINAM - NGHỆ THUẬT CẬN CHIẾN"},{"time":"02:30","title":"THẾ GIỚI TUỔI THƠ: VẦNG TRĂNG CỦA EM"},{"time":"02:55","title":"XIN CHÀO VIỆT NAM: ĐÀ NẴNG - MỘT GÓC BÌNH YÊN TRÊN ĐỈNH BÀ NÀ"},{"time":"03:00","title":"TỌA ĐÀM: CHÍNH PHỦ KIẾN TẠO PHÁT TRIỂN - SỐ 1"},{"time":"03:00","title":"CUỘC SỐNG VẪN TƯƠI ĐẸP: NHỮNG KHUNG HÌNH HẠNH PHÚC"},{"time":"03:00","title":"NÚI SÔNG BỜ CÕI: VIỆT NAM TRÚNG CỬ THẨM PHÁN ITLOS"},{"time":"03:30","title":"NÚI SÔNG BỜ CÕI: VIỆT NAM TRÚNG CỬ THẨM PHÁN ITLOS"},{"time":"03:30","title":"VĂN HỌC NGHỆ THUẬT: NSND HÀ THỦY - GỌI TÊN NHỮNG THANH ÂM"},{"time":"03:30","title":"TIẾNG VIỆT DIỆU KÌ: CÂU ĐỐ VỀ CON VẬT"},{"time":"03:45","title":"GÓC ĐỒNG HÀNH: ĐIỂM TỰA Y TẾ TẠI HÀN QUỐC"},{"time":"03:55","title":"XIN CHÀO VIỆT NAM: ĐÀ NẴNG - MỘT GÓC BÌNH YÊN TRÊN ĐỈNH BÀ NÀ"},{"time":"04:00","title":"GIAI ĐIỆU CUỘC SỐNG: NHẠT NẮNG"},{"time":"04:00","title":"PHIM CUỐI TUẦN: TRẢ GIÁ"},{"time":"04:00","title":"GIAI ĐIỆU CUỘC SỐNG: CỎ VÀ MƯA"},{"time":"04:15","title":"MIỀN ĐẤT VÕ: VOVINAM - DĨ NHU CHẾ CƯƠNG"},{"time":"04:15","title":"PHÓNG SỰ: PHÁT TRIỂN DU LỊCH SỨC KHOẺ TỪ DI SẢN"},{"time":"04:30","title":"ÁNH SÁNG TRI THỨC: TINH HOA KIẾN TRÚC"},{"time":"04:30","title":"GIAI ĐIỆU CUỘC SỐNG: LỜI MẸ RU"},{"time":"04:45","title":"TÁC PHẨM MỚI: NẾP NHÀ MIỀN XANH"},{"time":"05:00","title":"PHIM TRUYỆN: BIỆT DƯỢC ĐEN - TẬP 24"},{"time":"05:00","title":"PHIM TRUYỆN: BIỆT DƯỢC ĐEN - TẬP 25"},{"time":"05:45","title":"THỜI TIẾT DU LỊCH"},{"time":"05:50","title":"TRÁI TIM CHO EM: ƯỚC MƠ TỪ NHỊP TIM NHỎ"},{"time":"06:00","title":"ĐƯỜNG LÊN ĐỈNH OLYMPIA"},{"time":"06:00","title":"NHÀ MÌNH QUÁ ĐỈNH"},{"time":"06:45","title":"PHIM TRUYỆN: HOA HỒNG TRÊN NGỰC TRÁI - TẬP 46"},{"time":"06:45","title":"PHIM TRUYỆN: HƯỚNG DƯƠNG NGƯỢC NẮNG - TẬP 1 - PHẦN 1"},{"time":"06:45","title":"PHIM TRUYỆN: HƯỚNG DƯƠNG NGƯỢC NẮNG - TẬP 2 - PHẦN 1"},{"time":"07:30","title":"NGƯỜI VIỆT BỐN PHƯƠNG"},{"time":"07:30","title":"HƯỚNG VỀ TỔ QUỐC"},{"time":"07:30","title":"CUỘC SỐNG PHƯƠNG XA: LAN TỎA NGHỆ THUẬT MÚA VIỆT TẠI PHÁP"},{"time":"07:45","title":"GIAI ĐIỆU CUỘC SỐNG: NHẠT NẮNG"},{"time":"07:45","title":"TÁC PHẨM MỚI: NẾP NHÀ MIỀN XANH"},{"time":"07:55","title":"XIN CHÀO VIỆT NAM: ĐÀ NẴNG - MỘT GÓC BÌNH YÊN TRÊN ĐỈNH BÀ NÀ"},{"time":"08:00","title":"TỌA ĐÀM: CHÍNH PHỦ KIẾN TẠO PHÁT TRIỂN - SỐ 1"},{"time":"08:00","title":"SAO CHECK: CA SĨ VƯƠNG BÌNH"},{"time":"08:00","title":"TIẾNG VIỆT DIỆU KÌ: CÂU ĐỐ VỀ CON VẬT"},{"time":"08:15","title":"TALK VIETNAM: HẦU ĐỒNG - DI SẢN KHÔNG BIÊN GIỚI"},{"time":"08:30","title":"WHEN IN VIETNAM: NHỮNG NGƯỜI YÊU ĐỘNG VẬT"},{"time":"08:30","title":"VIETNAM DISCOVERY: KHÁM PHÁ DI SẢN THẾ GIỚI TẠI QUẢNG NINH"},{"time":"09:00","title":"NÚI SÔNG BỜ CÕI: VIỆT NAM TRÚNG CỬ THẨM PHÁN ITLOS"},{"time":"09:00","title":"VĂN HỌC NGHỆ THUẬT: NSND HÀ THỦY - GỌI TÊN NHỮNG THANH ÂM"},{"time":"09:00","title":"DU LỊCH VÀ ẨM THỰC: VĨNH LONG - NHỮNG CÂU CHUYỆN BÊN DÒNG PHÙ SA"},{"time":"09:30","title":"NHỊP SỐNG CỘNG ĐỒNG"},{"time":"09:30","title":"PHIM TÀI LIỆU: NHỮNG ĐỨA TRẺ HẠNH PHÚC - TẬP 3: KHI VỸ HÁT"},{"time":"09:30","title":"TỔ QUỐC TRONG TIM"},{"time":"09:35","title":"THẾ GIỚI TUỔI THƠ: ĐI ĐỂ LỚN LÊN"},{"time":"10:00","title":"NHÀ HÁT TRUYỀN HÌNH: VỞ CẢI LƯƠNG: HÀO KIỆT VỚI GIANG SƠN"},{"time":"10:00","title":"PHIM CUỐI TUẦN: TRẢ GIÁ"},{"time":"10:00","title":"KHÁM PHÁ VIỆT NAM: SÔNG THU BỒN VÀ LỄ HỘI BÀ MẸ SỨ XỞ"},{"time":"10:10","title":"S - VIỆT NAM"},{"time":"10:25","title":"XIN CHÀO VIỆT NAM: ĐÀ NẴNG - MỘT GÓC BÌNH YÊN TRÊN ĐỈNH BÀ NÀ"},{"time":"10:30","title":"GIAI ĐIỆU CUỘC SỐNG: LỜI MẸ RU"},{"time":"11:00","title":"ĐIỂM HẸN NGƯỜI VIỆT"},{"time":"11:15","title":"PHIM TRUYỆN: PHỐ TRONG LÀNG - TẬP 31"},{"time":"11:35","title":"ĐI ĐỂ BIẾT: MỘT NGÀY Ở ĐẢO NGỌC VỪNG"},{"time":"11:45","title":"DÁM SỐNG: HÀNH TRÌNH KỲ DIỆU"},{"time":"11:45","title":"GIAI ĐIỆU CUỘC SỐNG: MỖI KHI ANH NHÌN EM"},{"time":"12:00","title":"BẢN TIN THỜI SỰ"},{"time":"12:25","title":"SỨC SỐNG THỂ THAO"},{"time":"12:30","title":"NHÀ MÌNH QUÁ ĐỈNH"},{"time":"12:30","title":"STUDIO 3"},{"time":"12:30","title":"SẮC MÀU CÁC DÂN TỘC: CHUYỆN KỂ TỪ THANH ÂM"},{"time":"13:00","title":"PHÓNG SỰ: PHÁT TRIỂN DU LỊCH SỨC KHOẺ TỪ DI SẢN"},{"time":"13:15","title":"PHIM TRUYỆN: BIỆT DƯỢC ĐEN - TẬP 24"},{"time":"13:15","title":"PHIM TRUYỆN: BIỆT DƯỢC ĐEN - TẬP 25"},{"time":"14:00","title":"GÓC ĐỒNG HÀNH: ĐIỂM TỰA Y TẾ TẠI HÀN QUỐC"},{"time":"14:00","title":"HƯỚNG VỀ TỔ QUỐC"},{"time":"14:00","title":"ĐIỂM HẸN NGƯỜI VIỆT"},{"time":"14:15","title":"PHIM CA NHẠC: HÀ NỘI NGÀY TRỞ VỀ"},{"time":"14:15","title":"VUI - KHỎE - CÓ ÍCH"},{"time":"14:30","title":"NET ZERO - GỬI TƯƠNG LAI"},{"time":"15:00","title":"BẢN TIN THỜI SỰ"},{"time":"15:25","title":"NHỊP SỐNG CỘNG ĐỒNG"},{"time":"15:30","title":"PHIM TÀI LIỆU: THIÊN NHIÊN TÀ KÓU - PHẦN 2"},{"time":"15:30","title":"SẮC MÀU CÁC DÂN TỘC: BẢO TỒN QUẦN THỂ DI TÍCH CỐ ĐÔ HUẾ"},{"time":"15:30","title":"GÓC NHÌN CỘNG ĐỒNG: ỨNG XỬ NƠI CÔNG CỘNG - CHUYỆN KHÔNG NHỎ"},{"time":"15:55","title":"XIN CHÀO VIỆT NAM: ĐÀ NẴNG - MỘT GÓC BÌNH YÊN TRÊN ĐỈNH BÀ NÀ"},{"time":"16:00","title":"PHIM CUỐI TUẦN: TRẢ GIÁ"},{"time":"16:00","title":"CHUYẾN XE ÂM NHẠC XANH"},{"time":"16:00","title":"PHIM TRUYỆN: PHỐ TRONG LÀNG - TẬP 31"},{"time":"16:30","title":"GIAI ĐIỆU CUỘC SỐNG: TÌNH EM"},{"time":"16:45","title":"KHÁM PHÁ VIỆT NAM: SÔNG THU BỒN VÀ LỄ HỘI BÀ MẸ SỨ XỞ"},{"time":"17:00","title":"ĐƯỜNG LÊN ĐỈNH OLYMPIA"},{"time":"17:15","title":"ÁNH SÁNG TRI THỨC: TINH HOA KIẾN TRÚC"},{"time":"17:30","title":"KHÁM PHÁ VIỆT NAM: SẮC MÀU VĂN HÓA LAI CHÂU"},{"time":"17:30","title":"KHÁM PHÁ VIỆT NAM: CÁT BÀ - BẢN GIAO HƯỞNG CỦA BIỂN VÀ RỪNG"},{"time":"17:45","title":"TỪ NHỮNG MIỀN QUÊ: LÀNG RAU DƯỚI CHÂN NÚI TAM ĐẢO"},{"time":"17:45","title":"TỪ NHỮNG MIỀN QUÊ: ĐỘC ĐÁO VŨ ĐIỆU CỦA NGƯỜI SÁN CHỈ"},{"time":"17:45","title":"ÁNH SÁNG TRI THỨC: TRỞ VỀ ĐỂ KIẾN TẠO"},{"time":"18:00","title":"GIA ĐÌNH VUI VẺ"},{"time":"18:00","title":"NGƯỜI VIỆT BỐN PHƯƠNG"},{"time":"18:15","title":"VIỆT NAM KẾT NỐI THIÊN NHIÊN"},{"time":"19:00","title":"VIỆT NAM - ĐIỂM HẸN: THẮP SÁNG BẦU TRỜI"},{"time":"19:00","title":"TOÀN CẢNH THẾ GIỚI"},{"time":"19:00","title":"S - TECH"},{"time":"19:05","title":"HITECH CÔNG NGHỆ TƯƠNG LAI"},{"time":"19:30","title":"GÓC NHÌN CỘNG ĐỒNG: ỨNG XỬ NƠI CÔNG CỘNG - CHUYỆN KHÔNG NHỎ"},{"time":"19:30","title":"TIẾNG VIỆT KHÔNG KHÓ: DẠO QUANH THẢO CẦM VIÊN"},{"time":"19:30","title":"KẾT NỐI THỂ THAO"},{"time":"19:45","title":"NHÌN TỪ HÀ NỘI: KHÁT VỌNG TĂNG TRƯỞNG VÀ NIỀM TIN PHÁT TRIỂN"},{"time":"19:55","title":"RẠNG RỠ VIỆT NAM"},{"time":"20:00","title":"DU LỊCH VÀ ẨM THỰC: VĨNH LONG - NHỮNG CÂU CHUYỆN BÊN DÒNG PHÙ SA"},{"time":"20:00","title":"NET ZERO - GỬI TƯƠNG LAI"},{"time":"20:00","title":"MÔI TRƯỜNG - GÓC NHÌN TỪ QUỐC TẾ: KHI TRÁI ĐẤT NÓNG LÊN"},{"time":"20:30","title":"NÚI SÔNG BỜ CÕI: VIỆT NAM TRÚNG CỬ THẨM PHÁN ITLOS"},{"time":"20:30","title":"PHIM TÀI LIỆU: NHỮNG ĐỨA TRẺ HẠNH PHÚC - TẬP 3: KHI VỸ HÁT"},{"time":"20:30","title":"MIỀN ĐẤT VÕ: VOVINAM - NGHỆ THUẬT CẬN CHIẾN"},{"time":"20:45","title":"VIỆT NAM QUA GÓC NHÌN QUỐC TẾ"},{"time":"21:00","title":"BẢN TIN THỜI SỰ"},{"time":"21:30","title":"SỨC SỐNG THỂ THAO"},{"time":"21:35","title":"THỜI TIẾT DU LỊCH"},{"time":"21:40","title":"TIỂU PHẨM HÀI: HẺM 168 - TẬP 132"},{"time":"21:40","title":"VĂN HỌC NGHỆ THUẬT: NSND HÀ THỦY - GỌI TÊN NHỮNG THANH ÂM"},{"time":"21:40","title":"TIỂU PHẨM HÀI: HẺM 168 - TẬP 133"},{"time":"21:55","title":"GÓC ĐỒNG HÀNH: ĐIỂM TỰA Y TẾ TẠI HÀN QUỐC"},{"time":"21:55","title":"ĐIỂM HẸN NGƯỜI VIỆT"},{"time":"22:10","title":"NHỊP ĐẬP VIỆT NAM: TẾT KHU CÙ TÊ CỦA NGƯỜI LA CHÍ"},{"time":"22:10","title":"NHỊP ĐẬP VIỆT NAM: LÀNG NGHỀ MỘC CÚC BỒ"},{"time":"22:10","title":"NHỊP ĐẬP VIỆT NAM: HƯƠNG THU TRÊN BẢN TÀY"},{"time":"22:15","title":"PHIM TRUYỆN: BIỆT DƯỢC ĐEN - TẬP 24"},{"time":"22:15","title":"TALK VIETNAM: HẦU ĐỒNG - DI SẢN KHÔNG BIÊN GIỚI"},{"time":"22:15","title":"PHIM TRUYỆN: BIỆT DƯỢC ĐEN - TẬP 25"},{"time":"23:00","title":"GIAI ĐIỆU CUỘC SỐNG: CHIẾC LÁ VÔ TÌNH"},{"time":"23:00","title":"GIAI ĐIỆU CUỘC SỐNG: HOA NẮNG TÔI"},{"time":"23:00","title":"GIAI ĐIỆU CUỘC SỐNG: CỎ VÀ MƯA"},{"time":"23:15","title":"NHỊP SỐNG CỘNG ĐỒNG"},{"time":"23:15","title":"VIỆT NAM - ĐIỂM HẸN: THẮP SÁNG BẦU TRỜI"},{"time":"23:15","title":"S - VIỆT NAM: MÙA XANH Ở CÁT BÀ"},{"time":"23:20","title":"THẾ GIỚI TUỔI THƠ: ĐI ĐỂ LỚN LÊN"},{"time":"23:20","title":"NET ZERO - GỬI TƯƠNG LAI"},{"time":"23:20","title":"TỔ QUỐC TRONG TIM"},{"time":"23:45","title":"QUYẾN RŨ VIỆT NAM: THANH ÂM XƯA NINH BÌNH"},{"time":"23:45","title":"KHÁM PHÁ VIỆT NAM: HÀNH TRÌNH XANH TRÊN ĐẤT QUẢNG NGÃI"}],"vtv5":[{"time":"00:00","title":"PHIM TRUYỆN: THIÊN LONG BÁT BỘ - TẬP 9"},{"time":"00:05","title":"PHIM TRUYỆN: MỸ NHÂN TẦNG 22- TẬP 9"},{"time":"00:05","title":"PHIM TRUYỆN: MỸ NHÂN TẦNG 22 - TẬP 10"},{"time":"00:05","title":"PHIM TRUYỆN: MỸ NHÂN TẦNG 22 - TẬP 11"},{"time":"00:45","title":"PHÓNG SỰ: BƯỚC TRÊN CON ĐƯỜNG TRI THỨC"},{"time":"00:45","title":"NHỊP SỐNG HÔM NAY"},{"time":"00:45","title":"PHÓNG SỰ: NGHỊ QUYẾT 14- ĐÒN BẨY ĐỂ VÙNG CAO BỨT PHÁ BẰNG CÔNG NGHỆ SỐ"},{"time":"01:00","title":"VTV5 KẾT NỐI"},{"time":"01:00","title":"VTV SỐNG KHỎE: KHI HUYẾT ÁP ÂM THẦM TĂNG CAO"},{"time":"01:15","title":"NÔNG NGHIỆP XANH: THÚC ĐẨY NÔNG NGHIỆP HÀNG HOÁ"},{"time":"01:15","title":"VTV5 KẾT NỐI"},{"time":"01:30","title":"SỔ TAY CÔNG NGHỆ: SÁNG TẠO NỘI DUNG SỐ VỀ LÀNG QUÊ"},{"time":"01:30","title":"KINH TẾ NÔNG THÔN: LÀNG NGHỀ TRƯỚC SỨC ÉP CỦA THỊ TRƯỜNG"},{"time":"01:45","title":"CHÍNH SÁCH VÀ CUỘC SỐNG: PHÁT TRIỂN LÀNG DU LỊCH CỘNG ĐỒNG VEN BIỂN"},{"time":"01:45","title":"THANH ÂM VIỆT: EM GÁI NƠI BẢN DAO"},{"time":"01:45","title":"THƯƠNG NHỚ MIỀN TÂY"},{"time":"02:00","title":"PHIM TRUYỆN: HOA SỮA VỀ TRONG GIÓ - TẬP 32"},{"time":"02:00","title":"PHIM TRUYỆN: HOA SỮA VỀ TRONG GIÓ - TẬP 33"},{"time":"02:00","title":"PHIM TRUYỆN: HOA SỮA VỀ TRONG GIÓ - TẬP 34"},{"time":"02:45","title":"THÔNG TIN CHÍNH SÁCH PHÁP LUẬT"},{"time":"02:45","title":"KIẾN THỨC VÀ CUỘC SỐNG"},{"time":"02:45","title":"KHÁM PHÁ VIỆT NAM: SÔNG THU BỒN VÀ LỄ HỘI BÀ MẸ XỨ SỞ"},{"time":"03:00","title":"PHIM HOẠT HÌNH: 100% SÓI - TẬP 46"},{"time":"03:00","title":"PHIM HOẠT HÌNH: 100% SÓI - TẬP 47"},{"time":"03:00","title":"PHIM HOẠT HÌNH: 100% SÓI - TẬP 48"},{"time":"03:15","title":"NHỮNG NGƯỜI CON ĐẤT VIỆT: NƠI SÁNG KIẾN NẢY MẦM"},{"time":"03:15","title":"CHECK IN VN: CHÍN TẦNG MÂY GIỮA ĐẠI NGÀN"},{"time":"03:15","title":"VTV5 KẾT NỐI"},{"time":"03:30","title":"AN TOÀN GIAO THÔNG"},{"time":"03:30","title":"DÂN TỘC TÔN GIÁO: GIEO DUYÊN LÀNH CHO ĐỜI"},{"time":"03:30","title":"KHÁM PHÁ VIỆT NAM"},{"time":"03:45","title":"PHỤ NỮ SỐ: HÀNH TRÌNH NÂNG TẦM TRÀ VIỆT"},{"time":"03:45","title":"SẮC MÀU CÁC DÂN TỘC: GIỮA MIỀN SỚN CƯỚC NẶM ĐẶM"},{"time":"03:45","title":"DÂN TỘC PHÁT TRIỂN: LAI CHÂU PHÁT TRIỂN CÔNG NGHIỆP NĂNG LƯỢNG"},{"time":"04:15","title":"PHIM TRUYỆN: GIÂY PHÚT GẶP EM - TẬP 3"},{"time":"04:15","title":"PHIM TRUYỆN: GIÂY PHÚT GẶP EM - TẬP 4"},{"time":"04:15","title":"PHIM TRUYỆN: GIÂY PHÚT GẶP EM - TẬP 5"},{"time":"05:00","title":"CHƯƠNG TRÌNH TIẾNG MƯỜNG"},{"time":"05:00","title":"CHƯƠNG TRÌNH TIẾNG SÁN CHÍ"},{"time":"05:30","title":"CHƯƠNG TRÌNH TIẾNG TÀY"},{"time":"06:00","title":"CHƯƠNG TRÌNH TIẾNG MÔNG"},{"time":"06:30","title":"CHƯƠNG TRÌNH TIẾNG DAO"},{"time":"07:00","title":"CHƯƠNG TRÌNH TIẾNG THÁI"},{"time":"07:00","title":"CHƯƠNG TRÌNH TIẾNG DAO"},{"time":"07:30","title":"CHƯƠNG TRÌNH TIẾNG MÔNG"},{"time":"08:00","title":"CHƯƠNG TRÌNH TIẾNG DAO"},{"time":"08:30","title":"CHƯƠNG TRÌNH TIẾNG THÁI"},{"time":"08:30","title":"CHÀO TUẦN MỚI"},{"time":"09:00","title":"CHÍNH SÁCH VÀ CUỘC SỐNG: SÁT CÁNH CÙNG HỘ NGHÈO"},{"time":"09:00","title":"VTV SỐNG KHỎE: NGUY CƠ NẮNG NÓNG KÉO DÀI"},{"time":"09:00","title":"NÔNG THÔN MỚI: GIỮ CHUẨN NÔNG THÔN MỚI SAU SÁP NHẬP"},{"time":"09:15","title":"KHÁM PHÁ VIỆT NAM: SÔNG THU BỒN VÀ LỄ HỘI BÀ MẸ XỨ SỞ"},{"time":"09:15","title":"VTV5 KẾT NỐI"},{"time":"09:30","title":"VTV5 KẾT NỐI"},{"time":"09:30","title":"PHÁT HUY VAI TRÒ CỦA MẶT TRẬN"},{"time":"09:35","title":"QUÀ TẶNG CUỘC SỐNG: TÌNH YÊU CỦA VỊT MÁI"},{"time":"09:45","title":"ĐƯỜNG LÊN ĐỈNH OLYMPIA"},{"time":"09:45","title":"VTV5 KẾT NỐI"},{"time":"09:45","title":"VUA TIẾNG VIỆT"},{"time":"10:00","title":"SAO CHECK: DIỄN VIÊN NGỌC THANH TÂM"},{"time":"10:30","title":"PHIM TRUYỆN: HOA SỮA VỀ TRONG GIÓ - TẬP 33"},{"time":"10:30","title":"PHIM TRUYỆN: HOA SỮA VỀ TRONG GIÓ - TẬP 34"},{"time":"10:30","title":"PHIM TRUYỆN: HOA SỮA VỀ TRONG GIÓ - TẬP 35"},{"time":"11:00","title":"VĂN HÓA TÂY NGUYÊN: ĐỂ TIẾNG CHIÊNG MÃI NGÂN VANG"},{"time":"11:00","title":"SẮC MÀU VIỆT NAM: GIỮ HỒN MẠNH NGUỒN VĂN HOÁ SƠN LA"},{"time":"11:00","title":"GÌ THẾ NHỈ?"},{"time":"11:15","title":"PHIM HOẠT HÌNH: 100% SÓI - TẬP 47"},{"time":"11:15","title":"PHIM HOẠT HÌNH: 100% SÓI - TẬP 48"},{"time":"11:15","title":"PHIM HOẠT HÌNH: 100% SÓI - TẬP 49"},{"time":"11:30","title":"THỜI SỰ"},{"time":"11:50","title":"BẢN TIN THỂ THAO"},{"time":"11:55","title":"BẢN TIN THỊ TRƯỜNG"},{"time":"11:55","title":"SỨC SỐNG NGHỊ QUYẾT"},{"time":"12:00","title":"PHIM TRUYỆN: GIA ĐÌNH LÀ TẤT CẢ - TẬP 55"},{"time":"12:00","title":"PHIM TRUYỆN: GIA ĐÌNH LÀ TẤT CẢ - TẬP 57"},{"time":"12:00","title":"PHIM TRUYỆN: GIA ĐÌNH LÀ TẤT CẢ - TẬP 59"},{"time":"12:45","title":"PHIM TRUYỆN: GIA ĐÌNH LÀ TẤT CẢ - TẬP 56"},{"time":"12:45","title":"PHIM TRUYỆN: GIA ĐÌNH LÀ TẤT CẢ - TẬP 58"},{"time":"12:45","title":"PHIM TRUYỆN: GIA ĐÌNH LÀ TẤT CẢ - TẬP 60"},{"time":"13:30","title":"CHƯƠNG TRÌNH TIẾNG CAO LAN"},{"time":"13:30","title":"CHƯƠNG TRÌNH TIẾNG HÀ NHÌ"},{"time":"13:30","title":"CHƯƠNG TRÌNH TIẾNG MƯỜNG"},{"time":"14:00","title":"CHƯƠNG TRÌNH TIẾNG MÔNG"},{"time":"14:30","title":"CHƯƠNG TRÌNH TIẾNG DAO"},{"time":"15:00","title":"CHƯƠNG TRÌNH TIẾNG THÁI"},{"time":"15:30","title":"CHƯƠNG TRÌNH TIẾNG MÔNG"},{"time":"16:00","title":"CHƯƠNG TRÌNH TIẾNG TÀY"},{"time":"16:30","title":"PHIM TRUYỆN: KHOẢNG CÁCH - TẬP 4"},{"time":"16:30","title":"PHIM TRUYỆN: KHOẢNG CÁCH - TẬP 5"},{"time":"16:30","title":"PHIM TRUYỆN: KHOẢNG CÁCH - TẬP 6"},{"time":"17:15","title":"KIẾN THỨC VÀ CUỘC SỐNG"},{"time":"17:15","title":"HÀNH TRÌNH MỞ LỐI"},{"time":"17:15","title":"THÔNG TIN CHÍNH SÁCH PHÁP LUẬT"},{"time":"17:30","title":"NHỊP SỐNG HÔM NAY"},{"time":"17:30","title":"TRẠM YÊU THƯƠNG: KHI NGHỀ XƯA THẮP LÊN HY VỌNG"},{"time":"17:45","title":"PHÓNG SỰ: HỢP NHẤT BA CHƯƠNG TRÌNH MỤC TIÊU QUỐC GIA"},{"time":"18:00","title":"THỜI SỰ"},{"time":"18:30","title":"THỜI TIẾT"},{"time":"18:35","title":"SẮC MÀU THỂ THAO"},{"time":"18:35","title":"BẢN TIN THỊ TRƯỜNG"},{"time":"18:35","title":"CHÀO TUẦN MỚI"},{"time":"18:40","title":"THIẾU NHI"},{"time":"18:40","title":"CẬN CẢNH THỂ THAO"},{"time":"19:00","title":"PHIM TRUYỆN: VÒNG TRÒN ĐỊNH MỆNH - TẬP 73"},{"time":"19:00","title":"PHIM TRUYỆN: VÒNG TRÒN ĐỊNH MỆNH - TẬP 75"},{"time":"19:00","title":"PHIM TRUYỆN: VÒNG TRÒN ĐỊNH MỆNH - TẬP 77"},{"time":"19:45","title":"PHIM TRUYỆN: VÒNG TRÒN ĐỊNH MỆNH - TẬP 74"},{"time":"19:45","title":"PHIM TRUYỆN: VÒNG TRÒN ĐỊNH MỆNH - TẬP 76"},{"time":"19:45","title":"PHIM TRUYỆN: VÒNG TRÒN ĐỊNH MỆNH - TẬP 78"},{"time":"20:30","title":"VUA TIẾNG VIỆT"},{"time":"20:30","title":"KHÁCH SẠN 5 SAO: CA SĨ ĐÔNG HÙNG - VÕ HẠ TRÂM"},{"time":"20:30","title":"PHIM TÀI LIỆU: HỒ TÙNG MẬU- TRỌN ĐỜI VÌ ĐẢNG, VÌ DÂN"},{"time":"21:00","title":"LÀM GIÀU TRÊN QUÊ HƯƠNG: HIỆU QUẢ MÔ HÌNH NUÔI CÁ GIỐNG"},{"time":"21:15","title":"VTV5 KẾT NỐI"},{"time":"21:15","title":"ĐIỂM HẸN BẢN SẮC: VANG XA VÓ NGỰA BẮC HÀ"},{"time":"21:15","title":"TRANG VĂN HÓA"},{"time":"21:30","title":"PHIM TRUYỆN: GIÂY PHÚT GẶP EM - TẬP 4"},{"time":"21:30","title":"PHIM TRUYỆN: GIÂY PHÚT GẶP EM - TẬP 5"},{"time":"21:30","title":"PHIM TRUYỆN: GIÂY PHÚT GẶP EM - TẬP 6"},{"time":"22:15","title":"BẠN KỂ TÔI NGHE"},{"time":"22:15","title":"VTV5 KẾT NỐI"},{"time":"22:15","title":"NHÌN RA THẾ GIỚI"},{"time":"22:30","title":"VĂN HỌC NGHỆ THUẬT: NGƯỜI GÓI MƯA MIỀN TRUNG"},{"time":"22:45","title":"XEM VÀ NGHĨ"},{"time":"22:45","title":"VTV5 KẾT NỐI"},{"time":"23:00","title":"PHIM TRUYỆN: THIÊN LONG BÁT BỘ - TẬP 8"},{"time":"23:00","title":"PHIM TRUYỆN: THIÊN LONG BÁT BỘ - TẬP 9"},{"time":"23:00","title":"PHIM TRUYỆN: THIÊN LONG BÁT BỘ - TẬP 10"}],"vtv6":[{"time":"00:00","title":"TƯỜNG THUẬT FIFA WORLD CUP 2026 - VÒNG 32: COLOMBIA VS GHANA"},{"time":"00:20","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 - VÒNG 32: ÚC - AI CẬP"},{"time":"00:20","title":"TƯỜNG THUẬT FIFA WORLD CUP 2026 - VÒNG 1/8: PARAGUAY VS PHÁP"},{"time":"02:20","title":"TRUYỀN HÌNH TRỰC TIẾP FIFA WORLD CUP 2026 - VÒNG 1/8: BRAZIL VS NA UY"},{"time":"02:30","title":"360° THỂ THAO"},{"time":"03:20","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 - VÒNG 1/8: PARAGUAY - PHÁP"},{"time":"03:30","title":"360° THỂ THAO"},{"time":"04:20","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 - VÒNG 32: ARGENTINA - CABO VERDE"},{"time":"06:20","title":"TRUYỀN HÌNH TRỰC TIẾP FIFA WORLD CUP 2026 - VÒNG 1/8: MEXICO VS ANH"},{"time":"07:30","title":"TƯỜNG THUẬT: FIFA WORLD CUP 2026 - VÒNG 1/8: CANADA - MAROC"},{"time":"07:50","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 - VÒNG 32: COLOMBIA - GHANA"},{"time":"09:20","title":"TƯỜNG THUẬT: FIFA WORLD CUP 2026 - VÒNG 1/8: PARAGUAY - PHÁP"},{"time":"10:15","title":"TƯỜNG THUẬT FIFA WORLD CUP 2026 - VÒNG 1/8: BRAZIL VS NA UY"},{"time":"11:00","title":"CẬN CẢNH FIFA WORLD CUP 2026"},{"time":"11:30","title":"CẢM HỨNG FIFA WORL CUP 2026"},{"time":"11:45","title":"VTV SPORTS NEWS"},{"time":"12:00","title":"TƯỜNG THUẬT: FIFA WORLD CUP 2026 - VÒNG 32: ÚC - AI CẬP"},{"time":"12:00","title":"TƯỜNG THUẬT: FIFA WORLD CUP 2026 - VÒNG 32: TÂY BAN NHA - ÁO"},{"time":"12:00","title":"TƯỜNG THUẬT FIFA WORLD CUP 2026 - VÒNG 1/8: MEXICO VS ANH"},{"time":"14:00","title":"TƯỜNG THUẬT: FIFA WORLD CUP 2026 - VÒNG 32: ARGENTINA - CABO VERDE"},{"time":"14:00","title":"TƯỜNG THUẬT: FIFA WORLD CUP 2026 - VÒNG 32: ÚC - AI CẬP"},{"time":"14:00","title":"TƯỜNG THUẬT FIFA WORLD CUP 2026 - VÒNG 1/8: PARAGUAY VS PHÁP"},{"time":"15:50","title":"CẢM HỨNG FIFA WORL CUP 2026"},{"time":"16:00","title":"CẬN CẢNH FIFA WORLD CUP 2026"},{"time":"16:05","title":"SÂN CỎ FIFA WORLD CUP 2026"},{"time":"16:30","title":"TƯỜNG THUẬT FIFA WORLD CUP 2026 - VÒNG 1/8: CANADA - MAROC"},{"time":"16:35","title":"TƯỜNG THUẬT: FIFA WORLD CUP 2026 - VÒNG 32: COLOMBIA - GHANA"},{"time":"16:35","title":"TƯỜNG THUẬT: FIFA WORLD CUP 2026 - VÒNG 1/8: CANADA - MAROC"},{"time":"18:30","title":"VTV SPORTS NEWS"},{"time":"18:45","title":"CẢM HỨNG FIFA WORL CUP 2026"},{"time":"18:45","title":"CẢM HỨNG FIFA WORLD CUP 2026"},{"time":"19:00","title":"TƯỜNG THUẬT: FIFA WORLD CUP 2026 - VÒNG 32: ÚC - AI CẬP"},{"time":"19:00","title":"TƯỜNG THUẬT: FIFA WORLD CUP 2026 - VÒNG 1/8: PARAGUAY - PHÁP"},{"time":"19:00","title":"TƯỜNG THUẬT FIFA WORLD CUP 2026 - VÒNG 1/8: BRAZIL VS NA UY"},{"time":"21:00","title":"CẬN CẢNH FIFA WORLD CUP 2026"},{"time":"21:30","title":"SÂN CỎ FIFA WORLD CUP 2026"},{"time":"22:00","title":"360° THỂ THAO"},{"time":"22:30","title":"GIỜ VÀNG THỂ THAO"},{"time":"22:30","title":"TƯỜNG THUẬT: FIFA WORLD CUP 2026 - VÒNG 1/8: CANADA - MAROC"},{"time":"22:30","title":"TƯỜNG THUẬT FIFA WORLD CUP 2026 - VÒNG 1/8: MEXICO VS ANH"},{"time":"22:50","title":"NÓNG CÙNG FIFA WORLD CUP 2026"},{"time":"23:20","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 - VÒNG 1/8: CANADA - MAROC"}],"vtv7":[{"time":"06:03","title":"7 PHÚT CHO BỮA SÁNG: CÀ TÍM NHÚNG TRỨNG CHIÊN"},{"time":"06:03","title":"7 PHÚT CHO BỮA SÁNG: BÁNH MÌ NƯỚNG MUỐI ỚT"},{"time":"06:03","title":"7 PHÚT CHO BỮA SÁNG: CƠM HỘP BENTO CHO BÉ"},{"time":"06:13","title":"ĐẸP HƠN MỖI NGÀY: SỐ 7 - CHĂM SÓC MÙI HƯƠNG CƠ THỂ"},{"time":"06:13","title":"ĐẸP HƠN MỖI NGÀY: SỐ 8 - DỌN TỦ ĐỒ THEO PHONG CÁCH NHẬT BẢN"},{"time":"06:13","title":"ĐẸP HƠN MỖI NGÀY: SỐ 9 - CÁCH CHỌN ÁO SƠ MI CHO NAM"},{"time":"06:20","title":"CÙNG NHAU TA VẬN ĐỘNG: SỐ 8 - NGÔI NHÀ BIỂN XANH"},{"time":"06:20","title":"CÙNG NHAU TA VẬN ĐỘNG: SỐ 9 - TRÒ CHƠI GIẢI CỨU"},{"time":"06:20","title":"CÙNG NHAU TA VẬN ĐỘNG: SỐ 10 - CÙNG LẮC LƯ"},{"time":"06:30","title":"NHỮNG NGƯỜI BẠN DIỆU KỲ: TẬP 3"},{"time":"06:30","title":"BẠN LÀ HÌNH GÌ: VŨ ĐIỆU NGÔI SAO"},{"time":"06:30","title":"BẠN LÀ HÌNH GÌ: HÒN ĐẢO KỲ LẠ"},{"time":"06:35","title":"ĐỘI CỨU HỘ BIỂN XANH: TẬP 1"},{"time":"06:35","title":"ĐỘI CỨU HỘ BIỂN XANH - TẬP 2"},{"time":"06:45","title":"Ú ÒA: SỐ 14 - CHƠI VỚI BÓNG THỔI"},{"time":"06:45","title":"Ú ÒA: SỐ 15 - HAI BÀN TAY"},{"time":"06:45","title":"Ú ÒA: SỐ 16 - CẢM ƠN - XIN LỖI"},{"time":"07:00","title":"XỨ SỞ CẦU VỒNG: SỐ 18"},{"time":"07:00","title":"XỨ SỞ CẦU VỒNG: SỐ 19"},{"time":"07:00","title":"XỨ SỞ CẦU VỒNG - SỐ 20"},{"time":"07:30","title":"CRACK EM UP: SỐ 5"},{"time":"07:30","title":"CRACK EM UP: SỐ 6"},{"time":"07:30","title":"IELTS FACE OFF: SỐ 6 - GLOBETROTTER"},{"time":"08:00","title":"KHÁM PHÁ KHOA HỌC: SỐ 39 - TOÀ THÁP LỚN LÊN"},{"time":"08:00","title":"KHÁM PHÁ KHOA HỌC: SỐ 40 - DẠO BƯỚC TRÊN CẦU VỒNG"},{"time":"08:00","title":"KHÁM PHÁ KHOA HỌC: SỐ 41 - TỰ LÀM Ô TÔ ĐỘNG CƠ KHÍ NÉN"},{"time":"08:30","title":"BIỆT ĐỘI BICHILI: SỐ 2 - CÔNG CHÚA HOÁ HỌC"},{"time":"08:30","title":"CHUYỆN HỌC TRÒ: SỐ 7 - CÂU CHUYỆN VỚI HÌNH XĂM"},{"time":"08:30","title":"VĂN VUI VẺ: SỐ 1 - BỨC THƯ CỦA THỦ LĨNH DA ĐỎ"},{"time":"09:00","title":"HỌC SAO CHO TỐT: SỐ 3 - BƯỚC NGOẶT"},{"time":"09:00","title":"HỌC SAO CHO TỐT: SỐ 4 - MÔN TOÁN ĐÁNG SỢ"},{"time":"09:00","title":"HEO ĐẤT: SỐ 1 - TIỀN LÀ GÌ"},{"time":"09:20","title":"SÁNG TẠO 102: SỐ 10 - SA MẠC"},{"time":"09:35","title":"MỘT VÒNG TIẾNG VIỆT - SỐ 9"},{"time":"09:45","title":"ĐƯỜNG ĐẾN TRƯỜNG: TẤM VÉ TỚI TRƯỜNG"},{"time":"09:45","title":"ĐƯỜNG ĐẾN TRƯỜNG: NHỮNG CÂU CHUYỆN TRÊN ĐẢO"},{"time":"09:50","title":"MATH DORM: SỐ 5 - PHÉP NHÂN"},{"time":"10:00","title":"HÔM NAY CHƠI GÌ?: ĐỘNG VẬT - PHẦN 1"},{"time":"10:00","title":"HÔM NAY CHƠI GÌ?: ĐỘNG VẬT - PHẦN 2"},{"time":"10:05","title":"NHỮNG NGƯỜI BẠN CẦU VỒNG: HOA - TẬP 1"},{"time":"10:30","title":"CHUYẾN XE HẠT VỪNG: SỐ 16"},{"time":"10:30","title":"CHUYẾN XE HẠT VỪNG: SỐ 17"},{"time":"10:30","title":"CHUYẾN XE HẠT VỪNG - SỐ 18"},{"time":"11:00","title":"CHA MẸ THAY ĐỔI: SỐ 1 - ÂM THANH CỦA NHỮNG BẢN NHẠC BUỒN"},{"time":"11:00","title":"TRƯỜNG TEEN"},{"time":"11:00","title":"CÙNG LĂN VÀO BẾP - SỐ 23"},{"time":"11:15","title":"ĐẸP HƠN MỖI NGÀY: SỐ 8 - DỌN TỦ ĐỒ THEO PHONG CÁCH NHẬT BẢN"},{"time":"11:20","title":"5 KÝ HIỆU NGÔN NGỮ MỖI NGÀY: CẢM XÚC - PHẦN 2"},{"time":"11:30","title":"MẸ ƠI TẠI SAO: SỐ 3 - TẬP TRUNG"},{"time":"11:45","title":"TRƯỜNG HỌC HẠNH PHÚC: ĐỪNG ĐỂ NHỮNG ĐỨA TRẺ CÔ ĐƠN - TẬP 11"},{"time":"12:00","title":"XỨ SỞ CẦU VỒNG: SỐ 17"},{"time":"12:00","title":"XỨ SỞ CẦU VỒNG: SỐ 18"},{"time":"12:00","title":"XỨ SỞ CẦU VỒNG - SỐ 19"},{"time":"12:30","title":"ENGLISH BY STORIES: SỐ 10 - BẢY ĐIỀU ƯỚC"},{"time":"12:30","title":"ENGLISH BY STORIES: SỐ 1 - SỰ TÍCH SỌ DỪA"},{"time":"12:30","title":"VĂN VUI VẺ: SỐ 1 - BỨC THƯ CỦA THỦ LĨNH DA ĐỎ"},{"time":"12:55","title":"5 TỪ MỚI TIẾNG ANH MỖI NGÀY: MÀU SẮC"},{"time":"12:55","title":"5 TỪ MỚI TIẾNG ANH MỖI NGÀY: GIA VỊ"},{"time":"13:00","title":"JUMPING WITH TOEIC: SỐ 53"},{"time":"13:00","title":"JUMPING WITH TOEIC: SỐ 54"},{"time":"13:00","title":"JUMPING WITH TOEIC - SỐ 55"},{"time":"13:45","title":"FOLLOW US: SỐ 24 - LET ME GO HOME"},{"time":"13:45","title":"FOLLOW US: SỐ 25 - SKIN CARE"},{"time":"13:45","title":"FOLLOW US: SỐ 26 - SHOPAHOLICS"},{"time":"14:00","title":"CRACK EM UP: SỐ 5"},{"time":"14:00","title":"CRACK EM UP: SỐ 6"},{"time":"14:00","title":"IELTS FACE OFF: SỐ 6 - GLOBETROTTER"},{"time":"14:30","title":"KHÁM PHÁ KHOA HỌC: SỐ 39 - TOÀ THÁP LỚN LÊN"},{"time":"14:30","title":"KHÁM PHÁ KHOA HỌC: SỐ 40 - DẠO BƯỚC TRÊN CẦU VỒNG"},{"time":"14:30","title":"KHÁM PHÁ KHOA HỌC: SỐ 41 - TỰ LÀM Ô TÔ ĐỘNG CƠ KHÍ NÉN"},{"time":"15:00","title":"GÕ CỬA NGHỀ NGHIỆP: SỐ 3"},{"time":"15:00","title":"GÕ CỬA NGHỀ NGHIỆP: SỐ 5"},{"time":"15:00","title":"HEO ĐẤT: SỐ 1 - TIỀN LÀ GÌ"},{"time":"15:20","title":"SÁNG TẠO 102: SỐ 10 - SA MẠC"},{"time":"15:35","title":"MỘT VÒNG TIẾNG VIỆT - SỐ 9"},{"time":"15:45","title":"CON ĐƯỜNG NGHỀ NGHIỆP: SỐ 27 - CHĂM SÓC SẮC ĐẸP"},{"time":"15:45","title":"CON ĐƯỜNG NGHỀ NGHIỆP: SỐ 33 - KỸ SƯ CƠ KHÍ"},{"time":"15:50","title":"MATH DORM: SỐ 5 - PHÉP NHÂN"},{"time":"16:00","title":"CHA MẸ THAY ĐỔI: SỐ 1 - ÂM THANH CỦA NHỮNG BẢN NHẠC BUỒN"},{"time":"16:00","title":"TRƯỜNG TEEN"},{"time":"16:05","title":"CÙNG LĂN Vào Bếp - SỐ 23"},{"time":"16:20","title":"CUỐN SÁCH CỦA TÔI: SỐ 8 - RICO VÀ OSKAR"},{"time":"16:30","title":"MẸ ƠI TẠI SAO: SỐ 3 - TẬP TRUNG"},{"time":"16:45","title":"TRƯỜNG HỌC HẠNH PHÚC: ĐỪNG ĐỂ NHỮNG ĐỨA TRẺ CÔ ĐƠN - TẬP 11"},{"time":"17:00","title":"XỨ SỞ CẦU VỒNG: SỐ 18"},{"time":"17:00","title":"XỨ SỞ CẦU VỒNG: SỐ 19"},{"time":"17:00","title":"XỨ SỞ CẦU VỒNG - SỐ 20"},{"time":"17:30","title":"TRƯỜNG HỌC HẠNH PHÚC: ĐỪNG ĐỂ NHỮNG ĐỨA TRẺ CÔ ĐƠN - TẬP 9"},{"time":"17:30","title":"TRƯỜNG HỌC HẠNH PHÚC: ĐỪNG ĐỂ NHỮNG ĐỨA TRẺ CÔ ĐƠN - TẬP 10"},{"time":"17:30","title":"VĂN VUI VẺ: SỐ 1 - BỨC THƯ CỦA THỦ LĨNH DA ĐỎ"},{"time":"17:45","title":"EM YÊU VIỆT NAM: SỐ 6"},{"time":"17:45","title":"EM YÊU VIỆT NAM: SỐ 2 - VÀO MÙA"},{"time":"18:00","title":"THỬ THÁCH KHOA HỌC: SỐ 7 - HÓA HỌC ỨNG DỤNG"},{"time":"18:00","title":"THỬ THÁCH KHOA HỌC: SỐ 8 - KHÁM PHÁ CHẤT CHỈ THỊ"},{"time":"18:00","title":"THỬ THÁCH KHOA HỌC: SỐ 9 - ÁP SUẤT RẤT THÚ VỊ"},{"time":"18:15","title":"STREAM TOÁN HỌC: SỐ 8"},{"time":"18:15","title":"STREAM TOÁN HỌC: SỐ 9"},{"time":"18:15","title":"STREAM TOÁN HỌC - SỐ 10"},{"time":"18:30","title":"ENGLISH BY STORIES: SỐ 10 - BẢY ĐIỀU ƯỚC"},{"time":"18:30","title":"ENGLISH BY STORIES: SỐ 1 - SỰ TÍCH SỌ DỪA"},{"time":"18:30","title":"CUỐN SÁCH CỦA EM: SỐ 3 - PIPPY TẤT DÀI"},{"time":"18:45","title":"LÀ LA LÁ: SỐ 4 - CUỘC THI CAO ĐỘ"},{"time":"18:55","title":"5 TỪ MỚI TIẾNG ANH MỖI NGÀY: MÀU SẮC"},{"time":"18:55","title":"5 TỪ MỚI TIẾNG ANH MỖI NGÀY: GIA VỊ"},{"time":"19:00","title":"CHUYẾN XE HẠT VỪNG: SỐ 16"},{"time":"19:00","title":"CHUYẾN XE HẠT VỪNG: SỐ 17"},{"time":"19:00","title":"CHUYẾN XE HẠT VỪNG - SỐ 18"},{"time":"19:30","title":"HÔM NAY CHƠI GÌ?: ĐỘNG VẬT - PHẦN 1"},{"time":"19:30","title":"HÔM NAY CHƠI GÌ?: ĐỘNG VẬT - PHẦN 2"},{"time":"19:30","title":"NHỮNG NGƯỜI BẠN CẦU VỒNG: HOA - TẬP 1"},{"time":"19:50","title":"CHÔM CHÔM VÀ NHỮNG NGƯỜI BẠN: GIẢI CỨU CHÔM CHÔM"},{"time":"20:00","title":"123 TA CÙNG ĐẾM: SỐ 0"},{"time":"20:00","title":"123 TA CÙNG ĐẾM: SỐ 1"},{"time":"20:00","title":"KỸ NĂNG AN TOÀN CHO BÉ - SỐ 6"},{"time":"20:10","title":"HỌC VẼ CÙNG ẾCH CỐM: SỐ 39"},{"time":"20:10","title":"HỌC VẼ CÙNG ẾCH CỐM: SỐ 40"},{"time":"20:10","title":"NHỮNG NGƯỜI BẠN DIỆU KỲ - TẬP 4"},{"time":"20:20","title":"KIDS VOCAB: SỐ 12 - HEALTH PROBLEMS"},{"time":"20:20","title":"KIDS VOCAB: SỐ 13 - POSITIONS"},{"time":"20:20","title":"LÀ LA LÁ: SỐ 5 - CƯỜNG ĐỘ TO NHỎ CỦA ÂM THANH"},{"time":"20:35","title":"NGÀY XƯA CỔ TÍCH: SỐ 4 - QUẠ VÀ CÔNG"},{"time":"20:50","title":"CHUYỆN KỂ CỦA NHỮNG CHÚ CỪU: SỐ 20 - SỰ ĐOÀN KẾT CỦA BẦY CHIM"},{"time":"20:50","title":"CHUYỆN KỂ CỦA NHỮNG CHÚ CỪU: SỐ 21 - ĐÀN KIẾN TRẢ ƠN"},{"time":"20:50","title":"CHUYỆN KỂ CỦA NHỮNG CHÚ CỪU: SỐ 22 - CHÚ RÙA TẬP BAY"},{"time":"21:00","title":"GÕ CỬA NGHỀ NGHIỆP: SỐ 4"},{"time":"21:00","title":"GÕ CỬA NGHỀ NGHIỆP: SỐ 6"},{"time":"21:00","title":"GÕ CỬA NGHỀ NGHIỆP - SỐ 7"},{"time":"21:45","title":"HỌC TIẾNG ANH QUA BÀI HÁT: SỐ 23 - GIRL ON FIRE"},{"time":"21:45","title":"HỌC TIẾNG ANH QUA BÀI HÁT: SỐ 24 - WE DONT TALK ANYMORE"},{"time":"21:45","title":"HỌC TIẾNG ANH QUA BÀI HÁT: SỐ 25 - LA LA LA"},{"time":"22:00","title":"JUMPING WITH TOEIC: SỐ 53"},{"time":"22:00","title":"JUMPING WITH TOEIC: SỐ 54"},{"time":"22:00","title":"JUMPING WITH TOEIC - SỐ 55"},{"time":"22:45","title":"FOLLOW US: SỐ 24 - LET ME GO HOME"},{"time":"22:45","title":"FOLLOW US: SỐ 25 - SKIN CARE"},{"time":"22:45","title":"FOLLOW US: SỐ 26 - SHOPAHOLICS"},{"time":"23:00","title":"CUỐN SÁCH CỦA TÔI: SỐ 5 - NHÀ GIẢ KIM"},{"time":"23:00","title":"CUỐN SÁCH CỦA TÔI: SỐ 7 - DỐC HẾT TRÁI TIM"},{"time":"23:00","title":"CUỐN SÁCH CỦA TÔI: SỐ 8 - RICO VÀ OSKAR"},{"time":"23:15","title":"CHUYỆN HỌC TRÒ: SỐ 6 - ƯỚC MƠ TRỞ THÀNH GAME THỦ"},{"time":"23:15","title":"CHUYỆN HỌC TRÒ: SỐ 8 - NÓI DỐI"},{"time":"23:15","title":"CHUYỆN HỌC TRÒ: SỐ 9 - NHÀ BÁO TẬP SỰ"},{"time":"23:40","title":"KHÔNG THÌ THẦM: SỐ 11 - ẢO TƯỞNG"},{"time":"23:40","title":"KHÔNG THÌ THẦM: SỐ 12 - XA NHÀ"},{"time":"23:40","title":"KHÔNG THÌ THẦM: SỐ 13 - HẬU LẦN ĐẦU LÀM CHUYỆN ẤY"}],"vtv8":[{"time":"00:00","title":"KÝ SỰ: VỀ MIỀN DI SẢN: NGHỆ NHÂN - NGƯỜI GIỮ HỒN DI SẢN"},{"time":"00:00","title":"KÝ SỰ: PHÁT HUY GIÁ TRỊ CÁC DI SẢN VĂN HÓA MIỀN KINH BẮC"},{"time":"00:00","title":"KÝ SỰ"},{"time":"00:15","title":"TRƯỜNG SƠN VẠN DẶM: TINH HOA THỔ CẨM GIỮA ĐẠI NGÀN TRƯỜNG SƠN"},{"time":"00:15","title":"QUYẾN RŨ VIỆT NAM: ÂM SẮC LÂM BÌNH"},{"time":"00:15","title":"ĐIỂM TỰA BÌNH YÊN"},{"time":"00:30","title":"PHIM TRUYỆN: MỘNG HOA LỤC - TẬP 22"},{"time":"00:30","title":"PHIM TRUYỆN: MỘNG HOA LỤC - TẬP 23"},{"time":"00:30","title":"PHIM TRUYỆN"},{"time":"01:00","title":"PHÓNG SỰ: ĐẮK LẮK - MỞ CỬA TIỀM NĂNG, ĐÓN SÓNG ĐẦU TƯ"},{"time":"01:00","title":"GIAI ĐIỆU KẾT NỐI: MÙA HÈ YÊU THƯƠNG"},{"time":"01:00","title":"ĐI ĐỂ BIẾT"},{"time":"01:15","title":"KẾT NỐI VTV8"},{"time":"01:15","title":"PHÓNG SỰ: NÂNG CAO CHẤT LƯỢNG PHỤC VỤ TRẢI NGHIỆM DI SẢN HUẾ"},{"time":"01:30","title":"CÀ PHÊ TÁM: CHÂN DUNG HẠNH PHÚC"},{"time":"01:30","title":"ĐI ĐỂ BIẾT: NHỮNG NGÀY Ở ĐẢO TRẦN - ĐẢO TIỀN TIÊU CỦA TỔ QUỐC"},{"time":"01:30","title":"VĂN HỌC - NGHỆ THUẬT"},{"time":"01:45","title":"MIỀN ĐẤT VÕ: VOVINAM - DĨ NHU CHẾ CƯƠNG"},{"time":"01:55","title":"ẨM THỰC ĐỘC ĐÁO"},{"time":"02:00","title":"PHIM SITCOM: MỘT NHÀ TRĂM CHUYỆN - TẬP 21"},{"time":"02:00","title":"PHIM SITCOM: MỘT NHÀ TRĂM CHUYỆN - TẬP 22"},{"time":"02:00","title":"PHIM SITCOM"},{"time":"02:15","title":"KHÁM PHÁ THẾ GIỚI: BẢO TỒN CÁC LOÀI VẬT - TẬP 4"},{"time":"02:15","title":"KHÁM PHÁ THẾ GIỚI: BẢO TỒN CÁC LOÀI VẬT - TẬP 5"},{"time":"02:15","title":"KHÁM PHÁ THẾ GIỚI"},{"time":"02:45","title":"PHỐ TÀI CHÍNH: KỲ VỌNG THỊ TRƯỜNG TRONG TRUNG VÀ DÀI HẠN"},{"time":"02:45","title":"CHÉM GIÓ - GIÓ CHÉM: NGHỆ THUẬT GÓP Ý"},{"time":"02:45","title":"GIẢI MÃ CUỘC SỐNG"},{"time":"03:00","title":"ĐI ĐỂ BIẾT: TRẢI NGHIỆM CHÀI LƯỚI CÙNG NGƯ DÂN ĐẢO THANH LÂN"},{"time":"03:00","title":"GIAO LƯU - TỌA ĐÀM: TỪ TINH GỌN BỘ MÁY ĐẾN HIỆU QUẢ VẬN HÀNH"},{"time":"03:00","title":"ATLAS"},{"time":"03:25","title":"PHÓNG SỰ"},{"time":"03:30","title":"TỪ NHỮNG MIỀN QUÊ: NGƯỜI MÔNG NƠI ĐỈNH TRỜI ĐÁ XÁM"},{"time":"03:30","title":"PHÓNG SỰ: BẮC NINH - CÁC TÔN GIÁO ĐỒNG LÒNG CHUNG TAY XÂY DỰNG QUÊ HƯƠNG"},{"time":"03:35","title":"TỪ NHỮNG MIỀN QUÊ"},{"time":"03:45","title":"PHIM TRUYỆN: KIẾM CHỒNG CHO MẸ CHỒNG - TẬP 62"},{"time":"03:45","title":"PHIM TRUYỆN: KIẾM CHỒNG CHO MẸ CHỒNG - TẬP 63"},{"time":"03:45","title":"PHIM TRUYỆN"},{"time":"04:15","title":"PHIM TÀI LIỆU: DẤU ẤN PHẬT HOÀNG - DI SẢN NGÀN NĂM"},{"time":"04:15","title":"PHIM TÀI LIỆU: THIÊN NHIÊN HOANG DÃ PHONG NHA - KẺ BÀNG - PHẦN 1"},{"time":"04:15","title":"PHIM TÀI LIỆU"},{"time":"04:45","title":"PHÓNG SỰ: CHÍNH QUYỀN CẤP XÃ - BIẾN ÁP LỰC THÀNH ĐỘNG LỰC PHÁT TRIỂN"},{"time":"04:45","title":"TRƯỜNG SƠN VẠN DẶM: HÙNG SƠN - NƠI RỪNG KỂ CHUYỆN"},{"time":"04:45","title":"QUYẾN RŨ VIỆT NAM"},{"time":"05:00","title":"KHẾ ƯỚC THỜI GIAN: CỒNG CHIÊNG, TỪ ĐÁ ĐẾN ĐỒNG"},{"time":"05:00","title":"ATLAS: NGA SƠN - MIỀN QUÊ HUYỀN THOẠI"},{"time":"05:00","title":"DẤU ẤN LỊCH SỬ"},{"time":"05:30","title":"NẺO VỀ NGUỒN CỘI: DẤU ẤN VĂN HÓA CỔ ĐỒNG NAI"},{"time":"05:30","title":"GIẢI MÃ CUỘC SỐNG: NGHỀ VẼ TRUYỀN THẦN VÀ NHỮNG ĐIỀU CHƯA BIẾT"},{"time":"05:30","title":"NẺO VỀ NGUỒN CỘI"},{"time":"05:45","title":"KÝ SỰ: PHÁT HUY GIÁ TRỊ CÁC DI SẢN VĂN HÓA MIỀN KINH BẮC"},{"time":"05:45","title":"KÝ SỰ: CHÙA THIÊNG XỨ KINH BẮC: CHÙA DÂU"},{"time":"05:45","title":"KÝ SỰ"},{"time":"06:00","title":"CÂU CHUYỆN TỪ NHỮNG BÀI CA: LÁ THƯ TRUÔNG BỒN"},{"time":"06:00","title":"GIAI ĐIỆU KẾT NỐI: ƯỚC GÌ"},{"time":"06:00","title":"GIAI ĐIỆU KẾT NỐI"},{"time":"06:15","title":"NÔNG SẢN KỂ CHUYỆN: HƯƠNG QUẾ GIỮ RỪNG"},{"time":"06:20","title":"SẮC MÀU CÁC DÂN TỘC"},{"time":"06:25","title":"NẺO VỀ NGUỒN CỘI: DẤU ẤN VĂN HÓA CỔ ĐỒNG NAI"},{"time":"06:40","title":"THÔNG TIN - DỊCH VỤ"},{"time":"06:45","title":"HÀNH TRÌNH DI SẢN: MƯỜNG VANG TIẾNG ĐẤT"},{"time":"06:45","title":"HÀNH TRÌNH DI SẢN: HÀO KHÍ LAM SƠN"},{"time":"06:45","title":"GIẢI MÃ SỨC KHỎE"},{"time":"07:30","title":"NẺO VỀ NGUỒN CỘI: TRỐNG SÀNH CỦA NGƯỜI CAO LAN"},{"time":"07:30","title":"NHỮNG MẢNH GHÉP CỦA CUỘC SỐNG: HÌNH TƯỢNG NGỰA TRIỀU NGUYỄN"},{"time":"07:30","title":"NẺO VỀ NGUỒN CỘI"},{"time":"07:45","title":"GIẢI MÃ SỨC KHỎE: CẢI THIỆN ĐAU NHỨC XƯƠNG KHỚP THƯỜNG XUYÊN TÁI PHÁT Ở NGƯỜI LỚN TUỔI"},{"time":"07:45","title":"GIẢI MÃ SỨC KHỎE: BÍ QUYẾT NÂNG CAO SỨC KHỎE TIM MẠCH, CHỐNG LÃO HÓA Ở NGƯỜI CAO TUỔI"},{"time":"07:45","title":"GIẢI MÃ SỨC KHỎE"},{"time":"08:45","title":"CÂU CHUYỆN TỪ NHỮNG BÀI CA: THỜI HOA ĐỎ"},{"time":"08:45","title":"SỰ SỐNG DIỆU KỲ: YJEK NIÊ KDĂM - NGƯỜI SƯU TẦM SỬ THI TÂY NGUYÊN"},{"time":"08:45","title":"SỰ SỐNG DIỆU KỲ"},{"time":"09:00","title":"HIỂU ĐÚNG - SỐNG KHỎE: ĐÔNG TRÙNG HẠ THẢO VỚI SỨC KHỎE TIM MẠCH PHÒNG NGỪA ĐỘT QUỴ"},{"time":"09:00","title":"HIỂU ĐÚNG - SỐNG KHỎE: ĐIỀU HÒA HUYẾT ÁP, BẢO VỆ SỨC KHỎE TIM MẠCH NHỜ SÂM DÂN GIAN"},{"time":"09:00","title":"HIỂU ĐÚNG - SỐNG KHỎE"},{"time":"10:00","title":"KẾT NỐI VTV8"},{"time":"10:00","title":"TỪ NHỮNG MIỀN QUÊ: NGƯỜI LÀO Ở NÚA NGAM"},{"time":"10:00","title":"TỪ NHỮNG MIỀN QUÊ"},{"time":"10:15","title":"PHIM TÀI LIỆU: GIẢM KÉP, ÁP LỰC GẤP BA"},{"time":"10:15","title":"PHIM CA NHẠC: VỌNG NGUYỆT"},{"time":"10:15","title":"CHẤT LƯỢNG CUỘC SỐNG"},{"time":"11:00","title":"NÓNG CÙNG V8: NÓNG CÙNG V8 (20)"},{"time":"11:00","title":"NÓNG CÙNG V8"},{"time":"11:15","title":"KINH TẾ KẾT NỐI"},{"time":"11:30","title":"24H ONLINE"},{"time":"11:45","title":"DỰ BÁO THỜI TIẾT: DỰ BÁO THỜI TIẾT (V8)"},{"time":"11:45","title":"DỰ BÁO THỜI TIẾT"},{"time":"11:50","title":"PHIM SITCOM: MỘT NHÀ TRĂM CHUYỆN - TẬP 22"},{"time":"11:50","title":"PHIM SITCOM: MỘT NHÀ TRĂM CHUYỆN - TẬP 23"},{"time":"11:50","title":"PHIM SITCOM"},{"time":"12:05","title":"PHIM TRUYỆN: MỘNG HOA LỤC - TẬP 23"},{"time":"12:05","title":"PHIM TRUYỆN: MỘNG HOA LỤC - TẬP 24"},{"time":"12:05","title":"PHIM TRUYỆN"},{"time":"12:35","title":"DỰ BÁO THỜI TIẾT: DỰ BÁO THỜI TIẾT (V8)"},{"time":"12:35","title":"DỰ BÁO THỜI TIẾT"},{"time":"12:40","title":"THỂ THAO"},{"time":"12:45","title":"PHIM TRUYỆN: SỨ MỆNH VẪY GỌI - TẬP 52"},{"time":"12:45","title":"PHIM TRUYỆN: SỨ MỆNH VẪY GỌI - TẬP 53"},{"time":"12:45","title":"PHIM TRUYỆN"},{"time":"13:15","title":"QUYẾN RŨ VIỆT NAM: ÂM SẮC LÂM BÌNH"},{"time":"13:15","title":"PHIM TÀI LIỆU: TRÚC LÂM YÊN TỬ - DÒNG THIỀN THUẦN VIỆT"},{"time":"13:15","title":"NƠI ĐÂU CŨNG LÀ NHÀ"},{"time":"13:30","title":"GIẢI MÃ SỨC KHỎE: BÍ QUYẾT NÂNG CAO SỨC KHỎE TIM MẠCH, CHỐNG LÃO HÓA Ở NGƯỜI CAO TUỔI"},{"time":"13:30","title":"GIẢI MÃ SỨC KHỎE: SUY GIẢM SINH LÝ NAM - XU HƯỚNG ĐIỀU TRỊ TỪ THẢO DƯỢC"},{"time":"13:30","title":"GIẢI MÃ SỨC KHỎE"},{"time":"14:30","title":"MIỀN ĐẤT VÕ: VOVINAM - DĨ NHU CHẾ CƯƠNG"},{"time":"14:30","title":"CHECK IN VIỆT NAM: VIÊN NGỌC BIỂN KHƠI"},{"time":"14:30","title":"NHỮNG MẢNH GHÉP CỦA CUỘC SỐNG"},{"time":"14:50","title":"HIỂU ĐÚNG - SỐNG KHỎE: ĐIỀU HÒA HUYẾT ÁP, BẢO VỆ SỨC KHỎE TIM MẠCH NHỜ SÂM DÂN GIAN"},{"time":"14:50","title":"HIỂU ĐÚNG - SỐNG KHỎE: TĂNG CƯỜNG SỨC KHỎE TIM MẠCH, ỔN ĐỊNH HUYẾT ÁP"},{"time":"14:50","title":"HIỂU ĐÚNG - SỐNG KHỎE"},{"time":"15:50","title":"NHỮNG MẢNH GHÉP CỦA CUỘC SỐNG: GỐM VIỆT - TẬP 3: GỐM BÀU TRÚC"},{"time":"15:50","title":"KINH TẾ KẾT NỐI"},{"time":"15:50","title":"PHỐ TÀI CHÍNH"},{"time":"16:05","title":"MUÔN MÀU CUỘC SỐNG: MÙA SEN HUẾ"},{"time":"16:05","title":"MUÔN MÀU CUỘC SỐNG"},{"time":"16:15","title":"PHIM TÀI LIỆU: THIÊN NHIÊN HOANG DÃ PHONG NHA - KẺ BÀNG - PHẦN 1"},{"time":"16:15","title":"CHẤT LƯỢNG CUỘC SỐNG: CHỦ ĐỘNG PHÒNG NGỪA SỐT XUẤT HUYẾT TRONG MÙA CAO ĐIỂM"},{"time":"16:15","title":"PHIM TÀI LIỆU"},{"time":"16:45","title":"KINH TẾ KẾT NỐI"},{"time":"17:00","title":"SỐNG KHỎE: SUY GIẢM SINH LÝ NAM - PHƯƠNG PHÁP ĐIỀU TRỊ TỪ GỐC"},{"time":"17:00","title":"PHIM TÀI LIỆU: DÒNG NƯỚC KHÔNG LỐI VỀ"},{"time":"17:00","title":"HÀNH TRÌNH DI SẢN"},{"time":"17:45","title":"THẾ GIỚI THỂ THAO"},{"time":"18:00","title":"TRẠM 18H"},{"time":"18:30","title":"CHUYỂN ĐỘNG HÔM NAY"},{"time":"18:45","title":"THỂ THAO"},{"time":"18:50","title":"NHÂN TÀI ĐẠI VIỆT: LŨ CHIM QUÁI ÁC"},{"time":"18:50","title":"NHÂN TÀI ĐẠI VIỆT: ĐỘI QUÂN BÙ NHÌN"},{"time":"18:50","title":"NHÂN TÀI ĐẠI VIỆT"},{"time":"19:00","title":"PHIM TRUYỆN: KIẾM CHỒNG CHO MẸ CHỒNG - TẬP 63"},{"time":"19:00","title":"PHIM TRUYỆN: KIẾM CHỒNG CHO MẸ CHỒNG - TẬP 64"},{"time":"19:00","title":"PHIM TRUYỆN"},{"time":"19:30","title":"CA NHẠC: LỜI TRÁI TIM: KỂ CHUYỆN ĐÊM MƯA"},{"time":"19:30","title":"ĐI ĐỂ BIẾT: CHUYỆN Ở ĐẢO NHỎ THANH LÂN"},{"time":"19:30","title":"ATLAS"},{"time":"20:00","title":"TRUYỀN HÌNH TRỰC TIẾP: BẾ MẠC LIÊN HOAN PHIM CHÂU Á ĐÀ NẴNG LẦN THỨ IV"},{"time":"20:00","title":"DỰ BÁO THỜI TIẾT: DỰ BÁO THỜI TIẾT (V8)"},{"time":"20:00","title":"DỰ BÁO THỜI TIẾT"},{"time":"20:05","title":"CHÉM GIÓ - GIÓ CHÉM: TÌNH YÊU TƯƠNG ĐỒNG"},{"time":"20:05","title":"KẾT NỐI VTV8"},{"time":"20:20","title":"ẨM THỰC ĐỘC ĐÁO: CÀ MUỐI HÀ TĨNH"},{"time":"20:20","title":"ẨM THỰC ĐỘC ĐÁO"},{"time":"20:25","title":"TÌNH CA BẤT HỦ: TÌNH KHÚC PHÚ QUANG"},{"time":"20:25","title":"PHIM TRUYỆN"},{"time":"21:05","title":"PHIM TÀI LIỆU: MỘT NĂM SẮP XẾP LẠI GIANG SƠN"},{"time":"21:10","title":"PHIM TRUYỆN"},{"time":"21:35","title":"GIẢI MÃ SỨC KHỎE: XU HƯỚNG SỬ DỤNG THẢO DƯỢC TRONG HỖ TRỢ TĂNG CƯỜNG SINH LÝ NAM"},{"time":"21:35","title":"DẤU ẤN LỊCH SỬ"},{"time":"22:00","title":"DỰ BÁO THỜI TIẾT: DỰ BÁO THỜI TIẾT (V8)"},{"time":"22:05","title":"DẤU ẤN LỊCH SỬ: CHUYỆN LÀNG TRONG VẬN NƯỚC"},{"time":"22:05","title":"VĂN HỌC - NGHỆ THUẬT"},{"time":"22:35","title":"MUÔN MÀU CUỘC SỐNG"},{"time":"22:40","title":"TRẠM 18H"},{"time":"23:10","title":"CHUYỂN ĐỘNG HÔM NAY"},{"time":"23:30","title":"KHÁM PHÁ THẾ GIỚI: BẢO TỒN CÁC LOÀI VẬT - TẬP 5"},{"time":"23:30","title":"KHÁM PHÁ THẾ GIỚI: NHỮNG ANH HÙNG ĐỜI THƯỜNG - TẬP 1"},{"time":"23:30","title":"KHÁM PHÁ THẾ GIỚI"}],"vtv9":[{"time":"00:00","title":"ĐƯỜNG DÂY NÓNG VTV9"},{"time":"00:00","title":"PHIM TRUYỆN"},{"time":"00:20","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 - VÒNG 32 ĐỘI - AUSTRALIA – AI CẬP"},{"time":"00:45","title":"PHIM TRUYỆN"},{"time":"01:30","title":"NẺO VỀ NGUỒN CỘI"},{"time":"01:45","title":"TIÊU ĐIỂM CHÍNH SÁCH"},{"time":"02:00","title":"VÌ NHÂN DÂN QUÊN MÌNH"},{"time":"02:15","title":"PHIM TÀI LIỆU"},{"time":"02:45","title":"THTT FIFA WORLD CUP 2026 - VÒNG 16 ĐỘI: BRAZIL VS NA UY"},{"time":"03:20","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 - VÒNG 1/8 - PARAGUAY - PHÁP"},{"time":"04:20","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 - VÒNG 32 ĐỘI - ARGENTINA - CAPE VERDE"},{"time":"05:25","title":"TÁM CÔNG SỞ"},{"time":"05:35","title":"CHUYỆN PHỐ PHƯỜNG"},{"time":"05:40","title":"THỊ TRƯỜNG 360 ĐỘ"},{"time":"05:45","title":"VÕ THUẬT TỔNG HỢP MMA"},{"time":"06:15","title":"CÂU CHUYỆN TỪ CUỘC SỐNG"},{"time":"06:25","title":"THÀNH PHỐ ẤM ÁP TÌNH NGƯỜI"},{"time":"06:30","title":"THTT FIFA WORLD CUP 2026 - VÒNG 16 ĐỘI: MEXICO VS ANH"},{"time":"07:35","title":"TIẾU LÂM DU KÝ"},{"time":"07:50","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 - VÒNG 32 ĐỘI - COLOMBIA - GHANA"},{"time":"07:50","title":"KÍNH ĐA CHIỀU"},{"time":"08:00","title":"NỘI THẤT KHÔNG GIỚI HẠN: NỘI THẤT TỐI GIẢN - GIÁ TRỊ CỦA KHÔNG GIAN"},{"time":"08:15","title":"CHỮA BỆNH CÙNG CHUYÊN GIA"},{"time":"09:00","title":"PHIM TÀI LIỆU: MỘT NĂM SẮP XẾP LẠI GIANG SƠN – TINH GỌN, GẦN DÂN, HIỆU QUẢ"},{"time":"09:20","title":"CÂU CHUYỆN TỪ CUỘC SỐNG"},{"time":"09:30","title":"CÂU CHUYỆN NHÂN VẬT"},{"time":"09:30","title":"ALO DOCTOR CUỐI TUẦN"},{"time":"10:00","title":"CUỐI TUẦN KỂ CHUYỆN"},{"time":"10:00","title":"VÕ THUẬT TỔNG HỢP MMA"},{"time":"10:30","title":"DẠO QUANH THỊ TRƯỜNG"},{"time":"10:45","title":"THỊ TRƯỜNG 360 ĐỘ"},{"time":"10:50","title":"GÓC NHÌN NGƯỜI TIÊU DÙNG"},{"time":"10:50","title":"THÀNH PHỐ ẤM ÁP TÌNH NGƯỜI"},{"time":"10:55","title":"CHUYỆN PHỐ PHƯỜNG"},{"time":"11:00","title":"VIỆT NAM ƠI - MÌNH CÙNG ĐI: NHỮNG ĐIỂM ĐẾN ẤN TƯỢNG TẠI VĨNH LONG"},{"time":"11:00","title":"PHIM TRUYỆN"},{"time":"11:20","title":"THUẬN VỢ THUẬN CHỒNG"},{"time":"11:30","title":"QUẢ CẦU THÔNG THÁI"},{"time":"11:50","title":"ALO DOCTOR"},{"time":"12:00","title":"PHƯƠNG NAM HÔM NAY"},{"time":"12:30","title":"PHIM TRUYỆN: NỮ BÁC SĨ TÂM LÝ - TẬP 32"},{"time":"12:30","title":"PHIM TRUYỆN: NỮ BÁC SĨ TÂM LÝ - TẬP 33"},{"time":"12:30","title":"PHIM TRUYỆN"},{"time":"13:15","title":"PHIM TRUYỆN: MỘT CUỘC TẤN CÔNG - TẬP 24"},{"time":"13:15","title":"PHIM TRUYỆN: MỘT CUỘC TẤN CÔNG - TẬP 25"},{"time":"13:15","title":"PHIM TRUYỆN"},{"time":"14:00","title":"ĐẤU TRƯỜNG ẨM THỰC NHÍ"},{"time":"14:00","title":"CHECK IN VIỆT NAM: VỀ VÙNG ĐẤT CỔ Ô DIÊN"},{"time":"14:00","title":"KỶ NIỆM THANH XUÂN"},{"time":"14:15","title":"CUỐI TUẦN KỂ CHUYỆN"},{"time":"14:15","title":"GAMESHOW NGƯỜI ĐỨNG THẲNG"},{"time":"14:20","title":"CHỊ EM GỠ RỐI"},{"time":"14:40","title":"GIA ĐÌNH HẾT SẢY"},{"time":"15:00","title":"NẺO VỀ NGUỒN CỘI: THANH ÂM CUNG ĐÌNH HUẾ"},{"time":"15:00","title":"NẺO VỀ NGUỒN CỘI"},{"time":"15:15","title":"BÍ MẬT THẾ KỶ"},{"time":"15:15","title":"BÍ MẬT THẾ KỶ: VƯỢT THÁI BÌNH DƯƠNG - PHẦN 4"},{"time":"15:15","title":"CHỮA BỆNH CÙNG CHUYÊN GIA"},{"time":"15:45","title":"ĐƯỜNG DÂY NÓNG VTV9"},{"time":"15:45","title":"PHÓNG SỰ: ỨNG DỤNG CÔNG NGHỆ HƯỚNG ĐẾN CHÍNH QUYỀN ĐÔ THỊ THÔNG MINH"},{"time":"16:00","title":"PHIM TRUYỆN: GẠO NẾP GẠO TẺ - PHẦN 2 - TẬP 10"},{"time":"16:00","title":"PHIM TRUYỆN: GẠO NẾP GẠO TẺ - PHẦN 2 - TẬP 11"},{"time":"16:00","title":"PHIM TRUYỆN"},{"time":"16:30","title":"VIỆT NAM ƠI - MÌNH CÙNG ĐI: CHUYỆN BÊN HỒ"},{"time":"16:30","title":"VIỆT NAM ƠI - MÌNH CÙNG ĐI: ĐẾN THĂM VÙNG ĐẤT ĐỒNG THÁP"},{"time":"16:30","title":"VIỆT NAM - 365 NGÀY THÚ VỊ"},{"time":"16:45","title":"VÌ NHÂN DÂN QUÊN MÌNH"},{"time":"16:45","title":"DẠO QUANH THỊ TRƯỜNG"},{"time":"16:45","title":"HÀNH TRÌNH NET ZERO"},{"time":"17:00","title":"NỘI THẤT KHÔNG GIỚI HẠN: XU HƯỚNG THIẾT KẾ NỘI THẤT KHÔNG GIAN BẾP HIỆN ĐẠI"},{"time":"17:00","title":"CANH TÁC THÔNG MINH"},{"time":"17:00","title":"VÌ NHÂN DÂN QUÊN MÌNH"},{"time":"17:15","title":"THỊ TRƯỜNG 360 ĐỘ"},{"time":"17:20","title":"ALO DOCTOR CUỐI TUẦN"},{"time":"17:20","title":"CÂU CHUYỆN NHÂN VẬT"},{"time":"17:20","title":"TRAI ĐẸP VÀO BẾP"},{"time":"17:50","title":"TÁM CÔNG SỞ: HỌP KÍN GIỜ HÀNH CHÍNH - TẬP 167"},{"time":"17:50","title":"TÁM CÔNG SỞ: HỌP KÍN GIỜ HÀNH CHÍNH - TẬP 168"},{"time":"17:50","title":"TÁM CÔNG SỞ"},{"time":"18:00","title":"TOÀN CẢNH 24H"},{"time":"18:30","title":"NHẬT KÝ FIFA WORLD CUP 2026"},{"time":"18:30","title":"TẦM NHÌN BẤT ĐỘNG SẢN"},{"time":"18:45","title":"PHIM TRUYỆN: ƯỚC MÌNH CÙNG BAY - TẬP 72"},{"time":"18:45","title":"PHIM TRUYỆN: ƯỚC MÌNH CÙNG BAY - TẬP 73"},{"time":"18:50","title":"KÍNH ĐA CHIỀU"},{"time":"19:00","title":"PHIM TRUYỆN"},{"time":"19:15","title":"ĐỜI RẤT ĐẸP"},{"time":"19:15","title":"KỶ NIỆM THANH XUÂN"},{"time":"19:30","title":"ĐỜI NGHỆ SĨ"},{"time":"19:30","title":"VÒNG XOAY LỐC XOÁY"},{"time":"19:35","title":"CHỊ EM GỠ RỐI"},{"time":"20:00","title":"TIẾU LÂM DU KÝ"},{"time":"20:00","title":"THỨC TỈNH TÂM HỒN: HIỂU LẦM CON"},{"time":"20:00","title":"CÂU CHUYỆN TỪ CUỘC SỐNG"},{"time":"20:10","title":"PHIM TRUYỆN"},{"time":"20:15","title":"PHIM TRUYỆN: MỘT CUỘC TẤN CÔNG - TẬP 26"},{"time":"20:15","title":"PHIM TRUYỆN: ÂN OÁN TÌNH THÙ - TẬP 29"},{"time":"21:00","title":"PHIM TRUYỆN: MỘT CUỘC TẤN CÔNG - TẬP 27"},{"time":"21:00","title":"PHIM TRUYỆN: ÂN OÁN TÌNH THÙ - TẬP 30"},{"time":"21:00","title":"ĐƯỜNG DÂY NÓNG VTV9"},{"time":"21:15","title":"PHIM TRUYỆN"},{"time":"21:45","title":"GAMESHOW NGƯỜI ĐỨNG THẲNG"},{"time":"21:45","title":"ĐỜI NGHỆ SỸ"},{"time":"22:00","title":"VIỆT NAM ƠI - MÌNH CÙNG ĐI"},{"time":"22:15","title":"ĐIỀU CON MUỐN NÓI"},{"time":"22:25","title":"CHECK IN VIỆT NAM: CỬU THÁC GIỮA ĐẠI NGÀN"},{"time":"22:30","title":"VIỆT NAM - 365 NGÀY THÚ VỊ"},{"time":"22:35","title":"MẢNH GHÉP HOÀN HẢO"},{"time":"22:40","title":"VÒNG XOAY LỐC XOÁY"},{"time":"22:45","title":"TRAI ĐẸP VÀO BẾP"},{"time":"23:00","title":"TIẾU LÂM DU KÝ"},{"time":"23:10","title":"BƯỚC CHÂN KHÁM PHÁ"},{"time":"23:15","title":"GIẢI MÃ CUỘC SỐNG"},{"time":"23:20","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 - VÒNG 1/8 - CANADA - MOROCO"},{"time":"23:30","title":"ALO DOCTOR CUỐI TUẦN"},{"time":"23:30","title":"VÕ THUẬT TỔNG HỢP MMA"}],"vtv10":[{"time":"00:00","title":"TÌNH KHÚC VƯỢT THỜI GIAN - ĐÊM TÂM SỰ"},{"time":"00:20","title":"TRUYỀN HÌNH TRỰC TIẾP: WORLD CUP 2026: AUSTRALIA - AI CẬP"},{"time":"00:45","title":"DÂN CA NHẠC CỔ: DUYÊN DÁNG ĐỒNG BẰNG"},{"time":"00:45","title":"MẢNH GHÉP HOÀN HẢO - SỐ 102"},{"time":"01:15","title":"LẬP TRÌNH TRÁI TIM: NGƯỜI EM BẤT HẢO - PHẦN 1"},{"time":"01:15","title":"LẬP TRÌNH TRÁI TIM - NGƯỜI EM BẤT HẢO - PHẦN 2"},{"time":"01:30","title":"THỂ THAO: WORLD CUP 2026: TÂY BAN NHA - ÁO"},{"time":"01:30","title":"BẠN CỦA NHÀ NÔNG - SỐ 7"},{"time":"02:20","title":"TRUYỀN HÌNH TRỰC TIẾP - WORLD CUP 2026: BRAZIL - NA UY"},{"time":"03:00","title":"NHỮNG MẢNH GHÉP CỦA CUỘC SỐNG: VƯỜN QUỐC GIA CÔN ĐẢO - TẬP 1"},{"time":"03:30","title":"KÝ SỰ: VỀ LẠI CỨ ĐỊA XƯA - TẬP 5"},{"time":"03:45","title":"KÝ ỨC MIỀN TÂY: CỎ BÀNG BẤT TẬN"},{"time":"04:00","title":"PHIM TRUYỆN: CHÚNG TA PHẢI HẠNH PHÚC - TẬP 34"},{"time":"04:20","title":"LIÊN KẾT VÀ HỘI NHẬP: TÁI ĐỊNH VỊ DU LỊCH ĐỒNG BẰNG"},{"time":"04:45","title":"THỂ THAO"},{"time":"04:45","title":"THỂ THAO: KẾT NỐI THỂ THAO"},{"time":"05:15","title":"KÝ SỰ: VỀ LẠI CỨ ĐỊA XƯA - TẬP 5"},{"time":"05:15","title":"KÝ ỨC MIỀN TÂY: HỒI ĐÓ LẤM LEM"},{"time":"05:30","title":"CA NHẠC: CHƠI VƠI"},{"time":"05:30","title":"CA NHẠC: NỖI NHỚ DỊU ÊM"},{"time":"06:00","title":"BẢN TIN NÔNG NGHIỆP"},{"time":"06:10","title":"BẢN TIN THỂ THAO"},{"time":"06:15","title":"KÝ ỨC MIỀN TÂY: CỎ BÀNG BẤT TẬN"},{"time":"06:15","title":"NÔNG DÂN SỐ: SỐ 1"},{"time":"06:30","title":"PHIM HOẠT HÌNH: CHUYỆN CỔ TÍCH - PHẦN 3"},{"time":"06:30","title":"PHIM HOẠT HÌNH: CHUYỆN CỔ TÍCH - PHẦN 4"},{"time":"06:30","title":"THƯƠNG NHỚ MIỀN TÂY - NGHỀ SÔNG NƯỚC"},{"time":"06:45","title":"THẾ GIỚI QUANH TA - SỐ 27"},{"time":"07:00","title":"THỂ THAO: WORLD CUP 2026: TÂY BAN NHA - ÁO"},{"time":"07:00","title":"THỂ THAO: WORLD CUP 2026: ARGENTINA - CAPE VERDE"},{"time":"07:00","title":"THỂ THAO - WORLD CUP 2026: PARAGUAY - PHÁP"},{"time":"08:30","title":"PHIM TRUYỆN: NỮ HOÀNG XU HƯỚNG - TẬP 41"},{"time":"08:30","title":"PHIM TRUYỆN: NỮ HOÀNG XU HƯỚNG - TẬP 42"},{"time":"08:30","title":"NỮ HOÀNG XU HƯỚNG - TẬP 43"},{"time":"09:15","title":"SỐNG KHỎE MỖI NGÀY: ỔN ĐỊNH ĐƯỜNG HUYẾT"},{"time":"09:15","title":"SỐNG KHỎE MỖI NGÀY: BẢO VỆ TIM MẠCH Ở NGƯỜI CAO TUỔI"},{"time":"09:15","title":"SỐNG KHỎE MỖI NGÀY - PHÌ ĐẠI TUYẾN TIỀN LIỆT"},{"time":"10:15","title":"NHỮNG MẢNH GHÉP CỦA CUỘC SỐNG: VƯỜN QUỐC GIA CÔN ĐẢO - TẬP 1"},{"time":"10:15","title":"TẠP CHÍ KINH TẾ CUỐI TUẦN"},{"time":"10:15","title":"NHỮNG MẢNH GHÉP CỦA CUỘC SỐNG - VƯỜN QUỐC GIA CÔN ĐẢO - TẬP 1"},{"time":"10:45","title":"PHÓNG SỰ: BÀI TOÁN CHO Y TẾ CƠ SỞ"},{"time":"10:45","title":"KÝ ỨC MIỀN TÂY: CỎ BÀNG BẤT TẬN"},{"time":"10:45","title":"MIỀN TÂY NĂNG ĐỘNG - VĨNH LONG - SỐ 5"},{"time":"11:00","title":"MIỀN TÂY HÔM NAY"},{"time":"11:30","title":"SÂN KHẤU - CẢI LƯƠNG: MỘT THỜI ĐỂ NHỚ"},{"time":"11:35","title":"PHIM TRUYỆN: CHÚNG TA PHẢI HẠNH PHÚC - TẬP 34"},{"time":"11:35","title":"ĐỪNG NÓI KHI YÊU - TẬP 1"},{"time":"12:15","title":"PHIM TRUYỆN: ANH TRAI NHÀ ĐỐI DIỆN - TẬP 14"},{"time":"12:15","title":"ANH TRAI NHÀ ĐỐI DIỆN - TẬP 15"},{"time":"13:00","title":"CHƯƠNG TRÌNH VỀ SỨC KHỎE: DƯỠNG TIM, BỔ NÃO"},{"time":"13:00","title":"TỌA ĐÀM: SỐT XUẤT HUYẾT NHẬN DIỆN SỚM, XỬ TRÍ ĐÚNG"},{"time":"13:00","title":"CHƯƠNG TRÌNH VỀ SỨC KHỎE"},{"time":"14:00","title":"THỂ THAO: KẾT NỐI THỂ THAO"},{"time":"14:00","title":"BẠN CỦA NHÀ NÔNG: SỐ 7"},{"time":"14:00","title":"THỂ THAO - GIỜ VÀNG THỂ THAO"},{"time":"14:30","title":"DÂN CA NHẠC CỔ: DUYÊN DÁNG ĐỒNG BẰNG"},{"time":"14:30","title":"DÂN CA NHẠC CỔ - MIỀN TÂY BAO THƯƠNG NHỚ"},{"time":"14:45","title":"PHÓNG SỰ: BÀI TOÁN CHO Y TẾ CƠ SỞ"},{"time":"15:00","title":"ĐẤT KHỎE – CÂY TRỒNG KHỎE: GIẢM CHI PHÍ, TĂNG NĂNG SUẤT, CHẤT LƯỢNG LÚA HÈ THU"},{"time":"15:00","title":"KHỎE CÙNG CHUYÊN GIA: PHÒNG NGỪA ĐỘT QUỴ"},{"time":"15:00","title":"KHỎE CÙNG CHUYÊN GIA - PHÒNG NGỪA ĐỘT QUỴ NÃO"},{"time":"16:00","title":"BẢN TIN NÔNG NGHIỆP"},{"time":"16:10","title":"XỔ SỐ KIẾN THIẾT: HẬU GIANG - LONG AN"},{"time":"16:10","title":"XỔ SỐ KIẾN THIẾT: KIÊN GIANG - TIỀN GIANG"},{"time":"16:10","title":"XỔ SỐ KIẾN THIẾT - ĐỒNG THÁP - CÀ MAU"},{"time":"16:40","title":"NHÀ NÔNG VÀ CÔNG NGHỆ: SỐ 27"},{"time":"16:45","title":"LẬP TRÌNH TRÁI TIM: NGƯỜI EM BẤT HẢO - PHẦN 1"},{"time":"16:45","title":"LẬP TRÌNH TRÁI TIM: NGƯỜI EM BẤT HẢO - PHẦN 2"},{"time":"16:45","title":"LẬP TRÌNH TRÁI TIM - NGƯỜI EM BẤT HẢO - PHẦN 3"},{"time":"17:00","title":"PHIM TRUYỆN: BA ƠI MẸ CÓ VỀ KHÔNG - TẬP 20"},{"time":"17:00","title":"PHIM TRUYỆN: BA ƠI MẸ CÓ VỀ KHÔNG - TẬP 21"},{"time":"17:00","title":"BA ƠI MẸ CÓ VỀ KHÔNG - TẬP 22"},{"time":"17:45","title":"CHƯƠNG TRÌNH THIẾU NHI: TRƯỞNG THÀNH CÙNG TIAN TIAN - TẬP 22"},{"time":"17:45","title":"CHƯƠNG TRÌNH THIẾU NHI: TRƯỞNG THÀNH CÙNG TIAN TIAN - TẬP 23"},{"time":"17:45","title":"KÝ SỰ - VỀ LẠI CỨ ĐỊA XƯA - TẬP 6"},{"time":"18:00","title":"MIỀN TÂY HÔM NAY"},{"time":"18:30","title":"THẾ GIỚI QUANH TA: SỐ 27"},{"time":"18:30","title":"360 ĐỘ MIỀN TÂY: SỐ 25"},{"time":"18:30","title":"BẢN TIN THỂ THAO"},{"time":"18:45","title":"THƯƠNG NHỚ MIỀN TÂY: NGHỀ SÔNG NƯỚC"},{"time":"18:45","title":"QUỐC PHÒNG TOÀN DÂN QK9: SỐ 27"},{"time":"18:45","title":"BIẾN ĐỔI KHÍ HẬU - TRẢ LẠI HƠI THỞ CHO TRÀM CHIM"},{"time":"19:00","title":"THỜI SỰ"},{"time":"19:45","title":"PHIM TRUYỆN: KẾ HOẠCH TRÁI TIM - TẬP 24"},{"time":"19:45","title":"TỌA ĐÀM: BẢO HIỂM Y TẾ - LÁ CHẮN SỨC KHỎE CHO MỌI NGƯỜI"},{"time":"19:45","title":"KẾ HOẠCH TRÁI TIM - TẬP 25"},{"time":"20:30","title":"PHIM HOẠT HÌNH: CHUYỆN CỔ TÍCH - PHẦN 4"},{"time":"20:30","title":"PHIM HOẠT HÌNH"},{"time":"20:45","title":"KÝ ỨC MIỀN TÂY: HỒI ĐÓ LẤM LEM"},{"time":"21:00","title":"NHẬT KÝ WORLD CUP 2026"},{"time":"21:15","title":"SỐNG KHỎE - ĐẸP: SỐ 40"},{"time":"21:15","title":"PHIM TÀI LIỆU: TƯỚNG VỀ HƯU VÀ 1.000 CĂN NHÀ ĐỒNG ĐỘI"},{"time":"21:15","title":"CẢNH GIÁC 247"},{"time":"21:45","title":"PHIM TRUYỆN: NHỮNG NẺO ĐƯỜNG GẦN XA - TẬP 23"},{"time":"21:45","title":"PHIM TRUYỆN: NHỮNG NẺO ĐƯỜNG GẦN XA - TẬP 24"},{"time":"21:45","title":"NHỮNG NẺO ĐƯỜNG GẦN XA - TẬP 25"},{"time":"22:30","title":"CHUYỆN CUỐI TUẦN: NSND KIM XUÂN"},{"time":"22:30","title":"MẢNH GHÉP HOÀN HẢO: SỐ 102"},{"time":"22:30","title":"PHIM TÀI LIỆU - TƯỚNG VỀ HƯU VÀ 1.000 CĂN NHÀ ĐỒNG ĐỘI"},{"time":"23:00","title":"TÌNH KHÚC VƯỢT THỜI GIAN: ĐÊM TÂM SỰ"},{"time":"23:00","title":"ĐỜI NGHỆ SỸ: NHẠC SỸ ĐÀI PHƯƠNG TRANG"},{"time":"23:00","title":"SẮC MÀU CÁC DÂN TỘC - CHUYỆN KỂ TỪ THANH ÂM"},{"time":"23:30","title":"THỂ THAO - KẾT NỐI THỂ THAO"},{"time":"23:45","title":"KÝ ỨC MIỀN TÂY: HỒI ĐÓ LẤM LEM"},{"time":"23:45","title":"THƯƠNG NHỚ MIỀN TÂY: DU LỊCH XUYÊN RỪNG XUYÊN ĐÊM"}]} \ No newline at end of file diff --git a/vtv_scraper.py b/vtv_scraper.py deleted file mode 100644 index 9d26efbc903ffd34bfac7c5887b8958e0ef6ab2e..0000000000000000000000000000000000000000 --- a/vtv_scraper.py +++ /dev/null @@ -1,147 +0,0 @@ -""" -VTV Channels Scraper - Optimized for stable streaming -Fetches stream URLs from multiple CDN sources for VTV1-VTV10 + VTV Cần Thơ -""" -import requests, re, time, threading -from datetime import datetime, timedelta, timezone - -VN_TZ = timezone(timedelta(hours=17)) - -UA = { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", - "Accept-Language": "vi-VN,vi;q=0.9", - "Referer": "https://hd.xemtv.net/", -} - -# ===== PRIMARY CDN SOURCES (Optimized for stability) ===== -# Priority order: FPTPlay > VTVGo > MediaCDN - -XEMTV_PHP_ENDPOINTS = { - "vtv1": "https://hd.xemtv.net/kenh/vtv1.php", - "vtv2": "https://hd.xemtv.net/kenh/vtv2.php", - "vtv3": "https://hd.xemtv.net/kenh/vtv3.php", - "vtv4": "https://hd.xemtv.net/kenh/vtv4.php", - "vtv5": "https://hd.xemtv.net/kenh/vtv5.php", - "vtv6": "https://hd.xemtv.net/kenh/vtv6.php", - "vtv7": "https://hd.xemtv.net/kenh/vtv7.php", - "vtv8": "https://hd.xemtv.net/kenh/vtv8.php", - "vtv9": "https://hd.xemtv.net/kenh/vtv9.php", - "vtv10": "https://hd.xemtv.net/kenh/vtv10.php", -} - -CHANNEL_NAMES = { - "vtv1": "VTV1", "vtv2": "VTV2", "vtv3": "VTV3", "vtv4": "VTV4", - "vtv5": "VTV5", "vtv6": "VTV6", "vtv7": "VTV7", "vtv8": "VTV8", - "vtv9": "VTV9", "vtv10": "VTV10", -} - -# ===== RELIABLE CDN BACKUPS (verified working URLs) ===== -CDN_STREAMS = { - # FPTPlay - Primary (most stable) - "vtv1": "https://live-a.fptplay53.net/live/media/vtv1/live247-hls-avc/index.m3u8", - "vtv2": "https://live-a.fptplay53.net/live/media/vtv2/live247-hls-avc/index.m3u8", - "vtv3": "https://live-a.fptplay53.net/live/media/vtv3/live247-hls-avc/index.m3u8", - "vtv4": "https://live-a.fptplay53.net/live/media/vtv4/live247-hls-avc/index.m3u8", - "vtv5": "https://live-a.fptplay53.net/live/media/vtv5/live247-hls-avc/index.m3u8", - "vtv6": "https://live-a.fptplay53.net/live/media/vtv6/live247-hls-avc/index.m3u8", - "vtv7": "https://live-a.fptplay53.net/live/media/vtv7/live247-hls-avc/index.m3u8", - "vtv8": "https://live-a.fptplay53.net/live/media/vtv8/live-hls-avc/index.m3u8", - "vtv9": "https://live-a.fptplay53.net/live/media/vtv9/live247-hls-avc/index.m3u8", - "vtv10": "https://live-a.fptplay53.net/live/media/vtv10/live247-hls-avc/index.m3u8", - - # VTVGo Failover - "vtv1_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv1-manifest.m3u8", - "vtv2_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv2-manifest.m3u8", - "vtv3_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv3-manifest.m3u8", - "vtv4_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv4-manifest.m3u8", - "vtv5_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv5-manifest.m3u8", - "vtv6_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv6-manifest.m3u8", - "vtv7_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv7-manifest.m3u8", - "vtv8_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv8-manifest.m3u8", - "vtv9_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv9-manifest.m3u8", - "vtv10_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv10-manifest.m3u8", -} - -_vtv_cache = {} -_vtv_lock = threading.Lock() -_CACHE_TTL = 180 - -def _cached(key): - with _vtv_lock: - if key in _vtv_cache and time.time() - _vtv_cache[key]['t'] < _CACHE_TTL: - return _vtv_cache[key]['d'] - return None - -def _set_cache(key, data): - with _vtv_lock: - _vtv_cache[key] = {'t': time.time(), 'd': data} - -def extract_m3u8_from_html(html): - if not html: return None - # Look for file: "..." pattern - m = re.search(r"file\s*:\s*['\"]([^'\"]*\.m3u8[^'\"]*)['\"]", html, re.IGNORECASE) - if m: - url = m.group(1).strip() - if len(url) > 20: return url - # Look for direct m3u8 URL - m = re.search(r"(https?://[^\s\"'<>\\]+\.m3u8[^\s\"'<>\\]*)", html, re.IGNORECASE) - if m: - url = m.group(1).strip() - if len(url) > 20: return url - return None - -def verify_cdn(url, referer="", timeout=8): - """Quick verify CDN is working""" - if not url: return None - try: - r = requests.get(url, headers={"User-Agent": UA["User-Agent"], "Referer": referer}, timeout=timeout, allow_redirects=True, verify=False) - if r.status_code == 200 and '#EXTM3U' in r.text[:500]: - return url - except: pass - return None - -def fetch_vtv_stream(channel_id): - """Fetch VTV stream with priority order for maximum stability""" - channel_id = channel_id.lower().strip() - - # Normalize channel ID - name_map = { - 'vtvct': 'vtv10', 'vtv-can-tho': 'vtv10', 'vtv can tho': 'vtv10', - 'vtv_can_tho': 'vtv10', 'cantho': 'vtv10', 'cần thơ': 'vtv10', - 'vietnam_vtv1': 'vtv1', 'vietnam_vtv2': 'vtv2', 'vietnam_vtv3': 'vtv3', - 'vietnam_vtv4': 'vtv4', 'vietnam_vtv5': 'vtv5', 'vietnam_vtv6': 'vtv6', - 'vietnam_vtv7': 'vtv7', 'vietnam_vtv8': 'vtv8', 'vietnam_vtv9': 'vtv9', - } - channel_id = name_map.get(channel_id, channel_id) - - # Try direct CDN URLs first (most stable) - if channel_id in CDN_STREAMS: - return CDN_STREAMS[channel_id] - - # Try PHP endpoints as fallback - php_url = XEMTV_PHP_ENDPOINTS.get(channel_id) - if php_url: - try: - r = requests.get(php_url, headers=UA, timeout=15, allow_redirects=True, verify=False) - if r.status_code == 200: - m3u8 = extract_m3u8_from_html(r.text) - if m3u8: return m3u8 - except: pass - - # Try failover backup - fb_key = f"{channel_id}_fb" - if fb_key in CDN_STREAMS: - return CDN_STREAMS[fb_key] - - return None - -def get_all_vtv_streams(): - channels = [] - for ch_id in CHANNEL_NAMES: - stream_url = fetch_vtv_stream(ch_id) - channels.append({ - 'id': ch_id, - 'name': CHANNEL_NAMES.get(ch_id, ch_id.upper()), - 'stream_url': stream_url, - }) - return channels \ No newline at end of file diff --git a/vtv_shorts.py b/vtv_shorts.py deleted file mode 100644 index fc838b9b226f277002408e97b5a858346d1a5ec0..0000000000000000000000000000000000000000 --- a/vtv_shorts.py +++ /dev/null @@ -1,140 +0,0 @@ -""" -VTV Nam Bộ YouTube Shorts Scraper -""" -import requests -import re -import json -import subprocess -import html as html_lib -import time -import threading -from xml.etree import ElementTree as ET - -_cache = {} -_lock = threading.Lock() -CACHE_TTL = 1800 - -UA = { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", - "Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8", -} - -def _cached(key): - with _lock: - if key in _cache and time.time() - _cache[key]['t'] < CACHE_TTL: - return _cache[key]['d'] - return None - -def _set_cache(key, data): - with _lock: - _cache[key] = {'t': time.time(), 'd': data} - -def get_channel_id(username): - cached = _cached(f'ch_id_{username}') - if cached: - return cached - channel_id = None - try: - url = f"https://www.youtube.com/@{username}" - r = requests.get(url, headers=UA, timeout=15) - if r.status_code == 200: - m = re.search(r']+property=["\']og:image["\'][^>]+content=["\']([^"\']+)["\']',r.text[:5000]) - if not m:m=re.search(r']+content=["\']([^"\']+)["\'][^>]+property=["\']og:image["\']',r.text[:5000]) - if m:img=m.group(1);return('https:'+img if img.startswith('//') else img) - except:pass - return'' -def _utc_to_vn(date_str): - try: - dt_str=date_str.replace('Z','+00:00') - if '+' not in dt_str and 'T' in dt_str:dt_str+='+00:00' - return datetime.fromisoformat(dt_str).astimezone(VN_TZ).strftime('%H:%M %d/%m/%Y') - except:return date_str - -def scrape_fixtures(): - c=_cached('wc_fix',600) - if c is not None:return c - matches=[] - try: - r=requests.get('https://fixturedownload.com/feed/json/fifa-world-cup-2026',headers=UA,timeout=15) - if r.status_code==200: - for m in r.json(): - match={'match_number':m.get('MatchNumber',''),'round':m.get('RoundNumber',''),'group':m.get('Group',''),'date_utc':m.get('DateUtc',''),'date_vn':_utc_to_vn(m.get('DateUtc','')),'location':m.get('Location',''),'home':m.get('HomeTeam',''),'away':m.get('AwayTeam',''),'home_score':m.get('HomeTeamScore'),'away_score':m.get('AwayTeamScore')} - if match['home_score'] is not None and match['away_score'] is not None:match['score']=f"{match['home_score']} - {match['away_score']}";match['status']='finished' - else: - match['score']='vs' - try: - mdt=datetime.fromisoformat(m.get('DateUtc','').replace('Z','+00:00'));diff=(mdt-datetime.now(timezone.utc)).total_seconds() - match['status']='live' if -7200=limit:break - except:pass - return items - -def scrape_wc_news(): - """WC news from 8 sources (same as hashtag search) - NOT Google News.""" - c=_cached('wc_news',300) - if c is not None:return c - - topic="World Cup 2026" - sources_cfg=[ - ('https://timkiem.vnexpress.net/?q={q}','article.item-news h2 a, article.item-news h3 a','','VnExpress'), - ('https://dantri.com.vn/tim-kiem/{q}.htm','h3 a[href], .article-title a[href]','https://dantri.com.vn','Dân Trí'), - ('https://vietnamnet.vn/tim-kiem?q={q}','h3 a[href], .vnn-title a','https://vietnamnet.vn','VietNamNet'), - ('https://thanhnien.vn/tim-kiem?q={q}','h3 a[href], .box-title a','https://thanhnien.vn','Thanh Niên'), - ('https://tuoitre.vn/tim-kiem.htm?keywords={q}','h3 a[href], .box-title-text a','https://tuoitre.vn','Tuổi Trẻ'), - ('https://thethaovanhoa.vn/tim-kiem.htm?keyword={q}','h3 a[href], .title a[href]','https://thethaovanhoa.vn','TT&VH'), - ('https://genk.vn/tim-kiem?q={q}','a[href$=".chn"]','https://genk.vn','GenK'), - ] - - all_news=[] - def _fetch_source(cfg): - url_tpl,selector,base,source=cfg - items=_search_source(url_tpl,topic,selector,base,8) - return [(item,source) for item in items] - # bongda.com.vn + bongdaplus.vn via Jina (JS-protected) — keyword-filter for World Cup - def _fetch_bongda_jina(): - out=[] - kws=['world cup','wc 2026','world cup 2026','tuyển','đội tuyển','vòng loại','fifa'] - try: - from main import scrape_bongda_jina, scrape_bongdaplus_jina - for a in (scrape_bongda_jina(40) or []): - tl=(a.get('title','') or '').lower() - if any(k in tl for k in kws):out.append(({'title':a['title'],'link':a['link']},'Bóng Đá')) - for a in (scrape_bongdaplus_jina(40) or []): - tl=(a.get('title','') or '').lower() - if ('world cup' in tl) or ('/world-cup/' in a.get('link','')) or any(k in tl for k in kws[3:]): - out.append(({'title':a['title'],'link':a['link']},'Bóng Đá+')) - except:pass - return out - - with ThreadPoolExecutor(8) as ex: - futs=[ex.submit(_fetch_source,cfg) for cfg in sources_cfg] - futs.append(ex.submit(_fetch_bongda_jina)) - for f in as_completed(futs,timeout=16): - try: - for item,source in f.result(): - all_news.append({'title':item['title'],'link':item['link'],'img':'','source':source}) - except:pass - - # Deduplicate - seen=set();unique=[] - for n in all_news: - if n['link'] not in seen:seen.add(n['link']);unique.append(n) - - # Fetch og:image for first 12 - def _fill(item): - if not item.get('img'):item['img']=_get_og_image(item['link']) - with ThreadPoolExecutor(6) as ex: - futs=[ex.submit(_fill,n) for n in unique[:12]] - for f in as_completed(futs,timeout=12): - try:f.result() - except:pass - - _set('wc_news',unique[:30]);return unique[:30] - -def scrape_road_to_wc(): - """Road to WC also uses direct search (not Google News).""" - c=_cached('wc_road',600) - if c is not None:return c - articles=[] - for topic in['đường tới World Cup 2026','tuyển Việt Nam World Cup 2026']: - items=_search_source('https://timkiem.vnexpress.net/?q={q}',topic,'article.item-news h2 a, article.item-news h3 a','',5) - for item in items: - if item['link'] not in[x['link'] for x in articles]: - img=_get_og_image(item['link']) - articles.append({'title':item['title'],'link':item['link'],'img':img,'source':'VnExpress','type':'road'}) - _set('wc_road',articles[:20]);return articles[:20] - -def get_wc2026_all(): - c=_cached('wc_all',90) - if c is not None:return c - data={} - with ThreadPoolExecutor(5) as ex: - futs={ex.submit(scrape_fixtures):'fixtures',ex.submit(scrape_standings):'standings',ex.submit(scrape_stats):'stats',ex.submit(scrape_wc_news):'news',ex.submit(scrape_road_to_wc):'road'} - for f in as_completed(futs,timeout=35): - key=futs[f] - try:data[key]=f.result() - except:data[key]={} if key in('fixtures','standings','stats') else[] - data['summary']=data.get('standings',{});_set('wc_all',data);return data diff --git a/yt_scraper.py b/yt_scraper.py deleted file mode 100644 index 977e25edfe05b9c2d73a0763a7525e67e3b63774..0000000000000000000000000000000000000000 --- a/yt_scraper.py +++ /dev/null @@ -1,235 +0,0 @@ -""" -YouTube Shorts Scraper using yt-dlp (already installed on Space) -Runs yt-dlp as subprocess to extract video info and direct URLs -""" -import subprocess -import json -import time -import threading -import os -import re as re_mod - -_cache = {} -_lock = threading.Lock() -CACHE_TTL = 600 # 10 min cache - -def _cached(key): - with _lock: - if key in _cache and time.time() - _cache[key]['t'] < CACHE_TTL: - return _cache[key]['d'] - return None - -def _set_cache(key, data): - with _lock: - _cache[key] = {'t': time.time(), 'd': data} - -def run_yt_dlp(args, timeout=120): - """Run yt-dlp and return parsed JSON lines""" - try: - result = subprocess.run( - ["yt-dlp"] + args, - capture_output=True, text=True, timeout=timeout - ) - if result.returncode != 0 and not result.stdout.strip(): - print(f"yt-dlp error: {result.stderr[:200]}") - return [] - lines = result.stdout.strip().split('\n') - items = [] - for line in lines: - line = line.strip() - if not line: - continue - try: - items.append(json.loads(line)) - except json.JSONDecodeError: - continue - return items - except subprocess.TimeoutExpired: - print("yt-dlp timeout") - return [] - except FileNotFoundError: - print("yt-dlp not found!") - return [] - except Exception as e: - print(f"yt-dlp exception: {e}") - return [] - -def get_channel_shorts_via_playlist(channel_username, max_count=100): - """Get shorts from channel's shorts page using yt-dlp""" - shorts = [] - - # Method 1: Fetch from /shorts page - url = f"https://www.youtube.com/@{channel_username}/shorts" - items = run_yt_dlp([ - "--dump-json", - "--flat-playlist", - "--no-download", - "--playlist-end", str(max_count), - "--no-check-certificates", - "--user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", - url - ], timeout=90) - - seen_ids = set() - for item in items: - vid = item.get('id', '') - if not vid or vid in seen_ids: - continue - seen_ids.add(vid) - - title = item.get('title', 'VTV Nam Bộ Short') - duration = item.get('duration', 0) or 0 - - # Only include actual shorts (<= 120s to be safe) - if duration <= 120 or '#shorts' in title.lower() or '#short' in title.lower(): - shorts.append({ - 'id': vid, - 'title': title, - 'duration': duration, - 'channel': channel_username, - 'img': f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg", - }) - - return shorts - -def get_channel_videos_filter_shorts(channel_username, max_count=200): - """Get all videos from /videos page and filter for shorts by duration""" - shorts = [] - - url = f"https://www.youtube.com/@{channel_username}/videos" - items = run_yt_dlp([ - "--dump-json", - "--flat-playlist", - "--no-download", - "--playlist-end", str(max_count), - "--match-filter", "duration > 0 and duration <= 120", - "--no-check-certificates", - "--user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", - url - ], timeout=120) - - seen_ids = set() - for item in items: - vid = item.get('id', '') - if not vid or vid in seen_ids: - continue - seen_ids.add(vid) - - title = item.get('title', 'VTV Nam Bộ Short') - duration = item.get('duration', 0) or 0 - - shorts.append({ - 'id': vid, - 'title': title, - 'duration': duration, - 'channel': channel_username, - 'img': f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg", - }) - - return shorts - -def get_shorts_with_direct_url(video_ids): - """Get direct video download URLs for given video IDs""" - results = [] - - for vid in video_ids[:20]: # Limit to avoid timeout - try: - url = f"https://www.youtube.com/shorts/{vid}" - items = run_yt_dlp([ - "--dump-json", - "--no-download", - "--no-check-certificates", - "--format", "best[filesize<10M]/best", - url - ], timeout=30) - - if items: - info = items[0] - direct_url = info.get('url', '') - if not direct_url: - # Try to get from formats - formats = info.get('formats', []) - for f in formats: - if f.get('vcodec') != 'none' and f.get('acodec') != 'none': - direct_url = f.get('url', '') - break - - if direct_url: - results.append({ - 'id': vid, - 'title': info.get('title', ''), - 'direct_url': direct_url, - 'thumbnail': info.get('thumbnail', f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg"), - 'duration': info.get('duration', 0), - }) - except Exception as e: - print(f"Error getting URL for {vid}: {e}") - - return results - -def get_vtvnambo_shorts(max_count=50): - """Get all shorts from VTV Nam Bộ using yt-dlp""" - cached = _cached('vtvnambo_shorts_yt') - if cached is not None: - return cached - - all_shorts = [] - seen_ids = set() - - # Method 1: /shorts page - print(f"[yt-dlp] Fetching /shorts page...") - shorts_page = get_channel_shorts_via_playlist('vtvnambo', max_count) - for s in shorts_page: - if s['id'] not in seen_ids: - seen_ids.add(s['id']) - all_shorts.append(s) - print(f"[yt-dlp] /shorts page: {len(shorts_page)} shorts") - - # Method 2: /videos page with duration filter - if len(all_shorts) < 5: - print(f"[yt-dlp] Fetching /videos page with filter...") - videos_filtered = get_channel_videos_filter_shorts('vtvnambo', max_count * 2) - for s in videos_filtered: - if s['id'] not in seen_ids: - seen_ids.add(s['id']) - all_shorts.append(s) - print(f"[yt-dlp] /videos filter: {len(videos_filtered)} shorts") - - result = all_shorts[:max_count] - _set_cache('vtvnambo_shorts_yt', result) - print(f"[yt-dlp] Total: {len(result)} shorts from VTV Nam Bộ") - return result - -def get_wc_related_shorts(max_count=30): - """Get World Cup / football related shorts""" - all_shorts = get_vtvnambo_shorts(max_count * 3) - - wc_kws = [ - 'world cup', 'wc 2026', 'worldcup', 'fifa', 'bóng đá', - 'trận đấu', 'đội tuyển', 'tuyển', 'vòng loại', - 'khoảnh khắc', 'highlights', 'bàn thắng', 'goal', - 'kết quả', 'tỉ số', 'việt nam', 'vn', - 'ngoại hạng', 'premier league', 'champions league', - 'laliga', 'serie a', 'bundesliga', 'ligue 1', - 'copa', 'europa', 'c1', 'c2', - 'messi', 'ronaldo', 'neymar', 'mbappe', 'haaland', - 'v-league', 'vleague', 'bóng đá việt', - 'đội bóng', 'hlv', 'huấn luyện viên', - 'chuyển nhượng', 'transfer', - 'asian cup', 'aff cup', 'sea games', - 'olympic', 'u23', 'u20', 'u17', - ] - - wc_shorts = [] - for s in all_shorts: - tl = s.get('title', '').lower() - if any(k in tl for k in wc_kws): - wc_shorts.append(s) - - if not wc_shorts: - wc_shorts = all_shorts - - return wc_shorts[:max_count] - -# Aliases -get_vtvnamo_shorts = get_vtvnambo_shorts diff --git a/yt_scraper_fixed.py b/yt_scraper_fixed.py deleted file mode 100644 index 8e0b939d183af4fb63a43784c4ea7c05cbb2bfd3..0000000000000000000000000000000000000000 --- a/yt_scraper_fixed.py +++ /dev/null @@ -1,162 +0,0 @@ -""" -YouTube Shorts Scraper using yt-dlp (already installed on Space) -Optimized for fast load with long cache + fallback -""" -import subprocess -import json -import time -import threading -import os -import re as re_mod - -_cache = {} -_lock = threading.Lock() -CACHE_TTL = 1800 # 30 min cache - longer to reduce timeout issues - -def _get_cached(key): - """Get cached data if still valid""" - with _lock: - if key in _cache: - entry = _cache[key] - if time.time() - entry['t'] < CACHE_TTL: - return entry['d'] - return None - -def _set_cached(key, data): - """Set cache with timestamp""" - with _lock: - _cache[key] = {'t': time.time(), 'd': data} - -def run_yt_dlp(args, timeout=45): - """Run yt-dlp and return parsed JSON lines - with shorter timeout""" - try: - result = subprocess.run( - ["yt-dlp"] + args, - capture_output=True, text=True, timeout=timeout - ) - if result.returncode != 0 and not result.stdout.strip(): - return [] - lines = result.stdout.strip().split('\n') - items = [] - for line in lines: - line = line.strip() - if not line: - continue - try: - items.append(json.loads(line)) - except json.JSONDecodeError: - continue - return items - except subprocess.TimeoutExpired: - print("yt-dlp timeout (this is OK - using fallback)") - return [] - except FileNotFoundError: - print("yt-dlp not found - using fallback") - return [] - except Exception as e: - print(f"yt-dlp exception: {e}") - return [] - -def get_channel_shorts_fast(channel_username, max_count=25): - """Get shorts fast - prioritize /shorts page only to avoid timeout""" - shorts = [] - - url = f"https://www.youtube.com/@{channel_username}/shorts" - items = run_yt_dlp([ - "--dump-json", - "--flat-playlist", - "--no-download", - "--playlist-end", str(max_count), - "--no-check-certificates", - "--quiet", # Reduce output for speed - "--no-warnings", - url - ], timeout=35) # Increased timeout but still reasonable - - seen_ids = set() - for item in items: - vid = item.get('id', '') - if not vid or vid in seen_ids: - continue - seen_ids.add(vid) - - title = item.get('title', f'{channel_username} Short') - - shorts.append({ - 'id': vid, - 'title': title, - 'channel': channel_username, - 'img': f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg", - }) - - if len(shorts) >= max_count: - break - - return shorts - -def get_dantri_shorts(max_count=25): - """Get Dantri shorts - fast, no fallback needed, cache for 30min""" - cached = _get_cached('dantri_shorts_yt') - if cached is not None: - return cached - - shorts = get_channel_shorts_fast('baodantri7941', max_count) - - if shorts: - _set_cached('dantri_shorts_yt', shorts) - return shorts - - # Fallback to static list if scrape fails - return [ - {"id":"Lu_iCQ5YwNM","title":"Công an lập hồ sơ xử lý người phụ nữ chửi bới tát nam tài xế ô tô ở Hà Nội","channel":"baodantri7941"}, - {"id":"CwWvijF8BOA","title":"Chú rể Ninh Bình bật khóc nhận món quà bí mật người cha","channel":"baodantri7941"}, - ] - -def get_skds_shorts(max_count=25): - """Get SKĐS shorts - fast, no fallback needed, cache for 30min""" - cached = _get_cached('skds_shorts_yt') - if cached is not None: - return cached - - shorts = get_channel_shorts_fast('baosuckhoedoisongboyte', max_count) - - if shorts: - _set_cached('skds_shorts_yt', shorts) - return shorts - - # Fallback to static list if scrape fails - return [ - {"id":"7Pd6vZ2Lz1M","title":"Hành động ấm lòng của người đàn ông tìm kiếm 5 học sinh tử vong","channel":"baosuckhoedoisongboyte"}, - {"id":"SlHLt_ZyPiE","title":"Xử phạt người đàn ông xóa số điện thoại cứu hộ trên cao tốc Bắc Nam","channel":"baosuckhoedoisongboyte"}, - ] - -def get_dantri_skds_shorts(max_count=50): - """Get interleaved Dantri + SKĐS shorts - optimized with separate caching""" - # Get each channel's shorts separately (allows partial fallback) - dantri = get_dantri_shorts(max_count // 2 + 10) - skds = get_skds_shorts(max_count // 2 + 10) - - # Interleave them - result = [] - seen = set() - i, j = 0, 0 - - while (i < len(dantri) or j < len(skds)) and len(result) < max_count: - if i < len(dantri): - item = dantri[i] - if item.get('id') not in seen: - seen.add(item.get('id')) - result.append(item) - i += 1 - if j < len(skds): - item = skds[j] - if item.get('id') not in seen: - seen.add(item.get('id')) - result.append(item) - j += 1 - - return result - -# For backward compatibility -get_vtvnambo_shorts = get_dantri_skds_shorts -get_wc_related_shorts = get_dantri_skds_shorts \ No newline at end of file