bep40 commited on
Commit
32da1ba
·
verified ·
1 Parent(s): 1512b8f

Restore to commit 8f37e0efb93a31689e91942abbc2edb6f7493bf5 - v18.11 (add /api/proxy/img endpoint)

Browse files
Files changed (4) hide show
  1. .rebuild +1 -1
  2. app_v2_entry.py +1047 -74
  3. static/app_v2.js +4 -9
  4. static/index_v2.html +2 -1
.rebuild CHANGED
@@ -1 +1 @@
1
- 1783908190
 
1
+ 1783786956
app_v2_entry.py CHANGED
@@ -508,13 +508,18 @@ def _st():return JSONResponse({'persistent':os.path.isdir('/data') and os.access
508
  # ===== SHARE HELPERS: render content pages for shared links =====
509
  def _render_slides_page(post, safe_title, safe_img, safe_url):
510
  slides = post.get('slides', [])
 
511
  if not safe_img and slides and slides[0].get('image'):
512
  safe_img = slides[0].get('image', '')
 
513
  description = _clean((post.get('text') or '')[:200]) or "Tin tức tóm tắt, AI rewrite, World Cup 2026"
 
 
514
  if safe_url and safe_url != '/':
515
  canonical_url = f"{SPACE}/s?url={quote(safe_url)}&title={quote(safe_title[:100])}"
516
  else:
517
  canonical_url = f"{SPACE}/s?post_id={post.get('id') or ''}"
 
518
  h = f'''<!DOCTYPE html>
519
  <html lang="vi">
520
  <head>
@@ -547,11 +552,15 @@ def _render_slides_page(post, safe_title, safe_img, safe_url):
547
 
548
  def _render_video_page(post, safe_title, safe_img, safe_url):
549
  video_url = post.get('video', '')
 
550
  description = _clean((post.get('text') or '')[:200]) or "Tin tức tóm tắt, AI rewrite, World Cup 2026"
 
 
551
  if safe_url and safe_url != '/':
552
  canonical_url = f"{SPACE}/s?url={quote(safe_url)}&title={quote(safe_title[:100])}"
553
  else:
554
  canonical_url = f"{SPACE}/s?post_id={post.get('id') or ''}"
 
555
  h = f'''<!DOCTYPE html>
556
  <html lang="vi">
557
  <head>
@@ -583,9 +592,14 @@ video{{width:100%;height:100%;max-height:100vh;object-fit:contain;background:#00
583
 
584
  @app.get('/s/{slug}')
585
  async def _sh_slug(slug: str, request: Request, url: str = '', title: str = '', img: str = ''):
 
 
 
586
  safe_title = _clean(title) if title else 'VNEWS - Tin tức'
587
  safe_img = _clean(img) if img else ''
588
  safe_url = _clean(url) if url else '/'
 
 
589
  post = None
590
  try:
591
  if url:
@@ -597,22 +611,44 @@ async def _sh_slug(slug: str, request: Request, url: str = '', title: str = '',
597
  safe_img = p.get('img', safe_img) or safe_img
598
  safe_url = p.get('url', safe_url) or safe_url
599
  break
 
600
  if not post and url:
601
  for p in posts:
602
  if p.get('url') == url:
603
- post = p; safe_title = p.get('title', safe_title) or safe_title; safe_img = p.get('img', safe_img) or safe_img; safe_url = p.get('url', safe_url) or safe_url; break
604
- except: pass
605
- if post and post.get('slides'): return _render_slides_page(post, safe_title, safe_img, safe_url)
606
- if post and post.get('video'): return _render_video_page(post, safe_title, safe_img, safe_url)
 
 
 
 
 
 
 
 
 
 
 
607
  return HTMLResponse(f'''<!DOCTYPE html>
608
- <html lang="vi"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{_clean(safe_title)}</title>
609
- <meta property="og:title" content="{_clean(safe_title)}"><meta property="og:image" content="{_clean(safe_img)}">
 
 
 
 
 
610
  <meta property="og:description" content="Tin tức tóm tắt, AI rewrite, World Cup 2026">
611
- <meta property="og:url" content="{SPACE}/s/{slug}"><link rel="canonical" href="{SPACE}/s/{slug}">
612
- <meta http-equiv="refresh" content="0;url={safe_url}"></head><body></body></html>''')
 
 
 
 
613
 
614
  @app.get('/api/proxy/img')
615
  def proxy_img(url: str = Query(default=""), max_size: int = Query(default=1200)):
 
616
  from urllib.parse import unquote
617
  safe_url = unquote(url)
618
  import requests as _req
@@ -620,41 +656,79 @@ def proxy_img(url: str = Query(default=""), max_size: int = Query(default=1200))
620
  r = _req.get(safe_url, timeout=10, headers={"User-Agent": "Mozilla/5.0"})
621
  if r.status_code == 200 and r.content:
622
  return Response(r.content, media_type=r.headers.get("content-type", "image/jpeg"))
623
- except Exception as e: pass
 
624
  return Response(status_code=502)
625
-
626
  @app.get('/s')
627
  async def _sh(url:str='',title:str='',img:str='',post_id:str=''):
628
  safe_title = _clean(title) if title else 'VNEWS - Tin tức'
629
  safe_img = _clean(img) if img else ''
630
  safe_url = _clean(url) if url else '/'
 
 
631
  post = None
632
  try:
633
  posts = _load_wall_posts()
634
  if post_id:
635
  for p in posts:
636
- if p.get('id') == post_id: post = p; safe_title = p.get('title', safe_title) or safe_title; safe_img = p.get('img', safe_img) or safe_img; safe_url = p.get('url', safe_url) or safe_url; break
 
 
 
 
 
637
  elif url:
 
638
  for p in posts:
639
- if p.get('url') == url and p.get('slides'): post = p; safe_title = p.get('title', safe_title) or safe_title; safe_img = p.get('img', safe_img) or safe_img; safe_url = p.get('url', safe_url) or safe_url; break
 
 
 
 
 
640
  if not post:
 
641
  for p in posts:
642
- if p.get('url') == url: post = p; safe_title = p.get('title', safe_title) or safe_title; safe_img = p.get('img', safe_img) or safe_img; safe_url = p.get('url', safe_url) or safe_url; break
643
- except: pass
644
- if post and post.get('slides'): return _render_slides_page(post, safe_title, safe_img, safe_url)
645
- if post and post.get('video'): return _render_video_page(post, safe_title, safe_img, safe_url)
 
 
 
 
 
 
 
 
 
 
 
 
 
646
  if url and not safe_img:
647
  try:
648
  art = _scrape_article_fast(url)
649
- if art and art.get('og_image'): safe_img = art.get('og_image', '')
650
- if art and art.get('title'): safe_title = art.get('title', safe_title)
651
- except: pass
 
 
 
 
652
  return HTMLResponse(f'''<!DOCTYPE html>
653
- <html lang="vi"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{safe_title}</title>
654
- <meta property="og:title" content="{safe_title}"><meta property="og:image" content="{safe_img}">
 
 
 
 
 
655
  <meta property="og:description" content="Tin tức tóm tắt, AI rewrite, World Cup 2026">
656
- <meta property="og:url" content="{SPACE}/s?url={quote(safe_url)}"><link rel="canonical" href="{SPACE}/s?url={quote(safe_url)}">
657
- <meta http-equiv="refresh" content="0;url={safe_url}"></head><body></body></html>''')
 
 
658
 
659
  from wc2026_scraper import scrape_summary,scrape_fixtures,scrape_standings,scrape_stats,scrape_wc_news,scrape_road_to_wc,get_wc2026_all,scrape_history,scrape_h2h,scrape_lineups,scrape_match_detail
660
 
@@ -664,38 +738,56 @@ _xlb_lock = threading.Lock()
664
  def _xlb_scrape(path):
665
  url = f"https://xemlaibongda.top/{path}"
666
  r = req.get(url, headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}, timeout=15, allow_redirects=True)
667
- if r.status_code != 200: return []
 
668
  soup = BeautifulSoup(r.text, 'lxml')
669
- vids = []; seen = set()
 
670
  for a in soup.select('a[href*="/video/"]'):
671
  href = a.get('href', '')
672
- if not href or href in seen: continue
 
673
  seen.add(href)
674
- if not href.startswith('http'): href = 'https://xemlaibongda.top' + href
 
675
  img = a.select_one('img')
676
  p = a.parent
677
  for _ in range(4):
678
- if img: break
679
- if p: img = p.select_one('img'); p = p.parent
 
 
 
680
  img_src = ''
681
- if img: img_src = img.get('data-src','') or img.get('src','') or img.get('data-lazy','') or img.get('data-original','')
682
- if img_src.startswith('//'): img_src = 'https:' + img_src
683
- elif img_src.startswith('/'): img_src = 'https://xemlaibongda.top' + img_src
 
 
 
684
  title = ''
685
  for sel in ['.title', 'h3', 'h2', '.name', '.post-title', '.entry-title', '.video-title']:
686
  t = a.select_one(sel)
687
- if t: title = _clean(t.get_text()); break
688
- if not title: title = _clean(a.get('title',''))
 
 
 
689
  if not title:
690
  img_alt = a.select_one('img')
691
- if img_alt: title = _clean(img_alt.get('alt',''))
 
692
  if not title:
693
  parent = a.parent
694
- if parent: pt = _clean(parent.get_text(' ',strip=True))
695
- if 5 < len(pt) < 120: title = pt
696
- if not title or len(title) < 3: continue
 
 
 
697
  vids.append({"link": href, "img": img_src, "title": title})
698
- if len(vids) >= 30: break
 
699
  return vids
700
 
701
  @app.get('/api/proxy/xlb')
@@ -704,11 +796,13 @@ def proxy_xlb(path: str = Query(default="")):
704
  cache_key = f"xlb:{path}"
705
  with _xlb_lock:
706
  cached = _xlb_cache.get(cache_key)
707
- if cached and now - cached['t'] < 120: return JSONResponse(cached['d'])
 
708
  try:
709
  vids = _xlb_scrape(path)
710
  result = {"videos": vids, "count": len(vids)}
711
- with _xlb_lock: _xlb_cache[cache_key] = {'t': now, 'd': result}
 
712
  return JSONResponse(result)
713
  except Exception as e:
714
  return JSONResponse({"videos": [], "count": 0, "error": str(e)}, status_code=500)
@@ -747,7 +841,7 @@ def _lj(p):
747
  try:
748
  if os.path.exists(p):return json.load(open(p,'r',encoding='utf-8'))
749
  except:pass
750
- return []
751
  def _sj(p,d):
752
  try:open(p+'.tmp','w',encoding='utf-8').write(json.dumps(d,ensure_ascii=False));os.replace(p+'.tmp',p)
753
  except:pass
@@ -776,23 +870,28 @@ async def _pc(request:Request):
776
  return JSONResponse({'comments':cms})
777
 
778
  def _load_wall_posts():
779
- with _wl_lock: return _lj(WALL_FILE)
 
780
 
781
  def _save_wall_posts(posts):
782
- with _wl_lock: _sj(WALL_FILE, posts)
 
783
 
784
  @app.get('/api/wall')
785
  def api_wall():
786
  posts = _load_wall_posts()
787
- if not posts: return JSONResponse({"posts": []})
 
788
  return JSONResponse({"posts": posts})
789
 
790
  @app.post('/api/wall')
791
  async def api_wall_post(request: Request):
792
  content_type = request.headers.get('content-type', '')
793
  if 'multipart/form-data' in content_type:
794
- try: form = await request.form()
795
- except Exception as e: return JSONResponse({"error": f"Form parse error: {str(e)}"}, status_code=400)
 
 
796
  title = form.get('title', 'Video mới') or 'Video mới'
797
  text = form.get('text', '') or ''
798
  source = form.get('source', 'vtv_recorder') or 'vtv_recorder'
@@ -801,40 +900,80 @@ async def api_wall_post(request: Request):
801
  video_url = None
802
  if video_file and hasattr(video_file, 'filename') and video_file.filename:
803
  fname = video_file.filename.lower()
804
- ext = '.mp4' if fname.endswith('.mp4') else '.webm'
 
 
 
 
 
805
  video_filename = f"wall_{post_id}{ext}"
806
  video_path = os.path.join(WALL_VIDEO_DIR, video_filename)
807
  try:
808
  content = await video_file.read()
809
- if not content: return JSONResponse({"error": "Empty video file"}, status_code=400)
810
- with open(video_path, 'wb') as f: f.write(content)
 
 
811
  file_size_mb = len(content) / 1024 / 1024
812
- if file_size_mb > 50: os.remove(video_path); return JSONResponse({"error": f"Video quá lớn ({file_size_mb:.1f}MB). Tối đa 50MB."}, status_code=400)
 
 
813
  video_url = f"/api/wall/video/{video_filename}"
814
- except Exception as e: return JSONResponse({"error": f"Lỗi lưu video: {str(e)}"}, status_code=500)
815
- post = {"id": post_id,"title": title[:200],"text": text[:2000],"source": source,"video": video_url,"img": None,"images": [],"created": int(time.time()),"created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime())}
 
 
 
 
 
 
 
 
 
 
 
816
  posts = _load_wall_posts()
817
- if not isinstance(posts, list): posts = []
818
- posts.insert(0, post); posts = posts[:200]; _save_wall_posts(posts)
 
 
 
819
  return JSONResponse({"post": post, "ok": True})
820
- try: body = await request.json()
821
- except: body = {}
 
 
822
  title = body.get('title', 'Bài mới') or 'Bài mới'
823
  text = body.get('text', '') or ''
824
  img = body.get('img', None)
825
  source = body.get('source', 'user') or 'user'
826
  post_id = str(uuid.uuid4())[:12]
827
- post = {"id": post_id,"title": title[:200],"text": text[:2000],"source": source,"video": None,"img": img,"images": [],"created": int(time.time()),"created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime())}
 
 
 
 
 
 
 
 
 
 
828
  posts = _load_wall_posts()
829
- if not isinstance(posts, list): posts = []
830
- posts.insert(0, post); posts = posts[:200]; _save_wall_posts(posts)
 
 
 
831
  return JSONResponse({"post": post, "ok": True})
832
 
833
  @app.get('/api/wall/video/{filename}')
834
  def api_wall_video(filename: str):
835
- if '..' in filename or '/' in filename: return Response(status_code=403)
 
836
  video_path = os.path.join(WALL_VIDEO_DIR, filename)
837
- if not os.path.exists(video_path): return Response(status_code=404)
 
838
  ext = os.path.splitext(filename)[1].lower()
839
  media_type = 'video/mp4' if ext == '.mp4' else 'video/webm'
840
  return FileResponse(video_path, media_type=media_type)
@@ -842,14 +981,17 @@ def api_wall_video(filename: str):
842
  @app.delete('/api/wall/{post_id}')
843
  def api_wall_delete(post_id: str):
844
  posts = _load_wall_posts()
845
- if not isinstance(posts, list): return JSONResponse({"error": "No posts"}, status_code=404)
 
846
  for i, p in enumerate(posts):
847
  if p.get('id') == post_id:
848
  if p.get('video'):
849
  video_name = p['video'].split('/')[-1]
850
  video_path = os.path.join(WALL_VIDEO_DIR, video_name)
851
- if os.path.exists(video_path): os.remove(video_path)
852
- posts.pop(i); _save_wall_posts(posts)
 
 
853
  return JSONResponse({"ok": True})
854
  return JSONResponse({"error": "Post not found"}, status_code=404)
855
 
@@ -859,6 +1001,7 @@ from urllib.parse import quote as _quote2
859
 
860
  _UA_RW = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept-Language': 'vi-VN,vi;q=0.9'}
861
 
 
862
  _UNIQUE_CHARS = {
863
  'vietnamese': set('đăâêôơưàảãạáằẳẵặắầẩẫậấèẻẽẹéềễểệếìỉĩịíòỏõọóồổỗộốờởỡợớùủũụúừửữựứỳỷỹỵý'),
864
  'spanish': set('ñáéíóúü¿¡'),
@@ -873,23 +1016,853 @@ _STOPWORDS = {
873
  }
874
 
875
  def detect_language(text):
876
- if not text: return 'vietnamese'
877
- text_lower = text.lower(); text_chars = set(text_lower)
 
 
 
 
 
878
  vn_chars = len(text_chars & _UNIQUE_CHARS['vietnamese'])
879
- if vn_chars >= 2: return 'vietnamese'
 
 
 
880
  es_chars = len(text_chars & _UNIQUE_CHARS['spanish'])
881
  pt_chars = len(text_chars & _UNIQUE_CHARS['portuguese'])
 
 
882
  words = set(re.findall(r'\b\w+\b', text_lower))
883
  scores = {}
884
- for lang, stops in _STOPWORDS.items(): scores[lang] = len(words & stops) / max(len(stops), 1)
 
 
 
885
  pt_markers = {'não', 'pelo', 'pela', 'isso', 'há', 'estão', 'num', 'numa', 'tenho', 'posso', 'você', 'nós', 'eles', 'elas', 'também', 'muito', 'já', 'só', 'até', 'entre', 'depois', 'sem', 'mesmo', 'aos', 'serão'}
886
  es_markers = {'pero', 'está', 'están', 'porque', 'también', 'hasta', 'donde', 'quien', 'fue', 'son', 'fueron', 'ese', 'eso', 'ante', 'ellos', 'ella', 'nosotros', 'usted', 'ustedes', 'tú', 'él', 'desde', 'todo', 'durante', 'todos', 'uno', 'les', 'ni', 'contra', 'otros', 'fueron'}
887
- pt_overlap = len(words & pt_markers); es_overlap = len(words & es_markers)
888
- if scores.get('portuguese', 0) > 0 and pt_overlap > es_overlap: return 'portuguese'
889
- if scores.get('spanish', 0) > 0 and es_overlap > pt_overlap: return 'spanish'
890
- if scores.get('english', 0) > 0.15: return 'english'
 
 
 
 
 
 
 
891
  best = max(scores, key=scores.get)
892
  return best if scores[best] > 0.05 else 'vietnamese'
893
 
 
894
  _EMOTION_KEYWORDS = {
895
- 'happy': {'en': ['happy', 'joy', 'wonderful', 'great', 'amazing', 'fantastic', 'love', 'excellent', 'beautiful', 'glad', 'delighted', 'pleased', 'cheerful', 'celebrate',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
508
  # ===== SHARE HELPERS: render content pages for shared links =====
509
  def _render_slides_page(post, safe_title, safe_img, safe_url):
510
  slides = post.get('slides', [])
511
+ # Get image from post.img or first slide's image
512
  if not safe_img and slides and slides[0].get('image'):
513
  safe_img = slides[0].get('image', '')
514
+ # Use text for description if available
515
  description = _clean((post.get('text') or '')[:200]) or "Tin tức tóm tắt, AI rewrite, World Cup 2026"
516
+
517
+ # Build canonical URL preserving original query format if url was provided
518
  if safe_url and safe_url != '/':
519
  canonical_url = f"{SPACE}/s?url={quote(safe_url)}&title={quote(safe_title[:100])}"
520
  else:
521
  canonical_url = f"{SPACE}/s?post_id={post.get('id') or ''}"
522
+
523
  h = f'''<!DOCTYPE html>
524
  <html lang="vi">
525
  <head>
 
552
 
553
  def _render_video_page(post, safe_title, safe_img, safe_url):
554
  video_url = post.get('video', '')
555
+ # Use text for description if available
556
  description = _clean((post.get('text') or '')[:200]) or "Tin tức tóm tắt, AI rewrite, World Cup 2026"
557
+
558
+ # Build canonical URL preserving original query format if url was provided
559
  if safe_url and safe_url != '/':
560
  canonical_url = f"{SPACE}/s?url={quote(safe_url)}&title={quote(safe_title[:100])}"
561
  else:
562
  canonical_url = f"{SPACE}/s?post_id={post.get('id') or ''}"
563
+
564
  h = f'''<!DOCTYPE html>
565
  <html lang="vi">
566
  <head>
 
592
 
593
  @app.get('/s/{slug}')
594
  async def _sh_slug(slug: str, request: Request, url: str = '', title: str = '', img: str = ''):
595
+ """SEO-friendly share endpoint with slug in URL path.
596
+ Shows slide content when url matches a wall post, otherwise redirects.
597
+ """
598
  safe_title = _clean(title) if title else 'VNEWS - Tin tức'
599
  safe_img = _clean(img) if img else ''
600
  safe_url = _clean(url) if url else '/'
601
+
602
+ # Try to find post by URL first (most reliable)
603
  post = None
604
  try:
605
  if url:
 
611
  safe_img = p.get('img', safe_img) or safe_img
612
  safe_url = p.get('url', safe_url) or safe_url
613
  break
614
+ # Fallback: any matching URL
615
  if not post and url:
616
  for p in posts:
617
  if p.get('url') == url:
618
+ post = p
619
+ safe_title = p.get('title', safe_title) or safe_title
620
+ safe_img = p.get('img', safe_img) or safe_img
621
+ safe_url = p.get('url', safe_url) or safe_url
622
+ break
623
+ except:
624
+ pass
625
+
626
+ if post and post.get('slides'):
627
+ return _render_slides_page(post, safe_title, safe_img, safe_url)
628
+
629
+ if post and post.get('video'):
630
+ return _render_video_page(post, safe_title, safe_img, safe_url)
631
+
632
+ # Otherwise redirect
633
  return HTMLResponse(f'''<!DOCTYPE html>
634
+ <html lang="vi">
635
+ <head>
636
+ <meta charset="utf-8">
637
+ <meta name="viewport" content="width=device-width,initial-scale=1">
638
+ <title>{_clean(safe_title)}</title>
639
+ <meta property="og:title" content="{_clean(safe_title)}">
640
+ <meta property="og:image" content="{_clean(safe_img)}">
641
  <meta property="og:description" content="Tin tức tóm tắt, AI rewrite, World Cup 2026">
642
+ <meta property="og:url" content="{SPACE}/s/{slug}">
643
+ <link rel="canonical" href="{SPACE}/s/{slug}">
644
+ <meta http-equiv="refresh" content="0;url={safe_url}">
645
+ </head><body></body></html>''')
646
+
647
+
648
 
649
  @app.get('/api/proxy/img')
650
  def proxy_img(url: str = Query(default=""), max_size: int = Query(default=1200)):
651
+ """Proxy image from blocked CDN to public URL."""
652
  from urllib.parse import unquote
653
  safe_url = unquote(url)
654
  import requests as _req
 
656
  r = _req.get(safe_url, timeout=10, headers={"User-Agent": "Mozilla/5.0"})
657
  if r.status_code == 200 and r.content:
658
  return Response(r.content, media_type=r.headers.get("content-type", "image/jpeg"))
659
+ except Exception as e:
660
+ pass
661
  return Response(status_code=502)
 
662
  @app.get('/s')
663
  async def _sh(url:str='',title:str='',img:str='',post_id:str=''):
664
  safe_title = _clean(title) if title else 'VNEWS - Tin tức'
665
  safe_img = _clean(img) if img else ''
666
  safe_url = _clean(url) if url else '/'
667
+
668
+ # Try to find wall post by post_id or URL (prioritize posts with slides/video)
669
  post = None
670
  try:
671
  posts = _load_wall_posts()
672
  if post_id:
673
  for p in posts:
674
+ if p.get('id') == post_id:
675
+ post = p
676
+ safe_title = p.get('title', safe_title) or safe_title
677
+ safe_img = p.get('img', safe_img) or safe_img
678
+ safe_url = p.get('url', safe_url) or safe_url
679
+ break
680
  elif url:
681
+ # Find matching URL - prioritize posts with slides or video
682
  for p in posts:
683
+ if p.get('url') == url and p.get('slides'):
684
+ post = p
685
+ safe_title = p.get('title', safe_title) or safe_title
686
+ safe_img = p.get('img', safe_img) or safe_img
687
+ safe_url = p.get('url', safe_url) or safe_url
688
+ break
689
  if not post:
690
+ # Fallback: find any matching URL
691
  for p in posts:
692
+ if p.get('url') == url:
693
+ post = p
694
+ safe_title = p.get('title', safe_title) or safe_title
695
+ safe_img = p.get('img', safe_img) or safe_img
696
+ safe_url = p.get('url', safe_url) or safe_url
697
+ break
698
+ except:
699
+ pass
700
+
701
+ if post and post.get('slides'):
702
+ return _render_slides_page(post, safe_title, safe_img, safe_url)
703
+
704
+ if post and post.get('video'):
705
+ return _render_video_page(post, safe_title, safe_img, safe_url)
706
+
707
+ # Fallback: redirect to original URL
708
+ # Fetch og:image for better rich preview
709
  if url and not safe_img:
710
  try:
711
  art = _scrape_article_fast(url)
712
+ if art and art.get('og_image'):
713
+ safe_img = art.get('og_image', '')
714
+ if art and art.get('title'):
715
+ safe_title = art.get('title', safe_title)
716
+ except:
717
+ pass
718
+
719
  return HTMLResponse(f'''<!DOCTYPE html>
720
+ <html lang="vi">
721
+ <head>
722
+ <meta charset="utf-8">
723
+ <meta name="viewport" content="width=device-width,initial-scale=1">
724
+ <title>{safe_title}</title>
725
+ <meta property="og:title" content="{safe_title}">
726
+ <meta property="og:image" content="{safe_img}">
727
  <meta property="og:description" content="Tin tức tóm tắt, AI rewrite, World Cup 2026">
728
+ <meta property="og:url" content="{SPACE}/s?url={quote(safe_url)}">
729
+ <link rel="canonical" href="{SPACE}/s?url={quote(safe_url)}">
730
+ <meta http-equiv="refresh" content="0;url={safe_url}">
731
+ </head><body></body></html>''')
732
 
733
  from wc2026_scraper import scrape_summary,scrape_fixtures,scrape_standings,scrape_stats,scrape_wc_news,scrape_road_to_wc,get_wc2026_all,scrape_history,scrape_h2h,scrape_lineups,scrape_match_detail
734
 
 
738
  def _xlb_scrape(path):
739
  url = f"https://xemlaibongda.top/{path}"
740
  r = req.get(url, headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}, timeout=15, allow_redirects=True)
741
+ if r.status_code != 200:
742
+ return []
743
  soup = BeautifulSoup(r.text, 'lxml')
744
+ vids = []
745
+ seen = set()
746
  for a in soup.select('a[href*="/video/"]'):
747
  href = a.get('href', '')
748
+ if not href or href in seen:
749
+ continue
750
  seen.add(href)
751
+ if not href.startswith('http'):
752
+ href = 'https://xemlaibongda.top' + href
753
  img = a.select_one('img')
754
  p = a.parent
755
  for _ in range(4):
756
+ if img:
757
+ break
758
+ if p:
759
+ img = p.select_one('img')
760
+ p = p.parent
761
  img_src = ''
762
+ if img:
763
+ img_src = img.get('data-src','') or img.get('src','') or img.get('data-lazy','') or img.get('data-original','')
764
+ if img_src.startswith('//'):
765
+ img_src = 'https:' + img_src
766
+ elif img_src.startswith('/'):
767
+ img_src = 'https://xemlaibongda.top' + img_src
768
  title = ''
769
  for sel in ['.title', 'h3', 'h2', '.name', '.post-title', '.entry-title', '.video-title']:
770
  t = a.select_one(sel)
771
+ if t:
772
+ title = _clean(t.get_text())
773
+ break
774
+ if not title:
775
+ title = _clean(a.get('title',''))
776
  if not title:
777
  img_alt = a.select_one('img')
778
+ if img_alt:
779
+ title = _clean(img_alt.get('alt',''))
780
  if not title:
781
  parent = a.parent
782
+ if parent:
783
+ pt = _clean(parent.get_text(' ',strip=True))
784
+ if 5 < len(pt) < 120:
785
+ title = pt
786
+ if not title or len(title) < 3:
787
+ continue
788
  vids.append({"link": href, "img": img_src, "title": title})
789
+ if len(vids) >= 30:
790
+ break
791
  return vids
792
 
793
  @app.get('/api/proxy/xlb')
 
796
  cache_key = f"xlb:{path}"
797
  with _xlb_lock:
798
  cached = _xlb_cache.get(cache_key)
799
+ if cached and now - cached['t'] < 120:
800
+ return JSONResponse(cached['d'])
801
  try:
802
  vids = _xlb_scrape(path)
803
  result = {"videos": vids, "count": len(vids)}
804
+ with _xlb_lock:
805
+ _xlb_cache[cache_key] = {'t': now, 'd': result}
806
  return JSONResponse(result)
807
  except Exception as e:
808
  return JSONResponse({"videos": [], "count": 0, "error": str(e)}, status_code=500)
 
841
  try:
842
  if os.path.exists(p):return json.load(open(p,'r',encoding='utf-8'))
843
  except:pass
844
+ return [] # Return empty list instead of dict for wall posts
845
  def _sj(p,d):
846
  try:open(p+'.tmp','w',encoding='utf-8').write(json.dumps(d,ensure_ascii=False));os.replace(p+'.tmp',p)
847
  except:pass
 
870
  return JSONResponse({'comments':cms})
871
 
872
  def _load_wall_posts():
873
+ with _wl_lock:
874
+ return _lj(WALL_FILE)
875
 
876
  def _save_wall_posts(posts):
877
+ with _wl_lock:
878
+ _sj(WALL_FILE, posts)
879
 
880
  @app.get('/api/wall')
881
  def api_wall():
882
  posts = _load_wall_posts()
883
+ if not posts:
884
+ return JSONResponse({"posts": []})
885
  return JSONResponse({"posts": posts})
886
 
887
  @app.post('/api/wall')
888
  async def api_wall_post(request: Request):
889
  content_type = request.headers.get('content-type', '')
890
  if 'multipart/form-data' in content_type:
891
+ try:
892
+ form = await request.form()
893
+ except Exception as e:
894
+ return JSONResponse({"error": f"Form parse error: {str(e)}"}, status_code=400)
895
  title = form.get('title', 'Video mới') or 'Video mới'
896
  text = form.get('text', '') or ''
897
  source = form.get('source', 'vtv_recorder') or 'vtv_recorder'
 
900
  video_url = None
901
  if video_file and hasattr(video_file, 'filename') and video_file.filename:
902
  fname = video_file.filename.lower()
903
+ if fname.endswith('.mp4'):
904
+ ext = '.mp4'
905
+ elif fname.endswith('.webm'):
906
+ ext = '.webm'
907
+ else:
908
+ ext = '.webm'
909
  video_filename = f"wall_{post_id}{ext}"
910
  video_path = os.path.join(WALL_VIDEO_DIR, video_filename)
911
  try:
912
  content = await video_file.read()
913
+ if not content:
914
+ return JSONResponse({"error": "Empty video file"}, status_code=400)
915
+ with open(video_path, 'wb') as f:
916
+ f.write(content)
917
  file_size_mb = len(content) / 1024 / 1024
918
+ if file_size_mb > 50:
919
+ os.remove(video_path)
920
+ return JSONResponse({"error": f"Video quá lớn ({file_size_mb:.1f}MB). Tối đa 50MB."}, status_code=400)
921
  video_url = f"/api/wall/video/{video_filename}"
922
+ except Exception as e:
923
+ return JSONResponse({"error": f"Lỗi lưu video: {str(e)}"}, status_code=500)
924
+ post = {
925
+ "id": post_id,
926
+ "title": title[:200],
927
+ "text": text[:2000],
928
+ "source": source,
929
+ "video": video_url,
930
+ "img": None,
931
+ "images": [],
932
+ "created": int(time.time()),
933
+ "created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()),
934
+ }
935
  posts = _load_wall_posts()
936
+ if not isinstance(posts, list):
937
+ posts = []
938
+ posts.insert(0, post)
939
+ posts = posts[:200]
940
+ _save_wall_posts(posts)
941
  return JSONResponse({"post": post, "ok": True})
942
+ try:
943
+ body = await request.json()
944
+ except:
945
+ body = {}
946
  title = body.get('title', 'Bài mới') or 'Bài mới'
947
  text = body.get('text', '') or ''
948
  img = body.get('img', None)
949
  source = body.get('source', 'user') or 'user'
950
  post_id = str(uuid.uuid4())[:12]
951
+ post = {
952
+ "id": post_id,
953
+ "title": title[:200],
954
+ "text": text[:2000],
955
+ "source": source,
956
+ "video": None,
957
+ "img": img,
958
+ "images": [],
959
+ "created": int(time.time()),
960
+ "created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()),
961
+ }
962
  posts = _load_wall_posts()
963
+ if not isinstance(posts, list):
964
+ posts = []
965
+ posts.insert(0, post)
966
+ posts = posts[:200]
967
+ _save_wall_posts(posts)
968
  return JSONResponse({"post": post, "ok": True})
969
 
970
  @app.get('/api/wall/video/{filename}')
971
  def api_wall_video(filename: str):
972
+ if '..' in filename or '/' in filename:
973
+ return Response(status_code=403)
974
  video_path = os.path.join(WALL_VIDEO_DIR, filename)
975
+ if not os.path.exists(video_path):
976
+ return Response(status_code=404)
977
  ext = os.path.splitext(filename)[1].lower()
978
  media_type = 'video/mp4' if ext == '.mp4' else 'video/webm'
979
  return FileResponse(video_path, media_type=media_type)
 
981
  @app.delete('/api/wall/{post_id}')
982
  def api_wall_delete(post_id: str):
983
  posts = _load_wall_posts()
984
+ if not isinstance(posts, list):
985
+ return JSONResponse({"error": "No posts"}, status_code=404)
986
  for i, p in enumerate(posts):
987
  if p.get('id') == post_id:
988
  if p.get('video'):
989
  video_name = p['video'].split('/')[-1]
990
  video_path = os.path.join(WALL_VIDEO_DIR, video_name)
991
+ if os.path.exists(video_path):
992
+ os.remove(video_path)
993
+ posts.pop(i)
994
+ _save_wall_posts(posts)
995
  return JSONResponse({"ok": True})
996
  return JSONResponse({"error": "Post not found"}, status_code=404)
997
 
 
1001
 
1002
  _UA_RW = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept-Language': 'vi-VN,vi;q=0.9'}
1003
 
1004
+ # Unique character markers for language detection
1005
  _UNIQUE_CHARS = {
1006
  'vietnamese': set('đăâêôơưàảãạáằẳẵặắầẩẫậấèẻẽẹéềễểệếìỉĩịíòỏõọóồổỗộốờởỡợớùủũụúừửữựứỳỷỹỵý'),
1007
  'spanish': set('ñáéíóúü¿¡'),
 
1016
  }
1017
 
1018
  def detect_language(text):
1019
+ """Detect language from text content using stopword + character analysis."""
1020
+ if not text:
1021
+ return 'vietnamese'
1022
+ text_lower = text.lower()
1023
+ text_chars = set(text_lower)
1024
+
1025
+ # Strong signal: Vietnamese unique characters
1026
  vn_chars = len(text_chars & _UNIQUE_CHARS['vietnamese'])
1027
+ if vn_chars >= 2:
1028
+ return 'vietnamese'
1029
+
1030
+ # Spanish unique chars (ñ, ¿, ¡)
1031
  es_chars = len(text_chars & _UNIQUE_CHARS['spanish'])
1032
  pt_chars = len(text_chars & _UNIQUE_CHARS['portuguese'])
1033
+
1034
+ # Stopword scoring
1035
  words = set(re.findall(r'\b\w+\b', text_lower))
1036
  scores = {}
1037
+ for lang, stops in _STOPWORDS.items():
1038
+ scores[lang] = len(words & stops) / max(len(stops), 1)
1039
+
1040
+ # Disambiguate Portuguese vs Spanish
1041
  pt_markers = {'não', 'pelo', 'pela', 'isso', 'há', 'estão', 'num', 'numa', 'tenho', 'posso', 'você', 'nós', 'eles', 'elas', 'também', 'muito', 'já', 'só', 'até', 'entre', 'depois', 'sem', 'mesmo', 'aos', 'serão'}
1042
  es_markers = {'pero', 'está', 'están', 'porque', 'también', 'hasta', 'donde', 'quien', 'fue', 'son', 'fueron', 'ese', 'eso', 'ante', 'ellos', 'ella', 'nosotros', 'usted', 'ustedes', 'tú', 'él', 'desde', 'todo', 'durante', 'todos', 'uno', 'les', 'ni', 'contra', 'otros', 'fueron'}
1043
+
1044
+ pt_overlap = len(words & pt_markers)
1045
+ es_overlap = len(words & es_markers)
1046
+
1047
+ if scores.get('portuguese', 0) > 0 and pt_overlap > es_overlap:
1048
+ return 'portuguese'
1049
+ if scores.get('spanish', 0) > 0 and es_overlap > pt_overlap:
1050
+ return 'spanish'
1051
+ if scores.get('english', 0) > 0.15:
1052
+ return 'english'
1053
+
1054
  best = max(scores, key=scores.get)
1055
  return best if scores[best] > 0.05 else 'vietnamese'
1056
 
1057
+ # Emotion keyword-based detection
1058
  _EMOTION_KEYWORDS = {
1059
+ 'happy': {
1060
+ 'en': ['happy', 'joy', 'wonderful', 'great', 'amazing', 'fantastic', 'love', 'excellent', 'beautiful', 'glad', 'delighted', 'pleased', 'cheerful', 'celebrate', 'victory', 'win', 'success'],
1061
+ 'pt': ['feliz', 'alegria', 'maravilhoso', 'ótimo', 'incrível', 'fantástico', 'amor', 'excelente', 'lindo', 'contente', 'encantado', 'vitória', 'sucesso'],
1062
+ 'es': ['feliz', 'alegria', 'maravilloso', 'genial', 'increíble', 'fantástico', 'amor', 'excelente', 'hermoso', 'contento', 'encantado', 'victoria', 'éxito'],
1063
+ 'vi': ['vui', 'hạnh phúc', 'tuyệt vời', 'tuyệt', 'ý nghĩa', 'đẹp', 'thích', 'yêu', 'vui vẻ', 'hân hoan', 'phấn khích', 'chiến thắng', 'thành công'],
1064
+ },
1065
+ 'sad': {
1066
+ 'en': ['sad', 'unhappy', 'terrible', 'awful', 'horrible', 'miserable', 'depressed', 'grief', 'sorrow', 'tragic', 'unfortunate', 'painful', 'death', 'die', 'kill'],
1067
+ 'pt': ['triste', 'infeliz', 'terrível', 'horrível', 'miserável', 'deprimido', 'dor', 'trágico', 'infelizmente', 'penoso', 'morte', 'morrer'],
1068
+ 'es': ['triste', 'infeliz', 'terrible', 'horrible', 'miserable', 'deprimido', 'dolor', 'trágico', 'desafortunado', 'penoso', 'muerte', 'morir'],
1069
+ 'vi': ['buồn', 'không vui', 'tồi tệ', 'kinh khủng', '��au khổ', 'đau buồn', 'bi thương', 'khốn nạn', 'đau đớn', 'thảm họa', 'chết', 'mất'],
1070
+ },
1071
+ 'excited': {
1072
+ 'en': ['excited', 'thrilling', 'amazing', 'wow', 'incredible', 'unbelievable', 'awesome', 'exhilarating', 'electrifying', 'breathtaking', 'breakthrough', 'record'],
1073
+ 'pt': ['animado', 'emocionante', 'incrível', 'impressionante', 'sensacional', 'eletrizante', 'empolgante', 'recorde'],
1074
+ 'es': ['emocionante', 'increíble', 'impresionante', 'sensacional', 'electrizante', 'emocionado', 'entusiasmado', 'récord'],
1075
+ 'vi': ['hào hứng', 'phấn khích', 'thú vị', 'tuyệt cú mèo', 'đỉnh cao', 'ngoạn mục', 'sục sôi', 'kỷ lục', 'đột phá'],
1076
+ },
1077
+ 'humorous': {
1078
+ 'en': ['funny', 'hilarious', 'joke', 'laugh', 'comedy', 'humor', 'amusing', 'witty', 'sarcastic', 'ironic', 'ridiculous', 'absurd', 'lol', 'haha'],
1079
+ 'pt': ['engraçado', 'hilário', 'piada', 'rir', 'comédia', 'humor', 'divertido', 'irônico', 'ridículo', 'absurdo', 'kkk'],
1080
+ 'es': ['gracioso', 'hilarante', 'broma', 'risa', 'comedia', 'humor', 'divertido', 'irónico', 'ridículo', 'absurdo', 'jaja'],
1081
+ 'vi': ['hài hước', 'buồn cười', 'đùa', 'cười', 'hài', 'vui nhộn', 'hóm hỉnh', 'mỉa mai', 'lố bịch', 'vô lý', 'haha'],
1082
+ },
1083
+ 'serious': {
1084
+ 'en': ['serious', 'critical', 'important', 'urgent', 'severe', 'grave', 'significant', 'crucial', 'vital', 'essential', 'alarming', 'concerning', 'crisis', 'war', 'conflict'],
1085
+ 'pt': ['sério', 'crítico', 'importante', 'urgente', 'grave', 'significativo', 'crucial', 'vital', 'essencial', 'preocupante', 'crise', 'guerra', 'conflito'],
1086
+ 'es': ['serio', 'crítico', 'importante', 'urgente', 'grave', 'significativo', 'crucial', 'vital', 'esencial', 'preocupante', 'crisis', 'guerra', 'conflicto'],
1087
+ 'vi': ['nghiêm trọng', 'quan trọng', 'khẩn cấp', 'nghiêm túc', 'đáng kể', 'thiết yếu', 'cần thiết', 'báo động', 'lo ngại', 'khủng hoảng', 'chiến tranh', 'xung đột'],
1088
+ },
1089
+ }
1090
+
1091
+ def detect_emotion(text, language='vietnamese'):
1092
+ """Detect emotion from text using keyword matching."""
1093
+ if not text:
1094
+ return 'neutral'
1095
+ text_lower = text.lower()
1096
+
1097
+ scores = {}
1098
+ for emotion, lang_keywords in _EMOTION_KEYWORDS.items():
1099
+ keywords = lang_keywords.get(language, lang_keywords.get('en', []))
1100
+ score = sum(1 for kw in keywords if kw in text_lower)
1101
+ scores[emotion] = score
1102
+
1103
+ if max(scores.values()) == 0:
1104
+ return 'neutral'
1105
+
1106
+ return max(scores, key=scores.get)
1107
+
1108
+ def detect_language_and_emotion(title, text):
1109
+ """Detect both language and emotion from article content."""
1110
+ combined = f"{title} {text}"
1111
+ lang = detect_language(combined)
1112
+ emotion = detect_emotion(combined, lang)
1113
+ return lang, emotion
1114
+
1115
+ # Voice selection based on language and emotion (using MultilingualNeural voices)
1116
+ VOICE_BY_LANG_EMOTION = {
1117
+ 'vietnamese': {
1118
+ 'happy': ('vi-VN-HoaiMyNeural', 'vui'),
1119
+ 'sad': ('vi-VN-NamMinhNeural', 'buồn'),
1120
+ 'excited': ('vi-VN-HoaiMyNeural', 'hào hứng'),
1121
+ 'humorous': ('vi-VN-HoaiMyNeural', 'vui'),
1122
+ 'serious': ('vi-VN-NamMinhNeural', 'nghiêm túc'),
1123
+ 'neutral': ('vi-VN-HoaiMyNeural', 'trung_tinh'),
1124
+ },
1125
+ 'portuguese': {
1126
+ 'happy': ('pt-BR-ThalitaMultilingualNeural', 'feliz'),
1127
+ 'sad': ('pt-BR-ThalitaMultilingualNeural', 'triste'),
1128
+ 'excited': ('pt-BR-ThalitaMultilingualNeural', 'animado'),
1129
+ 'humorous': ('pt-BR-ThalitaMultilingualNeural', 'engraçado'),
1130
+ 'serious': ('pt-BR-ThalitaMultilingualNeural', 'sério'),
1131
+ 'neutral': ('pt-BR-ThalitaMultilingualNeural', 'neutro'),
1132
+ },
1133
+ 'english': {
1134
+ 'happy': ('en-US-AndrewMultilingualNeural', 'happy'),
1135
+ 'sad': ('en-AU-WilliamMultilingualNeural', 'sad'),
1136
+ 'excited': ('en-US-AndrewMultilingualNeural', 'excited'),
1137
+ 'humorous': ('en-US-AndrewMultilingualNeural', 'funny'),
1138
+ 'serious': ('en-AU-WilliamMultilingualNeural', 'serious'),
1139
+ 'neutral': ('en-US-AndrewMultilingualNeural', 'neutral'),
1140
+ },
1141
+ 'french': {
1142
+ 'happy': ('fr-FR-VivienneMultilingualNeural', 'heureux'),
1143
+ 'sad': ('fr-FR-RemyMultilingualNeural', 'triste'),
1144
+ 'excited': ('fr-FR-VivienneMultilingualNeural', 'excité'),
1145
+ 'humorous': ('fr-FR-VivienneMultilingualNeural', 'drôle'),
1146
+ 'serious': ('fr-FR-RemyMultilingualNeural', 'sérieux'),
1147
+ 'neutral': ('fr-FR-VivienneMultilingualNeural', 'neutre'),
1148
+ },
1149
+ 'german': {
1150
+ 'happy': ('de-DE-SeraphinaMultilingualNeural', 'glücklich'),
1151
+ 'sad': ('de-DE-FlorianMultilingualNeural', 'traurig'),
1152
+ 'excited': ('de-DE-SeraphinaMultilingualNeural', 'aufgeregt'),
1153
+ 'humorous': ('de-DE-SeraphinaMultilingualNeural', 'lustig'),
1154
+ 'serious': ('de-DE-FlorianMultilingualNeural', 'ernst'),
1155
+ 'neutral': ('de-DE-SeraphinaMultilingualNeural', 'neutral'),
1156
+ },
1157
+ 'korean': {
1158
+ 'happy': ('ko-KR-HyunsuMultilingualNeural', '행복'),
1159
+ 'sad': ('ko-KR-HyunsuMultilingualNeural', '슬픔'),
1160
+ 'excited': ('ko-KR-HyunsuMultilingualNeural', '흥분'),
1161
+ 'humorous': ('ko-KR-HyunsuMultilingualNeural', '유쾌'),
1162
+ 'serious': ('ko-KR-HyunsuMultilingualNeural', '진지'),
1163
+ 'neutral': ('ko-KR-HyunsuMultilingualNeural', '중립'),
1164
+ },
1165
+ 'italian': {
1166
+ 'happy': ('it-IT-GiuseppeMultilingualNeural', 'felice'),
1167
+ 'sad': ('it-IT-GiuseppeMultilingualNeural', 'triste'),
1168
+ 'excited': ('it-IT-GiuseppeMultilingualNeural', 'emozionato'),
1169
+ 'humorous': ('it-IT-GiuseppeMultilingualNeural', 'divertente'),
1170
+ 'serious': ('it-IT-GiuseppeMultilingualNeural', 'serio'),
1171
+ 'neutral': ('it-IT-GiuseppeMultilingualNeural', 'neutro'),
1172
+ },
1173
+ }
1174
+
1175
+ # All valid voice IDs (new MultilingualNeural format)
1176
+ VALID_VOICES = {
1177
+ 'vi-VN-HoaiMyNeural', 'vi-VN-NamMinhNeural',
1178
+ 'en-US-AndrewMultilingualNeural', 'en-AU-WilliamMultilingualNeural',
1179
+ 'pt-BR-ThalitaMultilingualNeural',
1180
+ 'fr-FR-VivienneMultilingualNeural', 'fr-FR-RemyMultilingualNeural',
1181
+ 'de-DE-SeraphinaMultilingualNeural', 'de-DE-FlorianMultilingualNeural',
1182
+ 'ko-KR-HyunsuMultilingualNeural',
1183
+ 'it-IT-GiuseppeMultilingualNeural',
1184
+ }
1185
+
1186
+ def get_voice_for_content(title, text, preferred_voice=None):
1187
+ """Get appropriate voice based on content language and emotion."""
1188
+ # Accept the new MultilingualNeural voices directly
1189
+ if preferred_voice and preferred_voice in VALID_VOICES:
1190
+ return preferred_voice
1191
+
1192
+ # Also accept old shorthand voice IDs and map them to new format
1193
+ old_voice_map = {
1194
+ 'hoaimy': 'vi-VN-HoaiMyNeural',
1195
+ 'namminh': 'vi-VN-NamMinhNeural',
1196
+ 'andrew': 'en-US-AndrewMultilingualNeural',
1197
+ 'jenny': 'en-US-AndrewMultilingualNeural',
1198
+ 'thalita': 'pt-BR-ThalitaMultilingualNeural',
1199
+ 'pt_thalita': 'pt-BR-ThalitaMultilingualNeural',
1200
+ 'pt_francisco': 'pt-BR-ThalitaMultilingualNeural',
1201
+ 'ela': 'en-US-AndrewMultilingualNeural',
1202
+ 'es_carlos': 'en-US-AndrewMultilingualNeural',
1203
+ 'denise': 'fr-FR-VivienneMultilingualNeural',
1204
+ 'katja': 'de-DE-SeraphinaMultilingualNeural',
1205
+ 'nanami': 'en-US-AndrewMultilingualNeural',
1206
+ 'sunhee': 'ko-KR-HyunsuMultilingualNeural',
1207
+ 'xiaochen': 'en-US-AndrewMultilingualNeural',
1208
+ }
1209
+ if preferred_voice and preferred_voice in old_voice_map:
1210
+ return old_voice_map[preferred_voice]
1211
+
1212
+ lang, emotion = detect_language_and_emotion(title, text)
1213
+ lang_map = VOICE_BY_LANG_EMOTION.get(lang, VOICE_BY_LANG_EMOTION['vietnamese'])
1214
+ voice, _ = lang_map.get(emotion, lang_map['neutral'])
1215
+ return voice
1216
+
1217
+
1218
+ def _is_relevant_image(img_url, title, text):
1219
+ """Check if an image is relevant to the article content."""
1220
+ if not img_url:
1221
+ return False
1222
+ skip_patterns = ['pixel', 'analytics', 'tracking', '1x1.gif', 'spacer.gif',
1223
+ 'logo', 'icon', 'avatar', 'emoji', 'smiley', 'sprite',
1224
+ 'advertisement', 'ad-banner', 'sponsored', 'banner-ads']
1225
+ img_lower = img_url.lower()
1226
+ for p in skip_patterns:
1227
+ if p in img_lower:
1228
+ return False
1229
+ if not any(img_lower.endswith(ext) for ext in ['.jpg', '.jpeg', '.png', '.webp', '.gif']):
1230
+ return False
1231
+ return True
1232
+
1233
+
1234
+ def _filter_relevant_images(images, title, text, max_images=8):
1235
+ """Filter and rank images by relevance to article content."""
1236
+ if not images:
1237
+ return []
1238
+ seen = set()
1239
+ relevant = []
1240
+ for img in images:
1241
+ if img in seen:
1242
+ continue
1243
+ seen.add(img)
1244
+ if _is_relevant_image(img, title, text):
1245
+ relevant.append(img)
1246
+ return relevant[:max_images]
1247
+
1248
+
1249
+ def _scrape_article_for_rewrite(url):
1250
+ """Scrape article: extract title, paragraphs, RELEVANT images, OG image."""
1251
+ try:
1252
+ r = req.get(url, headers=_UA_RW, timeout=15, allow_redirects=True)
1253
+ r.encoding = 'utf-8'
1254
+ soup = BeautifulSoup(r.text, 'lxml')
1255
+ for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form']):
1256
+ tag.decompose()
1257
+ h1 = soup.find('h1')
1258
+ ogt = soup.find('meta', property='og:title')
1259
+ title = (h1.get_text(strip=True) if h1 else '') or (ogt.get('content', '') if ogt else '')
1260
+ ogi = soup.find('meta', property='og:image')
1261
+ og_img = ogi.get('content', '') if ogi else ''
1262
+ if og_img and og_img.startswith('//'):
1263
+ og_img = 'https:' + og_img
1264
+ block = None
1265
+ for sel in ['article', '.singular-content', '.detail-content', '.fck_detail', '.content-detail', '.knc-content', 'main', '.cms-body', '.article__body']:
1266
+ el = soup.select_one(sel)
1267
+ if el and len(el.find_all('p')) >= 2:
1268
+ block = el
1269
+ break
1270
+ if not block:
1271
+ block = soup.body or soup
1272
+ paragraphs = []
1273
+ all_images = []
1274
+ seen_imgs = set()
1275
+ if og_img and og_img not in seen_imgs:
1276
+ all_images.append(og_img)
1277
+ seen_imgs.add(og_img)
1278
+ for el in block.find_all(['p', 'h2', 'h3', 'figure', 'img'], recursive=True):
1279
+ if el.name == 'p':
1280
+ t = _clean(el.get_text(strip=True))
1281
+ if t and len(t) > 40:
1282
+ paragraphs.append(t)
1283
+ elif el.name in ('figure', 'img'):
1284
+ im = el if el.name == 'img' else el.find('img')
1285
+ if im:
1286
+ src = im.get('data-src') or im.get('src') or im.get('data-original') or ''
1287
+ if src and 'base64' not in src:
1288
+ if src.startswith('//'):
1289
+ src = 'https:' + src
1290
+ if src not in seen_imgs:
1291
+ all_images.append(src)
1292
+ seen_imgs.add(src)
1293
+ # Filter to relevant images only
1294
+ relevant_images = _filter_relevant_images(all_images, title, ' '.join(paragraphs[:5]))
1295
+ return {'title': _clean(title), 'paragraphs': paragraphs, 'images': relevant_images, 'og_img': og_img}
1296
+ except Exception:
1297
+ return None
1298
+
1299
+
1300
+ def _extract_key_points_rw(paragraphs, max_points=5):
1301
+ r"""Extract key points from paragraphs - extracts ALL sentences, not just first one.
1302
+
1303
+ Fixes: Original regex `^(.+?[.!?])\s` only captured first sentence per paragraph.
1304
+ Now splits on all sentence boundaries and takes valid sentences until max_points.
1305
+ """
1306
+ points = []
1307
+
1308
+ for p in paragraphs:
1309
+ if len(points) >= max_points:
1310
+ break
1311
+
1312
+ p = _clean(p)
1313
+ if not p:
1314
+ continue
1315
+
1316
+ # Split paragraph into sentences using Vietnamese + English punctuation
1317
+ sentences = re.split(r'(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])', p)
1318
+ sentences = [s.strip() for s in sentences if s.strip()]
1319
+
1320
+ for sentence in sentences:
1321
+ if len(points) >= max_points:
1322
+ break
1323
+
1324
+ # Clean sentence - remove extra whitespace
1325
+ sentence = _clean(sentence)
1326
+
1327
+ if len(sentence) < 30:
1328
+ continue
1329
+
1330
+ # Check for duplicates
1331
+ if any(sentence[:60] in existing for existing in points):
1332
+ continue
1333
+
1334
+ # Ensure sentence ends with punctuation
1335
+ if not sentence.endswith(('.', '!', '?')):
1336
+ sentence = sentence + '.'
1337
+
1338
+ points.append(sentence)
1339
+
1340
+ # If no valid sentences found, take chunks from raw text
1341
+ if not points:
1342
+ raw = '\n'.join(paragraphs)
1343
+ for i in range(0, min(len(raw), max_points * 300), 280):
1344
+ chunk = _clean(raw[i:i+280])
1345
+ if len(chunk) >= 30 and chunk not in points:
1346
+ points.append(chunk + ('.' if not chunk.endswith('.') else ''))
1347
+ if len(points) >= max_points:
1348
+ break
1349
+
1350
+ return points
1351
+
1352
+
1353
+ @app.post("/api/rewrite_slide")
1354
+ async def api_rewrite_slide(request: Request):
1355
+ """Fast rewrite as SLIDES - no AI needed, instant response."""
1356
+ body = await request.json()
1357
+ url = _clean(body.get("url", ""))
1358
+ context = body.get("context", "")
1359
+ preferred_voice = body.get("voice", "") # Accept custom voice selection
1360
+ if not url and not context:
1361
+ return JSONResponse({"error": "Cần URL hoặc nội dung"}, status_code=400)
1362
+ data = None
1363
+ if url and url.startswith("http"):
1364
+ data = _scrape_article_for_rewrite(url)
1365
+ if not data and context:
1366
+ paragraphs = [_clean(p) for p in context.split('\n') if len(_clean(p)) > 40]
1367
+ data = {'title': paragraphs[0][:80] if paragraphs else 'Bài viết', 'paragraphs': paragraphs, 'images': [], 'og_img': ''}
1368
+ if not data or not data.get('paragraphs'):
1369
+ return JSONResponse({"error": "Không đọc được bài viết"}, status_code=422)
1370
+ points = _extract_key_points_rw(data['paragraphs'], max_points=12)
1371
+ if not points:
1372
+ return JSONResponse({"error": "Không tìm được ý chính"}, status_code=422)
1373
+ images = data.get('images', [])
1374
+ slides = []
1375
+ for i, point in enumerate(points):
1376
+ img = images[i] if i < len(images) else (images[-1] if images else '')
1377
+ if img and 'cdnphoto.dantri' in img:
1378
+ img = '/api/proxy/img?url=' + _quote2(img, safe='')
1379
+ slides.append({'text': point, 'image': img, 'index': i + 1})
1380
+ summary_text = '\n\n'.join([f"• {s['text']}" for s in slides])
1381
+
1382
+ # Auto-detect language and emotion
1383
+ lang, emotion = detect_language_and_emotion(data['title'], summary_text)
1384
+ # Use preferred voice if provided, otherwise auto-detect
1385
+ voice = preferred_voice if preferred_voice else get_voice_for_content(data['title'], summary_text)
1386
+
1387
+ post = {
1388
+ "id": str(int(time.time() * 1000)) + str(_random2.randint(100, 999)),
1389
+ "title": data['title'],
1390
+ "text": summary_text,
1391
+ "img": images[0] if images else '',
1392
+ "url": url,
1393
+ "kind": "slide_summary",
1394
+ "slides": slides,
1395
+ "images": images[:10],
1396
+ "video": "",
1397
+ "voice": voice,
1398
+ "emotion": emotion,
1399
+ "language": lang,
1400
+ "ts": int(time.time())
1401
+ }
1402
+ posts = _load_wall_posts()
1403
+ posts.insert(0, post)
1404
+ _save_wall_posts(posts)
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)
1420
+ if not data and ctx:
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
1433
+ domain = urlparse(url).netloc.replace('www.', '')
1434
+ except:
1435
+ pass
1436
+
1437
+ # Generate AI summary text
1438
+ ai_text = None
1439
+ try:
1440
+ import ai_ext
1441
+ if hasattr(ai_ext, 'qwen_generate'):
1442
+ prompt = f'Tóm tắt đăng Tường AI:\nTiêu đề: {data["title"]}\n{raw_text[:14000]}\n\n4-6 ý chính. Cuối ghi nguồn.'
1443
+ ai_text = await ai_ext.qwen_generate(prompt, max_tokens=1000)
1444
+ except Exception:
1445
+ pass
1446
+ if not ai_text or len(ai_text) < 80:
1447
+ key_pts = _extract_key_points_rw(data['paragraphs'], max_points=12)
1448
+ if key_pts:
1449
+ ai_text = '\n\n'.join([f"• {p}" for p in key_pts])
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 = []
1457
+ for i, point in enumerate(points):
1458
+ img = images[i] if i < len(images) else (images[-1] if images else '')
1459
+ if img and 'cdnphoto.dantri' in img:
1460
+ img = '/api/proxy/img?url=' + _quote2(img, safe='')
1461
+ slides.append({'text': point, 'image': img, 'index': i + 1})
1462
+
1463
+ # Auto-detect language and emotion
1464
+ lang, emotion = detect_language_and_emotion(data['title'], ai_text)
1465
+ # Use preferred voice if provided, otherwise auto-detect
1466
+ voice = preferred_voice if preferred_voice else get_voice_for_content(data['title'], ai_text)
1467
+
1468
+ post = {
1469
+ "id": str(int(time.time() * 1000)) + str(_random2.randint(100, 999)),
1470
+ "title": data['title'],
1471
+ "text": ai_text,
1472
+ "img": images[0] if images else '',
1473
+ "url": url,
1474
+ "kind": "rewrite",
1475
+ "slides": slides,
1476
+ "images": images[:10],
1477
+ "video": "",
1478
+ "voice": voice,
1479
+ "emotion": emotion,
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")
1490
+ async def api_url_wall(request: Request):
1491
+ """Submit URL to add to Tường AI."""
1492
+ body = await request.json()
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():
1502
+ time.sleep(15)
1503
+ while True:
1504
+ try:get_wc2026_all()
1505
+ except:pass
1506
+ time.sleep(90)
1507
+ threading.Thread(target=_bg,daemon=True).start()
1508
+
1509
+ # ===== AUTO SCHEDULER: rewrite AI + short at 7/13/19 VN time =====
1510
+ _AUTO_SCHEDULE_TIMES = [(7, '07:00'), (13, '13:00'), (19, '19:00')]
1511
+ _AUTO_LOG = os.path.join(DATA_DIR, 'auto_rewrite_log.json')
1512
+
1513
+ def _load_auto_log():
1514
+ try:
1515
+ if os.path.exists(_AUTO_LOG):
1516
+ with open(_AUTO_LOG, 'r') as f:
1517
+ return json.load(f)
1518
+ except: pass
1519
+ return {}
1520
+
1521
+ def _save_auto_log(log):
1522
+ try:
1523
+ tmp = _AUTO_LOG + '.tmp'
1524
+ with open(tmp, 'w') as f:
1525
+ json.dump(log, f)
1526
+ os.replace(tmp, _AUTO_LOG)
1527
+ except: pass
1528
+
1529
+ async def _auto_fetch_short(post_id):
1530
+ """Try to auto-generate a short for a post."""
1531
+ try:
1532
+ import httpx
1533
+ async with httpx.AsyncClient(timeout=180) as cl:
1534
+ r = await cl.post(
1535
+ f"http://localhost:7860/api/ai/short/{post_id}",
1536
+ json={"voice":"vi-VN-HoaiMyNeural","emotion":"neutral","speed":1.2},
1537
+ headers={"Content-Type":"application/json"}
1538
+ )
1539
+ if r.status_code < 300:
1540
+ sj = r.json()
1541
+ if sj.get('video'):
1542
+ posts = _load_wall_posts()
1543
+ for p in posts:
1544
+ if p.get('id') == post_id:
1545
+ p['video'] = sj['video']
1546
+ break
1547
+ _save_wall_posts(posts)
1548
+ return True
1549
+ except: pass
1550
+ return False
1551
+
1552
+ async def _auto_rewrite_one(topic, slot_label, used_urls=None, post_index=0):
1553
+ """Rewrite one topic: find articles, summarize, post to wall, trigger short.
1554
+ used_urls: shared set to avoid duplicate articles across topics.
1555
+ post_index: 0-based index to create multiple posts per topic (0,1,2 = up to 3 posts)."""
1556
+ from urllib.parse import quote as _q
1557
+ # Get MORE items to support 1-3 posts per topic
1558
+ items = _search_all(topic, limit=12)
1559
+ # Skip URLs already used by another topic
1560
+ if used_urls is not None:
1561
+ filtered = [it for it in items if it.get('url') not in used_urls]
1562
+ if filtered:
1563
+ items = filtered
1564
+ if not items or post_index >= len(items):
1565
+ return False
1566
+
1567
+ # Get article at post_index (0,1,2 for multiple posts)
1568
+ item = items[post_index] # post_index allows multiple articles per topic
1569
+ url = item.get('url', '')
1570
+ title = item.get('title', topic)
1571
+ if url and used_urls is not None:
1572
+ used_urls.add(url)
1573
+ if not url.startswith('http'):
1574
+ return False
1575
+
1576
+ data = _scrape_article_for_rewrite(url)
1577
+ if not data or not data.get('paragraphs'):
1578
+ return False
1579
+
1580
+ raw_text = '\n'.join(data['paragraphs'])
1581
+ ai_text = None
1582
+
1583
+ # Try AI generation
1584
+ try:
1585
+ import ai_ext
1586
+ prompt = f"Tóm tắt tin tức (tự động {slot_label}):\nTiêu đề: {data['title']}\n{raw_text[:10000]}\n\n4-6 ý chính dạng bullet. Cuối ghi nguồn."
1587
+ ai_text = await ai_ext.qwen_generate(prompt, max_tokens=1000)
1588
+ except: pass
1589
+
1590
+ if not ai_text or len(ai_text) < 80:
1591
+ pts = data['paragraphs'][:6]
1592
+ ai_text = '\n\n'.join([f"• {p[:300]}" for p in pts])
1593
+ via = item.get('via', '') or urlparse(url).netloc.replace('www.', '')
1594
+ ai_text += f"\n\nNguồn tham khảo: {via}"
1595
+
1596
+ # Build slides
1597
+ images = data.get('images', [])
1598
+ pts = data['paragraphs'][:10]
1599
+ slides = []
1600
+ for i, p in enumerate(pts[:8]):
1601
+ img = images[i] if i < len(images) else (images[-1] if images else data.get('og_img', ''))
1602
+ slides.append({'text': p[:300], 'image': img, 'index': i + 1})
1603
+
1604
+ post_id = str(int(time.time() * 1000)) + str(_random2.randint(100, 999))
1605
+ post = {
1606
+ "id": post_id, "title": data.get('title', title)[:200],
1607
+ "text": ai_text, "img": images[0] if images else data.get('og_img', ''),
1608
+ "url": url, "kind": "auto_rewrite", "slides": slides,
1609
+ "images": images[:10], "video": "",
1610
+ "voice": "vi-VN-HoaiMyNeural", "emotion": "neutral",
1611
+ "language": "vietnamese", "ts": int(time.time()),
1612
+ "auto_scheduled": True, "slot": slot_label,
1613
+ }
1614
+
1615
+ posts = _load_wall_posts()
1616
+ posts.insert(0, post)
1617
+ _save_wall_posts(posts)
1618
+
1619
+ # Trigger short generation async
1620
+ threading.Thread(target=lambda: asyncio.run(_auto_fetch_short(post_id)), daemon=True).start()
1621
+ return True
1622
+
1623
+ async def _do_scheduled_run(slot_label):
1624
+ """Main scheduled run: 1-3 posts from 3 different HOT topics (3-9 total), no duplicates."""
1625
+ print(f"[auto] Starting scheduled rewrite for {slot_label}")
1626
+
1627
+ # Get top hot topics, skip duplicates
1628
+ all_topics = _get_hot_topics()
1629
+ seen_topics = set()
1630
+ unique_topics = []
1631
+ for t in all_topics:
1632
+ kw = t.get('topic', '').lower().strip()
1633
+ if kw and len(kw) > 5 and kw not in seen_topics:
1634
+ is_dup = False
1635
+ for s in seen_topics:
1636
+ # Check if one topic is substring of another
1637
+ if kw in s or s in kw:
1638
+ is_dup = True
1639
+ break
1640
+ if not is_dup:
1641
+ seen_topics.add(kw)
1642
+ unique_topics.append(t)
1643
+ if len(unique_topics) >= 3:
1644
+ break
1645
+
1646
+ job_topics = [t['topic'] for t in unique_topics[:3] if t.get('topic')]
1647
+ if not job_topics:
1648
+ print(f"[auto] No hot topics found, skipping")
1649
+ return
1650
+
1651
+ print(f"[auto] Running 3 topics: {job_topics}")
1652
+
1653
+ # Track used URLs to avoid cross-topic duplicates
1654
+ _used_urls = set()
1655
+ results = []
1656
+
1657
+ # Process each topic, create 1-3 posts per topic
1658
+ for jt in job_topics:
1659
+ for post_idx in range(3): # Try up to 3 posts per topic
1660
+ try:
1661
+ ok = await asyncio.wait_for(_auto_rewrite_one(jt, slot_label, _used_urls, post_idx), timeout=120)
1662
+ if ok:
1663
+ results.append((jt, post_idx, True))
1664
+ print(f"[auto] Created post {post_idx+1} for '{jt}'")
1665
+ else:
1666
+ # No more articles for this topic
1667
+ break
1668
+ except Exception as e:
1669
+ print(f"[auto] Error on '{jt}' post {post_idx}: {e}")
1670
+ results.append((jt, post_idx, False))
1671
+ await asyncio.sleep(1) # Small delay between posts
1672
+
1673
+ # Ensure at least 3 posts total (fallback if needed)
1674
+ successful_posts = sum(1 for _, _, ok in results if ok)
1675
+ print(f"[auto] Done {slot_label}: {successful_posts} posts created")
1676
+
1677
+ # Log
1678
+ from datetime import datetime, timezone, timedelta
1679
+ VN_TZ_SCHED = timezone(timedelta(hours=7))
1680
+ today_str = datetime.now(VN_TZ_SCHED).strftime('%Y-%m-%d')
1681
+ log = _load_auto_log()
1682
+ if today_str not in log: log[today_str] = {}
1683
+ log[today_str][slot_label] = {
1684
+ 'time': datetime.now(VN_TZ_SCHED).strftime('%H:%M:%S'),
1685
+ 'count': successful_posts,
1686
+ 'total': len(job_topics),
1687
+ }
1688
+ _save_auto_log(log)
1689
+
1690
+ def _scheduler_loop():
1691
+ """Check every 60s; trigger at 7:00, 13:00, 19:00 VN time.
1692
+ On startup, check for any missed slots today and run them immediately."""
1693
+ time.sleep(35)
1694
+ from datetime import datetime, timezone, timedelta
1695
+ VN_TZ_SCHED = timezone(timedelta(hours=7))
1696
+
1697
+ _last_run_date = ""
1698
+ _last_run_slots = set()
1699
+
1700
+ # On startup: check log for missed slots today
1701
+ try:
1702
+ start_now = datetime.now(VN_TZ_SCHED)
1703
+ today_str = start_now.strftime('%Y-%m-%d')
1704
+ current_hour = start_now.hour
1705
+ current_minute = start_now.minute
1706
+ log = _load_auto_log()
1707
+ today_log = log.get(today_str, {})
1708
+ for h, label in _AUTO_SCHEDULE_TIMES:
1709
+ # Run if slot is past (either strictly earlier hour, or same hour but window has passed)
1710
+ should_run = False
1711
+ if h < current_hour:
1712
+ should_run = True
1713
+ elif h == current_hour and current_minute > 10:
1714
+ should_run = True
1715
+ if should_run and label not in today_log:
1716
+ print(f"[auto] Detected missed slot {label} (h={h} < now={current_hour}:{current_minute}), running catch-up now")
1717
+ _run_scheduled_sync(label)
1718
+ _last_run_slots.add(label)
1719
+ except Exception as e:
1720
+ print(f"[auto] Catch-up check error: {e}")
1721
+
1722
+ while True:
1723
+ try:
1724
+ now = datetime.now(VN_TZ_SCHED)
1725
+ today = now.strftime('%Y-%m-%d')
1726
+ hour = now.hour
1727
+ minute = now.minute
1728
+
1729
+ if today != _last_run_date:
1730
+ _last_run_date = today
1731
+ _last_run_slots = set()
1732
+
1733
+ slot = None
1734
+ for h, label in _AUTO_SCHEDULE_TIMES:
1735
+ if hour == h and 0 <= minute < 5:
1736
+ slot = label
1737
+ break
1738
+
1739
+ if slot and slot not in _last_run_slots:
1740
+ _last_run_slots.add(slot)
1741
+ _run_scheduled_sync(slot)
1742
+ except Exception as e:
1743
+ print(f"[auto] Loop error: {e}")
1744
+
1745
+ time.sleep(60)
1746
+
1747
+ threading.Thread(target=_scheduler_loop, daemon=True, name='auto-rewrite-scheduler').start()
1748
+
1749
+ @app.get('/api/debug/auto_schedule')
1750
+ async def debug_auto_schedule(slot: str = '07:00'):
1751
+ """Manually trigger auto scheduler for debugging."""
1752
+ try:
1753
+ # Check if we can access the data directory
1754
+ log = _load_auto_log()
1755
+ topics = _get_hot_topics()[:3]
1756
+ job_topics = [t['topic'] for t in topics if t.get('topic')]
1757
+ return JSONResponse({
1758
+ "slot": slot,
1759
+ "log": log,
1760
+ "hot_topics": job_topics,
1761
+ "wall_posts_count": len(_load_wall_posts()),
1762
+ "data_dir_writable": os.access(DATA_DIR, os.W_OK) if os.path.isdir(DATA_DIR) else False,
1763
+ "data_dir_exists": os.path.isdir(DATA_DIR),
1764
+ })
1765
+ except Exception as e:
1766
+ return JSONResponse({"error": str(e)}, status_code=500)
1767
+
1768
+ def _run_scheduled_sync(slot):
1769
+ """Run _do_scheduled_run in a separate event loop (for background thread)."""
1770
+ loop = asyncio.new_event_loop()
1771
+ asyncio.set_event_loop(loop)
1772
+ try:
1773
+ loop.run_until_complete(_do_scheduled_run(slot))
1774
+ except Exception as e:
1775
+ print(f"[auto] Background run error: {e}")
1776
+ finally:
1777
+ loop.close()
1778
+
1779
+ @app.get('/api/debug/trigger_auto')
1780
+ async def debug_trigger_auto(slot: str = '19:00'):
1781
+ """Trigger _do_scheduled_run in background thread (non-blocking)."""
1782
+ threading.Thread(target=_run_scheduled_sync, args=(slot,), daemon=True).start()
1783
+ return JSONResponse({"status": "started", "slot": slot})
1784
+
1785
+ # ===== SHORTS RSS PROXY ENDPOINT =====
1786
+ @app.get("/api/shorts/rss")
1787
+ def shorts_rss():
1788
+ """Get shorts from YouTube RSS feeds server-side"""
1789
+ import xml.etree.ElementTree as ET
1790
+ import html as html_lib2
1791
+ import re as re2
1792
+
1793
+ YOUTUBE_CHANNELS = {
1794
+ "baodantri7941": "UC_x5TKhOgd6GhYvv5z4I3jg",
1795
+ "baosuckhoedoisongboyte": "UCBsY5fXTQLkF_JnH9kLkL4g",
1796
+ }
1797
+
1798
+ shorts = []
1799
+ seen = set()
1800
+
1801
+ for handle, channel_id in YOUTUBE_CHANNELS.items():
1802
+ try:
1803
+ rss_url = f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"
1804
+ r = req.get(rss_url, headers=HEADERS, timeout=15)
1805
+ if r.status_code != 200:
1806
+ continue
1807
+
1808
+ root = ET.fromstring(r.text)
1809
+ ns = {
1810
+ 'atom': 'http://www.w3.org/2005/Atom',
1811
+ 'yt': 'http://www.youtube.com/xml/schemas/2015',
1812
+ 'media': 'http://search.yahoo.com/mrss/'
1813
+ }
1814
+
1815
+ for entry in root.findall('atom:entry', ns)[:30]:
1816
+ title_el = entry.find('atom:title', ns)
1817
+ title = html_lib2.unescape(title_el.text) if title_el is not None and title_el.text else ''
1818
+
1819
+ link_el = entry.find('atom:link', ns)
1820
+ link = link_el.get('href', '') if link_el is not None else ''
1821
+
1822
+ vid_el = entry.find('yt:videoId', ns)
1823
+ vid = vid_el.text if vid_el is not None else ''
1824
+
1825
+ if not vid or vid in seen:
1826
+ continue
1827
+
1828
+ # Check if it's a short
1829
+ is_short = '#shorts' in title.lower() or '#short' in title.lower() or '/shorts/' in link
1830
+
1831
+ if not is_short:
1832
+ desc_el = entry.find('media:description', ns)
1833
+ if desc_el is not None and desc_el.text:
1834
+ if '#shorts' in desc_el.text.lower():
1835
+ is_short = True
1836
+
1837
+ if not is_short:
1838
+ continue
1839
+
1840
+ seen.add(vid)
1841
+
1842
+ # Get thumbnail
1843
+ thumb = f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg"
1844
+ media_group = entry.find('media:group', ns)
1845
+ if media_group is not None:
1846
+ thumb_el = media_group.find('media:thumbnail', ns)
1847
+ if thumb_el is not None:
1848
+ thumb = thumb_el.get('url', thumb)
1849
+
1850
+ shorts.append({
1851
+ 'id': vid,
1852
+ 'title': title.replace('#shorts', '').replace('#short', '').strip()[:120],
1853
+ 'img': thumb,
1854
+ 'link': f'https://www.youtube.com/shorts/{vid}',
1855
+ 'channel': handle,
1856
+ 'source': 'yt'
1857
+ })
1858
+
1859
+ if len(shorts) >= 40:
1860
+ break
1861
+
1862
+ except Exception as e:
1863
+ print(f"RSS error for {handle}: {e}")
1864
+ continue
1865
+
1866
+ return {"shorts": shorts, "count": len(shorts)}
1867
+
1868
+ app.mount('/static',StaticFiles(directory=STATIC_DIR),name='vnews_static')
static/app_v2.js CHANGED
@@ -5,7 +5,6 @@
5
  * readSlidePost has share button, /s + /s/{slug} render slides/video inline
6
  * v2.5 - Added 'Tạo lại' button on wall cards with video
7
  * v2.4 - Fixed: prependWallPost detached-element bug, makeShortVideo UI update, slide viewer for rewrite posts
8
- * v2.9 - ADDED: rewriteUrl() function for the URL rewrite button on homepage (was missing!)
9
  */
10
  function _proxyImg(url){
11
  if(!url || typeof url !== 'string') return '';
@@ -147,8 +146,8 @@ function makeWallItem(p,i){
147
  +'</select>'
148
  +'</div>';
149
  var makeBtn = hasVideo
150
- ? '<button class="wall-btn-video" onclick="event.stopPropagation();openShortAIFeed('+i+')">▶ Xem Short</button><button class="wall-btn-make" style="margin-left:4px" onclick="event.stopPropagation();makeShortVideo(\''+esc(vid)+'\',this,_ttsSelections[\'inline-'+esc(vid)+'\'].voice,parseFloat(document.querySelector(\'.wss[data-selkey=inline-'+esc(vid)+']\').value)||1.2,_ttsSelections[\'inline-'+esc(vid)+'\'].emotion)">🎬 Tạo lại</button>'
151
- : '<button class="wall-btn-make" onclick="event.stopPropagation();makeShortVideo(\''+esc(vid)+'\',this,_ttsSelections[\'inline-'+esc(vid)+'\'].voice,parseFloat(document.querySelector(\'.wss[data-selkey=inline-'+esc(vid)+']\').value)||1.2,_ttsSelections[\'inline-'+esc(vid)+'\'].emotion)">🎬 Tạo Short</button>';
152
  return '<div class="wall-item" id="wall-item-'+esc(vid)+'"><div class="wall-thumb">'+thumbContent+videoBadge+'</div><div class="wall-title">'+esc(p.title)+'</div><div class="wall-text">'+esc((p.text||'').slice(0,180))+'</div>'+voiceBar+'<div class="wall-actions"><button class="primary" onclick="readWallPost('+i+')">Xem</button>'+makeBtn+'</div></div>';
153
  }
154
 
@@ -386,10 +385,6 @@ function searchTopic(topic){if(!topic){topic=document.getElementById('topic-inpu
386
  async function showHashtagSources(topic,page){const box=document.getElementById('hashtag-box');if(!box)return;if(page===0)box.innerHTML=`<div class="hashtag-sources"><h3>🔍 ${esc(topic)}</h3><div class="hashtag-loading"><div class="hashtag-spinner"></div>Đang tìm...</div></div>`;try{const r=await fetch(`/api/hashtag/sources?topic=${encodeURIComponent(topic)}&page=${page}`);const j=await r.json();const sources=j.sources||[];if(!sources.length&&page===0){box.innerHTML=`<div class="hashtag-sources"><h3>🔍 ${esc(topic)}</h3><div style="color:#888;padding:8px">Không tìm được bài viết liên quan</div></div>`;return;}let h='';if(page===0)h=`<div class="hashtag-sources"><h3>🔍 ${esc(topic)} <span style="font-size:10px;color:#888">(${j.total} bài từ 8 nguồn)</span></h3><div id="ht-list">`;sources.forEach((s,i)=>{const idx=page*8+i;h+=`<div class="hashtag-src-item" onclick="readArticle('${esc(s.url)}')"><div class="hashtag-src-img" id="ht-img-${idx}"></div><div class="hashtag-src-text"><div class="hashtag-src-title">${esc(s.title)}</div><div class="hashtag-src-via">${esc(s.via||'')}</div></div></div>`;});if(page===0){h+=`</div><button class="hashtag-rewrite-btn" onclick="rewriteHashtag('${esc(topic).replace(/'/g,"\\'")}')">🤖 Rewrite AI tổng hợp & đăng tường</button>`;if(j.has_more)h+=`<button class="hashtag-load-more" id="ht-more" onclick="loadMoreHashtag()">Tải thêm ▼</button>`;h+=`</div>`;box.innerHTML=h;}else{document.getElementById('ht-list')?.insertAdjacentHTML('beforeend',h);const btn=document.getElementById('ht-more');if(btn){if(!j.has_more)btn.remove();else{btn.disabled=false;btn.textContent='Tải thêm ▼';}}}sources.forEach((s,i)=>{const idx=page*8+i;if(!s.url)return;const ctrl=new AbortController();setTimeout(()=>ctrl.abort(),4000);fetch('/api/article?url='+encodeURIComponent(s.url),{signal:ctrl.signal}).then(r=>r.json()).then(d=>{if(d&&(d.og_image||d.img)){const el=document.getElementById('ht-img-'+idx);if(el)el.innerHTML=`<img src="${_proxyImg(esc(d.og_image||d.img))}" onerror="this.style.display='none'">`;}}).catch(()=>{});});}catch(e){box.innerHTML=`<div class="hashtag-sources"><h3>🔍 ${esc(topic)}</h3><div style="color:#e74c3c;padding:8px">Lỗi: ${esc(e.message)}</div></div>`;}}
387
  function loadMoreHashtag(){_htPage++;const btn=document.getElementById('ht-more');if(btn){btn.disabled=true;btn.textContent='Đang tải...';}showHashtagSources(_htTopic,_htPage);}
388
  async function rewriteHashtag(topic){const btn=event?.target;if(btn){btn.disabled=true;btn.textContent='Đang tổng hợp...';}try{const r=await fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');toast('✅ Đã đăng Tường AI!');if(btn)btn.textContent='✅ Đăng thành công!';if(j.post)prependWallPost(j.post);}catch(e){toast('❌ '+e.message);if(btn){btn.disabled=false;btn.textContent='🤖 Rewrite AI';}}}
389
-
390
- /** rewriteUrl: called when user clicks "Rewrite" button next to URL input on homepage */
391
- async function rewriteUrl(){const inp=document.getElementById('url-input');if(!inp)return;const url=inp.value.trim();if(!url){alert('Dán URL bài viết trước');return}if(!/^https?:\/\//i.test(url)){alert('URL cần bắt đầu bằng http:// hoặc https://');return}inp.value='';let btn=event?.target;if(btn){btn.disabled=true;btn.textContent='⏳ Đang xử lý...';}toast('⏳ Đang phân tích URL...');try{const r=await fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');toast('✅ Đã tạo rewrite! Đang tạo Short AI...');if(btn)btn.textContent='✅ Đang tạo Short AI...';if(j.post){j.post.slides=j.slides||[];prependWallPost(j.post);}if(j.slides&&j.slides.length){let slideDiv=document.createElement('div');slideDiv.style.cssText='margin:8px 4px;background:#141414;border:1px solid #2b2b2b;border-radius:10px;padding:10px';let sh='<div style="font-size:13px;color:#5cb87a;font-weight:800;margin-bottom:8px">📊 Slides đã tạo ('+j.slides.length+' ý)</div>';j.slides.forEach(function(s){sh+='<div style="padding:6px 0;border-bottom:1px solid #222;font-size:11px;color:#ccc;line-height:1.4">• '+esc(s.text.slice(0,120))+'</div>';});slideDiv.innerHTML=sh;const target=document.getElementById('hashtag-box');if(target)target.parentNode.insertBefore(slideDiv,target.nextSibling);}if(btn)btn.textContent='✅ Hoàn tất!';if(j.post&&j.post.id){setTimeout(async function(){try{const sr=await fetch('/api/ai/short/'+encodeURIComponent(j.post.id),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({voice:'vi-VN-HoaiMyNeural',emotion:'neutral',speed:1.2})});const sj=await sr.json();if(sj&&sj.video){toast('✅ Đã tạo Short AI!');const p=_wallPosts.find(x=>String(x.id)===String(j.post.id));if(p){p.video=sj.video;const idx=_wallPosts.indexOf(p);const itemId='wall-item-'+j.post.id;const el=document.getElementById(itemId);if(el){el.insertAdjacentHTML('afterend',makeWallItem(p,idx));el.remove();}}}else{toast('✅ Rewrite xong! Chưa tạo được Short, thử lại sau.')}}catch(e){toast('✅ Rewrite xong! Chưa tạo được Short.')}},1000);}setTimeout(function(){if(btn){btn.disabled=false;btn.textContent='Rewrite';}},5000);}catch(e){toast('❌ '+e.message);if(btn){btn.disabled=false;btn.textContent='Rewrite';}}}
392
-
393
  async function loadLivescore(tab){document.querySelectorAll('.ls-tab').forEach(t=>t.classList.remove('active'));document.querySelector(`.ls-tab[data-tab="${tab}"]`)?.classList.add('active');const el=document.getElementById('ls-content');if(!el)return;el.innerHTML='<div class="loading">Đang tải...</div>';let ep='/api/livescore/'+tab;if(tab.startsWith('bxh_'))ep='/api/livescore/standings/'+tab.replace('bxh_','');try{const r=await fetch(ep);const d=await r.json();el.innerHTML=d.html&&d.html.length>50?d.html:'<div class="loading">Không có dữ liệu</div>';bindMatchClicks(el);}catch(e){el.innerHTML='<div class="loading">Lỗi</div>';}}
394
  function bindMatchClicks(el){el.querySelectorAll('.match-detail').forEach(md=>{md.style.cursor='pointer';md.addEventListener('click',function(e){const statusA=this.querySelector('.status a');const teamA=this.querySelector('.teams a[href*="/tran-dau/"]');const a = statusA || teamA;if(a){e.preventDefault();e.stopPropagation();const href=a.getAttribute('href')||'';const m=href.match(/\/tran-dau\/(\d+)\//);if(m){const fullUrl=href.startsWith('http')?href:'https://bongda.com.vn'+href;openMatch(m[1],fullUrl);}}});});el.querySelectorAll('a').forEach(a=>{a.addEventListener('click',e=>{e.preventDefault();e.stopPropagation()});});}
395
  function openMatch(id,url){if(!id)return;_currentEventId=id;if(url)_currentMatchUrl=url;document.getElementById('match-overlay').classList.add('active');document.body.style.overflow='hidden';loadMatchTab('detail')}
@@ -460,9 +455,9 @@ function readWallPost(idx){const p=_wallPosts&&_wallPosts[idx];if(!p)return;if(p
460
  /** Show rewrite slide viewer - vertical slides with text+image */
461
  function readSlidePost(idx){const p=_wallPosts[idx];if(!p||!p.slides)return;showView('view-article');const el=document.getElementById('view-article');let h=`<button class="back-btn" onclick="switchCat('home')">← Tường AI</button><div class="slide-viewer" style="padding:12px;max-width:600px;margin:0 auto">`;p.slides.forEach((s,i)=>{h+=`<div class="slide-card" style="background:#1a1a1a;border:1px solid #2a2a2a;border-radius:12px;padding:16px;margin-bottom:12px"><div class="slide-num" style="color:#5cb87a;font-size:12px;font-weight:700;margin-bottom:6px">Slide ${s.index||i+1}/${p.slides.length}</div>${s.image?`<img src="${_proxyImg(s.image)}" style="width:100%;max-height:300px;object-fit:cover;border-radius:8px;margin-bottom:8px" loading="lazy" onerror="this.style.display=\'none\'">`:''}<p style="color:#ddd;font-size:14px;line-height:1.6;margin:0">${esc(s.text)}</p></div>`;});h+=`</div><div class="article-actions" style="padding:12px;text-align:center"><button onclick="doShare('${esc(p.title)}','${esc(p.url||'')}','${esc(p.img||'')}','${esc(p.id||'')}')" style="background:#2d8659;border:0;color:#fff;padding:8px 16px;border-radius:10px;cursor:pointer;font-size:12px">📤 Chia sẻ bài viết này</button></div>`;el.innerHTML=h;}
462
  function readNewsTab(tab){loadNewsTab();}
463
- function loadNewsTab(){const el=document.getElementById('view-cat');if(!el)return;el.innerHTML='<div class="loading">Đang tải tin tức...</div>';fetch('/api/homepage').then(r=>r.json()).then(articles=>{if(!articles||!articles.length){el.innerHTML='<div class="loading">Không có tin</div>';return}let h='<div class="grid">';articles.forEach(a=>{const src=a.source||'vne';const badge=a.group||a.source||'';h+=`<div class="card" onclick="readArticle('${esc(a.link)}')"><div class="card-img">${a.img?`<img src="${_proxyImg(a.img)}" loading="lazy" onerror="this.style.display='none'">`:''}</div><div class="card-body"><span class="badge badge-${src}">${esc(badge)}</span><div class="card-title">${esc(a.title)}</div></div></div>`;});h+='</div>';el.innerHTML=h;}).catch(()=>{el.innerHTML='<div class="loading">Lỗi tải</div>';});}
464
 
465
- function readArticle(url,title,img,presetTitle,presetText){showView('view-article');const el=document.getElementById('view-article');el.innerHTML='<div class="loading">Đang tải...</div><button class="back-btn" onclick="switchCat(\'home\')">← Quay lại</button>';if(presetTitle){el.innerHTML=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><h1 class="article-title">${esc(presetTitle)}</h1>${presetText?`<div class="article-summary" style="white-space:pre-wrap">${esc(presetText)}</div>`:''}</div><div class="article-actions"><button onclick="doShare('${esc(presetTitle)}','${esc(url||'')}','')">📤 Chia sẻ</button></div>`;return;}if(!url)return;fetch('/api/article?url='+encodeURIComponent(url)).then(r=>r.json()).then(d=>{let h=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view">`;if(d.title)h+=`<h1 class="article-title">${esc(d.title)}</h1>`;if(d.summary)h+=`<div class="article-summary">${esc(d.summary)}</div>`;if(d.body)d.body.forEach(b=>{if(b.type==='p')h+=`<p class="article-p">${esc(b.text)}</p>`;else if(b.type==='heading')h+=`<h2 class="article-h2">${esc(b.text)}</h2>`;else if(b.type==='img'&&b.src)h+=`<img class="article-img" src="${_proxyImg(b.src)}" loading="lazy" onerror="this.style.display='none'">`;});h+=`</div><div class="article-actions"><button onclick="doShare('${esc(d.title||'')}','${esc(url)}','${esc(d.og_image||'')}')">📤 Chia sẻ</button><button class="primary" onclick="rewriteSlide('${esc(url)}')">🤖 Slide Rewrite AI</button></div>`;el.innerHTML=h;}).catch(()=>{el.innerHTML=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><p>Không thể tải bài viết</p></div>`;});}
466
  async function rewriteSlide(url){if(!url)return;const btn=document.querySelector('.article-actions .primary')||event?.target;if(btn){btn.disabled=true;btn.textContent='⏳ Đang tạo slides...';}toast('⏳ Đang tạo slide rewrite...');try{const r=await fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');toast('✅ Đã tạo slide! Đang tạo Short AI...');if(btn)btn.textContent='✅ Đang tạo Short AI...';if(j.post){j.post.slides=j.slides||[];prependWallPost(j.post);}// Show slides immediately if available
467
  if(j.slides && j.slides.length){showView('view-article');const el=document.getElementById('view-article');let h=`<button class="back-btn" onclick="switchCat('home')">← Tường AI</button><div class="slide-viewer" style="padding:12px;max-width:600px;margin:0 auto">`;j.slides.forEach(s=>{h+=`<div class="slide-card" style="background:#1a1a1a;border:1px solid #2a2a2a;border-radius:12px;padding:16px;margin-bottom:12px"><div class="slide-num" style="color:#5cb87a;font-size:12px;font-weight:700;margin-bottom:6px">Slide ${s.index}/${j.slides.length}</div>${s.image?`<img src="${s.image.startsWith('/api/')?s.image:'/api/proxy/img?url='+encodeURIComponent(s.image)}" style="width:100%;max-height:300px;object-fit:cover;border-radius:8px;margin-bottom:8px" loading="lazy" onerror="this.style.display='none'">`:''}<p style="color:#ddd;font-size:14px;line-height:1.6;margin:0">${esc(s.text)}</p></div>`;});h+=`</div><div style="text-align:center;padding:12px"><div id="short-ai-status" style="color:#888;font-size:12px">⏳ Đang tạo video Short AI...</div></div>`;el.innerHTML=h;}
468
  const postId=j.post&&j.post.id;if(postId){setTimeout(async()=>{const sr=await fetch('/api/ai/short/'+encodeURIComponent(postId),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({voice:'vi-VN-HoaiMyNeural',emotion:'neutral',speed:1.2})});const sj=await sr.json();if(sr.ok&&sj.video&&typeof _wallPosts!=='undefined'){const p=_wallPosts.find(x=>String(x.id)===String(postId));if(p){p.video=sj.video;const itemId='wall-item-'+postId;const el2=document.getElementById(itemId);if(el2){const idx=_wallPosts.indexOf(p);el2.insertAdjacentHTML('afterend',makeWallItem(p,idx));el2.remove();}}toast('✅ Short AI đã sẵn sàng!');const statusEl=document.getElementById('short-ai-status');if(statusEl)statusEl.innerHTML='✅ Short AI đẵn sàng! <button class="primary" onclick="openShortAIFeed(0)" style="background:#2d8659;border:0;color:#fff;padding:6px 12px;border-radius:10px;font-size:11px;cursor:pointer;margin-left:8px">▶ Xem ngay</button>';}},500);}if(btn)btn.textContent='✅ Hoàn tất';}catch(e){toast('❌ '+e.message);if(btn){btn.disabled=false;btn.textContent='🤖 Slide Rewrite AI';}}}
 
5
  * readSlidePost has share button, /s + /s/{slug} render slides/video inline
6
  * v2.5 - Added 'Tạo lại' button on wall cards with video
7
  * v2.4 - Fixed: prependWallPost detached-element bug, makeShortVideo UI update, slide viewer for rewrite posts
 
8
  */
9
  function _proxyImg(url){
10
  if(!url || typeof url !== 'string') return '';
 
146
  +'</select>'
147
  +'</div>';
148
  var makeBtn = hasVideo
149
+ ? '<button class="wall-btn-video" onclick="event.stopPropagation();openShortAIFeed('+i+')">▶ Xem Short</button><button class="wall-btn-make" style="margin-left:4px" onclick="event.stopPropagation();makeShortVideo(\''+esc(vid)+'\',this,_ttsSelections[\'inline-'+esc(vid)+'\'].voice,parseFloat(document.querySelector(\'.wss[data-selkey=inline-'+esc(vid)+']\').value)||1.2,_ttsSelections[\'inline-'+esc(vid)+'\'].emotion)">🎬 Tạo lại</button>'
150
+ : '<button class="wall-btn-make" onclick="event.stopPropagation();makeShortVideo(\''+esc(vid)+'\',this,_ttsSelections[\'inline-'+esc(vid)+'\'].voice,parseFloat(document.querySelector(\'.wss[data-selkey=inline-'+esc(vid)+']\').value)||1.2,_ttsSelections[\'inline-'+esc(vid)+'\'].emotion)">🎬 Tạo Short</button>';
151
  return '<div class="wall-item" id="wall-item-'+esc(vid)+'"><div class="wall-thumb">'+thumbContent+videoBadge+'</div><div class="wall-title">'+esc(p.title)+'</div><div class="wall-text">'+esc((p.text||'').slice(0,180))+'</div>'+voiceBar+'<div class="wall-actions"><button class="primary" onclick="readWallPost('+i+')">Xem</button>'+makeBtn+'</div></div>';
152
  }
153
 
 
385
  async function showHashtagSources(topic,page){const box=document.getElementById('hashtag-box');if(!box)return;if(page===0)box.innerHTML=`<div class="hashtag-sources"><h3>🔍 ${esc(topic)}</h3><div class="hashtag-loading"><div class="hashtag-spinner"></div>Đang tìm...</div></div>`;try{const r=await fetch(`/api/hashtag/sources?topic=${encodeURIComponent(topic)}&page=${page}`);const j=await r.json();const sources=j.sources||[];if(!sources.length&&page===0){box.innerHTML=`<div class="hashtag-sources"><h3>🔍 ${esc(topic)}</h3><div style="color:#888;padding:8px">Không tìm được bài viết liên quan</div></div>`;return;}let h='';if(page===0)h=`<div class="hashtag-sources"><h3>🔍 ${esc(topic)} <span style="font-size:10px;color:#888">(${j.total} bài từ 8 nguồn)</span></h3><div id="ht-list">`;sources.forEach((s,i)=>{const idx=page*8+i;h+=`<div class="hashtag-src-item" onclick="readArticle('${esc(s.url)}')"><div class="hashtag-src-img" id="ht-img-${idx}"></div><div class="hashtag-src-text"><div class="hashtag-src-title">${esc(s.title)}</div><div class="hashtag-src-via">${esc(s.via||'')}</div></div></div>`;});if(page===0){h+=`</div><button class="hashtag-rewrite-btn" onclick="rewriteHashtag('${esc(topic).replace(/'/g,"\\'")}')">🤖 Rewrite AI tổng hợp & đăng tường</button>`;if(j.has_more)h+=`<button class="hashtag-load-more" id="ht-more" onclick="loadMoreHashtag()">Tải thêm ▼</button>`;h+=`</div>`;box.innerHTML=h;}else{document.getElementById('ht-list')?.insertAdjacentHTML('beforeend',h);const btn=document.getElementById('ht-more');if(btn){if(!j.has_more)btn.remove();else{btn.disabled=false;btn.textContent='Tải thêm ▼';}}}sources.forEach((s,i)=>{const idx=page*8+i;if(!s.url)return;const ctrl=new AbortController();setTimeout(()=>ctrl.abort(),4000);fetch('/api/article?url='+encodeURIComponent(s.url),{signal:ctrl.signal}).then(r=>r.json()).then(d=>{if(d&&(d.og_image||d.img)){const el=document.getElementById('ht-img-'+idx);if(el)el.innerHTML=`<img src="${_proxyImg(esc(d.og_image||d.img))}" onerror="this.style.display='none'">`;}}).catch(()=>{});});}catch(e){box.innerHTML=`<div class="hashtag-sources"><h3>🔍 ${esc(topic)}</h3><div style="color:#e74c3c;padding:8px">Lỗi: ${esc(e.message)}</div></div>`;}}
386
  function loadMoreHashtag(){_htPage++;const btn=document.getElementById('ht-more');if(btn){btn.disabled=true;btn.textContent='Đang tải...';}showHashtagSources(_htTopic,_htPage);}
387
  async function rewriteHashtag(topic){const btn=event?.target;if(btn){btn.disabled=true;btn.textContent='Đang tổng hợp...';}try{const r=await fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');toast('✅ Đã đăng Tường AI!');if(btn)btn.textContent='✅ Đăng thành công!';if(j.post)prependWallPost(j.post);}catch(e){toast('❌ '+e.message);if(btn){btn.disabled=false;btn.textContent='🤖 Rewrite AI';}}}
 
 
 
 
388
  async function loadLivescore(tab){document.querySelectorAll('.ls-tab').forEach(t=>t.classList.remove('active'));document.querySelector(`.ls-tab[data-tab="${tab}"]`)?.classList.add('active');const el=document.getElementById('ls-content');if(!el)return;el.innerHTML='<div class="loading">Đang tải...</div>';let ep='/api/livescore/'+tab;if(tab.startsWith('bxh_'))ep='/api/livescore/standings/'+tab.replace('bxh_','');try{const r=await fetch(ep);const d=await r.json();el.innerHTML=d.html&&d.html.length>50?d.html:'<div class="loading">Không có dữ liệu</div>';bindMatchClicks(el);}catch(e){el.innerHTML='<div class="loading">Lỗi</div>';}}
389
  function bindMatchClicks(el){el.querySelectorAll('.match-detail').forEach(md=>{md.style.cursor='pointer';md.addEventListener('click',function(e){const statusA=this.querySelector('.status a');const teamA=this.querySelector('.teams a[href*="/tran-dau/"]');const a = statusA || teamA;if(a){e.preventDefault();e.stopPropagation();const href=a.getAttribute('href')||'';const m=href.match(/\/tran-dau\/(\d+)\//);if(m){const fullUrl=href.startsWith('http')?href:'https://bongda.com.vn'+href;openMatch(m[1],fullUrl);}}});});el.querySelectorAll('a').forEach(a=>{a.addEventListener('click',e=>{e.preventDefault();e.stopPropagation()});});}
390
  function openMatch(id,url){if(!id)return;_currentEventId=id;if(url)_currentMatchUrl=url;document.getElementById('match-overlay').classList.add('active');document.body.style.overflow='hidden';loadMatchTab('detail')}
 
455
  /** Show rewrite slide viewer - vertical slides with text+image */
456
  function readSlidePost(idx){const p=_wallPosts[idx];if(!p||!p.slides)return;showView('view-article');const el=document.getElementById('view-article');let h=`<button class="back-btn" onclick="switchCat('home')">← Tường AI</button><div class="slide-viewer" style="padding:12px;max-width:600px;margin:0 auto">`;p.slides.forEach((s,i)=>{h+=`<div class="slide-card" style="background:#1a1a1a;border:1px solid #2a2a2a;border-radius:12px;padding:16px;margin-bottom:12px"><div class="slide-num" style="color:#5cb87a;font-size:12px;font-weight:700;margin-bottom:6px">Slide ${s.index||i+1}/${p.slides.length}</div>${s.image?`<img src="${_proxyImg(s.image)}" style="width:100%;max-height:300px;object-fit:cover;border-radius:8px;margin-bottom:8px" loading="lazy" onerror="this.style.display=\'none\'">`:''}<p style="color:#ddd;font-size:14px;line-height:1.6;margin:0">${esc(s.text)}</p></div>`;});h+=`</div><div class="article-actions" style="padding:12px;text-align:center"><button onclick="doShare('${esc(p.title)}','${esc(p.url||'')}','${esc(p.img||'')}','${esc(p.id||'')}')" style="background:#2d8659;border:0;color:#fff;padding:8px 16px;border-radius:10px;cursor:pointer;font-size:12px">📤 Chia sẻ bài viết này</button></div>`;el.innerHTML=h;}
457
  function readNewsTab(tab){loadNewsTab();}
458
+ function loadNewsTab(){const el=document.getElementById('view-cat');if(!el)return;el.innerHTML='<div class="loading">Đang tải tin tức...</div>';fetch('/api/homepage').then(r=>r.json()).then(articles=>{if(!articles||!articles.length){el.innerHTML='<div class="loading">Không có tin</div>';return}let h='<div class="grid">';articles.forEach(a=>{const src=a.source||'vne';const badge=a.group||a.source||'';h+=`<div class="card" onclick="readArticle('${esc(a.link)}')"><div class="card-img">${a.img?`<img src="${_proxyImg(a.img)}" loading="lazy" onerror="this.style.display=\'none\'">`:''}</div><div class="card-body"><span class="badge badge-${src}">${esc(badge)}</span><div class="card-title">${esc(a.title)}</div></div></div>`;});h+='</div>';el.innerHTML=h;}).catch(()=>{el.innerHTML='<div class="loading">Lỗi tải</div>';});}
459
 
460
+ function readArticle(url,title,img,presetTitle,presetText){showView('view-article');const el=document.getElementById('view-article');el.innerHTML='<div class="loading">Đang tải...</div><button class="back-btn" onclick="switchCat(\'home\')">← Quay lại</button>';if(presetTitle){el.innerHTML=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><h1 class="article-title">${esc(presetTitle)}</h1>${presetText?`<div class="article-summary" style="white-space:pre-wrap">${esc(presetText)}</div>`:''}</div><div class="article-actions"><button onclick="doShare('${esc(presetTitle)}','${esc(url||'')}','')">📤 Chia sẻ</button></div>`;return;}if(!url)return;fetch('/api/article?url='+encodeURIComponent(url)).then(r=>r.json()).then(d=>{let h=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view">`;if(d.title)h+=`<h1 class="article-title">${esc(d.title)}</h1>`;if(d.summary)h+=`<div class="article-summary">${esc(d.summary)}</div>`;if(d.body)d.body.forEach(b=>{if(b.type==='p')h+=`<p class="article-p">${esc(b.text)}</p>`;else if(b.type==='heading')h+=`<h2 class="article-h2">${esc(b.text)}</h2>`;else if(b.type==='img'&&b.src)h+=`<img class="article-img" src="${_proxyImg(b.src)}" loading="lazy" onerror="this.style.display=\'none\'">`;});h+=`</div><div class="article-actions"><button onclick="doShare('${esc(d.title||'')}','${esc(url)}','${esc(d.og_image||'')}')">📤 Chia sẻ</button><button class="primary" onclick="rewriteSlide('${esc(url)}')">🤖 Slide Rewrite AI</button></div>`;el.innerHTML=h;}).catch(()=>{el.innerHTML=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><p>Không thể tải bài viết</p></div>`;});}
461
  async function rewriteSlide(url){if(!url)return;const btn=document.querySelector('.article-actions .primary')||event?.target;if(btn){btn.disabled=true;btn.textContent='⏳ Đang tạo slides...';}toast('⏳ Đang tạo slide rewrite...');try{const r=await fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');toast('✅ Đã tạo slide! Đang tạo Short AI...');if(btn)btn.textContent='✅ Đang tạo Short AI...';if(j.post){j.post.slides=j.slides||[];prependWallPost(j.post);}// Show slides immediately if available
462
  if(j.slides && j.slides.length){showView('view-article');const el=document.getElementById('view-article');let h=`<button class="back-btn" onclick="switchCat('home')">← Tường AI</button><div class="slide-viewer" style="padding:12px;max-width:600px;margin:0 auto">`;j.slides.forEach(s=>{h+=`<div class="slide-card" style="background:#1a1a1a;border:1px solid #2a2a2a;border-radius:12px;padding:16px;margin-bottom:12px"><div class="slide-num" style="color:#5cb87a;font-size:12px;font-weight:700;margin-bottom:6px">Slide ${s.index}/${j.slides.length}</div>${s.image?`<img src="${s.image.startsWith('/api/')?s.image:'/api/proxy/img?url='+encodeURIComponent(s.image)}" style="width:100%;max-height:300px;object-fit:cover;border-radius:8px;margin-bottom:8px" loading="lazy" onerror="this.style.display='none'">`:''}<p style="color:#ddd;font-size:14px;line-height:1.6;margin:0">${esc(s.text)}</p></div>`;});h+=`</div><div style="text-align:center;padding:12px"><div id="short-ai-status" style="color:#888;font-size:12px">⏳ Đang tạo video Short AI...</div></div>`;el.innerHTML=h;}
463
  const postId=j.post&&j.post.id;if(postId){setTimeout(async()=>{const sr=await fetch('/api/ai/short/'+encodeURIComponent(postId),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({voice:'vi-VN-HoaiMyNeural',emotion:'neutral',speed:1.2})});const sj=await sr.json();if(sr.ok&&sj.video&&typeof _wallPosts!=='undefined'){const p=_wallPosts.find(x=>String(x.id)===String(postId));if(p){p.video=sj.video;const itemId='wall-item-'+postId;const el2=document.getElementById(itemId);if(el2){const idx=_wallPosts.indexOf(p);el2.insertAdjacentHTML('afterend',makeWallItem(p,idx));el2.remove();}}toast('✅ Short AI đã sẵn sàng!');const statusEl=document.getElementById('short-ai-status');if(statusEl)statusEl.innerHTML='✅ Short AI đẵn sàng! <button class="primary" onclick="openShortAIFeed(0)" style="background:#2d8659;border:0;color:#fff;padding:6px 12px;border-radius:10px;font-size:11px;cursor:pointer;margin-left:8px">▶ Xem ngay</button>';}},500);}if(btn)btn.textContent='✅ Hoàn tất';}catch(e){toast('❌ '+e.message);if(btn){btn.disabled=false;btn.textContent='🤖 Slide Rewrite AI';}}}
static/index_v2.html CHANGED
@@ -41,6 +41,7 @@ function doShare(title,url,img,postId){
41
  } else {
42
  shareUrl = SPACE+'/s?url='+encodeURIComponent(url)+'&title='+encodeURIComponent(title)+'&img='+encodeURIComponent(img||'');
43
  }
 
44
  if(navigator.clipboard && navigator.clipboard.writeText){
45
  navigator.clipboard.writeText(shareUrl).then(function(){
46
  toast('📋 Đã sao chép link!');
@@ -67,7 +68,7 @@ function doShare(title,url,img,postId){
67
  async function init(){_cats=await fetch('/api/categories').then(r=>r.json()).catch(()=>[]);let bar='<div class="cat active" data-cat="home">🏠</div><div class="cat" data-cat="news-all">📰 Tin tức</div>';_cats.forEach(c=>{bar+='<div class="cat" data-cat="'+c.id+'">'+c.name+'</div>'});document.getElementById('cat-bar').innerHTML=bar;document.querySelectorAll('.cat').forEach(t=>{t.onclick=()=>switchCat(t.dataset.cat)});}
68
  var SPACE=location.origin;
69
  </script>
70
- <script src="/static/app_v2.js?v=20260714"></script>
71
  <script src="/static/yt_live.js"></script>
72
  <script src="/static/vtv_init.js"></script>
73
  <script src="/static/hot_multi.js?v=1"></script>
 
41
  } else {
42
  shareUrl = SPACE+'/s?url='+encodeURIComponent(url)+'&title='+encodeURIComponent(title)+'&img='+encodeURIComponent(img||'');
43
  }
44
+ // Use clipboard API first (modern, works on Chrome mobile 85+)
45
  if(navigator.clipboard && navigator.clipboard.writeText){
46
  navigator.clipboard.writeText(shareUrl).then(function(){
47
  toast('📋 Đã sao chép link!');
 
68
  async function init(){_cats=await fetch('/api/categories').then(r=>r.json()).catch(()=>[]);let bar='<div class="cat active" data-cat="home">🏠</div><div class="cat" data-cat="news-all">📰 Tin tức</div>';_cats.forEach(c=>{bar+='<div class="cat" data-cat="'+c.id+'">'+c.name+'</div>'});document.getElementById('cat-bar').innerHTML=bar;document.querySelectorAll('.cat').forEach(t=>{t.onclick=()=>switchCat(t.dataset.cat)});}
69
  var SPACE=location.origin;
70
  </script>
71
+ <script src="/static/app_v2.js?v=20260711"></script>
72
  <script src="/static/yt_live.js"></script>
73
  <script src="/static/vtv_init.js"></script>
74
  <script src="/static/hot_multi.js?v=1"></script>