Spaces:
Running
Running
feat: inline article reader + BBC Vietnamese + fix shorts/highlights sliders
Browse files
main.py
CHANGED
|
@@ -1,8 +1,7 @@
|
|
| 1 |
"""VNEWS - FastAPI backend with news scrapers and API."""
|
| 2 |
-
import hashlib, re, time
|
| 3 |
-
from urllib.parse import quote as urlquote
|
| 4 |
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 5 |
-
from fastapi import FastAPI,
|
| 6 |
from fastapi.responses import HTMLResponse, JSONResponse
|
| 7 |
from fastapi.staticfiles import StaticFiles
|
| 8 |
import requests
|
|
@@ -25,15 +24,11 @@ def _cached(key, fn):
|
|
| 25 |
now = time.time()
|
| 26 |
if key in _cache and now - _cache[key]["t"] < _cache_ttl:
|
| 27 |
return _cache[key]["d"]
|
| 28 |
-
try:
|
| 29 |
-
|
| 30 |
-
except:
|
| 31 |
-
data = _cache.get(key, {}).get("d", [])
|
| 32 |
_cache[key] = {"d": data, "t": now}
|
| 33 |
return data
|
| 34 |
|
| 35 |
-
def make_id(url): return hashlib.md5(url.encode()).hexdigest()[:12]
|
| 36 |
-
|
| 37 |
def _get(url):
|
| 38 |
r = requests.get(url, headers=HEADERS, timeout=15); r.encoding="utf-8"
|
| 39 |
return BeautifulSoup(r.text, "lxml")
|
|
@@ -53,14 +48,43 @@ def scrape_vne(cat_url):
|
|
| 53 |
src = it.find("source")
|
| 54 |
if src: img = src.get("srcset","").split(",")[0].strip().split(" ")[0]
|
| 55 |
desc = it.select_one("p.description")
|
| 56 |
-
arts.append({"title":t,"link":lk,"img":img,"summary":(desc.get_text(strip=True)[:
|
| 57 |
return arts
|
| 58 |
except: return []
|
| 59 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
def scrape_bdp(url):
|
| 61 |
try:
|
| 62 |
soup = _get(url); arts = []; seen = set()
|
| 63 |
-
for sel
|
| 64 |
for it in soup.select(sel):
|
| 65 |
tag = it.find("a",class_="title") or it.find("a",href=True)
|
| 66 |
if not tag: continue
|
|
@@ -69,8 +93,7 @@ def scrape_bdp(url):
|
|
| 69 |
if lk and not lk.startswith("http"): lk=BASE_BDP+lk
|
| 70 |
if lk in seen: continue
|
| 71 |
im=it.find("img"); img=(im.get("data-src") or im.get("src","")) if im else ""
|
| 72 |
-
seen.add(lk)
|
| 73 |
-
arts.append({"title":t,"link":lk,"img":img,"source":"bdp"})
|
| 74 |
return arts[:15]
|
| 75 |
except: return []
|
| 76 |
|
|
@@ -106,7 +129,7 @@ def scrape_24h_highlights():
|
|
| 106 |
img_src = img.get("data-original") or img.get("data-src") or img.get("src","")
|
| 107 |
if "base64" in img_src: img_src = ""
|
| 108 |
arts.append({"title":title,"link":href,"img":img_src,"source":"24h"})
|
| 109 |
-
return arts[:
|
| 110 |
except: return []
|
| 111 |
|
| 112 |
def scrape_24h_shorts():
|
|
@@ -129,6 +152,51 @@ def scrape_24h_shorts():
|
|
| 129 |
return arts[:20]
|
| 130 |
except: return []
|
| 131 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
def extract_video_url(article_url):
|
| 133 |
try:
|
| 134 |
r = requests.get(article_url, headers={**HEADERS,"Referer":"https://www.24h.com.vn/"}, timeout=10)
|
|
@@ -160,110 +228,98 @@ def extract_bdp_video(url):
|
|
| 160 |
return {"src":source.get("src","") if source else "","poster":video.get("poster","")}
|
| 161 |
except: return None
|
| 162 |
|
| 163 |
-
# ═══ API
|
| 164 |
-
|
| 165 |
-
"thoi-su":
|
| 166 |
-
"the-gioi":
|
| 167 |
-
"kinh-doanh":
|
| 168 |
-
"cong-nghe":
|
| 169 |
-
"the-thao":
|
| 170 |
-
"giai-tri":
|
| 171 |
-
"suc-khoe":
|
| 172 |
}
|
| 173 |
-
|
| 174 |
-
"ngoai-hang-anh":
|
| 175 |
-
"la-liga":
|
| 176 |
-
"champions-league":
|
| 177 |
-
"bong-da-vn":
|
| 178 |
}
|
| 179 |
|
| 180 |
@app.get("/api/homepage")
|
| 181 |
def api_homepage():
|
| 182 |
-
def
|
| 183 |
-
articles
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
futs
|
| 187 |
for f in as_completed(futs):
|
| 188 |
try:
|
| 189 |
for a in f.result(): a["group"]=futs[f]; articles.append(a)
|
| 190 |
except: pass
|
| 191 |
-
# Add BDP
|
| 192 |
try:
|
| 193 |
-
|
| 194 |
-
for a in bdp[:6]: a["group"]="Bóng Đá"; articles.append(a)
|
| 195 |
except: pass
|
| 196 |
return articles
|
| 197 |
-
return JSONResponse(_cached("homepage",
|
| 198 |
|
| 199 |
@app.get("/api/category/{cat_id}")
|
| 200 |
-
def api_category(cat_id:
|
| 201 |
-
def
|
| 202 |
-
if cat_id
|
| 203 |
-
|
| 204 |
-
arts
|
| 205 |
-
for a in arts:
|
| 206 |
return arts
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
for a in arts: a["group"] = name
|
| 211 |
return arts
|
| 212 |
-
return
|
| 213 |
-
return JSONResponse(_cached(f"cat_{cat_id}",
|
| 214 |
|
| 215 |
@app.get("/api/categories")
|
| 216 |
def api_categories():
|
| 217 |
-
cats
|
| 218 |
-
for k,(u,n) in
|
| 219 |
-
|
| 220 |
-
for k,(u,n) in BDP_CATEGORIES.items():
|
| 221 |
-
cats.append({"id":k,"name":n,"source":"bdp"})
|
| 222 |
return JSONResponse(cats)
|
| 223 |
|
| 224 |
@app.get("/api/highlights")
|
| 225 |
def api_highlights():
|
| 226 |
-
return JSONResponse(_cached("highlights",
|
| 227 |
|
| 228 |
@app.get("/api/shorts")
|
| 229 |
def api_shorts():
|
| 230 |
-
return JSONResponse(_cached("shorts",
|
| 231 |
|
| 232 |
@app.get("/api/bdp_videos")
|
| 233 |
def api_bdp_videos():
|
| 234 |
-
return JSONResponse(_cached("bdp_videos",
|
| 235 |
|
| 236 |
@app.get("/api/video_url")
|
| 237 |
-
def api_video_url(url:
|
| 238 |
-
if "24h.com.vn" in url:
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
v = extract_bdp_video(url)
|
| 242 |
-
else:
|
| 243 |
-
v = None
|
| 244 |
return JSONResponse(v if v else {"error":"not found"})
|
| 245 |
|
| 246 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
@app.get("/share/{path:path}")
|
| 248 |
-
async def share_page(path:
|
| 249 |
-
og_image
|
| 250 |
-
html
|
| 251 |
-
<meta charset="utf-8"><title>{title}</title>
|
| 252 |
-
<meta property="og:title" content="{title}">
|
| 253 |
-
<meta property="og:image" content="{og_image}">
|
| 254 |
-
<meta property="og:url" content="{SPACE_URL}/share/{path}">
|
| 255 |
-
<meta property="og:type" content="article">
|
| 256 |
-
<meta property="og:site_name" content="VNEWS">
|
| 257 |
-
<meta name="twitter:card" content="summary_large_image">
|
| 258 |
-
<meta name="twitter:image" content="{og_image}">
|
| 259 |
-
<meta http-equiv="refresh" content="0;url={SPACE_URL}">
|
| 260 |
-
</head><body><p>Redirecting...</p></body></html>"""
|
| 261 |
return HTMLResponse(content=html)
|
| 262 |
|
| 263 |
-
# ═══ Serve frontend ═══
|
| 264 |
@app.get("/")
|
| 265 |
async def index():
|
| 266 |
-
with open("/app/static/index.html",
|
| 267 |
return HTMLResponse(content=f.read())
|
| 268 |
|
| 269 |
-
app.mount("/static",
|
|
|
|
| 1 |
"""VNEWS - FastAPI backend with news scrapers and API."""
|
| 2 |
+
import hashlib, re, time
|
|
|
|
| 3 |
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 4 |
+
from fastapi import FastAPI, Query
|
| 5 |
from fastapi.responses import HTMLResponse, JSONResponse
|
| 6 |
from fastapi.staticfiles import StaticFiles
|
| 7 |
import requests
|
|
|
|
| 24 |
now = time.time()
|
| 25 |
if key in _cache and now - _cache[key]["t"] < _cache_ttl:
|
| 26 |
return _cache[key]["d"]
|
| 27 |
+
try: data = fn()
|
| 28 |
+
except: data = _cache.get(key, {}).get("d", [])
|
|
|
|
|
|
|
| 29 |
_cache[key] = {"d": data, "t": now}
|
| 30 |
return data
|
| 31 |
|
|
|
|
|
|
|
| 32 |
def _get(url):
|
| 33 |
r = requests.get(url, headers=HEADERS, timeout=15); r.encoding="utf-8"
|
| 34 |
return BeautifulSoup(r.text, "lxml")
|
|
|
|
| 48 |
src = it.find("source")
|
| 49 |
if src: img = src.get("srcset","").split(",")[0].strip().split(" ")[0]
|
| 50 |
desc = it.select_one("p.description")
|
| 51 |
+
arts.append({"title":t,"link":lk,"img":img,"summary":(desc.get_text(strip=True)[:150] if desc else ""),"source":"vne"})
|
| 52 |
return arts
|
| 53 |
except: return []
|
| 54 |
|
| 55 |
+
def scrape_vne_article(url):
|
| 56 |
+
"""Scrape full VnExpress article content."""
|
| 57 |
+
try:
|
| 58 |
+
soup = _get(url)
|
| 59 |
+
h1 = soup.select_one("h1.title-detail")
|
| 60 |
+
title = h1.get_text(strip=True) if h1 else ""
|
| 61 |
+
desc = soup.select_one("p.description")
|
| 62 |
+
summary = desc.get_text(strip=True) if desc else ""
|
| 63 |
+
og = soup.find("meta",property="og:image")
|
| 64 |
+
og_img = og.get("content","") if og else ""
|
| 65 |
+
cd = soup.select_one("article.fck_detail")
|
| 66 |
+
body = []
|
| 67 |
+
if cd:
|
| 68 |
+
for ch in cd.children:
|
| 69 |
+
if not hasattr(ch,'name') or not ch.name: continue
|
| 70 |
+
if ch.name == "p":
|
| 71 |
+
t = ch.get_text(strip=True)
|
| 72 |
+
if t: body.append({"type":"p","text":t})
|
| 73 |
+
elif ch.name == "figure":
|
| 74 |
+
im = ch.find("img")
|
| 75 |
+
if im:
|
| 76 |
+
s = im.get("data-src") or im.get("src","")
|
| 77 |
+
cap = ch.find("figcaption")
|
| 78 |
+
if s: body.append({"type":"img","src":s,"alt":cap.get_text(strip=True) if cap else ""})
|
| 79 |
+
elif ch.name in ("h2","h3"):
|
| 80 |
+
body.append({"type":"heading","text":ch.get_text(strip=True)})
|
| 81 |
+
return {"title":title,"summary":summary,"og_image":og_img,"body":body,"source":"vne","url":url}
|
| 82 |
+
except: return None
|
| 83 |
+
|
| 84 |
def scrape_bdp(url):
|
| 85 |
try:
|
| 86 |
soup = _get(url); arts = []; seen = set()
|
| 87 |
+
for sel in ["div.news.fst","div.sld-itm.news","li.news"]:
|
| 88 |
for it in soup.select(sel):
|
| 89 |
tag = it.find("a",class_="title") or it.find("a",href=True)
|
| 90 |
if not tag: continue
|
|
|
|
| 93 |
if lk and not lk.startswith("http"): lk=BASE_BDP+lk
|
| 94 |
if lk in seen: continue
|
| 95 |
im=it.find("img"); img=(im.get("data-src") or im.get("src","")) if im else ""
|
| 96 |
+
seen.add(lk); arts.append({"title":t,"link":lk,"img":img,"source":"bdp","summary":""})
|
|
|
|
| 97 |
return arts[:15]
|
| 98 |
except: return []
|
| 99 |
|
|
|
|
| 129 |
img_src = img.get("data-original") or img.get("data-src") or img.get("src","")
|
| 130 |
if "base64" in img_src: img_src = ""
|
| 131 |
arts.append({"title":title,"link":href,"img":img_src,"source":"24h"})
|
| 132 |
+
return arts[:30]
|
| 133 |
except: return []
|
| 134 |
|
| 135 |
def scrape_24h_shorts():
|
|
|
|
| 152 |
return arts[:20]
|
| 153 |
except: return []
|
| 154 |
|
| 155 |
+
def scrape_bbc_vietnamese():
|
| 156 |
+
"""Scrape BBC Vietnamese news."""
|
| 157 |
+
try:
|
| 158 |
+
soup = _get("https://www.bbc.com/vietnamese")
|
| 159 |
+
arts = []; seen = set()
|
| 160 |
+
for a in soup.find_all("a", href=True):
|
| 161 |
+
href = a.get("href","")
|
| 162 |
+
if not href.startswith("/vietnamese/"): continue
|
| 163 |
+
if href.count("/") < 3: continue # skip section links
|
| 164 |
+
full = "https://www.bbc.com" + href
|
| 165 |
+
if full in seen: continue
|
| 166 |
+
# Get title from text
|
| 167 |
+
title = a.get_text(strip=True)
|
| 168 |
+
if not title or len(title) < 15: continue
|
| 169 |
+
# Find nearby image
|
| 170 |
+
parent = a.parent
|
| 171 |
+
img = ""
|
| 172 |
+
if parent:
|
| 173 |
+
im = parent.find("img")
|
| 174 |
+
if im: img = im.get("src","") or im.get("data-src","")
|
| 175 |
+
seen.add(full)
|
| 176 |
+
arts.append({"title":title,"link":full,"img":img,"source":"bbc","summary":""})
|
| 177 |
+
return arts[:15]
|
| 178 |
+
except: return []
|
| 179 |
+
|
| 180 |
+
def scrape_bbc_article(url):
|
| 181 |
+
"""Scrape BBC Vietnamese article content."""
|
| 182 |
+
try:
|
| 183 |
+
soup = _get(url)
|
| 184 |
+
h1 = soup.find("h1")
|
| 185 |
+
title = h1.get_text(strip=True) if h1 else ""
|
| 186 |
+
og = soup.find("meta",property="og:image")
|
| 187 |
+
og_img = og.get("content","") if og else ""
|
| 188 |
+
# BBC article body
|
| 189 |
+
body = []
|
| 190 |
+
for block in soup.select("main [data-component='text-block'], main p"):
|
| 191 |
+
t = block.get_text(strip=True)
|
| 192 |
+
if t and len(t) > 20: body.append({"type":"p","text":t})
|
| 193 |
+
for img in soup.select("main img[src]"):
|
| 194 |
+
src = img.get("src","")
|
| 195 |
+
if src and "ichef" in src:
|
| 196 |
+
body.append({"type":"img","src":src,"alt":img.get("alt","")})
|
| 197 |
+
return {"title":title,"summary":"","og_image":og_img,"body":body,"source":"bbc","url":url}
|
| 198 |
+
except: return None
|
| 199 |
+
|
| 200 |
def extract_video_url(article_url):
|
| 201 |
try:
|
| 202 |
r = requests.get(article_url, headers={**HEADERS,"Referer":"https://www.24h.com.vn/"}, timeout=10)
|
|
|
|
| 228 |
return {"src":source.get("src","") if source else "","poster":video.get("poster","")}
|
| 229 |
except: return None
|
| 230 |
|
| 231 |
+
# ═══ API ═══
|
| 232 |
+
VNE_CATS = {
|
| 233 |
+
"thoi-su":("https://vnexpress.net/thoi-su","Thời Sự"),
|
| 234 |
+
"the-gioi":("https://vnexpress.net/the-gioi","Thế Giới"),
|
| 235 |
+
"kinh-doanh":("https://vnexpress.net/kinh-doanh","Kinh Doanh"),
|
| 236 |
+
"cong-nghe":("https://vnexpress.net/so-hoa","Công Nghệ"),
|
| 237 |
+
"the-thao":("https://vnexpress.net/the-thao","Thể Thao"),
|
| 238 |
+
"giai-tri":("https://vnexpress.net/giai-tri","Giải Trí"),
|
| 239 |
+
"suc-khoe":("https://vnexpress.net/suc-khoe","Sức Khỏe"),
|
| 240 |
}
|
| 241 |
+
BDP_CATS = {
|
| 242 |
+
"ngoai-hang-anh":("https://bongdaplus.vn/ngoai-hang-anh","Ngoại Hạng Anh"),
|
| 243 |
+
"la-liga":("https://bongdaplus.vn/la-liga","La Liga"),
|
| 244 |
+
"champions-league":("https://bongdaplus.vn/champions-league-cup-c1","Champions League"),
|
| 245 |
+
"bong-da-vn":("https://bongdaplus.vn/bong-da-viet-nam","Bóng Đá VN"),
|
| 246 |
}
|
| 247 |
|
| 248 |
@app.get("/api/homepage")
|
| 249 |
def api_homepage():
|
| 250 |
+
def _f():
|
| 251 |
+
articles=[]
|
| 252 |
+
with ThreadPoolExecutor(6) as ex:
|
| 253 |
+
futs={ex.submit(scrape_vne,VNE_CATS[k][0]):VNE_CATS[k][1] for k in ["thoi-su","the-gioi","kinh-doanh","the-thao","giai-tri"]}
|
| 254 |
+
futs[ex.submit(scrape_bbc_vietnamese)]="BBC"
|
| 255 |
for f in as_completed(futs):
|
| 256 |
try:
|
| 257 |
for a in f.result(): a["group"]=futs[f]; articles.append(a)
|
| 258 |
except: pass
|
|
|
|
| 259 |
try:
|
| 260 |
+
for a in scrape_bdp(f"{BASE_BDP}/tin-moi")[:6]: a["group"]="Bóng Đá"; articles.append(a)
|
|
|
|
| 261 |
except: pass
|
| 262 |
return articles
|
| 263 |
+
return JSONResponse(_cached("homepage",_f))
|
| 264 |
|
| 265 |
@app.get("/api/category/{cat_id}")
|
| 266 |
+
def api_category(cat_id:str):
|
| 267 |
+
def _f():
|
| 268 |
+
if cat_id=="bbc":return scrape_bbc_vietnamese()
|
| 269 |
+
if cat_id in VNE_CATS:
|
| 270 |
+
arts=scrape_vne(VNE_CATS[cat_id][0])
|
| 271 |
+
for a in arts:a["group"]=VNE_CATS[cat_id][1]
|
| 272 |
return arts
|
| 273 |
+
if cat_id in BDP_CATS:
|
| 274 |
+
arts=scrape_bdp(BDP_CATS[cat_id][0])
|
| 275 |
+
for a in arts:a["group"]=BDP_CATS[cat_id][1]
|
|
|
|
| 276 |
return arts
|
| 277 |
+
return[]
|
| 278 |
+
return JSONResponse(_cached(f"cat_{cat_id}",_f))
|
| 279 |
|
| 280 |
@app.get("/api/categories")
|
| 281 |
def api_categories():
|
| 282 |
+
cats=[{"id":"bbc","name":"BBC Tiếng Việt","source":"bbc"}]
|
| 283 |
+
for k,(u,n) in VNE_CATS.items():cats.append({"id":k,"name":n,"source":"vne"})
|
| 284 |
+
for k,(u,n) in BDP_CATS.items():cats.append({"id":k,"name":n,"source":"bdp"})
|
|
|
|
|
|
|
| 285 |
return JSONResponse(cats)
|
| 286 |
|
| 287 |
@app.get("/api/highlights")
|
| 288 |
def api_highlights():
|
| 289 |
+
return JSONResponse(_cached("highlights",scrape_24h_highlights))
|
| 290 |
|
| 291 |
@app.get("/api/shorts")
|
| 292 |
def api_shorts():
|
| 293 |
+
return JSONResponse(_cached("shorts",scrape_24h_shorts))
|
| 294 |
|
| 295 |
@app.get("/api/bdp_videos")
|
| 296 |
def api_bdp_videos():
|
| 297 |
+
return JSONResponse(_cached("bdp_videos",scrape_bdp_videos))
|
| 298 |
|
| 299 |
@app.get("/api/video_url")
|
| 300 |
+
def api_video_url(url:str=Query(...)):
|
| 301 |
+
if "24h.com.vn" in url: v=extract_video_url(url)
|
| 302 |
+
elif "bongdaplus.vn" in url: v=extract_bdp_video(url)
|
| 303 |
+
else: v=None
|
|
|
|
|
|
|
|
|
|
| 304 |
return JSONResponse(v if v else {"error":"not found"})
|
| 305 |
|
| 306 |
+
@app.get("/api/article")
|
| 307 |
+
def api_article(url:str=Query(...)):
|
| 308 |
+
"""Read article content inline."""
|
| 309 |
+
if "vnexpress.net" in url: data=scrape_vne_article(url)
|
| 310 |
+
elif "bbc.com" in url: data=scrape_bbc_article(url)
|
| 311 |
+
else: data=None
|
| 312 |
+
return JSONResponse(data if data else {"error":"not supported"})
|
| 313 |
+
|
| 314 |
@app.get("/share/{path:path}")
|
| 315 |
+
async def share_page(path:str,img:str=Query(default=""),title:str=Query(default="VNEWS")):
|
| 316 |
+
og_image=img or "https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg"
|
| 317 |
+
html=f'<!DOCTYPE html><html><head><meta charset="utf-8"><title>{title}</title><meta property="og:title" content="{title}"><meta property="og:image" content="{og_image}"><meta property="og:type" content="article"><meta name="twitter:card" content="summary_large_image"><meta name="twitter:image" content="{og_image}"><meta http-equiv="refresh" content="0;url={SPACE_URL}"></head><body>Redirecting...</body></html>'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
return HTMLResponse(content=html)
|
| 319 |
|
|
|
|
| 320 |
@app.get("/")
|
| 321 |
async def index():
|
| 322 |
+
with open("/app/static/index.html","r",encoding="utf-8") as f:
|
| 323 |
return HTMLResponse(content=f.read())
|
| 324 |
|
| 325 |
+
app.mount("/static",StaticFiles(directory="/app/static"),name="static")
|