bep40 commited on
Commit
a335006
·
verified ·
1 Parent(s): f845ba5

Upload app_v2_entry.py

Browse files
Files changed (1) hide show
  1. app_v2_entry.py +9 -845
app_v2_entry.py CHANGED
@@ -12,6 +12,11 @@ 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
@@ -282,7 +287,7 @@ def _s_thanhnien(topic,limit=6):
282
  try:
283
  r=req.get(f"https://thanhnien.vn/tim-kiem?q={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=10);soup=BeautifulSoup(r.text,'lxml')
284
  for a in soup.select('h3 a[href], .box-title a')[:limit*2]:
285
- t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
286
  if t and len(t)>15 and _has_kw(topic,t):
287
  if not href.startswith('http'):href='https://thanhnien.vn'+href
288
  items.append({'title':t,'url':href,'via':'Thanh Niên'})
@@ -295,7 +300,7 @@ def _s_tuoitre(topic,limit=6):
295
  try:
296
  r=req.get(f"https://tuoitre.vn/tim-kiem.htm?keywords={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=10);soup=BeautifulSoup(r.text,'lxml')
297
  for a in soup.select('h3 a[href], .box-title-text a')[:limit*2]:
298
- t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
299
  if t and len(t)>15 and _has_kw(topic,t):
300
  if not href.startswith('http'):href='https://tuoitre.vn'+href
301
  items.append({'title':t,'url':href,'via':'Tuổi Trẻ'})
@@ -308,7 +313,7 @@ def _s_thethaovanhoa(topic,limit=5):
308
  try:
309
  r=req.get(f"https://thethaovanhoa.vn/tim-kiem.htm?keyword={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=8);soup=BeautifulSoup(r.text,'lxml')
310
  for a in soup.select('h3 a[href], .title a[href]')[:limit*2]:
311
- t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
312
  if t and len(t)>15 and _has_kw(topic,t):
313
  if not href.startswith('http'):href='https://thethaovanhoa.vn'+href
314
  items.append({'title':t,'url':href,'via':'TT&VH'})
@@ -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,14 +588,9 @@ 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 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,7 +602,6 @@ async def _sh_slug(slug: str, request: Request, url: str = '', title: str = '',
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:
@@ -622,14 +612,10 @@ async def _sh_slug(slug: str, request: Request, url: str = '', title: str = '',
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>
@@ -649,8 +635,6 @@ async def _sh(url:str='',title:str='',img:str='',post_id:str=''):
649
  safe_title = _clean(title) if title else 'VNEWS - Tin tức'
650
  safe_img = _clean(img) if img else ''
651
  safe_url = _clean(url) if url else '/'
652
-
653
- # Try to find wall post by post_id or URL (prioritize posts with slides/video)
654
  post = None
655
  try:
656
  posts = _load_wall_posts()
@@ -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
  elif url:
666
- # Find matching URL - prioritize posts with slides or video
667
  for p in posts:
668
  if p.get('url') == url and p.get('slides'):
669
  post = p
@@ -672,7 +655,6 @@ async def _sh(url:str='',title:str='',img:str='',post_id:str=''):
672
  safe_url = p.get('url', safe_url) or safe_url
673
  break
674
  if not post:
675
- # Fallback: find any matching URL
676
  for p in posts:
677
  if p.get('url') == url:
678
  post = p
@@ -682,14 +664,10 @@ async def _sh(url:str='',title:str='',img:str='',post_id:str=''):
682
  break
683
  except:
684
  pass
685
-
686
  if post and post.get('slides'):
687
  return _render_slides_page(post, safe_title, safe_img, safe_url)
688
-
689
  if post and post.get('video'):
690
  return _render_video_page(post, safe_title, safe_img, safe_url)
691
-
692
- # Fallback: redirect to original URL
693
  return HTMLResponse(f'''<!DOCTYPE html>
694
  <html lang="vi">
695
  <head>
@@ -975,7 +953,6 @@ from urllib.parse import quote as _quote2
975
 
976
  _UA_RW = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept-Language': 'vi-VN,vi;q=0.9'}
977
 
978
- # Unique character markers for language detection
979
  _UNIQUE_CHARS = {
980
  'vietnamese': set('đăâêôơưàảãạáằẳẵặắầẩẫậấèẻẽẹéềễểệếìỉĩịíòỏõọóồổỗộốờởỡợớùủũụúừửữựứỳỷỹỵý'),
981
  'spanish': set('ñáéíóúü¿¡'),
@@ -990,45 +967,32 @@ _STOPWORDS = {
990
  }
991
 
992
  def detect_language(text):
993
- """Detect language from text content using stopword + character analysis."""
994
  if not text:
995
  return 'vietnamese'
996
  text_lower = text.lower()
997
  text_chars = set(text_lower)
998
-
999
- # Strong signal: Vietnamese unique characters
1000
  vn_chars = len(text_chars & _UNIQUE_CHARS['vietnamese'])
1001
  if vn_chars >= 2:
1002
  return 'vietnamese'
1003
-
1004
- # Spanish unique chars (ñ, ¿, ¡)
1005
  es_chars = len(text_chars & _UNIQUE_CHARS['spanish'])
1006
  pt_chars = len(text_chars & _UNIQUE_CHARS['portuguese'])
1007
-
1008
- # Stopword scoring
1009
  words = set(re.findall(r'\b\w+\b', text_lower))
1010
  scores = {}
1011
  for lang, stops in _STOPWORDS.items():
1012
  scores[lang] = len(words & stops) / max(len(stops), 1)
1013
-
1014
- # Disambiguate Portuguese vs Spanish
1015
  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'}
1016
  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'}
1017
-
1018
  pt_overlap = len(words & pt_markers)
1019
  es_overlap = len(words & es_markers)
1020
-
1021
  if scores.get('portuguese', 0) > 0 and pt_overlap > es_overlap:
1022
  return 'portuguese'
1023
  if scores.get('spanish', 0) > 0 and es_overlap > pt_overlap:
1024
  return 'spanish'
1025
  if scores.get('english', 0) > 0.15:
1026
  return 'english'
1027
-
1028
  best = max(scores, key=scores.get)
1029
  return best if scores[best] > 0.05 else 'vietnamese'
1030
 
1031
- # Emotion keyword-based detection
1032
  _EMOTION_KEYWORDS = {
1033
  'happy': {
1034
  'en': ['happy', 'joy', 'wonderful', 'great', 'amazing', 'fantastic', 'love', 'excellent', 'beautiful', 'glad', 'delighted', 'pleased', 'cheerful', 'celebrate', 'victory', 'win', 'success'],
@@ -1039,804 +1003,4 @@ _EMOTION_KEYWORDS = {
1039
  'sad': {
1040
  'en': ['sad', 'unhappy', 'terrible', 'awful', 'horrible', 'miserable', 'depressed', 'grief', 'sorrow', 'tragic', 'unfortunate', 'painful', 'death', 'die', 'kill'],
1041
  'pt': ['triste', 'infeliz', 'terrível', 'horrível', 'miserável', 'deprimido', 'dor', 'trágico', 'infelizmente', 'penoso', 'morte', 'morrer'],
1042
- 'es': ['triste', 'infeliz', 'terrible', 'horrible', 'miserable', 'deprimido', 'dolor', 'trágico', 'desafortunado', 'penoso', 'muerte', 'morir'],
1043
- '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'],
1044
- },
1045
- 'excited': {
1046
- 'en': ['excited', 'thrilling', 'amazing', 'wow', 'incredible', 'unbelievable', 'awesome', 'exhilarating', 'electrifying', 'breathtaking', 'breakthrough', 'record'],
1047
- 'pt': ['animado', 'emocionante', 'incrível', 'impressionante', 'sensacional', 'eletrizante', 'empolgante', 'recorde'],
1048
- 'es': ['emocionante', 'increíble', 'impresionante', 'sensacional', 'electrizante', 'emocionado', 'entusiasmado', 'récord'],
1049
- '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á'],
1050
- },
1051
- 'humorous': {
1052
- 'en': ['funny', 'hilarious', 'joke', 'laugh', 'comedy', 'humor', 'amusing', 'witty', 'sarcastic', 'ironic', 'ridiculous', 'absurd', 'lol', 'haha'],
1053
- 'pt': ['engraçado', 'hilário', 'piada', 'rir', 'comédia', 'humor', 'divertido', 'irônico', 'ridículo', 'absurdo', 'kkk'],
1054
- 'es': ['gracioso', 'hilarante', 'broma', 'risa', 'comedia', 'humor', 'divertido', 'irónico', 'ridículo', 'absurdo', 'jaja'],
1055
- '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'],
1056
- },
1057
- 'serious': {
1058
- 'en': ['serious', 'critical', 'important', 'urgent', 'severe', 'grave', 'significant', 'crucial', 'vital', 'essential', 'alarming', 'concerning', 'crisis', 'war', 'conflict'],
1059
- 'pt': ['sério', 'crítico', 'importante', 'urgente', 'grave', 'significativo', 'crucial', 'vital', 'essencial', 'preocupante', 'crise', 'guerra', 'conflito'],
1060
- 'es': ['serio', 'crítico', 'importante', 'urgente', 'grave', 'significativo', 'crucial', 'vital', 'esencial', 'preocupante', 'crisis', 'guerra', 'conflicto'],
1061
- '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'],
1062
- },
1063
- }
1064
-
1065
- def detect_emotion(text, language='vietnamese'):
1066
- """Detect emotion from text using keyword matching."""
1067
- if not text:
1068
- return 'neutral'
1069
- text_lower = text.lower()
1070
-
1071
- scores = {}
1072
- for emotion, lang_keywords in _EMOTION_KEYWORDS.items():
1073
- keywords = lang_keywords.get(language, lang_keywords.get('en', []))
1074
- score = sum(1 for kw in keywords if kw in text_lower)
1075
- scores[emotion] = score
1076
-
1077
- if max(scores.values()) == 0:
1078
- return 'neutral'
1079
-
1080
- return max(scores, key=scores.get)
1081
-
1082
- def detect_language_and_emotion(title, text):
1083
- """Detect both language and emotion from article content."""
1084
- combined = f"{title} {text}"
1085
- lang = detect_language(combined)
1086
- emotion = detect_emotion(combined, lang)
1087
- return lang, emotion
1088
-
1089
- # Voice selection based on language and emotion (using MultilingualNeural voices)
1090
- VOICE_BY_LANG_EMOTION = {
1091
- 'vietnamese': {
1092
- 'happy': ('vi-VN-HoaiMyNeural', 'vui'),
1093
- 'sad': ('vi-VN-NamMinhNeural', 'buồn'),
1094
- 'excited': ('vi-VN-HoaiMyNeural', 'hào hứng'),
1095
- 'humorous': ('vi-VN-HoaiMyNeural', 'vui'),
1096
- 'serious': ('vi-VN-NamMinhNeural', 'nghiêm túc'),
1097
- 'neutral': ('vi-VN-HoaiMyNeural', 'trung_tinh'),
1098
- },
1099
- 'portuguese': {
1100
- 'happy': ('pt-BR-ThalitaMultilingualNeural', 'feliz'),
1101
- 'sad': ('pt-BR-ThalitaMultilingualNeural', 'triste'),
1102
- 'excited': ('pt-BR-ThalitaMultilingualNeural', 'animado'),
1103
- 'humorous': ('pt-BR-ThalitaMultilingualNeural', 'engraçado'),
1104
- 'serious': ('pt-BR-ThalitaMultilingualNeural', 'sério'),
1105
- 'neutral': ('pt-BR-ThalitaMultilingualNeural', 'neutro'),
1106
- },
1107
- 'english': {
1108
- 'happy': ('en-US-AndrewMultilingualNeural', 'happy'),
1109
- 'sad': ('en-AU-WilliamMultilingualNeural', 'sad'),
1110
- 'excited': ('en-US-AndrewMultilingualNeural', 'excited'),
1111
- 'humorous': ('en-US-AndrewMultilingualNeural', 'funny'),
1112
- 'serious': ('en-AU-WilliamMultilingualNeural', 'serious'),
1113
- 'neutral': ('en-US-AndrewMultilingualNeural', 'neutral'),
1114
- },
1115
- 'french': {
1116
- 'happy': ('fr-FR-VivienneMultilingualNeural', 'heureux'),
1117
- 'sad': ('fr-FR-RemyMultilingualNeural', 'triste'),
1118
- 'excited': ('fr-FR-VivienneMultilingualNeural', 'excité'),
1119
- 'humorous': ('fr-FR-VivienneMultilingualNeural', 'drôle'),
1120
- 'serious': ('fr-FR-RemyMultilingualNeural', 'sérieux'),
1121
- 'neutral': ('fr-FR-VivienneMultilingualNeural', 'neutre'),
1122
- },
1123
- 'german': {
1124
- 'happy': ('de-DE-SeraphinaMultilingualNeural', 'glücklich'),
1125
- 'sad': ('de-DE-FlorianMultilingualNeural', 'traurig'),
1126
- 'excited': ('de-DE-SeraphinaMultilingualNeural', 'aufgeregt'),
1127
- 'humorous': ('de-DE-SeraphinaMultilingualNeural', 'lustig'),
1128
- 'serious': ('de-DE-FlorianMultilingualNeural', 'ernst'),
1129
- 'neutral': ('de-DE-SeraphinaMultilingualNeural', 'neutral'),
1130
- },
1131
- 'korean': {
1132
- 'happy': ('ko-KR-HyunsuMultilingualNeural', '행복'),
1133
- 'sad': ('ko-KR-HyunsuMultilingualNeural', '슬픔'),
1134
- 'excited': ('ko-KR-HyunsuMultilingualNeural', '흥분'),
1135
- 'humorous': ('ko-KR-HyunsuMultilingualNeural', '유쾌'),
1136
- 'serious': ('ko-KR-HyunsuMultilingualNeural', '진지'),
1137
- 'neutral': ('ko-KR-HyunsuMultilingualNeural', '중립'),
1138
- },
1139
- 'italian': {
1140
- 'happy': ('it-IT-GiuseppeMultilingualNeural', 'felice'),
1141
- 'sad': ('it-IT-GiuseppeMultilingualNeural', 'triste'),
1142
- 'excited': ('it-IT-GiuseppeMultilingualNeural', 'emozionato'),
1143
- 'humorous': ('it-IT-GiuseppeMultilingualNeural', 'divertente'),
1144
- 'serious': ('it-IT-GiuseppeMultilingualNeural', 'serio'),
1145
- 'neutral': ('it-IT-GiuseppeMultilingualNeural', 'neutro'),
1146
- },
1147
- }
1148
-
1149
- # All valid voice IDs (new MultilingualNeural format)
1150
- VALID_VOICES = {
1151
- 'vi-VN-HoaiMyNeural', 'vi-VN-NamMinhNeural',
1152
- 'en-US-AndrewMultilingualNeural', 'en-AU-WilliamMultilingualNeural',
1153
- 'pt-BR-ThalitaMultilingualNeural',
1154
- 'fr-FR-VivienneMultilingualNeural', 'fr-FR-RemyMultilingualNeural',
1155
- 'de-DE-SeraphinaMultilingualNeural', 'de-DE-FlorianMultilingualNeural',
1156
- 'ko-KR-HyunsuMultilingualNeural',
1157
- 'it-IT-GiuseppeMultilingualNeural',
1158
- }
1159
-
1160
- def get_voice_for_content(title, text, preferred_voice=None):
1161
- """Get appropriate voice based on content language and emotion."""
1162
- # Accept the new MultilingualNeural voices directly
1163
- if preferred_voice and preferred_voice in VALID_VOICES:
1164
- return preferred_voice
1165
-
1166
- # Also accept old shorthand voice IDs and map them to new format
1167
- old_voice_map = {
1168
- 'hoaimy': 'vi-VN-HoaiMyNeural',
1169
- 'namminh': 'vi-VN-NamMinhNeural',
1170
- 'andrew': 'en-US-AndrewMultilingualNeural',
1171
- 'jenny': 'en-US-AndrewMultilingualNeural',
1172
- 'thalita': 'pt-BR-ThalitaMultilingualNeural',
1173
- 'pt_thalita': 'pt-BR-ThalitaMultilingualNeural',
1174
- 'pt_francisco': 'pt-BR-ThalitaMultilingualNeural',
1175
- 'ela': 'en-US-AndrewMultilingualNeural',
1176
- 'es_carlos': 'en-US-AndrewMultilingualNeural',
1177
- 'denise': 'fr-FR-VivienneMultilingualNeural',
1178
- 'katja': 'de-DE-SeraphinaMultilingualNeural',
1179
- 'nanami': 'en-US-AndrewMultilingualNeural',
1180
- 'sunhee': 'ko-KR-HyunsuMultilingualNeural',
1181
- 'xiaochen': 'en-US-AndrewMultilingualNeural',
1182
- }
1183
- if preferred_voice and preferred_voice in old_voice_map:
1184
- return old_voice_map[preferred_voice]
1185
-
1186
- lang, emotion = detect_language_and_emotion(title, text)
1187
- lang_map = VOICE_BY_LANG_EMOTION.get(lang, VOICE_BY_LANG_EMOTION['vietnamese'])
1188
- voice, _ = lang_map.get(emotion, lang_map['neutral'])
1189
- return voice
1190
-
1191
-
1192
- def _is_relevant_image(img_url, title, text):
1193
- """Check if an image is relevant to the article content."""
1194
- if not img_url:
1195
- return False
1196
- skip_patterns = ['pixel', 'analytics', 'tracking', '1x1.gif', 'spacer.gif',
1197
- 'logo', 'icon', 'avatar', 'emoji', 'smiley', 'sprite',
1198
- 'advertisement', 'ad-banner', 'sponsored', 'banner-ads']
1199
- img_lower = img_url.lower()
1200
- for p in skip_patterns:
1201
- if p in img_lower:
1202
- return False
1203
- if not any(img_lower.endswith(ext) for ext in ['.jpg', '.jpeg', '.png', '.webp', '.gif']):
1204
- return False
1205
- return True
1206
-
1207
-
1208
- def _filter_relevant_images(images, title, text, max_images=8):
1209
- """Filter and rank images by relevance to article content."""
1210
- if not images:
1211
- return []
1212
- seen = set()
1213
- relevant = []
1214
- for img in images:
1215
- if img in seen:
1216
- continue
1217
- seen.add(img)
1218
- if _is_relevant_image(img, title, text):
1219
- relevant.append(img)
1220
- return relevant[:max_images]
1221
-
1222
-
1223
- def _scrape_article_for_rewrite(url):
1224
- """Scrape article: extract title, paragraphs, RELEVANT images, OG image."""
1225
- try:
1226
- r = req.get(url, headers=_UA_RW, timeout=15, allow_redirects=True)
1227
- r.encoding = 'utf-8'
1228
- soup = BeautifulSoup(r.text, 'lxml')
1229
- for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form']):
1230
- tag.decompose()
1231
- h1 = soup.find('h1')
1232
- ogt = soup.find('meta', property='og:title')
1233
- title = (h1.get_text(strip=True) if h1 else '') or (ogt.get('content', '') if ogt else '')
1234
- ogi = soup.find('meta', property='og:image')
1235
- og_img = ogi.get('content', '') if ogi else ''
1236
- if og_img and og_img.startswith('//'):
1237
- og_img = 'https:' + og_img
1238
- block = None
1239
- for sel in ['article', '.singular-content', '.detail-content', '.fck_detail', '.content-detail', '.knc-content', 'main', '.cms-body', '.article__body']:
1240
- el = soup.select_one(sel)
1241
- if el and len(el.find_all('p')) >= 2:
1242
- block = el
1243
- break
1244
- if not block:
1245
- block = soup.body or soup
1246
- paragraphs = []
1247
- all_images = []
1248
- seen_imgs = set()
1249
- if og_img and og_img not in seen_imgs:
1250
- all_images.append(og_img)
1251
- seen_imgs.add(og_img)
1252
- for el in block.find_all(['p', 'h2', 'h3', 'figure', 'img'], recursive=True):
1253
- if el.name == 'p':
1254
- t = _clean(el.get_text(strip=True))
1255
- if t and len(t) > 40:
1256
- paragraphs.append(t)
1257
- elif el.name in ('figure', 'img'):
1258
- im = el if el.name == 'img' else el.find('img')
1259
- if im:
1260
- src = im.get('data-src') or im.get('src') or im.get('data-original') or ''
1261
- if src and 'base64' not in src:
1262
- if src.startswith('//'):
1263
- src = 'https:' + src
1264
- if src not in seen_imgs:
1265
- all_images.append(src)
1266
- seen_imgs.add(src)
1267
- # Filter to relevant images only
1268
- relevant_images = _filter_relevant_images(all_images, title, ' '.join(paragraphs[:5]))
1269
- return {'title': _clean(title), 'paragraphs': paragraphs, 'images': relevant_images, 'og_img': og_img}
1270
- except Exception:
1271
- return None
1272
-
1273
-
1274
- def _extract_key_points_rw(paragraphs, max_points=5):
1275
- r"""Extract key points from paragraphs - extracts ALL sentences, not just first one.
1276
-
1277
- Fixes: Original regex `^(.+?[.!?])\s` only captured first sentence per paragraph.
1278
- Now splits on all sentence boundaries and takes valid sentences until max_points.
1279
- """
1280
- points = []
1281
-
1282
- for p in paragraphs:
1283
- if len(points) >= max_points:
1284
- break
1285
-
1286
- p = _clean(p)
1287
- if not p:
1288
- continue
1289
-
1290
- # Split paragraph into sentences using Vietnamese + English punctuation
1291
- sentences = re.split(r'(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])', p)
1292
- sentences = [s.strip() for s in sentences if s.strip()]
1293
-
1294
- for sentence in sentences:
1295
- if len(points) >= max_points:
1296
- break
1297
-
1298
- # Clean sentence - remove extra whitespace
1299
- sentence = _clean(sentence)
1300
-
1301
- if len(sentence) < 30:
1302
- continue
1303
-
1304
- # Check for duplicates
1305
- if any(sentence[:60] in existing for existing in points):
1306
- continue
1307
-
1308
- # Ensure sentence ends with punctuation
1309
- if not sentence.endswith(('.', '!', '?')):
1310
- sentence = sentence + '.'
1311
-
1312
- points.append(sentence)
1313
-
1314
- # If no valid sentences found, take chunks from raw text
1315
- if not points:
1316
- raw = '\n'.join(paragraphs)
1317
- for i in range(0, min(len(raw), max_points * 300), 280):
1318
- chunk = _clean(raw[i:i+280])
1319
- if len(chunk) >= 30 and chunk not in points:
1320
- points.append(chunk + ('.' if not chunk.endswith('.') else ''))
1321
- if len(points) >= max_points:
1322
- break
1323
-
1324
- return points
1325
-
1326
-
1327
- @app.post("/api/rewrite_slide")
1328
- async def api_rewrite_slide(request: Request):
1329
- """Fast rewrite as SLIDES - no AI needed, instant response."""
1330
- body = await request.json()
1331
- url = _clean(body.get("url", ""))
1332
- context = body.get("context", "")
1333
- preferred_voice = body.get("voice", "") # Accept custom voice selection
1334
- if not url and not context:
1335
- return JSONResponse({"error": "Cần URL hoặc nội dung"}, status_code=400)
1336
- data = None
1337
- if url and url.startswith("http"):
1338
- data = _scrape_article_for_rewrite(url)
1339
- if not data and context:
1340
- paragraphs = [_clean(p) for p in context.split('\n') if len(_clean(p)) > 40]
1341
- data = {'title': paragraphs[0][:80] if paragraphs else 'Bài viết', 'paragraphs': paragraphs, 'images': [], 'og_img': ''}
1342
- if not data or not data.get('paragraphs'):
1343
- return JSONResponse({"error": "Không đọc được bài viết"}, status_code=422)
1344
- points = _extract_key_points_rw(data['paragraphs'], max_points=12)
1345
- if not points:
1346
- return JSONResponse({"error": "Không tìm được ý chính"}, status_code=422)
1347
- images = data.get('images', [])
1348
- slides = []
1349
- for i, point in enumerate(points):
1350
- img = images[i] if i < len(images) else (images[-1] if images else '')
1351
- if img and 'cdnphoto.dantri' in img:
1352
- img = '/api/proxy/img?url=' + _quote2(img, safe='')
1353
- slides.append({'text': point, 'image': img, 'index': i + 1})
1354
- summary_text = '\n\n'.join([f"• {s['text']}" for s in slides])
1355
-
1356
- # Auto-detect language and emotion
1357
- lang, emotion = detect_language_and_emotion(data['title'], summary_text)
1358
- # Use preferred voice if provided, otherwise auto-detect
1359
- voice = preferred_voice if preferred_voice else get_voice_for_content(data['title'], summary_text)
1360
-
1361
- post = {
1362
- "id": str(int(time.time() * 1000)) + str(_random2.randint(100, 999)),
1363
- "title": data['title'],
1364
- "text": summary_text,
1365
- "img": images[0] if images else '',
1366
- "url": url,
1367
- "kind": "slide_summary",
1368
- "slides": slides,
1369
- "images": images[:10],
1370
- "video": "",
1371
- "voice": voice,
1372
- "emotion": emotion,
1373
- "language": lang,
1374
- "ts": int(time.time())
1375
- }
1376
- posts = _load_wall_posts()
1377
- posts.insert(0, post)
1378
- _save_wall_posts(posts)
1379
- return JSONResponse({"post": post, "slides": slides})
1380
-
1381
-
1382
- @app.post("/api/rewrite_share")
1383
- async def api_rewrite_share(request: Request):
1384
- """Rewrite article and post to Tường AI with SLIDES + AI text."""
1385
- body = await request.json()
1386
- url = _clean(body.get("url", ""))
1387
- ctx = _clean(body.get("context", ""))
1388
- preferred_voice = body.get("voice", "") # Accept custom voice selection
1389
- if not url and not ctx:
1390
- return JSONResponse({"error": "Cần URL hoặc nội dung"}, status_code=400)
1391
- data = None
1392
- if url and url.startswith("http"):
1393
- data = _scrape_article_for_rewrite(url)
1394
- if not data and ctx:
1395
- paragraphs = [_clean(p) for p in ctx.split('\n') if len(_clean(p)) > 40]
1396
- data = {'title': paragraphs[0][:80] if paragraphs else 'Bài viết', 'paragraphs': paragraphs, 'images': [], 'og_img': ''}
1397
- if not data or not data.get('paragraphs'):
1398
- return JSONResponse({"error": "Không đọc được bài viết"}, status_code=422)
1399
- raw_text = '\n'.join(data['paragraphs'])
1400
- if len(raw_text) < 50:
1401
- raw_text = ctx[:14000]
1402
- if len(raw_text) < 50:
1403
- return JSONResponse({"error": "Bài viết quá ngắn"}, status_code=422)
1404
- domain = ''
1405
- try:
1406
- from urllib.parse import urlparse
1407
- domain = urlparse(url).netloc.replace('www.', '')
1408
- except:
1409
- pass
1410
-
1411
- # Generate AI summary text
1412
- ai_text = None
1413
- try:
1414
- import ai_ext
1415
- if hasattr(ai_ext, 'qwen_generate'):
1416
- 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.'
1417
- ai_text = await ai_ext.qwen_generate(prompt, max_tokens=1000)
1418
- except Exception:
1419
- pass
1420
- if not ai_text or len(ai_text) < 80:
1421
- key_pts = _extract_key_points_rw(data['paragraphs'], max_points=12)
1422
- if key_pts:
1423
- ai_text = '\n\n'.join([f"• {p}" for p in key_pts])
1424
- else:
1425
- ai_text = f"Tóm tắt: {data['title']}\n\n{raw_text[:1200]}\n\nNguồn: {domain}"
1426
-
1427
- # Build slides from key points (FIX: include slides in rewrite_share too!)
1428
- points = _extract_key_points_rw(data['paragraphs'], max_points=12)
1429
- images = data.get('images', [])
1430
- slides = []
1431
- for i, point in enumerate(points):
1432
- img = images[i] if i < len(images) else (images[-1] if images else '')
1433
- if img and 'cdnphoto.dantri' in img:
1434
- img = '/api/proxy/img?url=' + _quote2(img, safe='')
1435
- slides.append({'text': point, 'image': img, 'index': i + 1})
1436
-
1437
- # Auto-detect language and emotion
1438
- lang, emotion = detect_language_and_emotion(data['title'], ai_text)
1439
- # Use preferred voice if provided, otherwise auto-detect
1440
- voice = preferred_voice if preferred_voice else get_voice_for_content(data['title'], ai_text)
1441
-
1442
- post = {
1443
- "id": str(int(time.time() * 1000)) + str(_random2.randint(100, 999)),
1444
- "title": data['title'],
1445
- "text": ai_text,
1446
- "img": images[0] if images else '',
1447
- "url": url,
1448
- "kind": "rewrite",
1449
- "slides": slides,
1450
- "images": images[:10],
1451
- "video": "",
1452
- "voice": voice,
1453
- "emotion": emotion,
1454
- "language": lang,
1455
- "ts": int(time.time())
1456
- }
1457
- posts = _load_wall_posts()
1458
- posts.insert(0, post)
1459
- _save_wall_posts(posts)
1460
- return JSONResponse({"post": post, "slides": slides})
1461
-
1462
-
1463
- @app.post("/api/url_wall")
1464
- async def api_url_wall(request: Request):
1465
- """Submit URL to add to Tường AI."""
1466
- body = await request.json()
1467
- url = _clean(body.get("url", ""))
1468
- if not url or not url.startswith('http'):
1469
- return JSONResponse({"error": "URL không hợp lệ"}, status_code=400)
1470
- # Reuse rewrite_share logic
1471
- req._body = json.dumps({"url": url}).encode()
1472
- return await api_rewrite_share(request)
1473
-
1474
-
1475
- def _bg():
1476
- time.sleep(15)
1477
- while True:
1478
- try:get_wc2026_all()
1479
- except:pass
1480
- time.sleep(90)
1481
- threading.Thread(target=_bg,daemon=True).start()
1482
-
1483
- # ===== AUTO SCHEDULER: rewrite AI + short at 7/13/19 VN time =====
1484
- _AUTO_SCHEDULE_TIMES = [(7, '07:00'), (13, '13:00'), (19, '19:00')]
1485
- _AUTO_LOG = os.path.join(DATA_DIR, 'auto_rewrite_log.json')
1486
-
1487
- def _load_auto_log():
1488
- try:
1489
- if os.path.exists(_AUTO_LOG):
1490
- with open(_AUTO_LOG, 'r') as f:
1491
- return json.load(f)
1492
- except: pass
1493
- return {}
1494
-
1495
- def _save_auto_log(log):
1496
- try:
1497
- tmp = _AUTO_LOG + '.tmp'
1498
- with open(tmp, 'w') as f:
1499
- json.dump(log, f)
1500
- os.replace(tmp, _AUTO_LOG)
1501
- except: pass
1502
-
1503
- async def _auto_fetch_short(post_id):
1504
- """Try to auto-generate a short for a post."""
1505
- try:
1506
- import httpx
1507
- async with httpx.AsyncClient(timeout=180) as cl:
1508
- r = await cl.post(
1509
- f"http://localhost:7860/api/ai/short/{post_id}",
1510
- json={"voice":"vi-VN-HoaiMyNeural","emotion":"neutral","speed":1.2},
1511
- headers={"Content-Type":"application/json"}
1512
- )
1513
- if r.status_code < 300:
1514
- sj = r.json()
1515
- if sj.get('video'):
1516
- posts = _load_wall_posts()
1517
- for p in posts:
1518
- if p.get('id') == post_id:
1519
- p['video'] = sj['video']
1520
- break
1521
- _save_wall_posts(posts)
1522
- return True
1523
- except: pass
1524
- return False
1525
-
1526
- async def _auto_rewrite_one(topic, slot_label, used_urls=None, post_index=0):
1527
- """Rewrite one topic: find articles, summarize, post to wall, trigger short.
1528
- used_urls: shared set to avoid duplicate articles across topics.
1529
- post_index: 0-based index to create multiple posts per topic (0,1,2 = up to 3 posts)."""
1530
- from urllib.parse import quote as _q
1531
- # Get MORE items to support 1-3 posts per topic
1532
- items = _search_all(topic, limit=12)
1533
- # Skip URLs already used by another topic
1534
- if used_urls is not None:
1535
- filtered = [it for it in items if it.get('url') not in used_urls]
1536
- if filtered:
1537
- items = filtered
1538
- if not items or post_index >= len(items):
1539
- return False
1540
-
1541
- # Get article at post_index (0,1,2 for multiple posts)
1542
- item = items[post_index] # post_index allows multiple articles per topic
1543
- url = item.get('url', '')
1544
- title = item.get('title', topic)
1545
- if url and used_urls is not None:
1546
- used_urls.add(url)
1547
- if not url.startswith('http'):
1548
- return False
1549
-
1550
- data = _scrape_article_for_rewrite(url)
1551
- if not data or not data.get('paragraphs'):
1552
- return False
1553
-
1554
- raw_text = '\n'.join(data['paragraphs'])
1555
- ai_text = None
1556
-
1557
- # Try AI generation
1558
- try:
1559
- import ai_ext
1560
- 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."
1561
- ai_text = await ai_ext.qwen_generate(prompt, max_tokens=1000)
1562
- except: pass
1563
-
1564
- if not ai_text or len(ai_text) < 80:
1565
- pts = data['paragraphs'][:6]
1566
- ai_text = '\n\n'.join([f"• {p[:300]}" for p in pts])
1567
- via = item.get('via', '') or urlparse(url).netloc.replace('www.', '')
1568
- ai_text += f"\n\nNguồn tham khảo: {via}"
1569
-
1570
- # Build slides
1571
- images = data.get('images', [])
1572
- pts = data['paragraphs'][:10]
1573
- slides = []
1574
- for i, p in enumerate(pts[:8]):
1575
- img = images[i] if i < len(images) else (images[-1] if images else data.get('og_img', ''))
1576
- slides.append({'text': p[:300], 'image': img, 'index': i + 1})
1577
-
1578
- post_id = str(int(time.time() * 1000)) + str(_random2.randint(100, 999))
1579
- post = {
1580
- "id": post_id, "title": data.get('title', title)[:200],
1581
- "text": ai_text, "img": images[0] if images else data.get('og_img', ''),
1582
- "url": url, "kind": "auto_rewrite", "slides": slides,
1583
- "images": images[:10], "video": "",
1584
- "voice": "vi-VN-HoaiMyNeural", "emotion": "neutral",
1585
- "language": "vietnamese", "ts": int(time.time()),
1586
- "auto_scheduled": True, "slot": slot_label,
1587
- }
1588
-
1589
- posts = _load_wall_posts()
1590
- posts.insert(0, post)
1591
- _save_wall_posts(posts)
1592
-
1593
- # Trigger short generation async
1594
- threading.Thread(target=lambda: asyncio.run(_auto_fetch_short(post_id)), daemon=True).start()
1595
- return True
1596
-
1597
- async def _do_scheduled_run(slot_label):
1598
- """Main scheduled run: 1-3 posts from 3 different HOT topics (3-9 total), no duplicates."""
1599
- print(f"[auto] Starting scheduled rewrite for {slot_label}")
1600
-
1601
- # Get top hot topics, skip duplicates
1602
- all_topics = _get_hot_topics()
1603
- seen_topics = set()
1604
- unique_topics = []
1605
- for t in all_topics:
1606
- kw = t.get('topic', '').lower().strip()
1607
- if kw and len(kw) > 5 and kw not in seen_topics:
1608
- is_dup = False
1609
- for s in seen_topics:
1610
- # Check if one topic is substring of another
1611
- if kw in s or s in kw:
1612
- is_dup = True
1613
- break
1614
- if not is_dup:
1615
- seen_topics.add(kw)
1616
- unique_topics.append(t)
1617
- if len(unique_topics) >= 3:
1618
- break
1619
-
1620
- job_topics = [t['topic'] for t in unique_topics[:3] if t.get('topic')]
1621
- if not job_topics:
1622
- print(f"[auto] No hot topics found, skipping")
1623
- return
1624
-
1625
- print(f"[auto] Running 3 topics: {job_topics}")
1626
-
1627
- # Track used URLs to avoid cross-topic duplicates
1628
- _used_urls = set()
1629
- results = []
1630
-
1631
- # Process each topic, create 1-3 posts per topic
1632
- for jt in job_topics:
1633
- for post_idx in range(3): # Try up to 3 posts per topic
1634
- try:
1635
- ok = await asyncio.wait_for(_auto_rewrite_one(jt, slot_label, _used_urls, post_idx), timeout=120)
1636
- if ok:
1637
- results.append((jt, post_idx, True))
1638
- print(f"[auto] Created post {post_idx+1} for '{jt}'")
1639
- else:
1640
- # No more articles for this topic
1641
- break
1642
- except Exception as e:
1643
- print(f"[auto] Error on '{jt}' post {post_idx}: {e}")
1644
- results.append((jt, post_idx, False))
1645
- await asyncio.sleep(1) # Small delay between posts
1646
-
1647
- # Ensure at least 3 posts total (fallback if needed)
1648
- successful_posts = sum(1 for _, _, ok in results if ok)
1649
- print(f"[auto] Done {slot_label}: {successful_posts} posts created")
1650
-
1651
- # Log
1652
- from datetime import datetime, timezone, timedelta
1653
- VN_TZ_SCHED = timezone(timedelta(hours=7))
1654
- today_str = datetime.now(VN_TZ_SCHED).strftime('%Y-%m-%d')
1655
- log = _load_auto_log()
1656
- if today_str not in log: log[today_str] = {}
1657
- log[today_str][slot_label] = {
1658
- 'time': datetime.now(VN_TZ_SCHED).strftime('%H:%M:%S'),
1659
- 'count': successful_posts,
1660
- 'total': len(job_topics),
1661
- }
1662
- _save_auto_log(log)
1663
-
1664
- def _scheduler_loop():
1665
- """Check every 60s; trigger at 7:00, 13:00, 19:00 VN time.
1666
- On startup, check for any missed slots today and run them immediately."""
1667
- time.sleep(35)
1668
- from datetime import datetime, timezone, timedelta
1669
- VN_TZ_SCHED = timezone(timedelta(hours=7))
1670
-
1671
- _last_run_date = ""
1672
- _last_run_slots = set()
1673
-
1674
- # On startup: check log for missed slots today
1675
- try:
1676
- start_now = datetime.now(VN_TZ_SCHED)
1677
- today_str = start_now.strftime('%Y-%m-%d')
1678
- current_hour = start_now.hour
1679
- current_minute = start_now.minute
1680
- log = _load_auto_log()
1681
- today_log = log.get(today_str, {})
1682
- for h, label in _AUTO_SCHEDULE_TIMES:
1683
- # Run if slot is past (either strictly earlier hour, or same hour but window has passed)
1684
- should_run = False
1685
- if h < current_hour:
1686
- should_run = True
1687
- elif h == current_hour and current_minute > 10:
1688
- should_run = True
1689
- if should_run and label not in today_log:
1690
- print(f"[auto] Detected missed slot {label} (h={h} < now={current_hour}:{current_minute}), running catch-up now")
1691
- _run_scheduled_sync(label)
1692
- _last_run_slots.add(label)
1693
- except Exception as e:
1694
- print(f"[auto] Catch-up check error: {e}")
1695
-
1696
- while True:
1697
- try:
1698
- now = datetime.now(VN_TZ_SCHED)
1699
- today = now.strftime('%Y-%m-%d')
1700
- hour = now.hour
1701
- minute = now.minute
1702
-
1703
- if today != _last_run_date:
1704
- _last_run_date = today
1705
- _last_run_slots = set()
1706
-
1707
- slot = None
1708
- for h, label in _AUTO_SCHEDULE_TIMES:
1709
- if hour == h and 0 <= minute < 5:
1710
- slot = label
1711
- break
1712
-
1713
- if slot and slot not in _last_run_slots:
1714
- _last_run_slots.add(slot)
1715
- _run_scheduled_sync(slot)
1716
- except Exception as e:
1717
- print(f"[auto] Loop error: {e}")
1718
-
1719
- time.sleep(60)
1720
-
1721
- threading.Thread(target=_scheduler_loop, daemon=True, name='auto-rewrite-scheduler').start()
1722
-
1723
- @app.get('/api/debug/auto_schedule')
1724
- async def debug_auto_schedule(slot: str = '07:00'):
1725
- """Manually trigger auto scheduler for debugging."""
1726
- try:
1727
- # Check if we can access the data directory
1728
- log = _load_auto_log()
1729
- topics = _get_hot_topics()[:3]
1730
- job_topics = [t['topic'] for t in topics if t.get('topic')]
1731
- return JSONResponse({
1732
- "slot": slot,
1733
- "log": log,
1734
- "hot_topics": job_topics,
1735
- "wall_posts_count": len(_load_wall_posts()),
1736
- "data_dir_writable": os.access(DATA_DIR, os.W_OK) if os.path.isdir(DATA_DIR) else False,
1737
- "data_dir_exists": os.path.isdir(DATA_DIR),
1738
- })
1739
- except Exception as e:
1740
- return JSONResponse({"error": str(e)}, status_code=500)
1741
-
1742
- def _run_scheduled_sync(slot):
1743
- """Run _do_scheduled_run in a separate event loop (for background thread)."""
1744
- loop = asyncio.new_event_loop()
1745
- asyncio.set_event_loop(loop)
1746
- try:
1747
- loop.run_until_complete(_do_scheduled_run(slot))
1748
- except Exception as e:
1749
- print(f"[auto] Background run error: {e}")
1750
- finally:
1751
- loop.close()
1752
-
1753
- @app.get('/api/debug/trigger_auto')
1754
- async def debug_trigger_auto(slot: str = '19:00'):
1755
- """Trigger _do_scheduled_run in background thread (non-blocking)."""
1756
- threading.Thread(target=_run_scheduled_sync, args=(slot,), daemon=True).start()
1757
- return JSONResponse({"status": "started", "slot": slot})
1758
-
1759
- # ===== SHORTS RSS PROXY ENDPOINT =====
1760
- @app.get("/api/shorts/rss")
1761
- def shorts_rss():
1762
- """Get shorts from YouTube RSS feeds server-side"""
1763
- import xml.etree.ElementTree as ET
1764
- import html as html_lib2
1765
- import re as re2
1766
-
1767
- YOUTUBE_CHANNELS = {
1768
- "baodantri7941": "UC_x5TKhOgd6GhYvv5z4I3jg",
1769
- "baosuckhoedoisongboyte": "UCBsY5fXTQLkF_JnH9kLkL4g",
1770
- }
1771
-
1772
- shorts = []
1773
- seen = set()
1774
-
1775
- for handle, channel_id in YOUTUBE_CHANNELS.items():
1776
- try:
1777
- rss_url = f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"
1778
- r = req.get(rss_url, headers=HEADERS, timeout=15)
1779
- if r.status_code != 200:
1780
- continue
1781
-
1782
- root = ET.fromstring(r.text)
1783
- ns = {
1784
- 'atom': 'http://www.w3.org/2005/Atom',
1785
- 'yt': 'http://www.youtube.com/xml/schemas/2015',
1786
- 'media': 'http://search.yahoo.com/mrss/'
1787
- }
1788
-
1789
- for entry in root.findall('atom:entry', ns)[:30]:
1790
- title_el = entry.find('atom:title', ns)
1791
- title = html_lib2.unescape(title_el.text) if title_el is not None and title_el.text else ''
1792
-
1793
- link_el = entry.find('atom:link', ns)
1794
- link = link_el.get('href', '') if link_el is not None else ''
1795
-
1796
- vid_el = entry.find('yt:videoId', ns)
1797
- vid = vid_el.text if vid_el is not None else ''
1798
-
1799
- if not vid or vid in seen:
1800
- continue
1801
-
1802
- # Check if it's a short
1803
- is_short = '#shorts' in title.lower() or '#short' in title.lower() or '/shorts/' in link
1804
-
1805
- if not is_short:
1806
- desc_el = entry.find('media:description', ns)
1807
- if desc_el is not None and desc_el.text:
1808
- if '#shorts' in desc_el.text.lower():
1809
- is_short = True
1810
-
1811
- if not is_short:
1812
- continue
1813
-
1814
- seen.add(vid)
1815
-
1816
- # Get thumbnail
1817
- thumb = f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg"
1818
- media_group = entry.find('media:group', ns)
1819
- if media_group is not None:
1820
- thumb_el = media_group.find('media:thumbnail', ns)
1821
- if thumb_el is not None:
1822
- thumb = thumb_el.get('url', thumb)
1823
-
1824
- shorts.append({
1825
- 'id': vid,
1826
- 'title': title.replace('#shorts', '').replace('#short', '').strip()[:120],
1827
- 'img': thumb,
1828
- 'link': f'https://www.youtube.com/shorts/{vid}',
1829
- 'channel': handle,
1830
- 'source': 'yt'
1831
- })
1832
-
1833
- if len(shorts) >= 40:
1834
- break
1835
-
1836
- except Exception as e:
1837
- print(f"RSS error for {handle}: {e}")
1838
- continue
1839
-
1840
- return {"shorts": shorts, "count": len(shorts)}
1841
-
1842
- app.mount('/static',StaticFiles(directory=STATIC_DIR),name='vnews_static')
 
12
  except Exception as e:
13
  print(f"[WARN] ai_patch import failed: {e}")
14
 
15
+ try:
16
+ import rewrite_slide
17
+ except Exception as e:
18
+ print(f"[WARN] rewrite_slide import failed: {e}")
19
+
20
  from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, Response
21
  from fastapi.staticfiles import StaticFiles
22
  from starlette.routing import Mount
 
287
  try:
288
  r=req.get(f"https://thanhnien.vn/tim-kiem?q={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=10);soup=BeautifulSoup(r.text,'lxml')
289
  for a in soup.select('h3 a[href], .box-title a')[:limit*2]:
290
+ t=_clean(a.get_text(strip=True));href=a.get('href','')
291
  if t and len(t)>15 and _has_kw(topic,t):
292
  if not href.startswith('http'):href='https://thanhnien.vn'+href
293
  items.append({'title':t,'url':href,'via':'Thanh Niên'})
 
300
  try:
301
  r=req.get(f"https://tuoitre.vn/tim-kiem.htm?keywords={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=10);soup=BeautifulSoup(r.text,'lxml')
302
  for a in soup.select('h3 a[href], .box-title-text a')[:limit*2]:
303
+ t=_clean(a.get_text(strip=True));href=a.get('href','')
304
  if t and len(t)>15 and _has_kw(topic,t):
305
  if not href.startswith('http'):href='https://tuoitre.vn'+href
306
  items.append({'title':t,'url':href,'via':'Tuổi Trẻ'})
 
313
  try:
314
  r=req.get(f"https://thethaovanhoa.vn/tim-kiem.htm?keyword={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=8);soup=BeautifulSoup(r.text,'lxml')
315
  for a in soup.select('h3 a[href], .title a[href]')[:limit*2]:
316
+ t=_clean(a.get_text(strip=True));href=a.get('href','')
317
  if t and len(t)>15 and _has_kw(topic,t):
318
  if not href.startswith('http'):href='https://thethaovanhoa.vn'+href
319
  items.append({'title':t,'url':href,'via':'TT&VH'})
 
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 url:
 
602
  safe_img = p.get('img', safe_img) or safe_img
603
  safe_url = p.get('url', safe_url) or safe_url
604
  break
 
605
  if not post and url:
606
  for p in posts:
607
  if p.get('url') == url:
 
612
  break
613
  except:
614
  pass
 
615
  if post and post.get('slides'):
616
  return _render_slides_page(post, safe_title, safe_img, safe_url)
 
617
  if post and post.get('video'):
618
  return _render_video_page(post, safe_title, safe_img, safe_url)
 
 
619
  return HTMLResponse(f'''<!DOCTYPE html>
620
  <html lang="vi">
621
  <head>
 
635
  safe_title = _clean(title) if title else 'VNEWS - Tin tức'
636
  safe_img = _clean(img) if img else ''
637
  safe_url = _clean(url) if url else '/'
 
 
638
  post = None
639
  try:
640
  posts = _load_wall_posts()
 
647
  safe_url = p.get('url', safe_url) or safe_url
648
  break
649
  elif url:
 
650
  for p in posts:
651
  if p.get('url') == url and p.get('slides'):
652
  post = p
 
655
  safe_url = p.get('url', safe_url) or safe_url
656
  break
657
  if not post:
 
658
  for p in posts:
659
  if p.get('url') == url:
660
  post = p
 
664
  break
665
  except:
666
  pass
 
667
  if post and post.get('slides'):
668
  return _render_slides_page(post, safe_title, safe_img, safe_url)
 
669
  if post and post.get('video'):
670
  return _render_video_page(post, safe_title, safe_img, safe_url)
 
 
671
  return HTMLResponse(f'''<!DOCTYPE html>
672
  <html lang="vi">
673
  <head>
 
953
 
954
  _UA_RW = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept-Language': 'vi-VN,vi;q=0.9'}
955
 
 
956
  _UNIQUE_CHARS = {
957
  'vietnamese': set('đăâêôơưàảãạáằẳẵặắầẩẫậấèẻẽẹéềễểệếìỉĩịíòỏõọóồổỗộốờởỡợớùủũụúừửữựứỳỷỹỵý'),
958
  'spanish': set('ñáéíóúü¿¡'),
 
967
  }
968
 
969
  def detect_language(text):
 
970
  if not text:
971
  return 'vietnamese'
972
  text_lower = text.lower()
973
  text_chars = set(text_lower)
 
 
974
  vn_chars = len(text_chars & _UNIQUE_CHARS['vietnamese'])
975
  if vn_chars >= 2:
976
  return 'vietnamese'
 
 
977
  es_chars = len(text_chars & _UNIQUE_CHARS['spanish'])
978
  pt_chars = len(text_chars & _UNIQUE_CHARS['portuguese'])
 
 
979
  words = set(re.findall(r'\b\w+\b', text_lower))
980
  scores = {}
981
  for lang, stops in _STOPWORDS.items():
982
  scores[lang] = len(words & stops) / max(len(stops), 1)
 
 
983
  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'}
984
  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'}
 
985
  pt_overlap = len(words & pt_markers)
986
  es_overlap = len(words & es_markers)
 
987
  if scores.get('portuguese', 0) > 0 and pt_overlap > es_overlap:
988
  return 'portuguese'
989
  if scores.get('spanish', 0) > 0 and es_overlap > pt_overlap:
990
  return 'spanish'
991
  if scores.get('english', 0) > 0.15:
992
  return 'english'
 
993
  best = max(scores, key=scores.get)
994
  return best if scores[best] > 0.05 else 'vietnamese'
995
 
 
996
  _EMOTION_KEYWORDS = {
997
  'happy': {
998
  'en': ['happy', 'joy', 'wonderful', 'great', 'amazing', 'fantastic', 'love', 'excellent', 'beautiful', 'glad', 'delighted', 'pleased', 'cheerful', 'celebrate', 'victory', 'win', 'success'],
 
1003
  'sad': {
1004
  'en': ['sad', 'unhappy', 'terrible', 'awful', 'horrible', 'miserable', 'depressed', 'grief', 'sorrow', 'tragic', 'unfortunate', 'painful', 'death', 'die', 'kill'],
1005
  'pt': ['triste', 'infeliz', 'terrível', 'horrível', 'miserável', 'deprimido', 'dor', 'trágico', 'infelizmente', 'penoso', 'morte', 'morrer'],
1006
+ 'es': ['triste', 'infeliz', 'terrible', 'horrible', 'miserab