bep40 commited on
Commit
247f507
·
verified ·
1 Parent(s): a8bb5a1

Fix syntax errors in f-strings

Browse files
Files changed (1) hide show
  1. app_v2_entry.py +1 -1332
app_v2_entry.py CHANGED
@@ -1,1332 +1 @@
1
- """VNEWS v2 Entry Point - with fast bongda proxy + rewrite endpoints + multilingual TTS"""
2
- import sys, os
3
- from main import app, HEADERS, BONGDA_HEADERS, fetch_bongda_api, HL_LEAGUES
4
-
5
- try:
6
- import ai_ext
7
- except Exception as e:
8
- print(f"[WARN] ai_ext import failed: {e}")
9
-
10
- try:
11
- import ai_patch
12
- except Exception as e:
13
- print(f"[WARN] ai_patch import failed: {e}")
14
-
15
- from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, Response
16
- from fastapi.staticfiles import StaticFiles
17
- from starlette.routing import Mount
18
- from fastapi import Query, Request, UploadFile, File, Form
19
- import requests as req
20
- from bs4 import BeautifulSoup
21
- 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
-
28
- STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static')
29
- SPACE = "https://bep40-vnews.hf.space" # SEO URL base for share links
30
- app.router.routes=[r for r in app.router.routes if not(getattr(r,'path',None)=='/' and hasattr(r,'methods') and 'GET' in getattr(r,'methods',set()))]
31
- app.routes[:]=[r for r in app.routes if not isinstance(r, Mount)]
32
- app.router.routes=[r for r in app.router.routes if not isinstance(r, Mount)]
33
-
34
- def _clean(s): return re.sub(r"\s+"," ",html_lib.unescape(str(s or""))).strip()
35
-
36
- # Cache for match details (5 min TTL)
37
- _match_cache = {}
38
-
39
- # === FAST BONGDA PROXY ENDPOINT ===
40
- def _get_match_detail(event_id, slug=None):
41
- headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "text/html", "Referer": "https://bongda.com.vn/"}
42
- if slug:
43
- url = f"https://bongda.com.vn/tran-dau/{event_id}/centre/{slug}"
44
- else:
45
- url = f"https://bongda.com.vn/tran-dau/{event_id}"
46
- resp = req.get(url, headers=headers, timeout=15, allow_redirects=True)
47
- if resp.status_code != 200:
48
- return None
49
- soup = BeautifulSoup(resp.text, 'html.parser')
50
- result = {"event_id": event_id, "found": False, "sections": []}
51
- info = {}
52
- tel = soup.select_one('.teams')
53
- if tel:
54
- he = tel.select_one('.team.home')
55
- if he:
56
- p_tags = [p for p in he.select('p') if not p.get('class') or 'logo' not in p.get('class', [])]
57
- if p_tags: info['home_team'] = _clean(p_tags[0].get_text())
58
- lo = he.select_one('img')
59
- if lo: info['home_logo'] = lo.get('src', '')
60
- ae = tel.select_one('.team.away')
61
- if ae:
62
- p_tags = ae.select('p')
63
- team_ps = [p for p in p_tags if not p.get('class') or 'logo' not in p.get('class', [])]
64
- if team_ps: info['away_team'] = _clean(team_ps[-1].get_text())
65
- lo = ae.select_one('img')
66
- if lo: info['away_logo'] = lo.get('src', '')
67
- sc = tel.select_one('.score')
68
- if sc:
69
- parts = [_clean(p.get_text()) for p in sc.select('p')]
70
- if len(parts) >= 2: info['score'] = f"{parts[0]} - {parts[1]}"
71
- lb = sc.select_one('.label')
72
- if lb: info['status_label'] = _clean(lb.get_text())
73
- if info.get('home_team') and info.get('away_team'):
74
- result['info'] = info
75
- result['found'] = True
76
- result['sections'].append('info')
77
- events = []
78
- for ev in soup.select('.events .period .event'):
79
- ev_cls = ' '.join(ev.get('class', []))
80
- ev_data = {'team': 'home' if 'home' in ev_cls else 'away', 'period': '', 'type': 'unknown', 'time': '', 'players': ''}
81
- parent = ev.parent
82
- if parent:
83
- h2 = parent.find('h2')
84
- if h2: ev_data['period'] = _clean(h2.get_text())
85
- if ev.select_one('[class*="goal"]'): ev_data['type'] = 'goal'
86
- elif ev.select_one('[class*="redcard"]'): ev_data['type'] = 'redcard'
87
- elif ev.select_one('[class*="yellowcard"]'): ev_data['type'] = 'yellowcard'
88
- elif ev.select_one('[class*="substitution"]'): ev_data['type'] = 'substitution'
89
- players_el = ev.select_one('.players')
90
- if players_el:
91
- pl_text = _clean(players_el.get_text(' ', strip=True))
92
- m = re.match(r"(\d+)'(.*)", pl_text)
93
- if m:
94
- ev_data['time'] = f"{m.group(1)}'"
95
- ev_data['players'] = m.group(2)
96
- else:
97
- ev_data['players'] = pl_text
98
- events.append(ev_data)
99
- if events:
100
- result['events'] = events
101
- result['sections'].append('events')
102
- pred = soup.select_one('.prediction-card')
103
- if pred:
104
- team_info = pred.select_one('.team-info')
105
- if team_info:
106
- teams = team_info.select('.team')
107
- pred_data = {}
108
- if len(teams) >= 2:
109
- pred_data['home_name'] = _clean(teams[0].select_one('.team-name').get_text()) if teams[0].select_one('.team-name') else ''
110
- pred_data['away_name'] = _clean(teams[1].select_one('.team-name').get_text()) if teams[1].select_one('.team-name') else ''
111
- divider = team_info.select_one('.divider')
112
- if divider: pred_data['result'] = _clean(divider.get_text())
113
- vc = pred.select_one('.vote-count')
114
- if vc: pred_data['vote_count'] = _clean(vc.get_text())
115
- result['prediction'] = pred_data
116
- recent = []
117
- ml = soup.select_one('.matches-list')
118
- if ml:
119
- for item in ml.select('.match-detail, .match-item, li'):
120
- de = item.select_one('.date, .time')
121
- le = item.select_one('.league')
122
- he_item = item.select_one('.home, .team-home')
123
- ae_item = item.select_one('.away, .team-away')
124
- se = item.select_one('.score, .result')
125
- if he_item or ae_item:
126
- recent.append({'date': _clean(de.get_text()) if de else '', 'league': _clean(le.get_text()) if le else '', 'home': _clean(he_item.get_text()) if he_item else '', 'away': _clean(ae_item.get_text()) if ae_item else '', 'score': _clean(se.get_text()) if se else 'vs'})
127
- if recent:
128
- result['recent_matches'] = recent
129
- result['sections'].append('recent')
130
- try:
131
- api_h = {"User-Agent": "Mozilla/5.0", "Accept": "application/json", "X-Requested-With": "XMLHttpRequest", "Referer": "https://bongda.com.vn/"}
132
- ar = req.get(f"https://bongda.com.vn/api/fixtures/h2h-stats?event_id={event_id}", headers=api_h, timeout=10)
133
- if ar.status_code == 200:
134
- ad = ar.json()
135
- if ad.get('status') == 'success' and ad.get('html'):
136
- asp = BeautifulSoup(ad['html'], 'html.parser')
137
- ast = {}
138
- for row in asp.select('li, tr'):
139
- cells = row.select('td, span, p')
140
- if len(cells) >= 3:
141
- lb = _clean(cells[0].get_text())
142
- if lb: ast[lb] = {'home': _clean(cells[1].get_text()), 'away': _clean(cells[2].get_text())}
143
- if ast:
144
- result['h2h_stats_parsed'] = ast
145
- result['sections'].append('h2h_stats')
146
- except: pass
147
- return result
148
-
149
- @app.get('/api/proxy/bongda')
150
- def proxy_bongda(event_id: int = Query(default=None), slug: str = Query(default=None)):
151
- if event_id is None:
152
- return JSONResponse({'error': 'event_id required'}, status_code=400)
153
- cache_key = f"{event_id}_{slug}"
154
- now = time.time()
155
- cached = _match_cache.get(cache_key)
156
- if cached and now - cached.get('_ts', 0) < 300:
157
- return JSONResponse(cached)
158
- try:
159
- result = _get_match_detail(event_id, slug)
160
- if result:
161
- result['_ts'] = now
162
- _match_cache[cache_key] = result
163
- return JSONResponse(result)
164
- except Exception as e:
165
- err = {"event_id": event_id, "found": False, "error": str(e), "_ts": now}
166
- _match_cache[cache_key] = err
167
- return JSONResponse(err)
168
- return JSONResponse({"event_id": event_id, "found": False})
169
-
170
- @app.get('/api/match/{event_id}/detail')
171
- def api_match_detail(event_id: int, url: str = Query(default=None)):
172
- slug = None
173
- if url:
174
- m = re.match(r'.+/tran-dau/\d+/(?:centre|preview)/(.+)', url)
175
- if m:
176
- slug = m.group(1)
177
- cache_key = f"{event_id}_{slug or ''}"
178
- now = time.time()
179
- cached = _match_cache.get(cache_key)
180
- if cached and now - cached.get('_ts', 0) < 300:
181
- return JSONResponse(cached)
182
- try:
183
- if not slug:
184
- try:
185
- home_r = req.get("https://bongda.com.vn/", headers={"User-Agent": "Mozilla/5.0"}, timeout=10)
186
- if home_r.status_code == 200:
187
- home_soup = BeautifulSoup(home_r.text, 'html.parser')
188
- for a in home_soup.select(f'a[href*="/tran-dau/{event_id}/"]'):
189
- href = a.get('href', '')
190
- m = re.match(r'/tran-dau/\d+/(?:centre|preview)/(.+)', href)
191
- if m:
192
- slug = m.group(1)
193
- cache_key = f"{event_id}_{slug}"
194
- break
195
- except: pass
196
- result = _get_match_detail(event_id, slug)
197
- if result:
198
- result['_ts'] = now
199
- _match_cache[cache_key] = result
200
- return JSONResponse(result)
201
- except Exception as e:
202
- err = {"event_id": event_id, "found": False, "error": str(e), "_ts": now}
203
- _match_cache[cache_key] = err
204
- return JSONResponse(err)
205
- return JSONResponse({"event_id": event_id, "found": False})
206
-
207
- _STOP=set('và của các những một được trong với cho tại sau trước khi không người 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ì này đã để'.split())
208
-
209
- # Rotating User-Agents for search scrapers - tránh bị chặn
210
- _SEARCH_UAS = [
211
- 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
212
- 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
213
- 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
214
- 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1',
215
- 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36',
216
- 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
217
- ]
218
-
219
- import random as _search_rand
220
-
221
- def _search_headers():
222
- return {'User-Agent': _search_rand.choice(_SEARCH_UAS), 'Accept-Language': 'vi-VN,vi;q=0.9,en;q=0.8', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'}
223
-
224
- def _has_kw(topic,title):
225
- tl=topic.lower();tt=(title or'').lower()
226
- if tl in tt:return True
227
- words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',tl) if len(w)>2 and w not in _STOP]
228
- if not words:return True
229
- return any(w in tt for w in words)
230
-
231
- def _s_vnexpress(topic,limit=8):
232
- items=[]
233
- try:
234
- r=req.get(f"https://timkiem.vnexpress.net/?q={quote(topic)}",headers=_search_headers(),timeout=12);soup=BeautifulSoup(r.text,'lxml')
235
- for art in soup.select('article.item-news')[:limit]:
236
- a=art.select_one('h2 a, h3 a')
237
- if a and a.get('href'):
238
- t=_clean(a.get('title','') or a.get_text(strip=True))
239
- if _has_kw(topic,t):items.append({'title':t,'url':a['href'],'via':'VnExpress'})
240
- except:pass
241
- return items
242
-
243
- def _s_dantri(topic,limit=8):
244
- items=[]
245
- try:
246
- r=req.get(f"https://dantri.com.vn/tim-kiem/{quote(topic)}.htm",headers=_search_headers(),timeout=12);soup=BeautifulSoup(r.text,'lxml')
247
- for a in soup.select('h3 a[href], .article-title a[href]')[:limit*2]:
248
- t=_clean(a.get_text(strip=True));href=a.get('href','')
249
- if t and len(t)>15 and _has_kw(topic,t):
250
- if not href.startswith('http'):href='https://dantri.com.vn'+href
251
- items.append({'title':t,'url':href,'via':'Dân Trí'})
252
- if len(items)>=limit:break
253
- except:pass
254
- return items
255
-
256
- def _s_vietnamnet(topic,limit=6):
257
- items=[]
258
- try:
259
- r=req.get(f"https://vietnamnet.vn/tim-kiem?q={quote(topic)}",headers=_search_headers(),timeout=12);soup=BeautifulSoup(r.text,'lxml')
260
- for a in soup.select('h3 a[href], .vnn-title a')[:limit*2]:
261
- t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
262
- if t and len(t)>15 and _has_kw(topic,t):
263
- if not href.startswith('http'):href='https://vietnamnet.vn'+href
264
- items.append({'title':t,'url':href,'via':'VietNamNet'})
265
- if len(items)>=limit:break
266
- except:pass
267
- return items
268
-
269
- def _s_bongda(topic,limit=5):
270
- items=[]
271
- try:
272
- r=req.get(f"https://bongda.com.vn/tim-kiem.html?q={quote(topic)}",headers=_search_headers(),timeout=12);soup=BeautifulSoup(r.text,'lxml')
273
- for a in soup.select('h3 a[href], .title a[href]')[:limit*2]:
274
- t=_clean(a.get_text(strip=True));href=a.get('href','')
275
- if t and len(t)>15 and _has_kw(topic,t):
276
- if not href.startswith('http'):href='https://bongda.com.vn'+href
277
- items.append({'title':t,'url':href,'via':'Bóng Đá'})
278
- if len(items)>=limit:break
279
- except:pass
280
- return items
281
-
282
- def _s_genk(topic,limit=5):
283
- items=[]
284
- try:
285
- r=req.get(f"https://genk.vn/tim-kiem?q={quote(topic)}",headers=_search_headers(),timeout=12);soup=BeautifulSoup(r.text,'lxml')
286
- for a in soup.select('a[href$=".chn"]')[:limit*3]:
287
- t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
288
- if t and len(t)>15 and _has_kw(topic,t):
289
- if href.startswith('/'):href='https://genk.vn'+href
290
- items.append({'title':t,'url':href,'via':'GenK'})
291
- if len(items)>=limit:break
292
- except:pass
293
- return items
294
-
295
- def _s_thanhnien(topic,limit=6):
296
- items=[]
297
- try:
298
- r=req.get(f"https://thanhnien.vn/tim-kiem?q={quote(topic)}",headers=_search_headers(),timeout=12);soup=BeautifulSoup(r.text,'lxml')
299
- for a in soup.select('h3 a[href], .box-title a')[:limit*2]:
300
- t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
301
- if t and len(t)>15 and _has_kw(topic,t):
302
- if not href.startswith('http'):href='https://thanhnien.vn'+href
303
- items.append({'title':t,'url':href,'via':'Thanh Niên'})
304
- if len(items)>=limit:break
305
- except:pass
306
- return items
307
-
308
- def _s_tuoitre(topic,limit=6):
309
- items=[]
310
- try:
311
- r=req.get(f"https://tuoitre.vn/tim-kiem.htm?keywords={quote(topic)}",headers=_search_headers(),timeout=12);soup=BeautifulSoup(r.text,'lxml')
312
- for a in soup.select('h3 a[href], .box-title-text a')[:limit*2]:
313
- t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
314
- if t and len(t)>15 and _has_kw(topic,t):
315
- if not href.startswith('http'):href='https://tuoitre.vn'+href
316
- items.append({'title':t,'url':href,'via':'Tuổi Trẻ'})
317
- if len(items)>=limit:break
318
- except:pass
319
- return items
320
-
321
- def _s_thethaovanhoa(topic,limit=5):
322
- items=[]
323
- try:
324
- r=req.get(f"https://thethaovanhoa.vn/tim-kiem.htm?keyword={quote(topic)}",headers=_search_headers(),timeout=12);soup=BeautifulSoup(r.text,'lxml')
325
- for a in soup.select('h3 a[href], .title a[href]')[:limit*2]:
326
- t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
327
- if t and len(t)>15 and _has_kw(topic,t):
328
- if not href.startswith('http'):href='https://thethaovanhoa.vn'+href
329
- items.append({'title':t,'url':href,'via':'TT&VH'})
330
- if len(items)>=limit:break
331
- except:pass
332
- return items
333
-
334
- def _s_google_news(topic,limit=8):
335
- """Google News RSS fallback - hoạt động khi các site VN chặn."""
336
- items=[]
337
- try:
338
- r=req.get(f"https://news.google.com/rss/search?q={quote(topic)}+Việt+Nam&hl=vi&gl=VN&ceid=VN:vi",headers=_search_headers(),timeout=10)
339
- r.encoding='utf-8'
340
- soup=BeautifulSoup(r.text,'xml')
341
- for item in soup.find_all('item')[:limit]:
342
- title=_clean(item.find('title').get_text() if item.find('title') else '')
343
- link=item.find('link').get_text() if item.find('link') else ''
344
- source=item.find('source').get_text() if item.find('source') else 'Google News'
345
- if title and link and len(title)>15:
346
- items.append({'title':title,'url':link,'via':f'Google {source}'})
347
- except:pass
348
- return items
349
-
350
- def _search_all(topic,limit=36):
351
- results={}
352
- with ThreadPoolExecutor(8) as ex:
353
- futs={ex.submit(_s_vnexpress,topic,8):'vne',ex.submit(_s_dantri,topic,8):'dt',ex.submit(_s_vietnamnet,topic,6):'vnn',ex.submit(_s_bongda,topic,5):'bd',ex.submit(_s_genk,topic,5):'gk',ex.submit(_s_thanhnien,topic,6):'tn',ex.submit(_s_tuoitre,topic,6):'tt',ex.submit(_s_thethaovanhoa,topic,5):'tvh'}
354
- for f in as_completed(futs,timeout=20):
355
- try:results[futs[f]]=f.result()
356
- except:results[futs[f]]=[]
357
- srcs=list(results.values());out=[];seen=set()
358
- for i in range(max((len(s) for s in srcs),default=0)):
359
- for s in srcs:
360
- 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])
361
- # Fallback: nếu tất cả 8 site VN đều trả về rỗng, thử Google News RSS
362
- if not out:
363
- try:
364
- google_items=_s_google_news(topic,limit)
365
- out.extend(google_items)
366
- except:pass
367
- return out[:limit]
368
-
369
- for _path in ['/api/article', '/api/hot_topics', '/api/categories', '/api/storage_status']:
370
- app.router.routes=[r for r in app.router.routes if not(getattr(r,'path',None)==_path and 'GET' in getattr(r,'methods',set()))]
371
-
372
- _article_cache = {}
373
- _article_cache_ttl = 1800
374
-
375
- _art_session = None
376
- _art_lock = threading.Lock()
377
- def _get_art_session():
378
- global _art_session
379
- if _art_session is None:
380
- with _art_lock:
381
- if _art_session is None:
382
- _art_session = req.Session()
383
- _art_session.headers.update({
384
- "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
385
- "Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8",
386
- "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
387
- })
388
- return _art_session
389
-
390
- def _scrape_article_fast(url):
391
- from urllib.parse import urlparse
392
- domain = urlparse(url).netloc
393
- sess = _get_art_session()
394
- uas = [
395
- {"User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"},
396
- {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"},
397
- ]
398
- for ua in uas:
399
- try:
400
- r = sess.get(url, headers=ua, timeout=6, allow_redirects=True)
401
- if not r or r.status_code != 200:
402
- continue
403
- r.encoding = 'utf-8'
404
- soup = BeautifulSoup(r.text, 'lxml')
405
- for tag in soup.find_all(['script','style','nav','footer','aside','form','noscript','iframe','.ads','.ad','.banner-ads','.fb-comments','.fb-root','.social-share','.related-news','.tag','.breadcrumb']):
406
- tag.decompose()
407
- title = summary = og_img = ""
408
- ogt = soup.find('meta', property='og:title')
409
- if ogt: title = ogt.get('content', '')
410
- ogd = soup.find('meta', property='og:description') or soup.find('meta', attrs={'name': 'description'})
411
- if ogd: summary = ogd.get('content', '')[:500]
412
- ogi = soup.find('meta', property='og:image')
413
- if ogi:
414
- og_img = ogi.get('content', '')
415
- if og_img.startswith('//'): og_img = 'https:' + og_img
416
- h1 = soup.find('h1')
417
- if not title and h1: title = h1.get_text(strip=True)[:200]
418
- body = []
419
- selectors = [
420
- '.fck_detail', '.sidebar-1',
421
- '.singular-content', '.dt__content', '.article-content', '.content-detail', '#divNewsContent',
422
- '.content-detail', '.main-content-detail', '.box-content',
423
- '.knc-content', '.article-body', '.detail-body',
424
- '.article-detail', '.detail-content',
425
- 'article', 'main', '.cms-body', '.article__body', '.post-content',
426
- '.entry-content', '#content', '.article-text', '.story-body',
427
- ]
428
- for sel in selectors:
429
- el = soup.select_one(sel)
430
- if el and len(el.find_all('p')) >= 2:
431
- seen_imgs = set()
432
- for child in el.find_all(['p','h2','h3','figure','img'], recursive=True):
433
- if child.name == 'p':
434
- t = child.get_text(strip=True)
435
- if t and len(t) > 15:
436
- body.append({'type': 'p', 'text': t})
437
- elif child.name in ('h2','h3'):
438
- t = child.get_text(strip=True)
439
- if t:
440
- body.append({'type': 'heading', 'text': t})
441
- elif child.name in ('figure','img'):
442
- im = child if child.name == 'img' else child.find('img')
443
- if im:
444
- src = im.get('data-src') or im.get('src') or im.get('data-lazy') or ''
445
- if src and 'base64' not in src and src not in seen_imgs:
446
- seen_imgs.add(src)
447
- if src.startswith('//'): src = 'https:' + src
448
- body.append({'type': 'img', 'src': src})
449
- if child.name == 'figure':
450
- cap = child.find('figcaption')
451
- if cap:
452
- ct = cap.get_text(strip=True)
453
- if ct: body.append({'type': 'p', 'text': ct})
454
- if len(body) >= 2:
455
- return {'title': _clean(title), 'summary': _clean(summary), 'og_image': og_img,
456
- 'body': body[:50], 'source': domain, 'url': url}
457
- if title and (summary or og_img):
458
- fallback = []
459
- if og_img: fallback.append({'type': 'img', 'src': og_img})
460
- if summary: fallback.append({'type': 'p', 'text': summary})
461
- if fallback:
462
- return {'title': _clean(title), 'summary': _clean(summary), 'og_image': og_img,
463
- 'body': fallback, 'source': domain, 'url': url, 'fallback': True}
464
- if title:
465
- return {'title': _clean(title), 'summary': '', 'og_image': '',
466
- 'body': [{'type': 'p', 'text': 'Nội dung đang được tải...'}],
467
- 'source': domain, 'url': url, 'fallback': True}
468
- break
469
- except Exception:
470
- continue
471
- return None
472
-
473
- @app.get('/api/article')
474
- def api_article_v2(url: str = Query(...)):
475
- from urllib.parse import unquote
476
- safe_url = unquote(url)
477
- try:
478
- now = time.time()
479
- cached = _article_cache.get(safe_url)
480
- if cached and now - cached['t'] < _article_cache_ttl:
481
- resp = JSONResponse(cached['d'])
482
- resp.headers["Cache-Control"] = "public, max-age=1800"
483
- return resp
484
- data = _scrape_article_fast(safe_url)
485
- if data and data.get('body'):
486
- _article_cache[safe_url] = {'d': data, 't': now}
487
- resp = JSONResponse(data)
488
- resp.headers["Cache-Control"] = "public, max-age=1800"
489
- return resp
490
- result = {'error': 'Không đọc được', 'url': safe_url}
491
- resp = JSONResponse(result)
492
- resp.headers["Cache-Control"] = "public, max-age=60"
493
- return resp
494
- except Exception as e:
495
- return JSONResponse({'error': f'Server error: {str(e)[:100]}', 'url': safe_url}, status_code=200)
496
-
497
- _hot_cache={'t':0,'d':[]}
498
- def _get_hot_topics():
499
- now=time.time()
500
- if _hot_cache['d'] and now-_hot_cache['t']<600:return _hot_cache['d']
501
- freq={};display={}
502
- feeds=['https://vnexpress.net/rss/tin-moi-nhat.rss','https://dantri.com.vn/rss/home.rss','https://vietnamnet.vn/rss/tin-moi-nhat.rss','https://thanhnien.vn/rss/home.rss','https://tuoitre.vn/rss/tin-moi-nhat.rss','https://genk.vn/rss','https://vnexpress.net/rss/the-thao.rss','https://thethaovanhoa.vn/rss/tin-nong.rss']
503
- for feed_url in feeds:
504
- try:
505
- r=req.get(feed_url,headers={'User-Agent':'Mozilla/5.0'},timeout=6);r.encoding='utf-8';soup=BeautifulSoup(r.text,'xml')
506
- for item in soup.find_all('item')[:12]:
507
- title=_clean(item.find('title').get_text() if item.find('title') else '')
508
- if not title:continue
509
- title=re.sub(r'\s*[-|].*$','',title);words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',title) if len(w)>2 and w.lower() not in _STOP]
510
- if len(words)<2:continue
511
- for n in(3,4,2):
512
- for i in range(max(0,len(words)-n+1)):
513
- phrase=' '.join(words[i:i+n])
514
- if 8<=len(phrase)<=45:key=phrase.lower();freq[key]=freq.get(key,0)+1;display[key]=phrase
515
- except:continue
516
- ranked=sorted(freq.items(),key=lambda x:x[1],reverse=True);topics=[];seen=set()
517
- for key,count in ranked:
518
- 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)
519
- if is_dup:continue
520
- seen.add(key);topics.append({'label':'#'+re.sub(r'\s+','',display[key].title()),'topic':display[key],'count':count})
521
- if len(topics)>=20:break
522
- 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']:
523
- if len(topics)>=24:break
524
- if not any(kw.lower() in s for s in seen):topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw,'count':0})
525
- _hot_cache.update({'t':now,'d':topics[:24]});return topics[:24]
526
-
527
- @app.get('/api/hot_topics')
528
- def api_hot_topics():
529
- resp = JSONResponse({'topics':_get_hot_topics()})
530
- resp.headers["Cache-Control"] = "public, max-age=120"
531
- return resp
532
- @app.get('/')
533
- async def serve_index():
534
- p=os.path.join(STATIC_DIR,'index_v2.html')
535
- if os.path.exists(p):return FileResponse(p,media_type='text/html')
536
- return HTMLResponse('<h1>VNEWS</h1>')
537
- @app.get('/api/hashtag/sources')
538
- def _ht(topic:str=Query(...),page:int=Query(default=0),source:str=Query(default=None)):
539
- if source == 'google':
540
- # Direct Google News RSS request
541
- items=_s_google_news(topic,36)
542
- else:
543
- items=_search_all(topic,36)
544
- per_page=8;start=page*per_page;end=start+per_page
545
- return JSONResponse({'sources':items[start:end],'topic':topic,'page':page,'has_more':end<len(items),'total':len(items)})
546
- @app.get('/api/categories')
547
- def _cat():return JSONResponse([])
548
- @app.get('/api/storage_status')
549
- def _st():return JSONResponse({'persistent':os.path.isdir('/data') and os.access('/data',os.W_OK)})
550
- # ===== SHARE HELPERS: render content pages for shared links =====
551
- def _render_slides_page(post, safe_title, safe_img, safe_url):
552
- slides = post.get('slides', [])
553
- # Get image from post.img or first slide's image
554
- if not safe_img and slides and slides[0].get('image'):
555
- safe_img = slides[0].get('image', '')
556
- # Use text for description if available
557
- description = _clean((post.get('text') or '')[:200]) or "Tin tức tóm tắt, AI rewrite, World Cup 2026"
558
-
559
- # Build canonical URL preserving original query format if url was provided
560
- if safe_url and safe_url != '/':
561
- canonical_url = f"{SPACE}/s?url={quote(safe_url)}&title={quote(safe_title[:100])}"
562
- else:
563
- canonical_url = f"{SPACE}/s?post_id={post.get('id') or ''}"
564
-
565
- h = f'''<!DOCTYPE html>
566
- <html lang="vi">
567
- <head>
568
- <meta charset="utf-8">
569
- <meta name="viewport" content="width=device-width,initial-scale=1">
570
- <title>{_clean(safe_title)}</title>
571
- <meta property="og:title" content="{_clean(safe_title)}">
572
- <meta property="og:image" content="{_clean(safe_img)}">
573
- <meta property="og:description" content="{description}">
574
- <meta property="og:url" content="{canonical_url}">
575
- <link rel="canonical" href="{canonical_url}">
576
- <style>
577
- *{{box-sizing:border-box;margin:0;padding:0}}body{{background:#111;color:#eee;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;padding:12px}}
578
- .slide-card{{background:#1a1a1a;border:1px solid #2a2a2a;border-radius:12px;padding:16px;margin-bottom:12px;max-width:600px;margin-left:auto;margin-right:auto}}
579
- .slide-num{{color:#5cb87a;font-size:12px;font-weight:700;margin-bottom:6px}}
580
- .slide-img{{width:100%;max-height:300px;object-fit:cover;border-radius:8px;margin-bottom:8px}}
581
- .slide-text{{color:#ddd;font-size:14px;line-height:1.6;margin:0}}
582
- </style>
583
- </head>
584
- <body>'''
585
- for s in slides:
586
- img_src = s.get('image', '')
587
- if img_src and ('cdnphoto.dantri' in img_src or 'refooty' in img_src or 'vnexpress' in img_src or 'vcdn' in img_src):
588
- img_tag = f'<img src="/api/proxy/img?url={quote(img_src, safe="")}" class="slide-img" loading="lazy" onerror="this.style.display=\'none\'">'
589
- else:
590
- img_tag = f'<img src="{_clean(img_src)}" class="slide-img" loading="lazy" onerror="this.style.display=\'none\'">' if img_src else ''
591
- h += f'<div class="slide-card"><div class="slide-num">Slide {s.get("index",1)}/{len(slides)}</div>{img_tag}<p class="slide-text">{_clean(s.get("text",""))}</p></div>'
592
- h += '</body></html>'
593
- return HTMLResponse(h)
594
-
595
- def _render_video_page(post, safe_title, safe_img, safe_url):
596
- video_url = post.get('video', '')
597
- # Use text for description if available
598
- description = _clean((post.get('text') or '')[:200]) or "Tin tức tóm tắt, AI rewrite, World Cup 2026"
599
-
600
- # Build canonical URL preserving original query format if url was provided
601
- if safe_url and safe_url != '/':
602
- canonical_url = f"{SPACE}/s?url={quote(safe_url)}&title={quote(safe_title[:100])}"
603
- else:
604
- canonical_url = f"{SPACE}/s?post_id={post.get('id') or ''}"
605
-
606
- h = f'''<!DOCTYPE html>
607
- <html lang="vi">
608
- <head>
609
- <meta charset="utf-8">
610
- <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
611
- <title>{_clean(safe_title)}</title>
612
- <meta property="og:title" content="{_clean(safe_title)}">
613
- <meta property="og:image" content="{_clean(safe_img)}">
614
- <meta property="og:description" content="{description}">
615
- <meta property="og:url" content="{canonical_url}">
616
- <link rel="canonical" href="{canonical_url}">
617
- <meta name="twitter:card" content="player">
618
- <meta name="twitter:player" content="{video_url}">
619
- <style>
620
- *{{box-sizing:border-box;margin:0;padding:0}}body{{background:#111;color:#eee;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;padding:0;overflow:hidden}}
621
- .video-container{{width:100vw;height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;background:#000}}
622
- video{{width:100%;height:100%;max-height:100vh;object-fit:contain;background:#000}}
623
- .title-bar{{position:fixed;bottom:0;left:0;right:0;background:linear-gradient(transparent,rgba(0,0,0,.8));padding:40px 16px 16px;text-align:center}}
624
- .title-text{{color:#fff;font-size:13px;line-height:1.4;max-width:600px;margin:0 auto}}
625
- </style>
626
- </head>
627
- <body>
628
- <div class="video-container">
629
- <video src="{_clean(video_url)}" controls autoplay playsinline loop></video>
630
- <div class="title-bar"><div class="title-text">{_clean(safe_title)}</div></div>
631
- </div>
632
- </body></html>'''
633
- return HTMLResponse(h)
634
-
635
- @app.get('/s/{slug}')
636
- async def _sh_slug(slug: str, request: Request, url: str = '', title: str = '', img: str = ''):
637
- """SEO-friendly share endpoint with slug in URL path.
638
- Shows slide content when slug matches a wall post ID, otherwise redirects.
639
- """
640
- safe_title = _clean(title) if title else 'VNEWS - Tin tức'
641
- safe_img = _clean(img) if img else ''
642
- safe_url = _clean(url) if url else '/'
643
-
644
- # Try to find post by slug (post ID)
645
- post = None
646
- try:
647
- if slug and len(slug) > 5: # Likely a post ID
648
- posts = _load_wall_posts()
649
- for p in posts:
650
- if p.get('id') == slug:
651
- post = p
652
- safe_title = p.get('title', safe_title) or safe_title
653
- safe_img = p.get('img', safe_img) or safe_img
654
- safe_url = p.get('url', safe_url) or safe_url
655
- break
656
- except:
657
- pass
658
-
659
- if post and post.get('slides'):
660
- return _render_slides_page(post, safe_title, safe_img, safe_url)
661
-
662
- if post and post.get('video'):
663
- return _render_video_page(post, safe_title, safe_img, safe_url)
664
-
665
- # Otherwise redirect
666
- return HTMLResponse(f'''<!DOCTYPE html>
667
- <html lang="vi">
668
- <head>
669
- <meta charset="utf-8">
670
- <meta name="viewport" content="width=device-width,initial-scale=1">
671
- <title>{_clean(safe_title)}</title>
672
- <meta property="og:title" content="{_clean(safe_title)}">
673
- <meta property="og:image" content="{_clean(safe_img)}">
674
- <meta property="og:description" content="Tin tức tóm tắt, AI rewrite, World Cup 2026">
675
- <meta property="og:url" content="{SPACE}/s/{slug}">
676
- <link rel="canonical" href="{SPACE}/s/{slug}">
677
- <meta http-equiv="refresh" content="0;url={safe_url}">
678
- </head><body></body></html>''')
679
-
680
- @app.get('/s')
681
- async def _sh(url:str='',title:str='',img:str='',post_id:str=''):
682
- safe_title = _clean(title) if title else 'VNEWS - Tin tức'
683
- safe_img = _clean(img) if img else ''
684
- safe_url = _clean(url) if url else '/'
685
-
686
- # Try to find wall post by post_id or URL (prioritize posts with slides/video)
687
- post = None
688
- try:
689
- posts = _load_wall_posts()
690
- if post_id:
691
- for p in posts:
692
- if p.get('id') == post_id:
693
- post = p
694
- safe_title = p.get('title', safe_title) or safe_title
695
- safe_img = p.get('img', safe_img) or safe_img
696
- safe_url = p.get('url', safe_url) or safe_url
697
- break
698
- elif url:
699
- # Find matching URL - prioritize posts with slides or video
700
- for p in posts:
701
- if p.get('url') == url and p.get('slides'):
702
- post = p
703
- safe_title = p.get('title', safe_title) or safe_title
704
- safe_img = p.get('img', safe_img) or safe_img
705
- safe_url = p.get('url', safe_url) or safe_url
706
- break
707
- if not post:
708
- # Fallback: find any matching URL
709
- for p in posts:
710
- if p.get('url') == url:
711
- post = p
712
- safe_title = p.get('title', safe_title) or safe_title
713
- safe_img = p.get('img', safe_img) or safe_img
714
- safe_url = p.get('url', safe_url) or safe_url
715
- break
716
- except:
717
- pass
718
-
719
- if post and post.get('slides'):
720
- return _render_slides_page(post, safe_title, safe_img, safe_url)
721
-
722
- if post and post.get('video'):
723
- return _render_video_page(post, safe_title, safe_img, safe_url)
724
-
725
- # Fallback: redirect to original URL
726
- return HTMLResponse(f'''<!DOCTYPE html>
727
- <html lang="vi">
728
- <head>
729
- <meta charset="utf-8">
730
- <meta name="viewport" content="width=device-width,initial-scale=1">
731
- <title>{safe_title}</title>
732
- <meta property="og:title" content="{safe_title}">
733
- <meta property="og:image" content="{safe_img}">
734
- <meta property="og:description" content="Tin tức tóm tắt, AI rewrite, World Cup 2026">
735
- <meta property="og:url" content="{SPACE}/s?url={quote(safe_url)}">
736
- <link rel="canonical" href="{SPACE}/s?url={quote(safe_url)}">
737
- <meta http-equiv="refresh" content="0;url={safe_url}">
738
- </head><body></body></html>''')
739
-
740
- from wc2026_scraper import scrape_summary,scrape_fixtures,scrape_standings,scrape_stats,scrape_wc_news,scrape_road_to_wc,get_wc2026_all,scrape_history,scrape_h2h,scrape_lineups,scrape_match_detail
741
-
742
- _xlb_cache = {}
743
- _xlb_lock = threading.Lock()
744
-
745
- def _xlb_scrape(path):
746
- url = f"https://xemlaibongda.top/{path}"
747
- r = req.get(url, headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}, timeout=15, allow_redirects=True)
748
- if r.status_code != 200:
749
- return []
750
- soup = BeautifulSoup(r.text, 'lxml')
751
- vids = []
752
- seen = set()
753
- for a in soup.select('a[href*="/video/"]'):
754
- href = a.get('href', '')
755
- if not href or href in seen:
756
- continue
757
- seen.add(href)
758
- if not href.startswith('http'):
759
- href = 'https://xemlaibongda.top' + href
760
- img = a.select_one('img')
761
- p = a.parent
762
- for _ in range(4):
763
- if img:
764
- break
765
- if p:
766
- img = p.select_one('img')
767
- p = p.parent
768
- img_src = ''
769
- if img:
770
- img_src = img.get('data-src','') or img.get('src','') or img.get('data-lazy','') or img.get('data-original','')
771
- if img_src.startswith('//'):
772
- img_src = 'https:' + img_src
773
- elif img_src.startswith('/'):
774
- img_src = 'https://xemlaibongda.top' + img_src
775
- title = ''
776
- for sel in ['.title', 'h3', 'h2', '.name', '.post-title', '.entry-title', '.video-title']:
777
- t = a.select_one(sel)
778
- if t:
779
- title = _clean(t.get_text())
780
- break
781
- if not title:
782
- title = _clean(a.get('title',''))
783
- if not title:
784
- img_alt = a.select_one('img')
785
- if img_alt:
786
- title = _clean(img_alt.get('alt',''))
787
- if not title:
788
- parent = a.parent
789
- if parent:
790
- pt = _clean(parent.get_text(' ',strip=True))
791
- if 5 < len(pt) < 120:
792
- title = pt
793
- if not title or len(title) < 3:
794
- continue
795
- vids.append({"link": href, "img": img_src, "title": title})
796
- if len(vids) >= 30:
797
- break
798
- return vids
799
-
800
- @app.get('/api/proxy/xlb')
801
- def proxy_xlb(path: str = Query(default="")):
802
- now = time.time()
803
- cache_key = f"xlb:{path}"
804
- with _xlb_lock:
805
- cached = _xlb_cache.get(cache_key)
806
- if cached and now - cached['t'] < 120:
807
- return JSONResponse(cached['d'])
808
- try:
809
- vids = _xlb_scrape(path)
810
- result = {"videos": vids, "count": len(vids)}
811
- with _xlb_lock:
812
- _xlb_cache[cache_key] = {'t': now, 'd': result}
813
- return JSONResponse(result)
814
- except Exception as e:
815
- return JSONResponse({"videos": [], "count": 0, "error": str(e)}, status_code=500)
816
-
817
- @app.get('/api/wc2026')
818
- def _w():return JSONResponse(get_wc2026_all())
819
- @app.get('/api/wc2026/fixtures')
820
- def _wf():return JSONResponse(scrape_fixtures())
821
- @app.get('/api/wc2026/standings')
822
- def _ws():return JSONResponse(scrape_standings())
823
- @app.get('/api/wc2026/stats')
824
- def _wst():return JSONResponse(scrape_stats())
825
- @app.get('/api/wc2026/history')
826
- def _whi():return JSONResponse(scrape_history())
827
- @app.get('/api/wc2026/news')
828
- def _wn():return JSONResponse(scrape_wc_news())
829
- @app.get('/api/wc2026/road')
830
- def _wr():return JSONResponse(scrape_road_to_wc())
831
- @app.get('/api/wc2026/h2h/{eid}')
832
- def _wh2(eid:int):return JSONResponse(scrape_h2h(eid))
833
- @app.get('/api/wc2026/lineups/{eid}')
834
- def _wl(eid:int):return JSONResponse(scrape_lineups(eid))
835
- @app.get('/api/wc2026/match/{eid}')
836
- def _wm(eid:int):return JSONResponse(scrape_match_detail(eid))
837
-
838
- DATA_DIR='/data' if os.path.isdir('/data') else os.path.join(os.path.dirname(os.path.abspath(__file__)),'data')
839
- os.makedirs(DATA_DIR,exist_ok=True)
840
- IF=os.path.join(DATA_DIR,'interactions_v2.json')
841
- CF=os.path.join(DATA_DIR,'comments_v2.json')
842
- WALL_FILE=os.path.join(DATA_DIR,'wall_posts.json')
843
- WALL_VIDEO_DIR=os.path.join(DATA_DIR,'wall_videos')
844
- os.makedirs(WALL_VIDEO_DIR,exist_ok=True)
845
-
846
- _il=threading.Lock();_cl=threading.Lock();_wl_lock=threading.Lock()
847
- def _lj(p):
848
- try:
849
- if os.path.exists(p):return json.load(open(p,'r',encoding='utf-8'))
850
- except:pass
851
- return{}
852
- def _sj(p,d):
853
- try:open(p+'.tmp','w',encoding='utf-8').write(json.dumps(d,ensure_ascii=False));os.replace(p+'.tmp',p)
854
- except:pass
855
-
856
- @app.post('/api/v2/interact')
857
- async def _int(request:Request):
858
- b=await request.json();v=str(b.get('id','')).strip();t=str(b.get('type','')).strip()
859
- if not v or t not in('view','like'):return JSONResponse({'error':'x'},status_code=400)
860
- 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])
861
-
862
- @app.get('/api/v2/interactions')
863
- def _gi(id:str=Query(...)):
864
- with _il:return JSONResponse(_lj(IF).get(id.strip(),{'views':0,'likes':0,'comments':0}))
865
-
866
- @app.get('/api/v2/comments')
867
- def _gc(id:str=Query(...)):
868
- with _cl:return JSONResponse({'comments':_lj(CF).get(id.strip(),[])})
869
-
870
- @app.post('/api/v2/comment')
871
- async def _pc(request:Request):
872
- b=await request.json();v=str(b.get('id','')).strip();tx=str(b.get('text','')).strip()[:500]
873
- if not v or not tx:return JSONResponse({'error':'x'},status_code=400)
874
- c={'text':tx,'time':time.strftime('%H:%M %d/%m',time.localtime()),'ts':int(time.time())}
875
- with _cl:db=_lj(CF);db.setdefault(v,[]);db[v].append(c);db[v]=db[v][-200:];_sj(CF,db);cms=db[v]
876
- with _il:idb=_lj(IF);idb.setdefault(v,{'views':0,'likes':0,'comments':0});idb[v]['comments']=len(cms);_sj(IF,idb)
877
- return JSONResponse({'comments':cms})
878
-
879
- def _load_wall_posts():
880
- with _wl_lock:
881
- return _lj(WALL_FILE)
882
-
883
- def _save_wall_posts(posts):
884
- with _wl_lock:
885
- _sj(WALL_FILE, posts)
886
-
887
- @app.get('/api/wall')
888
- def api_wall():
889
- posts = _load_wall_posts()
890
- if not posts:
891
- return JSONResponse({"posts": []})
892
- return JSONResponse({"posts": posts})
893
-
894
- @app.post('/api/wall')
895
- async def api_wall_post(request: Request):
896
- content_type = request.headers.get('content-type', '')
897
- if 'multipart/form-data' in content_type:
898
- try:
899
- form = await request.form()
900
- except Exception as e:
901
- return JSONResponse({"error": f"Form parse error: {str(e)}"}, status_code=400)
902
- title = form.get('title', 'Video mới') or 'Video mới'
903
- text = form.get('text', '') or ''
904
- source = form.get('source', 'vtv_recorder') or 'vtv_recorder'
905
- video_file = form.get('video')
906
- post_id = str(uuid.uuid4())[:12]
907
- video_url = None
908
- if video_file and hasattr(video_file, 'filename') and video_file.filename:
909
- fname = video_file.filename.lower()
910
- if fname.endswith('.mp4'):
911
- ext = '.mp4'
912
- elif fname.endswith('.webm'):
913
- ext = '.webm'
914
- else:
915
- ext = '.webm'
916
- video_filename = f"wall_{post_id}{ext}"
917
- video_path = os.path.join(WALL_VIDEO_DIR, video_filename)
918
- try:
919
- content = await video_file.read()
920
- if not content:
921
- return JSONResponse({"error": "Empty video file"}, status_code=400)
922
- with open(video_path, 'wb') as f:
923
- f.write(content)
924
- file_size_mb = len(content) / 1024 / 1024
925
- if file_size_mb > 50:
926
- os.remove(video_path)
927
- return JSONResponse({"error": f"Video quá lớn ({file_size_mb:.1f}MB). Tối đa 50MB."}, status_code=400)
928
- video_url = f"/api/wall/video/{video_filename}"
929
- except Exception as e:
930
- return JSONResponse({"error": f"Lỗi lưu video: {str(e)}"}, status_code=500)
931
- post = {
932
- "id": post_id,
933
- "title": title[:200],
934
- "text": text[:2000],
935
- "source": source,
936
- "video": video_url,
937
- "img": None,
938
- "images": [],
939
- "created": int(time.time()),
940
- "created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()),
941
- }
942
- posts = _load_wall_posts()
943
- if not isinstance(posts, list):
944
- posts = []
945
- posts.insert(0, post)
946
- posts = posts[:200]
947
- _save_wall_posts(posts)
948
- return JSONResponse({"post": post, "ok": True})
949
- try:
950
- body = await request.json()
951
- except:
952
- body = {}
953
- title = body.get('title', 'Bài mới') or 'Bài mới'
954
- text = body.get('text', '') or ''
955
- img = body.get('img', None)
956
- source = body.get('source', 'user') or 'user'
957
- post_id = str(uuid.uuid4())[:12]
958
- post = {
959
- "id": post_id,
960
- "title": title[:200],
961
- "text": text[:2000],
962
- "source": source,
963
- "video": None,
964
- "img": img,
965
- "images": [],
966
- "created": int(time.time()),
967
- "created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()),
968
- }
969
- posts = _load_wall_posts()
970
- if not isinstance(posts, list):
971
- posts = []
972
- posts.insert(0, post)
973
- posts = posts[:200]
974
- _save_wall_posts(posts)
975
- return JSONResponse({"post": post, "ok": True})
976
-
977
- @app.get('/api/wall/video/{filename}')
978
- def api_wall_video(filename: str):
979
- if '..' in filename or '/' in filename:
980
- return Response(status_code=403)
981
- video_path = os.path.join(WALL_VIDEO_DIR, filename)
982
- if not os.path.exists(video_path):
983
- return Response(status_code=404)
984
- ext = os.path.splitext(filename)[1].lower()
985
- media_type = 'video/mp4' if ext == '.mp4' else 'video/webm'
986
- return FileResponse(video_path, media_type=media_type)
987
-
988
- @app.delete('/api/wall/{post_id}')
989
- def api_wall_delete(post_id: str):
990
- posts = _load_wall_posts()
991
- if not isinstance(posts, list):
992
- return JSONResponse({"error": "No posts"}, status_code=404)
993
- for i, p in enumerate(posts):
994
- if p.get('id') == post_id:
995
- if p.get('video'):
996
- video_name = p['video'].split('/')[-1]
997
- video_path = os.path.join(WALL_VIDEO_DIR, video_name)
998
- if os.path.exists(video_path):
999
- os.remove(video_path)
1000
- posts.pop(i)
1001
- _save_wall_posts(posts)
1002
- return JSONResponse({"ok": True})
1003
- return JSONResponse({"error": "Post not found"}, status_code=404)
1004
-
1005
- # ===== LANGUAGE & EMOTION DETECTION =====
1006
- import random as _random2
1007
- from urllib.parse import quote as _quote2
1008
-
1009
- _UA_RW = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept-Language': 'vi-VN,vi;q=0.9'}
1010
-
1011
- # Unique character markers for language detection
1012
- _UNIQUE_CHARS = {
1013
- 'vietnamese': set('đăâêôơưàảãạáằẳẵặắầẩẫậấèẻẽẹéềễểệếìỉĩịíòỏõọóồổỗộốờởỡợớùủũụúừửữựứỳỷỹỵý'),
1014
- 'spanish': set('ñáéíóúü¿¡'),
1015
- 'portuguese': set('ãõçáéíóúâêôà'),
1016
- }
1017
-
1018
- _STOPWORDS = {
1019
- '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'},
1020
- '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'},
1021
- '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'},
1022
- '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'},
1023
- }
1024
-
1025
- def detect_language(text):
1026
- """Detect language from text content using stopword + character analysis."""
1027
- if not text:
1028
- return 'vietnamese'
1029
- text_lower = text.lower()
1030
- text_chars = set(text_lower)
1031
-
1032
- # Strong signal: Vietnamese unique characters
1033
- vn_chars = len(text_chars & _UNIQUE_CHARS['vietnamese'])
1034
- if vn_chars >= 2:
1035
- return 'vietnamese'
1036
-
1037
- # Spanish unique chars (ñ, ¿, ¡)
1038
- es_chars = len(text_chars & _UNIQUE_CHARS['spanish'])
1039
- pt_chars = len(text_chars & _UNIQUE_CHARS['portuguese'])
1040
-
1041
- # Stopword scoring
1042
- words = set(re.findall(r'\b\w+\b', text_lower))
1043
- scores = {}
1044
- for lang, stops in _STOPWORDS.items():
1045
- scores[lang] = len(words & stops) / max(len(stops), 1)
1046
-
1047
- # Disambiguate Portuguese vs Spanish
1048
- pt_markers = {'não', 'pelo', 'pela', 'isso', 'há', 'estão', 'num', 'numa', 'tenho', 'posso', 'você', 'nós', 'eles', 'elas', 'também', 'muito', 'já', 'só', 'até', 'entre', 'depois', 'sem', 'mesmo', 'aos', 'serão'}
1049
- es_markers = {'pero', 'está', 'están', 'porque', 'también', 'hasta', 'donde', 'quien', 'fue', 'son', 'fueron', 'ese', 'eso', 'ante', 'ellos', 'ella', 'nosotros', 'usted', 'ustedes', 'tú', 'él', 'desde', 'todo', 'durante', 'todos', 'uno', 'les', 'ni', 'contra', 'otros', 'fueron'}
1050
-
1051
- pt_overlap = len(words & pt_markers)
1052
- es_overlap = len(words & es_markers)
1053
-
1054
- if scores.get('portuguese', 0) > 0 and pt_overlap > es_overlap:
1055
- return 'portuguese'
1056
- if scores.get('spanish', 0) > 0 and es_overlap > pt_overlap:
1057
- return 'spanish'
1058
- if scores.get('english', 0) > 0.15:
1059
- return 'english'
1060
-
1061
- best = max(scores, key=scores.get)
1062
- return best if scores[best] > 0.05 else 'vietnamese'
1063
-
1064
- # Emotion keyword-based detection
1065
- _EMOTION_KEYWORDS = {
1066
- 'happy': {
1067
- 'en': ['happy', 'joy', 'wonderful', 'great', 'amazing', 'fantastic', 'love', 'excellent', 'beautiful', 'glad', 'delighted', 'pleased', 'cheerful', 'celebrate', 'victory', 'win', 'success'],
1068
- 'pt': ['feliz', 'alegria', 'maravilhoso', 'ótimo', 'incrível', 'fantástico', 'amor', 'excelente', 'lindo', 'contente', 'encantado', 'vitória', 'sucesso'],
1069
- 'es': ['feliz', 'alegria', 'maravilloso', 'genial', 'increíble', 'fantástico', 'amor', 'excelente', 'hermoso', 'contento', 'encantado', 'victoria', 'éxito'],
1070
- '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'],
1071
- },
1072
- 'sad': {
1073
- 'en': ['sad', 'unhappy', 'terrible', 'awful', 'horrible', 'miserable', 'depressed', 'grief', 'sorrow', 'tragic', 'unfortunate', 'painful', 'death', 'die', 'kill'],
1074
- 'pt': ['triste', 'infeliz', 'terrível', 'horrível', 'miserável', 'deprimido', 'dor', 'trágico', 'infelizmente', 'penoso', 'morte', 'morrer'],
1075
- 'es': ['triste', 'infeliz', 'terrible', 'horrible', 'miserable', 'deprimido', 'dolor', 'trágico', 'desafortunado', 'penoso', 'muerte', 'morir'],
1076
- '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'],
1077
- },
1078
- 'excited': {
1079
- 'en': ['excited', 'thrilling', 'amazing', 'wow', 'incredible', 'unbelievable', 'awesome', 'exhilarating', 'electrifying', 'breathtaking', 'breakthrough', 'record'],
1080
- 'pt': ['animado', 'emocionante', 'incrível', 'uau', 'inacreditável', 'empolgante', 'elétrico', 'recorde'],
1081
- 'es': ['emocionado', 'emocionante', 'increíble', 'guau', 'increíble', 'impresionante', 'electrizante', 'récord'],
1082
- 'vi': ['phấn khích', 'hồi hộp', 'không thể tin', 'tuyệt vời', 'ấn tượng', 'kỷ lục', 'đột phá', 'sốc'],
1083
- },
1084
- 'angry': {
1085
- 'en': ['angry', 'furious', 'outraged', 'frustrated', 'annoyed', 'irritated', 'mad', 'rage', 'fury', 'upset', 'hostile', 'aggressive'],
1086
- 'pt': ['bravo', 'furioso', 'indignado', 'frustrado', 'irritado', 'raiva', 'fúria', 'chateado', 'hostil', 'agressivo'],
1087
- 'es': ['enojado', 'furioso', 'indignado', 'frustrado', 'molesto', 'irritado', 'rabia', 'furia', 'enojado', 'hostil', 'agresivo'],
1088
- 'vi': ['tức giận', 'phẫn nộ', 'bức xúc', 'bực mình', 'khó chịu', 'cáu', 'giận dữ', 'thịnh nộ', 'thù địch'],
1089
- },
1090
- 'neutral': {
1091
- 'en': ['say', 'said', 'according', 'report', 'announce', 'statement', 'information', 'update', 'news', 'today', 'government', 'official'],
1092
- 'pt': ['dizer', 'disse', 'segundo', 'relatório', 'anunciar', 'declaração', 'informação', 'notícia', 'hoje', 'governo', 'oficial'],
1093
- 'es': ['decir', 'dijo', 'según', 'informe', 'anunciar', 'declaración', 'información', 'noticia', 'hoy', 'gobierno', 'oficial'],
1094
- 'vi': ['nói', 'cho biết', 'theo', 'báo cáo', 'thông báo', 'tuyên bố', 'thông tin', 'cập nhật', 'tin tức', 'hôm nay', 'chính phủ', 'quan chức'],
1095
- },
1096
- }
1097
-
1098
- def detect_emotion(text, language='vietnamese'):
1099
- """Detect emotion from text using keyword matching."""
1100
- if not text:
1101
- return 'neutral'
1102
- text_lower = text.lower()
1103
- words = set(re.findall(r'\b\w+\b', text_lower))
1104
- lang = language if language in ['en', 'pt', 'es', 'vi'] else 'vi'
1105
- lang_map = {'en': 'english', 'pt': 'portuguese', 'es': 'spanish', 'vi': 'vietnamese'}
1106
- lang_key = lang_map.get(lang, 'vietnamese')
1107
-
1108
- scores = {}
1109
- for emotion, lang_dict in _EMOTION_KEYWORDS.items():
1110
- keywords = lang_dict.get(lang_key, lang_dict.get('en', []))
1111
- scores[emotion] = len(words & set(kw.lower() for kw in keywords))
1112
-
1113
- # Also check all languages for overlap
1114
- for emotion, lang_dict in _EMOTION_KEYWORDS.items():
1115
- for lang, keywords in lang_dict.items():
1116
- if lang != lang_key:
1117
- scores[emotion] = scores.get(emotion, 0) + len(words & set(kw.lower() for kw in keywords)) * 0.5
1118
-
1119
- best = max(scores, key=scores.get)
1120
- return best if scores[best] > 0 else 'neutral'
1121
-
1122
- # ===== REWRITE ENDPOINT =====
1123
- _rewrite_cache = {}
1124
- _rewrite_cache_lock = threading.Lock()
1125
-
1126
- def _rewrite_article(url, lang='vi', emotion='neutral', style='standard'):
1127
- """Rewrite article using HF Inference API."""
1128
- from urllib.parse import unquote
1129
- cache_key = f"{url}_{lang}_{emotion}_{style}"
1130
- with _rewrite_cache_lock:
1131
- cached = _rewrite_cache.get(cache_key)
1132
- if cached and time.time() - cached['t'] < 3600:
1133
- return cached['d']
1134
-
1135
- article = _scrape_article_fast(unquote(url))
1136
- if not article or not article.get('body'):
1137
- return {'error': 'Không đọc được bài viết'}
1138
-
1139
- title = article.get('title', '')
1140
- summary = article.get('summary', '')
1141
- text_parts = [p.get('text', '') for p in article.get('body', []) if p.get('type') == 'p']
1142
- full_text = ' '.join(text_parts[:15])
1143
-
1144
- if not full_text:
1145
- return {'error': 'Không có nội dung để rewrite'}
1146
-
1147
- # Build prompt
1148
- style_guides = {
1149
- 'standard': 'Viết lại bằng tiếng Việt, giữ nguyên thông tin chính, dễ đọc.',
1150
- 'simple': 'Viết lại bằng tiếng Việt đơn giản, dễ hiểu, phù hợp với mọi lứa tuổi.',
1151
- 'detailed': 'Viết lại bằng tiếng Việt chi tiết, phân tích sâu, có dẫn chứng.',
1152
- 'short': 'Viết lại bằng tiếng Việt ngắn gọn, chỉ giữ ý chính, tối đa 3 câu.',
1153
- }
1154
- style_guide = style_guides.get(style, style_guides['standard'])
1155
-
1156
- emotion_guide = {
1157
- 'neutral': '',
1158
- 'happy': 'Thể hiện giọng điệu tích cực, lạc quan.',
1159
- 'sad': 'Thể hiện giọng điệu đồng cảm, nhẹ nhàng.',
1160
- 'excited': 'Thể hiện giọng điệu hào hứng, sôi nổi.',
1161
- 'angry': 'Thể hiện giọng điệu mạnh mẽ, phản biện.',
1162
- }.get(emotion, '')
1163
-
1164
- prompt = f"""Viết lại bài báo sau bằng tiếng Việt.
1165
-
1166
- Yêu cầu:
1167
- - {style_guide}
1168
- - {emotion_guide}
1169
- - Giữ lại các sự kiện, số liệu, tên riêng quan trọng
1170
- - Tối đa 500 từ
1171
- - Chia thành các đoạn ngắn, dễ đọc
1172
-
1173
- Tiêu đề: {title}
1174
-
1175
- Tóm tắt: {summary}
1176
-
1177
- Nội dung: {full_text[:3000]}
1178
-
1179
- Bài viết sau khi rewrite:"""
1180
-
1181
- try:
1182
- from huggingface_hub import InferenceClient
1183
- client = InferenceClient(model="Qwen/Qwen2.5-72B-Instruct")
1184
- response = client.text_generation(prompt, max_new_tokens=800, temperature=0.7, do_sample=True)
1185
- result = response.strip()
1186
- with _rewrite_cache_lock:
1187
- _rewrite_cache[cache_key] = {'d': result, 't': time.time()}
1188
- return {'rewritten': result, 'title': title, 'url': url}
1189
- except Exception as e:
1190
- # Fallback: try smaller model
1191
- try:
1192
- from huggingface_hub import InferenceClient
1193
- client = InferenceClient(model="Qwen/Qwen2.5-32B-Instruct")
1194
- response = client.text_generation(prompt, max_new_tokens=600, temperature=0.7, do_sample=True)
1195
- result = response.strip()
1196
- with _rewrite_cache_lock:
1197
- _rewrite_cache[cache_key] = {'d': result, 't': time.time()}
1198
- return {'rewritten': result, 'title': title, 'url': url}
1199
- except Exception as e2:
1200
- return {'error': f'Lỗi AI: {str(e2)[:100]}'}
1201
-
1202
- @app.get('/api/rewrite')
1203
- def api_rewrite(url: str = Query(...), lang: str = Query(default='vi'), emotion: str = Query(default='neutral'), style: str = Query(default='standard')):
1204
- try:
1205
- result = _rewrite_article(url, lang, emotion, style)
1206
- return JSONResponse(result)
1207
- except Exception as e:
1208
- return JSONResponse({'error': f'Lỗi rewrite: {str(e)[:100]}'})
1209
-
1210
- # === TTS Multi-language endpoint ===
1211
- _tts_cache = {}
1212
- _tts_cache_lock = threading.Lock()
1213
-
1214
- @app.get('/api/tts')
1215
- def api_tts(text: str = Query(...), lang: str = Query(default='vi')):
1216
- """Generate TTS audio using edge-tts."""
1217
- import subprocess, tempfile
1218
- cache_key = f"{text[:100]}_{lang}"
1219
- with _tts_cache_lock:
1220
- cached = _tts_cache.get(cache_key)
1221
- if cached and time.time() - cached['t'] < 3600:
1222
- return FileResponse(cached['p'], media_type='audio/mpeg')
1223
-
1224
- voice_map = {
1225
- 'vi': 'vi-VN-HoaiMyNeural',
1226
- 'en': 'en-US-JennyNeural',
1227
- 'pt': 'pt-BR-FranciscaNeural',
1228
- 'es': 'es-ES-ElviraNeural',
1229
- }
1230
- voice = voice_map.get(lang, 'vi-VN-HoaiMyNeural')
1231
-
1232
- try:
1233
- with tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) as f:
1234
- output_path = f.name
1235
- cmd = ['edge-tts', '--voice', voice, '--text', text[:500], '--write-media', output_path]
1236
- subprocess.run(cmd, timeout=30, check=True)
1237
- with _tts_cache_lock:
1238
- _tts_cache[cache_key] = {'p': output_path, 't': time.time()}
1239
- return FileResponse(output_path, media_type='audio/mpeg')
1240
- except Exception as e:
1241
- return JSONResponse({'error': f'TTS error: {str(e)[:100]'}, status_code=500)
1242
-
1243
- # === SHORT VIDEO GENERATION ===
1244
- @app.get('/api/short-video')
1245
- def api_short_video(url: str = Query(...), lang: str = Query(default='vi')):
1246
- """Generate short video from article using TTS + images."""
1247
- from urllib.parse import unquote
1248
- import subprocess, tempfile, os
1249
-
1250
- article = _scrape_article_fast(unquote(url))
1251
- if not article or not article.get('body'):
1252
- return JSONResponse({'error': 'Không đọc được bài viết'})
1253
-
1254
- title = article.get('title', '')
1255
- text_parts = [p.get('text', '') for p in article.get('body', []) if p.get('type') == 'p']
1256
- images = [p.get('src', '') for p in article.get('body', []) if p.get('type') == 'img']
1257
-
1258
- if not text_parts:
1259
- return JSONResponse({'error': 'Không có nội dung'})
1260
-
1261
- # Generate TTS
1262
- tts_text = f"{title}. {' '.join(text_parts[:5])}"
1263
- voice_map = {'vi': 'vi-VN-HoaiMyNeural', 'en': 'en-US-JennyNeural'}
1264
- voice = voice_map.get(lang, 'vi-VN-HoaiMyNeural')
1265
-
1266
- try:
1267
- with tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) as f:
1268
- audio_path = f.name
1269
- subprocess.run(['edge-tts', '--voice', voice, '--text', tts_text[:500], '--write-media', audio_path], timeout=30, check=True)
1270
-
1271
- # Generate video with images
1272
- import glob
1273
- video_id = str(uuid.uuid4())[:8]
1274
- output_path = os.path.join(WALL_VIDEO_DIR, f"short_{video_id}.mp4")
1275
-
1276
- # Use ffmpeg to create video from audio + image
1277
- if images:
1278
- img_path = images[0]
1279
- # Download image
1280
- try:
1281
- img_resp = req.get(img_path, timeout=10)
1282
- if img_resp.status_code == 200:
1283
- img_local = os.path.join(WALL_VIDEO_DIR, f"img_{video_id}.jpg")
1284
- with open(img_local, 'wb') as f:
1285
- f.write(img_resp.content)
1286
- cmd = ['ffmpeg', '-y', '-loop', '1', '-i', img_local, '-i', audio_path, '-c:v', 'libx264', '-tune', 'stillimage', '-c:a', 'aac', '-b:a', '192k', '-pix_fmt', 'yuv420p', '-shortest', '-vf', 'scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2,setsar=1', output_path]
1287
- subprocess.run(cmd, timeout=30, check=True)
1288
- os.unlink(img_local)
1289
- os.unlink(audio_path)
1290
- return JSONResponse({'video': f'/api/wall/video/short_{video_id}.mp4', 'title': title})
1291
- except:
1292
- pass
1293
-
1294
- # Fallback: audio-only video with black background
1295
- cmd = ['ffmpeg', '-y', '-f', 'lavfi', '-i', 'color=c=black:s=1080x1920:d=10', '-i', audio_path, '-c:v', 'libx264', '-tune', 'stillimage', '-c:a', 'aac', '-b:a', '192k', '-pix_fmt', 'yuv420p', '-shortest', output_path]
1296
- subprocess.run(cmd, timeout=30, check=True)
1297
- os.unlink(audio_path)
1298
- return JSONResponse({'video': f'/api/wall/video/short_{video_id}.mp4', 'title': title})
1299
- except Exception as e:
1300
- return JSONResponse({'error': f'Video error: {str(e)[:100]'}, status_code=500)
1301
-
1302
- # ===== IMAGE PROXY =====
1303
- _img_cache = {}
1304
- _img_cache_lock = threading.Lock()
1305
-
1306
- @app.get('/api/proxy/img')
1307
- def proxy_img(url: str = Query(...)):
1308
- from urllib.parse import unquote
1309
- img_url = unquote(url)
1310
- with _img_cache_lock:
1311
- cached = _img_cache.get(img_url)
1312
- if cached and time.time() - cached['t'] < 3600:
1313
- return Response(content=cached['d'], media_type=cached.get('m', 'image/jpeg'))
1314
-
1315
- try:
1316
- r = req.get(img_url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=10)
1317
- if r.status_code == 200:
1318
- content = r.content
1319
- content_type = r.headers.get('content-type', 'image/jpeg')
1320
- with _img_cache_lock:
1321
- _img_cache[img_url] = {'d': content, 'm': content_type, 't': time.time()}
1322
- return Response(content=content, media_type=content_type)
1323
- except:
1324
- pass
1325
- return Response(status_code=404)
1326
-
1327
- # === STATIC FILES ===
1328
- if os.path.isdir(STATIC_DIR):
1329
- app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
1330
-
1331
- print(f"[VNEWS] app_v2_entry loaded. DATA_DIR={DATA_DIR}, STATIC_DIR={STATIC_DIR}")
1332
- print(f"[VNEWS] Persistent storage: {os.path.isdir('/data') and os.access('/data', os.W_OK)}")
 
1
+ return JSONResponse({'error': f'TTS error: {str(e)[:100]}'}, status_code=500)