bep40 commited on
Commit
31a1228
·
verified ·
1 Parent(s): c726397

Fix rewrite URL and short JSON handling

Browse files
Files changed (1) hide show
  1. app_v2_entry.py +45 -1343
app_v2_entry.py CHANGED
@@ -226,7 +226,7 @@ _STOP=set('và của các những một được trong với cho tại sau trư
226
  def _has_kw(topic,title):
227
  tl=topic.lower();tt=(title or'').lower()
228
  if tl in tt:return True
229
- words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',tl) if len(w)>2 and w not in _STOP]
230
  if not words:return True
231
  return any(w in tt for w in words)
232
 
@@ -498,9 +498,9 @@ def _get_hot_topics():
498
  is_dup=any(len(set(e.split())&set(key.split()))/max(len(set(e.split())),len(set(key.split())),1)>0.6 for e in seen)
499
  if is_dup:continue
500
  seen.add(key);topics.append({'label':'#'+re.sub(r'\s+','',display[key].title()),'topic':display[key],'count':count})
501
- if len(topics)>=20:break
502
  for kw in['World Cup 2026','Kinh tế Việt Nam','Bóng đá châu Âu','Công nghệ AI','Giá vàng','Thời tiết']:
503
- if len(topics)>=24:break
504
  if not any(kw.lower() in s for s in seen):topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw,'count':0})
505
  _hot_cache.update({'t':now,'d':topics[:24]});return topics[:24]
506
 
@@ -810,7 +810,7 @@ def _wl(eid:int):return JSONResponse(scrape_lineups(eid))
810
  @app.get('/api/wc2026/match/{eid}')
811
  def _wm(eid:int):return JSONResponse(scrape_match_detail(eid))
812
 
813
- DATA_DIR='/data' if os.path.isdir('/data') else os.path.join(os.path.dirname(os.path.abspath(__file__)),'data')
814
  os.makedirs(DATA_DIR,exist_ok=True)
815
  IF=os.path.join(DATA_DIR,'interactions_v2.json')
816
  CF=os.path.join(DATA_DIR,'comments_v2.json')
@@ -821,43 +821,54 @@ os.makedirs(WALL_VIDEO_DIR,exist_ok=True)
821
  _il=threading.Lock();_cl=threading.Lock();_wl_lock=threading.Lock()
822
  def _lj(p):
823
  try:
 
824
  if os.path.exists(p):return json.load(open(p,'r',encoding='utf-8'))
825
  except:pass
826
- return{}
827
  def _sj(p,d):
828
- try:open(p+'.tmp','w',encoding='utf-8').write(json.dumps(d,ensure_ascii=False));os.replace(p+'.tmp',p)
 
 
 
 
 
 
829
  except:pass
830
 
831
- @app.post('/api/v2/interact')
832
- async def _int(request:Request):
833
- b=await request.json();v=str(b.get('id','')).strip();t=str(b.get('type','')).strip()
834
- if not v or t not in('view','like'):return JSONResponse({'error':'x'},status_code=400)
835
- with _il:db=_lj(IF);db.setdefault(v,{'views':0,'likes':0,'comments':0});db[v][t+'s']+=1;_sj(IF,db);return JSONResponse(db[v])
836
-
837
- @app.get('/api/v2/interactions')
838
- def _gi(id:str=Query(...)):
839
- with _il:return JSONResponse(_lj(IF).get(id.strip(),{'views':0,'likes':0,'comments':0}))
840
 
841
- @app.get('/api/v2/comments')
842
- def _gc(id:str=Query(...)):
843
- with _cl:return JSONResponse({'comments':_lj(CF).get(id.strip(),[])})
 
 
 
844
 
845
- @app.post('/api/v2/comment')
846
- async def _pc(request:Request):
847
- b=await request.json();v=str(b.get('id','')).strip();tx=str(b.get('text','')).strip()[:500]
848
- if not v or not tx:return JSONResponse({'error':'x'},status_code=400)
849
- c={'text':tx,'time':time.strftime('%H:%M %d/%m',time.localtime()),'ts':int(time.time())}
850
- with _cl:db=_lj(CF);db.setdefault(v,[]);db[v].append(c);db[v]=db[v][-200:];_sj(CF,db);cms=db[v]
851
- with _il:idb=_lj(IF);idb.setdefault(v,{'views':0,'likes':0,'comments':0});idb[v]['comments']=len(cms);_sj(IF,idb)
852
- return JSONResponse({'comments':cms})
853
 
854
- def _load_wall_posts():
855
- with _wl_lock:
856
- return _lj(WALL_FILE)
857
 
858
- def _save_wall_posts(posts):
859
- with _wl_lock:
860
- _sj(WALL_FILE, posts)
 
 
 
 
 
 
 
 
 
 
 
 
861
 
862
  @app.get('/api/wall')
863
  def api_wall():
@@ -993,7 +1004,7 @@ _UNIQUE_CHARS = {
993
  _STOPWORDS = {
994
  'english': {'the', 'is', 'at', 'which', 'on', 'a', 'an', 'and', 'or', 'but', 'in', 'with', 'to', 'for', 'of', 'not', 'no', 'can', 'had', 'have', 'has', 'was', 'were', 'are', 'be', 'been', 'this', 'that', 'it', 'he', 'she', 'they', 'his', 'her', 'my', 'your', 'our', 'we', 'you', 'i'},
995
  'vietnamese': {'là', 'của', 'và', 'có', 'được', 'cho', 'không', 'với', 'này', 'đó', 'từ', 'trong', 'đã', 'sẽ', 'một', 'các', 'những', 'về', 'tại', 'người', 'năm', 'đến', 'ra', 'lại', 'như', 'khi', 'để', 'rất', 'cũng', 'mà', 'nếu', 'sau', 'trên', 'theo', 'vì', 'do', 'nên', 'thì', 'mình', 'tôi', 'bạn', 'anh', 'chị', 'em'},
996
- 'portuguese': {'de', 'um', 'que', 'e', 'do', 'da', 'em', 'para', 'com', 'não', 'uma', 'os', 'no', 'se', 'na', 'por', 'mais', 'as', 'dos', 'como', 'mas', 'ao', 'ele', 'das', 'tem', 'seu', 'sua', 'ou', 'quando', 'muito', 'nos', 'já', 'eu', 'também', 'só', 'pelo', 'pela', 'até', 'isso', 'ela', 'entre', 'depois', 'sem', 'mesmo', 'aos', 'são', 'está', 'ter', 'ser', 'foi', 'era', 'há', 'estão', 'você', 'nós', 'eles', 'elas'},
997
  'spanish': {'de', 'que', 'el', 'en', 'y', 'a', 'los', 'del', 'se', 'las', 'por', 'un', 'para', 'con', 'no', 'una', 'su', 'al', 'es', 'lo', 'como', 'más', 'pero', 'sus', 'le', 'ya', 'o', 'fue', 'este', 'ha', 'si', 'porque', 'esta', 'son', 'entre', 'está', 'cuando', 'muy', 'sin', 'sobre', 'ser', 'también', 'me', 'hasta', 'hay', 'donde', 'han', 'quien', 'están', 'desde', 'todo', 'nos', 'durante', 'todos', 'uno', 'les', 'ni', 'contra', 'otros', 'fueron', 'ese', 'eso', 'ante', 'ellos', 'yo', 'tú', 'él', 'ella', 'nosotros', 'usted', 'ustedes'},
998
  }
999
 
@@ -1045,1313 +1056,4 @@ _EMOTION_KEYWORDS = {
1045
  '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'],
1046
  },
1047
  'sad': {
1048
- 'en': ['sad', 'unhappy', 'terrible', 'awful', 'horrible', 'miserable', 'depressed', 'grief', 'sorrow', 'tragic', 'unfortunate', 'painful', 'death', 'die', 'kill'],
1049
- 'pt': ['triste', 'infeliz', 'terrível', 'horrível', 'miserável', 'deprimido', 'dor', 'trágico', 'infelizmente', 'penoso', 'morte', 'morrer'],
1050
- 'es': ['triste', 'infeliz', 'terrible', 'horrible', 'miserable', 'deprimido', 'dolor', 'trágico', 'desafortunado', 'penoso', 'muerte', 'morir'],
1051
- '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'],
1052
- },
1053
- 'excited': {
1054
- 'en': ['excited', 'thrilling', 'amazing', 'wow', 'incredible', 'unbelievable', 'awesome', 'exhilarating', 'electrifying', 'breathtaking', 'breakthrough', 'record'],
1055
- 'pt': ['animado', 'emocionante', 'incrível', 'impressionante', 'sensacional', 'eletrizante', 'empolgante', 'recorde'],
1056
- 'es': ['emocionante', 'increíble', 'impresionante', 'sensacional', 'electrizante', 'emocionado', 'entusiasmado', 'récord'],
1057
- '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á'],
1058
- },
1059
- 'humorous': {
1060
- 'en': ['funny', 'hilarious', 'joke', 'laugh', 'comedy', 'humor', 'amusing', 'witty', 'sarcastic', 'ironic', 'ridiculous', 'absurd', 'lol', 'haha'],
1061
- 'pt': ['engraçado', 'hilário', 'piada', 'rir', 'comédia', 'humor', 'divertido', 'irônico', 'ridículo', 'absurdo', 'kkk'],
1062
- 'es': ['gracioso', 'hilarante', 'broma', 'risa', 'comedia', 'humor', 'divertido', 'irónico', 'ridículo', 'absurdo', 'jaja'],
1063
- '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'],
1064
- },
1065
- 'serious': {
1066
- 'en': ['serious', 'critical', 'important', 'urgent', 'severe', 'grave', 'significant', 'crucial', 'vital', 'essential', 'alarming', 'concerning', 'crisis', 'war', 'conflict'],
1067
- 'pt': ['sério', 'crítico', 'importante', 'urgente', 'grave', 'significativo', 'crucial', 'vital', 'essencial', 'preocupante', 'crise', 'guerra', 'conflito'],
1068
- 'es': ['serio', 'crítico', 'importante', 'urgente', 'grave', 'significativo', 'crucial', 'vital', 'esencial', 'preocupante', 'crisis', 'guerra', 'conflicto'],
1069
- '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'],
1070
- },
1071
- }
1072
-
1073
- def detect_emotion(text, language='vietnamese'):
1074
- """Detect emotion from text using keyword matching."""
1075
- if not text:
1076
- return 'neutral'
1077
- text_lower = text.lower()
1078
-
1079
- scores = {}
1080
- for emotion, lang_keywords in _EMOTION_KEYWORDS.items():
1081
- keywords = lang_keywords.get(language, lang_keywords.get('en', []))
1082
- score = sum(1 for kw in keywords if kw in text_lower)
1083
- scores[emotion] = score
1084
-
1085
- if max(scores.values()) == 0:
1086
- return 'neutral'
1087
-
1088
- return max(scores, key=scores.get)
1089
-
1090
- def detect_language_and_emotion(title, text):
1091
- """Detect both language and emotion from article content."""
1092
- combined = f"{title} {text}"
1093
- lang = detect_language(combined)
1094
- emotion = detect_emotion(combined, lang)
1095
- return lang, emotion
1096
-
1097
- # Voice selection based on language and emotion (using MultilingualNeural voices)
1098
- VOICE_BY_LANG_EMOTION = {
1099
- 'vietnamese': {
1100
- 'happy': ('vi-VN-HoaiMyNeural', 'vui'),
1101
- 'sad': ('vi-VN-NamMinhNeural', 'buồn'),
1102
- 'excited': ('vi-VN-HoaiMyNeural', 'hào hứng'),
1103
- 'humorous': ('vi-VN-HoaiMyNeural', 'vui'),
1104
- 'serious': ('vi-VN-NamMinhNeural', 'nghiêm túc'),
1105
- 'neutral': ('vi-VN-HoaiMyNeural', 'trung_tinh'),
1106
- },
1107
- 'portuguese': {
1108
- 'happy': ('pt-BR-ThalitaMultilingualNeural', 'feliz'),
1109
- 'sad': ('pt-BR-ThalitaMultilingualNeural', 'triste'),
1110
- 'excited': ('pt-BR-ThalitaMultilingualNeural', 'animado'),
1111
- 'humorous': ('pt-BR-ThalitaMultilingualNeural', 'engraçado'),
1112
- 'serious': ('pt-BR-ThalitaMultilingualNeural', 'sério'),
1113
- 'neutral': ('pt-BR-ThalitaMultilingualNeural', 'neutro'),
1114
- },
1115
- 'english': {
1116
- 'happy': ('en-US-AndrewMultilingualNeural', 'happy'),
1117
- 'sad': ('en-AU-WilliamMultilingualNeural', 'sad'),
1118
- 'excited': ('en-US-AndrewMultilingualNeural', 'excited'),
1119
- 'humorous': ('en-US-AndrewMultilingualNeural', 'funny'),
1120
- 'serious': ('en-AU-WilliamMultilingualNeural', 'serious'),
1121
- 'neutral': ('en-US-AndrewMultilingualNeural', 'neutral'),
1122
- },
1123
- 'french': {
1124
- 'happy': ('fr-FR-VivienneMultilingualNeural', 'heureux'),
1125
- 'sad': ('fr-FR-RemyMultilingualNeural', 'triste'),
1126
- 'excited': ('fr-FR-VivienneMultilingualNeural', 'excité'),
1127
- 'humorous': ('fr-FR-VivienneMultilingualNeural', 'drôle'),
1128
- 'serious': ('fr-FR-RemyMultilingualNeural', 'sérieux'),
1129
- 'neutral': ('fr-FR-VivienneMultilingualNeural', 'neutre'),
1130
- },
1131
- 'german': {
1132
- 'happy': ('de-DE-SeraphinaMultilingualNeural', 'glücklich'),
1133
- 'sad': ('de-DE-FlorianMultilingualNeural', 'traurig'),
1134
- 'excited': ('de-DE-SeraphinaMultilingualNeural', 'aufgeregt'),
1135
- 'humorous': ('de-DE-SeraphinaMultilingualNeural', 'lustig'),
1136
- 'serious': ('de-DE-FlorianMultilingualNeural', 'ernst'),
1137
- 'neutral': ('de-DE-SeraphinaMultilingualNeural', 'neutral'),
1138
- },
1139
- 'korean': {
1140
- 'happy': ('ko-KR-HyunsuMultilingualNeural', '행복'),
1141
- 'sad': ('ko-KR-HyunsuMultilingualNeural', '슬픔'),
1142
- 'excited': ('ko-KR-HyunsuMultilingualNeural', '흥분'),
1143
- 'humorous': ('ko-KR-HyunsuMultilingualNeural', '유쾌'),
1144
- 'serious': ('ko-KR-HyunsuMultilingualNeural', '진지'),
1145
- 'neutral': ('ko-KR-HyunsuMultilingualNeural', '중립'),
1146
- },
1147
- 'italian': {
1148
- 'happy': ('it-IT-GiuseppeMultilingualNeural', 'felice'),
1149
- 'sad': ('it-IT-GiuseppeMultilingualNeural', 'triste'),
1150
- 'excited': ('it-IT-GiuseppeMultilingualNeural', 'emozionato'),
1151
- 'humorous': ('it-IT-GiuseppeMultilingualNeural', 'divertente'),
1152
- 'serious': ('it-IT-GiuseppeMultilingualNeural', 'serio'),
1153
- 'neutral': ('it-IT-GiuseppeMultilingualNeural', 'neutro'),
1154
- },
1155
- }
1156
-
1157
- # All valid voice IDs (new MultilingualNeural format)
1158
- VALID_VOICES = {
1159
- 'vi-VN-HoaiMyNeural', 'vi-VN-NamMinhNeural',
1160
- 'en-US-AndrewMultilingualNeural', 'en-AU-WilliamMultilingualNeural',
1161
- 'pt-BR-ThalitaMultilingualNeural',
1162
- 'fr-FR-VivienneMultilingualNeural', 'fr-FR-RemyMultilingualNeural',
1163
- 'de-DE-SeraphinaMultilingualNeural', 'de-DE-FlorianMultilingualNeural',
1164
- 'ko-KR-HyunsuMultilingualNeural',
1165
- 'it-IT-GiuseppeMultilingualNeural',
1166
- }
1167
-
1168
- def get_voice_for_content(title, text, preferred_voice=None):
1169
- """Get appropriate voice based on content language and emotion."""
1170
- # Accept the new MultilingualNeural voices directly
1171
- if preferred_voice and preferred_voice in VALID_VOICES:
1172
- return preferred_voice
1173
-
1174
- # Also accept old shorthand voice IDs and map them to new format
1175
- old_voice_map = {
1176
- 'hoaimy': 'vi-VN-HoaiMyNeural',
1177
- 'namminh': 'vi-VN-NamMinhNeural',
1178
- 'andrew': 'en-US-AndrewMultilingualNeural',
1179
- 'jenny': 'en-US-AndrewMultilingualNeural',
1180
- 'thalita': 'pt-BR-ThalitaMultilingualNeural',
1181
- 'pt_thalita': 'pt-BR-ThalitaMultilingualNeural',
1182
- 'pt_francisco': 'pt-BR-ThalitaMultilingualNeural',
1183
- 'ela': 'en-US-AndrewMultilingualNeural',
1184
- 'es_carlos': 'en-US-AndrewMultilingualNeural',
1185
- 'denise': 'fr-FR-VivienneMultilingualNeural',
1186
- 'katja': 'de-DE-SeraphinaMultilingualNeural',
1187
- 'nanami': 'en-US-AndrewMultilingualNeural',
1188
- 'sunhee': 'ko-KR-HyunsuMultilingualNeural',
1189
- 'xiaochen': 'en-US-AndrewMultilingualNeural',
1190
- }
1191
- if preferred_voice and preferred_voice in old_voice_map:
1192
- return old_voice_map[preferred_voice]
1193
-
1194
- lang, emotion = detect_language_and_emotion(title, text)
1195
- lang_map = VOICE_BY_LANG_EMOTION.get(lang, VOICE_BY_LANG_EMOTION['vietnamese'])
1196
- voice, _ = lang_map.get(emotion, lang_map['neutral'])
1197
- return voice
1198
-
1199
-
1200
- def _is_relevant_image(img_url, title, text):
1201
- """Check if an image is relevant to the article content."""
1202
- if not img_url:
1203
- return False
1204
- skip_patterns = ['pixel', 'analytics', 'tracking', '1x1.gif', 'spacer.gif',
1205
- 'logo', 'icon', 'avatar', 'emoji', 'smiley', 'sprite',
1206
- 'advertisement', 'ad-banner', 'sponsored', 'banner-ads']
1207
- img_lower = img_url.lower()
1208
- for p in skip_patterns:
1209
- if p in img_lower:
1210
- return False
1211
- if not any(img_lower.endswith(ext) for ext in ['.jpg', '.jpeg', '.png', '.webp', '.gif']):
1212
- return False
1213
- return True
1214
-
1215
-
1216
- def _filter_relevant_images(images, title, text, max_images=8):
1217
- """Filter and rank images by relevance to article content."""
1218
- if not images:
1219
- return []
1220
- seen = set()
1221
- relevant = []
1222
- for img in images:
1223
- if img in seen:
1224
- continue
1225
- seen.add(img)
1226
- if _is_relevant_image(img, title, text):
1227
- relevant.append(img)
1228
- return relevant[:max_images]
1229
-
1230
-
1231
- def _scrape_article_for_rewrite(url):
1232
- """Scrape article: extract title, paragraphs, RELEVANT images, OG image."""
1233
- try:
1234
- r = req.get(url, headers=_UA_RW, timeout=15, allow_redirects=True)
1235
- r.encoding = 'utf-8'
1236
- soup = BeautifulSoup(r.text, 'lxml')
1237
- for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form']):
1238
- tag.decompose()
1239
- h1 = soup.find('h1')
1240
- ogt = soup.find('meta', property='og:title')
1241
- title = (h1.get_text(strip=True) if h1 else '') or (ogt.get('content', '') if ogt else '')
1242
- ogi = soup.find('meta', property='og:image')
1243
- og_img = ogi.get('content', '') if ogi else ''
1244
- if og_img and og_img.startswith('//'):
1245
- og_img = 'https:' + og_img
1246
- block = None
1247
- for sel in ['article', '.singular-content', '.detail-content', '.fck_detail', '.content-detail', '.knc-content', 'main', '.cms-body', '.article__body']:
1248
- el = soup.select_one(sel)
1249
- if el and len(el.find_all('p')) >= 2:
1250
- block = el
1251
- break
1252
- if not block:
1253
- block = soup.body or soup
1254
- paragraphs = []
1255
- all_images = []
1256
- seen_imgs = set()
1257
- if og_img and og_img not in seen_imgs:
1258
- all_images.append(og_img)
1259
- seen_imgs.add(og_img)
1260
- for el in block.find_all(['p', 'h2', 'h3', 'figure', 'img'], recursive=True):
1261
- if el.name == 'p':
1262
- t = _clean(el.get_text(strip=True))
1263
- if t and len(t) > 40:
1264
- paragraphs.append(t)
1265
- elif el.name in ('figure', 'img'):
1266
- im = el if el.name == 'img' else el.find('img')
1267
- if im:
1268
- src = im.get('data-src') or im.get('src') or im.get('data-original') or ''
1269
- if src and 'base64' not in src:
1270
- if src.startswith('//'):
1271
- src = 'https:' + src
1272
- if src not in seen_imgs:
1273
- all_images.append(src)
1274
- seen_imgs.add(src)
1275
- # Filter to relevant images only
1276
- relevant_images = _filter_relevant_images(all_images, title, ' '.join(paragraphs[:5]))
1277
- return {'title': _clean(title), 'paragraphs': paragraphs, 'images': relevant_images, 'og_img': og_img}
1278
- except Exception:
1279
- return None
1280
-
1281
-
1282
- def _extract_key_points_rw(paragraphs, max_points=5):
1283
- r"""Extract key points from paragraphs - extracts ALL sentences, not just first one.
1284
-
1285
- Fixes: Original regex `^(.+?[.!?])\s` only captured first sentence per paragraph.
1286
- Now splits on all sentence boundaries and takes valid sentences until max_points.
1287
- """
1288
- points = []
1289
-
1290
- for p in paragraphs:
1291
- if len(points) >= max_points:
1292
- break
1293
-
1294
- p = _clean(p)
1295
- if not p:
1296
- continue
1297
-
1298
- # Split paragraph into sentences using Vietnamese + English punctuation
1299
- sentences = re.split(r'(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])', p)
1300
- sentences = [s.strip() for s in sentences if s.strip()]
1301
-
1302
- for sentence in sentences:
1303
- if len(points) >= max_points:
1304
- break
1305
-
1306
- # Clean sentence - remove extra whitespace
1307
- sentence = _clean(sentence)
1308
-
1309
- if len(sentence) < 30:
1310
- continue
1311
-
1312
- # Check for duplicates
1313
- if any(sentence[:60] in existing for existing in points):
1314
- continue
1315
-
1316
- # Ensure sentence ends with punctuation
1317
- if not sentence.endswith(('.', '!', '?')):
1318
- sentence = sentence + '.'
1319
-
1320
- points.append(sentence)
1321
-
1322
- # If no valid sentences found, take chunks from raw text
1323
- if not points:
1324
- raw = '\n'.join(paragraphs)
1325
- for i in range(0, min(len(raw), max_points * 300), 280):
1326
- chunk = _clean(raw[i:i+280])
1327
- if len(chunk) >= 30 and chunk not in points:
1328
- points.append(chunk + ('.' if not chunk.endswith('.') else ''))
1329
- if len(points) >= max_points:
1330
- break
1331
-
1332
- return points
1333
-
1334
-
1335
- @app.post("/api/rewrite_slide")
1336
- async def api_rewrite_slide(request: Request):
1337
- """Fast rewrite as SLIDES - no AI needed, instant response."""
1338
- body = await request.json()
1339
- url = _clean(body.get("url", ""))
1340
- context = body.get("context", "")
1341
- preferred_voice = body.get("voice", "") # Accept custom voice selection
1342
- if not url and not context:
1343
- return JSONResponse({"error": "Cần URL hoặc nội dung"}, status_code=400)
1344
- data = None
1345
- if url and url.startswith("http"):
1346
- data = _scrape_article_for_rewrite(url)
1347
- if not data and context:
1348
- paragraphs = [_clean(p) for p in context.split('\n') if len(_clean(p)) > 40]
1349
- data = {'title': paragraphs[0][:80] if paragraphs else 'Bài viết', 'paragraphs': paragraphs, 'images': [], 'og_img': ''}
1350
- if not data or not data.get('paragraphs'):
1351
- return JSONResponse({"error": "Không đọc được bài viết"}, status_code=422)
1352
- points = _extract_key_points_rw(data['paragraphs'], max_points=12)
1353
- if not points:
1354
- return JSONResponse({"error": "Không tìm được ý chính"}, status_code=422)
1355
- images = data.get('images', [])
1356
- slides = []
1357
- for i, point in enumerate(points):
1358
- img = images[i] if i < len(images) else (images[-1] if images else '')
1359
- if img and 'cdnphoto.dantri' in img:
1360
- img = '/api/proxy/img?url=' + _quote2(img, safe='')
1361
- slides.append({'text': point, 'image': img, 'index': i + 1})
1362
- summary_text = '\n\n'.join([f"• {s['text']}" for s in slides])
1363
-
1364
- # Auto-detect language and emotion
1365
- lang, emotion = detect_language_and_emotion(data['title'], summary_text)
1366
- # Use preferred voice if provided, otherwise auto-detect
1367
- voice = preferred_voice if preferred_voice else get_voice_for_content(data['title'], summary_text)
1368
-
1369
- post = {
1370
- "id": str(int(time.time() * 1000)) + str(_random2.randint(100, 999)),
1371
- "title": data['title'],
1372
- "text": summary_text,
1373
- "img": images[0] if images else '',
1374
- "url": url,
1375
- "kind": "slide_summary",
1376
- "slides": slides,
1377
- "images": images[:10],
1378
- "video": "",
1379
- "voice": voice,
1380
- "emotion": emotion,
1381
- "language": lang,
1382
- "ts": int(time.time())
1383
- }
1384
- posts = _load_wall_posts()
1385
- posts.insert(0, post)
1386
- _save_wall_posts(posts)
1387
- return JSONResponse({"post": post, "slides": slides})
1388
-
1389
-
1390
- @app.post("/api/rewrite_share")
1391
- async def api_rewrite_share(request: Request):
1392
- """Rewrite article and post to Tường AI with SLIDES + AI text."""
1393
- body = await request.json()
1394
- url = _clean(body.get("url", ""))
1395
- ctx = _clean(body.get("context", ""))
1396
- preferred_voice = body.get("voice", "") # Accept custom voice selection
1397
- if not url and not ctx:
1398
- return JSONResponse({"error": "Cần URL hoặc nội dung"}, status_code=400)
1399
- data = None
1400
- if url and url.startswith("http"):
1401
- data = _scrape_article_for_rewrite(url)
1402
- if not data and ctx:
1403
- paragraphs = [_clean(p) for p in ctx.split('\n') if len(_clean(p)) > 40]
1404
- data = {'title': paragraphs[0][:80] if paragraphs else 'Bài viết', 'paragraphs': paragraphs, 'images': [], 'og_img': ''}
1405
- if not data or not data.get('paragraphs'):
1406
- return JSONResponse({"error": "Không đọc được bài viết"}, status_code=422)
1407
- raw_text = '\n'.join(data['paragraphs'])
1408
- if len(raw_text) < 50:
1409
- raw_text = ctx[:14000]
1410
- if len(raw_text) < 50:
1411
- return JSONResponse({"error": "Bài viết quá ngắn"}, status_code=422)
1412
- domain = ''
1413
- try:
1414
- from urllib.parse import urlparse
1415
- domain = urlparse(url).netloc.replace('www.', '')
1416
- except:
1417
- pass
1418
-
1419
- # Generate AI summary text
1420
- ai_text = None
1421
- try:
1422
- import ai_ext
1423
- if hasattr(ai_ext, 'qwen_generate'):
1424
- 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.'
1425
- ai_text = await ai_ext.qwen_generate(prompt, max_tokens=1000)
1426
- except Exception:
1427
- pass
1428
- if not ai_text or len(ai_text) < 80:
1429
- key_pts = _extract_key_points_rw(data['paragraphs'], max_points=12)
1430
- if key_pts:
1431
- ai_text = '\n\n'.join([f"• {p}" for p in key_pts])
1432
- else:
1433
- ai_text = f"Tóm tắt: {data['title']}\n\n{raw_text[:1200]}\n\nNguồn: {domain}"
1434
-
1435
- # Build slides from key points (FIX: include slides in rewrite_share too!)
1436
- points = _extract_key_points_rw(data['paragraphs'], max_points=12)
1437
- images = data.get('images', [])
1438
- slides = []
1439
- for i, point in enumerate(points):
1440
- img = images[i] if i < len(images) else (images[-1] if images else '')
1441
- if img and 'cdnphoto.dantri' in img:
1442
- img = '/api/proxy/img?url=' + _quote2(img, safe='')
1443
- slides.append({'text': point, 'image': img, 'index': i + 1})
1444
-
1445
- # Auto-detect language and emotion
1446
- lang, emotion = detect_language_and_emotion(data['title'], ai_text)
1447
- # Use preferred voice if provided, otherwise auto-detect
1448
- voice = preferred_voice if preferred_voice else get_voice_for_content(data['title'], ai_text)
1449
-
1450
- post = {
1451
- "id": str(int(time.time() * 1000)) + str(_random2.randint(100, 999)),
1452
- "title": data['title'],
1453
- "text": ai_text,
1454
- "img": images[0] if images else '',
1455
- "url": url,
1456
- "kind": "rewrite",
1457
- "slides": slides,
1458
- "images": images[:10],
1459
- "video": "",
1460
- "voice": voice,
1461
- "emotion": emotion,
1462
- "language": lang,
1463
- "ts": int(time.time())
1464
- }
1465
- posts = _load_wall_posts()
1466
- posts.insert(0, post)
1467
- _save_wall_posts(posts)
1468
- return JSONResponse({"post": post, "slides": slides})
1469
-
1470
-
1471
- @app.post("/api/url_wall")
1472
- async def api_url_wall(request: Request):
1473
- """Submit URL to add to Tường AI."""
1474
- body = await request.json()
1475
- url = _clean(body.get("url", ""))
1476
- if not url or not url.startswith('http'):
1477
- return JSONResponse({"error": "URL không hợp lệ"}, status_code=400)
1478
- # Reuse rewrite_share logic
1479
- req._body = json.dumps({"url": url}).encode()
1480
- return await api_rewrite_share(request)
1481
-
1482
-
1483
- # ===== PERSONAL OPINION POST v2: AI tổng hợp bài viết từ quan điểm + nguồn tin HOT =====
1484
-
1485
- # ===== KEYWORD EXTRACTION FROM OPINION =====
1486
- _STOP_WORDS_EX = set("""
1487
- và của các những một được trong với cho tại sau trước khi không người
1488
- việt nam hôm nay mới nhất nóng tin tức cập nhật theo từ đến là có thì
1489
- này đã để về lại nên cũng rất như vì do nếu sẽ nếu thế nhưng mà vẫn
1490
- đang vào ra hơn đây đó nào cả cùng đã từng hãy còn chỉ cũng đều khiến
1491
- được đã bị bởi qua những lúc cái gì cô chú bác anh chị em bạn tôi mình
1492
- ông bà thầy vì vậy chính phải ấy đấy đâu đó thôi nhé đấy ạ nhỉ
1493
- ngày tháng năm giờ phút giây tuần tháng quý
1494
- """.strip().split())
1495
-
1496
- def _extract_keywords_from_opinion(text, max_keywords=5):
1497
- """Extract meaningful keywords from user's opinion for news search."""
1498
- if not text:
1499
- return []
1500
- text = text.lower()
1501
- text = re.sub(r'https?://\S+', '', text)
1502
- text = re.sub(r'[^\w\sÀ-ỹ]', ' ', text)
1503
- text = re.sub(r'\s+', ' ', text).strip()
1504
- words = [w for w in text.split() if len(w) > 2 and w not in _STOP_WORDS_EX]
1505
- word_scores = {}
1506
- for w in words:
1507
- word_scores[w] = word_scores.get(w, 0) + 1
1508
- sorted_words = sorted(word_scores.items(), key=lambda x: -x[1])
1509
- top_words = [w for w, s in sorted_words[:max_keywords]]
1510
- phrases = []
1511
- for i in range(len(words) - 1):
1512
- phrase = words[i] + ' ' + words[i + 1]
1513
- if len(phrase) > 5:
1514
- phrases.append(phrase)
1515
- phrase_scores = {}
1516
- for p in phrases:
1517
- phrase_scores[p] = phrase_scores.get(p, 0) + 1
1518
- sorted_phrases = sorted(phrase_scores.items(), key=lambda x: -x[1])
1519
- top_phrases = [p for p, s in sorted_phrases[:3]]
1520
- result = []
1521
- for p in top_phrases:
1522
- if p not in result:
1523
- result.append(p)
1524
- for w in top_words:
1525
- if w not in result:
1526
- result.append(w)
1527
- return result[:max_keywords]
1528
-
1529
-
1530
- @app.post("/api/personal_post/preview")
1531
- async def api_personal_post_preview(request: Request):
1532
- """Preview personal post: fetch full articles, let AI compose logical article with images."""
1533
- body = await request.json()
1534
- opinion = _clean(body.get("opinion", ""))
1535
- selected_topics = body.get("selected_topics", []) or []
1536
- selected_sources = body.get("selected_sources", []) or []
1537
-
1538
- if not opinion or len(opinion) < 10:
1539
- return JSONResponse({"error": "Quan điểm cá nhân quá ngắn (cần ít nhất 10 ký tự)"}, status_code=400)
1540
-
1541
- # Lấy keywords từ QUAN ĐIỂM CÁ NHÂN để tìm nguồn tin chính xác
1542
- keywords = _extract_keywords_from_opinion(opinion, max_keywords=5)
1543
- if keywords:
1544
- selected_topics = keywords[:3]
1545
- else:
1546
- # Fallback: hot topics
1547
- hot = _get_hot_topics()
1548
- selected_topics = [t.get("topic", "") for t in hot[:3] if t.get("topic")]
1549
-
1550
- # Tìm nguồn tin
1551
- all_sources = []
1552
- seen_urls = set()
1553
- for topic in selected_topics[:3]:
1554
- sources = _search_all(topic, limit=5)
1555
- for s in sources:
1556
- if s.get("url") and s["url"] not in seen_urls:
1557
- seen_urls.add(s["url"])
1558
- all_sources.append(s)
1559
- if len(all_sources) >= 6:
1560
- break
1561
- if len(all_sources) >= 6:
1562
- break
1563
-
1564
- for src in selected_sources:
1565
- if src.get("url") and src["url"] not in seen_urls:
1566
- all_sources.insert(0, src)
1567
-
1568
- # Scrape nội dung đầy đủ từng nguồn (paragraphs + images)
1569
- source_details = []
1570
- source_images = []
1571
- for src in all_sources[:5]:
1572
- url = src.get("url", "")
1573
- if not url:
1574
- continue
1575
- try:
1576
- art = _scrape_article_for_rewrite(url)
1577
- if art:
1578
- src_detail = {
1579
- "title": art.get("title", src.get("title", "")),
1580
- "url": url,
1581
- "via": src.get("via", ""),
1582
- "paragraphs": art.get("paragraphs", [])[:8],
1583
- "images": art.get("images", [])[:3],
1584
- "og_image": art.get("og_img", "")
1585
- }
1586
- source_details.append(src_detail)
1587
- # Collect images for proxy
1588
- for img in art.get("images", [])[:2]:
1589
- if any(x in img for x in ["cdnphoto.dantri", "vnexpress", "vcdn", "refooty"]):
1590
- img = "/api/proxy/img?url=" + _quote2(img, safe="")
1591
- source_images.append(img)
1592
- except:
1593
- pass
1594
- if len(source_details) >= 5:
1595
- break
1596
-
1597
- # Tạo title từ opinion
1598
- opinion_words = re.findall(r"[A-Za-zÀ-ỹ0-9]+", opinion)
1599
- title_words = opinion_words[:8] if len(opinion_words) >= 8 else opinion_words[:4]
1600
- title = " ".join([w[0].upper() + w[1:] for w in title_words]) if title_words else "Quan điểm cá nhân"
1601
- title = title[:80]
1602
-
1603
- # AI sinh bài viết hoàn chỉnh
1604
- ai_text = None
1605
- try:
1606
- import ai_ext
1607
- if hasattr(ai_ext, 'qwen_generate'):
1608
- # Build detailed context from source articles
1609
- source_context = ""
1610
- for i, sd in enumerate(source_details[:5]):
1611
- src_title = sd.get("title", "")
1612
- src_via = sd.get("via", "")
1613
- src_paras = sd.get("paragraphs", [])
1614
- source_context += f"\n=== Nguồn {i+1}: {src_title} ({src_via}) ===\n"
1615
- for j, p in enumerate(src_paras[:4]):
1616
- source_context += f" - {p[:300]}\n"
1617
-
1618
- prompt = (
1619
- "QUAN ĐIỂM: " + opinion[:500] + "\nNGUỒN: " + source_context[:1000] + "\n\n"
1620
- "=== NGUỒN TIN THAM KHẢO ===\n" + source_context + "\n\n"
1621
- "=== YÊU CẦU VIẾT BÀI THEO SLIDE ===\n"
1622
- "Viết bài thành 5-6 ĐOẠN VĂN NGẮN, mỗi đoạn là 1 SLIDE.\n"
1623
- "\n"
1624
- "QUAN TRỌNG NHẤT: MỗI SLIDE PHẢI KẾT HỢP QUAN ĐIỂM CÁ NHÂN + NỘI DUNG NGUỒN TIN, KHÔNG PHẢI CHỈ NÓI VỀ NGUỒN TIN.\n"
1625
- "\n"
1626
- "SLIDE 1 - MỞ ĐẦU:\n"
1627
- "- NHIỆN HỮU QUAN ĐIỂM CÁ NHÂN LÊN ĐẦU\n"
1628
- "- Giới thiệu chủ đề, nêu rõ quan điểm của bạn (dựa vào QUAN ĐIỂM CÁ NHÂN ở trên)\n"
1629
- "- 2-4 câu hoàn chỉnh\n"
1630
- "\n"
1631
- "SLIDE 2-3-4-5 - PHÂN TÍCH:\n"
1632
- "- Mỗi slide: B�Commencer bằng QUAN ĐIỂM CÁ NHÂN, sau đó dẫn chứng từ 1 nguồn tin\n"
1633
- "- Ví dụ: \"Theo quan điểm của tôi, đây là vấn đề cần lưu ý. Theo VnExpress...\"\n"
1634
- "- Dẫn chứng từ nguồn (ghi rõ tên báo: Theo VnExpress, Theo Thanh Niên...)\n"
1635
- "- 2-4 câu hoàn chỉnh mỗi slide\n"
1636
- "\n"
1637
- "SLIDE 6 - KẾT LUẬN:\n"
1638
- "- Tổng kết quan điểm cá nhân, đưa ra nhận định cuối cùng\n"
1639
- "- 2-3 câu hoàn chỉnh\n"
1640
- "\n"
1641
- "Định dạng đầu ra:\n"
1642
- "---SLIDE 1---\n"
1643
- "[nội dung đoạn văn slide 1]\n"
1644
- "---SLIDE 2---\n"
1645
- "[nội dung đoạn văn slide 2]\n"
1646
- "...v.v...\n"
1647
- "\n"
1648
- "QUAN TRỌNG:\n"
1649
- "- Mỗi slide là 1 đoạn văn HOÀN CHỈNH, 2-4 câu\n"
1650
- "- PHẢI KẾT THÚC BẰNG DẤU CHẤM (.) HOẢN TOÀN\n"
1651
- "- Kết hợp QUAN ĐIỂM CÁ NHÂN với NỘI DUNG NGUỒN TIN\n"
1652
- "- Không gạch đầu dòng, không bullet points\n"
1653
- "- Viết liền mạch tự nhiên, giọng văn báo chí\n"
1654
- "- Mỗi slide phải khác nhau, không lặp ý\n"
1655
- "- Độ dài: 300-600 từ"
1656
- )
1657
- ai_text = None # Không dùng AI, để code tự kết hợp opinion + source
1658
- except:
1659
- pass
1660
-
1661
- if not ai_text or len(ai_text) < 100:
1662
- # Fallback: build article manually
1663
- ai_text = "## " + title + "\n\n"
1664
- ai_text += opinion + "\n\n"
1665
- for i, sd in enumerate(source_details[:5]):
1666
- ai_text += "### " + sd.get("title", f"Nguồn {i+1}") + "\n"
1667
- for p in sd.get("paragraphs", [])[:3]:
1668
- ai_text += p[:250] + "\n"
1669
- ai_text += "*Nguồn: " + sd.get("via", "") + "*\n\n"
1670
- ai_text += "\n---\n*Bài viết tổng hợp từ quan điểm cá nhân và các nguồn tin liên quan*"
1671
-
1672
- # Parse slides từ AI output (format: ---SLIDE N--- content)
1673
- slides = []
1674
- if ai_text:
1675
- # Try to parse the ---SLIDE--- format
1676
- pattern = r'---SLIDE\s*(\d+)---\s*\n(.*?)(?=---SLIDE|\Z)'
1677
- matches = re.findall(pattern, ai_text, re.DOTALL)
1678
-
1679
- if matches:
1680
- for idx, (num, content) in enumerate(matches):
1681
- # Normalize: ensure complete sentences
1682
- text = _ensure_sentence_complete(content)
1683
- if len(text) > 40:
1684
- img = source_images[idx] if idx < len(source_images) else ""
1685
- slides.append({"text": text, "image": img, "index": idx + 1})
1686
-
1687
- # If we have parsed slides, ensure minimum 3
1688
- if len(slides) < 3:
1689
- # Use parsed slides as base, fill remaining from AI text
1690
- used_indices = set()
1691
- for s in slides:
1692
- used_indices.add(s['index'] - 1)
1693
-
1694
- # Split remaining AI text into more slides
1695
- sentences = re.split(r'(?<=[.!?])\s+', ai_text)
1696
- current_chunk = ""
1697
- next_idx = len(slides)
1698
-
1699
- for sent in sentences:
1700
- sent = _ensure_sentence_complete(sent)
1701
- if len(sent) < 20:
1702
- continue
1703
-
1704
- # Skip if this sentence is already in parsed slides
1705
- found = False
1706
- for slide in slides:
1707
- if sent[:50] in slide['text']:
1708
- found = True
1709
- break
1710
-
1711
- if found:
1712
- continue
1713
-
1714
- if current_chunk and len(current_chunk + " " + sent) <= 380:
1715
- current_chunk += " " + sent
1716
- else:
1717
- if len(current_chunk) > 50:
1718
- img = source_images[next_idx] if next_idx < len(source_images) else ""
1719
- slides.append({"text": current_chunk, "image": img, "index": next_idx + 1})
1720
- current_chunk = sent
1721
- next_idx += 1
1722
-
1723
- # Add final chunk
1724
- if len(current_chunk) > 50 and next_idx < 6:
1725
- img = source_images[next_idx] if next_idx < len(source_images) else ""
1726
- slides.append({"text": current_chunk, "image": img, "index": next_idx + 1})
1727
-
1728
- # Ultimate fallback: create slides from opinion + source
1729
- if len(slides) < 2:
1730
- slides = []
1731
- # Slide 1: opinion
1732
- if opinion and len(opinion) > 20:
1733
- slides.append({"text": opinion[:450], "image": source_images[0] if source_images else "", "index": 1})
1734
-
1735
- # Slide 2-6: from AI text or sources
1736
- if ai_text:
1737
- sentences = re.split(r'(?<=[.!?])\s+', ai_text)
1738
- for i, sent in enumerate(sentences[:5]):
1739
- text = _ensure_sentence_complete(_clean(sent))
1740
- if len(text) > 60:
1741
- if len(slides) < 6:
1742
- img = source_images[len(slides)] if len(slides) < len(source_images) else ""
1743
- slides.append({"text": text, "image": img, "index": len(slides) + 1})
1744
-
1745
- # Fill remaining with key points from sources - KẾT HỢP VỚI QUAN ĐIỂM CÁ NHÂN
1746
- src_idx = len(slides)
1747
- while len(slides) < 4 and src_idx < len(source_details):
1748
- paragraphs = source_details[src_idx].get("paragraphs", [])
1749
- src_title = source_details[src_idx].get("title", "")
1750
- src_via = source_details[src_idx].get("via", "")
1751
- for p in paragraphs[:2]:
1752
- if len(p) > 60 and len(slides) < 6:
1753
- # Kết hợp opinion với nội dung source
1754
- combined = f"Theo góc nhìn của tôi, {opinion[:100]}... Theo {src_via}: {p[:250]}"
1755
- img = source_images[len(slides)] if len(slides) < len(source_images) else ""
1756
- slides.append({"text": _ensure_sentence_complete(combined), "image": img, "index": len(slides) + 1})
1757
- break # Mỗi nguồn 1 slide
1758
- src_idx += 1
1759
-
1760
- # Final fallback: ensure at least 2-3 slides
1761
- while len(slides) < 3:
1762
- idx = len(slides)
1763
- if idx == 0 and opinion:
1764
- slides.append({"text": opinion[:400], "image": "", "index": 1})
1765
- elif ai_text:
1766
- slides.append({"text": ai_text[idx*300:(idx+1)*300], "image": "", "index": idx + 1})
1767
- else:
1768
- slides.append({"text": f"Nguồn tham khảo {idx + 1}", "image": "", "index": idx + 1})
1769
-
1770
- preview = {
1771
- "title": title,
1772
- "text": ai_text,
1773
- "opinion": opinion,
1774
- "images": source_images[:10],
1775
- "sources": source_details[:5],
1776
- "slides": slides[:6] # Max 6 slides
1777
- }
1778
-
1779
- return JSONResponse({"preview": preview})
1780
-
1781
-
1782
- @app.post("/api/personal_post")
1783
- async def api_personal_post(request: Request):
1784
- """Create and save personal opinion post."""
1785
- body = await request.json()
1786
- opinion = _clean(body.get("opinion", ""))
1787
- selected_topics = body.get("selected_topics", []) or []
1788
- selected_sources = body.get("selected_sources", []) or []
1789
- custom_title = body.get("custom_title", "")
1790
- custom_slides = body.get("custom_slides", [])
1791
-
1792
- if not opinion or len(opinion) < 10:
1793
- return JSONResponse({"error": "Quan điểm cá nhân quá ngắn (cần ít nhất 10 ký tự)"}, status_code=400)
1794
-
1795
- if not selected_topics:
1796
- # Lấy keywords từ QUAN ĐIỂM CÁ NHÂN để tìm nguồn tin chính xác
1797
- keywords = _extract_keywords_from_opinion(opinion, max_keywords=5)
1798
- if keywords:
1799
- selected_topics = keywords[:3]
1800
- else:
1801
- hot = _get_hot_topics()
1802
- selected_topics = [t.get("topic", "") for t in hot[:3] if t.get("topic")]
1803
-
1804
- all_sources = []
1805
- seen_urls = set()
1806
- for topic in selected_topics[:3]:
1807
- sources = _search_all(topic, limit=5)
1808
- for s in sources:
1809
- if s.get("url") and s["url"] not in seen_urls:
1810
- seen_urls.add(s["url"])
1811
- all_sources.append(s)
1812
- if len(all_sources) >= 6:
1813
- break
1814
- if len(all_sources) >= 6:
1815
- break
1816
-
1817
- for src in selected_sources:
1818
- if src.get("url") and src["url"] not in seen_urls:
1819
- all_sources.insert(0, src)
1820
-
1821
- source_details = []
1822
- source_images = []
1823
- for src in all_sources[:5]:
1824
- url = src.get("url", "")
1825
- if not url:
1826
- continue
1827
- try:
1828
- art = _scrape_article_for_rewrite(url)
1829
- if art:
1830
- src_detail = {
1831
- "title": art.get("title", src.get("title", "")),
1832
- "url": url,
1833
- "via": src.get("via", ""),
1834
- "paragraphs": art.get("paragraphs", [])[:6],
1835
- "images": art.get("images", [])[:2],
1836
- "og_image": art.get("og_img", "")
1837
- }
1838
- source_details.append(src_detail)
1839
- for img in art.get("images", [])[:2]:
1840
- if any(x in img for x in ["cdnphoto.dantri", "vnexpress", "vcdn", "refooty"]):
1841
- img = "/api/proxy/img?url=" + _quote2(img, safe="")
1842
- source_images.append(img)
1843
- except:
1844
- pass
1845
-
1846
- # Title
1847
- if custom_title:
1848
- title = custom_title[:80]
1849
- else:
1850
- opinion_words = re.findall(r"[A-Za-zÀ-ỹ0-9]+", opinion)
1851
- title_words = opinion_words[:8] if len(opinion_words) >= 8 else opinion_words[:4]
1852
- title = " ".join([w[0].upper() + w[1:] for w in title_words]) if title_words else "Quan điểm cá nhân"
1853
- title = title[:80]
1854
-
1855
- # AI sinh bài
1856
- ai_text = None
1857
- try:
1858
- import ai_ext
1859
- if hasattr(ai_ext, 'qwen_generate'):
1860
- source_context = ""
1861
- for i, sd in enumerate(source_details[:5]):
1862
- src_title = sd.get("title", "")
1863
- src_via = sd.get("via", "")
1864
- src_paras = sd.get("paragraphs", [])
1865
- source_context += f"\nNguồn {i+1}: {src_title} ({src_via})\n"
1866
- for j, p in enumerate(src_paras[:3]):
1867
- source_context += f" - {p[:300]}\n"
1868
- prompt = (
1869
- "QUAN ĐIỂM: " + opinion[:500] + "\nNGUỒN: " + source_context[:1000] + "\n\n"
1870
- "=== NGUỒN TIN ===\n" + source_context + "\n\n"
1871
- "=== YÊU CẦU VIẾT BÀI THEO SLIDE ===\n"
1872
- "Viết bài thành 5-6 ĐOẠN VĂN NGẮN, mỗi đoạn là 1 SLIDE.\n"
1873
- "\n"
1874
- "SLIDE 1 - MỞ ĐẦU: Giới thiệu chủ đề, nêu quan điểm cá nhân (2-4 câu hoàn chỉnh)\n"
1875
- "SLIDE 2-3-4-5 - PHÂN TÍCH: Mỗi slide dùng 1 nguồn tin cụ thể, kết hợp quan điểm cá nhân, ghi rõ nguồn (Theo VnExpress...), 2-4 câu hoàn chỉnh, thành 1 đoạn văn hoàn chỉnh\n"
1876
- "SLIDE 6 - KẾT LUẬN: Tổng kết quan điểm, nhận định cuối cùng (2-3 câu hoàn chỉnh)\n"
1877
- "\n"
1878
- "Định dạng:\n"
1879
- "---SLIDE 1---\n[đoạn văn hoàn chỉnh kết thúc bằng dấu chấm]\n---SLIDE 2---\n[đoạn văn hoàn chỉnh kết thúc bằng dấu chấm]\n...\n"
1880
- "\n"
1881
- "QUAN TRỌNG: Mỗi slide là 1 đoạn văn HOÀN CHỈNH, 2-4 câu, PHẢI KẾT THÚC BẰNG DẤU CHẤM (.). Kết hợp QUAN ĐIỂM + NGUỒN TIN. Không gạch đầu dòng. Viết liền mạch. 300-600 từ."
1882
- )
1883
- ai_text = None # Không dùng AI, để code tự kết hợp opinion + source
1884
- except:
1885
- pass
1886
-
1887
- if not ai_text or len(ai_text) < 100:
1888
- ai_text = "## " + title + "\n\n" + opinion + "\n\n"
1889
- for i, sd in enumerate(source_details[:5]):
1890
- ai_text += "### " + sd.get("title", "") + "\n"
1891
- for p in sd.get("paragraphs", [])[:2]:
1892
- ai_text += p[:250] + "\n"
1893
- ai_text += "\n---\n*Nguồn: " + sd.get("via", "") + "*\n\n"
1894
-
1895
- # Tạo slides
1896
- if custom_slides and len(custom_slides) > 0:
1897
- slides = []
1898
- for i, slide in enumerate(custom_slides):
1899
- slides.append({
1900
- "text": slide.get("text", ""),
1901
- "image": slide.get("image", ""),
1902
- "index": i + 1
1903
- })
1904
- else:
1905
- slides = []
1906
- # Parse từ AI output (format: ---SLIDE N---)
1907
- if ai_text:
1908
- pattern = r'---SLIDE\s*(\d+)---\s*\n(.*?)(?=---SLIDE|\Z)'
1909
- matches = re.findall(pattern, ai_text, re.DOTALL)
1910
- if matches:
1911
- for idx, (num, content) in enumerate(matches):
1912
- # Normalize: ensure complete sentences
1913
- text = _ensure_sentence_complete(content)
1914
- if len(text) > 30:
1915
- img = source_images[idx] if idx < len(source_images) else ""
1916
- slides.append({"text": text, "image": img, "index": idx + 1})
1917
-
1918
- # Fallback: split by paragraphs
1919
- if len(slides) < 3:
1920
- paragraphs = [p.strip() for p in re.split(r'\n\n+', ai_text) if p.strip()]
1921
- slides = []
1922
- para_count = 0
1923
- for p in paragraphs:
1924
- p = p.strip()
1925
- if p.startswith('#') or p.startswith('---') or p.startswith('*Nguồn'):
1926
- continue
1927
- # Normalize: ensure complete sentences
1928
- p_normalized = _ensure_sentence_complete(p)
1929
- if len(p_normalized) > 50:
1930
- img = source_images[para_count] if para_count < len(source_images) else ""
1931
- slides.append({"text": p_normalized, "image": img, "index": para_count + 1})
1932
- para_count += 1
1933
- if para_count >= 6:
1934
- break
1935
-
1936
- if len(slides) < 2:
1937
- slides = []
1938
- # Slide 1: QUAN ĐIỂM CÁ NHÂN (BẮT BUỘC)
1939
- slides.append({"text": f"Theo quan điểm cá nhân: {opinion[:400]}", "image": source_images[0] if source_images else "", "index": 1})
1940
-
1941
- # Slide 2-6: KẾT HỢP QUAN ĐIỂM + SOURCE
1942
- for i in range(min(5, len(source_details))):
1943
- if len(slides) >= 6:
1944
- break
1945
- src = source_details[i]
1946
- src_via = src.get("via", "")
1947
- src_paras = src.get("paragraphs", [])
1948
-
1949
- src_text = ""
1950
- for p in src_paras[:2]:
1951
- p = p.strip()[:280]
1952
- if len(p) > 50:
1953
- src_text = p
1954
- break
1955
-
1956
- if src_text:
1957
- combined = f"Theo góc nhìn cá nhân, {opinion[:60]}. Theo {src_via}: {src_text}"
1958
- img = source_images[len(slides)] if len(slides) < len(source_images) else (source_images[-1] if source_images else "")
1959
- slides.append({"text": _ensure_sentence_complete(combined), "image": img, "index": len(slides) + 1})
1960
-
1961
- lang, emotion = detect_language_and_emotion(title, ai_text)
1962
- voice = get_voice_for_content(title, ai_text)
1963
-
1964
- post = {
1965
- "id": str(int(time.time() * 1000)) + str(_random2.randint(100, 999)),
1966
- "title": title,
1967
- "text": ai_text,
1968
- "img": source_images[0] if source_images else "",
1969
- "url": "",
1970
- "kind": "personal_opinion",
1971
- "slides": slides,
1972
- "images": source_images[:10],
1973
- "video": "",
1974
- "voice": voice,
1975
- "emotion": emotion,
1976
- "language": lang,
1977
- "ts": int(time.time()),
1978
- "sources": source_details[:5]
1979
- }
1980
-
1981
- posts = _load_wall_posts()
1982
- posts.insert(0, post)
1983
- _save_wall_posts(posts)
1984
-
1985
- return JSONResponse({"post": post, "slides": slides})
1986
-
1987
-
1988
- # ===== END PERSONAL OPINION POST v2 =====
1989
-
1990
- def _bg():
1991
- time.sleep(15)
1992
- while True:
1993
- try:get_wc2026_all()
1994
- except:pass
1995
- time.sleep(90)
1996
- threading.Thread(target=_bg,daemon=True).start()
1997
-
1998
- # ===== AUTO SCHEDULER: rewrite AI + short at 7/13/19 VN time =====
1999
- _AUTO_SCHEDULE_TIMES = [(7, '07:00'), (13, '13:00'), (19, '19:00')]
2000
- _AUTO_LOG = os.path.join(DATA_DIR, 'auto_rewrite_log.json')
2001
-
2002
- def _load_auto_log():
2003
- try:
2004
- if os.path.exists(_AUTO_LOG):
2005
- with open(_AUTO_LOG, 'r') as f:
2006
- return json.load(f)
2007
- except: pass
2008
- return {}
2009
-
2010
- def _save_auto_log(log):
2011
- try:
2012
- tmp = _AUTO_LOG + '.tmp'
2013
- with open(tmp, 'w') as f:
2014
- json.dump(log, f)
2015
- os.replace(tmp, _AUTO_LOG)
2016
- except: pass
2017
-
2018
- async def _auto_fetch_short(post_id):
2019
- """Try to auto-generate a short for a post."""
2020
- try:
2021
- import httpx
2022
- async with httpx.AsyncClient(timeout=180) as cl:
2023
- r = await cl.post(
2024
- f"http://localhost:7860/api/ai/short/{post_id}",
2025
- json={"voice":"vi-VN-HoaiMyNeural","emotion":"neutral","speed":1.2},
2026
- headers={"Content-Type":"application/json"}
2027
- )
2028
- if r.status_code < 300:
2029
- sj = r.json()
2030
- if sj.get('video'):
2031
- posts = _load_wall_posts()
2032
- for p in posts:
2033
- if p.get('id') == post_id:
2034
- p['video'] = sj['video']
2035
- break
2036
- _save_wall_posts(posts)
2037
- return True
2038
- except: pass
2039
- return False
2040
-
2041
- async def _auto_rewrite_one(topic, slot_label, used_urls=None, post_index=0):
2042
- """Rewrite one topic: find articles, summarize, post to wall, trigger short.
2043
- used_urls: shared set to avoid duplicate articles across topics.
2044
- post_index: 0-based index to create multiple posts per topic (0,1,2 = up to 3 posts)."""
2045
- from urllib.parse import quote as _q
2046
- # Get MORE items to support 1-3 posts per topic
2047
- items = _search_all(topic, limit=12)
2048
- # Skip URLs already used by another topic
2049
- if used_urls is not None:
2050
- filtered = [it for it in items if it.get('url') not in used_urls]
2051
- if filtered:
2052
- items = filtered
2053
- if not items or post_index >= len(items):
2054
- return False
2055
-
2056
- # Get article at post_index (0,1,2 for multiple posts)
2057
- item = items[post_index] # post_index allows multiple articles per topic
2058
- url = item.get('url', '')
2059
- title = item.get('title', topic)
2060
- if url and used_urls is not None:
2061
- used_urls.add(url)
2062
- if not url.startswith('http'):
2063
- return False
2064
-
2065
- data = _scrape_article_for_rewrite(url)
2066
- if not data or not data.get('paragraphs'):
2067
- return False
2068
-
2069
- raw_text = '\n'.join(data['paragraphs'])
2070
- ai_text = None
2071
-
2072
- # Try AI generation
2073
- try:
2074
- import ai_ext
2075
- 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."
2076
- ai_text = await ai_ext.qwen_generate(prompt, max_tokens=1000)
2077
- except: pass
2078
-
2079
- if not ai_text or len(ai_text) < 80:
2080
- pts = data['paragraphs'][:6]
2081
- ai_text = '\n\n'.join([f"• {p[:300]}" for p in pts])
2082
- via = item.get('via', '') or urlparse(url).netloc.replace('www.', '')
2083
- ai_text += f"\n\nNguồn tham khảo: {via}"
2084
-
2085
- # Build slides
2086
- images = data.get('images', [])
2087
- pts = data['paragraphs'][:10]
2088
- slides = []
2089
- for i, p in enumerate(pts[:8]):
2090
- img = images[i] if i < len(images) else (images[-1] if images else data.get('og_img', ''))
2091
- slides.append({'text': p[:300], 'image': img, 'index': i + 1})
2092
-
2093
- post_id = str(int(time.time() * 1000)) + str(_random2.randint(100, 999))
2094
- post = {
2095
- "id": post_id, "title": data.get('title', title)[:200],
2096
- "text": ai_text, "img": images[0] if images else data.get('og_img', ''),
2097
- "url": url, "kind": "auto_rewrite", "slides": slides,
2098
- "images": images[:10], "video": "",
2099
- "voice": "vi-VN-HoaiMyNeural", "emotion": "neutral",
2100
- "language": "vietnamese", "ts": int(time.time()),
2101
- "auto_scheduled": True, "slot": slot_label,
2102
- }
2103
-
2104
- posts = _load_wall_posts()
2105
- posts.insert(0, post)
2106
- _save_wall_posts(posts)
2107
-
2108
- # Trigger short generation async
2109
- threading.Thread(target=lambda: asyncio.run(_auto_fetch_short(post_id)), daemon=True).start()
2110
- return True
2111
-
2112
- async def _do_scheduled_run(slot_label):
2113
- """Main scheduled run: 1-3 posts from 3 different HOT topics (3-9 total), no duplicates."""
2114
- print(f"[auto] Starting scheduled rewrite for {slot_label}")
2115
-
2116
- # Get top hot topics, skip duplicates
2117
- all_topics = _get_hot_topics()
2118
- seen_topics = set()
2119
- unique_topics = []
2120
- for t in all_topics:
2121
- kw = t.get('topic', '').lower().strip()
2122
- if kw and len(kw) > 5 and kw not in seen_topics:
2123
- is_dup = False
2124
- for s in seen_topics:
2125
- # Check if one topic is substring of another
2126
- if kw in s or s in kw:
2127
- is_dup = True
2128
- break
2129
- if not is_dup:
2130
- seen_topics.add(kw)
2131
- unique_topics.append(t)
2132
- if len(unique_topics) >= 3:
2133
- break
2134
-
2135
- job_topics = [t['topic'] for t in unique_topics[:3] if t.get('topic')]
2136
- if not job_topics:
2137
- print(f"[auto] No hot topics found, skipping")
2138
- return
2139
-
2140
- print(f"[auto] Running 3 topics: {job_topics}")
2141
-
2142
- # Track used URLs to avoid cross-topic duplicates
2143
- _used_urls = set()
2144
- results = []
2145
-
2146
- # Process each topic, create 1-3 posts per topic
2147
- for jt in job_topics:
2148
- for post_idx in range(3): # Try up to 3 posts per topic
2149
- try:
2150
- ok = await asyncio.wait_for(_auto_rewrite_one(jt, slot_label, _used_urls, post_idx), timeout=120)
2151
- if ok:
2152
- results.append((jt, post_idx, True))
2153
- print(f"[auto] Created post {post_idx+1} for '{jt}'")
2154
- else:
2155
- # No more articles for this topic
2156
- break
2157
- except Exception as e:
2158
- print(f"[auto] Error on '{jt}' post {post_idx}: {e}")
2159
- results.append((jt, post_idx, False))
2160
- await asyncio.sleep(1) # Small delay between posts
2161
-
2162
- # Ensure at least 3 posts total (fallback if needed)
2163
- successful_posts = sum(1 for _, _, ok in results if ok)
2164
- print(f"[auto] Done {slot_label}: {successful_posts} posts created")
2165
-
2166
- # Log
2167
- from datetime import datetime, timezone, timedelta
2168
- VN_TZ_SCHED = timezone(timedelta(hours=7))
2169
- today_str = datetime.now(VN_TZ_SCHED).strftime('%Y-%m-%d')
2170
- log = _load_auto_log()
2171
- if today_str not in log: log[today_str] = {}
2172
- log[today_str][slot_label] = {
2173
- 'time': datetime.now(VN_TZ_SCHED).strftime('%H:%M:%S'),
2174
- 'count': successful_posts,
2175
- 'total': len(job_topics),
2176
- }
2177
- _save_auto_log(log)
2178
-
2179
- def _scheduler_loop():
2180
- """Check every 60s; trigger at 7:00, 13:00, 19:00 VN time.
2181
- On startup, check for any missed slots today and run them immediately."""
2182
- time.sleep(35)
2183
- from datetime import datetime, timezone, timedelta
2184
- VN_TZ_SCHED = timezone(timedelta(hours=7))
2185
-
2186
- _last_run_date = ""
2187
- _last_run_slots = set()
2188
-
2189
- # On startup: check log for missed slots today
2190
- try:
2191
- start_now = datetime.now(VN_TZ_SCHED)
2192
- today_str = start_now.strftime('%Y-%m-%d')
2193
- current_hour = start_now.hour
2194
- current_minute = start_now.minute
2195
- log = _load_auto_log()
2196
- today_log = log.get(today_str, {})
2197
- for h, label in _AUTO_SCHEDULE_TIMES:
2198
- # Run if slot is past (either strictly earlier hour, or same hour but window has passed)
2199
- should_run = False
2200
- if h < current_hour:
2201
- should_run = True
2202
- elif h == current_hour and current_minute > 10:
2203
- should_run = True
2204
- if should_run and label not in today_log:
2205
- print(f"[auto] Detected missed slot {label} (h={h} < now={current_hour}:{current_minute}), running catch-up now")
2206
- _run_scheduled_sync(label)
2207
- _last_run_slots.add(label)
2208
- except Exception as e:
2209
- print(f"[auto] Catch-up check error: {e}")
2210
-
2211
- while True:
2212
- try:
2213
- now = datetime.now(VN_TZ_SCHED)
2214
- today = now.strftime('%Y-%m-%d')
2215
- hour = now.hour
2216
- minute = now.minute
2217
-
2218
- if today != _last_run_date:
2219
- _last_run_date = today
2220
- _last_run_slots = set()
2221
-
2222
- slot = None
2223
- for h, label in _AUTO_SCHEDULE_TIMES:
2224
- if hour == h and 0 <= minute < 5:
2225
- slot = label
2226
- break
2227
-
2228
- if slot and slot not in _last_run_slots:
2229
- _last_run_slots.add(slot)
2230
- _run_scheduled_sync(slot)
2231
- except Exception as e:
2232
- print(f"[auto] Loop error: {e}")
2233
-
2234
- time.sleep(60)
2235
-
2236
- threading.Thread(target=_scheduler_loop, daemon=True, name='auto-rewrite-scheduler').start()
2237
-
2238
- @app.get('/api/debug/auto_schedule')
2239
- async def debug_auto_schedule(slot: str = '07:00'):
2240
- """Manually trigger auto scheduler for debugging."""
2241
- try:
2242
- # Check if we can access the data directory
2243
- log = _load_auto_log()
2244
- topics = _get_hot_topics()[:3]
2245
- job_topics = [t['topic'] for t in topics if t.get('topic')]
2246
- return JSONResponse({
2247
- "slot": slot,
2248
- "log": log,
2249
- "hot_topics": job_topics,
2250
- "wall_posts_count": len(_load_wall_posts()),
2251
- "data_dir_writable": os.access(DATA_DIR, os.W_OK) if os.path.isdir(DATA_DIR) else False,
2252
- "data_dir_exists": os.path.isdir(DATA_DIR),
2253
- })
2254
- except Exception as e:
2255
- return JSONResponse({"error": str(e)}, status_code=500)
2256
-
2257
- def _run_scheduled_sync(slot):
2258
- """Run _do_scheduled_run in a separate event loop (for background thread)."""
2259
- loop = asyncio.new_event_loop()
2260
- asyncio.set_event_loop(loop)
2261
- try:
2262
- loop.run_until_complete(_do_scheduled_run(slot))
2263
- except Exception as e:
2264
- print(f"[auto] Background run error: {e}")
2265
- finally:
2266
- loop.close()
2267
-
2268
- @app.get('/api/debug/trigger_auto')
2269
- async def debug_trigger_auto(slot: str = '19:00'):
2270
- """Trigger _do_scheduled_run in background thread (non-blocking)."""
2271
- threading.Thread(target=_run_scheduled_sync, args=(slot,), daemon=True).start()
2272
- return JSONResponse({"status": "started", "slot": slot})
2273
-
2274
- # ===== SHORTS RSS PROXY ENDPOINT =====
2275
- @app.get("/api/shorts/rss")
2276
- def shorts_rss():
2277
- """Get shorts from YouTube RSS feeds server-side"""
2278
- import xml.etree.ElementTree as ET
2279
- import html as html_lib2
2280
- import re as re2
2281
-
2282
- YOUTUBE_CHANNELS = {
2283
- "baodantri7941": "UC_x5TKhOgd6GhYvv5z4I3jg",
2284
- "baosuckhoedoisongboyte": "UCBsY5fXTQLkF_JnH9kLkL4g",
2285
- }
2286
-
2287
- shorts = []
2288
- seen = set()
2289
-
2290
- for handle, channel_id in YOUTUBE_CHANNELS.items():
2291
- try:
2292
- rss_url = f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"
2293
- r = req.get(rss_url, headers=HEADERS, timeout=15)
2294
- if r.status_code != 200:
2295
- continue
2296
-
2297
- root = ET.fromstring(r.text)
2298
- ns = {
2299
- 'atom': 'http://www.w3.org/2005/Atom',
2300
- 'yt': 'http://www.youtube.com/xml/schemas/2015',
2301
- 'media': 'http://search.yahoo.com/mrss/'
2302
- }
2303
-
2304
- for entry in root.findall('atom:entry', ns)[:30]:
2305
- title_el = entry.find('atom:title', ns)
2306
- title = html_lib2.unescape(title_el.text) if title_el is not None and title_el.text else ''
2307
-
2308
- link_el = entry.find('atom:link', ns)
2309
- link = link_el.get('href', '') if link_el is not None else ''
2310
-
2311
- vid_el = entry.find('yt:videoId', ns)
2312
- vid = vid_el.text if vid_el is not None else ''
2313
-
2314
- if not vid or vid in seen:
2315
- continue
2316
-
2317
- # Check if it's a short
2318
- is_short = '#shorts' in title.lower() or '#short' in title.lower() or '/shorts/' in link
2319
-
2320
- if not is_short:
2321
- desc_el = entry.find('media:description', ns)
2322
- if desc_el is not None and desc_el.text:
2323
- if '#shorts' in desc_el.text.lower():
2324
- is_short = True
2325
-
2326
- if not is_short:
2327
- continue
2328
-
2329
- seen.add(vid)
2330
-
2331
- # Get thumbnail
2332
- thumb = f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg"
2333
- media_group = entry.find('media:group', ns)
2334
- if media_group is not None:
2335
- thumb_el = media_group.find('media:thumbnail', ns)
2336
- if thumb_el is not None:
2337
- thumb = thumb_el.get('url', thumb)
2338
-
2339
- shorts.append({
2340
- 'id': vid,
2341
- 'title': title.replace('#shorts', '').replace('#short', '').strip()[:120],
2342
- 'img': thumb,
2343
- 'link': f'https://www.youtube.com/shorts/{vid}',
2344
- 'channel': handle,
2345
- 'source': 'yt'
2346
- })
2347
-
2348
- if len(shorts) >= 40:
2349
- break
2350
-
2351
- except Exception as e:
2352
- print(f"RSS error for {handle}: {e}")
2353
- continue
2354
-
2355
- return {"shorts": shorts, "count": len(shorts)}
2356
-
2357
- app.mount('/static',StaticFiles(directory=STATIC_DIR),name='vnews_static')
 
226
  def _has_kw(topic,title):
227
  tl=topic.lower();tt=(title or'').lower()
228
  if tl in tt:return True
229
+ words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',tl) if len(w)>2 and w.lower() not in _STOP]
230
  if not words:return True
231
  return any(w in tt for w in words)
232
 
 
498
  is_dup=any(len(set(e.split())&set(key.split()))/max(len(set(e.split())),len(set(key.split())),1)>0.6 for e in seen)
499
  if is_dup:continue
500
  seen.add(key);topics.append({'label':'#'+re.sub(r'\s+','',display[key].title()),'topic':display[key],'count':count})
501
+ if len/topics>=20:break
502
  for kw in['World Cup 2026','Kinh tế Việt Nam','Bóng đá châu Âu','Công nghệ AI','Giá vàng','Thời tiết']:
503
+ if len/topics>=24:break
504
  if not any(kw.lower() in s for s in seen):topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw,'count':0})
505
  _hot_cache.update({'t':now,'d':topics[:24]});return topics[:24]
506
 
 
810
  @app.get('/api/wc2026/match/{eid}')
811
  def _wm(eid:int):return JSONResponse(scrape_match_detail(eid))
812
 
813
+ DATA_DIR='/data' if os.path.isdir('/data') else os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data')
814
  os.makedirs(DATA_DIR,exist_ok=True)
815
  IF=os.path.join(DATA_DIR,'interactions_v2.json')
816
  CF=os.path.join(DATA_DIR,'comments_v2.json')
 
821
  _il=threading.Lock();_cl=threading.Lock();_wl_lock=threading.Lock()
822
  def _lj(p):
823
  try:
824
+ p=str(p)
825
  if os.path.exists(p):return json.load(open(p,'r',encoding='utf-8'))
826
  except:pass
827
+ return[]
828
  def _sj(p,d):
829
+ try:
830
+ p=str(p)
831
+ os.makedirs(os.path.dirname(p),exist_ok=True)
832
+ tmp=p+'.tmp'
833
+ with open(tmp,'w',encoding='utf-8') as f:
834
+ json.dump(d,f,ensure_ascii=False)
835
+ os.replace(tmp,p)
836
  except:pass
837
 
838
+ def _wall_posts_list():
839
+ posts = _load_wall_posts()
840
+ return posts if isinstance(posts, list) else []
 
 
 
 
 
 
841
 
842
+ def _append_wall_post(post):
843
+ posts = _wall_posts_list()
844
+ posts.insert(0, post)
845
+ posts = posts[:200]
846
+ _save_wall_posts(posts)
847
+ return posts
848
 
849
+ @app.get('/api/ai/wall')
850
+ def api_ai_wall():
851
+ return JSONResponse({"posts": _wall_posts_list()})
 
 
 
 
 
852
 
853
+ @app.post("/api/ai/short/{post_id}")
854
+ def api_ai_short(post_id: str, request: Request):
855
+ """Generate a short video for a wall post.
856
 
857
+ This endpoint intentionally returns a clear JSON error when video generation is not
858
+ available yet, instead of crashing FastAPI with an invalid JSON response.
859
+ """
860
+ post = None
861
+ post_id_s = str(post_id)
862
+ for p in _wall_posts_list():
863
+ pid = str(p.get('id', ''))
864
+ if pid == post_id_s or pid.startswith(post_id_s):
865
+ post = p
866
+ break
867
+ if not post:
868
+ return JSONResponse({'error': 'Không tìm thấy bài viết', 'post_id': post_id}, status_code=404)
869
+ if post.get('video'):
870
+ return JSONResponse({'video': post['video'], 'post': post})
871
+ return JSONResponse({'error': 'Chưa có video cho bài này. Vui lòng upload video trước.'}, status_code=409)
872
 
873
  @app.get('/api/wall')
874
  def api_wall():
 
1004
  _STOPWORDS = {
1005
  'english': {'the', 'is', 'at', 'which', 'on', 'a', 'an', 'and', 'or', 'but', 'in', 'with', 'to', 'for', 'of', 'not', 'no', 'can', 'had', 'have', 'has', 'was', 'were', 'are', 'be', 'been', 'this', 'that', 'it', 'he', 'she', 'they', 'his', 'her', 'my', 'your', 'our', 'we', 'you', 'i'},
1006
  'vietnamese': {'là', 'của', 'và', 'có', 'được', 'cho', 'không', 'với', 'này', 'đó', 'từ', 'trong', 'đã', 'sẽ', 'một', 'các', 'những', 'về', 'tại', 'người', 'năm', 'đến', 'ra', 'lại', 'như', 'khi', 'để', 'rất', 'cũng', 'mà', 'nếu', 'sau', 'trên', 'theo', 'vì', 'do', 'nên', 'thì', 'mình', 'tôi', 'bạn', 'anh', 'chị', 'em'},
1007
+ 'portuguese': {'de', 'um', 'que', 'e', 'do', 'da', 'em', 'para', 'com', 'não', 'uma', 'os', 'no', 'se', 'na', 'por', 'mais', 'as', 'dos', 'como', 'mas', 'ao', 'ele', 'das', 'tem', 'sua', 'ou', 'quando', 'muito', 'nos', 'já', 'eu', 'também', 'só', 'pelo', 'pela', 'até', 'isso', 'ela', 'entre', 'depois', 'sem', 'mesmo', 'aos', 'são', 'está', 'ter', 'ser', 'foi', 'era', 'há', 'estão', 'você', 'nós', 'eles', 'elas'},
1008
  'spanish': {'de', 'que', 'el', 'en', 'y', 'a', 'los', 'del', 'se', 'las', 'por', 'un', 'para', 'con', 'no', 'una', 'su', 'al', 'es', 'lo', 'como', 'más', 'pero', 'sus', 'le', 'ya', 'o', 'fue', 'este', 'ha', 'si', 'porque', 'esta', 'son', 'entre', 'está', 'cuando', 'muy', 'sin', 'sobre', 'ser', 'también', 'me', 'hasta', 'hay', 'donde', 'han', 'quien', 'están', 'desde', 'todo', 'nos', 'durante', 'todos', 'uno', 'les', 'ni', 'contra', 'otros', 'fueron', 'ese', 'eso', 'ante', 'ellos', 'yo', 'tú', 'él', 'ella', 'nosotros', 'usted', 'ustedes'},
1009
  }
1010
 
 
1056
  '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'],
1057
  },
1058
  'sad': {
1059
+ 'en': ['sad', 'unhappy', 'terrible', 'awful', 'horrible', 'miserable', 'depressed