bep40 commited on
Commit
2f6c478
·
verified ·
1 Parent(s): cbfc069

Restore 100% to commit 57980de0 - 8 files reverted

Browse files

Restore all files to match commit 57980de0: revert app_v2_entry.py, auto_scheduler.py, static/hot_multi.js, static/auto_update.js, static/hot_ai_prepend.js, .restart_trigger, RESTART_TRIGGER.md, restart.txt

.restart_trigger CHANGED
@@ -1 +1 @@
1
- Rebuild: fix hot_multi.js - ép 2 chủ đề AI vào 5 hot topics - 2026-07-19 17:30
 
1
+ restart v5.1 - auto-scheduler trigger endpoint added
RESTART_TRIGGER.md CHANGED
@@ -1 +1,6 @@
1
- $(date -u +%s)
 
 
 
 
 
 
1
+ trigger rebuild 2026-07-18T10:35 +0700 - add missing ai/short/ and ai/short-file/ endpoints
2
+
3
+ - Added POST /api/ai/short/{post_id} endpoint (was lost during route cleanup)
4
+ - Added GET /api/ai/short-file/{file_id} endpoint (file serving)
5
+ - Both were supposed to be in ai_patch.py but never existed there
6
+ - Also added FileResponse import
app_v2_entry.py CHANGED
@@ -22,6 +22,9 @@ import re, html as html_lib, json, threading, time, uuid
22
  from concurrent.futures import ThreadPoolExecutor, as_completed
23
  from urllib.parse import quote
24
  import asyncio
 
 
 
25
 
26
  HL_LEAGUES['friendly'] = {"path": "giai-khac/friendly", "name": "Giao hữu", "emoji": "🤝"}
27
 
@@ -213,6 +216,20 @@ def _has_kw(topic,title):
213
  if not words:return True
214
  return any(w in tt for w in words)
215
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
216
  def _s_vnexpress(topic,limit=8):
217
  items=[]
218
  try:
@@ -221,7 +238,10 @@ def _s_vnexpress(topic,limit=8):
221
  a=art.select_one('h2 a, h3 a')
222
  if a and a.get('href'):
223
  t=_clean(a.get('title','') or a.get_text(strip=True))
224
- if _has_kw(topic,t):items.append({'title':t,'url':a['href'],'via':'VnExpress'})
 
 
 
225
  except:pass
226
  return items
227
 
@@ -233,7 +253,10 @@ def _s_dantri(topic,limit=8):
233
  t=_clean(a.get_text(strip=True));href=a.get('href','')
234
  if t and len(t)>15 and _has_kw(topic,t):
235
  if not href.startswith('http'):href='https://dantri.com.vn'+href
236
- items.append({'title':t,'url':href,'via':'Dân Trí'})
 
 
 
237
  if len(items)>=limit:break
238
  except:pass
239
  return items
@@ -246,7 +269,10 @@ def _s_vietnamnet(topic,limit=6):
246
  t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
247
  if t and len(t)>15 and _has_kw(topic,t):
248
  if not href.startswith('http'):href='https://vietnamnet.vn'+href
249
- items.append({'title':t,'url':href,'via':'VietNamNet'})
 
 
 
250
  if len(items)>=limit:break
251
  except:pass
252
  return items
@@ -259,7 +285,10 @@ def _s_bongda(topic,limit=5):
259
  t=_clean(a.get_text(strip=True));href=a.get('href','')
260
  if t and len(t)>15 and _has_kw(topic,t):
261
  if not href.startswith('http'):href='https://bongda.com.vn'+href
262
- items.append({'title':t,'url':href,'via':'Bóng Đá'})
 
 
 
263
  if len(items)>=limit:break
264
  except:pass
265
  return items
@@ -272,7 +301,10 @@ def _s_genk(topic,limit=5):
272
  t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
273
  if t and len(t)>15 and _has_kw(topic,t):
274
  if href.startswith('/'):href='https://genk.vn'+href
275
- items.append({'title':t,'url':href,'via':'GenK'})
 
 
 
276
  if len(items)>=limit:break
277
  except:pass
278
  return items
@@ -285,7 +317,10 @@ def _s_thanhnien(topic,limit=6):
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'})
 
 
 
289
  if len(items)>=limit:break
290
  except:pass
291
  return items
@@ -295,10 +330,13 @@ 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ẻ'})
 
 
 
302
  if len(items)>=limit:break
303
  except:pass
304
  return items
@@ -308,10 +346,13 @@ 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'})
 
 
 
315
  if len(items)>=limit:break
316
  except:pass
317
  return items
@@ -329,7 +370,7 @@ def _search_all(topic,limit=36):
329
  if i<len(s) and s[i].get('url') and s[i]['url'] not in seen:seen.add(s[i]['url']);out.append(s[i])
330
  return out[:limit]
331
 
332
- for _path in ['/api/article', '/api/hot_topics', '/api/hot_topics/more', '/api/hot_topics/refresh', '/api/categories', '/api/storage_status']:
333
  app.router.routes=[r for r in app.router.routes if not(getattr(r,'path',None)==_path and 'GET' in getattr(r,'methods',set()))]
334
 
335
  _article_cache = {}
@@ -482,10 +523,10 @@ def _get_hot_topics():
482
  if is_dup:continue
483
  seen.add(key);topics.append({'label':'#'+re.sub(r'\s+','',display[key].title()),'topic':display[key],'count':count})
484
  if len(topics)>=20:break
485
- for kw in['World Cup 2026','Kinh tế Việt Nam','Bóng đá châu Âu','Công nghệ AI','AI Thế Giới','AI Việt Nam','Giá vàng','Thời tiết']:
486
- if len(topics)>=28:break
487
- if not any(kw.lower() in s for s in seen):topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw,'count':0})
488
- _hot_cache.update({'t':now,'d':topics[:28]});return topics[:28]
489
 
490
  @app.get('/api/hot_topics')
491
  def api_hot_topics():
@@ -493,25 +534,59 @@ def api_hot_topics():
493
  resp.headers["Cache-Control"] = "public, max-age=120"
494
  return resp
495
 
496
- @app.get('/api/hot_topics/more')
497
- def api_hot_topics_more(offset: int = Query(default=0), limit: int = Query(default=5)):
498
- """Lazy-load more hot topics beyond the initial set."""
499
- all_topics = _get_hot_topics()
500
- page = all_topics[offset:offset+limit]
501
- return JSONResponse({
502
- 'topics': page,
503
- 'offset': offset,
504
- 'limit': limit,
505
- 'has_more': (offset + limit) < len(all_topics),
506
- 'total': len(all_topics)
507
- })
508
-
509
- @app.get('/api/hot_topics/refresh')
510
- def api_hot_topics_refresh():
511
- """Force refresh the hot topics cache and return fresh data."""
512
- _hot_cache['t'] = 0
513
- topics = _get_hot_topics()
514
- return JSONResponse({'topics': topics})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
515
 
516
  @app.get('/')
517
  async def serve_index():
@@ -521,7 +596,12 @@ async def serve_index():
521
  @app.get('/api/hashtag/sources')
522
  def _ht(topic:str=Query(...),page:int=Query(default=0)):
523
  items=_search_all(topic,36);per_page=8;start=page*per_page;end=start+per_page
524
- return JSONResponse({'sources':items[start:end],'topic':topic,'page':page,'has_more':end<len(items),'total':len(items)})
 
 
 
 
 
525
  @app.get('/api/categories')
526
  def _cat():return JSONResponse([])
527
  @app.get('/api/storage_status')
@@ -1078,812 +1158,4 @@ def detect_language(text):
1078
  # Emotion keyword-based detection
1079
  _EMOTION_KEYWORDS = {
1080
  'happy': {
1081
- 'en': ['happy', 'joy', 'wonderful', 'great', 'amazing', 'fantastic', 'love', 'excellent', 'beautiful', 'glad', 'delighted', 'pleased', 'cheerful', 'celebrate', 'victory', 'win', 'success'],
1082
- 'pt': ['feliz', 'alegria', 'maravilhoso', 'ótimo', 'incrível', 'fantástico', 'amor', 'excelente', 'lindo', 'contente', 'encantado', 'vitória', 'sucesso'],
1083
- 'es': ['feliz', 'alegria', 'maravilloso', 'genial', 'increíble', 'fantástico', 'amor', 'excelente', 'hermoso', 'contento', 'encantado', 'victoria', 'éxito'],
1084
- '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'],
1085
- },
1086
- 'sad': {
1087
- 'en': ['sad', 'unhappy', 'terrible', 'awful', 'horrible', 'miserable', 'depressed', 'grief', 'sorrow', 'tragic', 'unfortunate', 'painful', 'death', 'die', 'kill'],
1088
- 'pt': ['triste', 'infeliz', 'terrível', 'horrível', 'miserável', 'deprimido', 'dor', 'trágico', 'infelizmente', 'penoso', 'morte', 'morrer'],
1089
- 'es': ['triste', 'infeliz', 'terrible', 'horrible', 'miserable', 'deprimido', 'dolor', 'trágico', 'desafortunado', 'penoso', 'muerte', 'morir'],
1090
- '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'],
1091
- },
1092
- 'excited': {
1093
- 'en': ['excited', 'thrilling', 'amazing', 'wow', 'incredible', 'unbelievable', 'awesome', 'exhilarating', 'electrifying', 'breathtaking', 'breakthrough', 'record'],
1094
- 'pt': ['animado', 'emocionante', 'incrível', 'impressionante', 'sensacional', 'eletrizante', 'empolgante', 'recorde'],
1095
- 'es': ['emocionante', 'increíble', 'impresionante', 'sensacional', 'electrizante', 'emocionado', 'entusiasmado', 'récord'],
1096
- '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á'],
1097
- },
1098
- 'humorous': {
1099
- 'en': ['funny', 'hilarious', 'joke', 'laugh', 'comedy', 'humor', 'amusing', 'witty', 'sarcastic', 'ironic', 'ridiculous', 'absurd', 'lol', 'haha'],
1100
- 'pt': ['engraçado', 'hilário', 'piada', 'rir', 'comédia', 'humor', 'divertido', 'irônico', 'ridículo', 'absurdo', 'kkk'],
1101
- 'es': ['gracioso', 'hilarante', 'broma', 'risa', 'comedia', 'humor', 'divertido', 'irónico', 'ridículo', 'absurdo', 'jaja'],
1102
- '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'],
1103
- },
1104
- 'serious': {
1105
- 'en': ['serious', 'critical', 'important', 'urgent', 'severe', 'grave', 'significant', 'crucial', 'vital', 'essential', 'alarming', 'concerning', 'crisis', 'war', 'conflict'],
1106
- 'pt': ['sério', 'crítico', 'importante', 'urgente', 'grave', 'significativo', 'crucial', 'vital', 'essencial', 'preocupante', 'crise', 'guerra', 'conflito'],
1107
- 'es': ['serio', 'crítico', 'importante', 'urgente', 'grave', 'significativo', 'crucial', 'vital', 'esencial', 'preocupante', 'crisis', 'guerra', 'conflicto'],
1108
- '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'],
1109
- },
1110
- }
1111
-
1112
- def detect_emotion(text, language='vietnamese'):
1113
- """Detect emotion from text using keyword matching."""
1114
- if not text:
1115
- return 'neutral'
1116
- text_lower = text.lower()
1117
-
1118
- scores = {}
1119
- for emotion, lang_keywords in _EMOTION_KEYWORDS.items():
1120
- keywords = lang_keywords.get(language, lang_keywords.get('en', []))
1121
- score = sum(1 for kw in keywords if kw in text_lower)
1122
- scores[emotion] = score
1123
-
1124
- if max(scores.values()) == 0:
1125
- return 'neutral'
1126
-
1127
- return max(scores, key=scores.get)
1128
-
1129
- def detect_language_and_emotion(title, text):
1130
- """Detect both language and emotion from article content."""
1131
- combined = f"{title} {text}"
1132
- lang = detect_language(combined)
1133
- emotion = detect_emotion(combined, lang)
1134
- return lang, emotion
1135
-
1136
- # Voice selection based on language and emotion (using MultilingualNeural voices)
1137
- VOICE_BY_LANG_EMOTION = {
1138
- 'vietnamese': {
1139
- 'happy': ('vi-VN-HoaiMyNeural', 'vui'),
1140
- 'sad': ('vi-VN-NamMinhNeural', 'buồn'),
1141
- 'excited': ('vi-VN-HoaiMyNeural', 'hào hứng'),
1142
- 'humorous': ('vi-VN-HoaiMyNeural', 'vui'),
1143
- 'serious': ('vi-VN-NamMinhNeural', 'nghiêm túc'),
1144
- 'neutral': ('vi-VN-HoaiMyNeural', 'trung_tinh'),
1145
- },
1146
- 'portuguese': {
1147
- 'happy': ('pt-BR-ThalitaMultilingualNeural', 'feliz'),
1148
- 'sad': ('pt-BR-ThalitaMultilingualNeural', 'triste'),
1149
- 'excited': ('pt-BR-ThalitaMultilingualNeural', 'animado'),
1150
- 'humorous': ('pt-BR-ThalitaMultilingualNeural', 'engraçado'),
1151
- 'serious': ('pt-BR-ThalitaMultilingualNeural', 'sério'),
1152
- 'neutral': ('pt-BR-ThalitaMultilingualNeural', 'neutro'),
1153
- },
1154
- 'english': {
1155
- 'happy': ('en-US-AndrewMultilingualNeural', 'happy'),
1156
- 'sad': ('en-AU-WilliamMultilingualNeural', 'sad'),
1157
- 'excited': ('en-US-AndrewMultilingualNeural', 'excited'),
1158
- 'humorous': ('en-US-AndrewMultilingualNeural', 'funny'),
1159
- 'serious': ('en-AU-WilliamMultilingualNeural', 'serious'),
1160
- 'neutral': ('en-US-AndrewMultilingualNeural', 'neutral'),
1161
- },
1162
- 'french': {
1163
- 'happy': ('fr-FR-VivienneMultilingualNeural', 'heureux'),
1164
- 'sad': ('fr-FR-RemyMultilingualNeural', 'triste'),
1165
- 'excited': ('fr-FR-VivienneMultilingualNeural', 'excité'),
1166
- 'humorous': ('fr-FR-VivienneMultilingualNeural', 'drôle'),
1167
- 'serious': ('fr-FR-RemyMultilingualNeural', 'sérieux'),
1168
- 'neutral': ('fr-FR-VivienneMultilingualNeural', 'neutre'),
1169
- },
1170
- 'german': {
1171
- 'happy': ('de-DE-SeraphinaMultilingualNeural', 'glücklich'),
1172
- 'sad': ('de-DE-FlorianMultilingualNeural', 'traurig'),
1173
- 'excited': ('de-DE-SeraphinaMultilingualNeural', 'aufgeregt'),
1174
- 'humorous': ('de-DE-SeraphinaMultilingualNeural', 'lustig'),
1175
- 'serious': ('de-DE-FlorianMultilingualNeural', 'ernst'),
1176
- 'neutral': ('de-DE-SeraphinaMultilingualNeural', 'neutral'),
1177
- },
1178
- 'korean': {
1179
- 'happy': ('ko-KR-HyunsuMultilingualNeural', '행복'),
1180
- 'sad': ('ko-KR-HyunsuMultilingualNeural', '슬픔'),
1181
- 'excited': ('ko-KR-HyunsuMultilingualNeural', '흥분'),
1182
- 'humorous': ('ko-KR-HyunsuMultilingualNeural', '유쾌'),
1183
- 'serious': ('ko-KR-HyunsuMultilingualNeural', '진지'),
1184
- 'neutral': ('ko-KR-HyunsuMultilingualNeural', '중립'),
1185
- },
1186
- 'italian': {
1187
- 'happy': ('it-IT-GiuseppeMultilingualNeural', 'felice'),
1188
- 'sad': ('it-IT-GiuseppeMultilingualNeural', 'triste'),
1189
- 'excited': ('it-IT-GiuseppeMultilingualNeural', 'emozionato'),
1190
- 'humorous': ('it-IT-GiuseppeMultilingualNeural', 'divertente'),
1191
- 'serious': ('it-IT-GiuseppeMultilingualNeural', 'serio'),
1192
- 'neutral': ('it-IT-GiuseppeMultilingualNeural', 'neutro'),
1193
- },
1194
- }
1195
-
1196
- # All valid voice IDs (new MultilingualNeural format)
1197
- VALID_VOICES = {
1198
- 'vi-VN-HoaiMyNeural', 'vi-VN-NamMinhNeural',
1199
- 'en-US-AndrewMultilingualNeural', 'en-AU-WilliamMultilingualNeural',
1200
- 'pt-BR-ThalitaMultilingualNeural',
1201
- 'fr-FR-VivienneMultilingualNeural', 'fr-FR-RemyMultilingualNeural',
1202
- 'de-DE-SeraphinaMultilingualNeural', 'de-DE-FlorianMultilingualNeural',
1203
- 'ko-KR-HyunsuMultilingualNeural',
1204
- 'it-IT-GiuseppeMultilingualNeural',
1205
- }
1206
-
1207
- def get_voice_for_content(title, text, preferred_voice=None):
1208
- """Get appropriate voice based on content language and emotion."""
1209
- # Accept the new MultilingualNeural voices directly
1210
- if preferred_voice and preferred_voice in VALID_VOICES:
1211
- return preferred_voice
1212
-
1213
- # Also accept old shorthand voice IDs and map them to new format
1214
- old_voice_map = {
1215
- 'hoaimy': 'vi-VN-HoaiMyNeural',
1216
- 'namminh': 'vi-VN-NamMinhNeural',
1217
- 'andrew': 'en-US-AndrewMultilingualNeural',
1218
- 'jenny': 'en-US-AndrewMultilingualNeural',
1219
- 'thalita': 'pt-BR-ThalitaMultilingualNeural',
1220
- 'pt_thalita': 'pt-BR-ThalitaMultilingualNeural',
1221
- 'pt_francisco': 'pt-BR-ThalitaMultilingualNeural',
1222
- 'ela': 'en-US-AndrewMultilingualNeural',
1223
- 'es_carlos': 'en-US-AndrewMultilingualNeural',
1224
- 'denise': 'fr-FR-VivienneMultilingualNeural',
1225
- 'katja': 'de-DE-SeraphinaMultilingualNeural',
1226
- 'nanami': 'en-US-AndrewMultilingualNeural',
1227
- 'sunhee': 'ko-KR-HyunsuMultilingualNeural',
1228
- 'xiaochen': 'en-US-AndrewMultilingualNeural',
1229
- }
1230
- if preferred_voice and preferred_voice in old_voice_map:
1231
- return old_voice_map[preferred_voice]
1232
-
1233
- lang, emotion = detect_language_and_emotion(title, text)
1234
- lang_map = VOICE_BY_LANG_EMOTION.get(lang, VOICE_BY_LANG_EMOTION['vietnamese'])
1235
- voice, _ = lang_map.get(emotion, lang_map['neutral'])
1236
- return voice
1237
-
1238
-
1239
- def _is_relevant_image(img_url, title, text):
1240
- """Check if an image is relevant to the article content."""
1241
- if not img_url:
1242
- return False
1243
- skip_patterns = ['pixel', 'analytics', 'tracking', '1x1.gif', 'spacer.gif',
1244
- 'logo', 'icon', 'avatar', 'emoji', 'smiley', 'sprite',
1245
- 'advertisement', 'ad-banner', 'sponsored', 'banner-ads']
1246
- img_lower = img_url.lower()
1247
- for p in skip_patterns:
1248
- if p in img_lower:
1249
- return False
1250
- if not any(img_lower.endswith(ext) for ext in ['.jpg', '.jpeg', '.png', '.webp', '.gif']):
1251
- return False
1252
- return True
1253
-
1254
-
1255
- def _filter_relevant_images(images, title, text, max_images=8):
1256
- """Filter and rank images by relevance to article content."""
1257
- if not images:
1258
- return []
1259
- seen = set()
1260
- relevant = []
1261
- for img in images:
1262
- if img in seen:
1263
- continue
1264
- seen.add(img)
1265
- if _is_relevant_image(img, title, text):
1266
- relevant.append(img)
1267
- return relevant[:max_images]
1268
-
1269
-
1270
- def _scrape_article_for_rewrite(url):
1271
- """Scrape article: extract title, paragraphs, RELEVANT images, OG image."""
1272
- try:
1273
- r = req.get(url, headers=_UA_RW, timeout=15, allow_redirects=True)
1274
- r.encoding = 'utf-8'
1275
- soup = BeautifulSoup(r.text, 'lxml')
1276
- for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form']):
1277
- tag.decompose()
1278
- h1 = soup.find('h1')
1279
- ogt = soup.find('meta', property='og:title')
1280
- title = (h1.get_text(strip=True) if h1 else '') or (ogt.get('content', '') if ogt else '')
1281
- ogi = soup.find('meta', property='og:image')
1282
- og_img = ogi.get('content', '') if ogi else ''
1283
- if og_img and og_img.startswith('//'):
1284
- og_img = 'https:' + og_img
1285
- block = None
1286
- for sel in ['article', '.singular-content', '.detail-content', '.fck_detail', '.content-detail', '.knc-content', 'main', '.cms-body', '.article__body']:
1287
- el = soup.select_one(sel)
1288
- if el and len(el.find_all('p')) >= 2:
1289
- block = el
1290
- break
1291
- if not block:
1292
- block = soup.body or soup
1293
- paragraphs = []
1294
- all_images = []
1295
- seen_imgs = set()
1296
- if og_img and og_img not in seen_imgs:
1297
- all_images.append(og_img)
1298
- seen_imgs.add(og_img)
1299
- for el in block.find_all(['p', 'h2', 'h3', 'figure', 'img'], recursive=True):
1300
- if el.name == 'p':
1301
- t = _clean(el.get_text(strip=True))
1302
- if t and len(t) > 40:
1303
- paragraphs.append(t)
1304
- elif el.name in ('figure', 'img'):
1305
- im = el if el.name == 'img' else el.find('img')
1306
- if im:
1307
- src = im.get('data-src') or im.get('src') or im.get('data-original') or ''
1308
- if src and 'base64' not in src:
1309
- if src.startswith('//'):
1310
- src = 'https:' + src
1311
- if src not in seen_imgs:
1312
- all_images.append(src)
1313
- seen_imgs.add(src)
1314
- # Filter to relevant images only
1315
- relevant_images = _filter_relevant_images(all_images, title, ' '.join(paragraphs[:5]))
1316
- return {'title': _clean(title), 'paragraphs': paragraphs, 'images': relevant_images, 'og_img': og_img}
1317
- except Exception:
1318
- return None
1319
-
1320
-
1321
- def _extract_key_points_rw(paragraphs, max_points=5):
1322
- r"""Extract key points from paragraphs - extracts ALL sentences, not just first one.
1323
-
1324
- Fixes: Original regex `^(.+?[.!?])\s` only captured first sentence per paragraph.
1325
- Now splits on all sentence boundaries and takes valid sentences until max_points.
1326
- """
1327
- points = []
1328
-
1329
- for p in paragraphs:
1330
- if len(points) >= max_points:
1331
- break
1332
-
1333
- p = _clean(p)
1334
- if not p:
1335
- continue
1336
-
1337
- # Split paragraph into sentences using Vietnamese + English punctuation
1338
- sentences = re.split(r'(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])', p)
1339
- sentences = [s.strip() for s in sentences if s.strip()]
1340
-
1341
- for sentence in sentences:
1342
- if len(points) >= max_points:
1343
- break
1344
-
1345
- # Clean sentence - remove extra whitespace
1346
- sentence = _clean(sentence)
1347
-
1348
- if len(sentence) < 30:
1349
- continue
1350
-
1351
- # Check for duplicates
1352
- if any(sentence[:60] in existing for existing in points):
1353
- continue
1354
-
1355
- # Ensure sentence ends with punctuation
1356
- if not sentence.endswith(('.', '!', '?')):
1357
- sentence = sentence + '.'
1358
-
1359
- points.append(sentence)
1360
-
1361
- # If no valid sentences found, take chunks from raw text
1362
- if not points:
1363
- raw = '\n'.join(paragraphs)
1364
- for i in range(0, min(len(raw), max_points * 300), 280):
1365
- chunk = _clean(raw[i:i+280])
1366
- if len(chunk) >= 30 and chunk not in points:
1367
- points.append(chunk + ('.' if not chunk.endswith('.') else ''))
1368
- if len(points) >= max_points:
1369
- break
1370
-
1371
- return points
1372
-
1373
-
1374
- @app.post("/api/rewrite_slide")
1375
- async def api_rewrite_slide(request: Request):
1376
- """Fast rewrite as SLIDES - no AI needed, instant response."""
1377
- body = await request.json()
1378
- url = _clean(body.get("url", ""))
1379
- context = body.get("context", "")
1380
- preferred_voice = body.get("voice", "") # Accept custom voice selection
1381
- if not url and not context:
1382
- return JSONResponse({"error": "Cần URL hoặc nội dung"}, status_code=400)
1383
- data = None
1384
- if url and url.startswith("http"):
1385
- data = _scrape_article_for_rewrite(url)
1386
- if not data and context:
1387
- paragraphs = [_clean(p) for p in context.split('\n') if len(_clean(p)) > 40]
1388
- data = {'title': paragraphs[0][:80] if paragraphs else 'Bài viết', 'paragraphs': paragraphs, 'images': [], 'og_img': ''}
1389
- if not data or not data.get('paragraphs'):
1390
- return JSONResponse({"error": "Không đọc được bài viết"}, status_code=422)
1391
- points = _extract_key_points_rw(data['paragraphs'], max_points=12)
1392
- if not points:
1393
- return JSONResponse({"error": "Không tìm được ý chính"}, status_code=422)
1394
- images = data.get('images', [])
1395
- slides = []
1396
- for i, point in enumerate(points):
1397
- img = images[i] if i < len(images) else (images[-1] if images else '')
1398
- if img and 'cdnphoto.dantri' in img:
1399
- img = '/api/proxy/img?url=' + _quote2(img, safe='')
1400
- slides.append({'text': point, 'image': img, 'index': i + 1})
1401
- summary_text = '\n\n'.join([f"• {s['text']}" for s in slides])
1402
-
1403
- # Auto-detect language and emotion
1404
- lang, emotion = detect_language_and_emotion(data['title'], summary_text)
1405
- # Use preferred voice if provided, otherwise auto-detect
1406
- voice = preferred_voice if preferred_voice else get_voice_for_content(data['title'], summary_text)
1407
-
1408
- post = {
1409
- "id": str(int(time.time() * 1000)) + str(_random2.randint(100, 999)),
1410
- "title": data['title'],
1411
- "text": summary_text,
1412
- "img": images[0] if images else '',
1413
- "url": url,
1414
- "kind": "slide_summary",
1415
- "slides": slides,
1416
- "images": images[:10],
1417
- "video": "",
1418
- "voice": voice,
1419
- "emotion": emotion,
1420
- "language": lang,
1421
- "ts": int(time.time())
1422
- }
1423
- posts = _load_wall_posts()
1424
- posts.insert(0, post)
1425
- _save_wall_posts(posts)
1426
- return JSONResponse({"post": post, "slides": slides})
1427
-
1428
-
1429
- @app.post("/api/rewrite_share")
1430
- async def api_rewrite_share(request: Request):
1431
- """Rewrite article and post to Tường AI with SLIDES + AI text."""
1432
- body = await request.json()
1433
- url = _clean(body.get("url", ""))
1434
- ctx = _clean(body.get("context", ""))
1435
- preferred_voice = body.get("voice", "") # Accept custom voice selection
1436
- if not url and not ctx:
1437
- return JSONResponse({"error": "Cần URL hoặc nội dung"}, status_code=400)
1438
- data = None
1439
- if url and url.startswith("http"):
1440
- data = _scrape_article_for_rewrite(url)
1441
- if not data and ctx:
1442
- paragraphs = [_clean(p) for p in ctx.split('\n') if len(_clean(p)) > 40]
1443
- data = {'title': paragraphs[0][:80] if paragraphs else 'Bài viết', 'paragraphs': paragraphs, 'images': [], 'og_img': ''}
1444
- if not data or not data.get('paragraphs'):
1445
- return JSONResponse({"error": "Không đọc được bài viết"}, status_code=422)
1446
- raw_text = '\n'.join(data['paragraphs'])
1447
- if len(raw_text) < 50:
1448
- raw_text = ctx[:14000]
1449
- if len(raw_text) < 50:
1450
- return JSONResponse({"error": "Bài viết quá ngắn"}, status_code=422)
1451
- domain = ''
1452
- try:
1453
- from urllib.parse import urlparse
1454
- domain = urlparse(url).netloc.replace('www.', '')
1455
- except:
1456
- pass
1457
-
1458
- # Generate AI summary text
1459
- ai_text = None
1460
- try:
1461
- import ai_ext
1462
- if hasattr(ai_ext, 'qwen_generate'):
1463
- 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.'
1464
- ai_text = await ai_ext.qwen_generate(prompt, max_tokens=1000)
1465
- except Exception:
1466
- pass
1467
- if not ai_text or len(ai_text) < 80:
1468
- key_pts = _extract_key_points_rw(data['paragraphs'], max_points=12)
1469
- if key_pts:
1470
- ai_text = '\n\n'.join([f"• {p}" for p in key_pts])
1471
- else:
1472
- ai_text = f"Tóm tắt: {data['title']}\n\n{raw_text[:1200]}\n\nNguồn: {domain}"
1473
-
1474
- # Build slides from key points (FIX: include slides in rewrite_share too!)
1475
- points = _extract_key_points_rw(data['paragraphs'], max_points=12)
1476
- images = data.get('images', [])
1477
- slides = []
1478
- for i, point in enumerate(points):
1479
- img = images[i] if i < len(images) else (images[-1] if images else '')
1480
- if img and 'cdnphoto.dantri' in img:
1481
- img = '/api/proxy/img?url=' + _quote2(img, safe='')
1482
- slides.append({'text': point, 'image': img, 'index': i + 1})
1483
-
1484
- # Auto-detect language and emotion
1485
- lang, emotion = detect_language_and_emotion(data['title'], ai_text)
1486
- # Use preferred voice if provided, otherwise auto-detect
1487
- voice = preferred_voice if preferred_voice else get_voice_for_content(data['title'], ai_text)
1488
-
1489
- post = {
1490
- "id": str(int(time.time() * 1000)) + str(_random2.randint(100, 999)),
1491
- "title": data['title'],
1492
- "text": ai_text,
1493
- "img": images[0] if images else '',
1494
- "url": url,
1495
- "kind": "rewrite",
1496
- "slides": slides,
1497
- "images": images[:10],
1498
- "video": "",
1499
- "voice": voice,
1500
- "emotion": emotion,
1501
- "language": lang,
1502
- "ts": int(time.time())
1503
- }
1504
- posts = _load_wall_posts()
1505
- posts.insert(0, post)
1506
- _save_wall_posts(posts)
1507
- return JSONResponse({"post": post, "slides": slides})
1508
-
1509
-
1510
- @app.post("/api/url_wall")
1511
- async def api_url_wall(request: Request):
1512
- """Submit URL to add to Tường AI."""
1513
- body = await request.json()
1514
- url = _clean(body.get("url", ""))
1515
- if not url or not url.startswith('http'):
1516
- return JSONResponse({"error": "URL không hợp lệ"}, status_code=400)
1517
- # Reuse rewrite_share logic
1518
- req._body = json.dumps({"url": url}).encode()
1519
- return await api_rewrite_share(request)
1520
-
1521
-
1522
- def _bg():
1523
- time.sleep(15)
1524
- while True:
1525
- try:get_wc2026_all()
1526
- except:pass
1527
- time.sleep(90)
1528
- threading.Thread(target=_bg,daemon=True).start()
1529
-
1530
- # ===== AUTO SCHEDULER: rewrite AI + short at 7/13/19 VN time =====
1531
- _AUTO_SCHEDULE_TIMES = [(7, '07:00'), (13, '13:00'), (19, '19:00')]
1532
- _AUTO_LOG = os.path.join(DATA_DIR, 'auto_rewrite_log.json')
1533
-
1534
- def _load_auto_log():
1535
- try:
1536
- if os.path.exists(_AUTO_LOG):
1537
- with open(_AUTO_LOG, 'r') as f:
1538
- return json.load(f)
1539
- except: pass
1540
- return {}
1541
-
1542
- def _save_auto_log(log):
1543
- try:
1544
- tmp = _AUTO_LOG + '.tmp'
1545
- with open(tmp, 'w') as f:
1546
- json.dump(log, f)
1547
- os.replace(tmp, _AUTO_LOG)
1548
- except: pass
1549
-
1550
- async def _auto_fetch_short(post_id):
1551
- """Try to auto-generate a short for a post."""
1552
- try:
1553
- import httpx
1554
- async with httpx.AsyncClient(timeout=180) as cl:
1555
- r = await cl.post(
1556
- f"http://localhost:7860/api/ai/short/{post_id}",
1557
- json={"voice":"vi-VN-HoaiMyNeural","emotion":"neutral","speed":1.2},
1558
- headers={"Content-Type":"application/json"}
1559
- )
1560
- if r.status_code < 300:
1561
- sj = r.json()
1562
- if sj.get('video'):
1563
- posts = _load_wall_posts()
1564
- for p in posts:
1565
- if p.get('id') == post_id:
1566
- p['video'] = sj['video']
1567
- break
1568
- _save_wall_posts(posts)
1569
- return True
1570
- except: pass
1571
- return False
1572
-
1573
- async def _auto_rewrite_one(topic, slot_label, used_urls=None, post_index=0):
1574
- """Rewrite one topic: find articles, summarize, post to wall, trigger short.
1575
- used_urls: shared set to avoid duplicate articles across topics.
1576
- post_index: 0-based index to create multiple posts per topic (0,1,2 = up to 3 posts)."""
1577
- from urllib.parse import quote as _q
1578
- # Get MORE items to support 1-3 posts per topic
1579
- items = _search_all(topic, limit=12)
1580
- # Skip URLs already used by another topic
1581
- if used_urls is not None:
1582
- filtered = [it for it in items if it.get('url') not in used_urls]
1583
- if filtered:
1584
- items = filtered
1585
- if not items or post_index >= len(items):
1586
- return False
1587
-
1588
- # Get article at post_index (0,1,2 for multiple posts)
1589
- item = items[post_index] # post_index allows multiple articles per topic
1590
- url = item.get('url', '')
1591
- title = item.get('title', topic)
1592
- if url and used_urls is not None:
1593
- used_urls.add(url)
1594
- if not url.startswith('http'):
1595
- return False
1596
-
1597
- data = _scrape_article_for_rewrite(url)
1598
- if not data or not data.get('paragraphs'):
1599
- return False
1600
-
1601
- raw_text = '\n'.join(data['paragraphs'])
1602
- ai_text = None
1603
-
1604
- # Try AI generation
1605
- try:
1606
- import ai_ext
1607
- 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."
1608
- ai_text = await ai_ext.qwen_generate(prompt, max_tokens=1000)
1609
- except: pass
1610
-
1611
- if not ai_text or len(ai_text) < 80:
1612
- pts = data['paragraphs'][:6]
1613
- ai_text = '\n\n'.join([f"• {p[:300]}" for p in pts])
1614
- via = item.get('via', '') or urlparse(url).netloc.replace('www.', '')
1615
- ai_text += f"\n\nNguồn tham khảo: {via}"
1616
-
1617
- # Build slides
1618
- images = data.get('images', [])
1619
- pts = data['paragraphs'][:10]
1620
- slides = []
1621
- for i, p in enumerate(pts[:8]):
1622
- img = images[i] if i < len(images) else (images[-1] if images else data.get('og_img', ''))
1623
- slides.append({'text': p[:300], 'image': img, 'index': i + 1})
1624
-
1625
- post_id = str(int(time.time() * 1000)) + str(_random2.randint(100, 999))
1626
- post = {
1627
- "id": post_id, "title": data.get('title', title)[:200],
1628
- "text": ai_text, "img": images[0] if images else data.get('og_img', ''),
1629
- "url": url, "kind": "auto_rewrite", "slides": slides,
1630
- "images": images[:10], "video": "",
1631
- "voice": "vi-VN-HoaiMyNeural", "emotion": "neutral",
1632
- "language": "vietnamese", "ts": int(time.time()),
1633
- "auto_scheduled": True, "slot": slot_label,
1634
- }
1635
-
1636
- posts = _load_wall_posts()
1637
- posts.insert(0, post)
1638
- _save_wall_posts(posts)
1639
-
1640
- # Trigger short generation async
1641
- threading.Thread(target=lambda: asyncio.run(_auto_fetch_short(post_id)), daemon=True).start()
1642
- return True
1643
-
1644
- async def _do_scheduled_run(slot_label):
1645
- """Main scheduled run: 1-3 posts from 3 different HOT topics (3-9 total), no duplicates."""
1646
- print(f"[auto] Starting scheduled rewrite for {slot_label}")
1647
-
1648
- # Get top hot topics, skip duplicates
1649
- all_topics = _get_hot_topics()
1650
- seen_topics = set()
1651
- unique_topics = []
1652
- for t in all_topics:
1653
- kw = t.get('topic', '').lower().strip()
1654
- if kw and len(kw) > 5 and kw not in seen_topics:
1655
- is_dup = False
1656
- for s in seen_topics:
1657
- # Check if one topic is substring of another
1658
- if kw in s or s in kw:
1659
- is_dup = True
1660
- break
1661
- if not is_dup:
1662
- seen_topics.add(kw)
1663
- unique_topics.append(t)
1664
- if len(unique_topics) >= 3:
1665
- break
1666
-
1667
- job_topics = [t['topic'] for t in unique_topics[:3] if t.get('topic')]
1668
- if not job_topics:
1669
- print(f"[auto] No hot topics found, skipping")
1670
- return
1671
-
1672
- print(f"[auto] Running 3 topics: {job_topics}")
1673
-
1674
- # Track used URLs to avoid cross-topic duplicates
1675
- _used_urls = set()
1676
- results = []
1677
-
1678
- # Process each topic, create 1-3 posts per topic
1679
- for jt in job_topics:
1680
- for post_idx in range(3): # Try up to 3 posts per topic
1681
- try:
1682
- ok = await asyncio.wait_for(_auto_rewrite_one(jt, slot_label, _used_urls, post_idx), timeout=120)
1683
- if ok:
1684
- results.append((jt, post_idx, True))
1685
- print(f"[auto] Created post {post_idx+1} for '{jt}'")
1686
- else:
1687
- # No more articles for this topic
1688
- break
1689
- except Exception as e:
1690
- print(f"[auto] Error on '{jt}' post {post_idx}: {e}")
1691
- results.append((jt, post_idx, False))
1692
- await asyncio.sleep(1) # Small delay between posts
1693
-
1694
- # Ensure at least 3 posts total (fallback if needed)
1695
- successful_posts = sum(1 for _, _, ok in results if ok)
1696
- print(f"[auto] Done {slot_label}: {successful_posts} posts created")
1697
-
1698
- # Log
1699
- from datetime import datetime, timezone, timedelta
1700
- VN_TZ_SCHED = timezone(timedelta(hours=7))
1701
- today_str = datetime.now(VN_TZ_SCHED).strftime('%Y-%m-%d')
1702
- log = _load_auto_log()
1703
- if today_str not in log: log[today_str] = {}
1704
- log[today_str][slot_label] = {
1705
- 'time': datetime.now(VN_TZ_SCHED).strftime('%H:%M:%S'),
1706
- 'count': successful_posts,
1707
- 'total': len(job_topics),
1708
- }
1709
- _save_auto_log(log)
1710
-
1711
- def _scheduler_loop():
1712
- """Check every 60s; trigger at 7:00, 13:00, 19:00 VN time.
1713
- On startup, check for any missed slots today and run them immediately."""
1714
- time.sleep(35)
1715
- from datetime import datetime, timezone, timedelta
1716
- VN_TZ_SCHED = timezone(timedelta(hours=7))
1717
-
1718
- _last_run_date = ""
1719
- _last_run_slots = set()
1720
-
1721
- # On startup: check log for missed slots today
1722
- try:
1723
- start_now = datetime.now(VN_TZ_SCHED)
1724
- today_str = start_now.strftime('%Y-%m-%d')
1725
- current_hour = start_now.hour
1726
- current_minute = start_now.minute
1727
- log = _load_auto_log()
1728
- today_log = log.get(today_str, {})
1729
- for h, label in _AUTO_SCHEDULE_TIMES:
1730
- # Run if slot is past (either strictly earlier hour, or same hour but window has passed)
1731
- should_run = False
1732
- if h < current_hour:
1733
- should_run = True
1734
- elif h == current_hour and current_minute > 10:
1735
- should_run = True
1736
- if should_run and label not in today_log:
1737
- print(f"[auto] Detected missed slot {label} (h={h} < now={current_hour}:{current_minute}), running catch-up now")
1738
- _run_scheduled_sync(label)
1739
- _last_run_slots.add(label)
1740
- except Exception as e:
1741
- print(f"[auto] Catch-up check error: {e}")
1742
-
1743
- while True:
1744
- try:
1745
- now = datetime.now(VN_TZ_SCHED)
1746
- today = now.strftime('%Y-%m-%d')
1747
- hour = now.hour
1748
- minute = now.minute
1749
-
1750
- if today != _last_run_date:
1751
- _last_run_date = today
1752
- _last_run_slots = set()
1753
-
1754
- slot = None
1755
- for h, label in _AUTO_SCHEDULE_TIMES:
1756
- if hour == h and 0 <= minute < 5:
1757
- slot = label
1758
- break
1759
-
1760
- if slot and slot not in _last_run_slots:
1761
- _last_run_slots.add(slot)
1762
- _run_scheduled_sync(slot)
1763
- except Exception as e:
1764
- print(f"[auto] Loop error: {e}")
1765
-
1766
- time.sleep(60)
1767
-
1768
- threading.Thread(target=_scheduler_loop, daemon=True, name='auto-rewrite-scheduler').start()
1769
-
1770
- @app.get('/api/debug/auto_schedule')
1771
- async def debug_auto_schedule(slot: str = '07:00'):
1772
- """Manually trigger auto scheduler for debugging."""
1773
- try:
1774
- # Check if we can access the data directory
1775
- log = _load_auto_log()
1776
- topics = _get_hot_topics()[:3]
1777
- job_topics = [t['topic'] for t in topics if t.get('topic')]
1778
- return JSONResponse({
1779
- "slot": slot,
1780
- "log": log,
1781
- "hot_topics": job_topics,
1782
- "wall_posts_count": len(_load_wall_posts()),
1783
- "data_dir_writable": os.access(DATA_DIR, os.W_OK) if os.path.isdir(DATA_DIR) else False,
1784
- "data_dir_exists": os.path.isdir(DATA_DIR),
1785
- })
1786
- except Exception as e:
1787
- return JSONResponse({"error": str(e)}, status_code=500)
1788
-
1789
- def _run_scheduled_sync(slot):
1790
- """Run _do_scheduled_run in a separate event loop (for background thread)."""
1791
- loop = asyncio.new_event_loop()
1792
- asyncio.set_event_loop(loop)
1793
- try:
1794
- loop.run_until_complete(_do_scheduled_run(slot))
1795
- except Exception as e:
1796
- print(f"[auto] Background run error: {e}")
1797
- finally:
1798
- loop.close()
1799
-
1800
- @app.get('/api/debug/trigger_auto')
1801
- async def debug_trigger_auto(slot: str = '19:00'):
1802
- """Trigger _do_scheduled_run in background thread (non-blocking)."""
1803
- threading.Thread(target=_run_scheduled_sync, args=(slot,), daemon=True).start()
1804
- return JSONResponse({"status": "started", "slot": slot})
1805
-
1806
- # ===== SHORTS RSS PROXY ENDPOINT =====
1807
- @app.get("/api/shorts/rss")
1808
- def shorts_rss():
1809
- """Get shorts from YouTube RSS feeds server-side"""
1810
- import xml.etree.ElementTree as ET
1811
- import html as html_lib2
1812
- import re as re2
1813
-
1814
- YOUTUBE_CHANNELS = {
1815
- "baodantri7941": "UC_x5TKhOgd6GhYvv5z4I3jg",
1816
- "baosuckhoedoisongboyte": "UCBsY5fXTQLkF_JnH9kLkL4g",
1817
- }
1818
-
1819
- shorts = []
1820
- seen = set()
1821
-
1822
- for handle, channel_id in YOUTUBE_CHANNELS.items():
1823
- try:
1824
- rss_url = f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"
1825
- r = req.get(rss_url, headers=HEADERS, timeout=15)
1826
- if r.status_code != 200:
1827
- continue
1828
-
1829
- root = ET.fromstring(r.text)
1830
- ns = {
1831
- 'atom': 'http://www.w3.org/2005/Atom',
1832
- 'yt': 'http://www.youtube.com/xml/schemas/2015',
1833
- 'media': 'http://search.yahoo.com/mrss/'
1834
- }
1835
-
1836
- for entry in root.findall('atom:entry', ns)[:30]:
1837
- title_el = entry.find('atom:title', ns)
1838
- title = html_lib2.unescape(title_el.text) if title_el is not None and title_el.text else ''
1839
-
1840
- link_el = entry.find('atom:link', ns)
1841
- link = link_el.get('href', '') if link_el is not None else ''
1842
-
1843
- vid_el = entry.find('yt:videoId', ns)
1844
- vid = vid_el.text if vid_el is not None else ''
1845
-
1846
- if not vid or vid in seen:
1847
- continue
1848
-
1849
- # Check if it's a short
1850
- is_short = '#shorts' in title.lower() or '#short' in title.lower() or '/shorts/' in link
1851
-
1852
- if not is_short:
1853
- desc_el = entry.find('media:description', ns)
1854
- if desc_el is not None and desc_el.text:
1855
- if '#shorts' in desc_el.text.lower():
1856
- is_short = True
1857
-
1858
- if not is_short:
1859
- continue
1860
-
1861
- seen.add(vid)
1862
-
1863
- # Get thumbnail
1864
- thumb = f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg"
1865
- media_group = entry.find('media:group', ns)
1866
- if media_group is not None:
1867
- thumb_el = media_group.find('media:thumbnail', ns)
1868
- if thumb_el is not None:
1869
- thumb = thumb_el.get('url', thumb)
1870
-
1871
- shorts.append({
1872
- 'id': vid,
1873
- 'title': title.replace('#shorts', '').replace('#short', '').strip()[:120],
1874
- 'img': thumb,
1875
- 'link': f'https://www.youtube.com/shorts/{vid}',
1876
- 'channel': handle,
1877
- 'source': 'yt'
1878
- })
1879
-
1880
- if len(shorts) >= 40:
1881
- break
1882
-
1883
- except Exception as e:
1884
- print(f"RSS error for {handle}: {e}")
1885
- continue
1886
-
1887
- return {"shorts": shorts, "count": len(shorts)}
1888
-
1889
- app.mount('/static',StaticFiles(directory=STATIC_DIR),name='vnews_static')
 
22
  from concurrent.futures import ThreadPoolExecutor, as_completed
23
  from urllib.parse import quote
24
  import asyncio
25
+ from datetime import datetime, timezone, timedelta
26
+
27
+ VN_TZ = timezone(timedelta(hours=7))
28
 
29
  HL_LEAGUES['friendly'] = {"path": "giai-khac/friendly", "name": "Giao hữu", "emoji": "🤝"}
30
 
 
216
  if not words:return True
217
  return any(w in tt for w in words)
218
 
219
+ # === Date extraction helpers ===
220
+ def _today_str():
221
+ return datetime.now(VN_TZ).strftime('%d/%m/%Y')
222
+
223
+ def _try_extract_date(soup, selector):
224
+ """Try to extract date from a BeautifulSoup element using various selectors."""
225
+ for sel in selector:
226
+ el = soup.select_one(sel)
227
+ if el:
228
+ t = _clean(el.get_text())
229
+ if t and re.search(r'\d{1,2}[/-]\d{1,2}[/-]\d{2,4}', t):
230
+ return t
231
+ return ''
232
+
233
  def _s_vnexpress(topic,limit=8):
234
  items=[]
235
  try:
 
238
  a=art.select_one('h2 a, h3 a')
239
  if a and a.get('href'):
240
  t=_clean(a.get('title','') or a.get_text(strip=True))
241
+ if _has_kw(topic,t):
242
+ date = _try_extract_date(art, ['.time', '.date', 'time', '.item-time', 'span[class*="date"]', 'span[class*="time"]'])
243
+ if not date: date = _today_str()
244
+ items.append({'title':t,'url':a['href'],'via':'VnExpress','date':date})
245
  except:pass
246
  return items
247
 
 
253
  t=_clean(a.get_text(strip=True));href=a.get('href','')
254
  if t and len(t)>15 and _has_kw(topic,t):
255
  if not href.startswith('http'):href='https://dantri.com.vn'+href
256
+ parent = a.parent
257
+ date = _try_extract_date(parent or a, ['.time', '.date', 'time', 'span[class*="time"]', 'span[class*="date"]'])
258
+ if not date: date = _today_str()
259
+ items.append({'title':t,'url':href,'via':'Dân Trí','date':date})
260
  if len(items)>=limit:break
261
  except:pass
262
  return items
 
269
  t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
270
  if t and len(t)>15 and _has_kw(topic,t):
271
  if not href.startswith('http'):href='https://vietnamnet.vn'+href
272
+ parent = a.parent
273
+ date = _try_extract_date(parent or a, ['.time', '.date', 'time', 'span[class*="time"]', 'span[class*="date"]'])
274
+ if not date: date = _today_str()
275
+ items.append({'title':t,'url':href,'via':'VietNamNet','date':date})
276
  if len(items)>=limit:break
277
  except:pass
278
  return items
 
285
  t=_clean(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://bongda.com.vn'+href
288
+ parent = a.parent
289
+ date = _try_extract_date(parent or a, ['.time', '.date', 'time', 'span[class*="time"]', 'span[class*="date"]'])
290
+ if not date: date = _today_str()
291
+ items.append({'title':t,'url':href,'via':'Bóng Đá','date':date})
292
  if len(items)>=limit:break
293
  except:pass
294
  return items
 
301
  t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
302
  if t and len(t)>15 and _has_kw(topic,t):
303
  if href.startswith('/'):href='https://genk.vn'+href
304
+ parent = a.parent
305
+ date = _try_extract_date(parent or a, ['.time', '.date', 'time', 'span[class*="time"]', 'span[class*="date"]'])
306
+ if not date: date = _today_str()
307
+ items.append({'title':t,'url':href,'via':'GenK','date':date})
308
  if len(items)>=limit:break
309
  except:pass
310
  return items
 
317
  t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
318
  if t and len(t)>15 and _has_kw(topic,t):
319
  if not href.startswith('http'):href='https://thanhnien.vn'+href
320
+ parent = a.parent
321
+ date = _try_extract_date(parent or a, ['.time', '.date', 'time', 'span[class*="time"]', 'span[class*="date"]'])
322
+ if not date: date = _today_str()
323
+ items.append({'title':t,'url':href,'via':'Thanh Niên','date':date})
324
  if len(items)>=limit:break
325
  except:pass
326
  return items
 
330
  try:
331
  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')
332
  for a in soup.select('h3 a[href], .box-title-text a')[:limit*2]:
333
+ t=_clean(a.get_text(strip=True));href=a.get('href','')
334
  if t and len(t)>15 and _has_kw(topic,t):
335
  if not href.startswith('http'):href='https://tuoitre.vn'+href
336
+ parent = a.parent
337
+ date = _try_extract_date(parent or a, ['.time', '.date', 'time', 'span[class*="time"]', 'span[class*="date"]'])
338
+ if not date: date = _today_str()
339
+ items.append({'title':t,'url':href,'via':'Tuổi Trẻ','date':date})
340
  if len(items)>=limit:break
341
  except:pass
342
  return items
 
346
  try:
347
  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')
348
  for a in soup.select('h3 a[href], .title a[href]')[:limit*2]:
349
+ t=_clean(a.get_text(strip=True));href=a.get('href','')
350
  if t and len(t)>15 and _has_kw(topic,t):
351
  if not href.startswith('http'):href='https://thethaovanhoa.vn'+href
352
+ parent = a.parent
353
+ date = _try_extract_date(parent or a, ['.time', '.date', 'time', 'span[class*="time"]', 'span[class*="date"]'])
354
+ if not date: date = _today_str()
355
+ items.append({'title':t,'url':href,'via':'TT&VH','date':date})
356
  if len(items)>=limit:break
357
  except:pass
358
  return items
 
370
  if i<len(s) and s[i].get('url') and s[i]['url'] not in seen:seen.add(s[i]['url']);out.append(s[i])
371
  return out[:limit]
372
 
373
+ for _path in ['/api/article', '/api/hot_topics', '/api/categories', '/api/storage_status', '/api/hashtag/multi']:
374
  app.router.routes=[r for r in app.router.routes if not(getattr(r,'path',None)==_path and 'GET' in getattr(r,'methods',set()))]
375
 
376
  _article_cache = {}
 
523
  if is_dup:continue
524
  seen.add(key);topics.append({'label':'#'+re.sub(r'\s+','',display[key].title()),'topic':display[key],'count':count})
525
  if len(topics)>=20:break
526
+ for kw in['World Cup 2026','Kinh tế Việt Nam','Bóng đá châu Âu','Công nghệ AI','AI Thế Giới','Giá vàng','Thời tiết']:
527
+ if len(topics)>=24:break
528
+ if not any(kw.lower() in s for s in seen):topics.insert(0,{'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw,'count':0})
529
+ _hot_cache.update({'t':now,'d':topics[:24]});return topics[:24]
530
 
531
  @app.get('/api/hot_topics')
532
  def api_hot_topics():
 
534
  resp.headers["Cache-Control"] = "public, max-age=120"
535
  return resp
536
 
537
+ # === NEW: Multi-hashtag aggregated endpoint with pagination ===
538
+ _hashtag_multi_cache = {'t':0,'d':{}}
539
+ @app.get('/api/hashtag/multi')
540
+ def api_hashtag_multi(page: int = Query(default=0), per_page: int = Query(default=12)):
541
+ """Aggregate articles from top 5 hot topics, interleaved, with pagination."""
542
+ now = time.time()
543
+ cache_key = f"p{page}_pp{per_page}"
544
+ if _hashtag_multi_cache['d'] and now - _hashtag_multi_cache['t'] < 300:
545
+ cached = _hashtag_multi_cache['d'].get(cache_key)
546
+ if cached: return JSONResponse(cached)
547
+ try:
548
+ hot_topics = _get_hot_topics()
549
+ # Use top 5 topics (AI topics are already at positions 1-6)
550
+ all_topics = [t['topic'] for t in hot_topics[:7] if t.get('topic') and t.get('topic') != 'Giá vàng' and t.get('topic') != 'Thời tiết'][:5]
551
+ # Fetch all results in parallel
552
+ results = {}
553
+ with ThreadPoolExecutor(len(all_topics)) as ex:
554
+ futs = {ex.submit(_search_all, topic, 36): topic for topic in all_topics}
555
+ for f in as_completed(futs, timeout=25):
556
+ try: results[futs[f]] = f.result()
557
+ except: results[futs[f]] = []
558
+ # Interleave from all topics
559
+ all_articles = []
560
+ seen = set()
561
+ max_len = max((len(v) for v in results.values()), default=0)
562
+ for i in range(max_len):
563
+ for topic in all_topics:
564
+ srcs = results.get(topic, [])
565
+ if i < len(srcs) and srcs[i].get('url') and srcs[i]['url'] not in seen:
566
+ seen.add(srcs[i]['url'])
567
+ srcs[i]['_topic'] = topic
568
+ all_articles.append(srcs[i])
569
+ total = len(all_articles)
570
+ start = page * per_page
571
+ end = start + per_page
572
+ page_articles = all_articles[start:end]
573
+ # Lazy-load images: add article_url for frontend to fetch
574
+ response = {
575
+ 'sources': page_articles,
576
+ 'topics': all_topics,
577
+ 'page': page,
578
+ 'per_page': per_page,
579
+ 'has_more': end < total,
580
+ 'total': total
581
+ }
582
+ # Cache the full result
583
+ _hashtag_multi_cache['t'] = now
584
+ _hashtag_multi_cache['d'][cache_key] = response
585
+ _hashtag_multi_cache['d'] = {k: v for k, v in _hashtag_multi_cache['d'].items() if k.startswith('p')}
586
+ _hashtag_multi_cache['d'][cache_key] = response
587
+ return JSONResponse(response)
588
+ except Exception as e:
589
+ return JSONResponse({'error': str(e), 'sources': [], 'topics': [], 'has_more': False, 'total': 0})
590
 
591
  @app.get('/')
592
  async def serve_index():
 
596
  @app.get('/api/hashtag/sources')
597
  def _ht(topic:str=Query(...),page:int=Query(default=0)):
598
  items=_search_all(topic,36);per_page=8;start=page*per_page;end=start+per_page
599
+ page_items = items[start:end]
600
+ # Add dates to items
601
+ today = _today_str()
602
+ for item in page_items:
603
+ if 'date' not in item: item['date'] = today
604
+ return JSONResponse({'sources':page_items,'topic':topic,'page':page,'has_more':end<len(items),'total':len(items)})
605
  @app.get('/api/categories')
606
  def _cat():return JSONResponse([])
607
  @app.get('/api/storage_status')
 
1158
  # Emotion keyword-based detection
1159
  _EMOTION_KEYWORDS = {
1160
  'happy': {
1161
+ 'en': ['happy', 'joy', 'wonderful', 'great', 'amazing', 'fantastic', 'love', 'excellent', 'beautiful', 'glad', 'delighted', 'pleased', 'cheerful', 'celebrate',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
auto_scheduler.py CHANGED
@@ -393,4 +393,4 @@ def start_auto_scheduler():
393
  t = threading.Thread(target=_scheduler_loop, daemon=True, name="auto-scheduler")
394
  t.start()
395
  LOG.info("Auto scheduler started")
396
- return t
 
393
  t = threading.Thread(target=_scheduler_loop, daemon=True, name="auto-scheduler")
394
  t.start()
395
  LOG.info("Auto scheduler started")
396
+ return t
restart.txt CHANGED
@@ -1 +1 @@
1
- restart now
 
1
+ restart
static/auto_update.js ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // VNEWS Auto-update Frontend - SSE listener for real-time homepage updates
2
+ (function(){
3
+ let evtSource = null;
4
+
5
+ // Connect to SSE endpoint
6
+ function connectSSE() {
7
+ try {
8
+ evtSource = new EventSource('/api/events');
9
+ evtSource.onmessage = function(e) {
10
+ try {
11
+ const data = JSON.parse(e.data);
12
+ if (data.type === 'new_post' || data.type === 'new_short') {
13
+ console.log('[SSE] Received update:', data.type);
14
+ // Reload wall and re-render short AI
15
+ fetch('/api/ai_wall').then(r => r.json()).then(j => {
16
+ _wallPosts = j.posts || [];
17
+ if (typeof renderShortAISlide === 'function') {
18
+ renderShortAISlide();
19
+ }
20
+ // Update wall on homepage
21
+ const track = document.getElementById('ai-wall-track');
22
+ if (track && _wallPosts.length) {
23
+ track.innerHTML = _wallPosts.slice(0,20).map((p,i) => makeWallItem(p,i)).join('');
24
+ }
25
+ }).catch(() => {});
26
+ }
27
+ } catch(err) {}
28
+ };
29
+ evtSource.onerror = function() {
30
+ evtSource.close();
31
+ setTimeout(connectSSE, 5000); // Reconnect after 5s
32
+ };
33
+ } catch(err) {
34
+ console.log('[SSE] Not supported or error');
35
+ }
36
+ }
37
+
38
+ // Fallback polling (30s) - detects changes even without SSE
39
+ let _lastWallLen = 0;
40
+ function startPolling() {
41
+ setInterval(function(){
42
+ if (typeof _wallPosts === 'undefined') return;
43
+ fetch('/api/ai_wall').then(r => r.json()).then(j => {
44
+ const wall = j.posts || [];
45
+ if (wall.length !== _lastWallLen) {
46
+ console.log('[POLL] Wall changed:', _lastWallLen, '->', wall.length);
47
+ _lastWallLen = wall.length;
48
+ _wallPosts = wall;
49
+ const track = document.getElementById('ai-wall-track');
50
+ if (track && wall.length) {
51
+ track.innerHTML = wall.slice(0,20).map((p,i) => makeWallItem(p,i)).join('');
52
+ }
53
+ if (typeof renderShortAISlide === 'function') {
54
+ renderShortAISlide();
55
+ }
56
+ }
57
+ }).catch(() => {});
58
+ }, 30000);
59
+ }
60
+
61
+ // Start when DOM ready
62
+ if (document.readyState === 'loading') {
63
+ document.addEventListener('DOMContentLoaded', function(){
64
+ connectSSE();
65
+ startPolling();
66
+ });
67
+ } else {
68
+ connectSSE();
69
+ startPolling();
70
+ }
71
+
72
+ // Export for manual trigger
73
+ window.triggerHomepageRefresh = function() {
74
+ fetch('/api/ai_wall').then(r => r.json()).then(j => {
75
+ _wallPosts = j.posts || [];
76
+ if (typeof _renderWallIn === 'function') {
77
+ const afterEl = document.getElementById('home-after-wc');
78
+ if (afterEl) _renderWallIn(afterEl);
79
+ }
80
+ if (typeof renderShortAISlide === 'function') {
81
+ renderShortAISlide();
82
+ }
83
+ });
84
+ };
85
+ })();
static/hot_ai_prepend.js ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Prepend AI topics to hot topics list - ensures they're always visible first
2
+ // FIX v2: Thêm AI Thế Giới
3
+ (function(){
4
+ const AI_TOPICS = [
5
+ 'Công nghệ AI',
6
+ 'AI Thế Giới',
7
+ 'World Cup 2026',
8
+ 'Kinh tế Việt Nam',
9
+ 'Bóng đá châu Âu'
10
+ ];
11
+
12
+ // Override loadHotTopics to prepend AI topics
13
+ const origLoadHotTopics = window.loadHotTopics;
14
+ window.loadHotTopics = async function() {
15
+ try {
16
+ const r = await fetch('/api/hot_topics');
17
+ const j = await r.json();
18
+ const topics = j.topics || [];
19
+
20
+ // Prepend AI topics that aren't already in the list
21
+ const prependTopics = AI_TOPICS.filter(ai =>
22
+ !topics.some(t => (t.topic || '').toLowerCase().includes(ai.toLowerCase()))
23
+ ).map(ai => ({
24
+ label: '#' + ai.replace(/\s+/g, ''),
25
+ topic: ai,
26
+ count: 0
27
+ }));
28
+
29
+ const finalTopics = [...prependTopics, ...topics].slice(0, 24);
30
+
31
+ const el = document.getElementById('hot-topics');
32
+ if (el) {
33
+ el.innerHTML = finalTopics.map(t => {
34
+ const topicText = t.topic || t.label.replace(/^#/, '');
35
+ return `<button class="hot-chip" onclick="searchTopic('${topicText.replace(/'/g, "\\'")}')">${esc(t.label)}</button>`;
36
+ }).join('');
37
+ }
38
+ } catch(e) {
39
+ // Fallback to original
40
+ if (origLoadHotTopics) origLoadHotTopics.apply(this, arguments);
41
+ }
42
+ };
43
+ })();
static/hot_multi.js CHANGED
@@ -1,10 +1,6 @@
1
  // === OVERRIDE: loadHotTopics loads from MULTIPLE hashtags ===
2
  // This file loaded AFTER app_v2.js, overrides the function
3
 
4
- let _htAll = []; // All articles from multi-hashtag, for lazy loading
5
- let _htPage = 0;
6
- const _HT_PER_PAGE = 12;
7
-
8
  async function loadHotTopics(){
9
  const j=await fetch('/api/hot_topics').then(r=>r.json()).catch(()=>({topics:[]}));
10
  const el=document.getElementById('hot-topics');if(!el)return;
@@ -13,28 +9,9 @@ async function loadHotTopics(){
13
  const topicText=t.topic||t.label.replace(/^#/,'');
14
  return`<button class="hot-chip" onclick="searchTopic('${topicText.replace(/'/g,"\\'")}')">${esc(t.label)}</button>`;
15
  }).join('');
16
- // Load tin HOT = tổng hợp từ 5 chủ đề: 3 chủ đề hot nhất + 2 chủ đề AI (AI Thế Giới, AI Việt Nam)
17
  if(topics.length>=2){
18
- // Ép 2 chủ đề AI vào top 5
19
- const forcedAI = ['AI Thế Giới', 'AI Việt Nam'];
20
- const selected = [];
21
- // Lấy các chủ đề trending đầu tiên (bỏ qua forced AI để tránh trùng)
22
- for(let i=0; i<topics.length && selected.length < 3; i++){
23
- const t = topics[i].topic||topics[i].label.replace(/^#/,'');
24
- if(!forcedAI.some(ai => t.toLowerCase() === ai.toLowerCase())){
25
- selected.push(t);
26
- }
27
- }
28
- // Thêm 2 chủ đề AI
29
- forcedAI.forEach(ai => {
30
- if(selected.length < 5){
31
- // Tìm trong topics list
32
- const found = topics.find(t => (t.topic||t.label.replace(/^#/,'')).toLowerCase() === ai.toLowerCase());
33
- if(found) selected.push(found.topic||found.label.replace(/^#/,''));
34
- else selected.push(ai);
35
- }
36
- });
37
- loadMultiHashtag(selected);
38
  }else if(topics.length){
39
  searchTopic(topics[0].topic||topics[0].label.replace(/^#/,''));
40
  }
@@ -56,42 +33,17 @@ async function loadMultiHashtag(topicList){
56
  if(src&&src.url&&!seen.has(src.url)){seen.add(src.url);src._topic=topicList[j];all.push(src);}
57
  }
58
  }
59
- _htAll = all;
60
- _htPage = 0;
61
  if(!all.length){box.innerHTML=`<div class="hashtag-sources"><h3>🔥 Tin HOT</h3><div style="color:#888;padding:8px">Đang cập nhật...</div></div>`;return;}
62
- _renderHotPage(box, topicList);
 
 
 
 
 
 
 
 
 
63
  _htTopic=topicList[0];
64
  }catch(e){box.innerHTML=`<div class="hashtag-sources"><h3>🔥 Tin HOT</h3><div style="color:#e74c3c;padding:8px">Lỗi tải tin</div></div>`;}
65
- }
66
-
67
- function _renderHotPage(box, topicList){
68
- const start = 0;
69
- const end = (_htPage + 1) * _HT_PER_PAGE;
70
- const pageItems = _htAll.slice(0, end);
71
- let h=`<div class="hashtag-sources"><h3>🔥 Tin HOT tổng hợp <span style="font-size:10px;color:#888">(${_htAll.length} bài · ${topicList.length} chủ đề)</span></h3><div id="ht-list">`;
72
- pageItems.forEach((s,i)=>{
73
- h+=`<div class="hashtag-src-item" onclick="readArticle('${esc(s.url)}')"><div class="hashtag-src-img" id="ht-img-${i}"></div><div class="hashtag-src-text"><div class="hashtag-src-title">${esc(s.title)}</div><div class="hashtag-src-via">${esc(s.via||'')} · <span style="color:#f0c040;font-size:9px">#${esc(s._topic||'')}</span></div></div></div>`;
74
- });
75
- h+=`</div><div style="display:flex;gap:4px;flex-wrap:wrap;margin-top:8px">`;
76
- topicList.forEach(t=>{h+=`<button class="hot-chip" onclick="searchTopic('${t.replace(/'/g,"\\'")}')" style="font-size:10px">🔍 #${esc(t)}</button>`;});
77
- h+=`</div>`;
78
- // Nút lazy load - tải thêm tin
79
- if (_htAll.length > end) {
80
- h+=`<button class="hashtag-load-more" id="ht-load-more" onclick="_loadMoreHot()">Tải thêm ${Math.min(_HT_PER_PAGE, _htAll.length - end)} tin ▼</button>`;
81
- }
82
- h+=`</div>`;
83
- box.innerHTML=h;
84
- // Lazy load images
85
- pageItems.forEach((s,i)=>{if(!s.url)return;fetch('/api/article?url='+encodeURIComponent(s.url)).then(r=>r.json()).then(d=>{if(d&&(d.og_image||d.img)){const el=document.getElementById('ht-img-'+i);if(el)el.innerHTML=`<img src="${esc(d.og_image||d.img)}" onerror="this.style.display='none'">`;}}).catch(()=>{});});
86
- }
87
-
88
- function _loadMoreHot(){
89
- _htPage++;
90
- const box = document.getElementById('hashtag-box');
91
- if(!box)return;
92
- // Extract topicList from the current buttons
93
- const chipBtns = box.querySelectorAll('.hot-chip');
94
- const topicList = Array.from(chipBtns).map(b => b.textContent.replace(/^🔍\s*#/, '').trim());
95
- _renderHotPage(box, topicList.length ? topicList : [_htTopic]);
96
- toast('📰 Đã tải thêm tin');
97
  }
 
1
  // === OVERRIDE: loadHotTopics loads from MULTIPLE hashtags ===
2
  // This file loaded AFTER app_v2.js, overrides the function
3
 
 
 
 
 
4
  async function loadHotTopics(){
5
  const j=await fetch('/api/hot_topics').then(r=>r.json()).catch(()=>({topics:[]}));
6
  const el=document.getElementById('hot-topics');if(!el)return;
 
9
  const topicText=t.topic||t.label.replace(/^#/,'');
10
  return`<button class="hot-chip" onclick="searchTopic('${topicText.replace(/'/g,"\\'")}')">${esc(t.label)}</button>`;
11
  }).join('');
12
+ // Load tin HOT = tổng hợp từ TOP 3 hashtag nóng nhất
13
  if(topics.length>=2){
14
+ loadMultiHashtag(topics.slice(0,3).map(t=>t.topic||t.label.replace(/^#/,'')));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  }else if(topics.length){
16
  searchTopic(topics[0].topic||topics[0].label.replace(/^#/,''));
17
  }
 
33
  if(src&&src.url&&!seen.has(src.url)){seen.add(src.url);src._topic=topicList[j];all.push(src);}
34
  }
35
  }
 
 
36
  if(!all.length){box.innerHTML=`<div class="hashtag-sources"><h3>🔥 Tin HOT</h3><div style="color:#888;padding:8px">Đang cập nhật...</div></div>`;return;}
37
+ let h=`<div class="hashtag-sources"><h3>🔥 Tin HOT tổng hợp <span style="font-size:10px;color:#888">(${all.length} bài · ${topicList.length} chủ đề)</span></h3><div id="ht-list">`;
38
+ all.slice(0,12).forEach((s,i)=>{
39
+ h+=`<div class="hashtag-src-item" onclick="readArticle('${esc(s.url)}')"><div class="hashtag-src-img" id="ht-img-${i}"></div><div class="hashtag-src-text"><div class="hashtag-src-title">${esc(s.title)}</div><div class="hashtag-src-via">${esc(s.via||'')} · <span style="color:#f0c040;font-size:9px">#${esc(s._topic||'')}</span></div></div></div>`;
40
+ });
41
+ h+=`</div><div style="display:flex;gap:4px;flex-wrap:wrap;margin-top:8px">`;
42
+ topicList.forEach(t=>{h+=`<button class="hot-chip" onclick="searchTopic('${t.replace(/'/g,"\\'")}')" style="font-size:10px">🔍 #${esc(t)}</button>`;});
43
+ h+=`</div></div>`;
44
+ box.innerHTML=h;
45
+ // Lazy load images
46
+ all.slice(0,12).forEach((s,i)=>{if(!s.url)return;fetch('/api/article?url='+encodeURIComponent(s.url)).then(r=>r.json()).then(d=>{if(d&&(d.og_image||d.img)){const el=document.getElementById('ht-img-'+i);if(el)el.innerHTML=`<img src="${esc(d.og_image||d.img)}" onerror="this.style.display='none'">`;}}).catch(()=>{});});
47
  _htTopic=topicList[0];
48
  }catch(e){box.innerHTML=`<div class="hashtag-sources"><h3>🔥 Tin HOT</h3><div style="color:#e74c3c;padding:8px">Lỗi tải tin</div></div>`;}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  }