Spaces:
Running
Running
Delete patch_runtime.py, restore_runner.py, rewrite_slide.py
Browse files- patch_runtime.py +0 -274
- restore_runner.py +0 -31
- rewrite_slide.py +0 -185
patch_runtime.py
DELETED
|
@@ -1,274 +0,0 @@
|
|
| 1 |
-
"""Runtime patch layer for VNEWS.
|
| 2 |
-
Keeps the current large app intact, but replaces fragile AI wall endpoints with
|
| 3 |
-
stable JSON endpoints and injects frontend safeJson wrappers.
|
| 4 |
-
"""
|
| 5 |
-
import hashlib
|
| 6 |
-
import time
|
| 7 |
-
import os
|
| 8 |
-
from urllib.parse import quote
|
| 9 |
-
|
| 10 |
-
import requests
|
| 11 |
-
from bs4 import BeautifulSoup
|
| 12 |
-
from fastapi import Request
|
| 13 |
-
from fastapi.responses import JSONResponse, HTMLResponse
|
| 14 |
-
|
| 15 |
-
import main as _main
|
| 16 |
-
|
| 17 |
-
app = _main.app
|
| 18 |
-
DEFAULT_IMG = "https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg"
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
def _remove_routes(paths):
|
| 22 |
-
app.router.routes = [r for r in app.router.routes if getattr(r, "path", None) not in set(paths)]
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
def _safe_text(v):
|
| 26 |
-
return (v or "").strip()
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
def _ensure_article(url: str):
|
| 30 |
-
data = None
|
| 31 |
-
try:
|
| 32 |
-
if hasattr(_main, "_article_by_url"):
|
| 33 |
-
data = _main._article_by_url(url)
|
| 34 |
-
except Exception:
|
| 35 |
-
data = None
|
| 36 |
-
if not data:
|
| 37 |
-
try:
|
| 38 |
-
data = _main._scrape_generic_article(url) if hasattr(_main, "_scrape_generic_article") else None
|
| 39 |
-
except Exception:
|
| 40 |
-
data = None
|
| 41 |
-
if not data:
|
| 42 |
-
data = {"title": "", "summary": "", "og_image": "", "body": [], "url": url, "source": "generic"}
|
| 43 |
-
title = _safe_text(data.get("title"))
|
| 44 |
-
summary = _safe_text(data.get("summary"))
|
| 45 |
-
img = _safe_text(data.get("og_image"))
|
| 46 |
-
body = data.get("body") or []
|
| 47 |
-
if not title or not summary or not img or not body:
|
| 48 |
-
try:
|
| 49 |
-
r = requests.get(url, headers=getattr(_main, "HEADERS", {}), timeout=15)
|
| 50 |
-
r.encoding = "utf-8"
|
| 51 |
-
soup = BeautifulSoup(r.text, "lxml")
|
| 52 |
-
if not title:
|
| 53 |
-
tag = soup.find("meta", property="og:title") or soup.find("title")
|
| 54 |
-
title = tag.get("content", "").strip() if tag and tag.name == "meta" else (tag.get_text(strip=True) if tag else "")
|
| 55 |
-
if not summary:
|
| 56 |
-
tag = soup.find("meta", property="og:description") or soup.find("meta", attrs={"name": "description"})
|
| 57 |
-
summary = tag.get("content", "").strip() if tag else ""
|
| 58 |
-
if not img:
|
| 59 |
-
tag = soup.find("meta", property="og:image") or soup.find("meta", attrs={"name": "twitter:image"})
|
| 60 |
-
img = tag.get("content", "").strip() if tag else ""
|
| 61 |
-
if not body:
|
| 62 |
-
ps = []
|
| 63 |
-
for p in soup.find_all("p"):
|
| 64 |
-
t = p.get_text(" ", strip=True)
|
| 65 |
-
if len(t) > 40:
|
| 66 |
-
ps.append({"type": "p", "text": t})
|
| 67 |
-
if len(ps) >= 30:
|
| 68 |
-
break
|
| 69 |
-
body = ps
|
| 70 |
-
except Exception:
|
| 71 |
-
pass
|
| 72 |
-
if not summary and body:
|
| 73 |
-
first = next((b.get("text", "") for b in body if b.get("type") == "p" and b.get("text")), "")
|
| 74 |
-
summary = first[:360]
|
| 75 |
-
if not title:
|
| 76 |
-
title = url
|
| 77 |
-
if not img:
|
| 78 |
-
img = DEFAULT_IMG
|
| 79 |
-
if not body and summary:
|
| 80 |
-
body = [{"type": "p", "text": summary}]
|
| 81 |
-
data.update({"title": title, "summary": summary, "og_image": img, "body": body, "url": url})
|
| 82 |
-
return data
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
def _rewrite(data, tone="tu-nhien"):
|
| 86 |
-
try:
|
| 87 |
-
if hasattr(_main, "_ai_rewrite_article"):
|
| 88 |
-
text = _main._ai_rewrite_article(data, tone=tone)
|
| 89 |
-
if text and len(text.strip()) > 50:
|
| 90 |
-
return text.strip()
|
| 91 |
-
except Exception:
|
| 92 |
-
pass
|
| 93 |
-
title = data.get("title", "")
|
| 94 |
-
summary = data.get("summary", "")
|
| 95 |
-
ps = [b.get("text", "") for b in data.get("body", []) if b.get("type") == "p" and b.get("text")]
|
| 96 |
-
lead = summary or (ps[0] if ps else "")
|
| 97 |
-
points = "\n".join(["• " + p[:220] + ("..." if len(p) > 220 else "") for p in ps[:5]])
|
| 98 |
-
body = "\n\n".join(ps[:10])
|
| 99 |
-
return (f"Bản tin AI viết lại: {title}\n\n{lead}\n\n{body}\n\nĐiểm chính:\n{points}").strip()
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
def _topic_image(topic):
|
| 103 |
-
try:
|
| 104 |
-
if hasattr(_main, "_image_for_topic"):
|
| 105 |
-
return _main._image_for_topic(topic)
|
| 106 |
-
except Exception:
|
| 107 |
-
pass
|
| 108 |
-
return "https://image.pollinations.ai/prompt/" + quote("editorial illustration Vietnamese news " + topic, safe="") + "?width=1024&height=576&nologo=true"
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
def _save_post(post):
|
| 112 |
-
try:
|
| 113 |
-
posts = _main._load_wall() if hasattr(_main, "_load_wall") else []
|
| 114 |
-
except Exception:
|
| 115 |
-
posts = []
|
| 116 |
-
posts.insert(0, post)
|
| 117 |
-
try:
|
| 118 |
-
if hasattr(_main, "_save_wall"):
|
| 119 |
-
_main._save_wall(posts)
|
| 120 |
-
except Exception:
|
| 121 |
-
pass
|
| 122 |
-
return post
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
_remove_routes(["/api/url_wall", "/api/topic_post", "/api/rewrite_share", "/"])
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
@app.post("/api/url_wall")
|
| 129 |
-
async def patched_url_wall(request: Request):
|
| 130 |
-
try:
|
| 131 |
-
body = await request.json()
|
| 132 |
-
except Exception:
|
| 133 |
-
body = {}
|
| 134 |
-
url = _safe_text(body.get("url"))
|
| 135 |
-
tone = _safe_text(body.get("tone")) or "tu-nhien"
|
| 136 |
-
if not url:
|
| 137 |
-
return JSONResponse({"error": "missing url"}, status_code=400)
|
| 138 |
-
try:
|
| 139 |
-
data = _ensure_article(url)
|
| 140 |
-
text = _rewrite(data, tone=tone)
|
| 141 |
-
post = {
|
| 142 |
-
"id": hashlib.md5((url + str(time.time())).encode()).hexdigest()[:12],
|
| 143 |
-
"url": url,
|
| 144 |
-
"title": data.get("title") or url,
|
| 145 |
-
"summary": data.get("summary") or "",
|
| 146 |
-
"img": data.get("og_image") or DEFAULT_IMG,
|
| 147 |
-
"text": text or (data.get("summary") or data.get("title") or url),
|
| 148 |
-
"source": data.get("source", "url"),
|
| 149 |
-
"ts": int(time.time()),
|
| 150 |
-
}
|
| 151 |
-
_save_post(post)
|
| 152 |
-
return JSONResponse({"post": post})
|
| 153 |
-
except Exception as e:
|
| 154 |
-
return JSONResponse({"error": "Không tạo được tóm tắt URL", "detail": str(e)[:300]}, status_code=500)
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
@app.post("/api/rewrite_share")
|
| 158 |
-
async def patched_rewrite_share(request: Request):
|
| 159 |
-
return await patched_url_wall(request)
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
@app.post("/api/topic_post")
|
| 163 |
-
async def patched_topic_post(request: Request):
|
| 164 |
-
try:
|
| 165 |
-
body = await request.json()
|
| 166 |
-
except Exception:
|
| 167 |
-
body = {}
|
| 168 |
-
topic = _safe_text(body.get("topic"))
|
| 169 |
-
tone = _safe_text(body.get("tone")) or "tu-nhien"
|
| 170 |
-
if not topic:
|
| 171 |
-
return JSONResponse({"error": "missing topic"}, status_code=400)
|
| 172 |
-
try:
|
| 173 |
-
context = ""
|
| 174 |
-
try:
|
| 175 |
-
if hasattr(_main, "_topic_article_context"):
|
| 176 |
-
context = _main._topic_article_context(topic)
|
| 177 |
-
if not context and hasattr(_main, "_web_context"):
|
| 178 |
-
context = _main._web_context(topic)
|
| 179 |
-
except Exception:
|
| 180 |
-
context = ""
|
| 181 |
-
if not context:
|
| 182 |
-
context = f"Chủ đề: {topic}"
|
| 183 |
-
data = {"title": topic, "summary": context[:420], "og_image": _topic_image(topic), "body": [{"type": "p", "text": context}], "source": "topic", "url": ""}
|
| 184 |
-
text = _rewrite(data, tone=tone)
|
| 185 |
-
post = {
|
| 186 |
-
"id": hashlib.md5((topic + str(time.time())).encode()).hexdigest()[:12],
|
| 187 |
-
"url": "",
|
| 188 |
-
"title": topic,
|
| 189 |
-
"summary": data["summary"],
|
| 190 |
-
"img": data["og_image"] or DEFAULT_IMG,
|
| 191 |
-
"text": text or context,
|
| 192 |
-
"source": "topic",
|
| 193 |
-
"ts": int(time.time()),
|
| 194 |
-
}
|
| 195 |
-
_save_post(post)
|
| 196 |
-
return JSONResponse({"post": post})
|
| 197 |
-
except Exception as e:
|
| 198 |
-
return JSONResponse({"error": "Không tạo được bài theo chủ đề", "detail": str(e)[:300]}, status_code=500)
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
_FRONTEND_PATCH = r'''
|
| 202 |
-
<script>
|
| 203 |
-
(function(){
|
| 204 |
-
async function safeJson(res){
|
| 205 |
-
const text = await res.text();
|
| 206 |
-
try { return JSON.parse(text); }
|
| 207 |
-
catch(e){ return { error: (text || 'Server không trả JSON').slice(0,500) }; }
|
| 208 |
-
}
|
| 209 |
-
window.safeJson = safeJson;
|
| 210 |
-
window.createUrlPost = function(){
|
| 211 |
-
let inp=document.getElementById('ai-url-input');
|
| 212 |
-
let url=(inp&&inp.value||'').trim();
|
| 213 |
-
if(!url){ alert('Dán URL trước'); return; }
|
| 214 |
-
fetch('/api/url_wall',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})})
|
| 215 |
-
.then(safeJson).then(j=>{
|
| 216 |
-
if(j&&j.post){
|
| 217 |
-
if(!j.post.img) j.post.img='https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg';
|
| 218 |
-
if(!j.post.text) j.post.text=j.post.summary||j.post.title||'Không lấy được nội dung tóm tắt.';
|
| 219 |
-
if(typeof prependWallPost==='function') prependWallPost(j.post);
|
| 220 |
-
alert('Đã tóm tắt URL và đăng lên tường');
|
| 221 |
-
if(inp) inp.value='';
|
| 222 |
-
} else alert((j&&j.error)||'Lỗi URL');
|
| 223 |
-
}).catch(e=>alert('Lỗi URL: '+e.message));
|
| 224 |
-
};
|
| 225 |
-
window.createTopicPost = function(){
|
| 226 |
-
let inp=document.getElementById('ai-topic-input');
|
| 227 |
-
let topic=(inp&&inp.value||'').trim();
|
| 228 |
-
if(!topic){ alert('Nhập chủ đề trước'); return; }
|
| 229 |
-
fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})})
|
| 230 |
-
.then(safeJson).then(j=>{
|
| 231 |
-
if(j&&j.post){
|
| 232 |
-
if(!j.post.img) j.post.img='https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg';
|
| 233 |
-
if(!j.post.text) j.post.text=j.post.summary||j.post.title||'Không lấy được nội dung.';
|
| 234 |
-
if(typeof prependWallPost==='function') prependWallPost(j.post);
|
| 235 |
-
alert('Đã tạo bài và đăng lên tường');
|
| 236 |
-
if(inp) inp.value='';
|
| 237 |
-
} else alert((j&&j.error)||'Lỗi tạo bài');
|
| 238 |
-
}).catch(e=>alert('Lỗi tạo bài: '+e.message));
|
| 239 |
-
};
|
| 240 |
-
window.rewriteCurrentArticle = function(){
|
| 241 |
-
if(!window._currentArticle && typeof _currentArticle!=='undefined') window._currentArticle=_currentArticle;
|
| 242 |
-
let ca = (typeof _currentArticle!=='undefined') ? _currentArticle : window._currentArticle;
|
| 243 |
-
if(!ca || !ca.url){ alert('Chưa có bài viết để rewrite'); return; }
|
| 244 |
-
let tone=document.getElementById('rewrite-tone')?.value||'nghiem-tuc';
|
| 245 |
-
let btn=document.querySelector('.article-actions button.primary');
|
| 246 |
-
if(btn){btn.textContent='Đang rewrite...';btn.disabled=true;}
|
| 247 |
-
fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:ca.url,tone})})
|
| 248 |
-
.then(safeJson).then(j=>{
|
| 249 |
-
if(j&&j.post){
|
| 250 |
-
if(!j.post.img) j.post.img='https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg';
|
| 251 |
-
if(!j.post.text) j.post.text=j.post.summary||j.post.title||'Không lấy được nội dung.';
|
| 252 |
-
let box=document.getElementById('rewrite-result');
|
| 253 |
-
if(box) box.innerHTML='<div class="rewrite-box"><div class="rewrite-title">Đã rewrite và đăng lên Tường AI</div><div class="rewrite-text">'+(j.post.text||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]))+'</div></div>';
|
| 254 |
-
if(typeof prependWallPost==='function') prependWallPost(j.post);
|
| 255 |
-
alert('Đã đăng lên Tường AI');
|
| 256 |
-
} else alert((j&&j.error)||'Không tạo được bài AI');
|
| 257 |
-
}).catch(e=>alert('Lỗi tạo bài AI: '+e.message))
|
| 258 |
-
.finally(()=>{if(btn){btn.textContent='🤖 AI viết lại & đăng tường';btn.disabled=false;}});
|
| 259 |
-
};
|
| 260 |
-
})();
|
| 261 |
-
</script>
|
| 262 |
-
'''
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
@app.get("/")
|
| 266 |
-
async def patched_index():
|
| 267 |
-
try:
|
| 268 |
-
with open("/app/static/index.html", "r", encoding="utf-8") as f:
|
| 269 |
-
html = f.read()
|
| 270 |
-
if "window.safeJson" not in html:
|
| 271 |
-
html = html.replace("</body>", _FRONTEND_PATCH + "</body>")
|
| 272 |
-
return HTMLResponse(content=html)
|
| 273 |
-
except Exception as e:
|
| 274 |
-
return HTMLResponse(content=f"<pre>Index error: {str(e)}</pre>", status_code=500)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
restore_runner.py
DELETED
|
@@ -1,31 +0,0 @@
|
|
| 1 |
-
import os
|
| 2 |
-
import sys
|
| 3 |
-
import subprocess
|
| 4 |
-
from huggingface_hub import snapshot_download
|
| 5 |
-
|
| 6 |
-
REVISION = os.environ.get("VNEWS_RESTORE_REVISION", "bcaa2dc")
|
| 7 |
-
REPO_ID = os.environ.get("VNEWS_REPO_ID", "bep40/vnews")
|
| 8 |
-
|
| 9 |
-
# Download exact Space snapshot from Hugging Face Hub.
|
| 10 |
-
# This avoids manually copying huge files from an old commit.
|
| 11 |
-
snapshot_dir = snapshot_download(
|
| 12 |
-
repo_id=REPO_ID,
|
| 13 |
-
repo_type="space",
|
| 14 |
-
revision=REVISION,
|
| 15 |
-
local_dir="/tmp/vnews_restore",
|
| 16 |
-
local_dir_use_symlinks=False,
|
| 17 |
-
)
|
| 18 |
-
|
| 19 |
-
os.chdir(snapshot_dir)
|
| 20 |
-
sys.path.insert(0, snapshot_dir)
|
| 21 |
-
|
| 22 |
-
# Commit bcaa2dc Dockerfile ran ai_patch:app.
|
| 23 |
-
cmd = [
|
| 24 |
-
"uvicorn",
|
| 25 |
-
"ai_patch:app",
|
| 26 |
-
"--host",
|
| 27 |
-
"0.0.0.0",
|
| 28 |
-
"--port",
|
| 29 |
-
"7860",
|
| 30 |
-
]
|
| 31 |
-
os.execvp(cmd[0], cmd)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
rewrite_slide.py
DELETED
|
@@ -1,185 +0,0 @@
|
|
| 1 |
-
"""Fast rewrite as slides - no AI needed, extracts key points + images from article."""
|
| 2 |
-
from main import app
|
| 3 |
-
from fastapi import Request
|
| 4 |
-
from fastapi.responses import JSONResponse
|
| 5 |
-
import requests, re, time, random, json, os
|
| 6 |
-
from bs4 import BeautifulSoup
|
| 7 |
-
from urllib.parse import quote
|
| 8 |
-
|
| 9 |
-
UA = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept-Language': 'vi-VN,vi;q=0.9'}
|
| 10 |
-
|
| 11 |
-
try:
|
| 12 |
-
from main import _load_wall, _save_wall
|
| 13 |
-
except:
|
| 14 |
-
_data_dir = "/data" if os.path.isdir("/data") else "/app/data"
|
| 15 |
-
_wall_file = os.path.join(_data_dir, "wall_posts.json")
|
| 16 |
-
def _load_wall():
|
| 17 |
-
try:
|
| 18 |
-
if os.path.exists(_wall_file):
|
| 19 |
-
with open(_wall_file, 'r', encoding='utf-8') as f: return json.load(f)
|
| 20 |
-
except: pass
|
| 21 |
-
return []
|
| 22 |
-
def _save_wall(posts):
|
| 23 |
-
try:
|
| 24 |
-
os.makedirs(os.path.dirname(_wall_file), exist_ok=True)
|
| 25 |
-
with open(_wall_file+'.tmp', 'w', encoding='utf-8') as f: json.dump(posts[:100], f, ensure_ascii=False)
|
| 26 |
-
os.replace(_wall_file+'.tmp', _wall_file)
|
| 27 |
-
except: pass
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
def _clean(s): return re.sub(r'\s+', ' ', str(s or '')).strip()
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
def _scrape_article_full(url):
|
| 34 |
-
"""Scrape article: extract paragraphs + ALL images."""
|
| 35 |
-
try:
|
| 36 |
-
r = requests.get(url, headers=UA, timeout=15, allow_redirects=True)
|
| 37 |
-
r.encoding = 'utf-8'
|
| 38 |
-
soup = BeautifulSoup(r.text, 'lxml')
|
| 39 |
-
for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form']): tag.decompose()
|
| 40 |
-
|
| 41 |
-
# Title
|
| 42 |
-
h1 = soup.find('h1')
|
| 43 |
-
ogt = soup.find('meta', property='og:title')
|
| 44 |
-
title = (h1.get_text(strip=True) if h1 else '') or (ogt.get('content', '') if ogt else '')
|
| 45 |
-
|
| 46 |
-
# OG image
|
| 47 |
-
ogi = soup.find('meta', property='og:image')
|
| 48 |
-
og_img = ogi.get('content', '') if ogi else ''
|
| 49 |
-
if og_img and og_img.startswith('//'): og_img = 'https:' + og_img
|
| 50 |
-
|
| 51 |
-
# Find content block
|
| 52 |
-
block = None
|
| 53 |
-
for sel in ['article', '.singular-content', '.detail-content', '.fck_detail', '.content-detail', '.knc-content', 'main', '.cms-body', '.article__body']:
|
| 54 |
-
el = soup.select_one(sel)
|
| 55 |
-
if el and len(el.find_all('p')) >= 2: block = el; break
|
| 56 |
-
if not block: block = soup.body or soup
|
| 57 |
-
|
| 58 |
-
# Extract paragraphs and images IN ORDER
|
| 59 |
-
paragraphs = []
|
| 60 |
-
images = []
|
| 61 |
-
seen_imgs = set()
|
| 62 |
-
|
| 63 |
-
if og_img and og_img not in seen_imgs:
|
| 64 |
-
images.append(og_img)
|
| 65 |
-
seen_imgs.add(og_img)
|
| 66 |
-
|
| 67 |
-
for el in block.find_all(['p', 'h2', 'h3', 'figure', 'img'], recursive=True):
|
| 68 |
-
if el.name == 'p':
|
| 69 |
-
t = _clean(el.get_text(strip=True))
|
| 70 |
-
if t and len(t) > 40:
|
| 71 |
-
paragraphs.append(t)
|
| 72 |
-
elif el.name in ('figure', 'img'):
|
| 73 |
-
im = el if el.name == 'img' else el.find('img')
|
| 74 |
-
if im:
|
| 75 |
-
src = im.get('data-src') or im.get('src') or im.get('data-original') or ''
|
| 76 |
-
if src and 'base64' not in src:
|
| 77 |
-
if src.startswith('//'): src = 'https:' + src
|
| 78 |
-
if src not in seen_imgs:
|
| 79 |
-
images.append(src)
|
| 80 |
-
seen_imgs.add(src)
|
| 81 |
-
|
| 82 |
-
return {'title': _clean(title), 'paragraphs': paragraphs, 'images': images, 'og_img': og_img}
|
| 83 |
-
except Exception as e:
|
| 84 |
-
return None
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
def _extract_key_points(paragraphs, max_points=5):
|
| 88 |
-
"""Extract key points: take first sentence of each significant paragraph."""
|
| 89 |
-
points = []
|
| 90 |
-
for p in paragraphs:
|
| 91 |
-
if len(points) >= max_points: break
|
| 92 |
-
# Take first complete sentence (ends with . ! ?)
|
| 93 |
-
m = re.match(r'^(.+?[.!?])\s', p)
|
| 94 |
-
if m:
|
| 95 |
-
sentence = m.group(1)
|
| 96 |
-
else:
|
| 97 |
-
sentence = p[:150] + ('.' if not p.endswith('.') else '')
|
| 98 |
-
|
| 99 |
-
# Skip if too short or duplicate
|
| 100 |
-
if len(sentence) < 30: continue
|
| 101 |
-
if any(sentence[:50] in existing for existing in points): continue
|
| 102 |
-
|
| 103 |
-
points.append(sentence)
|
| 104 |
-
|
| 105 |
-
return points
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
@app.post("/api/rewrite_slide")
|
| 109 |
-
async def api_rewrite_slide(request: Request):
|
| 110 |
-
"""
|
| 111 |
-
Fast rewrite as SLIDES:
|
| 112 |
-
- Extract key points from article (1 sentence each, full and complete)
|
| 113 |
-
- Pair each point with an image from the article
|
| 114 |
-
- Return as slides array for frontend to display
|
| 115 |
-
- Save to Tường AI
|
| 116 |
-
NO AI NEEDED - instant response.
|
| 117 |
-
"""
|
| 118 |
-
body = await request.json()
|
| 119 |
-
url = _clean(body.get("url", ""))
|
| 120 |
-
context = body.get("context", "")
|
| 121 |
-
|
| 122 |
-
if not url and not context:
|
| 123 |
-
return JSONResponse({"error": "Cần URL hoặc nội dung"}, status_code=400)
|
| 124 |
-
|
| 125 |
-
# Scrape article
|
| 126 |
-
data = None
|
| 127 |
-
if url and url.startswith("http"):
|
| 128 |
-
data = _scrape_article_full(url)
|
| 129 |
-
|
| 130 |
-
if not data and context:
|
| 131 |
-
# Use context passed from frontend
|
| 132 |
-
paragraphs = [_clean(p) for p in context.split('\n') if len(_clean(p)) > 40]
|
| 133 |
-
data = {'title': paragraphs[0][:80] if paragraphs else 'Bài viết', 'paragraphs': paragraphs, 'images': [], 'og_img': ''}
|
| 134 |
-
|
| 135 |
-
if not data or not data.get('paragraphs'):
|
| 136 |
-
return JSONResponse({"error": "Không đọc được bài viết"}, status_code=422)
|
| 137 |
-
|
| 138 |
-
# Extract key points
|
| 139 |
-
points = _extract_key_points(data['paragraphs'], max_points=6)
|
| 140 |
-
if not points:
|
| 141 |
-
return JSONResponse({"error": "Không tìm được ý chính"}, status_code=422)
|
| 142 |
-
|
| 143 |
-
# Build slides: pair each point with an image
|
| 144 |
-
images = data.get('images', [])
|
| 145 |
-
slides = []
|
| 146 |
-
for i, point in enumerate(points):
|
| 147 |
-
img = images[i] if i < len(images) else (images[-1] if images else '')
|
| 148 |
-
# Proxy dantri images
|
| 149 |
-
if img and 'cdnphoto.dantri' in img:
|
| 150 |
-
img = '/api/proxy/img?url=' + quote(img, safe='')
|
| 151 |
-
slides.append({
|
| 152 |
-
'text': point,
|
| 153 |
-
'image': img,
|
| 154 |
-
'index': i + 1
|
| 155 |
-
})
|
| 156 |
-
|
| 157 |
-
# Create post for Tường AI
|
| 158 |
-
summary_text = '\n\n'.join([f"• {s['text']}" for s in slides])
|
| 159 |
-
# Auto voice + emotion based on topic (reuse ai_ext detector if available)
|
| 160 |
-
try:
|
| 161 |
-
from ai_ext import _detect_voice_emotion
|
| 162 |
-
_voice, _emotion = _detect_voice_emotion(data['title'], summary_text)
|
| 163 |
-
except Exception:
|
| 164 |
-
_voice, _emotion = "hoaimy", "trung_tinh"
|
| 165 |
-
post = {
|
| 166 |
-
"id": str(int(time.time() * 1000)) + str(random.randint(100, 999)),
|
| 167 |
-
"title": data['title'],
|
| 168 |
-
"text": summary_text,
|
| 169 |
-
"img": images[0] if images else '',
|
| 170 |
-
"url": url,
|
| 171 |
-
"kind": "slide_summary",
|
| 172 |
-
"slides": slides,
|
| 173 |
-
"images": images[:10],
|
| 174 |
-
"video": "",
|
| 175 |
-
"voice": _voice,
|
| 176 |
-
"emotion": _emotion,
|
| 177 |
-
"ts": int(time.time())
|
| 178 |
-
}
|
| 179 |
-
|
| 180 |
-
# Save to wall
|
| 181 |
-
posts = _load_wall()
|
| 182 |
-
posts.insert(0, post)
|
| 183 |
-
_save_wall(posts)
|
| 184 |
-
|
| 185 |
-
return JSONResponse({"post": post, "slides": slides})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|