"""VNEWS v2 - Clean frontend. CRITICAL: removes ALL old routes before registering new ones.""" from app_run import * from app_run import app, f5, f6, rt, PATCH_INJECT, UNIFIED_INJECT_FIXED, HIGHLIGHT_FULL_OVERRIDE, EXTRA_WALL_FIX, FAST_HASHTAG_JS from fastapi.responses import HTMLResponse, JSONResponse, FileResponse from fastapi.staticfiles import StaticFiles from fastapi import Query, Request import requests as req from urllib.parse import quote from bs4 import BeautifulSoup import re, html as html_lib, os, json, threading, time from concurrent.futures import ThreadPoolExecutor, as_completed def _clean(s):return re.sub(r"\s+"," ",html_lib.unescape(str(s or ""))).strip() _STOP_WORDS=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ì'.split()) def _relevance_score(topic, title): topic_lower = topic.lower().strip();title_lower = (title or '').lower() if topic_lower in title_lower: return 10 topic_words = [w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+', topic_lower) if len(w) > 1 and w not in _STOP_WORDS] if not topic_words: return 0 matched = sum(1 for w in topic_words if w in title_lower) ratio = matched / len(topic_words) if topic_words else 0 return int(ratio * 8) if ratio >= 0.6 else 0 def _search_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'):items.append({'title':_clean(a.get('title','') or a.get_text(strip=True)),'url':a['href'],'via':'VnExpress'}) except:pass return items def _search_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: if not href.startswith('http'):href='https://dantri.com.vn'+href if 'dantri.com.vn' in href:items.append({'title':t,'url':href,'via':'Dân Trí'}) if len(items)>=limit:break except:pass return items def _search_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], .horizontalPost__main-title a')[:limit*2]: t=_clean(a.get_text(strip=True));href=a.get('href','') if t and len(t)>15: if not href.startswith('http'):href='https://vietnamnet.vn'+href if 'vietnamnet.vn' in href:items.append({'title':t,'url':href,'via':'VietNamNet'}) if len(items)>=limit:break except:pass return items def _search_all(topic, limit=40): all_items=[] with ThreadPoolExecutor(5) as ex: futs=[ex.submit(_search_vnexpress,topic,10),ex.submit(_search_dantri,topic,10),ex.submit(_search_vietnamnet,topic,8)] for f in as_completed(futs,timeout=12): try:all_items.extend(f.result()) except:pass seen=set();unique=[] for i in all_items: if i.get('url') and i['url'] not in seen:seen.add(i['url']);unique.append(i) return unique[:limit] # ============================================================ # AGGRESSIVELY remove ALL old '/' routes from entire chain # This ensures our new '/' route is the ONLY one serving # ============================================================ # Remove from app.router.routes (APIRoute objects) app.router.routes = [r for r in app.router.routes if not ( (getattr(r, 'path', None) == '/' and 'GET' in getattr(r, 'methods', set())) or (getattr(r, 'path', None) == '/api/hashtag/sources' and 'GET' in getattr(r, 'methods', set())) or (getattr(r, 'path', None) in ('/api/short/comments', '/api/short/comment')) )] # Also remove from app.routes (includes mounts) app.routes[:] = [r for r in app.routes if not ( hasattr(r, 'path') and getattr(r, 'path', None) == '/' and hasattr(r, 'methods') and 'GET' in getattr(r, 'methods', set()) )] # ============================================================ # DEFINE ALL NEW ROUTES # ============================================================ STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static') @app.get('/api/hashtag/sources') def _ht(topic:str=Query(...), page:int=Query(default=0)): all_items=_search_all(topic, 40) scored = [(s,item) for item in all_items if (s:=_relevance_score(topic, item.get('title','')))>0] scored.sort(key=lambda x: x[0], reverse=True) filtered = [item for _, item in scored] if len(filtered) < 3: filtered = all_items per_page=6;start=page*per_page;end=start+per_page return JSONResponse({'sources':filtered[start:end],'topic':topic,'page':page,'has_more':endRedirecting...') # ============================================================ # INTERACTIONS + COMMENTS # ============================================================ 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) INTERACTIONS_FILE = os.path.join(DATA_DIR, 'interactions_v2.json') COMMENTS_FILE = os.path.join(DATA_DIR, 'comments_v2.json') _interact_lock = threading.Lock() _comment_lock = threading.Lock() def _load_json(path): try: if os.path.exists(path): with open(path,'r',encoding='utf-8') as f:return json.load(f) except:pass return {} def _save_json(path, data): try: tmp=path+'.tmp' with open(tmp,'w',encoding='utf-8') as f:json.dump(data,f,ensure_ascii=False) os.replace(tmp,path) except:pass @app.post('/api/v2/interact') async def api_interact(request:Request): body=await request.json();vid=str(body.get('id','')).strip();itype=str(body.get('type','')).strip() if not vid or itype not in('view','like'):return JSONResponse({'error':'invalid'},status_code=400) with _interact_lock: db=_load_json(INTERACTIONS_FILE) if vid not in db:db[vid]={'views':0,'likes':0,'comments':0} db[vid][itype+'s']=db[vid].get(itype+'s',0)+1 _save_json(INTERACTIONS_FILE,db);return JSONResponse(db[vid]) @app.get('/api/v2/interactions') def api_get_interactions(id:str=Query(...)): with _interact_lock:return JSONResponse(_load_json(INTERACTIONS_FILE).get(id.strip(),{'views':0,'likes':0,'comments':0})) @app.get('/api/v2/comments') def api_get_comments(id:str=Query(...)): with _comment_lock:return JSONResponse({'comments':_load_json(COMMENTS_FILE).get(id.strip(),[])}) @app.post('/api/v2/comment') async def api_post_comment(request:Request): body=await request.json();vid=str(body.get('id','')).strip();text=str(body.get('text','')).strip()[:500] if not vid or not text:return JSONResponse({'error':'invalid'},status_code=400) comment={'text':text,'time':time.strftime('%H:%M %d/%m',time.localtime()),'ts':int(time.time())} with _comment_lock: db=_load_json(COMMENTS_FILE) if vid not in db:db[vid]=[] db[vid].append(comment) if len(db[vid])>200:db[vid]=db[vid][-200:] _save_json(COMMENTS_FILE,db);comments=db[vid] with _interact_lock: idb=_load_json(INTERACTIONS_FILE) if vid not in idb:idb[vid]={'views':0,'likes':0,'comments':0} idb[vid]['comments']=len(comments);_save_json(INTERACTIONS_FILE,idb) return JSONResponse({'comments':comments}) # ============================================================ # WORLD CUP 2026 API # ============================================================ 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 ) @app.get('/api/wc2026') def api_wc2026_all():return JSONResponse(get_wc2026_all()) @app.get('/api/wc2026/summary') def api_wc2026_summary():return JSONResponse(scrape_summary()) @app.get('/api/wc2026/fixtures') def api_wc2026_fixtures():return JSONResponse(scrape_fixtures()) @app.get('/api/wc2026/standings') def api_wc2026_standings():return JSONResponse(scrape_standings()) @app.get('/api/wc2026/stats') def api_wc2026_stats():return JSONResponse(scrape_stats()) @app.get('/api/wc2026/history') def api_wc2026_history():return JSONResponse(scrape_history()) @app.get('/api/wc2026/news') def api_wc2026_news():return JSONResponse(scrape_wc_news()) @app.get('/api/wc2026/road') def api_wc2026_road():return JSONResponse(scrape_road_to_wc()) @app.get('/api/wc2026/h2h/{event_id}') def api_wc2026_h2h(event_id:int):return JSONResponse(scrape_h2h(event_id)) @app.get('/api/wc2026/lineups/{event_id}') def api_wc2026_lineups(event_id:int):return JSONResponse(scrape_lineups(event_id)) @app.get('/api/wc2026/match/{event_id}') def api_wc2026_match(event_id:int):return JSONResponse(scrape_match_detail(event_id)) def _wc2026_bg_refresh(): time.sleep(10) while True: try:get_wc2026_all() except:pass time.sleep(90) threading.Thread(target=_wc2026_bg_refresh,daemon=True).start() # ============================================================ # SERVE V2 FRONTEND - THIS MUST BE THE ONLY '/' ROUTE # ============================================================ @app.get('/') async def _index_v2(): """Serve clean v2 frontend from static/index_v2.html""" index_path = os.path.join(STATIC_DIR, 'index_v2.html') if os.path.exists(index_path): return FileResponse(index_path, media_type='text/html') return HTMLResponse('

VNEWS v2

index_v2.html not found

') # ============================================================ # MOUNT STATIC FILES LAST (must be after all route definitions) # ============================================================ app.mount('/static', StaticFiles(directory=STATIC_DIR), name='vnews_static')