Spaces:
Running
Running
Upload app_v2_entry.py
Browse files- app_v2_entry.py +182 -12
app_v2_entry.py
CHANGED
|
@@ -10,10 +10,10 @@ except Exception as e:
|
|
| 10 |
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, Response
|
| 11 |
from fastapi.staticfiles import StaticFiles
|
| 12 |
from starlette.routing import Mount
|
| 13 |
-
from fastapi import Query, Request
|
| 14 |
import requests as req
|
| 15 |
from bs4 import BeautifulSoup
|
| 16 |
-
import re, html as html_lib, json, threading, time
|
| 17 |
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 18 |
from urllib.parse import quote
|
| 19 |
|
|
@@ -236,7 +236,7 @@ def _s_vietnamnet(topic,limit=6):
|
|
| 236 |
try:
|
| 237 |
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')
|
| 238 |
for a in soup.select('h3 a[href], .vnn-title a')[:limit*2]:
|
| 239 |
-
t=_clean(a.get_text(strip=True));href=a.get('href','')
|
| 240 |
if t and len(t)>15 and _has_kw(topic,t):
|
| 241 |
if not href.startswith('http'):href='https://vietnamnet.vn'+href
|
| 242 |
items.append({'title':t,'url':href,'via':'VietNamNet'})
|
|
@@ -415,7 +415,6 @@ _xlb_cache = {}
|
|
| 415 |
_xlb_lock = threading.Lock()
|
| 416 |
|
| 417 |
def _xlb_scrape(path):
|
| 418 |
-
"""Scrape xemlaibongda.top and extract video articles. Multi-strategy parsing."""
|
| 419 |
url = f"https://xemlaibongda.top/{path}"
|
| 420 |
r = req.get(url, headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}, timeout=15, allow_redirects=True)
|
| 421 |
if r.status_code != 200:
|
|
@@ -430,7 +429,6 @@ def _xlb_scrape(path):
|
|
| 430 |
seen.add(href)
|
| 431 |
if not href.startswith('http'):
|
| 432 |
href = 'https://xemlaibongda.top' + href
|
| 433 |
-
# Image: search <img> in anchor, then parent up to 3 levels
|
| 434 |
img = a.select_one('img')
|
| 435 |
p = a.parent
|
| 436 |
for _ in range(4):
|
|
@@ -446,7 +444,6 @@ def _xlb_scrape(path):
|
|
| 446 |
img_src = 'https:' + img_src
|
| 447 |
elif img_src.startswith('/'):
|
| 448 |
img_src = 'https://xemlaibongda.top' + img_src
|
| 449 |
-
# Title: try heading/title tags inside anchor first
|
| 450 |
title = ''
|
| 451 |
for sel in ['.title', 'h3', 'h2', '.name', '.post-title', '.entry-title', '.video-title']:
|
| 452 |
t = a.select_one(sel)
|
|
@@ -459,14 +456,12 @@ def _xlb_scrape(path):
|
|
| 459 |
img_alt = a.select_one('img')
|
| 460 |
if img_alt:
|
| 461 |
title = _clean(img_alt.get('alt',''))
|
| 462 |
-
# Fallback: use parent's short text
|
| 463 |
if not title:
|
| 464 |
parent = a.parent
|
| 465 |
if parent:
|
| 466 |
pt = _clean(parent.get_text(' ',strip=True))
|
| 467 |
if 5 < len(pt) < 120:
|
| 468 |
title = pt
|
| 469 |
-
# Skip if still no meaningful title
|
| 470 |
if not title or len(title) < 3:
|
| 471 |
continue
|
| 472 |
vids.append({"link": href, "img": img_src, "title": title})
|
|
@@ -476,12 +471,11 @@ def _xlb_scrape(path):
|
|
| 476 |
|
| 477 |
@app.get('/api/proxy/xlb')
|
| 478 |
def proxy_xlb(path: str = Query(default="")):
|
| 479 |
-
"""Proxy for xemlaibongda.top to avoid CORS in browser."""
|
| 480 |
now = time.time()
|
| 481 |
cache_key = f"xlb:{path}"
|
| 482 |
with _xlb_lock:
|
| 483 |
cached = _xlb_cache.get(cache_key)
|
| 484 |
-
if cached and now - cached['t'] < 120:
|
| 485 |
return JSONResponse(cached['d'])
|
| 486 |
try:
|
| 487 |
vids = _xlb_scrape(path)
|
|
@@ -514,8 +508,14 @@ def _wl(eid:int):return JSONResponse(scrape_lineups(eid))
|
|
| 514 |
def _wm(eid:int):return JSONResponse(scrape_match_detail(eid))
|
| 515 |
|
| 516 |
DATA_DIR='/data' if os.path.isdir('/data') else os.path.join(os.path.dirname(os.path.abspath(__file__)),'data')
|
| 517 |
-
os.makedirs(DATA_DIR,exist_ok=True)
|
| 518 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 519 |
def _lj(p):
|
| 520 |
try:
|
| 521 |
if os.path.exists(p):return json.load(open(p,'r',encoding='utf-8'))
|
|
@@ -548,6 +548,176 @@ async def _pc(request:Request):
|
|
| 548 |
with _il:idb=_lj(IF);idb.setdefault(v,{'views':0,'likes':0,'comments':0});idb[v]['comments']=len(cms);_sj(IF,idb)
|
| 549 |
return JSONResponse({'comments':cms})
|
| 550 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 551 |
def _bg():
|
| 552 |
time.sleep(15)
|
| 553 |
while True:
|
|
|
|
| 10 |
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, Response
|
| 11 |
from fastapi.staticfiles import StaticFiles
|
| 12 |
from starlette.routing import Mount
|
| 13 |
+
from fastapi import Query, Request, UploadFile, File, Form
|
| 14 |
import requests as req
|
| 15 |
from bs4 import BeautifulSoup
|
| 16 |
+
import re, html as html_lib, json, threading, time, uuid
|
| 17 |
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 18 |
from urllib.parse import quote
|
| 19 |
|
|
|
|
| 236 |
try:
|
| 237 |
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')
|
| 238 |
for a in soup.select('h3 a[href], .vnn-title a')[:limit*2]:
|
| 239 |
+
t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
|
| 240 |
if t and len(t)>15 and _has_kw(topic,t):
|
| 241 |
if not href.startswith('http'):href='https://vietnamnet.vn'+href
|
| 242 |
items.append({'title':t,'url':href,'via':'VietNamNet'})
|
|
|
|
| 415 |
_xlb_lock = threading.Lock()
|
| 416 |
|
| 417 |
def _xlb_scrape(path):
|
|
|
|
| 418 |
url = f"https://xemlaibongda.top/{path}"
|
| 419 |
r = req.get(url, headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}, timeout=15, allow_redirects=True)
|
| 420 |
if r.status_code != 200:
|
|
|
|
| 429 |
seen.add(href)
|
| 430 |
if not href.startswith('http'):
|
| 431 |
href = 'https://xemlaibongda.top' + href
|
|
|
|
| 432 |
img = a.select_one('img')
|
| 433 |
p = a.parent
|
| 434 |
for _ in range(4):
|
|
|
|
| 444 |
img_src = 'https:' + img_src
|
| 445 |
elif img_src.startswith('/'):
|
| 446 |
img_src = 'https://xemlaibongda.top' + img_src
|
|
|
|
| 447 |
title = ''
|
| 448 |
for sel in ['.title', 'h3', 'h2', '.name', '.post-title', '.entry-title', '.video-title']:
|
| 449 |
t = a.select_one(sel)
|
|
|
|
| 456 |
img_alt = a.select_one('img')
|
| 457 |
if img_alt:
|
| 458 |
title = _clean(img_alt.get('alt',''))
|
|
|
|
| 459 |
if not title:
|
| 460 |
parent = a.parent
|
| 461 |
if parent:
|
| 462 |
pt = _clean(parent.get_text(' ',strip=True))
|
| 463 |
if 5 < len(pt) < 120:
|
| 464 |
title = pt
|
|
|
|
| 465 |
if not title or len(title) < 3:
|
| 466 |
continue
|
| 467 |
vids.append({"link": href, "img": img_src, "title": title})
|
|
|
|
| 471 |
|
| 472 |
@app.get('/api/proxy/xlb')
|
| 473 |
def proxy_xlb(path: str = Query(default="")):
|
|
|
|
| 474 |
now = time.time()
|
| 475 |
cache_key = f"xlb:{path}"
|
| 476 |
with _xlb_lock:
|
| 477 |
cached = _xlb_cache.get(cache_key)
|
| 478 |
+
if cached and now - cached['t'] < 120:
|
| 479 |
return JSONResponse(cached['d'])
|
| 480 |
try:
|
| 481 |
vids = _xlb_scrape(path)
|
|
|
|
| 508 |
def _wm(eid:int):return JSONResponse(scrape_match_detail(eid))
|
| 509 |
|
| 510 |
DATA_DIR='/data' if os.path.isdir('/data') else os.path.join(os.path.dirname(os.path.abspath(__file__)),'data')
|
| 511 |
+
os.makedirs(DATA_DIR,exist_ok=True)
|
| 512 |
+
IF=os.path.join(DATA_DIR,'interactions_v2.json')
|
| 513 |
+
CF=os.path.join(DATA_DIR,'comments_v2.json')
|
| 514 |
+
WALL_FILE=os.path.join(DATA_DIR,'wall_posts.json')
|
| 515 |
+
WALL_VIDEO_DIR=os.path.join(DATA_DIR,'wall_videos')
|
| 516 |
+
os.makedirs(WALL_VIDEO_DIR,exist_ok=True)
|
| 517 |
+
|
| 518 |
+
_il=threading.Lock();_cl=threading.Lock();_wl_lock=threading.Lock()
|
| 519 |
def _lj(p):
|
| 520 |
try:
|
| 521 |
if os.path.exists(p):return json.load(open(p,'r',encoding='utf-8'))
|
|
|
|
| 548 |
with _il:idb=_lj(IF);idb.setdefault(v,{'views':0,'likes':0,'comments':0});idb[v]['comments']=len(cms);_sj(IF,idb)
|
| 549 |
return JSONResponse({'comments':cms})
|
| 550 |
|
| 551 |
+
# ===== WALL / SHORT AI ENDPOINTS =====
|
| 552 |
+
|
| 553 |
+
def _load_wall_posts():
|
| 554 |
+
"""Load wall posts from JSON file."""
|
| 555 |
+
with _wl_lock:
|
| 556 |
+
return _lj(WALL_FILE)
|
| 557 |
+
|
| 558 |
+
def _save_wall_posts(posts):
|
| 559 |
+
"""Save wall posts to JSON file."""
|
| 560 |
+
with _wl_lock:
|
| 561 |
+
_sj(WALL_FILE, posts)
|
| 562 |
+
|
| 563 |
+
@app.get('/api/wall')
|
| 564 |
+
def api_wall():
|
| 565 |
+
"""Get all wall posts."""
|
| 566 |
+
posts = _load_wall_posts()
|
| 567 |
+
if not posts:
|
| 568 |
+
# Return empty list, not error
|
| 569 |
+
return JSONResponse({"posts": []})
|
| 570 |
+
return JSONResponse({"posts": posts})
|
| 571 |
+
|
| 572 |
+
@app.post('/api/wall')
|
| 573 |
+
async def api_wall_post(request: Request):
|
| 574 |
+
"""
|
| 575 |
+
Create a wall post. Supports:
|
| 576 |
+
- JSON body: {title, text, img, source}
|
| 577 |
+
- Multipart form: title, text, source + video file upload
|
| 578 |
+
"""
|
| 579 |
+
content_type = request.headers.get('content-type', '')
|
| 580 |
+
|
| 581 |
+
# Handle multipart upload (video file)
|
| 582 |
+
if 'multipart/form-data' in content_type:
|
| 583 |
+
try:
|
| 584 |
+
form = await request.form()
|
| 585 |
+
except Exception as e:
|
| 586 |
+
return JSONResponse({"error": f"Form parse error: {str(e)}"}, status_code=400)
|
| 587 |
+
|
| 588 |
+
title = form.get('title', 'Video mới') or 'Video mới'
|
| 589 |
+
text = form.get('text', '') or ''
|
| 590 |
+
source = form.get('source', 'vtv_recorder') or 'vtv_recorder'
|
| 591 |
+
video_file = form.get('video')
|
| 592 |
+
|
| 593 |
+
post_id = str(uuid.uuid4())[:12]
|
| 594 |
+
video_url = None
|
| 595 |
+
|
| 596 |
+
# Save video file if provided
|
| 597 |
+
if video_file and hasattr(video_file, 'filename') and video_file.filename:
|
| 598 |
+
# Determine extension
|
| 599 |
+
fname = video_file.filename.lower()
|
| 600 |
+
if fname.endswith('.mp4'):
|
| 601 |
+
ext = '.mp4'
|
| 602 |
+
elif fname.endswith('.webm'):
|
| 603 |
+
ext = '.webm'
|
| 604 |
+
else:
|
| 605 |
+
ext = '.webm'
|
| 606 |
+
|
| 607 |
+
video_filename = f"wall_{post_id}{ext}"
|
| 608 |
+
video_path = os.path.join(WALL_VIDEO_DIR, video_filename)
|
| 609 |
+
|
| 610 |
+
try:
|
| 611 |
+
# Read file content
|
| 612 |
+
content = await video_file.read()
|
| 613 |
+
if not content:
|
| 614 |
+
return JSONResponse({"error": "Empty video file"}, status_code=400)
|
| 615 |
+
|
| 616 |
+
# Save to disk
|
| 617 |
+
with open(video_path, 'wb') as f:
|
| 618 |
+
f.write(content)
|
| 619 |
+
|
| 620 |
+
file_size_mb = len(content) / 1024 / 1024
|
| 621 |
+
if file_size_mb > 50:
|
| 622 |
+
os.remove(video_path)
|
| 623 |
+
return JSONResponse({"error": f"Video quá lớn ({file_size_mb:.1f}MB). Tối đa 50MB."}, status_code=400)
|
| 624 |
+
|
| 625 |
+
# URL to access the video
|
| 626 |
+
video_url = f"/api/wall/video/{video_filename}"
|
| 627 |
+
except Exception as e:
|
| 628 |
+
return JSONResponse({"error": f"Lỗi lưu video: {str(e)}"}, status_code=500)
|
| 629 |
+
|
| 630 |
+
# Create post
|
| 631 |
+
post = {
|
| 632 |
+
"id": post_id,
|
| 633 |
+
"title": title[:200],
|
| 634 |
+
"text": text[:2000],
|
| 635 |
+
"source": source,
|
| 636 |
+
"video": video_url,
|
| 637 |
+
"img": None,
|
| 638 |
+
"images": [],
|
| 639 |
+
"created": int(time.time()),
|
| 640 |
+
"created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()),
|
| 641 |
+
}
|
| 642 |
+
|
| 643 |
+
# Save to wall
|
| 644 |
+
posts = _load_wall_posts()
|
| 645 |
+
if not isinstance(posts, list):
|
| 646 |
+
posts = []
|
| 647 |
+
posts.insert(0, post)
|
| 648 |
+
# Keep max 200 posts
|
| 649 |
+
posts = posts[:200]
|
| 650 |
+
_save_wall_posts(posts)
|
| 651 |
+
|
| 652 |
+
return JSONResponse({"post": post, "ok": True})
|
| 653 |
+
|
| 654 |
+
# Handle JSON body (text-only post)
|
| 655 |
+
try:
|
| 656 |
+
body = await request.json()
|
| 657 |
+
except:
|
| 658 |
+
body = {}
|
| 659 |
+
|
| 660 |
+
title = body.get('title', 'Bài mới') or 'Bài mới'
|
| 661 |
+
text = body.get('text', '') or ''
|
| 662 |
+
img = body.get('img', None)
|
| 663 |
+
source = body.get('source', 'user') or 'user'
|
| 664 |
+
|
| 665 |
+
post_id = str(uuid.uuid4())[:12]
|
| 666 |
+
post = {
|
| 667 |
+
"id": post_id,
|
| 668 |
+
"title": title[:200],
|
| 669 |
+
"text": text[:2000],
|
| 670 |
+
"source": source,
|
| 671 |
+
"video": None,
|
| 672 |
+
"img": img,
|
| 673 |
+
"images": [],
|
| 674 |
+
"created": int(time.time()),
|
| 675 |
+
"created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()),
|
| 676 |
+
}
|
| 677 |
+
|
| 678 |
+
posts = _load_wall_posts()
|
| 679 |
+
if not isinstance(posts, list):
|
| 680 |
+
posts = []
|
| 681 |
+
posts.insert(0, post)
|
| 682 |
+
posts = posts[:200]
|
| 683 |
+
_save_wall_posts(posts)
|
| 684 |
+
|
| 685 |
+
return JSONResponse({"post": post, "ok": True})
|
| 686 |
+
|
| 687 |
+
@app.get('/api/wall/video/{filename}')
|
| 688 |
+
def api_wall_video(filename: str):
|
| 689 |
+
"""Serve a wall video file."""
|
| 690 |
+
# Security: prevent path traversal
|
| 691 |
+
if '..' in filename or '/' in filename:
|
| 692 |
+
return Response(status_code=403)
|
| 693 |
+
video_path = os.path.join(WALL_VIDEO_DIR, filename)
|
| 694 |
+
if not os.path.exists(video_path):
|
| 695 |
+
return Response(status_code=404)
|
| 696 |
+
ext = os.path.splitext(filename)[1].lower()
|
| 697 |
+
media_type = 'video/mp4' if ext == '.mp4' else 'video/webm'
|
| 698 |
+
return FileResponse(video_path, media_type=media_type)
|
| 699 |
+
|
| 700 |
+
@app.delete('/api/wall/{post_id}')
|
| 701 |
+
def api_wall_delete(post_id: str):
|
| 702 |
+
"""Delete a wall post and its video."""
|
| 703 |
+
posts = _load_wall_posts()
|
| 704 |
+
if not isinstance(posts, list):
|
| 705 |
+
return JSONResponse({"error": "No posts"}, status_code=404)
|
| 706 |
+
|
| 707 |
+
for i, p in enumerate(posts):
|
| 708 |
+
if p.get('id') == post_id:
|
| 709 |
+
# Delete video file if exists
|
| 710 |
+
if p.get('video'):
|
| 711 |
+
video_name = p['video'].split('/')[-1]
|
| 712 |
+
video_path = os.path.join(WALL_VIDEO_DIR, video_name)
|
| 713 |
+
if os.path.exists(video_path):
|
| 714 |
+
os.remove(video_path)
|
| 715 |
+
posts.pop(i)
|
| 716 |
+
_save_wall_posts(posts)
|
| 717 |
+
return JSONResponse({"ok": True})
|
| 718 |
+
|
| 719 |
+
return JSONResponse({"error": "Post not found"}, status_code=404)
|
| 720 |
+
|
| 721 |
def _bg():
|
| 722 |
time.sleep(15)
|
| 723 |
while True:
|