"""VNEWS v2 Entry Point - with fast bongda proxy""" import sys, os from main import app, HEADERS, BONGDA_HEADERS, fetch_bongda_api, HL_LEAGUES try: import ai_ext except Exception as e: print(f"[WARN] ai_ext import failed: {e}") from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, Response from fastapi.staticfiles import StaticFiles from starlette.routing import Mount from fastapi import Query, Request, UploadFile, File, Form import requests as req from bs4 import BeautifulSoup import re, html as html_lib, json, threading, time, uuid from concurrent.futures import ThreadPoolExecutor, as_completed from urllib.parse import quote HL_LEAGUES['friendly'] = {"path": "giai-khac/friendly", "name": "Giao hữu", "emoji": "🤝"} STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static') 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()))] app.routes[:]=[r for r in app.routes if not isinstance(r, Mount)] app.router.routes=[r for r in app.router.routes if not isinstance(r, Mount)] def _clean(s): return re.sub(r"\s+"," ",html_lib.unescape(str(s or""))).strip() # Cache for match details (5 min TTL) _match_cache = {} # === FAST BONGDA PROXY ENDPOINT === def _get_match_detail(event_id, slug=None): headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "text/html", "Referer": "https://bongda.com.vn/"} if slug: url = f"https://bongda.com.vn/tran-dau/{event_id}/centre/{slug}" else: url = f"https://bongda.com.vn/tran-dau/{event_id}" resp = req.get(url, headers=headers, timeout=15, allow_redirects=True) if resp.status_code != 200: return None soup = BeautifulSoup(resp.text, 'html.parser') result = {"event_id": event_id, "found": False, "sections": []} info = {} tel = soup.select_one('.teams') if tel: he = tel.select_one('.team.home') if he: p_tags = [p for p in he.select('p') if not p.get('class') or 'logo' not in p.get('class', [])] if p_tags: info['home_team'] = _clean(p_tags[0].get_text()) lo = he.select_one('img') if lo: info['home_logo'] = lo.get('src', '') ae = tel.select_one('.team.away') if ae: p_tags = ae.select('p') team_ps = [p for p in p_tags if not p.get('class') or 'logo' not in p.get('class', [])] if team_ps: info['away_team'] = _clean(team_ps[-1].get_text()) lo = ae.select_one('img') if lo: info['away_logo'] = lo.get('src', '') sc = tel.select_one('.score') if sc: parts = [_clean(p.get_text()) for p in sc.select('p')] if len(parts) >= 2: info['score'] = f"{parts[0]} - {parts[1]}" lb = sc.select_one('.label') if lb: info['status_label'] = _clean(lb.get_text()) if info.get('home_team') and info.get('away_team'): result['info'] = info result['found'] = True result['sections'].append('info') events = [] for ev in soup.select('.events .period .event'): ev_cls = ' '.join(ev.get('class', [])) ev_data = {'team': 'home' if 'home' in ev_cls else 'away', 'period': '', 'type': 'unknown', 'time': '', 'players': ''} parent = ev.parent if parent: h2 = parent.find('h2') if h2: ev_data['period'] = _clean(h2.get_text()) if ev.select_one('[class*="goal"]'): ev_data['type'] = 'goal' elif ev.select_one('[class*="redcard"]'): ev_data['type'] = 'redcard' elif ev.select_one('[class*="yellowcard"]'): ev_data['type'] = 'yellowcard' elif ev.select_one('[class*="substitution"]'): ev_data['type'] = 'substitution' players_el = ev.select_one('.players') if players_el: pl_text = _clean(players_el.get_text(' ', strip=True)) m = re.match(r"(\d+)'(.*)", pl_text) if m: ev_data['time'] = f"{m.group(1)}'" ev_data['players'] = m.group(2) else: ev_data['players'] = pl_text events.append(ev_data) if events: result['events'] = events result['sections'].append('events') pred = soup.select_one('.prediction-card') if pred: team_info = pred.select_one('.team-info') if team_info: teams = team_info.select('.team') pred_data = {} if len(teams) >= 2: pred_data['home_name'] = _clean(teams[0].select_one('.team-name').get_text()) if teams[0].select_one('.team-name') else '' pred_data['away_name'] = _clean(teams[1].select_one('.team-name').get_text()) if teams[1].select_one('.team-name') else '' divider = team_info.select_one('.divider') if divider: pred_data['result'] = _clean(divider.get_text()) vc = pred.select_one('.vote-count') if vc: pred_data['vote_count'] = _clean(vc.get_text()) result['prediction'] = pred_data recent = [] ml = soup.select_one('.matches-list') if ml: for item in ml.select('.match-detail, .match-item, li'): de = item.select_one('.date, .time') le = item.select_one('.league') he_item = item.select_one('.home, .team-home') ae_item = item.select_one('.away, .team-away') se = item.select_one('.score, .result') if he_item or ae_item: 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'}) if recent: result['recent_matches'] = recent result['sections'].append('recent') try: api_h = {"User-Agent": "Mozilla/5.0", "Accept": "application/json", "X-Requested-With": "XMLHttpRequest", "Referer": "https://bongda.com.vn/"} ar = req.get(f"https://bongda.com.vn/api/fixtures/h2h-stats?event_id={event_id}", headers=api_h, timeout=10) if ar.status_code == 200: ad = ar.json() if ad.get('status') == 'success' and ad.get('html'): asp = BeautifulSoup(ad['html'], 'html.parser') ast = {} for row in asp.select('li, tr'): cells = row.select('td, span, p') if len(cells) >= 3: lb = _clean(cells[0].get_text()) if lb: ast[lb] = {'home': _clean(cells[1].get_text()), 'away': _clean(cells[2].get_text())} if ast: result['h2h_stats_parsed'] = ast result['sections'].append('h2h_stats') except: pass return result @app.get('/api/proxy/bongda') def proxy_bongda(event_id: int = Query(default=None), slug: str = Query(default=None)): if event_id is None: return JSONResponse({'error': 'event_id required'}, status_code=400) cache_key = f"{event_id}_{slug}" now = time.time() cached = _match_cache.get(cache_key) if cached and now - cached.get('_ts', 0) < 300: return JSONResponse(cached) try: result = _get_match_detail(event_id, slug) if result: result['_ts'] = now _match_cache[cache_key] = result return JSONResponse(result) except Exception as e: err = {"event_id": event_id, "found": False, "error": str(e), "_ts": now} _match_cache[cache_key] = err return JSONResponse(err) return JSONResponse({"event_id": event_id, "found": False}) @app.get('/api/match/{event_id}/detail') def api_match_detail(event_id: int, url: str = Query(default=None)): slug = None if url: m = re.match(r'.+/tran-dau/\d+/(?:centre|preview)/(.+)', url) if m: slug = m.group(1) cache_key = f"{event_id}_{slug or ''}" now = time.time() cached = _match_cache.get(cache_key) if cached and now - cached.get('_ts', 0) < 300: return JSONResponse(cached) try: if not slug: try: home_r = req.get("https://bongda.com.vn/", headers={"User-Agent": "Mozilla/5.0"}, timeout=10) if home_r.status_code == 200: home_soup = BeautifulSoup(home_r.text, 'html.parser') for a in home_soup.select(f'a[href*="/tran-dau/{event_id}/"]'): href = a.get('href', '') m = re.match(r'/tran-dau/\d+/(?:centre|preview)/(.+)', href) if m: slug = m.group(1) cache_key = f"{event_id}_{slug}" break except: pass result = _get_match_detail(event_id, slug) if result: result['_ts'] = now _match_cache[cache_key] = result return JSONResponse(result) except Exception as e: err = {"event_id": event_id, "found": False, "error": str(e), "_ts": now} _match_cache[cache_key] = err return JSONResponse(err) return JSONResponse({"event_id": event_id, "found": False}) _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()) def _has_kw(topic,title): tl=topic.lower();tt=(title or'').lower() if tl in tt:return True words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',tl) if len(w)>2 and w not in _STOP] if not words:return True return any(w in tt for w in words) def _s_vnexpress(topic,limit=8): items=[] try: r=req.get(f"https://timkiem.vnexpress.net/?q={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=10);soup=BeautifulSoup(r.text,'lxml') for art in soup.select('article.item-news')[:limit]: a=art.select_one('h2 a, h3 a') if a and a.get('href'): t=_clean(a.get('title','') or a.get_text(strip=True)) if _has_kw(topic,t):items.append({'title':t,'url':a['href'],'via':'VnExpress'}) except:pass return items def _s_dantri(topic,limit=8): items=[] try: r=req.get(f"https://dantri.com.vn/tim-kiem/{quote(topic)}.htm",headers={'User-Agent':'Mozilla/5.0'},timeout=10);soup=BeautifulSoup(r.text,'lxml') for a in soup.select('h3 a[href], .article-title a[href]')[:limit*2]: t=_clean(a.get_text(strip=True));href=a.get('href','') if t and len(t)>15 and _has_kw(topic,t): if not href.startswith('http'):href='https://dantri.com.vn'+href items.append({'title':t,'url':href,'via':'Dân Trí'}) if len(items)>=limit:break except:pass return items def _s_vietnamnet(topic,limit=6): items=[] try: r=req.get(f"https://vietnamnet.vn/tim-kiem?q={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=10);soup=BeautifulSoup(r.text,'lxml') for a in soup.select('h3 a[href], .vnn-title a')[:limit*2]: t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','') if t and len(t)>15 and _has_kw(topic,t): if not href.startswith('http'):href='https://vietnamnet.vn'+href items.append({'title':t,'url':href,'via':'VietNamNet'}) if len(items)>=limit:break except:pass return items def _s_bongda(topic,limit=5): items=[] try: r=req.get(f"https://bongda.com.vn/tim-kiem.html?q={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=8);soup=BeautifulSoup(r.text,'lxml') for a in soup.select('h3 a[href], .title a[href]')[:limit*2]: t=_clean(a.get_text(strip=True));href=a.get('href','') if t and len(t)>15 and _has_kw(topic,t): if not href.startswith('http'):href='https://bongda.com.vn'+href items.append({'title':t,'url':href,'via':'Bóng Đá'}) if len(items)>=limit:break except:pass return items def _s_genk(topic,limit=5): items=[] try: r=req.get(f"https://genk.vn/tim-kiem?q={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=8);soup=BeautifulSoup(r.text,'lxml') for a in soup.select('a[href$=".chn"]')[:limit*3]: t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','') if t and len(t)>15 and _has_kw(topic,t): if href.startswith('/'):href='https://genk.vn'+href items.append({'title':t,'url':href,'via':'GenK'}) if len(items)>=limit:break except:pass return items def _s_thanhnien(topic,limit=6): items=[] try: r=req.get(f"https://thanhnien.vn/tim-kiem?q={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=10);soup=BeautifulSoup(r.text,'lxml') for a in soup.select('h3 a[href], .box-title a')[:limit*2]: t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','') if t and len(t)>15 and _has_kw(topic,t): if not href.startswith('http'):href='https://thanhnien.vn'+href items.append({'title':t,'url':href,'via':'Thanh Niên'}) if len(items)>=limit:break except:pass return items def _s_tuoitre(topic,limit=6): items=[] try: r=req.get(f"https://tuoitre.vn/tim-kiem.htm?keywords={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=10);soup=BeautifulSoup(r.text,'lxml') for a in soup.select('h3 a[href], .box-title-text a')[:limit*2]: t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','') if t and len(t)>15 and _has_kw(topic,t): if not href.startswith('http'):href='https://tuoitre.vn'+href items.append({'title':t,'url':href,'via':'Tuổi Trẻ'}) if len(items)>=limit:break except:pass return items def _s_thethaovanhoa(topic,limit=5): items=[] try: r=req.get(f"https://thethaovanhoa.vn/tim-kiem.htm?keyword={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=8);soup=BeautifulSoup(r.text,'lxml') for a in soup.select('h3 a[href], .title a[href]')[:limit*2]: t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','') if t and len(t)>15 and _has_kw(topic,t): if not href.startswith('http'):href='https://thethaovanhoa.vn'+href items.append({'title':t,'url':href,'via':'TT&VH'}) if len(items)>=limit:break except:pass return items def _search_all(topic,limit=36): results={} with ThreadPoolExecutor(8) as ex: 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'} for f in as_completed(futs,timeout=14): try:results[futs[f]]=f.result() except:results[futs[f]]=[] srcs=list(results.values());out=[];seen=set() for i in range(max((len(s) for s in srcs),default=0)): for s in srcs: if i= 2: seen_imgs = set() for child in el.find_all(['p','h2','h3','figure','img'], recursive=True): if child.name == 'p': t = child.get_text(strip=True) if t and len(t) > 15: body.append({'type': 'p', 'text': t}) elif child.name in ('h2','h3'): t = child.get_text(strip=True) if t: body.append({'type': 'heading', 'text': t}) elif child.name in ('figure','img'): im = child if child.name == 'img' else child.find('img') if im: src = im.get('data-src') or im.get('src') or im.get('data-lazy') or '' if src and 'base64' not in src and src not in seen_imgs: seen_imgs.add(src) if src.startswith('//'): src = 'https:' + src body.append({'type': 'img', 'src': src}) if child.name == 'figure': cap = child.find('figcaption') if cap: ct = cap.get_text(strip=True) if ct: body.append({'type': 'p', 'text': ct}) if len(body) >= 2: return {'title': _clean(title), 'summary': _clean(summary), 'og_image': og_img, 'body': body[:50], 'source': domain, 'url': url} # No body found — use OG meta as fallback if title and (summary or og_img): fallback = [] if og_img: fallback.append({'type': 'img', 'src': og_img}) if summary: fallback.append({'type': 'p', 'text': summary}) if fallback: return {'title': _clean(title), 'summary': _clean(summary), 'og_image': og_img, 'body': fallback, 'source': domain, 'url': url, 'fallback': True} # Got HTML but no body and no OG — return title at least if title: return {'title': _clean(title), 'summary': '', 'og_image': '', 'body': [{'type': 'p', 'text': 'Nội dung đang được tải...'}], 'source': domain, 'url': url, 'fallback': True} break # Got 200 but no content at all — don't retry other UA except Exception: continue return None @app.get('/api/article') def api_article_v2(url: str = Query(...)): """Scrape article and return JSON for VNEWS SPA. Fast, cached, with fallback.""" from urllib.parse import unquote safe_url = unquote(url) try: # Check cache first now = time.time() cached = _article_cache.get(safe_url) if cached and now - cached['t'] < _article_cache_ttl: resp = JSONResponse(cached['d']) resp.headers["Cache-Control"] = "public, max-age=1800" return resp # Fetch fresh — use fast scraper for ALL sites (simpler, more reliable) data = _scrape_article_fast(safe_url) if data and data.get('body'): _article_cache[safe_url] = {'d': data, 't': now} resp = JSONResponse(data) resp.headers["Cache-Control"] = "public, max-age=1800" return resp # Last resort: try RSS fallback try: from main import _fetch_rss_fallback from urllib.parse import urlparse as _up rss_data = _fetch_rss_fallback(safe_url, _up(safe_url).netloc) if rss_data and rss_data.get('title'): body = [] if rss_data.get('og_image'): body.append({'type': 'img', 'src': rss_data['og_image']}) if rss_data.get('summary'): sentences = re.split(r'(?<=[.!?])\s+', rss_data['summary']) for s in sentences[:10]: if len(s.strip()) > 20: body.append({'type': 'p', 'text': s.strip()}) if body: result = { 'title': rss_data['title'], 'summary': rss_data['summary'][:500], 'og_image': rss_data.get('og_image', ''), 'body': body[:50], 'source': 'rss', 'url': safe_url, 'fallback': True, 'rss': True } _article_cache[safe_url] = {'d': result, 't': now} resp = JSONResponse(result) resp.headers["Cache-Control"] = "public, max-age=600" return resp except Exception: pass result = {'error': 'Không đọc được', 'url': safe_url} resp = JSONResponse(result) resp.headers["Cache-Control"] = "public, max-age=60" return resp except Exception as e: import traceback tb = traceback.format_exc() return JSONResponse({'error': f'Server error: {str(e)[:100]}', 'trace': tb[-500:], 'url': safe_url}, status_code=200) _hot_cache={'t':0,'d':[]} def _get_hot_topics(): now=time.time() if _hot_cache['d'] and now-_hot_cache['t']<600:return _hot_cache['d'] freq={};display={} 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'] for feed_url in feeds: try: r=req.get(feed_url,headers={'User-Agent':'Mozilla/5.0'},timeout=6);r.encoding='utf-8';soup=BeautifulSoup(r.text,'xml') for item in soup.find_all('item')[:12]: title=_clean(item.find('title').get_text() if item.find('title') else '') if not title:continue 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] if len(words)<2:continue for n in(3,4,2): for i in range(max(0,len(words)-n+1)): phrase=' '.join(words[i:i+n]) if 8<=len(phrase)<=45:key=phrase.lower();freq[key]=freq.get(key,0)+1;display[key]=phrase except:continue ranked=sorted(freq.items(),key=lambda x:x[1],reverse=True);topics=[];seen=set() for key,count in ranked: 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) if is_dup:continue seen.add(key);topics.append({'label':'#'+re.sub(r'\s+','',display[key].title()),'topic':display[key],'count':count}) if len(topics)>=20:break 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']: if len(topics)>=24:break if not any(kw.lower() in s for s in seen):topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw,'count':0}) _hot_cache.update({'t':now,'d':topics[:24]});return topics[:24] @app.get('/api/hot_topics') def api_hot_topics(): resp = JSONResponse({'topics':_get_hot_topics()}) resp.headers["Cache-Control"] = "public, max-age=120" return resp @app.get('/') async def serve_index(): p=os.path.join(STATIC_DIR,'index_v2.html') if os.path.exists(p):return FileResponse(p,media_type='text/html') return HTMLResponse('

VNEWS

') @app.get('/api/hashtag/sources') def _ht(topic:str=Query(...),page:int=Query(default=0)): items=_search_all(topic,36);per_page=8;start=page*per_page;end=start+per_page return JSONResponse({'sources':items[start:end],'topic':topic,'page':page,'has_more':end') 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) # === XEMLAIBONGDA PROXY (CORS workaround for WC highlights) === _xlb_cache = {} _xlb_lock = threading.Lock() def _xlb_scrape(path): url = f"https://xemlaibongda.top/{path}" r = req.get(url, headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}, timeout=15, allow_redirects=True) if r.status_code != 200: return [] soup = BeautifulSoup(r.text, 'lxml') vids = [] seen = set() for a in soup.select('a[href*="/video/"]'): href = a.get('href', '') if not href or href in seen: continue seen.add(href) if not href.startswith('http'): href = 'https://xemlaibongda.top' + href img = a.select_one('img') p = a.parent for _ in range(4): if img: break if p: img = p.select_one('img') p = p.parent img_src = '' if img: img_src = img.get('data-src','') or img.get('src','') or img.get('data-lazy','') or img.get('data-original','') if img_src.startswith('//'): img_src = 'https:' + img_src elif img_src.startswith('/'): img_src = 'https://xemlaibongda.top' + img_src title = '' for sel in ['.title', 'h3', 'h2', '.name', '.post-title', '.entry-title', '.video-title']: t = a.select_one(sel) if t: title = _clean(t.get_text()) break if not title: title = _clean(a.get('title','')) if not title: img_alt = a.select_one('img') if img_alt: title = _clean(img_alt.get('alt','')) if not title: parent = a.parent if parent: pt = _clean(parent.get_text(' ',strip=True)) if 5 < len(pt) < 120: title = pt if not title or len(title) < 3: continue vids.append({"link": href, "img": img_src, "title": title}) if len(vids) >= 30: break return vids @app.get('/api/proxy/xlb') def proxy_xlb(path: str = Query(default="")): now = time.time() cache_key = f"xlb:{path}" with _xlb_lock: cached = _xlb_cache.get(cache_key) if cached and now - cached['t'] < 120: return JSONResponse(cached['d']) try: vids = _xlb_scrape(path) result = {"videos": vids, "count": len(vids)} with _xlb_lock: _xlb_cache[cache_key] = {'t': now, 'd': result} return JSONResponse(result) except Exception as e: return JSONResponse({"videos": [], "count": 0, "error": str(e)}, status_code=500) @app.get('/api/wc2026') def _w():return JSONResponse(get_wc2026_all()) @app.get('/api/wc2026/fixtures') def _wf():return JSONResponse(scrape_fixtures()) @app.get('/api/wc2026/standings') def _ws():return JSONResponse(scrape_standings()) @app.get('/api/wc2026/stats') def _wst():return JSONResponse(scrape_stats()) @app.get('/api/wc2026/history') def _whi():return JSONResponse(scrape_history()) @app.get('/api/wc2026/news') def _wn():return JSONResponse(scrape_wc_news()) @app.get('/api/wc2026/road') def _wr():return JSONResponse(scrape_road_to_wc()) @app.get('/api/wc2026/h2h/{eid}') def _wh2(eid:int):return JSONResponse(scrape_h2h(eid)) @app.get('/api/wc2026/lineups/{eid}') def _wl(eid:int):return JSONResponse(scrape_lineups(eid)) @app.get('/api/wc2026/match/{eid}') def _wm(eid:int):return JSONResponse(scrape_match_detail(eid)) DATA_DIR='/data' if os.path.isdir('/data') else os.path.join(os.path.dirname(os.path.abspath(__file__)),'data') os.makedirs(DATA_DIR,exist_ok=True) IF=os.path.join(DATA_DIR,'interactions_v2.json') CF=os.path.join(DATA_DIR,'comments_v2.json') WALL_FILE=os.path.join(DATA_DIR,'wall_posts.json') WALL_VIDEO_DIR=os.path.join(DATA_DIR,'wall_videos') os.makedirs(WALL_VIDEO_DIR,exist_ok=True) _il=threading.Lock();_cl=threading.Lock();_wl_lock=threading.Lock() def _lj(p): try: if os.path.exists(p):return json.load(open(p,'r',encoding='utf-8')) except:pass return{} def _sj(p,d): try:open(p+'.tmp','w',encoding='utf-8').write(json.dumps(d,ensure_ascii=False));os.replace(p+'.tmp',p) except:pass @app.post('/api/v2/interact') async def _int(request:Request): b=await request.json();v=str(b.get('id','')).strip();t=str(b.get('type','')).strip() if not v or t not in('view','like'):return JSONResponse({'error':'x'},status_code=400) 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]) @app.get('/api/v2/interactions') def _gi(id:str=Query(...)): with _il:return JSONResponse(_lj(IF).get(id.strip(),{'views':0,'likes':0,'comments':0})) @app.get('/api/v2/comments') def _gc(id:str=Query(...)): with _cl:return JSONResponse({'comments':_lj(CF).get(id.strip(),[])}) @app.post('/api/v2/comment') async def _pc(request:Request): b=await request.json();v=str(b.get('id','')).strip();tx=str(b.get('text','')).strip()[:500] if not v or not tx:return JSONResponse({'error':'x'},status_code=400) c={'text':tx,'time':time.strftime('%H:%M %d/%m',time.localtime()),'ts':int(time.time())} with _cl:db=_lj(CF);db.setdefault(v,[]);db[v].append(c);db[v]=db[v][-200:];_sj(CF,db);cms=db[v] with _il:idb=_lj(IF);idb.setdefault(v,{'views':0,'likes':0,'comments':0});idb[v]['comments']=len(cms);_sj(IF,idb) return JSONResponse({'comments':cms}) # ===== WALL / SHORT AI ENDPOINTS ===== def _load_wall_posts(): """Load wall posts from JSON file.""" with _wl_lock: return _lj(WALL_FILE) def _save_wall_posts(posts): """Save wall posts to JSON file.""" with _wl_lock: _sj(WALL_FILE, posts) @app.get('/api/wall') def api_wall(): """Get all wall posts.""" posts = _load_wall_posts() if not posts: # Return empty list, not error return JSONResponse({"posts": []}) return JSONResponse({"posts": posts}) @app.post('/api/wall') async def api_wall_post(request: Request): """ Create a wall post. Supports: - JSON body: {title, text, img, source} - Multipart form: title, text, source + video file upload """ content_type = request.headers.get('content-type', '') # Handle multipart upload (video file) if 'multipart/form-data' in content_type: try: form = await request.form() except Exception as e: return JSONResponse({"error": f"Form parse error: {str(e)}"}, status_code=400) title = form.get('title', 'Video mới') or 'Video mới' text = form.get('text', '') or '' source = form.get('source', 'vtv_recorder') or 'vtv_recorder' video_file = form.get('video') post_id = str(uuid.uuid4())[:12] video_url = None # Save video file if provided if video_file and hasattr(video_file, 'filename') and video_file.filename: # Determine extension fname = video_file.filename.lower() if fname.endswith('.mp4'): ext = '.mp4' elif fname.endswith('.webm'): ext = '.webm' else: ext = '.webm' video_filename = f"wall_{post_id}{ext}" video_path = os.path.join(WALL_VIDEO_DIR, video_filename) try: # Read file content content = await video_file.read() if not content: return JSONResponse({"error": "Empty video file"}, status_code=400) # Save to disk with open(video_path, 'wb') as f: f.write(content) file_size_mb = len(content) / 1024 / 1024 if file_size_mb > 50: os.remove(video_path) return JSONResponse({"error": f"Video quá lớn ({file_size_mb:.1f}MB). Tối đa 50MB."}, status_code=400) # URL to access the video video_url = f"/api/wall/video/{video_filename}" except Exception as e: return JSONResponse({"error": f"Lỗi lưu video: {str(e)}"}, status_code=500) # Create post post = { "id": post_id, "title": title[:200], "text": text[:2000], "source": source, "video": video_url, "img": None, "images": [], "created": int(time.time()), "created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()), } # Save to wall posts = _load_wall_posts() if not isinstance(posts, list): posts = [] posts.insert(0, post) # Keep max 200 posts posts = posts[:200] _save_wall_posts(posts) return JSONResponse({"post": post, "ok": True}) # Handle JSON body (text-only post) try: body = await request.json() except: body = {} title = body.get('title', 'Bài mới') or 'Bài mới' text = body.get('text', '') or '' img = body.get('img', None) source = body.get('source', 'user') or 'user' post_id = str(uuid.uuid4())[:12] post = { "id": post_id, "title": title[:200], "text": text[:2000], "source": source, "video": None, "img": img, "images": [], "created": int(time.time()), "created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()), } posts = _load_wall_posts() if not isinstance(posts, list): posts = [] posts.insert(0, post) posts = posts[:200] _save_wall_posts(posts) return JSONResponse({"post": post, "ok": True}) @app.get('/api/wall/video/{filename}') def api_wall_video(filename: str): """Serve a wall video file.""" # Security: prevent path traversal if '..' in filename or '/' in filename: return Response(status_code=403) video_path = os.path.join(WALL_VIDEO_DIR, filename) if not os.path.exists(video_path): return Response(status_code=404) ext = os.path.splitext(filename)[1].lower() media_type = 'video/mp4' if ext == '.mp4' else 'video/webm' return FileResponse(video_path, media_type=media_type) @app.delete('/api/wall/{post_id}') def api_wall_delete(post_id: str): """Delete a wall post and its video.""" posts = _load_wall_posts() if not isinstance(posts, list): return JSONResponse({"error": "No posts"}, status_code=404) for i, p in enumerate(posts): if p.get('id') == post_id: # Delete video file if exists if p.get('video'): video_name = p['video'].split('/')[-1] video_path = os.path.join(WALL_VIDEO_DIR, video_name) if os.path.exists(video_path): os.remove(video_path) posts.pop(i) _save_wall_posts(posts) return JSONResponse({"ok": True}) return JSONResponse({"error": "Post not found"}, status_code=404) def _bg(): time.sleep(15) while True: try:get_wc2026_all() except:pass time.sleep(90) threading.Thread(target=_bg,daemon=True).start() app.mount('/static',StaticFiles(directory=STATIC_DIR),name='vnews_static')