bep40 commited on
Commit
158506a
·
verified ·
1 Parent(s): 3f602dc

feat: inline article reader + BBC Vietnamese + fix shorts/highlights sliders

Browse files
Files changed (1) hide show
  1. main.py +137 -81
main.py CHANGED
@@ -1,8 +1,7 @@
1
  """VNEWS - FastAPI backend with news scrapers and API."""
2
- import hashlib, re, time, threading
3
- from urllib.parse import quote as urlquote
4
  from concurrent.futures import ThreadPoolExecutor, as_completed
5
- from fastapi import FastAPI, Request, Query
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
- data = fn()
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)[:120] if desc else ""),"source":"vne"})
57
  return arts
58
  except: return []
59
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  def scrape_bdp(url):
61
  try:
62
  soup = _get(url); arts = []; seen = set()
63
- for sel,feat in [("div.news.fst",True),("div.sld-itm.news",True),("li.news",False)]:
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[:25]
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 Routes ═══
164
- VNE_CATEGORIES = {
165
- "thoi-su": ("https://vnexpress.net/thoi-su", "Thời Sự"),
166
- "the-gioi": ("https://vnexpress.net/the-gioi", "Thế Giới"),
167
- "kinh-doanh": ("https://vnexpress.net/kinh-doanh", "Kinh Doanh"),
168
- "cong-nghe": ("https://vnexpress.net/so-hoa", "Công Nghệ"),
169
- "the-thao": ("https://vnexpress.net/the-thao", "Thể Thao"),
170
- "giai-tri": ("https://vnexpress.net/giai-tri", "Giải Trí"),
171
- "suc-khoe": ("https://vnexpress.net/suc-khoe", "Sức Khỏe"),
172
  }
173
- BDP_CATEGORIES = {
174
- "ngoai-hang-anh": ("https://bongdaplus.vn/ngoai-hang-anh", "Ngoại Hạng Anh"),
175
- "la-liga": ("https://bongdaplus.vn/la-liga", "La Liga"),
176
- "champions-league": ("https://bongdaplus.vn/champions-league-cup-c1", "Champions League"),
177
- "bong-da-vn": ("https://bongdaplus.vn/bong-da-viet-nam", "Bóng Đá VN"),
178
  }
179
 
180
  @app.get("/api/homepage")
181
  def api_homepage():
182
- def _fetch():
183
- articles = []
184
- sources = [("thoi-su",),("the-gioi",),("kinh-doanh",),("the-thao",),("giai-tri",)]
185
- with ThreadPoolExecutor(5) as ex:
186
- futs = {ex.submit(scrape_vne, VNE_CATEGORIES[s[0]][0]): VNE_CATEGORIES[s[0]][1] for s in sources}
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
- bdp = scrape_bdp(f"{BASE_BDP}/tin-moi")
194
- for a in bdp[:6]: a["group"]="Bóng Đá"; articles.append(a)
195
  except: pass
196
  return articles
197
- return JSONResponse(_cached("homepage", _fetch))
198
 
199
  @app.get("/api/category/{cat_id}")
200
- def api_category(cat_id: str):
201
- def _fetch():
202
- if cat_id in VNE_CATEGORIES:
203
- url, name = VNE_CATEGORIES[cat_id]
204
- arts = scrape_vne(url)
205
- for a in arts: a["group"] = name
206
  return arts
207
- elif cat_id in BDP_CATEGORIES:
208
- url, name = BDP_CATEGORIES[cat_id]
209
- arts = scrape_bdp(url)
210
- for a in arts: a["group"] = name
211
  return arts
212
- return []
213
- return JSONResponse(_cached(f"cat_{cat_id}", _fetch))
214
 
215
  @app.get("/api/categories")
216
  def api_categories():
217
- cats = []
218
- for k,(u,n) in VNE_CATEGORIES.items():
219
- cats.append({"id":k,"name":n,"source":"vne"})
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", scrape_24h_highlights))
227
 
228
  @app.get("/api/shorts")
229
  def api_shorts():
230
- return JSONResponse(_cached("shorts", scrape_24h_shorts))
231
 
232
  @app.get("/api/bdp_videos")
233
  def api_bdp_videos():
234
- return JSONResponse(_cached("bdp_videos", scrape_bdp_videos))
235
 
236
  @app.get("/api/video_url")
237
- def api_video_url(url: str = Query(...)):
238
- if "24h.com.vn" in url:
239
- v = extract_video_url(url)
240
- elif "bongdaplus.vn" in url:
241
- v = extract_bdp_video(url)
242
- else:
243
- v = None
244
  return JSONResponse(v if v else {"error":"not found"})
245
 
246
- # ═══ Share page with og:image ═══
 
 
 
 
 
 
 
247
  @app.get("/share/{path:path}")
248
- async def share_page(path: str, img: str = Query(default=""), title: str = Query(default="VNEWS")):
249
- og_image = img or "https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg"
250
- html = f"""<!DOCTYPE html><html><head>
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", "r", encoding="utf-8") as f:
267
  return HTMLResponse(content=f.read())
268
 
269
- app.mount("/static", StaticFiles(directory="/app/static"), name="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")