bep40 commited on
Commit
0280ca5
·
verified ·
1 Parent(s): b915c23

Apply shorts feature from commit 32da1baa - /api/shorts + /api/ai/short endpoints

Browse files
Files changed (11) hide show
  1. _run.py +1 -10
  2. ai_ext.py +85 -112
  3. ai_patch.py +869 -272
  4. ai_runtime.py +1 -2
  5. ai_runtime_final.py +10 -10
  6. ai_runtime_final6.py +18 -12
  7. ai_runtime_patch_fast.py +24 -509
  8. app_patch_unified.py +3 -62
  9. app_v2_entry.py +16 -33
  10. main.py +3 -183
  11. static/rewrite_fix_v2.js +2 -0
_run.py CHANGED
@@ -1,10 +1 @@
1
- """VNEWS main entry - load all modules in correct order."""
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
- FIX v2: Vietnamese font auto-detection.
 
 
 
 
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 storage for persistent data
24
  try:
25
- from storage import (
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 _load_ai_wall():
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 _save_ai_wall(posts):
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
- # Aliases for backward compatibility
53
- _load_wall = _load_ai_wall
54
- _save_wall = _save_ai_wall
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
- global LAST_QWEN_ERROR
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, ads
420
- for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form', 'noscript', 'iframe']):
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
- selectors = [
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
- best = None; best_score = 0
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 im.get('data-lazy') or ''
478
- if src and 'base64' not in src and src not in seen_imgs:
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
- font_title = _VN_FONT_BOLD
556
- font_body = _VN_FONT_REG
 
 
 
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
- # ===== VIETNAMESE FONT DETECTION =====
29
- _VN_FONT_REG = None
30
- _VN_FONT_BOLD = None
 
 
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 _clean(s):
100
- """Clean text - replace None with empty string, collapse whitespace."""
101
- s = s or ''
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
- # Remove malformed HTML with both href and src attributes (e.g., <a href="..." src="...">)
112
- text = re.sub(r'<a[^>]*href\s*=\s*"[^"]*"[^>]*\s*src\s*=\s*"[^"]*"[^>]*>', '', text, flags=re.I)
113
- # Remove any remaining HTML artifacts
114
- text = re.sub(r'<a\s+[^>]*>', '', text, flags=re.I)
115
- text = re.sub(r'<img\s+[^>]*>', '', text, flags=re.I)
116
- text = re.sub(r'<[^>]+>', '', text)
117
- text = re.sub(r'</a>', '', text, flags=re.I)
118
- # Fix truncated HTML tags (missing closing >)
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
- # Deduplicate by word signature
132
- w = tuple(re.findall(r"[\w']+", u.lower()))
133
- if w in seen:
134
  continue
135
- seen.add(w)
136
- out.append("• " + u)
137
- if len(out) >= max_units:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  break
139
- return "\n".join(out) if out else ""
 
 
 
 
 
 
 
 
 
 
 
 
 
140
 
141
- def _scrape_article_images(url):
142
- """Use the comprehensive scraper from ai_ext for images + paragraphs."""
143
- sents = []; imgs = []; og = ''
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  try:
145
- if url:
146
- # Use the comprehensive scraper first
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  try:
148
- data = base.scrape_any_url(url)
149
- if data and data.get('text'):
150
- sents = [p.strip() for p in data['text'].split('\n') if len(p.strip()) > 40]
151
- imgs = data.get('images', [])
152
- og = data.get('og_image', '')
153
- if not imgs and og:
154
- imgs = [og]
155
- if sents or imgs:
156
- return {'paragraphs': sents, 'images': imgs, 'og_img': og}
157
- except:
158
- pass
159
-
160
- # Fallback: basic scrape
161
- r = requests.get(url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=8)
162
- r.encoding = 'utf-8'
163
- soup = BeautifulSoup(r.text, 'lxml')
164
- og = soup.find('meta', property='og:image')
165
- og = og.get('content', '') if og else ''
166
- if og and og.startswith('//'):
167
- og = 'https:' + og
168
- for p in soup.find_all('p'):
169
- t = _clean(p.get_text())
170
- if len(t) > 30:
171
- sents.append(t)
172
- seen = set()
173
- for im in soup.find_all('img'):
174
- src = im.get('data-src') or im.get('src') or im.get('data-lazy') or ''
175
- if src and 'base64' not in src and src not in seen:
176
- seen.add(src)
177
- if src.startswith('//'):
178
- src = 'https:' + src
179
- imgs.append(src)
180
- # Filter: prefer large images
181
- imgs = [im for im in imgs if not any(x in im.lower() for x in ['logo', 'icon', 'avatar', '.svg', 'banner'])]
182
- return {'paragraphs': sents, 'images': imgs, 'og_img': og}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
  except Exception as e:
184
- return {'paragraphs': [], 'images': [], 'og_img': ''}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
 
186
- def _extract_key_points_for_slides(paragraphs, max_points=8):
187
- """Extract key points from paragraphs, splitting long ones into sentence-level points."""
188
- pts = []
189
- for p in paragraphs:
190
- if len(p) < 40:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
  continue
192
- s = _clean(p)
193
- # Split long paragraphs into sentence-level points
194
- if len(s) > 200:
195
- sentences = re.split(r'(?<=[.!?])\s+(?=[A-Z0-9À-Ỹ])', s)
196
- for sent in sentences:
197
- sent = _clean(sent)
198
- if len(sent) > 60 and sent not in pts:
199
- pts.append(sent)
200
- if len(pts) >= max_points:
201
- break
202
- else:
203
- if s and s not in pts:
204
- pts.append(s)
205
- if len(pts) >= max_points:
206
  break
207
- return pts
208
-
209
- def _make_summary_prompt(title, body, source):
210
- topic = source or "báo chính"
211
- return f"""Tóm tắt tin tức: {title}
212
- Nguồn: {topic}
213
- ---
214
- {body[:1200] if body else ''}
215
- ---
216
- Hãy viết tóm tắt ngắn gọn, súc tích bằng tiếng Việt. Dùng bullet point (•) để liệt kê điểm chính. Không dùng định dạng HTML. Không lặp lại tiêu đề. Không viết "Bản tin VNEWS" hay tiêu đề phụ."""
217
-
218
- def _source_line(sources=None):
219
- if not sources:
220
- return "Nguồn tham khảo: VNEWS"
221
- src = sources[0] if sources else {}
222
- title = src.get('title', '')
223
- url = src.get('url', '')
224
- via = src.get('via', '')
225
- if url and via:
226
- return f"Nguồn: [{via}]({url})"
227
- if url:
228
- return f"Nguồn: {url}"
229
- if via:
230
- return f"Nguồn: {via}"
231
- return "Nguồn tham khảo: VNEWS"
232
-
233
- def _summary_segments_from_post(post, max_segments=15):
234
- """Split post text into segments for short video. Uses multi-strategy like main generator."""
235
- text = post.get('text', '')
236
- segs = []
237
- if not text:
238
- return segs
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
239
 
240
- # Strategy 1: split by newlines (bullet points)
241
- for line in text.split('\n'):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  line = _clean(line)
243
- line_bare = re.sub(r'^[•\-\*\d\.\)\s]+', '', line).strip()
244
- if len(line_bare) > 25:
245
- segs.append(line_bare)
246
-
247
- # Strategy 2: split by sentences
248
- if len(segs) < 3:
249
- segs = []
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
- def _make_scene_frame(post, segment, idx, total, img_path, out_path, emotion='neutral'):
274
- """Create a scene frame with Vietnamese font support."""
275
- from PIL import Image, ImageDraw, ImageFont
276
- f, fb = _get_vn_fonts()
277
-
278
- sz = (1080, 1920)
279
- bg = Image.new('RGB', sz, (15, 23, 38))
280
- d = ImageDraw.Draw(bg)
281
-
282
- # Draw title bar
283
- d.rectangle([0, 0, sz[0], 120], fill=(25, 118, 210))
284
- title = post.get('title', '')[:60]
285
- d.text((sz[0]//2, 60), title, fill='white', font=fb, anchor='mm')
286
-
287
- # Draw segment text with word wrapping
288
- max_width = 920
289
- words = (segment or '')[:200].split()
290
- lines = []
291
- cur = []
 
 
 
 
 
 
 
 
292
  for w in words:
293
- test = ' '.join(cur + [w])
294
  try:
295
- tw = d.textbbox((0, 0), test, font=f)[2]
296
- except:
297
- tw = len(test) * 22
298
- if tw <= max_width:
299
- cur.append(w)
300
  else:
301
  if cur:
302
- lines.append(' '.join(cur))
303
- cur = [w]
304
- if len(lines) >= 10:
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
- bg.save(out_path, quality=85)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
317
 
318
- @app.get('/health')
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
- # Determine brand text based on post kind (auto_scheduled vs manual)
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: f.write("file '" + c + "'\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
 
307
- FINAL_INJECT = r'''<style>.fix{}</style>'''
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=open('/app/static/index_v2.html','r',encoding='utf-8').read() if os.path.exists('/app/static/index_v2.html') else '<html></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=open('/app/static/index_v2.html','r',encoding='utf-8').read() if os.path.exists('/app/static/index_v2.html') else '<html></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
- FINAL6E_INJECT = r'''<style>.placeholder{}</style>'''
838
-
839
- @app.get('/')
840
- async def index_final6():
841
- html = open('/app/static/index_v2.html','r',encoding='utf-8').read() if os.path.exists('/app/static/index_v2.html') else '<html></html>'
842
- 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
843
- return HTMLResponse(html.replace('</body>', body + '\n</body>') if '</body>' in html else html + body)
 
 
 
 
 
 
 
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=>({'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',"'":'&#39;'}[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,'&quot;')},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 v6: FIXED short video generation Vietnamese fonts, robust text splitting, no silent errors."""
2
- import re, threading, time, json, os, asyncio, hashlib, subprocess, requests, sys, logging
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','/api/ai/short/{post_id}','/api/ai/short-file/{file_id}','/api/auto/schedule','/api/auto/status','/') and any(m in getattr(r,'methods',set()) for m in ('GET','POST')))]
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=og_img or None,max_tokens=1000),timeout=30)
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
- paragraphs = [p.strip() for p in raw.split('\n') if len(p.strip()) > 60]
577
- if not paragraphs:
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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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);setInterval(renderShortAISlide,15000);
 
 
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='Đang tạo video...';}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');if(j.video){alert('Đã tạo Short AI thành công!');renderShortAISlide();}else{alert(j.message||'Đã tạo!');renderShortAISlide();}}catch(e){alert('Lỗi: '+e.message)}finally{if(btn){btn.disabled=false;btn.textContent='🎬 Tạo Short AI';}}};
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=open('/app/static/index_v2.html','r',encoding='utf-8').read() if os.path.exists('/app/static/index_v2.html') else ('<html><body>VNEWS</body></html>' if os.path.exists('/app/static/index.html') else '')
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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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) + SLIDES + SHORT
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,.ls-content 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}
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
- async def _do_rewrite(url, ctx="", preferred_voice=""):
1414
- """Shared rewrite logic used by both /api/rewrite_share and /api/url_wall."""
 
 
 
 
 
 
 
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 None, JSONResponse({"error": "Không đọc được bài viết"}, status_code=422)
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 None, JSONResponse({"error": "Bài viết quá ngắn"}, status_code=422)
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": post.get("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
- post, err = await _do_rewrite(url, "", "")
1510
- if err:
1511
- return err
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 + AI scheduler"""
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, FileResponse
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(){})();