Spaces:
Running
Running
Apply shorts feature from commit 32da1baa - /api/shorts + /api/ai/short endpoints
Browse files- _run.py +1 -10
- ai_ext.py +85 -112
- ai_patch.py +869 -272
- ai_runtime.py +1 -2
- ai_runtime_final.py +10 -10
- ai_runtime_final6.py +18 -12
- ai_runtime_patch_fast.py +24 -509
- app_patch_unified.py +3 -62
- app_v2_entry.py +16 -33
- main.py +3 -183
- static/rewrite_fix_v2.js +2 -0
_run.py
CHANGED
|
@@ -1,10 +1 @@
|
|
| 1 |
-
|
| 2 |
-
from app_v2_entry import app
|
| 3 |
-
import logs_route
|
| 4 |
-
import ai_runtime_patch_fast # Original patch (for rewrite, hashtag, etc.)
|
| 5 |
-
# Load fix module LAST to register short endpoints cleanly
|
| 6 |
-
try:
|
| 7 |
-
import ai_runtime_fix # <-- This registers /api/ai/short endpoints
|
| 8 |
-
except Exception as e:
|
| 9 |
-
import logging
|
| 10 |
-
logging.getLogger("_run").warning(f"ai_runtime_fix not loaded: {e}")
|
|
|
|
| 1 |
+
from app_v2_entry import app # v5-stable inline bongda proxy
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
ai_ext.py
CHANGED
|
@@ -1,5 +1,9 @@
|
|
| 1 |
"""VNEWS AI Extension - rewrite + auto short video generation.
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
"""
|
| 4 |
import os, re, json, time, random, html as html_lib, subprocess, asyncio
|
| 5 |
from urllib.parse import quote_plus, quote, urlparse, urljoin
|
|
@@ -20,18 +24,13 @@ except ImportError:
|
|
| 20 |
except Exception:
|
| 21 |
app = None
|
| 22 |
|
| 23 |
-
# Import
|
| 24 |
try:
|
| 25 |
-
from
|
| 26 |
-
load_wall_posts as _load_ai_wall,
|
| 27 |
-
save_wall_posts as _save_ai_wall,
|
| 28 |
-
DATA_DIR
|
| 29 |
-
)
|
| 30 |
except ImportError:
|
| 31 |
_data_dir = "/data" if os.path.isdir("/data") else "/app/data"
|
| 32 |
-
DATA_DIR = _data_dir
|
| 33 |
_wall_file = os.path.join(_data_dir, "wall_posts.json")
|
| 34 |
-
def
|
| 35 |
try:
|
| 36 |
if os.path.exists(_wall_file):
|
| 37 |
with open(_wall_file, "r", encoding="utf-8") as f:
|
|
@@ -39,7 +38,7 @@ except ImportError:
|
|
| 39 |
except Exception:
|
| 40 |
pass
|
| 41 |
return []
|
| 42 |
-
def
|
| 43 |
try:
|
| 44 |
os.makedirs(os.path.dirname(_wall_file), exist_ok=True)
|
| 45 |
tmp = _wall_file + ".tmp"
|
|
@@ -48,10 +47,12 @@ except ImportError:
|
|
| 48 |
os.replace(tmp, _wall_file)
|
| 49 |
except Exception:
|
| 50 |
pass
|
|
|
|
|
|
|
| 51 |
|
| 52 |
-
#
|
| 53 |
-
|
| 54 |
-
|
| 55 |
|
| 56 |
try:
|
| 57 |
from huggingface_hub import AsyncInferenceClient
|
|
@@ -70,65 +71,6 @@ try:
|
|
| 70 |
except Exception:
|
| 71 |
edge_tts = None
|
| 72 |
|
| 73 |
-
# ===== VIETNAMESE FONT DETECTION =====
|
| 74 |
-
_VN_FONT_REG = None
|
| 75 |
-
_VN_FONT_BOLD = None
|
| 76 |
-
|
| 77 |
-
def _get_vn_fonts():
|
| 78 |
-
"""Auto-detect Vietnamese-supporting fonts."""
|
| 79 |
-
global _VN_FONT_REG, _VN_FONT_BOLD
|
| 80 |
-
if _VN_FONT_REG is not None:
|
| 81 |
-
return _VN_FONT_REG, _VN_FONT_BOLD
|
| 82 |
-
|
| 83 |
-
reg_paths = [
|
| 84 |
-
"/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",
|
| 85 |
-
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
| 86 |
-
"/usr/share/fonts/truetype/freefont/FreeSans.ttf",
|
| 87 |
-
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
|
| 88 |
-
]
|
| 89 |
-
bold_paths = [
|
| 90 |
-
"/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf",
|
| 91 |
-
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
| 92 |
-
"/usr/share/fonts/truetype/freefont/FreeSans.ttf",
|
| 93 |
-
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
|
| 94 |
-
]
|
| 95 |
-
|
| 96 |
-
# Search broadly
|
| 97 |
-
try:
|
| 98 |
-
result = subprocess.run(
|
| 99 |
-
['find', '/usr/share/fonts', '-name', '*.ttf', '-type', 'f'],
|
| 100 |
-
capture_output=True, text=True, timeout=5
|
| 101 |
-
)
|
| 102 |
-
all_ttf = result.stdout.strip().split('\n')
|
| 103 |
-
for key in ['noto', 'dejavu', 'liberation', 'freefont', 'vn']:
|
| 104 |
-
for path in all_ttf:
|
| 105 |
-
pl = path.lower()
|
| 106 |
-
if key in pl:
|
| 107 |
-
if ('bold' in pl or 'variable' in pl) and _VN_FONT_BOLD is None:
|
| 108 |
-
try: _VN_FONT_BOLD = ImageFont.truetype(path, 52)
|
| 109 |
-
except: pass
|
| 110 |
-
elif ('regular' in pl or path == '/usr/share/fonts/truetype/vn/VNFont.ttf') and _VN_FONT_REG is None:
|
| 111 |
-
try: _VN_FONT_REG = ImageFont.truetype(path, 40)
|
| 112 |
-
except: pass
|
| 113 |
-
except:
|
| 114 |
-
pass
|
| 115 |
-
|
| 116 |
-
for path in reg_paths:
|
| 117 |
-
if os.path.exists(path) and _VN_FONT_REG is None:
|
| 118 |
-
try: _VN_FONT_REG = ImageFont.truetype(path, 40)
|
| 119 |
-
except: continue
|
| 120 |
-
for path in bold_paths:
|
| 121 |
-
if os.path.exists(path) and _VN_FONT_BOLD is None:
|
| 122 |
-
try: _VN_FONT_BOLD = ImageFont.truetype(path, 52)
|
| 123 |
-
except: continue
|
| 124 |
-
|
| 125 |
-
if _VN_FONT_REG is None:
|
| 126 |
-
_VN_FONT_REG = ImageFont.load_default()
|
| 127 |
-
if _VN_FONT_BOLD is None:
|
| 128 |
-
_VN_FONT_BOLD = _VN_FONT_REG
|
| 129 |
-
|
| 130 |
-
return _VN_FONT_REG, _VN_FONT_BOLD
|
| 131 |
-
|
| 132 |
|
| 133 |
def _hf_token():
|
| 134 |
for k in ("HF_TOKEN", "HUGGINGFACE_HUB_API_TOKEN", "HUGGING_FACE_HUB_TOKEN", "HF_API_TOKEN"):
|
|
@@ -154,10 +96,17 @@ def _domain(url: str) -> str:
|
|
| 154 |
|
| 155 |
|
| 156 |
async def qwen_generate(prompt: str, image_url: str = None, max_tokens: int = 1200) -> str:
|
| 157 |
-
"""Generate text using Qwen models via Hugging Face Inference API.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
token = _hf_token()
|
| 159 |
errors = []
|
| 160 |
|
|
|
|
| 161 |
if token:
|
| 162 |
models = [
|
| 163 |
os.getenv("QWEN_VL_MODEL", ""),
|
|
@@ -169,6 +118,7 @@ async def qwen_generate(prompt: str, image_url: str = None, max_tokens: int = 12
|
|
| 169 |
"Qwen/Qwen2.5-72B-Instruct",
|
| 170 |
"meta-llama/Llama-3.3-70B-Instruct",
|
| 171 |
]
|
|
|
|
| 172 |
seen = set()
|
| 173 |
models = [m for m in models if m and m not in seen and not seen.add(m)]
|
| 174 |
|
|
@@ -218,7 +168,7 @@ async def qwen_generate(prompt: str, image_url: str = None, max_tokens: int = 12
|
|
| 218 |
except Exception as e:
|
| 219 |
errors.append(f"{model}: {type(e).__name__}")
|
| 220 |
|
| 221 |
-
|
| 222 |
LAST_QWEN_ERROR = errors[-3:] if errors else "unknown error"
|
| 223 |
return _fallback_summary_from_prompt(prompt, max_units=6)
|
| 224 |
|
|
@@ -233,17 +183,20 @@ def _fallback_summary_from_prompt(prompt: str, max_units: int = 6) -> str:
|
|
| 233 |
text = re.sub(r"https?://\S+", "", text)
|
| 234 |
text = re.sub(r"\s+", " ", text).strip()
|
| 235 |
|
|
|
|
| 236 |
sentences = re.split(r"(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])", text)
|
| 237 |
units = []
|
| 238 |
for s in sentences:
|
| 239 |
s = _clean_text(s)
|
| 240 |
-
if len(s) >= 30:
|
| 241 |
units.append(s)
|
| 242 |
|
| 243 |
if units:
|
|
|
|
| 244 |
result_units = units[:max_units]
|
| 245 |
return "\n".join("• " + u for u in result_units)
|
| 246 |
if text:
|
|
|
|
| 247 |
chunks = []
|
| 248 |
for i in range(0, min(len(text), max_units * 300), 280):
|
| 249 |
chunk = _clean_text(text[i:i+300])
|
|
@@ -264,6 +217,7 @@ QWEN_TEXT_MODELS = [m.strip() for m in os.getenv(
|
|
| 264 |
).split(",") if m.strip()]
|
| 265 |
_WORKING_MODEL_TEXT = None
|
| 266 |
_WORKING_MODEL_VL = None
|
|
|
|
| 267 |
SHORTS_DIR = os.path.join(DATA_DIR, "ai_shorts")
|
| 268 |
HEADERS = {
|
| 269 |
"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",
|
|
@@ -273,7 +227,9 @@ LAST_QWEN_ERROR = ""
|
|
| 273 |
|
| 274 |
|
| 275 |
# ===== MULTILINGUAL VOICES FOR TTS =====
|
|
|
|
| 276 |
MULTILINGUAL_VOICES = {
|
|
|
|
| 277 |
"vi-vn-hoaimyneural": "vi-VN-HoaiMyNeural",
|
| 278 |
"vi-vn-namminhneural": "vi-VN-NamMinhNeural",
|
| 279 |
"hoaimy": "vi-VN-HoaiMyNeural",
|
|
@@ -284,39 +240,48 @@ MULTILINGUAL_VOICES = {
|
|
| 284 |
"male": "vi-VN-NamMinhNeural",
|
| 285 |
"female": "vi-VN-HoaiMyNeural",
|
| 286 |
"mien-nam": "vi-VN-HoaiMyNeural",
|
|
|
|
| 287 |
"en-us-andrewmultilingualneural": "en-US-AndrewMultilingualNeural",
|
| 288 |
"en-au-williammultilingualneural": "en-AU-WilliamMultilingualNeural",
|
| 289 |
"en_andrew": "en-US-AndrewMultilingualNeural",
|
| 290 |
"andrew": "en-US-AndrewMultilingualNeural",
|
| 291 |
"en_jenny": "en-US-AndrewMultilingualNeural",
|
| 292 |
"jenny": "en-US-AndrewMultilingualNeural",
|
|
|
|
| 293 |
"pt-br-thalitamultilingualneural": "pt-BR-ThalitaMultilingualNeural",
|
| 294 |
"pt_thalita": "pt-BR-ThalitaMultilingualNeural",
|
| 295 |
"thalita": "pt-BR-ThalitaMultilingualNeural",
|
| 296 |
"pt_francisco": "pt-BR-ThalitaMultilingualNeural",
|
| 297 |
"pt": "pt-BR-ThalitaMultilingualNeural",
|
|
|
|
| 298 |
"fr-fr-viviennemultilingualneural": "fr-FR-VivienneMultilingualNeural",
|
| 299 |
"fr-fr-remymultilingualneural": "fr-FR-RemyMultilingualNeural",
|
| 300 |
"fr_denise": "fr-FR-VivienneMultilingualNeural",
|
| 301 |
"denise": "fr-FR-VivienneMultilingualNeural",
|
| 302 |
"fr": "fr-FR-VivienneMultilingualNeural",
|
|
|
|
| 303 |
"de-de-seraphinamultilingualneural": "de-DE-SeraphinaMultilingualNeural",
|
| 304 |
"de-de-florianmultilingualneural": "de-DE-FlorianMultilingualNeural",
|
| 305 |
"de_katja": "de-DE-SeraphinaMultilingualNeural",
|
| 306 |
"katja": "de-DE-SeraphinaMultilingualNeural",
|
| 307 |
"de": "de-DE-SeraphinaMultilingualNeural",
|
|
|
|
| 308 |
"ko-kr-hyunsumultilingualneural": "ko-KR-HyunsuMultilingualNeural",
|
| 309 |
"ko_sunhee": "ko-KR-HyunsuMultilingualNeural",
|
| 310 |
"sunhee": "ko-KR-HyunsuMultilingualNeural",
|
| 311 |
"ko": "ko-KR-HyunsuMultilingualNeural",
|
|
|
|
| 312 |
"it-it-giuseppemultilingualneural": "it-IT-GiuseppeMultilingualNeural",
|
|
|
|
| 313 |
"es_ela": "en-US-AndrewMultilingualNeural",
|
| 314 |
"ela": "en-US-AndrewMultilingualNeural",
|
| 315 |
"es_carlos": "en-US-AndrewMultilingualNeural",
|
| 316 |
"es": "en-US-AndrewMultilingualNeural",
|
|
|
|
| 317 |
"ja_nanami": "en-US-AndrewMultilingualNeural",
|
| 318 |
"nanami": "en-US-AndrewMultilingualNeural",
|
| 319 |
"ja": "en-US-AndrewMultilingualNeural",
|
|
|
|
| 320 |
"zh_xiaochen": "en-US-AndrewMultilingualNeural",
|
| 321 |
"xiaochen": "en-US-AndrewMultilingualNeural",
|
| 322 |
"zh": "en-US-AndrewMultilingualNeural",
|
|
@@ -327,9 +292,11 @@ def _detect_voice_emotion(title, text):
|
|
| 327 |
"""Detect appropriate voice and emotion based on content for multilingual TTS."""
|
| 328 |
content = ((title or "") + " " + (text or "")).lower()
|
| 329 |
|
|
|
|
| 330 |
if any(kw in content for kw in ["world cup", "wc 2026", "fifa", "bóng đá", "trận đấu", "bóng bóng", "đội tuyển", "cầu thủ"]):
|
| 331 |
return ("andrew", "excited")
|
| 332 |
|
|
|
|
| 333 |
if any(kw in content for kw in ["kinh tế", "tài chính", "thị trường", "economics", "finance"]):
|
| 334 |
return ("jenny", "calm")
|
| 335 |
if any(kw in content for kw in ["thiên tai", "bão", "lũ lụt", "cháy nổ", "tai nạn", "disaster", "accident"]):
|
|
@@ -339,6 +306,7 @@ def _detect_voice_emotion(title, text):
|
|
| 339 |
if any(kw in content for kw in ["công nghệ", "tech", "technology", "ai", "trí tuệ nhân tạo"]):
|
| 340 |
return ("katja", "excited")
|
| 341 |
|
|
|
|
| 342 |
return ("hoaimy", "trung_tinh")
|
| 343 |
|
| 344 |
|
|
@@ -372,6 +340,29 @@ def pollination_image_url(topic: str) -> str:
|
|
| 372 |
# Use the same wall file as app_v2_entry.py for consistency
|
| 373 |
WALL_FILE = os.path.join(DATA_DIR, "wall_posts.json")
|
| 374 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 375 |
# Helper functions for wall operations
|
| 376 |
def _load_wall_posts():
|
| 377 |
"""Alias for _load_ai_wall for consistency with app_v2_entry.py."""
|
|
@@ -402,7 +393,7 @@ def _short_script(post) -> str:
|
|
| 402 |
text = post.get("text", "") or post.get("title", "")
|
| 403 |
text = re.sub(r"^[•\-\*]\s*", "", text, flags=re.M)
|
| 404 |
text = re.sub(r"\s*\n\s*", ". ", text)
|
| 405 |
-
return _clean_text(text)[:2000]
|
| 406 |
|
| 407 |
|
| 408 |
# ===== SCRAPER FUNCTIONS (required by ai_patch.py) =====
|
|
@@ -416,8 +407,8 @@ def scrape_any_url(url: str) -> dict:
|
|
| 416 |
r.encoding = 'utf-8'
|
| 417 |
soup = BeautifulSoup(r.text, 'lxml')
|
| 418 |
|
| 419 |
-
# Remove scripts, styles, nav, footer
|
| 420 |
-
for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form'
|
| 421 |
tag.decompose()
|
| 422 |
|
| 423 |
# Extract title
|
|
@@ -431,52 +422,28 @@ def scrape_any_url(url: str) -> dict:
|
|
| 431 |
|
| 432 |
# Extract article body
|
| 433 |
block = None
|
| 434 |
-
|
| 435 |
-
'.fck_detail', '.sidebar-1',
|
| 436 |
-
'.singular-content', '.dt__content', '.article-content', '.content-detail', '#divNewsContent',
|
| 437 |
-
'.content-detail', '.main-content-detail', '.box-content',
|
| 438 |
-
'.knc-content', '.article-body', '.detail-body',
|
| 439 |
-
'.article-detail', '.detail-content',
|
| 440 |
-
'article', 'main', '.cms-body', '.article__body', '.post-content',
|
| 441 |
-
'.entry-content', '#content', '.article-text', '.story-body',
|
| 442 |
-
]
|
| 443 |
-
for sel in selectors:
|
| 444 |
el = soup.select_one(sel)
|
| 445 |
if el and len(el.find_all('p')) >= 2:
|
| 446 |
block = el
|
| 447 |
break
|
| 448 |
if not block:
|
| 449 |
-
|
| 450 |
-
for el in soup.find_all(['article', 'main', 'section', 'div']):
|
| 451 |
-
ps = el.find_all('p')
|
| 452 |
-
if len(ps) >= 2:
|
| 453 |
-
text_len = sum(len(p.get_text(strip=True)) for p in ps)
|
| 454 |
-
score = text_len + len(ps) * 100
|
| 455 |
-
cls = ' '.join(el.get('class', [])) + ' ' + el.get('id', '')
|
| 456 |
-
if any(k in cls.lower() for k in ['content', 'detail', 'article', 'story', 'body']):
|
| 457 |
-
score += 500
|
| 458 |
-
if score > best_score:
|
| 459 |
-
best = el; best_score = score
|
| 460 |
-
block = best or soup.body or soup
|
| 461 |
|
| 462 |
# Extract text from paragraphs
|
| 463 |
paragraphs = []
|
| 464 |
for el in block.find_all(['p', 'h2', 'h3'], recursive=True):
|
| 465 |
t = _clean_text(el.get_text(strip=True))
|
| 466 |
if t and len(t) > 40:
|
| 467 |
-
if 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']):
|
| 468 |
-
continue
|
| 469 |
paragraphs.append(t)
|
| 470 |
|
| 471 |
# Extract images
|
| 472 |
images = []
|
| 473 |
-
seen_imgs = set()
|
| 474 |
for el in block.find_all(['figure', 'img'], recursive=True):
|
| 475 |
im = el if el.name == 'img' else el.find('img')
|
| 476 |
if im:
|
| 477 |
-
src = im.get('data-src') or im.get('src') or im.get('data-original') or
|
| 478 |
-
if src and 'base64' not in src
|
| 479 |
-
seen_imgs.add(src)
|
| 480 |
if src.startswith('//'):
|
| 481 |
src = 'https:' + src
|
| 482 |
images.append(src)
|
|
@@ -501,6 +468,7 @@ def web_context(topic: str, limit: int = 5) -> tuple:
|
|
| 501 |
"""Get web context for a topic. Returns (context_text, sources_list)."""
|
| 502 |
sources = []
|
| 503 |
try:
|
|
|
|
| 504 |
rss_url = f"https://news.google.com/rss/search?q={quote_plus(topic)}&hl=vi&gl=VN&ceid=VN:vi"
|
| 505 |
r = requests.get(rss_url, headers=HEADERS, timeout=15)
|
| 506 |
r.encoding = 'utf-8'
|
|
@@ -519,13 +487,15 @@ def web_context(topic: str, limit: int = 5) -> tuple:
|
|
| 519 |
|
| 520 |
# ===== SHORT FRAME FUNCTION (required by ai_patch.py) =====
|
| 521 |
def _make_short_frame(post, img_path, out_path):
|
| 522 |
-
"""Create a short video frame from post and image.
|
|
|
|
|
|
|
|
|
|
| 523 |
if Image is None:
|
|
|
|
|
|
|
| 524 |
return False
|
| 525 |
|
| 526 |
-
_get_vn_fonts()
|
| 527 |
-
global _VN_FONT_REG, _VN_FONT_BOLD
|
| 528 |
-
|
| 529 |
W, H = 1080, 1920
|
| 530 |
bg = Image.new("RGB", (W, H), (14, 14, 14))
|
| 531 |
|
|
@@ -552,8 +522,11 @@ def _make_short_frame(post, img_path, out_path):
|
|
| 552 |
|
| 553 |
draw = ImageDraw.Draw(bg)
|
| 554 |
|
| 555 |
-
|
| 556 |
-
|
|
|
|
|
|
|
|
|
|
| 557 |
|
| 558 |
draw.rectangle((0, 720, W, H), fill=(14, 14, 14))
|
| 559 |
margin = 48
|
|
|
|
| 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
|
|
|
|
| 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:
|
|
|
|
| 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"
|
|
|
|
| 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
|
|
|
|
| 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"):
|
|
|
|
| 96 |
|
| 97 |
|
| 98 |
async def qwen_generate(prompt: str, image_url: str = None, max_tokens: int = 1200) -> str:
|
| 99 |
+
"""Generate text using Qwen models via Hugging Face Inference API.
|
| 100 |
+
|
| 101 |
+
This function provides a resilient implementation that:
|
| 102 |
+
1. First tries the SDK-based inference client if available
|
| 103 |
+
2. Falls back to REST API calls to HF router endpoint
|
| 104 |
+
3. Returns a fallback summary if all else fails
|
| 105 |
+
"""
|
| 106 |
token = _hf_token()
|
| 107 |
errors = []
|
| 108 |
|
| 109 |
+
# Try HF router API with multiple models
|
| 110 |
if token:
|
| 111 |
models = [
|
| 112 |
os.getenv("QWEN_VL_MODEL", ""),
|
|
|
|
| 118 |
"Qwen/Qwen2.5-72B-Instruct",
|
| 119 |
"meta-llama/Llama-3.3-70B-Instruct",
|
| 120 |
]
|
| 121 |
+
# Deduplicate while preserving order
|
| 122 |
seen = set()
|
| 123 |
models = [m for m in models if m and m not in seen and not seen.add(m)]
|
| 124 |
|
|
|
|
| 168 |
except Exception as e:
|
| 169 |
errors.append(f"{model}: {type(e).__name__}")
|
| 170 |
|
| 171 |
+
# Fallback: extractive summary from prompt
|
| 172 |
LAST_QWEN_ERROR = errors[-3:] if errors else "unknown error"
|
| 173 |
return _fallback_summary_from_prompt(prompt, max_units=6)
|
| 174 |
|
|
|
|
| 183 |
text = re.sub(r"https?://\S+", "", text)
|
| 184 |
text = re.sub(r"\s+", " ", text).strip()
|
| 185 |
|
| 186 |
+
# Split into sentences - extract ALL valid sentences, not just first few
|
| 187 |
sentences = re.split(r"(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])", text)
|
| 188 |
units = []
|
| 189 |
for s in sentences:
|
| 190 |
s = _clean_text(s)
|
| 191 |
+
if len(s) >= 30: # Lower threshold to capture more content
|
| 192 |
units.append(s)
|
| 193 |
|
| 194 |
if units:
|
| 195 |
+
# Take up to max_units valid sentences
|
| 196 |
result_units = units[:max_units]
|
| 197 |
return "\n".join("• " + u for u in result_units)
|
| 198 |
if text:
|
| 199 |
+
# Fallback: take chunks if no sentence boundaries found
|
| 200 |
chunks = []
|
| 201 |
for i in range(0, min(len(text), max_units * 300), 280):
|
| 202 |
chunk = _clean_text(text[i:i+300])
|
|
|
|
| 217 |
).split(",") if m.strip()]
|
| 218 |
_WORKING_MODEL_TEXT = None
|
| 219 |
_WORKING_MODEL_VL = None
|
| 220 |
+
DATA_DIR = "/data" if os.path.isdir("/data") else "/app/data"
|
| 221 |
SHORTS_DIR = os.path.join(DATA_DIR, "ai_shorts")
|
| 222 |
HEADERS = {
|
| 223 |
"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",
|
|
|
|
| 227 |
|
| 228 |
|
| 229 |
# ===== MULTILINGUAL VOICES FOR TTS =====
|
| 230 |
+
# Maps voice IDs to edge-tts voice names (only MultilingualNeural voices)
|
| 231 |
MULTILINGUAL_VOICES = {
|
| 232 |
+
# Vietnamese - Native voices
|
| 233 |
"vi-vn-hoaimyneural": "vi-VN-HoaiMyNeural",
|
| 234 |
"vi-vn-namminhneural": "vi-VN-NamMinhNeural",
|
| 235 |
"hoaimy": "vi-VN-HoaiMyNeural",
|
|
|
|
| 240 |
"male": "vi-VN-NamMinhNeural",
|
| 241 |
"female": "vi-VN-HoaiMyNeural",
|
| 242 |
"mien-nam": "vi-VN-HoaiMyNeural",
|
| 243 |
+
# English - Multilingual
|
| 244 |
"en-us-andrewmultilingualneural": "en-US-AndrewMultilingualNeural",
|
| 245 |
"en-au-williammultilingualneural": "en-AU-WilliamMultilingualNeural",
|
| 246 |
"en_andrew": "en-US-AndrewMultilingualNeural",
|
| 247 |
"andrew": "en-US-AndrewMultilingualNeural",
|
| 248 |
"en_jenny": "en-US-AndrewMultilingualNeural",
|
| 249 |
"jenny": "en-US-AndrewMultilingualNeural",
|
| 250 |
+
# Portuguese - Thalita Multilingual ONLY
|
| 251 |
"pt-br-thalitamultilingualneural": "pt-BR-ThalitaMultilingualNeural",
|
| 252 |
"pt_thalita": "pt-BR-ThalitaMultilingualNeural",
|
| 253 |
"thalita": "pt-BR-ThalitaMultilingualNeural",
|
| 254 |
"pt_francisco": "pt-BR-ThalitaMultilingualNeural",
|
| 255 |
"pt": "pt-BR-ThalitaMultilingualNeural",
|
| 256 |
+
# French - Multilingual
|
| 257 |
"fr-fr-viviennemultilingualneural": "fr-FR-VivienneMultilingualNeural",
|
| 258 |
"fr-fr-remymultilingualneural": "fr-FR-RemyMultilingualNeural",
|
| 259 |
"fr_denise": "fr-FR-VivienneMultilingualNeural",
|
| 260 |
"denise": "fr-FR-VivienneMultilingualNeural",
|
| 261 |
"fr": "fr-FR-VivienneMultilingualNeural",
|
| 262 |
+
# German - Multilingual
|
| 263 |
"de-de-seraphinamultilingualneural": "de-DE-SeraphinaMultilingualNeural",
|
| 264 |
"de-de-florianmultilingualneural": "de-DE-FlorianMultilingualNeural",
|
| 265 |
"de_katja": "de-DE-SeraphinaMultilingualNeural",
|
| 266 |
"katja": "de-DE-SeraphinaMultilingualNeural",
|
| 267 |
"de": "de-DE-SeraphinaMultilingualNeural",
|
| 268 |
+
# Korean - Hyunsu Multilingual (NOT SunHee)
|
| 269 |
"ko-kr-hyunsumultilingualneural": "ko-KR-HyunsuMultilingualNeural",
|
| 270 |
"ko_sunhee": "ko-KR-HyunsuMultilingualNeural",
|
| 271 |
"sunhee": "ko-KR-HyunsuMultilingualNeural",
|
| 272 |
"ko": "ko-KR-HyunsuMultilingualNeural",
|
| 273 |
+
# Italian - Multilingual
|
| 274 |
"it-it-giuseppemultilingualneural": "it-IT-GiuseppeMultilingualNeural",
|
| 275 |
+
# Spanish (fallback to English multilingual)
|
| 276 |
"es_ela": "en-US-AndrewMultilingualNeural",
|
| 277 |
"ela": "en-US-AndrewMultilingualNeural",
|
| 278 |
"es_carlos": "en-US-AndrewMultilingualNeural",
|
| 279 |
"es": "en-US-AndrewMultilingualNeural",
|
| 280 |
+
# Japanese (fallback to English multilingual)
|
| 281 |
"ja_nanami": "en-US-AndrewMultilingualNeural",
|
| 282 |
"nanami": "en-US-AndrewMultilingualNeural",
|
| 283 |
"ja": "en-US-AndrewMultilingualNeural",
|
| 284 |
+
# Chinese (fallback to English multilingual)
|
| 285 |
"zh_xiaochen": "en-US-AndrewMultilingualNeural",
|
| 286 |
"xiaochen": "en-US-AndrewMultilingualNeural",
|
| 287 |
"zh": "en-US-AndrewMultilingualNeural",
|
|
|
|
| 292 |
"""Detect appropriate voice and emotion based on content for multilingual TTS."""
|
| 293 |
content = ((title or "") + " " + (text or "")).lower()
|
| 294 |
|
| 295 |
+
# World Cup / Football content - use Andrew multilingual
|
| 296 |
if any(kw in content for kw in ["world cup", "wc 2026", "fifa", "bóng đá", "trận đấu", "bóng bóng", "đội tuyển", "cầu thủ"]):
|
| 297 |
return ("andrew", "excited")
|
| 298 |
|
| 299 |
+
# News categories - choose appropriate voice
|
| 300 |
if any(kw in content for kw in ["kinh tế", "tài chính", "thị trường", "economics", "finance"]):
|
| 301 |
return ("jenny", "calm")
|
| 302 |
if any(kw in content for kw in ["thiên tai", "bão", "lũ lụt", "cháy nổ", "tai nạn", "disaster", "accident"]):
|
|
|
|
| 306 |
if any(kw in content for kw in ["công nghệ", "tech", "technology", "ai", "trí tuệ nhân tạo"]):
|
| 307 |
return ("katja", "excited")
|
| 308 |
|
| 309 |
+
# Default Vietnamese
|
| 310 |
return ("hoaimy", "trung_tinh")
|
| 311 |
|
| 312 |
|
|
|
|
| 340 |
# Use the same wall file as app_v2_entry.py for consistency
|
| 341 |
WALL_FILE = os.path.join(DATA_DIR, "wall_posts.json")
|
| 342 |
|
| 343 |
+
def _load_ai_wall():
|
| 344 |
+
"""Load AI wall posts from JSON file (uses wall_posts.json for consistency with app_v2_entry)."""
|
| 345 |
+
try:
|
| 346 |
+
if os.path.exists(WALL_FILE):
|
| 347 |
+
with open(WALL_FILE, "r", encoding="utf-8") as f:
|
| 348 |
+
return json.load(f)
|
| 349 |
+
except Exception:
|
| 350 |
+
pass
|
| 351 |
+
return []
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
def _save_ai_wall(posts):
|
| 355 |
+
"""Save AI wall posts to JSON file (uses wall_posts.json for consistency with app_v2_entry)."""
|
| 356 |
+
try:
|
| 357 |
+
os.makedirs(os.path.dirname(WALL_FILE), exist_ok=True)
|
| 358 |
+
tmp = WALL_FILE + ".tmp"
|
| 359 |
+
with open(tmp, "w", encoding="utf-8") as f:
|
| 360 |
+
json.dump(posts[:100], f, ensure_ascii=False)
|
| 361 |
+
os.replace(tmp, WALL_FILE)
|
| 362 |
+
except Exception:
|
| 363 |
+
pass
|
| 364 |
+
|
| 365 |
+
|
| 366 |
# Helper functions for wall operations
|
| 367 |
def _load_wall_posts():
|
| 368 |
"""Alias for _load_ai_wall for consistency with app_v2_entry.py."""
|
|
|
|
| 393 |
text = post.get("text", "") or post.get("title", "")
|
| 394 |
text = re.sub(r"^[•\-\*]\s*", "", text, flags=re.M)
|
| 395 |
text = re.sub(r"\s*\n\s*", ". ", text)
|
| 396 |
+
return _clean_text(text)[:2000] # Increased from 1000 to 2000 for full content
|
| 397 |
|
| 398 |
|
| 399 |
# ===== SCRAPER FUNCTIONS (required by ai_patch.py) =====
|
|
|
|
| 407 |
r.encoding = 'utf-8'
|
| 408 |
soup = BeautifulSoup(r.text, 'lxml')
|
| 409 |
|
| 410 |
+
# Remove scripts, styles, nav, footer
|
| 411 |
+
for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form']):
|
| 412 |
tag.decompose()
|
| 413 |
|
| 414 |
# Extract title
|
|
|
|
| 422 |
|
| 423 |
# Extract article body
|
| 424 |
block = None
|
| 425 |
+
for sel in ['article', '.singular-content', '.detail-content', '.fck_detail', '.content-detail', '.knc-content', 'main', '.cms-body', '.article__body']:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 426 |
el = soup.select_one(sel)
|
| 427 |
if el and len(el.find_all('p')) >= 2:
|
| 428 |
block = el
|
| 429 |
break
|
| 430 |
if not block:
|
| 431 |
+
block = soup.body or soup
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 432 |
|
| 433 |
# Extract text from paragraphs
|
| 434 |
paragraphs = []
|
| 435 |
for el in block.find_all(['p', 'h2', 'h3'], recursive=True):
|
| 436 |
t = _clean_text(el.get_text(strip=True))
|
| 437 |
if t and len(t) > 40:
|
|
|
|
|
|
|
| 438 |
paragraphs.append(t)
|
| 439 |
|
| 440 |
# Extract images
|
| 441 |
images = []
|
|
|
|
| 442 |
for el in block.find_all(['figure', 'img'], recursive=True):
|
| 443 |
im = el if el.name == 'img' else el.find('img')
|
| 444 |
if im:
|
| 445 |
+
src = im.get('data-src') or im.get('src') or im.get('data-original') or ''
|
| 446 |
+
if src and 'base64' not in src:
|
|
|
|
| 447 |
if src.startswith('//'):
|
| 448 |
src = 'https:' + src
|
| 449 |
images.append(src)
|
|
|
|
| 468 |
"""Get web context for a topic. Returns (context_text, sources_list)."""
|
| 469 |
sources = []
|
| 470 |
try:
|
| 471 |
+
# Try Google News RSS
|
| 472 |
rss_url = f"https://news.google.com/rss/search?q={quote_plus(topic)}&hl=vi&gl=VN&ceid=VN:vi"
|
| 473 |
r = requests.get(rss_url, headers=HEADERS, timeout=15)
|
| 474 |
r.encoding = 'utf-8'
|
|
|
|
| 487 |
|
| 488 |
# ===== SHORT FRAME FUNCTION (required by ai_patch.py) =====
|
| 489 |
def _make_short_frame(post, img_path, out_path):
|
| 490 |
+
"""Create a short video frame from post and image.
|
| 491 |
+
|
| 492 |
+
Called by ai_patch.py _make_short_frame_full when Image is available.
|
| 493 |
+
"""
|
| 494 |
if Image is None:
|
| 495 |
+
# Create a minimal frame without PIL - just return success
|
| 496 |
+
# The caller should handle this case
|
| 497 |
return False
|
| 498 |
|
|
|
|
|
|
|
|
|
|
| 499 |
W, H = 1080, 1920
|
| 500 |
bg = Image.new("RGB", (W, H), (14, 14, 14))
|
| 501 |
|
|
|
|
| 522 |
|
| 523 |
draw = ImageDraw.Draw(bg)
|
| 524 |
|
| 525 |
+
try:
|
| 526 |
+
font_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 54)
|
| 527 |
+
font_body = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 38)
|
| 528 |
+
except Exception:
|
| 529 |
+
font_title = font_body = None
|
| 530 |
|
| 531 |
draw.rectangle((0, 720, W, H), fill=(14, 14, 14))
|
| 532 |
margin = 48
|
ai_patch.py
CHANGED
|
@@ -1,6 +1,3 @@
|
|
| 1 |
-
"""ai_patch.py — Helper functions for rewrite, summary, short frame creation.
|
| 2 |
-
FIX v2: Vietnamese font auto-detection (not hardcoded DejaVu paths).
|
| 3 |
-
"""
|
| 4 |
import os
|
| 5 |
import re
|
| 6 |
import time
|
|
@@ -10,7 +7,6 @@ import html as html_lib
|
|
| 10 |
import subprocess
|
| 11 |
import requests
|
| 12 |
import hashlib
|
| 13 |
-
from datetime import datetime, timezone
|
| 14 |
import ai_ext as base
|
| 15 |
from ai_ext import app
|
| 16 |
from fastapi import Request
|
|
@@ -23,298 +19,899 @@ try:
|
|
| 23 |
except Exception:
|
| 24 |
Image = ImageDraw = ImageFont = None
|
| 25 |
|
| 26 |
-
_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())
|
| 27 |
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
|
|
|
|
|
|
| 31 |
|
| 32 |
-
def _get_vn_fonts():
|
| 33 |
-
"""Auto-detect fonts that support Vietnamese diacritics."""
|
| 34 |
-
global _VN_FONT_REG, _VN_FONT_BOLD
|
| 35 |
-
if _VN_FONT_REG is not None:
|
| 36 |
-
return _VN_FONT_REG, _VN_FONT_BOLD
|
| 37 |
-
|
| 38 |
-
# Try system fonts in priority order (Noto first for full Vietnamese)
|
| 39 |
-
reg_paths = [
|
| 40 |
-
"/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",
|
| 41 |
-
"/usr/share/fonts/truetype/noto/NotoSans-Variable.ttf",
|
| 42 |
-
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
| 43 |
-
"/usr/share/fonts/truetype/freefont/FreeSans.ttf",
|
| 44 |
-
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
|
| 45 |
-
]
|
| 46 |
-
bold_paths = [
|
| 47 |
-
"/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf",
|
| 48 |
-
"/usr/share/fonts/truetype/noto/NotoSans-Variable.ttf",
|
| 49 |
-
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
| 50 |
-
"/usr/share/fonts/truetype/freefont/FreeSans.ttf",
|
| 51 |
-
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
|
| 52 |
-
]
|
| 53 |
-
|
| 54 |
-
# Also search broadly
|
| 55 |
-
try:
|
| 56 |
-
result = subprocess.run(
|
| 57 |
-
['find', '/usr/share/fonts', '-name', '*.ttf', '-type', 'f'],
|
| 58 |
-
capture_output=True, text=True, timeout=5
|
| 59 |
-
)
|
| 60 |
-
all_ttf = result.stdout.strip().split('\n')
|
| 61 |
-
for key in ['noto', 'dejavu', 'liberation', 'freefont', 'vn']:
|
| 62 |
-
for path in all_ttf:
|
| 63 |
-
pl = path.lower()
|
| 64 |
-
if key in pl:
|
| 65 |
-
if ('bold' in pl or 'variable' in pl) and _VN_FONT_BOLD is None:
|
| 66 |
-
try:
|
| 67 |
-
_VN_FONT_BOLD = ImageFont.truetype(path, 56)
|
| 68 |
-
except:
|
| 69 |
-
pass
|
| 70 |
-
elif ('regular' in pl or path == '/usr/share/fonts/truetype/vn/VNFont.ttf') and _VN_FONT_REG is None:
|
| 71 |
-
try:
|
| 72 |
-
_VN_FONT_REG = ImageFont.truetype(path, 48)
|
| 73 |
-
except:
|
| 74 |
-
pass
|
| 75 |
-
except:
|
| 76 |
-
pass
|
| 77 |
-
|
| 78 |
-
# Try regular paths
|
| 79 |
-
for path in reg_paths:
|
| 80 |
-
if os.path.exists(path) and _VN_FONT_REG is None:
|
| 81 |
-
try:
|
| 82 |
-
_VN_FONT_REG = ImageFont.truetype(path, 48)
|
| 83 |
-
except:
|
| 84 |
-
continue
|
| 85 |
-
for path in bold_paths:
|
| 86 |
-
if os.path.exists(path) and _VN_FONT_BOLD is None:
|
| 87 |
-
try:
|
| 88 |
-
_VN_FONT_BOLD = ImageFont.truetype(path, 56)
|
| 89 |
-
except:
|
| 90 |
-
continue
|
| 91 |
-
|
| 92 |
-
if _VN_FONT_REG is None:
|
| 93 |
-
_VN_FONT_REG = ImageFont.load_default()
|
| 94 |
-
if _VN_FONT_BOLD is None:
|
| 95 |
-
_VN_FONT_BOLD = _VN_FONT_REG
|
| 96 |
-
|
| 97 |
-
return _VN_FONT_REG, _VN_FONT_BOLD
|
| 98 |
|
| 99 |
-
def
|
| 100 |
-
|
| 101 |
-
s = s
|
| 102 |
-
s = s.strip()
|
| 103 |
-
s = re.sub(r'\s+', ' ', s)
|
| 104 |
return s
|
| 105 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
def _postprocess_ai_text(text, max_units=20):
|
| 107 |
-
"""Clean AI-generated text: remove HTML artifacts, deduplicate, return string with bullet points."""
|
| 108 |
text = _clean(text)
|
| 109 |
if not text:
|
| 110 |
-
return
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
text = text.replace('<a href=" src="', '').replace("<a href=' src='", '')
|
| 120 |
-
text = text.replace('<a href=" src=', '').replace("<a href=' src=", '')
|
| 121 |
-
# Remove leading bullet/dash
|
| 122 |
-
text = re.sub(r'^\s*[-•]\s*', '', text)
|
| 123 |
-
# Split by double-space (AI often separates items with double space)
|
| 124 |
-
units = text.split(' ')
|
| 125 |
-
out = []
|
| 126 |
-
seen = set()
|
| 127 |
-
for u in units:
|
| 128 |
-
u = _clean(u)
|
| 129 |
-
if not u:
|
| 130 |
continue
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
if w in seen:
|
| 134 |
continue
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
break
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
try:
|
| 145 |
-
|
| 146 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
try:
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
except Exception as e:
|
| 184 |
-
return {'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
continue
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
if s and s not in pts:
|
| 204 |
-
pts.append(s)
|
| 205 |
-
if len(pts) >= max_points:
|
| 206 |
break
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
|
| 240 |
-
|
| 241 |
-
for
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
line = _clean(line)
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
for sent in re.split(r'(?<=[.!?])\s+(?=[A-Z0-9À-ỸĐ])', text):
|
| 251 |
-
sent = _clean(sent)
|
| 252 |
-
if len(sent) > 25:
|
| 253 |
-
segs.append(sent)
|
| 254 |
-
|
| 255 |
-
# Strategy 3: chunk by size
|
| 256 |
-
if len(segs) < 2:
|
| 257 |
-
segs = []
|
| 258 |
-
words = text.split()
|
| 259 |
-
chunk = []
|
| 260 |
-
ccount = 0
|
| 261 |
-
for w in words:
|
| 262 |
-
chunk.append(w)
|
| 263 |
-
ccount += len(w) + 1
|
| 264 |
-
if ccount > 150:
|
| 265 |
-
segs.append(' '.join(chunk))
|
| 266 |
-
chunk = []
|
| 267 |
-
ccount = 0
|
| 268 |
-
if chunk:
|
| 269 |
-
segs.append(' '.join(chunk))
|
| 270 |
-
|
| 271 |
-
return segs[:max_segments]
|
| 272 |
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
for w in words:
|
| 293 |
-
test =
|
| 294 |
try:
|
| 295 |
-
|
| 296 |
-
except:
|
| 297 |
-
|
| 298 |
-
if
|
| 299 |
-
cur
|
| 300 |
else:
|
| 301 |
if cur:
|
| 302 |
-
lines.append(
|
| 303 |
-
cur =
|
| 304 |
-
|
| 305 |
-
break
|
| 306 |
-
if cur and len(lines) < 10:
|
| 307 |
-
lines.append(' '.join(cur))
|
| 308 |
-
|
| 309 |
-
y = 200
|
| 310 |
-
for line in lines:
|
| 311 |
-
d.text((80, y), line, fill='white', font=f)
|
| 312 |
-
y += 56
|
| 313 |
-
if y > sz[1] - 80:
|
| 314 |
break
|
| 315 |
-
|
| 316 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
|
| 318 |
-
|
| 319 |
-
async def health():
|
| 320 |
-
return JSONResponse({'status': 'ok'})
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import os
|
| 2 |
import re
|
| 3 |
import time
|
|
|
|
| 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
|
|
|
|
| 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
CHANGED
|
@@ -253,8 +253,7 @@ def make_frame(post,seg,idx,total,img_path,out_path):
|
|
| 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 |
-
|
| 257 |
-
brand = 'Tin tóm tắt VNEWS 7h sáng, 13h trưa, 19h tối' if post.get('kind') == 'auto_scheduled' else 'VNEWS AI SHORT'
|
| 258 |
try:
|
| 259 |
bb=draw.textbbox((0,0),brand,font=ft);tx=(W-(bb[2]-bb[0]))//2
|
| 260 |
except Exception:tx=360
|
|
|
|
| 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
|
ai_runtime_final.py
CHANGED
|
@@ -297,19 +297,19 @@ async def final_short(post_id:str,request:Request):
|
|
| 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:
|
| 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 |
|
| 307 |
-
FINAL_INJECT = r'''
|
| 308 |
-
|
| 309 |
-
@app.get('/')
|
| 310 |
-
async def index_final():
|
| 311 |
-
try:
|
| 312 |
-
html = open('/app/static/index.html', 'r', encoding='utf-8').read()
|
| 313 |
-
except Exception:
|
| 314 |
-
html = '<html><body><h1>VNEWS AI</h1></body></html>'
|
| 315 |
-
return HTMLResponse(html)
|
|
|
|
| 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_final6.py
CHANGED
|
@@ -354,7 +354,7 @@ setInterval(()=>{document.querySelectorAll('#ai-topic-input-final3,.topic-final3
|
|
| 354 |
|
| 355 |
@app.get('/')
|
| 356 |
async def index_final6():
|
| 357 |
-
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 |
|
|
@@ -752,7 +752,7 @@ setTimeout(()=>{window.__allowShortRefresh=true;},7000);
|
|
| 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=
|
| 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 |
|
|
@@ -831,13 +831,19 @@ Yêu cầu:
|
|
| 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 |
-
|
| 835 |
-
|
| 836 |
-
|
| 837 |
-
|
| 838 |
-
|
| 839 |
-
|
| 840 |
-
|
| 841 |
-
|
| 842 |
-
|
| 843 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|
|
|
|
| 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 |
|
|
|
|
| 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_patch_fast.py
CHANGED
|
@@ -1,16 +1,9 @@
|
|
| 1 |
-
"""Final patch
|
| 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 |
-
from fastapi.responses import FileResponse
|
| 6 |
import html as html_lib
|
| 7 |
from urllib.parse import urlparse
|
| 8 |
-
from datetime import datetime, timezone, timedelta
|
| 9 |
-
|
| 10 |
-
_log = logging.getLogger("patch_fast")
|
| 11 |
-
_log.setLevel(logging.INFO)
|
| 12 |
-
if not _log.handlers:
|
| 13 |
-
_log.addHandler(logging.StreamHandler(sys.stderr))
|
| 14 |
|
| 15 |
def clean(s):return re.sub(r"\s+"," ",html_lib.unescape(str(s or ""))).strip()
|
| 16 |
def _domain(u):
|
|
@@ -20,8 +13,6 @@ DATA_DIR="/data" if os.path.isdir('/data') else "/app/data"
|
|
| 20 |
os.makedirs(DATA_DIR,exist_ok=True)
|
| 21 |
SHORT_COMMENTS_FILE=os.path.join(DATA_DIR,'short_comments.json')
|
| 22 |
TTL_24H=86400;HAS_PERSISTENT=os.path.isdir('/data')
|
| 23 |
-
SHORTS_DIR = os.path.join(DATA_DIR, 'ai_shorts')
|
| 24 |
-
os.makedirs(SHORTS_DIR, exist_ok=True)
|
| 25 |
def _lj(p,d):
|
| 26 |
try:
|
| 27 |
if os.path.exists(p):return json.load(open(p,'r',encoding='utf-8'))
|
|
@@ -36,433 +27,6 @@ def _cleanup():
|
|
| 36 |
def _scrape(url,mc=8000):
|
| 37 |
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 '')
|
| 38 |
except:return('','','')
|
| 39 |
-
|
| 40 |
-
# ======================================================================
|
| 41 |
-
# VIETNAMESE FONT DETECTION — find ANY font that supports Vietnamese
|
| 42 |
-
# ======================================================================
|
| 43 |
-
_FONT_REGULAR = None
|
| 44 |
-
_FONT_BOLD = None
|
| 45 |
-
_FONT_REGULAR_PATH = None
|
| 46 |
-
_FONT_BOLD_PATH = None
|
| 47 |
-
|
| 48 |
-
def _find_vn_fonts():
|
| 49 |
-
"""Find fonts that support Vietnamese diacritics (ấ, ắ, ỗ, ệ, ỷ, etc.)."""
|
| 50 |
-
global _FONT_REGULAR, _FONT_BOLD, _FONT_REGULAR_PATH, _FONT_BOLD_PATH
|
| 51 |
-
if _FONT_REGULAR is not None:
|
| 52 |
-
return True
|
| 53 |
-
|
| 54 |
-
from PIL import ImageFont
|
| 55 |
-
|
| 56 |
-
# Priority 1: Noto Sans (full Vietnamese support)
|
| 57 |
-
candidates_regular = [
|
| 58 |
-
"/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",
|
| 59 |
-
"/usr/share/fonts/truetype/noto/NotoSans-Variable.ttf",
|
| 60 |
-
"/usr/share/fonts/truetype/vn/VNFont.ttf",
|
| 61 |
-
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
| 62 |
-
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
|
| 63 |
-
"/usr/share/fonts/truetype/freefont/FreeSans.ttf",
|
| 64 |
-
]
|
| 65 |
-
candidates_bold = [
|
| 66 |
-
"/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf",
|
| 67 |
-
"/usr/share/fonts/truetype/noto/NotoSans-Variable.ttf", # Variable weight
|
| 68 |
-
"/usr/share/fonts/truetype/vn/VNFont-Bold.ttf",
|
| 69 |
-
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
| 70 |
-
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
|
| 71 |
-
"/usr/share/fonts/truetype/freefont/FreeSans.ttf",
|
| 72 |
-
]
|
| 73 |
-
|
| 74 |
-
# Search for any ttf files in the system
|
| 75 |
-
try:
|
| 76 |
-
result = subprocess.run(
|
| 77 |
-
['find', '/usr/share/fonts', '-name', '*.ttf', '-type', 'f'],
|
| 78 |
-
capture_output=True, text=True, timeout=10
|
| 79 |
-
)
|
| 80 |
-
all_ttf = result.stdout.strip().split('\n')
|
| 81 |
-
# Prefer Noto/DejaVu/Liberation
|
| 82 |
-
for key in ['noto', 'dejavu', 'liberation', 'freefont', 'vn']:
|
| 83 |
-
for path in all_ttf:
|
| 84 |
-
if key in path.lower():
|
| 85 |
-
if 'bold' in path.lower() and _FONT_BOLD_PATH is None:
|
| 86 |
-
_FONT_BOLD_PATH = path
|
| 87 |
-
elif 'regular' in path.lower() and _FONT_REGULAR_PATH is None:
|
| 88 |
-
_FONT_REGULAR_PATH = path
|
| 89 |
-
except:
|
| 90 |
-
pass
|
| 91 |
-
|
| 92 |
-
# Try candidate paths
|
| 93 |
-
for path in candidates_regular:
|
| 94 |
-
if os.path.exists(path):
|
| 95 |
-
try:
|
| 96 |
-
_FONT_REGULAR = ImageFont.truetype(path, 40)
|
| 97 |
-
_FONT_REGULAR_PATH = path
|
| 98 |
-
_log.info(f"Font regular: {path}")
|
| 99 |
-
break
|
| 100 |
-
except:
|
| 101 |
-
continue
|
| 102 |
-
|
| 103 |
-
for path in candidates_bold:
|
| 104 |
-
if os.path.exists(path):
|
| 105 |
-
try:
|
| 106 |
-
_FONT_BOLD = ImageFont.truetype(path, 52)
|
| 107 |
-
_FONT_BOLD_PATH = path
|
| 108 |
-
_log.info(f"Font bold: {path}")
|
| 109 |
-
break
|
| 110 |
-
except:
|
| 111 |
-
continue
|
| 112 |
-
|
| 113 |
-
# Fallback: use whatever we found
|
| 114 |
-
if _FONT_REGULAR is None:
|
| 115 |
-
_FONT_REGULAR = ImageFont.load_default()
|
| 116 |
-
_FONT_BOLD = _FONT_REGULAR
|
| 117 |
-
_log.warning("No TrueType font found! Using PIL default (may not show Vietnamese)")
|
| 118 |
-
if _FONT_BOLD is None:
|
| 119 |
-
_FONT_BOLD = _FONT_REGULAR
|
| 120 |
-
|
| 121 |
-
return True
|
| 122 |
-
|
| 123 |
-
# ======================================================================
|
| 124 |
-
# ROBUST TEXT SEGMENTATION — split ANY text format into segments
|
| 125 |
-
# ======================================================================
|
| 126 |
-
def _split_into_segments(text, max_segments=10, min_len=30):
|
| 127 |
-
"""Split text into segments for short video, handling any format."""
|
| 128 |
-
text = clean(text)
|
| 129 |
-
if not text:
|
| 130 |
-
return []
|
| 131 |
-
|
| 132 |
-
# Strategy 1: Try bullet points (•, -, * numbered)
|
| 133 |
-
lines = text.split('\n')
|
| 134 |
-
segmented = []
|
| 135 |
-
for line in lines:
|
| 136 |
-
line = clean(line)
|
| 137 |
-
if not line:
|
| 138 |
-
continue
|
| 139 |
-
# Remove leading bullets
|
| 140 |
-
line_clean = re.sub(r'^[•\-\*\d\.\)\s]+', '', line).strip()
|
| 141 |
-
if len(line_clean) > min_len:
|
| 142 |
-
segmented.append(line_clean)
|
| 143 |
-
elif len(line) > min_len:
|
| 144 |
-
segmented.append(line)
|
| 145 |
-
|
| 146 |
-
# Strategy 2: If no bullet points found, split by sentences
|
| 147 |
-
if len(segmented) < 2:
|
| 148 |
-
# Split by sentences (Vietnamese period, exclamation, question)
|
| 149 |
-
sents = re.split(r'(?<=[.!?])\s+(?=[A-Z0-9À-ỸĐ])', text)
|
| 150 |
-
for s in sents:
|
| 151 |
-
s = clean(s)
|
| 152 |
-
if len(s) > min_len:
|
| 153 |
-
segmented.append(s)
|
| 154 |
-
|
| 155 |
-
# Strategy 3: If still no good segments, split by character chunks
|
| 156 |
-
if not segmented:
|
| 157 |
-
words = text.split()
|
| 158 |
-
chunk = []
|
| 159 |
-
char_count = 0
|
| 160 |
-
for w in words:
|
| 161 |
-
chunk.append(w)
|
| 162 |
-
char_count += len(w) + 1
|
| 163 |
-
if char_count > 150:
|
| 164 |
-
segmented.append(' '.join(chunk))
|
| 165 |
-
chunk = []
|
| 166 |
-
char_count = 0
|
| 167 |
-
if chunk:
|
| 168 |
-
segmented.append(' '.join(chunk))
|
| 169 |
-
|
| 170 |
-
# Strategy 4: Last resort — take whole text
|
| 171 |
-
if not segmented:
|
| 172 |
-
segmented = [text[:300]]
|
| 173 |
-
|
| 174 |
-
return segmented[:max_segments]
|
| 175 |
-
|
| 176 |
-
# ======================================================================
|
| 177 |
-
# FRAME CREATION — with Vietnamese font support
|
| 178 |
-
# ======================================================================
|
| 179 |
-
def _draw_vn_text(draw, text, font, x, y, max_width, max_lines=6, fill=(255,255,255)):
|
| 180 |
-
"""Draw text with word wrapping, handling Vietnamese characters."""
|
| 181 |
-
if font is None:
|
| 182 |
-
return y
|
| 183 |
-
|
| 184 |
-
words = text.split()
|
| 185 |
-
lines = []
|
| 186 |
-
current_line = []
|
| 187 |
-
|
| 188 |
-
for w in words:
|
| 189 |
-
test_line = ' '.join(current_line + [w])
|
| 190 |
-
try:
|
| 191 |
-
w_px = font.getlength(test_line)
|
| 192 |
-
except:
|
| 193 |
-
try:
|
| 194 |
-
w_px = draw.textbbox((0,0), test_line, font=font)[2]
|
| 195 |
-
except:
|
| 196 |
-
w_px = len(test_line) * 22
|
| 197 |
-
|
| 198 |
-
if w_px <= max_width:
|
| 199 |
-
current_line.append(w)
|
| 200 |
-
else:
|
| 201 |
-
if current_line:
|
| 202 |
-
lines.append(' '.join(current_line))
|
| 203 |
-
current_line = [w]
|
| 204 |
-
if len(lines) >= max_lines:
|
| 205 |
-
break
|
| 206 |
-
|
| 207 |
-
if current_line and len(lines) < max_lines:
|
| 208 |
-
lines.append(' '.join(current_line))
|
| 209 |
-
|
| 210 |
-
for ln in lines[:max_lines]:
|
| 211 |
-
draw.text((x, y), ln, fill=fill, font=font)
|
| 212 |
-
y += 55
|
| 213 |
-
|
| 214 |
-
return y
|
| 215 |
-
|
| 216 |
-
def _make_frame(post, seg, idx, img_path, downloaded, frame_path):
|
| 217 |
-
"""Create a single frame with Vietnamese text support."""
|
| 218 |
-
from PIL import Image, ImageDraw
|
| 219 |
-
_find_vn_fonts()
|
| 220 |
-
|
| 221 |
-
sz = (1080, 1920)
|
| 222 |
-
bg = Image.new('RGB', sz, (15, 23, 38))
|
| 223 |
-
draw = ImageDraw.Draw(bg)
|
| 224 |
-
|
| 225 |
-
# Background image
|
| 226 |
-
if downloaded:
|
| 227 |
-
try:
|
| 228 |
-
im = Image.open(img_path).convert('RGB')
|
| 229 |
-
im = im.resize((1080, 760))
|
| 230 |
-
bg.paste(im, (0, 0))
|
| 231 |
-
overlay = Image.new('RGBA', (1080, 400), (0, 0, 0, 180))
|
| 232 |
-
bg.paste(overlay, (0, 720), overlay)
|
| 233 |
-
except Exception as e:
|
| 234 |
-
_log.warning(f"paste img: {e}")
|
| 235 |
-
|
| 236 |
-
# Title bar
|
| 237 |
-
draw.rectangle([0, 0, sz[0], 100], fill=(25, 118, 210))
|
| 238 |
-
ttl = post.get('title', '')[:50]
|
| 239 |
-
if _FONT_BOLD:
|
| 240 |
-
draw.text((sz[0]//2, 50), ttl, fill='white', font=_FONT_BOLD, anchor='mm')
|
| 241 |
-
|
| 242 |
-
# Segment text
|
| 243 |
-
seg_text = seg[:200]
|
| 244 |
-
_draw_vn_text(draw, seg_text, _FONT_REGULAR, 80, 820, 920, max_lines=8)
|
| 245 |
-
|
| 246 |
-
bg.save(frame_path, quality=88)
|
| 247 |
-
return True
|
| 248 |
-
|
| 249 |
-
# ======================================================================
|
| 250 |
-
# SHORT VIDEO GENERATOR — FULLY ROBUST
|
| 251 |
-
# ======================================================================
|
| 252 |
-
def _gen_short_sync(post) -> str:
|
| 253 |
-
"""Generate short video NOW. Returns video URL or empty string on failure."""
|
| 254 |
-
post_id = post.get('id', '')
|
| 255 |
-
if not post_id:
|
| 256 |
-
_log.error("_gen_short_sync: no post_id")
|
| 257 |
-
return ''
|
| 258 |
-
|
| 259 |
-
text = post.get('text', '') or post.get('title', '')
|
| 260 |
-
if len(text) < 100:
|
| 261 |
-
_log.warning(f"_gen_short_sync: text too short ({len(text)})")
|
| 262 |
-
return ''
|
| 263 |
-
|
| 264 |
-
segments = _split_into_segments(text, max_segments=10, min_len=30)
|
| 265 |
-
if not segments:
|
| 266 |
-
_log.error("_gen_short_sync: no segments after splitting")
|
| 267 |
-
return ''
|
| 268 |
-
|
| 269 |
-
_log.info(f"Segments: {len(segments)} (first: '{segments[0][:50]}...')")
|
| 270 |
-
|
| 271 |
-
seg_hash = hashlib.md5(('|'.join(segments) + 'nu' + 'neutral' + '1.0').encode('utf-8')).hexdigest()[:8]
|
| 272 |
-
suffix = f"_nu_neutral_1p0_{seg_hash}_scenes_nosub"
|
| 273 |
-
out_name = f5.base._safe_name(post_id + suffix) + '.mp4'
|
| 274 |
-
out_mp4 = os.path.join(SHORTS_DIR, out_name)
|
| 275 |
-
video_url = '/api/ai/short-file/' + post_id + suffix
|
| 276 |
-
|
| 277 |
-
if os.path.exists(out_mp4):
|
| 278 |
-
_log.info(f"_gen_short_sync: already exists {out_mp4}")
|
| 279 |
-
return video_url
|
| 280 |
-
|
| 281 |
-
work = os.path.join(SHORTS_DIR, f5.base._safe_name(post_id + suffix))
|
| 282 |
-
os.makedirs(work, exist_ok=True)
|
| 283 |
-
|
| 284 |
-
# Download image
|
| 285 |
-
img_path = os.path.join(work, 'image.jpg')
|
| 286 |
-
downloaded = False
|
| 287 |
-
try:
|
| 288 |
-
img_url = post.get('img', '')
|
| 289 |
-
if img_url and img_url.startswith('http'):
|
| 290 |
-
r = requests.get(img_url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=15)
|
| 291 |
-
if r.status_code == 200:
|
| 292 |
-
with open(img_path, 'wb') as f:
|
| 293 |
-
f.write(r.content)
|
| 294 |
-
downloaded = True
|
| 295 |
-
except Exception as e:
|
| 296 |
-
_log.warning(f"download image: {e}")
|
| 297 |
-
|
| 298 |
-
# Check PIL/ffmpeg
|
| 299 |
-
try:
|
| 300 |
-
from PIL import Image, ImageDraw, ImageFont
|
| 301 |
-
has_pil = True
|
| 302 |
-
except Exception as e:
|
| 303 |
-
_log.warning(f"PIL import: {e}")
|
| 304 |
-
has_pil = False
|
| 305 |
-
|
| 306 |
-
try:
|
| 307 |
-
subprocess.run(['ffmpeg', '-version'], capture_output=True, timeout=5)
|
| 308 |
-
except:
|
| 309 |
-
_log.error("ffmpeg not found!")
|
| 310 |
-
return ''
|
| 311 |
-
|
| 312 |
-
try:
|
| 313 |
-
from gtts import gTTS
|
| 314 |
-
has_tts = True
|
| 315 |
-
except Exception as e:
|
| 316 |
-
_log.warning(f"gTTS: {e}, using silent video")
|
| 317 |
-
has_tts = False
|
| 318 |
-
|
| 319 |
-
part_files = []
|
| 320 |
-
errors = []
|
| 321 |
-
|
| 322 |
-
for idx, seg in enumerate(segments[:10]):
|
| 323 |
-
frame_path = os.path.join(work, f'frame_{idx:02d}.jpg')
|
| 324 |
-
audio_path = os.path.join(work, f'voice_{idx:02d}.mp3')
|
| 325 |
-
audio_fast = os.path.join(work, f'voice_{idx:02d}_fast.mp3')
|
| 326 |
-
part_path = os.path.join(work, f'part_{idx:02d}.mp4')
|
| 327 |
-
|
| 328 |
-
# ----- Frame -----
|
| 329 |
-
frame_ok = False
|
| 330 |
-
if has_pil:
|
| 331 |
-
try:
|
| 332 |
-
_make_frame(post, seg, idx, img_path, downloaded, frame_path)
|
| 333 |
-
if os.path.exists(frame_path) and os.path.getsize(frame_path) > 1000:
|
| 334 |
-
frame_ok = True
|
| 335 |
-
else:
|
| 336 |
-
_log.warning(f"frame {idx}: file too small or missing")
|
| 337 |
-
except Exception as e:
|
| 338 |
-
_log.error(f"frame {idx} creation: {e}")
|
| 339 |
-
errors.append(f"frame{idx}: {e}")
|
| 340 |
-
|
| 341 |
-
if not frame_ok:
|
| 342 |
-
try:
|
| 343 |
-
# Fallback: ffmpeg solid color frame
|
| 344 |
-
subprocess.run(['ffmpeg', '-y', '-f', 'lavfi', '-i', 'color=c=0x0f1726:s=1080x1920:d=1',
|
| 345 |
-
'-frames:v', '1', frame_path], capture_output=True, timeout=30)
|
| 346 |
-
frame_ok = os.path.exists(frame_path)
|
| 347 |
-
except:
|
| 348 |
-
continue
|
| 349 |
-
|
| 350 |
-
# ----- Audio -----
|
| 351 |
-
dur = 10.0
|
| 352 |
-
if has_tts:
|
| 353 |
-
try:
|
| 354 |
-
tts_text = re.sub(r'^[•\-\*\d\.\)\s]+', '', seg).strip()[:300]
|
| 355 |
-
if tts_text:
|
| 356 |
-
gTTS(tts_text, lang='vi', slow=False).save(audio_path)
|
| 357 |
-
# Copy audio directly (no atempo filter — it's a no-op at 1.0 and can fail)
|
| 358 |
-
subprocess.run(['ffmpeg', '-y', '-i', audio_path, '-vn', '-c:a', 'libmp3lame', audio_fast],
|
| 359 |
-
check=True, capture_output=True, timeout=90)
|
| 360 |
-
pr = subprocess.run(['ffprobe', '-v', 'error', '-show_entries', 'format=duration',
|
| 361 |
-
'-of', 'default=noprint_wrappers=1:no_key=1', audio_fast],
|
| 362 |
-
capture_output=True, timeout=20)
|
| 363 |
-
dur = max(6.0, float((pr.stdout or b'').decode().strip() or 10.0)) + 1.0
|
| 364 |
-
else:
|
| 365 |
-
dur = 8.0
|
| 366 |
-
except Exception as e:
|
| 367 |
-
_log.warning(f"TTS part {idx}: {e}")
|
| 368 |
-
errors.append(f"tts{idx}: {e}")
|
| 369 |
-
dur = 8.0
|
| 370 |
-
|
| 371 |
-
# ----- Combine frame + audio -----
|
| 372 |
-
try:
|
| 373 |
-
cmd = ['ffmpeg', '-y', '-loop', '1', '-t', str(dur), '-i', frame_path]
|
| 374 |
-
if has_tts and os.path.exists(audio_fast):
|
| 375 |
-
cmd += ['-i', audio_fast, '-shortest']
|
| 376 |
-
else:
|
| 377 |
-
cmd += ['-f', 'lavfi', '-i', 'anullsrc=r=44100:cl=mono', '-shortest']
|
| 378 |
-
cmd += ['-c:v', 'libx264', '-tune', 'stillimage', '-pix_fmt', 'yuv420p',
|
| 379 |
-
'-c:a', 'aac', '-b:a', '128k',
|
| 380 |
-
'-preset', 'fast', '-crf', '23',
|
| 381 |
-
part_path]
|
| 382 |
-
subprocess.run(cmd, check=True, capture_output=True, timeout=150)
|
| 383 |
-
if os.path.exists(part_path) and os.path.getsize(part_path) > 5000:
|
| 384 |
-
part_files.append(part_path)
|
| 385 |
-
_log.info(f"Part {idx}: {os.path.getsize(part_path)} bytes")
|
| 386 |
-
else:
|
| 387 |
-
_log.warning(f"Part {idx}: file too small or missing")
|
| 388 |
-
except Exception as e:
|
| 389 |
-
_log.warning(f"ffmpeg part {idx}: {e}")
|
| 390 |
-
errors.append(f"part{idx}: {e}")
|
| 391 |
-
|
| 392 |
-
if part_files:
|
| 393 |
-
try:
|
| 394 |
-
concat_file = os.path.join(work, 'concat.txt')
|
| 395 |
-
with open(concat_file, 'w', encoding='utf-8') as f:
|
| 396 |
-
for p in part_files:
|
| 397 |
-
f.write("file '" + p.replace("'", "'\\''") + "'\n")
|
| 398 |
-
subprocess.run(['ffmpeg', '-y', '-f', 'concat', '-safe', '0', '-i', concat_file, '-c', 'copy', out_mp4],
|
| 399 |
-
check=True, capture_output=True, timeout=180)
|
| 400 |
-
_log.info(f"Short video generated: {out_mp4} ({len(part_files)} parts, {os.path.getsize(out_mp4)} bytes)")
|
| 401 |
-
|
| 402 |
-
post['video'] = video_url
|
| 403 |
-
post['short_voice'] = 'nu'
|
| 404 |
-
post['short_emotion'] = 'neutral'
|
| 405 |
-
post['short_speed'] = 1.0
|
| 406 |
-
post['short_segments'] = segments
|
| 407 |
-
post['short_subtitles'] = False
|
| 408 |
-
|
| 409 |
-
wall = f5.base._load_ai_wall()
|
| 410 |
-
for i, p in enumerate(wall):
|
| 411 |
-
if p.get('id') == post_id:
|
| 412 |
-
wall[i] = post
|
| 413 |
-
break
|
| 414 |
-
f5.base._save_ai_wall(wall)
|
| 415 |
-
return video_url
|
| 416 |
-
except Exception as e:
|
| 417 |
-
_log.error(f"concat: {e}")
|
| 418 |
-
errors.append(f"concat: {e}")
|
| 419 |
-
else:
|
| 420 |
-
_log.error(f"No part files generated! Errors: {'; '.join(errors)}")
|
| 421 |
-
return ''
|
| 422 |
-
|
| 423 |
-
# ===== Auto Scheduler Trigger =====
|
| 424 |
-
_AUTO_SCHEDULER_RUNNING = False
|
| 425 |
-
_AUTO_SCHEDULER_LAST = 0
|
| 426 |
-
_AUTO_SCHEDULER_RESULTS = []
|
| 427 |
-
|
| 428 |
-
def _run_scheduler_now():
|
| 429 |
-
"""Trigger auto_scheduler._run_scheduled_posting() safely."""
|
| 430 |
-
global _AUTO_SCHEDULER_RUNNING, _AUTO_SCHEDULER_LAST, _AUTO_SCHEDULER_RESULTS
|
| 431 |
-
if _AUTO_SCHEDULER_RUNNING:
|
| 432 |
-
_log.warning("Scheduler already running!")
|
| 433 |
-
return {"status": "running", "message": "Scheduler đang chạy, vui lòng đợi."}
|
| 434 |
-
_AUTO_SCHEDULER_RUNNING = True
|
| 435 |
-
try:
|
| 436 |
-
import auto_scheduler as _as
|
| 437 |
-
_as.LOG.info("Triggered manually via API")
|
| 438 |
-
_as._run_scheduled_posting()
|
| 439 |
-
_AUTO_SCHEDULER_LAST = time.time()
|
| 440 |
-
_AUTO_SCHEDULER_RESULTS = [{'time': datetime.now().strftime('%H:%M %d/%m/%Y'), 'status': 'done'}]
|
| 441 |
-
_log.info("Auto scheduler completed successfully")
|
| 442 |
-
return {"status": "done", "message": "Đã đăng 3 bài tự động thành công!"}
|
| 443 |
-
except Exception as e:
|
| 444 |
-
_log.error(f"Auto scheduler failed: {e}", exc_info=True)
|
| 445 |
-
return {"status": "error", "message": f"Lỗi: {e}"}
|
| 446 |
-
finally:
|
| 447 |
-
_AUTO_SCHEDULER_RUNNING = False
|
| 448 |
-
|
| 449 |
-
@app.post('/api/auto/schedule')
|
| 450 |
-
async def _api_auto_schedule():
|
| 451 |
-
"""Trigger auto-scheduler to post 3 articles NOW."""
|
| 452 |
-
_log.info("POST /api/auto/schedule triggered")
|
| 453 |
-
result = await asyncio.get_event_loop().run_in_executor(None, _run_scheduler_now)
|
| 454 |
-
return JSONResponse(result)
|
| 455 |
-
|
| 456 |
-
@app.get('/api/auto/status')
|
| 457 |
-
async def _api_auto_status():
|
| 458 |
-
"""Check auto-scheduler status."""
|
| 459 |
-
return JSONResponse({
|
| 460 |
-
'running': _AUTO_SCHEDULER_RUNNING,
|
| 461 |
-
'last_run': _AUTO_SCHEDULER_LAST,
|
| 462 |
-
'last_run_str': datetime.fromtimestamp(_AUTO_SCHEDULER_LAST).strftime('%H:%M %d/%m/%Y') if _AUTO_SCHEDULER_LAST else 'never',
|
| 463 |
-
'results': _AUTO_SCHEDULER_RESULTS
|
| 464 |
-
})
|
| 465 |
-
|
| 466 |
_bg_home={"t":0,"d":[]};_bg_shorts={"t":0,"d":[]};_bg_lock=False
|
| 467 |
def _bg():
|
| 468 |
global _bg_lock
|
|
@@ -479,7 +43,7 @@ def _bg():
|
|
| 479 |
@app.on_event("startup")
|
| 480 |
async def _s():threading.Thread(target=_bg,daemon=True).start()
|
| 481 |
threading.Thread(target=lambda:[time.sleep(600) or _bg() for _ in iter(int,1)],daemon=True).start()
|
| 482 |
-
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','/
|
| 483 |
@app.get('/api/homepage')
|
| 484 |
def _h():
|
| 485 |
n=time.time()
|
|
@@ -511,48 +75,6 @@ async def _ask(request:Request):
|
|
| 511 |
if not raw:raw=ctx[:12000]
|
| 512 |
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)
|
| 513 |
return JSONResponse({'answer':ans or 'Chưa trả lời được.','title':title})
|
| 514 |
-
|
| 515 |
-
# ===== SHORT VIDEO ENDPOINT (SYNCHRONOUS) =====
|
| 516 |
-
@app.post('/api/ai/short/{post_id}')
|
| 517 |
-
async def api_ai_short_generate(post_id: str, request: Request):
|
| 518 |
-
wall = f5.base._load_ai_wall()
|
| 519 |
-
post = None
|
| 520 |
-
for p in wall:
|
| 521 |
-
if p.get('id') == post_id:
|
| 522 |
-
post = p
|
| 523 |
-
break
|
| 524 |
-
if not post:
|
| 525 |
-
return JSONResponse({'error': 'Post not found'}, status_code=404)
|
| 526 |
-
if post.get('video'):
|
| 527 |
-
return JSONResponse({'post': post, 'video': post['video'], 'status': 'done'})
|
| 528 |
-
_log.info(f"Generating short for post {post_id}...")
|
| 529 |
-
loop = asyncio.get_event_loop()
|
| 530 |
-
video_url = await loop.run_in_executor(None, _gen_short_sync, post)
|
| 531 |
-
if video_url:
|
| 532 |
-
_log.info(f"Short generated: {video_url}")
|
| 533 |
-
return JSONResponse({'post': post, 'video': video_url, 'status': 'done'})
|
| 534 |
-
else:
|
| 535 |
-
_log.error(f"Short generation FAILED for {post_id}")
|
| 536 |
-
return JSONResponse({'error': 'Không thể tạo video. Kiểm tra logs Space để biết chi tiết.'}, status_code=500)
|
| 537 |
-
|
| 538 |
-
@app.get('/api/ai/short-file/{file_id:path}')
|
| 539 |
-
async def api_ai_short_file(file_id: str):
|
| 540 |
-
safe_name = f5.base._safe_name(file_id)
|
| 541 |
-
fpath = os.path.join(SHORTS_DIR, safe_name + '.mp4')
|
| 542 |
-
if os.path.exists(fpath):
|
| 543 |
-
return FileResponse(fpath, media_type='video/mp4')
|
| 544 |
-
for fname in os.listdir(SHORTS_DIR):
|
| 545 |
-
if fname.endswith('.mp4') and (safe_name in fname or file_id in fname):
|
| 546 |
-
fpath = os.path.join(SHORTS_DIR, fname)
|
| 547 |
-
if os.path.exists(fpath):
|
| 548 |
-
return FileResponse(fpath, media_type='video/mp4')
|
| 549 |
-
base_id = f5.base._safe_name((file_id.split('_')[0] if '_' in file_id else file_id))
|
| 550 |
-
for fname in os.listdir(SHORTS_DIR):
|
| 551 |
-
if fname.endswith('.mp4') and fname.startswith(base_id):
|
| 552 |
-
return FileResponse(os.path.join(SHORTS_DIR, fname), media_type='video/mp4')
|
| 553 |
-
return JSONResponse({'error': 'File not found'}, status_code=404)
|
| 554 |
-
|
| 555 |
-
# ===== REWRITE =====
|
| 556 |
@app.post('/api/rewrite_share')
|
| 557 |
@app.post('/api/url_wall')
|
| 558 |
async def _rw(request:Request):
|
|
@@ -561,33 +83,12 @@ async def _rw(request:Request):
|
|
| 561 |
title,raw,img=_scrape(url,14000)
|
| 562 |
if len(raw)<50:raw=ctx[:14000]
|
| 563 |
if len(raw)<50:return JSONResponse({'error':'Không đọc được bài'},status_code=422)
|
| 564 |
-
og_img = img if img and img.startswith('http') else ''
|
| 565 |
-
if not og_img:
|
| 566 |
-
try:
|
| 567 |
-
d = f5.base.scrape_any_url(url)
|
| 568 |
-
if d: og_img = d.get('og_image', '') or d.get('image', '') or ''
|
| 569 |
-
except: pass
|
| 570 |
-
if not og_img:
|
| 571 |
-
og_img = 'https://image.pollinations.ai/prompt/Vietnamese%20news%20update'
|
| 572 |
text=None
|
| 573 |
-
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=
|
| 574 |
except:pass
|
| 575 |
if not text or len(text)<80:text=f"Tóm tắt: {title}\n\n{raw[:1200]}\n\nNguồn: {_domain(url)}"
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
paragraphs = [p.strip() for p in raw.split('\n') if len(p.strip()) > 20]
|
| 579 |
-
slides = []
|
| 580 |
-
for i, pt in enumerate(paragraphs[:8]):
|
| 581 |
-
slides.append({'text': pt, 'image': og_img, 'index': i + 1})
|
| 582 |
-
post=f5.base.make_post(title or 'Bài viết',text,og_img,url,'rewrite',sources=[{'title':title,'url':url,'via':_domain(url)}])
|
| 583 |
-
post['images'] = [og_img] if og_img else []
|
| 584 |
-
post['slides'] = slides
|
| 585 |
-
ps=f5.base._load_ai_wall();ps.insert(0,post);f5.base._save_ai_wall(ps)
|
| 586 |
-
if len(text) > 100:
|
| 587 |
-
t = threading.Thread(target=_gen_short_sync, args=(post,), daemon=True)
|
| 588 |
-
t.start()
|
| 589 |
-
return JSONResponse({'post':post})
|
| 590 |
-
|
| 591 |
@app.post('/api/topic/rewrite')
|
| 592 |
async def _tr(request:Request):
|
| 593 |
b=await request.json();pid=str(b.get('post_id','')).strip()
|
|
@@ -627,6 +128,7 @@ PATCH_INJECT=r'''
|
|
| 627 |
.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}
|
| 628 |
.storage-warn{background:#332200;border:1px solid #664400;color:#ffcc00;padding:8px 12px;border-radius:8px;font-size:11px;margin:6px 4px}
|
| 629 |
button[onclick*="rewriteCurrentArticle"]{display:none!important}
|
|
|
|
| 630 |
#ai-short-home,.ai-short-home,.ai-short-card-final{display:none!important}
|
| 631 |
.source-detail-box a[target="_blank"]{display:none!important}
|
| 632 |
</style>
|
|
@@ -635,27 +137,40 @@ button[onclick*="rewriteCurrentArticle"]{display:none!important}
|
|
| 635 |
(function(){
|
| 636 |
function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));}
|
| 637 |
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);}}});
|
|
|
|
|
|
|
| 638 |
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);}
|
| 639 |
-
setTimeout(renderShortAISlide,2500);
|
|
|
|
|
|
|
| 640 |
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(()=>{});});}
|
|
|
|
|
|
|
| 641 |
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);}
|
| 642 |
window.readAIWallPost=readAIWallPost;window.aiReadWallPatched=window.aiReadWall=window.readWallPost=function(i){readAIWallPost(i)};
|
|
|
|
|
|
|
| 643 |
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(()=>{});});}
|
| 644 |
window.likeShort=function(id,btn){let c=btn.querySelector('.count');c.textContent=parseInt(c.textContent||0)+1;}
|
| 645 |
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!');}}
|
| 646 |
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()})});}
|
|
|
|
|
|
|
| 647 |
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';}};
|
| 648 |
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';}};
|
| 649 |
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);}
|
| 650 |
-
window.makeShortFromPost=async function(pid,btn){if(btn){btn.disabled=true;btn.textContent='
|
| 651 |
window.rewriteCurrentArticle=function(){let btn=document.querySelector('[data-rw-article]');if(btn)doRewriteArticle(btn);};
|
| 652 |
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}};
|
| 653 |
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}};
|
| 654 |
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');}
|
| 655 |
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;}
|
|
|
|
|
|
|
| 656 |
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);}}
|
| 657 |
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(()=>{});});}
|
| 658 |
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);}});}
|
|
|
|
| 659 |
let oldRA=window.readArticle;if(oldRA){window.readArticle=async function(){let ret=await oldRA.apply(this,arguments);setTimeout(patchArticle,500);return ret;}}
|
| 660 |
let _hl=false;function dH(){if(_hl)return;_hl=true;setTimeout(()=>{if(typeof ensureHotTopics==='function')ensureHotTopics();if(typeof ensureNewsShortsHome==='function')ensureNewsShortsHome();},4000);}
|
| 661 |
if(document.readyState==='complete')dH();else window.addEventListener('load',dH);
|
|
@@ -666,8 +181,8 @@ setInterval(()=>{patchArticle();patchShortBtns();patchOldSourceLinks();},1500);
|
|
| 666 |
|
| 667 |
@app.get('/')
|
| 668 |
async def _index():
|
| 669 |
-
html=
|
| 670 |
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
|
| 671 |
body+=getattr(f6,'FINAL6_INJECT','');body+=getattr(f6,'FINAL6_FAST_HOME_INJECT','');body+=getattr(f6,'FINAL6E_INJECT','')
|
| 672 |
body+=PATCH_INJECT
|
| 673 |
-
return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
|
|
|
|
| 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):
|
|
|
|
| 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'))
|
|
|
|
| 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
|
|
|
|
| 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()
|
|
|
|
| 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):
|
|
|
|
| 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()
|
|
|
|
| 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>
|
|
|
|
| 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);
|
|
|
|
| 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)
|
app_patch_unified.py
CHANGED
|
@@ -11,7 +11,6 @@ Features:
|
|
| 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 |
-
7. Rewrite generates slides with images + auto short video trigger
|
| 15 |
"""
|
| 16 |
from ai_runtime_patch_fast import *
|
| 17 |
from ai_runtime_patch_fast import app, f5, f6, rt, PATCH_INJECT, _scrape, _domain, clean, _bg, _bg_home, _bg_shorts
|
|
@@ -75,30 +74,6 @@ def _source_image(sources, details):
|
|
| 75 |
def _ensure_img(img):
|
| 76 |
return img if (img and len(img)>20 and img.startswith('http')) else DEFAULT_IMG
|
| 77 |
|
| 78 |
-
def _make_slides_from_article(url, img, all_images):
|
| 79 |
-
"""Build slides from article paragraphs with images."""
|
| 80 |
-
slides = []
|
| 81 |
-
try:
|
| 82 |
-
art_data = f5.base.scrape_any_url(url)
|
| 83 |
-
if art_data:
|
| 84 |
-
imgs = art_data.get('images', [])
|
| 85 |
-
if not imgs and art_data.get('og_image'):
|
| 86 |
-
imgs = [art_data.get('og_image', '')]
|
| 87 |
-
if not imgs and img:
|
| 88 |
-
imgs = [img]
|
| 89 |
-
paragraphs = art_data.get('text', '').split('\n')
|
| 90 |
-
paragraphs = [p.strip() for p in paragraphs if len(p.strip()) > 60]
|
| 91 |
-
key_pts = paragraphs[:8]
|
| 92 |
-
if key_pts:
|
| 93 |
-
for i, pt in enumerate(key_pts):
|
| 94 |
-
slide_img = imgs[i] if i < len(imgs) else (imgs[-1] if imgs else img if img else '')
|
| 95 |
-
slides.append({'text': pt, 'image': slide_img, 'index': i + 1})
|
| 96 |
-
except:
|
| 97 |
-
if all_images:
|
| 98 |
-
for i, im in enumerate(all_images[:3]):
|
| 99 |
-
slides.append({'text': '', 'image': im, 'index': i + 1})
|
| 100 |
-
return slides
|
| 101 |
-
|
| 102 |
# ============================================================
|
| 103 |
# TOPIC POST (source image instead of AI image)
|
| 104 |
# ============================================================
|
|
@@ -124,7 +99,7 @@ async def _topic(request:Request):
|
|
| 124 |
return JSONResponse({'post':post})
|
| 125 |
|
| 126 |
# ============================================================
|
| 127 |
-
# REWRITE (auto-title, clean text)
|
| 128 |
# ============================================================
|
| 129 |
@app.post('/api/rewrite_share')
|
| 130 |
@app.post('/api/url_wall')
|
|
@@ -135,29 +110,6 @@ async def _rewrite(request:Request):
|
|
| 135 |
if len(raw)<50:raw=ctx[:14000]
|
| 136 |
if len(raw)<50:return JSONResponse({'error':'Không đọc được bài'},status_code=422)
|
| 137 |
img=_ensure_img(img)
|
| 138 |
-
|
| 139 |
-
# Get ALL images from article for slides
|
| 140 |
-
all_images = []
|
| 141 |
-
slides = []
|
| 142 |
-
try:
|
| 143 |
-
art_data = f5.base.scrape_any_url(url)
|
| 144 |
-
if art_data:
|
| 145 |
-
all_images = art_data.get('images', [])
|
| 146 |
-
if not all_images and art_data.get('og_image'):
|
| 147 |
-
all_images = [art_data.get('og_image', '')]
|
| 148 |
-
if not all_images:
|
| 149 |
-
all_images = [img] if img and img.startswith('http') else []
|
| 150 |
-
# Build slides from paragraphs
|
| 151 |
-
paragraphs = art_data.get('text', '').split('\n')
|
| 152 |
-
paragraphs = [p.strip() for p in paragraphs if len(p.strip()) > 60]
|
| 153 |
-
key_pts = paragraphs[:8]
|
| 154 |
-
if key_pts:
|
| 155 |
-
for i, pt in enumerate(key_pts):
|
| 156 |
-
slide_img = all_images[i] if i < len(all_images) else (all_images[-1] if all_images else img if img else '')
|
| 157 |
-
slides.append({'text': pt, 'image': slide_img, 'index': i + 1})
|
| 158 |
-
except:
|
| 159 |
-
all_images = [img] if img and img.startswith('http') else []
|
| 160 |
-
|
| 161 |
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).
|
| 162 |
|
| 163 |
Tiêu đề gốc: {title}
|
|
@@ -178,18 +130,7 @@ Yêu cầu:
|
|
| 178 |
lines=text.strip().split('\n')
|
| 179 |
body='\n'.join(lines[1:]).strip() if lines and lines[0].strip()==ai_title else text
|
| 180 |
post=f5.base.make_post(ai_title,_clean_text(body),img,url,'rewrite',sources=[{'title':title,'url':url,'via':_domain(url)}])
|
| 181 |
-
post['images'] = all_images
|
| 182 |
-
post['slides'] = slides
|
| 183 |
ps=f5.base._load_ai_wall();ps.insert(0,post);f5.base._save_ai_wall(ps)
|
| 184 |
-
|
| 185 |
-
# Try generate short video in background
|
| 186 |
-
if post.get('text') and len(post.get('text','')) > 100:
|
| 187 |
-
try:
|
| 188 |
-
import auto_scheduler
|
| 189 |
-
threading.Thread(target=lambda: auto_scheduler._try_generate_short(post), daemon=True).start()
|
| 190 |
-
except:
|
| 191 |
-
pass
|
| 192 |
-
|
| 193 |
return JSONResponse({'post':post})
|
| 194 |
|
| 195 |
@app.post('/api/topic/rewrite')
|
|
@@ -257,7 +198,7 @@ Object.defineProperty(window,'renderAIShorts7',{get:function(){return function()
|
|
| 257 |
.source-detail-item img{width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:6px;margin-bottom:6px;background:#222}
|
| 258 |
.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}
|
| 259 |
/* Livescore */
|
| 260 |
-
.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,.
|
| 261 |
</style>
|
| 262 |
<div id="short-progress-toast"></div>
|
| 263 |
<script>
|
|
@@ -329,4 +270,4 @@ async def _index():
|
|
| 329 |
body += getattr(f6,'FINAL6E_INJECT','') # Keep it — our PRE_KILL in UNIFIED neutralizes its destructive parts
|
| 330 |
body += PATCH_INJECT
|
| 331 |
body += UNIFIED_INJECT # This goes LAST and contains PRE_KILL at the TOP (runs first in browser)
|
| 332 |
-
return HTMLResponse(html.replace('</body>', body + '\n</body>') if '</body>' in html else html + body)
|
|
|
|
| 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
|
|
|
|
| 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 |
# ============================================================
|
|
|
|
| 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')
|
|
|
|
| 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}
|
|
|
|
| 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')
|
|
|
|
| 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>
|
|
|
|
| 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_v2_entry.py
CHANGED
|
@@ -332,11 +332,6 @@ def _search_all(topic,limit=36):
|
|
| 332 |
for _path in ['/api/article', '/api/hot_topics', '/api/categories', '/api/storage_status']:
|
| 333 |
app.router.routes=[r for r in app.router.routes if not(getattr(r,'path',None)==_path and 'GET' in getattr(r,'methods',set()))]
|
| 334 |
|
| 335 |
-
# Remove ai_patch.py's duplicate routes so app_v2_entry's versions take priority
|
| 336 |
-
# Keep ai_patch.py's /api/ai/short/{post_id} and /api/ai/short-file/{file_id} — they work correctly
|
| 337 |
-
for _p, _m in [('/api/url_wall','POST'), ('/api/rewrite_share','POST'), ('/api/topic_post','POST')]:
|
| 338 |
-
app.router.routes=[r for r in app.router.routes if not(getattr(r,'path',None)==_p and _m in getattr(r,'methods',set()))]
|
| 339 |
-
|
| 340 |
_article_cache = {}
|
| 341 |
_article_cache_ttl = 1800
|
| 342 |
|
|
@@ -1410,8 +1405,15 @@ async def api_rewrite_slide(request: Request):
|
|
| 1410 |
return JSONResponse({"post": post, "slides": slides})
|
| 1411 |
|
| 1412 |
|
| 1413 |
-
|
| 1414 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1415 |
data = None
|
| 1416 |
if url and url.startswith("http"):
|
| 1417 |
data = _scrape_article_for_rewrite(url)
|
|
@@ -1419,12 +1421,12 @@ async def _do_rewrite(url, ctx="", preferred_voice=""):
|
|
| 1419 |
paragraphs = [_clean(p) for p in ctx.split('\n') if len(_clean(p)) > 40]
|
| 1420 |
data = {'title': paragraphs[0][:80] if paragraphs else 'Bài viết', 'paragraphs': paragraphs, 'images': [], 'og_img': ''}
|
| 1421 |
if not data or not data.get('paragraphs'):
|
| 1422 |
-
return
|
| 1423 |
raw_text = '\n'.join(data['paragraphs'])
|
| 1424 |
if len(raw_text) < 50:
|
| 1425 |
raw_text = ctx[:14000]
|
| 1426 |
if len(raw_text) < 50:
|
| 1427 |
-
return
|
| 1428 |
domain = ''
|
| 1429 |
try:
|
| 1430 |
from urllib.parse import urlparse
|
|
@@ -1448,7 +1450,7 @@ async def _do_rewrite(url, ctx="", preferred_voice=""):
|
|
| 1448 |
else:
|
| 1449 |
ai_text = f"Tóm tắt: {data['title']}\n\n{raw_text[:1200]}\n\nNguồn: {domain}"
|
| 1450 |
|
| 1451 |
-
# Build slides from key points
|
| 1452 |
points = _extract_key_points_rw(data['paragraphs'], max_points=12)
|
| 1453 |
images = data.get('images', [])
|
| 1454 |
slides = []
|
|
@@ -1478,25 +1480,10 @@ async def _do_rewrite(url, ctx="", preferred_voice=""):
|
|
| 1478 |
"language": lang,
|
| 1479 |
"ts": int(time.time())
|
| 1480 |
}
|
| 1481 |
-
return post, None
|
| 1482 |
-
|
| 1483 |
-
|
| 1484 |
-
@app.post("/api/rewrite_share")
|
| 1485 |
-
async def api_rewrite_share(request: Request):
|
| 1486 |
-
"""Rewrite article and post to Tường AI with SLIDES + AI text."""
|
| 1487 |
-
body = await request.json()
|
| 1488 |
-
url = _clean(body.get("url", ""))
|
| 1489 |
-
ctx = _clean(body.get("context", ""))
|
| 1490 |
-
preferred_voice = body.get("voice", "")
|
| 1491 |
-
if not url and not ctx:
|
| 1492 |
-
return JSONResponse({"error": "Cần URL hoặc nội dung"}, status_code=400)
|
| 1493 |
-
post, err = await _do_rewrite(url, ctx, preferred_voice)
|
| 1494 |
-
if err:
|
| 1495 |
-
return err
|
| 1496 |
posts = _load_wall_posts()
|
| 1497 |
posts.insert(0, post)
|
| 1498 |
_save_wall_posts(posts)
|
| 1499 |
-
return JSONResponse({"post": post, "slides":
|
| 1500 |
|
| 1501 |
|
| 1502 |
@app.post("/api/url_wall")
|
|
@@ -1506,13 +1493,9 @@ async def api_url_wall(request: Request):
|
|
| 1506 |
url = _clean(body.get("url", ""))
|
| 1507 |
if not url or not url.startswith('http'):
|
| 1508 |
return JSONResponse({"error": "URL không hợp lệ"}, status_code=400)
|
| 1509 |
-
|
| 1510 |
-
|
| 1511 |
-
|
| 1512 |
-
posts = _load_wall_posts()
|
| 1513 |
-
posts.insert(0, post)
|
| 1514 |
-
_save_wall_posts(posts)
|
| 1515 |
-
return JSONResponse({"post": post, "slides": post.get("slides", [])})
|
| 1516 |
|
| 1517 |
|
| 1518 |
def _bg():
|
|
|
|
| 332 |
for _path in ['/api/article', '/api/hot_topics', '/api/categories', '/api/storage_status']:
|
| 333 |
app.router.routes=[r for r in app.router.routes if not(getattr(r,'path',None)==_path and 'GET' in getattr(r,'methods',set()))]
|
| 334 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 335 |
_article_cache = {}
|
| 336 |
_article_cache_ttl = 1800
|
| 337 |
|
|
|
|
| 1405 |
return JSONResponse({"post": post, "slides": slides})
|
| 1406 |
|
| 1407 |
|
| 1408 |
+
@app.post("/api/rewrite_share")
|
| 1409 |
+
async def api_rewrite_share(request: Request):
|
| 1410 |
+
"""Rewrite article and post to Tường AI with SLIDES + AI text."""
|
| 1411 |
+
body = await request.json()
|
| 1412 |
+
url = _clean(body.get("url", ""))
|
| 1413 |
+
ctx = _clean(body.get("context", ""))
|
| 1414 |
+
preferred_voice = body.get("voice", "") # Accept custom voice selection
|
| 1415 |
+
if not url and not ctx:
|
| 1416 |
+
return JSONResponse({"error": "Cần URL hoặc nội dung"}, status_code=400)
|
| 1417 |
data = None
|
| 1418 |
if url and url.startswith("http"):
|
| 1419 |
data = _scrape_article_for_rewrite(url)
|
|
|
|
| 1421 |
paragraphs = [_clean(p) for p in ctx.split('\n') if len(_clean(p)) > 40]
|
| 1422 |
data = {'title': paragraphs[0][:80] if paragraphs else 'Bài viết', 'paragraphs': paragraphs, 'images': [], 'og_img': ''}
|
| 1423 |
if not data or not data.get('paragraphs'):
|
| 1424 |
+
return JSONResponse({"error": "Không đọc được bài viết"}, status_code=422)
|
| 1425 |
raw_text = '\n'.join(data['paragraphs'])
|
| 1426 |
if len(raw_text) < 50:
|
| 1427 |
raw_text = ctx[:14000]
|
| 1428 |
if len(raw_text) < 50:
|
| 1429 |
+
return JSONResponse({"error": "Bài viết quá ngắn"}, status_code=422)
|
| 1430 |
domain = ''
|
| 1431 |
try:
|
| 1432 |
from urllib.parse import urlparse
|
|
|
|
| 1450 |
else:
|
| 1451 |
ai_text = f"Tóm tắt: {data['title']}\n\n{raw_text[:1200]}\n\nNguồn: {domain}"
|
| 1452 |
|
| 1453 |
+
# Build slides from key points (FIX: include slides in rewrite_share too!)
|
| 1454 |
points = _extract_key_points_rw(data['paragraphs'], max_points=12)
|
| 1455 |
images = data.get('images', [])
|
| 1456 |
slides = []
|
|
|
|
| 1480 |
"language": lang,
|
| 1481 |
"ts": int(time.time())
|
| 1482 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1483 |
posts = _load_wall_posts()
|
| 1484 |
posts.insert(0, post)
|
| 1485 |
_save_wall_posts(posts)
|
| 1486 |
+
return JSONResponse({"post": post, "slides": slides})
|
| 1487 |
|
| 1488 |
|
| 1489 |
@app.post("/api/url_wall")
|
|
|
|
| 1493 |
url = _clean(body.get("url", ""))
|
| 1494 |
if not url or not url.startswith('http'):
|
| 1495 |
return JSONResponse({"error": "URL không hợp lệ"}, status_code=400)
|
| 1496 |
+
# Reuse rewrite_share logic
|
| 1497 |
+
req._body = json.dumps({"url": url}).encode()
|
| 1498 |
+
return await api_rewrite_share(request)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1499 |
|
| 1500 |
|
| 1501 |
def _bg():
|
main.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 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
|
|
@@ -7,19 +7,11 @@ from collections import defaultdict
|
|
| 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 |
-
# Import storage for persistent data
|
| 16 |
-
from storage import (
|
| 17 |
-
load_wall_posts, save_wall_posts,
|
| 18 |
-
load_interactions, save_interactions,
|
| 19 |
-
load_comments, save_comments,
|
| 20 |
-
DATA_DIR
|
| 21 |
-
)
|
| 22 |
-
|
| 23 |
app = FastAPI()
|
| 24 |
|
| 25 |
# ===== WORLD CUP 2026 SCRAPER =====
|
|
@@ -525,176 +517,4 @@ def api_hot_topics():
|
|
| 525 |
|
| 526 |
@app.get("/", response_class=HTMLResponse)
|
| 527 |
async def root():
|
| 528 |
-
return HTMLResponse("<h1>VNEWS v17</h1><p>VTV Digital CDN ssaimh · No shorts Dantri/SKDS · Homepage full content</p>")
|
| 529 |
-
|
| 530 |
-
# ===== WALL POSTS API =====
|
| 531 |
-
_wl_lock = threading.Lock()
|
| 532 |
-
|
| 533 |
-
def _load_wall_posts():
|
| 534 |
-
with _wl_lock:
|
| 535 |
-
return load_wall_posts()
|
| 536 |
-
|
| 537 |
-
def _save_wall_posts(posts):
|
| 538 |
-
with _wl_lock:
|
| 539 |
-
return save_wall_posts(posts)
|
| 540 |
-
|
| 541 |
-
@app.get('/api/wall')
|
| 542 |
-
def api_wall():
|
| 543 |
-
posts = _load_wall_posts()
|
| 544 |
-
if not posts:
|
| 545 |
-
return JSONResponse({"posts": []})
|
| 546 |
-
return JSONResponse({"posts": posts})
|
| 547 |
-
|
| 548 |
-
@app.post('/api/wall')
|
| 549 |
-
async def api_wall_post(request: Request):
|
| 550 |
-
content_type = request.headers.get('content-type', '')
|
| 551 |
-
if 'multipart/form-data' in content_type:
|
| 552 |
-
try:
|
| 553 |
-
form = await request.form()
|
| 554 |
-
except Exception as e:
|
| 555 |
-
return JSONResponse({"error": f"Form parse error: {str(e)}"}, status_code=400)
|
| 556 |
-
title = form.get('title', 'Video mới') or 'Video mới'
|
| 557 |
-
text = form.get('text', '') or ''
|
| 558 |
-
source = form.get('source', 'vtv_recorder') or 'vtv_recorder'
|
| 559 |
-
video_file = form.get('video')
|
| 560 |
-
post_id = str(int(time.time() * 1000))
|
| 561 |
-
video_url = None
|
| 562 |
-
if video_file and hasattr(video_file, 'filename') and video_file.filename:
|
| 563 |
-
fname = video_file.filename.lower()
|
| 564 |
-
if fname.endswith('.mp4'):
|
| 565 |
-
ext = '.mp4'
|
| 566 |
-
elif fname.endswith('.webm'):
|
| 567 |
-
ext = '.webm'
|
| 568 |
-
else:
|
| 569 |
-
ext = '.webm'
|
| 570 |
-
video_filename = f"wall_{post_id}{ext}"
|
| 571 |
-
video_path = os.path.join(DATA_DIR, 'wall_videos', video_filename)
|
| 572 |
-
os.makedirs(os.path.dirname(video_path), exist_ok=True)
|
| 573 |
-
try:
|
| 574 |
-
content = await video_file.read()
|
| 575 |
-
if not content:
|
| 576 |
-
return JSONResponse({"error": "Empty video file"}, status_code=400)
|
| 577 |
-
with open(video_path, 'wb') as f:
|
| 578 |
-
f.write(content)
|
| 579 |
-
file_size_mb = len(content) / 1024 / 1024
|
| 580 |
-
if file_size_mb > 50:
|
| 581 |
-
os.remove(video_path)
|
| 582 |
-
return JSONResponse({"error": f"Video quá lớn ({file_size_mb:.1f}MB). Tối đa 50MB."}, status_code=400)
|
| 583 |
-
video_url = f"/api/wall/video/{video_filename}"
|
| 584 |
-
except Exception as e:
|
| 585 |
-
return JSONResponse({"error": f"Lỗi lưu video: {str(e)}"}, status_code=500)
|
| 586 |
-
post = {
|
| 587 |
-
"id": post_id,
|
| 588 |
-
"title": title[:200],
|
| 589 |
-
"text": text[:2000],
|
| 590 |
-
"source": source,
|
| 591 |
-
"video": video_url,
|
| 592 |
-
"img": None,
|
| 593 |
-
"images": [],
|
| 594 |
-
"created": int(time.time()),
|
| 595 |
-
"created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()),
|
| 596 |
-
}
|
| 597 |
-
posts = _load_wall_posts()
|
| 598 |
-
if not isinstance(posts, list):
|
| 599 |
-
posts = []
|
| 600 |
-
posts.insert(0, post)
|
| 601 |
-
posts = posts[:200]
|
| 602 |
-
_save_wall_posts(posts)
|
| 603 |
-
return JSONResponse({"post": post, "ok": True})
|
| 604 |
-
try:
|
| 605 |
-
body = await request.json()
|
| 606 |
-
except:
|
| 607 |
-
body = {}
|
| 608 |
-
title = body.get('title', 'Bài mới') or 'Bài mới'
|
| 609 |
-
text = body.get('text', '') or ''
|
| 610 |
-
img = body.get('img', None)
|
| 611 |
-
source = body.get('source', 'user') or 'user'
|
| 612 |
-
post_id = str(int(time.time() * 1000))
|
| 613 |
-
post = {
|
| 614 |
-
"id": post_id,
|
| 615 |
-
"title": title[:200],
|
| 616 |
-
"text": text[:2000],
|
| 617 |
-
"source": source,
|
| 618 |
-
"video": None,
|
| 619 |
-
"img": img,
|
| 620 |
-
"images": [],
|
| 621 |
-
"created": int(time.time()),
|
| 622 |
-
"created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()),
|
| 623 |
-
}
|
| 624 |
-
posts = _load_wall_posts()
|
| 625 |
-
if not isinstance(posts, list):
|
| 626 |
-
posts = []
|
| 627 |
-
posts.insert(0, post)
|
| 628 |
-
posts = posts[:200]
|
| 629 |
-
_save_wall_posts(posts)
|
| 630 |
-
return JSONResponse({"post": post, "ok": True})
|
| 631 |
-
|
| 632 |
-
@app.get('/api/wall/video/{filename}')
|
| 633 |
-
def api_wall_video(filename: str):
|
| 634 |
-
if '..' in filename or '/' in filename:
|
| 635 |
-
return Response(status_code=403)
|
| 636 |
-
video_path = os.path.join(DATA_DIR, 'wall_videos', filename)
|
| 637 |
-
if not os.path.exists(video_path):
|
| 638 |
-
return Response(status_code=404)
|
| 639 |
-
ext = os.path.splitext(filename)[1].lower()
|
| 640 |
-
media_type = 'video/mp4' if ext == '.mp4' else 'video/webm'
|
| 641 |
-
return FileResponse(video_path, media_type=media_type)
|
| 642 |
-
|
| 643 |
-
@app.delete('/api/wall/{post_id}')
|
| 644 |
-
def api_wall_delete(post_id: str):
|
| 645 |
-
posts = _load_wall_posts()
|
| 646 |
-
if not isinstance(posts, list):
|
| 647 |
-
return JSONResponse({"error": "No posts"}, status_code=404)
|
| 648 |
-
for i, p in enumerate(posts):
|
| 649 |
-
if p.get('id') == post_id:
|
| 650 |
-
if p.get('video'):
|
| 651 |
-
video_name = p['video'].split('/')[-1]
|
| 652 |
-
video_path = os.path.join(DATA_DIR, 'wall_videos', video_name)
|
| 653 |
-
if os.path.exists(video_path):
|
| 654 |
-
os.remove(video_path)
|
| 655 |
-
posts.pop(i)
|
| 656 |
-
_save_wall_posts(posts)
|
| 657 |
-
return JSONResponse({"ok": True})
|
| 658 |
-
return JSONResponse({"error": "Post not found"}, status_code=404)
|
| 659 |
-
|
| 660 |
-
# ===== INTERACTIONS API =====
|
| 661 |
-
_il = threading.Lock()
|
| 662 |
-
@app.post('/api/v2/interact')
|
| 663 |
-
async def _int(request:Request):
|
| 664 |
-
b=await request.json();v=str(b.get('id','')).strip();t=str(b.get('type','')).strip()
|
| 665 |
-
if not v or t not in('view','like'):return JSONResponse({'error':'x'},status_code=400)
|
| 666 |
-
with _il:
|
| 667 |
-
db=load_interactions()
|
| 668 |
-
db.setdefault(v,{'views':0,'likes':0,'comments':0})
|
| 669 |
-
db[v][t+'s']+=1
|
| 670 |
-
save_interactions(db)
|
| 671 |
-
return JSONResponse(db[v])
|
| 672 |
-
|
| 673 |
-
@app.get('/api/v2/interactions')
|
| 674 |
-
def _gi(id:str=Query(...)):
|
| 675 |
-
with _il:return JSONResponse(load_interactions().get(id.strip(),{'views':0,'likes':0,'comments':0}))
|
| 676 |
-
|
| 677 |
-
# ===== COMMENTS API =====
|
| 678 |
-
_cl = threading.Lock()
|
| 679 |
-
@app.get('/api/v2/comments')
|
| 680 |
-
def _gc(id:str=Query(...)):
|
| 681 |
-
with _cl:return JSONResponse({'comments':load_comments().get(id.strip(),[])})
|
| 682 |
-
|
| 683 |
-
@app.post('/api/v2/comment')
|
| 684 |
-
async def _pc(request:Request):
|
| 685 |
-
b=await request.json();v=str(b.get('id','')).strip();tx=str(b.get('text','')).strip()[:500]
|
| 686 |
-
if not v or not tx:return JSONResponse({'error':'x'},status_code=400)
|
| 687 |
-
c={'text':tx,'time':time.strftime('%H:%M %d/%m',time.localtime()),'ts':int(time.time())}
|
| 688 |
-
with _cl:
|
| 689 |
-
db=load_comments()
|
| 690 |
-
db.setdefault(v,[])
|
| 691 |
-
db[v].append(c)
|
| 692 |
-
db[v]=db[v][-200:]
|
| 693 |
-
save_comments(db)
|
| 694 |
-
cms=db[v]
|
| 695 |
-
with _il:
|
| 696 |
-
idb=load_interactions()
|
| 697 |
-
idb.setdefault(v,{'views':0,'likes':0,'comments':0})
|
| 698 |
-
idb[v]['comments']=len(cms)
|
| 699 |
-
save_interactions(idb)
|
| 700 |
-
return JSONResponse({'comments':cms})
|
|
|
|
| 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
|
|
|
|
| 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 =====
|
|
|
|
| 517 |
|
| 518 |
@app.get("/", response_class=HTMLResponse)
|
| 519 |
async def root():
|
| 520 |
+
return HTMLResponse("<h1>VNEWS v17</h1><p>VTV Digital CDN ssaimh · No shorts Dantri/SKDS · Homepage full content</p>")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static/rewrite_fix_v2.js
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// No-op - all functionality built into app_v2.js
|
| 2 |
+
(function(){})();
|