Spaces:
Running
Running
Restore 100% commit adce145d - full repo with all 85 files
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +0 -0
- .gitignore +6 -0
- CHANGELOG.md +16 -0
- Dockerfile +48 -0
- README.md +58 -0
- _run.py +1 -0
- ai_ext.py +332 -0
- ai_fix2.py +366 -0
- ai_patch.py +917 -0
- ai_runtime.py +357 -0
- ai_runtime_final.py +315 -0
- ai_runtime_final2.py +242 -0
- ai_runtime_final3.py +191 -0
- ai_runtime_final4.py +185 -0
- ai_runtime_final5.py +73 -0
- ai_runtime_final6.py +849 -0
- ai_runtime_fix.py +394 -0
- ai_runtime_patch_fast.py +188 -0
- ai_runtime_patch_final.py +78 -0
- ai_short_v2.py +1691 -0
- app_clean.py +69 -0
- app_entry.py +17 -0
- app_final.py +213 -0
- app_main.py +283 -0
- app_patch_unified.py +273 -0
- app_run.py +221 -0
- app_v2_entry.py +0 -0
- app_v2_entry.py.gitigignore +3 -0
- app_v2_entry_hot.py +24 -0
- app_v2_entry_test.py +16 -0
- app_v2_entry_v2.py +16 -0
- app_v2_patch.py +111 -0
- auto_scheduler.py +396 -0
- auto_update_sse.py +55 -0
- bongda_proxy.py +113 -0
- index_v2.html +83 -0
- logs_route.py +106 -0
- main.py +799 -0
- main_patch.py +8 -0
- match_detail.py +309 -0
- match_detail_v2.py +418 -0
- patch_ai_hot.py +55 -0
- patch_extra.py +50 -0
- patch_runtime.py +274 -0
- piped_client.py +258 -0
- rebuild3.md +1 -0
- rebuild_trigger.txt +1 -0
- requirements.txt +16 -0
- restart.txt +1 -0
- restart2.md +1 -0
.dockerignore
ADDED
|
File without changes
|
.gitignore
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
data/
|
| 4 |
+
.data
|
| 5 |
+
.huggingface/
|
| 6 |
+
.restart_trigger
|
CHANGELOG.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# FPT Play Stream Selector Update
|
| 2 |
+
|
| 3 |
+
Added FPT Play channel with stream selector UI similar to VTV6:
|
| 4 |
+
|
| 5 |
+
- New tab "FPT" (orange themed) in the channel tabs
|
| 6 |
+
- Stream selector with 4 sources:
|
| 7 |
+
1. 🌐 Web FPT Play (iframe)
|
| 8 |
+
2. 📡 HLS Proxy (via /api/proxy/m3u8)
|
| 9 |
+
3. 🔗 HLS Direct
|
| 10 |
+
4. 📺 HD1.xemtv.net (iframe from LINK 1)
|
| 11 |
+
- Backend vtv_api.py now returns stream_selectors for fpt-the-thao channel
|
| 12 |
+
- Frontend handles stream switching automatically when FPT tab is active
|
| 13 |
+
|
| 14 |
+
## Changes
|
| 15 |
+
- `static/vtv_init.js`: Added FPT tab + stream selector UI logic
|
| 16 |
+
- `vtv_api.py`: Added FPT Play endpoint responses with stream_selectors data
|
Dockerfile
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
RUN echo "[BUILD] step1: apt-get update+install ffmpeg + Vietnamese fonts" && \
|
| 6 |
+
apt-get update && apt-get install -y --no-install-recommends \
|
| 7 |
+
ffmpeg \
|
| 8 |
+
fonts-dejavu-core \
|
| 9 |
+
fonts-noto \
|
| 10 |
+
fonts-noto-cjk \
|
| 11 |
+
fonts-noto-color-emoji \
|
| 12 |
+
fonts-liberation \
|
| 13 |
+
fonts-freefont-ttf \
|
| 14 |
+
libfreetype6 \
|
| 15 |
+
&& rm -rf /var/lib/apt/lists/* && \
|
| 16 |
+
echo "[BUILD] step1 done"
|
| 17 |
+
|
| 18 |
+
RUN echo "[BUILD] step2: pip base pkgs (bs4/lxml)" && \
|
| 19 |
+
pip install --no-cache-dir "beautifulsoup4>=4.12" lxml && \
|
| 20 |
+
echo "[BUILD] step2 done"
|
| 21 |
+
|
| 22 |
+
RUN echo "[BUILD] step3: pip main pkgs" && \
|
| 23 |
+
pip install --no-cache-dir fastapi uvicorn requests beautifulsoup4 jinja2 yt-dlp huggingface_hub gTTS pillow edge-tts python-dateutil httpx pycryptodome && \
|
| 24 |
+
echo "[BUILD] step3 done"
|
| 25 |
+
|
| 26 |
+
COPY requirements.txt .
|
| 27 |
+
RUN echo "[BUILD] step4: pip requirements.txt" && \
|
| 28 |
+
pip install --no-cache-dir -r requirements.txt || true && \
|
| 29 |
+
echo "[BUILD] step4 done"
|
| 30 |
+
|
| 31 |
+
COPY . .
|
| 32 |
+
EXPOSE 7860
|
| 33 |
+
|
| 34 |
+
RUN echo "[BUILD] step5: setup Vietnamese font symlink" && \
|
| 35 |
+
mkdir -p /usr/share/fonts/truetype/vn && \
|
| 36 |
+
# Prefer Noto Sans for Vietnamese - it has full diacritic support
|
| 37 |
+
if [ -f /usr/share/fonts/truetype/noto/NotoSans-Regular.ttf ]; then \
|
| 38 |
+
ln -sf /usr/share/fonts/truetype/noto/NotoSans-Regular.ttf /usr/share/fonts/truetype/vn/VNFont.ttf; \
|
| 39 |
+
elif [ -f /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf ]; then \
|
| 40 |
+
ln -sf /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf /usr/share/fonts/truetype/vn/VNFont.ttf; \
|
| 41 |
+
ln -sf /usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf /usr/share/fonts/truetype/vn/VNFont-Bold.ttf; \
|
| 42 |
+
fi; \
|
| 43 |
+
fc-cache -f -v || true; \
|
| 44 |
+
date > /app/.build_done && \
|
| 45 |
+
echo "[BUILD] step5 done"
|
| 46 |
+
|
| 47 |
+
CMD ["uvicorn", "_run:app", "--host", "0.0.0.0", "--port", "7860"]
|
| 48 |
+
# v3.0-vn-font-fix-short-video-2026-07-19
|
README.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: VNEWS
|
| 3 |
+
emoji: 📰
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: yellow
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
tags:
|
| 9 |
+
- ml-intern
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
# VNEWS - Tin Tức Việt Nam
|
| 13 |
+
|
| 14 |
+
**v18 - FIXED VTV2/VTV3/VTV6/VTV9 stream hanging**
|
| 15 |
+
|
| 16 |
+
## 🔧 Changes in v18 (2026-07-06)
|
| 17 |
+
- **VTV2, VTV3, VTV6, VTV9**: Skip expired ssaimh CDN token → immediately fall through to sv2.xemtivitop.com
|
| 18 |
+
- **15+ extraction patterns** for m3u8 URL (up from 5), including: file:, src=, source:, player.src(), hls.loadSource(), href=, `<source src>`, url:, window.location, iframe follow (3 levels deep), base64 decode
|
| 19 |
+
- **Backup CDN** `tv.mediacdn.vn` for VTV2/VTV3/VTV6/VTV9
|
| 20 |
+
- **Fast timeout** 5s for CDN, 12s for PHP endpoints (was 15s each = 60s+ total)
|
| 21 |
+
- **sv2.xemtivitop.com** re-prioritized to check BEFORE xemtv.us
|
| 22 |
+
- **Iframe chain following**: if a PHP page returns an iframe → follow it up to 3 levels to find the m3u8
|
| 23 |
+
|
| 24 |
+
## Features:
|
| 25 |
+
- 📰 News from VnExpress (10 categories) + GenK AI
|
| 26 |
+
- ⚽ Livescore from bongda.com.vn (live, today, upcoming, results, standings)
|
| 27 |
+
- 🎬 Football highlights from xemlaibongda.top (8 leagues)
|
| 28 |
+
- 📺 VTV live channels (VTV1→VTV10, VTV Prime)
|
| 29 |
+
- Priority: ssaimh CDN → sv2.xemtivitop.com → xemtv.us → xemtivitop blogspot → FPTPlay → VTVGo → mediacdn → xemtv.net
|
| 30 |
+
- 🏆 World Cup 2026 (news, fixtures, standings, stats, highlights)
|
| 31 |
+
- 🤖 AI article writing + TTS (multilingual, emotion-aware)
|
| 32 |
+
- 🔍 Topic search (8 news sources)
|
| 33 |
+
- 🎤 TTS: voice selector + emotion selector + speed control
|
| 34 |
+
|
| 35 |
+
## 🎬 Short AI — Video từ link (scrap YouTube / TikTok / tin tức)
|
| 36 |
+
|
| 37 |
+
Short creator có chế độ **"🔗 Video từ link"**: dán link video YouTube / TikTok /
|
| 38 |
+
VnExpress / Dân trí / Znews / 24h... → bấm "Lấy video" để xem trước → tạo short
|
| 39 |
+
chạy video + ảnh đã chọn bù phần còn thiếu nếu video ngắn hơn giọng đọc.
|
| 40 |
+
|
| 41 |
+
### 🔑 Cài cookies cho YouTube (bỏ chặn "Sign in to confirm you're not a bot")
|
| 42 |
+
YouTube đôi khi chặn IP datacenter. Cách khắc phục bằng cookies:
|
| 43 |
+
|
| 44 |
+
1. Cài extension trình duyệt **"Get cookies.txt LOCALLY"** (Chrome/Edge) hoặc
|
| 45 |
+
**"cookies.txt"** (Firefox).
|
| 46 |
+
2. Mở `https://www.youtube.com` (đã đăng nhập) → bấm extension → **Export** → ra file `cookies.txt` (định dạng Netscape).
|
| 47 |
+
3. Đưa cookies vào Space bằng **một trong hai cách**:
|
| 48 |
+
- **Cách A (khuyến nghị):** Vào Settings của Space
|
| 49 |
+
`huggingface.co/spaces/bep40/VNEWS/settings` → **Variables and secrets** →
|
| 50 |
+
tạo secret tên **`YT_COOKIES`**, giá trị = toàn bộ nội dung file `cookies.txt`.
|
| 51 |
+
- **Cách B:** đặt file `cookies.txt` vào thư mục gốc repo `VNEWS/` và commit
|
| 52 |
+
(chú ý: cookies sẽ công khai nếu repo public — ưu tiên Cách A).
|
| 53 |
+
4. Rebuild Space (mỗi lần đổi secret phải **Restart** Space).
|
| 54 |
+
|
| 55 |
+
Backend tự đọc `YT_COOKIES` (secret) hoặc `/app/cookies.txt`, ghi thành file tạm
|
| 56 |
+
và truyền cho yt-dlp qua `cookiefile`. Không cần sửa code.
|
| 57 |
+
|
| 58 |
+
> Lưu ý: cookies có hạn (thường vài tuần). Khi hết hạn, export lại và cập nhật secret.
|
_run.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
from app_v2_entry import app # v5-stable inline bongda proxy
|
ai_ext.py
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""VNEWS AI Extension - rewrite + auto short video generation.
|
| 2 |
+
Imported by app_v2_entry.py to register /api/rewrite_share, /api/topic_post,
|
| 3 |
+
/api/ai_wall, /api/wall, /api/ai/short endpoints on the main FastAPI app.
|
| 4 |
+
|
| 5 |
+
Uses main.py's WALL_FILE (wall_posts.json) for unified data store.
|
| 6 |
+
TTS: edge-tts (HoaiMy female, NamMinh male) with speed control + gTTS fallback.
|
| 7 |
+
"""
|
| 8 |
+
import os, re, json, time, random, html as html_lib, subprocess, asyncio
|
| 9 |
+
from urllib.parse import quote_plus, quote, urlparse, urljoin
|
| 10 |
+
from typing import Optional, List, Dict
|
| 11 |
+
import requests
|
| 12 |
+
from bs4 import BeautifulSoup
|
| 13 |
+
from fastapi import Request, Query
|
| 14 |
+
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
|
| 15 |
+
|
| 16 |
+
# Try to import main app, but don't fail if it doesn't exist
|
| 17 |
+
try:
|
| 18 |
+
from main import app
|
| 19 |
+
except ImportError:
|
| 20 |
+
# Create a minimal FastAPI app for standalone testing
|
| 21 |
+
try:
|
| 22 |
+
from fastapi import FastAPI
|
| 23 |
+
app = FastAPI()
|
| 24 |
+
except Exception:
|
| 25 |
+
app = None
|
| 26 |
+
|
| 27 |
+
# Import wall store from main.py so we read/write the SAME file
|
| 28 |
+
try:
|
| 29 |
+
from main import _load_wall, _save_wall, _web_context # noqa: F401
|
| 30 |
+
except ImportError:
|
| 31 |
+
_data_dir = "/data" if os.path.isdir("/data") else "/app/data"
|
| 32 |
+
_wall_file = os.path.join(_data_dir, "wall_posts.json")
|
| 33 |
+
def _load_wall():
|
| 34 |
+
try:
|
| 35 |
+
if os.path.exists(_wall_file):
|
| 36 |
+
with open(_wall_file, "r", encoding="utf-8") as f:
|
| 37 |
+
return json.load(f)
|
| 38 |
+
except Exception:
|
| 39 |
+
pass
|
| 40 |
+
return []
|
| 41 |
+
def _save_wall(posts):
|
| 42 |
+
try:
|
| 43 |
+
os.makedirs(os.path.dirname(_wall_file), exist_ok=True)
|
| 44 |
+
tmp = _wall_file + ".tmp"
|
| 45 |
+
with open(tmp, "w", encoding="utf-8") as f:
|
| 46 |
+
json.dump(posts[:100], f, ensure_ascii=False)
|
| 47 |
+
os.replace(tmp, _wall_file)
|
| 48 |
+
except Exception:
|
| 49 |
+
pass
|
| 50 |
+
def _web_context(topic):
|
| 51 |
+
return ""
|
| 52 |
+
|
| 53 |
+
# ai_ext alias for backward compatibility
|
| 54 |
+
_load_ai_wall = _load_wall
|
| 55 |
+
_save_ai_wall = _save_wall
|
| 56 |
+
|
| 57 |
+
try:
|
| 58 |
+
from huggingface_hub import AsyncInferenceClient
|
| 59 |
+
except Exception:
|
| 60 |
+
AsyncInferenceClient = None
|
| 61 |
+
try:
|
| 62 |
+
from gtts import gTTS
|
| 63 |
+
except Exception:
|
| 64 |
+
gTTS = None
|
| 65 |
+
try:
|
| 66 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 67 |
+
except Exception:
|
| 68 |
+
Image = ImageDraw = ImageFont = None
|
| 69 |
+
try:
|
| 70 |
+
import edge_tts
|
| 71 |
+
except Exception:
|
| 72 |
+
edge_tts = None
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _hf_token():
|
| 76 |
+
for k in ("HF_TOKEN", "HUGGINGFACE_HUB_API_TOKEN", "HUGGING_FACE_HUB_TOKEN", "HF_API_TOKEN"):
|
| 77 |
+
v = os.getenv(k, "").strip()
|
| 78 |
+
if v:
|
| 79 |
+
return v
|
| 80 |
+
return ""
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _clean_text(s: str) -> str:
|
| 84 |
+
"""Clean text for processing."""
|
| 85 |
+
s = html_lib.unescape(s or "")
|
| 86 |
+
s = re.sub(r"\s+", " ", s)
|
| 87 |
+
return s.strip()
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _domain(url: str) -> str:
|
| 91 |
+
"""Extract domain from URL."""
|
| 92 |
+
try:
|
| 93 |
+
return urlparse(url or "").netloc.replace("www.", "")
|
| 94 |
+
except Exception:
|
| 95 |
+
return ""
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
async def qwen_generate(prompt: str, image_url: str = None, max_tokens: int = 1200) -> str:
|
| 99 |
+
"""Generate text using Llama/Qwen models via Hugging Face Inference API.
|
| 100 |
+
|
| 101 |
+
Prioritizes Llama-3.3-70B for better creative/opinion writing.
|
| 102 |
+
"""
|
| 103 |
+
token = _hf_token()
|
| 104 |
+
errors = []
|
| 105 |
+
|
| 106 |
+
# Try HF router API with multiple models - Llama FIRST for opinion writing
|
| 107 |
+
if token:
|
| 108 |
+
models = [
|
| 109 |
+
os.getenv("QWEN_VL_MODEL", ""),
|
| 110 |
+
"meta-llama/Llama-3.3-70B-Instruct", # FIRST - best for opinion/analysis
|
| 111 |
+
"Qwen/Qwen2.5-VL-7B-Instruct",
|
| 112 |
+
"Qwen/Qwen2.5-72B-Instruct",
|
| 113 |
+
]
|
| 114 |
+
# Deduplicate while preserving order
|
| 115 |
+
seen = set()
|
| 116 |
+
models = [m for m in models if m and m not in seen and not seen.add(m)]
|
| 117 |
+
|
| 118 |
+
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
| 119 |
+
|
| 120 |
+
for model in models:
|
| 121 |
+
try:
|
| 122 |
+
is_vl = "VL" in model and image_url
|
| 123 |
+
if is_vl:
|
| 124 |
+
user_content = [
|
| 125 |
+
{"type": "image_url", "image_url": {"url": image_url}},
|
| 126 |
+
{"type": "text", "text": prompt}
|
| 127 |
+
]
|
| 128 |
+
else:
|
| 129 |
+
user_content = prompt
|
| 130 |
+
|
| 131 |
+
payload = {
|
| 132 |
+
"model": model,
|
| 133 |
+
"messages": [
|
| 134 |
+
{"role": "system", "content": "Bạn là nhà báo phản biện chuyên nghiệp. Luôn viết theo quan điểm cá nhân, phân tích sâu, không sao chép nguyên văn nguồn tin."},
|
| 135 |
+
{"role": "user", "content": user_content},
|
| 136 |
+
],
|
| 137 |
+
"max_tokens": min(int(max_tokens or 2000), 2500),
|
| 138 |
+
"temperature": 0.75,
|
| 139 |
+
"top_p": 0.9,
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
r = requests.post(
|
| 143 |
+
"https://router.huggingface.co/v1/chat/completions",
|
| 144 |
+
headers=headers,
|
| 145 |
+
json=payload,
|
| 146 |
+
timeout=95
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
if r.status_code >= 300:
|
| 150 |
+
errors.append(f"{model}: HTTP {r.status_code}")
|
| 151 |
+
continue
|
| 152 |
+
|
| 153 |
+
j = r.json()
|
| 154 |
+
txt = (j.get("choices", [{}])[0].get("message", {}).get("content") or "").strip()
|
| 155 |
+
|
| 156 |
+
if txt:
|
| 157 |
+
return txt
|
| 158 |
+
|
| 159 |
+
errors.append(f"{model}: empty response")
|
| 160 |
+
|
| 161 |
+
except Exception as e:
|
| 162 |
+
errors.append(f"{model}: {type(e).__name__}")
|
| 163 |
+
|
| 164 |
+
# Fallback: extractive summary from prompt
|
| 165 |
+
LAST_QWEN_ERROR = errors[-3:] if errors else "unknown error"
|
| 166 |
+
return _fallback_summary_from_prompt(prompt, max_units=6)
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def _fallback_summary_from_prompt(prompt: str, max_units: int = 6) -> str:
|
| 170 |
+
"""Generate a simple fallback summary when AI is unavailable."""
|
| 171 |
+
text = prompt or ""
|
| 172 |
+
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:"]:
|
| 173 |
+
if marker in text:
|
| 174 |
+
text = text.split(marker, 1)[1]
|
| 175 |
+
break
|
| 176 |
+
text = re.sub(r"https?://\S+", "", text)
|
| 177 |
+
text = re.sub(r"\s+", " ", text).strip()
|
| 178 |
+
|
| 179 |
+
# Split into sentences
|
| 180 |
+
sentences = re.split(r"(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])", text)
|
| 181 |
+
units = []
|
| 182 |
+
for s in sentences:
|
| 183 |
+
s = _clean_text(s)
|
| 184 |
+
if len(s) >= 30:
|
| 185 |
+
units.append(s)
|
| 186 |
+
|
| 187 |
+
if units:
|
| 188 |
+
result_units = units[:max_units]
|
| 189 |
+
return "\n".join("• " + u for u in result_units)
|
| 190 |
+
if text:
|
| 191 |
+
chunks = []
|
| 192 |
+
for i in range(0, min(len(text), max_units * 300), 280):
|
| 193 |
+
chunk = _clean_text(text[i:i+300])
|
| 194 |
+
if chunk and chunk not in chunks:
|
| 195 |
+
chunks.append(chunk)
|
| 196 |
+
if len(chunks) >= max_units:
|
| 197 |
+
break
|
| 198 |
+
if chunks:
|
| 199 |
+
return "\n".join("• " + c for c in chunks)
|
| 200 |
+
return "• Không có đủ nội dung để tóm tắt."
|
| 201 |
+
|
| 202 |
+
# ===== URL scraping & article processing =====
|
| 203 |
+
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-Language": "vi-VN,vi;q=0.9,en;q=0.8"}
|
| 204 |
+
|
| 205 |
+
try:
|
| 206 |
+
_shorts_base = "/data" if os.path.isdir("/data") else os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
|
| 207 |
+
except Exception:
|
| 208 |
+
_shorts_base = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
|
| 209 |
+
SHORTS_DIR = os.path.join(_shorts_base, "ai_shorts")
|
| 210 |
+
os.makedirs(SHORTS_DIR, exist_ok=True)
|
| 211 |
+
|
| 212 |
+
import random as _random2
|
| 213 |
+
from datetime import datetime, timezone, timedelta
|
| 214 |
+
_VN_TZ = timezone(timedelta(hours=7))
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def _safe_name(filename: str) -> str:
|
| 218 |
+
"""Sanitize filename."""
|
| 219 |
+
return re.sub(r"[^a-zA-Z0-9_.-]", "_", filename)[:120]
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def pollinations_image_url(topic: str) -> str:
|
| 223 |
+
"""Generate a placeholder image URL via Pollinations."""
|
| 224 |
+
try:
|
| 225 |
+
return "https://image.pollinations.ai/prompt/" + quote("Vietnamese editorial illustration, " + topic, safe="") + "?width=1024&height=576&nologo=true"
|
| 226 |
+
except Exception:
|
| 227 |
+
return ""
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def _download_image(url: str, fallback_title: str, out_path: str) -> str:
|
| 231 |
+
"""Download an image from URL or create a placeholder."""
|
| 232 |
+
if url:
|
| 233 |
+
try:
|
| 234 |
+
r = requests.get(url, headers=HEADERS, timeout=15)
|
| 235 |
+
if r.status_code == 200 and len(r.content) > 1200:
|
| 236 |
+
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
| 237 |
+
with open(out_path, "wb") as f:
|
| 238 |
+
f.write(r.content)
|
| 239 |
+
return out_path
|
| 240 |
+
except Exception:
|
| 241 |
+
pass
|
| 242 |
+
# Fallback: create a placeholder image
|
| 243 |
+
try:
|
| 244 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 245 |
+
img = Image.new("RGB", (1080, 760), (24, 24, 24))
|
| 246 |
+
draw = ImageDraw.Draw(img)
|
| 247 |
+
try:
|
| 248 |
+
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 48)
|
| 249 |
+
except Exception:
|
| 250 |
+
font = None
|
| 251 |
+
text = (fallback_title or "VNEWS")[:40]
|
| 252 |
+
try:
|
| 253 |
+
bbox = draw.textbbox((0, 0), text, font=font)
|
| 254 |
+
tw = bbox[2] - bbox[0]
|
| 255 |
+
except Exception:
|
| 256 |
+
tw = len(text) * 24
|
| 257 |
+
draw.text(((1080 - tw) // 2, 330), text, fill=(255, 255, 255), font=font)
|
| 258 |
+
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
| 259 |
+
img.save(out_path, quality=90)
|
| 260 |
+
return out_path
|
| 261 |
+
except Exception:
|
| 262 |
+
return out_path
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def scrape_any_url(url: str) -> dict:
|
| 266 |
+
"""Scrape article content from any URL."""
|
| 267 |
+
if not url or not url.startswith("http"):
|
| 268 |
+
return {"title": "", "text": "", "summary": "", "image": "", "og_image": "", "via": ""}
|
| 269 |
+
try:
|
| 270 |
+
r = requests.get(url, headers=HEADERS, timeout=15, allow_redirects=True)
|
| 271 |
+
if r.status_code != 200 or not r.text:
|
| 272 |
+
return {"title": "", "text": "", "summary": "", "image": "", "og_image": "", "via": _domain(url)}
|
| 273 |
+
r.encoding = "utf-8"
|
| 274 |
+
soup = BeautifulSoup(r.text, "lxml")
|
| 275 |
+
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", ".breadcrumb"]):
|
| 276 |
+
tag.decompose()
|
| 277 |
+
title = ""
|
| 278 |
+
ogt = soup.find("meta", property="og:title")
|
| 279 |
+
if ogt:
|
| 280 |
+
title = ogt.get("content", "")
|
| 281 |
+
h1 = soup.find("h1")
|
| 282 |
+
if not title and h1:
|
| 283 |
+
title = h1.get_text(strip=True)
|
| 284 |
+
if not title:
|
| 285 |
+
t = soup.find("title")
|
| 286 |
+
if t:
|
| 287 |
+
title = t.get_text(strip=True)
|
| 288 |
+
og_image = ""
|
| 289 |
+
ogi = soup.find("meta", property="og:image")
|
| 290 |
+
if ogi:
|
| 291 |
+
og_image = ogi.get("content", "")
|
| 292 |
+
if og_image.startswith("//"):
|
| 293 |
+
og_image = "https:" + og_image
|
| 294 |
+
summary = ""
|
| 295 |
+
ogd = soup.find("meta", property="og:description") or soup.find("meta", attrs={"name": "description"})
|
| 296 |
+
if ogd:
|
| 297 |
+
summary = ogd.get("content", "")[:500]
|
| 298 |
+
body_text = []
|
| 299 |
+
for sel in ["article", ".singular-content", ".detail-content", ".fck_detail", ".content-detail", ".knc-content", "main", ".cms-body", ".article__body", ".post-content", ".entry-content"]:
|
| 300 |
+
el = soup.select_one(sel)
|
| 301 |
+
if el and len(el.find_all("p")) >= 2:
|
| 302 |
+
for p in el.find_all("p"):
|
| 303 |
+
t = _clean_text(p.get_text(strip=True))
|
| 304 |
+
if t and len(t) > 30:
|
| 305 |
+
body_text.append(t)
|
| 306 |
+
break
|
| 307 |
+
if not body_text and soup.body:
|
| 308 |
+
for p in soup.body.find_all("p"):
|
| 309 |
+
t = _clean_text(p.get_text(strip=True))
|
| 310 |
+
if t and len(t) > 30:
|
| 311 |
+
body_text.append(t)
|
| 312 |
+
text = "\n".join(body_text)
|
| 313 |
+
return {"title": _clean_text(title), "text": text, "summary": _clean_text(summary), "image": og_image, "og_image": og_image, "via": _domain(url), "url": url}
|
| 314 |
+
except Exception as e:
|
| 315 |
+
return {"title": "", "text": "", "summary": "", "image": "", "og_image": "", "via": _domain(url)}
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
def make_post(title: str, text: str, img: str, url: str, kind: str = "auto", sources: list = None) -> dict:
|
| 319 |
+
"""Create a wall post dict."""
|
| 320 |
+
import random as _r2
|
| 321 |
+
now = int(time.time() * 1000)
|
| 322 |
+
return {
|
| 323 |
+
"id": str(now) + str(_r2.randint(100, 999)),
|
| 324 |
+
"title": (title or "Bài viết")[:200],
|
| 325 |
+
"text": (text or "")[:5000],
|
| 326 |
+
"img": img or "",
|
| 327 |
+
"url": url or "",
|
| 328 |
+
"kind": kind or "auto",
|
| 329 |
+
"sources": sources or [],
|
| 330 |
+
"created": now,
|
| 331 |
+
"created_str": datetime.now(_VN_TZ).strftime("%H:%M %d/%m/%Y"),
|
| 332 |
+
}
|
ai_fix2.py
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os, re, subprocess, html as html_lib, json
|
| 2 |
+
from urllib.parse import quote_plus, urlparse, parse_qs, unquote
|
| 3 |
+
import requests
|
| 4 |
+
import ai_patch as prev
|
| 5 |
+
from ai_patch import app
|
| 6 |
+
from fastapi import Request
|
| 7 |
+
from fastapi.responses import JSONResponse, HTMLResponse, FileResponse
|
| 8 |
+
|
| 9 |
+
base = prev.base
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def clean(s):
|
| 13 |
+
return re.sub(r"\s+", " ", html_lib.unescape(s or "")).strip()
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _is_real_article_text(raw):
|
| 17 |
+
raw = clean(raw)
|
| 18 |
+
if len(raw) < 500:
|
| 19 |
+
return False
|
| 20 |
+
# Reject search-result/title-only pages: need several real sentences.
|
| 21 |
+
sentences = re.split(r"(?<=[\.\!\?])\s+", raw)
|
| 22 |
+
long_sentences = [s for s in sentences if len(s) > 45]
|
| 23 |
+
return len(long_sentences) >= 5
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _extract_ddg_url(href):
|
| 27 |
+
if not href:
|
| 28 |
+
return ""
|
| 29 |
+
if href.startswith("//"):
|
| 30 |
+
href = "https:" + href
|
| 31 |
+
if "duckduckgo.com/l/" in href:
|
| 32 |
+
try:
|
| 33 |
+
qs = parse_qs(urlparse(href).query)
|
| 34 |
+
if qs.get("uddg"):
|
| 35 |
+
return unquote(qs["uddg"][0])
|
| 36 |
+
except Exception:
|
| 37 |
+
pass
|
| 38 |
+
return href
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _ddg_article_urls(topic, limit=12):
|
| 42 |
+
urls = []
|
| 43 |
+
try:
|
| 44 |
+
q = quote_plus(topic + " tin tức bài viết phân tích")
|
| 45 |
+
r = requests.get("https://html.duckduckgo.com/html/?q=" + q, headers=base.HEADERS, timeout=18)
|
| 46 |
+
r.encoding = "utf-8"
|
| 47 |
+
from bs4 import BeautifulSoup
|
| 48 |
+
soup = BeautifulSoup(r.text, "lxml")
|
| 49 |
+
for a in soup.select("a.result__a"):
|
| 50 |
+
u = _extract_ddg_url(a.get("href", ""))
|
| 51 |
+
if not u.startswith("http"):
|
| 52 |
+
continue
|
| 53 |
+
if any(bad in u for bad in ["google.com", "youtube.com", "facebook.com", "x.com", "twitter.com"]):
|
| 54 |
+
continue
|
| 55 |
+
if u not in urls:
|
| 56 |
+
urls.append(u)
|
| 57 |
+
if len(urls) >= limit:
|
| 58 |
+
break
|
| 59 |
+
except Exception:
|
| 60 |
+
pass
|
| 61 |
+
return urls
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _rss_article_urls(topic, limit=10):
|
| 65 |
+
out = []
|
| 66 |
+
try:
|
| 67 |
+
url = "https://news.google.com/rss/search?q=" + quote_plus(topic) + "&hl=vi&gl=VN&ceid=VN:vi"
|
| 68 |
+
r = requests.get(url, headers=base.HEADERS, timeout=15)
|
| 69 |
+
r.encoding = "utf-8"
|
| 70 |
+
from bs4 import BeautifulSoup
|
| 71 |
+
soup = BeautifulSoup(r.text, "xml")
|
| 72 |
+
for it in soup.find_all("item")[:limit]:
|
| 73 |
+
title = it.find("title").get_text(" ", strip=True) if it.find("title") else ""
|
| 74 |
+
link = it.find("link").get_text(strip=True) if it.find("link") else ""
|
| 75 |
+
src = it.find("source").get_text(" ", strip=True) if it.find("source") else base._domain(link)
|
| 76 |
+
if title and link:
|
| 77 |
+
out.append({"title": title, "url": link, "via": src, "excerpt": title})
|
| 78 |
+
except Exception:
|
| 79 |
+
pass
|
| 80 |
+
return out
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _topic_source_articles(topic, limit=5):
|
| 84 |
+
"""Scrape actual article bodies. Do not accept title-only sources."""
|
| 85 |
+
candidates = []
|
| 86 |
+
seen = set()
|
| 87 |
+
|
| 88 |
+
# 1) DuckDuckGo actual result URLs are usually more directly scrapable.
|
| 89 |
+
for u in _ddg_article_urls(topic, limit=14):
|
| 90 |
+
if u not in seen:
|
| 91 |
+
seen.add(u)
|
| 92 |
+
candidates.append({"url": u, "title": "", "via": base._domain(u)})
|
| 93 |
+
|
| 94 |
+
# 2) Add base web_context sources.
|
| 95 |
+
try:
|
| 96 |
+
_ctx, srcs = base.web_context(topic, limit=8)
|
| 97 |
+
for s in srcs or []:
|
| 98 |
+
u = s.get("url") or ""
|
| 99 |
+
if u.startswith("http") and u not in seen:
|
| 100 |
+
seen.add(u)
|
| 101 |
+
candidates.append(s)
|
| 102 |
+
except Exception:
|
| 103 |
+
pass
|
| 104 |
+
|
| 105 |
+
# 3) Google News RSS fallback last.
|
| 106 |
+
for s in _rss_article_urls(topic, limit=10):
|
| 107 |
+
u = s.get("url") or ""
|
| 108 |
+
if u.startswith("http") and u not in seen:
|
| 109 |
+
seen.add(u)
|
| 110 |
+
candidates.append(s)
|
| 111 |
+
|
| 112 |
+
out = []
|
| 113 |
+
for s in candidates[:24]:
|
| 114 |
+
url = s.get("url") or ""
|
| 115 |
+
try:
|
| 116 |
+
page = base.scrape_any_url(url)
|
| 117 |
+
raw = (page.get("summary", "") + "\n" + page.get("text", "")).strip()
|
| 118 |
+
if not _is_real_article_text(raw):
|
| 119 |
+
continue
|
| 120 |
+
title = page.get("title") or s.get("title") or url
|
| 121 |
+
via = page.get("via") or s.get("via") or base._domain(url)
|
| 122 |
+
out.append({
|
| 123 |
+
"title": title,
|
| 124 |
+
"url": url,
|
| 125 |
+
"raw": raw,
|
| 126 |
+
"image": page.get("image") or "",
|
| 127 |
+
"via": via,
|
| 128 |
+
"source": {"title": title, "url": url, "excerpt": raw[:700], "via": via}
|
| 129 |
+
})
|
| 130 |
+
if len(out) >= limit:
|
| 131 |
+
break
|
| 132 |
+
except Exception:
|
| 133 |
+
continue
|
| 134 |
+
return out[:limit]
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def sentence_split(text):
|
| 138 |
+
text = re.sub(r"^[•\-\*]\s*", "", text or "", flags=re.M)
|
| 139 |
+
text = re.sub(r"\n+", ". ", text)
|
| 140 |
+
parts = []
|
| 141 |
+
for s in re.split(r"(?<=[\.\!\?])\s+", text):
|
| 142 |
+
s = clean(s)
|
| 143 |
+
if len(s) >= 8:
|
| 144 |
+
parts.append(s)
|
| 145 |
+
return parts
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def srt_time(sec):
|
| 149 |
+
ms = int((sec - int(sec)) * 1000)
|
| 150 |
+
sec = int(sec)
|
| 151 |
+
return f"{sec//3600:02d}:{(sec%3600)//60:02d}:{sec%60:02d},{ms:03d}"
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def parse_timecode(t):
|
| 155 |
+
# 00:00:01.234 or 00:00:01,234
|
| 156 |
+
t = t.replace(',', '.')
|
| 157 |
+
parts = t.split(':')
|
| 158 |
+
if len(parts) == 3:
|
| 159 |
+
return int(parts[0])*3600 + int(parts[1])*60 + float(parts[2])
|
| 160 |
+
if len(parts) == 2:
|
| 161 |
+
return int(parts[0])*60 + float(parts[1])
|
| 162 |
+
return float(parts[0])
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def convert_vtt_to_scaled_srt(vtt_path, srt_path, speed=1.2):
|
| 166 |
+
try:
|
| 167 |
+
txt = open(vtt_path, 'r', encoding='utf-8').read().splitlines()
|
| 168 |
+
cues = []
|
| 169 |
+
i = 0
|
| 170 |
+
while i < len(txt):
|
| 171 |
+
line = txt[i].strip()
|
| 172 |
+
if '-->' in line:
|
| 173 |
+
a, b = [x.strip().split()[0] for x in line.split('-->')[:2]]
|
| 174 |
+
start = parse_timecode(a) / speed
|
| 175 |
+
end = parse_timecode(b) / speed
|
| 176 |
+
i += 1
|
| 177 |
+
texts = []
|
| 178 |
+
while i < len(txt) and txt[i].strip():
|
| 179 |
+
texts.append(txt[i].strip())
|
| 180 |
+
i += 1
|
| 181 |
+
s = clean(' '.join(texts))
|
| 182 |
+
if s:
|
| 183 |
+
cues.append((start, end, s))
|
| 184 |
+
i += 1
|
| 185 |
+
if not cues:
|
| 186 |
+
return False
|
| 187 |
+
with open(srt_path, 'w', encoding='utf-8') as f:
|
| 188 |
+
for idx, (st, en, s) in enumerate(cues, 1):
|
| 189 |
+
if en <= st:
|
| 190 |
+
en = st + 1.2
|
| 191 |
+
f.write(f"{idx}\n{srt_time(st)} --> {srt_time(en)}\n{s}\n\n")
|
| 192 |
+
return True
|
| 193 |
+
except Exception:
|
| 194 |
+
return False
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def write_weighted_srt(script, path, total_duration):
|
| 198 |
+
subs = sentence_split(script)
|
| 199 |
+
if not subs:
|
| 200 |
+
subs = [clean(script)[:140] or "VNEWS"]
|
| 201 |
+
total_chars = max(1, sum(len(x) for x in subs))
|
| 202 |
+
usable = max(2.0, float(total_duration) - 1.0)
|
| 203 |
+
cur = 0.5
|
| 204 |
+
with open(path, "w", encoding="utf-8") as f:
|
| 205 |
+
for i, s in enumerate(subs, 1):
|
| 206 |
+
dur = max(1.8, min(7.0, usable * len(s) / total_chars))
|
| 207 |
+
start = cur
|
| 208 |
+
end = min(total_duration - 0.15, cur + dur)
|
| 209 |
+
cur = end + 0.18
|
| 210 |
+
f.write(f"{i}\n{srt_time(start)} --> {srt_time(end)}\n{s}\n\n")
|
| 211 |
+
if cur >= total_duration - 0.2:
|
| 212 |
+
break
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def tts_script_full(post, emotion):
|
| 216 |
+
title = clean(post.get("title", ""))
|
| 217 |
+
text = clean(post.get("text", ""))
|
| 218 |
+
text = re.sub(r"Nguồn tham khảo:.*", "", text, flags=re.S).strip()
|
| 219 |
+
prefix = {
|
| 220 |
+
"urgent": "Tin nhanh.",
|
| 221 |
+
"warm": "Câu chuyện đáng chú ý.",
|
| 222 |
+
"serious": "Bản tin nghiêm túc.",
|
| 223 |
+
"energetic": "Cập nhật nổi bật.",
|
| 224 |
+
}.get(emotion, "")
|
| 225 |
+
script = f"{prefix} {title}. {text}".strip()
|
| 226 |
+
# Keep complete wall summary. Only trim pathological payloads, on sentence boundary.
|
| 227 |
+
if len(script) > 3600:
|
| 228 |
+
tmp = script[:3600]
|
| 229 |
+
cut = max(tmp.rfind("."), tmp.rfind("!"), tmp.rfind("?"))
|
| 230 |
+
script = tmp[:cut + 1] if cut > 1600 else tmp
|
| 231 |
+
script = re.sub(r"([\.\!\?])\s*", r"\1\n", script)
|
| 232 |
+
script = re.sub(r"\n{2,}", "\n", script).strip()
|
| 233 |
+
return script
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
_PATCH = {('/api/topic_post','POST'),('/api/ai/short/{post_id}','POST'),('/api/ai/short-file/{file_id}','GET'),('/','GET')}
|
| 237 |
+
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)]
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
@app.post('/api/topic_post')
|
| 241 |
+
async def topic_post_aggregate(request: Request):
|
| 242 |
+
body = await request.json()
|
| 243 |
+
topic = base._clean_text(body.get('topic',''))
|
| 244 |
+
if not topic:
|
| 245 |
+
return JSONResponse({'error':'missing topic'}, status_code=400)
|
| 246 |
+
articles = _topic_source_articles(topic, limit=5)
|
| 247 |
+
if not articles:
|
| 248 |
+
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)
|
| 249 |
+
source_blocks = []
|
| 250 |
+
sources = []
|
| 251 |
+
image = ""
|
| 252 |
+
for i, art in enumerate(articles, 1):
|
| 253 |
+
raw = art.get('raw','')
|
| 254 |
+
source_blocks.append(f"[Nguồn {i}] {art.get('title','')} ({art.get('via','')})\n{raw[:3000]}")
|
| 255 |
+
sources.append(art.get('source') or {'title': art.get('title'), 'url': art.get('url'), 'via': art.get('via'), 'excerpt': raw[:600]})
|
| 256 |
+
if not image and art.get('image'):
|
| 257 |
+
image = art.get('image')
|
| 258 |
+
ctx = "\n\n".join(source_blocks)
|
| 259 |
+
prompt = f"""Bạn là biên tập viên tổng hợp tin tức tiếng Việt.
|
| 260 |
+
|
| 261 |
+
Chủ đề: {topic}
|
| 262 |
+
|
| 263 |
+
NHIỆM VỤ:
|
| 264 |
+
- Đọc nội dung của TẤT CẢ các bài nguồn bên dưới.
|
| 265 |
+
- 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.
|
| 266 |
+
- Không tạo mỗi tiêu đề thành một bài riêng.
|
| 267 |
+
- Không chỉ liệt kê tiêu đề; phải dựa vào nội dung trong từng bài.
|
| 268 |
+
- Không lặp ý giữa các nguồn.
|
| 269 |
+
- Tối đa 6 gạch đầu dòng, mỗi dòng 1 câu rõ ràng.
|
| 270 |
+
- Nếu các nguồn có góc nhìn khác nhau, gộp lại thành ý tổng hợp.
|
| 271 |
+
- Cuối cùng thêm dòng: Nguồn tham khảo: tên website.
|
| 272 |
+
|
| 273 |
+
Nội dung nguồn:
|
| 274 |
+
{ctx[:16000]}"""
|
| 275 |
+
text = await prev.base.qwen_generate(prompt, image_url=image or None, max_tokens=1100)
|
| 276 |
+
text = prev._postprocess_ai_text(text, max_units=7)
|
| 277 |
+
if 'Nguồn tham khảo:' not in text:
|
| 278 |
+
text += '\n\n' + prev._source_line(sources)
|
| 279 |
+
post = base.make_post('Tổng hợp: ' + topic, text, image or base.pollinations_image_url(topic), '', 'topic_aggregate', sources=sources[:5])
|
| 280 |
+
posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts)
|
| 281 |
+
return JSONResponse({'post': post, 'count_sources': len(sources)})
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
@app.post('/api/ai/short/{post_id}')
|
| 285 |
+
async def ai_short_full(post_id: str, request: Request):
|
| 286 |
+
try:
|
| 287 |
+
body = await request.json()
|
| 288 |
+
except Exception:
|
| 289 |
+
body = {}
|
| 290 |
+
voice = str(body.get('voice','nu')).lower().strip()
|
| 291 |
+
emotion = str(body.get('emotion','neutral')).lower().strip()
|
| 292 |
+
speed = max(0.85, min(1.35, float(body.get('speed', 1.2) or 1.2)))
|
| 293 |
+
posts = base._load_ai_wall()
|
| 294 |
+
post = next((p for p in posts if str(p.get('id')) == str(post_id)), None)
|
| 295 |
+
if not post:
|
| 296 |
+
return JSONResponse({'error':'post not found'}, status_code=404)
|
| 297 |
+
os.makedirs(base.SHORTS_DIR, exist_ok=True)
|
| 298 |
+
suffix = f"_{voice}_{emotion}_{str(speed).replace('.', 'p')}_fullv2"
|
| 299 |
+
out_mp4 = os.path.join(base.SHORTS_DIR, base._safe_name(post_id + suffix) + '.mp4')
|
| 300 |
+
if os.path.exists(out_mp4):
|
| 301 |
+
post['video'] = '/api/ai/short-file/' + post_id + suffix
|
| 302 |
+
base._save_ai_wall(posts)
|
| 303 |
+
return JSONResponse({'video': post['video'], 'speed': speed, 'subtitles': True})
|
| 304 |
+
work = os.path.join(base.SHORTS_DIR, base._safe_name(post_id + suffix)); os.makedirs(work, exist_ok=True)
|
| 305 |
+
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')
|
| 306 |
+
try:
|
| 307 |
+
base._download_image(post.get('img'), post.get('title','AI news'), img)
|
| 308 |
+
prev._make_short_frame_full(post, img, frame)
|
| 309 |
+
script = tts_script_full(post, emotion)
|
| 310 |
+
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')
|
| 311 |
+
used_edge = False
|
| 312 |
+
try:
|
| 313 |
+
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)
|
| 314 |
+
used_edge = True
|
| 315 |
+
except Exception:
|
| 316 |
+
tld = 'com.vn' if voice in ('nu','female','mien-nam') else 'com'
|
| 317 |
+
try:
|
| 318 |
+
base.gTTS(script, lang='vi', tld=tld, slow=False).save(audio)
|
| 319 |
+
except TypeError:
|
| 320 |
+
base.gTTS(script, lang='vi', slow=False).save(audio)
|
| 321 |
+
subprocess.run(['ffmpeg','-y','-i',audio,'-filter:a',f'atempo={speed}','-vn',audio_fast], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=220)
|
| 322 |
+
duration = 45.0
|
| 323 |
+
try:
|
| 324 |
+
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)
|
| 325 |
+
duration = float((pr.stdout or b'45').decode().strip() or 45)
|
| 326 |
+
except Exception:
|
| 327 |
+
pass
|
| 328 |
+
if used_edge and os.path.exists(vtt):
|
| 329 |
+
ok = convert_vtt_to_scaled_srt(vtt, srt, speed=speed)
|
| 330 |
+
if not ok:
|
| 331 |
+
write_weighted_srt(script, srt, duration)
|
| 332 |
+
else:
|
| 333 |
+
write_weighted_srt(script, srt, duration)
|
| 334 |
+
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("'", "\\'"))
|
| 335 |
+
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]
|
| 336 |
+
subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=420)
|
| 337 |
+
post['video'] = '/api/ai/short-file/' + post_id + suffix
|
| 338 |
+
post['short_voice'] = voice; post['short_emotion'] = emotion; post['short_speed'] = speed; post['short_subtitles'] = True
|
| 339 |
+
base._save_ai_wall(posts)
|
| 340 |
+
return JSONResponse({'video': post['video'], 'voice': voice, 'emotion': emotion, 'speed': speed, 'subtitles': True, 'duration': duration})
|
| 341 |
+
except Exception as e:
|
| 342 |
+
return JSONResponse({'error':'Không tạo được shorts: '+str(e)[:180]}, status_code=500)
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
@app.get('/api/ai/short-file/{file_id}')
|
| 346 |
+
def ai_short_file_full(file_id: str):
|
| 347 |
+
path = os.path.join(base.SHORTS_DIR, base._safe_name(file_id) + '.mp4')
|
| 348 |
+
if not os.path.exists(path):
|
| 349 |
+
return JSONResponse({'error':'not found'}, status_code=404)
|
| 350 |
+
return FileResponse(path, media_type='video/mp4', filename=f'vnews-ai-{file_id}.mp4')
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
app.router.routes = [r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
|
| 354 |
+
|
| 355 |
+
@app.get('/')
|
| 356 |
+
async def index_fix2():
|
| 357 |
+
with open('/app/static/index.html','r',encoding='utf-8') as f:
|
| 358 |
+
html = f.read()
|
| 359 |
+
inject = prev.PATCH_INJECT + r'''
|
| 360 |
+
<script>
|
| 361 |
+
(function(){
|
| 362 |
+
window.createTopicPost=function(){let inp=document.getElementById('ai-topic-input');let topic=(inp&&inp.value||'').trim();if(!topic)return alert('Nhập chủ đề trước');fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})}).then(r=>r.json().then(j=>({ok:r.ok,j}))).then(({ok,j})=>{if(ok&&j.post){window.location.reload();alert('Đã tổng hợp NỘI DUNG các bài nguồn thành 1 bản tóm tắt trên Tường AI');}else alert(j.error||'Lỗi tạo bài')}).catch(e=>alert(e.message||'Lỗi tạo bài'));};
|
| 363 |
+
})();
|
| 364 |
+
</script>
|
| 365 |
+
'''
|
| 366 |
+
return HTMLResponse(html.replace('</body>', inject+'\n</body>'))
|
ai_patch.py
ADDED
|
@@ -0,0 +1,917 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import re
|
| 3 |
+
import time
|
| 4 |
+
import random
|
| 5 |
+
import json
|
| 6 |
+
import html as html_lib
|
| 7 |
+
import subprocess
|
| 8 |
+
import requests
|
| 9 |
+
import hashlib
|
| 10 |
+
import ai_ext as base
|
| 11 |
+
from ai_ext import app
|
| 12 |
+
from fastapi import Request
|
| 13 |
+
from fastapi.responses import JSONResponse, HTMLResponse, FileResponse
|
| 14 |
+
from bs4 import BeautifulSoup
|
| 15 |
+
from urllib.parse import quote_plus
|
| 16 |
+
|
| 17 |
+
try:
|
| 18 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 19 |
+
except Exception:
|
| 20 |
+
Image = ImageDraw = ImageFont = None
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _clean(s):
|
| 24 |
+
s = html_lib.unescape(s or "")
|
| 25 |
+
s = re.sub(r"[ \t]+", " ", s)
|
| 26 |
+
s = re.sub(r"\n{3,}", "\n\n", s)
|
| 27 |
+
return s.strip()
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _norm(s):
|
| 31 |
+
s = s.lower()
|
| 32 |
+
s = re.sub(r"[^\wÀ-ỹ\s]", " ", s)
|
| 33 |
+
s = re.sub(r"\s+", " ", s).strip()
|
| 34 |
+
return s
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _similar(a, b):
|
| 38 |
+
ta = set(_norm(a).split())
|
| 39 |
+
tb = set(_norm(b).split())
|
| 40 |
+
if not ta or not tb:
|
| 41 |
+
return False
|
| 42 |
+
return len(ta & tb) / max(1, min(len(ta), len(tb))) >= 0.72
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _dedupe_units(units, max_units=25):
|
| 46 |
+
"""Deduplicate units - only skip exact matches to ensure all bullet points are read."""
|
| 47 |
+
out, seen = [], set()
|
| 48 |
+
for u in units:
|
| 49 |
+
u = _clean(re.sub(r"^[-•*\d\.\)\s]+", "", u))
|
| 50 |
+
if len(u) < 18:
|
| 51 |
+
continue
|
| 52 |
+
nu = _norm(u)
|
| 53 |
+
# Only skip exact matches, NOT similar content (to avoid skipping valid bullet points)
|
| 54 |
+
if nu in seen:
|
| 55 |
+
continue
|
| 56 |
+
seen.add(nu)
|
| 57 |
+
out.append(u)
|
| 58 |
+
if len(out) >= max_units:
|
| 59 |
+
break
|
| 60 |
+
return out
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _postprocess_ai_text(text, max_units=20):
|
| 64 |
+
text = _clean(text)
|
| 65 |
+
if not text:
|
| 66 |
+
return text
|
| 67 |
+
drop_prefixes = (
|
| 68 |
+
"dưới đây", "sau đây", "bài viết", "tôi sẽ", "mình sẽ",
|
| 69 |
+
"tóm tắt bài", "tiêu đề:", "sapo:", "nội dung:", "kết luận:"
|
| 70 |
+
)
|
| 71 |
+
raw_lines = []
|
| 72 |
+
for line in re.split(r"\n+", text):
|
| 73 |
+
line = _clean(line)
|
| 74 |
+
if not line:
|
| 75 |
+
continue
|
| 76 |
+
low = line.lower().strip()
|
| 77 |
+
if any(low.startswith(p) and len(line) < 80 for p in drop_prefixes):
|
| 78 |
+
continue
|
| 79 |
+
raw_lines.append(line)
|
| 80 |
+
units = []
|
| 81 |
+
for line in raw_lines:
|
| 82 |
+
# KEEP FULL bullet point - don't truncate or split into segments
|
| 83 |
+
if len(line) >= 18:
|
| 84 |
+
units.append(_clean(re.sub(r"^[-•*\d\.\)\s]+", "", line)))
|
| 85 |
+
units = _dedupe_units(units, max_units=max_units)
|
| 86 |
+
if not units:
|
| 87 |
+
return text[:900]
|
| 88 |
+
title = ""
|
| 89 |
+
if raw_lines and len(raw_lines[0]) <= 90 and not raw_lines[0].startswith(("-", "•", "*")):
|
| 90 |
+
title = raw_lines[0]
|
| 91 |
+
units = [u for u in units if not _similar(u, title)]
|
| 92 |
+
body = "\n".join("• " + u for u in units[:max_units])
|
| 93 |
+
return (title + "\n\n" + body).strip() if title else body
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _fallback_summary_from_prompt(prompt, max_units=6):
|
| 97 |
+
text = prompt or ""
|
| 98 |
+
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:"]:
|
| 99 |
+
if marker in text:
|
| 100 |
+
text = text.split(marker, 1)[1]
|
| 101 |
+
break
|
| 102 |
+
text = re.sub(r"https?://\S+", "", text)
|
| 103 |
+
text = re.sub(r"\s+", " ", text).strip()
|
| 104 |
+
sentences = re.split(r"(?<=[\.\!\?])\s+(?=[A-ZÀ-Ỹ0-9])", text)
|
| 105 |
+
candidates = []
|
| 106 |
+
for s in sentences:
|
| 107 |
+
s = _clean(s)
|
| 108 |
+
if 45 <= len(s) <= 260:
|
| 109 |
+
candidates.append(s)
|
| 110 |
+
units = _dedupe_units(candidates, max_units=max_units)
|
| 111 |
+
if units:
|
| 112 |
+
return "\n".join("• " + u for u in units)
|
| 113 |
+
if text:
|
| 114 |
+
return "• " + text[:700].rsplit(" ", 1)[0]
|
| 115 |
+
return "• Không có đủ nội dung nguồn để tóm tắt."
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def _source_line(sources):
|
| 119 |
+
names = []
|
| 120 |
+
for s in (sources or [])[:5]:
|
| 121 |
+
via = s.get("via") or base._domain(s.get("url", "")) or s.get("title", "")
|
| 122 |
+
if via and via not in names:
|
| 123 |
+
names.append(via)
|
| 124 |
+
return "Nguồn tham khảo: " + ", ".join(names[:5]) if names else "Nguồn tham khảo: tổng hợp internet"
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def _make_summary_prompt(title, raw, source_hint=""):
|
| 128 |
+
return f"""Bạn là biên tập viên tóm tắt tin tức tiếng Việt.
|
| 129 |
+
|
| 130 |
+
NHIỆM VỤ BẮT BUỘC:
|
| 131 |
+
- Chỉ TÓM TẮT nội dung chính, KHÔNG viết lại toàn bộ bài.
|
| 132 |
+
- Không lặp lại cùng một ý, cùng một câu, cùng một chi tiết.
|
| 133 |
+
- Không thêm thông tin ngoài nguồn.
|
| 134 |
+
- Tối đa 5 gạch đầu dòng, mỗi gạch đầu dòng 1 câu ngắn.
|
| 135 |
+
- Nếu bài có số liệu/nhân vật/thời điểm quan trọng thì giữ lại.
|
| 136 |
+
- Không viết phần mở bài dài, không viết văn kể lại.
|
| 137 |
+
|
| 138 |
+
Tiêu đề nguồn: {title}
|
| 139 |
+
Nguồn: {source_hint}
|
| 140 |
+
|
| 141 |
+
Nội dung nguồn:
|
| 142 |
+
{raw[:14000]}
|
| 143 |
+
"""
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _direct_news_rss(topic, limit=10):
|
| 147 |
+
out = []
|
| 148 |
+
try:
|
| 149 |
+
url = "https://news.google.com/rss/search?q=" + quote_plus(topic) + "&hl=vi&gl=VN&ceid=VN:vi"
|
| 150 |
+
r = requests.get(url, headers=base.HEADERS, timeout=15)
|
| 151 |
+
r.encoding = "utf-8"
|
| 152 |
+
soup = BeautifulSoup(r.text, "xml")
|
| 153 |
+
for it in soup.find_all("item")[:limit]:
|
| 154 |
+
title = it.find("title").get_text(" ", strip=True) if it.find("title") else ""
|
| 155 |
+
link = it.find("link").get_text(strip=True) if it.find("link") else ""
|
| 156 |
+
src = it.find("source").get_text(" ", strip=True) if it.find("source") else base._domain(link)
|
| 157 |
+
if title and link:
|
| 158 |
+
out.append({"title": title, "url": link, "via": src, "excerpt": title})
|
| 159 |
+
except Exception:
|
| 160 |
+
pass
|
| 161 |
+
return out
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def _topic_source_articles(topic, limit=5):
|
| 165 |
+
"""Return actual scraped article bodies for a topic. Each source becomes one Wall AI post."""
|
| 166 |
+
try:
|
| 167 |
+
_ctx, sources = base.web_context(topic, limit=limit)
|
| 168 |
+
except Exception:
|
| 169 |
+
sources = []
|
| 170 |
+
if not sources:
|
| 171 |
+
sources = _direct_news_rss(topic, limit=10)
|
| 172 |
+
out, seen = [], set()
|
| 173 |
+
for s in (sources or [])[:limit * 3]:
|
| 174 |
+
url = s.get("url") or ""
|
| 175 |
+
if not url.startswith("http") or url in seen:
|
| 176 |
+
continue
|
| 177 |
+
seen.add(url)
|
| 178 |
+
try:
|
| 179 |
+
page = base.scrape_any_url(url)
|
| 180 |
+
raw = (page.get("summary", "") + "\n" + page.get("text", "")).strip()
|
| 181 |
+
if len(raw) < 180:
|
| 182 |
+
continue
|
| 183 |
+
title = page.get("title") or s.get("title") or url
|
| 184 |
+
via = page.get("via") or s.get("via") or base._domain(url)
|
| 185 |
+
out.append({
|
| 186 |
+
"title": title,
|
| 187 |
+
"url": url,
|
| 188 |
+
"raw": raw,
|
| 189 |
+
"image": page.get("image") or "",
|
| 190 |
+
"via": via,
|
| 191 |
+
"source": {"title": title, "url": url, "excerpt": raw[:700], "via": via}
|
| 192 |
+
})
|
| 193 |
+
if len(out) >= limit:
|
| 194 |
+
break
|
| 195 |
+
except Exception:
|
| 196 |
+
continue
|
| 197 |
+
if not out:
|
| 198 |
+
for s in (sources or _direct_news_rss(topic, 6))[:limit]:
|
| 199 |
+
title = s.get("title") or topic
|
| 200 |
+
excerpt = s.get("excerpt") or s.get("description") or s.get("content") or title
|
| 201 |
+
url = s.get("url", "")
|
| 202 |
+
via = s.get("via") or base._domain(url)
|
| 203 |
+
out.append({
|
| 204 |
+
"title": title,
|
| 205 |
+
"url": url,
|
| 206 |
+
"raw": excerpt,
|
| 207 |
+
"image": base.pollinations_image_url(title),
|
| 208 |
+
"via": via,
|
| 209 |
+
"source": {"title": title, "url": url, "excerpt": excerpt[:700], "via": via}
|
| 210 |
+
})
|
| 211 |
+
return out[:limit]
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
async def qwen_generate_resilient(prompt: str, image_url=None, max_tokens: int = 1200):
|
| 215 |
+
errors = []
|
| 216 |
+
token = base._hf_token()
|
| 217 |
+
try:
|
| 218 |
+
original = getattr(base, "_original_qwen_generate", None)
|
| 219 |
+
if original:
|
| 220 |
+
txt = await original(prompt, image_url=image_url, max_tokens=max_tokens)
|
| 221 |
+
if txt:
|
| 222 |
+
base.LAST_QWEN_ERROR = ""
|
| 223 |
+
return txt
|
| 224 |
+
if getattr(base, "LAST_QWEN_ERROR", ""):
|
| 225 |
+
errors.append("sdk: " + str(base.LAST_QWEN_ERROR)[:260])
|
| 226 |
+
except Exception as e:
|
| 227 |
+
errors.append(f"sdk: {type(e).__name__}: {str(e)[:260]}")
|
| 228 |
+
if token:
|
| 229 |
+
models = []
|
| 230 |
+
for m in [
|
| 231 |
+
os.getenv("QWEN_VL_MODEL", ""),
|
| 232 |
+
"Qwen/Qwen2.5-VL-7B-Instruct",
|
| 233 |
+
"Qwen/Qwen2.5-VL-3B-Instruct",
|
| 234 |
+
"Qwen/Qwen2.5-7B-Instruct",
|
| 235 |
+
"Qwen/Qwen2.5-3B-Instruct",
|
| 236 |
+
"Qwen/Qwen2.5-1.5B-Instruct",
|
| 237 |
+
]:
|
| 238 |
+
if m and m not in models:
|
| 239 |
+
models.append(m)
|
| 240 |
+
headers = {"Authorization": "Bearer " + token, "Content-Type": "application/json"}
|
| 241 |
+
for model in models:
|
| 242 |
+
try:
|
| 243 |
+
is_vl = "VL" in model and bool(image_url)
|
| 244 |
+
user_content = ([{"type": "image_url", "image_url": {"url": image_url}}, {"type": "text", "text": prompt}] if is_vl else prompt)
|
| 245 |
+
payload = {
|
| 246 |
+
"model": model,
|
| 247 |
+
"messages": [
|
| 248 |
+
{"role": "system", "content": "Bạn là biên tập viên AI tiếng Việt. Chỉ tóm tắt súc tích nội dung nguồn, không viết lại toàn bài, không lặp ý, không bịa chi tiết."},
|
| 249 |
+
{"role": "user", "content": user_content},
|
| 250 |
+
],
|
| 251 |
+
"max_tokens": min(int(max_tokens or 900), 1400),
|
| 252 |
+
"temperature": 0.35,
|
| 253 |
+
"top_p": 0.85,
|
| 254 |
+
}
|
| 255 |
+
r = requests.post("https://router.huggingface.co/v1/chat/completions", headers=headers, json=payload, timeout=95)
|
| 256 |
+
if r.status_code >= 300:
|
| 257 |
+
errors.append(f"{model}: HTTP {r.status_code} {r.text[:180]}")
|
| 258 |
+
continue
|
| 259 |
+
j = r.json()
|
| 260 |
+
txt = (j.get("choices", [{}])[0].get("message", {}).get("content") or "").strip()
|
| 261 |
+
if txt:
|
| 262 |
+
base.LAST_QWEN_ERROR = ""
|
| 263 |
+
return txt
|
| 264 |
+
errors.append(f"{model}: empty response")
|
| 265 |
+
except Exception as e:
|
| 266 |
+
errors.append(f"{model}: {type(e).__name__}: {str(e)[:220]}")
|
| 267 |
+
else:
|
| 268 |
+
errors.append("missing HF_TOKEN")
|
| 269 |
+
base.LAST_QWEN_ERROR = " | ".join(errors[-6:]) or "Qwen unavailable; used extractive fallback"
|
| 270 |
+
print("[qwen resilient fallback]", base.LAST_QWEN_ERROR)
|
| 271 |
+
return _fallback_summary_from_prompt(prompt, max_units=12)
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
if not hasattr(base, "_original_qwen_generate"):
|
| 275 |
+
base._original_qwen_generate = base.qwen_generate
|
| 276 |
+
base.qwen_generate = qwen_generate_resilient
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
@app.get('/api/wall')
|
| 280 |
+
def compat_wall():
|
| 281 |
+
return JSONResponse({'posts': base._load_ai_wall()[:80]})
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
_PATCHED_PATHS = {
|
| 285 |
+
('/api/topic_post', 'POST'),
|
| 286 |
+
('/api/url_wall', 'POST'),
|
| 287 |
+
('/api/rewrite_share', 'POST'),
|
| 288 |
+
('/api/ai/short/{post_id}', 'POST'),
|
| 289 |
+
}
|
| 290 |
+
app.router.routes = [
|
| 291 |
+
r for r in app.router.routes
|
| 292 |
+
if not any(getattr(r, 'path', None) == p and m in getattr(r, 'methods', set()) for p, m in _PATCHED_PATHS)
|
| 293 |
+
]
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
@app.post('/api/topic_post')
|
| 297 |
+
async def compat_topic_post(request: Request):
|
| 298 |
+
body = await request.json()
|
| 299 |
+
topic = base._clean_text(body.get('topic', ''))
|
| 300 |
+
if not topic:
|
| 301 |
+
return JSONResponse({'error': 'missing topic'}, status_code=400)
|
| 302 |
+
articles = _topic_source_articles(topic, limit=4)
|
| 303 |
+
if not articles:
|
| 304 |
+
return JSONResponse({'error': 'Không lấy được bài viết nguồn cho chủ đề này.'}, status_code=422)
|
| 305 |
+
new_posts = []
|
| 306 |
+
posts = base._load_ai_wall()
|
| 307 |
+
for art in articles:
|
| 308 |
+
prompt = f"""Tóm tắt RIÊNG bài viết nguồn sau để đăng Tường AI.
|
| 309 |
+
|
| 310 |
+
Chủ đề lọc: {topic}
|
| 311 |
+
Tiêu đề bài nguồn: {art['title']}
|
| 312 |
+
Nguồn: {art['via']}
|
| 313 |
+
|
| 314 |
+
Yêu cầu bắt buộc:
|
| 315 |
+
- Tóm tắt nội dung trong BÀI VIẾT này, không chỉ tiêu đề.
|
| 316 |
+
- Không trộn với bài khác.
|
| 317 |
+
- Không viết lại toàn bộ bài.
|
| 318 |
+
- Không lặp ý.
|
| 319 |
+
- 4-6 gạch đầu dòng, mỗi dòng 1 câu rõ ràng.
|
| 320 |
+
- Giữ số liệu/nhân vật/thời điểm quan trọng nếu có.
|
| 321 |
+
|
| 322 |
+
Nội dung bài:
|
| 323 |
+
{art['raw'][:14000]}"""
|
| 324 |
+
text = await base.qwen_generate(prompt, image_url=art.get('image') or None, max_tokens=1500)
|
| 325 |
+
text = _postprocess_ai_text(text, max_units=20)
|
| 326 |
+
src = [art['source']]
|
| 327 |
+
if 'Nguồn tham khảo:' not in text:
|
| 328 |
+
text += "\n\n" + _source_line(src)
|
| 329 |
+
post = base.make_post(art['title'], text, art.get('image') or base.pollinations_image_url(art['title']), art.get('url') or '', 'topic_article', sources=src)
|
| 330 |
+
|
| 331 |
+
# Generate slides for this post so they persist after page reload
|
| 332 |
+
try:
|
| 333 |
+
page_data = _scrape_article_images(art.get('url', ''))
|
| 334 |
+
if page_data and page_data.get('paragraphs'):
|
| 335 |
+
key_points = _extract_key_points_for_slides(page_data['paragraphs'], max_points=12)
|
| 336 |
+
if key_points:
|
| 337 |
+
relevant_imgs = page_data.get('images', [])
|
| 338 |
+
if not relevant_imgs and page_data.get('og_img'):
|
| 339 |
+
relevant_imgs = [page_data['og_img']]
|
| 340 |
+
slides = []
|
| 341 |
+
for i, point in enumerate(key_points):
|
| 342 |
+
img = relevant_imgs[i] if i < len(relevant_imgs) else (relevant_imgs[-1] if relevant_imgs else '')
|
| 343 |
+
slides.append({'text': point, 'image': img, 'index': i + 1})
|
| 344 |
+
post['slides'] = slides
|
| 345 |
+
except Exception:
|
| 346 |
+
pass
|
| 347 |
+
|
| 348 |
+
new_posts.append(post)
|
| 349 |
+
posts = new_posts + posts
|
| 350 |
+
base._save_ai_wall(posts)
|
| 351 |
+
return JSONResponse({'post': new_posts[0], 'posts': new_posts, 'count': len(new_posts)})
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
@app.post('/api/url_wall')
|
| 355 |
+
async def compat_url_wall(request: Request):
|
| 356 |
+
body = await request.json()
|
| 357 |
+
url = base._clean_text(body.get('url', ''))
|
| 358 |
+
if not url.startswith('http'):
|
| 359 |
+
return JSONResponse({'error': 'missing url'}, status_code=400)
|
| 360 |
+
try:
|
| 361 |
+
data = base.scrape_any_url(url)
|
| 362 |
+
except Exception as e:
|
| 363 |
+
return JSONResponse({'error': 'Không scrape được URL: ' + str(e)[:180]}, status_code=422)
|
| 364 |
+
raw = (data.get('summary', '') + '\n' + data.get('text', '')).strip()
|
| 365 |
+
if len(raw) < 120:
|
| 366 |
+
return JSONResponse({'error': 'URL không có đủ nội dung để tóm tắt'}, status_code=422)
|
| 367 |
+
prompt = _make_summary_prompt(data.get('title', ''), raw, data.get('via', '') or base._domain(url))
|
| 368 |
+
text = await base.qwen_generate(prompt, image_url=data.get('image') or None, max_tokens=1500)
|
| 369 |
+
text = _postprocess_ai_text(text, max_units=20)
|
| 370 |
+
src = [{'title': data.get('title'), 'url': url, 'excerpt': raw[:500], 'via': data.get('via') or base._domain(url)}]
|
| 371 |
+
if 'Nguồn tham khảo:' not in text:
|
| 372 |
+
text += "\n\n" + _source_line(src)
|
| 373 |
+
post = base.make_post(data.get('title') or 'Bài viết', text, data.get('image') or '', url, 'url', sources=src)
|
| 374 |
+
|
| 375 |
+
# Generate slides so they persist after page reload
|
| 376 |
+
slides = []
|
| 377 |
+
try:
|
| 378 |
+
page_data = _scrape_article_images(url)
|
| 379 |
+
if page_data and page_data.get('paragraphs'):
|
| 380 |
+
key_points = _extract_key_points_for_slides(page_data['paragraphs'], max_points=12)
|
| 381 |
+
if key_points:
|
| 382 |
+
relevant_imgs = page_data.get('images', [])
|
| 383 |
+
if not relevant_imgs and page_data.get('og_img'):
|
| 384 |
+
relevant_imgs = [page_data['og_img']]
|
| 385 |
+
for i, point in enumerate(key_points):
|
| 386 |
+
img = relevant_imgs[i] if i < len(relevant_imgs) else (relevant_imgs[-1] if relevant_imgs else '')
|
| 387 |
+
slides.append({'text': point, 'image': img, 'index': i + 1})
|
| 388 |
+
except Exception:
|
| 389 |
+
pass
|
| 390 |
+
post['slides'] = slides
|
| 391 |
+
|
| 392 |
+
posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts)
|
| 393 |
+
return JSONResponse({'post': post, 'slides': slides})
|
| 394 |
+
|
| 395 |
+
|
| 396 |
+
def _is_relevant_image(img_url, title, text):
|
| 397 |
+
"""Check if an image is relevant to the article content."""
|
| 398 |
+
if not img_url:
|
| 399 |
+
return False
|
| 400 |
+
skip_patterns = ['pixel', 'analytics', 'tracking', '1x1.gif', 'spacer.gif',
|
| 401 |
+
'logo', 'icon', 'avatar', 'emoji', 'smiley', 'sprite',
|
| 402 |
+
'advertisement', 'ad-banner', 'sponsored', 'banner-ads']
|
| 403 |
+
img_lower = img_url.lower()
|
| 404 |
+
for p in skip_patterns:
|
| 405 |
+
if p in img_lower:
|
| 406 |
+
return False
|
| 407 |
+
if not any(img_lower.endswith(ext) for ext in ['.jpg', '.jpeg', '.png', '.webp', '.gif']):
|
| 408 |
+
return False
|
| 409 |
+
return True
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
def _filter_relevant_images(images, title, text, max_images=8):
|
| 413 |
+
"""Filter and rank images by relevance to article content."""
|
| 414 |
+
if not images:
|
| 415 |
+
return []
|
| 416 |
+
seen = set()
|
| 417 |
+
relevant = []
|
| 418 |
+
for img in images:
|
| 419 |
+
if img in seen:
|
| 420 |
+
continue
|
| 421 |
+
seen.add(img)
|
| 422 |
+
if _is_relevant_image(img, title, text):
|
| 423 |
+
relevant.append(img)
|
| 424 |
+
return relevant[:max_images]
|
| 425 |
+
|
| 426 |
+
|
| 427 |
+
def _extract_key_points_for_slides(paragraphs, max_points=12):
|
| 428 |
+
"""Extract key points from paragraphs for slides - extracts ALL sentences, not just first one."""
|
| 429 |
+
points = []
|
| 430 |
+
for p in paragraphs:
|
| 431 |
+
if len(points) >= max_points:
|
| 432 |
+
break
|
| 433 |
+
p = _clean(p)
|
| 434 |
+
if not p:
|
| 435 |
+
continue
|
| 436 |
+
# Split paragraph into sentences using Vietnamese + English punctuation - GET ALL SENTENCES
|
| 437 |
+
sentences = re.split(r'(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])', p)
|
| 438 |
+
sentences = [s.strip() for s in sentences if s.strip()]
|
| 439 |
+
|
| 440 |
+
for sentence in sentences:
|
| 441 |
+
if len(points) >= max_points:
|
| 442 |
+
break
|
| 443 |
+
sentence = _clean(sentence)
|
| 444 |
+
if len(sentence) < 30:
|
| 445 |
+
continue
|
| 446 |
+
if any(sentence[:60] in existing for existing in points):
|
| 447 |
+
continue
|
| 448 |
+
if not sentence.endswith(('.', '!', '?')):
|
| 449 |
+
sentence = sentence + '.'
|
| 450 |
+
points.append(sentence)
|
| 451 |
+
return points
|
| 452 |
+
|
| 453 |
+
|
| 454 |
+
def _scrape_article_images(url):
|
| 455 |
+
"""Scrape article page and return only relevant images."""
|
| 456 |
+
try:
|
| 457 |
+
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
| 458 |
+
"Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8"}
|
| 459 |
+
r = requests.get(url, headers=headers, timeout=15, allow_redirects=True)
|
| 460 |
+
r.encoding = 'utf-8'
|
| 461 |
+
soup = BeautifulSoup(r.text, 'lxml')
|
| 462 |
+
for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form']):
|
| 463 |
+
tag.decompose()
|
| 464 |
+
h1 = soup.find('h1')
|
| 465 |
+
ogt = soup.find('meta', property='og:title')
|
| 466 |
+
title = (h1.get_text(strip=True) if h1 else '') or (ogt.get('content', '') if ogt else '')
|
| 467 |
+
ogi = soup.find('meta', property='og:image')
|
| 468 |
+
og_img = ogi.get('content', '') if ogi else ''
|
| 469 |
+
if og_img and og_img.startswith('//'):
|
| 470 |
+
og_img = 'https:' + og_img
|
| 471 |
+
block = None
|
| 472 |
+
for sel in ['article', '.singular-content', '.detail-content', '.fck_detail', '.content-detail', '.knc-content', 'main', '.cms-body', '.article__body']:
|
| 473 |
+
el = soup.select_one(sel)
|
| 474 |
+
if el and len(el.find_all('p')) >= 2:
|
| 475 |
+
block = el
|
| 476 |
+
break
|
| 477 |
+
if not block:
|
| 478 |
+
block = soup.body or soup
|
| 479 |
+
paragraphs = []
|
| 480 |
+
all_images = []
|
| 481 |
+
seen_imgs = set()
|
| 482 |
+
if og_img and og_img not in seen_imgs:
|
| 483 |
+
all_images.append(og_img)
|
| 484 |
+
seen_imgs.add(og_img)
|
| 485 |
+
for el in block.find_all(['p', 'h2', 'h3', 'figure', 'img'], recursive=True):
|
| 486 |
+
if el.name == 'p':
|
| 487 |
+
t = _clean(el.get_text(strip=True))
|
| 488 |
+
if t and len(t) > 40:
|
| 489 |
+
paragraphs.append(t)
|
| 490 |
+
elif el.name in ('figure', 'img'):
|
| 491 |
+
im = el if el.name == 'img' else el.find('img')
|
| 492 |
+
if im:
|
| 493 |
+
src = im.get('data-src') or im.get('src') or im.get('data-original') or ''
|
| 494 |
+
if src and 'base64' not in src:
|
| 495 |
+
if src.startswith('//'):
|
| 496 |
+
src = 'https:' + src
|
| 497 |
+
if src not in seen_imgs:
|
| 498 |
+
all_images.append(src)
|
| 499 |
+
seen_imgs.add(src)
|
| 500 |
+
relevant_images = _filter_relevant_images(all_images, title, ' '.join(paragraphs[:5]))
|
| 501 |
+
return {'title': _clean(title), 'paragraphs': paragraphs, 'images': relevant_images, 'og_img': og_img}
|
| 502 |
+
except Exception:
|
| 503 |
+
return None
|
| 504 |
+
|
| 505 |
+
|
| 506 |
+
@app.post('/api/rewrite_share')
|
| 507 |
+
async def compat_rewrite_share(request: Request):
|
| 508 |
+
body = await request.json()
|
| 509 |
+
url = base._clean_text(body.get('url', ''))
|
| 510 |
+
if not url.startswith('http'):
|
| 511 |
+
return JSONResponse({'error': 'missing url'}, status_code=400)
|
| 512 |
+
try:
|
| 513 |
+
data = base.scrape_any_url(url)
|
| 514 |
+
except Exception as e:
|
| 515 |
+
return JSONResponse({'error': 'Không đọc được bài viết: ' + str(e)[:180]}, status_code=422)
|
| 516 |
+
raw = (data.get('summary', '') + '\n' + data.get('text', '')).strip()
|
| 517 |
+
if len(raw) < 120:
|
| 518 |
+
return JSONResponse({'error': 'Bài viết không đủ nội dung để tóm tắt'}, status_code=422)
|
| 519 |
+
prompt = _make_summary_prompt(data.get('title', ''), raw, data.get('via', '') or base._domain(url))
|
| 520 |
+
text = await base.qwen_generate(prompt, image_url=data.get('image') or None, max_tokens=1500)
|
| 521 |
+
text = _postprocess_ai_text(text, max_units=20)
|
| 522 |
+
src = [{'title': data.get('title'), 'url': url, 'excerpt': raw[:500], 'via': data.get('via') or base._domain(url)}]
|
| 523 |
+
if 'Nguồn tham khảo:' not in text:
|
| 524 |
+
text += "\n\n" + _source_line(src)
|
| 525 |
+
post = base.make_post(data.get('title') or 'Bài viết', text, data.get('image') or '', url, 'summary', sources=src)
|
| 526 |
+
|
| 527 |
+
# Generate slides with relevant images only
|
| 528 |
+
slides = []
|
| 529 |
+
page_data = _scrape_article_images(url)
|
| 530 |
+
if page_data and page_data.get('paragraphs'):
|
| 531 |
+
key_points = _extract_key_points_for_slides(page_data['paragraphs'], max_points=12)
|
| 532 |
+
if key_points:
|
| 533 |
+
relevant_imgs = page_data.get('images', [])
|
| 534 |
+
if not relevant_imgs and page_data.get('og_img'):
|
| 535 |
+
relevant_imgs = [page_data['og_img']]
|
| 536 |
+
for i, point in enumerate(key_points):
|
| 537 |
+
img = relevant_imgs[i] if i < len(relevant_imgs) else (relevant_imgs[-1] if relevant_imgs else '')
|
| 538 |
+
slides.append({'text': point, 'image': img, 'index': i + 1})
|
| 539 |
+
|
| 540 |
+
# FIX: Save slides into post so they persist after page reload
|
| 541 |
+
post['slides'] = slides
|
| 542 |
+
posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts)
|
| 543 |
+
|
| 544 |
+
return JSONResponse({'post': post, 'slides': slides})
|
| 545 |
+
|
| 546 |
+
|
| 547 |
+
def _emotion_script(text, emotion):
|
| 548 |
+
"""Prepend emotion-appropriate prefix to text based on emotion type.
|
| 549 |
+
|
| 550 |
+
NOTE: Prefix is NOT added to avoid cluttering Short AI speech.
|
| 551 |
+
The emotion is still used for voice selection but content is read cleanly.
|
| 552 |
+
"""
|
| 553 |
+
text = _clean(text)
|
| 554 |
+
# REMOVED: No prefix added to keep content clean and natural
|
| 555 |
+
return text
|
| 556 |
+
|
| 557 |
+
|
| 558 |
+
def _tts_script_smart(post, emotion):
|
| 559 |
+
raw = base._short_script(post) if hasattr(base, '_short_script') else _clean(post.get('text', '') or post.get('title', ''))
|
| 560 |
+
raw = re.sub(r"^[•\-\*]\s*", "", raw, flags=re.M)
|
| 561 |
+
raw = re.sub(r"\s*\n\s*", ". ", raw)
|
| 562 |
+
raw = re.sub(r"([\.\!\?])\s*", r"\1\n", raw)
|
| 563 |
+
raw = re.sub(r"\n{2,}", "\n", raw).strip()
|
| 564 |
+
# REMOVED: _emotion_script call - read content cleanly without prefix
|
| 565 |
+
# INCREASED to 3000 to read full content of all bullet points
|
| 566 |
+
if len(raw) > 3000:
|
| 567 |
+
raw = raw[:3000]
|
| 568 |
+
cut = max(raw.rfind("."), raw.rfind("!"), raw.rfind("?"))
|
| 569 |
+
if cut > 700:
|
| 570 |
+
raw = raw[:cut + 1]
|
| 571 |
+
return raw
|
| 572 |
+
|
| 573 |
+
|
| 574 |
+
def _split_subtitle_sentences(script):
|
| 575 |
+
parts = []
|
| 576 |
+
for line in script.splitlines():
|
| 577 |
+
line = _clean(line)
|
| 578 |
+
if not line:
|
| 579 |
+
continue
|
| 580 |
+
for s in re.split(r"(?<=[\.\!\?])\s+", line):
|
| 581 |
+
s = _clean(s)
|
| 582 |
+
if 8 <= len(s) <= 140:
|
| 583 |
+
parts.append(s)
|
| 584 |
+
return parts[:12]
|
| 585 |
+
|
| 586 |
+
|
| 587 |
+
def _srt_time(sec):
|
| 588 |
+
ms = int((sec - int(sec)) * 1000)
|
| 589 |
+
sec = int(sec)
|
| 590 |
+
h = sec // 3600
|
| 591 |
+
m = (sec % 3600) // 60
|
| 592 |
+
s = sec % 60
|
| 593 |
+
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
|
| 594 |
+
|
| 595 |
+
|
| 596 |
+
def _write_srt(script, path, total_duration=30):
|
| 597 |
+
subs = _split_subtitle_sentences(script)
|
| 598 |
+
if not subs:
|
| 599 |
+
subs = [script[:120]]
|
| 600 |
+
dur = max(2.2, min(5.0, total_duration / max(1, len(subs))))
|
| 601 |
+
cur = 0.3
|
| 602 |
+
with open(path, 'w', encoding='utf-8') as f:
|
| 603 |
+
for i, s in enumerate(subs, 1):
|
| 604 |
+
start = cur
|
| 605 |
+
end = cur + dur
|
| 606 |
+
cur = end + 0.15
|
| 607 |
+
f.write(f"{i}\n{_srt_time(start)} --> {_srt_time(end)}\n{s}\n\n")
|
| 608 |
+
|
| 609 |
+
|
| 610 |
+
def _wrap_text_px(draw, text, font, max_width, max_lines):
|
| 611 |
+
words = _clean(text).split()
|
| 612 |
+
lines, cur = [], ""
|
| 613 |
+
for w in words:
|
| 614 |
+
test = (cur + " " + w).strip()
|
| 615 |
+
try:
|
| 616 |
+
width = draw.textbbox((0, 0), test, font=font)[2]
|
| 617 |
+
except Exception:
|
| 618 |
+
width = len(test) * 20
|
| 619 |
+
if width <= max_width:
|
| 620 |
+
cur = test
|
| 621 |
+
else:
|
| 622 |
+
if cur:
|
| 623 |
+
lines.append(cur)
|
| 624 |
+
cur = w
|
| 625 |
+
if len(lines) >= max_lines:
|
| 626 |
+
break
|
| 627 |
+
if cur and len(lines) < max_lines:
|
| 628 |
+
lines.append(cur)
|
| 629 |
+
return lines
|
| 630 |
+
|
| 631 |
+
|
| 632 |
+
def _make_short_frame_full(post, img_path, out_path):
|
| 633 |
+
if Image is None:
|
| 634 |
+
return base._make_short_frame(post, img_path, out_path)
|
| 635 |
+
W, H = 1080, 1920
|
| 636 |
+
bg = Image.new("RGB", (W, H), (14, 14, 14))
|
| 637 |
+
try:
|
| 638 |
+
im = Image.open(img_path).convert("RGB")
|
| 639 |
+
target = (1080, 760)
|
| 640 |
+
im_ratio = im.width / im.height
|
| 641 |
+
target_ratio = target[0] / target[1]
|
| 642 |
+
if im_ratio > target_ratio:
|
| 643 |
+
new_h = target[1]
|
| 644 |
+
new_w = int(new_h * im_ratio)
|
| 645 |
+
else:
|
| 646 |
+
new_w = target[0]
|
| 647 |
+
new_h = int(new_w / im_ratio)
|
| 648 |
+
im = im.resize((new_w, new_h))
|
| 649 |
+
left = (new_w - target[0]) // 2
|
| 650 |
+
top = (new_h - target[1]) // 2
|
| 651 |
+
im = im.crop((left, top, left + target[0], top + target[1]))
|
| 652 |
+
bg.paste(im, (0, 0))
|
| 653 |
+
except Exception:
|
| 654 |
+
pass
|
| 655 |
+
draw = ImageDraw.Draw(bg)
|
| 656 |
+
try:
|
| 657 |
+
font_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 54)
|
| 658 |
+
font_body = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 38)
|
| 659 |
+
font_label = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 30)
|
| 660 |
+
except Exception:
|
| 661 |
+
font_title = font_body = font_label = None
|
| 662 |
+
draw.rectangle((0, 720, W, H), fill=(14, 14, 14))
|
| 663 |
+
margin = 48
|
| 664 |
+
maxw = W - margin * 2
|
| 665 |
+
draw.text((margin, 770), "VNEWS · Tường AI", fill=(92, 184, 122), font=font_label)
|
| 666 |
+
y = 830
|
| 667 |
+
for ln in _wrap_text_px(draw, post.get("title", ""), font_title, maxw, 4):
|
| 668 |
+
draw.text((margin, y), ln, fill=(255, 255, 255), font=font_title)
|
| 669 |
+
y += 66
|
| 670 |
+
y += 18
|
| 671 |
+
text = post.get("text", "")
|
| 672 |
+
text = re.sub(r"Nguồn tham khảo:.*", "", text, flags=re.S).strip()
|
| 673 |
+
body_lines = _wrap_text_px(draw, text, font_body, maxw, 14)
|
| 674 |
+
for ln in body_lines:
|
| 675 |
+
draw.text((margin, y), ln, fill=(220, 220, 220), font=font_body)
|
| 676 |
+
y += 50
|
| 677 |
+
if y > 1640:
|
| 678 |
+
break
|
| 679 |
+
bg.save(out_path, quality=92)
|
| 680 |
+
|
| 681 |
+
|
| 682 |
+
|
| 683 |
+
|
| 684 |
+
def _summary_segments_from_post(post, max_segments=25):
|
| 685 |
+
raw = _clean(post.get('text') or post.get('title') or '')
|
| 686 |
+
raw = re.sub(r'^Bản tin AI viết lại:\s*', '', raw, flags=re.I)
|
| 687 |
+
raw = re.sub(r'Nguồn tham khảo:.*$', '', raw, flags=re.I|re.S).strip()
|
| 688 |
+
lines=[]
|
| 689 |
+
for ln in raw.splitlines():
|
| 690 |
+
ln=_clean(re.sub(r'^[•\-\*\d\.\)\s]+','',ln))
|
| 691 |
+
if not ln: continue
|
| 692 |
+
low=ln.lower()
|
| 693 |
+
if low.startswith(('điểm chính','tiêu đề','sapo','nguồn tham khảo')): continue
|
| 694 |
+
if len(ln)>=18: lines.append(ln)
|
| 695 |
+
if len(lines)<3:
|
| 696 |
+
lines=[]
|
| 697 |
+
for s in re.split(r'(?<=[\.\!\?])\s+', raw):
|
| 698 |
+
s=_clean(s)
|
| 699 |
+
if len(s)>=25: lines.append(s)
|
| 700 |
+
segs=_dedupe_units(lines, max_units=max_segments)
|
| 701 |
+
return segs[:max_segments] if segs else [post.get('title','Bản tin VNEWS')]
|
| 702 |
+
|
| 703 |
+
|
| 704 |
+
def _make_scene_frame(post, segment, idx, total, img_path, out_path, emotion='neutral'):
|
| 705 |
+
if Image is None:
|
| 706 |
+
return _make_short_frame_full(post, img_path, out_path)
|
| 707 |
+
W,H=1080,1920
|
| 708 |
+
bg=Image.new('RGB',(W,H),(10,10,10))
|
| 709 |
+
try:
|
| 710 |
+
im=Image.open(img_path).convert('RGB')
|
| 711 |
+
ratio=im.width/max(1,im.height); target=W/H
|
| 712 |
+
if ratio>target:
|
| 713 |
+
nh=H; nw=int(nh*ratio)
|
| 714 |
+
else:
|
| 715 |
+
nw=W; nh=int(nw/ratio)
|
| 716 |
+
cover=im.resize((nw,nh)); left=(nw-W)//2; top=(nh-H)//2
|
| 717 |
+
cover=cover.crop((left,top,left+W,top+H))
|
| 718 |
+
bg.paste(cover,(0,0))
|
| 719 |
+
bg=Image.blend(bg, Image.new('RGB',(W,H),(0,0,0)), 0.50)
|
| 720 |
+
hero_h=720; target=W/hero_h
|
| 721 |
+
if ratio>target:
|
| 722 |
+
nh=hero_h; nw=int(nh*ratio)
|
| 723 |
+
else:
|
| 724 |
+
nw=W; nh=int(nw/ratio)
|
| 725 |
+
hero=im.resize((nw,nh)); left=(nw-W)//2; top=(nh-hero_h)//2
|
| 726 |
+
hero=hero.crop((left,top,left+W,top+hero_h))
|
| 727 |
+
bg.paste(hero,(0,0))
|
| 728 |
+
except Exception:
|
| 729 |
+
pass
|
| 730 |
+
draw=ImageDraw.Draw(bg)
|
| 731 |
+
try:
|
| 732 |
+
font_brand=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',34)
|
| 733 |
+
font_small=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',28)
|
| 734 |
+
font_seg=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',58)
|
| 735 |
+
font_title=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',34)
|
| 736 |
+
except Exception:
|
| 737 |
+
font_brand=font_small=font_seg=font_title=None
|
| 738 |
+
draw.rectangle((0,680,W,H), fill=(12,12,12))
|
| 739 |
+
dot_x=48; dot_y=742
|
| 740 |
+
for i in range(total):
|
| 741 |
+
fill=(92,184,122) if i==idx else (70,70,70)
|
| 742 |
+
draw.rounded_rectangle((dot_x+i*38,dot_y,dot_x+i*38+24,dot_y+10), radius=5, fill=fill)
|
| 743 |
+
draw.text((48,780),'VNEWS AI SHORT',fill=(110,231,143),font=font_brand)
|
| 744 |
+
draw.rounded_rectangle((48,834,260,880), radius=20, fill=(28,70,45))
|
| 745 |
+
draw.text((66,842),f'Đoạn {idx+1}/{total}',fill=(235,235,235),font=font_small)
|
| 746 |
+
y=940; maxw=W-96
|
| 747 |
+
# INCREASED from 12 to 18 for full content display - each key point can span multiple lines
|
| 748 |
+
for ln in _wrap_text_px(draw, segment, font_seg, maxw, 18):
|
| 749 |
+
draw.text((48,y),ln,fill=(255,255,255),font=font_seg)
|
| 750 |
+
y+=74
|
| 751 |
+
if y>1500: break
|
| 752 |
+
y2=1640
|
| 753 |
+
draw.line((48,y2-22,W-48,y2-22),fill=(70,70,70),width=2)
|
| 754 |
+
for ln in _wrap_text_px(draw, post.get('title',''), font_title, maxw, 3):
|
| 755 |
+
draw.text((48,y2),ln,fill=(220,220,220),font=font_title)
|
| 756 |
+
y2+=46
|
| 757 |
+
bg.save(out_path, quality=92)
|
| 758 |
+
|
| 759 |
+
|
| 760 |
+
def _estimate_audio_duration(path, fallback=15.0):
|
| 761 |
+
"""Estimate audio duration with 15s minimum per segment for complete bullet reading."""
|
| 762 |
+
try:
|
| 763 |
+
pr=subprocess.run(['ffprobe','-v','error','-show_entries','format=duration','-of','default=noprint_wrappers=1:no_key=1',path], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20)
|
| 764 |
+
return max(12.0, float((pr.stdout or b'').decode().strip() or fallback))
|
| 765 |
+
except Exception:
|
| 766 |
+
return fallback
|
| 767 |
+
|
| 768 |
+
|
| 769 |
+
@app.post('/api/ai/short/{post_id}')
|
| 770 |
+
async def patched_ai_short(post_id: str, request: Request):
|
| 771 |
+
try:
|
| 772 |
+
body = await request.json()
|
| 773 |
+
except Exception:
|
| 774 |
+
body = {}
|
| 775 |
+
voice = str(body.get('voice', 'nu')).strip().lower()
|
| 776 |
+
emotion = str(body.get('emotion', 'neutral')).strip().lower()
|
| 777 |
+
speed = float(body.get('speed', 1.0) or 1.0)
|
| 778 |
+
speed = max(0.85, min(1.35, speed))
|
| 779 |
+
|
| 780 |
+
posts = base._load_ai_wall()
|
| 781 |
+
post = next((p for p in posts if str(p.get('id')) == str(post_id)), None)
|
| 782 |
+
if not post:
|
| 783 |
+
return JSONResponse({'error': 'post not found'}, status_code=404)
|
| 784 |
+
|
| 785 |
+
segments = _summary_segments_from_post(post, max_segments=25)
|
| 786 |
+
seg_hash = hashlib.md5(('|'.join(segments)+voice+emotion+str(speed)).encode('utf-8')).hexdigest()[:8]
|
| 787 |
+
os.makedirs(base.SHORTS_DIR, exist_ok=True)
|
| 788 |
+
suffix = f"_{voice}_{emotion}_{str(speed).replace('.', 'p')}_{seg_hash}_scenes_nosub"
|
| 789 |
+
out_mp4 = os.path.join(base.SHORTS_DIR, base._safe_name(post_id + suffix) + '.mp4')
|
| 790 |
+
if os.path.exists(out_mp4):
|
| 791 |
+
post['video'] = '/api/ai/short-file/' + post_id + suffix
|
| 792 |
+
post['short_voice'] = voice
|
| 793 |
+
post['short_emotion'] = emotion
|
| 794 |
+
post['short_speed'] = speed
|
| 795 |
+
post['short_segments'] = segments
|
| 796 |
+
post['short_subtitles'] = False
|
| 797 |
+
base._save_ai_wall(posts)
|
| 798 |
+
return JSONResponse({'video': post['video'], 'voice': voice, 'emotion': emotion, 'speed': speed, 'subtitles': False, 'segments': segments})
|
| 799 |
+
if base.gTTS is None:
|
| 800 |
+
return JSONResponse({'error': 'gTTS chưa sẵn sàng'}, status_code=503)
|
| 801 |
+
|
| 802 |
+
work = os.path.join(base.SHORTS_DIR, base._safe_name(post_id + suffix))
|
| 803 |
+
os.makedirs(work, exist_ok=True)
|
| 804 |
+
img = os.path.join(work, 'image.jpg')
|
| 805 |
+
try:
|
| 806 |
+
base._download_image(post.get('img'), post.get('title', 'AI news'), img)
|
| 807 |
+
edge_voice = {
|
| 808 |
+
# Vietnamese
|
| 809 |
+
'vi-vn-hoaimyneural': 'vi-VN-HoaiMyNeural',
|
| 810 |
+
'vi-vn-namminhneural': 'vi-VN-NamMinhNeural',
|
| 811 |
+
'hoaimy': 'vi-VN-HoaiMyNeural',
|
| 812 |
+
'namminh': 'vi-VN-NamMinhNeural',
|
| 813 |
+
'nam': 'vi-VN-NamMinhNeural',
|
| 814 |
+
'male': 'vi-VN-NamMinhNeural',
|
| 815 |
+
'nu': 'vi-VN-HoaiMyNeural',
|
| 816 |
+
'female': 'vi-VN-HoaiMyNeural',
|
| 817 |
+
'mien-nam': 'vi-VN-HoaiMyNeural',
|
| 818 |
+
# English - Multilingual
|
| 819 |
+
'en-us-andrewmultilingualneural': 'en-US-AndrewMultilingualNeural',
|
| 820 |
+
'en-au-williammultilingualneural': 'en-AU-WilliamMultilingualNeural',
|
| 821 |
+
'andrew': 'en-US-AndrewMultilingualNeural',
|
| 822 |
+
'en_andrew': 'en-US-AndrewMultilingualNeural',
|
| 823 |
+
'jenny': 'en-US-AndrewMultilingualNeural',
|
| 824 |
+
'en_jenny': 'en-US-AndrewMultilingualNeural',
|
| 825 |
+
# Portuguese - Multilingual (ONLY Thalita)
|
| 826 |
+
'pt-br-thalitamultilingualneural': 'pt-BR-ThalitaMultilingualNeural',
|
| 827 |
+
'thalita': 'pt-BR-ThalitaMultilingualNeural',
|
| 828 |
+
'pt_thalita': 'pt-BR-ThalitaMultilingualNeural',
|
| 829 |
+
'pt_br_thalita': 'pt-BR-ThalitaMultilingualNeural',
|
| 830 |
+
'pt': 'pt-BR-ThalitaMultilingualNeural',
|
| 831 |
+
'pt_francisco': 'pt-BR-ThalitaMultilingualNeural',
|
| 832 |
+
# French - Multilingual
|
| 833 |
+
'fr-fr-viviennemultilingualneural': 'fr-FR-VivienneMultilingualNeural',
|
| 834 |
+
'fr-fr-remymultilingualneural': 'fr-FR-RemyMultilingualNeural',
|
| 835 |
+
'denise': 'fr-FR-VivienneMultilingualNeural',
|
| 836 |
+
'fr': 'fr-FR-VivienneMultilingualNeural',
|
| 837 |
+
'fr_denise': 'fr-FR-VivienneMultilingualNeural',
|
| 838 |
+
# German - Multilingual
|
| 839 |
+
'de-de-seraphinamultilingualneural': 'de-DE-SeraphinaMultilingualNeural',
|
| 840 |
+
'de-de-florianmultilingualneural': 'de-DE-FlorianMultilingualNeural',
|
| 841 |
+
'katja': 'de-DE-SeraphinaMultilingualNeural',
|
| 842 |
+
'de': 'de-DE-SeraphinaMultilingualNeural',
|
| 843 |
+
'de_katja': 'de-DE-SeraphinaMultilingualNeural',
|
| 844 |
+
# Korean - Multilingual (Hyunsu, NOT SunHee)
|
| 845 |
+
'ko-kr-hyusumultilingualneural': 'ko-KR-HyunsuMultilingualNeural',
|
| 846 |
+
'ko-kr-hyunsuneural': 'ko-KR-HyunsuMultilingualNeural',
|
| 847 |
+
'sunhee': 'ko-KR-HyunsuMultilingualNeural',
|
| 848 |
+
'ko': 'ko-KR-HyunsuMultilingualNeural',
|
| 849 |
+
'ko_sunhee': 'ko-KR-HyunsuMultilingualNeural',
|
| 850 |
+
# Italian - Multilingual
|
| 851 |
+
'it-it-giuseppemultilingualneural': 'it-IT-GiuseppeMultilingualNeural',
|
| 852 |
+
# Spanish (keep for backward compat)
|
| 853 |
+
'ela': 'en-US-AndrewMultilingualNeural',
|
| 854 |
+
'es_ela': 'en-US-AndrewMultilingualNeural',
|
| 855 |
+
'es': 'en-US-AndrewMultilingualNeural',
|
| 856 |
+
'es_carlos': 'en-US-AndrewMultilingualNeural',
|
| 857 |
+
# Japanese (keep for backward compat)
|
| 858 |
+
'nanami': 'en-US-AndrewMultilingualNeural',
|
| 859 |
+
'ja': 'en-US-AndrewMultilingualNeural',
|
| 860 |
+
'ja_nanami': 'en-US-AndrewMultilingualNeural',
|
| 861 |
+
# Chinese (keep for backward compat)
|
| 862 |
+
'xiaochen': 'en-US-AndrewMultilingualNeural',
|
| 863 |
+
'zh': 'en-US-AndrewMultilingualNeural',
|
| 864 |
+
'zh_xiaochen': 'en-US-AndrewMultilingualNeural',
|
| 865 |
+
}.get(voice, 'vi-VN-HoaiMyNeural')
|
| 866 |
+
part_files=[]
|
| 867 |
+
for idx, seg in enumerate(segments):
|
| 868 |
+
frame=os.path.join(work,f'frame_{idx:02d}.jpg')
|
| 869 |
+
aud=os.path.join(work,f'voice_{idx:02d}.mp3')
|
| 870 |
+
aud_fast=os.path.join(work,f'voice_{idx:02d}_fast.mp3')
|
| 871 |
+
part=os.path.join(work,f'part_{idx:02d}.mp4')
|
| 872 |
+
_make_scene_frame(post, seg, idx, len(segments), img, frame, emotion=emotion)
|
| 873 |
+
spoken=_emotion_script(seg, emotion)
|
| 874 |
+
try:
|
| 875 |
+
subprocess.run(['python','-m','edge_tts','--voice',edge_voice,'--text',spoken,'--write-media',aud], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
|
| 876 |
+
except Exception:
|
| 877 |
+
tld='com.vn' if voice in ('nu','female','mien-nam','hoaimy') else 'com'
|
| 878 |
+
try:
|
| 879 |
+
base.gTTS(spoken, lang='vi', tld=tld, slow=False).save(aud)
|
| 880 |
+
except TypeError:
|
| 881 |
+
base.gTTS(spoken, lang='vi', slow=False).save(aud)
|
| 882 |
+
subprocess.run(['ffmpeg','-y','-i',aud,'-filter:a',f'atempo={speed}','-vn',aud_fast], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=90)
|
| 883 |
+
dur=_estimate_audio_duration(aud_fast, fallback=15.0)+0.35
|
| 884 |
+
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)
|
| 885 |
+
part_files.append(part)
|
| 886 |
+
concat=os.path.join(work,'concat.txt')
|
| 887 |
+
with open(concat,'w',encoding='utf-8') as f:
|
| 888 |
+
for p in part_files:
|
| 889 |
+
f.write("file '" + p.replace("'", "'\\''") + "'\n")
|
| 890 |
+
subprocess.run(['ffmpeg','-y','-f','concat','-safe','0','-i',concat,'-c','copy',out_mp4], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180)
|
| 891 |
+
post['video'] = '/api/ai/short-file/' + post_id + suffix
|
| 892 |
+
post['short_voice'] = voice
|
| 893 |
+
post['short_emotion'] = emotion
|
| 894 |
+
post['short_speed'] = speed
|
| 895 |
+
post['short_segments'] = segments
|
| 896 |
+
post['short_subtitles'] = False
|
| 897 |
+
base._save_ai_wall(posts)
|
| 898 |
+
return JSONResponse({'video': post['video'], 'voice': voice, 'emotion': emotion, 'speed': speed, 'subtitles': False, 'segments': segments})
|
| 899 |
+
except Exception as e:
|
| 900 |
+
return JSONResponse({'error': 'Không tạo được shorts: ' + str(e)[:220]}, status_code=500)
|
| 901 |
+
|
| 902 |
+
|
| 903 |
+
@app.get('/api/ai/short-file/{file_id}')
|
| 904 |
+
def patched_ai_short_file(file_id: str):
|
| 905 |
+
path = os.path.join(base.SHORTS_DIR, base._safe_name(file_id) + '.mp4')
|
| 906 |
+
if not os.path.exists(path):
|
| 907 |
+
return JSONResponse({'error': 'not found'}, status_code=404)
|
| 908 |
+
return FileResponse(path, media_type='video/mp4', filename=f'vnews-ai-{file_id}.mp4')
|
| 909 |
+
|
| 910 |
+
|
| 911 |
+
@app.get('/api/ai_shorts')
|
| 912 |
+
def api_ai_shorts():
|
| 913 |
+
posts = [p for p in base._load_ai_wall() if p.get('video')]
|
| 914 |
+
return JSONResponse({'posts': posts[:80]})
|
| 915 |
+
|
| 916 |
+
|
| 917 |
+
app.router.routes = [r for r in app.router.routes if not (getattr(r, 'path', None) == '/' and 'GET' in getattr(r, 'methods', set()))]
|
ai_runtime.py
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os, re, subprocess, json, time, hashlib
|
| 2 |
+
import ai_patch as old
|
| 3 |
+
from ai_patch import app
|
| 4 |
+
import ai_ext as base
|
| 5 |
+
from fastapi import Request
|
| 6 |
+
from fastapi.responses import JSONResponse, HTMLResponse, FileResponse
|
| 7 |
+
try:
|
| 8 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 9 |
+
except Exception:
|
| 10 |
+
Image = ImageDraw = ImageFont = None
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def clean(s):
|
| 14 |
+
import html as html_lib
|
| 15 |
+
return re.sub(r"\s+", " ", html_lib.unescape(s or "")).strip()
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _domain(url):
|
| 19 |
+
try:
|
| 20 |
+
from urllib.parse import urlparse
|
| 21 |
+
return urlparse(url or '').netloc.replace('www.','')
|
| 22 |
+
except Exception:
|
| 23 |
+
return ''
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _strip_bullet_prefix(s):
|
| 27 |
+
# remove bullets, numbered prefixes, leading dots commonly produced by AI summaries
|
| 28 |
+
return clean(re.sub(r'^[\s•\-\*·▪▫●○\d\.\)\(]+', '', s or ''))
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def source_line(sources):
|
| 32 |
+
names=[]
|
| 33 |
+
for s in (sources or [])[:5]:
|
| 34 |
+
via=s.get('via') or _domain(s.get('url','')) or s.get('title','')
|
| 35 |
+
if via and via not in names:names.append(via)
|
| 36 |
+
return 'Nguồn tham khảo: '+', '.join(names[:5]) if names else 'Nguồn tham khảo: tổng hợp internet'
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _source_badge(post):
|
| 40 |
+
sources=post.get('sources') or []
|
| 41 |
+
for s in sources:
|
| 42 |
+
via=s.get('via') or _domain(s.get('url',''))
|
| 43 |
+
if via:return via
|
| 44 |
+
return _domain(post.get('url','')) or post.get('source') or 'VNEWS'
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _collect_all_images(data):
|
| 48 |
+
imgs=[]
|
| 49 |
+
def add(u):
|
| 50 |
+
u=(u or '').strip()
|
| 51 |
+
if not u or u.startswith('data:') or 'base64' in u:return
|
| 52 |
+
if u.startswith('//'):u='https:'+u
|
| 53 |
+
if u not in imgs:imgs.append(u)
|
| 54 |
+
add(data.get('image') or data.get('og_image') or data.get('img'))
|
| 55 |
+
for u in data.get('images') or []:add(u)
|
| 56 |
+
for b in data.get('body') or []:
|
| 57 |
+
if isinstance(b,dict) and b.get('type')=='img':add(b.get('src'))
|
| 58 |
+
return imgs[:20]
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _scrape_url_with_images(url):
|
| 62 |
+
data=base.scrape_any_url(url)
|
| 63 |
+
# extra pass: collect every useful image from original HTML, because some readers only return one image
|
| 64 |
+
try:
|
| 65 |
+
import requests
|
| 66 |
+
from bs4 import BeautifulSoup
|
| 67 |
+
r=requests.get(url,headers=base.HEADERS,timeout=18);r.encoding='utf-8'
|
| 68 |
+
soup=BeautifulSoup(r.text,'lxml')
|
| 69 |
+
extra=[]
|
| 70 |
+
for im in soup.find_all('img'):
|
| 71 |
+
src=im.get('data-src') or im.get('data-original') or im.get('data-lazy-src') or im.get('src') or ''
|
| 72 |
+
if src.startswith('//'):src='https:'+src
|
| 73 |
+
if src and 'base64' not in src and src not in extra:
|
| 74 |
+
# skip tiny icons/logos as much as possible
|
| 75 |
+
low=src.lower()
|
| 76 |
+
if any(x in low for x in ['logo','icon','avatar','sprite']):
|
| 77 |
+
continue
|
| 78 |
+
extra.append(src)
|
| 79 |
+
if len(extra)>=20:break
|
| 80 |
+
data['images']=_collect_all_images(data)+[u for u in extra if u not in _collect_all_images(data)]
|
| 81 |
+
except Exception:
|
| 82 |
+
data['images']=_collect_all_images(data)
|
| 83 |
+
data['images']=_collect_all_images(data)
|
| 84 |
+
if data['images'] and not data.get('image'):
|
| 85 |
+
data['image']=data['images'][0]
|
| 86 |
+
return data
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def rich_context(topic, limit=5):
|
| 90 |
+
try: ctx,sources=base.web_context(topic, limit=limit)
|
| 91 |
+
except Exception: ctx,sources='',[]
|
| 92 |
+
rich=[];rs=[];seen=set()
|
| 93 |
+
for s in (sources or [])[:limit*2]:
|
| 94 |
+
url=s.get('url') or ''
|
| 95 |
+
if not url.startswith('http') or url in seen:continue
|
| 96 |
+
seen.add(url)
|
| 97 |
+
try:
|
| 98 |
+
data=base.scrape_any_url(url)
|
| 99 |
+
raw=(data.get('summary','')+'\n'+data.get('text','')).strip()
|
| 100 |
+
if len(raw)<180:continue
|
| 101 |
+
title=data.get('title') or s.get('title') or url
|
| 102 |
+
via=data.get('via') or s.get('via') or _domain(url)
|
| 103 |
+
rich.append(f"### {title} ({via})\n{raw[:2600]}")
|
| 104 |
+
rs.append({'title':title,'url':url,'excerpt':raw[:700],'via':via})
|
| 105 |
+
if len(rich)>=limit:break
|
| 106 |
+
except Exception:continue
|
| 107 |
+
if rich:return '\n\n'.join(rich),rs
|
| 108 |
+
return ctx or f'Chủ đề: {topic}', sources or []
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def postprocess(text):
|
| 112 |
+
if hasattr(old,'_postprocess_ai_text'):
|
| 113 |
+
out=old._postprocess_ai_text(text, max_units=7)
|
| 114 |
+
else:
|
| 115 |
+
out=clean(text)
|
| 116 |
+
# keep wall text readable, but ensure short generation later won't show bullets
|
| 117 |
+
return out
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
# Remove old routes we must override.
|
| 121 |
+
_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')}
|
| 122 |
+
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)]
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
@app.post('/api/url_wall')
|
| 126 |
+
async def url_wall_only(request:Request):
|
| 127 |
+
body=await request.json();url=base._clean_text(body.get('url',''))
|
| 128 |
+
if not url.startswith('http'):return JSONResponse({'error':'missing url'},status_code=400)
|
| 129 |
+
try:data=_scrape_url_with_images(url)
|
| 130 |
+
except Exception as e:return JSONResponse({'error':'Không scrape được URL: '+str(e)[:180]},status_code=422)
|
| 131 |
+
raw=(data.get('summary','')+'\n'+data.get('text','')).strip()
|
| 132 |
+
if len(raw)<120:return JSONResponse({'error':'URL không có đủ nội dung để tóm tắt'},status_code=422)
|
| 133 |
+
prompt=f"""Tóm tắt bài viết nguồn dưới đây để đăng lên Tường AI VNEWS.
|
| 134 |
+
|
| 135 |
+
Yêu cầu bắt buộc:
|
| 136 |
+
- Chỉ tóm tắt nội dung chính, không viết lại toàn bộ bài.
|
| 137 |
+
- Ngắn gọn, cụ thể, dễ hiểu.
|
| 138 |
+
- Không lặp lại ý và không thêm chi tiết ngoài nguồn.
|
| 139 |
+
- Tối đa 5 ý chính hoặc 2 đoạn ngắn.
|
| 140 |
+
- Tránh dùng dấu đầu dòng nếu không thật cần thiết.
|
| 141 |
+
|
| 142 |
+
Tiêu đề gốc: {data.get('title','')}
|
| 143 |
+
Nguồn: {data.get('via','') or _domain(url)}
|
| 144 |
+
Nội dung gốc:
|
| 145 |
+
{raw[:16000]}"""
|
| 146 |
+
text=await base.qwen_generate(prompt,image_url=(data.get('image') or None),max_tokens=900)
|
| 147 |
+
if not text:text=old._fallback_summary_from_prompt(prompt,max_units=5) if hasattr(old,'_fallback_summary_from_prompt') else raw[:900]
|
| 148 |
+
text=postprocess(text)
|
| 149 |
+
src=[{'title':data.get('title'), 'url':url, 'excerpt':raw[:500], 'via':data.get('via') or _domain(url)}]
|
| 150 |
+
if 'Nguồn tham khảo:' not in text:text+='\n\n'+source_line(src)
|
| 151 |
+
images=_collect_all_images(data)
|
| 152 |
+
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)
|
| 153 |
+
post['images']=images
|
| 154 |
+
posts=base._load_ai_wall();posts.insert(0,post);base._save_ai_wall(posts)
|
| 155 |
+
return JSONResponse({'post':post})
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
@app.post('/api/rewrite_share')
|
| 159 |
+
async def rewrite_share_url_only(request:Request):
|
| 160 |
+
return await url_wall_only(request)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
@app.post('/api/ai/url')
|
| 164 |
+
async def ai_url_compat(request:Request):
|
| 165 |
+
return await url_wall_only(request)
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
@app.post('/api/topic_post')
|
| 169 |
+
async def topic_disabled(request:Request):
|
| 170 |
+
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)
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def split_segments(post,max_segments=8):
|
| 174 |
+
text=clean(post.get('text') or post.get('title') or '')
|
| 175 |
+
text=re.sub(r'Nguồn tham khảo:.*$','',text,flags=re.I|re.S).strip()
|
| 176 |
+
lines=[]
|
| 177 |
+
for ln in text.splitlines():
|
| 178 |
+
ln=_strip_bullet_prefix(ln)
|
| 179 |
+
if len(ln)>=18:lines.append(ln)
|
| 180 |
+
if len(lines)<2:
|
| 181 |
+
lines=[_strip_bullet_prefix(s) for s in re.split(r'(?<=[\.\!\?])\s+',text) if len(_strip_bullet_prefix(s))>=25]
|
| 182 |
+
segs=[];cur=''
|
| 183 |
+
for ln in lines:
|
| 184 |
+
ln=_strip_bullet_prefix(ln)
|
| 185 |
+
if not ln:continue
|
| 186 |
+
if len(cur)+len(ln)<180:cur=(cur+' '+ln).strip()
|
| 187 |
+
else:
|
| 188 |
+
if cur:segs.append(_strip_bullet_prefix(cur))
|
| 189 |
+
cur=ln
|
| 190 |
+
if cur:segs.append(_strip_bullet_prefix(cur))
|
| 191 |
+
return segs[:max_segments] or [_strip_bullet_prefix(post.get('title','VNEWS'))]
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def wrap_text(draw,text,font,maxw,max_lines):
|
| 195 |
+
words=clean(text).split();lines=[];cur=''
|
| 196 |
+
for w in words:
|
| 197 |
+
test=(cur+' '+w).strip()
|
| 198 |
+
try:width=draw.textbbox((0,0),test,font=font)[2]
|
| 199 |
+
except Exception:width=len(test)*20
|
| 200 |
+
if width<=maxw:cur=test
|
| 201 |
+
else:
|
| 202 |
+
if cur:lines.append(cur)
|
| 203 |
+
cur=w
|
| 204 |
+
if len(lines)>=max_lines:break
|
| 205 |
+
if cur and len(lines)<max_lines:lines.append(cur)
|
| 206 |
+
return lines
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def _draw_center(draw, lines, font, y, fill, W, line_h):
|
| 210 |
+
for ln in lines:
|
| 211 |
+
try:
|
| 212 |
+
box=draw.textbbox((0,0),ln,font=font);tw=box[2]-box[0]
|
| 213 |
+
except Exception:
|
| 214 |
+
tw=len(ln)*24
|
| 215 |
+
x=max(30,(W-tw)//2)
|
| 216 |
+
draw.text((x,y),ln,fill=fill,font=font)
|
| 217 |
+
y+=line_h
|
| 218 |
+
return y
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def make_frame(post,seg,idx,total,img_path,out_path):
|
| 222 |
+
if Image is None:raise RuntimeError('Pillow not ready')
|
| 223 |
+
W,H=1080,1920;bg=Image.new('RGB',(W,H),(12,12,12))
|
| 224 |
+
hero_h=760
|
| 225 |
+
try:
|
| 226 |
+
im=Image.open(img_path).convert('RGB');ratio=im.width/max(1,im.height)
|
| 227 |
+
target=(W,hero_h);tr=target[0]/target[1]
|
| 228 |
+
if ratio>tr:nh=target[1];nw=int(nh*ratio)
|
| 229 |
+
else:nw=target[0];nh=int(nw/ratio)
|
| 230 |
+
im=im.resize((nw,nh));left=(nw-target[0])//2;top=(nh-target[1])//2
|
| 231 |
+
bg.paste(im.crop((left,top,left+target[0],top+target[1])),(0,0))
|
| 232 |
+
except Exception:pass
|
| 233 |
+
draw=ImageDraw.Draw(bg)
|
| 234 |
+
try:
|
| 235 |
+
fb=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',58)
|
| 236 |
+
ft=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',38)
|
| 237 |
+
fs=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',30)
|
| 238 |
+
fsmall=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',28)
|
| 239 |
+
except Exception:fb=ft=fs=fsmall=None
|
| 240 |
+
# source badge on top image corner
|
| 241 |
+
badge='Nguồn: '+_source_badge(post)
|
| 242 |
+
try:
|
| 243 |
+
b=draw.textbbox((0,0),badge,font=fsmall);bw=b[2]-b[0];bh=b[3]-b[1]
|
| 244 |
+
except Exception:
|
| 245 |
+
bw=len(badge)*16;bh=34
|
| 246 |
+
bx=W-bw-42;by=24
|
| 247 |
+
draw.rounded_rectangle((bx-16,by-8,W-24,by+bh+14),radius=18,fill=(0,0,0,170))
|
| 248 |
+
draw.text((bx,by),badge,fill=(255,255,255),font=fsmall)
|
| 249 |
+
# bottom text area
|
| 250 |
+
draw.rectangle((0,hero_h-20,W,H),fill=(12,12,12))
|
| 251 |
+
# progress bars centered
|
| 252 |
+
total_w=total*38-14;start=(W-total_w)//2
|
| 253 |
+
for i in range(total):
|
| 254 |
+
fill=(92,184,122) if i==idx else (70,70,70)
|
| 255 |
+
draw.rounded_rectangle((start+i*38,820,start+i*38+24,832),radius=6,fill=fill)
|
| 256 |
+
brand='VNEWS AI SHORT'
|
| 257 |
+
try:
|
| 258 |
+
bb=draw.textbbox((0,0),brand,font=ft);tx=(W-(bb[2]-bb[0]))//2
|
| 259 |
+
except Exception:tx=360
|
| 260 |
+
draw.text((tx,870),brand,fill=(110,231,143),font=ft)
|
| 261 |
+
clean_seg=_strip_bullet_prefix(seg)
|
| 262 |
+
lines=wrap_text(draw,clean_seg,fb,W-120,8)
|
| 263 |
+
block_h=len(lines)*74
|
| 264 |
+
y=max(980, 1250-block_h//2)
|
| 265 |
+
_draw_center(draw,lines,fb,y,(255,255,255),W,74)
|
| 266 |
+
# small title centered near bottom
|
| 267 |
+
title_lines=wrap_text(draw,_strip_bullet_prefix(post.get('title','')),fs,W-120,3)
|
| 268 |
+
y2=1640
|
| 269 |
+
draw.line((80,y2-26,W-80,y2-26),fill=(70,70,70),width=2)
|
| 270 |
+
_draw_center(draw,title_lines,fs,y2,(220,220,220),W,42)
|
| 271 |
+
bg.save(out_path,quality=92)
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
def make_tts(text,voice,out_path):
|
| 275 |
+
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')
|
| 276 |
+
text=_strip_bullet_prefix(text)
|
| 277 |
+
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)
|
| 278 |
+
except Exception:
|
| 279 |
+
tld='com.vn' if voice in ('nu','female','mien-nam') else 'com'
|
| 280 |
+
try:base.gTTS(text,lang='vi',tld=tld,slow=False).save(out_path)
|
| 281 |
+
except TypeError:base.gTTS(text,lang='vi',slow=False).save(out_path)
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
@app.post('/api/ai/short/{post_id}')
|
| 285 |
+
async def short_segments(post_id:str,request:Request):
|
| 286 |
+
try:body=await request.json()
|
| 287 |
+
except Exception:body={}
|
| 288 |
+
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)))
|
| 289 |
+
posts=base._load_ai_wall();post=next((p for p in posts if str(p.get('id'))==str(post_id)),None)
|
| 290 |
+
if not post:return JSONResponse({'error':'post not found'},status_code=404)
|
| 291 |
+
segs=split_segments(post,8)
|
| 292 |
+
os.makedirs(base.SHORTS_DIR,exist_ok=True);suffix=f'_{voice}_{emotion}_{str(speed).replace(".","p")}_centered_source_nobullet'
|
| 293 |
+
out=os.path.join(base.SHORTS_DIR,base._safe_name(post_id+suffix)+'.mp4')
|
| 294 |
+
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})
|
| 295 |
+
work=os.path.join(base.SHORTS_DIR,base._safe_name(post_id+suffix));os.makedirs(work,exist_ok=True)
|
| 296 |
+
img=os.path.join(work,'image.jpg');base._download_image(post.get('img'),post.get('title','AI news'),img)
|
| 297 |
+
clips=[]
|
| 298 |
+
try:
|
| 299 |
+
for i,seg in enumerate(segs):
|
| 300 |
+
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')
|
| 301 |
+
seg=_strip_bullet_prefix(seg)
|
| 302 |
+
make_frame(post,seg,i,len(segs),img,frame)
|
| 303 |
+
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,'')
|
| 304 |
+
spoken=(prefix+' '+seg).strip() if i==0 and prefix else seg
|
| 305 |
+
make_tts(spoken,voice,aud)
|
| 306 |
+
subprocess.run(['ffmpeg','-y','-i',aud,'-filter:a',f'atempo={speed}','-vn',aud2],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=120)
|
| 307 |
+
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)
|
| 308 |
+
clips.append(clip)
|
| 309 |
+
lf=os.path.join(work,'list.txt')
|
| 310 |
+
with open(lf,'w',encoding='utf-8') as f:
|
| 311 |
+
for c in clips:f.write("file '{}".format(c.replace("'","'\\''"))+"'\n")
|
| 312 |
+
subprocess.run(['ffmpeg','-y','-f','concat','-safe','0','-i',lf,'-c','copy',out],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=240)
|
| 313 |
+
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)
|
| 314 |
+
return JSONResponse({'video':post['video'],'segments':len(segs),'subtitles':False})
|
| 315 |
+
except Exception as e:return JSONResponse({'error':'Không tạo được shorts: '+str(e)[:200]},status_code=500)
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
@app.get('/api/ai/short-file/{file_id}')
|
| 319 |
+
def short_file(file_id:str):
|
| 320 |
+
path=os.path.join(base.SHORTS_DIR,base._safe_name(file_id)+'.mp4')
|
| 321 |
+
if not os.path.exists(path):return JSONResponse({'error':'not found'},status_code=404)
|
| 322 |
+
return FileResponse(path,media_type='video/mp4',filename=f'vnews-ai-{file_id}.mp4')
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
# Rebuild / with old UI injection plus final UI overrides.
|
| 326 |
+
app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
|
| 327 |
+
@app.get('/')
|
| 328 |
+
async def index_runtime():
|
| 329 |
+
with open('/app/static/index.html','r',encoding='utf-8') as f:html=f.read()
|
| 330 |
+
inject=getattr(old,'PATCH_INJECT','')+r'''
|
| 331 |
+
<style>
|
| 332 |
+
/* Hide old topic UI, keep URL input only */
|
| 333 |
+
#ai-topic-input{display:none!important}
|
| 334 |
+
#ai-topic-input,*[onclick*="createTopicPost"]{display:none!important}
|
| 335 |
+
.ai-topic-row,.topic-row,.ai-compose-topic{display:none!important}
|
| 336 |
+
.ai-wall-gallery{display:grid;grid-template-columns:repeat(2,1fr);gap:6px;margin:10px 0}.ai-wall-gallery img{width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:8px;background:#222}.ai-wall-gallery img:first-child{grid-column:1/-1}.ai-url-only-note{font-size:11px;color:#888;margin:5px 0 8px}
|
| 337 |
+
</style>
|
| 338 |
+
<script>
|
| 339 |
+
(function(){
|
| 340 |
+
function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));}
|
| 341 |
+
function hideTopicControls(){
|
| 342 |
+
document.querySelectorAll('#ai-topic-input').forEach(e=>{let p=e.closest('.ai-compose,.ai-compose-topic,.topic-row,div'); if(p&&p.querySelector('#ai-url-input')) e.style.display='none'; else if(p) p.style.display='none';});
|
| 343 |
+
document.querySelectorAll('button').forEach(b=>{let t=(b.textContent||'').toLowerCase();let oc=b.getAttribute('onclick')||'';if(oc.includes('createTopicPost')||t.includes('chủ đề'))b.style.display='none';});
|
| 344 |
+
let url=document.getElementById('ai-url-input'); if(url&&!document.getElementById('ai-url-only-note')){let n=document.createElement('div');n.id='ai-url-only-note';n.className='ai-url-only-note';n.textContent='Dán URL bài viết để AI tóm tắt và lấy ảnh từ bài.';url.insertAdjacentElement('afterend',n);}
|
| 345 |
+
}
|
| 346 |
+
window.createTopicPost=function(){alert('Đã tắt ô nhập chủ đề. Vui lòng dán URL bài viết.');};
|
| 347 |
+
window.createUrlPost=function(){let inp=document.getElementById('ai-url-input');let url=(inp&&inp.value||'').trim();if(!url)return alert('Dán URL trước');if(!/^https?:\/\//i.test(url))return alert('URL cần bắt đầu bằng http:// hoặc https://');fetch('/api/url_wall',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})}).then(r=>r.json().then(j=>({ok:r.ok,j}))).then(({ok,j})=>{if(ok&&j.post){if(typeof prependWallPost==='function')prependWallPost(j.post);if(window.patchedWall)window.patchedWall=[j.post].concat(window.patchedWall||[]);if(inp)inp.value='';alert('Đã tóm tắt URL, lấy ảnh trong bài và đăng lên Tường AI');location.reload();}else alert(j.error||'Lỗi URL')}).catch(e=>alert(e.message||'Lỗi URL'));};
|
| 348 |
+
function galleryHtml(p){let imgs=(p.images||[]).filter(Boolean);if(!imgs.length&&p.img)imgs=[p.img];if(!imgs.length)return '';return '<div class="ai-wall-gallery">'+imgs.slice(0,12).map(u=>`<img src="${esc(u)}" loading="lazy">`).join('')+'</div>';}
|
| 349 |
+
function patchReaders(){
|
| 350 |
+
let oldRead=window.aiReadWallPatched||window.aiReadWall;
|
| 351 |
+
window.aiReadWallPatched=window.aiReadWall=function(i){let arr=window.patchedWall||window.aiWall||[];let p=arr[i];if(!p&&oldRead)return oldRead(i);if(!p)return;showView('view-article');let sources='';if(p.sources&&p.sources.length){sources='<div class="article-summary"><b>Nguồn tham khảo:</b><br>'+p.sources.slice(0,5).map(s=>`• ${esc(s.title||s.url||'Nguồn')} ${s.url?`(${esc(new URL(s.url).hostname.replace('www.',''))})`:''}`).join('<br>')+'</div>'}let h=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><span class="badge badge-ai">AI</span><h1 class="article-title">${esc(p.title)}</h1>${galleryHtml(p)}${sources}<p class="article-p" style="white-space:pre-wrap">${esc(p.text)}</p>${p.video?`<video class="article-img" src="${p.video}" controls playsinline></video>`:''}<div class="article-actions">${p.url?`<button onclick="window.open('${p.url}','_blank')">🔗 Nguồn</button>`:''}<button onclick="aiMakeShortPatched?aiMakeShortPatched(${i}):aiMakeShort(${i})">🎬 Tạo video shorts</button></div></div>`;document.getElementById('view-article').innerHTML=h;window.scrollTo(0,0);};
|
| 352 |
+
}
|
| 353 |
+
setInterval(hideTopicControls,1000);setTimeout(hideTopicControls,300);setTimeout(patchReaders,1600);
|
| 354 |
+
})();
|
| 355 |
+
</script>
|
| 356 |
+
'''
|
| 357 |
+
return HTMLResponse(html.replace('</body>',inject+'\n</body>') if '</body>' in html else html+inject)
|
ai_runtime_final.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Final runtime overrides for VNEWS AI UI, article-only images, shareable AI wall, and robust Vietnamese shorts."""
|
| 2 |
+
import os, re, requests, subprocess, time
|
| 3 |
+
from urllib.parse import urlparse, quote
|
| 4 |
+
import ai_runtime as rt
|
| 5 |
+
from ai_runtime import app
|
| 6 |
+
import ai_ext as base
|
| 7 |
+
from fastapi import Request, Query
|
| 8 |
+
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
|
| 9 |
+
try:
|
| 10 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 11 |
+
except Exception:
|
| 12 |
+
Image = ImageDraw = ImageFont = None
|
| 13 |
+
|
| 14 |
+
RESTORE_INDEX_URL = "https://huggingface.co/spaces/bep40/vnews/raw/restore-33c3dda/static/index.html"
|
| 15 |
+
SPACE_URL = "https://bep40-vnews.hf.space"
|
| 16 |
+
DEFAULT_IMG = "https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg"
|
| 17 |
+
|
| 18 |
+
# Only voices that support Vietnamese reliably. Extra labels map to these Vietnamese neural voices.
|
| 19 |
+
VN_VOICES = {
|
| 20 |
+
"nu": "vi-VN-HoaiMyNeural", "female": "vi-VN-HoaiMyNeural", "hoaimy": "vi-VN-HoaiMyNeural",
|
| 21 |
+
"nu-tre": "vi-VN-HoaiMyNeural", "nu-truyen-cam": "vi-VN-HoaiMyNeural", "nu-tin-nhanh": "vi-VN-HoaiMyNeural",
|
| 22 |
+
"nam": "vi-VN-NamMinhNeural", "male": "vi-VN-NamMinhNeural", "namminh": "vi-VN-NamMinhNeural",
|
| 23 |
+
"nam-tram": "vi-VN-NamMinhNeural", "nam-ban-tin": "vi-VN-NamMinhNeural", "nam-nang-dong": "vi-VN-NamMinhNeural",
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def clean(s):
|
| 28 |
+
import html as html_lib
|
| 29 |
+
return re.sub(r"\s+", " ", html_lib.unescape(s or "")).strip()
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _domain(url):
|
| 33 |
+
try:return urlparse(url or '').netloc.replace('www.','')
|
| 34 |
+
except Exception:return ''
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _strip_bullet_prefix(s):
|
| 38 |
+
return clean(re.sub(r'^[\s•\-\*·▪▫●○\d\.\)\(]+', '', s or ''))
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _source_badge_url_first(post):
|
| 42 |
+
d=_domain(post.get('url',''))
|
| 43 |
+
if d:return d
|
| 44 |
+
for s in post.get('sources') or []:
|
| 45 |
+
d=_domain(s.get('url',''))
|
| 46 |
+
if d:return d
|
| 47 |
+
return 'VNEWS'
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _abs_url(src, base_url):
|
| 51 |
+
if not src:return ''
|
| 52 |
+
src=src.strip()
|
| 53 |
+
if src.startswith('//'):return 'https:'+src
|
| 54 |
+
if src.startswith('/'):
|
| 55 |
+
try:
|
| 56 |
+
p=urlparse(base_url);return f'{p.scheme}://{p.netloc}{src}'
|
| 57 |
+
except Exception:return src
|
| 58 |
+
return src
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _article_content_block(soup):
|
| 62 |
+
for tag in soup.find_all(['script','style','nav','footer','aside','form','noscript','iframe']):tag.decompose()
|
| 63 |
+
# Aggressively remove related/ad/recommend containers before image collection.
|
| 64 |
+
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)
|
| 65 |
+
for el in list(soup.find_all(True)):
|
| 66 |
+
cls=' '.join(el.get('class',[])); eid=el.get('id',''); role=el.get('role','')
|
| 67 |
+
if bad_re.search(cls) or bad_re.search(eid) or bad_re.search(role):
|
| 68 |
+
el.decompose()
|
| 69 |
+
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]']
|
| 70 |
+
for sel in selectors:
|
| 71 |
+
el=soup.select_one(sel)
|
| 72 |
+
if el and (len(el.find_all('p'))>=2 or len(el.find_all(['figure','picture','img']))>=1):return el
|
| 73 |
+
best=None;score=0
|
| 74 |
+
for el in soup.find_all(['article','main','section','div']):
|
| 75 |
+
ps=el.find_all('p');imgs=el.find_all('img');txt=' '.join(p.get_text(' ',strip=True) for p in ps)
|
| 76 |
+
sc=len(ps)*120+len(imgs)*10+min(len(txt),4500)
|
| 77 |
+
cls=' '.join(el.get('class',[])).lower()
|
| 78 |
+
if any(k in cls for k in ['article','content','detail','post','entry','story']):sc+=800
|
| 79 |
+
if sc>score:best=el;score=sc
|
| 80 |
+
return best or soup
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _image_is_likely_article(im, src):
|
| 84 |
+
low=(src or '').lower()
|
| 85 |
+
if not src or src.startswith('data:') or 'base64' in low:return False
|
| 86 |
+
if any(x in low for x in ['logo','icon','avatar','sprite','banner','ads','advert','tracking','pixel','social','share','author','thumb-related']):return False
|
| 87 |
+
alt=(im.get('alt') or im.get('title') or '').lower()
|
| 88 |
+
if any(x in alt for x in ['logo','avatar','quảng cáo','advertisement','banner']):return False
|
| 89 |
+
try:
|
| 90 |
+
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)
|
| 91 |
+
if (w and w<220) or (h and h<140):return False
|
| 92 |
+
except Exception:pass
|
| 93 |
+
return True
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _article_only_images(url):
|
| 97 |
+
"""Collect images only inside main article content. If uncertain, return fewer/no images rather than related/ad images."""
|
| 98 |
+
imgs=[]
|
| 99 |
+
try:
|
| 100 |
+
from bs4 import BeautifulSoup
|
| 101 |
+
r=requests.get(url,headers=getattr(base,'HEADERS',{}),timeout=18);r.encoding='utf-8'
|
| 102 |
+
soup=BeautifulSoup(r.text,'lxml')
|
| 103 |
+
block=_article_content_block(soup)
|
| 104 |
+
candidates=[]
|
| 105 |
+
# Prefer figure/picture under article body; then direct img in body.
|
| 106 |
+
for el in block.find_all(['figure','picture'],recursive=True):
|
| 107 |
+
im=el.find('img')
|
| 108 |
+
if im:candidates.append(im)
|
| 109 |
+
for im in block.find_all('img',recursive=True):
|
| 110 |
+
if im not in candidates:candidates.append(im)
|
| 111 |
+
seen=set()
|
| 112 |
+
for im in candidates:
|
| 113 |
+
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 '')
|
| 114 |
+
if ',' in src:src=src.split(',')[0].strip().split(' ')[0]
|
| 115 |
+
else:src=src.strip().split(' ')[0]
|
| 116 |
+
src=_abs_url(src,url)
|
| 117 |
+
if src in seen or not _image_is_likely_article(im,src):continue
|
| 118 |
+
# parent text guard: skip images from any remaining related block
|
| 119 |
+
parent_txt=' '.join((im.parent.get('class',[]) if im.parent else []))+' '+(im.parent.get('id','') if im.parent else '')
|
| 120 |
+
if re.search(r'(related|recommend|tin-lien-quan|doc-them|xem-them|popular|ads|banner)',parent_txt,re.I):continue
|
| 121 |
+
seen.add(src);imgs.append(src)
|
| 122 |
+
if len(imgs)>=20:break
|
| 123 |
+
# Use og:image ONLY as article main image fallback when no body image found.
|
| 124 |
+
if not imgs:
|
| 125 |
+
og=soup.find('meta',property='og:image') or soup.find('meta',attrs={'name':'twitter:image'})
|
| 126 |
+
if og:
|
| 127 |
+
src=_abs_url(og.get('content',''),url)
|
| 128 |
+
if src and 'logo' not in src.lower() and 'banner' not in src.lower():imgs.append(src)
|
| 129 |
+
except Exception:pass
|
| 130 |
+
return imgs[:20]
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def _scrape_url_article_only(url):
|
| 134 |
+
data=base.scrape_any_url(url)
|
| 135 |
+
imgs=_article_only_images(url)
|
| 136 |
+
data['images']=imgs
|
| 137 |
+
if imgs:data['image']=imgs[0]
|
| 138 |
+
else:data['image']=''
|
| 139 |
+
return data
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def _blank_image(path, title='VNEWS'):
|
| 143 |
+
if Image is None:return None
|
| 144 |
+
im=Image.new('RGB',(1080,760),(24,48,36));draw=ImageDraw.Draw(im)
|
| 145 |
+
try:f=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',48)
|
| 146 |
+
except Exception:f=None
|
| 147 |
+
draw.text((60,330),clean(title)[:40] or 'VNEWS',fill=(255,255,255),font=f)
|
| 148 |
+
im.save(path,quality=90);return path
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def _download_image_safe(url, fallback_title, out_path):
|
| 152 |
+
if url:
|
| 153 |
+
try:
|
| 154 |
+
r=requests.get(url,headers=getattr(base,'HEADERS',{}),timeout=18)
|
| 155 |
+
if r.status_code==200 and len(r.content)>1200:
|
| 156 |
+
with open(out_path,'wb') as f:f.write(r.content)
|
| 157 |
+
# verify PIL opens it
|
| 158 |
+
if Image:
|
| 159 |
+
Image.open(out_path).verify()
|
| 160 |
+
return out_path
|
| 161 |
+
except Exception:pass
|
| 162 |
+
try:
|
| 163 |
+
return base._download_image('',fallback_title,out_path)
|
| 164 |
+
except Exception:
|
| 165 |
+
return _blank_image(out_path,fallback_title)
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def final_make_tts(text,voice,out_path):
|
| 169 |
+
text=_strip_bullet_prefix(text) or 'Bản tin VNEWS.'
|
| 170 |
+
# Only Vietnamese voices. Unknown choices fall back to Vietnamese female.
|
| 171 |
+
edge_voice=VN_VOICES.get(str(voice or '').lower().strip(), 'vi-VN-HoaiMyNeural')
|
| 172 |
+
for ev in [edge_voice, 'vi-VN-HoaiMyNeural', 'vi-VN-NamMinhNeural']:
|
| 173 |
+
try:
|
| 174 |
+
subprocess.run(['python','-m','edge_tts','--voice',ev,'--text',text,'--write-media',out_path],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=180)
|
| 175 |
+
if os.path.exists(out_path) and os.path.getsize(out_path)>1000:return out_path
|
| 176 |
+
except Exception:pass
|
| 177 |
+
try:
|
| 178 |
+
base.gTTS(text,lang='vi',tld='com.vn',slow=False).save(out_path)
|
| 179 |
+
if os.path.exists(out_path) and os.path.getsize(out_path)>1000:return out_path
|
| 180 |
+
except Exception:pass
|
| 181 |
+
# Last-resort silent audio guarantees short generation succeeds.
|
| 182 |
+
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)
|
| 183 |
+
return out_path
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def _draw_center(draw, lines, font, y, fill, W, line_h):
|
| 187 |
+
for ln in lines:
|
| 188 |
+
try:box=draw.textbbox((0,0),ln,font=font);tw=box[2]-box[0]
|
| 189 |
+
except Exception:tw=len(ln)*24
|
| 190 |
+
draw.text((max(30,(W-tw)//2),y),ln,fill=fill,font=font);y+=line_h
|
| 191 |
+
return y
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def final_make_frame(post,seg,idx,total,img_path,out_path):
|
| 195 |
+
if Image is None:return rt.make_frame(post,seg,idx,total,img_path,out_path)
|
| 196 |
+
W,H=1080,1920;hero_h=760;bg=Image.new('RGB',(W,H),(12,12,12))
|
| 197 |
+
try:
|
| 198 |
+
im=Image.open(img_path).convert('RGB');ratio=im.width/max(1,im.height);tr=W/hero_h
|
| 199 |
+
if ratio>tr:nh=hero_h;nw=int(nh*ratio)
|
| 200 |
+
else:nw=W;nh=int(nw/ratio)
|
| 201 |
+
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))
|
| 202 |
+
except Exception:pass
|
| 203 |
+
draw=ImageDraw.Draw(bg)
|
| 204 |
+
try:
|
| 205 |
+
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)
|
| 206 |
+
except Exception:fb=ft=fs=fsmall=None
|
| 207 |
+
badge='Nguồn: '+_source_badge_url_first(post)
|
| 208 |
+
try:b=draw.textbbox((0,0),badge,font=fsmall);bw=b[2]-b[0];bh=b[3]-b[1]
|
| 209 |
+
except Exception:bw=len(badge)*16;bh=34
|
| 210 |
+
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)
|
| 211 |
+
draw.rectangle((0,hero_h-20,W,H),fill=(12,12,12))
|
| 212 |
+
total=max(1,total);total_w=total*38-14;start=(W-total_w)//2
|
| 213 |
+
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))
|
| 214 |
+
brand='VNEWS AI SHORT'
|
| 215 |
+
try:bb=draw.textbbox((0,0),brand,font=ft);tx=(W-(bb[2]-bb[0]))//2
|
| 216 |
+
except Exception:tx=360
|
| 217 |
+
draw.text((tx,870),brand,fill=(110,231,143),font=ft)
|
| 218 |
+
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)
|
| 219 |
+
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)
|
| 220 |
+
bg.save(out_path,quality=92)
|
| 221 |
+
|
| 222 |
+
# Monkey patches for old functions.
|
| 223 |
+
rt.make_frame=final_make_frame;rt.make_tts=final_make_tts;rt._source_badge=_source_badge_url_first
|
| 224 |
+
|
| 225 |
+
# Override endpoints.
|
| 226 |
+
_PATCH={('/api/url_wall','POST'),('/api/rewrite_share','POST'),('/api/ai/url','POST'),('/api/ai/short/{post_id}','POST'),('/','GET'),('/aw','GET')}
|
| 227 |
+
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)]
|
| 228 |
+
|
| 229 |
+
@app.post('/api/url_wall')
|
| 230 |
+
async def final_url_wall(request:Request):
|
| 231 |
+
body=await request.json();url=base._clean_text(body.get('url',''))
|
| 232 |
+
if not url.startswith('http'):return JSONResponse({'error':'missing url'},status_code=400)
|
| 233 |
+
try:data=_scrape_url_article_only(url)
|
| 234 |
+
except Exception as e:return JSONResponse({'error':'Không scrape được URL: '+str(e)[:180]},status_code=422)
|
| 235 |
+
raw=(data.get('summary','')+'\n'+data.get('text','')).strip()
|
| 236 |
+
if len(raw)<120:return JSONResponse({'error':'URL không có đủ nội dung để tóm tắt'},status_code=422)
|
| 237 |
+
prompt=f"""Tóm tắt bài viết nguồn dưới đây để đăng lên Tường AI VNEWS.
|
| 238 |
+
|
| 239 |
+
Yêu cầu:
|
| 240 |
+
- Chỉ tóm tắt nội dung chính, không viết lại toàn bộ bài.
|
| 241 |
+
- Ngắn gọn, cụ thể, dễ hiểu.
|
| 242 |
+
- Không lặp ý, không thêm chi tiết ngoài nguồn.
|
| 243 |
+
- Tối đa 5 ý chính hoặc 2 đoạn ngắn.
|
| 244 |
+
- Hạn chế dùng dấu đầu dòng.
|
| 245 |
+
|
| 246 |
+
Tiêu đề gốc: {data.get('title','')}
|
| 247 |
+
Nguồn: {_domain(url)}
|
| 248 |
+
Nội dung gốc:
|
| 249 |
+
{raw[:16000]}"""
|
| 250 |
+
text=await base.qwen_generate(prompt,image_url=(data.get('image') or None),max_tokens=900)
|
| 251 |
+
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]
|
| 252 |
+
text=rt.postprocess(text) if hasattr(rt,'postprocess') else text
|
| 253 |
+
src=[{'title':data.get('title'), 'url':url, 'excerpt':raw[:500], 'via':_domain(url)}]
|
| 254 |
+
if 'Nguồn tham khảo:' not in text:text+='\n\n'+rt.source_line(src)
|
| 255 |
+
imgs=data.get('images') or []
|
| 256 |
+
post=base.make_post(data.get('title') or 'Bài viết',text,imgs[0] if imgs else '',url,'url',sources=src)
|
| 257 |
+
post['images']=imgs
|
| 258 |
+
posts=base._load_ai_wall();posts.insert(0,post);base._save_ai_wall(posts)
|
| 259 |
+
return JSONResponse({'post':post})
|
| 260 |
+
|
| 261 |
+
@app.post('/api/rewrite_share')
|
| 262 |
+
async def final_rewrite_share(request:Request):return await final_url_wall(request)
|
| 263 |
+
@app.post('/api/ai/url')
|
| 264 |
+
async def final_ai_url(request:Request):return await final_url_wall(request)
|
| 265 |
+
|
| 266 |
+
@app.post('/api/ai/short/{post_id}')
|
| 267 |
+
async def final_short(post_id:str,request:Request):
|
| 268 |
+
try:body=await request.json()
|
| 269 |
+
except Exception:body={}
|
| 270 |
+
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)))
|
| 271 |
+
posts=base._load_ai_wall();post=next((p for p in posts if str(p.get('id'))==str(post_id)),None)
|
| 272 |
+
if not post:return JSONResponse({'error':'post not found'},status_code=404)
|
| 273 |
+
segs=rt.split_segments(post,8) if hasattr(rt,'split_segments') else [_strip_bullet_prefix(post.get('text') or post.get('title') or 'VNEWS')]
|
| 274 |
+
imgs=[u for u in (post.get('images') or []) if u] or ([post.get('img')] if post.get('img') else [])
|
| 275 |
+
os.makedirs(base.SHORTS_DIR,exist_ok=True);suffix=f'_{voice}_{emotion}_{str(speed).replace(".","p")}_articleimgs_vivoice'
|
| 276 |
+
out=os.path.join(base.SHORTS_DIR,base._safe_name(post_id+suffix)+'.mp4')
|
| 277 |
+
if os.path.exists(out):
|
| 278 |
+
post['video']='/api/ai/short-file/'+post_id+suffix;base._save_ai_wall(posts);return JSONResponse({'video':post['video'],'segments':len(segs),'subtitles':False})
|
| 279 |
+
work=os.path.join(base.SHORTS_DIR,base._safe_name(post_id+suffix));os.makedirs(work,exist_ok=True)
|
| 280 |
+
clips=[]
|
| 281 |
+
try:
|
| 282 |
+
for i,seg in enumerate(segs):
|
| 283 |
+
img_url=imgs[i % len(imgs)] if imgs else ''
|
| 284 |
+
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')
|
| 285 |
+
_download_image_safe(img_url,post.get('title','AI news'),img)
|
| 286 |
+
seg=_strip_bullet_prefix(seg);final_make_frame(post,seg,i,len(segs),img,frame)
|
| 287 |
+
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,'')
|
| 288 |
+
spoken=(prefix+' '+seg).strip() if i==0 and prefix else seg
|
| 289 |
+
final_make_tts(spoken,voice,aud)
|
| 290 |
+
try:subprocess.run(['ffmpeg','-y','-i',aud,'-filter:a',f'atempo={speed}','-vn',aud2],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=120)
|
| 291 |
+
except Exception:aud2=aud
|
| 292 |
+
try:
|
| 293 |
+
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)
|
| 294 |
+
except Exception:
|
| 295 |
+
# last-resort visual-only 4s clip
|
| 296 |
+
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)
|
| 297 |
+
clips.append(clip)
|
| 298 |
+
lf=os.path.join(work,'list.txt')
|
| 299 |
+
with open(lf,'w',encoding='utf-8') as f:
|
| 300 |
+
for c in clips:f.write("file '"+c.replace("","'\\''"))+"'\n")
|
| 301 |
+
subprocess.run(['ffmpeg','-y','-f','concat','-safe','0','-i',lf,'-c','copy',out],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=240)
|
| 302 |
+
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)
|
| 303 |
+
return JSONResponse({'video':post['video'],'segments':len(segs),'subtitles':False})
|
| 304 |
+
except Exception as e:return JSONResponse({'error':'Không tạo được shorts: '+str(e)[:220]},status_code=500)
|
| 305 |
+
|
| 306 |
+
@app.get('/aw')
|
| 307 |
+
def ai_wall_share(post:str=Query(default=''), short:int=Query(default=0)):
|
| 308 |
+
posts=base._load_ai_wall();p=next((x for x in posts if str(x.get('id'))==str(post)),None)
|
| 309 |
+
if not p:return HTMLResponse(f'<script>location.href="{SPACE_URL}"</script>')
|
| 310 |
+
title=p.get('title') or 'VNEWS AI';img=p.get('img') or DEFAULT_IMG
|
| 311 |
+
desc=(p.get('text') or '')[:220]
|
| 312 |
+
return HTMLResponse(f'<!doctype html><html><head><meta charset="utf-8"><title>{title}</title><meta property="og:title" content="{title}"><meta property="og:description" content="{desc}"><meta property="og:image" content="{img}"><meta property="og:type" content="article"><meta name="twitter:card" content="summary_large_image"></head><body><script>localStorage.setItem('pending_ai_post','{post}');location.href='{SPACE_URL}'</script></body></html>')
|
| 313 |
+
|
| 314 |
+
FINAL_INJECT = r'''
|
| 315 |
+
<style>
|
ai_runtime_final2.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Final2: improve article-image detection without over-filtering real article images."""
|
| 2 |
+
import re, requests
|
| 3 |
+
from urllib.parse import urlparse
|
| 4 |
+
import ai_runtime_final as f1
|
| 5 |
+
from ai_runtime_final import app, base, rt, HTMLResponse, JSONResponse, Request, Query
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _domain(url):
|
| 9 |
+
try:return urlparse(url or '').netloc.replace('www.','')
|
| 10 |
+
except Exception:return ''
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _abs_url(src, base_url):
|
| 14 |
+
if not src:return ''
|
| 15 |
+
src=src.strip()
|
| 16 |
+
if src.startswith('//'):return 'https:'+src
|
| 17 |
+
if src.startswith('/'):
|
| 18 |
+
try:
|
| 19 |
+
p=urlparse(base_url);return f'{p.scheme}://{p.netloc}{src}'
|
| 20 |
+
except Exception:return src
|
| 21 |
+
return src
|
| 22 |
+
|
| 23 |
+
BAD_RE=re.compile(r'(related|relate|recommend|suggest|sidebar|ads|advert|popular|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|most-view|banner|qc|quang-cao|sponsor|social|share|comment|author|newsletter)',re.I)
|
| 24 |
+
GOOD_RE=re.compile(r'(article|content|detail|body|post|entry|story|fck|cms|singular|main|news)',re.I)
|
| 25 |
+
IMG_EXT_RE=re.compile(r'\.(jpg|jpeg|png|webp|avif)(\?|$)',re.I)
|
| 26 |
+
ARTICLE_LINK_RE=re.compile(r'\.(html|htm|shtml|tpo|chn)(\?|$)|/\d{4}/|post\d+|article',re.I)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _clean_soup(soup):
|
| 30 |
+
for tag in soup.find_all(['script','style','nav','footer','aside','form','noscript','iframe']):
|
| 31 |
+
tag.decompose()
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _find_article_block(soup):
|
| 35 |
+
"""Find the article body first; do not delete suspected related blocks before finding it."""
|
| 36 |
+
selectors=[
|
| 37 |
+
'article', 'main article',
|
| 38 |
+
'.article-content','.article__content','.article__body','.article-body','.article-detail','.article__detail',
|
| 39 |
+
'.detail-content','.content-detail','.singular-content','.news-content','.post-content','.entry-content',
|
| 40 |
+
'.knc-content','.fck_detail','.cms-body','.story-body','.maincontent','.main-content',
|
| 41 |
+
'[class*=article-content]','[class*=article__content]','[class*=detail-content]','[class*=singular-content]',
|
| 42 |
+
'[class*=cms-body]','[class*=story-body]'
|
| 43 |
+
]
|
| 44 |
+
for sel in selectors:
|
| 45 |
+
el=soup.select_one(sel)
|
| 46 |
+
if el and (len(el.find_all('p'))>=2 or len(el.find_all(['figure','picture','img']))>=1):
|
| 47 |
+
return el
|
| 48 |
+
best=None;best_score=0
|
| 49 |
+
for el in soup.find_all(['article','main','section','div']):
|
| 50 |
+
cls=' '.join(el.get('class',[]));eid=el.get('id','')
|
| 51 |
+
if BAD_RE.search(cls+' '+eid) and not GOOD_RE.search(cls+' '+eid):
|
| 52 |
+
continue
|
| 53 |
+
ps=el.find_all('p');imgs=el.find_all('img')
|
| 54 |
+
text=' '.join(p.get_text(' ',strip=True) for p in ps)
|
| 55 |
+
long_ps=sum(1 for p in ps if len(p.get_text(' ',strip=True))>40)
|
| 56 |
+
score=long_ps*180+len(ps)*40+min(len(text),5000)+len(imgs)*25
|
| 57 |
+
if GOOD_RE.search(cls+' '+eid):score+=800
|
| 58 |
+
if score>best_score:
|
| 59 |
+
best=el;best_score=score
|
| 60 |
+
return best or soup
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _ancestor_bad(im, block):
|
| 64 |
+
node=im
|
| 65 |
+
while node and node is not block:
|
| 66 |
+
if getattr(node,'name',None) in ['aside','nav','footer']:
|
| 67 |
+
return True
|
| 68 |
+
cls=' '.join(node.get('class',[])) if hasattr(node,'get') else ''
|
| 69 |
+
eid=node.get('id','') if hasattr(node,'get') else ''
|
| 70 |
+
if BAD_RE.search(cls+' '+eid):
|
| 71 |
+
return True
|
| 72 |
+
node=getattr(node,'parent',None)
|
| 73 |
+
return False
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _image_anchor_penalty(im, page_url):
|
| 77 |
+
a=im.find_parent('a')
|
| 78 |
+
if not a:return 0
|
| 79 |
+
href=_abs_url(a.get('href',''),page_url)
|
| 80 |
+
if not href:return 0
|
| 81 |
+
# If anchor opens the image itself, do not penalize.
|
| 82 |
+
if IMG_EXT_RE.search(href):return 0
|
| 83 |
+
# If anchor points to another article, it is probably related content.
|
| 84 |
+
try:
|
| 85 |
+
p1=urlparse(page_url);p2=urlparse(href)
|
| 86 |
+
if href!=page_url and ARTICLE_LINK_RE.search(href) and (p2.path!=p1.path):
|
| 87 |
+
return -100
|
| 88 |
+
except Exception:pass
|
| 89 |
+
return -10
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _near_article_text_score(im):
|
| 93 |
+
score=0
|
| 94 |
+
# caption/figcaption is strong sign of article image
|
| 95 |
+
fig=im.find_parent('figure')
|
| 96 |
+
if fig:
|
| 97 |
+
score+=5
|
| 98 |
+
cap=fig.find('figcaption')
|
| 99 |
+
if cap and len(cap.get_text(' ',strip=True))>10:score+=4
|
| 100 |
+
if im.find_parent('picture'):score+=2
|
| 101 |
+
# paragraph around image
|
| 102 |
+
parent=im.parent
|
| 103 |
+
for node in [parent, getattr(parent,'parent',None) if parent else None, fig]:
|
| 104 |
+
if not node:continue
|
| 105 |
+
ps=node.find_all('p') if hasattr(node,'find_all') else []
|
| 106 |
+
if any(len(p.get_text(' ',strip=True))>40 for p in ps):score+=3;break
|
| 107 |
+
# sibling paragraph near figure/image
|
| 108 |
+
holder=fig or parent
|
| 109 |
+
if holder:
|
| 110 |
+
for sib in [holder.find_previous_sibling(), holder.find_next_sibling()]:
|
| 111 |
+
if sib and len(sib.get_text(' ',strip=True))>40:
|
| 112 |
+
score+=2
|
| 113 |
+
break
|
| 114 |
+
return score
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _image_score(im, src, block, page_url):
|
| 118 |
+
low=(src or '').lower()
|
| 119 |
+
if not src or src.startswith('data:') or 'base64' in low:return -999
|
| 120 |
+
if any(x in low for x in ['logo','icon','avatar','sprite','tracking','pixel','social','share','author']):return -999
|
| 121 |
+
if _ancestor_bad(im,block):return -999
|
| 122 |
+
score=0
|
| 123 |
+
# Explicit dimensions: only reject truly tiny images; if missing dimensions, allow.
|
| 124 |
+
try:
|
| 125 |
+
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)
|
| 126 |
+
if (w and w<120) or (h and h<90):return -999
|
| 127 |
+
if w>=500 or h>=300:score+=3
|
| 128 |
+
except Exception:pass
|
| 129 |
+
alt=(im.get('alt') or im.get('title') or '').lower()
|
| 130 |
+
if any(x in alt for x in ['logo','avatar','quảng cáo','advertisement','banner']):return -999
|
| 131 |
+
cls=' '.join(im.get('class',[]));eid=im.get('id','')
|
| 132 |
+
if BAD_RE.search(cls+' '+eid):return -999
|
| 133 |
+
if GOOD_RE.search(cls+' '+eid):score+=2
|
| 134 |
+
score+=_near_article_text_score(im)
|
| 135 |
+
score+=_image_anchor_penalty(im,page_url)
|
| 136 |
+
if any(x in low for x in ['cdn','photo','image','media','upload','thumb','avatar']):score+=1
|
| 137 |
+
# Tienphong and many VN papers use lazy/data src without figure; still accept if inside article block.
|
| 138 |
+
if im.find_parent(['article','main']) or GOOD_RE.search(' '.join(block.get('class',[]))+' '+block.get('id','')):score+=3
|
| 139 |
+
return score
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def _extract_img_src(im, page_url):
|
| 143 |
+
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 '')
|
| 144 |
+
if ',' in src:src=src.split(',')[0].strip().split(' ')[0]
|
| 145 |
+
else:src=src.strip().split(' ')[0]
|
| 146 |
+
return _abs_url(src,page_url)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def _article_only_images(url):
|
| 150 |
+
imgs=[]
|
| 151 |
+
try:
|
| 152 |
+
from bs4 import BeautifulSoup
|
| 153 |
+
r=requests.get(url,headers=getattr(base,'HEADERS',{}),timeout=18);r.encoding='utf-8'
|
| 154 |
+
soup=BeautifulSoup(r.text,'lxml')
|
| 155 |
+
_clean_soup(soup)
|
| 156 |
+
block=_find_article_block(soup)
|
| 157 |
+
candidates=[]
|
| 158 |
+
for el in block.find_all(['figure','picture'],recursive=True):
|
| 159 |
+
im=el.find('img')
|
| 160 |
+
if im and im not in candidates:candidates.append(im)
|
| 161 |
+
for im in block.find_all('img',recursive=True):
|
| 162 |
+
if im not in candidates:candidates.append(im)
|
| 163 |
+
scored=[];seen=set()
|
| 164 |
+
for im in candidates:
|
| 165 |
+
src=_extract_img_src(im,url)
|
| 166 |
+
if not src or src in seen:continue
|
| 167 |
+
seen.add(src)
|
| 168 |
+
sc=_image_score(im,src,block,url)
|
| 169 |
+
if sc>=2:
|
| 170 |
+
scored.append((sc,src))
|
| 171 |
+
# Keep original article order but only for scored images, filtering duplicate URLs.
|
| 172 |
+
good=set(src for sc,src in sorted(scored,reverse=True) if sc>=2)
|
| 173 |
+
for im in candidates:
|
| 174 |
+
src=_extract_img_src(im,url)
|
| 175 |
+
if src in good and src not in imgs:imgs.append(src)
|
| 176 |
+
if len(imgs)>=20:break
|
| 177 |
+
# Fallback: og:image is usually article main image, and better than no image.
|
| 178 |
+
if not imgs:
|
| 179 |
+
og=soup.find('meta',property='og:image') or soup.find('meta',attrs={'name':'twitter:image'})
|
| 180 |
+
if og:
|
| 181 |
+
src=_abs_url(og.get('content',''),url)
|
| 182 |
+
if src and not any(x in src.lower() for x in ['logo','icon','avatar','sprite']):imgs.append(src)
|
| 183 |
+
except Exception:pass
|
| 184 |
+
return imgs[:20]
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def _scrape_url_article_only(url):
|
| 188 |
+
data=base.scrape_any_url(url)
|
| 189 |
+
imgs=_article_only_images(url)
|
| 190 |
+
data['images']=imgs
|
| 191 |
+
data['image']=imgs[0] if imgs else ''
|
| 192 |
+
return data
|
| 193 |
+
|
| 194 |
+
# Override the functions used by inherited endpoints.
|
| 195 |
+
f1._article_only_images=_article_only_images
|
| 196 |
+
f1._scrape_url_article_only=_scrape_url_article_only
|
| 197 |
+
|
| 198 |
+
# Replace URL endpoints to use improved extraction.
|
| 199 |
+
_PATCH={('/api/url_wall','POST'),('/api/rewrite_share','POST'),('/api/ai/url','POST'),('/','GET')}
|
| 200 |
+
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)]
|
| 201 |
+
|
| 202 |
+
@app.post('/api/url_wall')
|
| 203 |
+
async def final2_url_wall(request:Request):
|
| 204 |
+
body=await request.json();url=base._clean_text(body.get('url',''))
|
| 205 |
+
if not url.startswith('http'):return JSONResponse({'error':'missing url'},status_code=400)
|
| 206 |
+
try:data=_scrape_url_article_only(url)
|
| 207 |
+
except Exception as e:return JSONResponse({'error':'Không scrape được URL: '+str(e)[:180]},status_code=422)
|
| 208 |
+
raw=(data.get('summary','')+'\n'+data.get('text','')).strip()
|
| 209 |
+
if len(raw)<120:return JSONResponse({'error':'URL không có đủ nội dung để tóm tắt'},status_code=422)
|
| 210 |
+
prompt=f"""Tóm tắt bài viết nguồn dưới đây để đăng lên Tường AI VNEWS.
|
| 211 |
+
|
| 212 |
+
Yêu cầu:
|
| 213 |
+
- Chỉ tóm tắt nội dung chính, không viết lại toàn bộ bài.
|
| 214 |
+
- Ngắn gọn, cụ thể, dễ hiểu.
|
| 215 |
+
- Không lặp ý, không thêm chi tiết ngoài nguồn.
|
| 216 |
+
- Tối đa 5 ý chính hoặc 2 đoạn ngắn.
|
| 217 |
+
- Hạn chế dùng dấu đầu dòng.
|
| 218 |
+
|
| 219 |
+
Tiêu đề gốc: {data.get('title','')}
|
| 220 |
+
Nguồn: {_domain(url)}
|
| 221 |
+
Nội dung gốc:
|
| 222 |
+
{raw[:16000]}"""
|
| 223 |
+
text=await base.qwen_generate(prompt,image_url=(data.get('image') or None),max_tokens=900)
|
| 224 |
+
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]
|
| 225 |
+
text=rt.postprocess(text) if hasattr(rt,'postprocess') else text
|
| 226 |
+
src=[{'title':data.get('title'), 'url':url, 'excerpt':raw[:500], 'via':_domain(url)}]
|
| 227 |
+
if 'Nguồn tham khảo:' not in text:text+='\n\n'+rt.source_line(src)
|
| 228 |
+
imgs=data.get('images') or []
|
| 229 |
+
post=base.make_post(data.get('title') or 'Bài viết',text,imgs[0] if imgs else '',url,'url',sources=src)
|
| 230 |
+
post['images']=imgs
|
| 231 |
+
posts=base._load_ai_wall();posts.insert(0,post);base._save_ai_wall(posts)
|
| 232 |
+
return JSONResponse({'post':post})
|
| 233 |
+
|
| 234 |
+
@app.post('/api/rewrite_share')
|
| 235 |
+
async def final2_rewrite_share(request:Request):return await final2_url_wall(request)
|
| 236 |
+
@app.post('/api/ai/url')
|
| 237 |
+
async def final2_ai_url(request:Request):return await final2_url_wall(request)
|
| 238 |
+
|
| 239 |
+
@app.get('/')
|
| 240 |
+
async def index_final2():
|
| 241 |
+
html=f1._load_index_html();body=getattr(rt.old,'PATCH_INJECT','') + f1.FINAL_INJECT
|
| 242 |
+
return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
|
ai_runtime_final3.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Final3 runtime: Qwen topic posts, robust YouTube shorts, TikTok-style actions for Shorts and Short AI."""
|
| 2 |
+
import os, re, time, json, hashlib, requests
|
| 3 |
+
from urllib.parse import quote, urlparse
|
| 4 |
+
import ai_runtime_final2 as f2
|
| 5 |
+
from ai_runtime_final2 import app, base, rt, HTMLResponse, JSONResponse, Request, Query
|
| 6 |
+
|
| 7 |
+
SPACE_URL="https://bep40-vnews.hf.space"
|
| 8 |
+
SHORT_CHANNELS=["baodantri7941","baosuckhoedoisongboyte"]
|
| 9 |
+
_SHORTS_CACHE={"t":0,"d":[]}
|
| 10 |
+
AI_INTERACTIONS_FILE="/data/ai_interactions.json" if os.path.isdir('/data') else "/app/data/ai_interactions.json"
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def clean(s):
|
| 14 |
+
import html as html_lib
|
| 15 |
+
return re.sub(r"\s+"," ",html_lib.unescape(s or "")).strip()
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _domain(u):
|
| 19 |
+
try:return urlparse(u or '').netloc.replace('www.','')
|
| 20 |
+
except Exception:return ''
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _load_json(path,default):
|
| 24 |
+
try:
|
| 25 |
+
if os.path.exists(path):
|
| 26 |
+
with open(path,'r',encoding='utf-8') as f:return json.load(f)
|
| 27 |
+
except Exception:pass
|
| 28 |
+
return default
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _save_json(path,data):
|
| 32 |
+
try:
|
| 33 |
+
os.makedirs(os.path.dirname(path),exist_ok=True);tmp=path+'.tmp'
|
| 34 |
+
with open(tmp,'w',encoding='utf-8') as f:json.dump(data,f,ensure_ascii=False)
|
| 35 |
+
os.replace(tmp,path)
|
| 36 |
+
except Exception:pass
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _youtube_shorts_ytdlp(handle,count=20):
|
| 40 |
+
try:
|
| 41 |
+
import yt_dlp
|
| 42 |
+
url=f"https://www.youtube.com/@{handle}/shorts"
|
| 43 |
+
opts={'quiet':True,'extract_flat':True,'skip_download':True,'playlistend':count,'ignoreerrors':True,'no_warnings':True}
|
| 44 |
+
with yt_dlp.YoutubeDL(opts) as ydl:
|
| 45 |
+
info=ydl.extract_info(url,download=False)
|
| 46 |
+
out=[]
|
| 47 |
+
for e in (info or {}).get('entries') or []:
|
| 48 |
+
vid=e.get('id') or ''
|
| 49 |
+
if not re.match(r'^[A-Za-z0-9_-]{11}$',vid):continue
|
| 50 |
+
title=e.get('title') or 'YouTube Short'
|
| 51 |
+
out.append({'title':title,'link':f'https://www.youtube.com/watch?v={vid}','img':f'https://i.ytimg.com/vi/{vid}/hqdefault.jpg','source':'yt','id':vid,'channel':handle})
|
| 52 |
+
return out
|
| 53 |
+
except Exception:return []
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _youtube_shorts_html(handle,count=20):
|
| 57 |
+
try:
|
| 58 |
+
html=requests.get(f"https://www.youtube.com/@{handle}/shorts",headers=getattr(base,'HEADERS',{}),timeout=15).text
|
| 59 |
+
ids=[];out=[]
|
| 60 |
+
for m in re.finditer(r'"videoId":"([A-Za-z0-9_-]{11})"',html):
|
| 61 |
+
vid=m.group(1)
|
| 62 |
+
if vid in ids:continue
|
| 63 |
+
ids.append(vid)
|
| 64 |
+
snip=html[max(0,m.start()-1000):m.start()+1800]
|
| 65 |
+
title='YouTube Short'
|
| 66 |
+
mt=re.search(r'"title":\{"runs":\[\{"text":"([^"]+)"',snip) or re.search(r'"accessibilityText":"([^"]+)"',snip)
|
| 67 |
+
if mt:title=clean(mt.group(1).replace('\\n',' '))
|
| 68 |
+
out.append({'title':title,'link':f'https://www.youtube.com/watch?v={vid}','img':f'https://i.ytimg.com/vi/{vid}/hqdefault.jpg','source':'yt','id':vid,'channel':handle})
|
| 69 |
+
if len(out)>=count:break
|
| 70 |
+
return out
|
| 71 |
+
except Exception:return []
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _fresh_shorts():
|
| 75 |
+
items=[];seen=set()
|
| 76 |
+
for ch in SHORT_CHANNELS:
|
| 77 |
+
got=_youtube_shorts_ytdlp(ch,24) or _youtube_shorts_html(ch,24)
|
| 78 |
+
for v in got:
|
| 79 |
+
if v['id'] not in seen:
|
| 80 |
+
seen.add(v['id']);items.append(v)
|
| 81 |
+
# fallback from main if live scrape fails
|
| 82 |
+
try:
|
| 83 |
+
for v in getattr(rt.old.base if hasattr(rt.old,'base') else rt,'SHORTS_FALLBACK',[]) or []:
|
| 84 |
+
vid=v.get('id')
|
| 85 |
+
if vid and vid not in seen:
|
| 86 |
+
seen.add(vid);items.append(v)
|
| 87 |
+
except Exception:pass
|
| 88 |
+
return items[:50]
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def _topic_image(topic):
|
| 92 |
+
try:return base.pollinations_image_url(topic)
|
| 93 |
+
except Exception:return "https://image.pollinations.ai/prompt/"+quote("Vietnamese news editorial illustration "+topic)+"?width=1024&height=576&nologo=true"
|
| 94 |
+
|
| 95 |
+
# Remove old endpoints/root to override.
|
| 96 |
+
_PATCH={('/api/shorts','GET'),('/api/topic_post','POST'),('/api/ai/interact','POST'),('/','GET')}
|
| 97 |
+
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)]
|
| 98 |
+
|
| 99 |
+
@app.get('/api/shorts')
|
| 100 |
+
def api_shorts_final3(refresh:int=Query(default=0)):
|
| 101 |
+
now=time.time()
|
| 102 |
+
if not refresh and _SHORTS_CACHE['d'] and now-_SHORTS_CACHE['t']<900:
|
| 103 |
+
return JSONResponse(_SHORTS_CACHE['d'])
|
| 104 |
+
data=_fresh_shorts()
|
| 105 |
+
_SHORTS_CACHE.update({'t':now,'d':data})
|
| 106 |
+
return JSONResponse(data)
|
| 107 |
+
|
| 108 |
+
@app.post('/api/topic_post')
|
| 109 |
+
async def topic_post_qwen(request:Request):
|
| 110 |
+
body=await request.json();topic=clean(body.get('topic',''))
|
| 111 |
+
if not topic:return JSONResponse({'error':'missing topic'},status_code=400)
|
| 112 |
+
img=_topic_image(topic)
|
| 113 |
+
prompt=f"""Bạn là biên tập viên VNEWS. Dựa trên kiến thức tổng quát của bạn, hãy tạo một bài đăng Tường AI bằng tiếng Việt về chủ đề: {topic}
|
| 114 |
+
|
| 115 |
+
Yêu cầu:
|
| 116 |
+
- Viết như một bài tin/tạp chí ngắn, có tiêu đề hấp dẫn.
|
| 117 |
+
- 1 đoạn mở đầu 2 câu.
|
| 118 |
+
- 4-6 ý chính rõ ràng, không lan man.
|
| 119 |
+
- Nếu chủ đề là thể thao/c��ng nghệ/xã hội, hãy viết có bối cảnh và nhận định.
|
| 120 |
+
- Không khẳng định số liệu thời sự mới nếu không chắc; dùng cách diễn đạt thận trọng.
|
| 121 |
+
- Cuối bài thêm dòng: Nguồn tham khảo: Qwen2.5-VL / kiến thức tổng hợp.
|
| 122 |
+
"""
|
| 123 |
+
text=await base.qwen_generate(prompt,image_url=img,max_tokens=1100)
|
| 124 |
+
if not text:
|
| 125 |
+
text=f"{topic}\n\nĐây là bài gợi ý do AI tạo dựa trên kiến thức tổng hợp. Nội dung cung cấp bối cảnh, các điểm đáng chú ý và góc nhìn tham khảo về chủ đề này.\n\nNguồn tham khảo: Qwen2.5-VL / kiến thức tổng hợp."
|
| 126 |
+
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'}])
|
| 127 |
+
post['images']=[img]
|
| 128 |
+
posts=base._load_ai_wall();posts.insert(0,post);base._save_ai_wall(posts)
|
| 129 |
+
return JSONResponse({'post':post})
|
| 130 |
+
|
| 131 |
+
@app.post('/api/ai/interact')
|
| 132 |
+
async def ai_interact(request:Request):
|
| 133 |
+
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',''))
|
| 134 |
+
if not pid:return JSONResponse({'error':'missing id'},status_code=400)
|
| 135 |
+
db=_load_json(AI_INTERACTIONS_FILE,{})
|
| 136 |
+
key=kind+':'+pid
|
| 137 |
+
st=db.get(key) or {'views':0,'likes':0,'comments':[],'asks':[]}
|
| 138 |
+
if action=='view':st['views']=int(st.get('views',0))+1
|
| 139 |
+
elif action=='like':st['likes']=int(st.get('likes',0))+1
|
| 140 |
+
elif action=='comment' and text:
|
| 141 |
+
st.setdefault('comments',[]).insert(0,{'text':text[:240],'ts':int(time.time())});st['comments']=st['comments'][:80]
|
| 142 |
+
elif action=='ask' and text:
|
| 143 |
+
posts=base._load_ai_wall();p=next((x for x in posts if str(x.get('id'))==pid),{})
|
| 144 |
+
prompt=f"""Trả lời ngắn bằng tiếng Việt cho câu hỏi của người xem về nội dung này.
|
| 145 |
+
Tiêu đề: {p.get('title','')}
|
| 146 |
+
Nội dung: {(p.get('text') or '')[:4000]}
|
| 147 |
+
Câu hỏi: {text}
|
| 148 |
+
"""
|
| 149 |
+
ans=await base.qwen_generate(prompt,max_tokens=500)
|
| 150 |
+
if not ans:ans='AI chưa trả lời được lúc này. Bạn thử hỏi lại ngắn gọn hơn.'
|
| 151 |
+
st.setdefault('asks',[]).insert(0,{'q':text[:240],'a':ans[:1000],'ts':int(time.time())});st['asks']=st['asks'][:50]
|
| 152 |
+
db[key]=st;_save_json(AI_INTERACTIONS_FILE,db)
|
| 153 |
+
return JSONResponse({'stats':st})
|
| 154 |
+
|
| 155 |
+
FINAL3_INJECT = r'''
|
| 156 |
+
<style>
|
| 157 |
+
.ai-compose-row.topic-final3{display:flex!important;flex-direction:column!important;gap:8px!important;width:100%!important}.ai-compose-row.topic-final3 input,.ai-compose-row.topic-final3 button{width:100%!important;box-sizing:border-box!important}.short-action-panel{position:absolute;right:8px;bottom:92px;display:flex;flex-direction:column;gap:12px;z-index:20}.short-action-btn{background:none;border:0;color:#fff;text-align:center;font-size:10px}.short-action-btn .ico{width:44px;height:44px;border-radius:50%;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;font-size:21px;margin:auto}.short-modal{position:fixed;inset:auto 0 0 0;max-height:60vh;background:#181818;border-radius:16px 16px 0 0;z-index:99999;padding:14px;display:none;overflow:auto}.short-modal.active{display:block}.short-modal textarea,.short-modal input{width:100%;background:#222;border:1px solid #444;color:#eee;border-radius:10px;padding:9px;margin:6px 0}.short-modal button{background:#2d8659;border:0;color:#fff;border-radius:10px;padding:8px 12px;margin:4px}.ai-short-home{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}.ai-short-card-final{flex:0 0 120px}.ai-short-card-final video{width:100%;aspect-ratio:9/16;object-fit:cover;background:#000;border-radius:8px}
|
| 158 |
+
</style>
|
| 159 |
+
<div id="short-modal" class="short-modal"></div>
|
| 160 |
+
<script>
|
| 161 |
+
(function(){
|
| 162 |
+
let finalWall3=[];let currentShortCtx=null;
|
| 163 |
+
function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));}
|
| 164 |
+
function ensureTopicBox(){let comp=document.querySelector('.ai-compose');if(!comp)return;if(!document.getElementById('ai-topic-input-final3')){let row=document.createElement('div');row.className='ai-compose-row topic-final3';row.innerHTML='<input id="ai-topic-input-final3" placeholder="Nhập chủ đề để Qwen2.5VL gợi ý bài đăng lên Tường AI..."><button onclick="createTopicPostFinal3()">✨ Tạo bài theo chủ đề bằng Qwen</button>';comp.insertBefore(row,comp.firstChild.nextSibling);} }
|
| 165 |
+
window.createTopicPostFinal3=async function(){let inp=document.getElementById('ai-topic-input-final3');let topic=(inp&&inp.value||'').trim();if(!topic)return alert('Nhập chủ đề trước');let btn=event?.target;if(btn){btn.disabled=true;btn.textContent='Đang tạo...'}try{let r=await fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');finalWall3.unshift(j.post);if(window.finalWall)window.finalWall.unshift(j.post);if(inp)inp.value='';renderAIShortHome();if(window.renderWall)window.renderWall();alert('Đã tạo bài chủ đề và đăng lên Tường AI, không reload.');}catch(e){alert(e.message)}finally{if(btn){btn.disabled=false;btn.textContent='✨ Tạo bài theo chủ đề bằng Qwen'}}};
|
| 166 |
+
async function refreshFinalWall3(){try{finalWall3=(await (await fetch('/api/ai_wall')).json()).posts||[];renderAIShortHome();}catch(e){}}
|
| 167 |
+
function renderAIShortHome(){let home=document.getElementById('view-home');if(!home)return;document.getElementById('ai-short-home')?.remove();let vids=finalWall3.filter(p=>p.video);if(!vids.length)return;let wrap=document.createElement('div');wrap.id='ai-short-home';wrap.className='ai-short-home';let h='<div class="slider-header"><span class="slider-label">🎬 Short AI</span><span class="slider-note">Video đã tạo</span></div><div class="slider-track">';vids.slice(0,30).forEach((p,i)=>{h+=`<div class="ai-short-card-final" onclick="openAIShortFeed(${i})"><video src="${p.video}" muted playsinline preload="metadata"></video><div class="slider-title">${esc(p.title)}</div></div>`});h+='</div>';wrap.innerHTML=h;let after=document.getElementById('ai-wall-final')||document.querySelector('.ai-compose');if(after)after.after(wrap);else home.prepend(wrap);}
|
| 168 |
+
window.openAIShortFeed=function(start){let vids=finalWall3.filter(p=>p.video);if(!vids.length)return;showView('view-tiktok');let h='<button class="back-btn" onclick="switchCat(\'home\')">← Short AI</button><div class="tiktok-container"><div class="tiktok-feed" id="tiktok-feed">';let ordered=start>0?vids.slice(start).concat(vids.slice(0,start)):vids;ordered.forEach((p,i)=>{h+=`<div class="tiktok-slide" data-kind="ai" data-id="${p.id}"><video src="${p.video}" playsinline controls loop></video><div class="tiktok-bottom"><span class="badge badge-ai">AI</span><p class="tiktok-title">${esc(p.title)}</p></div>${actionPanel('ai',p.id,i)}<span class="tiktok-counter">${i+1}/${ordered.length}</span></div>`});h+='</div></div>';document.getElementById('view-tiktok').innerHTML=h;initActionFeed();}
|
| 169 |
+
function actionPanel(kind,id,i){return `<div class="short-action-panel"><button class="short-action-btn" onclick="shortAct('${kind}','${id}','view')"><div class="ico">👁</div><span id="v-${kind}-${id}">0</span></button><button class="short-action-btn" onclick="shortAct('${kind}','${id}','like')"><div class="ico">❤️</div><span id="l-${kind}-${id}">0</span></button><button class="short-action-btn" onclick="openCommentBox('${kind}','${id}')"><div class="ico">💬</div><span>BL</span></button><button class="short-action-btn" onclick="openAskBox('${kind}','${id}')"><div class="ico">🤖</div><span>Hỏi</span></button><button class="short-action-btn" onclick="shareShortCtx('${kind}','${id}')"><div class="ico">📤</div><span>Share</span></button></div>`}
|
| 170 |
+
window.shortAct=async function(kind,id,action,text=''){let url=kind==='yt'?'/api/short-action':'/api/ai/interact';let body=kind==='yt'?{id,action,text}:{id,kind:'short',action,text};let r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});let j=await r.json();let st=j.stats||j;let v=document.getElementById(`v-${kind}-${id}`),l=document.getElementById(`l-${kind}-${id}`);if(v&&st.views!=null)v.textContent=st.views;if(l&&st.likes!=null)l.textContent=st.likes;return st;}
|
| 171 |
+
window.openCommentBox=function(kind,id){let m=document.getElementById('short-modal');m.innerHTML=`<h3>💬 Bình luận</h3><textarea id="short-comment-text" placeholder="Nhập bình luận..."></textarea><button onclick="submitShortComment('${kind}','${id}')">Gửi</button><button onclick="closeShortModal()">Đóng</button>`;m.classList.add('active')}
|
| 172 |
+
window.submitShortComment=async function(kind,id){let t=document.getElementById('short-comment-text').value.trim();if(!t)return;await shortAct(kind,id,'comment',t);alert('Đã gửi bình luận');closeShortModal()}
|
| 173 |
+
window.openAskBox=function(kind,id){let m=document.getElementById('short-modal');m.innerHTML=`<h3>🤖 Hỏi AI</h3><input id="short-ask-text" placeholder="Bạn muốn hỏi gì về nội dung này?"><div id="short-answer"></div><button onclick="submitShortAsk('${kind}','${id}')">Hỏi</button><button onclick="closeShortModal()">Đóng</button>`;m.classList.add('active')}
|
| 174 |
+
window.submitShortAsk=async function(kind,id){let t=document.getElementById('short-ask-text').value.trim();if(!t)return;if(kind==='yt'){document.getElementById('short-answer').innerHTML='AI chỉ hỗ trợ trả lời sâu cho Short AI/Tường AI.';return}let st=await shortAct(kind,id,'ask',t);let a=(st.asks&&st.asks[0]&&st.asks[0].a)||'Chưa có trả lời';document.getElementById('short-answer').innerHTML='<p style="white-space:pre-wrap;color:#ccc">'+esc(a)+'</p>'}
|
| 175 |
+
window.closeShortModal=function(){document.getElementById('short-modal').classList.remove('active')}
|
| 176 |
+
window.shareShortCtx=function(kind,id){if(kind==='ai'){let p=finalWall3.find(x=>x.id===id);if(p){let url=location.origin+'/aw?post='+encodeURIComponent(id)+'&short=1';if(navigator.share)navigator.share({title:'🎬 Short AI: '+p.title,url}).catch(()=>{});else navigator.clipboard.writeText(url).then(()=>alert('Đã sao chép link!'));}}else{let url='https://www.youtube.com/watch?v='+id;if(navigator.share)navigator.share({title:'Shorts VNEWS',url}).catch(()=>{});else navigator.clipboard.writeText(url).then(()=>alert('Đã sao chép link!'));}}
|
| 177 |
+
function initActionFeed(){let feed=document.getElementById('tiktok-feed');if(!feed)return;let slides=feed.querySelectorAll('.tiktok-slide');let cur=-1;function act(i){if(i===cur)return;slides.forEach((sl,idx)=>{let v=sl.querySelector('video');let fr=sl.querySelector('iframe');if(idx===i){if(v)v.play().catch(()=>{});if(fr&&!fr.src&&fr.dataset.ytSrc)fr.src=fr.dataset.ytSrc;let kind=sl.dataset.kind,id=sl.dataset.id;if(kind&&id)shortAct(kind,id,'view').catch(()=>{})}else{if(v)v.pause();if(fr&&fr.src)fr.src=''}});cur=i}let t;feed.addEventListener('scroll',()=>{clearTimeout(t);t=setTimeout(()=>{let rect=feed.getBoundingClientRect(),ctr=rect.top+rect.height/2,b=-1,d=1e9;slides.forEach((sl,i)=>{let dd=Math.abs(sl.getBoundingClientRect().top+sl.getBoundingClientRect().height/2-ctr);if(dd<d){d=dd;b=i}});if(b>=0)act(b)},150)});setTimeout(()=>act(0),300)}
|
| 178 |
+
// Override openTikTok for regular YouTube shorts with same action layout.
|
| 179 |
+
window.openTikTok=async function(type,startIdx){showView('view-tiktok');let arts= type==='shorts'? await fetch('/api/shorts?refresh=1').then(r=>r.json()).catch(()=>[]) : await fetch(type==='highlights'?'/api/highlights':'/api/bdp_videos').then(r=>r.json()).catch(()=>[]);if(type!=='shorts'&&window.buildTikTokPlayer)return window.buildTikTokPlayer(arts,startIdx,type);let ordered=startIdx>0?arts.slice(startIdx).concat(arts.slice(0,startIdx)):arts;let h='<button class="back-btn" onclick="switchCat(\'home\')">← Shorts</button><div class="tiktok-container"><div class="tiktok-feed" id="tiktok-feed">';ordered.forEach((v,i)=>{let id=v.id||((v.link||'').match(/v=([A-Za-z0-9_-]{11})/)||[])[1]||String(i);let src='https://www.youtube.com/embed/'+id+'?autoplay=1&rel=0&playsinline=1';h+=`<div class="tiktok-slide" data-kind="yt" data-id="${id}"><iframe data-yt-src="${src}" allowfullscreen allow="accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture"></iframe><div class="tiktok-bottom"><span class="badge badge-fpt">YT</span><p class="tiktok-title">${esc(v.title)}</p></div>${actionPanel('yt',id,i)}<span class="tiktok-counter">${i+1}/${ordered.length}</span></div>`});h+='</div></div>';document.getElementById('view-tiktok').innerHTML=h;initActionFeed();}
|
| 180 |
+
// Patch make short: update home Short AI slide without reload.
|
| 181 |
+
let oldMake=window.makeFinalShort||window.aiMakeShortPatched;
|
| 182 |
+
window.makeFinalShort=window.aiMakeShortPatched=async function(i){let arr=finalWall3.length?finalWall3:(window.finalWall||[]);let p=arr[i];if(!p&&oldMake)return oldMake(i);if(!p)return;let voice=document.getElementById('ai-short-voice')?.value||'nu';let emotion=document.getElementById('ai-short-emotion')?.value||'neutral';let btn=event?.target;if(btn){btn.disabled=true;btn.textContent='Đang tạo...'}try{let r=await fetch('/api/ai/short/'+p.id,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({voice,emotion,speed:1.2})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi tạo short');p.video=j.video;let idx=finalWall3.findIndex(x=>x.id===p.id);if(idx<0)finalWall3.unshift(p);renderAIShortHome();if(window.renderWall)window.renderWall();alert('Đã tạo short và thêm vào slide Short AI, không reload.');}catch(e){alert(e.message)}finally{if(btn){btn.disabled=false;btn.textContent='🎬 Tạo short'}}}
|
| 183 |
+
setTimeout(()=>{ensureTopicBox();refreshFinalWall3();},700);setInterval(ensureTopicBox,1500);
|
| 184 |
+
})();
|
| 185 |
+
</script>
|
| 186 |
+
'''
|
| 187 |
+
|
| 188 |
+
@app.get('/')
|
| 189 |
+
async def index_final3():
|
| 190 |
+
html=f2.f1._load_index_html();body=getattr(rt.old,'PATCH_INJECT','') + f2.f1.FINAL_INJECT + FINAL3_INJECT
|
| 191 |
+
return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
|
ai_runtime_final4.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Final4 runtime: fix topic button visibility, shorts home feed, AI asking for videos/articles."""
|
| 2 |
+
import re, time, json, os, requests
|
| 3 |
+
from urllib.parse import urlparse
|
| 4 |
+
import ai_runtime_final3 as f3
|
| 5 |
+
from ai_runtime_final3 import app, base, rt, HTMLResponse, JSONResponse, Request, Query
|
| 6 |
+
try:
|
| 7 |
+
import main as main_mod
|
| 8 |
+
except Exception:
|
| 9 |
+
main_mod=None
|
| 10 |
+
|
| 11 |
+
AI_INTERACTIONS_FILE=f3.AI_INTERACTIONS_FILE
|
| 12 |
+
_SHORTS_CACHE={"t":0,"d":[]}
|
| 13 |
+
SHORT_CHANNELS=f3.SHORT_CHANNELS
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def clean(s):
|
| 17 |
+
import html as html_lib
|
| 18 |
+
return re.sub(r"\s+"," ",html_lib.unescape(s or "")).strip()
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _domain(u):
|
| 22 |
+
try:return urlparse(u or '').netloc.replace('www.','')
|
| 23 |
+
except Exception:return ''
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _load_json(path,default):
|
| 27 |
+
try:
|
| 28 |
+
if os.path.exists(path):
|
| 29 |
+
with open(path,'r',encoding='utf-8') as f:return json.load(f)
|
| 30 |
+
except Exception:pass
|
| 31 |
+
return default
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _save_json(path,data):
|
| 35 |
+
try:
|
| 36 |
+
os.makedirs(os.path.dirname(path),exist_ok=True);tmp=path+'.tmp'
|
| 37 |
+
with open(tmp,'w',encoding='utf-8') as f:json.dump(data,f,ensure_ascii=False)
|
| 38 |
+
os.replace(tmp,path)
|
| 39 |
+
except Exception:pass
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _fallback_shorts():
|
| 43 |
+
out=[];seen=set()
|
| 44 |
+
candidates=[]
|
| 45 |
+
try:candidates+=(getattr(main_mod,'SHORTS_FALLBACK',[]) or [])
|
| 46 |
+
except Exception:pass
|
| 47 |
+
try:candidates+=(getattr(rt,'SHORTS_FALLBACK',[]) or [])
|
| 48 |
+
except Exception:pass
|
| 49 |
+
# hard fallback if imports fail
|
| 50 |
+
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')]
|
| 51 |
+
for vid,title,ch in hard:
|
| 52 |
+
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'})
|
| 53 |
+
for v in candidates:
|
| 54 |
+
vid=v.get('id') or ''
|
| 55 |
+
if vid and vid not in seen:
|
| 56 |
+
seen.add(vid)
|
| 57 |
+
if not v.get('link'):v['link']='https://www.youtube.com/watch?v='+vid
|
| 58 |
+
if not v.get('img'):v['img']='https://i.ytimg.com/vi/'+vid+'/hqdefault.jpg'
|
| 59 |
+
v['source']='yt';out.append(v)
|
| 60 |
+
return out
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _fresh_shorts():
|
| 64 |
+
items=[];seen=set()
|
| 65 |
+
for ch in SHORT_CHANNELS:
|
| 66 |
+
got=f3._youtube_shorts_ytdlp(ch,24) or f3._youtube_shorts_html(ch,24)
|
| 67 |
+
for v in got:
|
| 68 |
+
vid=v.get('id')
|
| 69 |
+
if vid and vid not in seen:
|
| 70 |
+
seen.add(vid);items.append(v)
|
| 71 |
+
for v in _fallback_shorts():
|
| 72 |
+
vid=v.get('id')
|
| 73 |
+
if vid and vid not in seen:
|
| 74 |
+
seen.add(vid);items.append(v)
|
| 75 |
+
return items[:60]
|
| 76 |
+
|
| 77 |
+
# Remove endpoints/root to override.
|
| 78 |
+
_PATCH={('/api/shorts','GET'),('/api/ai/interact','POST'),('/api/article/ask','POST'),('/','GET')}
|
| 79 |
+
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)]
|
| 80 |
+
|
| 81 |
+
@app.get('/api/shorts')
|
| 82 |
+
def api_shorts_final4(refresh:int=Query(default=0)):
|
| 83 |
+
now=time.time()
|
| 84 |
+
if not refresh and _SHORTS_CACHE['d'] and now-_SHORTS_CACHE['t']<900:return JSONResponse(_SHORTS_CACHE['d'])
|
| 85 |
+
data=_fresh_shorts()
|
| 86 |
+
_SHORTS_CACHE.update({'t':now,'d':data})
|
| 87 |
+
return JSONResponse(data)
|
| 88 |
+
|
| 89 |
+
@app.post('/api/ai/interact')
|
| 90 |
+
async def ai_interact_final4(request:Request):
|
| 91 |
+
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',''))
|
| 92 |
+
if not pid:return JSONResponse({'error':'missing id'},status_code=400)
|
| 93 |
+
db=_load_json(AI_INTERACTIONS_FILE,{})
|
| 94 |
+
key=kind+':'+pid
|
| 95 |
+
st=db.get(key) or {'views':0,'likes':0,'comments':[],'asks':[]}
|
| 96 |
+
if action=='view':st['views']=int(st.get('views',0))+1
|
| 97 |
+
elif action=='like':st['likes']=int(st.get('likes',0))+1
|
| 98 |
+
elif action=='comment' and text:
|
| 99 |
+
st.setdefault('comments',[]).insert(0,{'text':text[:240],'ts':int(time.time())});st['comments']=st['comments'][:80]
|
| 100 |
+
elif action=='ask' and text:
|
| 101 |
+
if kind in ('ai','short','wall'):
|
| 102 |
+
posts=base._load_ai_wall();p=next((x for x in posts if str(x.get('id'))==pid),{})
|
| 103 |
+
title=title or p.get('title','');context=context or (p.get('text') or '')
|
| 104 |
+
# For YouTube shorts, frontend sends title/context because AI cannot watch video.
|
| 105 |
+
if not context:context=title or pid
|
| 106 |
+
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.
|
| 107 |
+
|
| 108 |
+
Tiêu đề/ngữ cảnh: {title}
|
| 109 |
+
Nội dung mô tả: {context[:5000]}
|
| 110 |
+
|
| 111 |
+
Câu hỏi người dùng: {text}
|
| 112 |
+
|
| 113 |
+
Yêu cầu:
|
| 114 |
+
- 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.
|
| 115 |
+
- Trả lời cụ thể, có giải thích, không quá ngắn.
|
| 116 |
+
"""
|
| 117 |
+
ans=await base.qwen_generate(prompt,max_tokens=900)
|
| 118 |
+
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.'
|
| 119 |
+
st.setdefault('asks',[]).insert(0,{'q':text[:240],'a':ans[:1500],'ts':int(time.time())});st['asks']=st['asks'][:50]
|
| 120 |
+
db[key]=st;_save_json(AI_INTERACTIONS_FILE,db)
|
| 121 |
+
return JSONResponse({'stats':st})
|
| 122 |
+
|
| 123 |
+
@app.post('/api/article/ask')
|
| 124 |
+
async def article_ask(request:Request):
|
| 125 |
+
body=await request.json();url=clean(body.get('url',''));question=clean(body.get('question',''))
|
| 126 |
+
if not question:return JSONResponse({'error':'missing question'},status_code=400)
|
| 127 |
+
title='';raw=''
|
| 128 |
+
try:
|
| 129 |
+
data=None
|
| 130 |
+
if url and hasattr(f3.f2.f1,'_scrape_url_article_only'):
|
| 131 |
+
data=f3.f2.f1._scrape_url_article_only(url)
|
| 132 |
+
if not data and url:data=base.scrape_any_url(url)
|
| 133 |
+
if data:
|
| 134 |
+
title=data.get('title','');raw=(data.get('summary','')+'\n'+data.get('text','')).strip()
|
| 135 |
+
except Exception:pass
|
| 136 |
+
context=raw[:12000] if raw else clean(body.get('context',''))[:12000]
|
| 137 |
+
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.
|
| 138 |
+
|
| 139 |
+
Tiêu đề bài: {title}
|
| 140 |
+
Nội dung bài:
|
| 141 |
+
{context}
|
| 142 |
+
|
| 143 |
+
Câu hỏi: {question}
|
| 144 |
+
|
| 145 |
+
Yêu cầu:
|
| 146 |
+
- Trả lời bằng tiếng Việt.
|
| 147 |
+
- Dựa sát nội dung bài, nếu bài không có thông tin thì nói rõ.
|
| 148 |
+
- Giải thích chi tiết, có gạch đầu dòng khi hữu ích.
|
| 149 |
+
"""
|
| 150 |
+
ans=await base.qwen_generate(prompt,max_tokens=1200)
|
| 151 |
+
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.'
|
| 152 |
+
return JSONResponse({'answer':ans,'title':title})
|
| 153 |
+
|
| 154 |
+
FINAL4_INJECT = r'''
|
| 155 |
+
<style>
|
| 156 |
+
/* Ensure topic Qwen button is visible; earlier patches hide any button containing “chủ đề”. */
|
| 157 |
+
.topic-final4{display:flex!important;flex-direction:column!important;gap:8px!important;width:100%!important;margin-top:6px}.topic-final4 input,.topic-final4 button{display:block!important;width:100%!important;box-sizing:border-box!important}.topic-final4 button{background:#2d8659!important;color:#fff!important;border:0!important;border-radius:18px!important;padding:9px 12px!important;font-size:11px!important;font-weight:700!important}.article-ai-ask{margin-top:12px;background:#141414;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.article-ai-ask textarea{width:100%;min-height:70px;background:#222;border:1px solid #444;color:#eee;border-radius:10px;padding:9px}.article-ai-ask button{background:#2d8659;border:0;color:#fff;border-radius:10px;padding:8px 12px;margin-top:6px}.article-ai-answer{white-space:pre-wrap;color:#ccc;font-size:13px;line-height:1.55;margin-top:8px}.ai-compose-row:has(#ai-url-input){display:flex!important;flex-direction:column!important}.ai-compose-row:has(#ai-url-input) input,.ai-compose-row:has(#ai-url-input) button{width:100%!important}
|
| 158 |
+
</style>
|
| 159 |
+
<script>
|
| 160 |
+
(function(){
|
| 161 |
+
function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));}
|
| 162 |
+
let shortsMap={};
|
| 163 |
+
function ensureTopicButtonFinal4(){let comp=document.querySelector('.ai-compose');if(!comp)return;if(!document.getElementById('ai-topic-input-final4')){let row=document.createElement('div');row.className='topic-final4';row.innerHTML='<input id="ai-topic-input-final4" placeholder="Nhập chủ đề để Qwen2.5VL tạo bài lên Tường AI..."><button id="ai-topic-btn-final4" onclick="createTopicPostFinal4()">✨ Tạo bài bằng Qwen</button>';comp.insertBefore(row,comp.firstChild.nextSibling);}let b=document.getElementById('ai-topic-btn-final4');if(b){b.style.display='block';b.textContent='✨ Tạo bài bằng Qwen';}}
|
| 164 |
+
window.createTopicPostFinal4=async function(){let inp=document.getElementById('ai-topic-input-final4');let topic=(inp&&inp.value||'').trim();if(!topic)return alert('Nhập chủ đề trước');let btn=document.getElementById('ai-topic-btn-final4');if(btn){btn.disabled=true;btn.textContent='Đang tạo...'}try{let r=await fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');if(window.finalWall)window.finalWall.unshift(j.post);if(window.finalWall3)window.finalWall3.unshift(j.post);if(inp)inp.value='';if(window.renderWall)window.renderWall();alert('Đã tạo bài bằng Qwen và đăng lên Tường AI.');}catch(e){alert(e.message)}finally{if(btn){btn.disabled=false;btn.textContent='✨ Tạo bài bằng Qwen'}}};
|
| 165 |
+
// Guarantee Shorts slide appears on home even if previous loadHome missed it.
|
| 166 |
+
async function ensureShortsHome(){let home=document.getElementById('view-home');if(!home||document.getElementById('shorts-final4'))return;let sh=await fetch('/api/shorts?refresh=1').then(r=>r.json()).catch(()=>[]);if(!sh.length)return;let wrap=document.createElement('div');wrap.id='shorts-final4';wrap.className='slider-wrap';let h='<div class="slider-header"><span class="slider-label">📱 Shorts Dân trí & SKĐS</span><span class="slider-note">Cập nhật YouTube</span></div><div class="slider-track">';sh.slice(0,30).forEach((a,i)=>{shortsMap[a.id]=a;h+=`<div class="slider-item shorts-item" onclick="openTikTok('shorts',${i})"><div class="slider-thumb shorts-thumb">${a.img?`<img src="${a.img}">`:''}<div class="card-play">▶</div></div><div class="slider-title">${esc(a.title)}</div></div>`});h+='</div>';wrap.innerHTML=h;let after=document.querySelector('.ai-compose')||home.firstChild;if(after)after.after(wrap);else home.prepend(wrap);}
|
| 167 |
+
// Patch ask for YouTube shorts: AI receives title/context.
|
| 168 |
+
let oldShortAct=window.shortAct;
|
| 169 |
+
window.shortAct=async function(kind,id,action,text=''){let meta=shortsMap[id]||{};let url='/api/ai/interact';let body={id,kind:kind==='yt'?'yt':kind,action,text,title:meta.title||'',context:meta.title?('Video Shorts YouTube từ kênh '+(meta.channel||'')+'. Tiêu đề: '+meta.title):''};let r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});let j=await r.json();let st=j.stats||j;let v=document.getElementById(`v-${kind}-${id}`),l=document.getElementById(`l-${kind}-${id}`);if(v&&st.views!=null)v.textContent=st.views;if(l&&st.likes!=null)l.textContent=st.likes;return st;};
|
| 170 |
+
window.submitShortAsk=async function(kind,id){let t=document.getElementById('short-ask-text').value.trim();if(!t)return;let st=await shortAct(kind,id,'ask',t);let a=(st.asks&&st.asks[0]&&st.asks[0].a)||'Chưa có trả lời';document.getElementById('short-answer').innerHTML='<p style="white-space:pre-wrap;color:#ccc">'+esc(a)+'</p>';};
|
| 171 |
+
// Patch openTikTok to populate shortsMap.
|
| 172 |
+
let oldOpenTikTok=window.openTikTok;
|
| 173 |
+
window.openTikTok=async function(type,startIdx){if(type==='shorts'){let arts=await fetch('/api/shorts?refresh=1').then(r=>r.json()).catch(()=>[]);arts.forEach(a=>{if(a.id)shortsMap[a.id]=a});}return oldOpenTikTok?oldOpenTikTok(type,startIdx):null;};
|
| 174 |
+
function addArticleAskBox(){let view=document.getElementById('view-article');if(!view||document.getElementById('article-ai-ask'))return;let art=view.querySelector('.article-view');if(!art)return;let box=document.createElement('div');box.id='article-ai-ask';box.className='article-ai-ask';box.innerHTML='<h3 style="font-size:14px;color:#5cb87a;margin-bottom:6px">🤖 Hỏi AI về bài viết</h3><textarea id="article-ai-question" placeholder="Nhập câu hỏi cần AI trả lời chi tiết về bài viết..."></textarea><button onclick="askArticleAI()">Hỏi AI</button><div id="article-ai-answer" class="article-ai-answer"></div>';art.appendChild(box);}
|
| 175 |
+
window.askArticleAI=async function(){let q=document.getElementById('article-ai-question')?.value.trim();if(!q)return alert('Nhập câu hỏi trước');let ans=document.getElementById('article-ai-answer');ans.textContent='Đang hỏi AI...';let url=(window._currentArticle&&window._currentArticle.url)||((typeof _currentArticle!=='undefined'&&_currentArticle.url)||'');let context=document.querySelector('.article-view')?.innerText||'';try{let r=await fetch('/api/article/ask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url,question:q,context})});let j=await r.json();ans.textContent=j.answer||j.error||'Không có trả lời';}catch(e){ans.textContent='Lỗi hỏi AI: '+e.message}}
|
| 176 |
+
let oldReadArticle=window.readArticle;if(oldReadArticle){window.readArticle=async function(){let ret=await oldReadArticle.apply(this,arguments);setTimeout(addArticleAskBox,700);return ret;}}
|
| 177 |
+
setTimeout(()=>{ensureTopicButtonFinal4();ensureShortsHome();},1000);setInterval(()=>{ensureTopicButtonFinal4();if(document.getElementById('view-home')?.classList.contains('active'))ensureShortsHome();addArticleAskBox();},2000);
|
| 178 |
+
})();
|
| 179 |
+
</script>
|
| 180 |
+
'''
|
| 181 |
+
|
| 182 |
+
@app.get('/')
|
| 183 |
+
async def index_final4():
|
| 184 |
+
html=f3.f2.f1._load_index_html();body=getattr(rt.old,'PATCH_INJECT','')+f3.f2.f1.FINAL_INJECT+f3.FINAL3_INJECT+FINAL4_INJECT
|
| 185 |
+
return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
|
ai_runtime_final5.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Final5 runtime: remove duplicate topic box, improve Qwen topic knowledge output, fix Shorts direct playback."""
|
| 2 |
+
import re, time
|
| 3 |
+
from urllib.parse import quote
|
| 4 |
+
import ai_runtime_final4 as f4
|
| 5 |
+
from ai_runtime_final4 import app, base, rt, HTMLResponse, JSONResponse, Request, Query
|
| 6 |
+
|
| 7 |
+
# Remove topic/root endpoints to override.
|
| 8 |
+
_PATCH={('/api/topic_post','POST'),('/','GET')}
|
| 9 |
+
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)]
|
| 10 |
+
|
| 11 |
+
def clean(s):
|
| 12 |
+
import html as html_lib
|
| 13 |
+
return re.sub(r"\s+"," ",html_lib.unescape(s or "")).strip()
|
| 14 |
+
|
| 15 |
+
def _topic_image(topic):
|
| 16 |
+
try:return base.pollinations_image_url(topic)
|
| 17 |
+
except Exception:return "https://image.pollinations.ai/prompt/"+quote("Vietnamese educational editorial illustration "+topic)+"?width=1024&height=576&nologo=true"
|
| 18 |
+
|
| 19 |
+
@app.post('/api/topic_post')
|
| 20 |
+
async def topic_post_knowledge(request:Request):
|
| 21 |
+
body=await request.json();topic=clean(body.get('topic',''))
|
| 22 |
+
if not topic:return JSONResponse({'error':'missing topic'},status_code=400)
|
| 23 |
+
img=_topic_image(topic)
|
| 24 |
+
prompt=f"""Người dùng muốn đăng một bài trên Tường AI về chủ đề: "{topic}".
|
| 25 |
+
|
| 26 |
+
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.
|
| 27 |
+
|
| 28 |
+
Yêu cầu đầu ra:
|
| 29 |
+
- Tiêu đề hấp dẫn, cụ thể.
|
| 30 |
+
- 1 đoạn mở đầu giải thích trực tiếp chủ đề là gì/vì sao đáng chú ý.
|
| 31 |
+
- 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 ý.
|
| 32 |
+
- 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.
|
| 33 |
+
- 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ế.
|
| 34 |
+
- 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.
|
| 35 |
+
- Viết như bài đăng hoàn chỉnh để đọc được ngay.
|
| 36 |
+
- Cuối bài thêm: Nguồn tham khảo: Qwen2.5-VL / kiến thức tổng hợp.
|
| 37 |
+
"""
|
| 38 |
+
text=await base.qwen_generate(prompt,image_url=img,max_tokens=1400)
|
| 39 |
+
if not text:
|
| 40 |
+
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."
|
| 41 |
+
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'}])
|
| 42 |
+
post['images']=[img]
|
| 43 |
+
posts=base._load_ai_wall();posts.insert(0,post);base._save_ai_wall(posts)
|
| 44 |
+
return JSONResponse({'post':post})
|
| 45 |
+
|
| 46 |
+
FINAL5_INJECT=r'''
|
| 47 |
+
<style>
|
| 48 |
+
/* Keep exactly one topic input */
|
| 49 |
+
#ai-topic-input-final3,.ai-compose-row.topic-final3,#ai-topic-input-final4,.topic-final4{display:none!important}.topic-final5{display:flex!important;flex-direction:column!important;gap:8px!important;width:100%!important;margin-top:6px}.topic-final5 input,.topic-final5 button{display:block!important;width:100%!important;box-sizing:border-box!important}.topic-final5 button{background:#2d8659!important;color:#fff!important;border:0!important;border-radius:18px!important;padding:9px 12px!important;font-size:11px!important;font-weight:700!important}
|
| 50 |
+
</style>
|
| 51 |
+
<script>
|
| 52 |
+
(function(){
|
| 53 |
+
function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));}
|
| 54 |
+
let shortsFinal5=[];
|
| 55 |
+
function removeDuplicateTopicBoxes(){document.querySelectorAll('#ai-topic-input-final3,.topic-final3,#ai-topic-input-final4,.topic-final4').forEach(e=>{let row=e.closest('.topic-final3,.topic-final4,.ai-compose-row')||e;e.remove?row.remove():row.style.display='none'});let comp=document.querySelector('.ai-compose');if(!comp)return;if(!document.getElementById('ai-topic-input-final5')){let row=document.createElement('div');row.className='topic-final5';row.innerHTML='<input id="ai-topic-input-final5" placeholder="Bạn muốn AI viết kiến thức về chủ đề gì? Ví dụ: thần đồng Arsenal, AI trong giáo dục, biến đổi khí hậu..."><button id="ai-topic-btn-final5" onclick="createTopicPostFinal5()">✨ Tạo bài kiến thức bằng Qwen</button>';comp.insertBefore(row,comp.firstChild.nextSibling);} }
|
| 56 |
+
window.createTopicPostFinal5=async function(){let inp=document.getElementById('ai-topic-input-final5');let topic=(inp&&inp.value||'').trim();if(!topic)return alert('Nhập chủ đề trước');let btn=document.getElementById('ai-topic-btn-final5');if(btn){btn.disabled=true;btn.textContent='Đang tạo bài...'}try{let r=await fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');if(window.finalWall)window.finalWall.unshift(j.post);if(window.finalWall3)window.finalWall3.unshift(j.post);if(inp)inp.value='';if(window.renderWall)window.renderWall();if(window.renderAIShortHome)window.renderAIShortHome();alert('Đã tạo bài kiến thức và đăng lên Tường AI.');}catch(e){alert(e.message)}finally{if(btn){btn.disabled=false;btn.textContent='✨ Tạo bài kiến thức bằng Qwen'}}};
|
| 57 |
+
async function loadShortsFinal5(){shortsFinal5=await fetch('/api/shorts?refresh=1').then(r=>r.json()).catch(()=>[]);return shortsFinal5;}
|
| 58 |
+
function actionPanel(kind,id){return `<div class="short-action-panel"><button class="short-action-btn" onclick="shortAct('${kind}','${id}','view')"><div class="ico">👁</div><span id="v-${kind}-${id}">0</span></button><button class="short-action-btn" onclick="shortAct('${kind}','${id}','like')"><div class="ico">❤️</div><span id="l-${kind}-${id}">0</span></button><button class="short-action-btn" onclick="openCommentBox('${kind}','${id}')"><div class="ico">💬</div><span>BL</span></button><button class="short-action-btn" onclick="openAskBox('${kind}','${id}')"><div class="ico">🤖</div><span>Hỏi</span></button><button class="short-action-btn" onclick="shareShortCtx('${kind}','${id}')"><div class="ico">📤</div><span>Share</span></button></div>`}
|
| 59 |
+
window.openShortsFinal5=async function(startIdx){let arts=shortsFinal5.length?shortsFinal5:await loadShortsFinal5();if(!arts.length)return alert('Chưa tải được Shorts');let ordered=startIdx>0?arts.slice(startIdx).concat(arts.slice(0,startIdx)):arts;showView('view-tiktok');let h='<button class="back-btn" onclick="switchCat(\'home\')">← Shorts Dân trí & SKĐS</button><div class="tiktok-container"><div class="tiktok-feed" id="tiktok-feed">';ordered.forEach((v,i)=>{let id=v.id||((v.link||'').match(/v=([A-Za-z0-9_-]{11})/)||[])[1]||String(i);let src='https://www.youtube.com/embed/'+id+'?autoplay=1&rel=0&playsinline=1';h+=`<div class="tiktok-slide" data-kind="yt" data-id="${id}" data-title="${esc(v.title)}" data-channel="${esc(v.channel||'')}"><iframe data-yt-src="${src}" allowfullscreen allow="accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture"></iframe><div class="tiktok-bottom"><span class="badge badge-fpt">YT</span><p class="tiktok-title">${esc(v.title)}</p></div>${actionPanel('yt',id)}<span class="tiktok-counter">${i+1}/${ordered.length}</span></div>`});h+='</div></div>';document.getElementById('view-tiktok').innerHTML=h;initShortsFeedFinal5();}
|
| 60 |
+
function initShortsFeedFinal5(){let feed=document.getElementById('tiktok-feed');if(!feed)return;let slides=feed.querySelectorAll('.tiktok-slide');let cur=-1;function act(i){if(i===cur)return;slides.forEach((sl,idx)=>{let fr=sl.querySelector('iframe');let v=sl.querySelector('video');if(idx===i){if(fr&&!fr.src&&fr.dataset.ytSrc)fr.src=fr.dataset.ytSrc;if(v)v.play().catch(()=>{});shortAct(sl.dataset.kind,sl.dataset.id,'view').catch(()=>{})}else{if(fr&&fr.src)fr.src='';if(v)v.pause();}});cur=i}let t;feed.addEventListener('scroll',()=>{clearTimeout(t);t=setTimeout(()=>{let rect=feed.getBoundingClientRect(),ctr=rect.top+rect.height/2,b=-1,d=1e9;slides.forEach((sl,i)=>{let dd=Math.abs(sl.getBoundingClientRect().top+sl.getBoundingClientRect().height/2-ctr);if(dd<d){d=dd;b=i}});if(b>=0)act(b)},130)});setTimeout(()=>act(0),250)}
|
| 61 |
+
function patchShortsHomeClick(){let home=document.getElementById('view-home');if(!home)return;document.querySelectorAll('#shorts-final4 .slider-item').forEach((el,i)=>{el.setAttribute('onclick',`openShortsFinal5(${i})`)});document.querySelectorAll('.slider-wrap .slider-label').forEach(label=>{if((label.textContent||'').includes('Shorts')){let wrap=label.closest('.slider-wrap');wrap?.querySelectorAll('.slider-item').forEach((el,i)=>el.setAttribute('onclick',`openShortsFinal5(${i})`));}})}
|
| 62 |
+
let oldOpen=window.openTikTok;window.openTikTok=function(type,startIdx){if(type==='shorts')return openShortsFinal5(startIdx||0);return oldOpen?oldOpen(type,startIdx):null;};
|
| 63 |
+
// Make YouTube ask AI receive title/channel from slide dataset.
|
| 64 |
+
let oldShortAct=window.shortAct;window.shortAct=async function(kind,id,action,text=''){let slide=document.querySelector(`.tiktok-slide[data-id="${id}"]`);let title=slide?.dataset.title||'';let channel=slide?.dataset.channel||'';let body={id,kind:kind==='yt'?'yt':kind,action,text,title,context:title?('Video Shorts YouTube từ kênh '+channel+'. Tiêu đề: '+title):''};let r=await fetch('/api/ai/interact',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});let j=await r.json();let st=j.stats||j;let v=document.getElementById(`v-${kind}-${id}`),l=document.getElementById(`l-${kind}-${id}`);if(v&&st.views!=null)v.textContent=st.views;if(l&&st.likes!=null)l.textContent=st.likes;return st;};
|
| 65 |
+
setTimeout(async()=>{removeDuplicateTopicBoxes();await loadShortsFinal5();patchShortsHomeClick();},900);setInterval(()=>{removeDuplicateTopicBoxes();patchShortsHomeClick();},1800);
|
| 66 |
+
})();
|
| 67 |
+
</script>
|
| 68 |
+
'''
|
| 69 |
+
|
| 70 |
+
@app.get('/')
|
| 71 |
+
async def index_final5():
|
| 72 |
+
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
|
| 73 |
+
return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
|
ai_runtime_final6.py
ADDED
|
@@ -0,0 +1,849 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Final6: robust topic synthesis, stable shorts, hot topic hashtags.
|
| 2 |
+
|
| 3 |
+
This runtime intentionally overrides only the topic/shorts/root endpoints from the restored app.
|
| 4 |
+
"""
|
| 5 |
+
import re, time, json, os, threading, html as html_lib
|
| 6 |
+
from urllib.parse import quote, urlparse, parse_qs, unquote
|
| 7 |
+
import requests
|
| 8 |
+
from bs4 import BeautifulSoup
|
| 9 |
+
import ai_runtime_final5 as f5
|
| 10 |
+
from ai_runtime_final5 import app, rt, HTMLResponse, JSONResponse, Request, Query
|
| 11 |
+
|
| 12 |
+
_PATCH={('/api/topic_post','POST'),('/api/shorts','GET'),('/api/hot_topics','GET'),('/api/topic_sources','GET'),('/','GET')}
|
| 13 |
+
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)]
|
| 14 |
+
|
| 15 |
+
_TOPIC_CACHE={}
|
| 16 |
+
_HOT_CACHE={"t":0,"d":[]}
|
| 17 |
+
_SHORTS_CACHE_FINAL6={"t":0,"d":[]}
|
| 18 |
+
_TRANSLATE_CACHE_PATH="/data/title_vi_cache.json" if os.path.isdir('/data') else "/app/data/title_vi_cache.json"
|
| 19 |
+
_translate_lock=threading.Lock()
|
| 20 |
+
YOUTUBE_HANDLES=["baodantri7941","baosuckhoedoisongboyte"]
|
| 21 |
+
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"}
|
| 22 |
+
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())
|
| 23 |
+
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']
|
| 24 |
+
|
| 25 |
+
def clean(s):return re.sub(r"\s+"," ",html_lib.unescape(str(s or ""))).strip()
|
| 26 |
+
def _domain(u):
|
| 27 |
+
try:return urlparse(u or '').netloc.replace('www.','')
|
| 28 |
+
except Exception:return ''
|
| 29 |
+
|
| 30 |
+
def _load_title_cache():
|
| 31 |
+
try:
|
| 32 |
+
if os.path.exists(_TRANSLATE_CACHE_PATH):
|
| 33 |
+
with open(_TRANSLATE_CACHE_PATH,'r',encoding='utf-8') as f:return json.load(f)
|
| 34 |
+
except Exception:pass
|
| 35 |
+
return {}
|
| 36 |
+
def _save_title_cache(db):
|
| 37 |
+
try:
|
| 38 |
+
os.makedirs(os.path.dirname(_TRANSLATE_CACHE_PATH),exist_ok=True);tmp=_TRANSLATE_CACHE_PATH+'.tmp'
|
| 39 |
+
with open(tmp,'w',encoding='utf-8') as f:json.dump(db,f,ensure_ascii=False)
|
| 40 |
+
os.replace(tmp,_TRANSLATE_CACHE_PATH)
|
| 41 |
+
except Exception:pass
|
| 42 |
+
|
| 43 |
+
def _looks_vietnamese(s):
|
| 44 |
+
s=s or ''
|
| 45 |
+
if re.search(r'[àáạảãâầấậẩẫăằắặẳẵèéẹẻẽêềếệểễìíịỉĩòóọỏõôồốộổỗơờớợởỡùúụủũưừứựửữỳýỵỷỹđ]',s,re.I):return True
|
| 46 |
+
low=' '+s.lower()+' '
|
| 47 |
+
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 '])
|
| 48 |
+
def _translate_title_vi(title):
|
| 49 |
+
title=clean(title)
|
| 50 |
+
if not title or _looks_vietnamese(title):return title
|
| 51 |
+
with _translate_lock:
|
| 52 |
+
db=_load_title_cache()
|
| 53 |
+
if title in db:return db[title]
|
| 54 |
+
vi=title
|
| 55 |
+
try:
|
| 56 |
+
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)
|
| 57 |
+
if r.status_code==200:
|
| 58 |
+
data=r.json();vi=''.join(part[0] for part in data[0] if part and part[0]).strip() or title
|
| 59 |
+
except Exception:pass
|
| 60 |
+
vi=clean(vi)
|
| 61 |
+
with _translate_lock:
|
| 62 |
+
db=_load_title_cache();db[title]=vi;_save_title_cache(db)
|
| 63 |
+
return vi
|
| 64 |
+
|
| 65 |
+
# ===== Hot topics / hashtags =====
|
| 66 |
+
def _keywords_from_title(title):
|
| 67 |
+
title=clean(re.sub(r'\s+-\s+.*$','',title))
|
| 68 |
+
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]
|
| 69 |
+
phrases=[]
|
| 70 |
+
for n in (4,3,2):
|
| 71 |
+
for i in range(0,max(0,len(words)-n+1)):
|
| 72 |
+
ph=' '.join(words[i:i+n]).strip()
|
| 73 |
+
if len(ph)>=8:phrases.append(ph)
|
| 74 |
+
if words:phrases.append(' '.join(words[:5]))
|
| 75 |
+
return phrases[:4]
|
| 76 |
+
|
| 77 |
+
def _hot_topics():
|
| 78 |
+
now=time.time()
|
| 79 |
+
if _HOT_CACHE['d'] and now-_HOT_CACHE['t']<900:return _HOT_CACHE['d']
|
| 80 |
+
topics=[];seen=set()
|
| 81 |
+
feeds=[
|
| 82 |
+
'https://news.google.com/rss?hl=vi&gl=VN&ceid=VN:vi',
|
| 83 |
+
'https://news.google.com/rss/headlines/section/topic/NATION?hl=vi&gl=VN&ceid=VN:vi',
|
| 84 |
+
'https://news.google.com/rss/headlines/section/topic/BUSINESS?hl=vi&gl=VN&ceid=VN:vi',
|
| 85 |
+
'https://news.google.com/rss/headlines/section/topic/SPORTS?hl=vi&gl=VN&ceid=VN:vi',
|
| 86 |
+
'https://news.google.com/rss/headlines/section/topic/TECHNOLOGY?hl=vi&gl=VN&ceid=VN:vi'
|
| 87 |
+
]
|
| 88 |
+
for feed in feeds:
|
| 89 |
+
try:
|
| 90 |
+
r=requests.get(feed,headers=UA,timeout=10);r.encoding='utf-8'
|
| 91 |
+
soup=BeautifulSoup(r.text,'xml')
|
| 92 |
+
for it in soup.find_all('item')[:15]:
|
| 93 |
+
title=clean(it.find('title').get_text(' ',strip=True) if it.find('title') else '')
|
| 94 |
+
for kw in _keywords_from_title(title):
|
| 95 |
+
key=kw.lower()
|
| 96 |
+
if key not in seen and len(kw)<=60:
|
| 97 |
+
seen.add(key);topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw})
|
| 98 |
+
if len(topics)>=24:break
|
| 99 |
+
if len(topics)>=24:break
|
| 100 |
+
except Exception:pass
|
| 101 |
+
if len(topics)>=24:break
|
| 102 |
+
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']:
|
| 103 |
+
if kw.lower() not in seen:topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw})
|
| 104 |
+
_HOT_CACHE.update({'t':now,'d':topics[:24]})
|
| 105 |
+
return _HOT_CACHE['d']
|
| 106 |
+
@app.get('/api/hot_topics')
|
| 107 |
+
def api_hot_topics():return JSONResponse({'topics':_hot_topics()})
|
| 108 |
+
|
| 109 |
+
# ===== Topic web research =====
|
| 110 |
+
def _unwrap_ddg_href(href):
|
| 111 |
+
if not href:return ''
|
| 112 |
+
if href.startswith('//duckduckgo.com/l/?') or 'duckduckgo.com/l/?' in href:
|
| 113 |
+
qs=parse_qs(urlparse('https:'+href if href.startswith('//') else href).query)
|
| 114 |
+
return unquote(qs.get('uddg',[''])[0])
|
| 115 |
+
return href
|
| 116 |
+
|
| 117 |
+
def _ddg_search(query, limit=10):
|
| 118 |
+
items=[];seen=set()
|
| 119 |
+
try:
|
| 120 |
+
url='https://html.duckduckgo.com/html/?q='+quote(query)
|
| 121 |
+
r=requests.get(url,headers=UA,timeout=14);r.encoding='utf-8'
|
| 122 |
+
soup=BeautifulSoup(r.text,'lxml')
|
| 123 |
+
for res in soup.select('.result'):
|
| 124 |
+
a=res.select_one('.result__title a') or res.find('a',href=True)
|
| 125 |
+
if not a:continue
|
| 126 |
+
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))
|
| 127 |
+
if not link.startswith('http') or link in seen:continue
|
| 128 |
+
if any(bad in link for bad in ['duckduckgo.com','youtube.com','facebook.com','tiktok.com','twitter.com','x.com']):continue
|
| 129 |
+
seen.add(link);items.append({'title':title,'url':link,'source':_domain(link),'snippet':snippet})
|
| 130 |
+
if len(items)>=limit:break
|
| 131 |
+
except Exception:pass
|
| 132 |
+
return items
|
| 133 |
+
|
| 134 |
+
def _google_news_items(topic, limit=8):
|
| 135 |
+
items=[];seen=set()
|
| 136 |
+
try:
|
| 137 |
+
rss='https://news.google.com/rss/search?q='+quote(topic)+'&hl=vi&gl=VN&ceid=VN:vi'
|
| 138 |
+
r=requests.get(rss,headers=UA,timeout=12);r.encoding='utf-8'
|
| 139 |
+
soup=BeautifulSoup(r.text,'xml')
|
| 140 |
+
for it in soup.find_all('item')[:limit*2]:
|
| 141 |
+
title=clean(it.find('title').get_text(' ',strip=True) if it.find('title') else '')
|
| 142 |
+
link=clean(it.find('link').get_text(strip=True) if it.find('link') else '')
|
| 143 |
+
src=clean(it.find('source').get_text(' ',strip=True) if it.find('source') else _domain(link))
|
| 144 |
+
if title and link and link not in seen:
|
| 145 |
+
seen.add(link);items.append({'title':title,'url':link,'source':src,'snippet':''})
|
| 146 |
+
if len(items)>=limit:break
|
| 147 |
+
except Exception:pass
|
| 148 |
+
return items
|
| 149 |
+
|
| 150 |
+
def _candidate_urls(topic):
|
| 151 |
+
seen=set();items=[]
|
| 152 |
+
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']
|
| 153 |
+
for q in queries:
|
| 154 |
+
for it in _ddg_search(q,8):
|
| 155 |
+
if it['url'] not in seen:
|
| 156 |
+
seen.add(it['url']);items.append(it)
|
| 157 |
+
if len(items)>=12:break
|
| 158 |
+
for site in TRUSTED_SITES[:8]:
|
| 159 |
+
for it in _ddg_search(f'{topic} site:{site}',3):
|
| 160 |
+
if it['url'] not in seen:
|
| 161 |
+
seen.add(it['url']);items.append(it)
|
| 162 |
+
for it in _google_news_items(topic,8):
|
| 163 |
+
if it['url'] not in seen:
|
| 164 |
+
seen.add(it['url']);items.append(it)
|
| 165 |
+
return items[:24]
|
| 166 |
+
|
| 167 |
+
def _extract_article_text_bs(url, max_chars=9000):
|
| 168 |
+
try:
|
| 169 |
+
r=requests.get(url,headers=UA,timeout=16,allow_redirects=True)
|
| 170 |
+
if r.status_code>=400:return ''
|
| 171 |
+
r.encoding='utf-8';soup=BeautifulSoup(r.text,'lxml')
|
| 172 |
+
for tag in soup.find_all(['script','style','nav','footer','aside','form','noscript','iframe','svg']):tag.decompose()
|
| 173 |
+
candidates=[]
|
| 174 |
+
for sel in ['article','main','.article-content','.detail-content','.singular-content','.fck_detail','.content-detail','.entry-content','.story-body','.knc-content']:
|
| 175 |
+
el=soup.select_one(sel)
|
| 176 |
+
if el:candidates.append(el)
|
| 177 |
+
if not candidates:candidates=[soup.body or soup]
|
| 178 |
+
best=max(candidates,key=lambda el:len(el.find_all('p')) if el else 0)
|
| 179 |
+
ps=[]
|
| 180 |
+
for el in best.find_all(['p','h2','h3'],recursive=True):
|
| 181 |
+
t=clean(el.get_text(' ',strip=True))
|
| 182 |
+
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)
|
| 183 |
+
if sum(len(x) for x in ps)>max_chars:break
|
| 184 |
+
return '\n'.join(ps)[:max_chars]
|
| 185 |
+
except Exception:return ''
|
| 186 |
+
|
| 187 |
+
def _jina_read_text(url, max_chars=9000):
|
| 188 |
+
try:
|
| 189 |
+
ju='https://r.jina.ai/http://'+url
|
| 190 |
+
r=requests.get(ju,headers=UA,timeout=28);r.encoding='utf-8'
|
| 191 |
+
if r.status_code!=200 or not r.text:return ''
|
| 192 |
+
lines=[]
|
| 193 |
+
for ln in r.text.splitlines():
|
| 194 |
+
t=clean(ln)
|
| 195 |
+
if not t or t.startswith(('Title:','URL Source:','Published Time:','Markdown Content:','Image:','Description:')):continue
|
| 196 |
+
if len(t)>45:lines.append(t)
|
| 197 |
+
if sum(len(x) for x in lines)>max_chars:break
|
| 198 |
+
return '\n'.join(lines)[:max_chars]
|
| 199 |
+
except Exception:return ''
|
| 200 |
+
|
| 201 |
+
def _scrape_article_text(url, max_chars=9000):
|
| 202 |
+
text=_extract_article_text_bs(url,max_chars)
|
| 203 |
+
if len(text)<350:text=_jina_read_text(url,max_chars)
|
| 204 |
+
return text
|
| 205 |
+
|
| 206 |
+
def _score_relevance(topic, title, text, snippet=''):
|
| 207 |
+
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]
|
| 208 |
+
hay=(title+' '+snippet+' '+text[:2500]).lower()
|
| 209 |
+
if not keys:return 1
|
| 210 |
+
return sum(1 for k in keys if k in hay)
|
| 211 |
+
|
| 212 |
+
def _web_research_context(topic):
|
| 213 |
+
now=time.time();key=topic.lower().strip()
|
| 214 |
+
if key in _TOPIC_CACHE and now-_TOPIC_CACHE[key]['t']<900:return _TOPIC_CACHE[key]['d']
|
| 215 |
+
items=_candidate_urls(topic)
|
| 216 |
+
crawled=[]
|
| 217 |
+
for it in items:
|
| 218 |
+
text=_scrape_article_text(it['url'],9000)
|
| 219 |
+
rel=_score_relevance(topic,it.get('title',''),text,it.get('snippet',''))
|
| 220 |
+
if text and len(text)>300 and rel>0:
|
| 221 |
+
crawled.append({**it,'text':text,'rel':rel})
|
| 222 |
+
elif it.get('snippet') and rel>0:
|
| 223 |
+
crawled.append({**it,'text':it['snippet'],'rel':rel,'snippet_only':True})
|
| 224 |
+
crawled=sorted(crawled,key=lambda x:(x.get('rel',0),len(x.get('text',''))),reverse=True)[:6]
|
| 225 |
+
blocks=[];sources=[]
|
| 226 |
+
for it in crawled:
|
| 227 |
+
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'
|
| 228 |
+
blocks.append(f"NGUỒN: {it['source']}\nTIÊU ĐỀ: {it['title']}\n{label}:\n{it['text'][:8500]}")
|
| 229 |
+
sources.append({'title':it['title'],'url':it['url'],'via':it['source']})
|
| 230 |
+
data={'context':'\n\n---\n\n'.join(blocks),'sources':sources[:8],'count':len(blocks)}
|
| 231 |
+
_TOPIC_CACHE[key]={'t':now,'d':data}
|
| 232 |
+
return data
|
| 233 |
+
|
| 234 |
+
def _topic_image(topic):
|
| 235 |
+
try:return f5.base.pollinations_image_url(topic)
|
| 236 |
+
except Exception:return 'https://image.pollinations.ai/prompt/'+quote('Vietnamese editorial illustration, '+topic)+'?width=1024&height=576&nologo=true'
|
| 237 |
+
|
| 238 |
+
@app.get('/api/topic_sources')
|
| 239 |
+
def api_topic_sources(topic:str=Query(...)):
|
| 240 |
+
data=_web_research_context(clean(topic))
|
| 241 |
+
return JSONResponse({'count':data.get('count',0),'sources':data.get('sources',[]),'has_context':bool(data.get('context'))})
|
| 242 |
+
|
| 243 |
+
@app.post('/api/topic_post')
|
| 244 |
+
async def topic_post_synthesis(request:Request):
|
| 245 |
+
body=await request.json();topic=clean(body.get('topic',''))
|
| 246 |
+
if not topic:return JSONResponse({'error':'missing topic'},status_code=400)
|
| 247 |
+
img=_topic_image(topic);research=_web_research_context(topic);context=research.get('context','');sources=research.get('sources',[])
|
| 248 |
+
if not context or research.get('count',0)==0:
|
| 249 |
+
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)
|
| 250 |
+
prompt=f"""Bạn là biên tập viên VNEWS. Người dùng chọn chủ đề: "{topic}".
|
| 251 |
+
|
| 252 |
+
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.
|
| 253 |
+
|
| 254 |
+
DỮ LIỆU CRAWL:
|
| 255 |
+
{context[:30000]}
|
| 256 |
+
|
| 257 |
+
Yêu cầu bắt buộc:
|
| 258 |
+
- Viết bằng tiếng Việt, văn phong báo điện tử/tạp chí.
|
| 259 |
+
- Tiêu đề mới, rõ, hấp dẫn.
|
| 260 |
+
- Sapo 2-3 câu nêu vấn đề chính.
|
| 261 |
+
- 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 ý.
|
| 262 |
+
- 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.
|
| 263 |
+
- 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ẽ".
|
| 264 |
+
- Cuối bài thêm mục "Nguồn tham khảo" gồm tên nguồn ngắn gọn.
|
| 265 |
+
"""
|
| 266 |
+
text=await f5.base.qwen_generate(prompt,image_url=img,max_tokens=2800)
|
| 267 |
+
if not text or len(text)<500:
|
| 268 |
+
parts=[]
|
| 269 |
+
for block in context.split('---'):
|
| 270 |
+
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()
|
| 271 |
+
if len(body)>120:parts.append(body)
|
| 272 |
+
joined='\n\n'.join(parts)[:8500]
|
| 273 |
+
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')})))
|
| 274 |
+
post=f5.base.make_post(topic,text,img,'','topic_web_synthesis',sources=[s for s in sources if s.get('url')]);post['images']=[img]
|
| 275 |
+
posts=f5.base._load_ai_wall();posts.insert(0,post);f5.base._save_ai_wall(posts)
|
| 276 |
+
return JSONResponse({'post':post})
|
| 277 |
+
|
| 278 |
+
# ===== Stable newest Dantri/SKDS Shorts =====
|
| 279 |
+
def _yt_ytdlp(handle,count=30):
|
| 280 |
+
try:
|
| 281 |
+
import yt_dlp
|
| 282 |
+
urls=[f'https://www.youtube.com/@{handle}/shorts',f'https://www.youtube.com/@{handle}/videos']
|
| 283 |
+
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']}}}
|
| 284 |
+
for url in urls:
|
| 285 |
+
with yt_dlp.YoutubeDL(opts) as ydl:info=ydl.extract_info(url,download=False)
|
| 286 |
+
for e in (info or {}).get('entries') or []:
|
| 287 |
+
vid=e.get('id') or ''
|
| 288 |
+
if not re.match(r'^[A-Za-z0-9_-]{11}$',vid) or vid in seen:continue
|
| 289 |
+
title=e.get('title') or 'YouTube Short'
|
| 290 |
+
if url.endswith('/videos') and '#short' not in title.lower() and 'shorts' not in title.lower():continue
|
| 291 |
+
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})
|
| 292 |
+
if len(out)>=count:break
|
| 293 |
+
if len(out)>=count:break
|
| 294 |
+
return out
|
| 295 |
+
except Exception:return []
|
| 296 |
+
def _yt_html(handle,count=30):
|
| 297 |
+
out=[];seen=set()
|
| 298 |
+
for suffix in ['shorts','videos']:
|
| 299 |
+
try:
|
| 300 |
+
r=requests.get(f'https://www.youtube.com/@{handle}/{suffix}',headers=UA,timeout=15);html=r.text
|
| 301 |
+
for m in re.finditer(r'"videoId":"([A-Za-z0-9_-]{11})"',html):
|
| 302 |
+
vid=m.group(1)
|
| 303 |
+
if vid in seen:continue
|
| 304 |
+
snip=html[max(0,m.start()-1200):m.start()+2200];title='YouTube Short'
|
| 305 |
+
mt=re.search(r'"title":\{"runs":\[\{"text":"([^"]+)"',snip) or re.search(r'"accessibilityText":"([^"]+)"',snip)
|
| 306 |
+
if mt:title=clean(mt.group(1).replace('\\n',' '))
|
| 307 |
+
if suffix=='videos' and '#short' not in title.lower() and 'shorts' not in title.lower():continue
|
| 308 |
+
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})
|
| 309 |
+
if len(out)>=count:break
|
| 310 |
+
except Exception:pass
|
| 311 |
+
if len(out)>=count:break
|
| 312 |
+
return out[:count]
|
| 313 |
+
def _fallback_shorts():
|
| 314 |
+
try:return f5._fallback_shorts()
|
| 315 |
+
except Exception:return []
|
| 316 |
+
@app.get('/api/shorts')
|
| 317 |
+
def api_shorts_final6(refresh:int=Query(default=0)):
|
| 318 |
+
now=time.time()
|
| 319 |
+
if not refresh and _SHORTS_CACHE_FINAL6['d'] and now-_SHORTS_CACHE_FINAL6['t']<600:return JSONResponse(_SHORTS_CACHE_FINAL6['d'])
|
| 320 |
+
raw=[]
|
| 321 |
+
for h in YOUTUBE_HANDLES:raw.extend(_yt_ytdlp(h,30) or _yt_html(h,30))
|
| 322 |
+
raw.extend(_fallback_shorts())
|
| 323 |
+
seen=set();out=[]
|
| 324 |
+
for v in raw:
|
| 325 |
+
vid=v.get('id') or ''
|
| 326 |
+
if not vid:
|
| 327 |
+
m=re.search(r'(?:v=|shorts/|youtu\.be/)([A-Za-z0-9_-]{11})',v.get('link',''));vid=m.group(1) if m else ''
|
| 328 |
+
title=_translate_title_vi(v.get('title') or 'YouTube Short');key=vid or re.sub(r'\W+','',title.lower())[:80]
|
| 329 |
+
if not key or key in seen:continue
|
| 330 |
+
seen.add(key);item=dict(v);item['id']=vid;item['title']=title
|
| 331 |
+
if vid:item['link']='https://www.youtube.com/watch?v='+vid;item['img']='https://i.ytimg.com/vi/'+vid+'/hqdefault.jpg'
|
| 332 |
+
item['source']='yt';out.append(item)
|
| 333 |
+
if len(out)>=40:break
|
| 334 |
+
_SHORTS_CACHE_FINAL6.update({'t':now,'d':out})
|
| 335 |
+
return JSONResponse(out)
|
| 336 |
+
|
| 337 |
+
FINAL6_INJECT=r'''
|
| 338 |
+
<style>
|
| 339 |
+
#ai-topic-input-final3,.topic-final3,#ai-topic-input-final4,.topic-final4{display:none!important}.topic-final5{display:flex!important}.ai-wall-topic-live{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}.hot-topic-row{display:flex;gap:6px;overflow-x:auto;padding:4px 0}.hot-chip{flex:0 0 auto;background:#222;border:1px solid #333;color:#ddd;border-radius:16px;padding:5px 10px;font-size:11px;cursor:pointer}.hot-chip:active{transform:scale(.96)}.topic-source-note{font-size:10px;color:#777;margin-top:4px;line-height:1.3}
|
| 340 |
+
</style>
|
| 341 |
+
<script>
|
| 342 |
+
(function(){
|
| 343 |
+
function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));}
|
| 344 |
+
let liveTopicWall=[];
|
| 345 |
+
async function ensureHotTopics(){let inp=document.getElementById('ai-topic-input-final5');if(!inp||document.getElementById('hot-topic-row-final6'))return;let row=document.createElement('div');row.id='hot-topic-row-final6';row.className='hot-topic-row';row.innerHTML='<span style="color:#777;font-size:11px;padding:5px 0">Đang tải từ khóa nóng...</span>';inp.insertAdjacentElement('afterend',row);let note=document.createElement('div');note.id='topic-source-note';note.className='topic-source-note';note.textContent='AI sẽ tìm nhiều nguồn, crawl nội dung bài viết rồi tổng hợp thành bài mới.';row.insertAdjacentElement('afterend',note);let j=await fetch('/api/hot_topics').then(r=>r.json()).catch(()=>({topics:[]}));let topics=j.topics||[];row.innerHTML=topics.slice(0,18).map(t=>`<button class="hot-chip" onclick="document.getElementById('ai-topic-input-final5').value='${esc(t.topic).replace(/'/g,'\\\'')}';document.getElementById('ai-topic-input-final5').focus();">${esc(t.label)}</button>`).join('')||'';}
|
| 346 |
+
async function ensureNewsShortsHome(){if(!document.getElementById('view-home')?.classList.contains('active'))return;let labels=[...document.querySelectorAll('.slider-wrap .slider-label')];let wraps=labels.filter(l=>/shorts|short /i.test(l.textContent||'')&&!/short ai/i.test(l.textContent||'')).map(l=>l.closest('.slider-wrap')).filter(Boolean);wraps.forEach((w,i)=>{if(i>0)w.remove();});let w=wraps[0];if(w){let seen=new Set();[...w.querySelectorAll('.slider-item')].forEach(it=>{let img=it.querySelector('img')?.src||'';let tt=(it.querySelector('.slider-title')?.textContent||'').trim().toLowerCase();let k=img||tt;if(k&&seen.has(k))it.remove();else if(k)seen.add(k);});if(w.querySelectorAll('.slider-item').length>=6)return;w.remove();}let sh=await fetch('/api/shorts?refresh=1').then(r=>r.json()).catch(()=>[]);if(!sh.length)return;let wrap=document.createElement('div');wrap.className='slider-wrap';wrap.id='shorts-final6-stable';let h='<div class="slider-header"><span class="slider-label">📱 Shorts Dân trí & SKĐS</span><span class="slider-note">Mới nhất</span></div><div class="slider-track">';sh.slice(0,30).forEach((a,i)=>{h+=`<div class="slider-item shorts-item" onclick="openTikTok('shorts',${i})"><div class="slider-thumb shorts-thumb">${a.img?`<img src="${esc(a.img)}">`:''}<div class="card-play">▶</div></div><div class="slider-title">${esc(a.title)}</div></div>`});h+='</div>';wrap.innerHTML=h;let comp=document.querySelector('.ai-compose')||document.getElementById('view-home').firstChild;if(comp)comp.after(wrap);else document.getElementById('view-home').prepend(wrap);}
|
| 347 |
+
function renderLiveTopicWall(){let home=document.getElementById('view-home');if(!home||!liveTopicWall.length)return;document.getElementById('ai-wall-topic-live')?.remove();let wrap=document.createElement('div');wrap.id='ai-wall-topic-live';wrap.className='ai-wall-topic-live';let h='<div class="slider-header"><span class="slider-label">🧱 Tường AI mới</span><span class="slider-note">Tổng hợp từ web</span></div><div class="slider-track">';liveTopicWall.slice(0,20).forEach((p,i)=>{h+=`<div class="wall-item"><div class="wall-thumb">${p.img?`<img src="${esc(p.img)}">`:''}</div><div class="wall-title">${esc(p.title)}</div><div class="wall-text">${esc(p.text)}</div><div class="wall-actions"><button class="primary" onclick="readLiveTopicWall(${i})">Xem</button></div></div>`});h+='</div>';wrap.innerHTML=h;let comp=document.querySelector('.ai-compose');if(comp)comp.after(wrap);else home.prepend(wrap);}
|
| 348 |
+
window.readLiveTopicWall=function(i){let p=liveTopicWall[i];if(!p)return;showView('view-article');let imgs=(p.images||[]).filter(Boolean);let gal=imgs.length?'<div class="ai-wall-gallery">'+imgs.slice(0,12).map(u=>`<img src="${esc(u)}" loading="lazy">`).join('')+'</div>':(p.img?`<img class="article-img" src="${esc(p.img)}">`:'');document.getElementById('view-article').innerHTML=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><span class="badge badge-ai">AI</span><h1 class="article-title">${esc(p.title)}</h1>${gal}<p class="article-p" style="white-space:pre-wrap">${esc(p.text)}</p><div class="article-actions"><button onclick="shareAI?shareAI(${JSON.stringify(p).replace(/"/g,'"')},false):navigator.clipboard.writeText(location.href)">📤 Chia sẻ</button></div></div>`;window.scrollTo(0,0)};
|
| 349 |
+
window.createTopicPostFinal5=async function(){let inp=document.getElementById('ai-topic-input-final5');let topic=(inp&&inp.value||'').trim();if(!topic)return alert('Nhập chủ đề trước');let btn=document.getElementById('ai-topic-btn-final5');if(btn){btn.disabled=true;btn.textContent='Đang tìm nguồn...'}try{let src=await fetch('/api/topic_sources?topic='+encodeURIComponent(topic)).then(r=>r.json()).catch(()=>null);if(btn&&src)btn.textContent='Đã tìm '+(src.count||0)+' nguồn, đang tổng hợp...';let r=await fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');liveTopicWall.unshift(j.post);if(inp)inp.value='';renderLiveTopicWall();readLiveTopicWall(0);alert('Đã tạo bài tổng hợp từ nội dung web và đăng lên Tường AI.');}catch(e){alert(e.message)}finally{if(btn){btn.disabled=false;btn.textContent='✨ Tạo bài tổng hợp từ web bằng Qwen'}}};
|
| 350 |
+
setInterval(()=>{document.querySelectorAll('#ai-topic-input-final3,.topic-final3,#ai-topic-input-final4,.topic-final4').forEach(e=>(e.closest('.topic-final3,.topic-final4,.ai-compose-row')||e).remove());let b=document.getElementById('ai-topic-btn-final5');if(b){b.style.display='block';b.textContent='✨ Tạo bài tổng hợp từ web bằng Qwen';}ensureHotTopics();ensureNewsShortsHome();},1200);setTimeout(()=>{ensureHotTopics();ensureNewsShortsHome();},1200);
|
| 351 |
+
})();
|
| 352 |
+
</script>
|
| 353 |
+
'''
|
| 354 |
+
|
| 355 |
+
@app.get('/')
|
| 356 |
+
async def index_final6():
|
| 357 |
+
html=f5.f4.f3.f2.f1._load_index_html()
|
| 358 |
+
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
|
| 359 |
+
return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
# ===== FINAL6B: Vietnam hot hashtags + reliable VN RSS/source retrieval =====
|
| 363 |
+
VN_RSS_FEEDS = [
|
| 364 |
+
('VnExpress Thời sự','https://vnexpress.net/rss/thoi-su.rss'),
|
| 365 |
+
('VnExpress Thế giới','https://vnexpress.net/rss/the-gioi.rss'),
|
| 366 |
+
('VnExpress Kinh doanh','https://vnexpress.net/rss/kinh-doanh.rss'),
|
| 367 |
+
('VnExpress Công nghệ','https://vnexpress.net/rss/so-hoa.rss'),
|
| 368 |
+
('VnExpress Thể thao','https://vnexpress.net/rss/the-thao.rss'),
|
| 369 |
+
('VnExpress Giải trí','https://vnexpress.net/rss/giai-tri.rss'),
|
| 370 |
+
('VnExpress Sức khỏe','https://vnexpress.net/rss/suc-khoe.rss'),
|
| 371 |
+
('VnExpress Giáo dục','https://vnexpress.net/rss/giao-duc.rss'),
|
| 372 |
+
('Dân trí Xã hội','https://dantri.com.vn/rss/xa-hoi.rss'),
|
| 373 |
+
('Dân trí Thế giới','https://dantri.com.vn/rss/the-gioi.rss'),
|
| 374 |
+
('Dân trí Kinh doanh','https://dantri.com.vn/rss/kinh-doanh.rss'),
|
| 375 |
+
('Dân trí Sức khỏe','https://dantri.com.vn/rss/suc-khoe.rss'),
|
| 376 |
+
('Dân trí Thể thao','https://dantri.com.vn/rss/the-thao.rss'),
|
| 377 |
+
('Dân trí Công nghệ','https://dantri.com.vn/rss/suc-manh-so.rss'),
|
| 378 |
+
('Vietnamnet Thời sự','https://vietnamnet.vn/thoi-su.rss'),
|
| 379 |
+
('Vietnamnet Kinh doanh','https://vietnamnet.vn/kinh-doanh.rss'),
|
| 380 |
+
('Vietnamnet Công nghệ','https://vietnamnet.vn/cong-nghe.rss'),
|
| 381 |
+
('Vietnamnet Thể thao','https://vietnamnet.vn/the-thao.rss'),
|
| 382 |
+
]
|
| 383 |
+
|
| 384 |
+
def _fetch_rss_items(feed_name, feed_url, max_items=15):
|
| 385 |
+
items=[]
|
| 386 |
+
try:
|
| 387 |
+
r=requests.get(feed_url,headers=UA,timeout=10);r.encoding='utf-8'
|
| 388 |
+
soup=BeautifulSoup(r.text,'xml')
|
| 389 |
+
for it in soup.find_all('item')[:max_items]:
|
| 390 |
+
title=clean(it.find('title').get_text(' ',strip=True) if it.find('title') else '')
|
| 391 |
+
link=clean(it.find('link').get_text(strip=True) if it.find('link') else '')
|
| 392 |
+
desc=it.find('description').get_text(' ',strip=True) if it.find('description') else ''
|
| 393 |
+
desc_txt=clean(BeautifulSoup(desc,'lxml').get_text(' ',strip=True))
|
| 394 |
+
if title and link:
|
| 395 |
+
items.append({'title':title,'url':link,'source':feed_name,'snippet':desc_txt})
|
| 396 |
+
except Exception:pass
|
| 397 |
+
return items
|
| 398 |
+
|
| 399 |
+
def _vn_rss_pool():
|
| 400 |
+
now=time.time();key='vn_rss_pool'
|
| 401 |
+
if key in _TOPIC_CACHE and now-_TOPIC_CACHE[key]['t']<600:return _TOPIC_CACHE[key]['d']
|
| 402 |
+
pool=[];seen=set()
|
| 403 |
+
for name,url in VN_RSS_FEEDS:
|
| 404 |
+
for it in _fetch_rss_items(name,url,12):
|
| 405 |
+
if it['url'] not in seen:
|
| 406 |
+
seen.add(it['url']);pool.append(it)
|
| 407 |
+
_TOPIC_CACHE[key]={'t':now,'d':pool}
|
| 408 |
+
return pool
|
| 409 |
+
|
| 410 |
+
def _topic_tokens(topic):
|
| 411 |
+
toks=[w.lower() for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',topic or '') if len(w)>1]
|
| 412 |
+
return [t for t in toks if t not in STOP_WORDS]
|
| 413 |
+
|
| 414 |
+
def _score_topic_item(topic,item):
|
| 415 |
+
toks=_topic_tokens(topic)
|
| 416 |
+
hay=(item.get('title','')+' '+item.get('snippet','')+' '+item.get('source','')).lower()
|
| 417 |
+
if not toks:return 0
|
| 418 |
+
score=0
|
| 419 |
+
for t in toks:
|
| 420 |
+
if t in hay:score+=2 if len(t)>3 else 1
|
| 421 |
+
phrase=topic.lower().strip()
|
| 422 |
+
if phrase and phrase in hay:score+=8
|
| 423 |
+
return score
|
| 424 |
+
|
| 425 |
+
# Override: hashtags must be Việt Nam-focused, using VN news RSS directly.
|
| 426 |
+
def _hot_topics():
|
| 427 |
+
now=time.time()
|
| 428 |
+
if _HOT_CACHE['d'] and now-_HOT_CACHE['t']<600:return _HOT_CACHE['d']
|
| 429 |
+
pool=_vn_rss_pool()
|
| 430 |
+
freq={};display={}
|
| 431 |
+
for it in pool[:180]:
|
| 432 |
+
title=re.sub(r'\s+-\s+.*$','',it.get('title',''))
|
| 433 |
+
# Extract compact Vietnamese hot phrases from current VN headlines.
|
| 434 |
+
kws=[]
|
| 435 |
+
# quoted/name phrases first
|
| 436 |
+
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):
|
| 437 |
+
if len(m)>=6:kws.append(m)
|
| 438 |
+
kws += _keywords_from_title(title)
|
| 439 |
+
for kw in kws[:5]:
|
| 440 |
+
kw=clean(kw)
|
| 441 |
+
words=[w for w in kw.split() if w.lower() not in STOP_WORDS]
|
| 442 |
+
if len(words)<2:continue
|
| 443 |
+
kw=' '.join(words[:5])
|
| 444 |
+
if len(kw)<6 or len(kw)>55:continue
|
| 445 |
+
key=kw.lower()
|
| 446 |
+
freq[key]=freq.get(key,0)+1
|
| 447 |
+
display[key]=kw
|
| 448 |
+
ranked=sorted(freq.items(),key=lambda x:x[1],reverse=True)
|
| 449 |
+
topics=[];seen=set()
|
| 450 |
+
for key,_ in ranked:
|
| 451 |
+
kw=display[key]
|
| 452 |
+
if key in seen:continue
|
| 453 |
+
seen.add(key)
|
| 454 |
+
label='#'+re.sub(r'\s+','',kw.title())
|
| 455 |
+
topics.append({'label':label,'topic':kw})
|
| 456 |
+
if len(topics)>=24:break
|
| 457 |
+
# VN fallback, not generic global.
|
| 458 |
+
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']:
|
| 459 |
+
if kw.lower() not in seen:topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw})
|
| 460 |
+
_HOT_CACHE.update({'t':now,'d':topics[:24]})
|
| 461 |
+
return _HOT_CACHE['d']
|
| 462 |
+
|
| 463 |
+
def _candidate_urls(topic):
|
| 464 |
+
seen=set();items=[]
|
| 465 |
+
# 1) VN RSS pool relevance is most reliable and has direct URLs.
|
| 466 |
+
scored=[]
|
| 467 |
+
for it in _vn_rss_pool():
|
| 468 |
+
sc=_score_topic_item(topic,it)
|
| 469 |
+
if sc>0:scored.append((sc,it))
|
| 470 |
+
for sc,it in sorted(scored,key=lambda x:x[0],reverse=True)[:12]:
|
| 471 |
+
if it['url'] not in seen:
|
| 472 |
+
seen.add(it['url']);items.append(it)
|
| 473 |
+
# 2) Search trusted web if RSS not enough.
|
| 474 |
+
queries=[topic+' Việt Nam tin tức',topic+' phân tích Việt Nam',topic+' mới nhất']
|
| 475 |
+
for q in queries:
|
| 476 |
+
for it in _ddg_search(q,8):
|
| 477 |
+
if it['url'] not in seen:
|
| 478 |
+
seen.add(it['url']);items.append(it)
|
| 479 |
+
if len(items)>=14:break
|
| 480 |
+
# 3) Google News as supplemental titles/direct links.
|
| 481 |
+
for it in _google_news_items(topic,10):
|
| 482 |
+
if it['url'] not in seen:
|
| 483 |
+
seen.add(it['url']);items.append(it)
|
| 484 |
+
return items[:24]
|
| 485 |
+
|
| 486 |
+
def _web_research_context(topic):
|
| 487 |
+
now=time.time();key='ctx2:'+topic.lower().strip()
|
| 488 |
+
if key in _TOPIC_CACHE and now-_TOPIC_CACHE[key]['t']<900:return _TOPIC_CACHE[key]['d']
|
| 489 |
+
items=_candidate_urls(topic)
|
| 490 |
+
crawled=[]
|
| 491 |
+
for it in items:
|
| 492 |
+
text=_scrape_article_text(it['url'],9000)
|
| 493 |
+
rel=_score_relevance(topic,it.get('title',''),text,it.get('snippet','')) or _score_topic_item(topic,it)
|
| 494 |
+
# If RSS item has good snippet, keep it even when full text blocks.
|
| 495 |
+
if text and len(text)>300 and rel>0:
|
| 496 |
+
crawled.append({**it,'text':text,'rel':rel})
|
| 497 |
+
elif it.get('snippet') and len(it['snippet'])>120 and rel>0:
|
| 498 |
+
crawled.append({**it,'text':it['snippet'],'rel':rel,'snippet_only':True})
|
| 499 |
+
crawled=sorted(crawled,key=lambda x:(x.get('rel',0),len(x.get('text',''))),reverse=True)[:7]
|
| 500 |
+
blocks=[];sources=[]
|
| 501 |
+
for it in crawled:
|
| 502 |
+
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'
|
| 503 |
+
blocks.append(f"NGUỒN: {it['source']}\nTIÊU ĐỀ: {it['title']}\n{label}:\n{it['text'][:8500]}")
|
| 504 |
+
sources.append({'title':it['title'],'url':it['url'],'via':it['source']})
|
| 505 |
+
data={'context':'\n\n---\n\n'.join(blocks),'sources':sources[:8],'count':len(blocks)}
|
| 506 |
+
_TOPIC_CACHE[key]={'t':now,'d':data}
|
| 507 |
+
return data
|
| 508 |
+
|
| 509 |
+
|
| 510 |
+
# ===== FINAL6C: FAST topic generation (RSS cache first, no slow full-page crawling) =====
|
| 511 |
+
import asyncio
|
| 512 |
+
_FAST_TOPIC_CACHE={}
|
| 513 |
+
FAST_RSS_FEEDS=[
|
| 514 |
+
('VnExpress','https://vnexpress.net/rss/tin-moi-nhat.rss'),
|
| 515 |
+
('VnExpress Thời sự','https://vnexpress.net/rss/thoi-su.rss'),
|
| 516 |
+
('VnExpress Thế giới','https://vnexpress.net/rss/the-gioi.rss'),
|
| 517 |
+
('VnExpress Kinh doanh','https://vnexpress.net/rss/kinh-doanh.rss'),
|
| 518 |
+
('VnExpress Công nghệ','https://vnexpress.net/rss/so-hoa.rss'),
|
| 519 |
+
('VnExpress Thể thao','https://vnexpress.net/rss/the-thao.rss'),
|
| 520 |
+
('Dân trí','https://dantri.com.vn/rss/home.rss'),
|
| 521 |
+
('Dân trí Xã hội','https://dantri.com.vn/rss/xa-hoi.rss'),
|
| 522 |
+
('Dân trí Kinh doanh','https://dantri.com.vn/rss/kinh-doanh.rss'),
|
| 523 |
+
('Dân trí Thể thao','https://dantri.com.vn/rss/the-thao.rss'),
|
| 524 |
+
('Dân trí Công nghệ','https://dantri.com.vn/rss/suc-manh-so.rss'),
|
| 525 |
+
('Vietnamnet','https://vietnamnet.vn/rss/tin-moi-nhat.rss'),
|
| 526 |
+
('Vietnamnet Thời sự','https://vietnamnet.vn/thoi-su.rss'),
|
| 527 |
+
('Vietnamnet Kinh doanh','https://vietnamnet.vn/kinh-doanh.rss'),
|
| 528 |
+
('Vietnamnet Công nghệ','https://vietnamnet.vn/cong-nghe.rss'),
|
| 529 |
+
('Vietnamnet Thể thao','https://vietnamnet.vn/the-thao.rss'),
|
| 530 |
+
]
|
| 531 |
+
|
| 532 |
+
def _fast_fetch_rss(feed_name, feed_url, max_items=20):
|
| 533 |
+
items=[]
|
| 534 |
+
try:
|
| 535 |
+
r=requests.get(feed_url,headers=UA,timeout=6);r.encoding='utf-8'
|
| 536 |
+
soup=BeautifulSoup(r.text,'xml')
|
| 537 |
+
for it in soup.find_all('item')[:max_items]:
|
| 538 |
+
title=clean(it.find('title').get_text(' ',strip=True) if it.find('title') else '')
|
| 539 |
+
link=clean(it.find('link').get_text(strip=True) if it.find('link') else '')
|
| 540 |
+
desc_raw=it.find('description').get_text(' ',strip=True) if it.find('description') else ''
|
| 541 |
+
desc=clean(BeautifulSoup(desc_raw,'lxml').get_text(' ',strip=True))
|
| 542 |
+
if title and link:
|
| 543 |
+
items.append({'title':title,'url':link,'source':feed_name,'snippet':desc})
|
| 544 |
+
except Exception:pass
|
| 545 |
+
return items
|
| 546 |
+
|
| 547 |
+
def _fast_rss_pool():
|
| 548 |
+
now=time.time();key='fast_rss_pool'
|
| 549 |
+
if key in _FAST_TOPIC_CACHE and now-_FAST_TOPIC_CACHE[key]['t']<600:return _FAST_TOPIC_CACHE[key]['d']
|
| 550 |
+
pool=[];seen=set()
|
| 551 |
+
# Sequential with short timeouts is predictable; RSS is small.
|
| 552 |
+
for name,url in FAST_RSS_FEEDS:
|
| 553 |
+
for it in _fast_fetch_rss(name,url,16):
|
| 554 |
+
if it['url'] not in seen:
|
| 555 |
+
seen.add(it['url']);pool.append(it)
|
| 556 |
+
_FAST_TOPIC_CACHE[key]={'t':now,'d':pool}
|
| 557 |
+
return pool
|
| 558 |
+
|
| 559 |
+
def _fast_topic_tokens(topic):
|
| 560 |
+
toks=[w.lower() for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',topic or '') if len(w)>1]
|
| 561 |
+
return [t for t in toks if t not in STOP_WORDS]
|
| 562 |
+
|
| 563 |
+
def _fast_score(topic,item):
|
| 564 |
+
toks=_fast_topic_tokens(topic)
|
| 565 |
+
hay=(item.get('title','')+' '+item.get('snippet','')+' '+item.get('source','')).lower()
|
| 566 |
+
if not toks:return 0
|
| 567 |
+
score=0
|
| 568 |
+
for t in toks:
|
| 569 |
+
if t in hay:score+=3 if len(t)>3 else 1
|
| 570 |
+
phrase=topic.lower().strip()
|
| 571 |
+
if phrase and phrase in hay:score+=12
|
| 572 |
+
return score
|
| 573 |
+
|
| 574 |
+
def _fast_sources(topic, limit=8):
|
| 575 |
+
pool=_fast_rss_pool()
|
| 576 |
+
scored=[]
|
| 577 |
+
for it in pool:
|
| 578 |
+
sc=_fast_score(topic,it)
|
| 579 |
+
if sc>0:scored.append((sc,it))
|
| 580 |
+
scored=sorted(scored,key=lambda x:(x[0],len(x[1].get('snippet',''))),reverse=True)
|
| 581 |
+
out=[];seen=set()
|
| 582 |
+
for sc,it in scored:
|
| 583 |
+
if it['url'] in seen:continue
|
| 584 |
+
seen.add(it['url']);out.append({**it,'score':sc})
|
| 585 |
+
if len(out)>=limit:break
|
| 586 |
+
# If topic too narrow and no match, use top latest from VN RSS as weak context instead of slow crawling.
|
| 587 |
+
if not out:
|
| 588 |
+
out=pool[:min(limit,8)]
|
| 589 |
+
return out
|
| 590 |
+
|
| 591 |
+
def _fast_context(topic):
|
| 592 |
+
now=time.time();key='fast_ctx:'+topic.lower().strip()
|
| 593 |
+
if key in _FAST_TOPIC_CACHE and now-_FAST_TOPIC_CACHE[key]['t']<600:return _FAST_TOPIC_CACHE[key]['d']
|
| 594 |
+
sources=_fast_sources(topic,8)
|
| 595 |
+
blocks=[];src=[]
|
| 596 |
+
for it in sources:
|
| 597 |
+
text=(it.get('snippet') or '').strip()
|
| 598 |
+
# Use title + RSS description only: fast and reliable.
|
| 599 |
+
blocks.append(f"NGUỒN: {it.get('source','')}\nTIÊU ĐỀ: {it.get('title','')}\nTÓM TẮT RSS:\n{text}")
|
| 600 |
+
src.append({'title':it.get('title',''),'url':it.get('url',''),'via':it.get('source','')})
|
| 601 |
+
data={'context':'\n\n---\n\n'.join(blocks),'sources':src,'count':len(blocks)}
|
| 602 |
+
_FAST_TOPIC_CACHE[key]={'t':now,'d':data}
|
| 603 |
+
return data
|
| 604 |
+
|
| 605 |
+
def _fallback_fast_article(topic, sources):
|
| 606 |
+
lines=[]
|
| 607 |
+
for s in sources[:7]:
|
| 608 |
+
title=s.get('title','')
|
| 609 |
+
if title:lines.append(title)
|
| 610 |
+
body='\n'.join('• '+x for x in lines[:7])
|
| 611 |
+
vias=', '.join(sorted({s.get('via','') for s in sources if s.get('via')}))
|
| 612 |
+
return (f"{topic}: những điểm đáng chú ý\n\n"
|
| 613 |
+
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"
|
| 614 |
+
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"
|
| 615 |
+
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"
|
| 616 |
+
f"Nguồn tham khảo: {vias}")
|
| 617 |
+
|
| 618 |
+
# Remove previous slow topic routes and register fast versions last.
|
| 619 |
+
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')})]
|
| 620 |
+
|
| 621 |
+
@app.get('/api/topic_sources')
|
| 622 |
+
def api_topic_sources_fast(topic:str=Query(...)):
|
| 623 |
+
data=_fast_context(clean(topic))
|
| 624 |
+
return JSONResponse({'count':data.get('count',0),'sources':data.get('sources',[]),'has_context':bool(data.get('context')),'mode':'fast_rss'})
|
| 625 |
+
|
| 626 |
+
@app.post('/api/topic_post')
|
| 627 |
+
async def topic_post_fast(request:Request):
|
| 628 |
+
body=await request.json();topic=clean(body.get('topic',''))
|
| 629 |
+
if not topic:return JSONResponse({'error':'missing topic'},status_code=400)
|
| 630 |
+
img=_topic_image(topic)
|
| 631 |
+
research=_fast_context(topic);context=research.get('context','');sources=research.get('sources',[])
|
| 632 |
+
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}
|
| 633 |
+
|
| 634 |
+
Dữ liệu nhanh từ RSS nguồn Việt Nam:
|
| 635 |
+
{context[:12000]}
|
| 636 |
+
|
| 637 |
+
Yêu cầu:
|
| 638 |
+
- Không liệt kê tiêu đề nguồn thành bài viết.
|
| 639 |
+
- Tổng hợp thành bài báo/tạp chí hoàn chỉnh.
|
| 640 |
+
- 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.
|
| 641 |
+
- Diễn đạt lại, không sao chép nguyên văn.
|
| 642 |
+
- Nếu dữ liệu ít, viết thận trọng và nêu các điểm cần theo dõi.
|
| 643 |
+
- Cuối bài có mục Nguồn tham khảo.
|
| 644 |
+
"""
|
| 645 |
+
text=None
|
| 646 |
+
try:
|
| 647 |
+
text=await asyncio.wait_for(f5.base.qwen_generate(prompt,image_url=img,max_tokens=1300),timeout=28)
|
| 648 |
+
except Exception:
|
| 649 |
+
text=None
|
| 650 |
+
if not text or len(text)<350:
|
| 651 |
+
text=_fallback_fast_article(topic,sources)
|
| 652 |
+
post=f5.base.make_post(topic,text,img,'','topic_fast_rss',sources=[s for s in sources if s.get('url')])
|
| 653 |
+
post['images']=[img]
|
| 654 |
+
posts=f5.base._load_ai_wall();posts.insert(0,post);f5.base._save_ai_wall(posts)
|
| 655 |
+
return JSONResponse({'post':post,'mode':'fast_rss','sources_count':len(sources)})
|
| 656 |
+
|
| 657 |
+
|
| 658 |
+
# ===== FINAL6D: FAST HOME LOAD =====
|
| 659 |
+
_FAST_HOME_CACHE={"t":0,"d":[]}
|
| 660 |
+
_FAST_DT_CACHE={"t":0,"d":[]}
|
| 661 |
+
_FAST_VNEGO_CACHE={"t":0,"d":[]}
|
| 662 |
+
_FAST_HL_CACHE={"t":0,"d":[]}
|
| 663 |
+
|
| 664 |
+
def _rss_articles_fast(feed_url, group, source='vne', limit=6):
|
| 665 |
+
out=[]
|
| 666 |
+
try:
|
| 667 |
+
r=requests.get(feed_url,headers=UA,timeout=4);r.encoding='utf-8'
|
| 668 |
+
soup=BeautifulSoup(r.text,'xml')
|
| 669 |
+
for it in soup.find_all('item')[:limit*2]:
|
| 670 |
+
title=clean(it.find('title').get_text(' ',strip=True) if it.find('title') else '')
|
| 671 |
+
link=clean(it.find('link').get_text(strip=True) if it.find('link') else '')
|
| 672 |
+
desc_raw=it.find('description').get_text(' ',strip=True) if it.find('description') else ''
|
| 673 |
+
ds=BeautifulSoup(desc_raw,'lxml')
|
| 674 |
+
im=ds.find('img'); img=im.get('src','') if im else ''
|
| 675 |
+
desc=clean(ds.get_text(' ',strip=True))[:160]
|
| 676 |
+
if title and link:
|
| 677 |
+
out.append({'title':title,'link':link,'img':img,'summary':desc,'source':source,'group':group})
|
| 678 |
+
if len(out)>=limit:break
|
| 679 |
+
except Exception:pass
|
| 680 |
+
return out
|
| 681 |
+
|
| 682 |
+
def _fast_homepage():
|
| 683 |
+
now=time.time()
|
| 684 |
+
if _FAST_HOME_CACHE['d'] and now-_FAST_HOME_CACHE['t']<600:return _FAST_HOME_CACHE['d']
|
| 685 |
+
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')]
|
| 686 |
+
arts=[]
|
| 687 |
+
try:
|
| 688 |
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 689 |
+
with ThreadPoolExecutor(max_workers=6) as ex:
|
| 690 |
+
futs=[ex.submit(_rss_articles_fast,u,g,'vne',6) for g,u in feeds]
|
| 691 |
+
for f in as_completed(futs,timeout=7):
|
| 692 |
+
try:arts.extend(f.result() or [])
|
| 693 |
+
except Exception:pass
|
| 694 |
+
except Exception:
|
| 695 |
+
for g,u in feeds[:5]:arts.extend(_rss_articles_fast(u,g,'vne',4))
|
| 696 |
+
if arts:_FAST_HOME_CACHE.update({'t':now,'d':arts})
|
| 697 |
+
return _FAST_HOME_CACHE['d'] or arts
|
| 698 |
+
|
| 699 |
+
def _fast_dantri_hot():
|
| 700 |
+
now=time.time()
|
| 701 |
+
if _FAST_DT_CACHE['d'] and now-_FAST_DT_CACHE['t']<900:return _FAST_DT_CACHE['d']
|
| 702 |
+
data=_rss_articles_fast('https://dantri.com.vn/rss/home.rss','Tin Nổi Bật','dantri',12)
|
| 703 |
+
if data:_FAST_DT_CACHE.update({'t':now,'d':data})
|
| 704 |
+
return data
|
| 705 |
+
|
| 706 |
+
def _fast_vnego():
|
| 707 |
+
now=time.time()
|
| 708 |
+
if _FAST_VNEGO_CACHE['d'] and now-_FAST_VNEGO_CACHE['t']<900:return _FAST_VNEGO_CACHE['d']
|
| 709 |
+
out=[]
|
| 710 |
+
try:
|
| 711 |
+
r=requests.get('https://vnexpress.net/vne-go',headers=UA,timeout=4);r.encoding='utf-8'
|
| 712 |
+
soup=BeautifulSoup(r.text,'lxml');seen=set()
|
| 713 |
+
for a in soup.find_all('a',href=True):
|
| 714 |
+
href=a.get('href','');title=clean(a.get('title','') or a.get_text(' ',strip=True))
|
| 715 |
+
if not title or len(title)<8 or not href.startswith('http') or href in seen:continue
|
| 716 |
+
if '/vne-go' not in href and '/video/' not in href:continue
|
| 717 |
+
seen.add(href);img='';im=a.find('img') or (a.parent.find('img') if a.parent else None)
|
| 718 |
+
if im:img=im.get('data-src') or im.get('src','')
|
| 719 |
+
out.append({'title':title,'link':href,'img':img,'source':'vne-video'})
|
| 720 |
+
if len(out)>=10:break
|
| 721 |
+
except Exception:pass
|
| 722 |
+
_FAST_VNEGO_CACHE.update({'t':now,'d':out})
|
| 723 |
+
return out
|
| 724 |
+
|
| 725 |
+
def _fast_highlights():
|
| 726 |
+
now=time.time()
|
| 727 |
+
if _FAST_HL_CACHE['d'] and now-_FAST_HL_CACHE['t']<900:return _FAST_HL_CACHE['d']
|
| 728 |
+
_FAST_HL_CACHE.update({'t':now,'d':[]})
|
| 729 |
+
return []
|
| 730 |
+
|
| 731 |
+
for _p in ['/api/homepage','/api/dantri_hot','/api/vne_video','/api/highlights']:
|
| 732 |
+
app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)==_p and 'GET' in getattr(r,'methods',set()))]
|
| 733 |
+
@app.get('/api/homepage')
|
| 734 |
+
def api_homepage_fast():return JSONResponse(_fast_homepage())
|
| 735 |
+
@app.get('/api/dantri_hot')
|
| 736 |
+
def api_dantri_hot_fast():return JSONResponse(_fast_dantri_hot())
|
| 737 |
+
@app.get('/api/vne_video')
|
| 738 |
+
def api_vne_video_fast():return JSONResponse(_fast_vnego())
|
| 739 |
+
@app.get('/api/highlights')
|
| 740 |
+
def api_highlights_fast():return JSONResponse(_fast_highlights())
|
| 741 |
+
|
| 742 |
+
FINAL6_FAST_HOME_INJECT = """
|
| 743 |
+
<script>
|
| 744 |
+
(function(){
|
| 745 |
+
const oldFetch=window.fetch;
|
| 746 |
+
window.__allowShortRefresh=false;
|
| 747 |
+
window.fetch=function(url,opts){try{let u=String(url||'');if(u.includes('/api/shorts?refresh=1')&&!window.__allowShortRefresh)url='/api/shorts';}catch(e){}return oldFetch.call(this,url,opts)};
|
| 748 |
+
setTimeout(()=>{window.__allowShortRefresh=true;},7000);
|
| 749 |
+
})();
|
| 750 |
+
</script>
|
| 751 |
+
"""
|
| 752 |
+
app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
|
| 753 |
+
@app.get('/')
|
| 754 |
+
async def index_final6_fast_home():
|
| 755 |
+
html=f5.f4.f3.f2.f1._load_index_html()
|
| 756 |
+
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
|
| 757 |
+
return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
|
| 758 |
+
|
| 759 |
+
|
| 760 |
+
# ===== FINAL6E: SHOW SOURCE CONTENTS IN TOPIC ARTICLE =====
|
| 761 |
+
def _extract_source_details_from_context(context, sources):
|
| 762 |
+
details=[]
|
| 763 |
+
# Map source urls by title for URL/via enrichment
|
| 764 |
+
src_by_title={clean(s.get('title','')):s for s in (sources or [])}
|
| 765 |
+
for block in (context or '').split('---'):
|
| 766 |
+
block=block.strip()
|
| 767 |
+
if not block:continue
|
| 768 |
+
via='';title='';content=''
|
| 769 |
+
m=re.search(r'NGUỒN:\s*(.*)',block)
|
| 770 |
+
if m:via=clean(m.group(1))
|
| 771 |
+
m=re.search(r'TIÊU ĐỀ:\s*(.*)',block)
|
| 772 |
+
if m:title=clean(m.group(1))
|
| 773 |
+
if 'NỘI DUNG BÀI VIẾT ĐÃ CRAWL:' in block:
|
| 774 |
+
content=block.split('NỘI DUNG BÀI VIẾT ĐÃ CRAWL:',1)[1]
|
| 775 |
+
elif 'TÓM TẮT RSS:' in block:
|
| 776 |
+
content=block.split('TÓM TẮT RSS:',1)[1]
|
| 777 |
+
elif 'ĐOẠN MÔ TẢ' in block:
|
| 778 |
+
content=re.split(r'ĐOẠN MÔ TẢ[^:]*:',block,1)[-1]
|
| 779 |
+
content=clean(content)
|
| 780 |
+
if not title and not content:continue
|
| 781 |
+
s=src_by_title.get(title,{})
|
| 782 |
+
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]})
|
| 783 |
+
if len(details)>=8:break
|
| 784 |
+
return details
|
| 785 |
+
|
| 786 |
+
# Remove prior topic endpoint and register one that stores source_details in post.
|
| 787 |
+
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()))]
|
| 788 |
+
|
| 789 |
+
@app.post('/api/topic_post')
|
| 790 |
+
async def topic_post_with_source_contents(request:Request):
|
| 791 |
+
body=await request.json();topic=clean(body.get('topic',''))
|
| 792 |
+
if not topic:return JSONResponse({'error':'missing topic'},status_code=400)
|
| 793 |
+
img=_topic_image(topic)
|
| 794 |
+
research=_fast_context(topic) if '_fast_context' in globals() else _web_research_context(topic)
|
| 795 |
+
context=research.get('context','');sources=research.get('sources',[])
|
| 796 |
+
details=_extract_source_details_from_context(context,sources)
|
| 797 |
+
if not context or not details:
|
| 798 |
+
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)
|
| 799 |
+
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)])
|
| 800 |
+
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}
|
| 801 |
+
|
| 802 |
+
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.
|
| 803 |
+
|
| 804 |
+
NỘI DUNG NGUỒN:
|
| 805 |
+
{source_brief[:18000]}
|
| 806 |
+
|
| 807 |
+
Yêu cầu:
|
| 808 |
+
- Tiêu đề mới, rõ, hấp dẫn.
|
| 809 |
+
- Sapo 2-3 câu.
|
| 810 |
+
- 5-8 đoạn phân tích/bối cảnh/tác động/điểm cần lưu ý.
|
| 811 |
+
- Không dùng câu "Dưới đây là" hoặc "Tôi sẽ".
|
| 812 |
+
- Cuối bài có mục "Nguồn tham khảo" nêu tên nguồn.
|
| 813 |
+
"""
|
| 814 |
+
text=None
|
| 815 |
+
try:
|
| 816 |
+
import asyncio
|
| 817 |
+
text=await asyncio.wait_for(f5.base.qwen_generate(prompt,image_url=img,max_tokens=1700),timeout=35)
|
| 818 |
+
except Exception:
|
| 819 |
+
text=None
|
| 820 |
+
if not text or len(text)<350:
|
| 821 |
+
bullets='\n'.join([f"• {d['title']}: {d.get('content','')[:320]}" for d in details[:6]])
|
| 822 |
+
vias=', '.join(sorted({d.get('via','') for d in details if d.get('via')}))
|
| 823 |
+
text=(f"{topic}: tổng hợp những điểm đáng chú ý\n\n"
|
| 824 |
+
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"
|
| 825 |
+
f"{bullets}\n\n"
|
| 826 |
+
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"
|
| 827 |
+
f"Nguồn tham khảo: {vias}")
|
| 828 |
+
post=f5.base.make_post(topic,text,img,'','topic_fast_rss_with_sources',sources=[s for s in sources if s.get('url')])
|
| 829 |
+
post['images']=[img]
|
| 830 |
+
post['source_details']=details
|
| 831 |
+
posts=f5.base._load_ai_wall();posts.insert(0,post);f5.base._save_ai_wall(posts)
|
| 832 |
+
return JSONResponse({'post':post,'mode':'fast_rss_with_source_details','sources_count':len(details)})
|
| 833 |
+
|
| 834 |
+
FINAL6E_INJECT = """
|
| 835 |
+
<style>
|
| 836 |
+
.source-detail-box{margin-top:14px;background:#151515;border:1px solid #2b2b2b;border-radius:10px;padding:10px}.source-detail-box h3{font-size:14px;color:#5cb87a;margin-bottom:8px}.source-detail-item{background:#202020;border-radius:8px;padding:9px;margin:7px 0}.source-detail-title{font-size:12px;font-weight:700;color:#eee;line-height:1.35}.source-detail-meta{font-size:10px;color:#888;margin:3px 0}.source-detail-content{font-size:12px;color:#bbb;line-height:1.5;white-space:pre-wrap;max-height:220px;overflow:auto}.source-detail-item a{color:#5cb87a;font-size:11px;text-decoration:none}
|
| 837 |
+
</style>
|
| 838 |
+
<script>
|
| 839 |
+
(function(){
|
| 840 |
+
function escE(s){return String(s||'').replace(/[&<>\"']/g,m=>({'&':'&','<':'<','>':'>','\"':'"',"'":'''}[m]));}
|
| 841 |
+
window.__topicWallE=[];
|
| 842 |
+
function sourceDetailsHtml(p){let arr=p.source_details||[];if(!arr.length)return '';let h='<div class="source-detail-box"><h3>📚 Nội dung từng nguồn đã dùng</h3>';arr.forEach((s,i)=>{h+=`<div class="source-detail-item"><div class="source-detail-title">${i+1}. ${escE(s.title)}</div><div class="source-detail-meta">${escE(s.via||'Nguồn')}</div><div class="source-detail-content">${escE(s.content||'')}</div>${s.url?`<a href="${escE(s.url)}" target="_blank">Mở nguồn gốc</a>`:''}</div>`});h+='</div>';return h;}
|
| 843 |
+
function renderTopicWallE(){let home=document.getElementById('view-home');if(!home||!window.__topicWallE.length)return;document.getElementById('ai-wall-topic-live')?.remove();let wrap=document.createElement('div');wrap.id='ai-wall-topic-live';wrap.className='ai-wall-topic-live';let h='<div class="slider-header"><span class="slider-label">🧱 Tường AI mới</span><span class="slider-note">Tổng hợp từ web</span></div><div class="slider-track">';window.__topicWallE.slice(0,20).forEach((p,i)=>{h+=`<div class="wall-item"><div class="wall-thumb">${p.img?`<img src="${escE(p.img)}">`:''}</div><div class="wall-title">${escE(p.title)}</div><div class="wall-text">${escE(p.text)}</div><div class="wall-actions"><button class="primary" onclick="readTopicWallE(${i})">Xem</button></div></div>`});h+='</div>';wrap.innerHTML=h;let comp=document.querySelector('.ai-compose');if(comp)comp.after(wrap);else home.prepend(wrap);}
|
| 844 |
+
window.readTopicWallE=function(i){let p=window.__topicWallE[i];if(!p)return;showView('view-article');let imgs=(p.images||[]).filter(Boolean);let gal=imgs.length?'<div class="ai-wall-gallery">'+imgs.slice(0,12).map(u=>`<img src="${escE(u)}" loading="lazy">`).join('')+'</div>':(p.img?`<img class="article-img" src="${escE(p.img)}">`:'');let srcDetails=sourceDetailsHtml(p);document.getElementById('view-article').innerHTML=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><span class="badge badge-ai">AI</span><h1 class="article-title">${escE(p.title)}</h1>${gal}<p class="article-p" style="white-space:pre-wrap">${escE(p.text)}</p>${srcDetails}<div class="article-actions"><button onclick="shareAI?shareAI(${JSON.stringify(p).replace(/"/g,'"')},false):navigator.clipboard.writeText(location.href)">📤 Chia sẻ</button></div></div>`;window.scrollTo(0,0)};
|
| 845 |
+
window.createTopicPostFinal5=async function(){let inp=document.getElementById('ai-topic-input-final5');let topic=(inp&&inp.value||'').trim();if(!topic)return alert('Nhập chủ đề trước');let btn=document.getElementById('ai-topic-btn-final5');if(btn){btn.disabled=true;btn.textContent='Đang tìm nguồn...'}try{let src=await fetch('/api/topic_sources?topic='+encodeURIComponent(topic)).then(r=>r.json()).catch(()=>null);if(btn&&src)btn.textContent='Đã tìm '+(src.count||0)+' nguồn, đang tổng hợp...';let r=await fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');window.__topicWallE.unshift(j.post);if(inp)inp.value='';renderTopicWallE();readTopicWallE(0);alert('Đã tạo bài tổng hợp từ nội dung web và đăng lên Tường AI.');}catch(e){alert(e.message)}finally{if(btn){btn.disabled=false;btn.textContent='✨ Tạo bài tổng hợp từ web bằng Qwen'}}};
|
| 846 |
+
setInterval(()=>{document.querySelectorAll('#ai-topic-input-final3,.topic-final3,#ai-topic-input-final4,.topic-final4').forEach(e=>(e.closest('.topic-final3,.topic-final4,.ai-compose-row')||e).remove());let b=document.getElementById('ai-topic-btn-final5');if(b){b.style.display='block';b.textContent='✨ Tạo bài tổng hợp từ web bằng Qwen';}},1200);
|
| 847 |
+
})();
|
| 848 |
+
</script>
|
| 849 |
+
'''
|
ai_runtime_fix.py
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""VNEWS Short Video Fix - standalone module with clean registration.
|
| 2 |
+
This module MUST be imported LAST to register /api/ai/short endpoints.
|
| 3 |
+
FIX v1: No route filtering issues - registers endpoints unconditionally.
|
| 4 |
+
FIX v2: SSE inline endpoint for auto homepage updates
|
| 5 |
+
"""
|
| 6 |
+
import os
|
| 7 |
+
import re
|
| 8 |
+
import time
|
| 9 |
+
import json
|
| 10 |
+
import sys
|
| 11 |
+
import logging
|
| 12 |
+
import asyncio
|
| 13 |
+
import hashlib
|
| 14 |
+
import subprocess
|
| 15 |
+
import requests
|
| 16 |
+
from datetime import datetime, timezone, timedelta
|
| 17 |
+
from urllib.parse import urlparse
|
| 18 |
+
from fastapi import Request, Query
|
| 19 |
+
from fastapi.responses import JSONResponse, FileResponse
|
| 20 |
+
|
| 21 |
+
# Import dependencies
|
| 22 |
+
try:
|
| 23 |
+
import ai_ext as base
|
| 24 |
+
except ImportError:
|
| 25 |
+
import ai_runtime_final6 as base
|
| 26 |
+
|
| 27 |
+
# Try to import app from various sources
|
| 28 |
+
try:
|
| 29 |
+
from app_v2_entry import app
|
| 30 |
+
except ImportError:
|
| 31 |
+
try:
|
| 32 |
+
from main import app
|
| 33 |
+
except ImportError:
|
| 34 |
+
from ai_runtime_final6 import app
|
| 35 |
+
|
| 36 |
+
_log = logging.getLogger("short_fix")
|
| 37 |
+
_log.setLevel(logging.INFO)
|
| 38 |
+
if not _log.handlers:
|
| 39 |
+
_log.addHandler(logging.StreamHandler(sys.stderr))
|
| 40 |
+
|
| 41 |
+
DATA_DIR = "/data" if os.path.isdir("/data") else "/app/data"
|
| 42 |
+
os.makedirs(DATA_DIR, exist_ok=True)
|
| 43 |
+
SHORTS_DIR = os.path.join(DATA_DIR, "ai_shorts")
|
| 44 |
+
os.makedirs(SHORTS_DIR, exist_ok=True)
|
| 45 |
+
|
| 46 |
+
# ===== VIETNAMESE FONT DETECTION =====
|
| 47 |
+
_VN_FONT_REG = None
|
| 48 |
+
_VN_FONT_BOLD = None
|
| 49 |
+
|
| 50 |
+
def _get_vn_fonts():
|
| 51 |
+
"""Find Vietnamese-supporting fonts."""
|
| 52 |
+
global _VN_FONT_REG, _VN_FONT_BOLD
|
| 53 |
+
if _VN_FONT_REG is not None:
|
| 54 |
+
return _VN_FONT_REG, _VN_FONT_BOLD
|
| 55 |
+
|
| 56 |
+
try:
|
| 57 |
+
from PIL import ImageFont
|
| 58 |
+
except Exception:
|
| 59 |
+
_log.error("PIL not available!")
|
| 60 |
+
return None, None
|
| 61 |
+
|
| 62 |
+
# Priority: Noto > DejaVu > Liberation
|
| 63 |
+
reg_paths = [
|
| 64 |
+
"/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",
|
| 65 |
+
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
| 66 |
+
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
|
| 67 |
+
"/usr/share/fonts/truetype/freefont/FreeSans.ttf",
|
| 68 |
+
]
|
| 69 |
+
bold_paths = [
|
| 70 |
+
"/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf",
|
| 71 |
+
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
| 72 |
+
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
|
| 73 |
+
"/usr/share/fonts/truetype/freefont/FreeSans.ttf",
|
| 74 |
+
]
|
| 75 |
+
|
| 76 |
+
for path in reg_paths:
|
| 77 |
+
if os.path.exists(path):
|
| 78 |
+
try:
|
| 79 |
+
_VN_FONT_REG = ImageFont.truetype(path, 40)
|
| 80 |
+
_log.info(f"Found regular font: {path}")
|
| 81 |
+
break
|
| 82 |
+
except:
|
| 83 |
+
continue
|
| 84 |
+
|
| 85 |
+
for path in bold_paths:
|
| 86 |
+
if os.path.exists(path):
|
| 87 |
+
try:
|
| 88 |
+
_VN_FONT_BOLD = ImageFont.truetype(path, 52)
|
| 89 |
+
_log.info(f"Found bold font: {path}")
|
| 90 |
+
break
|
| 91 |
+
except:
|
| 92 |
+
continue
|
| 93 |
+
|
| 94 |
+
if _VN_FONT_REG is None:
|
| 95 |
+
_VN_FONT_REG = ImageFont.load_default()
|
| 96 |
+
if _VN_FONT_BOLD is None:
|
| 97 |
+
_VN_FONT_BOLD = _VN_FONT_REG
|
| 98 |
+
|
| 99 |
+
return _VN_FONT_REG, _VN_FONT_BOLD
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def _clean(s):
|
| 103 |
+
import html as html_lib
|
| 104 |
+
return re.sub(r"\s+", " ", html_lib.unescape(str(s or ""))).strip()
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
# ===== ROBUST TEXT SEGMENTATION =====
|
| 108 |
+
def _split_into_segments(text, max_segments=10, min_len=30):
|
| 109 |
+
"""Split text into segments - multi strategy."""
|
| 110 |
+
text = _clean(text)
|
| 111 |
+
if not text:
|
| 112 |
+
return []
|
| 113 |
+
|
| 114 |
+
# Strategy 1: bullet points
|
| 115 |
+
lines = text.split('\n')
|
| 116 |
+
segmented = []
|
| 117 |
+
for line in lines:
|
| 118 |
+
line = _clean(line)
|
| 119 |
+
line_bare = re.sub(r'^[•\-\*\d\.\)\s]+', '', line).strip()
|
| 120 |
+
if len(line_bare) > min_len:
|
| 121 |
+
segmented.append(line_bare)
|
| 122 |
+
elif len(line) > min_len:
|
| 123 |
+
segmented.append(line)
|
| 124 |
+
|
| 125 |
+
# Strategy 2: sentences (Vietnamese)
|
| 126 |
+
if len(segmented) < 2:
|
| 127 |
+
sents = re.split(r'(?<=[.!?])\s+(?=[A-Z0-9À-ỸĐ])', text)
|
| 128 |
+
segmented = [s for s in sents if len(_clean(s)) > min_len]
|
| 129 |
+
|
| 130 |
+
# Strategy 3: character chunks
|
| 131 |
+
if not segmented:
|
| 132 |
+
words = text.split()
|
| 133 |
+
for i in range(0, min(len(words), max_segments * 20), 20):
|
| 134 |
+
chunk = ' '.join(words[i:i+20])
|
| 135 |
+
if len(chunk) > min_len:
|
| 136 |
+
segmented.append(chunk)
|
| 137 |
+
|
| 138 |
+
# Strategy 4: fallback
|
| 139 |
+
if not segmented:
|
| 140 |
+
segmented = [text[:300]]
|
| 141 |
+
|
| 142 |
+
return segmented[:max_segments]
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
# ===== SHORT VIDEO GENERATOR =====
|
| 146 |
+
def _gen_short_core(post, work_dir):
|
| 147 |
+
"""Core short generation - returns video path or None."""
|
| 148 |
+
post_id = post.get('id', '')
|
| 149 |
+
text = post.get('text', '') or post.get('title', '')
|
| 150 |
+
|
| 151 |
+
if not post_id or len(text) < 100:
|
| 152 |
+
_log.error(f"Invalid post: id={post_id}, text_len={len(text)}")
|
| 153 |
+
return None
|
| 154 |
+
|
| 155 |
+
segments = _split_into_segments(text, max_segments=10, min_len=30)
|
| 156 |
+
if not segments:
|
| 157 |
+
_log.error("No segments generated")
|
| 158 |
+
return None
|
| 159 |
+
|
| 160 |
+
_log.info(f"Generating short: {len(segments)} segments")
|
| 161 |
+
|
| 162 |
+
seg_hash = hashlib.md5(('|'.join(segments) + 'nu').encode()).hexdigest()[:8]
|
| 163 |
+
suffix = f"_nu_{seg_hash}"
|
| 164 |
+
out_mp4 = os.path.join(work_dir, f"{post_id}{suffix}.mp4")
|
| 165 |
+
|
| 166 |
+
if os.path.exists(out_mp4):
|
| 167 |
+
_log.info(f"Already exists: {out_mp4}")
|
| 168 |
+
return out_mp4
|
| 169 |
+
|
| 170 |
+
# Check dependencies
|
| 171 |
+
try:
|
| 172 |
+
subprocess.run(['ffmpeg', '-version'], capture_output=True, timeout=5)
|
| 173 |
+
except Exception as e:
|
| 174 |
+
_log.error(f"ffmpeg missing: {e}")
|
| 175 |
+
return None
|
| 176 |
+
|
| 177 |
+
# Download image
|
| 178 |
+
img_path = os.path.join(work_dir, 'bg.jpg')
|
| 179 |
+
downloaded = False
|
| 180 |
+
try:
|
| 181 |
+
img_url = post.get('img', '')
|
| 182 |
+
if img_url and img_url.startswith('http'):
|
| 183 |
+
r = requests.get(img_url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=12)
|
| 184 |
+
if r.status_code == 200:
|
| 185 |
+
with open(img_path, 'wb') as f:
|
| 186 |
+
f.write(r.content)
|
| 187 |
+
downloaded = True
|
| 188 |
+
except Exception as e:
|
| 189 |
+
_log.warning(f"Image download: {e}")
|
| 190 |
+
|
| 191 |
+
try:
|
| 192 |
+
from PIL import Image, ImageDraw
|
| 193 |
+
has_pil = True
|
| 194 |
+
except:
|
| 195 |
+
has_pil = False
|
| 196 |
+
_log.warning("PIL not available")
|
| 197 |
+
|
| 198 |
+
try:
|
| 199 |
+
from gtts import gTTS
|
| 200 |
+
has_tts = True
|
| 201 |
+
except:
|
| 202 |
+
has_tts = False
|
| 203 |
+
_log.warning("gTTS not available")
|
| 204 |
+
|
| 205 |
+
parts = []
|
| 206 |
+
|
| 207 |
+
for i, seg in enumerate(segments[:10]):
|
| 208 |
+
frame = os.path.join(work_dir, f'frame_{i}.jpg')
|
| 209 |
+
audio = os.path.join(work_dir, f'audio_{i}.mp3')
|
| 210 |
+
part = os.path.join(work_dir, f'part_{i}.mp4')
|
| 211 |
+
|
| 212 |
+
# Create frame
|
| 213 |
+
try:
|
| 214 |
+
if has_pil:
|
| 215 |
+
_make_frame(post, seg, img_path, downloaded, frame)
|
| 216 |
+
else:
|
| 217 |
+
subprocess.run(['ffmpeg', '-y', '-f', 'lavfi', '-i',
|
| 218 |
+
'color=c=black:s=1080x1920:d=1', '-frames:v', '1', frame],
|
| 219 |
+
capture_output=True, timeout=20)
|
| 220 |
+
except Exception as e:
|
| 221 |
+
_log.error(f"Frame error: {e}")
|
| 222 |
+
continue
|
| 223 |
+
|
| 224 |
+
# Create audio
|
| 225 |
+
if has_tts:
|
| 226 |
+
try:
|
| 227 |
+
tts = _clean(seg)[:300]
|
| 228 |
+
gTTS(tts, lang='vi', slow=False).save(audio)
|
| 229 |
+
except Exception as e:
|
| 230 |
+
_log.warning(f"TTS error: {e}")
|
| 231 |
+
audio = None
|
| 232 |
+
|
| 233 |
+
# Combine
|
| 234 |
+
dur = 10
|
| 235 |
+
try:
|
| 236 |
+
cmd = ['ffmpeg', '-y', '-loop', '1', '-t', str(dur), '-i', frame]
|
| 237 |
+
if has_tts and os.path.exists(audio):
|
| 238 |
+
cmd += ['-i', audio, '-shortest']
|
| 239 |
+
else:
|
| 240 |
+
cmd += ['-f', 'lavfi', '-i', 'anullsrc', '-shortest']
|
| 241 |
+
cmd += ['-c:v', 'libx264', '-tune', 'stillimage', '-pix_fmt', 'yuv420p',
|
| 242 |
+
'-c:a', 'aac', '-b:a', '128k', part]
|
| 243 |
+
subprocess.run(cmd, capture_output=True, timeout=120)
|
| 244 |
+
if os.path.exists(part) and os.path.getsize(part) > 5000:
|
| 245 |
+
parts.append(part)
|
| 246 |
+
except Exception as e:
|
| 247 |
+
_log.error(f"Part combine error: {e}")
|
| 248 |
+
|
| 249 |
+
if not parts:
|
| 250 |
+
_log.error("No video parts created!")
|
| 251 |
+
return None
|
| 252 |
+
|
| 253 |
+
# Concatenate
|
| 254 |
+
try:
|
| 255 |
+
concat = os.path.join(work_dir, 'list.txt')
|
| 256 |
+
with open(concat, 'w') as f:
|
| 257 |
+
for p in parts:
|
| 258 |
+
f.write(f"file '{p}'\n")
|
| 259 |
+
subprocess.run(['ffmpeg', '-y', '-f', 'concat', '-safe', '0', '-i', concat, '-c', 'copy', out_mp4],
|
| 260 |
+
capture_output=True, timeout=180)
|
| 261 |
+
_log.info(f"Short created: {out_mp4}")
|
| 262 |
+
return out_mp4
|
| 263 |
+
except Exception as e:
|
| 264 |
+
_log.error(f"Concat error: {e}")
|
| 265 |
+
return None
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
def _make_frame(post, text, img_path, downloaded, out_path):
|
| 269 |
+
"""Create video frame with Vietnamese font."""
|
| 270 |
+
from PIL import Image, ImageDraw
|
| 271 |
+
_get_vn_fonts()
|
| 272 |
+
|
| 273 |
+
W, H = 1080, 1920
|
| 274 |
+
bg = Image.new('RGB', (W, H), (15, 23, 38))
|
| 275 |
+
d = ImageDraw.Draw(bg)
|
| 276 |
+
|
| 277 |
+
# Background image
|
| 278 |
+
if downloaded and os.path.exists(img_path):
|
| 279 |
+
try:
|
| 280 |
+
im = Image.open(img_path).convert('RGB')
|
| 281 |
+
im = im.resize((W, 760))
|
| 282 |
+
bg.paste(im, (0, 0))
|
| 283 |
+
except:
|
| 284 |
+
pass
|
| 285 |
+
|
| 286 |
+
# Title
|
| 287 |
+
d.rectangle([0, 0, W, 100], fill=(25, 118, 210))
|
| 288 |
+
ttl = post.get('title', '')[:50]
|
| 289 |
+
if _VN_FONT_BOLD:
|
| 290 |
+
d.text((W//2, 50), ttl, fill='white', font=_VN_FONT_BOLD, anchor='mm')
|
| 291 |
+
|
| 292 |
+
# Content
|
| 293 |
+
y = 150
|
| 294 |
+
for ln in _wrap_text(d, text[:200], _VN_FONT_REG, 80, 920, 10):
|
| 295 |
+
d.text((80, y), ln, fill='white', font=_VN_FONT_REG)
|
| 296 |
+
y += 55
|
| 297 |
+
|
| 298 |
+
bg.save(out_path, quality=85)
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
def _wrap_text(draw, text, font, x, max_w, max_lines):
|
| 302 |
+
"""Word wrap text."""
|
| 303 |
+
words = text.split()
|
| 304 |
+
lines = []
|
| 305 |
+
cur = []
|
| 306 |
+
for w in words:
|
| 307 |
+
test = ' '.join(cur + [w])
|
| 308 |
+
try:
|
| 309 |
+
w_px = draw.textbbox((0, 0), test, font=font)[2]
|
| 310 |
+
except:
|
| 311 |
+
w_px = len(test) * 22
|
| 312 |
+
if w_px <= max_w:
|
| 313 |
+
cur.append(w)
|
| 314 |
+
else:
|
| 315 |
+
if cur:
|
| 316 |
+
lines.append(' '.join(cur))
|
| 317 |
+
cur = [w]
|
| 318 |
+
if len(lines) >= max_lines:
|
| 319 |
+
break
|
| 320 |
+
if cur and len(lines) < max_lines:
|
| 321 |
+
lines.append(' '.join(cur))
|
| 322 |
+
return lines
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
def _gen_short_sync(post) -> str:
|
| 326 |
+
"""Sync wrapper - returns video URL."""
|
| 327 |
+
work = os.path.join(SHORTS_DIR, f"work_{post.get('id', int(time.time()))}")
|
| 328 |
+
os.makedirs(work, exist_ok=True)
|
| 329 |
+
result = _gen_short_core(post, work)
|
| 330 |
+
if result:
|
| 331 |
+
# Update wall
|
| 332 |
+
try:
|
| 333 |
+
wall = base._load_ai_wall()
|
| 334 |
+
for i, p in enumerate(wall):
|
| 335 |
+
if str(p.get('id')) == str(post.get('id')):
|
| 336 |
+
p['video'] = f'/api/ai/short-file/{post.get("id")}_nu_{hashlib.md5(str(post).encode()).hexdigest()[:8]}'
|
| 337 |
+
wall[i] = p
|
| 338 |
+
break
|
| 339 |
+
base._save_ai_wall(wall)
|
| 340 |
+
# Notify SSE for auto-update
|
| 341 |
+
try:
|
| 342 |
+
from auto_update_sse import notify_new_short
|
| 343 |
+
notify_new_short(post)
|
| 344 |
+
except:
|
| 345 |
+
pass
|
| 346 |
+
except Exception as e:
|
| 347 |
+
_log.warning(f"Wall update: {e}")
|
| 348 |
+
return result
|
| 349 |
+
return ''
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
# ===== REGISTER ENDPOINTS - MUST BE AT MODULE LEVEL =====
|
| 353 |
+
@app.post('/api/ai/short/{post_id}')
|
| 354 |
+
async def api_short_generate(post_id: str, request: Request):
|
| 355 |
+
_log.info(f"POST /api/ai/short/{post_id}")
|
| 356 |
+
wall = base._load_ai_wall()
|
| 357 |
+
post = next((p for p in wall if str(p.get('id')) == str(post_id)), None)
|
| 358 |
+
if not post:
|
| 359 |
+
return JSONResponse({'error': 'Post not found in wall'}, status_code=404)
|
| 360 |
+
|
| 361 |
+
if post.get('video'):
|
| 362 |
+
return JSONResponse({'post': post, 'video': post['video'], 'status': 'done'})
|
| 363 |
+
|
| 364 |
+
loop = asyncio.get_event_loop()
|
| 365 |
+
result = await loop.run_in_executor(None, _gen_short_sync, post)
|
| 366 |
+
|
| 367 |
+
if result:
|
| 368 |
+
# Get the video URL from wall (updated in _gen_short_sync)
|
| 369 |
+
wall = base._load_ai_wall()
|
| 370 |
+
post = next((p for p in wall if str(p.get('id')) == str(post_id)), post)
|
| 371 |
+
return JSONResponse({'post': post, 'video': post.get('video'), 'status': 'done'})
|
| 372 |
+
return JSONResponse({'error': 'Video generation failed'}, status_code=500)
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
@app.get('/api/ai/short-file/{file_id:path}')
|
| 376 |
+
async def api_short_file(file_id: str):
|
| 377 |
+
safe = re.sub(r'[^\w\-.]', '_', file_id)[:100]
|
| 378 |
+
for fname in os.listdir(SHORTS_DIR) if os.path.isdir(SHORTS_DIR) else []:
|
| 379 |
+
if fname.endswith('.mp4') and safe in fname:
|
| 380 |
+
return FileResponse(os.path.join(SHORTS_DIR, fname), media_type='video/mp4')
|
| 381 |
+
return JSONResponse({'error': 'Not found'}, status_code=404)
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
# ===== SSE ENDPOINT FOR AUTO-UPDATE =====
|
| 385 |
+
try:
|
| 386 |
+
from auto_update_sse import sse_events as _sse_handler
|
| 387 |
+
app.add_api_route('/api/events', _sse_handler, methods=['GET'])
|
| 388 |
+
_log.info("SSE endpoint registered at /api/events")
|
| 389 |
+
except Exception as e:
|
| 390 |
+
_log.warning(f"SSE route not loaded: {e}")
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
# Log startup
|
| 394 |
+
_log.info("Short video endpoints registered")
|
ai_runtime_patch_fast.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Final patch v2: fix topic rewrite, remove duplicate short slide, full short interaction buttons."""
|
| 2 |
+
import re, threading, time, json, os, asyncio
|
| 3 |
+
import ai_runtime_final6 as f6
|
| 4 |
+
from ai_runtime_final6 import app, rt, f5, HTMLResponse, JSONResponse, Request, Query
|
| 5 |
+
import html as html_lib
|
| 6 |
+
from urllib.parse import urlparse
|
| 7 |
+
|
| 8 |
+
def clean(s):return re.sub(r"\s+"," ",html_lib.unescape(str(s or ""))).strip()
|
| 9 |
+
def _domain(u):
|
| 10 |
+
try:return urlparse(u or '').netloc.replace('www.','')
|
| 11 |
+
except:return ''
|
| 12 |
+
DATA_DIR="/data" if os.path.isdir('/data') else "/app/data"
|
| 13 |
+
os.makedirs(DATA_DIR,exist_ok=True)
|
| 14 |
+
SHORT_COMMENTS_FILE=os.path.join(DATA_DIR,'short_comments.json')
|
| 15 |
+
TTL_24H=86400;HAS_PERSISTENT=os.path.isdir('/data')
|
| 16 |
+
def _lj(p,d):
|
| 17 |
+
try:
|
| 18 |
+
if os.path.exists(p):return json.load(open(p,'r',encoding='utf-8'))
|
| 19 |
+
except:pass
|
| 20 |
+
return d
|
| 21 |
+
def _sj(p,d):
|
| 22 |
+
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)
|
| 23 |
+
except:pass
|
| 24 |
+
def _cleanup():
|
| 25 |
+
n=int(time.time());ps=f5.base._load_ai_wall();f=[p for p in ps if n-int(p.get('ts') or 0)<TTL_24H]
|
| 26 |
+
if len(f)<len(ps):f5.base._save_ai_wall(f)
|
| 27 |
+
def _scrape(url,mc=8000):
|
| 28 |
+
try:d=f5.base.scrape_any_url(url);return(d.get('title',''),((d.get('summary','')+'\n'+d.get('text','')).strip())[:mc],d.get('image') or d.get('og_image') or '')
|
| 29 |
+
except:return('','','')
|
| 30 |
+
_bg_home={"t":0,"d":[]};_bg_shorts={"t":0,"d":[]};_bg_lock=False
|
| 31 |
+
def _bg():
|
| 32 |
+
global _bg_lock
|
| 33 |
+
if _bg_lock:return
|
| 34 |
+
_bg_lock=True
|
| 35 |
+
try:
|
| 36 |
+
if hasattr(f6,'_fast_homepage'):d=f6._fast_homepage();(_bg_home.update({"t":time.time(),"d":d}) if d else None)
|
| 37 |
+
raw=[];[raw.extend(f6._yt_ytdlp(h,20) or f6._yt_html(h,20)) for h in f6.YOUTUBE_HANDLES];raw.extend(f6._fallback_shorts())
|
| 38 |
+
seen=set();out=[v for v in raw if v.get('id') and v['id'] not in seen and not seen.add(v['id'])]
|
| 39 |
+
if out:_bg_shorts.update({"t":time.time(),"d":out[:40]})
|
| 40 |
+
_cleanup()
|
| 41 |
+
except:pass
|
| 42 |
+
finally:_bg_lock=False
|
| 43 |
+
@app.on_event("startup")
|
| 44 |
+
async def _s():threading.Thread(target=_bg,daemon=True).start()
|
| 45 |
+
threading.Thread(target=lambda:[time.sleep(600) or _bg() for _ in iter(int,1)],daemon=True).start()
|
| 46 |
+
app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None) in ('/api/homepage','/api/shorts','/api/ai_wall','/api/topic_post','/api/article/ask','/api/topic/rewrite','/api/rewrite_share','/api/url_wall','/api/short/comments','/api/short/comment','/api/storage_status','/') and any(m in getattr(r,'methods',set()) for m in ('GET','POST')))]
|
| 47 |
+
@app.get('/api/homepage')
|
| 48 |
+
def _h():
|
| 49 |
+
n=time.time()
|
| 50 |
+
if _bg_home['d']:(threading.Thread(target=_bg,daemon=True).start() if n-_bg_home['t']>300 else None);return JSONResponse(_bg_home['d'])
|
| 51 |
+
if hasattr(f6,'_fast_homepage'):d=f6._fast_homepage();_bg_home.update({"t":n,"d":d or []});return JSONResponse(d or [])
|
| 52 |
+
return JSONResponse([])
|
| 53 |
+
@app.get('/api/shorts')
|
| 54 |
+
def _sh(refresh:int=Query(default=0)):
|
| 55 |
+
n=time.time()
|
| 56 |
+
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'])
|
| 57 |
+
return f6.api_shorts_final6(refresh=refresh) if hasattr(f6,'api_shorts_final6') else JSONResponse([])
|
| 58 |
+
@app.get('/api/ai_wall')
|
| 59 |
+
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)<TTL_24H],'persistent':HAS_PERSISTENT})
|
| 60 |
+
@app.get('/api/storage_status')
|
| 61 |
+
def _st():return JSONResponse({'persistent':HAS_PERSISTENT})
|
| 62 |
+
@app.get('/api/short/comments')
|
| 63 |
+
def _gc(id:str=Query(...)):return JSONResponse({'comments':_lj(SHORT_COMMENTS_FILE,{}).get(id,[])})
|
| 64 |
+
@app.post('/api/short/comment')
|
| 65 |
+
async def _pc(request:Request):
|
| 66 |
+
b=await request.json();v=str(b.get('id','')).strip();t=clean(b.get('text',''))
|
| 67 |
+
if not v or not t:return JSONResponse({'error':'missing'},status_code=400)
|
| 68 |
+
db=_lj(SHORT_COMMENTS_FILE,{});c=db.get(v,[]);c.insert(0,{'text':t[:300],'ts':int(time.time())});db[v]=c[:100];_sj(SHORT_COMMENTS_FILE,db);return JSONResponse({'comments':db[v]})
|
| 69 |
+
@app.post('/api/article/ask')
|
| 70 |
+
async def _ask(request:Request):
|
| 71 |
+
b=await request.json();q=clean(b.get('question',''));ctx=clean(b.get('context',''));url=clean(b.get('url',''))
|
| 72 |
+
if not q:return JSONResponse({'error':'missing question'},status_code=400)
|
| 73 |
+
title='';raw=''
|
| 74 |
+
if url:title,raw,_=_scrape(url,10000)
|
| 75 |
+
if not raw:raw=ctx[:12000]
|
| 76 |
+
ans=await f5.base.qwen_generate(f'Bạn là VNEWS AI. Nội dung: "{title}"\n{raw[:9000]}\n\nHỏi: "{q}"\n\nTrả lời tự nhiên bằng tiếng Việt.',max_tokens=1200)
|
| 77 |
+
return JSONResponse({'answer':ans or 'Chưa trả lời được.','title':title})
|
| 78 |
+
@app.post('/api/rewrite_share')
|
| 79 |
+
@app.post('/api/url_wall')
|
| 80 |
+
async def _rw(request:Request):
|
| 81 |
+
b=await request.json();url=clean(b.get('url',''));ctx=clean(b.get('context',''))
|
| 82 |
+
if not url.startswith('http'):return JSONResponse({'error':'URL không hợp lệ'},status_code=400)
|
| 83 |
+
title,raw,img=_scrape(url,14000)
|
| 84 |
+
if len(raw)<50:raw=ctx[:14000]
|
| 85 |
+
if len(raw)<50:return JSONResponse({'error':'Không đọc được bài'},status_code=422)
|
| 86 |
+
text=None
|
| 87 |
+
try:text=await asyncio.wait_for(f5.base.qwen_generate(f'Tóm tắt đăng Tường AI:\nTiêu đề: {title}\n{raw[:14000]}\n\n4-6 ý chính. Cuối ghi nguồn.',image_url=img or None,max_tokens=1000),timeout=30)
|
| 88 |
+
except:pass
|
| 89 |
+
if not text or len(text)<80:text=f"Tóm tắt: {title}\n\n{raw[:1200]}\n\nNguồn: {_domain(url)}"
|
| 90 |
+
post=f5.base.make_post(title or 'Bài viết',text,img,url,'rewrite',sources=[{'title':title,'url':url,'via':_domain(url)}])
|
| 91 |
+
ps=f5.base._load_ai_wall();ps.insert(0,post);f5.base._save_ai_wall(ps);return JSONResponse({'post':post})
|
| 92 |
+
@app.post('/api/topic/rewrite')
|
| 93 |
+
async def _tr(request:Request):
|
| 94 |
+
b=await request.json();pid=str(b.get('post_id','')).strip()
|
| 95 |
+
if not pid:return JSONResponse({'error':'missing post_id'},status_code=400)
|
| 96 |
+
ps=f5.base._load_ai_wall();p=next((x for x in ps if str(x.get('id'))==pid),None)
|
| 97 |
+
if not p:return JSONResponse({'error':'Bài không tồn tại'},status_code=404)
|
| 98 |
+
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]
|
| 99 |
+
parts=[]
|
| 100 |
+
for u in urls:t,r,_=_scrape(u,6000);(parts.append(f"[{_domain(u)}] {t}\n{r}") if r and len(r)>150 else None)
|
| 101 |
+
ac='\n---\n'.join(parts) if parts else (p.get('text') or '')
|
| 102 |
+
title=p.get('title','')
|
| 103 |
+
text=None
|
| 104 |
+
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)
|
| 105 |
+
except:pass
|
| 106 |
+
if not text or len(text)<100:text=f"Tóm tắt: {title}\n\n{ac[:1500]}\n\nNguồn: VNEWS AI"
|
| 107 |
+
np=f5.base.make_post('Rewrite: '+title,text,p.get('img',''),'','rewrite_topic',sources=p.get('sources',[]));np['images']=p.get('images',[])
|
| 108 |
+
all_p=f5.base._load_ai_wall();all_p.insert(0,np);f5.base._save_ai_wall(all_p);return JSONResponse({'post':np})
|
| 109 |
+
@app.post('/api/topic_post')
|
| 110 |
+
async def _tp(request:Request):
|
| 111 |
+
b=await request.json();topic=clean(b.get('topic',''))
|
| 112 |
+
if not topic:return JSONResponse({'error':'missing topic'},status_code=400)
|
| 113 |
+
img=f6._topic_image(topic);research=f6._fast_context(topic) if hasattr(f6,'_fast_context') else f6._web_research_context(topic)
|
| 114 |
+
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 []
|
| 115 |
+
if not ctx or not src:return JSONResponse({'error':'Không tìm được nội dung.'},status_code=422)
|
| 116 |
+
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]
|
| 117 |
+
text=None
|
| 118 |
+
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)
|
| 119 |
+
except:pass
|
| 120 |
+
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')}))
|
| 121 |
+
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
|
| 122 |
+
ps=f5.base._load_ai_wall();ps.insert(0,post);f5.base._save_ai_wall(ps);return JSONResponse({'post':post})
|
| 123 |
+
|
| 124 |
+
PATCH_INJECT=r'''
|
| 125 |
+
<style>
|
| 126 |
+
.short-cmt-panel{position:fixed;bottom:0;left:0;right:0;max-height:55vh;background:#181818;border-radius:16px 16px 0 0;z-index:99999;padding:14px;display:none;overflow-y:auto}.short-cmt-panel.active{display:block}.short-cmt-panel textarea{width:100%;background:#222;border:1px solid #444;color:#eee;border-radius:10px;padding:9px;margin:6px 0;min-height:60px}.short-cmt-panel button{background:#2d8659;border:0;color:#fff;border-radius:10px;padding:8px 12px;margin:4px}.cmt-item{background:#222;border-radius:8px;padding:7px;margin:5px 0;color:#ccc;font-size:12px}
|
| 127 |
+
.source-detail-box{margin-top:14px;background:#151515;border:1px solid #2b2b2b;border-radius:10px;padding:10px}.source-detail-item{background:#202020;border-radius:8px;padding:9px;margin:7px 0;cursor:pointer}.source-detail-title{font-size:12px;font-weight:700;color:#eee}.source-detail-content{font-size:12px;color:#bbb;line-height:1.5;white-space:pre-wrap;max-height:120px;overflow:hidden}.source-detail-item img{width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:6px;margin-bottom:6px;background:#222}.source-vnews-btn{display:inline-block;margin-top:6px;background:#2d8659;color:#fff;padding:5px 10px;border-radius:12px;font-size:11px;font-weight:700}
|
| 128 |
+
.article-ai-ask{margin-top:12px;background:#141414;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.article-ai-ask textarea{width:100%;min-height:60px;background:#222;border:1px solid #444;color:#eee;border-radius:10px;padding:9px}.article-ai-ask button{background:#2d8659;border:0;color:#fff;border-radius:10px;padding:8px 12px;margin-top:6px}.article-ai-answer{white-space:pre-wrap;color:#ccc;font-size:13px;line-height:1.55;margin-top:8px}
|
| 129 |
+
.storage-warn{background:#332200;border:1px solid #664400;color:#ffcc00;padding:8px 12px;border-radius:8px;font-size:11px;margin:6px 4px}
|
| 130 |
+
button[onclick*="rewriteCurrentArticle"]{display:none!important}
|
| 131 |
+
/* Hide ALL old Short AI slides from previous layers */
|
| 132 |
+
#ai-short-home,.ai-short-home,.ai-short-card-final{display:none!important}
|
| 133 |
+
.source-detail-box a[target="_blank"]{display:none!important}
|
| 134 |
+
</style>
|
| 135 |
+
<div id="short-cmt-panel" class="short-cmt-panel"></div>
|
| 136 |
+
<script>
|
| 137 |
+
(function(){
|
| 138 |
+
function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));}
|
| 139 |
+
fetch('/api/storage_status').then(r=>r.json()).then(j=>{if(!j.persistent){let h=document.getElementById('view-home');if(h){let w=document.createElement('div');w.className='storage-warn';w.innerHTML='⚠️ <b>Persistent Storage chưa bật.</b> Bật: Space Settings → Persistent Storage → Small.';h.prepend(w);}}});
|
| 140 |
+
|
| 141 |
+
// === Short AI Slide on homepage (same as Dantri shorts) ===
|
| 142 |
+
async function renderShortAISlide(){let home=document.getElementById('view-home');if(!home)return;document.getElementById('short-ai-final-slide')?.remove();let wall=(await fetch('/api/ai_wall').then(r=>r.json()).catch(()=>({posts:[]}))).posts||[];let vids=wall.filter(p=>p.video);if(!vids.length)return;let wrap=document.createElement('div');wrap.id='short-ai-final-slide';wrap.className='slider-wrap';wrap.innerHTML='<div class="slider-header"><span class="slider-label">🎬 Short AI</span></div><div class="slider-track">'+vids.slice(0,30).map((p,i)=>`<div class="slider-item shorts-item" onclick="openAIShortFeed(${i})"><div class="slider-thumb shorts-thumb"><video src="${p.video}" muted preload="metadata" style="width:100%;height:100%;object-fit:cover"></video><div class="card-play">▶</div></div><div class="slider-title">${esc(p.title)}</div></div>`).join('')+'</div>';let comp=home.querySelector('.ai-compose');if(comp&&comp.nextSibling)comp.parentNode.insertBefore(wrap,comp.nextSibling);else home.prepend(wrap);}
|
| 143 |
+
setTimeout(renderShortAISlide,2500);
|
| 144 |
+
|
| 145 |
+
// === Source Details ===
|
| 146 |
+
function renderSourceDetails(post,container){let det=post.source_details||[];if(!det.length)return;container.querySelectorAll('.source-detail-box').forEach(e=>e.remove());let box=document.createElement('div');box.className='source-detail-box';box.innerHTML='<h3 style="font-size:14px;color:#5cb87a;margin-bottom:8px">📚 Bài nguồn</h3>'+det.map((s,i)=>`<div class="source-detail-item" data-url="${esc(s.url||'')}"><div class="source-detail-title">${i+1}. ${esc(s.title)}</div><div class="source-detail-content">${esc((s.content||'').slice(0,300))}</div><span class="source-vnews-btn">📖 Xem trên VNEWS</span></div>`).join('');container.appendChild(box);box.querySelectorAll('.source-detail-item').forEach(el=>{el.onclick=function(){let u=el.dataset.url;if(u&&typeof readArticle==='function')readArticle(u);}});det.forEach((s,i)=>{if(!s.url)return;fetch('/api/article?url='+encodeURIComponent(s.url)).then(r=>r.json()).then(d=>{if(d&&(d.og_image||d.img)){let items=box.querySelectorAll('.source-detail-item');if(items[i]){let img=document.createElement('img');img.src=d.og_image||d.img;img.loading='lazy';img.onerror=function(){this.style.display='none'};items[i].prepend(img);}}}).catch(()=>{});});}
|
| 147 |
+
|
| 148 |
+
// === AI Wall Post View ===
|
| 149 |
+
async function readAIWallPost(i){let wall=(await fetch('/api/ai_wall').then(r=>r.json()).catch(()=>({posts:[]}))).posts||[];let p=wall[i];if(!p)return;showView('view-article');let h=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><span class="badge badge-ai">AI</span><h1 class="article-title">${esc(p.title)}</h1>${p.img?`<img class="article-img" src="${p.img}">`:''}`;h+=`<p class="article-p" style="white-space:pre-wrap">${esc(p.text)}</p>`;h+=`<div class="article-actions"><button class="primary" onclick="doRewriteTopic(this,'${esc(p.id)}')">🤖 Rewrite AI đăng tường</button>${p.video?`<button onclick="openAIShortFeed(${i})">🎬 Xem Short</button>`:''}<button onclick="doShare('${esc(p.title)}','${location.origin}','${esc(p.img||'')}')">📤</button></div>`;h+=`<div class="article-ai-ask"><h3 style="font-size:14px;color:#5cb87a">🤖 Hỏi AI</h3><textarea id="article-ai-q" placeholder="Hỏi về nội dung..."></textarea><button onclick="askAIWall(${i})">Hỏi</button><div id="article-ai-ans" class="article-ai-answer"></div></div></div>`;document.getElementById('view-article').innerHTML=h;let art=document.querySelector('.article-view');if(art)renderSourceDetails(p,art);window.scrollTo(0,0);}
|
| 150 |
+
window.readAIWallPost=readAIWallPost;window.aiReadWallPatched=window.aiReadWall=window.readWallPost=function(i){readAIWallPost(i)};
|
| 151 |
+
|
| 152 |
+
// === Short AI Feed: FULL interaction buttons like Dantri Shorts ===
|
| 153 |
+
window.openAIShortFeed=async function(startIdx){let wall=(await fetch('/api/ai_wall').then(r=>r.json()).catch(()=>({posts:[]}))).posts||[];let vids=wall.filter(p=>p.video);if(!vids.length)return alert('Chưa có Short AI');let ordered=startIdx>0?vids.slice(startIdx).concat(vids.slice(0,startIdx)):vids;showView('view-tiktok');let h='<button class="back-btn" onclick="switchCat(\'home\')">← Short AI</button><div class="tiktok-container"><div class="tiktok-feed" id="tiktok-feed">';ordered.forEach((p,i)=>{h+=`<div class="tiktok-slide" data-id="${p.id}"><video src="${p.video}" playsinline loop></video><div class="tiktok-bottom"><span class="badge badge-ai">AI Short</span><p class="tiktok-title">${esc(p.title)}</p></div><div class="tiktok-right"><button class="tiktok-right-btn" onclick="event.stopPropagation()"><div class="icon">👁</div><div class="count">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();likeShort('${p.id}',this)"><div class="icon">❤️</div><div class="count">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();openShortComments('${p.id}')"><div class="icon">💬</div><div class="count" id="cc-${p.id}">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();shareShort('${esc(p.title)}')"><div class="icon">📤</div><div class="count">Share</div></button></div><span class="tiktok-counter">${i+1}/${ordered.length}</span></div>`});h+='</div></div>';document.getElementById('view-tiktok').innerHTML=h;initShortFeed();ordered.forEach(p=>{fetch('/api/short/comments?id='+encodeURIComponent(p.id)).then(r=>r.json()).then(j=>{let el=document.getElementById('cc-'+p.id);if(el)el.textContent=(j.comments||[]).length}).catch(()=>{});});}
|
| 154 |
+
window.likeShort=function(id,btn){let c=btn.querySelector('.count');c.textContent=parseInt(c.textContent||0)+1;}
|
| 155 |
+
window.shareShort=function(title){if(navigator.share)navigator.share({title,url:location.href}).catch(()=>{});else{navigator.clipboard.writeText(location.href);alert('Đã sao chép link!');}}
|
| 156 |
+
function initShortFeed(){let feed=document.getElementById('tiktok-feed');if(!feed)return;let slides=feed.querySelectorAll('.tiktok-slide');let cur=-1;function act(i){if(i===cur)return;slides.forEach((sl,idx)=>{let v=sl.querySelector('video');let fr=sl.querySelector('iframe');if(idx===i){if(v)v.play().catch(()=>{});if(fr&&!fr.src&&fr.dataset.ytSrc)fr.src=fr.dataset.ytSrc;}else{if(v)v.pause();if(fr&&fr.src)fr.src='';}});cur=i}let t;feed.addEventListener('scroll',()=>{clearTimeout(t);t=setTimeout(()=>{let rect=feed.getBoundingClientRect(),ctr=rect.top+rect.height/2,b=-1,d=1e9;slides.forEach((sl,i)=>{let dd=Math.abs(sl.getBoundingClientRect().top+sl.getBoundingClientRect().height/2-ctr);if(dd<d){d=dd;b=i}});if(b>=0)act(b)},130)});setTimeout(()=>act(0),300);slides.forEach(sl=>{let v=sl.querySelector('video');if(v)v.addEventListener('click',e=>{e.preventDefault();v.paused?v.play().catch(()=>{}):v.pause()})});}
|
| 157 |
+
|
| 158 |
+
// === Handlers ===
|
| 159 |
+
window.doRewriteTopic=async function(btn,pid){btn.disabled=true;btn.textContent='Đang rewrite...';try{let r=await fetch('/api/topic/rewrite',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({post_id:pid})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');alert('Rewrite thành công!');showRewriteResult(j.post);}catch(e){alert(e.message)}finally{btn.disabled=false;btn.textContent='🤖 Rewrite AI đăng tường';}};
|
| 160 |
+
window.doRewriteArticle=async function(btn){let url=(window._currentArticle&&window._currentArticle.url)||'';if(!url){let a=document.querySelector('#view-article a[href*="://"]');if(a)url=a.href;}if(!url){let text=document.querySelector('.article-view')?.innerText?.slice(0,14000)||'';if(text.length<100){alert('Không tìm được nội dung để rewrite');return;}btn.disabled=true;btn.textContent='Đang rewrite...';try{let r=await fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:'https://vnews.local/inline',context:text})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error);alert('Rewrite thành công!');showRewriteResult(j.post);}catch(e){alert(e.message)}finally{btn.disabled=false;btn.textContent='🤖 Rewrite AI đăng tường';}return;}btn.disabled=true;btn.textContent='Đang rewrite...';try{let ctx=document.querySelector('.article-view')?.innerText?.slice(0,14000)||'';let r=await fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url,context:ctx})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');alert('Rewrite thành công!');showRewriteResult(j.post);}catch(e){alert(e.message)}finally{btn.disabled=false;btn.textContent='🤖 Rewrite AI đăng tường';}};
|
| 161 |
+
function showRewriteResult(post){if(!post)return;showView('view-article');document.getElementById('view-article').innerHTML=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><span class="badge badge-ai">Rewrite</span><h1 class="article-title">${esc(post.title)}</h1>${post.img?`<img class="article-img" src="${post.img}">`:''}` +`<p class="article-p" style="white-space:pre-wrap">${esc(post.text)}</p><div class="article-actions"><button class="primary" onclick="makeShortFromPost('${esc(post.id)}',this)">🎬 Tạo Short AI</button><button onclick="doShare('${esc(post.title)}','${location.origin}','${esc(post.img||'')}')">📤</button></div></div>`;window.scrollTo(0,0);}
|
| 162 |
+
window.makeShortFromPost=async function(pid,btn){if(btn){btn.disabled=true;btn.textContent='Đang tạo...';}try{let r=await fetch('/api/ai/short/'+pid,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({voice:'nu',emotion:'neutral',speed:1.2})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');alert('Đã tạo Short AI!');renderShortAISlide();}catch(e){alert(e.message)}finally{if(btn){btn.disabled=false;btn.textContent='🎬 Tạo Short AI';}}};
|
| 163 |
+
window.rewriteCurrentArticle=function(){let btn=document.querySelector('[data-rw-article]');if(btn)doRewriteArticle(btn);};
|
| 164 |
+
window.askAIWall=async function(i){let q=document.getElementById('article-ai-q')?.value.trim();if(!q)return alert('Nhập câu hỏi');document.getElementById('article-ai-ans').textContent='Đang hỏi...';let wall=(await fetch('/api/ai_wall').then(r=>r.json()).catch(()=>({posts:[]}))).posts||[];let p=wall[i]||{};let ctx=(p.text||'');for(let s of (p.source_details||[]))ctx+='\n'+(s.content||'');try{let r=await fetch('/api/article/ask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({question:q,context:ctx.slice(0,12000)})});let j=await r.json();document.getElementById('article-ai-ans').textContent=j.answer||'Không trả lời được';}catch(e){document.getElementById('article-ai-ans').textContent='Lỗi: '+e.message}};
|
| 165 |
+
window.askArticleAI=async function(){let q=document.getElementById('article-ai-question')?.value.trim();if(!q)return alert('Nhập câu hỏi');let a=document.getElementById('article-ai-answer');a.textContent='Đang hỏi...';let url=(window._currentArticle&&window._currentArticle.url)||'';let ctx=document.querySelector('.article-view')?.innerText?.slice(0,12000)||'';try{let r=await fetch('/api/article/ask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url,question:q,context:ctx})});let j=await r.json();a.textContent=j.answer||'Không trả lời được';}catch(e){a.textContent='Lỗi: '+e.message}};
|
| 166 |
+
window.openShortComments=async function(id){let panel=document.getElementById('short-cmt-panel');let j=await fetch('/api/short/comments?id='+encodeURIComponent(id)).then(r=>r.json()).catch(()=>({comments:[]}));panel.innerHTML=`<h3 style="color:#5cb87a">💬 Bình luận</h3><div id="cmt-list">${(j.comments||[]).map(c=>`<div class="cmt-item">${esc(c.text)}</div>`).join('')||'<div class="cmt-item" style="color:#777">Chưa có</div>'}</div><textarea id="cmt-text" placeholder="Bình luận..."></textarea><button onclick="submitShortCmt('${esc(id)}')">Gửi</button><button onclick="document.getElementById('short-cmt-panel').classList.remove('active')">Đóng</button>`;panel.classList.add('active');}
|
| 167 |
+
window.submitShortCmt=async function(id){let t=document.getElementById('cmt-text')?.value.trim();if(!t)return;let j=await fetch('/api/short/comment',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id,text:t})}).then(r=>r.json()).catch(()=>({comments:[]}));document.getElementById('cmt-list').innerHTML=(j.comments||[]).map(c=>`<div class="cmt-item">${esc(c.text)}</div>`).join('');document.getElementById('cmt-text').value='';let el=document.getElementById('cc-'+id);if(el)el.textContent=(j.comments||[]).length;}
|
| 168 |
+
|
| 169 |
+
// === Patch regular articles ===
|
| 170 |
+
function patchArticle(){let art=document.querySelector('#view-article .article-view');if(!art)return;art.querySelectorAll('button[onclick*="rewriteCurrentArticle"],[data-rewrite],.rewrite-injected').forEach(e=>e.remove());art.querySelectorAll('.article-ai-ask').forEach((e,i)=>{if(i>0)e.remove();});if(!art.querySelector('[data-rw-article]')){let a=art.querySelector('.article-actions');if(a){let b=document.createElement('button');b.className='primary';b.setAttribute('data-rw-article','1');b.textContent='🤖 Rewrite AI đăng tường';b.onclick=function(){doRewriteArticle(b)};a.insertBefore(b,a.firstChild);}}if(!art.querySelector('.article-ai-ask')){let box=document.createElement('div');box.className='article-ai-ask';box.innerHTML='<h3 style="font-size:14px;color:#5cb87a">🤖 Hỏi AI</h3><textarea id="article-ai-question" placeholder="Hỏi..."></textarea><button onclick="askArticleAI()">Hỏi</button><div id="article-ai-answer" class="article-ai-answer"></div>';art.appendChild(box);}}
|
| 171 |
+
function patchShortBtns(){document.querySelectorAll('.tiktok-slide').forEach(sl=>{if(sl.dataset.cmtDone)return;sl.dataset.cmtDone='1';let id=sl.dataset.id||'';if(!id)return;let r=sl.querySelector('.tiktok-right');if(!r||r.querySelector('[data-cmt]'))return;let b=document.createElement('button');b.className='tiktok-right-btn';b.setAttribute('data-cmt','1');b.innerHTML='<div class="icon">💬</div><div class="count">0</div>';b.onclick=function(e){e.stopPropagation();openShortComments(id);};r.appendChild(b);fetch('/api/short/comments?id='+encodeURIComponent(id)).then(r=>r.json()).then(j=>{b.querySelector('.count').textContent=(j.comments||[]).length}).catch(()=>{});});}
|
| 172 |
+
function patchOldSourceLinks(){document.querySelectorAll('.source-detail-item a[target="_blank"],.source-detail-item a[href]').forEach(a=>{if(a.dataset.p7)return;a.dataset.p7='1';let url=a.href||'';a.removeAttribute('target');a.removeAttribute('href');a.textContent='📖 Xem trên VNEWS';a.className='source-vnews-btn';a.style.cursor='pointer';a.onclick=function(e){e.preventDefault();e.stopPropagation();if(url&&typeof readArticle==='function')readArticle(url);}});}
|
| 173 |
+
|
| 174 |
+
let oldRA=window.readArticle;if(oldRA){window.readArticle=async function(){let ret=await oldRA.apply(this,arguments);setTimeout(patchArticle,500);return ret;}}
|
| 175 |
+
let _hl=false;function dH(){if(_hl)return;_hl=true;setTimeout(()=>{if(typeof ensureHotTopics==='function')ensureHotTopics();if(typeof ensureNewsShortsHome==='function')ensureNewsShortsHome();},4000);}
|
| 176 |
+
if(document.readyState==='complete')dH();else window.addEventListener('load',dH);
|
| 177 |
+
setInterval(()=>{patchArticle();patchShortBtns();patchOldSourceLinks();},1500);
|
| 178 |
+
})();
|
| 179 |
+
</script>
|
| 180 |
+
'''
|
| 181 |
+
|
| 182 |
+
@app.get('/')
|
| 183 |
+
async def _index():
|
| 184 |
+
html=f5.f4.f3.f2.f1._load_index_html()
|
| 185 |
+
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
|
| 186 |
+
body+=getattr(f6,'FINAL6_INJECT','');body+=getattr(f6,'FINAL6_FAST_HOME_INJECT','');body+=getattr(f6,'FINAL6E_INJECT','')
|
| 187 |
+
body+=PATCH_INJECT
|
| 188 |
+
return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
|
ai_runtime_patch_final.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Final patch: homepage fix + AI topics at top + SSE auto-update"""
|
| 2 |
+
import re, json, time
|
| 3 |
+
from fastapi.responses import HTMLResponse, JSONResponse
|
| 4 |
+
from fastapi import Query
|
| 5 |
+
|
| 6 |
+
# Import chain - must be after ai_runtime_final6
|
| 7 |
+
try:
|
| 8 |
+
import ai_runtime_final6 as f6
|
| 9 |
+
from ai_runtime_final6 import app, f5
|
| 10 |
+
from main import rt
|
| 11 |
+
except Exception as e:
|
| 12 |
+
print(f"[ERROR] f6 import: {e}")
|
| 13 |
+
f6 = None
|
| 14 |
+
f5 = None
|
| 15 |
+
rt = None
|
| 16 |
+
|
| 17 |
+
PATCH_CSS_JS = r'''
|
| 18 |
+
<style>
|
| 19 |
+
.short-cmt-panel{position:fixed;bottom:0;left:0;right:0;max-height:55vh;background:#181818;border-radius:16px 16px 0 0;z-index:99999;padding:14px;display:none;overflow-y:auto}.short-cmt-panel.active{display:block}.short-cmt-panel textarea{width:100%;background:#222;border:1px solid #444;color:#eee;border-radius:10px;padding:9px;margin:6px 0;min-height:60px}.short-cmt-panel button{background:#2d8659;border:0;color:#fff;border-radius:10px;padding:8px 12px;margin:4px}.cmt-item{background:#222;border-radius:8px;padding:7px;margin:5px 0;color:#ccc;font-size:12px}
|
| 20 |
+
.storage-warn{background:#332200;border:1px solid #664400;color:#ffcc00;padding:8px 12px;border-radius:8px;font-size:11px;margin:6px 0}
|
| 21 |
+
button[onclick*="rewriteCurrentArticle"]{display:none!important}
|
| 22 |
+
#ai-short-home,.ai-short-home,.ai-short-card-final{display:none!important}
|
| 23 |
+
</style>
|
| 24 |
+
<div id="short-cmt-panel" class="short-cmt-panel"></div>
|
| 25 |
+
<script>
|
| 26 |
+
(function(){
|
| 27 |
+
function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));}
|
| 28 |
+
fetch('/api/storage_status').then(r=>r.json()).then(j=>{if(!j.persistent){let h=document.getElementById('view-home');if(h){let w=document.createElement('div');w.className='storage-warn';w.innerHTML='⚠️ Persistent Storage chưa bật. Bật: Space Settings → Persistent Storage → Small.';h.prepend(w);}}});
|
| 29 |
+
|
| 30 |
+
// ===== AI HOT TOPICS PREPEND =====
|
| 31 |
+
const AI_HOT_TOPICS = ['Công nghệ AI', 'World Cup 2026', 'Kinh tế Việt Nam', 'Bóng đá châu Âu'];
|
| 32 |
+
async function ensureHotTopics(){let inp=document.getElementById('ai-topic-input-final5');if(!inp||document.getElementById('hot-topic-row-ai'))return;let row=document.createElement('div');row.id='hot-topic-row-ai';row.style.cssText='display:flex;gap:6px;overflow-x:auto;padding:4px 0;margin:6px 0';let topics=[];try{let j=await fetch('/api/hot_topics').then(r=>r.json()).catch(()=>({topics:[]}));topics=j.topics||[];}catch(e){topics=[];}AI_HOT_TOPICS.forEach(ai=>{if(!topics.find(t=>(t.topic||'').toLowerCase()===ai.toLowerCase())){topics.unshift({label:'#'+ai.replace(/\s+/g,''),topic:ai,count:0});}});row.innerHTML=topics.slice(0,14).map(t=>`<button class="hot-chip" style="flex:0 0 auto;background:#222;border:1px solid #333;color:#ddd;border-radius:16px;padding:5px 10px;font-size:11px;cursor:pointer" onclick="document.getElementById('ai-topic-input-final5').value='${esc(t.topic).replace(/'/g,'\\''}';document.getElementById('ai-topic-input-final5').focus();searchTopic('${esc(t.topic).replace(/'/g,'\\''}')">${esc(t.label)}</button>`).join('');inp.insertAdjacentElement('afterend',row);}
|
| 33 |
+
|
| 34 |
+
// ===== SSE AUTO UPDATE =====
|
| 35 |
+
let _sseSource=null;
|
| 36 |
+
function connectSSE(){try{_sseSource=new EventSource('/api/events');_sseSource.onmessage=e=>{try{const d=JSON.parse(e.data);if(d.type==='new_post'||d.type==='new_short'){fetch('/api/ai_wall').then(r=>r.json()).then(j=>{_wallPosts=j.posts||[];if(typeof renderShortAISlide==='function')renderShortAISlide();const track=document.getElementById('ai-wall-track');if(track)_wallPosts.length?track.innerHTML=_wallPosts.slice(0,20).map((p,i)=>makeWallItem(p,i)).join(''):null;});}}catch{}};_sseSource.onerror=()=>setTimeout(connectSSE,5000);}catch(e){}}
|
| 37 |
+
let _lastWallLen=0;
|
| 38 |
+
setInterval(()=>{fetch('/api/ai_wall').then(r=>r.json()).then(j=>{const w=j.posts||[];if(w.length!==_lastWallLen){_lastWallLen=w.length;_wallPosts=w;const track=document.getElementById('ai-wall-track');if(track)w.length?track.innerHTML=w.slice(0,20).map((p,i)=>makeWallItem(p,i)).join(''):null;if(typeof renderShortAISlide==='function')renderShortAISlide();}}).catch(()=>{});},30000);
|
| 39 |
+
connectSSE();
|
| 40 |
+
|
| 41 |
+
// ===== Short AI slide =====
|
| 42 |
+
async function renderShortAISlide(){let home=document.getElementById('view-home');if(!home)return;document.getElementById('short-ai-final-slide')?.remove();let wall=(await fetch('/api/ai_wall').then(r=>r.json()).catch(()=>({posts:[]}))).posts||[];let vids=wall.filter(p=>p.video);if(!vids.length)return;let wrap=document.createElement('div');wrap.id='short-ai-final-slide';wrap.className='slider-wrap';wrap.innerHTML='<div class="slider-header"><span class="slider-label">🎬 Short AI</span></div><div class="slider-track">'+vids.slice(0,30).map((p,i)=>`<div class="slider-item shorts-item" onclick="openAIShortFeed(${i})"><div class="slider-thumb shorts-thumb"><video src="${p.video}" muted preload="metadata" style="width:100%;height:100%;object-fit:cover"></video><div class="card-play">▶</div></div><div class="slider-title">${esc(p.title)}</div></div>`).join('')+'</div>';let comp=home.querySelector('.ai-compose');(comp&&comp.nextSibling?comp.parentNode.insertBefore(wrap,comp.nextSibling):home.prepend(wrap));}
|
| 43 |
+
setTimeout(renderShortAISlide,2500);
|
| 44 |
+
|
| 45 |
+
setInterval(ensureHotTopics,2000);
|
| 46 |
+
})();
|
| 47 |
+
</script>
|
| 48 |
+
'''
|
| 49 |
+
|
| 50 |
+
# Register route if possible
|
| 51 |
+
if f6 and app:
|
| 52 |
+
try:
|
| 53 |
+
# Remove duplicate / route to avoid conflict
|
| 54 |
+
original_routes = [r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
|
| 55 |
+
app.router.routes = original_routes
|
| 56 |
+
|
| 57 |
+
@app.get('/')
|
| 58 |
+
async def patch_homepage():
|
| 59 |
+
html = f5.f4.f3.f2.f1._load_index_html() if f5 else "<html><body></body></html>"
|
| 60 |
+
body = ""
|
| 61 |
+
if hasattr(rt,'old') and hasattr(rt.old,'PATCH_INJECT'):
|
| 62 |
+
body += getattr(rt.old,'PATCH_INJECT','')
|
| 63 |
+
if f5:
|
| 64 |
+
body += getattr(f5.f4.f3.f2.f1,'FINAL_INJECT','') if hasattr(f5,'f4') else ''
|
| 65 |
+
body += getattr(f5.f4.f3,'FINAL3_INJECT','') if hasattr(f5,'f4') else ''
|
| 66 |
+
body += getattr(f5.f4,'FINAL4_INJECT','') if hasattr(f5,'f4') else ''
|
| 67 |
+
body += getattr(f5,'FINAL5_INJECT','') if hasattr(f5,'f4') else ''
|
| 68 |
+
body += getattr(f6,'FINAL6_INJECT','') if f6 else ''
|
| 69 |
+
body += getattr(f6,'FINAL6_FAST_HOME_INJECT','') if f6 else ''
|
| 70 |
+
body += getattr(f6,'FINAL6E_INJECT','') if f6 else ''
|
| 71 |
+
body += PATCH_CSS_JS
|
| 72 |
+
if '</body>' in html:
|
| 73 |
+
html = html.replace('</body>', body + '\n</body>')
|
| 74 |
+
else:
|
| 75 |
+
html = html + body
|
| 76 |
+
return HTMLResponse(html)
|
| 77 |
+
except Exception as e:
|
| 78 |
+
print(f"[ERROR] register route: {e}")
|
ai_short_v2.py
ADDED
|
@@ -0,0 +1,1691 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""VNEWS Short AI v2 — Short creator with TikTok background music, uploaded audio,
|
| 2 |
+
uploaded video/image background, and 'recreate from designed slides' (image-only,
|
| 3 |
+
reusing previous short audio, NO text overlays) mode.
|
| 4 |
+
|
| 5 |
+
Registered by app_v2_entry.py (import ai_short_v2). Uses the SAME wall store
|
| 6 |
+
(WALL_FILE) as the wall endpoints so posts created by the designer survive and
|
| 7 |
+
can be re-shorted purely from designed images.
|
| 8 |
+
"""
|
| 9 |
+
import os
|
| 10 |
+
import re
|
| 11 |
+
import json
|
| 12 |
+
import time
|
| 13 |
+
import uuid
|
| 14 |
+
import hashlib
|
| 15 |
+
import subprocess
|
| 16 |
+
import threading
|
| 17 |
+
|
| 18 |
+
import requests
|
| 19 |
+
from urllib.parse import quote as urllib_quote
|
| 20 |
+
from fastapi import Request, UploadFile, File, Query
|
| 21 |
+
from fastapi.responses import JSONResponse, FileResponse, Response, StreamingResponse
|
| 22 |
+
|
| 23 |
+
try:
|
| 24 |
+
import yt_dlp
|
| 25 |
+
except Exception: # pragma: no cover
|
| 26 |
+
yt_dlp = None
|
| 27 |
+
|
| 28 |
+
try:
|
| 29 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 30 |
+
except Exception: # pragma: no cover
|
| 31 |
+
Image = ImageDraw = ImageFont = None
|
| 32 |
+
|
| 33 |
+
try:
|
| 34 |
+
from main import app
|
| 35 |
+
except Exception: # pragma: no cover
|
| 36 |
+
from fastapi import FastAPI
|
| 37 |
+
app = FastAPI()
|
| 38 |
+
|
| 39 |
+
DATA_DIR = "/data" if os.path.isdir("/data") else os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
|
| 40 |
+
WALL_FILE = os.path.join(DATA_DIR, "wall_posts.json")
|
| 41 |
+
SHORTS_DIR = os.path.join(DATA_DIR, "ai_shorts")
|
| 42 |
+
UPLOAD_DIR = os.path.join(DATA_DIR, "short_uploads")
|
| 43 |
+
WALL_IMG_DIR = os.path.join(DATA_DIR, "wall_imgs")
|
| 44 |
+
os.makedirs(SHORTS_DIR, exist_ok=True)
|
| 45 |
+
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
| 46 |
+
|
| 47 |
+
_wl_lock = threading.Lock()
|
| 48 |
+
|
| 49 |
+
UA_HEADERS = {
|
| 50 |
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
| 51 |
+
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
| 52 |
+
"Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8",
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
# ---------------------------------------------------------------------------
|
| 56 |
+
# Video scraping (YouTube / TikTok / news sites) via yt-dlp + oEmbed fallback
|
| 57 |
+
# ---------------------------------------------------------------------------
|
| 58 |
+
_SCRAPE_CACHE = {} # url -> {ok, ...} (in-memory, TTL 30 min)
|
| 59 |
+
_SCRAPE_TTL = 30 * 60
|
| 60 |
+
|
| 61 |
+
YTDLP_OPTS = {
|
| 62 |
+
"format": "best[height<=720][ext=mp4]/best[height<=720]/best",
|
| 63 |
+
"quiet": True,
|
| 64 |
+
"no_warnings": True,
|
| 65 |
+
"noplaylist": True,
|
| 66 |
+
"socket_timeout": 15,
|
| 67 |
+
"retries": 3,
|
| 68 |
+
"ignoreerrors": False,
|
| 69 |
+
"extractor_args": {"youtube": {"player_client": ["tv", "ios", "mweb"]}},
|
| 70 |
+
"http_headers": UA_HEADERS,
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
# ---------------------------------------------------------------------------
|
| 75 |
+
# YouTube cookies (bypasses the "Sign in to confirm you're not a bot" block).
|
| 76 |
+
# Sources, in priority order:
|
| 77 |
+
# 1. YT_COOKIES env var (HF Space secret — raw Netscape-format cookies.txt)
|
| 78 |
+
# 2. /app/cookies.txt (a cookies.txt file baked into the repo/runtime)
|
| 79 |
+
# yt-dlp reads a Netscape cookies.txt via the 'cookiefile' option.
|
| 80 |
+
# ---------------------------------------------------------------------------
|
| 81 |
+
_cookie_file = None
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _ensure_cookies():
|
| 85 |
+
"""Materialise a cookies.txt (Netscape format) for yt-dlp if any cookie
|
| 86 |
+
source is available. Returns the cookiefile path or None."""
|
| 87 |
+
global _cookie_file
|
| 88 |
+
if _cookie_file and os.path.exists(_cookie_file):
|
| 89 |
+
return _cookie_file
|
| 90 |
+
content = None
|
| 91 |
+
# 1. HF Space secret YT_COOKIES (raw cookies.txt content)
|
| 92 |
+
secret = os.environ.get("YT_COOKIES", "").strip()
|
| 93 |
+
if secret and ("# Netscape" in secret or "#HTTP" in secret or "youtube.com" in secret or "\tTRUE" in secret):
|
| 94 |
+
content = secret
|
| 95 |
+
# 2. baked-in file
|
| 96 |
+
if not content:
|
| 97 |
+
for p in ("/app/cookies.txt", "cookies.txt"):
|
| 98 |
+
if os.path.exists(p):
|
| 99 |
+
content = open(p, encoding="utf-8", errors="ignore").read()
|
| 100 |
+
break
|
| 101 |
+
if not content:
|
| 102 |
+
_cookie_file = None
|
| 103 |
+
return None
|
| 104 |
+
try:
|
| 105 |
+
path = os.path.join(DATA_DIR, "yt_cookies.txt")
|
| 106 |
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
| 107 |
+
with open(path, "w", encoding="utf-8") as f:
|
| 108 |
+
f.write(content)
|
| 109 |
+
_cookie_file = path
|
| 110 |
+
return path
|
| 111 |
+
except Exception:
|
| 112 |
+
return None
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def _ytdlp_opts():
|
| 116 |
+
"""YTDLP opts + cookiefile (only when cookies are available)."""
|
| 117 |
+
opts = dict(YTDLP_OPTS)
|
| 118 |
+
cf = _ensure_cookies()
|
| 119 |
+
if cf:
|
| 120 |
+
opts["cookiefile"] = cf
|
| 121 |
+
# also add "cookiesfrombrowser" fallback? No — datacenter IPs can't read
|
| 122 |
+
# a local browser. cookiefile is the reliable path.
|
| 123 |
+
return opts
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def _oembed_probe(url):
|
| 127 |
+
"""Cheap metadata fallback for TikTok/Facebook/Instagram (no direct URL)."""
|
| 128 |
+
try:
|
| 129 |
+
host = (re.sub(r"^https?://", "", url).split("/")[0] or "").lower()
|
| 130 |
+
api = None
|
| 131 |
+
if "tiktok" in host:
|
| 132 |
+
api = "https://www.tiktok.com/oembed?url=" + urllib_quote(url, safe="")
|
| 133 |
+
elif "facebook" in host or "fb.watch" in host:
|
| 134 |
+
api = "https://www.facebook.com/plugins/video/oembed.json?url=" + urllib_quote(url, safe="")
|
| 135 |
+
if not api:
|
| 136 |
+
return None
|
| 137 |
+
r = requests.get(api, headers=UA_HEADERS, timeout=12)
|
| 138 |
+
if r.status_code != 200:
|
| 139 |
+
return None
|
| 140 |
+
j = r.json()
|
| 141 |
+
return {
|
| 142 |
+
"ok": True,
|
| 143 |
+
"title": (j.get("title") or "").strip()[:200],
|
| 144 |
+
"thumbnail": (j.get("thumbnail_url") or "").strip(),
|
| 145 |
+
"duration": None,
|
| 146 |
+
"extractor": "oembed",
|
| 147 |
+
"direct_url": None,
|
| 148 |
+
"previewable": False, # no direct streamable URL from oEmbed alone
|
| 149 |
+
"note": "Có thể lấy được thông tin, nhưng không tải được video trực tiếp từ link này.",
|
| 150 |
+
}
|
| 151 |
+
except Exception:
|
| 152 |
+
return None
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def _is_real_http(u):
|
| 156 |
+
"""True if a candidate URL is a real http(s) media URL (not a JS template)."""
|
| 157 |
+
u = (u or "").strip()
|
| 158 |
+
if not re.match(r"^https?://", u):
|
| 159 |
+
return False
|
| 160 |
+
if "'" in u or '"' in u or "+" in u or u.count("(") > 0:
|
| 161 |
+
return False
|
| 162 |
+
return True
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def _scrape_page_html(url):
|
| 166 |
+
"""Fallback scraper for news sites (24h, dantri, znews...): parse the article
|
| 167 |
+
HTML for og:video / <video>/<source> / m3u8|mp4 regex inside JS configs
|
| 168 |
+
(e.g. 24h 'playlistConf' blob). Returns info dict or None."""
|
| 169 |
+
try:
|
| 170 |
+
r = requests.get(url, headers=UA_HEADERS, timeout=25)
|
| 171 |
+
if r.status_code != 200:
|
| 172 |
+
return None
|
| 173 |
+
r.encoding = "utf-8"
|
| 174 |
+
html = r.text
|
| 175 |
+
except Exception:
|
| 176 |
+
return None
|
| 177 |
+
title = ""
|
| 178 |
+
mt = re.search(r'<meta[^>]+property="og:title"[^>]+content="([^"]+)"', html) or \
|
| 179 |
+
re.search(r'<meta[^>]+content="([^"]+)"[^>]+property="og:title"', html) or \
|
| 180 |
+
re.search(r"<title[^>]*>([^<]+)</title>", html)
|
| 181 |
+
if mt:
|
| 182 |
+
title = re.sub(r"\s+", " ", mt.group(1)).strip()[:250]
|
| 183 |
+
|
| 184 |
+
# 1) og:video
|
| 185 |
+
cands = []
|
| 186 |
+
for pat in (r'property="og:video[^"]*"\s+content="([^"]+)"',
|
| 187 |
+
r'content="([^"]+)"\s+property="og:video[^"]*"'):
|
| 188 |
+
cands += re.findall(pat, html)
|
| 189 |
+
# 2) <video>/<source> tags
|
| 190 |
+
cands += re.findall(r'<source[^>]+src="([^"]+)"', html)
|
| 191 |
+
cands += re.findall(r"<source[^>]+src='([^']+)'", html)
|
| 192 |
+
cands += re.findall(r'<video[^>]+src="([^"]+)"', html)
|
| 193 |
+
# 3) raw .mp4/.m3u8 URLs in the page (incl. JS playlistConf src:"...")
|
| 194 |
+
cands += re.findall(r'https?://[^"\'\s<>\\]+?\.(?:m3u8|mp4)[^"\'\s<>\\]*', html)
|
| 195 |
+
# 4) src: "..." inside JS (m3u8/mp4)
|
| 196 |
+
cands += re.findall(r'src\s*:\s*["\']([^"\']+?\.(?:m3u8|mp4)[^"\']*)["\']', html)
|
| 197 |
+
|
| 198 |
+
# normalize relative URLs + dedupe + prefer 720p m3u8 then mp4
|
| 199 |
+
from urllib.parse import urljoin
|
| 200 |
+
seen = set()
|
| 201 |
+
good = []
|
| 202 |
+
for c in cands:
|
| 203 |
+
c = c.strip().strip('"').strip("'")
|
| 204 |
+
if not c or not _is_real_http(c) and not c.startswith("/"):
|
| 205 |
+
continue
|
| 206 |
+
if c.startswith("/"):
|
| 207 |
+
c = urljoin(url, c)
|
| 208 |
+
if not c.startswith("http"):
|
| 209 |
+
continue
|
| 210 |
+
if c in seen:
|
| 211 |
+
continue
|
| 212 |
+
seen.add(c)
|
| 213 |
+
good.append(c)
|
| 214 |
+
if not good:
|
| 215 |
+
return None
|
| 216 |
+
|
| 217 |
+
# score: prefer m3u8 720p > m3u8 > mp4 720p > mp4
|
| 218 |
+
def score(c):
|
| 219 |
+
s = 0
|
| 220 |
+
if "720p" in c or "720" in c:
|
| 221 |
+
s += 20
|
| 222 |
+
if ".m3u8" in c:
|
| 223 |
+
s += 10
|
| 224 |
+
if "1080" in c:
|
| 225 |
+
s += 18
|
| 226 |
+
if "480" in c:
|
| 227 |
+
s += 8
|
| 228 |
+
return s
|
| 229 |
+
good.sort(key=score, reverse=True)
|
| 230 |
+
direct = good[0]
|
| 231 |
+
|
| 232 |
+
thumb = ""
|
| 233 |
+
mt2 = re.search(r'<meta[^>]+property="og:image[^"]*"\s+content="([^"]+)"', html) or \
|
| 234 |
+
re.search(r'<meta[^>]+content="([^"]+)"[^>]+property="og:image[^"]*"', html)
|
| 235 |
+
if mt2:
|
| 236 |
+
thumb = mt2.group(1).strip()
|
| 237 |
+
|
| 238 |
+
is_hls = ".m3u8" in direct.lower()
|
| 239 |
+
return {
|
| 240 |
+
"ok": True,
|
| 241 |
+
"url": url,
|
| 242 |
+
"title": title,
|
| 243 |
+
"duration": None,
|
| 244 |
+
"thumbnail": thumb,
|
| 245 |
+
"direct_url": direct,
|
| 246 |
+
"extractor": "html-page",
|
| 247 |
+
"previewable": True,
|
| 248 |
+
"is_hls": is_hls,
|
| 249 |
+
"note": "",
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def _resolve_media_url(url):
|
| 254 |
+
"""Fetch the body of a media URL using the right headers (referer for news CDNs)."""
|
| 255 |
+
HDRS = dict(UA_HEADERS)
|
| 256 |
+
host = (re.sub(r"^https?://", "", url).split("/")[0] or "").lower()
|
| 257 |
+
if "vnecdn" in host or "vnexpress" in host:
|
| 258 |
+
HDRS["Referer"] = "https://vnexpress.net/"
|
| 259 |
+
elif "cdn.24h" in host or "24h.com.vn" in host:
|
| 260 |
+
HDRS["Referer"] = "https://www.24h.com.vn/"
|
| 261 |
+
return HDRS
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
def _scrape_video_info(url):
|
| 265 |
+
"""Extract direct video URL + metadata for a user-pasted link.
|
| 266 |
+
|
| 267 |
+
Returns dict with keys: ok, url (original), title, duration, thumbnail,
|
| 268 |
+
direct_url (proxy-able), extractor, previewable, note.
|
| 269 |
+
"""
|
| 270 |
+
try:
|
| 271 |
+
now = time.time()
|
| 272 |
+
cached = _SCRAPE_CACHE.get(url)
|
| 273 |
+
if cached and now - cached.get("_t", 0) < _SCRAPE_TTL:
|
| 274 |
+
return cached
|
| 275 |
+
except Exception:
|
| 276 |
+
pass
|
| 277 |
+
|
| 278 |
+
out = None
|
| 279 |
+
if yt_dlp is not None:
|
| 280 |
+
try:
|
| 281 |
+
with yt_dlp.YoutubeDL(_ytdlp_opts()) as ydl:
|
| 282 |
+
info = ydl.extract_info(url, download=False)
|
| 283 |
+
# pick best direct (non-m3u8) progressive URL
|
| 284 |
+
direct = None
|
| 285 |
+
if info:
|
| 286 |
+
direct = info.get("url") or ""
|
| 287 |
+
for f in info.get("formats") or []:
|
| 288 |
+
proto = (f.get("protocol") or "")
|
| 289 |
+
if f.get("url") and proto not in ("m3u8_native", "m3u8"):
|
| 290 |
+
direct = f.get("url")
|
| 291 |
+
break
|
| 292 |
+
dur = info.get("duration") if info else None
|
| 293 |
+
try:
|
| 294 |
+
dur = float(dur) if dur is not None else None
|
| 295 |
+
except Exception:
|
| 296 |
+
dur = None
|
| 297 |
+
extractor = (info.get("extractor_key") or info.get("extractor") or "generic") if info else "generic"
|
| 298 |
+
is_hls = bool(direct) and ".m3u8" in direct.lower()
|
| 299 |
+
# If yt-dlp returned a bogus JS template URL ('+d+' etc.), treat as
|
| 300 |
+
# a scrape failure -> fall through to the HTML page parser.
|
| 301 |
+
yt_bad = not _is_real_http(direct)
|
| 302 |
+
out = {
|
| 303 |
+
"ok": not yt_bad,
|
| 304 |
+
"url": url,
|
| 305 |
+
"title": (info.get("title") or "").strip()[:250] if info else "",
|
| 306 |
+
"duration": dur,
|
| 307 |
+
"thumbnail": (info.get("thumbnail") or "").strip() or "" if not yt_bad else "",
|
| 308 |
+
"direct_url": direct or "",
|
| 309 |
+
"extractor": extractor,
|
| 310 |
+
"previewable": bool(direct) and not yt_bad,
|
| 311 |
+
"is_hls": is_hls,
|
| 312 |
+
"note": "",
|
| 313 |
+
}
|
| 314 |
+
except Exception as e:
|
| 315 |
+
if getattr(e, "args", None):
|
| 316 |
+
msg = str(e.args[0]) if isinstance(e.args[0], str) else str(e)
|
| 317 |
+
else:
|
| 318 |
+
msg = str(e)
|
| 319 |
+
# split real error after '[xxx] '
|
| 320 |
+
m = re.search(r"\]\s*(.+)$", msg)
|
| 321 |
+
err = m.group(1) if m else msg[:200]
|
| 322 |
+
out = {"ok": False, "url": url, "error": err[:200]}
|
| 323 |
+
|
| 324 |
+
# If yt-dlp failed / gave a bad URL, try parsing the article HTML directly
|
| 325 |
+
# (handles 24h.com.vn JS configs, dantri, znews, some vnexpress pages).
|
| 326 |
+
if not out or not out.get("ok") or not out.get("direct_url"):
|
| 327 |
+
page = _scrape_page_html(url)
|
| 328 |
+
if page:
|
| 329 |
+
# keep yt-dlp title/duration for social sites, take media from page
|
| 330 |
+
base = {"ok": True, "url": url,
|
| 331 |
+
"title": page.get("title") or (out or {}).get("title", ""),
|
| 332 |
+
"duration": (out or {}).get("duration") if (out or {}).get("direct_url") else page.get("duration"),
|
| 333 |
+
"thumbnail": page.get("thumbnail") or (out or {}).get("thumbnail", ""),
|
| 334 |
+
"direct_url": page.get("direct_url"),
|
| 335 |
+
"extractor": page.get("extractor") or "html-page",
|
| 336 |
+
"previewable": True,
|
| 337 |
+
"is_hls": page.get("is_hls", False),
|
| 338 |
+
"note": page.get("note", ""),
|
| 339 |
+
}
|
| 340 |
+
out = base
|
| 341 |
+
|
| 342 |
+
# metadata-only fallback (oEmbed) — lets the user at least see title/thumb
|
| 343 |
+
if not out or not out.get("ok"):
|
| 344 |
+
probe = _oembed_probe(url)
|
| 345 |
+
if probe:
|
| 346 |
+
probe["url"] = url
|
| 347 |
+
out = probe
|
| 348 |
+
|
| 349 |
+
if not out:
|
| 350 |
+
out = {"ok": False, "url": url, "error": "Không lấy được thông tin video từ link này."}
|
| 351 |
+
if "is_hls" not in out:
|
| 352 |
+
out["is_hls"] = bool(out.get("direct_url", "")) and ".m3u8" in (out.get("direct_url", "") or "").lower()
|
| 353 |
+
try:
|
| 354 |
+
out["_t"] = time.time()
|
| 355 |
+
_SCRAPE_CACHE[url] = out
|
| 356 |
+
if len(_SCRAPE_CACHE) > 200:
|
| 357 |
+
for k in list(_SCRAPE_CACHE)[:50]:
|
| 358 |
+
_SCRAPE_CACHE.pop(k, None)
|
| 359 |
+
except Exception:
|
| 360 |
+
pass
|
| 361 |
+
return out
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
def _load_wall_posts():
|
| 365 |
+
try:
|
| 366 |
+
with _wl_lock:
|
| 367 |
+
if os.path.exists(WALL_FILE):
|
| 368 |
+
with open(WALL_FILE, "r", encoding="utf-8") as f:
|
| 369 |
+
return json.load(f) or []
|
| 370 |
+
except Exception:
|
| 371 |
+
pass
|
| 372 |
+
return []
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
def _save_wall_posts(posts):
|
| 376 |
+
try:
|
| 377 |
+
with _wl_lock:
|
| 378 |
+
tmp = WALL_FILE + ".tmp"
|
| 379 |
+
with open(tmp, "w", encoding="utf-8") as f:
|
| 380 |
+
json.dump(posts, f, ensure_ascii=False)
|
| 381 |
+
os.replace(tmp, WALL_FILE)
|
| 382 |
+
except Exception:
|
| 383 |
+
pass
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
def _clean(s):
|
| 387 |
+
s = re.sub(r"[ \t]+", " ", s or "")
|
| 388 |
+
s = re.sub(r"\n{3,}", "\n\n", s)
|
| 389 |
+
return s.strip()
|
| 390 |
+
|
| 391 |
+
|
| 392 |
+
def _safe_name(name):
|
| 393 |
+
return re.sub(r"[^a-zA-Z0-9_.-]", "_", name)[:120]
|
| 394 |
+
|
| 395 |
+
|
| 396 |
+
# =========================================================================
|
| 397 |
+
# TikTok / background MUSIC list — royalty-free tracks with verified direct
|
| 398 |
+
# mp3 URLs (incompetech CC-BY 4.0, Pixabay, Archive.org).
|
| 399 |
+
# =========================================================================
|
| 400 |
+
MUSIC_LIST = [
|
| 401 |
+
{"id": "none", "name": "🚫 Không có nhạc nền", "url": ""},
|
| 402 |
+
{"id": "carefree", "name": "🎵 Carefree (Joyful)", "url": "https://incompetech.com/music/royalty-free/mp3-royaltyfree/Carefree.mp3"},
|
| 403 |
+
{"id": "wholesome", "name": "🎵 Wholesome (Warm)", "url": "https://incompetech.com/music/royalty-free/mp3-royaltyfree/Wholesome.mp3"},
|
| 404 |
+
{"id": "fluffing", "name": "�� Fluffing a Duck (Quirky)", "url": "https://incompetech.com/music/royalty-free/mp3-royaltyfree/Fluffing%20a%20Duck.mp3"},
|
| 405 |
+
{"id": "loping", "name": "🎵 Loping Sting (Short)", "url": "https://incompetech.com/music/royalty-free/mp3-royaltyfree/Loping%20Sting.mp3"},
|
| 406 |
+
{"id": "modern", "name": "🎵 Modern Vibes (Electronic)", "url": "https://incompetech.com/music/royalty-free/mp3-royaltyfree/Modern%20Vibes.mp3"},
|
| 407 |
+
{"id": "cheezee", "name": "🎵 Chee Zee Jungle (Upbeat)", "url": "https://incompetech.com/music/royalty-free/mp3-royaltyfree/Chee%20Zee%20Jungle.mp3"},
|
| 408 |
+
{"id": "constance", "name": "🎵 Constance (Cinematic)", "url": "https://incompetech.com/music/royalty-free/mp3-royaltyfree/Constance.mp3"},
|
| 409 |
+
{"id": "edm", "name": "🎵 EDM Detection Mode (Energetic)", "url": "https://incompetech.com/music/royalty-free/mp3-royaltyfree/EDM%20Detection%20Mode.mp3"},
|
| 410 |
+
{"id": "spyglass", "name": "🎵 Spy Glass (Mystery)", "url": "https://incompetech.com/music/royalty-free/mp3-royaltyfree/Spy%20Glass.mp3"},
|
| 411 |
+
{"id": "raving", "name": "🎵 Raving Energy (Dance)", "url": "https://incompetech.com/music/royalty-free/mp3-royaltyfree/Raving%20Energy.mp3"},
|
| 412 |
+
{"id": "pixabay", "name": "🎵 Pixabay Sunset Vibes", "url": "https://cdn.pixabay.com/download/audio/2022/05/27/audio_1808fbf07a.mp3"},
|
| 413 |
+
]
|
| 414 |
+
|
| 415 |
+
MUSIC_BY_ID = {m["id"]: m for m in MUSIC_LIST}
|
| 416 |
+
|
| 417 |
+
|
| 418 |
+
# =========================================================================
|
| 419 |
+
# local URL resolution (uploaded files / wall images) + remote fetch
|
| 420 |
+
# =========================================================================
|
| 421 |
+
def _fetch_bytes(url):
|
| 422 |
+
"""Fetch image/audio/video bytes from a URL. Handles local /api/ URLs."""
|
| 423 |
+
if not url:
|
| 424 |
+
return None
|
| 425 |
+
if url.startswith("/api/ai-short/upload/"):
|
| 426 |
+
p = os.path.join(UPLOAD_DIR, os.path.basename(url.rstrip("/")))
|
| 427 |
+
if os.path.exists(p):
|
| 428 |
+
with open(p, "rb") as f:
|
| 429 |
+
return f.read()
|
| 430 |
+
return None
|
| 431 |
+
if url.startswith("/api/wall/img/"):
|
| 432 |
+
p = os.path.join(WALL_IMG_DIR, os.path.basename(url.rstrip("/")))
|
| 433 |
+
if os.path.exists(p):
|
| 434 |
+
with open(p, "rb") as f:
|
| 435 |
+
return f.read()
|
| 436 |
+
return None
|
| 437 |
+
try:
|
| 438 |
+
r = requests.get(url, headers=UA_HEADERS, timeout=30)
|
| 439 |
+
if r.status_code == 200 and len(r.content) > 900:
|
| 440 |
+
return r.content
|
| 441 |
+
except Exception:
|
| 442 |
+
pass
|
| 443 |
+
return None
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
def _download_to_file(url, dst, max_mb=400):
|
| 447 |
+
"""Stream a remote file (video) to disk without loading it into RAM.
|
| 448 |
+
Returns True on success. Handles same-origin /api/ URLs by copy.
|
| 449 |
+
If the URL is an HLS playlist (.m3u8), downloads+decrypts all segments
|
| 450 |
+
via _download_hls_to_ts (handles AES-128 encryption from news CDNs)."""
|
| 451 |
+
try:
|
| 452 |
+
# HLS playlists need segment-wise download + AES decryption
|
| 453 |
+
if ".m3u8" in (url or "").lower():
|
| 454 |
+
return _download_hls_to_ts(url, dst, max_mb=max_mb)
|
| 455 |
+
if url.startswith("/api/ai-short/upload/"):
|
| 456 |
+
p = os.path.join(UPLOAD_DIR, os.path.basename(url.rstrip("/")))
|
| 457 |
+
if os.path.exists(p):
|
| 458 |
+
with open(p, "rb") as f, open(dst, "wb") as g:
|
| 459 |
+
g.write(f.read())
|
| 460 |
+
return True
|
| 461 |
+
return False
|
| 462 |
+
if url.startswith("/api/wall/img/"):
|
| 463 |
+
p = os.path.join(WALL_IMG_DIR, os.path.basename(url.rstrip("/")))
|
| 464 |
+
if os.path.exists(p):
|
| 465 |
+
with open(p, "rb") as f, open(dst, "wb") as g:
|
| 466 |
+
g.write(f.read())
|
| 467 |
+
return True
|
| 468 |
+
return False
|
| 469 |
+
with requests.get(url, headers=UA_HEADERS, timeout=60, stream=True,
|
| 470 |
+
allow_redirects=True) as r:
|
| 471 |
+
if r.status_code != 200:
|
| 472 |
+
return False
|
| 473 |
+
total = 0
|
| 474 |
+
with open(dst, "wb") as f:
|
| 475 |
+
for chunk in r.iter_content(chunk_size=256 * 1024):
|
| 476 |
+
if not chunk:
|
| 477 |
+
continue
|
| 478 |
+
f.write(chunk)
|
| 479 |
+
total += len(chunk)
|
| 480 |
+
if total > max_mb * 1024 * 1024:
|
| 481 |
+
return False
|
| 482 |
+
return os.path.exists(dst) and os.path.getsize(dst) > 900
|
| 483 |
+
except Exception:
|
| 484 |
+
return False
|
| 485 |
+
|
| 486 |
+
|
| 487 |
+
def _download_hls_to_ts(m3u8_url, dst, max_mb=400):
|
| 488 |
+
"""Download an HLS stream (incl. AES-128 encrypted) to a single .ts file,
|
| 489 |
+
decrypting segments in Python. Returns True on success.
|
| 490 |
+
|
| 491 |
+
Avoids relying on ffmpeg's network HLS demuxer (which can be fragile on
|
| 492 |
+
news CDNs like vnecdn/24h that require referer + AES-128 keys)."""
|
| 493 |
+
try:
|
| 494 |
+
from urllib.parse import urljoin, urlparse
|
| 495 |
+
try:
|
| 496 |
+
from Crypto.Cipher import AES
|
| 497 |
+
except Exception:
|
| 498 |
+
AES = None
|
| 499 |
+
HDRS = _resolve_media_url(m3u8_url)
|
| 500 |
+
r = requests.get(m3u8_url, headers=HDRS, timeout=30)
|
| 501 |
+
if r.status_code != 200:
|
| 502 |
+
return False
|
| 503 |
+
text = r.text
|
| 504 |
+
if "#EXTM3U" not in text:
|
| 505 |
+
# Not actually HLS — write raw bytes
|
| 506 |
+
with open(dst, "wb") as f:
|
| 507 |
+
f.write(r.content)
|
| 508 |
+
return os.path.exists(dst) and os.path.getsize(dst) > 900
|
| 509 |
+
# master playlist? pick a variant (prefer 720)
|
| 510 |
+
if "#EXT-X-STREAM-INF" in text:
|
| 511 |
+
ch = re.findall(r'#EXT-X-STREAM-INF[^\n]*\n([^\n]+)', text)
|
| 512 |
+
variants = [v.strip() for v in ch if v.strip()]
|
| 513 |
+
# pick one containing 720, else first
|
| 514 |
+
chosen = next((v for v in variants if "720" in v), variants[0] if variants else None)
|
| 515 |
+
if chosen:
|
| 516 |
+
if not chosen.startswith("http"):
|
| 517 |
+
chosen = urljoin(m3u8_url, chosen)
|
| 518 |
+
return _download_hls_to_ts(chosen, dst, max_mb=max_mb)
|
| 519 |
+
|
| 520 |
+
key = None
|
| 521 |
+
key_uri = None
|
| 522 |
+
km = re.search(r'#EXT-X-KEY:METHOD=AES-128,URI="([^"]+)"', text)
|
| 523 |
+
if km:
|
| 524 |
+
key_uri = km.group(1)
|
| 525 |
+
if not key_uri.startswith("http"):
|
| 526 |
+
key_uri = urljoin(m3u8_url, key_uri)
|
| 527 |
+
kr = requests.get(key_uri, headers=HDRS, timeout=20)
|
| 528 |
+
if kr.status_code == 200:
|
| 529 |
+
key = kr.content
|
| 530 |
+
use_aes = key is not None and AES is not None
|
| 531 |
+
|
| 532 |
+
# media sequence drives the AES-CBC IV
|
| 533 |
+
seq = 0
|
| 534 |
+
mm = re.search(r'#EXT-X-MEDIA-SEQUENCE:\s*(\d+)', text)
|
| 535 |
+
if mm:
|
| 536 |
+
seq = int(mm.group(1))
|
| 537 |
+
keyfm = re.search(r'#EXT-X-KEY:METHOD=AES-128,.*?IV=0x([0-9A-Fa-f]+)', text)
|
| 538 |
+
explicit_iv = keyfm.group(1) if keyfm else None
|
| 539 |
+
|
| 540 |
+
segs = [l.strip() for l in text.splitlines()
|
| 541 |
+
if l.strip() and not l.startswith("#")]
|
| 542 |
+
total = 0
|
| 543 |
+
with open(dst, "wb") as out:
|
| 544 |
+
for i, s in enumerate(segs):
|
| 545 |
+
if not s.startswith("http"):
|
| 546 |
+
s = urljoin(m3u8_url, s)
|
| 547 |
+
sr = requests.get(s, headers=HDRS, timeout=60)
|
| 548 |
+
if sr.status_code != 200:
|
| 549 |
+
return False
|
| 550 |
+
data = sr.content
|
| 551 |
+
sr.close()
|
| 552 |
+
if use_aes:
|
| 553 |
+
# AES-128-CBC decrypt the ENTIRE segment with one call so
|
| 554 |
+
# PKCS7 + CBC chaining stay correct (segment is self-contained).
|
| 555 |
+
if explicit_iv:
|
| 556 |
+
iv = bytes.fromhex(explicit_iv)
|
| 557 |
+
else:
|
| 558 |
+
iv = (seq + i).to_bytes(16, "big")
|
| 559 |
+
data = AES.new(key, AES.MODE_CBC, iv).decrypt(data)
|
| 560 |
+
out.write(data)
|
| 561 |
+
total += len(data)
|
| 562 |
+
if total > max_mb * 1024 * 1024:
|
| 563 |
+
return False
|
| 564 |
+
return os.path.exists(dst) and os.path.getsize(dst) > 900
|
| 565 |
+
except Exception:
|
| 566 |
+
return False
|
| 567 |
+
|
| 568 |
+
|
| 569 |
+
def _probe_duration(path, fallback=15.0):
|
| 570 |
+
try:
|
| 571 |
+
pr = subprocess.run(
|
| 572 |
+
["ffprobe", "-v", "error", "-show_entries", "format=duration",
|
| 573 |
+
"-of", "default=noprint_wrappers=1:no_key=1", path],
|
| 574 |
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=30)
|
| 575 |
+
return max(1.0, float((pr.stdout or b"").decode().strip() or fallback))
|
| 576 |
+
except Exception:
|
| 577 |
+
return fallback
|
| 578 |
+
|
| 579 |
+
|
| 580 |
+
def _probe_has_audio(path):
|
| 581 |
+
try:
|
| 582 |
+
pr = subprocess.run(
|
| 583 |
+
["ffprobe", "-v", "error", "-select_streams", "a", "-show_entries",
|
| 584 |
+
"stream=index", "-of", "csv=p=0", path],
|
| 585 |
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=30)
|
| 586 |
+
return bool((pr.stdout or b"").decode().strip())
|
| 587 |
+
except Exception:
|
| 588 |
+
return False
|
| 589 |
+
|
| 590 |
+
|
| 591 |
+
# =========================================================================
|
| 592 |
+
# Image helpers
|
| 593 |
+
# =========================================================================
|
| 594 |
+
def _wrap_text_px(draw, text, font, max_width, max_lines):
|
| 595 |
+
words = _clean(text).split()
|
| 596 |
+
lines, cur = [], ""
|
| 597 |
+
for w in words:
|
| 598 |
+
test = (cur + " " + w).strip()
|
| 599 |
+
try:
|
| 600 |
+
width = draw.textbbox((0, 0), test, font=font)[2]
|
| 601 |
+
except Exception:
|
| 602 |
+
width = len(test) * 20
|
| 603 |
+
if width <= max_width:
|
| 604 |
+
cur = test
|
| 605 |
+
else:
|
| 606 |
+
if cur:
|
| 607 |
+
lines.append(cur)
|
| 608 |
+
cur = w
|
| 609 |
+
if len(lines) >= max_lines:
|
| 610 |
+
break
|
| 611 |
+
if cur and len(lines) < max_lines:
|
| 612 |
+
lines.append(cur)
|
| 613 |
+
return lines
|
| 614 |
+
|
| 615 |
+
|
| 616 |
+
def _cover_resize(img, w, h):
|
| 617 |
+
"""Center-crop + resize to exactly (w, h)."""
|
| 618 |
+
rw, rh = w / h, img.width / img.height
|
| 619 |
+
if rw > rh: # image too tall -> crop bottom/top
|
| 620 |
+
nh = int(img.width / rw)
|
| 621 |
+
top = max(0, (img.height - nh) // 2)
|
| 622 |
+
img = img.crop((0, top, img.width, top + nh))
|
| 623 |
+
else: # image too wide -> crop left/right
|
| 624 |
+
nw = int(img.height * rw)
|
| 625 |
+
left = max(0, (img.width - nw) // 2)
|
| 626 |
+
img = img.crop((left, 0, left + nw, img.height))
|
| 627 |
+
return img.resize((w, h))
|
| 628 |
+
|
| 629 |
+
|
| 630 |
+
def _frame_from_image(image_bytes, out_path, seg_idx=0, seg_total=1):
|
| 631 |
+
"""Full-bleed designed image background + tiny progress chip. NO text."""
|
| 632 |
+
W, H = 1080, 1920
|
| 633 |
+
bg = Image.new("RGB", (W, H), (10, 10, 10))
|
| 634 |
+
try:
|
| 635 |
+
im = Image.open(io_bytes(image_bytes)).convert("RGB")
|
| 636 |
+
bg = _cover_resize(im, W, H)
|
| 637 |
+
except Exception:
|
| 638 |
+
pass
|
| 639 |
+
draw = ImageDraw.Draw(bg)
|
| 640 |
+
try:
|
| 641 |
+
font_small = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 26)
|
| 642 |
+
except Exception:
|
| 643 |
+
font_small = None
|
| 644 |
+
if seg_total > 1:
|
| 645 |
+
chip = f"{seg_idx + 1}/{seg_total}"
|
| 646 |
+
try:
|
| 647 |
+
tw = draw.textbbox((0, 0), chip, font=font_small)[2]
|
| 648 |
+
except Exception:
|
| 649 |
+
tw = len(chip) * 14
|
| 650 |
+
draw.rounded_rectangle((W - 40 - tw - 24, 34, W - 22, 74), radius=18, fill=(0, 0, 0))
|
| 651 |
+
draw.text((W - 40 - tw - 10, 40), chip, fill=(255, 255, 255), font=font_small)
|
| 652 |
+
bg.save(out_path, quality=92)
|
| 653 |
+
|
| 654 |
+
|
| 655 |
+
def _frame_with_text(image_bytes, out_path, segment, title, seg_idx=0, seg_total=1):
|
| 656 |
+
"""Designed image bg + centered key point + footer title (default design)."""
|
| 657 |
+
W, H = 1080, 1920
|
| 658 |
+
bg = Image.new("RGB", (W, H), (10, 10, 10))
|
| 659 |
+
try:
|
| 660 |
+
im = Image.open(io_bytes(image_bytes)).convert("RGB")
|
| 661 |
+
cover = _cover_resize(im, W, H)
|
| 662 |
+
bg = Image.blend(cover, Image.new("RGB", (W, H), (0, 0, 0)), 0.45)
|
| 663 |
+
except Exception:
|
| 664 |
+
pass
|
| 665 |
+
draw = ImageDraw.Draw(bg)
|
| 666 |
+
try:
|
| 667 |
+
font_brand = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 34)
|
| 668 |
+
font_small = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 28)
|
| 669 |
+
font_seg = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 56)
|
| 670 |
+
font_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 32)
|
| 671 |
+
except Exception:
|
| 672 |
+
font_brand = font_small = font_seg = font_title = None
|
| 673 |
+
draw.rectangle((0, 620, W, H), fill=(12, 12, 12))
|
| 674 |
+
y = 700
|
| 675 |
+
dot_x, dot_y = 48, 762
|
| 676 |
+
for i in range(max(1, seg_total)):
|
| 677 |
+
fill = (92, 184, 122) if i == seg_idx else (70, 70, 70)
|
| 678 |
+
draw.rounded_rectangle((dot_x + i * 38, dot_y, dot_x + i * 38 + 24, dot_y + 10), radius=5, fill=fill)
|
| 679 |
+
draw.text((48, 800), "VNEWS AI SHORT", fill=(110, 231, 143), font=font_brand)
|
| 680 |
+
draw.rounded_rectangle((48, 854, 260, 900), radius=20, fill=(28, 70, 45))
|
| 681 |
+
draw.text((66, 862), f"Đoạn {seg_idx + 1}/{max(1, seg_total)}", fill=(235, 235, 235), font=font_small)
|
| 682 |
+
y = 960
|
| 683 |
+
maxw = W - 96
|
| 684 |
+
for ln in _wrap_text_px(draw, segment, font_seg, maxw, 16):
|
| 685 |
+
draw.text((48, y), ln, fill=(255, 255, 255), font=font_seg)
|
| 686 |
+
y += 72
|
| 687 |
+
if y > 1560:
|
| 688 |
+
break
|
| 689 |
+
y2 = 1700
|
| 690 |
+
draw.line((48, y2 - 24, W - 48, y2 - 24), fill=(70, 70, 70), width=2)
|
| 691 |
+
for ln in _wrap_text_px(draw, title, font_title, maxw, 3):
|
| 692 |
+
draw.text((48, y2), ln, fill=(220, 220, 220), font=font_title)
|
| 693 |
+
y2 += 44
|
| 694 |
+
bg.save(out_path, quality=92)
|
| 695 |
+
|
| 696 |
+
|
| 697 |
+
def io_bytes(b):
|
| 698 |
+
import io
|
| 699 |
+
return io.BytesIO(b)
|
| 700 |
+
|
| 701 |
+
|
| 702 |
+
def _tts_one(text, voice, speed, out_path):
|
| 703 |
+
"""Generate one TTS segment (edge-tts with gTTS fallback), speed-adjusted.
|
| 704 |
+
Returns the final file path or None on failure."""
|
| 705 |
+
edge_voice = {
|
| 706 |
+
"vi-vn-hoaimyneural": "vi-VN-HoaiMyNeural", "vi-vn-namminhneural": "vi-VN-NamMinhNeural",
|
| 707 |
+
"hoaimy": "vi-VN-HoaiMyNeural", "namminh": "vi-VN-NamMinhNeural", "nam": "vi-VN-NamMinhNeural",
|
| 708 |
+
"male": "vi-VN-NamMinhNeural", "nu": "vi-VN-HoaiMyNeural", "female": "vi-VN-HoaiMyNeural",
|
| 709 |
+
"mien-nam": "vi-VN-HoaiMyNeural",
|
| 710 |
+
"en-us-andrewmultilingualneural": "en-US-AndrewMultilingualNeural",
|
| 711 |
+
"en-au-williammultilingualneural": "en-AU-WilliamMultilingualNeural",
|
| 712 |
+
"andrew": "en-US-AndrewMultilingualNeural", "en_andrew": "en-US-AndrewMultilingualNeural",
|
| 713 |
+
"jenny": "en-US-AndrewMultilingualNeural", "en_jenny": "en-US-AndrewMultilingualNeural",
|
| 714 |
+
"pt-br-thalitamultilingualneural": "pt-BR-ThalitaMultilingualNeural",
|
| 715 |
+
"thalita": "pt-BR-ThalitaMultilingualNeural", "pt": "pt-BR-ThalitaMultilingualNeural",
|
| 716 |
+
"fr-fr-viviennemultilingualneural": "fr-FR-VivienneMultilingualNeural",
|
| 717 |
+
"fr-fr-remymultilingualneural": "fr-FR-RemyMultilingualNeural",
|
| 718 |
+
"denise": "fr-FR-VivienneMultilingualNeural", "fr": "fr-FR-VivienneMultilingualNeural",
|
| 719 |
+
"de-de-seraphinamultilingualneural": "de-DE-SeraphinaMultilingualNeural",
|
| 720 |
+
"de-de-florianmultilingualneural": "de-DE-FlorianMultilingualNeural",
|
| 721 |
+
"katja": "de-DE-SeraphinaMultilingualNeural", "de": "de-DE-SeraphinaMultilingualNeural",
|
| 722 |
+
"ko-kr-hyusumultilingualneural": "ko-KR-HyunsuMultilingualNeural",
|
| 723 |
+
"ko-kr-hyunsuneural": "ko-KR-HyunsuMultilingualNeural", "sunhee": "ko-KR-HyunsuMultilingualNeural",
|
| 724 |
+
"ko": "ko-KR-HyunsuMultilingualNeural",
|
| 725 |
+
"it-it-giuseppemultilingualneural": "it-IT-GiuseppeMultilingualNeural",
|
| 726 |
+
"ela": "en-US-AndrewMultilingualNeural", "es": "en-US-AndrewMultilingualNeural",
|
| 727 |
+
}.get(voice.lower(), voice)
|
| 728 |
+
aud = out_path
|
| 729 |
+
aud_fast = re.sub(r"\.mp3$", "_fast.mp3", out_path)
|
| 730 |
+
try:
|
| 731 |
+
subprocess.run(
|
| 732 |
+
["python", "-m", "edge_tts", "--voice", edge_voice, "--text", text,
|
| 733 |
+
"--write-media", aud],
|
| 734 |
+
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
|
| 735 |
+
except Exception:
|
| 736 |
+
aud = None
|
| 737 |
+
try:
|
| 738 |
+
from gtts import gTTS
|
| 739 |
+
tld = "com.vn" if voice.lower() in ("nu", "female", "mien-nam", "hoaimy") else "com"
|
| 740 |
+
try:
|
| 741 |
+
gTTS(text, lang="vi", tld=tld, slow=False).save(aud)
|
| 742 |
+
except TypeError:
|
| 743 |
+
gTTS(text, lang="vi", slow=False).save(aud)
|
| 744 |
+
except Exception:
|
| 745 |
+
pass
|
| 746 |
+
if aud and os.path.exists(aud) and os.path.getsize(aud) > 900:
|
| 747 |
+
try:
|
| 748 |
+
subprocess.run(["ffmpeg", "-y", "-i", aud, "-filter:a", f"atempo={speed}", "-vn", aud_fast],
|
| 749 |
+
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=90)
|
| 750 |
+
if os.path.exists(aud_fast) and os.path.getsize(aud_fast) > 900:
|
| 751 |
+
return aud_fast
|
| 752 |
+
except Exception:
|
| 753 |
+
pass
|
| 754 |
+
return aud
|
| 755 |
+
return None
|
| 756 |
+
|
| 757 |
+
|
| 758 |
+
def _concat_audio(paths, out_path, pad=0.3):
|
| 759 |
+
"""Concatenate audio files into one track (m4a/aac)."""
|
| 760 |
+
if not paths:
|
| 761 |
+
return None
|
| 762 |
+
lst = os.path.join(os.path.dirname(out_path), "_alist.txt")
|
| 763 |
+
with open(lst, "w", encoding="utf-8") as f:
|
| 764 |
+
for p in paths:
|
| 765 |
+
f.write("file '" + p.replace("'", "'\\''") + "'\n")
|
| 766 |
+
subprocess.run(["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", lst,
|
| 767 |
+
"-c:a", "aac", "-b:a", "128k", out_path],
|
| 768 |
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=240)
|
| 769 |
+
return out_path if os.path.exists(out_path) and os.path.getsize(out_path) > 900 else None
|
| 770 |
+
|
| 771 |
+
|
| 772 |
+
def _assemble_video_short(work, video_path, video_dur, img_paths, segs,
|
| 773 |
+
reuse_audio_path, video_audio_path, voice, speed,
|
| 774 |
+
music_path, out_mp4, fallback_title):
|
| 775 |
+
"""New video-first short assembly.
|
| 776 |
+
|
| 777 |
+
Timeline: scraped/uploaded video plays first (up to min(video_dur, voice)),
|
| 778 |
+
then the designed/selected images fill the remainder of the voice duration
|
| 779 |
+
(or until the video covers the whole short). The voice track is
|
| 780 |
+
continuous TTS narration of the segments (or reused previous short audio).
|
| 781 |
+
|
| 782 |
+
Returns (out_mp4, total_dur) or raises on failure.
|
| 783 |
+
"""
|
| 784 |
+
# ---------- 1. narration track (continuous) ----------
|
| 785 |
+
narr = None
|
| 786 |
+
if reuse_audio_path and os.path.exists(reuse_audio_path):
|
| 787 |
+
narr = os.path.join(work, "narr.m4a")
|
| 788 |
+
subprocess.run(["ffmpeg", "-y", "-i", reuse_audio_path,
|
| 789 |
+
"-c:a", "aac", "-b:a", "128k", narr],
|
| 790 |
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180)
|
| 791 |
+
narr = narr if os.path.exists(narr) and os.path.getsize(narr) > 900 else None
|
| 792 |
+
else:
|
| 793 |
+
voice_parts = []
|
| 794 |
+
for i, seg in enumerate(segs):
|
| 795 |
+
vf = _tts_one(seg, voice, speed, os.path.join(work, f"vf_{i:02d}.mp3"))
|
| 796 |
+
if vf:
|
| 797 |
+
voice_parts.append(vf)
|
| 798 |
+
if voice_parts:
|
| 799 |
+
narr = _concat_audio(voice_parts, os.path.join(work, "narr.m4a"))
|
| 800 |
+
if narr and os.path.exists(narr):
|
| 801 |
+
# small tail silence so the last word isn't cut
|
| 802 |
+
tail = os.path.join(work, "narr_tail.m4a")
|
| 803 |
+
subprocess.run(["ffmpeg", "-y", "-i", narr, "-af", "apad=pad_dur=0.6",
|
| 804 |
+
"-c:a", "aac", "-b:a", "128k", tail],
|
| 805 |
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
|
| 806 |
+
if os.path.exists(tail) and os.path.getsize(tail) > 900:
|
| 807 |
+
narr = tail
|
| 808 |
+
|
| 809 |
+
narr_dur = _probe_duration(narr, fallback=0.0) if narr else 0.0
|
| 810 |
+
total_dur = narr_dur
|
| 811 |
+
if total_dur < 1.0:
|
| 812 |
+
# no voice -> short is driven by video length (or music overlays later)
|
| 813 |
+
total_dur = max(1.0, video_dur)
|
| 814 |
+
|
| 815 |
+
# ---------- 2. split timeline ----------
|
| 816 |
+
use_video = max(0.0, min(video_dur, total_dur))
|
| 817 |
+
img_dur = total_dur - use_video
|
| 818 |
+
imgs = img_paths or []
|
| 819 |
+
# image durations: split remaining time across available images (min 1.2s,
|
| 820 |
+
# repeat cyclically if the voice is longer than all images)
|
| 821 |
+
img_plan = []
|
| 822 |
+
if img_dur > 0.5 and imgs:
|
| 823 |
+
per = max(1.2, min(8.0, img_dur / len(imgs)))
|
| 824 |
+
remaining = img_dur
|
| 825 |
+
i = 0
|
| 826 |
+
while remaining > 0.3:
|
| 827 |
+
img_plan.append((imgs[i % len(imgs)], min(per, remaining)))
|
| 828 |
+
remaining -= per
|
| 829 |
+
i += 1
|
| 830 |
+
|
| 831 |
+
# ---------- 3. build ffmpeg command ----------
|
| 832 |
+
cmd = ["ffmpeg", "-y"]
|
| 833 |
+
# video input first (index 0)
|
| 834 |
+
cmd += ["-i", video_path]
|
| 835 |
+
# image loop inputs
|
| 836 |
+
for idx, (_, dur) in enumerate(img_plan):
|
| 837 |
+
cmd += ["-framerate", "25", "-loop", "1", "-t", str(max(0.4, dur)), "-i", img_plan[idx][0]]
|
| 838 |
+
# narration + optional video audio inputs
|
| 839 |
+
audio_inputs = []
|
| 840 |
+
narr_idx = None
|
| 841 |
+
va_idx = None
|
| 842 |
+
if narr:
|
| 843 |
+
narr_idx = 1 + len(img_plan)
|
| 844 |
+
cmd += ["-i", narr]
|
| 845 |
+
audio_inputs.append(narr_idx)
|
| 846 |
+
if video_audio_path and os.path.exists(video_audio_path):
|
| 847 |
+
va_idx = 1 + len(img_plan) + (1 if narr_idx is not None else 0)
|
| 848 |
+
cmd += ["-i", video_audio_path]
|
| 849 |
+
audio_inputs.append(va_idx)
|
| 850 |
+
|
| 851 |
+
# filter graph
|
| 852 |
+
fc = []
|
| 853 |
+
# video inputs: [0:v] = scraped video, [k:v] = images
|
| 854 |
+
vlabels = []
|
| 855 |
+
# video: trim to use_video, scale/crop to 1080x1920 portrait, normalize fps
|
| 856 |
+
fc.append(
|
| 857 |
+
f"[0:v]trim=duration={use_video:.3f},setpts=PTS-STARTPTS,"
|
| 858 |
+
f"scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,"
|
| 859 |
+
f"fps=25,format=yuv420p[v0]"
|
| 860 |
+
)
|
| 861 |
+
vlabels.append("[v0]")
|
| 862 |
+
for idx, (_, dur) in enumerate(img_plan):
|
| 863 |
+
lab = f"v{idx + 1}"
|
| 864 |
+
fc.append(
|
| 865 |
+
f"[{1 + idx}:v]trim=duration={dur:.3f},setpts=PTS-STARTPTS,"
|
| 866 |
+
f"scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,"
|
| 867 |
+
f"fps=25,format=yuv420p[{lab}]"
|
| 868 |
+
)
|
| 869 |
+
vlabels.append(f"[{lab}]")
|
| 870 |
+
if len(vlabels) > 1:
|
| 871 |
+
concat_in = "".join(vlabels)
|
| 872 |
+
fc.append(f"{concat_in}concat=n={len(vlabels)}:v=1:a=0[vout]")
|
| 873 |
+
vmap = "[vout]"
|
| 874 |
+
else:
|
| 875 |
+
vmap = "[v0]"
|
| 876 |
+
|
| 877 |
+
# audio graph
|
| 878 |
+
if audio_inputs:
|
| 879 |
+
amix_sources = []
|
| 880 |
+
if narr_idx is not None:
|
| 881 |
+
fc.append(f"[{narr_idx}:a]aresample=44100,atrim=duration={total_dur:.3f},asetpts=PTS-STARTPTS[n]")
|
| 882 |
+
amix_sources.append("[n]")
|
| 883 |
+
if va_idx is not None:
|
| 884 |
+
fc.append(f"[{va_idx}:a]aresample=44100,volume=0.85,atrim=duration={use_video:.3f},asetpts=PTS-STARTPTS[xva]")
|
| 885 |
+
amix_sources.append("[xva]")
|
| 886 |
+
fc.append(f"{''.join(amix_sources)}amix=inputs={len(amix_sources)}:duration=first:dropout_transition=0:normalize=0[aout]")
|
| 887 |
+
amap = "[aout]"
|
| 888 |
+
else:
|
| 889 |
+
amap = None
|
| 890 |
+
|
| 891 |
+
fc_str = ";".join(fc)
|
| 892 |
+
cmd += ["-filter_complex", fc_str]
|
| 893 |
+
cmd += ["-map", vmap]
|
| 894 |
+
if amap:
|
| 895 |
+
cmd += ["-map", amap, "-c:a", "aac", "-b:a", "160k"]
|
| 896 |
+
else:
|
| 897 |
+
cmd += ["-an"]
|
| 898 |
+
cmd += ["-c:v", "libx264", "-preset", "veryfast", "-crf", "23",
|
| 899 |
+
"-pix_fmt", "yuv420p", "-movflags", "+faststart",
|
| 900 |
+
"-t", f"{total_dur:.3f}", "-shortest", out_mp4]
|
| 901 |
+
subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=600)
|
| 902 |
+
return out_mp4, total_dur
|
| 903 |
+
|
| 904 |
+
|
| 905 |
+
def _segments_from_post(post, max_segments=25):
|
| 906 |
+
raw = _clean(post.get("text") or post.get("title") or "")
|
| 907 |
+
raw = re.sub(r"^Bản tin AI viết lại:\s*", "", raw, flags=re.I)
|
| 908 |
+
raw = re.sub(r"Nguồn tham khảo:.*$", "", raw, flags=re.I | re.S).strip()
|
| 909 |
+
lines = []
|
| 910 |
+
for ln in raw.splitlines():
|
| 911 |
+
ln = _clean(re.sub(r"^[•\-\*\d\.\)\s]+", "", ln))
|
| 912 |
+
if not ln:
|
| 913 |
+
continue
|
| 914 |
+
low = ln.lower()
|
| 915 |
+
if low.startswith(("điểm chính", "tiêu đề", "sapo", "nguồn tham khảo")):
|
| 916 |
+
continue
|
| 917 |
+
if len(ln) >= 18:
|
| 918 |
+
lines.append(ln)
|
| 919 |
+
if len(lines) < 3:
|
| 920 |
+
lines = []
|
| 921 |
+
for s in re.split(r"(?<=[\.\!\?])\s+", raw):
|
| 922 |
+
s = _clean(s)
|
| 923 |
+
if len(s) >= 25:
|
| 924 |
+
lines.append(s)
|
| 925 |
+
seen = set()
|
| 926 |
+
out = []
|
| 927 |
+
for u in lines[: max_segments * 3]:
|
| 928 |
+
nu = re.sub(r"[^\wÀ-ỹ\s]", "", u.lower())
|
| 929 |
+
if nu in seen:
|
| 930 |
+
continue
|
| 931 |
+
seen.add(nu)
|
| 932 |
+
out.append(u)
|
| 933 |
+
if len(out) >= max_segments:
|
| 934 |
+
break
|
| 935 |
+
return out[:max_segments] if out else [post.get("title", "Bản tin VNEWS")]
|
| 936 |
+
|
| 937 |
+
|
| 938 |
+
def _find_previous_short(post_id):
|
| 939 |
+
"""Find the most recent short mp4 for this post (prior generation)."""
|
| 940 |
+
prefix = _safe_name(post_id)
|
| 941 |
+
best, best_mtime = None, 0
|
| 942 |
+
try:
|
| 943 |
+
for f in os.listdir(SHORTS_DIR):
|
| 944 |
+
# current naming: <post_id>v2_<...>.mp4
|
| 945 |
+
if f.startswith(prefix + "v2_") and f.endswith(".mp4"):
|
| 946 |
+
p = os.path.join(SHORTS_DIR, f)
|
| 947 |
+
mt = os.path.getmtime(p)
|
| 948 |
+
if mt > best_mtime:
|
| 949 |
+
best, best_mtime = p, mt
|
| 950 |
+
except Exception:
|
| 951 |
+
pass
|
| 952 |
+
# also legacy patched files: <post_id>_<voice>_..._scenes_nosub.mp4
|
| 953 |
+
if not best:
|
| 954 |
+
try:
|
| 955 |
+
for f in os.listdir(SHORTS_DIR):
|
| 956 |
+
if f.startswith(prefix + "_") and f.endswith("_scenes_nosub.mp4"):
|
| 957 |
+
p = os.path.join(SHORTS_DIR, f)
|
| 958 |
+
mt = os.path.getmtime(p)
|
| 959 |
+
if mt > best_mtime:
|
| 960 |
+
best, best_mtime = p, mt
|
| 961 |
+
except Exception:
|
| 962 |
+
pass
|
| 963 |
+
return best
|
| 964 |
+
|
| 965 |
+
|
| 966 |
+
# =========================================================================
|
| 967 |
+
# Endpoints
|
| 968 |
+
# =========================================================================
|
| 969 |
+
@app.get("/api/ai-short/music")
|
| 970 |
+
def api_short_music_list():
|
| 971 |
+
return JSONResponse({"music": MUSIC_LIST})
|
| 972 |
+
|
| 973 |
+
|
| 974 |
+
@app.get("/api/ai-short/music/stream")
|
| 975 |
+
def api_short_music_stream(url: str = Query(...)):
|
| 976 |
+
"""Proxy một track nhạc nền với CORS headers để trình duyệt phát trước khi chọn."""
|
| 977 |
+
try:
|
| 978 |
+
r = requests.get(url, headers=UA_HEADERS, timeout=30,
|
| 979 |
+
stream=True, allow_redirects=True)
|
| 980 |
+
if r.status_code != 200:
|
| 981 |
+
return Response(status_code=502)
|
| 982 |
+
return Response(
|
| 983 |
+
content=r.content,
|
| 984 |
+
media_type="audio/mpeg",
|
| 985 |
+
headers={
|
| 986 |
+
"Cache-Control": "public, max-age=86400",
|
| 987 |
+
"Access-Control-Allow-Origin": "*",
|
| 988 |
+
"Accept-Ranges": "bytes",
|
| 989 |
+
"Content-Disposition": "inline",
|
| 990 |
+
},
|
| 991 |
+
)
|
| 992 |
+
except Exception:
|
| 993 |
+
return Response(status_code=502)
|
| 994 |
+
|
| 995 |
+
|
| 996 |
+
@app.post("/api/ai-short/upload")
|
| 997 |
+
async def api_short_upload(file: UploadFile = File(...)):
|
| 998 |
+
"""Upload audio/video/image used as short background."""
|
| 999 |
+
try:
|
| 1000 |
+
if not file or not hasattr(file, "filename"):
|
| 1001 |
+
return JSONResponse({"error": "Thiếu file"}, status_code=400)
|
| 1002 |
+
fname = file.filename or ""
|
| 1003 |
+
ext = os.path.splitext(fname)[1].lower()
|
| 1004 |
+
if ext not in (".mp3", ".wav", ".m4a", ".aac", ".ogg",
|
| 1005 |
+
".mp4", ".webm", ".mov",
|
| 1006 |
+
".jpg", ".jpeg", ".png", ".webp"):
|
| 1007 |
+
return JSONResponse({"error": "Định dạng file không hỗ trợ"}, status_code=400)
|
| 1008 |
+
content = await file.read()
|
| 1009 |
+
if not content:
|
| 1010 |
+
return JSONResponse({"error": "File rỗng"}, status_code=400)
|
| 1011 |
+
if len(content) > 60 * 1024 * 1024:
|
| 1012 |
+
return JSONResponse({"error": "File quá lớn (>60MB)"}, status_code=400)
|
| 1013 |
+
fuid = str(uuid.uuid4())[:12]
|
| 1014 |
+
out_name = f"su_{fuid}{ext}"
|
| 1015 |
+
with open(os.path.join(UPLOAD_DIR, out_name), "wb") as f:
|
| 1016 |
+
f.write(content)
|
| 1017 |
+
kind = ("audio" if ext in (".mp3", ".wav", ".m4a", ".aac", ".ogg")
|
| 1018 |
+
else ("video" if ext in (".mp4", ".webm", ".mov") else "image"))
|
| 1019 |
+
return JSONResponse({"ok": True, "url": f"/api/ai-short/upload/{out_name}", "kind": kind})
|
| 1020 |
+
except Exception as e:
|
| 1021 |
+
return JSONResponse({"error": f"Lỗi upload: {str(e)[:150]}"}, status_code=500)
|
| 1022 |
+
|
| 1023 |
+
|
| 1024 |
+
@app.get("/api/ai-short/upload/{fname}")
|
| 1025 |
+
def api_short_upload_file(fname: str):
|
| 1026 |
+
if ".." in fname or "/" in fname:
|
| 1027 |
+
return JSONResponse({"error": "bad name"}, status_code=400)
|
| 1028 |
+
p = os.path.join(UPLOAD_DIR, fname)
|
| 1029 |
+
if not os.path.exists(p):
|
| 1030 |
+
return JSONResponse({"error": "not found"}, status_code=404)
|
| 1031 |
+
ext = os.path.splitext(fname)[1].lower()
|
| 1032 |
+
mt = {
|
| 1033 |
+
".mp3": "audio/mpeg", ".wav": "audio/wav", ".m4a": "audio/mp4",
|
| 1034 |
+
".aac": "audio/aac", ".ogg": "audio/ogg",
|
| 1035 |
+
".mp4": "video/mp4", ".webm": "video/webm", ".mov": "video/quicktime",
|
| 1036 |
+
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
|
| 1037 |
+
".webp": "image/webp",
|
| 1038 |
+
}.get(ext, "application/octet-stream")
|
| 1039 |
+
return FileResponse(p, media_type=mt)
|
| 1040 |
+
|
| 1041 |
+
|
| 1042 |
+
@app.get("/api/ai-short/scrape")
|
| 1043 |
+
def api_scrape_video(url: str = Query(...)):
|
| 1044 |
+
"""Scrap video info from any link (YouTube/TikTok/news...).
|
| 1045 |
+
|
| 1046 |
+
Returns direct media URL + duration + thumbnail so the frontend can
|
| 1047 |
+
show a preview before generating the short.
|
| 1048 |
+
"""
|
| 1049 |
+
if not url or not re.match(r"^https?://", url):
|
| 1050 |
+
return JSONResponse({"error": "Link không hợp lệ"}, status_code=400)
|
| 1051 |
+
info = _scrape_video_info(url.strip())
|
| 1052 |
+
if not info.get("ok"):
|
| 1053 |
+
return JSONResponse({"error": info.get("error", "Không lấy được video")}, status_code=422)
|
| 1054 |
+
# strip cache-internal key before returning to the client
|
| 1055 |
+
info = {k: v for k, v in info.items() if k != "_t"}
|
| 1056 |
+
return JSONResponse(info)
|
| 1057 |
+
|
| 1058 |
+
|
| 1059 |
+
@app.get("/api/ai-short/scrape/preview")
|
| 1060 |
+
def api_scrape_video_preview(url: str = Query(...), request: Request = None):
|
| 1061 |
+
"""Stream the scraped video (or its direct mp4) so the browser can play
|
| 1062 |
+
it inside the short creator modal. Range-capable proxy.
|
| 1063 |
+
"""
|
| 1064 |
+
if not url or not re.match(r"^https?://", url):
|
| 1065 |
+
return JSONResponse({"error": "Link không hợp lệ"}, status_code=400)
|
| 1066 |
+
req_headers = dict(UA_HEADERS)
|
| 1067 |
+
if request and request.headers.get("range"):
|
| 1068 |
+
req_headers["Range"] = request.headers["range"]
|
| 1069 |
+
try:
|
| 1070 |
+
r = requests.get(url, headers=req_headers, timeout=30, stream=True,
|
| 1071 |
+
allow_redirects=True)
|
| 1072 |
+
except Exception:
|
| 1073 |
+
return JSONResponse({"error": "Không tải được video" }, status_code=502)
|
| 1074 |
+
if r.status_code >= 400:
|
| 1075 |
+
return JSONResponse({"error": "Lỗi nguồn video"}, status_code=502)
|
| 1076 |
+
resp_headers = {
|
| 1077 |
+
"Access-Control-Allow-Origin": "*",
|
| 1078 |
+
"Accept-Ranges": "bytes",
|
| 1079 |
+
"Content-Type": r.headers.get("Content-Type", "video/mp4"),
|
| 1080 |
+
}
|
| 1081 |
+
if "Content-Range" in r.headers:
|
| 1082 |
+
resp_headers["Content-Range"] = r.headers["Content-Range"]
|
| 1083 |
+
if "Content-Length" in r.headers:
|
| 1084 |
+
resp_headers["Content-Length"] = r.headers["Content-Length"]
|
| 1085 |
+
if "Cache-Control" in r.headers:
|
| 1086 |
+
resp_headers["Cache-Control"] = r.headers["Cache-Control"]
|
| 1087 |
+
else:
|
| 1088 |
+
resp_headers["Cache-Control"] = "public, max-age=3600"
|
| 1089 |
+
|
| 1090 |
+
def gen():
|
| 1091 |
+
try:
|
| 1092 |
+
yield from r.iter_content(chunk_size=256 * 1024)
|
| 1093 |
+
finally:
|
| 1094 |
+
r.close()
|
| 1095 |
+
|
| 1096 |
+
return StreamingResponse(gen(), status_code=r.status_code, headers=resp_headers)
|
| 1097 |
+
|
| 1098 |
+
|
| 1099 |
+
@app.get("/api/ai-short/scrape/hls")
|
| 1100 |
+
def api_scrape_video_hls(url: str = Query(...)):
|
| 1101 |
+
"""Proxy an HLS master/media playlist and rewrite segment + key URIs to
|
| 1102 |
+
same-origin proxy routes so the browser (hls.js) can play AES-128 HLS
|
| 1103 |
+
from news CDNs (vnexpress/24h) without CORS errors."""
|
| 1104 |
+
if not url or not re.match(r"^https?://", url):
|
| 1105 |
+
return JSONResponse({"error": "Link không hợp lệ"}, status_code=400)
|
| 1106 |
+
try:
|
| 1107 |
+
HDRS = _resolve_media_url(url)
|
| 1108 |
+
r = requests.get(url, headers=HDRS, timeout=25)
|
| 1109 |
+
if r.status_code != 200:
|
| 1110 |
+
return JSONResponse({"error": "Lỗi nguồn HLS"}, status_code=502)
|
| 1111 |
+
text = r.text
|
| 1112 |
+
if "#EXTM3U" not in text:
|
| 1113 |
+
# not a playlist; pass through as binary
|
| 1114 |
+
return Response(content=r.content,
|
| 1115 |
+
media_type=r.headers.get("Content-Type", "video/mp4"),
|
| 1116 |
+
headers={"Access-Control-Allow-Origin": "*"})
|
| 1117 |
+
from urllib.parse import urljoin, quote
|
| 1118 |
+
out_lines = []
|
| 1119 |
+
for line in text.splitlines():
|
| 1120 |
+
s = line.strip()
|
| 1121 |
+
if s.startswith("#"):
|
| 1122 |
+
# rewrite #EXT-X-KEY URI to same-origin key proxy
|
| 1123 |
+
if "#EXT-X-KEY" in s and "URI=" in s:
|
| 1124 |
+
m = re.search(r'URI="([^"]+)"', s)
|
| 1125 |
+
if m:
|
| 1126 |
+
ku = m.group(1)
|
| 1127 |
+
if not ku.startswith("http"):
|
| 1128 |
+
ku = urljoin(url, ku)
|
| 1129 |
+
s2 = s.replace(m.group(0), 'URI="/api/ai-short/scrape/key?url=' + urllib_quote(ku, safe="") + '"')
|
| 1130 |
+
out_lines.append(s2)
|
| 1131 |
+
continue
|
| 1132 |
+
out_lines.append(s)
|
| 1133 |
+
elif s.startswith("http"): # absolute segment URI
|
| 1134 |
+
out_lines.append("/api/ai-short/scrape/seg?url=" + urllib_quote(s, safe=""))
|
| 1135 |
+
elif s: # relative segment URI
|
| 1136 |
+
out_lines.append("/api/ai-short/scrape/seg?url=" + urllib_quote(urljoin(url, s), safe=""))
|
| 1137 |
+
return Response(content="\n".join(out_lines).encode("utf-8"),
|
| 1138 |
+
media_type="application/vnd.apple.mpegurl",
|
| 1139 |
+
headers={"Access-Control-Allow-Origin": "*",
|
| 1140 |
+
"Cache-Control": "public, max-age=300"})
|
| 1141 |
+
except Exception:
|
| 1142 |
+
return JSONResponse({"error": "Lỗi proxy HLS"}, status_code=502)
|
| 1143 |
+
|
| 1144 |
+
|
| 1145 |
+
@app.get("/api/ai-short/scrape/seg")
|
| 1146 |
+
def api_scrape_video_seg(url: str = Query(...)):
|
| 1147 |
+
"""Stream a single HLS segment (.ts) with proper referer headers."""
|
| 1148 |
+
if not url or not re.match(r"^https?://", url):
|
| 1149 |
+
return JSONResponse({"error": "Link không hợp lệ"}, status_code=400)
|
| 1150 |
+
try:
|
| 1151 |
+
HDRS = _resolve_media_url(url)
|
| 1152 |
+
r = requests.get(url, headers=HDRS, timeout=60, stream=True)
|
| 1153 |
+
if r.status_code != 200:
|
| 1154 |
+
return JSONResponse({"error": "Lỗi segment"}, status_code=502)
|
| 1155 |
+
return StreamingResponse(r.iter_content(chunk_size=256 * 1024),
|
| 1156 |
+
media_type="video/mp2t",
|
| 1157 |
+
headers={"Access-Control-Allow-Origin": "*",
|
| 1158 |
+
"Cache-Control": "public, max-age=3600"})
|
| 1159 |
+
except Exception:
|
| 1160 |
+
return JSONResponse({"error": "Lỗi segment"}, status_code=502)
|
| 1161 |
+
|
| 1162 |
+
|
| 1163 |
+
@app.get("/api/ai-short/scrape/key")
|
| 1164 |
+
def api_scrape_video_key(url: str = Query(...)):
|
| 1165 |
+
"""Proxy the AES-128 key URI for HLS playlists."""
|
| 1166 |
+
if not url or not re.match(r"^https?://", url):
|
| 1167 |
+
return JSONResponse({"error": "Link không hợp lệ"}, status_code=400)
|
| 1168 |
+
try:
|
| 1169 |
+
HDRS = _resolve_media_url(url)
|
| 1170 |
+
r = requests.get(url, headers=HDRS, timeout=20)
|
| 1171 |
+
if r.status_code != 200:
|
| 1172 |
+
return JSONResponse({"error": "Lỗi key"}, status_code=502)
|
| 1173 |
+
return Response(content=r.content, media_type="application/octet-stream",
|
| 1174 |
+
headers={"Access-Control-Allow-Origin": "*",
|
| 1175 |
+
"Cache-Control": "public, max-age=3600"})
|
| 1176 |
+
except Exception:
|
| 1177 |
+
return JSONResponse({"error": "Lỗi key"}, status_code=502)
|
| 1178 |
+
|
| 1179 |
+
|
| 1180 |
+
@app.post("/api/ai-short")
|
| 1181 |
+
async def api_short_generate(request: Request):
|
| 1182 |
+
"""Generate a Short AI video.
|
| 1183 |
+
|
| 1184 |
+
JSON body:
|
| 1185 |
+
post_id (str) wall post id (required)
|
| 1186 |
+
use_slides (bool) one scene per designed slide; image = designed slide
|
| 1187 |
+
image; NO text overlays; narration = audio of previous
|
| 1188 |
+
short if available, else TTS of slide text (audio only).
|
| 1189 |
+
images (list[str]) custom background image URLs (replaces default)
|
| 1190 |
+
audio_music (str) music id from MUSIC_LIST ('' -> none)
|
| 1191 |
+
audio_url (str) custom audio URL (uploaded /api/ai-short/upload/...)
|
| 1192 |
+
voice/emotion/speed — TTS controls
|
| 1193 |
+
"""
|
| 1194 |
+
try:
|
| 1195 |
+
body = await request.json()
|
| 1196 |
+
except Exception:
|
| 1197 |
+
body = {}
|
| 1198 |
+
post_id = _clean(str(body.get("post_id", "")))
|
| 1199 |
+
create_new = bool(body.get("create_new"))
|
| 1200 |
+
posts = _load_wall_posts()
|
| 1201 |
+
if not isinstance(posts, list):
|
| 1202 |
+
posts = []
|
| 1203 |
+
post = None
|
| 1204 |
+
if create_new:
|
| 1205 |
+
# "Thêm Short HOT" from the homepage: no source wall post. Create a
|
| 1206 |
+
# brand-new post now; the generated video is attached to it and it is
|
| 1207 |
+
# posted to the AI wall. Retention: if generation fails, the draft is
|
| 1208 |
+
# removed again so no empty post stays on the wall.
|
| 1209 |
+
import uuid as _uuid
|
| 1210 |
+
new_id = str(_uuid.uuid4())[:12]
|
| 1211 |
+
hot_title = _clean(str(body.get("title", "")))[:200]
|
| 1212 |
+
hot_text = _clean(str(body.get("text", "")))[:2000]
|
| 1213 |
+
hot_imgs = [str(x) for x in (body.get("images") or []) if str(x).strip()][:10]
|
| 1214 |
+
post = {
|
| 1215 |
+
"id": new_id,
|
| 1216 |
+
"title": hot_title or "Short HOT",
|
| 1217 |
+
"text": hot_text,
|
| 1218 |
+
"source": "hot_short",
|
| 1219 |
+
"kind": "hot_short",
|
| 1220 |
+
"video": None,
|
| 1221 |
+
"img": hot_imgs[0] if hot_imgs else None,
|
| 1222 |
+
"images": hot_imgs,
|
| 1223 |
+
"url": _clean(str(body.get("url", ""))),
|
| 1224 |
+
"voice": _clean(str(body.get("voice", ""))),
|
| 1225 |
+
"emotion": _clean(str(body.get("emotion", ""))),
|
| 1226 |
+
"language": _clean(str(body.get("language", "vi"))),
|
| 1227 |
+
"created": int(time.time()),
|
| 1228 |
+
"created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()),
|
| 1229 |
+
}
|
| 1230 |
+
posts.insert(0, post)
|
| 1231 |
+
post_id = new_id
|
| 1232 |
+
else:
|
| 1233 |
+
if not post_id:
|
| 1234 |
+
return JSONResponse({"error": "Thiếu post_id"}, status_code=400)
|
| 1235 |
+
post = next((p for p in posts if str(p.get("id")) == str(post_id)), None)
|
| 1236 |
+
if not post:
|
| 1237 |
+
return JSONResponse({"error": "Không tìm thấy bài viết"}, status_code=404)
|
| 1238 |
+
|
| 1239 |
+
use_slides = bool(body.get("use_slides"))
|
| 1240 |
+
reuse_audio = body.get("reuse_audio")
|
| 1241 |
+
if reuse_audio is None:
|
| 1242 |
+
# default: when recreating from slides, reuse the previous short's audio
|
| 1243 |
+
reuse_audio = use_slides
|
| 1244 |
+
custom_images = [str(x) for x in (body.get("images") or []) if str(x).strip()]
|
| 1245 |
+
fixed_image = _clean(str(body.get("fixed_image", ""))) # 1 ảnh cố định cho nhiều slide
|
| 1246 |
+
raw_indices = body.get("slide_indices") or []
|
| 1247 |
+
slide_indices = []
|
| 1248 |
+
for v in raw_indices:
|
| 1249 |
+
try:
|
| 1250 |
+
slide_indices.append(int(v))
|
| 1251 |
+
except Exception:
|
| 1252 |
+
pass
|
| 1253 |
+
video_url = _clean(str(body.get("video_url", "")))
|
| 1254 |
+
video_muted = bool(body.get("video_muted"))
|
| 1255 |
+
try:
|
| 1256 |
+
video_dur = float(body.get("video_dur")) if body.get("video_dur") is not None else None
|
| 1257 |
+
except Exception:
|
| 1258 |
+
video_dur = None
|
| 1259 |
+
scrape_mode = bool(body.get("scrape_mode"))
|
| 1260 |
+
audio_music = _clean(str(body.get("audio_music", "")))
|
| 1261 |
+
audio_url = _clean(str(body.get("audio_url", "")))
|
| 1262 |
+
voice = _clean(str(body.get("voice", "vi-VN-HoaiMyNeural")))
|
| 1263 |
+
emotion = _clean(str(body.get("emotion", "neutral")))
|
| 1264 |
+
try:
|
| 1265 |
+
speed = max(0.85, min(1.35, float(body.get("speed", 1.0) or 1.0)))
|
| 1266 |
+
except Exception:
|
| 1267 |
+
speed = 1.0
|
| 1268 |
+
|
| 1269 |
+
os.makedirs(SHORTS_DIR, exist_ok=True)
|
| 1270 |
+
|
| 1271 |
+
# ---------- scene images ----------
|
| 1272 |
+
scene_images = []
|
| 1273 |
+
slide_texts = []
|
| 1274 |
+
if use_slides:
|
| 1275 |
+
slides = post.get("slides") or []
|
| 1276 |
+
if not slides:
|
| 1277 |
+
return JSONResponse({"error": "Bài chưa có ảnh slide thiết kế. Hãy chọn '🎨 Thiết kế ảnh' trước."}, status_code=400)
|
| 1278 |
+
# optional per-slide selection from the recreate modal
|
| 1279 |
+
if slide_indices:
|
| 1280 |
+
slides = [s for i, s in enumerate(slides) if i in slide_indices]
|
| 1281 |
+
if not slides:
|
| 1282 |
+
return JSONResponse({"error": "Slide đã chọn không hợp lệ."}, status_code=400)
|
| 1283 |
+
for s in slides:
|
| 1284 |
+
im = (s.get("image") or "").strip()
|
| 1285 |
+
if im:
|
| 1286 |
+
scene_images.append(im)
|
| 1287 |
+
slide_texts.append(_clean(s.get("text", "")))
|
| 1288 |
+
# fixed_image: 1 ảnh do người dùng chọn (trong danh sách rewrite hoặc upload)
|
| 1289 |
+
# thay thế toàn bộ ảnh slide -> dùng ảnh này cho N slide (sau dedupe = 1 scene)
|
| 1290 |
+
if fixed_image:
|
| 1291 |
+
scene_images = [fixed_image]
|
| 1292 |
+
slide_texts = [slide_texts[0] if slide_texts else ""]
|
| 1293 |
+
# dedupe identical designed images globally: if the same image appears
|
| 1294 |
+
# in multiple slides (e.g. only 1 slide was designed, others share the
|
| 1295 |
+
# fallback cover), collapse to one scene so the slide isn't repeated
|
| 1296 |
+
deduped = []
|
| 1297 |
+
seen_imgs = set()
|
| 1298 |
+
for im in scene_images:
|
| 1299 |
+
if im not in seen_imgs:
|
| 1300 |
+
seen_imgs.add(im)
|
| 1301 |
+
deduped.append(im)
|
| 1302 |
+
scene_images = deduped
|
| 1303 |
+
if not scene_images:
|
| 1304 |
+
return JSONResponse({"error": "Các slide chưa có ảnh đã thiết kế."}, status_code=400)
|
| 1305 |
+
# align slide_texts to the deduped scenes (keep first text per image group)
|
| 1306 |
+
if slide_texts and len(slide_texts) > len(scene_images):
|
| 1307 |
+
grouped = []
|
| 1308 |
+
seen = set()
|
| 1309 |
+
for idx, s in enumerate(slides):
|
| 1310 |
+
im = (s.get("image") or "").strip()
|
| 1311 |
+
if not im or im in seen:
|
| 1312 |
+
continue
|
| 1313 |
+
seen.add(im)
|
| 1314 |
+
grouped.append(slide_texts[idx] if idx < len(slide_texts) else "")
|
| 1315 |
+
if grouped:
|
| 1316 |
+
slide_texts = grouped
|
| 1317 |
+
else:
|
| 1318 |
+
slide_texts = slide_texts[:len(scene_images)]
|
| 1319 |
+
else:
|
| 1320 |
+
# fixed_image (chosen from rewrite list) replaces the bg for the short
|
| 1321 |
+
if fixed_image:
|
| 1322 |
+
scene_images = [fixed_image]
|
| 1323 |
+
else:
|
| 1324 |
+
for im in custom_images:
|
| 1325 |
+
scene_images.append(im)
|
| 1326 |
+
if not scene_images:
|
| 1327 |
+
scene_images = [(x or "").strip() for x in (post.get("images") or []) if (x or "").strip()]
|
| 1328 |
+
if not scene_images:
|
| 1329 |
+
slides = post.get("slides") or []
|
| 1330 |
+
for s in slides:
|
| 1331 |
+
im = (s.get("image") or "").strip()
|
| 1332 |
+
if im:
|
| 1333 |
+
scene_images.append(im)
|
| 1334 |
+
if not scene_images and post.get("img"):
|
| 1335 |
+
scene_images = [post.get("img")]
|
| 1336 |
+
# video-only (scraped/uploaded) short: video is the background, so
|
| 1337 |
+
# images are OPTIONAL. Let the video-first assembler handle it.
|
| 1338 |
+
if not scene_images and not video_url:
|
| 1339 |
+
return JSONResponse({"error": "Bài viết không có ảnh nền"}, status_code=400)
|
| 1340 |
+
segs0 = _segments_from_post(post)
|
| 1341 |
+
if len(scene_images) > len(segs0):
|
| 1342 |
+
scene_images = scene_images[:max(1, len(segs0))]
|
| 1343 |
+
|
| 1344 |
+
# ---------- naming / cache ----------
|
| 1345 |
+
scene_key = "slides" if use_slides else "norm"
|
| 1346 |
+
sel_key = ("s" + "".join(str(i) for i in slide_indices)) if slide_indices else ""
|
| 1347 |
+
vid_key = hashlib.md5(video_url.encode("utf-8")).hexdigest()[:8] if video_url else ""
|
| 1348 |
+
img_key = hashlib.md5("|".join(scene_images).encode("utf-8")).hexdigest()[:8]
|
| 1349 |
+
mus_key = audio_music or "nomus"
|
| 1350 |
+
aud_key = hashlib.md5(audio_url.encode("utf-8")).hexdigest()[:8] if audio_url else "none"
|
| 1351 |
+
suffix = f"v2_{scene_key}_{'v'+vid_key if vid_key else ''}{sel_key}{img_key}_{mus_key}_{aud_key}_{voice}_{emotion}_{str(speed).replace('.', 'p')}"
|
| 1352 |
+
out_mp4 = os.path.join(SHORTS_DIR, _safe_name(post_id + suffix) + ".mp4")
|
| 1353 |
+
out_url = "/api/ai/short-file/" + post_id + suffix
|
| 1354 |
+
|
| 1355 |
+
if os.path.exists(out_mp4):
|
| 1356 |
+
post["video"] = out_url
|
| 1357 |
+
post["short_voice"] = voice
|
| 1358 |
+
post["short_emotion"] = emotion
|
| 1359 |
+
post["short_speed"] = speed
|
| 1360 |
+
post["short_music"] = audio_music
|
| 1361 |
+
post["short_audio_url"] = audio_url
|
| 1362 |
+
post["short_use_slides"] = use_slides
|
| 1363 |
+
post["short_images"] = scene_images
|
| 1364 |
+
_save_wall_posts(posts)
|
| 1365 |
+
return JSONResponse({"video": out_url, "voice": voice, "emotion": emotion,
|
| 1366 |
+
"speed": speed, "music": audio_music, "use_slides": use_slides,
|
| 1367 |
+
"post": post})
|
| 1368 |
+
|
| 1369 |
+
work = os.path.join(SHORTS_DIR, _safe_name(post_id + suffix))
|
| 1370 |
+
os.makedirs(work, exist_ok=True)
|
| 1371 |
+
|
| 1372 |
+
# ---------- download scene images ----------
|
| 1373 |
+
local_imgs = []
|
| 1374 |
+
for i, im_url in enumerate(scene_images):
|
| 1375 |
+
dst = os.path.join(work, f"scene_{i:02d}.jpg")
|
| 1376 |
+
data = _fetch_bytes(im_url)
|
| 1377 |
+
if data:
|
| 1378 |
+
with open(dst, "wb") as f:
|
| 1379 |
+
f.write(data)
|
| 1380 |
+
local_imgs.append(dst)
|
| 1381 |
+
if not local_imgs:
|
| 1382 |
+
img = Image.new("RGB", (1080, 1920), (24, 24, 24))
|
| 1383 |
+
img.save(os.path.join(work, "scene_00.jpg"), quality=90)
|
| 1384 |
+
local_imgs = [os.path.join(work, "scene_00.jpg")]
|
| 1385 |
+
|
| 1386 |
+
# ---------- uploaded/scraped video background ----------
|
| 1387 |
+
video_audio_path = None
|
| 1388 |
+
using_video_first = False # video plays as-is (video-first assembly), images fill tail
|
| 1389 |
+
if video_url:
|
| 1390 |
+
vid_local = os.path.join(work, "bg_video.mp4")
|
| 1391 |
+
dl_ok = _download_to_file(video_url, vid_local)
|
| 1392 |
+
if not dl_ok:
|
| 1393 |
+
return JSONResponse({"error": "Không tải được video từ link. Link đã hết hạn hoặc nguồn chặn."}, status_code=422)
|
| 1394 |
+
try:
|
| 1395 |
+
vdur = _probe_duration(vid_local, fallback=6.0)
|
| 1396 |
+
except Exception:
|
| 1397 |
+
vdur = 6.0
|
| 1398 |
+
if video_dur and video_dur > 0:
|
| 1399 |
+
vdur = float(video_dur)
|
| 1400 |
+
# extract video's own audio (used as narration track)
|
| 1401 |
+
vaudio = os.path.join(work, "vaudio.m4a")
|
| 1402 |
+
subprocess.run(["ffmpeg", "-y", "-v", "error", "-i", vid_local, "-vn",
|
| 1403 |
+
"-c:a", "aac", "-b:a", "128k", vaudio],
|
| 1404 |
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180)
|
| 1405 |
+
if os.path.exists(vaudio) and os.path.getsize(vaudio) > 900:
|
| 1406 |
+
video_audio_path = vaudio
|
| 1407 |
+
|
| 1408 |
+
# --- NEW: video-first mode (scraped video or muted uploaded video)
|
| 1409 |
+
# Video plays first; designed/selected images fill the rest if the
|
| 1410 |
+
# video is shorter than the voice. No per-frame mosaic.
|
| 1411 |
+
using_video_first = scrape_mode or video_muted
|
| 1412 |
+
if not using_video_first:
|
| 1413 |
+
# legacy uploaded-video mode: extract up to N frames as the
|
| 1414 |
+
# moving-background scenes
|
| 1415 |
+
n_frames = max(1, min(5, len(local_imgs) if len(local_imgs) >= 1 else 5))
|
| 1416 |
+
frame_imgs = []
|
| 1417 |
+
for i in range(n_frames):
|
| 1418 |
+
t = vdur * (i + 0.5) / n_frames
|
| 1419 |
+
fimg = os.path.join(work, f"vc_{i:02d}.jpg")
|
| 1420 |
+
subprocess.run(["ffmpeg", "-y", "-v", "error", "-ss", str(t), "-i", vid_local,
|
| 1421 |
+
"-frames:v", "1", "-q:v", "3", fimg],
|
| 1422 |
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=60)
|
| 1423 |
+
if os.path.exists(fimg) and os.path.getsize(fimg) > 1200:
|
| 1424 |
+
frame_imgs.append(fimg)
|
| 1425 |
+
if frame_imgs:
|
| 1426 |
+
local_imgs = frame_imgs
|
| 1427 |
+
|
| 1428 |
+
# ---------- narration plan ----------
|
| 1429 |
+
reuse_audio_path = None
|
| 1430 |
+
if reuse_audio:
|
| 1431 |
+
prev = _find_previous_short(post_id)
|
| 1432 |
+
if prev:
|
| 1433 |
+
try:
|
| 1434 |
+
prev_audio = os.path.join(work, "prev_audio.m4a")
|
| 1435 |
+
subprocess.run(["ffmpeg", "-y", "-i", prev, "-vn", "-acodec", "aac", "-b:a", "128k", prev_audio],
|
| 1436 |
+
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180)
|
| 1437 |
+
if os.path.exists(prev_audio) and os.path.getsize(prev_audio) > 900:
|
| 1438 |
+
reuse_audio_path = prev_audio
|
| 1439 |
+
except Exception:
|
| 1440 |
+
reuse_audio_path = None
|
| 1441 |
+
if video_audio_path and not reuse_audio_path and not video_muted and not using_video_first:
|
| 1442 |
+
# uploaded/scraped video has its own audio -> use it instead of TTS
|
| 1443 |
+
# (only in legacy mosaic mode; video-first mode keeps video audio as a
|
| 1444 |
+
# background layer mixed with the TTS narration)
|
| 1445 |
+
reuse_audio_path = video_audio_path
|
| 1446 |
+
segs = slide_texts if (use_slides and slide_texts) else _segments_from_post(post)
|
| 1447 |
+
while len(segs) < len(local_imgs):
|
| 1448 |
+
segs.append(segs[-1] if segs else post.get("title", "Bản tin VNEWS"))
|
| 1449 |
+
|
| 1450 |
+
# ---------- NEW: video-first short assembly ----------
|
| 1451 |
+
if using_video_first:
|
| 1452 |
+
# images for the tail: designed slides first, then post images
|
| 1453 |
+
tail_imgs = list(local_imgs) if (local_imgs and not (use_slides and fixed_image)) else local_imgs
|
| 1454 |
+
# if user chose a fixed image -> only that image for the tail
|
| 1455 |
+
if fixed_image:
|
| 1456 |
+
fimg = os.path.join(work, "fixed_tail.jpg")
|
| 1457 |
+
data = _fetch_bytes(fixed_image)
|
| 1458 |
+
if data:
|
| 1459 |
+
with open(fimg, "wb") as f:
|
| 1460 |
+
f.write(data)
|
| 1461 |
+
tail_imgs = [fimg] if os.path.getsize(fimg) > 1200 else (tail_imgs or [])
|
| 1462 |
+
# ensure at least the post image exists as tail
|
| 1463 |
+
if not tail_imgs:
|
| 1464 |
+
fallback_img = os.path.join(work, "scene_00.jpg")
|
| 1465 |
+
if os.path.exists(fallback_img):
|
| 1466 |
+
tail_imgs = [fallback_img]
|
| 1467 |
+
# TTS segments: for video-first, use per-slide texts if slides,
|
| 1468 |
+
# else the whole article as segments (each read out)
|
| 1469 |
+
tts_segs = slide_texts if (use_slides and slide_texts) else _segments_from_post(post)
|
| 1470 |
+
if not tts_segs:
|
| 1471 |
+
tts_segs = [post.get("title", "Bản tin VNEWS")]
|
| 1472 |
+
try:
|
| 1473 |
+
out_mp4, _total = _assemble_video_short(
|
| 1474 |
+
work, vid_local, vdur,
|
| 1475 |
+
tail_imgs, tts_segs,
|
| 1476 |
+
reuse_audio_path,
|
| 1477 |
+
(video_audio_path if not video_muted else None),
|
| 1478 |
+
voice, speed,
|
| 1479 |
+
None, out_mp4, post.get("title", ""),
|
| 1480 |
+
)
|
| 1481 |
+
# music overlay handled by the shared block below
|
| 1482 |
+
music_url = ""
|
| 1483 |
+
if audio_url:
|
| 1484 |
+
music_url = audio_url
|
| 1485 |
+
elif audio_music and audio_music in MUSIC_BY_ID:
|
| 1486 |
+
music_url = MUSIC_BY_ID[audio_music].get("url", "")
|
| 1487 |
+
if music_url:
|
| 1488 |
+
music_path = os.path.join(work, "music.mp3")
|
| 1489 |
+
data = _fetch_bytes(music_url)
|
| 1490 |
+
if data:
|
| 1491 |
+
with open(music_path, "wb") as f:
|
| 1492 |
+
f.write(data)
|
| 1493 |
+
if os.path.exists(music_path) and os.path.getsize(music_path) > 900:
|
| 1494 |
+
muxed = os.path.join(work, "muxed.mp4")
|
| 1495 |
+
vid_dur2 = _probe_duration(out_mp4, fallback=30.0)
|
| 1496 |
+
if _probe_has_audio(out_mp4):
|
| 1497 |
+
fc = (
|
| 1498 |
+
f"[1:a]volume=0.5,aloop=loop=-1:size=2e9,atrim=duration={vid_dur2}[m];"
|
| 1499 |
+
f"[0:a][m]amix=inputs=2:duration=first:dropout_transition=0:normalize=0[aout]"
|
| 1500 |
+
)
|
| 1501 |
+
cmd = ["ffmpeg", "-y", "-i", out_mp4, "-i", music_path,
|
| 1502 |
+
"-filter_complex", fc, "-map", "0:v", "-map", "[aout]",
|
| 1503 |
+
"-c:v", "copy", "-c:a", "aac", "-b:a", "160k", "-shortest", muxed]
|
| 1504 |
+
else:
|
| 1505 |
+
fc = f"[1:a]volume=0.55,aloop=loop=-1:size=2e9,atrim=duration={vid_dur2}[a]"
|
| 1506 |
+
cmd = ["ffmpeg", "-y", "-i", out_mp4, "-i", music_path,
|
| 1507 |
+
"-filter_complex", fc, "-map", "0:v", "-map", "[a]",
|
| 1508 |
+
"-c:v", "copy", "-c:a", "aac", "-b:a", "160k", "-shortest", muxed]
|
| 1509 |
+
subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=240)
|
| 1510 |
+
os.replace(muxed, out_mp4)
|
| 1511 |
+
post["video"] = out_url
|
| 1512 |
+
post["short_voice"] = voice
|
| 1513 |
+
post["short_emotion"] = emotion
|
| 1514 |
+
post["short_speed"] = speed
|
| 1515 |
+
post["short_music"] = audio_music
|
| 1516 |
+
post["short_audio_url"] = audio_url
|
| 1517 |
+
post["short_use_slides"] = use_slides
|
| 1518 |
+
post["short_images"] = scene_images
|
| 1519 |
+
post["short_video_url"] = video_url
|
| 1520 |
+
post["short_video_muted"] = video_muted
|
| 1521 |
+
_save_wall_posts(posts)
|
| 1522 |
+
return JSONResponse({"video": out_url, "voice": voice, "emotion": emotion,
|
| 1523 |
+
"speed": speed, "music": audio_music, "use_slides": use_slides,
|
| 1524 |
+
"post": post})
|
| 1525 |
+
except Exception as e:
|
| 1526 |
+
if create_new:
|
| 1527 |
+
_load_wall_posts() # ensure fresh list
|
| 1528 |
+
posts2 = [p for p in _load_wall_posts() if str(p.get("id")) != str(post_id)]
|
| 1529 |
+
_save_wall_posts(posts2)
|
| 1530 |
+
return JSONResponse({"error": "Không tạo được shorts: " + str(e)[:220]}, status_code=500)
|
| 1531 |
+
|
| 1532 |
+
# ---------- frame rendering ----------
|
| 1533 |
+
frames = []
|
| 1534 |
+
for idx in range(len(local_imgs)):
|
| 1535 |
+
frame = os.path.join(work, f"frame_{idx:02d}.jpg")
|
| 1536 |
+
with open(local_imgs[idx], "rb") as f:
|
| 1537 |
+
data = f.read()
|
| 1538 |
+
if use_slides:
|
| 1539 |
+
_frame_from_image(data, frame, seg_idx=idx, seg_total=len(local_imgs))
|
| 1540 |
+
else:
|
| 1541 |
+
_frame_with_text(data, frame, segs[idx], post.get("title", ""), seg_idx=idx, seg_total=len(local_imgs))
|
| 1542 |
+
frames.append(frame)
|
| 1543 |
+
|
| 1544 |
+
part_files = []
|
| 1545 |
+
try:
|
| 1546 |
+
if reuse_audio_path:
|
| 1547 |
+
# ---------- reuse previous short audio ----------
|
| 1548 |
+
total_dur = _probe_duration(reuse_audio_path, fallback=30.0)
|
| 1549 |
+
per = max(2.2, min(8.0, total_dur / max(1, len(frames))))
|
| 1550 |
+
for idx, frame in enumerate(frames):
|
| 1551 |
+
part = os.path.join(work, f"part_{idx:02d}.mp4")
|
| 1552 |
+
subprocess.run(
|
| 1553 |
+
["ffmpeg", "-y", "-loop", "1", "-t", str(per), "-i", frame,
|
| 1554 |
+
"-c:v", "libx264", "-tune", "stillimage", "-pix_fmt", "yuv420p", "-an", part],
|
| 1555 |
+
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=150)
|
| 1556 |
+
part_files.append(part)
|
| 1557 |
+
concat = os.path.join(work, "concat.txt")
|
| 1558 |
+
with open(concat, "w", encoding="utf-8") as f:
|
| 1559 |
+
for p in part_files:
|
| 1560 |
+
f.write("file '" + p.replace("'", "'\\''") + "'\n")
|
| 1561 |
+
subprocess.run(["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", concat, "-c", "copy", out_mp4],
|
| 1562 |
+
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=240)
|
| 1563 |
+
# mux previous audio (trim to video length)
|
| 1564 |
+
vid_dur = _probe_duration(out_mp4, fallback=total_dur)
|
| 1565 |
+
with_audio = os.path.join(work, "with_audio.mp4")
|
| 1566 |
+
subprocess.run(
|
| 1567 |
+
["ffmpeg", "-y", "-i", out_mp4, "-i", reuse_audio_path,
|
| 1568 |
+
"-filter_complex", f"[1:a]atrim=duration={vid_dur}[a]",
|
| 1569 |
+
"-map", "0:v", "-map", "[a]", "-c:v", "copy", "-c:a", "aac",
|
| 1570 |
+
"-b:a", "128k", "-shortest", with_audio],
|
| 1571 |
+
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=240)
|
| 1572 |
+
os.replace(with_audio, out_mp4)
|
| 1573 |
+
else:
|
| 1574 |
+
# ---------- fresh TTS narration ----------
|
| 1575 |
+
edge_voice = {
|
| 1576 |
+
"vi-vn-hoaimyneural": "vi-VN-HoaiMyNeural", "vi-vn-namminhneural": "vi-VN-NamMinhNeural",
|
| 1577 |
+
"hoaimy": "vi-VN-HoaiMyNeural", "namminh": "vi-VN-NamMinhNeural", "nam": "vi-VN-NamMinhNeural",
|
| 1578 |
+
"male": "vi-VN-NamMinhNeural", "nu": "vi-VN-HoaiMyNeural", "female": "vi-VN-HoaiMyNeural",
|
| 1579 |
+
"mien-nam": "vi-VN-HoaiMyNeural",
|
| 1580 |
+
"en-us-andrewmultilingualneural": "en-US-AndrewMultilingualNeural",
|
| 1581 |
+
"en-au-williammultilingualneural": "en-AU-WilliamMultilingualNeural",
|
| 1582 |
+
"andrew": "en-US-AndrewMultilingualNeural", "en_andrew": "en-US-AndrewMultilingualNeural",
|
| 1583 |
+
"jenny": "en-US-AndrewMultilingualNeural", "en_jenny": "en-US-AndrewMultilingualNeural",
|
| 1584 |
+
"pt-br-thalitamultilingualneural": "pt-BR-ThalitaMultilingualNeural",
|
| 1585 |
+
"thalita": "pt-BR-ThalitaMultilingualNeural", "pt": "pt-BR-ThalitaMultilingualNeural",
|
| 1586 |
+
"fr-fr-viviennemultilingualneural": "fr-FR-VivienneMultilingualNeural",
|
| 1587 |
+
"fr-fr-remymultilingualneural": "fr-FR-RemyMultilingualNeural",
|
| 1588 |
+
"denise": "fr-FR-VivienneMultilingualNeural", "fr": "fr-FR-VivienneMultilingualNeural",
|
| 1589 |
+
"de-de-seraphinamultilingualneural": "de-DE-SeraphinaMultilingualNeural",
|
| 1590 |
+
"de-de-florianmultilingualneural": "de-DE-FlorianMultilingualNeural",
|
| 1591 |
+
"katja": "de-DE-SeraphinaMultilingualNeural", "de": "de-DE-SeraphinaMultilingualNeural",
|
| 1592 |
+
"ko-kr-hyusumultilingualneural": "ko-KR-HyunsuMultilingualNeural",
|
| 1593 |
+
"ko-kr-hyunsuneural": "ko-KR-HyunsuMultilingualNeural", "sunhee": "ko-KR-HyunsuMultilingualNeural",
|
| 1594 |
+
"ko": "ko-KR-HyunsuMultilingualNeural",
|
| 1595 |
+
"it-it-giuseppemultilingualneural": "it-IT-GiuseppeMultilingualNeural",
|
| 1596 |
+
"ela": "en-US-AndrewMultilingualNeural", "es": "en-US-AndrewMultilingualNeural",
|
| 1597 |
+
}.get(voice.lower(), voice)
|
| 1598 |
+
for idx, frame in enumerate(frames):
|
| 1599 |
+
aud = os.path.join(work, f"voice_{idx:02d}.mp3")
|
| 1600 |
+
aud_fast = os.path.join(work, f"voice_{idx:02d}_fast.mp3")
|
| 1601 |
+
part = os.path.join(work, f"part_{idx:02d}.mp4")
|
| 1602 |
+
spoken = segs[idx] if idx < len(segs) else post.get("title", "Bản tin VNEWS")
|
| 1603 |
+
try:
|
| 1604 |
+
subprocess.run(
|
| 1605 |
+
["python", "-m", "edge_tts", "--voice", edge_voice, "--text", spoken,
|
| 1606 |
+
"--write-media", aud],
|
| 1607 |
+
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
|
| 1608 |
+
except Exception:
|
| 1609 |
+
aud = None
|
| 1610 |
+
try:
|
| 1611 |
+
from gtts import gTTS
|
| 1612 |
+
tld = "com.vn" if voice.lower() in ("nu", "female", "mien-nam", "hoaimy") else "com"
|
| 1613 |
+
try:
|
| 1614 |
+
gTTS(spoken, lang="vi", tld=tld, slow=False).save(aud)
|
| 1615 |
+
except TypeError:
|
| 1616 |
+
gTTS(spoken, lang="vi", slow=False).save(aud)
|
| 1617 |
+
except Exception:
|
| 1618 |
+
pass
|
| 1619 |
+
if aud and os.path.exists(aud) and os.path.getsize(aud) > 900:
|
| 1620 |
+
subprocess.run(["ffmpeg", "-y", "-i", aud, "-filter:a", f"atempo={speed}", "-vn", aud_fast],
|
| 1621 |
+
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=90)
|
| 1622 |
+
dur = _probe_duration(aud_fast, fallback=15.0) + 0.35
|
| 1623 |
+
subprocess.run(
|
| 1624 |
+
["ffmpeg", "-y", "-loop", "1", "-t", str(dur), "-i", frame, "-i", aud_fast,
|
| 1625 |
+
"-shortest", "-c:v", "libx264", "-tune", "stillimage", "-pix_fmt", "yuv420p",
|
| 1626 |
+
"-c:a", "aac", "-b:a", "128k", part],
|
| 1627 |
+
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=150)
|
| 1628 |
+
else:
|
| 1629 |
+
subprocess.run(
|
| 1630 |
+
["ffmpeg", "-y", "-loop", "1", "-t", "4", "-i", frame,
|
| 1631 |
+
"-c:v", "libx264", "-tune", "stillimage", "-pix_fmt", "yuv420p", "-an", part],
|
| 1632 |
+
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
|
| 1633 |
+
part_files.append(part)
|
| 1634 |
+
concat = os.path.join(work, "concat.txt")
|
| 1635 |
+
with open(concat, "w", encoding="utf-8") as f:
|
| 1636 |
+
for p in part_files:
|
| 1637 |
+
f.write("file '" + p.replace("'", "'\\''") + "'\n")
|
| 1638 |
+
subprocess.run(["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", concat, "-c", "copy", out_mp4],
|
| 1639 |
+
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=240)
|
| 1640 |
+
|
| 1641 |
+
# ---------- background music overlay ----------
|
| 1642 |
+
music_url = ""
|
| 1643 |
+
if audio_url:
|
| 1644 |
+
music_url = audio_url
|
| 1645 |
+
elif audio_music and audio_music in MUSIC_BY_ID:
|
| 1646 |
+
music_url = MUSIC_BY_ID[audio_music].get("url", "")
|
| 1647 |
+
if music_url:
|
| 1648 |
+
music_path = os.path.join(work, "music.mp3")
|
| 1649 |
+
data = _fetch_bytes(music_url)
|
| 1650 |
+
if data:
|
| 1651 |
+
with open(music_path, "wb") as f:
|
| 1652 |
+
f.write(data)
|
| 1653 |
+
if os.path.exists(music_path) and os.path.getsize(music_path) > 900:
|
| 1654 |
+
muxed = os.path.join(work, "muxed.mp4")
|
| 1655 |
+
vid_dur = _probe_duration(out_mp4, fallback=30.0)
|
| 1656 |
+
if _probe_has_audio(out_mp4):
|
| 1657 |
+
# narration at full volume + music at 0.5, no amix downscale (normalize=0)
|
| 1658 |
+
fc = (
|
| 1659 |
+
f"[1:a]volume=0.5,aloop=loop=-1:size=2e9,atrim=duration={vid_dur}[m];"
|
| 1660 |
+
f"[0:a][m]amix=inputs=2:duration=first:dropout_transition=0:normalize=0[aout]"
|
| 1661 |
+
)
|
| 1662 |
+
cmd = ["ffmpeg", "-y", "-i", out_mp4, "-i", music_path,
|
| 1663 |
+
"-filter_complex", fc, "-map", "0:v", "-map", "[aout]",
|
| 1664 |
+
"-c:v", "copy", "-c:a", "aac", "-b:a", "160k", "-shortest", muxed]
|
| 1665 |
+
else:
|
| 1666 |
+
fc = f"[1:a]volume=0.55,aloop=loop=-1:size=2e9,atrim=duration={vid_dur}[a]"
|
| 1667 |
+
cmd = ["ffmpeg", "-y", "-i", out_mp4, "-i", music_path,
|
| 1668 |
+
"-filter_complex", fc, "-map", "0:v", "-map", "[a]",
|
| 1669 |
+
"-c:v", "copy", "-c:a", "aac", "-b:a", "160k", "-shortest", muxed]
|
| 1670 |
+
subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=240)
|
| 1671 |
+
os.replace(muxed, out_mp4)
|
| 1672 |
+
|
| 1673 |
+
except Exception as e:
|
| 1674 |
+
if create_new:
|
| 1675 |
+
posts2 = [p for p in _load_wall_posts() if str(p.get("id")) != str(post_id)]
|
| 1676 |
+
_save_wall_posts(posts2)
|
| 1677 |
+
return JSONResponse({"error": "Không tạo được shorts: " + str(e)[:220]}, status_code=500)
|
| 1678 |
+
|
| 1679 |
+
post["video"] = out_url
|
| 1680 |
+
post["short_voice"] = voice
|
| 1681 |
+
post["short_emotion"] = emotion
|
| 1682 |
+
post["short_speed"] = speed
|
| 1683 |
+
post["short_music"] = audio_music
|
| 1684 |
+
post["short_audio_url"] = audio_url
|
| 1685 |
+
post["short_use_slides"] = use_slides
|
| 1686 |
+
post["short_images"] = scene_images
|
| 1687 |
+
_save_wall_posts(posts)
|
| 1688 |
+
|
| 1689 |
+
return JSONResponse({"video": out_url, "voice": voice, "emotion": emotion,
|
| 1690 |
+
"speed": speed, "music": audio_music, "use_slides": use_slides,
|
| 1691 |
+
"post": post})
|
app_clean.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
VNEWS Clean Backend - serves static/index_v2.html directly.
|
| 3 |
+
No injection layers. All APIs from existing modules preserved.
|
| 4 |
+
Comments feature REMOVED per user request.
|
| 5 |
+
"""
|
| 6 |
+
import sys, os
|
| 7 |
+
|
| 8 |
+
# Import the full chain which registers all API endpoints on the FastAPI app
|
| 9 |
+
from app_main import app, _search_all, _clean
|
| 10 |
+
|
| 11 |
+
# Now override the root '/' to serve our clean frontend
|
| 12 |
+
from fastapi import Query, Request
|
| 13 |
+
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
|
| 14 |
+
from fastapi.staticfiles import StaticFiles
|
| 15 |
+
import os
|
| 16 |
+
|
| 17 |
+
# Remove old '/' route
|
| 18 |
+
app.router.routes = [r for r in app.router.routes if not (
|
| 19 |
+
getattr(r, 'path', None) == '/' and 'GET' in getattr(r, 'methods', set())
|
| 20 |
+
)]
|
| 21 |
+
|
| 22 |
+
# Remove comment endpoints (user requested removal)
|
| 23 |
+
app.router.routes = [r for r in app.router.routes if not (
|
| 24 |
+
getattr(r, 'path', None) in ('/api/short/comments', '/api/short/comment')
|
| 25 |
+
)]
|
| 26 |
+
|
| 27 |
+
# Mount static files
|
| 28 |
+
STATIC_DIR = os.path.join(os.path.dirname(__file__), 'static')
|
| 29 |
+
app.mount('/static', StaticFiles(directory=STATIC_DIR), name='static')
|
| 30 |
+
|
| 31 |
+
@app.get('/')
|
| 32 |
+
async def serve_index():
|
| 33 |
+
"""Serve the clean v2 frontend - single HTML file, no injection."""
|
| 34 |
+
index_path = os.path.join(STATIC_DIR, 'index_v2.html')
|
| 35 |
+
if os.path.exists(index_path):
|
| 36 |
+
return FileResponse(index_path, media_type='text/html')
|
| 37 |
+
return HTMLResponse('<h1>VNEWS</h1><p>index_v2.html not found</p>', status_code=500)
|
| 38 |
+
|
| 39 |
+
# Keep /api/hashtag/sources using direct search (not Google News)
|
| 40 |
+
# This was already overridden in app_main.py with _search_all
|
| 41 |
+
# Just make sure it's accessible
|
| 42 |
+
|
| 43 |
+
# Storage status endpoint
|
| 44 |
+
@app.get('/api/storage_status')
|
| 45 |
+
def storage_status():
|
| 46 |
+
"""Check if persistent storage is enabled."""
|
| 47 |
+
data_dir = '/data'
|
| 48 |
+
persistent = os.path.isdir(data_dir) and os.access(data_dir, os.W_OK)
|
| 49 |
+
return JSONResponse({'persistent': persistent, 'path': data_dir})
|
| 50 |
+
|
| 51 |
+
# Categories for the tab bar
|
| 52 |
+
@app.get('/api/categories')
|
| 53 |
+
def get_categories():
|
| 54 |
+
"""Return category list for frontend tab bar."""
|
| 55 |
+
return JSONResponse([]) # Categories moved into News tab, homepage shows media content
|
| 56 |
+
|
| 57 |
+
# Share page
|
| 58 |
+
@app.get('/s')
|
| 59 |
+
async def share_page(url: str = '', title: str = '', img: str = ''):
|
| 60 |
+
"""OG share page for social media."""
|
| 61 |
+
html = f'''<!DOCTYPE html><html><head>
|
| 62 |
+
<meta property="og:title" content="{_clean(title)}">
|
| 63 |
+
<meta property="og:url" content="{_clean(url)}">
|
| 64 |
+
<meta property="og:image" content="{_clean(img)}">
|
| 65 |
+
<meta property="og:type" content="article">
|
| 66 |
+
<meta property="og:site_name" content="VNEWS">
|
| 67 |
+
<meta http-equiv="refresh" content="0;url={_clean(url) or '/'}">
|
| 68 |
+
</head><body>Redirecting...</body></html>'''
|
| 69 |
+
return HTMLResponse(html)
|
app_entry.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Wrapper: load main patch then inject extra fixes for tiktok-right position, kill duplicate slides, progress toast."""
|
| 2 |
+
from ai_runtime_patch_fast import *
|
| 3 |
+
from ai_runtime_patch_fast import app, f5, f6, rt, PATCH_INJECT
|
| 4 |
+
from patch_extra import EXTRA_FIX
|
| 5 |
+
from fastapi.responses import HTMLResponse
|
| 6 |
+
|
| 7 |
+
# Remove old root and re-register with EXTRA_FIX appended.
|
| 8 |
+
app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
|
| 9 |
+
|
| 10 |
+
@app.get('/')
|
| 11 |
+
async def _index_final():
|
| 12 |
+
html=f5.f4.f3.f2.f1._load_index_html()
|
| 13 |
+
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
|
| 14 |
+
body+=getattr(f6,'FINAL6_INJECT','');body+=getattr(f6,'FINAL6_FAST_HOME_INJECT','');body+=getattr(f6,'FINAL6E_INJECT','')
|
| 15 |
+
body+=PATCH_INJECT
|
| 16 |
+
body+=EXTRA_FIX
|
| 17 |
+
return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
|
app_final.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Final wrapper with complete highlight override including interaction buttons.
|
| 2 |
+
PLUS: Hashtag inline sources on homepage with rewrite button."""
|
| 3 |
+
import json, os, time
|
| 4 |
+
from app_patch_unified import *
|
| 5 |
+
from app_patch_unified import app, UNIFIED_INJECT, f5, f6, rt, PATCH_INJECT
|
| 6 |
+
from fastapi.responses import HTMLResponse, JSONResponse
|
| 7 |
+
from fastapi import Request, Query
|
| 8 |
+
|
| 9 |
+
DATA_DIR="/data" if os.path.isdir('/data') else "/app/data"
|
| 10 |
+
os.makedirs(DATA_DIR,exist_ok=True)
|
| 11 |
+
HL_STATS_FILE=os.path.join(DATA_DIR,'highlight_stats.json')
|
| 12 |
+
|
| 13 |
+
def _load_hl():
|
| 14 |
+
try:
|
| 15 |
+
if os.path.exists(HL_STATS_FILE):return json.load(open(HL_STATS_FILE,'r',encoding='utf-8'))
|
| 16 |
+
except:pass
|
| 17 |
+
return {}
|
| 18 |
+
def _save_hl(db):
|
| 19 |
+
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)
|
| 20 |
+
except:pass
|
| 21 |
+
|
| 22 |
+
app.router.routes=[r for r in app.router.routes if not (
|
| 23 |
+
(getattr(r,'path',None)=='/api/highlight/interact' and 'POST' in getattr(r,'methods',set())) or
|
| 24 |
+
(getattr(r,'path',None)=='/api/highlight/stats' and 'GET' in getattr(r,'methods',set())) or
|
| 25 |
+
(getattr(r,'path',None)=='/api/hashtag/sources' and 'GET' in getattr(r,'methods',set())) or
|
| 26 |
+
(getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))
|
| 27 |
+
)]
|
| 28 |
+
|
| 29 |
+
@app.post('/api/highlight/interact')
|
| 30 |
+
async def _hl_act(request:Request):
|
| 31 |
+
b=await request.json();vid=str(b.get('id','')).strip();action=str(b.get('action','')).strip()
|
| 32 |
+
if not vid or action not in ('view','like','share'):return JSONResponse({'error':'invalid'},status_code=400)
|
| 33 |
+
db=_load_hl();st=db.get(vid,{'views':0,'likes':0,'shares':0})
|
| 34 |
+
st[action+'s']=st.get(action+'s',0)+1
|
| 35 |
+
db[vid]=st;_save_hl(db);return JSONResponse({'stats':st})
|
| 36 |
+
|
| 37 |
+
@app.get('/api/highlight/stats')
|
| 38 |
+
def _hl_stats(ids:str=Query(default='')):
|
| 39 |
+
db=_load_hl();out={}
|
| 40 |
+
for vid in ids.split(','):
|
| 41 |
+
vid=vid.strip()
|
| 42 |
+
if vid:out[vid]=db.get(vid,{'views':0,'likes':0,'shares':0})
|
| 43 |
+
return JSONResponse({'stats':out})
|
| 44 |
+
|
| 45 |
+
@app.get('/api/hashtag/sources')
|
| 46 |
+
def _hashtag_sources(topic:str=Query(...)):
|
| 47 |
+
"""Return sources for a hashtag topic to display inline on homepage."""
|
| 48 |
+
research=f6._fast_context(topic) if hasattr(f6,'_fast_context') else f6._web_research_context(topic)
|
| 49 |
+
sources=research.get('sources',[])
|
| 50 |
+
# Add og:image for each source
|
| 51 |
+
from ai_runtime_patch_fast import _scrape
|
| 52 |
+
for s in sources[:6]:
|
| 53 |
+
if s.get('url') and not s.get('img'):
|
| 54 |
+
try:_,_,img=_scrape(s['url'],500)
|
| 55 |
+
except:img=''
|
| 56 |
+
s['img']=img if img and len(img)>20 else ''
|
| 57 |
+
return JSONResponse({'sources':sources[:6],'topic':topic})
|
| 58 |
+
|
| 59 |
+
# PRE_KILL fix
|
| 60 |
+
UNIFIED_INJECT_FIXED = UNIFIED_INJECT.replace(
|
| 61 |
+
"""Object.defineProperty(window,'renderAIShorts7',{get:function(){return function(){}},set:function(){},configurable:true});""",
|
| 62 |
+
"""Object.defineProperty(window,'renderAIShorts7',{get:function(){return function(){}},set:function(){},configurable:true});
|
| 63 |
+
Object.defineProperty(window,'renderPatchedWall',{get:function(){return function(){}},set:function(){},configurable:true});
|
| 64 |
+
Object.defineProperty(window,'renderAiShorts',{get:function(){return function(){}},set:function(){},configurable:true});
|
| 65 |
+
Object.defineProperty(window,'renderWall',{get:function(){return function(){}},set:function(){},configurable:true});
|
| 66 |
+
Object.defineProperty(window,'renderAIShorts',{get:function(){return function(){}},set:function(){},configurable:true});
|
| 67 |
+
Object.defineProperty(window,'loadPatchedWall',{get:function(){return function(){}},set:function(){},configurable:true});
|
| 68 |
+
Object.defineProperty(window,'refreshFinalWall3',{get:function(){return function(){}},set:function(){},configurable:true});"""
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
# Fix highlight fetch
|
| 72 |
+
UNIFIED_INJECT_FIXED = UNIFIED_INJECT_FIXED.replace(
|
| 73 |
+
"var articles=(window._hlLeagueData||{})[league]||[];\n if(!articles.length){el.innerHTML=",
|
| 74 |
+
"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="
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
# Highlight full override (same as 5a5b626)
|
| 78 |
+
HIGHLIGHT_FULL_OVERRIDE = r'''
|
| 79 |
+
<style>
|
| 80 |
+
.tiktok-slide.ratio-wide video,.tiktok-slide.ratio-wide iframe{object-fit:contain!important}
|
| 81 |
+
.hl-ask-panel{position:fixed;bottom:0;left:0;right:0;max-height:50vh;background:#181818;border-radius:16px 16px 0 0;z-index:99999;padding:14px;display:none;overflow-y:auto}.hl-ask-panel.active{display:block}.hl-ask-panel textarea,.hl-ask-panel input{width:100%;background:#222;border:1px solid #444;color:#eee;border-radius:10px;padding:9px;margin:6px 0}.hl-ask-panel button{background:#2d8659;border:0;color:#fff;border-radius:10px;padding:8px 12px;margin:4px}.hl-ask-answer{white-space:pre-wrap;color:#ccc;font-size:12px;margin-top:8px}
|
| 82 |
+
.hashtag-sources{margin:8px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.hashtag-sources h3{font-size:13px;color:#5cb87a;margin-bottom:8px}.hashtag-src-item{display:flex;gap:8px;padding:8px;background:#202020;border-radius:8px;margin:6px 0;cursor:pointer}.hashtag-src-item:active{opacity:.8}.hashtag-src-img{flex:0 0 80px;aspect-ratio:16/9;background:#333;border-radius:6px;overflow:hidden}.hashtag-src-img img{width:100%;height:100%;object-fit:cover}.hashtag-src-text{flex:1;min-width:0}.hashtag-src-title{font-size:12px;font-weight:700;color:#eee;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.hashtag-src-via{font-size:10px;color:#888;margin-top:2px}.hashtag-rewrite-btn{width:100%;margin-top:8px;background:#2d8659;border:0;color:#fff;padding:9px;border-radius:10px;font-size:12px;font-weight:700;cursor:pointer}
|
| 83 |
+
</style>
|
| 84 |
+
<div id="hl-ask-panel" class="hl-ask-panel"></div>
|
| 85 |
+
<script>
|
| 86 |
+
(function(){
|
| 87 |
+
function esc(s){return String(s||'').replace(/[&<>"']/g,function(m){return{'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]});}
|
| 88 |
+
|
| 89 |
+
// === HASHTAG INLINE: click hashtag → show sources on homepage + rewrite button ===
|
| 90 |
+
window.showHashtagSources=async function(topic){
|
| 91 |
+
var home=document.getElementById('view-home');if(!home)return;
|
| 92 |
+
document.getElementById('hashtag-sources-box')?.remove();
|
| 93 |
+
var box=document.createElement('div');box.id='hashtag-sources-box';box.className='hashtag-sources';
|
| 94 |
+
box.innerHTML='<h3>🔍 '+esc(topic)+'</h3><div style="color:#888;font-size:11px">Đang tìm nguồn...</div>';
|
| 95 |
+
var compose=home.querySelector('.ai-compose');
|
| 96 |
+
if(compose)compose.after(box);else home.prepend(box);
|
| 97 |
+
try{
|
| 98 |
+
var r=await fetch('/api/hashtag/sources?topic='+encodeURIComponent(topic));
|
| 99 |
+
var j=await r.json();var sources=j.sources||[];
|
| 100 |
+
if(!sources.length){box.innerHTML='<h3>🔍 '+esc(topic)+'</h3><div style="color:#888;font-size:12px">Không tìm được nguồn</div>';return;}
|
| 101 |
+
var h='<h3>🔍 '+esc(topic)+' <span style="font-size:10px;color:#888">('+sources.length+' nguồn)</span></h3>';
|
| 102 |
+
sources.forEach(function(s){
|
| 103 |
+
h+='<div class="hashtag-src-item" onclick="if(typeof readArticle===\'function\')readArticle(\''+esc(s.url||'')+'\')">';
|
| 104 |
+
h+='<div class="hashtag-src-img">'+(s.img?'<img src="'+esc(s.img)+'" onerror="this.style.display=\'none\'">':'')+'</div>';
|
| 105 |
+
h+='<div class="hashtag-src-text"><div class="hashtag-src-title">'+esc(s.title)+'</div><div class="hashtag-src-via">'+esc(s.via||s.source||'')+'</div></div>';
|
| 106 |
+
h+='</div>';
|
| 107 |
+
});
|
| 108 |
+
h+='<button class="hashtag-rewrite-btn" onclick="rewriteHashtagTopic(\''+esc(topic)+'\')">🤖 Rewrite AI tổng hợp nguồn & đăng tường</button>';
|
| 109 |
+
box.innerHTML=h;
|
| 110 |
+
}catch(e){box.innerHTML='<h3>🔍 '+esc(topic)+'</h3><div style="color:#e74c3c;font-size:12px">Lỗi: '+esc(e.message)+'</div>';}
|
| 111 |
+
};
|
| 112 |
+
|
| 113 |
+
window.rewriteHashtagTopic=async function(topic){
|
| 114 |
+
var btn=event?.target;if(btn){btn.disabled=true;btn.textContent='Đang tổng hợp...';}
|
| 115 |
+
try{
|
| 116 |
+
var r=await fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic:topic})});
|
| 117 |
+
var j=await r.json();
|
| 118 |
+
if(!r.ok||j.error)throw new Error(j.error||'Lỗi');
|
| 119 |
+
if(btn)btn.textContent='✅ Đã đăng lên Tường AI!';
|
| 120 |
+
setTimeout(function(){document.getElementById('hashtag-sources-box')?.remove();},2000);
|
| 121 |
+
}catch(e){
|
| 122 |
+
if(btn){btn.disabled=false;btn.textContent='❌ '+e.message;}
|
| 123 |
+
}
|
| 124 |
+
};
|
| 125 |
+
|
| 126 |
+
// Override hashtag chip click to use showHashtagSources instead of topic input
|
| 127 |
+
setTimeout(function(){
|
| 128 |
+
document.querySelectorAll('.hot-chip').forEach(function(chip){
|
| 129 |
+
chip.onclick=function(e){
|
| 130 |
+
e.preventDefault();e.stopPropagation();
|
| 131 |
+
var topic=chip.textContent.replace(/^#/,'').trim();
|
| 132 |
+
if(topic)showHashtagSources(topic);
|
| 133 |
+
};
|
| 134 |
+
});
|
| 135 |
+
},3000);
|
| 136 |
+
// Re-patch after hot topics load
|
| 137 |
+
setInterval(function(){
|
| 138 |
+
document.querySelectorAll('.hot-chip:not([data-patched])').forEach(function(chip){
|
| 139 |
+
chip.dataset.patched='1';
|
| 140 |
+
chip.onclick=function(e){
|
| 141 |
+
e.preventDefault();e.stopPropagation();
|
| 142 |
+
var topic=chip.textContent.replace(/^#/,'').trim();
|
| 143 |
+
if(topic)showHashtagSources(topic);
|
| 144 |
+
};
|
| 145 |
+
});
|
| 146 |
+
},2000);
|
| 147 |
+
|
| 148 |
+
// === FULL openLeaguePlayer override (same as before) ===
|
| 149 |
+
window.openLeaguePlayer=async function(league,idx){
|
| 150 |
+
showView('view-tiktok');document.querySelectorAll('.cat').forEach(function(x){x.classList.remove('active')});
|
| 151 |
+
var el=document.getElementById('view-tiktok');el.innerHTML='<div class="loading">Đang tải highlight...</div>';
|
| 152 |
+
var cfg=(window.HL_CONFIG||{})[league]||{name:league,emoji:'🎬'};
|
| 153 |
+
var articles=(window._hlLeagueData||{})[league]||[];
|
| 154 |
+
if(!articles.length){try{var resp=await fetch('/api/highlights/'+league);articles=await resp.json();if(!Array.isArray(articles))articles=[];}catch(e){articles=[];}}
|
| 155 |
+
if(!articles.length){el.innerHTML='<div class="loading">Không có video</div>';return;}
|
| 156 |
+
var vids=[];var results=await Promise.all(articles.map(async function(a,i){try{var r=await fetch('/api/video_url?url='+encodeURIComponent(a.link));var v=await r.json();if(v&&v.src)return Object.assign({},a,v,{_idx:i});}catch(e){}return null;}));results.forEach(function(r){if(r)vids.push(r);});vids.sort(function(a,b){return a._idx-b._idx;});
|
| 157 |
+
if(!vids.length){el.innerHTML='<div class="loading">Không tìm thấy video</div>';return;}
|
| 158 |
+
var ti=vids.findIndex(function(v){return v._idx===idx;});if(ti<0)ti=0;var ordered=ti>0?vids.slice(ti).concat(vids.slice(0,ti)):vids;
|
| 159 |
+
var h='<button class="back-btn" onclick="switchCat(\'home\')">← '+esc(cfg.emoji)+' '+esc(cfg.name)+'</button><div class="tiktok-container"><div class="tiktok-feed" id="tiktok-feed">';
|
| 160 |
+
ordered.forEach(function(v,i){var hlid=encodeURIComponent(v.link||v.title);var isYT=v.type==='youtube';var isHLS=!isYT&&v.src&&v.src.indexOf('.m3u8')>-1;var poster=v.poster?' poster="'+v.poster+'"':'';var vtag=isYT?'<iframe data-yt-src="'+v.src+'" allowfullscreen allow="accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture" style="width:100%;height:100%;border:none"></iframe>':isHLS?'<video playsinline preload="none"'+poster+' data-hls="'+v.src+'" loop controls style="width:100%;height:100%;object-fit:cover"></video>':'<video playsinline preload="none"'+poster+' loop controls style="width:100%;height:100%;object-fit:cover"><source src="'+v.src+'" type="video/mp4"></video>';h+='<div class="tiktok-slide" id="tslide-'+i+'" data-hlid="'+hlid+'">'+vtag+'<div class="tiktok-bottom"><span class="badge badge-fpt">'+esc(cfg.name)+'</span><p class="tiktok-title">'+esc(v.title)+'</p></div><div class="tiktok-right"><button class="tiktok-right-btn" onclick="event.stopPropagation();hlAct(this,\'view\')"><div class="icon">👁</div><div class="count" data-a="views">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();hlAct(this,\'like\')"><div class="icon">❤️</div><div class="count" data-a="likes">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();openHlComments(\''+hlid+'\')"><div class="icon">💬</div><div class="count">BL</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();openHlAsk(\''+hlid+'\',\''+esc(v.title)+'\')"><div class="icon">🤖</div><div class="count">Hỏi</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();hlAct(this,\'share\');if(typeof doShareVideo===\'function\')doShareVideo(\''+esc(v.title)+'\',\''+esc(v.link||'')+'\',\''+esc(v.poster||v.img||'')+'\',\'highlights\')"><div class="icon">📤</div><div class="count" data-a="shares">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();toggleHlRatio(this)"><div class="icon">⬜</div><div class="count">16:9</div></button></div><span class="tiktok-counter">'+(i+1)+'/'+ordered.length+'</span></div>';});
|
| 161 |
+
h+='</div></div>';el.innerHTML=h;
|
| 162 |
+
var feed=document.getElementById('tiktok-feed');if(!feed)return;var slides=feed.querySelectorAll('.tiktok-slide');var cur=-1;
|
| 163 |
+
function act(i){if(i===cur)return;slides.forEach(function(sl,idx){var v=sl.querySelector('video');var fr=sl.querySelector('iframe');if(idx===i){if(v&&v.dataset.hls){if(!v._hls&&typeof Hls!=='undefined'&&Hls.isSupported()){var hls=new Hls();hls.loadSource(v.dataset.hls);hls.attachMedia(v);hls.on(Hls.Events.MANIFEST_PARSED,function(){v.play().catch(function(){});});v._hls=hls;}else if(v._hls)v.play().catch(function(){});}else if(v)v.play().catch(function(){});if(fr&&!fr.src&&fr.dataset.ytSrc)fr.src=fr.dataset.ytSrc;hlAct(sl.querySelector('.tiktok-right .tiktok-right-btn'),'view');}else{if(v){v.pause();if(v._hls){v._hls.destroy();v._hls=null;}}if(fr&&fr.src)fr.src='';}});cur=i;}
|
| 164 |
+
var sT;feed.addEventListener('scroll',function(){clearTimeout(sT);sT=setTimeout(function(){var rect=feed.getBoundingClientRect(),ctr=rect.top+rect.height/2,best=-1,bestD=1e9;slides.forEach(function(sl,i){var d=Math.abs(sl.getBoundingClientRect().top+sl.getBoundingClientRect().height/2-ctr);if(d<bestD){bestD=d;best=i;}});if(best>=0)act(best);},150);});
|
| 165 |
+
setTimeout(function(){act(0);},400);slides.forEach(function(sl){var v=sl.querySelector('video');if(v)v.addEventListener('click',function(e){e.preventDefault();v.paused?v.play().catch(function(){}):v.pause();});});
|
| 166 |
+
var ids=[];slides.forEach(function(sl){if(sl.dataset.hlid)ids.push(sl.dataset.hlid);});
|
| 167 |
+
if(ids.length)fetch('/api/highlight/stats?ids='+ids.join(',')).then(function(r){return r.json()}).then(function(j){var stats=j.stats||{};slides.forEach(function(sl){var st=stats[sl.dataset.hlid];if(!st)return;var r=sl.querySelector('.tiktok-right');if(!r)return;var vc=r.querySelector('[data-a="views"]');if(vc)vc.textContent=st.views||0;var lc=r.querySelector('[data-a="likes"]');if(lc)lc.textContent=st.likes||0;var sc=r.querySelector('[data-a="shares"]');if(sc)sc.textContent=st.shares||0;});}).catch(function(){});
|
| 168 |
+
};
|
| 169 |
+
window.hlAct=async function(btn,action){var slide=btn?btn.closest('.tiktok-slide'):null;var id=slide?slide.dataset.hlid:'';if(!id)return;try{var r=await fetch('/api/highlight/interact',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:id,action:action})});var j=await r.json();if(j.stats&&slide){var right=slide.querySelector('.tiktok-right');if(right){var vc=right.querySelector('[data-a="views"]');if(vc)vc.textContent=j.stats.views||0;var lc=right.querySelector('[data-a="likes"]');if(lc)lc.textContent=j.stats.likes||0;var sc=right.querySelector('[data-a="shares"]');if(sc)sc.textContent=j.stats.shares||0;}}}catch(e){}};
|
| 170 |
+
window.toggleHlRatio=function(btn){var slide=btn.closest('.tiktok-slide');if(!slide)return;slide.classList.toggle('ratio-wide');var label=btn.querySelector('.count');if(label)label.textContent=slide.classList.contains('ratio-wide')?'1:1':'16:9';};
|
| 171 |
+
window.openHlComments=async function(id){var panel=document.getElementById('hl-ask-panel');var j=await fetch('/api/short/comments?id='+id).then(function(r){return r.json()}).catch(function(){return{comments:[]}});var cmts=j.comments||[];panel.innerHTML='<h3 style="color:#5cb87a;font-size:14px">💬 Bình luận</h3><div id="hl-cmt-list">'+(cmts.map(function(c){return'<div style="background:#222;border-radius:8px;padding:7px;margin:5px 0;color:#ccc;font-size:12px">'+esc(c.text)+'</div>'}).join('')||'<div style="color:#777;font-size:12px">Chưa có</div>')+'</div><textarea id="hl-cmt-text" placeholder="Bình luận..."></textarea><button onclick="submitHlCmt(\''+id+'\')">Gửi</button><button onclick="document.getElementById(\'hl-ask-panel\').classList.remove(\'active\')">Đóng</button>';panel.classList.add('active');};
|
| 172 |
+
window.submitHlCmt=async function(id){var t=document.getElementById('hl-cmt-text');if(!t||!t.value.trim())return;var j=await fetch('/api/short/comment',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:id,text:t.value.trim()})}).then(function(r){return r.json()}).catch(function(){return{comments:[]}});document.getElementById('hl-cmt-list').innerHTML=(j.comments||[]).map(function(c){return'<div style="background:#222;border-radius:8px;padding:7px;margin:5px 0;color:#ccc;font-size:12px">'+esc(c.text)+'</div>'}).join('');t.value='';};
|
| 173 |
+
window.openHlAsk=function(id,title){var panel=document.getElementById('hl-ask-panel');panel.innerHTML='<h3 style="color:#5cb87a;font-size:14px">🤖 Hỏi AI</h3><input id="hl-ask-q" placeholder="Hỏi về: '+esc(title)+'..."><div id="hl-ask-ans" class="hl-ask-answer"></div><button onclick="submitHlAsk(\''+id+'\',\''+esc(title)+'\')">Hỏi</button><button onclick="document.getElementById(\'hl-ask-panel\').classList.remove(\'active\')">Đóng</button>';panel.classList.add('active');};
|
| 174 |
+
window.submitHlAsk=async function(id,title){var q=document.getElementById('hl-ask-q');if(!q||!q.value.trim())return;var ans=document.getElementById('hl-ask-ans');ans.textContent='Đang hỏi...';try{var r=await fetch('/api/article/ask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({question:q.value.trim(),context:'Video highlight: '+decodeURIComponent(title||id)})});var j=await r.json();ans.textContent=j.answer||'Không trả lời được';}catch(e){ans.textContent='Lỗi: '+e.message}};
|
| 175 |
+
})();
|
| 176 |
+
</script>
|
| 177 |
+
'''
|
| 178 |
+
|
| 179 |
+
EXTRA_WALL_FIX = r'''
|
| 180 |
+
<style>[data-wall-live="1"]{display:none!important}</style>
|
| 181 |
+
<script>
|
| 182 |
+
(function(){
|
| 183 |
+
var _wc=setInterval(function(){
|
| 184 |
+
var home=document.getElementById('view-home');if(!home||!home.classList.contains('active'))return;
|
| 185 |
+
var has=document.getElementById('short-ai-final-slide');
|
| 186 |
+
if(!has&&typeof renderShortAISlide==='function')renderShortAISlide();
|
| 187 |
+
if(!document.querySelector('.slider-wrap[data-wall-live]')){
|
| 188 |
+
fetch('/api/ai_wall').then(function(r){return r.json()}).then(function(j){
|
| 189 |
+
var posts=(j&&j.posts)||[];if(!posts.length)return;
|
| 190 |
+
if(typeof window._serverWall!=='undefined')window._serverWall=posts;
|
| 191 |
+
if(typeof prependWallPost==='function')prependWallPost(posts[0]);
|
| 192 |
+
}).catch(function(){});
|
| 193 |
+
}
|
| 194 |
+
},4000);
|
| 195 |
+
setTimeout(function(){clearInterval(_wc);},30000);
|
| 196 |
+
})();
|
| 197 |
+
</script>
|
| 198 |
+
'''
|
| 199 |
+
|
| 200 |
+
@app.get('/')
|
| 201 |
+
async def _index_fixed():
|
| 202 |
+
html=f5.f4.f3.f2.f1._load_index_html()
|
| 203 |
+
body=''
|
| 204 |
+
body+=getattr(rt.old,'PATCH_INJECT','')
|
| 205 |
+
body+=f5.f4.f3.f2.f1.FINAL_INJECT+f5.f4.f3.FINAL3_INJECT+f5.f4.FINAL4_INJECT+f5.FINAL5_INJECT
|
| 206 |
+
body+=getattr(f6,'FINAL6_INJECT','')
|
| 207 |
+
body+=getattr(f6,'FINAL6_FAST_HOME_INJECT','')
|
| 208 |
+
body+=getattr(f6,'FINAL6E_INJECT','')
|
| 209 |
+
body+=PATCH_INJECT
|
| 210 |
+
body+=UNIFIED_INJECT_FIXED
|
| 211 |
+
body+=HIGHLIGHT_FULL_OVERRIDE
|
| 212 |
+
body+=EXTRA_WALL_FIX
|
| 213 |
+
return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
|
app_main.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""VNEWS v2 - Clean frontend. CRITICAL: removes ALL old routes before registering new ones."""
|
| 2 |
+
from app_run import *
|
| 3 |
+
from app_run import app, f5, f6, rt, PATCH_INJECT, UNIFIED_INJECT_FIXED, HIGHLIGHT_FULL_OVERRIDE, EXTRA_WALL_FIX, FAST_HASHTAG_JS
|
| 4 |
+
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, Response
|
| 5 |
+
from fastapi.staticfiles import StaticFiles
|
| 6 |
+
from fastapi import Query, Request
|
| 7 |
+
import requests as req
|
| 8 |
+
from urllib.parse import quote
|
| 9 |
+
from bs4 import BeautifulSoup
|
| 10 |
+
import re, html as html_lib, os, json, threading, time
|
| 11 |
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 12 |
+
|
| 13 |
+
def _clean(s):return re.sub(r"\s+"," ",html_lib.unescape(str(s or ""))).strip()
|
| 14 |
+
_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())
|
| 15 |
+
|
| 16 |
+
def _relevance_score(topic, title):
|
| 17 |
+
topic_lower = topic.lower().strip();title_lower = (title or '').lower()
|
| 18 |
+
if topic_lower in title_lower: return 10
|
| 19 |
+
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]
|
| 20 |
+
if not topic_words: return 0
|
| 21 |
+
matched = sum(1 for w in topic_words if w in title_lower)
|
| 22 |
+
ratio = matched / len(topic_words) if topic_words else 0
|
| 23 |
+
return int(ratio * 8) if ratio >= 0.6 else 0
|
| 24 |
+
|
| 25 |
+
def _search_vnexpress(topic,limit=8):
|
| 26 |
+
items=[]
|
| 27 |
+
try:
|
| 28 |
+
r=req.get(f"https://timkiem.vnexpress.net/?q={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=10);soup=BeautifulSoup(r.text,'lxml')
|
| 29 |
+
for art in soup.select('article.item-news')[:limit]:
|
| 30 |
+
a=art.select_one('h2 a, h3 a')
|
| 31 |
+
if a and a.get('href'):items.append({'title':_clean(a.get('title','') or a.get_text(strip=True)),'url':a['href'],'via':'VnExpress'})
|
| 32 |
+
except:pass
|
| 33 |
+
return items
|
| 34 |
+
def _search_dantri(topic,limit=8):
|
| 35 |
+
items=[]
|
| 36 |
+
try:
|
| 37 |
+
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')
|
| 38 |
+
for a in soup.select('h3 a[href], .article-title a[href]')[:limit*2]:
|
| 39 |
+
t=_clean(a.get_text(strip=True));href=a.get('href','')
|
| 40 |
+
if t and len(t)>15:
|
| 41 |
+
if not href.startswith('http'):href='https://dantri.com.vn'+href
|
| 42 |
+
if 'dantri.com.vn' in href:items.append({'title':t,'url':href,'via':'Dân Trí'})
|
| 43 |
+
if len(items)>=limit:break
|
| 44 |
+
except:pass
|
| 45 |
+
return items
|
| 46 |
+
def _search_vietnamnet(topic,limit=6):
|
| 47 |
+
items=[]
|
| 48 |
+
try:
|
| 49 |
+
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')
|
| 50 |
+
for a in soup.select('h3 a[href], .horizontalPost__main-title a')[:limit*2]:
|
| 51 |
+
t=_clean(a.get_text(strip=True));href=a.get('href','')
|
| 52 |
+
if t and len(t)>15:
|
| 53 |
+
if not href.startswith('http'):href='https://vietnamnet.vn'+href
|
| 54 |
+
if 'vietnamnet.vn' in href:items.append({'title':t,'url':href,'via':'VietNamNet'})
|
| 55 |
+
if len(items)>=limit:break
|
| 56 |
+
except:pass
|
| 57 |
+
return items
|
| 58 |
+
def _search_all(topic, limit=40):
|
| 59 |
+
all_items=[]
|
| 60 |
+
with ThreadPoolExecutor(5) as ex:
|
| 61 |
+
futs=[ex.submit(_search_vnexpress,topic,10),ex.submit(_search_dantri,topic,10),ex.submit(_search_vietnamnet,topic,8)]
|
| 62 |
+
for f in as_completed(futs,timeout=12):
|
| 63 |
+
try:all_items.extend(f.result())
|
| 64 |
+
except:pass
|
| 65 |
+
seen=set();unique=[]
|
| 66 |
+
for i in all_items:
|
| 67 |
+
if i.get('url') and i['url'] not in seen:seen.add(i['url']);unique.append(i)
|
| 68 |
+
return unique[:limit]
|
| 69 |
+
|
| 70 |
+
# Remove old routes
|
| 71 |
+
app.router.routes = [r for r in app.router.routes if not (
|
| 72 |
+
(getattr(r, 'path', None) == '/' and 'GET' in getattr(r, 'methods', set())) or
|
| 73 |
+
(getattr(r, 'path', None) == '/api/hashtag/sources' and 'GET' in getattr(r, 'methods', set())) or
|
| 74 |
+
(getattr(r, 'path', None) in ('/api/short/comments', '/api/short/comment'))
|
| 75 |
+
)]
|
| 76 |
+
app.routes[:] = [r for r in app.routes if not (
|
| 77 |
+
hasattr(r, 'path') and getattr(r, 'path', None) == '/' and
|
| 78 |
+
hasattr(r, 'methods') and 'GET' in getattr(r, 'methods', set())
|
| 79 |
+
)]
|
| 80 |
+
|
| 81 |
+
STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static')
|
| 82 |
+
|
| 83 |
+
@app.get('/api/hashtag/sources')
|
| 84 |
+
def _ht(topic:str=Query(...), page:int=Query(default=0)):
|
| 85 |
+
all_items=_search_all(topic, 40)
|
| 86 |
+
scored = [(s,item) for item in all_items if (s:=_relevance_score(topic, item.get('title','')))>0]
|
| 87 |
+
scored.sort(key=lambda x: x[0], reverse=True)
|
| 88 |
+
filtered = [item for _, item in scored]
|
| 89 |
+
if len(filtered) < 3: filtered = all_items
|
| 90 |
+
per_page=6;start=page*per_page;end=start+per_page
|
| 91 |
+
return JSONResponse({'sources':filtered[start:end],'topic':topic,'page':page,'has_more':end<len(filtered),'total':len(filtered)})
|
| 92 |
+
|
| 93 |
+
@app.get('/api/categories')
|
| 94 |
+
def _categories():return JSONResponse([])
|
| 95 |
+
@app.get('/api/storage_status')
|
| 96 |
+
def _storage():return JSONResponse({'persistent':os.path.isdir('/data') and os.access('/data', os.W_OK)})
|
| 97 |
+
@app.get('/s')
|
| 98 |
+
async def _share(url:str='',title:str='',img:str=''):
|
| 99 |
+
return HTMLResponse(f'<!DOCTYPE html><html><head><meta property="og:title" content="{_clean(title)}"><meta property="og:image" content="{_clean(img)}"><meta http-equiv="refresh" content="0;url={_clean(url) or "/"}"></head><body>Redirecting...</body></html>')
|
| 100 |
+
|
| 101 |
+
@app.get('/api/proxy/page')
|
| 102 |
+
def proxy_page(url: str = Query(...)):
|
| 103 |
+
try:
|
| 104 |
+
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)
|
| 105 |
+
return HTMLResponse(content=r.text)
|
| 106 |
+
except:
|
| 107 |
+
return HTMLResponse(content='', status_code=502)
|
| 108 |
+
|
| 109 |
+
@app.get('/api/proxy/hls')
|
| 110 |
+
def proxy_hls(url: str = Query(...)):
|
| 111 |
+
try:
|
| 112 |
+
headers = {
|
| 113 |
+
'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',
|
| 114 |
+
'Accept': '*/*',
|
| 115 |
+
'Accept-Language': 'vi-VN,vi;q=0.9',
|
| 116 |
+
'Referer': 'https://fptplay.vn/',
|
| 117 |
+
'Origin': 'https://fptplay.vn',
|
| 118 |
+
}
|
| 119 |
+
r = req.get(url, headers=headers, timeout=15)
|
| 120 |
+
content_type = r.headers.get('Content-Type', 'application/vnd.apple.mpegurl')
|
| 121 |
+
text = r.text
|
| 122 |
+
base_url = url.rsplit('/', 1)[0] + '/'
|
| 123 |
+
def _rewrite_url(m):
|
| 124 |
+
seg_url = m.group(0)
|
| 125 |
+
if seg_url.startswith('http'):
|
| 126 |
+
return '/api/proxy/seg?url=' + quote(seg_url, safe='')
|
| 127 |
+
elif seg_url.startswith('/'):
|
| 128 |
+
return '/api/proxy/seg?url=' + quote(base_url.rsplit('/', 2)[0] + seg_url, safe='')
|
| 129 |
+
else:
|
| 130 |
+
return '/api/proxy/seg?url=' + quote(base_url + seg_url, safe='')
|
| 131 |
+
text = re.sub(r'https?://[^\s"\'<>]+\.(ts|m3u8)[^\s"\'<>]*', _rewrite_url, text)
|
| 132 |
+
return HTMLResponse(content=text, media_type=content_type)
|
| 133 |
+
except:
|
| 134 |
+
return HTMLResponse(content='', status_code=502)
|
| 135 |
+
|
| 136 |
+
@app.get('/api/proxy/seg')
|
| 137 |
+
def proxy_seg(url: str = Query(...)):
|
| 138 |
+
try:
|
| 139 |
+
headers = {
|
| 140 |
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
| 141 |
+
'Referer': 'https://fptplay.vn/',
|
| 142 |
+
'Origin': 'https://fptplay.vn',
|
| 143 |
+
}
|
| 144 |
+
r = req.get(url, headers=headers, timeout=15)
|
| 145 |
+
content_type = r.headers.get('Content-Type', 'video/MP2T')
|
| 146 |
+
return Response(content=r.content, media_type=content_type)
|
| 147 |
+
except:
|
| 148 |
+
return Response(content=b'', status_code=502)
|
| 149 |
+
|
| 150 |
+
# Interactions
|
| 151 |
+
DATA_DIR = '/data' if os.path.isdir('/data') else os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data')
|
| 152 |
+
os.makedirs(DATA_DIR, exist_ok=True)
|
| 153 |
+
INTERACTIONS_FILE = os.path.join(DATA_DIR, 'interactions_v2.json')
|
| 154 |
+
COMMENTS_FILE = os.path.join(DATA_DIR, 'comments_v2.json')
|
| 155 |
+
_interact_lock = threading.Lock()
|
| 156 |
+
_comment_lock = threading.Lock()
|
| 157 |
+
def _load_json(path):
|
| 158 |
+
try:
|
| 159 |
+
if os.path.exists(path):
|
| 160 |
+
with open(path,'r',encoding='utf-8') as f:return json.load(f)
|
| 161 |
+
except:pass
|
| 162 |
+
return {}
|
| 163 |
+
def _save_json(path, data):
|
| 164 |
+
try:
|
| 165 |
+
tmp=path+'.tmp'
|
| 166 |
+
with open(tmp,'w',encoding='utf-8') as f:json.dump(data,f,ensure_ascii=False)
|
| 167 |
+
os.replace(tmp,path)
|
| 168 |
+
except:pass
|
| 169 |
+
|
| 170 |
+
@app.post('/api/v2/interact')
|
| 171 |
+
async def api_interact(request:Request):
|
| 172 |
+
body=await request.json();vid=str(body.get('id','')).strip();itype=str(body.get('type','')).strip()
|
| 173 |
+
if not vid or itype not in('view','like'):return JSONResponse({'error':'invalid'},status_code=400)
|
| 174 |
+
with _interact_lock:
|
| 175 |
+
db=_load_json(INTERACTIONS_FILE)
|
| 176 |
+
if vid not in db:db[vid]={'views':0,'likes':0,'comments':0}
|
| 177 |
+
db[vid][itype+'s']=db[vid].get(itype+'s',0)+1
|
| 178 |
+
_save_json(INTERACTIONS_FILE,db);return JSONResponse(db[vid])
|
| 179 |
+
@app.get('/api/v2/interactions')
|
| 180 |
+
def api_get_interactions(id:str=Query(...)):
|
| 181 |
+
with _interact_lock:return JSONResponse(_load_json(INTERACTIONS_FILE).get(id.strip(),{'views':0,'likes':0,'comments':0}))
|
| 182 |
+
@app.get('/api/v2/comments')
|
| 183 |
+
def api_get_comments(id:str=Query(...)):
|
| 184 |
+
with _comment_lock:return JSONResponse({'comments':_load_json(COMMENTS_FILE).get(id.strip(),[])})
|
| 185 |
+
@app.post('/api/v2/comment')
|
| 186 |
+
async def api_post_comment(request:Request):
|
| 187 |
+
body=await request.json();vid=str(body.get('id','')).strip();text=str(body.get('text','')).strip()[:500]
|
| 188 |
+
if not vid or not text:return JSONResponse({'error':'invalid'},status_code=400)
|
| 189 |
+
comment={'text':text,'time':time.strftime('%H:%M %d/%m',time.localtime()),'ts':int(time.time())}
|
| 190 |
+
with _comment_lock:
|
| 191 |
+
db=_load_json(COMMENTS_FILE)
|
| 192 |
+
if vid not in db:db[vid]=[]
|
| 193 |
+
db[vid].append(comment)
|
| 194 |
+
if len(db[vid])>200:db[vid]=db[vid][-200:]
|
| 195 |
+
_save_json(COMMENTS_FILE,db);comments=db[vid]
|
| 196 |
+
with _interact_lock:
|
| 197 |
+
idb=_load_json(INTERACTIONS_FILE)
|
| 198 |
+
if vid not in idb:idb[vid]={'views':0,'likes':0,'comments':0}
|
| 199 |
+
idb[vid]['comments']=len(comments);_save_json(INTERACTIONS_FILE,idb)
|
| 200 |
+
return JSONResponse({'comments':comments})
|
| 201 |
+
|
| 202 |
+
# World Cup 2026 API
|
| 203 |
+
from wc2026_scraper import (
|
| 204 |
+
scrape_summary, scrape_fixtures, scrape_standings, scrape_stats,
|
| 205 |
+
scrape_wc_news, scrape_road_to_wc, get_wc2026_all,
|
| 206 |
+
scrape_history, scrape_h2h, scrape_lineups, scrape_match_detail
|
| 207 |
+
)
|
| 208 |
+
|
| 209 |
+
@app.get('/api/wc2026')
|
| 210 |
+
def api_wc2026_all():return JSONResponse(get_wc2026_all())
|
| 211 |
+
@app.get('/api/wc2026/summary')
|
| 212 |
+
def api_wc2026_summary():return JSONResponse(scrape_summary())
|
| 213 |
+
@app.get('/api/wc2026/fixtures')
|
| 214 |
+
def api_wc2026_fixtures():return JSONResponse(scrape_fixtures())
|
| 215 |
+
@app.get('/api/wc2026/standings')
|
| 216 |
+
def api_wc2026_standings():return JSONResponse(scrape_standings())
|
| 217 |
+
@app.get('/api/wc2026/stats')
|
| 218 |
+
def api_wc2026_stats():return JSONResponse(scrape_stats())
|
| 219 |
+
@app.get('/api/wc2026/history')
|
| 220 |
+
def api_wc2026_history():return JSONResponse(scrape_history())
|
| 221 |
+
@app.get('/api/wc2026/news')
|
| 222 |
+
def api_wc2026_news():return JSONResponse(scrape_wc_news())
|
| 223 |
+
@app.get('/api/wc2026/road')
|
| 224 |
+
def api_wc2026_road():return JSONResponse(scrape_road_to_wc())
|
| 225 |
+
@app.get('/api/wc2026/h2h/{event_id}')
|
| 226 |
+
def api_wc2026_h2h(event_id:int):return JSONResponse(scrape_h2h(event_id))
|
| 227 |
+
@app.get('/api/wc2026/lineups/{event_id}')
|
| 228 |
+
def api_wc2026_lineups(event_id:int):return JSONResponse(scrape_lineups(event_id))
|
| 229 |
+
@app.get('/api/wc2026/match/{event_id}')
|
| 230 |
+
def api_wc2026_match(event_id:int):return JSONResponse(scrape_match_detail(event_id))
|
| 231 |
+
|
| 232 |
+
# Match Detail API (for any match from bongda.com.vn)
|
| 233 |
+
from match_detail import fetch_match_detail, fetch_match_detail_by_url, _bongda_api
|
| 234 |
+
|
| 235 |
+
@app.get('/api/match/{event_id}/detail')
|
| 236 |
+
def api_match_detail(event_id: int, url: str = Query(default=None)):
|
| 237 |
+
"""Get complete match detail. Optional 'url' param with full bongda URL (with slug) for HTML scraping."""
|
| 238 |
+
if url:
|
| 239 |
+
return JSONResponse(fetch_match_detail_by_url(url))
|
| 240 |
+
return JSONResponse(fetch_match_detail(event_id))
|
| 241 |
+
|
| 242 |
+
@app.get('/api/match/{event_id}/commentaries')
|
| 243 |
+
def api_match_commentaries(event_id: int):
|
| 244 |
+
"""Get match commentaries from bongda API."""
|
| 245 |
+
comm = _bongda_api("/api/fixtures/commentaries", {"event_id": event_id})
|
| 246 |
+
if comm and comm.get("status") == "success":
|
| 247 |
+
html = comm.get("html", "")
|
| 248 |
+
if html and len(html.strip()) > 10:
|
| 249 |
+
return JSONResponse({"html": html})
|
| 250 |
+
return JSONResponse({"html": ""})
|
| 251 |
+
|
| 252 |
+
@app.get('/api/match/{event_id}/stats')
|
| 253 |
+
def api_match_stats(event_id: int):
|
| 254 |
+
"""Get match player performance stats from bongda API."""
|
| 255 |
+
perf = _bongda_api("/api/event-standing/player-performance", {"event_id": event_id})
|
| 256 |
+
if perf and perf.get("status") == "success":
|
| 257 |
+
html = perf.get("html", "")
|
| 258 |
+
if html and len(html.strip()) > 10:
|
| 259 |
+
return JSONResponse({"html": html})
|
| 260 |
+
return JSONResponse({"html": ""})
|
| 261 |
+
|
| 262 |
+
@app.get('/api/match/detail')
|
| 263 |
+
def api_match_detail_by_url(url: str = Query(...)):
|
| 264 |
+
"""Get match detail by full bongda.com.vn URL."""
|
| 265 |
+
return JSONResponse(fetch_match_detail_by_url(url))
|
| 266 |
+
|
| 267 |
+
def _wc2026_bg_refresh():
|
| 268 |
+
time.sleep(10)
|
| 269 |
+
while True:
|
| 270 |
+
try:get_wc2026_all()
|
| 271 |
+
except:pass
|
| 272 |
+
time.sleep(90)
|
| 273 |
+
threading.Thread(target=_wc2026_bg_refresh,daemon=True).start()
|
| 274 |
+
|
| 275 |
+
# Serve frontend
|
| 276 |
+
@app.get('/')
|
| 277 |
+
async def _index_v2():
|
| 278 |
+
index_path = os.path.join(STATIC_DIR, 'index_v2.html')
|
| 279 |
+
if os.path.exists(index_path):
|
| 280 |
+
return FileResponse(index_path, media_type='text/html')
|
| 281 |
+
return HTMLResponse('<html><body><h1>VNEWS v2</h1><p>index_v2.html not found</p></body></html>')
|
| 282 |
+
|
| 283 |
+
app.mount('/static', StaticFiles(directory=STATIC_DIR), name='vnews_static')
|
app_patch_unified.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
VNEWS Unified Patch v2
|
| 3 |
+
======================
|
| 4 |
+
Single file replacing app_entry.py + patch_extra.py functionality.
|
| 5 |
+
No conflicts, no duplicate slides, no DOM destruction.
|
| 6 |
+
|
| 7 |
+
Features:
|
| 8 |
+
1. Tường AI persistent (fix FINAL6E destroying DOM)
|
| 9 |
+
2. Source details with image + description + "Xem trên VNEWS"
|
| 10 |
+
3. Highlight = TikTok fullheight 1:1 crop center with interaction buttons
|
| 11 |
+
4. Rewrite auto-title, no "xem trên VNEWS" junk
|
| 12 |
+
5. Topic post uses source og:image instead of AI image
|
| 13 |
+
6. Fast homepage load (non-blocking)
|
| 14 |
+
"""
|
| 15 |
+
from ai_runtime_patch_fast import *
|
| 16 |
+
from ai_runtime_patch_fast import app, f5, f6, rt, PATCH_INJECT, _scrape, _domain, clean, _bg, _bg_home, _bg_shorts
|
| 17 |
+
from fastapi.responses import HTMLResponse, JSONResponse
|
| 18 |
+
from fastapi import Request, Query
|
| 19 |
+
import asyncio, re, threading, time
|
| 20 |
+
|
| 21 |
+
DEFAULT_IMG = "https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg"
|
| 22 |
+
|
| 23 |
+
# ============================================================
|
| 24 |
+
# REMOVE ALL CONFLICTING ROUTES — we redefine them cleanly
|
| 25 |
+
# ============================================================
|
| 26 |
+
_OVERRIDE_PATHS = {'/api/homepage','/api/shorts','/api/topic_post','/api/topic/rewrite','/api/rewrite_share','/api/url_wall','/'}
|
| 27 |
+
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')))]
|
| 28 |
+
|
| 29 |
+
# ============================================================
|
| 30 |
+
# FAST HOMEPAGE + SHORTS (non-blocking)
|
| 31 |
+
# ============================================================
|
| 32 |
+
@app.get('/api/homepage')
|
| 33 |
+
def _homepage():
|
| 34 |
+
if _bg_home['d']:
|
| 35 |
+
if time.time()-_bg_home['t']>300:threading.Thread(target=_bg,daemon=True).start()
|
| 36 |
+
return JSONResponse(_bg_home['d'])
|
| 37 |
+
threading.Thread(target=_bg,daemon=True).start()
|
| 38 |
+
return JSONResponse([])
|
| 39 |
+
|
| 40 |
+
@app.get('/api/shorts')
|
| 41 |
+
def _shorts(refresh:int=Query(default=0)):
|
| 42 |
+
if _bg_shorts['d']:
|
| 43 |
+
if time.time()-_bg_shorts['t']>600:threading.Thread(target=_bg,daemon=True).start()
|
| 44 |
+
return JSONResponse(_bg_shorts['d'])
|
| 45 |
+
threading.Thread(target=_bg,daemon=True).start()
|
| 46 |
+
return JSONResponse([])
|
| 47 |
+
|
| 48 |
+
# ============================================================
|
| 49 |
+
# HELPERS
|
| 50 |
+
# ============================================================
|
| 51 |
+
def _extract_title(text):
|
| 52 |
+
if not text:return 'Bài viết AI'
|
| 53 |
+
lines=[l.strip() for l in text.strip().split('\n') if l.strip()]
|
| 54 |
+
if lines:
|
| 55 |
+
first=re.sub(r'^[#*\-•\d\.\)\s]+','',lines[0]).strip()
|
| 56 |
+
if 10<=len(first)<=120:return first
|
| 57 |
+
return lines[0][:100] if lines else 'Bài viết AI'
|
| 58 |
+
|
| 59 |
+
def _clean_text(text):
|
| 60 |
+
if not text:return text
|
| 61 |
+
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']:
|
| 62 |
+
text=text.replace(junk,'')
|
| 63 |
+
return re.sub(r'\n{3,}','\n\n',text).strip()
|
| 64 |
+
|
| 65 |
+
def _source_image(sources, details):
|
| 66 |
+
for s in (details or [])+(sources or []):
|
| 67 |
+
url=s.get('url','')
|
| 68 |
+
if not url:continue
|
| 69 |
+
try:_,_,img=_scrape(url,500)
|
| 70 |
+
except:img=''
|
| 71 |
+
if img and 'pollinations' not in img and len(img)>20:return img
|
| 72 |
+
return ''
|
| 73 |
+
|
| 74 |
+
def _ensure_img(img):
|
| 75 |
+
return img if (img and len(img)>20 and img.startswith('http')) else DEFAULT_IMG
|
| 76 |
+
|
| 77 |
+
# ============================================================
|
| 78 |
+
# TOPIC POST (source image instead of AI image)
|
| 79 |
+
# ============================================================
|
| 80 |
+
@app.post('/api/topic_post')
|
| 81 |
+
async def _topic(request:Request):
|
| 82 |
+
b=await request.json();topic=clean(b.get('topic',''))
|
| 83 |
+
if not topic:return JSONResponse({'error':'missing topic'},status_code=400)
|
| 84 |
+
research=f6._fast_context(topic) if hasattr(f6,'_fast_context') else f6._web_research_context(topic)
|
| 85 |
+
ctx=research.get('context','');src=research.get('sources',[])
|
| 86 |
+
det=f6._extract_source_details_from_context(ctx,src) if hasattr(f6,'_extract_source_details_from_context') else []
|
| 87 |
+
if not ctx or not src:return JSONResponse({'error':'Không tìm được nội dung.'},status_code=422)
|
| 88 |
+
img=_ensure_img(_source_image(src,det) or f6._topic_image(topic))
|
| 89 |
+
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]
|
| 90 |
+
text=None
|
| 91 |
+
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)
|
| 92 |
+
except:pass
|
| 93 |
+
if not text or len(text)<300:
|
| 94 |
+
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')}))
|
| 95 |
+
text=_clean_text(text)
|
| 96 |
+
post=f5.base.make_post(topic,text,img,'','topic_focused',sources=[s for s in src if s.get('url')])
|
| 97 |
+
post['images']=[img];post['source_details']=det
|
| 98 |
+
ps=f5.base._load_ai_wall();ps.insert(0,post);f5.base._save_ai_wall(ps)
|
| 99 |
+
return JSONResponse({'post':post})
|
| 100 |
+
|
| 101 |
+
# ============================================================
|
| 102 |
+
# REWRITE (auto-title, clean text)
|
| 103 |
+
# ============================================================
|
| 104 |
+
@app.post('/api/rewrite_share')
|
| 105 |
+
@app.post('/api/url_wall')
|
| 106 |
+
async def _rewrite(request:Request):
|
| 107 |
+
b=await request.json();url=clean(b.get('url',''));ctx=clean(b.get('context',''))
|
| 108 |
+
if not url.startswith('http'):return JSONResponse({'error':'URL không hợp lệ'},status_code=400)
|
| 109 |
+
title,raw,img=_scrape(url,14000)
|
| 110 |
+
if len(raw)<50:raw=ctx[:14000]
|
| 111 |
+
if len(raw)<50:return JSONResponse({'error':'Không đọc được bài'},status_code=422)
|
| 112 |
+
img=_ensure_img(img)
|
| 113 |
+
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).
|
| 114 |
+
|
| 115 |
+
Tiêu đề gốc: {title}
|
| 116 |
+
Nội dung:
|
| 117 |
+
{raw[:14000]}
|
| 118 |
+
|
| 119 |
+
Yêu cầu:
|
| 120 |
+
- Dòng 1: Tiêu đề MỚI ngắn gọn hấp dẫn.
|
| 121 |
+
- Tiếp: 4-6 ý chính.
|
| 122 |
+
- Cuối: nguồn.
|
| 123 |
+
- KHÔNG viết bất kỳ cụm điều hướng nào."""
|
| 124 |
+
text=None
|
| 125 |
+
try:text=await asyncio.wait_for(f5.base.qwen_generate(prompt,image_url=img,max_tokens=1000),timeout=30)
|
| 126 |
+
except:pass
|
| 127 |
+
if not text or len(text)<80:text=f"{title}\n\n{raw[:1200]}\n\nNguồn: {_domain(url)}"
|
| 128 |
+
text=_clean_text(text)
|
| 129 |
+
ai_title=_extract_title(text)
|
| 130 |
+
lines=text.strip().split('\n')
|
| 131 |
+
body='\n'.join(lines[1:]).strip() if lines and lines[0].strip()==ai_title else text
|
| 132 |
+
post=f5.base.make_post(ai_title,_clean_text(body),img,url,'rewrite',sources=[{'title':title,'url':url,'via':_domain(url)}])
|
| 133 |
+
ps=f5.base._load_ai_wall();ps.insert(0,post);f5.base._save_ai_wall(ps)
|
| 134 |
+
return JSONResponse({'post':post})
|
| 135 |
+
|
| 136 |
+
@app.post('/api/topic/rewrite')
|
| 137 |
+
async def _topic_rewrite(request:Request):
|
| 138 |
+
b=await request.json();pid=str(b.get('post_id','')).strip()
|
| 139 |
+
if not pid:return JSONResponse({'error':'missing post_id'},status_code=400)
|
| 140 |
+
ps=f5.base._load_ai_wall();p=next((x for x in ps if str(x.get('id'))==pid),None)
|
| 141 |
+
if not p:return JSONResponse({'error':'Bài không tồn tại'},status_code=404)
|
| 142 |
+
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]
|
| 143 |
+
parts=[];best_img=''
|
| 144 |
+
for u in urls:
|
| 145 |
+
t,r,uimg=_scrape(u,6000)
|
| 146 |
+
if r and len(r)>150:parts.append(f"[{_domain(u)}] {t}\n{r}")
|
| 147 |
+
if not best_img and uimg and len(uimg)>20:best_img=uimg
|
| 148 |
+
ac='\n---\n'.join(parts) if parts else (p.get('text') or '')
|
| 149 |
+
img=_ensure_img(best_img or p.get('img',''))
|
| 150 |
+
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.
|
| 151 |
+
|
| 152 |
+
Chủ đề: {p.get('title','')}
|
| 153 |
+
Nguồn:
|
| 154 |
+
{ac[:16000]}
|
| 155 |
+
|
| 156 |
+
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."""
|
| 157 |
+
text=None
|
| 158 |
+
try:text=await asyncio.wait_for(f5.base.qwen_generate(prompt,image_url=img,max_tokens=1200),timeout=35)
|
| 159 |
+
except:pass
|
| 160 |
+
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"
|
| 161 |
+
text=_clean_text(text)
|
| 162 |
+
ai_title=_extract_title(text)
|
| 163 |
+
lines=text.strip().split('\n')
|
| 164 |
+
body='\n'.join(lines[1:]).strip() if lines and lines[0].strip()==ai_title else text
|
| 165 |
+
np=f5.base.make_post(ai_title,_clean_text(body),img,'','rewrite_topic',sources=p.get('sources',[]));np['images']=[img]
|
| 166 |
+
all_p=f5.base._load_ai_wall();all_p.insert(0,np);f5.base._save_ai_wall(all_p)
|
| 167 |
+
return JSONResponse({'post':np})
|
| 168 |
+
|
| 169 |
+
# ============================================================
|
| 170 |
+
# UNIFIED INJECT: everything in one clean block
|
| 171 |
+
# ============================================================
|
| 172 |
+
UNIFIED_INJECT = r'''
|
| 173 |
+
<script>
|
| 174 |
+
// === PRE-KILL: prevent old code from destroying Tường AI and Short AI slides ===
|
| 175 |
+
Object.defineProperty(window,'renderTopicWallE',{get:function(){return function(){}},set:function(){},configurable:true});
|
| 176 |
+
Object.defineProperty(window,'renderAIShortHome',{get:function(){return function(){}},set:function(){},configurable:true});
|
| 177 |
+
Object.defineProperty(window,'renderAIShorts7',{get:function(){return function(){}},set:function(){},configurable:true});
|
| 178 |
+
</script>
|
| 179 |
+
<style>
|
| 180 |
+
/* Tiktok right panel for shorts/highlights */
|
| 181 |
+
.tiktok-slide{position:relative!important}
|
| 182 |
+
.tiktok-right{position:absolute!important;right:8px!important;bottom:100px!important;display:flex!important;flex-direction:column!important;align-items:center!important;gap:14px!important;z-index:5!important}
|
| 183 |
+
.tiktok-right-btn{display:flex!important;flex-direction:column!important;align-items:center!important;gap:2px!important;background:none!important;border:0!important;color:#fff!important;cursor:pointer!important}
|
| 184 |
+
.tiktok-right-btn .icon{width:42px!important;height:42px!important;border-radius:50%!important;background:rgba(255,255,255,.12)!important;display:flex!important;align-items:center!important;justify-content:center!important;font-size:20px!important}
|
| 185 |
+
.tiktok-right-btn .count{font-size:10px!important;color:#ddd!important}
|
| 186 |
+
/* Highlight: TikTok feed with 1:1 crop center */
|
| 187 |
+
.tiktok-slide video{object-fit:cover!important}
|
| 188 |
+
/* Hide duplicate slides/walls from old layers */
|
| 189 |
+
#ai-short-home,.ai-short-home,.ai-short-card-final,[id*="ai-shorts-patched"]{display:none!important}
|
| 190 |
+
/* Progress toast */
|
| 191 |
+
#short-progress-toast{position:fixed;bottom:70px;left:50%;transform:translateX(-50%);background:#2d8659;color:#fff;padding:10px 20px;border-radius:20px;font-size:12px;z-index:99998;box-shadow:0 4px 12px rgba(0,0,0,.4);display:none}
|
| 192 |
+
/* Source details */
|
| 193 |
+
.source-detail-box{margin-top:14px;background:#151515;border:1px solid #2b2b2b;border-radius:10px;padding:10px}
|
| 194 |
+
.source-detail-item{background:#202020;border-radius:8px;padding:9px;margin:7px 0;cursor:pointer}
|
| 195 |
+
.source-detail-item:active{opacity:.8}
|
| 196 |
+
.source-detail-title{font-size:12px;font-weight:700;color:#eee}
|
| 197 |
+
.source-detail-content{font-size:11px;color:#bbb;line-height:1.4;max-height:80px;overflow:hidden;margin-top:4px}
|
| 198 |
+
.source-detail-item img{width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:6px;margin-bottom:6px;background:#222}
|
| 199 |
+
.source-vnews-btn{display:inline-block;margin-top:6px;background:#2d8659;color:#fff;padding:4px 10px;border-radius:10px;font-size:10px;font-weight:700}
|
| 200 |
+
/* Livescore */
|
| 201 |
+
.ls-content{max-height:480px;overflow-y:auto;padding:0 6px 8px;font-size:12px;color:#ddd}.ls-content ul{list-style:none;padding:0;margin:0}.ls-content .title-content{display:flex;gap:6px;align-items:center;background:#222;border-radius:4px;margin:4px 0;padding:5px 8px}.ls-content .title-content img{width:18px;height:18px}.ls-content .title-content strong{font-size:11px;color:#ccc}.ls-content .match-detail{padding:6px;border-bottom:1px solid #262626;cursor:pointer}.ls-content .match-detail:hover{background:#1a2a1f}.ls-content .match{display:flex;flex-wrap:wrap;align-items:center;gap:4px}.ls-content .datetime{width:100%;font-size:9px;color:#888}.ls-content .teams{display:flex;width:100%;align-items:center;gap:4px}.ls-content .team{flex:1;display:flex;align-items:center;gap:4px;min-width:0}.ls-content .team .name{font-size:11px;color:#ddd;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ls-content .team .logo img{width:18px;height:18px}.ls-content .home-team{justify-content:flex-end;text-align:right}.ls-content .status{flex:0 0 54px;text-align:center}.ls-content .status a{color:#fff;text-decoration:none;font-weight:800;font-size:12px}.ls-content .status .label{font-size:8px;color:#888;display:block}.ls-content .status .label.live{color:#e74c3c}.ls-content .info,.ls-content .btns{display:none}.ls-content table,.mo-body table{width:100%;border-collapse:collapse;font-size:11px;color:#ccc}.ls-content table th,.mo-body table th{background:#222;color:#999;padding:5px 4px;font-size:10px;border-bottom:1px solid #333}.ls-content table td,.mo-body table td{padding:4px 3px;border-bottom:1px solid #1a1a1a}.ls-content table .team-name,.mo-body table .team-name{display:flex;align-items:center;gap:4px}.ls-content table .team-name img,.mo-body table .team-name img{width:16px;height:16px}.ls-content table .pts{font-weight:800;color:#f0c040}.mo-body{padding:8px;font-size:12px;color:#ddd}.mo-body ul{list-style:none;padding:0}.mo-body li{padding:5px 0;border-bottom:1px solid #222}
|
| 202 |
+
</style>
|
| 203 |
+
<div id="short-progress-toast"></div>
|
| 204 |
+
<script>
|
| 205 |
+
(function(){
|
| 206 |
+
function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));}
|
| 207 |
+
|
| 208 |
+
// === Progress toast ===
|
| 209 |
+
window.showShortProgress=function(msg){var t=document.getElementById('short-progress-toast');if(t){t.textContent=msg;t.style.display='block';}};
|
| 210 |
+
window.hideShortProgress=function(){var t=document.getElementById('short-progress-toast');if(t)t.style.display='none';};
|
| 211 |
+
window.makeShortFromPost=async function(pid,btn){
|
| 212 |
+
showShortProgress('⏳ Đang tạo Short AI...');if(btn){btn.disabled=true;btn.textContent='Đang tạo...';}
|
| 213 |
+
try{var r=await fetch('/api/ai/short/'+pid,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({voice:'nu',emotion:'neutral',speed:1.2})});var j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');showShortProgress('✅ Đã tạo!');setTimeout(hideShortProgress,3000);if(typeof renderShortAISlide==='function')renderShortAISlide();}catch(e){showShortProgress('❌ '+e.message);setTimeout(hideShortProgress,4000);}finally{if(btn){btn.disabled=false;btn.textContent='🎬 Tạo Short AI';}}
|
| 214 |
+
};
|
| 215 |
+
|
| 216 |
+
// === Remove duplicate slides ===
|
| 217 |
+
setInterval(function(){document.querySelectorAll('#ai-short-home,.ai-short-home,[id*="ai-shorts-patched"]').forEach(function(el){if(el.id!=='short-ai-final-slide')el.remove();});},3000);
|
| 218 |
+
|
| 219 |
+
// === Override openLeaguePlayer: TikTok vertical feed, 1:1 crop center ===
|
| 220 |
+
window.openLeaguePlayer=async function(league,idx){
|
| 221 |
+
showView('view-tiktok');document.querySelectorAll('.cat').forEach(x=>x.classList.remove('active'));
|
| 222 |
+
var el=document.getElementById('view-tiktok');el.innerHTML='<div class="loading">Đang tải...</div>';
|
| 223 |
+
var cfg=(window.HL_CONFIG||{})[league]||{name:league,emoji:'🎬'};
|
| 224 |
+
var articles=(window._hlLeagueData||{})[league]||[];
|
| 225 |
+
if(!articles.length){el.innerHTML='<div class="loading">Không có video</div>';return;}
|
| 226 |
+
var vids=[];
|
| 227 |
+
var results=await Promise.all(articles.map(async function(a,i){try{var r=await fetch('/api/video_url?url='+encodeURIComponent(a.link));var v=await r.json();if(v&&v.src)return Object.assign({},a,v,{_idx:i});}catch(e){}return null;}));
|
| 228 |
+
results.forEach(function(r){if(r)vids.push(r);});
|
| 229 |
+
vids.sort(function(a,b){return a._idx-b._idx;});
|
| 230 |
+
if(!vids.length){el.innerHTML='<div class="loading">Không tìm thấy video</div>';return;}
|
| 231 |
+
var ti=vids.findIndex(function(v){return v._idx===idx;});if(ti<0)ti=0;
|
| 232 |
+
var ordered=ti>0?vids.slice(ti).concat(vids.slice(0,ti)):vids;
|
| 233 |
+
var h='<button class="back-btn" onclick="switchCat(\'home\')">← '+cfg.emoji+' '+cfg.name+'</button><div class="tiktok-container"><div class="tiktok-feed" id="tiktok-feed">';
|
| 234 |
+
ordered.forEach(function(v,i){
|
| 235 |
+
var isYT=v.type==='youtube';var isHLS=!isYT&&v.src&&v.src.indexOf('.m3u8')>-1;
|
| 236 |
+
var poster=v.poster?' poster="'+v.poster+'"':'';
|
| 237 |
+
var vtag=isYT?'<iframe data-yt-src="'+v.src+'" allowfullscreen allow="accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture" style="width:100%;height:100%;border:none"></iframe>':isHLS?'<video playsinline preload="none"'+poster+' data-hls="'+v.src+'" loop controls style="width:100%;height:100%;object-fit:cover"></video>':'<video playsinline preload="none"'+poster+' loop controls style="width:100%;height:100%;object-fit:cover"><source src="'+v.src+'" type="video/mp4"></video>';
|
| 238 |
+
h+='<div class="tiktok-slide" id="tslide-'+i+'">'+vtag+'<div class="tiktok-bottom"><span class="badge badge-fpt">'+esc(cfg.name)+'</span><p class="tiktok-title">'+esc(v.title)+'</p></div><div class="tiktok-right"><button class="tiktok-right-btn" onclick="event.stopPropagation()"><div class="icon">👁</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation()"><div class="icon">❤️</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();if(typeof doShareVideo===\'function\')doShareVideo(\''+esc(v.title)+'\',\''+esc(v.link||'')+'\',\''+esc(v.poster||v.img||'')+'\',\'highlights\')"><div class="icon">📤</div></button></div><span class="tiktok-counter">'+(i+1)+'/'+ordered.length+'</span></div>';
|
| 239 |
+
});
|
| 240 |
+
h+='</div></div>';el.innerHTML=h;
|
| 241 |
+
// Init feed
|
| 242 |
+
var feed=document.getElementById('tiktok-feed');if(!feed)return;
|
| 243 |
+
var slides=feed.querySelectorAll('.tiktok-slide');var cur=-1;
|
| 244 |
+
function act(i){if(i===cur)return;slides.forEach(function(sl,idx){var v=sl.querySelector('video');var fr=sl.querySelector('iframe');if(idx===i){if(v&&v.dataset.hls){if(!v._hls&&typeof Hls!=='undefined'&&Hls.isSupported()){var hls=new Hls();hls.loadSource(v.dataset.hls);hls.attachMedia(v);hls.on(Hls.Events.MANIFEST_PARSED,function(){v.play().catch(function(){});});v._hls=hls;}else if(v._hls)v.play().catch(function(){});}else if(v)v.play().catch(function(){});if(fr&&!fr.src&&fr.dataset.ytSrc)fr.src=fr.dataset.ytSrc;}else{if(v){v.pause();if(v._hls){v._hls.destroy();v._hls=null;}}if(fr&&fr.src)fr.src='';}});cur=i;}
|
| 245 |
+
var sT;feed.addEventListener('scroll',function(){clearTimeout(sT);sT=setTimeout(function(){var rect=feed.getBoundingClientRect(),ctr=rect.top+rect.height/2,best=-1,bestD=1e9;slides.forEach(function(sl,i){var d=Math.abs(sl.getBoundingClientRect().top+sl.getBoundingClientRect().height/2-ctr);if(d<bestD){bestD=d;best=i;}});if(best>=0)act(best);},150);});
|
| 246 |
+
setTimeout(function(){act(0);},400);
|
| 247 |
+
slides.forEach(function(sl){var v=sl.querySelector('video');if(v)v.addEventListener('click',function(e){e.preventDefault();v.paused?v.play().catch(function(){}):v.pause();});});
|
| 248 |
+
};
|
| 249 |
+
|
| 250 |
+
// === Block slow YouTube refresh on first load ===
|
| 251 |
+
var _origFetch=window.fetch,_allowRefresh=false;
|
| 252 |
+
window.fetch=function(url,opts){try{if(String(url).indexOf('/api/shorts?refresh=1')>-1&&!_allowRefresh)url='/api/shorts';}catch(e){}return _origFetch.call(this,url,opts);};
|
| 253 |
+
setTimeout(function(){_allowRefresh=true;},8000);
|
| 254 |
+
})();
|
| 255 |
+
</script>
|
| 256 |
+
'''
|
| 257 |
+
|
| 258 |
+
# ============================================================
|
| 259 |
+
# ROOT ROUTE: inject order matters
|
| 260 |
+
# ============================================================
|
| 261 |
+
@app.get('/')
|
| 262 |
+
async def _index():
|
| 263 |
+
html = f5.f4.f3.f2.f1._load_index_html()
|
| 264 |
+
# Inject order: PRE_KILL (in UNIFIED) → old injects → PATCH_INJECT → UNIFIED
|
| 265 |
+
body = ''
|
| 266 |
+
body += getattr(rt.old,'PATCH_INJECT','')
|
| 267 |
+
body += f5.f4.f3.f2.f1.FINAL_INJECT + f5.f4.f3.FINAL3_INJECT + f5.f4.FINAL4_INJECT + f5.FINAL5_INJECT
|
| 268 |
+
body += getattr(f6,'FINAL6_INJECT','')
|
| 269 |
+
body += getattr(f6,'FINAL6_FAST_HOME_INJECT','')
|
| 270 |
+
body += getattr(f6,'FINAL6E_INJECT','') # Keep it — our PRE_KILL in UNIFIED neutralizes its destructive parts
|
| 271 |
+
body += PATCH_INJECT
|
| 272 |
+
body += UNIFIED_INJECT # This goes LAST and contains PRE_KILL at the TOP (runs first in browser)
|
| 273 |
+
return HTMLResponse(html.replace('</body>', body + '\n</body>') if '</body>' in html else html + body)
|
app_run.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Wrapper: hashtag via Google News with pagination, strict relevance, load more."""
|
| 2 |
+
from app_final import *
|
| 3 |
+
from app_final import app, f6, f5, rt, PATCH_INJECT, UNIFIED_INJECT_FIXED, HIGHLIGHT_FULL_OVERRIDE, EXTRA_WALL_FIX
|
| 4 |
+
from fastapi.responses import HTMLResponse, JSONResponse
|
| 5 |
+
from fastapi import Query, Request
|
| 6 |
+
import requests as req
|
| 7 |
+
from urllib.parse import quote
|
| 8 |
+
from bs4 import BeautifulSoup
|
| 9 |
+
import re, html as html_lib
|
| 10 |
+
|
| 11 |
+
def _clean(s):return re.sub(r"\s+"," ",html_lib.unescape(str(s or ""))).strip()
|
| 12 |
+
|
| 13 |
+
def _follow_redirect(url):
|
| 14 |
+
try:
|
| 15 |
+
r=req.head(url,allow_redirects=True,timeout=10,headers={'User-Agent':'Mozilla/5.0'})
|
| 16 |
+
return r.url
|
| 17 |
+
except:
|
| 18 |
+
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
|
| 19 |
+
except:return url
|
| 20 |
+
|
| 21 |
+
def _scrape_any_article(url):
|
| 22 |
+
if 'news.google.com' in url or 'google.com/rss' in url:url=_follow_redirect(url)
|
| 23 |
+
try:
|
| 24 |
+
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)
|
| 25 |
+
r.encoding='utf-8';soup=BeautifulSoup(r.text,'lxml')
|
| 26 |
+
for tag in soup.find_all(['script','style','nav','footer','aside','form','noscript','iframe']):tag.decompose()
|
| 27 |
+
h1=soup.find('h1');ogt=soup.find('meta',property='og:title')
|
| 28 |
+
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 '')
|
| 29 |
+
ogd=soup.find('meta',property='og:description') or soup.find('meta',attrs={'name':'description'})
|
| 30 |
+
summary=ogd.get('content','') if ogd else ''
|
| 31 |
+
ogi=soup.find('meta',property='og:image') or soup.find('meta',attrs={'name':'twitter:image'})
|
| 32 |
+
og_image=ogi.get('content','') if ogi else ''
|
| 33 |
+
if og_image and og_image.startswith('//'):og_image='https:'+og_image
|
| 34 |
+
selectors=['article','main','.article-content','.detail-content','.singular-content','.fck_detail','.content-detail','.entry-content','.story-body','.knc-content','.cms-body']
|
| 35 |
+
block=None
|
| 36 |
+
for sel in selectors:
|
| 37 |
+
el=soup.select_one(sel)
|
| 38 |
+
if el and len(el.find_all('p'))>=2:block=el;break
|
| 39 |
+
if not block:
|
| 40 |
+
best=None;best_score=0
|
| 41 |
+
for el in soup.find_all(['article','main','section','div']):
|
| 42 |
+
ps=el.find_all('p');score=len(ps)*100+sum(len(p.get_text())for p in ps[:10])
|
| 43 |
+
if score>best_score:best=el;best_score=score
|
| 44 |
+
block=best or soup.body or soup
|
| 45 |
+
body=[]
|
| 46 |
+
for el in block.find_all(['p','h2','h3','figure','img'],recursive=True):
|
| 47 |
+
if el.name=='p':
|
| 48 |
+
t=_clean(el.get_text(' ',strip=True))
|
| 49 |
+
if len(t)>30:body.append({'type':'p','text':t})
|
| 50 |
+
elif el.name in ('h2','h3'):
|
| 51 |
+
t=_clean(el.get_text(' ',strip=True))
|
| 52 |
+
if t:body.append({'type':'heading','text':t})
|
| 53 |
+
elif el.name in ('figure','img'):
|
| 54 |
+
im=el if el.name=='img' else el.find('img')
|
| 55 |
+
if im:
|
| 56 |
+
src=im.get('data-src') or im.get('data-original') or im.get('src') or ''
|
| 57 |
+
if src and 'base64' not in src:
|
| 58 |
+
if src.startswith('//'):src='https:'+src
|
| 59 |
+
body.append({'type':'img','src':src})
|
| 60 |
+
if not body and summary:body=[{'type':'p','text':summary}]
|
| 61 |
+
return {'title':_clean(title),'summary':_clean(summary),'og_image':og_image,'body':body[:50],'source':'generic','url':url}
|
| 62 |
+
except:return None
|
| 63 |
+
|
| 64 |
+
def _google_news_search_all(topic, limit=30):
|
| 65 |
+
"""Get ALL results from Google News RSS for a topic — no filtering here, filter in endpoint."""
|
| 66 |
+
items=[]
|
| 67 |
+
try:
|
| 68 |
+
url='https://news.google.com/rss/search?q='+quote(topic)+'&hl=vi&gl=VN&ceid=VN:vi'
|
| 69 |
+
r=req.get(url,headers={'User-Agent':'Mozilla/5.0'},timeout=10);r.encoding='utf-8'
|
| 70 |
+
soup=BeautifulSoup(r.text,'xml')
|
| 71 |
+
for it in soup.find_all('item')[:limit]:
|
| 72 |
+
title=_clean(it.find('title').get_text(' ',strip=True) if it.find('title') else '')
|
| 73 |
+
link=_clean(it.find('link').get_text(strip=True) if it.find('link') else '')
|
| 74 |
+
src=_clean(it.find('source').get_text(' ',strip=True) if it.find('source') else '')
|
| 75 |
+
pub=_clean(it.find('pubDate').get_text(strip=True) if it.find('pubDate') else '')
|
| 76 |
+
if not title or not link:continue
|
| 77 |
+
items.append({'title':title,'url':link,'via':src,'snippet':'','pubDate':pub})
|
| 78 |
+
except:pass
|
| 79 |
+
return items
|
| 80 |
+
|
| 81 |
+
def _filter_relevant(items, topic):
|
| 82 |
+
"""Strict filter: topic keywords MUST appear in title."""
|
| 83 |
+
topic_lower=topic.lower()
|
| 84 |
+
topic_words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',topic_lower) if len(w)>2]
|
| 85 |
+
filtered=[]
|
| 86 |
+
for s in items:
|
| 87 |
+
title_lower=s.get('title','').lower()
|
| 88 |
+
# Whole phrase match OR majority of words match
|
| 89 |
+
if topic_lower in title_lower:
|
| 90 |
+
filtered.append(s);continue
|
| 91 |
+
if topic_words:
|
| 92 |
+
match=sum(1 for w in topic_words if w in title_lower)
|
| 93 |
+
if match>=len(topic_words)*0.6:
|
| 94 |
+
filtered.append(s)
|
| 95 |
+
return filtered
|
| 96 |
+
|
| 97 |
+
# Override endpoints
|
| 98 |
+
app.router.routes=[r for r in app.router.routes if not (
|
| 99 |
+
(getattr(r,'path',None)=='/api/hashtag/sources' and 'GET' in getattr(r,'methods',set())) or
|
| 100 |
+
(getattr(r,'path',None)=='/api/article' and 'GET' in getattr(r,'methods',set())) or
|
| 101 |
+
(getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))
|
| 102 |
+
)]
|
| 103 |
+
|
| 104 |
+
@app.get('/api/article')
|
| 105 |
+
def _article_universal(url:str=Query(...)):
|
| 106 |
+
data=_scrape_any_article(url)
|
| 107 |
+
if data and data.get('body'):return JSONResponse(data)
|
| 108 |
+
from main import scrape_vne_article,scrape_bbc_article,scrape_dantri_article,scrape_genk_article,scrape_ttvh_article
|
| 109 |
+
if 'vnexpress.net' in url:d=scrape_vne_article(url)
|
| 110 |
+
elif 'bbc.com' in url:d=scrape_bbc_article(url)
|
| 111 |
+
elif 'dantri.com.vn' in url:d=scrape_dantri_article(url)
|
| 112 |
+
elif 'genk.vn' in url:d=scrape_genk_article(url)
|
| 113 |
+
elif 'thethaovanhoa.vn' in url:d=scrape_ttvh_article(url)
|
| 114 |
+
else:d=None
|
| 115 |
+
if d and d.get('body'):return JSONResponse(d)
|
| 116 |
+
return JSONResponse({'error':'Không đọc được bài viết','url':url})
|
| 117 |
+
|
| 118 |
+
@app.get('/api/hashtag/sources')
|
| 119 |
+
def _hashtag_paged(topic:str=Query(...),page:int=Query(default=0)):
|
| 120 |
+
"""Google News search with pagination. page=0 returns first 6, page=1 returns next 6, etc."""
|
| 121 |
+
all_items=_google_news_search_all(topic,30)
|
| 122 |
+
filtered=_filter_relevant(all_items,topic)
|
| 123 |
+
# If strict filter too harsh, fallback to all
|
| 124 |
+
if len(filtered)<3:filtered=all_items
|
| 125 |
+
per_page=6;start=page*per_page;end=start+per_page
|
| 126 |
+
page_items=filtered[start:end]
|
| 127 |
+
has_more=end<len(filtered)
|
| 128 |
+
return JSONResponse({'sources':page_items,'topic':topic,'page':page,'has_more':has_more,'total':len(filtered)})
|
| 129 |
+
|
| 130 |
+
FAST_HASHTAG_JS = r'''
|
| 131 |
+
<style>
|
| 132 |
+
.hashtag-loading{display:flex;align-items:center;gap:8px;padding:12px;color:#888;font-size:12px}
|
| 133 |
+
.hashtag-spinner{width:16px;height:16px;border:2px solid #333;border-top-color:#5cb87a;border-radius:50%;animation:ht-spin .8s linear infinite}
|
| 134 |
+
@keyframes ht-spin{to{transform:rotate(360deg)}}
|
| 135 |
+
.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}
|
| 136 |
+
</style>
|
| 137 |
+
<script>
|
| 138 |
+
(function(){
|
| 139 |
+
function esc(s){return String(s||'').replace(/[&<>"']/g,function(m){return{'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]});}
|
| 140 |
+
var _htPage=0,_htTopic='',_htImgIdx=0;
|
| 141 |
+
|
| 142 |
+
window.readArticle=async function(url){
|
| 143 |
+
showView('view-article');var el=document.getElementById('view-article');el.innerHTML='<div class="loading">Đang tải...</div>';
|
| 144 |
+
try{var r=await fetch('/api/article?url='+encodeURIComponent(url));var data=await r.json();
|
| 145 |
+
if(data&&!data.error&&data.body&&data.body.length){window._currentArticle={url:url,data:data};var h='<button class="back-btn" onclick="switchCat(\'home\')">← Quay lại</button><div class="article-view"><h1 class="article-title">'+esc(data.title)+'</h1>';if(data.summary)h+='<div class="article-summary">'+esc(data.summary)+'</div>';var seen={};data.body.forEach(function(b){if(b.type==='p')h+='<p class="article-p">'+b.text+'</p>';else if(b.type==='img'&&b.src&&!seen[b.src]){seen[b.src]=1;h+='<img class="article-img" src="'+esc(b.src)+'" onerror="this.style.display=\'none\'">';}else if(b.type==='heading')h+='<h2 class="article-h2">'+esc(b.text)+'</h2>';});h+='<div class="article-actions"><button class="primary" onclick="doRewriteArticle(this)">🤖 Rewrite AI đăng tường</button><button onclick="doShare(\''+esc(data.title)+'\',\''+esc(url)+'\',\''+esc(data.og_image||'')+'\')">📤</button><button onclick="window.open(\''+esc(url)+'\',\'_blank\')">🔗 Gốc</button></div><div class="article-ai-ask"><h3 style="font-size:14px;color:#5cb87a">🤖 Hỏi AI</h3><textarea id="article-ai-question" placeholder="Hỏi..."></textarea><button onclick="askArticleAI()">Hỏi</button><div id="article-ai-answer" class="article-ai-answer"></div></div></div>';el.innerHTML=h;window.scrollTo(0,0);return;}}catch(e){}
|
| 146 |
+
el.innerHTML='<button class="back-btn" onclick="switchCat(\'home\')">← Quay lại</button><div class="loading"><p>Không đọc được.</p><a href="'+esc(url)+'" target="_blank" style="color:#5cb87a">Mở gốc →</a></div>';
|
| 147 |
+
};
|
| 148 |
+
window.doRewriteArticle=async function(btn){var url=(window._currentArticle&&window._currentArticle.url)||'';if(!url){alert('Không có URL');return;}var ctx=document.querySelector('.article-view')?.innerText?.slice(0,14000)||'';btn.disabled=true;btn.textContent='Đang rewrite...';try{var r=await fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:url,context:ctx})});var j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');alert('Đã đăng Tường AI!');}catch(e){alert(e.message);}finally{btn.disabled=false;btn.textContent='🤖 Rewrite AI đăng tường';}};
|
| 149 |
+
window.askArticleAI=async function(){var q=document.getElementById('article-ai-question')?.value.trim();if(!q)return alert('Nhập câu hỏi');var a=document.getElementById('article-ai-answer');a.textContent='Đang hỏi...';var url=(window._currentArticle&&window._currentArticle.url)||'';var ctx=document.querySelector('.article-view')?.innerText?.slice(0,12000)||'';try{var r=await fetch('/api/article/ask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:url,question:q,context:ctx})});var j=await r.json();a.textContent=j.answer||'Không trả lời được';}catch(e){a.textContent='Lỗi: '+e.message}};
|
| 150 |
+
|
| 151 |
+
function renderSources(sources,append){
|
| 152 |
+
var list=document.getElementById('hashtag-src-list');if(!list)return;
|
| 153 |
+
var h='';
|
| 154 |
+
sources.forEach(function(s){
|
| 155 |
+
var idx=_htImgIdx++;
|
| 156 |
+
h+='<div class="hashtag-src-item" onclick="readArticle(\''+esc(s.url||'')+'\')">';
|
| 157 |
+
h+='<div class="hashtag-src-img" id="ht-img-'+idx+'"></div>';
|
| 158 |
+
h+='<div class="hashtag-src-text"><div class="hashtag-src-title">'+esc(s.title)+'</div><div class="hashtag-src-via">'+esc(s.via||'')+(s.pubDate?' · '+esc(s.pubDate.split(',')[0]||''):'')+'</div></div>';
|
| 159 |
+
h+='</div>';
|
| 160 |
+
// Lazy load image
|
| 161 |
+
setTimeout(function(){fetch('/api/article?url='+encodeURIComponent(s.url)).then(function(r){return r.json()}).then(function(d){if(d&&(d.og_image||d.img)){var el=document.getElementById('ht-img-'+idx);if(el)el.innerHTML='<img src="'+esc(d.og_image||d.img)+'" onerror="this.style.display=\'none\'" loading="lazy">';}}).catch(function(){});},idx*500);
|
| 162 |
+
});
|
| 163 |
+
if(append)list.insertAdjacentHTML('beforeend',h);else list.innerHTML=h;
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
window.showHashtagSources=async function(topic){
|
| 167 |
+
_htTopic=topic;_htPage=0;_htImgIdx=0;
|
| 168 |
+
var home=document.getElementById('view-home');if(!home)return;
|
| 169 |
+
document.getElementById('hashtag-sources-box')?.remove();
|
| 170 |
+
var box=document.createElement('div');box.id='hashtag-sources-box';box.className='hashtag-sources';
|
| 171 |
+
box.innerHTML='<h3>🔍 '+esc(topic)+'</h3><div class="hashtag-loading"><div class="hashtag-spinner"></div>Đang tìm bài viết mới nhất...</div>';
|
| 172 |
+
var compose=home.querySelector('.ai-compose');
|
| 173 |
+
if(compose)compose.after(box);else home.prepend(box);
|
| 174 |
+
box.scrollIntoView({behavior:'smooth',block:'start'});
|
| 175 |
+
try{
|
| 176 |
+
var r=await fetch('/api/hashtag/sources?topic='+encodeURIComponent(topic)+'&page=0');
|
| 177 |
+
var j=await r.json();var sources=j.sources||[];
|
| 178 |
+
if(!sources.length){box.innerHTML='<h3>🔍 '+esc(topic)+'</h3><div style="color:#888;font-size:12px;padding:8px">Không tìm được bài viết liên quan</div>';return;}
|
| 179 |
+
var h='<h3>🔍 '+esc(topic)+' <span style="font-size:10px;color:#888">('+j.total+' bài mới nhất từ Google News)</span></h3>';
|
| 180 |
+
h+='<div id="hashtag-src-list"></div>';
|
| 181 |
+
h+='<button class="hashtag-rewrite-btn" onclick="rewriteHashtagTopic(\''+esc(topic)+'\')">🤖 Rewrite AI tổng hợp & đăng tường</button>';
|
| 182 |
+
if(j.has_more)h+='<button class="hashtag-load-more" id="ht-load-more" onclick="loadMoreSources()">Tải thêm bài viết ▼</button>';
|
| 183 |
+
box.innerHTML=h;
|
| 184 |
+
renderSources(sources,false);
|
| 185 |
+
}catch(e){box.innerHTML='<h3>🔍 '+esc(topic)+'</h3><div style="color:#e74c3c;font-size:12px;padding:8px">Lỗi: '+esc(e.message)+'</div>';}
|
| 186 |
+
};
|
| 187 |
+
|
| 188 |
+
window.loadMoreSources=async function(){
|
| 189 |
+
_htPage++;var btn=document.getElementById('ht-load-more');
|
| 190 |
+
if(btn){btn.textContent='Đang tải...';btn.disabled=true;}
|
| 191 |
+
try{
|
| 192 |
+
var r=await fetch('/api/hashtag/sources?topic='+encodeURIComponent(_htTopic)+'&page='+_htPage);
|
| 193 |
+
var j=await r.json();var sources=j.sources||[];
|
| 194 |
+
renderSources(sources,true);
|
| 195 |
+
if(!j.has_more&&btn)btn.remove();
|
| 196 |
+
else if(btn){btn.textContent='Tải thêm bài viết ▼';btn.disabled=false;}
|
| 197 |
+
}catch(e){if(btn){btn.textContent='Lỗi, thử lại';btn.disabled=false;}}
|
| 198 |
+
};
|
| 199 |
+
|
| 200 |
+
window.rewriteHashtagTopic=async function(topic){var btn=event?.target;if(btn){btn.disabled=true;btn.textContent='Đang tổng hợp...';}try{var r=await fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic:topic})});var j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');if(btn)btn.textContent='✅ Đã đăng!';setTimeout(function(){document.getElementById('hashtag-sources-box')?.remove();},2000);}catch(e){if(btn){btn.disabled=false;btn.textContent='❌ '+e.message;}}};
|
| 201 |
+
window.createTopicPost=function(){var inp=document.getElementById('ai-topic-input');var topic=(inp&&inp.value||'').trim();if(!topic){alert('Nhập chủ đề');return;}showHashtagSources(topic);if(inp)inp.value='';};
|
| 202 |
+
window.createTopicPostFinal5=function(){var inp=document.getElementById('ai-topic-input-final5')||document.getElementById('ai-topic-input');var topic=(inp&&inp.value||'').trim();if(!topic){alert('Nhập chủ đề');return;}showHashtagSources(topic);if(inp)inp.value='';};
|
| 203 |
+
})();
|
| 204 |
+
</script>
|
| 205 |
+
'''
|
| 206 |
+
|
| 207 |
+
@app.get('/')
|
| 208 |
+
async def _index_run():
|
| 209 |
+
html=f5.f4.f3.f2.f1._load_index_html()
|
| 210 |
+
body=''
|
| 211 |
+
body+=getattr(rt.old,'PATCH_INJECT','')
|
| 212 |
+
body+=f5.f4.f3.f2.f1.FINAL_INJECT+f5.f4.f3.FINAL3_INJECT+f5.f4.FINAL4_INJECT+f5.FINAL5_INJECT
|
| 213 |
+
body+=getattr(f6,'FINAL6_INJECT','')
|
| 214 |
+
body+=getattr(f6,'FINAL6_FAST_HOME_INJECT','')
|
| 215 |
+
body+=getattr(f6,'FINAL6E_INJECT','')
|
| 216 |
+
body+=PATCH_INJECT
|
| 217 |
+
body+=UNIFIED_INJECT_FIXED
|
| 218 |
+
body+=HIGHLIGHT_FULL_OVERRIDE
|
| 219 |
+
body+=EXTRA_WALL_FIX
|
| 220 |
+
body+=FAST_HASHTAG_JS
|
| 221 |
+
return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
|
app_v2_entry.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
app_v2_entry.py.gitigignore
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.pyc
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
app_v2_entry_hot.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hot topics patch - makes AI topics always visible at top of HOT list."""
|
| 2 |
+
# This file is imported by app_v2_entry.py
|
| 3 |
+
|
| 4 |
+
# AI topics to prepend to hot topics
|
| 5 |
+
AI_HOT_TOPICS = [
|
| 6 |
+
{'label': '#Công nghệ AI', 'topic': 'Công nghệ AI', 'count': 0},
|
| 7 |
+
{'label': '#World Cup 2026', 'topic': 'World Cup 2026', 'count': 0},
|
| 8 |
+
{'label': '#Kinh tế Việt Nam', 'topic': 'Kinh tế Việt Nam', 'count': 0},
|
| 9 |
+
{'label': '#Bóng đá châu Âu', 'topic': 'Bóng đá châu Âu', 'count': 0},
|
| 10 |
+
{'label': '#Giá vàng', 'topic': 'Giá vàng', 'count': 0},
|
| 11 |
+
{'label': '#Thời tiết', 'topic': 'Thời tiết', 'count': 0},
|
| 12 |
+
]
|
| 13 |
+
|
| 14 |
+
def prepend_ai_hot_topics(topics):
|
| 15 |
+
"""Prepend AI topics to hot topics list, ensuring they're always visible."""
|
| 16 |
+
if not topics:
|
| 17 |
+
return AI_HOT_TOPICS[:]
|
| 18 |
+
# Remove duplicates that already exist
|
| 19 |
+
existing_topics = [t.get('topic', '').lower() for t in topics]
|
| 20 |
+
result = []
|
| 21 |
+
for ai_topic in AI_HOT_TOPICS:
|
| 22 |
+
if ai_topic.get('topic', '').lower() not in existing_topics:
|
| 23 |
+
result.append(ai_topic)
|
| 24 |
+
return result + topics
|
app_v2_entry_test.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
VNEWS App v2 - Main application with match detail API
|
| 3 |
+
"""
|
| 4 |
+
import os, json, re, time, asyncio, hashlib, logging, threading, importlib, sys
|
| 5 |
+
from datetime import datetime, timezone, timedelta
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Optional
|
| 8 |
+
|
| 9 |
+
import httpx
|
| 10 |
+
import requests
|
| 11 |
+
from fastapi import FastAPI, HTTPException, Query
|
| 12 |
+
from fastapi.responses import JSONResponse, FileResponse, HTMLResponse
|
| 13 |
+
from fastapi.staticfiles import StaticFiles
|
| 14 |
+
from fastapi.templating import Jinja2Templates
|
| 15 |
+
|
| 16 |
+
# ... (rest of app_v2_entry.py content)
|
app_v2_entry_v2.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
VNEWS App v2 - Main application with match detail API
|
| 3 |
+
"""
|
| 4 |
+
import os, json, re, time, asyncio, hashlib, logging, threading, importlib
|
| 5 |
+
from datetime import datetime, timezone, timedelta
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Optional
|
| 8 |
+
|
| 9 |
+
import httpx
|
| 10 |
+
import requests
|
| 11 |
+
from fastapi import FastAPI, HTTPException, Query
|
| 12 |
+
from fastapi.responses import JSONResponse, FileResponse, HTMLResponse
|
| 13 |
+
from fastapi.staticfiles import StaticFiles
|
| 14 |
+
from fastapi.templating import Jinja2Templates
|
| 15 |
+
|
| 16 |
+
# ... (rest of app_v2_entry.py content)
|
app_v2_patch.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""VNEWS v2 Patch - auto scheduler + status endpoints + keep-alive.
|
| 2 |
+
This is imported by app_v2_entry.py to add auto posting functionality.
|
| 3 |
+
FIX v2: Catch-up scheduler + keep-alive to prevent Space sleep
|
| 4 |
+
"""
|
| 5 |
+
import sys, os, threading, json, time, logging
|
| 6 |
+
from datetime import datetime, timezone, timedelta
|
| 7 |
+
from fastapi import Request
|
| 8 |
+
from fastapi.responses import JSONResponse
|
| 9 |
+
import requests as _req
|
| 10 |
+
|
| 11 |
+
VN_TZ = timezone(timedelta(hours=7))
|
| 12 |
+
LOG = logging.getLogger("app_v2_patch")
|
| 13 |
+
LOG.setLevel(logging.INFO)
|
| 14 |
+
if not LOG.handlers:
|
| 15 |
+
ch = logging.StreamHandler()
|
| 16 |
+
ch.setFormatter(logging.Formatter('%(asctime)s [app_v2_patch] %(levelname)s: %(message)s'))
|
| 17 |
+
LOG.addHandler(ch)
|
| 18 |
+
|
| 19 |
+
# ===== Keep-alive: prevent Space from sleeping =====
|
| 20 |
+
# HF Spaces sleep after ~30 min of inactivity on free tier
|
| 21 |
+
# This thread pings the Space every 10 minutes to keep it alive
|
| 22 |
+
SPACE_URL = "https://bep40-vnews.hf.space"
|
| 23 |
+
|
| 24 |
+
def _keep_alive_loop():
|
| 25 |
+
"""Ping the Space every 10 minutes to prevent sleep."""
|
| 26 |
+
LOG.info(f"🔄 Keep-alive thread started - ping {SPACE_URL} every 10 min")
|
| 27 |
+
while True:
|
| 28 |
+
try:
|
| 29 |
+
time.sleep(600) # 10 minutes
|
| 30 |
+
_req.get(f"{SPACE_URL}/api/scheduler/status",
|
| 31 |
+
headers={"User-Agent": "VNEWS-KeepAlive/1.0"},
|
| 32 |
+
timeout=15)
|
| 33 |
+
LOG.debug("Keep-alive ping OK")
|
| 34 |
+
except Exception as e:
|
| 35 |
+
LOG.warning(f"Keep-alive ping failed (Space may be sleeping): {e}")
|
| 36 |
+
|
| 37 |
+
# Start keep-alive in background
|
| 38 |
+
try:
|
| 39 |
+
_ka_thread = threading.Thread(target=_keep_alive_loop, daemon=True, name="keep-alive")
|
| 40 |
+
_ka_thread.start()
|
| 41 |
+
LOG.info("🔄 Keep-alive started - Space will stay awake")
|
| 42 |
+
except Exception as e:
|
| 43 |
+
LOG.warning(f"Keep-alive start failed: {e}")
|
| 44 |
+
|
| 45 |
+
# ===== Start auto scheduler =====
|
| 46 |
+
try:
|
| 47 |
+
import auto_scheduler as _as
|
| 48 |
+
_as.start_auto_scheduler()
|
| 49 |
+
LOG.info("[auto_scheduler] Started successfully - will post at 7:00, 13:00, 19:00 VN time (with catch-up)")
|
| 50 |
+
except Exception as e:
|
| 51 |
+
LOG.error(f"[auto_scheduler] Start failed: {e}")
|
| 52 |
+
|
| 53 |
+
def register_scheduler_endpoints(app):
|
| 54 |
+
"""Register scheduler status/trigger endpoints on the FastAPI app."""
|
| 55 |
+
|
| 56 |
+
@app.get('/api/scheduler/status')
|
| 57 |
+
def scheduler_status():
|
| 58 |
+
running = any(t.name == 'auto-scheduler' and t.is_alive() for t in threading.enumerate())
|
| 59 |
+
keep_alive = any(t.name == 'keep-alive' and t.is_alive() for t in threading.enumerate())
|
| 60 |
+
|
| 61 |
+
# Load state to show which slots ran today
|
| 62 |
+
today_str = datetime.now(VN_TZ).strftime('%Y-%m-%d')
|
| 63 |
+
state = {}
|
| 64 |
+
try:
|
| 65 |
+
state_file = '/data/scheduler_state.json' if os.path.isdir('/data') else None
|
| 66 |
+
if state_file and os.path.exists(state_file):
|
| 67 |
+
state = json.load(open(state_file, 'r'))
|
| 68 |
+
except:
|
| 69 |
+
pass
|
| 70 |
+
|
| 71 |
+
ran_today = state.get(today_str, {}) if state else {}
|
| 72 |
+
|
| 73 |
+
return JSONResponse({
|
| 74 |
+
"running": running,
|
| 75 |
+
"keep_alive": keep_alive,
|
| 76 |
+
"schedule": "7:00, 13:00, 19:00 VN time",
|
| 77 |
+
"today": today_str,
|
| 78 |
+
"slots_ran_today": ran_today,
|
| 79 |
+
"catch_up_enabled": True,
|
| 80 |
+
"next_run": "7:00, 13:00, or 19:00 VN time (whichever is next)"
|
| 81 |
+
})
|
| 82 |
+
|
| 83 |
+
@app.post('/api/scheduler/trigger')
|
| 84 |
+
async def scheduler_trigger():
|
| 85 |
+
try:
|
| 86 |
+
import auto_scheduler as _as2
|
| 87 |
+
_as2._run_scheduled_posting()
|
| 88 |
+
return JSONResponse({"ok": True, "message": "Scheduled posting triggered manually"})
|
| 89 |
+
except Exception as e:
|
| 90 |
+
return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
|
| 91 |
+
|
| 92 |
+
@app.get('/api/scheduler/force')
|
| 93 |
+
def scheduler_force():
|
| 94 |
+
"""Force-run all missed slots immediately. Useful after deploy."""
|
| 95 |
+
try:
|
| 96 |
+
import auto_scheduler as _as2
|
| 97 |
+
_as2._check_missed_slots()
|
| 98 |
+
return JSONResponse({"ok": True, "message": "Missed slots check triggered"})
|
| 99 |
+
except Exception as e:
|
| 100 |
+
return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
|
| 101 |
+
|
| 102 |
+
return app
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
# Auto-register on the main app from app_v2_entry
|
| 106 |
+
try:
|
| 107 |
+
from main import app
|
| 108 |
+
register_scheduler_endpoints(app)
|
| 109 |
+
LOG.info("[app_v2_patch] Scheduler endpoints registered: /api/scheduler/status, /api/scheduler/trigger, /api/scheduler/force")
|
| 110 |
+
except Exception as e:
|
| 111 |
+
LOG.error(f"[app_v2_patch] Could not register endpoints: {e}")
|
auto_scheduler.py
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""VNEWS Auto Scheduler - tự động đăng 3 bài rewrite AI + shorts từ 3 chủ đề HOT
|
| 2 |
+
Vào các khung giờ: 7:00, 13:00, 19:00 (giờ Việt Nam)
|
| 3 |
+
Mỗi bài: Rewrite AI từ nguồn báo + short video tự động
|
| 4 |
+
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
|
| 5 |
+
"""
|
| 6 |
+
import os, re, json, time, threading, asyncio, logging, random, hashlib, html as html_lib
|
| 7 |
+
from datetime import datetime, timezone, timedelta, date
|
| 8 |
+
from urllib.parse import quote
|
| 9 |
+
import requests
|
| 10 |
+
from bs4 import BeautifulSoup
|
| 11 |
+
|
| 12 |
+
# Import storage for persistent data
|
| 13 |
+
from storage import load_wall_posts, save_wall_posts, DATA_DIR
|
| 14 |
+
|
| 15 |
+
VN_TZ = timezone(timedelta(hours=7))
|
| 16 |
+
LOG = logging.getLogger("auto_scheduler")
|
| 17 |
+
LOG.setLevel(logging.INFO)
|
| 18 |
+
if not LOG.handlers:
|
| 19 |
+
ch = logging.StreamHandler()
|
| 20 |
+
ch.setFormatter(logging.Formatter('%(asctime)s [%(name)s] %(levelname)s: %(message)s'))
|
| 21 |
+
LOG.addHandler(ch)
|
| 22 |
+
|
| 23 |
+
SCHEDULE_TIMES = [(7, 0), (13, 0), (19, 0)]
|
| 24 |
+
SCHEDULE_LABELS = {t: f"{t[0]:02d}:{t[1]:02d}" for t in SCHEDULE_TIMES}
|
| 25 |
+
|
| 26 |
+
os.makedirs(DATA_DIR, exist_ok=True)
|
| 27 |
+
SCHEDULE_STATE_FILE = os.path.join(DATA_DIR, 'scheduler_state.json')
|
| 28 |
+
|
| 29 |
+
def _load_state():
|
| 30 |
+
try:
|
| 31 |
+
if os.path.exists(SCHEDULE_STATE_FILE):
|
| 32 |
+
with open(SCHEDULE_STATE_FILE, 'r') as f: return json.load(f)
|
| 33 |
+
except: pass
|
| 34 |
+
return {}
|
| 35 |
+
|
| 36 |
+
def _save_state(state):
|
| 37 |
+
try:
|
| 38 |
+
tmp = SCHEDULE_STATE_FILE + '.tmp'
|
| 39 |
+
with open(tmp, 'w') as f: json.dump(state, f, ensure_ascii=False)
|
| 40 |
+
os.replace(tmp, SCHEDULE_STATE_FILE)
|
| 41 |
+
except Exception as e: LOG.warning(f"Cannot save state: {e}")
|
| 42 |
+
|
| 43 |
+
_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())
|
| 44 |
+
|
| 45 |
+
def _clean(s):
|
| 46 |
+
s = html_lib.unescape(s or "")
|
| 47 |
+
# FIX: Remove malformed HTML artifacts (truncated tags without closing >)
|
| 48 |
+
s = s.replace('<a href=" src="', '').replace("<a href=' src='", '')
|
| 49 |
+
s = s.replace('<a href=" src=', '').replace("<a href=' src=", '')
|
| 50 |
+
s = re.sub(r'<[^>]+>', '', s) # Remove all HTML tags
|
| 51 |
+
return re.sub(r"\s+", " ", s).strip()
|
| 52 |
+
|
| 53 |
+
def _get_hot_topics():
|
| 54 |
+
freq = {}; display = {}
|
| 55 |
+
feeds = [
|
| 56 |
+
'https://vnexpress.net/rss/tin-moi-nhat.rss',
|
| 57 |
+
'https://dantri.com.vn/rss/home.rss',
|
| 58 |
+
'https://vietnamnet.vn/rss/tin-moi-nhat.rss',
|
| 59 |
+
'https://thanhnien.vn/rss/home.rss',
|
| 60 |
+
'https://tuoitre.vn/rss/tin-moi-nhat.rss',
|
| 61 |
+
'https://genk.vn/rss',
|
| 62 |
+
'https://vnexpress.net/rss/the-thao.rss',
|
| 63 |
+
'https://thethaovanhoa.vn/rss/tin-nong.rss',
|
| 64 |
+
'https://vnexpress.net/rss/kinh-doanh.rss',
|
| 65 |
+
'https://dantri.com.vn/rss/the-gioi.rss',
|
| 66 |
+
]
|
| 67 |
+
for feed_url in feeds:
|
| 68 |
+
try:
|
| 69 |
+
r = requests.get(feed_url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=6)
|
| 70 |
+
r.encoding = 'utf-8'
|
| 71 |
+
soup = BeautifulSoup(r.text, 'xml')
|
| 72 |
+
for item in soup.find_all('item')[:12]:
|
| 73 |
+
title = _clean(item.find('title').get_text() if item.find('title') else '')
|
| 74 |
+
if not title: continue
|
| 75 |
+
title = re.sub(r'\s*[-|].*$', '', title)
|
| 76 |
+
words = [w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+', title) if len(w) > 2 and w.lower() not in _STOP]
|
| 77 |
+
if len(words) < 2: continue
|
| 78 |
+
for n in (3, 4, 2):
|
| 79 |
+
for i in range(max(0, len(words) - n + 1)):
|
| 80 |
+
phrase = ' '.join(words[i:i + n])
|
| 81 |
+
if 8 <= len(phrase) <= 45:
|
| 82 |
+
key = phrase.lower()
|
| 83 |
+
freq[key] = freq.get(key, 0) + 1
|
| 84 |
+
display[key] = phrase
|
| 85 |
+
except: continue
|
| 86 |
+
ranked = sorted(freq.items(), key=lambda x: x[1], reverse=True)
|
| 87 |
+
topics = []; seen = set()
|
| 88 |
+
for key, count in ranked:
|
| 89 |
+
kw = display[key]
|
| 90 |
+
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)
|
| 91 |
+
if is_dup: continue
|
| 92 |
+
seen.add(key)
|
| 93 |
+
topics.append({'label': '#' + re.sub(r'\s+', '', kw.title()), 'topic': kw, 'count': count})
|
| 94 |
+
if len(topics) >= 20: break
|
| 95 |
+
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']:
|
| 96 |
+
if len(topics) >= 24: break
|
| 97 |
+
if not any(kw.lower() in s for s in seen):
|
| 98 |
+
topics.append({'label': '#' + re.sub(r'\s+', '', kw.title()), 'topic': kw, 'count': 0})
|
| 99 |
+
return topics[:24]
|
| 100 |
+
|
| 101 |
+
_ai_ext = None; _ai_patch = None
|
| 102 |
+
def _get_ai_ext():
|
| 103 |
+
global _ai_ext
|
| 104 |
+
if _ai_ext is None: import ai_ext as m; _ai_ext = m
|
| 105 |
+
return _ai_ext
|
| 106 |
+
def _get_ai_patch():
|
| 107 |
+
global _ai_patch
|
| 108 |
+
if _ai_patch is None: import ai_patch as m; _ai_patch = m
|
| 109 |
+
return _ai_patch
|
| 110 |
+
|
| 111 |
+
_RSS_FEEDS = [
|
| 112 |
+
('https://vnexpress.net/rss/tin-moi-nhat.rss', 'VnExpress'),
|
| 113 |
+
('https://dantri.com.vn/rss/home.rss', 'Dân Trí'),
|
| 114 |
+
('https://vietnamnet.vn/rss/tin-moi-nhat.rss', 'VietNamNet'),
|
| 115 |
+
('https://thanhnien.vn/rss/home.rss', 'Thanh Niên'),
|
| 116 |
+
('https://tuoitre.vn/rss/tin-moi-nhat.rss', 'Tuổi Trẻ'),
|
| 117 |
+
('https://genk.vn/rss', 'GenK'),
|
| 118 |
+
('https://vnexpress.net/rss/the-thao.rss', 'VnExpress'),
|
| 119 |
+
('https://thethaovanhoa.vn/rss/tin-nong.rss', 'TT&VH'),
|
| 120 |
+
('https://vnexpress.net/rss/kinh-doanh.rss', 'VnExpress'),
|
| 121 |
+
('https://dantri.com.vn/rss/the-gioi.rss', 'Dân Trí'),
|
| 122 |
+
]
|
| 123 |
+
|
| 124 |
+
def _search_articles_by_topic(topic, limit=4):
|
| 125 |
+
all_articles = []; seen_urls = set()
|
| 126 |
+
topic_lower = topic.lower()
|
| 127 |
+
topic_words = set(re.findall(r'[A-Za-zÀ-ỹ0-9]+', topic_lower))
|
| 128 |
+
for feed_url, source in _RSS_FEEDS:
|
| 129 |
+
try:
|
| 130 |
+
r = requests.get(feed_url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=6)
|
| 131 |
+
r.encoding = 'utf-8'
|
| 132 |
+
soup = BeautifulSoup(r.text, 'xml')
|
| 133 |
+
for item in soup.find_all('item')[:8]:
|
| 134 |
+
title = _clean(item.find('title').get_text() if item.find('title') else '')
|
| 135 |
+
link = _clean(item.find('link').get_text() if item.find('link') else '')
|
| 136 |
+
desc = _clean(item.find('description').get_text() if item.find('description') else '')
|
| 137 |
+
if not title or not link or link in seen_urls: continue
|
| 138 |
+
seen_urls.add(link)
|
| 139 |
+
title_words = set(re.findall(r'[A-Za-zÀ-ỹ0-9]+', title.lower()))
|
| 140 |
+
overlap = len(topic_words & title_words) if topic_words else 0
|
| 141 |
+
exact_match = topic_lower in title.lower() or topic_lower in desc.lower()
|
| 142 |
+
if exact_match or overlap >= 2:
|
| 143 |
+
img = ''
|
| 144 |
+
encl = item.find('enclosure')
|
| 145 |
+
if encl: img = encl.get('url', '')
|
| 146 |
+
if not img:
|
| 147 |
+
try:
|
| 148 |
+
art_r = requests.get(link, headers={'User-Agent': 'Mozilla/5.0'}, timeout=4)
|
| 149 |
+
art_r.encoding = 'utf-8'
|
| 150 |
+
art_soup = BeautifulSoup(art_r.text, 'lxml')
|
| 151 |
+
ogi = art_soup.find('meta', property='og:image')
|
| 152 |
+
if ogi: img = ogi.get('content', '')
|
| 153 |
+
except: pass
|
| 154 |
+
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}})
|
| 155 |
+
if len(all_articles) >= limit: break
|
| 156 |
+
except: continue
|
| 157 |
+
return all_articles[:limit]
|
| 158 |
+
|
| 159 |
+
async def _create_ai_post(topic):
|
| 160 |
+
ai_ext = _get_ai_ext(); ai_patch = _get_ai_patch()
|
| 161 |
+
articles = _search_articles_by_topic(topic, limit=4)
|
| 162 |
+
if not articles:
|
| 163 |
+
LOG.warning(f"No articles for topic: {topic}. Fallback.")
|
| 164 |
+
return await _create_fallback_post(topic, ai_ext, ai_patch)
|
| 165 |
+
posts = []
|
| 166 |
+
# Get schedule time label for text intro (7h sáng, 13h trưa, 19h tối)
|
| 167 |
+
now = datetime.now(VN_TZ)
|
| 168 |
+
hour = now.hour
|
| 169 |
+
time_label = "7h sáng" if hour == 7 else ("13h trưa" if hour == 13 else "19h tối")
|
| 170 |
+
text_intro = f"Tin tóm tắt VNEWS {time_label}"
|
| 171 |
+
wall = ai_ext._load_ai_wall()
|
| 172 |
+
if not isinstance(wall, list): wall = []
|
| 173 |
+
for art in articles:
|
| 174 |
+
try:
|
| 175 |
+
prompt = ai_patch._make_summary_prompt(art.get('title', topic), art.get('raw', ''), art.get('via', ''))
|
| 176 |
+
text = await ai_ext.qwen_generate(prompt, image_url=art.get('image'), max_tokens=1500)
|
| 177 |
+
text = ai_patch._postprocess_ai_text(text, max_units=20)
|
| 178 |
+
src = [art.get('source', {'title': art.get('title', topic), 'url': art.get('url', ''), 'via': art.get('via', '')})]
|
| 179 |
+
# Prepend time label intro to text (giữ nguyên title là tiêu đề gốc của bài báo)
|
| 180 |
+
if text and not text.startswith(text_intro):
|
| 181 |
+
text = f"{text_intro}\n\n{text}"
|
| 182 |
+
if 'Nguồn tham khảo:' not in (text or ''):
|
| 183 |
+
text = (text or '') + "\n\n" + ai_patch._source_line(src)
|
| 184 |
+
img = art.get('image') or ai_ext.pollination_image_url(art.get('title', topic))
|
| 185 |
+
# Dùng art.get('title') GIỮ NGUYÊN tiêu đề gốc từ bài báo
|
| 186 |
+
post = ai_ext.make_post(art.get('title', topic), text, img, art.get('url', ''), 'auto_scheduled', sources=src)
|
| 187 |
+
try:
|
| 188 |
+
page_data = ai_patch._scrape_article_images(art.get('url', ''))
|
| 189 |
+
if page_data and page_data.get('paragraphs'):
|
| 190 |
+
kp = ai_patch._extract_key_points_for_slides(page_data['paragraphs'], max_points=8)
|
| 191 |
+
if kp:
|
| 192 |
+
imgs = page_data.get('images', [])
|
| 193 |
+
if not imgs and page_data.get('og_img'): imgs = [page_data['og_img']]
|
| 194 |
+
slides = []
|
| 195 |
+
for i, pt in enumerate(kp):
|
| 196 |
+
slides.append({'text': pt, 'image': imgs[i] if i < len(imgs) else (imgs[-1] if imgs else ''), 'index': i + 1})
|
| 197 |
+
post['slides'] = slides
|
| 198 |
+
except: pass
|
| 199 |
+
posts.append(post)
|
| 200 |
+
except Exception as e:
|
| 201 |
+
LOG.error(f"Error post: {e}")
|
| 202 |
+
if not posts: return await _create_fallback_post(topic, ai_ext, ai_patch)
|
| 203 |
+
wall = posts + wall
|
| 204 |
+
ai_ext._save_ai_wall(wall)
|
| 205 |
+
for post in posts:
|
| 206 |
+
try: _try_generate_short(post)
|
| 207 |
+
except: pass
|
| 208 |
+
return posts
|
| 209 |
+
|
| 210 |
+
async def _create_fallback_post(topic, ai_ext, ai_patch):
|
| 211 |
+
LOG.info(f"Fallback: {topic}")
|
| 212 |
+
try:
|
| 213 |
+
# Still add time label to fallback posts
|
| 214 |
+
now = datetime.now(VN_TZ)
|
| 215 |
+
hour = now.hour
|
| 216 |
+
time_label = "7h sáng" if hour == 7 else ("13h trưa" if hour == 13 else "19h tối")
|
| 217 |
+
text_intro = f"Tin tóm tắt VNEWS {time_label}"
|
| 218 |
+
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."
|
| 219 |
+
img = ai_ext.pollination_image_url(topic)
|
| 220 |
+
post = ai_ext.make_post(topic, text, img, '', 'auto_scheduled', sources=[])
|
| 221 |
+
wall = ai_ext._load_ai_wall()
|
| 222 |
+
if not isinstance(wall, list): wall = []
|
| 223 |
+
wall = [post] + wall
|
| 224 |
+
ai_ext._save_ai_wall(wall)
|
| 225 |
+
LOG.info(f"Fallback saved: {topic}")
|
| 226 |
+
return [post]
|
| 227 |
+
except Exception as e:
|
| 228 |
+
LOG.error(f"Fallback failed: {e}")
|
| 229 |
+
return []
|
| 230 |
+
|
| 231 |
+
def _try_generate_short(post):
|
| 232 |
+
post_id = post.get('id', '')
|
| 233 |
+
if not post_id: return
|
| 234 |
+
try:
|
| 235 |
+
ai_ext = _get_ai_ext(); ai_patch = _get_ai_patch()
|
| 236 |
+
if ai_ext.gTTS is None: return
|
| 237 |
+
segments = ai_patch._summary_segments_from_post(post, max_segments=15)
|
| 238 |
+
if not segments: return
|
| 239 |
+
seg_hash = hashlib.md5(('|'.join(segments) + 'nu' + 'neutral' + '1.0').encode('utf-8')).hexdigest()[:8]
|
| 240 |
+
suffix = f"_nu_neutral_1p0_{seg_hash}_scenes_nosub"
|
| 241 |
+
out_mp4 = os.path.join(ai_ext.SHORTS_DIR, ai_ext._safe_name(post_id + suffix) + '.mp4')
|
| 242 |
+
if os.path.exists(out_mp4):
|
| 243 |
+
post['video'] = '/api/ai/short-file/' + post_id + suffix
|
| 244 |
+
wall = ai_ext._load_ai_wall()
|
| 245 |
+
for i, p in enumerate(wall):
|
| 246 |
+
if p.get('id') == post_id: wall[i] = post; break
|
| 247 |
+
ai_ext._save_ai_wall(wall); return
|
| 248 |
+
threading.Thread(target=lambda: _generate_short_worker(post, segments, post_id, suffix, out_mp4), daemon=True).start()
|
| 249 |
+
except Exception as e: LOG.warning(f"Short init: {e}")
|
| 250 |
+
|
| 251 |
+
def _generate_short_worker(post, segments, post_id, suffix, out_mp4):
|
| 252 |
+
import subprocess
|
| 253 |
+
try:
|
| 254 |
+
ai_ext = _get_ai_ext(); ai_patch = _get_ai_patch()
|
| 255 |
+
work = os.path.join(ai_ext.SHORTS_DIR, ai_ext._safe_name(post_id + suffix))
|
| 256 |
+
os.makedirs(work, exist_ok=True)
|
| 257 |
+
img = os.path.join(work, 'image.jpg')
|
| 258 |
+
ai_ext._download_image(post.get('img'), post.get('title', 'AI news'), img)
|
| 259 |
+
part_files = []
|
| 260 |
+
for idx, seg in enumerate(segments[:10]):
|
| 261 |
+
frame = os.path.join(work, f'frame_{idx:02d}.jpg')
|
| 262 |
+
aud = os.path.join(work, f'voice_{idx:02d}.mp3')
|
| 263 |
+
aud_fast = os.path.join(work, f'voice_{idx:02d}_fast.mp3')
|
| 264 |
+
part = os.path.join(work, f'part_{idx:02d}.mp4')
|
| 265 |
+
try: ai_patch._make_scene_frame(post, seg, idx, min(len(segments), 10), img, frame, emotion='neutral')
|
| 266 |
+
except:
|
| 267 |
+
if not os.path.exists(img): continue
|
| 268 |
+
from PIL import Image
|
| 269 |
+
Image.new('RGB', (1080, 1920), (14, 14, 14)).save(frame, quality=85)
|
| 270 |
+
tts_text = re.sub(r'^[•\-\*\d\.\)\s]+', '', seg).strip()
|
| 271 |
+
try: ai_ext.gTTS(tts_text, lang='vi', slow=False).save(aud)
|
| 272 |
+
except:
|
| 273 |
+
try: ai_ext.gTTS(tts_text, lang='vi', tld='com.vn', slow=False).save(aud)
|
| 274 |
+
except: continue
|
| 275 |
+
subprocess.run(['ffmpeg', '-y', '-i', aud, '-filter:a', 'atempo=1.0', '-vn', aud_fast], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=90)
|
| 276 |
+
dur = 12.0
|
| 277 |
+
try:
|
| 278 |
+
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)
|
| 279 |
+
dur = max(8.0, float((pr.stdout or b'').decode().strip() or 12.0)) + 0.5
|
| 280 |
+
except: pass
|
| 281 |
+
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)
|
| 282 |
+
part_files.append(part)
|
| 283 |
+
if part_files:
|
| 284 |
+
concat = os.path.join(work, 'concat.txt')
|
| 285 |
+
with open(concat, 'w', encoding='utf-8') as f:
|
| 286 |
+
for p in part_files: f.write("file '" + p.replace("'", "'\\''") + "'\n")
|
| 287 |
+
subprocess.run(['ffmpeg', '-y', '-f', 'concat', '-safe', '0', '-i', concat, '-c', 'copy', out_mp4], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180)
|
| 288 |
+
post['video'] = '/api/ai/short-file/' + post_id + suffix
|
| 289 |
+
post['short_voice'] = 'nu'; post['short_emotion'] = 'neutral'; post['short_speed'] = 1.0
|
| 290 |
+
post['short_segments'] = segments; post['short_subtitles'] = False
|
| 291 |
+
wall = ai_ext._load_ai_wall()
|
| 292 |
+
for i, p in enumerate(wall):
|
| 293 |
+
if p.get('id') == post_id: wall[i] = post; break
|
| 294 |
+
ai_ext._save_ai_wall(wall)
|
| 295 |
+
LOG.info(f"Short: {post_id}")
|
| 296 |
+
except Exception as e: LOG.warning(f"Short fail: {e}")
|
| 297 |
+
|
| 298 |
+
def _run_async(coro):
|
| 299 |
+
"""Run async coroutine safely regardless of current event loop state."""
|
| 300 |
+
try:
|
| 301 |
+
loop = asyncio.get_running_loop()
|
| 302 |
+
except RuntimeError:
|
| 303 |
+
return asyncio.run(coro)
|
| 304 |
+
import concurrent.futures
|
| 305 |
+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
| 306 |
+
return pool.submit(asyncio.run, coro).result(timeout=300)
|
| 307 |
+
|
| 308 |
+
def _run_scheduled_posting():
|
| 309 |
+
LOG.info("=" * 50)
|
| 310 |
+
LOG.info("Scheduler triggered at %s", datetime.now(VN_TZ).strftime('%H:%M %d/%m/%Y'))
|
| 311 |
+
LOG.info("=" * 50)
|
| 312 |
+
try:
|
| 313 |
+
hot_topics = _get_hot_topics()
|
| 314 |
+
if not hot_topics:
|
| 315 |
+
LOG.warning("No hot topics"); return
|
| 316 |
+
selected = []; seen_labels = set()
|
| 317 |
+
for t in hot_topics:
|
| 318 |
+
label = t.get('label', '')
|
| 319 |
+
if label and label not in seen_labels:
|
| 320 |
+
seen_labels.add(label); selected.append(t['topic'])
|
| 321 |
+
if len(selected) >= 3: break
|
| 322 |
+
if len(selected) < 3:
|
| 323 |
+
selected = ['Thời sự Việt Nam', 'Kinh tế Việt Nam', 'Thể thao']
|
| 324 |
+
LOG.info(f"Topics: {selected}")
|
| 325 |
+
async def _do_all():
|
| 326 |
+
results = []
|
| 327 |
+
for topic in selected:
|
| 328 |
+
try:
|
| 329 |
+
posts = await _create_ai_post(topic)
|
| 330 |
+
results.append({'topic': topic, 'posts': len(posts) if posts else 0})
|
| 331 |
+
LOG.info(f"{'✓' if posts else '✗'} {topic}: {len(posts) if posts else 0} posts")
|
| 332 |
+
except Exception as e:
|
| 333 |
+
LOG.error(f"Error {topic}: {e}")
|
| 334 |
+
results.append({'topic': topic, 'posts': 0})
|
| 335 |
+
return results
|
| 336 |
+
results = _run_async(_do_all())
|
| 337 |
+
LOG.info(f"Done: {len(results)} topics")
|
| 338 |
+
for r in results: LOG.info(f" • {r['topic']}: {r['posts']} bài")
|
| 339 |
+
except Exception as e:
|
| 340 |
+
LOG.error(f"Scheduler error: {e}", exc_info=True)
|
| 341 |
+
|
| 342 |
+
def _check_missed_slots():
|
| 343 |
+
try:
|
| 344 |
+
state = _load_state()
|
| 345 |
+
today_str = datetime.now(VN_TZ).strftime('%Y-%m-%d')
|
| 346 |
+
now = datetime.now(VN_TZ); cur_mins = now.hour * 60 + now.minute
|
| 347 |
+
ran = state.get(today_str, {})
|
| 348 |
+
for s in SCHEDULE_TIMES:
|
| 349 |
+
lbl = SCHEDULE_LABELS[s]; sm = s[0] * 60 + s[1]
|
| 350 |
+
if ran.get(lbl): continue
|
| 351 |
+
if cur_mins >= sm:
|
| 352 |
+
LOG.info(f"Catch-up: {lbl}")
|
| 353 |
+
_run_scheduled_posting()
|
| 354 |
+
if today_str not in state: state[today_str] = {}
|
| 355 |
+
state[today_str][lbl] = True; _save_state(state)
|
| 356 |
+
except Exception as e: LOG.error(f"Catch-up: {e}")
|
| 357 |
+
|
| 358 |
+
def _scheduler_loop():
|
| 359 |
+
LOG.info("Scheduler started")
|
| 360 |
+
LOG.info(f"Schedule: {', '.join(f'{h:02d}:{m:02d}' for h,m in SCHEDULE_TIMES)} VN")
|
| 361 |
+
state = _load_state(); today_str = datetime.now(VN_TZ).strftime('%Y-%m-%d')
|
| 362 |
+
ran = state.get(today_str, {})
|
| 363 |
+
now = datetime.now(VN_TZ); cur_mins = now.hour * 60 + now.minute
|
| 364 |
+
for s in SCHEDULE_TIMES:
|
| 365 |
+
lbl = SCHEDULE_LABELS[s]; sm = s[0] * 60 + s[1]
|
| 366 |
+
if ran.get(lbl): LOG.info(f" ✓ {lbl} done"); continue
|
| 367 |
+
if cur_mins >= sm:
|
| 368 |
+
LOG.info(f" → {lbl} missed! Catch-up")
|
| 369 |
+
_run_scheduled_posting()
|
| 370 |
+
if today_str not in state: state[today_str] = {}
|
| 371 |
+
state[today_str][lbl] = True; _save_state(state)
|
| 372 |
+
else: LOG.info(f" ⏩ {lbl} upcoming")
|
| 373 |
+
while True:
|
| 374 |
+
try:
|
| 375 |
+
now = datetime.now(VN_TZ)
|
| 376 |
+
ck = (now.hour, now.minute)
|
| 377 |
+
state = _load_state(); today_str = now.strftime('%Y-%m-%d')
|
| 378 |
+
ran = state.get(today_str, {})
|
| 379 |
+
for s in SCHEDULE_TIMES:
|
| 380 |
+
lbl = SCHEDULE_LABELS[s]
|
| 381 |
+
if ck == s and not ran.get(lbl):
|
| 382 |
+
LOG.info(f"On-time: {lbl}")
|
| 383 |
+
_run_scheduled_posting()
|
| 384 |
+
if today_str not in state: state[today_str] = {}
|
| 385 |
+
state[today_str][lbl] = True; _save_state(state)
|
| 386 |
+
break
|
| 387 |
+
time.sleep(60)
|
| 388 |
+
except Exception as e:
|
| 389 |
+
LOG.error(f"Loop: {e}")
|
| 390 |
+
time.sleep(60)
|
| 391 |
+
|
| 392 |
+
def start_auto_scheduler():
|
| 393 |
+
t = threading.Thread(target=_scheduler_loop, daemon=True, name="auto-scheduler")
|
| 394 |
+
t.start()
|
| 395 |
+
LOG.info("Auto scheduler started")
|
| 396 |
+
return t
|
auto_update_sse.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Auto-update SSE endpoint for VNEWS - pushes updates when new posts/shorts published."""
|
| 2 |
+
import asyncio
|
| 3 |
+
import json
|
| 4 |
+
import time
|
| 5 |
+
from fastapi import Request
|
| 6 |
+
from fastapi.responses import StreamingResponse
|
| 7 |
+
|
| 8 |
+
# Connected clients queue
|
| 9 |
+
_clients = []
|
| 10 |
+
_lock = asyncio.Lock()
|
| 11 |
+
|
| 12 |
+
async def _notify_clients(event_type: str, data: dict):
|
| 13 |
+
"""Send notification to all SSE clients."""
|
| 14 |
+
if not _clients:
|
| 15 |
+
return
|
| 16 |
+
msg = f"data: {json.dumps({'type': event_type, 'data': data, 'ts': int(time.time())})}\n\n"
|
| 17 |
+
async with _lock:
|
| 18 |
+
dead = []
|
| 19 |
+
for q in _clients:
|
| 20 |
+
try:
|
| 21 |
+
await q.put_nowait(msg)
|
| 22 |
+
except asyncio.QueueFull:
|
| 23 |
+
pass
|
| 24 |
+
except:
|
| 25 |
+
dead.append(q)
|
| 26 |
+
for q in dead:
|
| 27 |
+
if q in _clients:
|
| 28 |
+
_clients.remove(q)
|
| 29 |
+
|
| 30 |
+
# Public functions to call from other modules
|
| 31 |
+
notify_new_post = lambda post: asyncio.create_task(_notify_clients("new_post", post)) if post else None
|
| 32 |
+
notify_new_short = lambda post: asyncio.create_task(_notify_clients("new_short", post)) if post else None
|
| 33 |
+
|
| 34 |
+
async def sse_events(request: Request):
|
| 35 |
+
"""SSE endpoint for real-time updates on homepage."""
|
| 36 |
+
q = asyncio.Queue(maxsize=10)
|
| 37 |
+
_clients.append(q)
|
| 38 |
+
|
| 39 |
+
async def event_generator():
|
| 40 |
+
try:
|
| 41 |
+
# Send initial connection message
|
| 42 |
+
yield "data: {\"type\":\"connected\",\"ts\":null}\n\n"
|
| 43 |
+
while not await request.is_disconnected():
|
| 44 |
+
try:
|
| 45 |
+
msg = await asyncio.wait_for(q.get(), timeout=25.0)
|
| 46 |
+
yield msg
|
| 47 |
+
except asyncio.TimeoutError:
|
| 48 |
+
yield ":keepalive\n\n"
|
| 49 |
+
except:
|
| 50 |
+
pass
|
| 51 |
+
finally:
|
| 52 |
+
if q in _clients:
|
| 53 |
+
_clients.remove(q)
|
| 54 |
+
|
| 55 |
+
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
bongda_proxy.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""VNEWS — Bongda Proxy Endpoint (for fast match detail loading)"""
|
| 2 |
+
import requests
|
| 3 |
+
from bs4 import BeautifulSoup
|
| 4 |
+
import re
|
| 5 |
+
import json
|
| 6 |
+
|
| 7 |
+
def _cl(s):
|
| 8 |
+
return re.sub(r'\s+', ' ', str(s or '')).strip()
|
| 9 |
+
|
| 10 |
+
def _normalize_time(raw):
|
| 11 |
+
t = _cl(raw)
|
| 12 |
+
t = re.sub(r"(\d+)'\s*\+(\d+)", r"\1+\2'", t)
|
| 13 |
+
t = t.replace("''", "'")
|
| 14 |
+
return t
|
| 15 |
+
|
| 16 |
+
def scrape_match_html(event_id, url=None):
|
| 17 |
+
result = {"event_id": event_id, "found": False, "sections": []}
|
| 18 |
+
headers = {
|
| 19 |
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
| 20 |
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
| 21 |
+
"Referer": "https://bongda.com.vn/",
|
| 22 |
+
}
|
| 23 |
+
html = None
|
| 24 |
+
urls_to_try = [url] if url else []
|
| 25 |
+
urls_to_try += [
|
| 26 |
+
f"https://bongda.com.vn/tran-dau/{event_id}/centre/",
|
| 27 |
+
f"https://bongda.com.vn/tran-dau/{event_id}/preview/",
|
| 28 |
+
]
|
| 29 |
+
for u in urls_to_try:
|
| 30 |
+
if not u:
|
| 31 |
+
continue
|
| 32 |
+
try:
|
| 33 |
+
resp = requests.get(u, headers=headers, timeout=15, allow_redirects=True)
|
| 34 |
+
if resp.status_code == 200 and len(resp.text) > 1000:
|
| 35 |
+
html = resp.text
|
| 36 |
+
break
|
| 37 |
+
except Exception:
|
| 38 |
+
continue
|
| 39 |
+
if not html:
|
| 40 |
+
return result
|
| 41 |
+
try:
|
| 42 |
+
soup = BeautifulSoup(html, 'html.parser')
|
| 43 |
+
info = {}
|
| 44 |
+
tel = soup.select_one('.teams')
|
| 45 |
+
if tel:
|
| 46 |
+
he = tel.select_one('.team.home')
|
| 47 |
+
if he:
|
| 48 |
+
ne = he.select_one('p:not(.logo)') or he.find('p')
|
| 49 |
+
if ne: info['home_team'] = _cl(ne.get_text())
|
| 50 |
+
lo = he.select_one('img')
|
| 51 |
+
if lo: info['home_logo'] = lo.get('src', '')
|
| 52 |
+
ae = tel.select_one('.team.away')
|
| 53 |
+
if ae:
|
| 54 |
+
ne = ae.select_one('p:not(.logo)') or ae.find('p')
|
| 55 |
+
if ne: info['away_team'] = _cl(ne.get_text())
|
| 56 |
+
lo = ae.select_one('img')
|
| 57 |
+
if lo: info['away_logo'] = lo.get('src', '')
|
| 58 |
+
sc = tel.select_one('.score')
|
| 59 |
+
if sc:
|
| 60 |
+
parts = [_cl(p.get_text()) for p in sc.select('p')]
|
| 61 |
+
if len(parts) >= 2: info['score'] = f"{parts[0]} - {parts[1]}"
|
| 62 |
+
lb = sc.select_one('.label')
|
| 63 |
+
if lb: info['status_label'] = _cl(lb.get_text())
|
| 64 |
+
if info.get('home_team') and info.get('away_team'):
|
| 65 |
+
result['info'] = info
|
| 66 |
+
result['found'] = True
|
| 67 |
+
result['sections'].append('info')
|
| 68 |
+
else:
|
| 69 |
+
return result
|
| 70 |
+
events = []
|
| 71 |
+
events_div = soup.select_one('.events')
|
| 72 |
+
if events_div:
|
| 73 |
+
period = ''
|
| 74 |
+
for child in events_div.children:
|
| 75 |
+
if not hasattr(child, 'name') or not child.name: continue
|
| 76 |
+
cls = ' '.join(child.get('class', []))
|
| 77 |
+
if 'period' in cls:
|
| 78 |
+
h2 = child.find('h2')
|
| 79 |
+
if h2: period = _cl(h2.get_text())
|
| 80 |
+
for ev in child.children:
|
| 81 |
+
if not hasattr(ev, 'name') or not ev.name: continue
|
| 82 |
+
ev_cls = ' '.join(ev.get('class', []))
|
| 83 |
+
if 'event' not in ev_cls: continue
|
| 84 |
+
ev_data = {'team': 'home' if 'home' in ev_cls else 'away', 'period': period, 'type': 'unknown', 'time': ''}
|
| 85 |
+
type_el = ev.select_one('.event-type')
|
| 86 |
+
if type_el:
|
| 87 |
+
if type_el.select_one('[class*="redcard"]'): ev_data['type'] = 'redcard'
|
| 88 |
+
elif type_el.select_one('[class*="yellowcard"]'): ev_data['type'] = 'yellowcard'
|
| 89 |
+
elif type_el.select_one('[class*="goal"]'): ev_data['type'] = 'goal'
|
| 90 |
+
elif type_el.select_one('[class*="substitution"]'): ev_data['type'] = 'substitution'
|
| 91 |
+
players_el = ev.select_one('.players')
|
| 92 |
+
if players_el:
|
| 93 |
+
time_el = players_el.select_one('.event-time')
|
| 94 |
+
if time_el: ev_data['time'] = _normalize_time(time_el.get_text())
|
| 95 |
+
text = _cl(players_el.get_text(' ', strip=True).replace(ev_data['time'], '').strip())
|
| 96 |
+
ev_data['players'] = text
|
| 97 |
+
events.append(ev_data)
|
| 98 |
+
if events:
|
| 99 |
+
result['events'] = events
|
| 100 |
+
result['sections'].append('events')
|
| 101 |
+
except Exception as e:
|
| 102 |
+
result['error'] = str(e)
|
| 103 |
+
return result
|
| 104 |
+
|
| 105 |
+
from fastapi import Query
|
| 106 |
+
from fastapi.responses import JSONResponse
|
| 107 |
+
|
| 108 |
+
def add_bongda_proxy_endpoint(app):
|
| 109 |
+
@app.get('/api/proxy/bongda')
|
| 110 |
+
def proxy_bongda(event_id: int = Query(default=None), url: str = Query(default=None)):
|
| 111 |
+
if event_id is None:
|
| 112 |
+
return JSONResponse({'error': 'event_id required'}, status_code=400)
|
| 113 |
+
return JSONResponse(scrape_match_html(event_id, url))
|
index_v2.html
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="vi">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">
|
| 6 |
+
<title>VNEWS - Tin Tức Việt Nam</title>
|
| 7 |
+
<meta name="description" content="Tin tức tổng hợp, bóng đá trực tiếp, video highlight, AI tóm tắt.">
|
| 8 |
+
<meta property="og:title" content="VNEWS - Tin Tức Việt Nam">
|
| 9 |
+
<meta property="og:image" content="https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg">
|
| 10 |
+
<link rel="canonical" href="https://bep40-vnews.hf.space">
|
| 11 |
+
<link rel="stylesheet" href="/static/wc2026.css">
|
| 12 |
+
<script src="https://cdn.jsdelivr.net/npm/hls.js@1/dist/hls.min.js"></script>
|
| 13 |
+
<style>
|
| 14 |
+
*{box-sizing:border-box;margin:0;padding:0}body{background:#111;color:#eee;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;overflow-x:hidden}.header{background:linear-gradient(135deg,#0d1117,#1a3a2a 50%,#8b7500);padding:12px;text-align:center}.header h1{font-size:18px;color:#fff}.header p{font-size:10px;color:#aaa}.cats{display:flex;overflow-x:auto;background:#1a1a1a;border-bottom:1px solid #333;padding:0 4px;position:sticky;top:0;z-index:50;scrollbar-width:none}.cats::-webkit-scrollbar{display:none}.cat{padding:9px 11px;color:#888;font-size:11px;white-space:nowrap;border-bottom:2px solid transparent;cursor:pointer;flex-shrink:0}.cat.active{color:#5cb87a;border-bottom-color:#5cb87a;font-weight:700}.view{display:none}.view.active{display:block}.loading{text-align:center;padding:30px;color:#777;font-size:12px}.slider-wrap{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}.slider-header{padding:7px 10px;display:flex;align-items:center;justify-content:space-between}.slider-label{color:#f0c040;font-size:13px;font-weight:800}.slider-note{font-size:10px;color:#777}.slider-track{display:flex;overflow-x:auto;gap:8px;padding:4px 10px 10px;scrollbar-width:none}.slider-track::-webkit-scrollbar{display:none}.slider-item{flex:0 0 160px;cursor:pointer}.slider-thumb{position:relative;width:100%;aspect-ratio:16/9;border-radius:6px;overflow:hidden;background:#333}.slider-thumb img,.slider-thumb video{width:100%;height:100%;object-fit:cover}.slider-title{font-size:10px;color:#ccc;margin-top:3px;line-height:1.2;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.card-play{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:30px;height:30px;border-radius:50%;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;color:#fff;font-size:12px}.grid{display:grid;grid-template-columns:repeat(2,1fr);gap:6px;padding:6px 4px}@media(min-width:650px){.grid{grid-template-columns:repeat(3,1fr)}}.card{background:#1a1a1a;border:1px solid #222;border-radius:8px;overflow:hidden;cursor:pointer}.card-img{position:relative;aspect-ratio:16/9;background:#333}.card-img img{width:100%;height:100%;object-fit:cover}.card-body{padding:6px 8px}.card-title{font-size:11px;line-height:1.35;color:#eee;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.badge{font-size:8px;padding:1px 5px;border-radius:3px;font-weight:700;display:inline-block;margin-bottom:2px;color:#fff}.badge-vne{background:#c0392b}.badge-genk{background:#6a1b9a}.badge-ai{background:#2d8659}.badge-wc{background:#0b6bcb}.section-title{font-size:13px;font-weight:800;color:#5cb87a;margin:8px 0 4px;padding-left:8px;border-left:3px solid #5cb87a}.back-btn{background:#111;color:#fff;border:none;padding:10px;font-size:12px;width:100%;position:sticky;top:0;z-index:60;cursor:pointer}.article-view{padding:12px 8px 40px;max-width:760px;margin:0 auto}.article-title{font-size:18px;font-weight:800;line-height:1.3;margin-bottom:8px}.article-summary{background:#1a2a1f;border-left:3px solid #2d8659;padding:10px;margin-bottom:14px;color:#ccc;font-size:13px}.article-p{font-size:14px;line-height:1.7;color:#ccc;margin-bottom:10px}.article-img{width:100%;border-radius:6px;margin:10px 0}.article-h2{font-size:16px;margin:16px 0 8px;color:#eee}.article-actions{display:flex;gap:8px;flex-wrap:wrap;border-top:1px solid #333;margin-top:16px;padding-top:10px}.article-actions button{background:#1a1a1a;border:1px solid #333;color:#ccc;padding:7px 12px;border-radius:14px;font-size:11px;cursor:pointer}.article-actions button.primary{background:#2d8659;border-color:#2d8659;color:#fff}.article-ai-ask{margin-top:12px;background:#141414;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.article-ai-ask textarea{width:100%;min-height:60px;background:#222;border:1px solid #444;color:#eee;border-radius:10px;padding:9px;font-size:12px}.article-ai-ask button{background:#2d8659;border:0;color:#fff;border-radius:10px;padding:8px 12px;margin-top:6px;font-size:11px;cursor:pointer}.article-ai-answer{white-space:pre-wrap;color:#ccc;font-size:13px;line-height:1.55;margin-top:8px}.tiktok-container{width:100%;height:80vh;max-height:680px;min-height:400px;background:#000}.tiktok-feed{height:100%;overflow-y:scroll;scroll-snap-type:y mandatory;scrollbar-width:none}.tiktok-feed::-webkit-scrollbar{display:none}.tiktok-slide{height:80vh;max-height:680px;min-height:400px;scroll-snap-align:start;position:relative;background:#000;display:flex;align-items:center;justify-content:center}.tiktok-slide video,.tiktok-slide iframe{width:100%;height:100%;object-fit:cover;border:none}.tiktok-slide.ratio-wide video,.tiktok-slide.ratio-wide iframe{object-fit:contain}.tiktok-bottom{position:absolute;bottom:0;left:0;right:60px;padding:12px 10px 16px;background:linear-gradient(transparent,rgba(0,0,0,.85));z-index:3}.tiktok-title{font-size:12px;color:#fff}.tiktok-counter{position:absolute;top:8px;left:8px;background:rgba(0,0,0,.5);font-size:9px;padding:2px 7px;border-radius:8px;color:#fff;z-index:4}.tiktok-right{position:absolute;right:8px;bottom:100px;display:flex;flex-direction:column;align-items:center;gap:14px;z-index:5}.tiktok-right-btn{display:flex;flex-direction:column;align-items:center;gap:2px;background:none;border:0;color:#fff;cursor:pointer;font-size:10px}.tiktok-right-btn .icon{width:42px;height:42px;border-radius:50%;background:rgba(255,255,255,.12);display:flex;align-items:center;justify-content:center;font-size:20px}.tiktok-right-btn .count{font-size:10px;color:#ddd}.inline-comments{position:absolute;bottom:0;left:0;right:0;max-height:60%;min-height:140px;background:rgba(18,18,18,.95);border-radius:14px 14px 0 0;z-index:10;overflow:clip;display:flex;flex-direction:column}.inline-cmt-header{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;border-bottom:1px solid #333;color:#5cb87a;font-size:12px;font-weight:700;flex-shrink:0}.inline-cmt-header button{background:none;border:0;color:#fff;font-size:16px;cursor:pointer}.inline-cmt-list{flex:1 1 auto;overflow-y:auto;padding:6px 10px;max-height:140px;min-height:40px}.inline-cmt-item{background:#222;border-radius:8px;padding:6px 8px;margin:4px 0;color:#ccc;font-size:11px;line-height:1.3}.inline-cmt-time{font-size:9px;color:#777;margin-right:6px}.inline-cmt-input{display:flex;gap:6px;padding:8px 10px;border-top:1px solid #333;flex-shrink:0;position:sticky;bottom:0;background:rgba(18,18,18,.98)}.inline-cmt-input input{flex:1;background:#222;border:1px solid #444;color:#eee;border-radius:16px;padding:7px 12px;font-size:11px;min-height:32px}.inline-cmt-input button{background:#2d8659;border:0;color:#fff;border-radius:16px;padding:7px 12px;font-size:11px;cursor:pointer;min-height:32px}.wc2026-section{margin:6px 4px;background:linear-gradient(135deg,#0d1117,#1a1a3a);border:1px solid #1a3a5a;border-radius:10px;overflow:hidden}.wc-header{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:linear-gradient(90deg,#0b2e4a,#1a3a5a)}.wc-header h2{font-size:15px;color:#fff;margin:0}.wc-live-badge{font-size:10px;color:#e74c3c;font-weight:700;animation:wc-pulse 1.5s infinite}@keyframes wc-pulse{0%,100%{opacity:1}50%{opacity:.4}}.wc-tabs{display:flex;gap:4px;padding:8px 10px;overflow-x:auto;scrollbar-width:none}.wc-tabs::-webkit-scrollbar{display:none}.wc-tab{padding:5px 10px;background:#1a2a3a;border:1px solid #2a3a4a;border-radius:12px;color:#8ab4d8;font-size:10px;cursor:pointer;white-space:nowrap;flex-shrink:0}.wc-tab.active{background:#0b6bcb;border-color:#0b6bcb;color:#fff;font-weight:700}.wc-content{padding:8px 10px;max-height:500px;overflow-y:auto}.wc-news-grid{display:flex;flex-direction:column;gap:8px}.wc-news-item{display:flex;gap:8px;padding:8px;background:#1a2030;border-radius:8px;cursor:pointer}.wc-news-item:active{opacity:.8}.wc-news-img{flex:0 0 70px;aspect-ratio:16/9;border-radius:6px;overflow:hidden;background:#222}.wc-news-img img{width:100%;height:100%;object-fit:cover}.wc-news-text{flex:1;min-width:0}.wc-news-title{font-size:11px;font-weight:700;color:#eee;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.wc-news-via{font-size:9px;color:#6a9fca;margin-top:2px}.ls-section{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}.ls-header{padding:7px 10px;display:flex;align-items:center;justify-content:space-between}.ls-header h3{color:#f0c040;font-size:13px;font-weight:800}.ls-tabs{display:flex;gap:4px;padding:0 10px 8px;overflow-x:auto;scrollbar-width:none}.ls-tabs::-webkit-scrollbar{display:none}.ls-tab{padding:4px 10px;background:#222;border:1px solid #333;border-radius:12px;color:#999;font-size:10px;white-space:nowrap;cursor:pointer;flex-shrink:0}.ls-tab.active{background:#2d8659;border-color:#2d8659;color:#fff;font-weight:700}.ls-content{max-height:420px;overflow-y:auto;padding:0 6px 8px;font-size:12px;color:#ddd}.ls-content ul{list-style:none;padding:0;margin:0}.ls-content .title-content{display:flex;gap:6px;align-items:center;background:#222;border-radius:4px;margin:4px 0;padding:5px 8px}.ls-content .title-content img{width:18px;height:18px}.ls-content .title-content strong{font-size:11px;color:#ccc}.ls-content .match-detail{padding:6px;border-bottom:1px solid #262626;cursor:pointer}.ls-content .match-detail:hover{background:#1a2a1f}.ls-content .match{display:flex;flex-wrap:wrap;align-items:center;gap:4px}.ls-content .datetime{width:100%;font-size:9px;color:#888}.ls-content .teams{display:flex;width:100%;align-items:center;gap:4px}.ls-content .team{flex:1;display:flex;align-items:center;gap:4px;min-width:0;text-decoration:none}.ls-content .team .name{font-size:11px;color:#ddd;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ls-content .team .logo img{width:18px;height:18px}.ls-content .home-team{justify-content:flex-end;text-align:right}.ls-content .status{flex:0 0 54px;text-align:center}.ls-content .status a{color:#fff;text-decoration:none;font-weight:800;font-size:12px}.ls-content .status .label{font-size:8px;color:#888;display:block}.ls-content .status .label.live{color:#e74c3c}.ls-content .info,.ls-content .btns{display:none}.ls-content table{width:100%;border-collapse:collapse;font-size:11px;color:#ccc}.ls-content table th{background:#222;color:#999;padding:5px 4px;font-size:10px;border-bottom:1px solid #333}.ls-content table td{padding:4px 3px;border-bottom:1px solid #1a1a1a}.ls-content table .team-name{display:flex;align-items:center;gap:4px}.ls-content table .team-name img{width:16px;height:16px}.ls-content table .pts{font-weight:800;color:#f0c040}.match-overlay{position:fixed;inset:0;background:#111;z-index:9999;display:none;flex-direction:column;overflow:auto}.match-overlay.active{display:flex}.mo-header{padding:10px;background:#1a1a1a;display:flex;justify-content:space-between;align-items:center;position:sticky;top:0;z-index:1}.mo-header h3{font-size:13px;color:#eee}.mo-close{background:none;border:0;color:#fff;font-size:22px;cursor:pointer}.mo-tabs{display:flex;gap:4px;padding:8px 10px;background:#1a1a1a;overflow-x:auto}.mo-tab{padding:5px 12px;background:#222;border:1px solid #333;border-radius:10px;color:#999;font-size:10px;cursor:pointer;white-space:nowrap}.mo-tab.active{background:#2d8659;color:#fff}.mo-body{padding:8px;overflow-x:auto;font-size:12px;color:#ddd}.mo-body ul{list-style:none;padding:0;margin:0}.mo-body li{padding:5px 0;border-bottom:1px solid #222}.featured-match{margin:6px 4px;background:linear-gradient(135deg,#1a2a1f,#0d1117);border:1px solid #2d8659;border-radius:10px;padding:12px;cursor:pointer}.fm-league{text-align:center;color:#5cb87a;font-size:9px;font-weight:700;text-transform:uppercase}.fm-teams{display:flex;align-items:center;justify-content:center;gap:10px;margin-top:6px}.fm-team{flex:1;display:flex;flex-direction:column;align-items:center;gap:4px}.fm-team img{width:32px;height:32px;object-fit:contain}.fm-team span{font-size:10px;color:#ccc;text-align:center}.fm-score{font-size:22px;font-weight:900;min-width:60px;text-align:center;color:#fff}.fm-status{text-align:center;margin-top:6px;font-size:9px;color:#e74c3c;font-weight:700}.fm-status.upcoming{color:#f0c040}.ai-compose{margin:6px 4px;background:#141414;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.ai-compose-title{font-size:13px;font-weight:800;color:#5cb87a;margin-bottom:8px}.ai-compose-row{display:flex;gap:6px;margin-top:6px}.ai-compose input{flex:1;background:#222;border:1px solid #333;color:#eee;border-radius:18px;padding:9px 12px;font-size:12px;min-width:0}.ai-compose button{background:#2d8659;border:0;color:#fff;border-radius:18px;padding:9px 12px;font-size:11px;font-weight:700;cursor:pointer;white-space:nowrap}.ai-compose button.secondary{background:#333}.hot-topic-row{display:flex;gap:6px;overflow-x:auto;padding:4px 0;scrollbar-width:none}.hot-topic-row::-webkit-scrollbar{display:none}.hot-chip{flex:0 0 auto;background:#222;border:1px solid #333;color:#ddd;border-radius:16px;padding:5px 10px;font-size:11px;cursor:pointer;white-space:nowrap}.hot-chip:active{transform:scale(.96)}.hashtag-sources{margin:8px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.hashtag-sources h3{font-size:13px;color:#5cb87a;margin-bottom:8px}.hashtag-src-item{display:flex;gap:8px;padding:8px;background:#202020;border-radius:8px;margin:6px 0;cursor:pointer}.hashtag-src-item:active{opacity:.8}.hashtag-src-img{flex:0 0 80px;aspect-ratio:16/9;background:#333;border-radius:6px;overflow:hidden}.hashtag-src-img img{width:100%;height:100%;object-fit:cover}.hashtag-src-text{flex:1;min-width:0}.hashtag-src-title{font-size:12px;font-weight:700;color:#eee;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.hashtag-src-via{font-size:10px;color:#888;margin-top:2px}.hashtag-rewrite-btn{width:100%;margin-top:8px;background:#2d8659;border:0;color:#fff;padding:9px;border-radius:10px;font-size:12px;font-weight:700;cursor:pointer}.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-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)}}.wall-item{flex:0 0 260px;background:#141414;border:1px solid #2b2b2b;border-radius:10px;padding:8px}.wall-item-new{animation:wall-flash 1.8s ease-out}@keyframes wall-flash{0%{border-color:#f0c040;box-shadow:0 0 18px rgba(240,192,64,.35)}30%{border-color:#f0c040;box-shadow:0 0 12px rgba(240,192,64,.2)}100%{border-color:#2b2b2b;box-shadow:none}}.wall-thumb{width:100%;aspect-ratio:16/9;border-radius:8px;background:#222;overflow:hidden;margin-bottom:6px;position:relative}.wall-thumb img{width:100%;height:100%;object-fit:cover}.wall-video-badge{position:absolute;top:4px;right:4px;background:rgba(45,134,89,.9);color:#fff;font-size:10px;padding:2px 6px;border-radius:6px;font-weight:700}.wall-title{font-size:12px;color:#5cb87a;font-weight:800;line-height:1.3;margin-bottom:4px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.wall-text{font-size:11px;color:#bbb;line-height:1.4;white-space:pre-wrap;display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden}.wall-actions{display:flex;gap:6px;margin-top:8px}.wall-actions button{flex:1;border:1px solid #333;background:#222;color:#ddd;border-radius:14px;padding:6px 8px;font-size:10px;cursor:pointer}.wall-actions button.primary{background:#2d8659;border-color:#2d8659;color:#fff}.wall-actions button.wall-btn-design{background:#1a3a5a;border-color:#3a5a7a;color:#fff;font-weight:700}#progress-toast{position:fixed;bottom:70px;left:50%;transform:translateX(-50%);background:#2d8659;color:#fff;padding:10px 20px;border-radius:20px;font-size:12px;z-index:99998;box-shadow:0 4px 12px rgba(0,0,0,.4);display:none;white-space:nowrap}.storage-warn{background:#332200;border:1px solid #664400;color:#ffcc00;padding:8px 12px;border-radius:8px;font-size:11px;margin:6px 4px}
|
| 15 |
+
.hl-load-more:hover{background:#2a2a2a !important}
|
| 16 |
+
.slider-wrap{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}
|
| 17 |
+
</style>
|
| 18 |
+
</head>
|
| 19 |
+
<body>
|
| 20 |
+
<div class="header"><h1>📰 VNEWS</h1><p>Tin tức · Bóng đá LIVE · Highlight · AI · World Cup 2026</p></div>
|
| 21 |
+
<div class="cats" id="cat-bar"></div>
|
| 22 |
+
<div id="view-home" class="view active"><div class="loading">Đang tải...</div></div>
|
| 23 |
+
<div id="view-cat" class="view"></div>
|
| 24 |
+
<div id="view-video" class="view"></div>
|
| 25 |
+
<div id="view-tiktok" class="view"></div>
|
| 26 |
+
<div id="view-article" class="view"></div>
|
| 27 |
+
<div class="match-overlay" id="match-overlay">
|
| 28 |
+
<div class="mo-header"><h3 id="mo-title">Chi tiết trận đấu</h3><button class="mo-close" onclick="closeMatch()">✕</button></div>
|
| 29 |
+
<div class="mo-tabs"><span class="mo-tab active" onclick="loadMatchTab('detail')">📋 Chi tiết</span><span class="mo-tab" onclick="loadMatchTab('comm')">Diễn biến</span><span class="mo-tab" onclick="loadMatchTab('stats')">Thống kê</span></div>
|
| 30 |
+
<div class="mo-body" id="mo-body"><div class="loading">Đang tải...</div></div>
|
| 31 |
+
</div>
|
| 32 |
+
<div id="progress-toast"></div>
|
| 33 |
+
<script>
|
| 34 |
+
var _cats=[],_hlLeagueData={},_currentArticle=null;window._currentEventId='';
|
| 35 |
+
function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]))}
|
| 36 |
+
function showView(id){document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));document.getElementById(id)?.classList.add('active')}
|
| 37 |
+
function switchCat(id){document.querySelectorAll('.cat').forEach(c=>c.classList.remove('active'));document.querySelector('[data-cat="'+id+'"]')?.classList.add('active');document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));document.querySelectorAll('video').forEach(v=>{v.pause();if(v._hls){v._hls.destroy();v._hls=null}});document.querySelectorAll('iframe[data-yt-src]').forEach(f=>{f.src=''});if(id==='home')document.getElementById('view-home').classList.add('active');else if(id==='news-all'){document.getElementById('view-cat').classList.add('active');loadNewsTab()}else{document.getElementById('view-cat').classList.add('active');loadCat(id)}}
|
| 38 |
+
function toast(msg){let t=document.getElementById('progress-toast');if(t){t.textContent=msg;t.style.display='block';setTimeout(()=>{t.style.display='none'},3500)}}
|
| 39 |
+
function doShare(title,url,img,postId){
|
| 40 |
+
var shareUrl;
|
| 41 |
+
if(postId){
|
| 42 |
+
shareUrl = SPACE+'/s?post_id='+encodeURIComponent(postId)+'&title='+encodeURIComponent(title);
|
| 43 |
+
} else {
|
| 44 |
+
shareUrl = SPACE+'/s?url='+encodeURIComponent(url)+'&title='+encodeURIComponent(title)+'&img='+encodeURIComponent(img||'');
|
| 45 |
+
}
|
| 46 |
+
// Use clipboard API first (modern, works on Chrome mobile 85+)
|
| 47 |
+
if(navigator.clipboard && navigator.clipboard.writeText){
|
| 48 |
+
navigator.clipboard.writeText(shareUrl).then(function(){
|
| 49 |
+
toast('📋 Đã sao chép link!');
|
| 50 |
+
try{if(navigator.share)navigator.share({title:title||'',url:shareUrl}).catch(function(){});}catch(e){}
|
| 51 |
+
}).catch(function(){
|
| 52 |
+
try{
|
| 53 |
+
var ta=document.createElement('textarea');ta.value=shareUrl;ta.style.position='fixed';ta.style.left='-9999px';ta.style.top='-9999px';ta.style.opacity='0';
|
| 54 |
+
document.body.appendChild(ta);ta.select();ta.setSelectionRange(0,99999);
|
| 55 |
+
if(document.execCommand('copy')){toast('📋 Đã sao chép link!');}else{prompt('📋 Sao chép link:', shareUrl);}
|
| 56 |
+
document.body.removeChild(ta);
|
| 57 |
+
}catch(e){prompt('📋 Sao chép link:', shareUrl);}
|
| 58 |
+
try{if(navigator.share)navigator.share({title:title||'',url:shareUrl}).catch(function(){});}catch(e){}
|
| 59 |
+
});
|
| 60 |
+
} else {
|
| 61 |
+
try{
|
| 62 |
+
var ta=document.createElement('textarea');ta.value=shareUrl;ta.style.position='fixed';ta.style.left='-9999px';ta.style.top='-9999px';ta.style.opacity='0';
|
| 63 |
+
document.body.appendChild(ta);ta.select();ta.setSelectionRange(0,99999);
|
| 64 |
+
if(document.execCommand('copy')){toast('📋 Đã sao chép link!');}else{prompt('📋 Sao chép link:', shareUrl);}
|
| 65 |
+
document.body.removeChild(ta);
|
| 66 |
+
}catch(e){prompt('📋 Sao chép link:', shareUrl);}
|
| 67 |
+
try{if(navigator.share)navigator.share({title:title||'',url:shareUrl}).catch(function(){});}catch(e){}
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
async function init(){_cats=await fetch('/api/categories').then(r=>r.json()).catch(()=>[]);let bar='<div class="cat active" data-cat="home">🏠</div><div class="cat" data-cat="news-all">📰 Tin tức</div>';_cats.forEach(c=>{bar+='<div class="cat" data-cat="'+c.id+'">'+c.name+'</div>'});document.getElementById('cat-bar').innerHTML=bar;document.querySelectorAll('.cat').forEach(t=>{t.onclick=()=>switchCat(t.dataset.cat)});}
|
| 71 |
+
var SPACE=location.origin;
|
| 72 |
+
</script>
|
| 73 |
+
<script src="/static/app_v2.js?v=20260821a"></script>
|
| 74 |
+
<script src="/static/designer_v2.js?v=20260811a"></script>
|
| 75 |
+
<script src="/static/yt_live.js"></script>
|
| 76 |
+
<script src="/static/vtv_init.js"></script>
|
| 77 |
+
<script src="/static/hot_multi.js?v=1"></script>
|
| 78 |
+
<script src="/static/wc2026_v2.js"></script>
|
| 79 |
+
<script src="/static/live_mode.js"></script>
|
| 80 |
+
<script src="/static/match_detail_v6.js"></script>
|
| 81 |
+
<script>init();loadHome();</script>
|
| 82 |
+
</body>
|
| 83 |
+
</html>
|
logs_route.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Independent logs page for VNEWS Space.
|
| 2 |
+
Serves /logs (HTML) and /logs.txt (raw) so build/runtime errors are visible
|
| 3 |
+
even when the Hugging Face build-logs tab is stuck/unavailable.
|
| 4 |
+
Mounted from _run.py.
|
| 5 |
+
"""
|
| 6 |
+
import os
|
| 7 |
+
import time
|
| 8 |
+
import json
|
| 9 |
+
import subprocess
|
| 10 |
+
from fastapi import Request
|
| 11 |
+
from fastapi.responses import HTMLResponse, PlainTextResponse
|
| 12 |
+
|
| 13 |
+
try:
|
| 14 |
+
from app_v2_entry import app
|
| 15 |
+
except Exception:
|
| 16 |
+
from main import app
|
| 17 |
+
|
| 18 |
+
BUILD_DONE = "/app/.build_done"
|
| 19 |
+
DATA_DIR = '/data' if os.path.isdir('/data') else os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data')
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _collect():
|
| 23 |
+
lines = []
|
| 24 |
+
lines.append("=== VNEWS LOGS ===")
|
| 25 |
+
lines.append("generated: " + time.strftime('%Y-%m-%d %H:%M:%S', time.localtime()))
|
| 26 |
+
lines.append("")
|
| 27 |
+
# Build marker
|
| 28 |
+
if os.path.exists(BUILD_DONE):
|
| 29 |
+
lines.append("[BUILD] .build_done exists -> container started OK")
|
| 30 |
+
try:
|
| 31 |
+
lines.append("[BUILD] built at: " + open(BUILD_DONE).read().strip())
|
| 32 |
+
except Exception:
|
| 33 |
+
pass
|
| 34 |
+
else:
|
| 35 |
+
lines.append("[BUILD] WARNING: .build_done MISSING -> uvicorn started before build finished?")
|
| 36 |
+
lines.append("")
|
| 37 |
+
|
| 38 |
+
# Space status from HF runtime file
|
| 39 |
+
try:
|
| 40 |
+
import json as _j
|
| 41 |
+
mj = os.path.join(os.path.dirname(os.path.abspath(__file__)), '.huggingface', 'main.json')
|
| 42 |
+
if os.path.exists(mj):
|
| 43 |
+
lines.append("[RUNTIME] .huggingface/main.json present")
|
| 44 |
+
else:
|
| 45 |
+
lines.append("[RUNTIME] .huggingface/main.json NOT found")
|
| 46 |
+
except Exception as e:
|
| 47 |
+
lines.append("[RUNTIME] error: " + str(e))
|
| 48 |
+
lines.append("")
|
| 49 |
+
|
| 50 |
+
# Data dir contents
|
| 51 |
+
lines.append("[DATA] dir=" + DATA_DIR)
|
| 52 |
+
try:
|
| 53 |
+
if os.path.isdir(DATA_DIR):
|
| 54 |
+
for f in sorted(os.listdir(DATA_DIR)):
|
| 55 |
+
p = os.path.join(DATA_DIR, f)
|
| 56 |
+
lines.append(" - %s (%d bytes)" % (f, os.path.getsize(p)))
|
| 57 |
+
else:
|
| 58 |
+
lines.append(" (data dir missing)")
|
| 59 |
+
except Exception as e:
|
| 60 |
+
lines.append(" error: " + str(e))
|
| 61 |
+
lines.append("")
|
| 62 |
+
|
| 63 |
+
# Recent container logs (stdout) if captured
|
| 64 |
+
log_paths = ["/tmp/vnews_stdout.log", os.path.join(DATA_DIR, "app.log")]
|
| 65 |
+
for lp in log_paths:
|
| 66 |
+
if os.path.exists(lp):
|
| 67 |
+
lines.append("[STDOUT] tail of " + lp + ":")
|
| 68 |
+
try:
|
| 69 |
+
with open(lp, "r", errors="replace") as fh:
|
| 70 |
+
tail = fh.read().splitlines()[-50:]
|
| 71 |
+
for l in tail:
|
| 72 |
+
lines.append(" " + l)
|
| 73 |
+
except Exception as e:
|
| 74 |
+
lines.append(" read error: " + str(e))
|
| 75 |
+
lines.append("")
|
| 76 |
+
|
| 77 |
+
# Environment hints
|
| 78 |
+
lines.append("[ENV] HF_SPACE: " + os.environ.get("HF_SPACE", "?"))
|
| 79 |
+
lines.append("[ENV] SPACE_ID: " + os.environ.get("SPACE_ID", "?"))
|
| 80 |
+
lines.append("[ENV] CUDA/CPU: " + ("gpu" if os.environ.get("CUDA_VISIBLE_DEVICES") else "cpu"))
|
| 81 |
+
lines.append("")
|
| 82 |
+
lines.append("=== END ===")
|
| 83 |
+
return "\n".join(lines)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
@app.get("/logs")
|
| 87 |
+
def logs_page(request: Request):
|
| 88 |
+
txt = _collect()
|
| 89 |
+
html = (
|
| 90 |
+
"<!DOCTYPE html><html lang='vi'><head><meta charset='utf-8'>"
|
| 91 |
+
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
|
| 92 |
+
"<title>VNEWS Logs</title>"
|
| 93 |
+
"<style>body{background:#0d1117;color:#c9d1d9;font-family:monospace;padding:16px}"
|
| 94 |
+
"pre{white-space:pre-wrap;word-break:break-word;font-size:13px;line-height:1.5}"
|
| 95 |
+
"a{color:#58a6ff}</style></head><body>"
|
| 96 |
+
"<h2>VNEWS — Build & Runtime Logs</h2>"
|
| 97 |
+
"<p><a href='/logs.txt'>📄 raw text</a> · refresh để cập nhật</p>"
|
| 98 |
+
"<pre>" + txt.replace("&", "&").replace("<", "<").replace(">", ">") + "</pre>"
|
| 99 |
+
"</body></html>"
|
| 100 |
+
)
|
| 101 |
+
return HTMLResponse(html)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
@app.get("/logs.txt")
|
| 105 |
+
def logs_raw(request: Request):
|
| 106 |
+
return PlainTextResponse(_collect())
|
main.py
ADDED
|
@@ -0,0 +1,799 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""VNEWS - FastAPI backend with livescore + xemlaibongda highlights + VTV channels"""
|
| 2 |
+
import re, time, subprocess, json, os, threading
|
| 3 |
+
import html as html_lib
|
| 4 |
+
from datetime import datetime, timezone, timedelta, date
|
| 5 |
+
from collections import defaultdict
|
| 6 |
+
|
| 7 |
+
VN_TZ = timezone(timedelta(hours=7))
|
| 8 |
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 9 |
+
from fastapi import FastAPI, Query, Request
|
| 10 |
+
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse, Response
|
| 11 |
+
from urllib.parse import quote
|
| 12 |
+
import requests
|
| 13 |
+
from bs4 import BeautifulSoup
|
| 14 |
+
|
| 15 |
+
app = FastAPI()
|
| 16 |
+
|
| 17 |
+
# ===== WORLD CUP 2026 SCRAPER =====
|
| 18 |
+
from wc2026_scraper import get_wc2026_all, scrape_fixtures, scrape_standings, scrape_stats, scrape_wc_news
|
| 19 |
+
|
| 20 |
+
# ===== RATE LIMITING =====
|
| 21 |
+
_rate_limit_data = defaultdict(list)
|
| 22 |
+
_rate_limit_lock = threading.Lock()
|
| 23 |
+
RATE_LIMIT_MAX = 60
|
| 24 |
+
RATE_LIMIT_WINDOW = 60
|
| 25 |
+
|
| 26 |
+
def _check_rate_limit(ip: str) -> bool:
|
| 27 |
+
with _rate_limit_lock:
|
| 28 |
+
now = time.time()
|
| 29 |
+
_rate_limit_data[ip] = [t for t in _rate_limit_data[ip] if now - t < RATE_LIMIT_WINDOW]
|
| 30 |
+
if len(_rate_limit_data[ip]) >= RATE_LIMIT_MAX: return False
|
| 31 |
+
_rate_limit_data[ip].append(now)
|
| 32 |
+
return True
|
| 33 |
+
|
| 34 |
+
@app.middleware("http")
|
| 35 |
+
async def rate_limit_middleware(request: Request, call_next):
|
| 36 |
+
if request.url.path.startswith("/api/"):
|
| 37 |
+
ip = request.client.host
|
| 38 |
+
if not _check_rate_limit(ip): return JSONResponse({"error": "rate limit exceeded"}, status_code=429)
|
| 39 |
+
return await call_next(request)
|
| 40 |
+
|
| 41 |
+
# ===== VTV CHANNELS API =====
|
| 42 |
+
from vtv_api import router as vtv_router
|
| 43 |
+
app.include_router(vtv_router)
|
| 44 |
+
|
| 45 |
+
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-Language":"vi-VN,vi;q=0.9,en;q=0.8"}
|
| 46 |
+
BONGDA_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://bongda.com.vn/lich-thi-dau","X-Requested-With":"XMLHttpRequest"}
|
| 47 |
+
BASE_BDP = "https://bongdaplus.vn"
|
| 48 |
+
_cache = {}
|
| 49 |
+
_cache_ttl = 300
|
| 50 |
+
_cache_ttl_live = 60
|
| 51 |
+
_cache_ttl_yt = 1800
|
| 52 |
+
|
| 53 |
+
PRIORITY_LEAGUES = ["Ngoại Hạng Anh","FA Cup","Champions League","LaLiga","Copa del Rey","Serie A","Bundesliga","Ligue 1","V-League"]
|
| 54 |
+
LEAGUE_IDS = {"nha":27110,"laliga":27233,"seriea":27044,"bundesliga":26891,"ligue1":27212}
|
| 55 |
+
HL_LEAGUES = {
|
| 56 |
+
"premier-league":{"path":"anh/premier-league","name":"Premier League","emoji":"🏴"},
|
| 57 |
+
"fa-cup":{"path":"anh/fa-cup","name":"FA Cup","emoji":"🏆"},
|
| 58 |
+
"bundesliga":{"path":"duc/bundesliga","name":"Bundesliga","emoji":"🇩🇪"},
|
| 59 |
+
"serie-a":{"path":"italy/serie-a","name":"Serie A","emoji":"🇮🇹"},
|
| 60 |
+
"la-liga":{"path":"tay-ban-nha/la-liga","name":"La Liga","emoji":"🇪🇸"},
|
| 61 |
+
"champions-league":{"path":"cup-chau-au/uefa-champions-league","name":"Champions League","emoji":"⭐"},
|
| 62 |
+
"europa-league":{"path":"cup-chau-au/uefa-europa-league","name":"Europa League","emoji":"🟠"},
|
| 63 |
+
"world-cup":{"path":"the-gioi/world-cup","name":"World Cup 2026","emoji":"🌍"},
|
| 64 |
+
}
|
| 65 |
+
def _cached(key, fn, ttl=None):
|
| 66 |
+
now=time.time(); t=ttl or _cache_ttl
|
| 67 |
+
if key in _cache and now-_cache[key]["t"]<t: return _cache[key]["d"]
|
| 68 |
+
try: data=fn()
|
| 69 |
+
except: data=_cache.get(key,{}).get("d",[])
|
| 70 |
+
_cache[key]={"d":data,"t":now}; return data
|
| 71 |
+
def _get(url, headers=None):
|
| 72 |
+
h=headers or HEADERS; r=requests.get(url, headers=h, timeout=15); r.encoding="utf-8"
|
| 73 |
+
return BeautifulSoup(r.text,"lxml")
|
| 74 |
+
def fetch_bongda_api(endpoint):
|
| 75 |
+
try:
|
| 76 |
+
r=requests.get(f"https://bongda.com.vn{endpoint}", headers=BONGDA_HEADERS, timeout=10)
|
| 77 |
+
if r.status_code==200:
|
| 78 |
+
data=r.json()
|
| 79 |
+
if data.get("status")=="success": return data.get("html","")
|
| 80 |
+
return ""
|
| 81 |
+
except: return ""
|
| 82 |
+
|
| 83 |
+
def _parse_match_from_li(li, status_type="live"):
|
| 84 |
+
match_div=li.select_one("div.match")
|
| 85 |
+
if not match_div: return None
|
| 86 |
+
home_el=match_div.select_one(".home-team .name"); away_el=match_div.select_one(".away-team .name")
|
| 87 |
+
if not home_el or not away_el: return None
|
| 88 |
+
status_el=match_div.select_one(".status a"); league_el=li.find_previous("strong"); time_el=match_div.select_one(".match-time")
|
| 89 |
+
home_logo=match_div.select_one(".home-team .logo img"); away_logo=match_div.select_one(".away-team .logo img")
|
| 90 |
+
event_id=""
|
| 91 |
+
if status_el:
|
| 92 |
+
href=status_el.get("href",""); m=re.search(r'/tran-dau/(\d+)/',href)
|
| 93 |
+
if m: event_id=m.group(1)
|
| 94 |
+
spans=status_el.find_all("span") if status_el else []; score=""; minute=""
|
| 95 |
+
if len(spans)>=3: score=f"{spans[0].get_text(strip=True)} - {spans[2].get_text(strip=True)}"
|
| 96 |
+
if len(spans)>=4: minute=spans[3].get_text(strip=True)
|
| 97 |
+
if not score and status_el and status_el.select_one(".vs"): score="VS"
|
| 98 |
+
league=league_el.get_text(strip=True) if league_el else ""
|
| 99 |
+
return {"home":home_el.get_text(strip=True),"away":away_el.get_text(strip=True),"score":score or"VS","minute":minute,"league":league,"time":time_el.get_text(strip=True) if time_el else "","event_id":event_id,"home_logo":home_logo.get("src","") if home_logo else "","away_logo":away_logo.get("src","") if away_logo else "","status":status_type}
|
| 100 |
+
|
| 101 |
+
# ===== VIDEO PROXY =====
|
| 102 |
+
@app.get("/api/proxy/m3u8")
|
| 103 |
+
def proxy_m3u8(url: str = Query(...)):
|
| 104 |
+
try:
|
| 105 |
+
r = requests.get(url, headers=HEADERS, timeout=15)
|
| 106 |
+
if r.status_code != 200: return Response(status_code=502, content="upstream error")
|
| 107 |
+
lines = r.text.strip().split('\n'); rewritten = []
|
| 108 |
+
for line in lines:
|
| 109 |
+
if line.startswith('#') or not line.strip(): rewritten.append(line)
|
| 110 |
+
else: rewritten.append("/api/proxy/seg?url=" + quote(line.strip(), safe=""))
|
| 111 |
+
return Response(content='\n'.join(rewritten).encode('utf-8'), media_type="application/vnd.apple.mpegurl", headers={"Access-Control-Allow-Origin":"*","Cache-Control":"public, max-age=300"})
|
| 112 |
+
except: return Response(status_code=502, content="proxy error")
|
| 113 |
+
|
| 114 |
+
@app.get("/api/proxy/seg")
|
| 115 |
+
def proxy_segment(url: str = Query(...)):
|
| 116 |
+
try:
|
| 117 |
+
r = requests.get(url, headers=HEADERS, timeout=30)
|
| 118 |
+
if r.status_code != 200: return Response(status_code=502, content="upstream error")
|
| 119 |
+
data = r.content
|
| 120 |
+
if len(data) > 188 and data[0:4] == b'\x89PNG' and data[188] == 0x47: data = data[188:]
|
| 121 |
+
return Response(content=data, media_type="video/mp2t", headers={"Access-Control-Allow-Origin":"*","Cache-Control":"public, max-age=3600"})
|
| 122 |
+
except: return Response(status_code=502, content="proxy error")
|
| 123 |
+
|
| 124 |
+
@app.get("/api/proxy/video")
|
| 125 |
+
def proxy_video(url: str = Query(...), request: Request = None):
|
| 126 |
+
try:
|
| 127 |
+
req_headers = dict(HEADERS)
|
| 128 |
+
if request and request.headers.get("range"): req_headers["Range"] = request.headers["range"]
|
| 129 |
+
r = requests.get(url, headers=req_headers, timeout=30, stream=True)
|
| 130 |
+
resp_headers = {"Access-Control-Allow-Origin":"*","Accept-Ranges":"bytes","Content-Type":r.headers.get("Content-Type","video/mp4")}
|
| 131 |
+
if "Content-Range" in r.headers: resp_headers["Content-Range"] = r.headers["Content-Range"]
|
| 132 |
+
if "Content-Length" in r.headers: resp_headers["Content-Length"] = r.headers["Content-Length"]
|
| 133 |
+
return StreamingResponse(r.iter_content(chunk_size=256*1024), status_code=r.status_code, headers=resp_headers)
|
| 134 |
+
except: return Response(status_code=502, content="proxy error")
|
| 135 |
+
|
| 136 |
+
@app.get("/api/proxy/img")
|
| 137 |
+
def proxy_img(url: str = Query(...)):
|
| 138 |
+
try:
|
| 139 |
+
from urllib.parse import urlparse
|
| 140 |
+
_u = urlparse(url); _host = _u.netloc.lower()
|
| 141 |
+
_referer = "https://dantri.com.vn/"
|
| 142 |
+
if "refooty" in _host or "xemlaibongda" in _host: _referer = "https://xemlaibongda.top/"
|
| 143 |
+
elif "ytimg" in _host or "youtube" in _host: _referer = "https://www.youtube.com/"
|
| 144 |
+
elif "vncecdn" in _host or "vnexpress" in _host: _referer = "https://vnexpress.net/"
|
| 145 |
+
r = requests.get(url, headers={**HEADERS, "Referer": _referer}, timeout=10)
|
| 146 |
+
if r.status_code != 200: return Response(status_code=502)
|
| 147 |
+
return Response(content=r.content, media_type=r.headers.get("Content-Type", "image/jpeg"), headers={"Cache-Control": "public, max-age=86400", "Access-Control-Allow-Origin": "*"})
|
| 148 |
+
except: return Response(status_code=502)
|
| 149 |
+
|
| 150 |
+
# ===== XEMLAIBONGDA HIGHLIGHTS =====
|
| 151 |
+
def _scrape_xemlaibongda_page(page_path, limit=20):
|
| 152 |
+
try:
|
| 153 |
+
url = f"https://xemlaibongda.top/{page_path}" if page_path else "https://xemlaibongda.top/"
|
| 154 |
+
r = requests.get(url, headers=HEADERS, timeout=15)
|
| 155 |
+
if r.status_code != 200: return []
|
| 156 |
+
r.encoding = "utf-8"
|
| 157 |
+
soup = BeautifulSoup(r.text, "lxml")
|
| 158 |
+
videos = []; seen = set()
|
| 159 |
+
for a in soup.find_all("a", href=True):
|
| 160 |
+
href = a.get("href", "")
|
| 161 |
+
if "/video/" not in href and "/xem-lai/" not in href: continue
|
| 162 |
+
if not href.startswith("http"): href = "https://xemlaibongda.top" + href
|
| 163 |
+
clean_href = href.split("?")[0].split("#")[0]
|
| 164 |
+
if clean_href in seen: continue
|
| 165 |
+
seen.add(clean_href)
|
| 166 |
+
img_src = ""
|
| 167 |
+
img = a.find("img")
|
| 168 |
+
if not img and a.parent: img = a.parent.find("img")
|
| 169 |
+
if not img:
|
| 170 |
+
p = a.parent
|
| 171 |
+
for _ in range(4):
|
| 172 |
+
if p and p.find("img"): img = p.find("img"); break
|
| 173 |
+
p = p.parent if p else None
|
| 174 |
+
if img:
|
| 175 |
+
img_src = (img.get("data-src", "") or img.get("src", "") or img.get("data-lazy", "") or img.get("data-original", "") or img.get("data-thumb", "") or img.get("data-image", ""))
|
| 176 |
+
if img_src.startswith("//"): img_src = "https:" + img_src
|
| 177 |
+
elif img_src.startswith("/"): img_src = "https://xemlaibongda.top" + img_src
|
| 178 |
+
if not img_src:
|
| 179 |
+
p = a.parent
|
| 180 |
+
for _ in range(5):
|
| 181 |
+
if p is None: break
|
| 182 |
+
style = p.get("style", "")
|
| 183 |
+
bg_match = re.search(r'url\(["\']?(.*?)["\']?\)', style)
|
| 184 |
+
if bg_match:
|
| 185 |
+
img_src = bg_match.group(1)
|
| 186 |
+
if img_src.startswith("//"): img_src = "https:" + img_src
|
| 187 |
+
elif img_src.startswith("/"): img_src = "https://xemlaibongda.top" + img_src
|
| 188 |
+
break
|
| 189 |
+
p = p.parent if p else None
|
| 190 |
+
title = ""
|
| 191 |
+
for attr in ["title", "aria-label"]:
|
| 192 |
+
val = a.get(attr, "")
|
| 193 |
+
if val and len(val) >= 5: title = val; break
|
| 194 |
+
if not title:
|
| 195 |
+
for selector in ["h3", "h2", "h4", ".title", ".video-title", "strong"]:
|
| 196 |
+
try:
|
| 197 |
+
el = a.select_one(selector)
|
| 198 |
+
if el: t = el.get_text(strip=True)
|
| 199 |
+
if t and len(t) >= 5: title = t; break
|
| 200 |
+
except: pass
|
| 201 |
+
if not title:
|
| 202 |
+
text = a.get_text(strip=True)
|
| 203 |
+
if text and len(text) >= 5: title = text[:100]
|
| 204 |
+
if not title or len(title) < 3:
|
| 205 |
+
slug = clean_href.split("/video/")[-1].rstrip("/").split("/xem-lai/")[-1].rstrip("/")
|
| 206 |
+
title = slug.replace("-", " ").replace("_", " ").title()
|
| 207 |
+
title = re.sub(r'\d{4}-\d{2}-\d{2}', '', title).strip()
|
| 208 |
+
if not title or len(title) < 3: continue
|
| 209 |
+
if not img_src:
|
| 210 |
+
slug = clean_href.split("/video/")[-1].rstrip("/").split("/xem-lai/")[-1].rstrip("/")
|
| 211 |
+
img_src = f"https://xemlaibongda.top/uploads/thumb/{slug}.jpg"
|
| 212 |
+
videos.append({"title": title[:100], "link": clean_href, "img": img_src, "source": "xemlaibongda"})
|
| 213 |
+
if len(videos) >= limit: break
|
| 214 |
+
return videos
|
| 215 |
+
except Exception as e:
|
| 216 |
+
print(f"[xemlaibongda] Error: {e}"); return []
|
| 217 |
+
|
| 218 |
+
def scrape_xemlaibongda(): return _scrape_xemlaibongda_page("", 20)
|
| 219 |
+
def scrape_highlights_by_league(league_key):
|
| 220 |
+
if league_key not in HL_LEAGUES: return []
|
| 221 |
+
vids = _scrape_xemlaibongda_page(HL_LEAGUES[league_key]["path"], 20)
|
| 222 |
+
if vids: return vids
|
| 223 |
+
# Fallback: bongdaplus general video feed, only for the primary home league to avoid duplicate rows
|
| 224 |
+
if league_key == "premier-league":
|
| 225 |
+
return scrape_bongdaplus_videos(20)
|
| 226 |
+
return []
|
| 227 |
+
def scrape_all_league_highlights():
|
| 228 |
+
results = {}
|
| 229 |
+
def _fetch(key): return key, scrape_highlights_by_league(key)
|
| 230 |
+
with ThreadPoolExecutor(8) as ex:
|
| 231 |
+
futs = [ex.submit(_fetch, k) for k in HL_LEAGUES]
|
| 232 |
+
for f in as_completed(futs, timeout=25):
|
| 233 |
+
try: key, vids = f.result()
|
| 234 |
+
except: continue
|
| 235 |
+
if vids: results[key] = vids
|
| 236 |
+
if not results:
|
| 237 |
+
fb = scrape_bongdaplus_videos(20)
|
| 238 |
+
if fb:
|
| 239 |
+
results["premier-league"] = fb
|
| 240 |
+
return results
|
| 241 |
+
|
| 242 |
+
BDP_HL = "https://bongdaplus.vn/video"
|
| 243 |
+
def scrape_bongdaplus_videos(limit=20):
|
| 244 |
+
"""Scrape football highlight/clip videos from bongdaplus.vn/video (reachable from HF Spaces)."""
|
| 245 |
+
try:
|
| 246 |
+
r = requests.get(BDP_HL, headers=HEADERS, timeout=15)
|
| 247 |
+
if r.status_code != 200: return []
|
| 248 |
+
r.encoding = "utf-8"
|
| 249 |
+
soup = BeautifulSoup(r.text, "lxml")
|
| 250 |
+
videos = []; seen = set()
|
| 251 |
+
for a in soup.find_all("a", href=True):
|
| 252 |
+
href = a.get("href","")
|
| 253 |
+
if "/video/" not in href or not href.endswith(".html"): continue
|
| 254 |
+
if not href.startswith("http"): href = "https://bongdaplus.vn" + href
|
| 255 |
+
link = href.split("?")[0].split("#")[0]
|
| 256 |
+
if link in seen: continue
|
| 257 |
+
seen.add(link)
|
| 258 |
+
img_src = ""
|
| 259 |
+
img = a.find("img")
|
| 260 |
+
if img:
|
| 261 |
+
img_src = img.get("data-src","") or img.get("src","") or ""
|
| 262 |
+
title = ""
|
| 263 |
+
t = a.get("title","") or ""
|
| 264 |
+
if t and len(t)>=5: title = t
|
| 265 |
+
if not title:
|
| 266 |
+
img_alt = img.get("alt","") if img else ""
|
| 267 |
+
if img_alt and len(img_alt)>=5: title = img_alt
|
| 268 |
+
if not title:
|
| 269 |
+
txt = a.get_text(strip=True)
|
| 270 |
+
if txt and len(txt)>=5: title = txt
|
| 271 |
+
title = re.sub(r'^(VIDEO\s*:\s*|VIDEO\s+)', '', title, flags=re.I).strip()
|
| 272 |
+
if not title or len(title)<5: continue
|
| 273 |
+
if img_src and img_src.startswith("//"): img_src = "https:" + img_src
|
| 274 |
+
videos.append({"title": title[:100], "link": link, "img": img_src, "source": "bongdaplus"})
|
| 275 |
+
if len(videos) >= limit: break
|
| 276 |
+
return videos
|
| 277 |
+
except Exception as e:
|
| 278 |
+
print(f"[bongdaplus] Error: {e}"); return []
|
| 279 |
+
|
| 280 |
+
def extract_bongdaplus_video(url):
|
| 281 |
+
"""Extract direct MP4 from bongdaplus.vn video detail via its embed page."""
|
| 282 |
+
try:
|
| 283 |
+
m = re.search(r'/video/(?:[^/]+-)?(\d+)\.html', url)
|
| 284 |
+
if not m:
|
| 285 |
+
return None
|
| 286 |
+
vid = m.group(1)
|
| 287 |
+
embed = f"https://bongdaplus.vn/video-embed/{vid}.html"
|
| 288 |
+
r = requests.get(embed, headers=HEADERS, timeout=15)
|
| 289 |
+
if r.status_code != 200: return None
|
| 290 |
+
r.encoding = "utf-8"
|
| 291 |
+
soup = BeautifulSoup(r.text, "lxml")
|
| 292 |
+
video = soup.find("video")
|
| 293 |
+
src = ""
|
| 294 |
+
poster = ""
|
| 295 |
+
if video:
|
| 296 |
+
src = video.get("src","")
|
| 297 |
+
poster = video.get("poster","")
|
| 298 |
+
if not src:
|
| 299 |
+
src_el = soup.find("source")
|
| 300 |
+
if src_el: src = src_el.get("src","")
|
| 301 |
+
if not src: return None
|
| 302 |
+
src = src.split("?")[0].split("#")[0]
|
| 303 |
+
# Only accept real media; placeholder .jpg/jpeg/png means video not available
|
| 304 |
+
if not re.search(r'\.(mp4|m3u8|webm)$', src, re.I):
|
| 305 |
+
return None
|
| 306 |
+
if not poster:
|
| 307 |
+
og = soup.find("meta",property="og:image")
|
| 308 |
+
if og: poster = og.get("content","")
|
| 309 |
+
return {"src": src, "poster": poster, "type": "video"}
|
| 310 |
+
except Exception:
|
| 311 |
+
return None
|
| 312 |
+
|
| 313 |
+
def extract_xemlaibongda_video(url):
|
| 314 |
+
try:
|
| 315 |
+
r=requests.get(url, headers=HEADERS, timeout=15)
|
| 316 |
+
if r.status_code!=200: return None
|
| 317 |
+
r.encoding="utf-8"; soup=BeautifulSoup(r.text,"lxml")
|
| 318 |
+
og=soup.find("meta",property="og:image")
|
| 319 |
+
og_poster=og.get("content","") if og else ""
|
| 320 |
+
if og_poster.startswith("//"): og_poster="https:"+og_poster
|
| 321 |
+
video=soup.find("video")
|
| 322 |
+
if video:
|
| 323 |
+
src=video.get("src",""); poster=video.get("poster","")
|
| 324 |
+
if not src:
|
| 325 |
+
source=video.find("source")
|
| 326 |
+
if source: src=source.get("src","")
|
| 327 |
+
if not poster: poster=og_poster
|
| 328 |
+
if src: return{"src":src,"poster":poster,"type":"hls" if".m3u8" in src else"video"}
|
| 329 |
+
m3u8s=re.findall(r'(https?://[^\s"\'<>]+\.m3u8)',r.text)
|
| 330 |
+
if m3u8s: return{"src":m3u8s[0],"poster":og_poster,"type":"hls"}
|
| 331 |
+
yt_iframe = soup.find("iframe", src=re.compile(r"youtube\.com/embed|youtube-nocookie\.com/embed"))
|
| 332 |
+
if yt_iframe: return{"src":yt_iframe.get("src",""),"poster":og_poster,"type":"youtube"}
|
| 333 |
+
return None
|
| 334 |
+
except: return None
|
| 335 |
+
|
| 336 |
+
# ===== LIVESCORE =====
|
| 337 |
+
@app.get("/api/livescore/live")
|
| 338 |
+
def api_livescore_live(): return JSONResponse({"html":_cached("ls_live",lambda:fetch_bongda_api("/api/fixtures/live"),ttl=_cache_ttl_live)})
|
| 339 |
+
@app.get("/api/livescore/incoming")
|
| 340 |
+
def api_livescore_incoming(): return JSONResponse({"html":_cached("ls_incoming",lambda:fetch_bongda_api("/api/fixtures/incoming"),ttl=_cache_ttl_live)})
|
| 341 |
+
@app.get("/api/livescore/today")
|
| 342 |
+
def api_livescore_today():
|
| 343 |
+
today=datetime.now(VN_TZ).strftime("%Y-%m-%d");return JSONResponse({"html":_cached("ls_today",lambda:fetch_bongda_api(f"/api/fixtures/get-by-date?date={today}"),ttl=_cache_ttl)})
|
| 344 |
+
@app.get("/api/livescore/results")
|
| 345 |
+
def api_livescore_results():
|
| 346 |
+
today=datetime.now(VN_TZ).strftime("%Y-%m-%d");return JSONResponse({"html":_cached("ls_results",lambda:fetch_bongda_api(f"/api/fixtures/get-by-date?date={today}&status=finished"),ttl=_cache_ttl)})
|
| 347 |
+
@app.get("/api/livescore/standings/{league}")
|
| 348 |
+
def api_livescore_standings(league:str):
|
| 349 |
+
tid=LEAGUE_IDS.get(league,27110);return JSONResponse({"html":_cached(f"ls_bxh_{league}",lambda:fetch_bongda_api(f"/api/league-table/home?tournament_id={tid}&is_detail=True"),ttl=_cache_ttl)})
|
| 350 |
+
@app.get("/api/livescore/date/{date}")
|
| 351 |
+
def api_livescore_date(date:str):return JSONResponse({"html":fetch_bongda_api(f"/api/fixtures/get-by-date?date={date}")})
|
| 352 |
+
|
| 353 |
+
def _compute_updates7d():
|
| 354 |
+
from datetime import date as _date
|
| 355 |
+
today = _date.today()
|
| 356 |
+
all_html = []
|
| 357 |
+
# Past 7 days (results)
|
| 358 |
+
for i in range(7, 0, -1):
|
| 359 |
+
d = (today - timedelta(days=i)).strftime("%Y-%m-%d")
|
| 360 |
+
html = fetch_bongda_api(f"/api/fixtures/get-by-date?date={d}&status=finished")
|
| 361 |
+
if html and len(html) > 50:
|
| 362 |
+
soup = BeautifulSoup(html, "lxml")
|
| 363 |
+
day_label = (today - timedelta(days=i)).strftime("%d/%m")
|
| 364 |
+
for match in soup.select(".match-detail"):
|
| 365 |
+
dt = soup.new_tag("div", **{"class": "datetime"})
|
| 366 |
+
dt.string = f"📅 {day_label}"
|
| 367 |
+
match.insert(0, dt)
|
| 368 |
+
all_html.append(str(soup))
|
| 369 |
+
# Next 7 days (upcoming)
|
| 370 |
+
for i in range(7):
|
| 371 |
+
d = (today + timedelta(days=i)).strftime("%Y-%m-%d")
|
| 372 |
+
html = fetch_bongda_api(f"/api/fixtures/get-by-date?date={d}")
|
| 373 |
+
if html and len(html) > 50:
|
| 374 |
+
soup = BeautifulSoup(html, "lxml")
|
| 375 |
+
day_label = (today + timedelta(days=i)).strftime("%d/%m")
|
| 376 |
+
for match in soup.select(".match-detail"):
|
| 377 |
+
dt = soup.new_tag("div", **{"class": "datetime"})
|
| 378 |
+
dt.string = f"📅 {day_label}"
|
| 379 |
+
match.insert(0, dt)
|
| 380 |
+
all_html.append(str(soup))
|
| 381 |
+
combined = "<div class='updates7d'>" + "".join(all_html) + "</div>"
|
| 382 |
+
return combined if all_html else ""
|
| 383 |
+
|
| 384 |
+
_updates7d_cache = {"t": 0, "d": "", "busy": False}
|
| 385 |
+
_updates7d_lock = threading.Lock()
|
| 386 |
+
def _start_updates7d_refresh():
|
| 387 |
+
def _run():
|
| 388 |
+
try:
|
| 389 |
+
with _updates7d_lock:
|
| 390 |
+
if _updates7d_cache["busy"]: return
|
| 391 |
+
_updates7d_cache["busy"] = True
|
| 392 |
+
data = _compute_updates7d()
|
| 393 |
+
with _updates7d_lock:
|
| 394 |
+
if data:
|
| 395 |
+
_updates7d_cache["t"] = time.time()
|
| 396 |
+
_updates7d_cache["d"] = data
|
| 397 |
+
_updates7d_cache["busy"] = False
|
| 398 |
+
except Exception:
|
| 399 |
+
with _updates7d_lock:
|
| 400 |
+
_updates7d_cache["busy"] = False
|
| 401 |
+
th = threading.Thread(target=_run, daemon=True)
|
| 402 |
+
th.start()
|
| 403 |
+
|
| 404 |
+
@app.get("/api/livescore/updates7d")
|
| 405 |
+
def api_livescore_updates7d():
|
| 406 |
+
"""Aggregate matches. Never blocks synchronously — returns cached value instantly
|
| 407 |
+
and refreshes in a background thread. Pre-warmed at startup."""
|
| 408 |
+
now = time.time()
|
| 409 |
+
with _updates7d_lock:
|
| 410 |
+
fresh = _updates7d_cache["d"] and (now - _updates7d_cache["t"] < _cache_ttl)
|
| 411 |
+
stale = _updates7d_cache["d"]
|
| 412 |
+
if not fresh:
|
| 413 |
+
_start_updates7d_refresh()
|
| 414 |
+
return JSONResponse({"html": stale or "", "cached": bool(stale)})
|
| 415 |
+
|
| 416 |
+
# ===== LIVESCORE RECENT (prioritized: upcoming → just-finished today → yesterday → older) =====
|
| 417 |
+
def _compute_recent():
|
| 418 |
+
from datetime import date as _date
|
| 419 |
+
today = _date.today()
|
| 420 |
+
all_html = []
|
| 421 |
+
# 1) Trận sắp tới (hôm nay + ngày mai)
|
| 422 |
+
for offset in [0, 1]:
|
| 423 |
+
d = (today + timedelta(days=offset)).strftime("%Y-%m-%d")
|
| 424 |
+
html = fetch_bongda_api(f"/api/fixtures/get-by-date?date={d}")
|
| 425 |
+
if html and len(html) > 50:
|
| 426 |
+
soup = BeautifulSoup(html, "lxml")
|
| 427 |
+
day_label = (today + timedelta(days=offset)).strftime("%d/%m")
|
| 428 |
+
for match in soup.select(".match-detail"):
|
| 429 |
+
dt = soup.new_tag("div", **{"class": "datetime"})
|
| 430 |
+
dt.string = f"📅 {day_label}"
|
| 431 |
+
match.insert(0, dt)
|
| 432 |
+
all_html.append(str(soup))
|
| 433 |
+
# 2) Vừa kết thúc hôm nay (results)
|
| 434 |
+
html = fetch_bongda_api(f"/api/fixtures/get-by-date?date={today.strftime('%Y-%m-%d')}&status=finished")
|
| 435 |
+
if html and len(html) > 50:
|
| 436 |
+
soup = BeautifulSoup(html, "lxml")
|
| 437 |
+
day_label = today.strftime("%d/%m")
|
| 438 |
+
for match in soup.select(".match-detail"):
|
| 439 |
+
dt = soup.new_tag("div", **{"class": "datetime"})
|
| 440 |
+
dt.string = f"📅 {day_label}"
|
| 441 |
+
match.insert(0, dt)
|
| 442 |
+
all_html.append(str(soup))
|
| 443 |
+
# 3) Hôm qua (results)
|
| 444 |
+
yesterday = today - timedelta(days=1)
|
| 445 |
+
d = yesterday.strftime("%Y-%m-%d")
|
| 446 |
+
html = fetch_bongda_api(f"/api/fixtures/get-by-date?date={d}&status=finished")
|
| 447 |
+
if html and len(html) > 50:
|
| 448 |
+
soup = BeautifulSoup(html, "lxml")
|
| 449 |
+
day_label = yesterday.strftime("%d/%m")
|
| 450 |
+
for match in soup.select(".match-detail"):
|
| 451 |
+
dt = soup.new_tag("div", **{"class": "datetime"})
|
| 452 |
+
dt.string = f"📅 {day_label}"
|
| 453 |
+
match.insert(0, dt)
|
| 454 |
+
all_html.append(str(soup))
|
| 455 |
+
# 4) Các ngày cũ hơn (results, 2 ngày trước → 6 ngày trước)
|
| 456 |
+
for i in range(2, 7):
|
| 457 |
+
d = (today - timedelta(days=i)).strftime("%Y-%m-%d")
|
| 458 |
+
html = fetch_bongda_api(f"/api/fixtures/get-by-date?date={d}&status=finished")
|
| 459 |
+
if html and len(html) > 50:
|
| 460 |
+
soup = BeautifulSoup(html, "lxml")
|
| 461 |
+
day_label = (today - timedelta(days=i)).strftime("%d/%m")
|
| 462 |
+
for match in soup.select(".match-detail"):
|
| 463 |
+
dt = soup.new_tag("div", **{"class": "datetime"})
|
| 464 |
+
dt.string = f"📅 {day_label}"
|
| 465 |
+
match.insert(0, dt)
|
| 466 |
+
all_html.append(str(soup))
|
| 467 |
+
combined = "<div class='updates7d'>" + "".join(all_html) + "</div>"
|
| 468 |
+
return combined if all_html else ""
|
| 469 |
+
|
| 470 |
+
_recent_cache = {"t": 0, "d": "", "busy": False}
|
| 471 |
+
_recent_lock = threading.Lock()
|
| 472 |
+
def _start_recent_refresh():
|
| 473 |
+
def _run():
|
| 474 |
+
try:
|
| 475 |
+
with _recent_lock:
|
| 476 |
+
if _recent_cache["busy"]: return
|
| 477 |
+
_recent_cache["busy"] = True
|
| 478 |
+
data = _compute_recent()
|
| 479 |
+
with _recent_lock:
|
| 480 |
+
if data:
|
| 481 |
+
_recent_cache["t"] = time.time()
|
| 482 |
+
_recent_cache["d"] = data
|
| 483 |
+
_recent_cache["busy"] = False
|
| 484 |
+
except Exception:
|
| 485 |
+
with _recent_lock:
|
| 486 |
+
_recent_cache["busy"] = False
|
| 487 |
+
th = threading.Thread(target=_run, daemon=True)
|
| 488 |
+
th.start()
|
| 489 |
+
|
| 490 |
+
@app.get("/api/livescore/recent")
|
| 491 |
+
def api_livescore_recent():
|
| 492 |
+
"""Recent matches with new priority: upcoming today/tomorrow → finished today → yesterday → older."""
|
| 493 |
+
now = time.time()
|
| 494 |
+
with _recent_lock:
|
| 495 |
+
fresh = _recent_cache["d"] and (now - _recent_cache["t"] < _cache_ttl)
|
| 496 |
+
stale = _recent_cache["d"]
|
| 497 |
+
if not fresh:
|
| 498 |
+
_start_recent_refresh()
|
| 499 |
+
return JSONResponse({"html": stale or "", "cached": bool(stale)})
|
| 500 |
+
|
| 501 |
+
def _warm_recent():
|
| 502 |
+
def _warm():
|
| 503 |
+
try:
|
| 504 |
+
time.sleep(3)
|
| 505 |
+
_start_recent_refresh()
|
| 506 |
+
except Exception:
|
| 507 |
+
pass
|
| 508 |
+
th = threading.Thread(target=_warm, daemon=True)
|
| 509 |
+
th.start()
|
| 510 |
+
_warm_recent()
|
| 511 |
+
|
| 512 |
+
def _warm_updates7d():
|
| 513 |
+
# Lightweight startup pre-warm so the first user request is instant.
|
| 514 |
+
def _warm():
|
| 515 |
+
try:
|
| 516 |
+
time.sleep(3)
|
| 517 |
+
_start_updates7d_refresh()
|
| 518 |
+
except Exception:
|
| 519 |
+
pass
|
| 520 |
+
th = threading.Thread(target=_warm, daemon=True)
|
| 521 |
+
th.start()
|
| 522 |
+
_warm_updates7d()
|
| 523 |
+
|
| 524 |
+
@app.get("/api/match/{event_id}/commentaries")
|
| 525 |
+
def api_match_commentaries(event_id:int):return JSONResponse({"html":fetch_bongda_api(f"/api/fixtures/commentaries?event_id={event_id}")})
|
| 526 |
+
@app.get("/api/match/{event_id}/stats")
|
| 527 |
+
def api_match_stats(event_id:int):return JSONResponse({"html":fetch_bongda_api(f"/api/event-standing/player-performance?event_id={event_id}")})
|
| 528 |
+
|
| 529 |
+
from match_detail_v2 import fetch_match_detail, fetch_match_detail_by_url
|
| 530 |
+
|
| 531 |
+
@app.get("/api/match/{event_id}/detail")
|
| 532 |
+
def api_match_detail(event_id: int, url: str = Query(default="")):
|
| 533 |
+
try:
|
| 534 |
+
if url: data = fetch_match_detail_by_url(url)
|
| 535 |
+
else: data = fetch_match_detail(event_id)
|
| 536 |
+
return JSONResponse(data)
|
| 537 |
+
except Exception as e: return JSONResponse({"event_id": event_id, "found": False, "error": str(e)})
|
| 538 |
+
|
| 539 |
+
@app.get("/api/livescore/featured")
|
| 540 |
+
def api_livescore_featured():
|
| 541 |
+
def _f():
|
| 542 |
+
sources=[("/api/fixtures/live","live"),("/api/fixtures/get-by-date?date="+datetime.now(VN_TZ).strftime("%Y-%m-%d"),"today"),("/api/fixtures/incoming","upcoming")]
|
| 543 |
+
for endpoint, stype in sources:
|
| 544 |
+
html=fetch_bongda_api(endpoint)
|
| 545 |
+
if not html or len(html)<100:continue
|
| 546 |
+
soup=BeautifulSoup(html,"lxml");all_matches=[]
|
| 547 |
+
for li in soup.select("li.match-detail"):
|
| 548 |
+
match=_parse_match_from_li(li, stype)
|
| 549 |
+
if not match or not match["event_id"]:continue
|
| 550 |
+
if stype=="today" and "KT" in match.get("minute",""):continue
|
| 551 |
+
all_matches.append(match)
|
| 552 |
+
if not all_matches:continue
|
| 553 |
+
for pl in PRIORITY_LEAGUES:
|
| 554 |
+
for match in all_matches:
|
| 555 |
+
if pl in match["league"]:return match
|
| 556 |
+
return all_matches[0]
|
| 557 |
+
return None
|
| 558 |
+
return JSONResponse(_cached("ls_featured",_f,ttl=30))
|
| 559 |
+
|
| 560 |
+
@app.get("/api/highlights")
|
| 561 |
+
def api_highlights(): return JSONResponse(_cached("xemlaibongda_hl",scrape_xemlaibongda,ttl=_cache_ttl))
|
| 562 |
+
@app.get("/api/highlights/leagues")
|
| 563 |
+
def api_highlights_leagues(): return JSONResponse(_cached("hl_leagues",scrape_all_league_highlights,ttl=_cache_ttl))
|
| 564 |
+
@app.get("/api/highlights/{league}")
|
| 565 |
+
def api_highlights_league(league:str):
|
| 566 |
+
if league not in HL_LEAGUES: return JSONResponse({"error":"league not found"})
|
| 567 |
+
return JSONResponse(_cached(f"hl_{league}",lambda:scrape_highlights_by_league(league),ttl=_cache_ttl))
|
| 568 |
+
|
| 569 |
+
@app.get("/api/highlights/{league}/page")
|
| 570 |
+
def api_highlights_league_page(league:str, page:int=Query(default=0, ge=0), limit:int=Query(default=15, ge=5, le=40)):
|
| 571 |
+
"""Paginated league highlights for 'Xem thêm' load-more. Supports 'all' to fetch every league aggregated."""
|
| 572 |
+
if league == "all":
|
| 573 |
+
all_vids = []
|
| 574 |
+
with ThreadPoolExecutor(8) as ex:
|
| 575 |
+
futs = {ex.submit(scrape_highlights_by_league, k): k for k in HL_LEAGUES}
|
| 576 |
+
for f in as_completed(futs, timeout=25):
|
| 577 |
+
try:
|
| 578 |
+
all_vids.extend(f.result())
|
| 579 |
+
except: pass
|
| 580 |
+
start = page * limit
|
| 581 |
+
end = start + limit
|
| 582 |
+
paged = all_vids[start:end]
|
| 583 |
+
return JSONResponse({"videos": paged, "league": "all", "page": page, "has_more": end < len(all_vids), "total": len(all_vids)})
|
| 584 |
+
if league not in HL_LEAGUES: return JSONResponse({"error":"league not found"})
|
| 585 |
+
all_vids = scrape_highlights_by_league(league)
|
| 586 |
+
start = page * limit
|
| 587 |
+
end = start + limit
|
| 588 |
+
paged = all_vids[start:end]
|
| 589 |
+
return JSONResponse({"videos": paged, "league": league, "page": page, "has_more": end < len(all_vids), "total": len(all_vids)})
|
| 590 |
+
|
| 591 |
+
@app.get("/api/video_url")
|
| 592 |
+
def api_video_url(url:str=Query(...), img:str=Query(default="")):
|
| 593 |
+
if "youtube.com" in url or "youtu.be" in url:
|
| 594 |
+
m=re.search(r'(?:v=|shorts/|youtu\.be/)([a-zA-Z0-9_-]{11})',url)
|
| 595 |
+
if m: vid=m.group(1); return JSONResponse({"src":f"https://www.youtube.com/embed/{vid}?autoplay=1&rel=0&enablejsapi=1","poster":f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg","type":"youtube"})
|
| 596 |
+
if "xemlaibongda.top" in url:
|
| 597 |
+
v=extract_xemlaibongda_video(url)
|
| 598 |
+
if v:
|
| 599 |
+
if v["type"]=="hls": v["src"]="/api/proxy/m3u8?url="+quote(v["src"],safe="")
|
| 600 |
+
if not v.get("poster") and img: v["poster"] = img
|
| 601 |
+
return JSONResponse(v)
|
| 602 |
+
if "bongdaplus.vn" in url and "/video/" in url:
|
| 603 |
+
v=extract_bongdaplus_video(url)
|
| 604 |
+
if v:
|
| 605 |
+
if not v.get("poster") and img: v["poster"] = img
|
| 606 |
+
return JSONResponse(v)
|
| 607 |
+
return JSONResponse({"error":"not found"})
|
| 608 |
+
|
| 609 |
+
# ===== WORLD CUP 2026 API =====
|
| 610 |
+
_wc_request_times = []; _wc_rate_limit_lock = threading.Lock()
|
| 611 |
+
_WC_RATE_LIMIT = 10
|
| 612 |
+
def _wc_rate_limit():
|
| 613 |
+
global _wc_request_times
|
| 614 |
+
with _wc_rate_limit_lock:
|
| 615 |
+
now = time.time()
|
| 616 |
+
_wc_request_times = [t for t in _wc_request_times if now - t < 60]
|
| 617 |
+
if len(_wc_request_times) >= _WC_RATE_LIMIT: return False
|
| 618 |
+
_wc_request_times.append(now)
|
| 619 |
+
return True
|
| 620 |
+
|
| 621 |
+
@app.get("/api/wc2026")
|
| 622 |
+
def api_wc2026():
|
| 623 |
+
return JSONResponse(_cached("wc2026", get_wc2026_all, ttl=_cache_ttl))
|
| 624 |
+
|
| 625 |
+
@app.get("/api/wc2026/{tab}")
|
| 626 |
+
def api_wc2026_tab(tab: str):
|
| 627 |
+
valid_tabs = ["news", "fixtures", "standings", "stats", "highlights"]
|
| 628 |
+
if tab not in valid_tabs: return JSONResponse({"error": "invalid tab"}, status_code=400)
|
| 629 |
+
def _fetch_tab():
|
| 630 |
+
if tab == "highlights": return scrape_highlights_by_league("world-cup")
|
| 631 |
+
elif tab == "news": return scrape_wc_news()
|
| 632 |
+
elif tab == "fixtures": return scrape_fixtures()
|
| 633 |
+
elif tab == "standings": return scrape_standings()
|
| 634 |
+
elif tab == "stats": return scrape_stats()
|
| 635 |
+
return []
|
| 636 |
+
return JSONResponse(_cached(f"wc2026_{tab}", _fetch_tab, ttl=_cache_ttl))
|
| 637 |
+
|
| 638 |
+
@app.get("/api/bdp_videos")
|
| 639 |
+
def api_bdp_videos():
|
| 640 |
+
def _f():
|
| 641 |
+
try:
|
| 642 |
+
soup=_get(f"{BASE_BDP}/video"); arts=[]; seen=set()
|
| 643 |
+
for a in soup.find_all("a",href=True):
|
| 644 |
+
href=a.get("href","")
|
| 645 |
+
if"/video/" not in href or href in("/video/","/video/ban-thang-dep","/video/highlight"):continue
|
| 646 |
+
if not href.startswith("http"): href=BASE_BDP+href
|
| 647 |
+
if href in seen: continue
|
| 648 |
+
title=re.sub(r'^\d{2}:\d{2}','',a.get_text(strip=True)).strip()
|
| 649 |
+
if not title or len(title)<5: continue
|
| 650 |
+
img_tag=a.find("img") or(a.parent.find("img") if a.parent else None)
|
| 651 |
+
img=(img_tag.get("data-src") or img_tag.get("src","")) if img_tag else ""
|
| 652 |
+
seen.add(href); arts.append({"title":title,"link":href,"img":img,"source":"bdp"})
|
| 653 |
+
return arts[:20]
|
| 654 |
+
except: return []
|
| 655 |
+
return JSONResponse(_cached("bdp_videos",_f))
|
| 656 |
+
|
| 657 |
+
# ===== NEWS =====
|
| 658 |
+
VNE_CATS={"thoi-su":("https://vnexpress.net/thoi-su","Thời Sự"),"the-gioi":("https://vnexpress.net/the-gioi","Thế Giới"),"kinh-doanh":("https://vnexpress.net/kinh-doanh","Kinh Doanh"),"the-thao":("https://vnexpress.net/the-thao","Thể Thao"),"giai-tri":("https://vnexpress.net/giai-tri","Giải Trí"),"suc-khoe":("https://vnexpress.net/suc-khoe","Sức Khỏe"),"phap-luat":("https://vnexpress.net/phap-luat","Pháp Luật"),"giao-duc":("https://vnexpress.net/giao-duc","Giáo Dục"),"du-lich":("https://vnexpress.net/du-lich","Du Lịch"),"doi-song":("https://vnexpress.net/doi-song","Đời Sống")}
|
| 659 |
+
|
| 660 |
+
def scrape_vne(cat_url):
|
| 661 |
+
try:
|
| 662 |
+
soup=_get(cat_url); arts=[]
|
| 663 |
+
for it in soup.select("article.item-news")[:15]:
|
| 664 |
+
a=it.select_one("h2.title-news a") or it.select_one("h3.title-news a")
|
| 665 |
+
if not a: continue
|
| 666 |
+
t=a.get("title","") or a.get_text(strip=True); lk=a.get("href","")
|
| 667 |
+
if not t or not lk: continue
|
| 668 |
+
im=it.find("img"); img=(im.get("data-src") or im.get("src","")) if im else ""
|
| 669 |
+
if img and 'blank' in img:
|
| 670 |
+
src=it.find("source")
|
| 671 |
+
if src: img=src.get("srcset","").split(",")[0].strip().split(" ")[0]
|
| 672 |
+
arts.append({"title":t,"link":lk,"img":img,"source":"vne"})
|
| 673 |
+
return arts
|
| 674 |
+
except: return []
|
| 675 |
+
|
| 676 |
+
def scrape_genk_ai():
|
| 677 |
+
try:
|
| 678 |
+
r=requests.get("https://genk.vn/ai.chn",headers=HEADERS,timeout=15)
|
| 679 |
+
if r.status_code!=200: return []
|
| 680 |
+
r.encoding="utf-8"; soup=BeautifulSoup(r.text,"lxml"); articles=[]; seen=set()
|
| 681 |
+
for a in soup.find_all("a",href=True):
|
| 682 |
+
href=a.get("href","")
|
| 683 |
+
if not href.endswith(".chn") or href=="/ai.chn": continue
|
| 684 |
+
if href.startswith("/"): href="https://genk.vn"+href
|
| 685 |
+
if href in seen or "genk.vn" not in href: continue
|
| 686 |
+
title=a.get("title","") or a.get_text(strip=True)
|
| 687 |
+
if not title or len(title)<20: continue
|
| 688 |
+
container=a.parent; img_src=""
|
| 689 |
+
for _ in range(6):
|
| 690 |
+
if container is None: break
|
| 691 |
+
for img in container.find_all("img"):
|
| 692 |
+
s=img.get("data-src","") or img.get("src","")
|
| 693 |
+
if s and "mediacdn" in s and "avatar" not in s and "logo" not in s: img_src=s; break
|
| 694 |
+
if img_src: break; container=container.parent
|
| 695 |
+
seen.add(href)
|
| 696 |
+
if not img_src:
|
| 697 |
+
try:
|
| 698 |
+
og_r=requests.get(href,headers=HEADERS,timeout=8); og_r.encoding="utf-8"
|
| 699 |
+
og_soup=BeautifulSoup(og_r.text,"lxml"); og_tag=og_soup.find("meta",property="og:image")
|
| 700 |
+
if og_tag: img_src=og_tag.get("content","")
|
| 701 |
+
except: pass
|
| 702 |
+
articles.append({"title":title,"link":href,"img":img_src,"source":"genk"})
|
| 703 |
+
if len(articles)>=30: break
|
| 704 |
+
return articles
|
| 705 |
+
except: return []
|
| 706 |
+
|
| 707 |
+
@app.get("/api/homepage")
|
| 708 |
+
def api_homepage():
|
| 709 |
+
def _f():
|
| 710 |
+
articles=[]
|
| 711 |
+
with ThreadPoolExecutor(12) as ex:
|
| 712 |
+
futs={ex.submit(scrape_vne,VNE_CATS[k][0]):VNE_CATS[k][1] for k in["thoi-su","the-gioi","kinh-doanh","the-thao","giai-tri","phap-luat","giao-duc","du-lich","doi-song"]}
|
| 713 |
+
for f in as_completed(futs):
|
| 714 |
+
try:
|
| 715 |
+
for a in f.result(): a["group"]=futs[f]; articles.append(a)
|
| 716 |
+
except: pass
|
| 717 |
+
return articles
|
| 718 |
+
return JSONResponse(_cached("homepage",_f))
|
| 719 |
+
|
| 720 |
+
@app.get("/api/category/{cat_id}")
|
| 721 |
+
def api_category(cat_id:str):
|
| 722 |
+
def _f():
|
| 723 |
+
if cat_id=="cong-nghe": return scrape_genk_ai()
|
| 724 |
+
if cat_id in VNE_CATS:
|
| 725 |
+
arts=scrape_vne(VNE_CATS[cat_id][0])
|
| 726 |
+
[a.update({"group":VNE_CATS[cat_id][1]}) for a in arts]
|
| 727 |
+
return arts
|
| 728 |
+
return []
|
| 729 |
+
return JSONResponse(_cached(f"cat_{cat_id}",_f))
|
| 730 |
+
|
| 731 |
+
@app.get("/api/categories")
|
| 732 |
+
def api_categories():
|
| 733 |
+
cats=[{"id":"cong-nghe","name":"Công Nghệ","source":"genk"}]
|
| 734 |
+
for k,(u,n) in VNE_CATS.items(): cats.append({"id":k,"name":n,"source":"vne"})
|
| 735 |
+
return JSONResponse(cats)
|
| 736 |
+
|
| 737 |
+
@app.get("/api/proxy/xlb")
|
| 738 |
+
def api_xlb(path: str = Query(default=""), limit: int = Query(default=20)):
|
| 739 |
+
try:
|
| 740 |
+
url = f"https://xemlaibongda.top/{path}" if path else "https://xemlaibongda.top/"
|
| 741 |
+
r = requests.get(url, headers=HEADERS, timeout=15)
|
| 742 |
+
if r.status_code != 200: return JSONResponse({"videos": []})
|
| 743 |
+
r.encoding = "utf-8"
|
| 744 |
+
soup = BeautifulSoup(r.text, "lxml")
|
| 745 |
+
videos, seen = [], set()
|
| 746 |
+
for a in soup.find_all("a", href=True):
|
| 747 |
+
href = a.get("href", "")
|
| 748 |
+
if "/video/" not in href and "/xem-lai/" not in href: continue
|
| 749 |
+
if not href.startswith("http"): href = "https://xemlaibongda.top" + href
|
| 750 |
+
clean = href.split("?")[0].split("#")[0]
|
| 751 |
+
if clean in seen: continue
|
| 752 |
+
seen.add(clean)
|
| 753 |
+
img_src = ""
|
| 754 |
+
img = a.find("img") or (a.parent.find("img") if a.parent else None)
|
| 755 |
+
if not img:
|
| 756 |
+
p = a.parent
|
| 757 |
+
for _ in range(5):
|
| 758 |
+
if p and p.find("img"): img = p.find("img"); break
|
| 759 |
+
p = p.parent if p else None
|
| 760 |
+
if img:
|
| 761 |
+
img_src = (img.get("data-src", "") or img.get("src", "") or img.get("data-lazy", "") or img.get("data-original", ""))
|
| 762 |
+
if img_src.startswith("//"): img_src = "https:" + img_src
|
| 763 |
+
elif img_src.startswith("/"): img_src = "https://xemlaibongda.top" + img_src
|
| 764 |
+
title = a.find("h3")
|
| 765 |
+
if not title: title = a.find("h2")
|
| 766 |
+
if not title: title = a.find("strong")
|
| 767 |
+
t = title.get_text(strip=True) if title else ""
|
| 768 |
+
if not t:
|
| 769 |
+
slug = clean.split("/video/")[-1].rstrip("/")
|
| 770 |
+
t = slug.replace("-", " ").title()
|
| 771 |
+
videos.append({"title": t[:100], "link": clean, "img": img_src, "source": "xemlaibongda"})
|
| 772 |
+
if len(videos) >= limit: break
|
| 773 |
+
return JSONResponse({"videos": videos})
|
| 774 |
+
except Exception as e:
|
| 775 |
+
return JSONResponse({"videos": [], "error": str(e)})
|
| 776 |
+
|
| 777 |
+
@app.get("/api/article")
|
| 778 |
+
def api_article(url:str=Query(...)):
|
| 779 |
+
try:
|
| 780 |
+
r2 = requests.get(url, headers=HEADERS, timeout=10)
|
| 781 |
+
if r2.status_code == 200:
|
| 782 |
+
r2.encoding = "utf-8"
|
| 783 |
+
soup = BeautifulSoup(r2.text, "lxml")
|
| 784 |
+
og = soup.find("meta", property="og:image")
|
| 785 |
+
return JSONResponse({"og_image": og.get("content", "") if og else ""})
|
| 786 |
+
except: pass
|
| 787 |
+
return JSONResponse({"og_image": ""})
|
| 788 |
+
|
| 789 |
+
@app.get("/api/storage_status")
|
| 790 |
+
def api_storage_status():
|
| 791 |
+
return JSONResponse({"persistent":os.path.isdir("/data")})
|
| 792 |
+
|
| 793 |
+
@app.get("/api/hot_topics")
|
| 794 |
+
def api_hot_topics():
|
| 795 |
+
return JSONResponse({"topics":[]})
|
| 796 |
+
|
| 797 |
+
@app.get("/", response_class=HTMLResponse)
|
| 798 |
+
async def root():
|
| 799 |
+
return HTMLResponse("<h1>VNEWS v17</h1><p>VTV Digital CDN ssaimh · No shorts Dantri/SKDS · Homepage full content</p>")
|
main_patch.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PATCH: Add these 2 lines to main.py right after "app = FastAPI()"
|
| 2 |
+
# Line 1: from vtv_api import router as vtv_router
|
| 3 |
+
# Line 2: app.include_router(vtv_router)
|
| 4 |
+
#
|
| 5 |
+
# This enables the VTV1-VTV10 + VTVPrime stream endpoints:
|
| 6 |
+
# GET /api/vtv/streams - Get all channel streams
|
| 7 |
+
# GET /api/vtv/stream/{id} - Get specific channel stream
|
| 8 |
+
# GET /api/proxy/page?url=... - Proxy web pages (for xemtv PHP scraping)
|
match_detail.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Match Detail Scraper for bongda.com.vn
|
| 3 |
+
"""
|
| 4 |
+
import requests, re, json, time, threading
|
| 5 |
+
from bs4 import BeautifulSoup
|
| 6 |
+
|
| 7 |
+
def _sp(html):
|
| 8 |
+
try:
|
| 9 |
+
return BeautifulSoup(html, 'lxml')
|
| 10 |
+
except:
|
| 11 |
+
return BeautifulSoup(html, 'html.parser')
|
| 12 |
+
|
| 13 |
+
BH = {
|
| 14 |
+
"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",
|
| 15 |
+
"Accept": "application/json, text/javascript, */*; q=0.01",
|
| 16 |
+
"Referer": "https://bongda.com.vn/",
|
| 17 |
+
"X-Requested-With": "XMLHttpRequest",
|
| 18 |
+
}
|
| 19 |
+
HH = {
|
| 20 |
+
"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",
|
| 21 |
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
| 22 |
+
"Referer": "https://bongda.com.vn/",
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
def _cl(s):
|
| 26 |
+
return re.sub(r'\s+', ' ', str(s or '')).strip()
|
| 27 |
+
|
| 28 |
+
def _api(ep, params=None):
|
| 29 |
+
try:
|
| 30 |
+
url = f"https://bongda.com.vn{ep}"
|
| 31 |
+
if params:
|
| 32 |
+
url += "?" + "&".join(f"{k}={v}" for k, v in params.items())
|
| 33 |
+
r = requests.get(url, headers=BH, timeout=15)
|
| 34 |
+
if r.status_code == 200:
|
| 35 |
+
try: return r.json()
|
| 36 |
+
except: pass
|
| 37 |
+
except: pass
|
| 38 |
+
return None
|
| 39 |
+
|
| 40 |
+
def _get_teams(soup):
|
| 41 |
+
info = {}
|
| 42 |
+
tel = soup.select_one('.teams')
|
| 43 |
+
if not tel:
|
| 44 |
+
return info
|
| 45 |
+
he = tel.select_one('.team.home, .home-team')
|
| 46 |
+
if he:
|
| 47 |
+
ne = he.select_one('p:not(.logo)') or he.find('p')
|
| 48 |
+
if ne: info['home_team'] = _cl(ne.get_text())
|
| 49 |
+
lo = he.select_one('img')
|
| 50 |
+
if lo: info['home_logo'] = lo.get('src', '')
|
| 51 |
+
le = he if he.name == 'a' else he.find('a')
|
| 52 |
+
if le and le.get('href'):
|
| 53 |
+
m = re.search(r'/doi-bong/(\d+)/', le['href'])
|
| 54 |
+
if m: info['home_team_id'] = m.group(1)
|
| 55 |
+
ae = tel.select_one('.team.away, .away-team')
|
| 56 |
+
if ae:
|
| 57 |
+
ne = ae.select_one('p:not(.logo)') or ae.find('p')
|
| 58 |
+
if ne: info['away_team'] = _cl(ne.get_text())
|
| 59 |
+
lo = ae.select_one('img')
|
| 60 |
+
if lo: info['away_logo'] = lo.get('src', '')
|
| 61 |
+
le = ae if ae.name == 'a' else ae.find('a')
|
| 62 |
+
if le and le.get('href'):
|
| 63 |
+
m = re.search(r'/doi-bong/(\d+)/', le['href'])
|
| 64 |
+
if m: info['away_team_id'] = m.group(1)
|
| 65 |
+
sc = tel.select_one('.score')
|
| 66 |
+
if sc:
|
| 67 |
+
parts = [_cl(p.get_text()) for p in sc.select('p')]
|
| 68 |
+
if len(parts) >= 2: info['score'] = f"{parts[0]} - {parts[1]}"
|
| 69 |
+
lb = sc.select_one('.label')
|
| 70 |
+
if lb: info['status_label'] = _cl(lb.get_text())
|
| 71 |
+
return info
|
| 72 |
+
|
| 73 |
+
def _get_timeline(soup):
|
| 74 |
+
tl = []
|
| 75 |
+
el = soup.select_one('.timeline')
|
| 76 |
+
if not el: return tl
|
| 77 |
+
half = ''
|
| 78 |
+
for c in el.children:
|
| 79 |
+
if not hasattr(c, 'name') or not c.name: continue
|
| 80 |
+
t = _cl(c.get_text())
|
| 81 |
+
if not t: continue
|
| 82 |
+
if t in ['H1','H2','Hiệp 1','Hiệp 2']:
|
| 83 |
+
half = t; continue
|
| 84 |
+
m = re.match(r"(\d+'\+?\d*)", t)
|
| 85 |
+
if m:
|
| 86 |
+
tl.append({'time': m.group(1), 'text': t[m.end():].strip(), 'half': half})
|
| 87 |
+
elif len(t) > 5:
|
| 88 |
+
tl.append({'time': '', 'text': t, 'half': half})
|
| 89 |
+
return tl
|
| 90 |
+
|
| 91 |
+
def _get_events(soup):
|
| 92 |
+
evts = []
|
| 93 |
+
for el in soup.select('.event'):
|
| 94 |
+
e = {}
|
| 95 |
+
cl = ' '.join(el.get('class', []))
|
| 96 |
+
e['team'] = 'home' if 'home' in cl else ('away' if 'away' in cl else '')
|
| 97 |
+
ps = [_cl(p.get_text()) for p in el.select('p')]
|
| 98 |
+
ps = [p for p in ps if p]
|
| 99 |
+
if ps: e['players'] = ps
|
| 100 |
+
tl = el.select_one('.time, .minute, span')
|
| 101 |
+
if tl: e['time'] = _cl(tl.get_text())
|
| 102 |
+
evts.append(e)
|
| 103 |
+
return evts
|
| 104 |
+
|
| 105 |
+
def _get_stats(soup):
|
| 106 |
+
st = {}
|
| 107 |
+
for sel in ['.match-stats','[class*="stats"]']:
|
| 108 |
+
el = soup.select_one(sel)
|
| 109 |
+
if el and len(str(el)) > 50:
|
| 110 |
+
for row in el.select('li,tr,.stat-row'):
|
| 111 |
+
cells = row.select('td,span,p')
|
| 112 |
+
if len(cells) >= 3:
|
| 113 |
+
lb = _cl(cells[0].get_text())
|
| 114 |
+
if lb: st[lb] = {'home': _cl(cells[1].get_text()), 'away': _cl(cells[2].get_text())}
|
| 115 |
+
if st: break
|
| 116 |
+
return st
|
| 117 |
+
|
| 118 |
+
def _get_h2h(soup):
|
| 119 |
+
h2h = {'matches': [], 'stats': {}}
|
| 120 |
+
for sel in ['.head-to-head','[class*="h2h"]']:
|
| 121 |
+
el = soup.select_one(sel)
|
| 122 |
+
if el and len(str(el)) > 50:
|
| 123 |
+
for it in el.select('li,tr,.match-item'):
|
| 124 |
+
m = {}
|
| 125 |
+
cells = it.select('td,span,p')
|
| 126 |
+
if len(cells) >= 3:
|
| 127 |
+
m['date'] = _cl(cells[0].get_text())
|
| 128 |
+
m['home'] = _cl(cells[1].get_text())
|
| 129 |
+
m['score'] = _cl(cells[2].get_text())
|
| 130 |
+
if m.get('home'):
|
| 131 |
+
if len(cells) > 3: m['away'] = _cl(cells[3].get_text())
|
| 132 |
+
h2h['matches'].append(m)
|
| 133 |
+
if h2h['matches']: break
|
| 134 |
+
return h2h
|
| 135 |
+
|
| 136 |
+
def _get_form(soup):
|
| 137 |
+
f = {'home': [], 'away': []}
|
| 138 |
+
for sel in ['.form-guide','[class*="form"]']:
|
| 139 |
+
el = soup.select_one(sel)
|
| 140 |
+
if el and len(str(el)) > 50:
|
| 141 |
+
items = el.select('li,.form-item,tr')
|
| 142 |
+
for it in items[:10]:
|
| 143 |
+
t = _cl(it.get_text())
|
| 144 |
+
if t: f['home'].append({'text': t})
|
| 145 |
+
for it in items[10:20]:
|
| 146 |
+
t = _cl(it.get_text())
|
| 147 |
+
if t: f['away'].append({'text': t})
|
| 148 |
+
break
|
| 149 |
+
return f
|
| 150 |
+
|
| 151 |
+
def _get_info(soup):
|
| 152 |
+
info = {}
|
| 153 |
+
mi = soup.select_one('.match-info')
|
| 154 |
+
if mi:
|
| 155 |
+
te = mi.select_one('.times,li')
|
| 156 |
+
if te: info['datetime'] = _cl(te.get_text())
|
| 157 |
+
le = soup.select_one('.league,.tournament,[class*="league"]')
|
| 158 |
+
if le: info['league'] = _cl(le.get_text())
|
| 159 |
+
return info
|
| 160 |
+
|
| 161 |
+
def _scrape(url):
|
| 162 |
+
print(f"[DEBUG] _scrape: {url[:80]}", flush=True)
|
| 163 |
+
try:
|
| 164 |
+
r = requests.get(url, headers=HH, timeout=15, allow_redirects=True)
|
| 165 |
+
print(f"[DEBUG] HTTP={r.status_code}", flush=True)
|
| 166 |
+
if r.status_code != 200:
|
| 167 |
+
return False, {}
|
| 168 |
+
sp = _sp(r.text)
|
| 169 |
+
d = {}
|
| 170 |
+
|
| 171 |
+
teams = _get_teams(sp)
|
| 172 |
+
print(f"[DEBUG] teams={teams}", flush=True)
|
| 173 |
+
if teams: d['info'] = teams
|
| 174 |
+
|
| 175 |
+
mi = _get_info(sp)
|
| 176 |
+
if mi:
|
| 177 |
+
d.setdefault('info', {}).update(mi)
|
| 178 |
+
|
| 179 |
+
tl = _get_timeline(sp)
|
| 180 |
+
if tl:
|
| 181 |
+
d['timeline'] = tl
|
| 182 |
+
d['commentaries_html'] = '\n'.join([f"{t.get('time','')} {t.get('text','')}" for t in tl])
|
| 183 |
+
|
| 184 |
+
ev = _get_events(sp)
|
| 185 |
+
if ev: d['events'] = ev
|
| 186 |
+
|
| 187 |
+
st = _get_stats(sp)
|
| 188 |
+
if st:
|
| 189 |
+
d['stats_parsed'] = st
|
| 190 |
+
d['stats_html'] = str(st)
|
| 191 |
+
|
| 192 |
+
h2h = _get_h2h(sp)
|
| 193 |
+
if h2h.get('matches'): d['h2h_matches'] = h2h['matches']
|
| 194 |
+
if h2h.get('stats'): d['h2h_stats'] = h2h['stats']
|
| 195 |
+
|
| 196 |
+
if '/preview/' in url:
|
| 197 |
+
fm = _get_form(sp)
|
| 198 |
+
if fm.get('home'): d['home_form'] = fm['home']
|
| 199 |
+
if fm.get('away'): d['away_form'] = fm['away']
|
| 200 |
+
|
| 201 |
+
print(f"[DEBUG] success keys={list(d.keys())}", flush=True)
|
| 202 |
+
return True, d
|
| 203 |
+
except Exception as e:
|
| 204 |
+
import traceback
|
| 205 |
+
print(f"[DEBUG] error: {e}", flush=True)
|
| 206 |
+
traceback.print_exc()
|
| 207 |
+
return False, {}
|
| 208 |
+
|
| 209 |
+
def fetch_match_detail_by_url(url):
|
| 210 |
+
m = re.search(r'/tran-dau/(\d+)/', url)
|
| 211 |
+
if not m: return {"error": "Could not extract event_id", "found": False}
|
| 212 |
+
event_id = int(m.group(1))
|
| 213 |
+
res = {"event_id": event_id, "found": False, "sections": []}
|
| 214 |
+
_fetch_api(event_id, res)
|
| 215 |
+
ok, d = _scrape(url)
|
| 216 |
+
print(f"[DEBUG] by_url: ok={ok} d_keys={list(d.keys())}", flush=True)
|
| 217 |
+
if ok: _merge(res, d)
|
| 218 |
+
return res
|
| 219 |
+
|
| 220 |
+
def fetch_match_detail(event_id):
|
| 221 |
+
print(f"[DEBUG] fetch_match_detail({event_id})", flush=True)
|
| 222 |
+
res = {"event_id": event_id, "found": False, "sections": []}
|
| 223 |
+
_fetch_api(event_id, res)
|
| 224 |
+
|
| 225 |
+
for pt in ["centre", "preview"]:
|
| 226 |
+
url = f"https://bongda.com.vn/tran-dau/{event_id}/{pt}/"
|
| 227 |
+
ok, d = _scrape(url)
|
| 228 |
+
print(f"[DEBUG] {pt}: ok={ok}", flush=True)
|
| 229 |
+
if ok:
|
| 230 |
+
_merge(res, d)
|
| 231 |
+
if res.get("found"): break
|
| 232 |
+
|
| 233 |
+
print(f"[DEBUG] final: found={res['found']} sections={res['sections']}", flush=True)
|
| 234 |
+
return res
|
| 235 |
+
|
| 236 |
+
def _fetch_api(eid, res):
|
| 237 |
+
pm = _api("/api/event-standing/pre-match", {"event_id": eid})
|
| 238 |
+
res["pre_match"] = pm
|
| 239 |
+
res["pre_match_html"] = pm.get("html","") if pm and pm.get("status")=="success" and len(pm.get("html","").strip())>10 else ""
|
| 240 |
+
|
| 241 |
+
hm = _api("/api/fixtures/h2h-match", {"event_id": eid})
|
| 242 |
+
res["h2h_match"] = hm
|
| 243 |
+
if hm and hm.get("status")=="success":
|
| 244 |
+
h = hm.get("html","")
|
| 245 |
+
if len(h.strip())>10:
|
| 246 |
+
res["h2h_html"] = h
|
| 247 |
+
res["sections"].append("h2h")
|
| 248 |
+
else: res["h2h_html"] = ""
|
| 249 |
+
|
| 250 |
+
hs = _api("/api/fixtures/h2h-stats", {"event_id": eid})
|
| 251 |
+
res["h2h_stats"] = hs
|
| 252 |
+
if hs and hs.get("status")=="success":
|
| 253 |
+
h = hs.get("html","")
|
| 254 |
+
if len(h.strip())>10:
|
| 255 |
+
res["h2h_stats_html"] = h
|
| 256 |
+
res["sections"].append("h2h_stats")
|
| 257 |
+
try:
|
| 258 |
+
sp = _sp(h)
|
| 259 |
+
stats = {}
|
| 260 |
+
for row in sp.select('li,tr,.stat-row'):
|
| 261 |
+
cells = row.select('td,span,p')
|
| 262 |
+
if len(cells)>=3:
|
| 263 |
+
lb = _cl(cells[0].get_text())
|
| 264 |
+
if lb: stats[lb] = {'home': _cl(cells[1].get_text()), 'away': _cl(cells[2].get_text())}
|
| 265 |
+
if stats: res["h2h_stats_parsed"] = stats
|
| 266 |
+
except: pass
|
| 267 |
+
else: res["h2h_stats_html"] = ""
|
| 268 |
+
|
| 269 |
+
pf = _api("/api/event-standing/player-performance", {"event_id": eid})
|
| 270 |
+
res["performance"] = pf
|
| 271 |
+
if pf and pf.get("status")=="success" and len(pf.get("html","").strip())>10:
|
| 272 |
+
res["stats_html"] = pf["html"]
|
| 273 |
+
res["sections"].append("stats")
|
| 274 |
+
else: res["stats_html"] = ""
|
| 275 |
+
|
| 276 |
+
cm = _api("/api/fixtures/commentaries", {"event_id": eid})
|
| 277 |
+
if cm and cm.get("status")=="success" and len(cm.get("html","").strip())>10:
|
| 278 |
+
res["commentaries_html"] = cm["html"]
|
| 279 |
+
res["sections"].append("commentaries")
|
| 280 |
+
elif not res.get("commentaries_html"): res["commentaries_html"] = ""
|
| 281 |
+
|
| 282 |
+
def _merge(res, d):
|
| 283 |
+
if d.get("info"):
|
| 284 |
+
res.setdefault("info", {}).update(d["info"])
|
| 285 |
+
res["found"] = True
|
| 286 |
+
if "info" not in res["sections"]: res["sections"].append("info")
|
| 287 |
+
if d.get("timeline"):
|
| 288 |
+
res["timeline"] = d["timeline"]
|
| 289 |
+
if not res.get("commentaries_html"): res["commentaries_html"] = d.get("commentaries_html","")
|
| 290 |
+
res["sections"].append("commentaries")
|
| 291 |
+
if d.get("events"):
|
| 292 |
+
res["events"] = d["events"]
|
| 293 |
+
res["sections"].append("events")
|
| 294 |
+
if d.get("stats_parsed"):
|
| 295 |
+
res["stats_parsed"] = d["stats_parsed"]
|
| 296 |
+
if not res.get("stats_html"): res["stats_html"] = d.get("stats_html","")
|
| 297 |
+
res["sections"].append("stats")
|
| 298 |
+
if d.get("h2h_matches"):
|
| 299 |
+
res["h2h"] = d["h2h_matches"]
|
| 300 |
+
res["sections"].append("h2h")
|
| 301 |
+
if d.get("h2h_stats"):
|
| 302 |
+
res["h2h_stats_parsed"] = d["h2h_stats"]
|
| 303 |
+
res["sections"].append("h2h_stats")
|
| 304 |
+
if d.get("home_form"):
|
| 305 |
+
res["home_form"] = d["home_form"]
|
| 306 |
+
res["sections"].append("home_form")
|
| 307 |
+
if d.get("away_form"):
|
| 308 |
+
res["away_form"] = d["away_form"]
|
| 309 |
+
res["sections"].append("away_form")
|
match_detail_v2.py
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""VNEWS — Match Detail Parser v2 (html.parser only, no lxml dependency)"""
|
| 2 |
+
import re
|
| 3 |
+
import requests
|
| 4 |
+
from bs4 import BeautifulSoup
|
| 5 |
+
|
| 6 |
+
HEADERS = {
|
| 7 |
+
"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",
|
| 8 |
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
| 9 |
+
"Accept-Language": "vi-VN,vi;q=0.9",
|
| 10 |
+
"Referer": "https://bongda.com.vn/",
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
API_HEADERS = {
|
| 14 |
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
| 15 |
+
"Accept": "application/json, text/javascript, */*; q=0.01",
|
| 16 |
+
"X-Requested-With": "XMLHttpRequest",
|
| 17 |
+
"Referer": "https://bongda.com.vn/",
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _cl(s):
|
| 22 |
+
return re.sub(r'\s+', ' ', str(s or '')).strip()
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _normalize_time(raw):
|
| 26 |
+
t = _cl(raw)
|
| 27 |
+
if not t:
|
| 28 |
+
return t
|
| 29 |
+
t = re.sub(r"(\d+)'\s*\+(\d+)", r"\1+\2'", t)
|
| 30 |
+
t = t.replace("''", "'")
|
| 31 |
+
return t
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _mk(html):
|
| 35 |
+
"""Parse HTML using html.parser (lxml may not be available)."""
|
| 36 |
+
return BeautifulSoup(html, 'html.parser')
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def fetch_html(url, timeout=8):
|
| 40 |
+
resp = requests.get(url, headers=HEADERS, timeout=timeout, allow_redirects=True)
|
| 41 |
+
resp.raise_for_status()
|
| 42 |
+
return resp.text
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def parse_events(sp):
|
| 46 |
+
"""Parse .events > .period > .event structure."""
|
| 47 |
+
events = []
|
| 48 |
+
events_div = sp.select_one('.events')
|
| 49 |
+
if not events_div:
|
| 50 |
+
return events
|
| 51 |
+
|
| 52 |
+
current_period = ''
|
| 53 |
+
for child in events_div.children:
|
| 54 |
+
if not hasattr(child, 'name') or not child.name:
|
| 55 |
+
continue
|
| 56 |
+
cls_str = ' '.join(child.get('class', []) if child.get('class') else [])
|
| 57 |
+
|
| 58 |
+
if 'period' in cls_str:
|
| 59 |
+
h2 = child.find('h2')
|
| 60 |
+
if h2:
|
| 61 |
+
current_period = _cl(h2.get_text())
|
| 62 |
+
|
| 63 |
+
for ev in child.children:
|
| 64 |
+
if not hasattr(ev, 'name') or not ev.name:
|
| 65 |
+
continue
|
| 66 |
+
ev_cls_str = ' '.join(ev.get('class', []) if ev.get('class') else [])
|
| 67 |
+
if 'event' not in ev_cls_str:
|
| 68 |
+
continue
|
| 69 |
+
|
| 70 |
+
team = 'home' if 'home' in ev_cls_str else 'away'
|
| 71 |
+
ev_data = {
|
| 72 |
+
'team': team, 'period': current_period, 'type': 'unknown',
|
| 73 |
+
'time': '', 'players': '', 'player_in': '', 'player_out': '',
|
| 74 |
+
'scorer': '', 'assist': '', 'card_type': '', 'player': '',
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
type_el = ev.select_one('.event-type')
|
| 78 |
+
if type_el:
|
| 79 |
+
if type_el.select_one('[class*="redcard"]'):
|
| 80 |
+
ev_data['type'] = 'redcard'; ev_data['card_type'] = 'red'
|
| 81 |
+
elif type_el.select_one('[class*="yellowcard"]'):
|
| 82 |
+
ev_data['type'] = 'yellowcard'; ev_data['card_type'] = 'yellow'
|
| 83 |
+
elif type_el.select_one('[class*="goal"]'):
|
| 84 |
+
ev_data['type'] = 'goal'
|
| 85 |
+
elif type_el.select_one('[class*="substitution"]'):
|
| 86 |
+
ev_data['type'] = 'substitution'
|
| 87 |
+
else:
|
| 88 |
+
for rect in type_el.select('svg rect'):
|
| 89 |
+
if rect.get('fill') == '#E20007':
|
| 90 |
+
ev_data['type'] = 'redcard'; ev_data['card_type'] = 'red'; break
|
| 91 |
+
if ev_data['type'] == 'unknown':
|
| 92 |
+
for circle in type_el.select('svg circle'):
|
| 93 |
+
if circle.get('fill') == 'white' and circle.get('r') == '8':
|
| 94 |
+
ev_data['type'] = 'goal'; break
|
| 95 |
+
if ev_data['type'] == 'unknown' and ev.select_one('.players.subst'):
|
| 96 |
+
ev_data['type'] = 'substitution'
|
| 97 |
+
|
| 98 |
+
players_el = ev.select_one('.players')
|
| 99 |
+
if players_el and ev_data['type'] == 'unknown':
|
| 100 |
+
pcls = ' '.join(players_el.get('class', []) if players_el.get('class') else [])
|
| 101 |
+
if 'goal' in pcls: ev_data['type'] = 'goal'
|
| 102 |
+
elif 'card' in pcls: ev_data['type'] = 'redcard'; ev_data['card_type'] = 'red'
|
| 103 |
+
elif 'subst' in pcls: ev_data['type'] = 'substitution'
|
| 104 |
+
|
| 105 |
+
if players_el:
|
| 106 |
+
time_el = players_el.select_one('.event-time')
|
| 107 |
+
if time_el:
|
| 108 |
+
ev_data['time'] = _normalize_time(time_el.get_text())
|
| 109 |
+
ev_data['players'] = _cl(players_el.get_text(' ', strip=True))
|
| 110 |
+
|
| 111 |
+
texts = []
|
| 112 |
+
for d in players_el.find_all('div', recursive=False):
|
| 113 |
+
t = _cl(d.get_text())
|
| 114 |
+
if t and t != ev_data['time']:
|
| 115 |
+
texts.append(t)
|
| 116 |
+
for p in players_el.find_all('p', recursive=False):
|
| 117 |
+
t = _cl(p.get_text())
|
| 118 |
+
if t and t not in texts:
|
| 119 |
+
texts.append(t)
|
| 120 |
+
|
| 121 |
+
if ev_data['type'] == 'substitution':
|
| 122 |
+
if len(texts) >= 2:
|
| 123 |
+
ev_data['player_out'] = texts[0]; ev_data['player_in'] = texts[1]
|
| 124 |
+
elif len(texts) == 1:
|
| 125 |
+
ev_data['player_in'] = texts[0]
|
| 126 |
+
elif ev_data['type'] == 'goal':
|
| 127 |
+
if len(texts) >= 1: ev_data['scorer'] = texts[0]
|
| 128 |
+
if len(texts) >= 2: ev_data['assist'] = texts[1]
|
| 129 |
+
elif ev_data['type'] in ('redcard', 'yellowcard'):
|
| 130 |
+
if texts: ev_data['player'] = ' '.join(texts)
|
| 131 |
+
|
| 132 |
+
events.append(ev_data)
|
| 133 |
+
return events
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def fetch_match_detail(event_id: int) -> dict:
|
| 137 |
+
import concurrent.futures
|
| 138 |
+
result = {"event_id": event_id, "found": False, "sections": []}
|
| 139 |
+
|
| 140 |
+
html = None
|
| 141 |
+
base = f"https://bongda.com.vn/tran-dau/{event_id}"
|
| 142 |
+
urls = [base + suffix for suffix in ['/centre/', '/preview/', '/bao-cao-nhanh/']]
|
| 143 |
+
|
| 144 |
+
# Try all URLs in parallel, take first success
|
| 145 |
+
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as ex:
|
| 146 |
+
futures = {ex.submit(requests.get, url, headers=HEADERS, timeout=8, allow_redirects=True): url for url in urls}
|
| 147 |
+
for future in concurrent.futures.as_completed(futures, timeout=12):
|
| 148 |
+
try:
|
| 149 |
+
resp = future.result()
|
| 150 |
+
if resp.status_code == 200 and len(resp.text) > 1000:
|
| 151 |
+
html = resp.text
|
| 152 |
+
for f in futures:
|
| 153 |
+
f.cancel()
|
| 154 |
+
break
|
| 155 |
+
except Exception:
|
| 156 |
+
continue
|
| 157 |
+
|
| 158 |
+
if not html:
|
| 159 |
+
return result
|
| 160 |
+
|
| 161 |
+
sp = _mk(html)
|
| 162 |
+
info = {}
|
| 163 |
+
|
| 164 |
+
tel = sp.select_one('.teams')
|
| 165 |
+
if tel:
|
| 166 |
+
he = tel.select_one('.team.home') or tel.select_one('[class*="home"]')
|
| 167 |
+
if he:
|
| 168 |
+
ne = he.select_one('p:not(.logo)') or he.find('p')
|
| 169 |
+
if ne: info['home_team'] = _cl(ne.get_text())
|
| 170 |
+
lo = he.select_one('img')
|
| 171 |
+
if lo: info['home_logo'] = lo.get('src', '')
|
| 172 |
+
|
| 173 |
+
ae = tel.select_one('.team.away') or tel.select_one('[class*="away"]')
|
| 174 |
+
if ae:
|
| 175 |
+
ne = ae.select_one('p:not(.logo)') or ae.find('p')
|
| 176 |
+
if ne: info['away_team'] = _cl(ne.get_text())
|
| 177 |
+
lo = ae.select_one('img')
|
| 178 |
+
if lo: info['away_logo'] = lo.get('src', '')
|
| 179 |
+
|
| 180 |
+
sc = tel.select_one('.score')
|
| 181 |
+
if sc:
|
| 182 |
+
parts = [_cl(p.get_text()) for p in sc.select('p')]
|
| 183 |
+
if len(parts) >= 2: info['score'] = f"{parts[0]} - {parts[1]}"
|
| 184 |
+
lb = sc.select_one('.label')
|
| 185 |
+
if lb: info['status_label'] = _cl(lb.get_text())
|
| 186 |
+
|
| 187 |
+
if info.get('home_team') and info.get('away_team'):
|
| 188 |
+
result['info'] = info
|
| 189 |
+
result['found'] = True
|
| 190 |
+
result['sections'].append('info')
|
| 191 |
+
else:
|
| 192 |
+
return result
|
| 193 |
+
|
| 194 |
+
mi = sp.select_one('.match-info')
|
| 195 |
+
if mi:
|
| 196 |
+
for sel in ['.times', 'li']:
|
| 197 |
+
el = mi.select_one(sel)
|
| 198 |
+
if el:
|
| 199 |
+
t = _cl(el.get_text())
|
| 200 |
+
if t: info.setdefault('datetime', t); break
|
| 201 |
+
|
| 202 |
+
events = parse_events(sp)
|
| 203 |
+
if events:
|
| 204 |
+
result['events'] = events
|
| 205 |
+
result['sections'].append('events')
|
| 206 |
+
|
| 207 |
+
pred = sp.select_one('.prediction-card')
|
| 208 |
+
if pred:
|
| 209 |
+
pred_data = {}
|
| 210 |
+
team_info = pred.select_one('.team-info')
|
| 211 |
+
if team_info:
|
| 212 |
+
teams = team_info.select('.team')
|
| 213 |
+
if len(teams) >= 2:
|
| 214 |
+
pred_data['home_name'] = _cl(teams[0].select_one('.team-name').get_text()) if teams[0].select_one('.team-name') else ''
|
| 215 |
+
pred_data['away_name'] = _cl(teams[1].select_one('.team-name').get_text()) if teams[1].select_one('.team-name') else ''
|
| 216 |
+
divider = team_info.select_one('.divider')
|
| 217 |
+
if divider: pred_data['result'] = _cl(divider.get_text())
|
| 218 |
+
vote_count = pred.select_one('.vote-count')
|
| 219 |
+
if vote_count: pred_data['vote_count'] = _cl(vote_count.get_text())
|
| 220 |
+
result['prediction'] = pred_data
|
| 221 |
+
|
| 222 |
+
try:
|
| 223 |
+
ar = requests.get(
|
| 224 |
+
f"https://bongda.com.vn/api/fixtures/h2h-stats?event_id={event_id}",
|
| 225 |
+
headers=API_HEADERS, timeout=6
|
| 226 |
+
)
|
| 227 |
+
if ar.status_code == 200:
|
| 228 |
+
ad = ar.json()
|
| 229 |
+
if ad.get('status') == 'success' and ad.get('html'):
|
| 230 |
+
asp = _mk(ad['html'])
|
| 231 |
+
ast = {}
|
| 232 |
+
for row in asp.select('li, tr, .stat-row'):
|
| 233 |
+
cells = row.select('td, span, p')
|
| 234 |
+
if len(cells) >= 3:
|
| 235 |
+
lb = _cl(cells[0].get_text())
|
| 236 |
+
if lb: ast[lb] = {'home': _cl(cells[1].get_text()), 'away': _cl(cells[2].get_text())}
|
| 237 |
+
if ast: result['h2h_stats_parsed'] = ast; result['sections'].append('h2h_stats')
|
| 238 |
+
except Exception:
|
| 239 |
+
pass
|
| 240 |
+
|
| 241 |
+
h2h_data = []
|
| 242 |
+
h2h_el = sp.select_one('.h2h-standings')
|
| 243 |
+
if h2h_el:
|
| 244 |
+
rows = h2h_el.select('.ranking-table tbody tr, .leaderboard tr')
|
| 245 |
+
for row in rows:
|
| 246 |
+
cells = row.select('td')
|
| 247 |
+
if len(cells) >= 4:
|
| 248 |
+
logo = row.select_one('img')
|
| 249 |
+
name_el = row.select_one('.team-name, p.link, .name')
|
| 250 |
+
h2h_data.append({
|
| 251 |
+
'pos': _cl(cells[0].get_text()),
|
| 252 |
+
'logo': logo.get('src', '') if logo else '',
|
| 253 |
+
'name': _cl(name_el.get_text()) if name_el else '',
|
| 254 |
+
'played': _cl(cells[1].get_text()) if len(cells) > 1 else '',
|
| 255 |
+
'wins': _cl(cells[2].get_text()) if len(cells) > 2 else '',
|
| 256 |
+
'draws': _cl(cells[3].get_text()) if len(cells) > 3 else '',
|
| 257 |
+
'losses': _cl(cells[4].get_text()) if len(cells) > 4 else '',
|
| 258 |
+
'gf': _cl(cells[5].get_text()) if len(cells) > 5 else '',
|
| 259 |
+
'ga': _cl(cells[6].get_text()) if len(cells) > 6 else '',
|
| 260 |
+
'points': _cl(cells[8].get_text()) if len(cells) > 8 else '',
|
| 261 |
+
})
|
| 262 |
+
if h2h_data: result['h2h_standings'] = h2h_data; result['sections'].append('h2h_standings')
|
| 263 |
+
|
| 264 |
+
recent_matches = []
|
| 265 |
+
matches_list = sp.select_one('.matches-list')
|
| 266 |
+
if matches_list:
|
| 267 |
+
for item in matches_list.select('.match-detail, .match-item, li'):
|
| 268 |
+
date_el = item.select_one('.date, .time, .match-time')
|
| 269 |
+
league_el = item.select_one('.league')
|
| 270 |
+
home_el = item.select_one('.home, .team-home')
|
| 271 |
+
away_el = item.select_one('.away, .team-away')
|
| 272 |
+
score_el = item.select_one('.score, .result')
|
| 273 |
+
if home_el or away_el:
|
| 274 |
+
recent_matches.append({
|
| 275 |
+
'date': _cl(date_el.get_text()) if date_el else '',
|
| 276 |
+
'league': _cl(league_el.get_text()) if league_el else '',
|
| 277 |
+
'home': _cl(home_el.get_text()) if home_el else '',
|
| 278 |
+
'away': _cl(away_el.get_text()) if away_el else '',
|
| 279 |
+
'score': _cl(score_el.get_text()) if score_el else 'vs',
|
| 280 |
+
})
|
| 281 |
+
if recent_matches: result['recent_matches'] = recent_matches; result['sections'].append('recent')
|
| 282 |
+
|
| 283 |
+
return result
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def fetch_match_detail_by_url(url: str) -> dict:
|
| 287 |
+
import concurrent.futures
|
| 288 |
+
eid_match = re.search(r'/tran-dau/(\d+)/', url)
|
| 289 |
+
if not eid_match:
|
| 290 |
+
return {"event_id": 0, "found": False, "error": "Cannot extract event_id from URL"}
|
| 291 |
+
event_id = int(eid_match.group(1))
|
| 292 |
+
result = {"event_id": event_id, "found": False, "sections": []}
|
| 293 |
+
|
| 294 |
+
html = None
|
| 295 |
+
try:
|
| 296 |
+
resp = requests.get(url, headers=HEADERS, timeout=8, allow_redirects=True)
|
| 297 |
+
if resp.status_code == 200 and len(resp.text) > 1000:
|
| 298 |
+
html = resp.text
|
| 299 |
+
except Exception:
|
| 300 |
+
pass
|
| 301 |
+
|
| 302 |
+
if not html:
|
| 303 |
+
return fetch_match_detail(event_id)
|
| 304 |
+
|
| 305 |
+
sp = _mk(html)
|
| 306 |
+
info = {}
|
| 307 |
+
|
| 308 |
+
tel = sp.select_one('.teams')
|
| 309 |
+
if tel:
|
| 310 |
+
he = tel.select_one('.team.home') or tel.select_one('[class*="home"]')
|
| 311 |
+
if he:
|
| 312 |
+
ne = he.select_one('p:not(.logo)') or he.find('p')
|
| 313 |
+
if ne: info['home_team'] = _cl(ne.get_text())
|
| 314 |
+
lo = he.select_one('img')
|
| 315 |
+
if lo: info['home_logo'] = lo.get('src', '')
|
| 316 |
+
ae = tel.select_one('.team.away') or tel.select_one('[class*="away"]')
|
| 317 |
+
if ae:
|
| 318 |
+
ne = ae.select_one('p:not(.logo)') or ae.find('p')
|
| 319 |
+
if ne: info['away_team'] = _cl(ne.get_text())
|
| 320 |
+
lo = ae.select_one('img')
|
| 321 |
+
if lo: info['away_logo'] = lo.get('src', '')
|
| 322 |
+
sc = tel.select_one('.score')
|
| 323 |
+
if sc:
|
| 324 |
+
parts = [_cl(p.get_text()) for p in sc.select('p')]
|
| 325 |
+
if len(parts) >= 2: info['score'] = f"{parts[0]} - {parts[1]}"
|
| 326 |
+
lb = sc.select_one('.label')
|
| 327 |
+
if lb: info['status_label'] = _cl(lb.get_text())
|
| 328 |
+
|
| 329 |
+
if info.get('home_team') and info.get('away_team'):
|
| 330 |
+
result['info'] = info; result['found'] = True; result['sections'].append('info')
|
| 331 |
+
else:
|
| 332 |
+
return fetch_match_detail(event_id)
|
| 333 |
+
|
| 334 |
+
mi = sp.select_one('.match-info')
|
| 335 |
+
if mi:
|
| 336 |
+
te = mi.select_one('.times, li')
|
| 337 |
+
if te: info.setdefault('datetime', _cl(te.get_text()))
|
| 338 |
+
|
| 339 |
+
events = parse_events(sp)
|
| 340 |
+
if events: result['events'] = events; result['sections'].append('events')
|
| 341 |
+
|
| 342 |
+
pred = sp.select_one('.prediction-card')
|
| 343 |
+
if pred:
|
| 344 |
+
pred_data = {}
|
| 345 |
+
team_info = pred.select_one('.team-info')
|
| 346 |
+
if team_info:
|
| 347 |
+
teams = team_info.select('.team')
|
| 348 |
+
if len(teams) >= 2:
|
| 349 |
+
pred_data['home_name'] = _cl(teams[0].select_one('.team-name').get_text()) if teams[0].select_one('.team-name') else ''
|
| 350 |
+
pred_data['away_name'] = _cl(teams[1].select_one('.team-name').get_text()) if teams[1].select_one('.team-name') else ''
|
| 351 |
+
divider = team_info.select_one('.divider')
|
| 352 |
+
if divider: pred_data['result'] = _cl(divider.get_text())
|
| 353 |
+
vote_count = pred.select_one('.vote-count')
|
| 354 |
+
if vote_count: pred_data['vote_count'] = _cl(vote_count.get_text())
|
| 355 |
+
result['prediction'] = pred_data
|
| 356 |
+
|
| 357 |
+
try:
|
| 358 |
+
ar = requests.get(
|
| 359 |
+
f"https://bongda.com.vn/api/fixtures/h2h-stats?event_id={event_id}",
|
| 360 |
+
headers=API_HEADERS, timeout=6
|
| 361 |
+
)
|
| 362 |
+
if ar.status_code == 200:
|
| 363 |
+
ad = ar.json()
|
| 364 |
+
if ad.get('status') == 'success' and ad.get('html'):
|
| 365 |
+
asp = _mk(ad['html'])
|
| 366 |
+
ast = {}
|
| 367 |
+
for row in asp.select('li, tr, .stat-row'):
|
| 368 |
+
cells = row.select('td, span, p')
|
| 369 |
+
if len(cells) >= 3:
|
| 370 |
+
lb = _cl(cells[0].get_text())
|
| 371 |
+
if lb: ast[lb] = {'home': _cl(cells[1].get_text()), 'away': _cl(cells[2].get_text())}
|
| 372 |
+
if ast: result['h2h_stats_parsed'] = ast; result['sections'].append('h2h_stats')
|
| 373 |
+
except Exception:
|
| 374 |
+
pass
|
| 375 |
+
|
| 376 |
+
h2h_data = []
|
| 377 |
+
h2h_el = sp.select_one('.h2h-standings')
|
| 378 |
+
if h2h_el:
|
| 379 |
+
rows = h2h_el.select('.ranking-table tbody tr, .leaderboard tr')
|
| 380 |
+
for row in rows:
|
| 381 |
+
cells = row.select('td')
|
| 382 |
+
if len(cells) >= 4:
|
| 383 |
+
logo = row.select_one('img')
|
| 384 |
+
name_el = row.select_one('.team-name, p.link, .name')
|
| 385 |
+
h2h_data.append({
|
| 386 |
+
'pos': _cl(cells[0].get_text()),
|
| 387 |
+
'logo': logo.get('src', '') if logo else '',
|
| 388 |
+
'name': _cl(name_el.get_text()) if name_el else '',
|
| 389 |
+
'played': _cl(cells[1].get_text()) if len(cells) > 1 else '',
|
| 390 |
+
'wins': _cl(cells[2].get_text()) if len(cells) > 2 else '',
|
| 391 |
+
'draws': _cl(cells[3].get_text()) if len(cells) > 3 else '',
|
| 392 |
+
'losses': _cl(cells[4].get_text()) if len(cells) > 4 else '',
|
| 393 |
+
'gf': _cl(cells[5].get_text()) if len(cells) > 5 else '',
|
| 394 |
+
'ga': _cl(cells[6].get_text()) if len(cells) > 6 else '',
|
| 395 |
+
'points': _cl(cells[8].get_text()) if len(cells) > 8 else '',
|
| 396 |
+
})
|
| 397 |
+
if h2h_data: result['h2h_standings'] = h2h_data; result['sections'].append('h2h_standings')
|
| 398 |
+
|
| 399 |
+
recent_matches = []
|
| 400 |
+
matches_list = sp.select_one('.matches-list')
|
| 401 |
+
if matches_list:
|
| 402 |
+
for item in matches_list.select('.match-detail, .match-item, li'):
|
| 403 |
+
date_el = item.select_one('.date, .time, .match-time')
|
| 404 |
+
league_el = item.select_one('.league')
|
| 405 |
+
home_el = item.select_one('.home, .team-home')
|
| 406 |
+
away_el = item.select_one('.away, .team-away')
|
| 407 |
+
score_el = item.select_one('.score, .result')
|
| 408 |
+
if home_el or away_el:
|
| 409 |
+
recent_matches.append({
|
| 410 |
+
'date': _cl(date_el.get_text()) if date_el else '',
|
| 411 |
+
'league': _cl(league_el.get_text()) if league_el else '',
|
| 412 |
+
'home': _cl(home_el.get_text()) if home_el else '',
|
| 413 |
+
'away': _cl(away_el.get_text()) if away_el else '',
|
| 414 |
+
'score': _cl(score_el.get_text()) if score_el else 'vs',
|
| 415 |
+
})
|
| 416 |
+
if recent_matches: result['recent_matches'] = recent_matches; result['sections'].append('recent')
|
| 417 |
+
|
| 418 |
+
return result
|
patch_ai_hot.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PATCH AI: prepend AI topics to hot list + homepage route fix"""
|
| 2 |
+
import re, json, time
|
| 3 |
+
from fastapi.responses import HTMLResponse
|
| 4 |
+
|
| 5 |
+
# Import at runtime to avoid circular
|
| 6 |
+
try:
|
| 7 |
+
from main import app, rt
|
| 8 |
+
import ai_runtime_final6 as f6
|
| 9 |
+
from ai_runtime_final6 import f5
|
| 10 |
+
except:
|
| 11 |
+
f6, f5, rt = None, None, None
|
| 12 |
+
|
| 13 |
+
# Patch hot_topics to prepend AI topics
|
| 14 |
+
if f6 and hasattr(f6, '_HOT_CACHE') and hasattr(f6, '_hot_topics'):
|
| 15 |
+
_orig_hot = f6._hot_topics
|
| 16 |
+
def _hot_topics_patched():
|
| 17 |
+
topics = _orig_hot()
|
| 18 |
+
# Prepend AI topics to front
|
| 19 |
+
for ai in ['Công nghệ AI', 'World Cup 2026', 'Kinh tế Việt Nam']:
|
| 20 |
+
if not any(ai.lower() == t.get('topic','').lower() for t in topics):
|
| 21 |
+
topics.insert(0, {'label': f'#{ai.replace(" ", "")}', 'topic': ai, 'count': 0})
|
| 22 |
+
return topics[:24]
|
| 23 |
+
f6._hot_topics = f6._HOT_CACHE['d'] = _hot_topics_patched()
|
| 24 |
+
f6._HOT_CACHE['t'] = time.time()
|
| 25 |
+
|
| 26 |
+
PATCH_INJECT = r'''
|
| 27 |
+
<script>
|
| 28 |
+
const AI_HOT_TOPICS = ['Công nghệ AI', 'World Cup 2026', 'Kinh tế Việt Nam', 'Bóng đá châu Âu'];
|
| 29 |
+
async function ensureHotTopics(){let i=document.getElementById('ai-topic-input-final5');if(!i||document.getElementById('ai-hot-row'))return;let r=document.createElement('div');r.id='ai-hot-row';r.style.cssText='display:flex;gap:6px;overflow-x:auto;padding:4px 0;margin:6px 0';let t=[];try{let j=await fetch('/api/hot_topics').then(r=>r.json()).catch(()=>({topics:[]}));t=j.topics||[];}catch(e){}AI_HOT_TOPICS.forEach(ai=>{if(!t.find(x=>(x.topic||'').toLowerCase()===ai.toLowerCase())){t.unshift({label:'#'+ai.replace(/\s+/g,''),topic:ai});}});r.innerHTML=t.slice(0,14).map(x=>`<button class="hot-chip" style="flex:0 0 auto;background:#222;border:1px solid #333;color:#ddd;border-radius:16px;padding:5px 10px;font-size:11px;cursor:pointer" onclick="document.getElementById('ai-topic-input-final5').value='${x.topic.replace(/'/g,'\\''}';document.getElementById('ai-topic-input-final5').focus();searchTopic('${x.topic.replace(/'/g,'\\''}')">${x.label}</button>`).join('');i.insertAdjacentElement('afterend',r);}
|
| 30 |
+
setInterval(ensureHotTopics,1500);
|
| 31 |
+
</script>
|
| 32 |
+
'''
|
| 33 |
+
|
| 34 |
+
# Register homepage route
|
| 35 |
+
if app and f5 and f6:
|
| 36 |
+
# Remove old / route
|
| 37 |
+
app.router.routes = [r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
|
| 38 |
+
|
| 39 |
+
@app.get('/')
|
| 40 |
+
async def patch_homepage():
|
| 41 |
+
html = f5.f4.f3.f2.f1._load_index_html() if f5 else "<html><body></body></html>"
|
| 42 |
+
body = (getattr(rt.old,'PATCH_INJECT','') if hasattr(rt,'old') else '') + \
|
| 43 |
+
(getattr(f5.f4.f3.f2.f1,'FINAL_INJECT','') if f5 else '') + \
|
| 44 |
+
(getattr(f5.f4.f3,'FINAL3_INJECT','') if f5 else '') + \
|
| 45 |
+
(getattr(f5.f4,'FINAL4_INJECT','') if f5 else '') + \
|
| 46 |
+
(getattr(f5,'FINAL5_INJECT','') if f5 else '') + \
|
| 47 |
+
(getattr(f6,'FINAL6_INJECT','') or '') + \
|
| 48 |
+
(getattr(f6,'FINAL6_FAST_HOME_INJECT','') or '') + \
|
| 49 |
+
(getattr(f6,'FINAL6E_INJECT','') or '') + \
|
| 50 |
+
PATCH_INJECT
|
| 51 |
+
if '</body>' in html:
|
| 52 |
+
html = html.replace('</body>', body + '\n</body>')
|
| 53 |
+
else:
|
| 54 |
+
html += body
|
| 55 |
+
return HTMLResponse(html)
|
patch_extra.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Extra CSS/JS fixes injected AFTER main PATCH_INJECT."""
|
| 2 |
+
EXTRA_FIX = r'''
|
| 3 |
+
<style>
|
| 4 |
+
/* Force correct position for Short AI interaction buttons */
|
| 5 |
+
.tiktok-slide{position:relative!important}
|
| 6 |
+
.tiktok-right{position:absolute!important;right:8px!important;bottom:100px!important;display:flex!important;flex-direction:column!important;align-items:center!important;gap:14px!important;z-index:5!important}
|
| 7 |
+
.tiktok-right-btn{display:flex!important;flex-direction:column!important;align-items:center!important;gap:2px!important;background:none!important;border:0!important;color:#fff!important;font-size:10px!important;cursor:pointer!important}
|
| 8 |
+
.tiktok-right-btn .icon{width:42px!important;height:42px!important;border-radius:50%!important;background:rgba(255,255,255,.12)!important;display:flex!important;align-items:center!important;justify-content:center!important;font-size:20px!important}
|
| 9 |
+
.tiktok-right-btn .count{font-size:10px!important;color:#ddd!important}
|
| 10 |
+
#short-progress-toast{position:fixed;bottom:70px;left:50%;transform:translateX(-50%);background:#2d8659;color:#fff;padding:10px 20px;border-radius:20px;font-size:12px;z-index:99998;box-shadow:0 4px 12px rgba(0,0,0,.4);display:none;white-space:nowrap}
|
| 11 |
+
/* Kill ALL duplicate short AI slides from old layers */
|
| 12 |
+
#ai-short-home,.ai-short-home,.ai-short-card-final,[id*="ai-shorts-patched"]{display:none!important}
|
| 13 |
+
</style>
|
| 14 |
+
<div id="short-progress-toast"></div>
|
| 15 |
+
<script>
|
| 16 |
+
(function(){
|
| 17 |
+
// Kill old renderers that create duplicate Short AI slides
|
| 18 |
+
window.renderAIShortHome=function(){};
|
| 19 |
+
window.renderAIShorts7=function(){};
|
| 20 |
+
window.renderTopicWallE=function(){};
|
| 21 |
+
window.renderAiShorts=function(){};
|
| 22 |
+
// Also remove any already-rendered duplicate slides
|
| 23 |
+
setInterval(function(){
|
| 24 |
+
document.querySelectorAll('#ai-short-home,.ai-short-home,[id*="ai-shorts-patched"]').forEach(function(el){el.remove()});
|
| 25 |
+
},2000);
|
| 26 |
+
// Progress toast for short creation
|
| 27 |
+
window.showShortProgress=function(msg){var t=document.getElementById('short-progress-toast');if(t){t.textContent=msg;t.style.display='block';}};
|
| 28 |
+
window.hideShortProgress=function(){var t=document.getElementById('short-progress-toast');if(t)t.style.display='none';};
|
| 29 |
+
// Override makeShortFromPost to use progress toast
|
| 30 |
+
var _origMakeShort=window.makeShortFromPost;
|
| 31 |
+
window.makeShortFromPost=async function(pid,btn){
|
| 32 |
+
showShortProgress('⏳ Đang tạo Short AI...');
|
| 33 |
+
if(btn){btn.disabled=true;btn.textContent='Đang tạo...';}
|
| 34 |
+
try{
|
| 35 |
+
var r=await fetch('/api/ai/short/'+pid,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({voice:'nu',emotion:'neutral',speed:1.2})});
|
| 36 |
+
var j=await r.json();
|
| 37 |
+
if(!r.ok||j.error)throw new Error(j.error||'Lỗi');
|
| 38 |
+
showShortProgress('✅ Đ�ã tạo Short AI!');
|
| 39 |
+
setTimeout(hideShortProgress,3000);
|
| 40 |
+
if(typeof renderShortAISlide==='function')renderShortAISlide();
|
| 41 |
+
}catch(e){
|
| 42 |
+
showShortProgress('❌ Lỗi: '+e.message);
|
| 43 |
+
setTimeout(hideShortProgress,4000);
|
| 44 |
+
}finally{
|
| 45 |
+
if(btn){btn.disabled=false;btn.textContent='🎬 Tạo Short AI';}
|
| 46 |
+
}
|
| 47 |
+
};
|
| 48 |
+
})();
|
| 49 |
+
</script>
|
| 50 |
+
'''
|
patch_runtime.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Runtime patch layer for VNEWS.
|
| 2 |
+
Keeps the current large app intact, but replaces fragile AI wall endpoints with
|
| 3 |
+
stable JSON endpoints and injects frontend safeJson wrappers.
|
| 4 |
+
"""
|
| 5 |
+
import hashlib
|
| 6 |
+
import time
|
| 7 |
+
import os
|
| 8 |
+
from urllib.parse import quote
|
| 9 |
+
|
| 10 |
+
import requests
|
| 11 |
+
from bs4 import BeautifulSoup
|
| 12 |
+
from fastapi import Request
|
| 13 |
+
from fastapi.responses import JSONResponse, HTMLResponse
|
| 14 |
+
|
| 15 |
+
import main as _main
|
| 16 |
+
|
| 17 |
+
app = _main.app
|
| 18 |
+
DEFAULT_IMG = "https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _remove_routes(paths):
|
| 22 |
+
app.router.routes = [r for r in app.router.routes if getattr(r, "path", None) not in set(paths)]
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _safe_text(v):
|
| 26 |
+
return (v or "").strip()
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _ensure_article(url: str):
|
| 30 |
+
data = None
|
| 31 |
+
try:
|
| 32 |
+
if hasattr(_main, "_article_by_url"):
|
| 33 |
+
data = _main._article_by_url(url)
|
| 34 |
+
except Exception:
|
| 35 |
+
data = None
|
| 36 |
+
if not data:
|
| 37 |
+
try:
|
| 38 |
+
data = _main._scrape_generic_article(url) if hasattr(_main, "_scrape_generic_article") else None
|
| 39 |
+
except Exception:
|
| 40 |
+
data = None
|
| 41 |
+
if not data:
|
| 42 |
+
data = {"title": "", "summary": "", "og_image": "", "body": [], "url": url, "source": "generic"}
|
| 43 |
+
title = _safe_text(data.get("title"))
|
| 44 |
+
summary = _safe_text(data.get("summary"))
|
| 45 |
+
img = _safe_text(data.get("og_image"))
|
| 46 |
+
body = data.get("body") or []
|
| 47 |
+
if not title or not summary or not img or not body:
|
| 48 |
+
try:
|
| 49 |
+
r = requests.get(url, headers=getattr(_main, "HEADERS", {}), timeout=15)
|
| 50 |
+
r.encoding = "utf-8"
|
| 51 |
+
soup = BeautifulSoup(r.text, "lxml")
|
| 52 |
+
if not title:
|
| 53 |
+
tag = soup.find("meta", property="og:title") or soup.find("title")
|
| 54 |
+
title = tag.get("content", "").strip() if tag and tag.name == "meta" else (tag.get_text(strip=True) if tag else "")
|
| 55 |
+
if not summary:
|
| 56 |
+
tag = soup.find("meta", property="og:description") or soup.find("meta", attrs={"name": "description"})
|
| 57 |
+
summary = tag.get("content", "").strip() if tag else ""
|
| 58 |
+
if not img:
|
| 59 |
+
tag = soup.find("meta", property="og:image") or soup.find("meta", attrs={"name": "twitter:image"})
|
| 60 |
+
img = tag.get("content", "").strip() if tag else ""
|
| 61 |
+
if not body:
|
| 62 |
+
ps = []
|
| 63 |
+
for p in soup.find_all("p"):
|
| 64 |
+
t = p.get_text(" ", strip=True)
|
| 65 |
+
if len(t) > 40:
|
| 66 |
+
ps.append({"type": "p", "text": t})
|
| 67 |
+
if len(ps) >= 30:
|
| 68 |
+
break
|
| 69 |
+
body = ps
|
| 70 |
+
except Exception:
|
| 71 |
+
pass
|
| 72 |
+
if not summary and body:
|
| 73 |
+
first = next((b.get("text", "") for b in body if b.get("type") == "p" and b.get("text")), "")
|
| 74 |
+
summary = first[:360]
|
| 75 |
+
if not title:
|
| 76 |
+
title = url
|
| 77 |
+
if not img:
|
| 78 |
+
img = DEFAULT_IMG
|
| 79 |
+
if not body and summary:
|
| 80 |
+
body = [{"type": "p", "text": summary}]
|
| 81 |
+
data.update({"title": title, "summary": summary, "og_image": img, "body": body, "url": url})
|
| 82 |
+
return data
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _rewrite(data, tone="tu-nhien"):
|
| 86 |
+
try:
|
| 87 |
+
if hasattr(_main, "_ai_rewrite_article"):
|
| 88 |
+
text = _main._ai_rewrite_article(data, tone=tone)
|
| 89 |
+
if text and len(text.strip()) > 50:
|
| 90 |
+
return text.strip()
|
| 91 |
+
except Exception:
|
| 92 |
+
pass
|
| 93 |
+
title = data.get("title", "")
|
| 94 |
+
summary = data.get("summary", "")
|
| 95 |
+
ps = [b.get("text", "") for b in data.get("body", []) if b.get("type") == "p" and b.get("text")]
|
| 96 |
+
lead = summary or (ps[0] if ps else "")
|
| 97 |
+
points = "\n".join(["• " + p[:220] + ("..." if len(p) > 220 else "") for p in ps[:5]])
|
| 98 |
+
body = "\n\n".join(ps[:10])
|
| 99 |
+
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()
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def _topic_image(topic):
|
| 103 |
+
try:
|
| 104 |
+
if hasattr(_main, "_image_for_topic"):
|
| 105 |
+
return _main._image_for_topic(topic)
|
| 106 |
+
except Exception:
|
| 107 |
+
pass
|
| 108 |
+
return "https://image.pollinations.ai/prompt/" + quote("editorial illustration Vietnamese news " + topic, safe="") + "?width=1024&height=576&nologo=true"
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def _save_post(post):
|
| 112 |
+
try:
|
| 113 |
+
posts = _main._load_wall() if hasattr(_main, "_load_wall") else []
|
| 114 |
+
except Exception:
|
| 115 |
+
posts = []
|
| 116 |
+
posts.insert(0, post)
|
| 117 |
+
try:
|
| 118 |
+
if hasattr(_main, "_save_wall"):
|
| 119 |
+
_main._save_wall(posts)
|
| 120 |
+
except Exception:
|
| 121 |
+
pass
|
| 122 |
+
return post
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
_remove_routes(["/api/url_wall", "/api/topic_post", "/api/rewrite_share", "/"])
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
@app.post("/api/url_wall")
|
| 129 |
+
async def patched_url_wall(request: Request):
|
| 130 |
+
try:
|
| 131 |
+
body = await request.json()
|
| 132 |
+
except Exception:
|
| 133 |
+
body = {}
|
| 134 |
+
url = _safe_text(body.get("url"))
|
| 135 |
+
tone = _safe_text(body.get("tone")) or "tu-nhien"
|
| 136 |
+
if not url:
|
| 137 |
+
return JSONResponse({"error": "missing url"}, status_code=400)
|
| 138 |
+
try:
|
| 139 |
+
data = _ensure_article(url)
|
| 140 |
+
text = _rewrite(data, tone=tone)
|
| 141 |
+
post = {
|
| 142 |
+
"id": hashlib.md5((url + str(time.time())).encode()).hexdigest()[:12],
|
| 143 |
+
"url": url,
|
| 144 |
+
"title": data.get("title") or url,
|
| 145 |
+
"summary": data.get("summary") or "",
|
| 146 |
+
"img": data.get("og_image") or DEFAULT_IMG,
|
| 147 |
+
"text": text or (data.get("summary") or data.get("title") or url),
|
| 148 |
+
"source": data.get("source", "url"),
|
| 149 |
+
"ts": int(time.time()),
|
| 150 |
+
}
|
| 151 |
+
_save_post(post)
|
| 152 |
+
return JSONResponse({"post": post})
|
| 153 |
+
except Exception as e:
|
| 154 |
+
return JSONResponse({"error": "Không tạo được tóm tắt URL", "detail": str(e)[:300]}, status_code=500)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
@app.post("/api/rewrite_share")
|
| 158 |
+
async def patched_rewrite_share(request: Request):
|
| 159 |
+
return await patched_url_wall(request)
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
@app.post("/api/topic_post")
|
| 163 |
+
async def patched_topic_post(request: Request):
|
| 164 |
+
try:
|
| 165 |
+
body = await request.json()
|
| 166 |
+
except Exception:
|
| 167 |
+
body = {}
|
| 168 |
+
topic = _safe_text(body.get("topic"))
|
| 169 |
+
tone = _safe_text(body.get("tone")) or "tu-nhien"
|
| 170 |
+
if not topic:
|
| 171 |
+
return JSONResponse({"error": "missing topic"}, status_code=400)
|
| 172 |
+
try:
|
| 173 |
+
context = ""
|
| 174 |
+
try:
|
| 175 |
+
if hasattr(_main, "_topic_article_context"):
|
| 176 |
+
context = _main._topic_article_context(topic)
|
| 177 |
+
if not context and hasattr(_main, "_web_context"):
|
| 178 |
+
context = _main._web_context(topic)
|
| 179 |
+
except Exception:
|
| 180 |
+
context = ""
|
| 181 |
+
if not context:
|
| 182 |
+
context = f"Chủ đề: {topic}"
|
| 183 |
+
data = {"title": topic, "summary": context[:420], "og_image": _topic_image(topic), "body": [{"type": "p", "text": context}], "source": "topic", "url": ""}
|
| 184 |
+
text = _rewrite(data, tone=tone)
|
| 185 |
+
post = {
|
| 186 |
+
"id": hashlib.md5((topic + str(time.time())).encode()).hexdigest()[:12],
|
| 187 |
+
"url": "",
|
| 188 |
+
"title": topic,
|
| 189 |
+
"summary": data["summary"],
|
| 190 |
+
"img": data["og_image"] or DEFAULT_IMG,
|
| 191 |
+
"text": text or context,
|
| 192 |
+
"source": "topic",
|
| 193 |
+
"ts": int(time.time()),
|
| 194 |
+
}
|
| 195 |
+
_save_post(post)
|
| 196 |
+
return JSONResponse({"post": post})
|
| 197 |
+
except Exception as e:
|
| 198 |
+
return JSONResponse({"error": "Không tạo được bài theo chủ đề", "detail": str(e)[:300]}, status_code=500)
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
_FRONTEND_PATCH = r'''
|
| 202 |
+
<script>
|
| 203 |
+
(function(){
|
| 204 |
+
async function safeJson(res){
|
| 205 |
+
const text = await res.text();
|
| 206 |
+
try { return JSON.parse(text); }
|
| 207 |
+
catch(e){ return { error: (text || 'Server không trả JSON').slice(0,500) }; }
|
| 208 |
+
}
|
| 209 |
+
window.safeJson = safeJson;
|
| 210 |
+
window.createUrlPost = function(){
|
| 211 |
+
let inp=document.getElementById('ai-url-input');
|
| 212 |
+
let url=(inp&&inp.value||'').trim();
|
| 213 |
+
if(!url){ alert('Dán URL trước'); return; }
|
| 214 |
+
fetch('/api/url_wall',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})})
|
| 215 |
+
.then(safeJson).then(j=>{
|
| 216 |
+
if(j&&j.post){
|
| 217 |
+
if(!j.post.img) j.post.img='https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg';
|
| 218 |
+
if(!j.post.text) j.post.text=j.post.summary||j.post.title||'Không lấy được nội dung tóm tắt.';
|
| 219 |
+
if(typeof prependWallPost==='function') prependWallPost(j.post);
|
| 220 |
+
alert('Đã tóm tắt URL và đăng lên tường');
|
| 221 |
+
if(inp) inp.value='';
|
| 222 |
+
} else alert((j&&j.error)||'Lỗi URL');
|
| 223 |
+
}).catch(e=>alert('Lỗi URL: '+e.message));
|
| 224 |
+
};
|
| 225 |
+
window.createTopicPost = function(){
|
| 226 |
+
let inp=document.getElementById('ai-topic-input');
|
| 227 |
+
let topic=(inp&&inp.value||'').trim();
|
| 228 |
+
if(!topic){ alert('Nhập chủ đề trước'); return; }
|
| 229 |
+
fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})})
|
| 230 |
+
.then(safeJson).then(j=>{
|
| 231 |
+
if(j&&j.post){
|
| 232 |
+
if(!j.post.img) j.post.img='https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg';
|
| 233 |
+
if(!j.post.text) j.post.text=j.post.summary||j.post.title||'Không lấy được nội dung.';
|
| 234 |
+
if(typeof prependWallPost==='function') prependWallPost(j.post);
|
| 235 |
+
alert('Đã tạo bài và đăng lên tường');
|
| 236 |
+
if(inp) inp.value='';
|
| 237 |
+
} else alert((j&&j.error)||'Lỗi tạo bài');
|
| 238 |
+
}).catch(e=>alert('Lỗi tạo bài: '+e.message));
|
| 239 |
+
};
|
| 240 |
+
window.rewriteCurrentArticle = function(){
|
| 241 |
+
if(!window._currentArticle && typeof _currentArticle!=='undefined') window._currentArticle=_currentArticle;
|
| 242 |
+
let ca = (typeof _currentArticle!=='undefined') ? _currentArticle : window._currentArticle;
|
| 243 |
+
if(!ca || !ca.url){ alert('Chưa có bài viết để rewrite'); return; }
|
| 244 |
+
let tone=document.getElementById('rewrite-tone')?.value||'nghiem-tuc';
|
| 245 |
+
let btn=document.querySelector('.article-actions button.primary');
|
| 246 |
+
if(btn){btn.textContent='Đang rewrite...';btn.disabled=true;}
|
| 247 |
+
fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:ca.url,tone})})
|
| 248 |
+
.then(safeJson).then(j=>{
|
| 249 |
+
if(j&&j.post){
|
| 250 |
+
if(!j.post.img) j.post.img='https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg';
|
| 251 |
+
if(!j.post.text) j.post.text=j.post.summary||j.post.title||'Không lấy được nội dung.';
|
| 252 |
+
let box=document.getElementById('rewrite-result');
|
| 253 |
+
if(box) box.innerHTML='<div class="rewrite-box"><div class="rewrite-title">Đã rewrite và đăng lên Tường AI</div><div class="rewrite-text">'+(j.post.text||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]))+'</div></div>';
|
| 254 |
+
if(typeof prependWallPost==='function') prependWallPost(j.post);
|
| 255 |
+
alert('Đã đăng lên Tường AI');
|
| 256 |
+
} else alert((j&&j.error)||'Không tạo được bài AI');
|
| 257 |
+
}).catch(e=>alert('Lỗi tạo bài AI: '+e.message))
|
| 258 |
+
.finally(()=>{if(btn){btn.textContent='🤖 AI viết lại & đăng tường';btn.disabled=false;}});
|
| 259 |
+
};
|
| 260 |
+
})();
|
| 261 |
+
</script>
|
| 262 |
+
'''
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
@app.get("/")
|
| 266 |
+
async def patched_index():
|
| 267 |
+
try:
|
| 268 |
+
with open("/app/static/index.html", "r", encoding="utf-8") as f:
|
| 269 |
+
html = f.read()
|
| 270 |
+
if "window.safeJson" not in html:
|
| 271 |
+
html = html.replace("</body>", _FRONTEND_PATCH + "</body>")
|
| 272 |
+
return HTMLResponse(content=html)
|
| 273 |
+
except Exception as e:
|
| 274 |
+
return HTMLResponse(content=f"<pre>Index error: {str(e)}</pre>", status_code=500)
|
piped_client.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
YouTube Shorts Scraper using Piped API
|
| 3 |
+
Piped is a privacy-friendly YouTube proxy that works without JS
|
| 4 |
+
"""
|
| 5 |
+
import requests
|
| 6 |
+
import json
|
| 7 |
+
import time
|
| 8 |
+
import threading
|
| 9 |
+
|
| 10 |
+
_cache = {}
|
| 11 |
+
_lock = threading.Lock()
|
| 12 |
+
CACHE_TTL = 900 # 15 min
|
| 13 |
+
|
| 14 |
+
# Piped API instances (public)
|
| 15 |
+
PIPED_INSTANCES = [
|
| 16 |
+
"https://pipedapi.kavin.rocks",
|
| 17 |
+
"https://pipedapi.adminforge.de",
|
| 18 |
+
"https://api.piped.projectsegfau.lt",
|
| 19 |
+
]
|
| 20 |
+
|
| 21 |
+
UA = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}
|
| 22 |
+
|
| 23 |
+
def _cached(key):
|
| 24 |
+
with _lock:
|
| 25 |
+
if key in _cache and time.time() - _cache[key]['t'] < CACHE_TTL:
|
| 26 |
+
return _cache[key]['d']
|
| 27 |
+
return None
|
| 28 |
+
|
| 29 |
+
def _set_cache(key, data):
|
| 30 |
+
with _lock:
|
| 31 |
+
_cache[key] = {'t': time.time(), 'd': data}
|
| 32 |
+
|
| 33 |
+
def _piped_request(path, params=None):
|
| 34 |
+
"""Try multiple Piped instances"""
|
| 35 |
+
last_err = None
|
| 36 |
+
for base in PIPED_INSTANCES:
|
| 37 |
+
try:
|
| 38 |
+
url = f"{base}{path}"
|
| 39 |
+
r = requests.get(url, params=params, headers=UA, timeout=15)
|
| 40 |
+
if r.status_code == 200:
|
| 41 |
+
return r.json()
|
| 42 |
+
except Exception as e:
|
| 43 |
+
last_err = e
|
| 44 |
+
continue
|
| 45 |
+
raise Exception(f"All Piped instances failed: {last_err}")
|
| 46 |
+
|
| 47 |
+
def get_channel_videos(channel_id, max_videos=200):
|
| 48 |
+
"""Get all videos from a channel using Piped API with pagination"""
|
| 49 |
+
cached = _cached(f'ch_vids_{channel_id}')
|
| 50 |
+
if cached is not None:
|
| 51 |
+
return cached
|
| 52 |
+
|
| 53 |
+
all_videos = []
|
| 54 |
+
page = None
|
| 55 |
+
|
| 56 |
+
while len(all_videos) < max_videos:
|
| 57 |
+
try:
|
| 58 |
+
if page:
|
| 59 |
+
data = _piped_request(f"/channels/{channel_id}/videos", {"nextpage": page})
|
| 60 |
+
else:
|
| 61 |
+
data = _piped_request(f"/channels/{channel_id}/videos")
|
| 62 |
+
|
| 63 |
+
videos = data.get('relatedStreams', [])
|
| 64 |
+
if not videos:
|
| 65 |
+
break
|
| 66 |
+
|
| 67 |
+
all_videos.extend(videos)
|
| 68 |
+
|
| 69 |
+
# Check for next page
|
| 70 |
+
next_page = data.get('nextpage')
|
| 71 |
+
if not next_page or next_page == page:
|
| 72 |
+
break
|
| 73 |
+
page = next_page
|
| 74 |
+
|
| 75 |
+
# Small delay to be polite
|
| 76 |
+
time.sleep(0.3)
|
| 77 |
+
|
| 78 |
+
if len(all_videos) >= max_videos:
|
| 79 |
+
break
|
| 80 |
+
|
| 81 |
+
except Exception as e:
|
| 82 |
+
print(f"Piped pagination error: {e}")
|
| 83 |
+
break
|
| 84 |
+
|
| 85 |
+
result = all_videos[:max_videos]
|
| 86 |
+
_set_cache(f'ch_vids_{channel_id}', result)
|
| 87 |
+
return result
|
| 88 |
+
|
| 89 |
+
def get_vtvnambo_shorts_piped(max_count=50):
|
| 90 |
+
"""Get shorts from VTV Nam Bộ using Piped API"""
|
| 91 |
+
# VTV Nam Bộ channel ID
|
| 92 |
+
channel_id = "UCJ0btJV8qh7J7R2aXb9GmGA"
|
| 93 |
+
|
| 94 |
+
try:
|
| 95 |
+
videos = get_channel_videos(channel_id, 200)
|
| 96 |
+
|
| 97 |
+
shorts = []
|
| 98 |
+
for v in videos:
|
| 99 |
+
title = v.get('title', '')
|
| 100 |
+
vid = v.get('url', '').replace('/watch?v=', '')
|
| 101 |
+
if not vid:
|
| 102 |
+
continue
|
| 103 |
+
|
| 104 |
+
# Filter for shorts: title has #shorts, or duration <= 60s
|
| 105 |
+
duration = v.get('duration', 0)
|
| 106 |
+
is_short = (
|
| 107 |
+
'#shorts' in title.lower() or
|
| 108 |
+
'#short' in title.lower() or
|
| 109 |
+
(duration > 0 and duration <= 60)
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
if is_short:
|
| 113 |
+
shorts.append({
|
| 114 |
+
'id': vid,
|
| 115 |
+
'title': title,
|
| 116 |
+
'img': f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg",
|
| 117 |
+
'channel': 'vtvnambo',
|
| 118 |
+
})
|
| 119 |
+
|
| 120 |
+
if shorts:
|
| 121 |
+
return shorts[:max_count]
|
| 122 |
+
except Exception as e:
|
| 123 |
+
print(f"Piped shorts error: {e}")
|
| 124 |
+
|
| 125 |
+
return []
|
| 126 |
+
|
| 127 |
+
def get_vtvnambo_shorts_rss(max_count=50):
|
| 128 |
+
"""Get shorts from YouTube RSS feed"""
|
| 129 |
+
cached = _cached('vtvnambo_rss')
|
| 130 |
+
if cached is not None:
|
| 131 |
+
return cached
|
| 132 |
+
|
| 133 |
+
from xml.etree import ElementTree as ET
|
| 134 |
+
|
| 135 |
+
# First get channel ID from page
|
| 136 |
+
channel_id = None
|
| 137 |
+
try:
|
| 138 |
+
r = requests.get("https://www.youtube.com/@vtvnambo", headers=UA, timeout=15)
|
| 139 |
+
if r.status_code == 200:
|
| 140 |
+
m = re.search(r'"channelId":"(UC[^"]+)"', r.text)
|
| 141 |
+
if m:
|
| 142 |
+
channel_id = m.group(1)
|
| 143 |
+
except:
|
| 144 |
+
pass
|
| 145 |
+
|
| 146 |
+
if not channel_id:
|
| 147 |
+
channel_id = "UCJ0btJV8qh7J7R2aXb9GmGA" # fallback
|
| 148 |
+
|
| 149 |
+
try:
|
| 150 |
+
url = f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"
|
| 151 |
+
r = requests.get(url, headers=UA, timeout=15)
|
| 152 |
+
if r.status_code != 200:
|
| 153 |
+
return []
|
| 154 |
+
|
| 155 |
+
root = ET.fromstring(r.text)
|
| 156 |
+
ns = {'atom': 'http://www.w3.org/2005/Atom', 'yt': 'http://www.youtube.com/xml/schemas/2015'}
|
| 157 |
+
|
| 158 |
+
shorts = []
|
| 159 |
+
for entry in root.findall('atom:entry', ns)[:max_count * 2]:
|
| 160 |
+
title_el = entry.find('atom:title', ns)
|
| 161 |
+
title = title_el.text if title_el is not None and title_el.text else ''
|
| 162 |
+
|
| 163 |
+
vid_el = entry.find('yt:videoId', ns)
|
| 164 |
+
vid = vid_el.text if vid_el is not None else ''
|
| 165 |
+
if not vid:
|
| 166 |
+
continue
|
| 167 |
+
|
| 168 |
+
is_short = '#shorts' in title.lower() or '#short' in title.lower()
|
| 169 |
+
link_el = entry.find('atom:link', ns)
|
| 170 |
+
link = link_el.get('href', '') if link_el is not None else ''
|
| 171 |
+
if '/shorts/' in link:
|
| 172 |
+
is_short = True
|
| 173 |
+
|
| 174 |
+
if is_short:
|
| 175 |
+
shorts.append({
|
| 176 |
+
'id': vid,
|
| 177 |
+
'title': title,
|
| 178 |
+
'img': f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg",
|
| 179 |
+
'channel': 'vtvnambo',
|
| 180 |
+
})
|
| 181 |
+
|
| 182 |
+
_set_cache('vtvnambo_rss', shorts[:max_count])
|
| 183 |
+
return shorts[:max_count]
|
| 184 |
+
except Exception as e:
|
| 185 |
+
print(f"RSS error: {e}")
|
| 186 |
+
|
| 187 |
+
return []
|
| 188 |
+
|
| 189 |
+
def get_vtvnambo_shorts(max_count=50):
|
| 190 |
+
"""Get all shorts from VTV Nam Bộ. Tries Piped API first, then RSS."""
|
| 191 |
+
cached = _cached('vtvnambo_shorts_v3')
|
| 192 |
+
if cached is not None:
|
| 193 |
+
return cached
|
| 194 |
+
|
| 195 |
+
all_shorts = []
|
| 196 |
+
seen_ids = set()
|
| 197 |
+
|
| 198 |
+
# Method 1: Piped API (most reliable)
|
| 199 |
+
try:
|
| 200 |
+
piped_shorts = get_vtvnambo_shorts_piped(max_count)
|
| 201 |
+
for s in piped_shorts:
|
| 202 |
+
if s['id'] not in seen_ids:
|
| 203 |
+
seen_ids.add(s['id'])
|
| 204 |
+
all_shorts.append(s)
|
| 205 |
+
print(f"Piped API found {len(piped_shorts)} shorts")
|
| 206 |
+
except Exception as e:
|
| 207 |
+
print(f"Piped method failed: {e}")
|
| 208 |
+
|
| 209 |
+
# Method 2: RSS feed
|
| 210 |
+
if len(all_shorts) < 3:
|
| 211 |
+
try:
|
| 212 |
+
rss_shorts = get_vtvnambo_shorts_rss(max_count)
|
| 213 |
+
for s in rss_shorts:
|
| 214 |
+
if s['id'] not in seen_ids:
|
| 215 |
+
seen_ids.add(s['id'])
|
| 216 |
+
all_shorts.append(s)
|
| 217 |
+
print(f"RSS found {len(rss_shorts)} shorts")
|
| 218 |
+
except Exception as e:
|
| 219 |
+
print(f"RSS method failed: {e}")
|
| 220 |
+
|
| 221 |
+
result = all_shorts[:max_count]
|
| 222 |
+
_set_cache('vtvnambo_shorts_v3', result)
|
| 223 |
+
return result
|
| 224 |
+
|
| 225 |
+
def get_wc_related_shorts(max_count=30):
|
| 226 |
+
"""Get World Cup / football related shorts."""
|
| 227 |
+
all_shorts = get_vtvnambo_shorts(max_count * 3)
|
| 228 |
+
|
| 229 |
+
wc_kws = [
|
| 230 |
+
'world cup', 'wc 2026', 'worldcup', 'fifa', 'bóng đá',
|
| 231 |
+
'trận đấu', 'đội tuyển', 'tuyển', 'vòng loại',
|
| 232 |
+
'khoảnh khắc', 'highlights', 'bàn thắng', 'goal',
|
| 233 |
+
'kết quả', 'tỉ số', 'việt nam', 'vn',
|
| 234 |
+
'ngoại hạng', 'premier league', 'champions league',
|
| 235 |
+
'laliga', 'serie a', 'bundesliga', 'ligue 1',
|
| 236 |
+
'copa', 'europa', 'c1', 'c2',
|
| 237 |
+
'messi', 'ronaldo', 'neymar', 'mbappe', 'haaland',
|
| 238 |
+
'v-league', 'vleague', 'bóng đá việt',
|
| 239 |
+
'đội bóng', 'hlv', 'huấn luyện viên',
|
| 240 |
+
'chuyển nhượng', 'transfer',
|
| 241 |
+
'asian cup', 'aff cup', 'sea games',
|
| 242 |
+
'olympic', 'u23', 'u20', 'u17',
|
| 243 |
+
]
|
| 244 |
+
|
| 245 |
+
wc_shorts = []
|
| 246 |
+
for s in all_shorts:
|
| 247 |
+
tl = s.get('title', '').lower()
|
| 248 |
+
if any(k in tl for k in wc_kws):
|
| 249 |
+
wc_shorts.append(s)
|
| 250 |
+
|
| 251 |
+
if not wc_shorts:
|
| 252 |
+
wc_shorts = all_shorts
|
| 253 |
+
|
| 254 |
+
return wc_shorts[:max_count]
|
| 255 |
+
|
| 256 |
+
import re
|
| 257 |
+
# Alias for backward compatibility
|
| 258 |
+
get_vtvnamo_shorts = get_vtvnambo_shorts
|
rebuild3.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
rebuild
|
rebuild_trigger.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
TRIGGER_REBUILD=20260719
|
requirements.txt
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn
|
| 3 |
+
requests
|
| 4 |
+
beautifulsoup4>=4.12.0
|
| 5 |
+
lxml
|
| 6 |
+
jinja2
|
| 7 |
+
yt-dlp
|
| 8 |
+
huggingface_hub
|
| 9 |
+
gTTS
|
| 10 |
+
pillow
|
| 11 |
+
edge-tts
|
| 12 |
+
python-dateutil
|
| 13 |
+
httpx
|
| 14 |
+
python-multipart
|
| 15 |
+
pycryptodome
|
| 16 |
+
# trigger rebuild 1784359130
|
restart.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
rebuild 1786378489
|
restart2.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
restart with _run.py fix
|