bep40 commited on
Commit
d9e41fb
Β·
verified Β·
1 Parent(s): 4cfa094

fix: database persistent + hash share URL + /s/slug/hash SEO route

Browse files
Files changed (1) hide show
  1. main.py +128 -45
main.py CHANGED
@@ -1,8 +1,8 @@
1
- """VNEWS - FastAPI backend."""
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
  from urllib.parse import unquote, quote
8
  import requests
@@ -13,10 +13,122 @@ HEADERS = {"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/5
13
  BASE_BDP = "https://bongdaplus.vn"
14
  BASE_24H = "https://www.24h.com.vn"
15
  SPACE_URL = "https://bep40-vnews.hf.space"
16
- _cache = {}
17
- _cache_ttl = 300
18
 
19
- def _cached(key, fn):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  now=time.time()
21
  if key in _cache and now-_cache[key]["t"]<_cache_ttl:return _cache[key]["d"]
22
  try:data=fn()
@@ -36,7 +148,7 @@ def scrape_vne(cat_url):
36
  t=a.get("title","") or a.get_text(strip=True);lk=a.get("href","")
37
  if not t or not lk:continue
38
  im=it.find("img");img=(im.get("data-src") or im.get("src","")) if im else ""
39
- if img and'blank'in img:
40
  src=it.find("source")
41
  if src:img=src.get("srcset","").split(",")[0].strip().split(" ")[0]
42
  desc=it.select_one("p.description")
@@ -107,10 +219,7 @@ def scrape_24h_highlights():
107
  arts.append({"title":title,"link":href,"img":img_src,"source":"24h"})
108
  def _og(art):
109
  if art["img"]:return
110
- try:
111
- r=requests.get(art["link"],headers=HEADERS,timeout=8);r.encoding="utf-8"
112
- og=BeautifulSoup(r.text,"lxml").find("meta",property="og:image")
113
- if og:art["img"]=og.get("content","")
114
  except:pass
115
  need=[a for a in arts if not a["img"]][:10]
116
  if need:
@@ -128,8 +237,7 @@ def scrape_24h_shorts():
128
  href=a.get("href","")
129
  if not href.startswith("http"):href=BASE_24H+href
130
  if href in seen:continue
131
- img_tag=art.find("img")
132
- title=(img_tag.get("alt","") if img_tag else "") or a.get("title","") or a.get_text(strip=True)
133
  if not title or len(title)<10:continue
134
  img_src=""
135
  if img_tag:img_src=img_tag.get("data-original") or img_tag.get("data-src") or img_tag.get("src","")
@@ -162,8 +270,7 @@ def scrape_bbc_vietnamese():
162
  if not href.startswith("http"):href="https://www.bbc.com"+href
163
  if href in seen:continue
164
  title=a.get_text(strip=True)
165
- if not title or len(title)<15:continue
166
- if any(x in title.lower() for x in["Δ‘Δƒng nhαΊ­p","trang chα»§","bbc news"]):continue
167
  img="";container=a.parent
168
  for _ in range(3):
169
  if container:
@@ -184,9 +291,6 @@ def scrape_bbc_article(url):
184
  for p in soup.select("[data-component='text-block'] p, article p, main p"):
185
  t=p.get_text(strip=True)
186
  if t and len(t)>20:body.append({"type":"p","text":t})
187
- for img in soup.select("main img, article img"):
188
- src=img.get("src","")
189
- if src and("ichef" in src or"bbci" in src):body.append({"type":"img","src":src,"alt":img.get("alt","")})
190
  return{"title":h1.get_text(strip=True) if h1 else "","summary":"","og_image":og_img,"body":body,"source":"bbc","url":url}
191
  except:return None
192
 
@@ -199,8 +303,7 @@ def extract_video_url(article_url):
199
  primary=full or p720
200
  if not primary:return None
201
  soup=BeautifulSoup(r.text,"lxml");og=soup.find("meta",property="og:image");poster=og.get("content","") if og else ""
202
- base_url=primary[0];parts=[base_url]
203
- is_720p='_720p.m3u8' in base_url;name=base_url.replace('_720p.m3u8','').replace('.m3u8','')
204
  m=re.search(r'(\D)(\d{1,2})$',name)
205
  if m:
206
  cur=int(m.group(2));w=len(m.group(2));bn=name[:m.start(2)]
@@ -238,7 +341,9 @@ def extract_bdp_video(url):
238
  return{"src":source.get("src","") if source else "","poster":video.get("poster","")}
239
  except:return None
240
 
241
- # API
 
 
242
  VNE_CATS={"thoi-su":("https://vnexpress.net/thoi-su","Thời Sα»±"),"the-gioi":("https://vnexpress.net/the-gioi","ThαΊΏ Giα»›i"),"kinh-doanh":("https://vnexpress.net/kinh-doanh","Kinh Doanh"),"cong-nghe":("https://vnexpress.net/so-hoa","CΓ΄ng Nghệ"),"the-thao":("https://vnexpress.net/the-thao","Thể Thao"),"giai-tri":("https://vnexpress.net/giai-tri","GiαΊ£i TrΓ­"),"suc-khoe":("https://vnexpress.net/suc-khoe","Sα»©c Khỏe")}
243
  BDP_CATS={"ngoai-hang-anh":("https://bongdaplus.vn/ngoai-hang-anh","NgoαΊ‘i HαΊ‘ng Anh"),"la-liga":("https://bongdaplus.vn/la-liga","La Liga"),"champions-league":("https://bongdaplus.vn/champions-league-cup-c1","Champions League"),"bong-da-vn":("https://bongdaplus.vn/bong-da-viet-nam","BΓ³ng ĐÑ VN")}
244
 
@@ -281,14 +386,12 @@ def api_highlights():return JSONResponse(_cached("highlights",scrape_24h_highlig
281
  def api_shorts():return JSONResponse(_cached("shorts",scrape_24h_shorts))
282
  @app.get("/api/bdp_videos")
283
  def api_bdp_videos():return JSONResponse(_cached("bdp_videos",scrape_bdp_videos))
284
-
285
  @app.get("/api/video_url")
286
  def api_video_url(url:str=Query(...)):
287
  if"24h.com.vn" in url:v=extract_video_url(url)
288
  elif"bongdaplus.vn" in url:v=extract_bdp_video(url)
289
  else:v=None
290
  return JSONResponse(v if v else{"error":"not found"})
291
-
292
  @app.get("/api/article")
293
  def api_article(url:str=Query(...)):
294
  if"vnexpress.net" in url:data=scrape_vne_article(url)
@@ -296,28 +399,8 @@ def api_article(url:str=Query(...)):
296
  else:data=None
297
  return JSONResponse(data if data else{"error":"not supported"})
298
 
299
- # Share page: SEO with og:image + redirect to article
300
- @app.get("/s")
301
- async def share_redirect(url:str=Query(default=""),title:str=Query(default="VNEWS"),img:str=Query(default="")):
302
- """Share URL with proper og:image. Format: /s?url=ENCODED_ARTICLE_URL&title=...&img=..."""
303
- og_image=unquote(img) if img else "https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg"
304
- decoded_url=unquote(url)
305
- redirect_script=f'<script>localStorage.setItem("pending_article","{decoded_url}");location.href="{SPACE_URL}";</script>' if decoded_url else f'<script>location.href="{SPACE_URL}";</script>'
306
- return HTMLResponse(f'''<!DOCTYPE html><html><head>
307
- <meta charset="utf-8"><title>{unquote(title)}</title>
308
- <meta property="og:title" content="{unquote(title)}">
309
- <meta property="og:image" content="{og_image}">
310
- <meta property="og:url" content="{SPACE_URL}/s?url={url}&title={title}&img={img}">
311
- <meta property="og:type" content="article">
312
- <meta property="og:site_name" content="VNEWS">
313
- <meta property="og:description" content="{unquote(title)} - Đọc trΓͺn VNEWS">
314
- <meta name="twitter:card" content="summary_large_image">
315
- <meta name="twitter:title" content="{unquote(title)}">
316
- <meta name="twitter:image" content="{og_image}">
317
- </head><body>{redirect_script}</body></html>''')
318
-
319
  @app.get("/")
320
  async def index():
321
- with open("/app/static/index.html","r",encoding="utf-8") as f:return HTMLResponse(content=f.read())
322
 
323
  app.mount("/static",StaticFiles(directory="/app/static"),name="static")
 
1
+ """VNEWS - FastAPI backend with persistent DB + hash-based share URLs."""
2
+ import hashlib, re, time, json, os, threading, unicodedata
3
  from concurrent.futures import ThreadPoolExecutor, as_completed
4
+ from fastapi import FastAPI, Query, Body
5
+ from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
6
  from fastapi.staticfiles import StaticFiles
7
  from urllib.parse import unquote, quote
8
  import requests
 
13
  BASE_BDP = "https://bongdaplus.vn"
14
  BASE_24H = "https://www.24h.com.vn"
15
  SPACE_URL = "https://bep40-vnews.hf.space"
 
 
16
 
17
+ # ═══════════════════════════════════════════════════════════════
18
+ # DATABASE: persistent JSON on /data volume
19
+ # ═══════════════════════════════════════════════════════════════
20
+ _DATA_DIR = "/data" if os.path.isdir("/data") else "."
21
+ DB_PATH = os.path.join(_DATA_DIR, "db.json")
22
+ SHARE_PATH = os.path.join(_DATA_DIR, "shares.json")
23
+ _db_lock = threading.Lock()
24
+ _share_lock = threading.Lock()
25
+
26
+ def _load_json(path, default):
27
+ try:
28
+ with open(path,"r") as f: return json.load(f)
29
+ except: return default
30
+
31
+ def _save_json(path, data):
32
+ try:
33
+ with open(path,"w") as f: json.dump(data, f, ensure_ascii=False)
34
+ except: pass
35
+
36
+ # Stats DB
37
+ def db_inc(category, aid):
38
+ with _db_lock:
39
+ db = _load_json(DB_PATH, {"views":{},"shares":{},"comments":{}})
40
+ db[category][aid] = db[category].get(aid, 0) + 1
41
+ _save_json(DB_PATH, db)
42
+ return db[category][aid]
43
+
44
+ def db_add_comment(aid, name, text):
45
+ with _db_lock:
46
+ db = _load_json(DB_PATH, {"views":{},"shares":{},"comments":{}})
47
+ if aid not in db["comments"]: db["comments"][aid] = []
48
+ db["comments"][aid].append({"n":name,"t":text,"d":time.strftime("%d/%m %H:%M")})
49
+ _save_json(DB_PATH, db)
50
+ return db["comments"][aid]
51
+
52
+ def db_get(aid):
53
+ db = _load_json(DB_PATH, {"views":{},"shares":{},"comments":{}})
54
+ return {"views":db["views"].get(aid,0),"shares":db["shares"].get(aid,0),"comments":db["comments"].get(aid,[])}
55
+
56
+ # Share registry
57
+ def _slug(title):
58
+ s = unicodedata.normalize('NFKD', title.lower())
59
+ s = ''.join(c for c in s if not unicodedata.combining(c))
60
+ s = re.sub(r'[^a-z0-9\s-]','',s)
61
+ s = re.sub(r'[\s]+','-',s.strip())
62
+ return re.sub(r'-+','-',s).strip('-')[:60]
63
+
64
+ def _hash(url):
65
+ return hashlib.md5(url.encode()).hexdigest()[:12]
66
+
67
+ def register_share(url, title, img=""):
68
+ h = _hash(url)
69
+ sl = _slug(title)
70
+ with _share_lock:
71
+ db = _load_json(SHARE_PATH, {})
72
+ db[h] = {"url":url,"title":title,"img":img,"slug":sl}
73
+ _save_json(SHARE_PATH, db)
74
+ return sl, h
75
+
76
+ def get_share(h):
77
+ db = _load_json(SHARE_PATH, {})
78
+ return db.get(h)
79
+
80
+ # ═══════════════════════════════════════════════════════════════
81
+ # API: Database endpoints
82
+ # ═══════════════════════════════════════════════════════════════
83
+ @app.post("/api/view")
84
+ def api_view(aid:str=Body(...,embed=True)):
85
+ return JSONResponse({"views": db_inc("views", aid)})
86
+
87
+ @app.post("/api/share_inc")
88
+ def api_share_inc(aid:str=Body(...,embed=True)):
89
+ return JSONResponse({"shares": db_inc("shares", aid)})
90
+
91
+ @app.post("/api/comment")
92
+ def api_comment(aid:str=Body(...), name:str=Body(...), text:str=Body(...)):
93
+ return JSONResponse({"comments": db_add_comment(aid, name, text)})
94
+
95
+ @app.get("/api/stats/{aid}")
96
+ def api_stats(aid:str):
97
+ return JSONResponse(db_get(aid))
98
+
99
+ # Register share β†’ returns hash URL
100
+ @app.post("/api/register_share")
101
+ def api_register_share(url:str=Body(...), title:str=Body(...), img:str=Body("")):
102
+ sl, h = register_share(url, title, img)
103
+ db_inc("shares", _hash(url))
104
+ return JSONResponse({"slug":sl,"hash":h,"share_url":f"{SPACE_URL}/#/{sl}/{h}","social_url":f"{SPACE_URL}/s/{sl}/{h}"})
105
+
106
+ # SEO share page: og:image for crawlers, JS redirect for users
107
+ @app.get("/s/{slug}/{hash_id}")
108
+ async def share_page(slug:str, hash_id:str):
109
+ info = get_share(hash_id)
110
+ if not info:
111
+ return HTMLResponse(f'<meta http-equiv="refresh" content="0;url={SPACE_URL}">')
112
+ title = info["title"]
113
+ img = info.get("img") or "https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg"
114
+ hash_url = f"{SPACE_URL}/#/{slug}/{hash_id}"
115
+ return HTMLResponse(f'''<!DOCTYPE html><html><head><meta charset="utf-8">
116
+ <title>{title}</title>
117
+ <meta property="og:title" content="{title}">
118
+ <meta property="og:image" content="{img}">
119
+ <meta property="og:url" content="{hash_url}">
120
+ <meta property="og:type" content="article">
121
+ <meta property="og:site_name" content="VNEWS">
122
+ <meta name="twitter:card" content="summary_large_image">
123
+ <meta name="twitter:title" content="{title}">
124
+ <meta name="twitter:image" content="{img}">
125
+ </head><body><script>localStorage.setItem("pending_article",'{json.dumps(info["url"])}');location.href="{SPACE_URL}";</script></body></html>''')
126
+
127
+ # ═══════════════════════════════════════════════════════════════
128
+ # SCRAPERS (unchanged from working version)
129
+ # ═══════════════════════════════════════════════════════════════
130
+ _cache={}; _cache_ttl=300
131
+ def _cached(key,fn):
132
  now=time.time()
133
  if key in _cache and now-_cache[key]["t"]<_cache_ttl:return _cache[key]["d"]
134
  try:data=fn()
 
148
  t=a.get("title","") or a.get_text(strip=True);lk=a.get("href","")
149
  if not t or not lk:continue
150
  im=it.find("img");img=(im.get("data-src") or im.get("src","")) if im else ""
151
+ if img and 'blank' in img:
152
  src=it.find("source")
153
  if src:img=src.get("srcset","").split(",")[0].strip().split(" ")[0]
154
  desc=it.select_one("p.description")
 
219
  arts.append({"title":title,"link":href,"img":img_src,"source":"24h"})
220
  def _og(art):
221
  if art["img"]:return
222
+ try:r=requests.get(art["link"],headers=HEADERS,timeout=8);r.encoding="utf-8";og=BeautifulSoup(r.text,"lxml").find("meta",property="og:image");art["img"]=og.get("content","") if og else ""
 
 
 
223
  except:pass
224
  need=[a for a in arts if not a["img"]][:10]
225
  if need:
 
237
  href=a.get("href","")
238
  if not href.startswith("http"):href=BASE_24H+href
239
  if href in seen:continue
240
+ img_tag=art.find("img");title=(img_tag.get("alt","") if img_tag else "") or a.get("title","") or a.get_text(strip=True)
 
241
  if not title or len(title)<10:continue
242
  img_src=""
243
  if img_tag:img_src=img_tag.get("data-original") or img_tag.get("data-src") or img_tag.get("src","")
 
270
  if not href.startswith("http"):href="https://www.bbc.com"+href
271
  if href in seen:continue
272
  title=a.get_text(strip=True)
273
+ if not title or len(title)<15 or any(x in title.lower() for x in["Δ‘Δƒng nhαΊ­p","trang chα»§","bbc news"]):continue
 
274
  img="";container=a.parent
275
  for _ in range(3):
276
  if container:
 
291
  for p in soup.select("[data-component='text-block'] p, article p, main p"):
292
  t=p.get_text(strip=True)
293
  if t and len(t)>20:body.append({"type":"p","text":t})
 
 
 
294
  return{"title":h1.get_text(strip=True) if h1 else "","summary":"","og_image":og_img,"body":body,"source":"bbc","url":url}
295
  except:return None
296
 
 
303
  primary=full or p720
304
  if not primary:return None
305
  soup=BeautifulSoup(r.text,"lxml");og=soup.find("meta",property="og:image");poster=og.get("content","") if og else ""
306
+ base_url=primary[0];parts=[base_url];is_720p='_720p.m3u8' in base_url;name=base_url.replace('_720p.m3u8','').replace('.m3u8','')
 
307
  m=re.search(r'(\D)(\d{1,2})$',name)
308
  if m:
309
  cur=int(m.group(2));w=len(m.group(2));bn=name[:m.start(2)]
 
341
  return{"src":source.get("src","") if source else "","poster":video.get("poster","")}
342
  except:return None
343
 
344
+ # ════���══════════════════════════════════════════════════════════
345
+ # API Routes
346
+ # ═══════════════════════════════════════════════════════════════
347
  VNE_CATS={"thoi-su":("https://vnexpress.net/thoi-su","Thời Sα»±"),"the-gioi":("https://vnexpress.net/the-gioi","ThαΊΏ Giα»›i"),"kinh-doanh":("https://vnexpress.net/kinh-doanh","Kinh Doanh"),"cong-nghe":("https://vnexpress.net/so-hoa","CΓ΄ng Nghệ"),"the-thao":("https://vnexpress.net/the-thao","Thể Thao"),"giai-tri":("https://vnexpress.net/giai-tri","GiαΊ£i TrΓ­"),"suc-khoe":("https://vnexpress.net/suc-khoe","Sα»©c Khỏe")}
348
  BDP_CATS={"ngoai-hang-anh":("https://bongdaplus.vn/ngoai-hang-anh","NgoαΊ‘i HαΊ‘ng Anh"),"la-liga":("https://bongdaplus.vn/la-liga","La Liga"),"champions-league":("https://bongdaplus.vn/champions-league-cup-c1","Champions League"),"bong-da-vn":("https://bongdaplus.vn/bong-da-viet-nam","BΓ³ng ĐÑ VN")}
349
 
 
386
  def api_shorts():return JSONResponse(_cached("shorts",scrape_24h_shorts))
387
  @app.get("/api/bdp_videos")
388
  def api_bdp_videos():return JSONResponse(_cached("bdp_videos",scrape_bdp_videos))
 
389
  @app.get("/api/video_url")
390
  def api_video_url(url:str=Query(...)):
391
  if"24h.com.vn" in url:v=extract_video_url(url)
392
  elif"bongdaplus.vn" in url:v=extract_bdp_video(url)
393
  else:v=None
394
  return JSONResponse(v if v else{"error":"not found"})
 
395
  @app.get("/api/article")
396
  def api_article(url:str=Query(...)):
397
  if"vnexpress.net" in url:data=scrape_vne_article(url)
 
399
  else:data=None
400
  return JSONResponse(data if data else{"error":"not supported"})
401
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
402
  @app.get("/")
403
  async def index():
404
+ return FileResponse("/app/static/index.html")
405
 
406
  app.mount("/static",StaticFiles(directory="/app/static"),name="static")