bep40 commited on
Commit
d5050aa
·
verified ·
1 Parent(s): 8851824

Fix AI scraping with Jina Reader/Search and require Qwen for summaries

Browse files
Files changed (1) hide show
  1. ai_ext.py +159 -103
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 scrape_any_url(url: str):
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
- return {"url": url, "title": _clean_text(title), "summary": _clean_text(summary), "text": "\n".join(paras), "image": image, "images": images}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 _domain(u):
 
107
  try:
108
- return urlparse(u).netloc.replace("www.", "")
 
 
 
 
 
 
 
 
 
109
  except Exception:
110
- return ""
 
111
 
112
 
113
- def web_context(topic: str, limit=6):
114
- """Search web and fetch short excerpts from result pages so topic posts are grounded, not generic."""
115
- results = []
116
  try:
117
- search_url = "https://duckduckgo.com/html/?q=" + quote_plus(topic + " tin tức phân tích bối cảnh")
118
- r = requests.get(search_url, headers=HEADERS, timeout=12)
119
  soup = BeautifulSoup(r.text, "lxml")
120
- for res in soup.select(".result")[:limit * 2]:
121
- a = res.select_one(".result__a") or res.select_one("a.result__url") or res.find("a", href=True)
 
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 href.startswith("//duckduckgo.com/l/?"):
127
- # DDG redirect URLs often include uddg=<encoded>; keep as fallback if parsing fails.
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
- pass
137
- # Fetch excerpts from top pages; this makes output much less generic.
138
- enriched = []
139
- for item in results[:4]:
140
- excerpt = item.get("snippet", "")
141
- try:
142
- page = scrape_any_url(item["url"])
143
- text = page.get("summary") or page.get("text", "")
144
- text = _clean_text(text)[:650]
145
- if text:
146
- excerpt = text
147
- except Exception:
148
- pass
149
- enriched.append({**item, "excerpt": excerpt})
150
- if not enriched and results:
151
- enriched = [{**x, "excerpt": x.get("snippet", "")} for x in results]
152
- context = "\n".join([f"- {x.get('title') or _domain(x.get('url',''))} ({_domain(x.get('url',''))}): {x.get('excerpt','')}" for x in enriched if x.get("excerpt") or x.get("title")])
 
 
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; nếu nguồn không đủ, nói rõ góc nhìn là tổng quan.
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 or 'Không lấy được nguồn cụ thể; hãy viết tổng quan, tránh khẳng định số liệu hoặc sự kiện cụ thể.'}
220
  """
221
- text = await qwen_generate(prompt, image_url=image, max_tokens=1500)
222
  if not text:
223
- source_lines = "\n".join([f"• {s.get('title') or _domain(s.get('url',''))} ({_domain(s.get('url',''))})" for s in sources[:4]])
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 = _load_ai_wall(); posts.insert(0, post); _save_ai_wall(posts)
227
- return JSONResponse({"post": post})
228
 
229
 
230
  @app.post("/api/ai/url")
231
  async def api_ai_url(request: Request):
232
- body = await request.json()
233
- url = _clean_text(body.get("url", ""))
234
- if not url.startswith("http"):
235
- return JSONResponse({"error":"missing url"}, status_code=400)
236
- try:
237
- data = scrape_any_url(url)
238
- except Exception:
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[:15000]}
256
  """
257
- text = await qwen_generate(prompt, image_url=data.get("image") or None, max_tokens=1700)
258
  if not text:
259
- text = fallback_rewrite(data.get("title", "Bài viết"), raw)
260
- 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]}])
261
- posts = _load_ai_wall(); posts.insert(0, post); _save_ai_wall(posts)
262
- return JSONResponse({"post": post})
263
 
264
 
265
  @app.post("/api/rewrite_share")
266
  async def api_rewrite_share(request: Request):
267
- body = await request.json()
268
- url = _clean_text(body.get("url", ""))
269
- if not url.startswith("http"):
270
- return JSONResponse({"error":"missing url"}, status_code=400)
271
- try:
272
- data = scrape_any_url(url)
273
- except Exception:
274
- return JSONResponse({"error":"Không đọc được bài viết"}, status_code=422)
275
- raw = (data.get("summary", "") + "\n" + data.get("text", "")).strip()
276
- 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:\n\nTiêu đề: {data.get('title','')}\n\nNội dung:\n{raw[:15000]}"
277
- text = await qwen_generate(prompt, image_url=data.get("image") or None, max_tokens=1700)
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 = [r for r in app.router.routes if not (getattr(r, 'path', None) == '/' and 'GET' in getattr(r, 'methods', set()))]
287
 
288
- AI_INJECT = r'''
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(){const keep=currentInputs();await oldLoad.apply(this,arguments);setTimeout(()=>refreshAiWall(),50);};}
303
  setTimeout(refreshAiWall,1200);
304
  })();
305
  </script>
306
  '''
307
 
308
- @app.get("/")
309
  async def index_ai():
310
- with open("/app/static/index.html", "r", encoding="utf-8") as f:
311
- html = f.read()
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>'))