bep40 commited on
Commit
e62d653
·
verified ·
1 Parent(s): 259d6f1

Upload app_v2_entry.py

Browse files
Files changed (1) hide show
  1. app_v2_entry.py +12 -1078
app_v2_entry.py CHANGED
@@ -1,4 +1,4 @@
1
- """VNEWS v2 Entry Point - with fast bongda proxy + rewrite endpoints + multilingual TTS"""
2
  import sys, os
3
  from main import app, HEADERS, BONGDA_HEADERS, fetch_bongda_api, HL_LEAGUES
4
 
@@ -12,6 +12,13 @@ try:
12
  except Exception as e:
13
  print(f"[WARN] ai_patch import failed: {e}")
14
 
 
 
 
 
 
 
 
15
  from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, Response
16
  from fastapi.staticfiles import StaticFiles
17
  from starlette.routing import Mount
@@ -26,17 +33,15 @@ import asyncio
26
  HL_LEAGUES['friendly'] = {"path": "giai-khac/friendly", "name": "Giao hữu", "emoji": "🤝"}
27
 
28
  STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static')
29
- SPACE = "https://bep40-vnews.hf.space" # SEO URL base for share links
30
  app.router.routes=[r for r in app.router.routes if not(getattr(r,'path',None)=='/' and hasattr(r,'methods') and 'GET' in getattr(r,'methods',set()))]
31
  app.routes[:]=[r for r in app.routes if not isinstance(r, Mount)]
32
  app.router.routes=[r for r in app.router.routes if not isinstance(r, Mount)]
33
 
34
  def _clean(s): return re.sub(r"\s+"," ",html_lib.unescape(str(s or""))).strip()
35
 
36
- # Cache for match details (5 min TTL)
37
  _match_cache = {}
38
 
39
- # === FAST BONGDA PROXY ENDPOINT ===
40
  def _get_match_detail(event_id, slug=None):
41
  headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "text/html", "Referer": "https://bongda.com.vn/"}
42
  if slug:
@@ -508,18 +513,13 @@ 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
- # 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,15 +552,11 @@ def _render_slides_page(post, safe_title, safe_img, safe_url):
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,17 +588,12 @@ video{{width:100%;height:100%;max-height:100vh;object-fit:contain;background:#00
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 slug matches a wall post ID, 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 slug (post ID)
603
  post = None
604
  try:
605
- if slug and len(slug) > 5: # Likely a post ID
606
  posts = _load_wall_posts()
607
  for p in posts:
608
  if p.get('id') == slug:
@@ -613,14 +604,10 @@ async def _sh_slug(slug: str, request: Request, url: str = '', title: str = '',
613
  break
614
  except:
615
  pass
616
-
617
  if post and post.get('slides'):
618
  return _render_slides_page(post, safe_title, safe_img, safe_url)
619
-
620
  if post and post.get('video'):
621
  return _render_video_page(post, safe_title, safe_img, safe_url)
622
-
623
- # Otherwise redirect
624
  return HTMLResponse(f'''<!DOCTYPE html>
625
  <html lang="vi">
626
  <head>
@@ -640,8 +627,6 @@ async def _sh(url:str='',title:str='',img:str='',post_id:str=''):
640
  safe_title = _clean(title) if title else 'VNEWS - Tin tức'
641
  safe_img = _clean(img) if img else ''
642
  safe_url = _clean(url) if url else '/'
643
-
644
- # Try to find wall post by post_id or URL (prioritize posts with slides/video)
645
  post = None
646
  try:
647
  posts = _load_wall_posts()
@@ -654,7 +639,6 @@ async def _sh(url:str='',title:str='',img:str='',post_id:str=''):
654
  safe_url = p.get('url', safe_url) or safe_url
655
  break
656
  elif url:
657
- # Find matching URL - prioritize posts with slides or video
658
  for p in posts:
659
  if p.get('url') == url and p.get('slides'):
660
  post = p
@@ -663,7 +647,6 @@ async def _sh(url:str='',title:str='',img:str='',post_id:str=''):
663
  safe_url = p.get('url', safe_url) or safe_url
664
  break
665
  if not post:
666
- # Fallback: find any matching URL
667
  for p in posts:
668
  if p.get('url') == url:
669
  post = p
@@ -673,14 +656,10 @@ async def _sh(url:str='',title:str='',img:str='',post_id:str=''):
673
  break
674
  except:
675
  pass
676
-
677
  if post and post.get('slides'):
678
  return _render_slides_page(post, safe_title, safe_img, safe_url)
679
-
680
  if post and post.get('video'):
681
  return _render_video_page(post, safe_title, safe_img, safe_url)
682
-
683
- # Fallback: redirect to original URL
684
  return HTMLResponse(f'''<!DOCTYPE html>
685
  <html lang="vi">
686
  <head>
@@ -966,7 +945,6 @@ from urllib.parse import quote as _quote2
966
 
967
  _UA_RW = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept-Language': 'vi-VN,vi;q=0.9'}
968
 
969
- # Unique character markers for language detection
970
  _UNIQUE_CHARS = {
971
  'vietnamese': set('đăâêôơưàảãạáằẳẵặắầẩẫậấèẻẽẹéềễểệếìỉĩịíòỏõọóồổỗộốờởỡợớùủũụúừửữựứỳỷỹỵý'),
972
  'spanish': set('ñáéíóúü¿¡'),
@@ -981,1075 +959,31 @@ _STOPWORDS = {
981
  }
982
 
983
  def detect_language(text):
984
- """Detect language from text content using stopword + character analysis."""
985
  if not text:
986
  return 'vietnamese'
987
  text_lower = text.lower()
988
  text_chars = set(text_lower)
989
-
990
- # Strong signal: Vietnamese unique characters
991
  vn_chars = len(text_chars & _UNIQUE_CHARS['vietnamese'])
992
  if vn_chars >= 2:
993
  return 'vietnamese'
994
-
995
- # Spanish unique chars (ñ, ¿, ¡)
996
  es_chars = len(text_chars & _UNIQUE_CHARS['spanish'])
997
  pt_chars = len(text_chars & _UNIQUE_CHARS['portuguese'])
998
-
999
- # Stopword scoring
1000
  words = set(re.findall(r'\b\w+\b', text_lower))
1001
  scores = {}
1002
  for lang, stops in _STOPWORDS.items():
1003
  scores[lang] = len(words & stops) / max(len(stops), 1)
1004
-
1005
- # Disambiguate Portuguese vs Spanish
1006
  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'}
1007
  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'}
1008
-
1009
  pt_overlap = len(words & pt_markers)
1010
  es_overlap = len(words & es_markers)
1011
-
1012
  if scores.get('portuguese', 0) > 0 and pt_overlap > es_overlap:
1013
  return 'portuguese'
1014
  if scores.get('spanish', 0) > 0 and es_overlap > pt_overlap:
1015
  return 'spanish'
1016
  if scores.get('english', 0) > 0.15:
1017
  return 'english'
1018
-
1019
  best = max(scores, key=scores.get)
1020
  return best if scores[best] > 0.05 else 'vietnamese'
1021
 
1022
- # Emotion keyword-based detection
1023
- _EMOTION_KEYWORDS = {
1024
- 'happy': {
1025
- 'en': ['happy', 'joy', 'wonderful', 'great', 'amazing', 'fantastic', 'love', 'excellent', 'beautiful', 'glad', 'delighted', 'pleased', 'cheerful', 'celebrate', 'victory', 'win', 'success'],
1026
- 'pt': ['feliz', 'alegria', 'maravilhoso', 'ótimo', 'incrível', 'fantástico', 'amor', 'excelente', 'lindo', 'contente', 'encantado', 'vitória', 'sucesso'],
1027
- 'es': ['feliz', 'alegria', 'maravilloso', 'genial', 'increíble', 'fantástico', 'amor', 'excelente', 'hermoso', 'contento', 'encantado', 'victoria', 'éxito'],
1028
- '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'],
1029
- },
1030
- 'sad': {
1031
- 'en': ['sad', 'unhappy', 'terrible', 'awful', 'horrible', 'miserable', 'depressed', 'grief', 'sorrow', 'tragic', 'unfortunate', 'painful', 'death', 'die', 'kill'],
1032
- 'pt': ['triste', 'infeliz', 'terrível', 'horrível', 'miserável', 'deprimido', 'dor', 'trágico', 'infelizmente', 'penoso', 'morte', 'morrer'],
1033
- 'es': ['triste', 'infeliz', 'terrible', 'horrible', 'miserable', 'deprimido', 'dolor', 'trágico', 'desafortunado', 'penoso', 'muerte', 'morir'],
1034
- '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'],
1035
- },
1036
- 'excited': {
1037
- 'en': ['excited', 'thrilling', 'amazing', 'wow', 'incredible', 'unbelievable', 'awesome', 'exhilarating', 'electrifying', 'breathtaking', 'breakthrough', 'record'],
1038
- 'pt': ['animado', 'emocionante', 'incrível', 'impressionante', 'sensacional', 'eletrizante', 'empolgante', 'recorde'],
1039
- 'es': ['emocionante', 'increíble', 'impresionante', 'sensacional', 'electrizante', 'emocionado', 'entusiasmado', 'récord'],
1040
- '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á'],
1041
- },
1042
- 'humorous': {
1043
- 'en': ['funny', 'hilarious', 'joke', 'laugh', 'comedy', 'humor', 'amusing', 'witty', 'sarcastic', 'ironic', 'ridiculous', 'absurd', 'lol', 'haha'],
1044
- 'pt': ['engraçado', 'hilário', 'piada', 'rir', 'comédia', 'humor', 'divertido', 'irônico', 'ridículo', 'absurdo', 'kkk'],
1045
- 'es': ['gracioso', 'hilarante', 'broma', 'risa', 'comedia', 'humor', 'divertido', 'irónico', 'ridículo', 'absurdo', 'jaja'],
1046
- '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'],
1047
- },
1048
- 'serious': {
1049
- 'en': ['serious', 'critical', 'important', 'urgent', 'severe', 'grave', 'significant', 'crucial', 'vital', 'essential', 'alarming', 'concerning', 'crisis', 'war', 'conflict'],
1050
- 'pt': ['sério', 'crítico', 'importante', 'urgente', 'grave', 'significativo', 'crucial', 'vital', 'essencial', 'preocupante', 'crise', 'guerra', 'conflito'],
1051
- 'es': ['serio', 'crítico', 'importante', 'urgente', 'grave', 'significativo', 'crucial', 'vital', 'esencial', 'preocupante', 'crisis', 'guerra', 'conflicto'],
1052
- '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'],
1053
- },
1054
- }
1055
-
1056
- def detect_emotion(text, language='vietnamese'):
1057
- """Detect emotion from text using keyword matching."""
1058
- if not text:
1059
- return 'neutral'
1060
- text_lower = text.lower()
1061
-
1062
- scores = {}
1063
- for emotion, lang_keywords in _EMOTION_KEYWORDS.items():
1064
- keywords = lang_keywords.get(language, lang_keywords.get('en', []))
1065
- score = sum(1 for kw in keywords if kw in text_lower)
1066
- scores[emotion] = score
1067
-
1068
- if max(scores.values()) == 0:
1069
- return 'neutral'
1070
-
1071
- return max(scores, key=scores.get)
1072
-
1073
- def detect_language_and_emotion(title, text):
1074
- """Detect both language and emotion from article content."""
1075
- combined = f"{title} {text}"
1076
- lang = detect_language(combined)
1077
- emotion = detect_emotion(combined, lang)
1078
- return lang, emotion
1079
-
1080
- # Voice selection based on language and emotion (using MultilingualNeural voices)
1081
- VOICE_BY_LANG_EMOTION = {
1082
- 'vietnamese': {
1083
- 'happy': ('vi-VN-HoaiMyNeural', 'vui'),
1084
- 'sad': ('vi-VN-NamMinhNeural', 'buồn'),
1085
- 'excited': ('vi-VN-HoaiMyNeural', 'hào hứng'),
1086
- 'humorous': ('vi-VN-HoaiMyNeural', 'vui'),
1087
- 'serious': ('vi-VN-NamMinhNeural', 'nghiêm túc'),
1088
- 'neutral': ('vi-VN-HoaiMyNeural', 'trung_tinh'),
1089
- },
1090
- 'portuguese': {
1091
- 'happy': ('pt-BR-ThalitaMultilingualNeural', 'feliz'),
1092
- 'sad': ('pt-BR-ThalitaMultilingualNeural', 'triste'),
1093
- 'excited': ('pt-BR-ThalitaMultilingualNeural', 'animado'),
1094
- 'humorous': ('pt-BR-ThalitaMultilingualNeural', 'engraçado'),
1095
- 'serious': ('pt-BR-ThalitaMultilingualNeural', 'sério'),
1096
- 'neutral': ('pt-BR-ThalitaMultilingualNeural', 'neutro'),
1097
- },
1098
- 'english': {
1099
- 'happy': ('en-US-AndrewMultilingualNeural', 'happy'),
1100
- 'sad': ('en-AU-WilliamMultilingualNeural', 'sad'),
1101
- 'excited': ('en-US-AndrewMultilingualNeural', 'excited'),
1102
- 'humorous': ('en-US-AndrewMultilingualNeural', 'funny'),
1103
- 'serious': ('en-AU-WilliamMultilingualNeural', 'serious'),
1104
- 'neutral': ('en-US-AndrewMultilingualNeural', 'neutral'),
1105
- },
1106
- 'french': {
1107
- 'happy': ('fr-FR-VivienneMultilingualNeural', 'heureux'),
1108
- 'sad': ('fr-FR-RemyMultilingualNeural', 'triste'),
1109
- 'excited': ('fr-FR-VivienneMultilingualNeural', 'excité'),
1110
- 'humorous': ('fr-FR-VivienneMultilingualNeural', 'drôle'),
1111
- 'serious': ('fr-FR-RemyMultilingualNeural', 'sérieux'),
1112
- 'neutral': ('fr-FR-VivienneMultilingualNeural', 'neutre'),
1113
- },
1114
- 'german': {
1115
- 'happy': ('de-DE-SeraphinaMultilingualNeural', 'glücklich'),
1116
- 'sad': ('de-DE-FlorianMultilingualNeural', 'traurig'),
1117
- 'excited': ('de-DE-SeraphinaMultilingualNeural', 'aufgeregt'),
1118
- 'humorous': ('de-DE-SeraphinaMultilingualNeural', 'lustig'),
1119
- 'serious': ('de-DE-FlorianMultilingualNeural', 'ernst'),
1120
- 'neutral': ('de-DE-SeraphinaMultilingualNeural', 'neutral'),
1121
- },
1122
- 'korean': {
1123
- 'happy': ('ko-KR-HyunsuMultilingualNeural', '행복'),
1124
- 'sad': ('ko-KR-HyunsuMultilingualNeural', '슬픔'),
1125
- 'excited': ('ko-KR-HyunsuMultilingualNeural', '흥분'),
1126
- 'humorous': ('ko-KR-HyunsuMultilingualNeural', '유쾌'),
1127
- 'serious': ('ko-KR-HyunsuMultilingualNeural', '진지'),
1128
- 'neutral': ('ko-KR-HyunsuMultilingualNeural', '중립'),
1129
- },
1130
- 'italian': {
1131
- 'happy': ('it-IT-GiuseppeMultilingualNeural', 'felice'),
1132
- 'sad': ('it-IT-GiuseppeMultilingualNeural', 'triste'),
1133
- 'excited': ('it-IT-GiuseppeMultilingualNeural', 'emozionato'),
1134
- 'humorous': ('it-IT-GiuseppeMultilingualNeural', 'divertente'),
1135
- 'serious': ('it-IT-GiuseppeMultilingualNeural', 'serio'),
1136
- 'neutral': ('it-IT-GiuseppeMultilingualNeural', 'neutro'),
1137
- },
1138
- }
1139
-
1140
- # All valid voice IDs (new MultilingualNeural format)
1141
- VALID_VOICES = {
1142
- 'vi-VN-HoaiMyNeural', 'vi-VN-NamMinhNeural',
1143
- 'en-US-AndrewMultilingualNeural', 'en-AU-WilliamMultilingualNeural',
1144
- 'pt-BR-ThalitaMultilingualNeural',
1145
- 'fr-FR-VivienneMultilingualNeural', 'fr-FR-RemyMultilingualNeural',
1146
- 'de-DE-SeraphinaMultilingualNeural', 'de-DE-FlorianMultilingualNeural',
1147
- 'ko-KR-HyunsuMultilingualNeural',
1148
- 'it-IT-GiuseppeMultilingualNeural',
1149
- }
1150
-
1151
- def get_voice_for_content(title, text, preferred_voice=None):
1152
- """Get appropriate voice based on content language and emotion."""
1153
- # Accept the new MultilingualNeural voices directly
1154
- if preferred_voice and preferred_voice in VALID_VOICES:
1155
- return preferred_voice
1156
-
1157
- # Also accept old shorthand voice IDs and map them to new format
1158
- old_voice_map = {
1159
- 'hoaimy': 'vi-VN-HoaiMyNeural',
1160
- 'namminh': 'vi-VN-NamMinhNeural',
1161
- 'andrew': 'en-US-AndrewMultilingualNeural',
1162
- 'jenny': 'en-US-AndrewMultilingualNeural',
1163
- 'thalita': 'pt-BR-ThalitaMultilingualNeural',
1164
- 'pt_thalita': 'pt-BR-ThalitaMultilingualNeural',
1165
- 'pt_francisco': 'pt-BR-ThalitaMultilingualNeural',
1166
- 'ela': 'en-US-AndrewMultilingualNeural',
1167
- 'es_carlos': 'en-US-AndrewMultilingualNeural',
1168
- 'denise': 'fr-FR-VivienneMultilingualNeural',
1169
- 'katja': 'de-DE-SeraphinaMultilingualNeural',
1170
- 'nanami': 'en-US-AndrewMultilingualNeural',
1171
- 'sunhee': 'ko-KR-HyunsuMultilingualNeural',
1172
- 'xiaochen': 'en-US-AndrewMultilingualNeural',
1173
- }
1174
- if preferred_voice and preferred_voice in old_voice_map:
1175
- return old_voice_map[preferred_voice]
1176
-
1177
- lang, emotion = detect_language_and_emotion(title, text)
1178
- lang_map = VOICE_BY_LANG_EMOTION.get(lang, VOICE_BY_LANG_EMOTION['vietnamese'])
1179
- voice, _ = lang_map.get(emotion, lang_map['neutral'])
1180
- return voice
1181
-
1182
-
1183
- def _is_relevant_image(img_url, title, text):
1184
- """Check if an image is relevant to the article content."""
1185
- if not img_url:
1186
- return False
1187
- skip_patterns = ['pixel', 'analytics', 'tracking', '1x1.gif', 'spacer.gif',
1188
- 'logo', 'icon', 'avatar', 'emoji', 'smiley', 'sprite',
1189
- 'advertisement', 'ad-banner', 'sponsored', 'banner-ads']
1190
- img_lower = img_url.lower()
1191
- for p in skip_patterns:
1192
- if p in img_lower:
1193
- return False
1194
- if not any(img_lower.endswith(ext) for ext in ['.jpg', '.jpeg', '.png', '.webp', '.gif']):
1195
- return False
1196
- return True
1197
-
1198
-
1199
- def _filter_relevant_images(images, title, text, max_images=8):
1200
- """Filter and rank images by relevance to article content."""
1201
- if not images:
1202
- return []
1203
- seen = set()
1204
- relevant = []
1205
- for img in images:
1206
- if img in seen:
1207
- continue
1208
- seen.add(img)
1209
- if _is_relevant_image(img, title, text):
1210
- relevant.append(img)
1211
- return relevant[:max_images]
1212
-
1213
-
1214
- def _scrape_article_for_rewrite(url):
1215
- """Scrape article: extract title, paragraphs, RELEVANT images, OG image."""
1216
- try:
1217
- r = req.get(url, headers=_UA_RW, timeout=15, allow_redirects=True)
1218
- r.encoding = 'utf-8'
1219
- soup = BeautifulSoup(r.text, 'lxml')
1220
- for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form']):
1221
- tag.decompose()
1222
- h1 = soup.find('h1')
1223
- ogt = soup.find('meta', property='og:title')
1224
- title = (h1.get_text(strip=True) if h1 else '') or (ogt.get('content', '') if ogt else '')
1225
- ogi = soup.find('meta', property='og:image')
1226
- og_img = ogi.get('content', '') if ogi else ''
1227
- if og_img and og_img.startswith('//'):
1228
- og_img = 'https:' + og_img
1229
- block = None
1230
- for sel in ['article', '.singular-content', '.detail-content', '.fck_detail', '.content-detail', '.knc-content', 'main', '.cms-body', '.article__body']:
1231
- el = soup.select_one(sel)
1232
- if el and len(el.find_all('p')) >= 2:
1233
- block = el
1234
- break
1235
- if not block:
1236
- block = soup.body or soup
1237
- paragraphs = []
1238
- all_images = []
1239
- seen_imgs = set()
1240
- if og_img and og_img not in seen_imgs:
1241
- all_images.append(og_img)
1242
- seen_imgs.add(og_img)
1243
- for el in block.find_all(['p', 'h2', 'h3', 'figure', 'img'], recursive=True):
1244
- if el.name == 'p':
1245
- t = _clean(el.get_text(strip=True))
1246
- if t and len(t) > 40:
1247
- paragraphs.append(t)
1248
- elif el.name in ('figure', 'img'):
1249
- im = el if el.name == 'img' else el.find('img')
1250
- if im:
1251
- src = im.get('data-src') or im.get('src') or im.get('data-original') or ''
1252
- if src and 'base64' not in src:
1253
- if src.startswith('//'):
1254
- src = 'https:' + src
1255
- if src not in seen_imgs:
1256
- all_images.append(src)
1257
- seen_imgs.add(src)
1258
- # Filter to relevant images only
1259
- relevant_images = _filter_relevant_images(all_images, title, ' '.join(paragraphs[:5]))
1260
- return {'title': _clean(title), 'paragraphs': paragraphs, 'images': relevant_images, 'og_img': og_img}
1261
- except Exception:
1262
- return None
1263
-
1264
-
1265
- def _extract_key_points_rw(paragraphs, max_points=5):
1266
- r"""Extract key points from paragraphs - extracts ALL sentences, not just first one.
1267
-
1268
- Fixes: Original regex `^(.+?[.!?])\s` only captured first sentence per paragraph.
1269
- Now splits on all sentence boundaries and takes valid sentences until max_points.
1270
- """
1271
- points = []
1272
-
1273
- for p in paragraphs:
1274
- if len(points) >= max_points:
1275
- break
1276
-
1277
- p = _clean(p)
1278
- if not p:
1279
- continue
1280
-
1281
- # Split paragraph into sentences using Vietnamese + English punctuation
1282
- sentences = re.split(r'(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])', p)
1283
- sentences = [s.strip() for s in sentences if s.strip()]
1284
-
1285
- for sentence in sentences:
1286
- if len(points) >= max_points:
1287
- break
1288
-
1289
- # Clean sentence - remove extra whitespace
1290
- sentence = _clean(sentence)
1291
-
1292
- if len(sentence) < 30:
1293
- continue
1294
-
1295
- # Check for duplicates
1296
- if any(sentence[:60] in existing for existing in points):
1297
- continue
1298
-
1299
- # Ensure sentence ends with punctuation
1300
- if not sentence.endswith(('.', '!', '?')):
1301
- sentence = sentence + '.'
1302
-
1303
- points.append(sentence)
1304
-
1305
- # If no valid sentences found, take chunks from raw text
1306
- if not points:
1307
- raw = '\n'.join(paragraphs)
1308
- for i in range(0, min(len(raw), max_points * 300), 280):
1309
- chunk = _clean(raw[i:i+280])
1310
- if len(chunk) >= 30 and chunk not in points:
1311
- points.append(chunk + ('.' if not chunk.endswith('.') else ''))
1312
- if len(points) >= max_points:
1313
- break
1314
-
1315
- return points
1316
-
1317
-
1318
- @app.post("/api/rewrite_slide")
1319
- async def api_rewrite_slide(request: Request):
1320
- """Fast rewrite as SLIDES - no AI needed, instant response."""
1321
- body = await request.json()
1322
- url = _clean(body.get("url", ""))
1323
- context = body.get("context", "")
1324
- preferred_voice = body.get("voice", "") # Accept custom voice selection
1325
- if not url and not context:
1326
- return JSONResponse({"error": "Cần URL hoặc nội dung"}, status_code=400)
1327
- data = None
1328
- if url and url.startswith("http"):
1329
- data = _scrape_article_for_rewrite(url)
1330
- if not data and context:
1331
- paragraphs = [_clean(p) for p in context.split('\n') if len(_clean(p)) > 40]
1332
- data = {'title': paragraphs[0][:80] if paragraphs else 'Bài viết', 'paragraphs': paragraphs, 'images': [], 'og_img': ''}
1333
- if not data or not data.get('paragraphs'):
1334
- return JSONResponse({"error": "Không đọc được bài viết"}, status_code=422)
1335
- points = _extract_key_points_rw(data['paragraphs'], max_points=12)
1336
- if not points:
1337
- return JSONResponse({"error": "Không tìm được ý chính"}, status_code=422)
1338
- images = data.get('images', [])
1339
- slides = []
1340
- for i, point in enumerate(points):
1341
- img = images[i] if i < len(images) else (images[-1] if images else '')
1342
- if img and 'cdnphoto.dantri' in img:
1343
- img = '/api/proxy/img?url=' + _quote2(img, safe='')
1344
- slides.append({'text': point, 'image': img, 'index': i + 1})
1345
- summary_text = '\n\n'.join([f"• {s['text']}" for s in slides])
1346
-
1347
- # Auto-detect language and emotion
1348
- lang, emotion = detect_language_and_emotion(data['title'], summary_text)
1349
- # Use preferred voice if provided, otherwise auto-detect
1350
- voice = preferred_voice if preferred_voice else get_voice_for_content(data['title'], summary_text)
1351
-
1352
- post = {
1353
- "id": str(int(time.time() * 1000)) + str(_random2.randint(100, 999)),
1354
- "title": data['title'],
1355
- "text": summary_text,
1356
- "img": images[0] if images else '',
1357
- "url": url,
1358
- "kind": "slide_summary",
1359
- "slides": slides,
1360
- "images": images[:10],
1361
- "video": "",
1362
- "voice": voice,
1363
- "emotion": emotion,
1364
- "language": lang,
1365
- "ts": int(time.time())
1366
- }
1367
- posts = _load_wall_posts()
1368
- posts.insert(0, post)
1369
- _save_wall_posts(posts)
1370
- return JSONResponse({"post": post, "slides": slides})
1371
-
1372
-
1373
- @app.post("/api/rewrite_share")
1374
- async def api_rewrite_share(request: Request):
1375
- """Rewrite article and post to Tường AI with SLIDES + AI text."""
1376
- body = await request.json()
1377
- url = _clean(body.get("url", ""))
1378
- ctx = _clean(body.get("context", ""))
1379
- preferred_voice = body.get("voice", "") # Accept custom voice selection
1380
- if not url and not ctx:
1381
- return JSONResponse({"error": "Cần URL hoặc nội dung"}, status_code=400)
1382
- data = None
1383
- if url and url.startswith("http"):
1384
- data = _scrape_article_for_rewrite(url)
1385
- if not data and ctx:
1386
- paragraphs = [_clean(p) for p in ctx.split('\n') if len(_clean(p)) > 40]
1387
- data = {'title': paragraphs[0][:80] if paragraphs else 'Bài viết', 'paragraphs': paragraphs, 'images': [], 'og_img': ''}
1388
- if not data or not data.get('paragraphs'):
1389
- return JSONResponse({"error": "Không đọc được bài viết"}, status_code=422)
1390
- raw_text = '\n'.join(data['paragraphs'])
1391
- if len(raw_text) < 50:
1392
- raw_text = ctx[:14000]
1393
- if len(raw_text) < 50:
1394
- return JSONResponse({"error": "Bài viết quá ngắn"}, status_code=422)
1395
- domain = ''
1396
- try:
1397
- from urllib.parse import urlparse
1398
- domain = urlparse(url).netloc.replace('www.', '')
1399
- except:
1400
- pass
1401
-
1402
- # Generate AI summary text
1403
- ai_text = None
1404
- try:
1405
- import ai_ext
1406
- if hasattr(ai_ext, 'qwen_generate'):
1407
- 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.'
1408
- ai_text = await ai_ext.qwen_generate(prompt, max_tokens=1000)
1409
- except Exception:
1410
- pass
1411
- if not ai_text or len(ai_text) < 80:
1412
- key_pts = _extract_key_points_rw(data['paragraphs'], max_points=12)
1413
- if key_pts:
1414
- ai_text = '\n\n'.join([f"• {p}" for p in key_pts])
1415
- else:
1416
- ai_text = f"Tóm tắt: {data['title']}\n\n{raw_text[:1200]}\n\nNguồn: {domain}"
1417
-
1418
- # Build slides from key points (FIX: include slides in rewrite_share too!)
1419
- points = _extract_key_points_rw(data['paragraphs'], max_points=12)
1420
- images = data.get('images', [])
1421
- slides = []
1422
- for i, point in enumerate(points):
1423
- img = images[i] if i < len(images) else (images[-1] if images else '')
1424
- if img and 'cdnphoto.dantri' in img:
1425
- img = '/api/proxy/img?url=' + _quote2(img, safe='')
1426
- slides.append({'text': point, 'image': img, 'index': i + 1})
1427
-
1428
- # Auto-detect language and emotion
1429
- lang, emotion = detect_language_and_emotion(data['title'], ai_text)
1430
- # Use preferred voice if provided, otherwise auto-detect
1431
- voice = preferred_voice if preferred_voice else get_voice_for_content(data['title'], ai_text)
1432
-
1433
- post = {
1434
- "id": str(int(time.time() * 1000)) + str(_random2.randint(100, 999)),
1435
- "title": data['title'],
1436
- "text": ai_text,
1437
- "img": images[0] if images else '',
1438
- "url": url,
1439
- "kind": "rewrite",
1440
- "slides": slides,
1441
- "images": images[:10],
1442
- "video": "",
1443
- "voice": voice,
1444
- "emotion": emotion,
1445
- "language": lang,
1446
- "ts": int(time.time())
1447
- }
1448
- posts = _load_wall_posts()
1449
- posts.insert(0, post)
1450
- _save_wall_posts(posts)
1451
- return JSONResponse({"post": post, "slides": slides})
1452
-
1453
-
1454
- @app.post("/api/url_wall")
1455
- async def api_url_wall(request: Request):
1456
- """Submit URL to add to Tường AI."""
1457
- body = await request.json()
1458
- url = _clean(body.get("url", ""))
1459
- if not url or not url.startswith('http'):
1460
- return JSONResponse({"error": "URL không hợp lệ"}, status_code=400)
1461
- # Reuse rewrite_share logic
1462
- req._body = json.dumps({"url": url}).encode()
1463
- return await api_rewrite_share(request)
1464
-
1465
-
1466
- # ===== PERSONAL OPINION POST v3: AI VIET BAI MOI, dao nau quan diem + nguon tin =====
1467
- async def _scrape_source_for_opinion(url):
1468
- """Scrape article content for opinion synthesis."""
1469
- try:
1470
- art = _scrape_article_for_rewrite(url)
1471
- if art:
1472
- paras = art.get("paragraphs", [])[:6]
1473
- imgs = art.get("images", [])[:2]
1474
- proxied_imgs = []
1475
- for img in imgs[:2]:
1476
- if any(x in img for x in ["cdnphoto.dantri", "vnexpress", "vcdn", "refooty"]):
1477
- img = "/api/proxy/img?url=" + _quote2(img, safe="")
1478
- proxied_imgs.append(img)
1479
- return {
1480
- "title": art.get("title", ""),
1481
- "paragraphs": paras,
1482
- "images": proxied_imgs,
1483
- "og_image": art.get("og_img", ""),
1484
- "via": ""
1485
- }
1486
- except:
1487
- pass
1488
- return None
1489
-
1490
- def _split_into_slides(text, source_images, max_slides=6):
1491
- """Split article text into slides with relevant images."""
1492
- import re as _re
1493
- paragraphs = [p.strip() for p in text.split("\n") if p.strip() and len(p.strip()) > 50]
1494
- slides = []
1495
- img_idx = 0
1496
- for p in paragraphs:
1497
- if len(slides) >= max_slides:
1498
- break
1499
- p = _re.sub(r"^#+\s*", "", p)
1500
- if p.startswith("---") or (p.startswith("*") and p.endswith("*")):
1501
- continue
1502
- img = source_images[img_idx] if img_idx < len(source_images) else ""
1503
- if img:
1504
- img_idx += 1
1505
- slides.append({"text": p, "image": img, "index": len(slides) + 1})
1506
- if not slides:
1507
- slides.append({"text": "Dang tao bai viet...", "image": "", "index": 1})
1508
- return slides
1509
-
1510
- def _build_fallback_article(opinion, source_details, title):
1511
- """Build proper synthesized article when AI is unavailable."""
1512
- import random
1513
- lines = []
1514
- lines.append(title)
1515
- lines.append("")
1516
- # Intro - paraphrase the opinion
1517
- lines.append("Trong boi canh hien nay, van de '" + opinion[:80] + "' dang nhan duoc nhieu su quan tam. Bai viet duoi day se phan tich goc nhin ca nhan dua tren cac nguon tin bao chi.")
1518
- lines.append("")
1519
- # Body - synthesize from sources with personal take
1520
- for sd in source_details[:4]:
1521
- paras = sd.get("paragraphs", [])
1522
- via = sd.get("via", "Nguon tin")
1523
- sd_title = sd.get("title", "")
1524
- if paras:
1525
- key_point = paras[0][:200]
1526
- lines.append("Theo " + via + ", " + key_point + ". Dieu nay cho thay " + opinion[:40] + " la mot goc nhin dang duoc ban luan.")
1527
- lines.append("")
1528
- # Conclusion
1529
- lines.append("Tom lai, " + opinion[:120] + ". Day la quan diem ca nhan duoc tong hop tu nhieu nguon tin khac nhau.")
1530
- lines.append("")
1531
- lines.append("---")
1532
- vias = [sd.get("via","") for sd in source_details[:4] if sd.get("via")]
1533
- if vias:
1534
- lines.append("Nguon tham khao: " + ", ".join(vias) + ".")
1535
- return "\n".join(lines)
1536
-
1537
- @app.post("/api/personal_post/preview")
1538
- async def api_personal_post_preview(request: Request):
1539
- """Preview: AI writes NEW article from opinion + HOT sources."""
1540
- body = await request.json()
1541
- opinion = _clean(body.get("opinion", ""))
1542
- selected_topics = body.get("selected_topics", []) or []
1543
- selected_sources = body.get("selected_sources", []) or []
1544
- if not opinion or len(opinion) < 10:
1545
- return JSONResponse({"error": "Quan diem ca nhan qua ngan"}, status_code=400)
1546
- if not selected_topics:
1547
- hot = _get_hot_topics()
1548
- selected_topics = [t.get("topic", "") for t in hot[:3] if t.get("topic")]
1549
- all_sources = []
1550
- seen_urls = set()
1551
- for topic in selected_topics[:3]:
1552
- for s in _search_all(topic, limit=5):
1553
- if s.get("url") and s["url"] not in seen_urls:
1554
- seen_urls.add(s["url"])
1555
- all_sources.append(s)
1556
- if len(all_sources) >= 5:
1557
- break
1558
- if len(all_sources) >= 5:
1559
- break
1560
- for src in selected_sources:
1561
- if src.get("url") and src["url"] not in seen_urls:
1562
- all_sources.insert(0, src)
1563
- source_details = []
1564
- source_images = []
1565
- for src in all_sources[:5]:
1566
- sd = await _scrape_source_for_opinion(src.get("url", ""))
1567
- if sd:
1568
- sd["via"] = src.get("via", "")
1569
- source_details.append(sd)
1570
- for img in sd.get("images", []):
1571
- if img not in source_images:
1572
- source_images.append(img)
1573
- # Title
1574
- words = re.findall(r"[A-Za-z0-9\u00c0-\u1ef9]+", opinion)
1575
- tw = words[:6] if len(words) >= 6 else words[:3]
1576
- title = " ".join(w[0].upper() + w[1:] for w in tw) if tw else "Quan diem ca nhan"
1577
- title = title[:80]
1578
- # AI generate
1579
- ai_success = False
1580
- ai_text = None
1581
- try:
1582
- import ai_ext
1583
- if hasattr(ai_ext, 'qwen_generate'):
1584
- src_lines = []
1585
- for i, sd in enumerate(source_details[:5]):
1586
- src_lines.append("[" + sd.get("via","Nguon") + "] " + sd.get("title",""))
1587
- for p in sd.get("paragraphs", [])[:3]:
1588
- src_lines.append(" -> " + p[:250])
1589
- src_text = "\n".join(src_lines)
1590
- prompt = "Hay viet MOT BAI BAO HOAN TOAN MOI dua tren y kien ca nhan va tu lieu tham khao duoi day.\n\nY KIEN CA NHAN (chu de):\n" + opinion[:1000] + "\n\nTU LIEU THAM KHAO:\n" + src_text + "\n\nYEU CAU:\n- Viet bai moi, KHONG sao chep nguyen van tu lieu.\n- Dung y kien ca nhan lam chu dao.\n- Long ghep dan chung tu bao mot cach tu nhien. Ghi ro nguon khi trich dan.\n- Giong van bao chi, mach lac.\n- 4-5 doan, moi doan 2-3 cau.\n- Ket thuc bang ket luan ca nhan."
1591
- ai_text = await ai_ext.qwen_generate(prompt, max_tokens=1500)
1592
- if ai_text and len(ai_text) > 150:
1593
- ai_success = True
1594
- except:
1595
- pass
1596
- if not ai_success:
1597
- ai_text = _build_fallback_article(opinion, source_details, title)
1598
- slides = _split_into_slides(ai_text, source_images)
1599
- return JSONResponse({"preview": {"title": title, "text": ai_text, "opinion": opinion, "images": source_images, "sources": source_details[:5], "slides": slides}})
1600
-
1601
- @app.post("/api/personal_post")
1602
- async def api_personal_post(request: Request):
1603
- """Publish: AI writes new article, saves to wall."""
1604
- body = await request.json()
1605
- opinion = _clean(body.get("opinion", ""))
1606
- selected_topics = body.get("selected_topics", []) or []
1607
- selected_sources = body.get("selected_sources", []) or []
1608
- custom_title = body.get("custom_title", "")
1609
- custom_slides = body.get("custom_slides", [])
1610
- if not opinion or len(opinion) < 10:
1611
- return JSONResponse({"error": "Quan diem ca nhan qua ngan"}, status_code=400)
1612
- if not selected_topics:
1613
- hot = _get_hot_topics()
1614
- selected_topics = [t.get("topic", "") for t in hot[:3] if t.get("topic")]
1615
- all_sources = []
1616
- seen_urls = set()
1617
- for topic in selected_topics[:3]:
1618
- for s in _search_all(topic, limit=5):
1619
- if s.get("url") and s["url"] not in seen_urls:
1620
- seen_urls.add(s["url"])
1621
- all_sources.append(s)
1622
- if len(all_sources) >= 5:
1623
- break
1624
- if len(all_sources) >= 5:
1625
- break
1626
- for src in selected_sources:
1627
- if src.get("url") and src["url"] not in seen_urls:
1628
- all_sources.insert(0, src)
1629
- source_details = []
1630
- source_images = []
1631
- for src in all_sources[:5]:
1632
- sd = await _scrape_source_for_opinion(src.get("url", ""))
1633
- if sd:
1634
- sd["via"] = src.get("via", "")
1635
- source_details.append(sd)
1636
- for img in sd.get("images", []):
1637
- if img not in source_images:
1638
- source_images.append(img)
1639
- if custom_title:
1640
- title = custom_title[:80]
1641
- else:
1642
- words = re.findall(r"[A-Za-z0-9\u00c0-\u1ef9]+", opinion)
1643
- tw = words[:6] if len(words) >= 6 else words[:3]
1644
- title = " ".join(w[0].upper() + w[1:] for w in tw) if tw else "Quan diem ca nhan"
1645
- title = title[:80]
1646
- ai_success = False
1647
- ai_text = None
1648
- try:
1649
- import ai_ext
1650
- if hasattr(ai_ext, 'qwen_generate'):
1651
- src_lines = []
1652
- for i, sd in enumerate(source_details[:5]):
1653
- src_lines.append("[" + sd.get("via","Nguon") + "] " + sd.get("title",""))
1654
- for p in sd.get("paragraphs", [])[:3]:
1655
- src_lines.append(" -> " + p[:250])
1656
- src_text = "\n".join(src_lines)
1657
- prompt = "Hay viet MOT BAI BAO HOAN TOAN MOI dua tren:\n\nY KIEN CA NHAN:\n" + opinion[:1000] + "\n\nTU LIEU THAM KHAO:\n" + src_text + "\n\nYEU CAU: Bai moi, lay y kien ca nhan lam chu dao, long dan chung tu bao. Ghi nguon. 4-5 doan. Ket luan ca nhan."
1658
- ai_text = await ai_ext.qwen_generate(prompt, max_tokens=1500)
1659
- if ai_text and len(ai_text) > 150:
1660
- ai_success = True
1661
- except:
1662
- pass
1663
- if not ai_success:
1664
- ai_text = _build_fallback_article(opinion, source_details, title)
1665
- if custom_slides and len(custom_slides) > 0:
1666
- slides = [{"text": s.get("text",""), "image": s.get("image",""), "index": i+1} for i, s in enumerate(custom_slides)]
1667
- else:
1668
- slides = _split_into_slides(ai_text, source_images)
1669
- lang, emotion = detect_language_and_emotion(title, ai_text)
1670
- voice = get_voice_for_content(title, ai_text)
1671
- post = {
1672
- "id": str(int(time.time() * 1000)) + str(_random2.randint(100, 999)),
1673
- "title": title, "text": ai_text,
1674
- "img": source_images[0] if source_images else "",
1675
- "url": "", "kind": "personal_opinion",
1676
- "slides": slides, "images": source_images[:10],
1677
- "video": "", "voice": voice, "emotion": emotion,
1678
- "language": lang, "ts": int(time.time()),
1679
- "sources": source_details[:5]
1680
- }
1681
- posts = _load_wall_posts()
1682
- posts.insert(0, post)
1683
- _save_wall_posts(posts)
1684
- return JSONResponse({"post": post, "slides": slides})
1685
-
1686
- # ===== END PERSONAL OPINION POST v3 =====
1687
-
1688
- def _bg():
1689
- time.sleep(15)
1690
- while True:
1691
- try:get_wc2026_all()
1692
- except:pass
1693
- time.sleep(90)
1694
- threading.Thread(target=_bg,daemon=True).start()
1695
-
1696
- # ===== AUTO SCHEDULER: rewrite AI + short at 7/13/19 VN time =====
1697
- _AUTO_SCHEDULE_TIMES = [(7, '07:00'), (13, '13:00'), (19, '19:00')]
1698
- _AUTO_LOG = os.path.join(DATA_DIR, 'auto_rewrite_log.json')
1699
-
1700
- def _load_auto_log():
1701
- try:
1702
- if os.path.exists(_AUTO_LOG):
1703
- with open(_AUTO_LOG, 'r') as f:
1704
- return json.load(f)
1705
- except: pass
1706
- return {}
1707
-
1708
- def _save_auto_log(log):
1709
- try:
1710
- tmp = _AUTO_LOG + '.tmp'
1711
- with open(tmp, 'w') as f:
1712
- json.dump(log, f)
1713
- os.replace(tmp, _AUTO_LOG)
1714
- except: pass
1715
-
1716
- async def _auto_fetch_short(post_id):
1717
- """Try to auto-generate a short for a post."""
1718
- try:
1719
- import httpx
1720
- async with httpx.AsyncClient(timeout=180) as cl:
1721
- r = await cl.post(
1722
- f"http://localhost:7860/api/ai/short/{post_id}",
1723
- json={"voice":"vi-VN-HoaiMyNeural","emotion":"neutral","speed":1.2},
1724
- headers={"Content-Type":"application/json"}
1725
- )
1726
- if r.status_code < 300:
1727
- sj = r.json()
1728
- if sj.get('video'):
1729
- posts = _load_wall_posts()
1730
- for p in posts:
1731
- if p.get('id') == post_id:
1732
- p['video'] = sj['video']
1733
- break
1734
- _save_wall_posts(posts)
1735
- return True
1736
- except: pass
1737
- return False
1738
-
1739
- async def _auto_rewrite_one(topic, slot_label, used_urls=None, post_index=0):
1740
- """Rewrite one topic: find articles, summarize, post to wall, trigger short.
1741
- used_urls: shared set to avoid duplicate articles across topics.
1742
- post_index: 0-based index to create multiple posts per topic (0,1,2 = up to 3 posts)."""
1743
- from urllib.parse import quote as _q
1744
- # Get MORE items to support 1-3 posts per topic
1745
- items = _search_all(topic, limit=12)
1746
- # Skip URLs already used by another topic
1747
- if used_urls is not None:
1748
- filtered = [it for it in items if it.get('url') not in used_urls]
1749
- if filtered:
1750
- items = filtered
1751
- if not items or post_index >= len(items):
1752
- return False
1753
-
1754
- # Get article at post_index (0,1,2 for multiple posts)
1755
- item = items[post_index] # post_index allows multiple articles per topic
1756
- url = item.get('url', '')
1757
- title = item.get('title', topic)
1758
- if url and used_urls is not None:
1759
- used_urls.add(url)
1760
- if not url.startswith('http'):
1761
- return False
1762
-
1763
- data = _scrape_article_for_rewrite(url)
1764
- if not data or not data.get('paragraphs'):
1765
- return False
1766
-
1767
- raw_text = '\n'.join(data['paragraphs'])
1768
- ai_text = None
1769
-
1770
- # Try AI generation
1771
- try:
1772
- import ai_ext
1773
- 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."
1774
- ai_text = await ai_ext.qwen_generate(prompt, max_tokens=1000)
1775
- except: pass
1776
-
1777
- if not ai_text or len(ai_text) < 80:
1778
- pts = data['paragraphs'][:6]
1779
- ai_text = '\n\n'.join([f"• {p[:300]}" for p in pts])
1780
- via = item.get('via', '') or urlparse(url).netloc.replace('www.', '')
1781
- ai_text += f"\n\nNguồn tham khảo: {via}"
1782
-
1783
- # Build slides
1784
- images = data.get('images', [])
1785
- pts = data['paragraphs'][:10]
1786
- slides = []
1787
- for i, p in enumerate(pts[:8]):
1788
- img = images[i] if i < len(images) else (images[-1] if images else data.get('og_img', ''))
1789
- slides.append({'text': p[:300], 'image': img, 'index': i + 1})
1790
-
1791
- post_id = str(int(time.time() * 1000)) + str(_random2.randint(100, 999))
1792
- post = {
1793
- "id": post_id, "title": data.get('title', title)[:200],
1794
- "text": ai_text, "img": images[0] if images else data.get('og_img', ''),
1795
- "url": url, "kind": "auto_rewrite", "slides": slides,
1796
- "images": images[:10], "video": "",
1797
- "voice": "vi-VN-HoaiMyNeural", "emotion": "neutral",
1798
- "language": "vietnamese", "ts": int(time.time()),
1799
- "auto_scheduled": True, "slot": slot_label,
1800
- }
1801
-
1802
- posts = _load_wall_posts()
1803
- posts.insert(0, post)
1804
- _save_wall_posts(posts)
1805
-
1806
- # Trigger short generation async
1807
- threading.Thread(target=lambda: asyncio.run(_auto_fetch_short(post_id)), daemon=True).start()
1808
- return True
1809
-
1810
- async def _do_scheduled_run(slot_label):
1811
- """Main scheduled run: 1-3 posts from 3 different HOT topics (3-9 total), no duplicates."""
1812
- print(f"[auto] Starting scheduled rewrite for {slot_label}")
1813
-
1814
- # Get top hot topics, skip duplicates
1815
- all_topics = _get_hot_topics()
1816
- seen_topics = set()
1817
- unique_topics = []
1818
- for t in all_topics:
1819
- kw = t.get('topic', '').lower().strip()
1820
- if kw and len(kw) > 5 and kw not in seen_topics:
1821
- is_dup = False
1822
- for s in seen_topics:
1823
- # Check if one topic is substring of another
1824
- if kw in s or s in kw:
1825
- is_dup = True
1826
- break
1827
- if not is_dup:
1828
- seen_topics.add(kw)
1829
- unique_topics.append(t)
1830
- if len(unique_topics) >= 3:
1831
- break
1832
-
1833
- job_topics = [t['topic'] for t in unique_topics[:3] if t.get('topic')]
1834
- if not job_topics:
1835
- print(f"[auto] No hot topics found, skipping")
1836
- return
1837
-
1838
- print(f"[auto] Running 3 topics: {job_topics}")
1839
-
1840
- # Track used URLs to avoid cross-topic duplicates
1841
- _used_urls = set()
1842
- results = []
1843
-
1844
- # Process each topic, create 1-3 posts per topic
1845
- for jt in job_topics:
1846
- for post_idx in range(3): # Try up to 3 posts per topic
1847
- try:
1848
- ok = await asyncio.wait_for(_auto_rewrite_one(jt, slot_label, _used_urls, post_idx), timeout=120)
1849
- if ok:
1850
- results.append((jt, post_idx, True))
1851
- print(f"[auto] Created post {post_idx+1} for '{jt}'")
1852
- else:
1853
- # No more articles for this topic
1854
- break
1855
- except Exception as e:
1856
- print(f"[auto] Error on '{jt}' post {post_idx}: {e}")
1857
- results.append((jt, post_idx, False))
1858
- await asyncio.sleep(1) # Small delay between posts
1859
-
1860
- # Ensure at least 3 posts total (fallback if needed)
1861
- successful_posts = sum(1 for _, _, ok in results if ok)
1862
- print(f"[auto] Done {slot_label}: {successful_posts} posts created")
1863
-
1864
- # Log
1865
- from datetime import datetime, timezone, timedelta
1866
- VN_TZ_SCHED = timezone(timedelta(hours=7))
1867
- today_str = datetime.now(VN_TZ_SCHED).strftime('%Y-%m-%d')
1868
- log = _load_auto_log()
1869
- if today_str not in log: log[today_str] = {}
1870
- log[today_str][slot_label] = {
1871
- 'time': datetime.now(VN_TZ_SCHED).strftime('%H:%M:%S'),
1872
- 'count': successful_posts,
1873
- 'total': len(job_topics),
1874
- }
1875
- _save_auto_log(log)
1876
-
1877
- def _scheduler_loop():
1878
- """Check every 60s; trigger at 7:00, 13:00, 19:00 VN time.
1879
- On startup, check for any missed slots today and run them immediately."""
1880
- time.sleep(35)
1881
- from datetime import datetime, timezone, timedelta
1882
- VN_TZ_SCHED = timezone(timedelta(hours=7))
1883
-
1884
- _last_run_date = ""
1885
- _last_run_slots = set()
1886
-
1887
- # On startup: check log for missed slots today
1888
- try:
1889
- start_now = datetime.now(VN_TZ_SCHED)
1890
- today_str = start_now.strftime('%Y-%m-%d')
1891
- current_hour = start_now.hour
1892
- current_minute = start_now.minute
1893
- log = _load_auto_log()
1894
- today_log = log.get(today_str, {})
1895
- for h, label in _AUTO_SCHEDULE_TIMES:
1896
- # Run if slot is past (either strictly earlier hour, or same hour but window has passed)
1897
- should_run = False
1898
- if h < current_hour:
1899
- should_run = True
1900
- elif h == current_hour and current_minute > 10:
1901
- should_run = True
1902
- if should_run and label not in today_log:
1903
- print(f"[auto] Detected missed slot {label} (h={h} < now={current_hour}:{current_minute}), running catch-up now")
1904
- _run_scheduled_sync(label)
1905
- _last_run_slots.add(label)
1906
- except Exception as e:
1907
- print(f"[auto] Catch-up check error: {e}")
1908
-
1909
- while True:
1910
- try:
1911
- now = datetime.now(VN_TZ_SCHED)
1912
- today = now.strftime('%Y-%m-%d')
1913
- hour = now.hour
1914
- minute = now.minute
1915
-
1916
- if today != _last_run_date:
1917
- _last_run_date = today
1918
- _last_run_slots = set()
1919
-
1920
- slot = None
1921
- for h, label in _AUTO_SCHEDULE_TIMES:
1922
- if hour == h and 0 <= minute < 5:
1923
- slot = label
1924
- break
1925
-
1926
- if slot and slot not in _last_run_slots:
1927
- _last_run_slots.add(slot)
1928
- _run_scheduled_sync(slot)
1929
- except Exception as e:
1930
- print(f"[auto] Loop error: {e}")
1931
-
1932
- time.sleep(60)
1933
-
1934
- threading.Thread(target=_scheduler_loop, daemon=True, name='auto-rewrite-scheduler').start()
1935
-
1936
- @app.get('/api/debug/auto_schedule')
1937
- async def debug_auto_schedule(slot: str = '07:00'):
1938
- """Manually trigger auto scheduler for debugging."""
1939
- try:
1940
- # Check if we can access the data directory
1941
- log = _load_auto_log()
1942
- topics = _get_hot_topics()[:3]
1943
- job_topics = [t['topic'] for t in topics if t.get('topic')]
1944
- return JSONResponse({
1945
- "slot": slot,
1946
- "log": log,
1947
- "hot_topics": job_topics,
1948
- "wall_posts_count": len(_load_wall_posts()),
1949
- "data_dir_writable": os.access(DATA_DIR, os.W_OK) if os.path.isdir(DATA_DIR) else False,
1950
- "data_dir_exists": os.path.isdir(DATA_DIR),
1951
- })
1952
- except Exception as e:
1953
- return JSONResponse({"error": str(e)}, status_code=500)
1954
-
1955
- def _run_scheduled_sync(slot):
1956
- """Run _do_scheduled_run in a separate event loop (for background thread)."""
1957
- loop = asyncio.new_event_loop()
1958
- asyncio.set_event_loop(loop)
1959
- try:
1960
- loop.run_until_complete(_do_scheduled_run(slot))
1961
- except Exception as e:
1962
- print(f"[auto] Background run error: {e}")
1963
- finally:
1964
- loop.close()
1965
-
1966
- @app.get('/api/debug/trigger_auto')
1967
- async def debug_trigger_auto(slot: str = '19:00'):
1968
- """Trigger _do_scheduled_run in background thread (non-blocking)."""
1969
- threading.Thread(target=_run_scheduled_sync, args=(slot,), daemon=True).start()
1970
- return JSONResponse({"status": "started", "slot": slot})
1971
-
1972
- # ===== SHORTS RSS PROXY ENDPOINT =====
1973
- @app.get("/api/shorts/rss")
1974
- def shorts_rss():
1975
- """Get shorts from YouTube RSS feeds server-side"""
1976
- import xml.etree.ElementTree as ET
1977
- import html as html_lib2
1978
- import re as re2
1979
-
1980
- YOUTUBE_CHANNELS = {
1981
- "baodantri7941": "UC_x5TKhOgd6GhYvv5z4I3jg",
1982
- "baosuckhoedoisongboyte": "UCBsY5fXTQLkF_JnH9kLkL4g",
1983
- }
1984
-
1985
- shorts = []
1986
- seen = set()
1987
-
1988
- for handle, channel_id in YOUTUBE_CHANNELS.items():
1989
- try:
1990
- rss_url = f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"
1991
- r = req.get(rss_url, headers=HEADERS, timeout=15)
1992
- if r.status_code != 200:
1993
- continue
1994
-
1995
- root = ET.fromstring(r.text)
1996
- ns = {
1997
- 'atom': 'http://www.w3.org/2005/Atom',
1998
- 'yt': 'http://www.youtube.com/xml/schemas/2015',
1999
- 'media': 'http://search.yahoo.com/mrss/'
2000
- }
2001
-
2002
- for entry in root.findall('atom:entry', ns)[:30]:
2003
- title_el = entry.find('atom:title', ns)
2004
- title = html_lib2.unescape(title_el.text) if title_el is not None and title_el.text else ''
2005
-
2006
- link_el = entry.find('atom:link', ns)
2007
- link = link_el.get('href', '') if link_el is not None else ''
2008
-
2009
- vid_el = entry.find('yt:videoId', ns)
2010
- vid = vid_el.text if vid_el is not None else ''
2011
-
2012
- if not vid or vid in seen:
2013
- continue
2014
-
2015
- # Check if it's a short
2016
- is_short = '#shorts' in title.lower() or '#short' in title.lower() or '/shorts/' in link
2017
-
2018
- if not is_short:
2019
- desc_el = entry.find('media:description', ns)
2020
- if desc_el is not None and desc_el.text:
2021
- if '#shorts' in desc_el.text.lower():
2022
- is_short = True
2023
-
2024
- if not is_short:
2025
- continue
2026
-
2027
- seen.add(vid)
2028
-
2029
- # Get thumbnail
2030
- thumb = f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg"
2031
- media_group = entry.find('media:group', ns)
2032
- if media_group is not None:
2033
- thumb_el = media_group.find('media:thumbnail', ns)
2034
- if thumb_el is not None:
2035
- thumb = thumb_el.get('url', thumb)
2036
-
2037
- shorts.append({
2038
- 'id': vid,
2039
- 'title': title.replace('#shorts', '').replace('#short', '').strip()[:120],
2040
- 'img': thumb,
2041
- 'link': f'https://www.youtube.com/shorts/{vid}',
2042
- 'channel': handle,
2043
- 'source': 'yt'
2044
- })
2045
-
2046
- if len(shorts) >= 40:
2047
- break
2048
-
2049
- except Exception as e:
2050
- print(f"RSS error for {handle}: {e}")
2051
- continue
2052
-
2053
- return {"shorts": shorts, "count": len(shorts)}
2054
-
2055
- app.mount('/static',StaticFiles(directory=STATIC_DIR),name='vnews_static')
 
1
+ """VNEWS v2 Entry Point - with fast bongda proxy + rewrite endpoints + multilingual TTS + opinion v3"""
2
  import sys, os
3
  from main import app, HEADERS, BONGDA_HEADERS, fetch_bongda_api, HL_LEAGUES
4
 
 
12
  except Exception as e:
13
  print(f"[WARN] ai_patch import failed: {e}")
14
 
15
+ # PERSONAL OPINION POST v3 - AI synthesis from user opinion + hot news sources
16
+ try:
17
+ import opinion_v3_patch
18
+ print("[app_v2_entry] opinion_v3_patch loaded: POST /api/opinion/post + GET /api/opinion/hot_context")
19
+ except Exception as e:
20
+ print(f"[WARN] opinion_v3_patch import failed: {e}")
21
+
22
  from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, Response
23
  from fastapi.staticfiles import StaticFiles
24
  from starlette.routing import Mount
 
33
  HL_LEAGUES['friendly'] = {"path": "giai-khac/friendly", "name": "Giao hữu", "emoji": "🤝"}
34
 
35
  STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static')
36
+ SPACE = "https://bep40-vnews.hf.space"
37
  app.router.routes=[r for r in app.router.routes if not(getattr(r,'path',None)=='/' and hasattr(r,'methods') and 'GET' in getattr(r,'methods',set()))]
38
  app.routes[:]=[r for r in app.routes if not isinstance(r, Mount)]
39
  app.router.routes=[r for r in app.router.routes if not isinstance(r, Mount)]
40
 
41
  def _clean(s): return re.sub(r"\s+"," ",html_lib.unescape(str(s or""))).strip()
42
 
 
43
  _match_cache = {}
44
 
 
45
  def _get_match_detail(event_id, slug=None):
46
  headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "text/html", "Referer": "https://bongda.com.vn/"}
47
  if slug:
 
513
  # ===== SHARE HELPERS: render content pages for shared links =====
514
  def _render_slides_page(post, safe_title, safe_img, safe_url):
515
  slides = post.get('slides', [])
 
516
  if not safe_img and slides and slides[0].get('image'):
517
  safe_img = slides[0].get('image', '')
 
518
  description = _clean((post.get('text') or '')[:200]) or "Tin tức tóm tắt, AI rewrite, World Cup 2026"
 
 
519
  if safe_url and safe_url != '/':
520
  canonical_url = f"{SPACE}/s?url={quote(safe_url)}&title={quote(safe_title[:100])}"
521
  else:
522
  canonical_url = f"{SPACE}/s?post_id={post.get('id') or ''}"
 
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
  description = _clean((post.get('text') or '')[:200]) or "Tin tức tóm tắt, AI rewrite, World Cup 2026"
 
 
556
  if safe_url and safe_url != '/':
557
  canonical_url = f"{SPACE}/s?url={quote(safe_url)}&title={quote(safe_title[:100])}"
558
  else:
559
  canonical_url = f"{SPACE}/s?post_id={post.get('id') or ''}"
 
560
  h = f'''<!DOCTYPE html>
561
  <html lang="vi">
562
  <head>
 
588
 
589
  @app.get('/s/{slug}')
590
  async def _sh_slug(slug: str, request: Request, url: str = '', title: str = '', img: str = ''):
 
 
 
591
  safe_title = _clean(title) if title else 'VNEWS - Tin tức'
592
  safe_img = _clean(img) if img else ''
593
  safe_url = _clean(url) if url else '/'
 
 
594
  post = None
595
  try:
596
+ if slug and len(slug) > 5:
597
  posts = _load_wall_posts()
598
  for p in posts:
599
  if p.get('id') == slug:
 
604
  break
605
  except:
606
  pass
 
607
  if post and post.get('slides'):
608
  return _render_slides_page(post, safe_title, safe_img, safe_url)
 
609
  if post and post.get('video'):
610
  return _render_video_page(post, safe_title, safe_img, safe_url)
 
 
611
  return HTMLResponse(f'''<!DOCTYPE html>
612
  <html lang="vi">
613
  <head>
 
627
  safe_title = _clean(title) if title else 'VNEWS - Tin tức'
628
  safe_img = _clean(img) if img else ''
629
  safe_url = _clean(url) if url else '/'
 
 
630
  post = None
631
  try:
632
  posts = _load_wall_posts()
 
639
  safe_url = p.get('url', safe_url) or safe_url
640
  break
641
  elif url:
 
642
  for p in posts:
643
  if p.get('url') == url and p.get('slides'):
644
  post = p
 
647
  safe_url = p.get('url', safe_url) or safe_url
648
  break
649
  if not post:
 
650
  for p in posts:
651
  if p.get('url') == url:
652
  post = p
 
656
  break
657
  except:
658
  pass
 
659
  if post and post.get('slides'):
660
  return _render_slides_page(post, safe_title, safe_img, safe_url)
 
661
  if post and post.get('video'):
662
  return _render_video_page(post, safe_title, safe_img, safe_url)
 
 
663
  return HTMLResponse(f'''<!DOCTYPE html>
664
  <html lang="vi">
665
  <head>
 
945
 
946
  _UA_RW = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept-Language': 'vi-VN,vi;q=0.9'}
947
 
 
948
  _UNIQUE_CHARS = {
949
  'vietnamese': set('đăâêôơưàảãạáằẳẵặắầẩẫậấèẻẽẹéềễểệếìỉĩịíòỏõọóồổỗộốờởỡợớùủũụúừửữựứỳỷỹỵý'),
950
  'spanish': set('ñáéíóúü¿¡'),
 
959
  }
960
 
961
  def detect_language(text):
 
962
  if not text:
963
  return 'vietnamese'
964
  text_lower = text.lower()
965
  text_chars = set(text_lower)
 
 
966
  vn_chars = len(text_chars & _UNIQUE_CHARS['vietnamese'])
967
  if vn_chars >= 2:
968
  return 'vietnamese'
 
 
969
  es_chars = len(text_chars & _UNIQUE_CHARS['spanish'])
970
  pt_chars = len(text_chars & _UNIQUE_CHARS['portuguese'])
 
 
971
  words = set(re.findall(r'\b\w+\b', text_lower))
972
  scores = {}
973
  for lang, stops in _STOPWORDS.items():
974
  scores[lang] = len(words & stops) / max(len(stops), 1)
 
 
975
  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'}
976
  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'}
 
977
  pt_overlap = len(words & pt_markers)
978
  es_overlap = len(words & es_markers)
 
979
  if scores.get('portuguese', 0) > 0 and pt_overlap > es_overlap:
980
  return 'portuguese'
981
  if scores.get('spanish', 0) > 0 and es_overlap > pt_overlap:
982
  return 'spanish'
983
  if scores.get('english', 0) > 0.15:
984
  return 'english'
 
985
  best = max(scores, key=scores.get)
986
  return best if scores[best] > 0.05 else 'vietnamese'
987
 
988
+ # ===== EXPORT FUNCTIONS NEEDED BY opinion_v3_patch =====
989
+ # These are imported by opinion_v3_patch to avoid circular deps