Spaces:
Running
Running
Fix rewrite URL, short JSON error, add missing endpoints, wc2026 timeout
Browse files- ai_ext.py +133 -1
- app_v2_entry.py +26 -1
ai_ext.py
CHANGED
|
@@ -197,4 +197,136 @@ def _fallback_summary_from_prompt(prompt: str, max_units: int = 6) -> str:
|
|
| 197 |
break
|
| 198 |
if chunks:
|
| 199 |
return "\n".join("• " + c for c in chunks)
|
| 200 |
-
return "• Không có đủ nội dung để tóm tắt."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
break
|
| 198 |
if chunks:
|
| 199 |
return "\n".join("• " + c for c in chunks)
|
| 200 |
+
return "• Không có đủ nội dung để tóm tắt."
|
| 201 |
+
|
| 202 |
+
# ===== URL scraping & article processing =====
|
| 203 |
+
HEADERS = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8"}
|
| 204 |
+
|
| 205 |
+
try:
|
| 206 |
+
_shorts_base = "/data" if os.path.isdir("/data") else os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
|
| 207 |
+
except Exception:
|
| 208 |
+
_shorts_base = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
|
| 209 |
+
SHORTS_DIR = os.path.join(_shorts_base, "ai_shorts")
|
| 210 |
+
os.makedirs(SHORTS_DIR, exist_ok=True)
|
| 211 |
+
|
| 212 |
+
import random as _random2
|
| 213 |
+
from datetime import datetime, timezone, timedelta
|
| 214 |
+
_VN_TZ = timezone(timedelta(hours=7))
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def _safe_name(filename: str) -> str:
|
| 218 |
+
"""Sanitize filename."""
|
| 219 |
+
return re.sub(r"[^a-zA-Z0-9_.-]", "_", filename)[:120]
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def pollinations_image_url(topic: str) -> str:
|
| 223 |
+
"""Generate a placeholder image URL via Pollinations."""
|
| 224 |
+
try:
|
| 225 |
+
return "https://image.pollinations.ai/prompt/" + quote("Vietnamese editorial illustration, " + topic, safe="") + "?width=1024&height=576&nologo=true"
|
| 226 |
+
except Exception:
|
| 227 |
+
return ""
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def _download_image(url: str, fallback_title: str, out_path: str) -> str:
|
| 231 |
+
"""Download an image from URL or create a placeholder."""
|
| 232 |
+
if url:
|
| 233 |
+
try:
|
| 234 |
+
r = requests.get(url, headers=HEADERS, timeout=15)
|
| 235 |
+
if r.status_code == 200 and len(r.content) > 1200:
|
| 236 |
+
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
| 237 |
+
with open(out_path, "wb") as f:
|
| 238 |
+
f.write(r.content)
|
| 239 |
+
return out_path
|
| 240 |
+
except Exception:
|
| 241 |
+
pass
|
| 242 |
+
# Fallback: create a placeholder image
|
| 243 |
+
try:
|
| 244 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 245 |
+
img = Image.new("RGB", (1080, 760), (24, 24, 24))
|
| 246 |
+
draw = ImageDraw.Draw(img)
|
| 247 |
+
try:
|
| 248 |
+
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 48)
|
| 249 |
+
except Exception:
|
| 250 |
+
font = None
|
| 251 |
+
text = (fallback_title or "VNEWS")[:40]
|
| 252 |
+
try:
|
| 253 |
+
bbox = draw.textbbox((0, 0), text, font=font)
|
| 254 |
+
tw = bbox[2] - bbox[0]
|
| 255 |
+
except Exception:
|
| 256 |
+
tw = len(text) * 24
|
| 257 |
+
draw.text(((1080 - tw) // 2, 330), text, fill=(255, 255, 255), font=font)
|
| 258 |
+
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
| 259 |
+
img.save(out_path, quality=90)
|
| 260 |
+
return out_path
|
| 261 |
+
except Exception:
|
| 262 |
+
return out_path
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def scrape_any_url(url: str) -> dict:
|
| 266 |
+
"""Scrape article content from any URL."""
|
| 267 |
+
if not url or not url.startswith("http"):
|
| 268 |
+
return {"title": "", "text": "", "summary": "", "image": "", "og_image": "", "via": ""}
|
| 269 |
+
try:
|
| 270 |
+
r = requests.get(url, headers=HEADERS, timeout=15, allow_redirects=True)
|
| 271 |
+
if r.status_code != 200 or not r.text:
|
| 272 |
+
return {"title": "", "text": "", "summary": "", "image": "", "og_image": "", "via": _domain(url)}
|
| 273 |
+
r.encoding = "utf-8"
|
| 274 |
+
soup = BeautifulSoup(r.text, "lxml")
|
| 275 |
+
for tag in soup.find_all(["script", "style", "nav", "footer", "aside", "form", "noscript", "iframe", ".ads", ".ad", ".banner-ads", ".fb-comments", ".fb-root", ".social-share", ".related-news", ".breadcrumb"]):
|
| 276 |
+
tag.decompose()
|
| 277 |
+
title = ""
|
| 278 |
+
ogt = soup.find("meta", property="og:title")
|
| 279 |
+
if ogt:
|
| 280 |
+
title = ogt.get("content", "")
|
| 281 |
+
h1 = soup.find("h1")
|
| 282 |
+
if not title and h1:
|
| 283 |
+
title = h1.get_text(strip=True)
|
| 284 |
+
if not title:
|
| 285 |
+
t = soup.find("title")
|
| 286 |
+
if t:
|
| 287 |
+
title = t.get_text(strip=True)
|
| 288 |
+
og_image = ""
|
| 289 |
+
ogi = soup.find("meta", property="og:image")
|
| 290 |
+
if ogi:
|
| 291 |
+
og_image = ogi.get("content", "")
|
| 292 |
+
if og_image.startswith("//"):
|
| 293 |
+
og_image = "https:" + og_image
|
| 294 |
+
summary = ""
|
| 295 |
+
ogd = soup.find("meta", property="og:description") or soup.find("meta", attrs={"name": "description"})
|
| 296 |
+
if ogd:
|
| 297 |
+
summary = ogd.get("content", "")[:500]
|
| 298 |
+
body_text = []
|
| 299 |
+
for sel in ["article", ".singular-content", ".detail-content", ".fck_detail", ".content-detail", ".knc-content", "main", ".cms-body", ".article__body", ".post-content", ".entry-content"]:
|
| 300 |
+
el = soup.select_one(sel)
|
| 301 |
+
if el and len(el.find_all("p")) >= 2:
|
| 302 |
+
for p in el.find_all("p"):
|
| 303 |
+
t = _clean_text(p.get_text(strip=True))
|
| 304 |
+
if t and len(t) > 30:
|
| 305 |
+
body_text.append(t)
|
| 306 |
+
break
|
| 307 |
+
if not body_text and soup.body:
|
| 308 |
+
for p in soup.body.find_all("p"):
|
| 309 |
+
t = _clean_text(p.get_text(strip=True))
|
| 310 |
+
if t and len(t) > 30:
|
| 311 |
+
body_text.append(t)
|
| 312 |
+
text = "\n".join(body_text)
|
| 313 |
+
return {"title": _clean_text(title), "text": text, "summary": _clean_text(summary), "image": og_image, "og_image": og_image, "via": _domain(url), "url": url}
|
| 314 |
+
except Exception as e:
|
| 315 |
+
return {"title": "", "text": "", "summary": "", "image": "", "og_image": "", "via": _domain(url)}
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
def make_post(title: str, text: str, img: str, url: str, kind: str = "auto", sources: list = None) -> dict:
|
| 319 |
+
"""Create a wall post dict."""
|
| 320 |
+
import random as _r2
|
| 321 |
+
now = int(time.time() * 1000)
|
| 322 |
+
return {
|
| 323 |
+
"id": str(now) + str(_r2.randint(100, 999)),
|
| 324 |
+
"title": (title or "Bài viết")[:200],
|
| 325 |
+
"text": (text or "")[:5000],
|
| 326 |
+
"img": img or "",
|
| 327 |
+
"url": url or "",
|
| 328 |
+
"kind": kind or "auto",
|
| 329 |
+
"sources": sources or [],
|
| 330 |
+
"created": now,
|
| 331 |
+
"created_str": datetime.now(_VN_TZ).strftime("%H:%M %d/%m/%Y"),
|
| 332 |
+
}
|
app_v2_entry.py
CHANGED
|
@@ -226,7 +226,7 @@ _STOP=set('và của các những một được trong với cho tại sau trư
|
|
| 226 |
def _has_kw(topic,title):
|
| 227 |
tl=topic.lower();tt=(title or'').lower()
|
| 228 |
if tl in tt:return True
|
| 229 |
-
words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',tl) if len(w)>2 and w not in _STOP]
|
| 230 |
if not words:return True
|
| 231 |
return any(w in tt for w in words)
|
| 232 |
|
|
@@ -859,6 +859,31 @@ def _save_wall_posts(posts):
|
|
| 859 |
with _wl_lock:
|
| 860 |
_sj(WALL_FILE, posts)
|
| 861 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 862 |
@app.get('/api/wall')
|
| 863 |
def api_wall():
|
| 864 |
posts = _load_wall_posts()
|
|
|
|
| 226 |
def _has_kw(topic,title):
|
| 227 |
tl=topic.lower();tt=(title or'').lower()
|
| 228 |
if tl in tt:return True
|
| 229 |
+
words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',tl) if len(w)>2 and w.lower() not in _STOP]
|
| 230 |
if not words:return True
|
| 231 |
return any(w in tt for w in words)
|
| 232 |
|
|
|
|
| 859 |
with _wl_lock:
|
| 860 |
_sj(WALL_FILE, posts)
|
| 861 |
|
| 862 |
+
@app.get("/api/ai/wall")
|
| 863 |
+
def api_ai_wall():
|
| 864 |
+
return JSONResponse({"posts": _load_wall_posts()})
|
| 865 |
+
|
| 866 |
+
@app.post("/api/ai/short/{post_id}")
|
| 867 |
+
def api_ai_short(post_id: str):
|
| 868 |
+
"""Generate a short video for a wall post.
|
| 869 |
+
Returns clear JSON error when video not available yet.
|
| 870 |
+
"""
|
| 871 |
+
post_id_s = str(post_id)
|
| 872 |
+
posts = _load_wall_posts()
|
| 873 |
+
if not isinstance(posts, list):
|
| 874 |
+
posts = []
|
| 875 |
+
post = None
|
| 876 |
+
for p in posts:
|
| 877 |
+
pid = str(p.get("id", ""))
|
| 878 |
+
if pid == post_id_s or pid.startswith(post_id_s):
|
| 879 |
+
post = p
|
| 880 |
+
break
|
| 881 |
+
if not post:
|
| 882 |
+
return JSONResponse({"error": "Không tìm thấy bài viết", "post_id": post_id}, status_code=404)
|
| 883 |
+
if post.get("video"):
|
| 884 |
+
return JSONResponse({"video": post["video"], "post": post})
|
| 885 |
+
return JSONResponse({"error": "Chưa có video cho bài này. Vui lòng upload video trước."}, status_code=409)
|
| 886 |
+
|
| 887 |
@app.get('/api/wall')
|
| 888 |
def api_wall():
|
| 889 |
posts = _load_wall_posts()
|