Spaces:
Running
Running
Fix AI scraping with Jina Reader/Search and require Qwen for summaries
Browse files
ai_ext.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
import os, re, json, time, random, html as html_lib
|
| 2 |
-
from urllib.parse import quote_plus, urlparse
|
| 3 |
from typing import Optional
|
| 4 |
import requests
|
| 5 |
from bs4 import BeautifulSoup
|
|
@@ -45,6 +45,62 @@ def _clean_text(s: str) -> str:
|
|
| 45 |
return re.sub(r"\s+", " ", s).strip()
|
| 46 |
|
| 47 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
def _best_content_block(soup):
|
| 49 |
best, best_score = None, 0
|
| 50 |
for el in soup.find_all(["article", "main", "section", "div"]):
|
|
@@ -59,8 +115,11 @@ def _best_content_block(soup):
|
|
| 59 |
return best
|
| 60 |
|
| 61 |
|
| 62 |
-
def
|
| 63 |
r = requests.get(url, headers=HEADERS, timeout=18)
|
|
|
|
|
|
|
|
|
|
| 64 |
r.encoding = "utf-8"
|
| 65 |
soup = BeautifulSoup(r.text, "lxml")
|
| 66 |
for tag in soup.find_all(["script", "style", "nav", "footer", "aside", "form", "noscript"]):
|
|
@@ -95,7 +154,22 @@ def scrape_any_url(url: str):
|
|
| 95 |
break
|
| 96 |
if not image and images:
|
| 97 |
image = images[0]
|
| 98 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
|
| 100 |
|
| 101 |
def pollinations_image_url(topic: str, width=1024, height=576):
|
|
@@ -103,53 +177,58 @@ def pollinations_image_url(topic: str, width=1024, height=576):
|
|
| 103 |
return f"https://image.pollinations.ai/prompt/{quote_plus(prompt)}?width={width}&height={height}&seed={random.randint(1,999999)}&nologo=true&private=true"
|
| 104 |
|
| 105 |
|
| 106 |
-
def
|
|
|
|
| 107 |
try:
|
| 108 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
except Exception:
|
| 110 |
-
|
|
|
|
| 111 |
|
| 112 |
|
| 113 |
-
def
|
| 114 |
-
"""Search web and fetch short excerpts from result pages so topic posts are grounded, not generic."""
|
| 115 |
-
results = []
|
| 116 |
try:
|
| 117 |
-
|
| 118 |
-
r = requests.get(
|
| 119 |
soup = BeautifulSoup(r.text, "lxml")
|
| 120 |
-
|
| 121 |
-
|
|
|
|
| 122 |
href = a.get("href", "") if a else ""
|
| 123 |
title = _clean_text(a.get_text(" ", strip=True) if a else "")
|
| 124 |
sn = res.select_one(".result__snippet")
|
| 125 |
snippet = _clean_text(sn.get_text(" ", strip=True) if sn else "")
|
| 126 |
-
if
|
| 127 |
-
|
| 128 |
-
import urllib.parse as up
|
| 129 |
-
q = up.urlparse("https:" + href).query
|
| 130 |
-
href = up.parse_qs(q).get("uddg", [href])[0]
|
| 131 |
-
if href.startswith("http") and (title or snippet):
|
| 132 |
-
results.append({"title": title, "url": href, "snippet": snippet})
|
| 133 |
-
if len(results) >= limit:
|
| 134 |
-
break
|
| 135 |
except Exception:
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
|
|
|
|
|
|
| 153 |
return context, enriched
|
| 154 |
|
| 155 |
|
|
@@ -173,19 +252,6 @@ async def qwen_generate(prompt: str, image_url: Optional[str] = None, max_tokens
|
|
| 173 |
return None
|
| 174 |
|
| 175 |
|
| 176 |
-
def fallback_rewrite(title, raw_text):
|
| 177 |
-
text = _clean_text(raw_text)
|
| 178 |
-
chunks = []
|
| 179 |
-
while text and len(chunks) < 7:
|
| 180 |
-
part = text[:620]
|
| 181 |
-
cut = max(part.rfind(". "), part.rfind("! "), part.rfind("? "))
|
| 182 |
-
if cut > 220:
|
| 183 |
-
part = part[:cut+1]
|
| 184 |
-
chunks.append(part.strip())
|
| 185 |
-
text = text[len(part):].strip()
|
| 186 |
-
return f"{title}\n\n" + "\n\n".join(chunks)
|
| 187 |
-
|
| 188 |
-
|
| 189 |
def make_post(title, text, image, source_url, kind, sources=None):
|
| 190 |
return {"id": str(int(time.time()*1000)) + str(random.randint(100,999)), "title": title, "text": text, "img": image, "url": source_url, "kind": kind, "sources": sources or [], "ts": int(time.time())}
|
| 191 |
|
|
@@ -199,15 +265,16 @@ def api_ai_wall():
|
|
| 199 |
async def api_ai_topic(request: Request):
|
| 200 |
body = await request.json()
|
| 201 |
topic = _clean_text(body.get("topic", ""))
|
| 202 |
-
if not topic:
|
| 203 |
-
return JSONResponse({"error":"missing topic"}, status_code=400)
|
| 204 |
ctx, sources = web_context(topic)
|
|
|
|
|
|
|
| 205 |
image = pollinations_image_url(topic)
|
| 206 |
prompt = f"""
|
| 207 |
Hãy viết một dàn ý bài viết tiếng Việt tự nhiên cho chủ đề: {topic}
|
| 208 |
|
| 209 |
Yêu cầu:
|
| 210 |
-
- Phải dựa trên các nguồn/bối cảnh internet bên dưới
|
| 211 |
- Có tiêu đề hấp dẫn nhưng không giật tít.
|
| 212 |
- 1 đoạn mở bài ngắn nêu bối cảnh thực tế.
|
| 213 |
- 5-7 luận điểm chính, mỗi luận điểm có 1-2 câu giải thích cụ thể.
|
|
@@ -216,76 +283,66 @@ Yêu cầu:
|
|
| 216 |
- Văn phong tự nhiên, dễ đọc.
|
| 217 |
|
| 218 |
Bối cảnh/nguồn tìm được trên internet:
|
| 219 |
-
{ctx
|
| 220 |
"""
|
| 221 |
-
text = await qwen_generate(prompt, image_url=image, max_tokens=
|
| 222 |
if not text:
|
| 223 |
-
|
| 224 |
-
text = f"{topic}\n\nMở bài: Chủ đề này có nhiều khía cạnh cần xem xét trong bối cảnh hiện nay.\n\nDàn ý:\n• Bối cảnh chung: {sources[0]['excerpt'][:220] if sources else 'Cần nhìn chủ đề trong mối liên hệ với đời sống, công nghệ và xã hội.'}\n• Vì sao đáng chú ý: chủ đề có thể tác động tới cách người dùng tiếp nhận thông tin hoặc ra quyết định.\n• Các tác động chính: cần xem xét cả lợi ích, rủi ro và nhóm đối tượng chịu ảnh hưởng.\n• Góc nhìn thực tiễn: nên đối chiếu nhiều nguồn thay vì dựa vào một nhận định đơn lẻ.\n• Hướng khai thác bài viết: đặt câu hỏi điều gì đang thay đổi, ai được lợi, ai gặp rủi ro.\n\nKết luận: Đây là chủ đề nên được trình bày cân bằng, có dẫn nguồn và tránh khẳng định quá mức.\n\nNguồn tham khảo:\n{source_lines}"
|
| 225 |
post = make_post(topic, text, image, "", "topic", sources=sources[:5])
|
| 226 |
-
posts
|
| 227 |
-
return JSONResponse({"post":
|
| 228 |
|
| 229 |
|
| 230 |
@app.post("/api/ai/url")
|
| 231 |
async def api_ai_url(request: Request):
|
| 232 |
-
body
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
return JSONResponse({"error":"Không scrape được URL"}, status_code=422)
|
| 240 |
-
raw = (data.get("summary", "") + "\n" + data.get("text", "")).strip()
|
| 241 |
-
if len(raw) < 80:
|
| 242 |
-
return JSONResponse({"error":"URL không có đủ nội dung để tóm tắt"}, status_code=422)
|
| 243 |
-
prompt = f"""
|
| 244 |
Hãy đọc nội dung scrape dưới đây và viết lại thành một bài ngắn gọn đưa lên Tường AI VNEWS.
|
| 245 |
|
| 246 |
Yêu cầu:
|
|
|
|
| 247 |
- Không cắt khúc, không bỏ mất ý chính.
|
| 248 |
- 3-6 đoạn ngắn, văn phong tự nhiên.
|
| 249 |
- Giữ sự kiện, nhân vật, bối cảnh đúng theo nguồn.
|
| 250 |
- Không thêm chi tiết không có trong nguồn.
|
| 251 |
-
- Có thể dùng ảnh như bối cảnh thị giác nếu ảnh được cung cấp.
|
| 252 |
|
| 253 |
Tiêu đề gốc: {data.get('title','')}
|
|
|
|
| 254 |
Nội dung gốc:
|
| 255 |
-
{raw[:
|
| 256 |
"""
|
| 257 |
-
text
|
| 258 |
if not text:
|
| 259 |
-
|
| 260 |
-
post
|
| 261 |
-
posts
|
| 262 |
-
return JSONResponse({"post":
|
| 263 |
|
| 264 |
|
| 265 |
@app.post("/api/rewrite_share")
|
| 266 |
async def api_rewrite_share(request: Request):
|
| 267 |
-
body
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
if not text:
|
| 279 |
-
text = fallback_rewrite(data.get("title", "Bài viết"), raw)
|
| 280 |
-
post = make_post(data.get("title") or "Bài viết", text, data.get("image") or "", url, "rewrite")
|
| 281 |
-
posts = _load_ai_wall(); posts.insert(0, post); _save_ai_wall(posts)
|
| 282 |
-
return JSONResponse({"post": post})
|
| 283 |
|
| 284 |
|
| 285 |
# Override index route to inject AI composer/wall without modifying large index.html.
|
| 286 |
-
app.router.routes
|
| 287 |
|
| 288 |
-
AI_INJECT
|
| 289 |
<style>
|
| 290 |
.ai-compose{margin:6px 4px;background:#141414;border:1px solid #2b2b2b;border-radius:10px;padding:10px}.ai-compose-title{font-size:13px;color:#5cb87a;font-weight:800;margin-bottom:8px}.ai-row{display:flex;gap:6px;margin-bottom:6px}.ai-row input{flex:1;background:#222;border:1px solid #333;color:#eee;border-radius:8px;padding:8px;font-size:12px}.ai-row button{background:#2d8659;border:0;color:white;border-radius:8px;padding:8px 10px;font-size:12px;font-weight:700}.ai-row button.secondary{background:#333}.ai-wall-extra{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}.ai-wall-card{flex:0 0 250px;background:#141414;border:1px solid #2b2b2b;border-radius:10px;padding:8px}.ai-wall-img{width:100%;aspect-ratio:16/9;background:#222;border-radius:8px;overflow:hidden;margin-bottom:6px}.ai-wall-img img{width:100%;height:100%;object-fit:cover}.ai-wall-title{font-size:12px;color:#5cb87a;font-weight:800;line-height:1.3;margin-bottom:4px}.ai-wall-text{font-size:11px;color:#bbb;line-height:1.45;white-space:pre-wrap;display:-webkit-box;-webkit-line-clamp:5;-webkit-box-orient:vertical;overflow:hidden}.ai-wall-card button{margin-top:8px;width:100%;border:1px solid #333;background:#222;color:#ddd;border-radius:14px;padding:6px 8px;font-size:10px}</style>
|
| 291 |
<script>
|
|
@@ -299,14 +356,13 @@ window.aiReadWall=function(i){const p=aiWall[i];if(!p)return;showView('view-arti
|
|
| 299 |
async function postJson(url,data){const st=document.getElementById('ai-compose-status');if(st)st.textContent='AI đang xử lý...';const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');if(st)st.textContent='Đã đăng lên Tường AI';await refreshAiWall();return j;}
|
| 300 |
window.aiCreateTopic=function(){const v=(document.getElementById('ai-topic-input')?.value||'').trim();if(!v)return alert('Nhập chủ đề');postJson('/api/ai/topic',{topic:v}).catch(e=>alert(e.message));};
|
| 301 |
window.aiCreateUrl=function(){const input=document.getElementById('ai-url-input');const v=(input?.value||'').trim();if(!v)return alert('Dán URL trước');if(!/^https?:\/\//i.test(v))return alert('URL cần bắt đầu bằng http:// hoặc https://');postJson('/api/ai/url',{url:v}).catch(e=>alert(e.message));};
|
| 302 |
-
const oldLoad=window.loadHome;if(typeof oldLoad==='function'){window.loadHome=async function(){
|
| 303 |
setTimeout(refreshAiWall,1200);
|
| 304 |
})();
|
| 305 |
</script>
|
| 306 |
'''
|
| 307 |
|
| 308 |
-
@app.get(
|
| 309 |
async def index_ai():
|
| 310 |
-
with open(
|
| 311 |
-
|
| 312 |
-
return HTMLResponse(html.replace("</body>", AI_INJECT + "\n</body>"))
|
|
|
|
| 1 |
import os, re, json, time, random, html as html_lib
|
| 2 |
+
from urllib.parse import quote_plus, quote, urlparse
|
| 3 |
from typing import Optional
|
| 4 |
import requests
|
| 5 |
from bs4 import BeautifulSoup
|
|
|
|
| 45 |
return re.sub(r"\s+", " ", s).strip()
|
| 46 |
|
| 47 |
|
| 48 |
+
def _domain(u):
|
| 49 |
+
try:
|
| 50 |
+
return urlparse(u).netloc.replace("www.", "")
|
| 51 |
+
except Exception:
|
| 52 |
+
return ""
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _reader_url(target_url: str) -> str:
|
| 56 |
+
safe = quote(target_url, safe=":/?#[]@!$&'()*+,;=%")
|
| 57 |
+
return "https://r.jina.ai/http://" + safe
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def jina_reader_markdown(url: str) -> str:
|
| 61 |
+
"""No-key fallback for sites blocking direct scrape, returns markdown/text."""
|
| 62 |
+
jr = _reader_url(url)
|
| 63 |
+
r = requests.get(jr, headers={"Accept":"text/markdown,text/plain,*/*", "X-Return-Format":"markdown", "User-Agent":"Mozilla/5.0"}, timeout=35)
|
| 64 |
+
r.raise_for_status()
|
| 65 |
+
return r.text or ""
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _parse_jina_markdown(md: str, url: str):
|
| 69 |
+
lines = [x.rstrip() for x in (md or "").splitlines()]
|
| 70 |
+
title = ""
|
| 71 |
+
image = ""
|
| 72 |
+
content_lines = []
|
| 73 |
+
in_content = False
|
| 74 |
+
for ln in lines:
|
| 75 |
+
if ln.startswith("Title:") and not title:
|
| 76 |
+
title = _clean_text(ln.replace("Title:", "", 1))
|
| 77 |
+
continue
|
| 78 |
+
if ln.startswith("URL Source:"):
|
| 79 |
+
continue
|
| 80 |
+
if ln.startswith("Markdown Content:"):
|
| 81 |
+
in_content = True
|
| 82 |
+
continue
|
| 83 |
+
if re.search(r'!\[[^\]]*\]\((https?://[^)]+)\)', ln) and not image:
|
| 84 |
+
m = re.search(r'!\[[^\]]*\]\((https?://[^)]+)\)', ln)
|
| 85 |
+
if m:image = m.group(1)
|
| 86 |
+
if in_content or (title and not ln.startswith("Title:")):
|
| 87 |
+
if ln.strip():
|
| 88 |
+
content_lines.append(ln)
|
| 89 |
+
text = "\n".join(content_lines)
|
| 90 |
+
text = re.sub(r'!\[[^\]]*\]\([^)]+\)', '', text)
|
| 91 |
+
text = re.sub(r'\[[^\]]+\]\((https?://[^)]+)\)', lambda m: m.group(0).split('](')[0].strip('['), text)
|
| 92 |
+
paras = []
|
| 93 |
+
for part in re.split(r'\n{2,}|\n(?=#{1,3}\s)', text):
|
| 94 |
+
t = _clean_text(re.sub(r'^#{1,6}\s*', '', part))
|
| 95 |
+
if len(t) >= 40:
|
| 96 |
+
paras.append(t)
|
| 97 |
+
if len(paras) >= 35:
|
| 98 |
+
break
|
| 99 |
+
if not title and paras:
|
| 100 |
+
title = paras[0][:90]
|
| 101 |
+
return {"url": url, "title": title or url, "summary": paras[0] if paras else "", "text": "\n".join(paras), "image": image, "images": [image] if image else [], "via":"jina"}
|
| 102 |
+
|
| 103 |
+
|
| 104 |
def _best_content_block(soup):
|
| 105 |
best, best_score = None, 0
|
| 106 |
for el in soup.find_all(["article", "main", "section", "div"]):
|
|
|
|
| 115 |
return best
|
| 116 |
|
| 117 |
|
| 118 |
+
def scrape_any_url_direct(url: str):
|
| 119 |
r = requests.get(url, headers=HEADERS, timeout=18)
|
| 120 |
+
# If site blocks or returns almost empty content, let caller fallback to Jina.
|
| 121 |
+
if r.status_code in {401, 403, 406, 409, 429, 451, 503}:
|
| 122 |
+
raise RuntimeError(f"blocked status {r.status_code}")
|
| 123 |
r.encoding = "utf-8"
|
| 124 |
soup = BeautifulSoup(r.text, "lxml")
|
| 125 |
for tag in soup.find_all(["script", "style", "nav", "footer", "aside", "form", "noscript"]):
|
|
|
|
| 154 |
break
|
| 155 |
if not image and images:
|
| 156 |
image = images[0]
|
| 157 |
+
data = {"url": url, "title": _clean_text(title), "summary": _clean_text(summary), "text": "\n".join(paras), "image": image, "images": images, "via":"direct"}
|
| 158 |
+
if len(data["text"]) < 160:
|
| 159 |
+
raise RuntimeError("direct scrape too short")
|
| 160 |
+
return data
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def scrape_any_url(url: str):
|
| 164 |
+
"""Direct scrape first, Jina Reader fallback for blocked JS/WAF sites like vatvostudio.vn."""
|
| 165 |
+
try:
|
| 166 |
+
return scrape_any_url_direct(url)
|
| 167 |
+
except Exception as direct_error:
|
| 168 |
+
md = jina_reader_markdown(url)
|
| 169 |
+
data = _parse_jina_markdown(md, url)
|
| 170 |
+
if len(data.get("text", "")) < 120:
|
| 171 |
+
raise RuntimeError(f"Jina Reader also returned too little content; direct={direct_error}")
|
| 172 |
+
return data
|
| 173 |
|
| 174 |
|
| 175 |
def pollinations_image_url(topic: str, width=1024, height=576):
|
|
|
|
| 177 |
return f"https://image.pollinations.ai/prompt/{quote_plus(prompt)}?width={width}&height={height}&seed={random.randint(1,999999)}&nologo=true&private=true"
|
| 178 |
|
| 179 |
|
| 180 |
+
def jina_search(query: str, limit=6):
|
| 181 |
+
"""No-key Jina Search; stronger than DuckDuckGo HTML scraping."""
|
| 182 |
try:
|
| 183 |
+
url = "https://s.jina.ai/" + quote(query, safe="")
|
| 184 |
+
r = requests.get(url, headers={"Accept":"application/json", "User-Agent":"Mozilla/5.0"}, timeout=30)
|
| 185 |
+
if r.status_code == 200 and r.text.strip().startswith("{"):
|
| 186 |
+
payload = r.json()
|
| 187 |
+
data = payload.get("data", [])
|
| 188 |
+
if isinstance(data, dict):data=[data]
|
| 189 |
+
out=[]
|
| 190 |
+
for item in data[:limit]:
|
| 191 |
+
out.append({"title":_clean_text(item.get("title","")),"url":item.get("url",""),"description":_clean_text(item.get("description","")),"content":_clean_text(item.get("content",""))[:1200]})
|
| 192 |
+
if out:return out
|
| 193 |
except Exception:
|
| 194 |
+
pass
|
| 195 |
+
return []
|
| 196 |
|
| 197 |
|
| 198 |
+
def duck_context(topic: str, limit=6):
|
|
|
|
|
|
|
| 199 |
try:
|
| 200 |
+
url = "https://duckduckgo.com/html/?q=" + quote_plus(topic + " tin tức phân tích bối cảnh")
|
| 201 |
+
r = requests.get(url, headers=HEADERS, timeout=12)
|
| 202 |
soup = BeautifulSoup(r.text, "lxml")
|
| 203 |
+
results=[]
|
| 204 |
+
for res in soup.select(".result")[:limit]:
|
| 205 |
+
a = res.select_one(".result__a") or res.find("a", href=True)
|
| 206 |
href = a.get("href", "") if a else ""
|
| 207 |
title = _clean_text(a.get_text(" ", strip=True) if a else "")
|
| 208 |
sn = res.select_one(".result__snippet")
|
| 209 |
snippet = _clean_text(sn.get_text(" ", strip=True) if sn else "")
|
| 210 |
+
if title or snippet:results.append({"title":title,"url":href,"description":snippet,"content":snippet})
|
| 211 |
+
return results
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
except Exception:
|
| 213 |
+
return []
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def web_context(topic: str, limit=6):
|
| 217 |
+
results = jina_search(topic + " tin tức phân tích bối cảnh", limit=limit)
|
| 218 |
+
if not results:
|
| 219 |
+
results = duck_context(topic, limit=limit)
|
| 220 |
+
# Enrich first 2 results with Reader if content short.
|
| 221 |
+
enriched=[]
|
| 222 |
+
for item in results[:limit]:
|
| 223 |
+
content = item.get("content") or item.get("description") or ""
|
| 224 |
+
if len(content) < 250 and item.get("url", "").startswith("http"):
|
| 225 |
+
try:
|
| 226 |
+
page = scrape_any_url(item["url"])
|
| 227 |
+
content = (page.get("summary") or "") + " " + page.get("text", "")[:900]
|
| 228 |
+
except Exception:
|
| 229 |
+
pass
|
| 230 |
+
enriched.append({**item, "excerpt": _clean_text(content)[:1200]})
|
| 231 |
+
context = "\n".join([f"- {x.get('title') or _domain(x.get('url',''))} ({_domain(x.get('url',''))}): {x.get('excerpt') or x.get('description','')}" for x in enriched if x.get("excerpt") or x.get("title")])
|
| 232 |
return context, enriched
|
| 233 |
|
| 234 |
|
|
|
|
| 252 |
return None
|
| 253 |
|
| 254 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
def make_post(title, text, image, source_url, kind, sources=None):
|
| 256 |
return {"id": str(int(time.time()*1000)) + str(random.randint(100,999)), "title": title, "text": text, "img": image, "url": source_url, "kind": kind, "sources": sources or [], "ts": int(time.time())}
|
| 257 |
|
|
|
|
| 265 |
async def api_ai_topic(request: Request):
|
| 266 |
body = await request.json()
|
| 267 |
topic = _clean_text(body.get("topic", ""))
|
| 268 |
+
if not topic:return JSONResponse({"error":"missing topic"}, status_code=400)
|
|
|
|
| 269 |
ctx, sources = web_context(topic)
|
| 270 |
+
if not ctx:
|
| 271 |
+
return JSONResponse({"error":"Không lấy được dữ liệu internet cho chủ đề này. Hãy thử chủ đề cụ thể hơn."}, status_code=422)
|
| 272 |
image = pollinations_image_url(topic)
|
| 273 |
prompt = f"""
|
| 274 |
Hãy viết một dàn ý bài viết tiếng Việt tự nhiên cho chủ đề: {topic}
|
| 275 |
|
| 276 |
Yêu cầu:
|
| 277 |
+
- Phải dựa trên các nguồn/bối cảnh internet bên dưới.
|
| 278 |
- Có tiêu đề hấp dẫn nhưng không giật tít.
|
| 279 |
- 1 đoạn mở bài ngắn nêu bối cảnh thực tế.
|
| 280 |
- 5-7 luận điểm chính, mỗi luận điểm có 1-2 câu giải thích cụ thể.
|
|
|
|
| 283 |
- Văn phong tự nhiên, dễ đọc.
|
| 284 |
|
| 285 |
Bối cảnh/nguồn tìm được trên internet:
|
| 286 |
+
{ctx}
|
| 287 |
"""
|
| 288 |
+
text = await qwen_generate(prompt, image_url=image, max_tokens=1600)
|
| 289 |
if not text:
|
| 290 |
+
return JSONResponse({"error":"Qwen2.5-VL chưa sẵn sàng hoặc thiếu HF_TOKEN, nên không thể tạo bài từ chủ đề theo yêu cầu."}, status_code=503)
|
|
|
|
| 291 |
post = make_post(topic, text, image, "", "topic", sources=sources[:5])
|
| 292 |
+
posts=_load_ai_wall();posts.insert(0,post);_save_ai_wall(posts)
|
| 293 |
+
return JSONResponse({"post":post})
|
| 294 |
|
| 295 |
|
| 296 |
@app.post("/api/ai/url")
|
| 297 |
async def api_ai_url(request: Request):
|
| 298 |
+
body=await request.json();url=_clean_text(body.get("url", ""))
|
| 299 |
+
if not url.startswith("http"):return JSONResponse({"error":"missing url"}, status_code=400)
|
| 300 |
+
try:data=scrape_any_url(url)
|
| 301 |
+
except Exception as e:return JSONResponse({"error":"Không scrape được URL: "+str(e)[:180]}, status_code=422)
|
| 302 |
+
raw=(data.get("summary","")+"\n"+data.get("text","")).strip()
|
| 303 |
+
if len(raw)<120:return JSONResponse({"error":"URL không có đủ nội dung để Qwen tóm tắt"}, status_code=422)
|
| 304 |
+
prompt=f"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 305 |
Hãy đọc nội dung scrape dưới đây và viết lại thành một bài ngắn gọn đưa lên Tường AI VNEWS.
|
| 306 |
|
| 307 |
Yêu cầu:
|
| 308 |
+
- BẮT BUỘC dùng nội dung nguồn, không tự bịa.
|
| 309 |
- Không cắt khúc, không bỏ mất ý chính.
|
| 310 |
- 3-6 đoạn ngắn, văn phong tự nhiên.
|
| 311 |
- Giữ sự kiện, nhân vật, bối cảnh đúng theo nguồn.
|
| 312 |
- Không thêm chi tiết không có trong nguồn.
|
|
|
|
| 313 |
|
| 314 |
Tiêu đề gốc: {data.get('title','')}
|
| 315 |
+
Nguồn scrape: {data.get('via','')}
|
| 316 |
Nội dung gốc:
|
| 317 |
+
{raw[:16000]}
|
| 318 |
"""
|
| 319 |
+
text=await qwen_generate(prompt, image_url=data.get("image") or None, max_tokens=1800)
|
| 320 |
if not text:
|
| 321 |
+
return JSONResponse({"error":"Qwen2.5-VL chưa sẵn sàng hoặc thiếu HF_TOKEN, nên không thể tóm tắt theo yêu cầu bắt buộc."}, status_code=503)
|
| 322 |
+
post=make_post(data.get("title") or "Bài viết", text, data.get("image") or "", url, "url", sources=[{"title":data.get("title"),"url":url,"excerpt":raw[:500],"via":data.get("via")}])
|
| 323 |
+
posts=_load_ai_wall();posts.insert(0,post);_save_ai_wall(posts)
|
| 324 |
+
return JSONResponse({"post":post})
|
| 325 |
|
| 326 |
|
| 327 |
@app.post("/api/rewrite_share")
|
| 328 |
async def api_rewrite_share(request: Request):
|
| 329 |
+
body=await request.json();url=_clean_text(body.get("url", ""))
|
| 330 |
+
if not url.startswith("http"):return JSONResponse({"error":"missing url"}, status_code=400)
|
| 331 |
+
try:data=scrape_any_url(url)
|
| 332 |
+
except Exception as e:return JSONResponse({"error":"Không đọc được bài viết: "+str(e)[:180]}, status_code=422)
|
| 333 |
+
raw=(data.get("summary","")+"\n"+data.get("text","")).strip()
|
| 334 |
+
prompt=f"Viết lại bài sau bằng tiếng Việt tự nhiên, đầy đủ ý, không cắt cụt, 4-7 đoạn. Không thêm chi tiết ngoài nguồn.\n\nTiêu đề: {data.get('title','')}\n\nNội dung:\n{raw[:16000]}"
|
| 335 |
+
text=await qwen_generate(prompt, image_url=data.get("image") or None, max_tokens=1800)
|
| 336 |
+
if not text:return JSONResponse({"error":"Qwen2.5-VL chưa sẵn sàng hoặc thiếu HF_TOKEN."}, status_code=503)
|
| 337 |
+
post=make_post(data.get("title") or "Bài viết", text, data.get("image") or "", url, "rewrite")
|
| 338 |
+
posts=_load_ai_wall();posts.insert(0,post);_save_ai_wall(posts)
|
| 339 |
+
return JSONResponse({"post":post})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 340 |
|
| 341 |
|
| 342 |
# Override index route to inject AI composer/wall without modifying large index.html.
|
| 343 |
+
app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
|
| 344 |
|
| 345 |
+
AI_INJECT=r'''
|
| 346 |
<style>
|
| 347 |
.ai-compose{margin:6px 4px;background:#141414;border:1px solid #2b2b2b;border-radius:10px;padding:10px}.ai-compose-title{font-size:13px;color:#5cb87a;font-weight:800;margin-bottom:8px}.ai-row{display:flex;gap:6px;margin-bottom:6px}.ai-row input{flex:1;background:#222;border:1px solid #333;color:#eee;border-radius:8px;padding:8px;font-size:12px}.ai-row button{background:#2d8659;border:0;color:white;border-radius:8px;padding:8px 10px;font-size:12px;font-weight:700}.ai-row button.secondary{background:#333}.ai-wall-extra{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}.ai-wall-card{flex:0 0 250px;background:#141414;border:1px solid #2b2b2b;border-radius:10px;padding:8px}.ai-wall-img{width:100%;aspect-ratio:16/9;background:#222;border-radius:8px;overflow:hidden;margin-bottom:6px}.ai-wall-img img{width:100%;height:100%;object-fit:cover}.ai-wall-title{font-size:12px;color:#5cb87a;font-weight:800;line-height:1.3;margin-bottom:4px}.ai-wall-text{font-size:11px;color:#bbb;line-height:1.45;white-space:pre-wrap;display:-webkit-box;-webkit-line-clamp:5;-webkit-box-orient:vertical;overflow:hidden}.ai-wall-card button{margin-top:8px;width:100%;border:1px solid #333;background:#222;color:#ddd;border-radius:14px;padding:6px 8px;font-size:10px}</style>
|
| 348 |
<script>
|
|
|
|
| 356 |
async function postJson(url,data){const st=document.getElementById('ai-compose-status');if(st)st.textContent='AI đang xử lý...';const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');if(st)st.textContent='Đã đăng lên Tường AI';await refreshAiWall();return j;}
|
| 357 |
window.aiCreateTopic=function(){const v=(document.getElementById('ai-topic-input')?.value||'').trim();if(!v)return alert('Nhập chủ đề');postJson('/api/ai/topic',{topic:v}).catch(e=>alert(e.message));};
|
| 358 |
window.aiCreateUrl=function(){const input=document.getElementById('ai-url-input');const v=(input?.value||'').trim();if(!v)return alert('Dán URL trước');if(!/^https?:\/\//i.test(v))return alert('URL cần bắt đầu bằng http:// hoặc https://');postJson('/api/ai/url',{url:v}).catch(e=>alert(e.message));};
|
| 359 |
+
const oldLoad=window.loadHome;if(typeof oldLoad==='function'){window.loadHome=async function(){await oldLoad.apply(this,arguments);setTimeout(()=>refreshAiWall(),50);};}
|
| 360 |
setTimeout(refreshAiWall,1200);
|
| 361 |
})();
|
| 362 |
</script>
|
| 363 |
'''
|
| 364 |
|
| 365 |
+
@app.get('/')
|
| 366 |
async def index_ai():
|
| 367 |
+
with open('/app/static/index.html','r',encoding='utf-8') as f:html=f.read()
|
| 368 |
+
return HTMLResponse(html.replace('</body>',AI_INJECT+'\n</body>'))
|
|
|