Spaces:
Running
Running
Strict article images, per-slide images, Vietnamese TTS fallback, no-reload posts, share AI
Browse files- ai_runtime_final.py +197 -166
ai_runtime_final.py
CHANGED
|
@@ -1,37 +1,26 @@
|
|
| 1 |
-
"""Final runtime overrides for VNEWS AI UI and
|
| 2 |
-
import os, re, requests, subprocess
|
| 3 |
-
from urllib.parse import urlparse
|
| 4 |
import ai_runtime as rt
|
| 5 |
from ai_runtime import app
|
| 6 |
import ai_ext as base
|
| 7 |
-
from fastapi import Request
|
| 8 |
-
from fastapi.responses import HTMLResponse, JSONResponse
|
| 9 |
try:
|
| 10 |
from PIL import Image, ImageDraw, ImageFont
|
| 11 |
except Exception:
|
| 12 |
Image = ImageDraw = ImageFont = None
|
| 13 |
|
| 14 |
RESTORE_INDEX_URL = "https://huggingface.co/spaces/bep40/vnews/raw/restore-33c3dda/static/index.html"
|
|
|
|
|
|
|
| 15 |
|
|
|
|
| 16 |
VN_VOICES = {
|
| 17 |
-
|
| 18 |
-
"nu": "vi-VN-HoaiMyNeural",
|
| 19 |
-
"
|
| 20 |
-
"
|
| 21 |
-
"vi-female-hoaimy": "vi-VN-HoaiMyNeural",
|
| 22 |
-
"nam": "vi-VN-NamMinhNeural",
|
| 23 |
-
"male": "vi-VN-NamMinhNeural",
|
| 24 |
-
"namminh": "vi-VN-NamMinhNeural",
|
| 25 |
-
"vi-male-namminh": "vi-VN-NamMinhNeural",
|
| 26 |
-
# “multilanguage” choices for richer short narration; Vietnamese text is still read, but accent/style may differ.
|
| 27 |
-
"en-jenny": "en-US-JennyMultilingualNeural",
|
| 28 |
-
"en-guy": "en-US-GuyNeural",
|
| 29 |
-
"zh-xiaoxiao": "zh-CN-XiaoxiaoNeural",
|
| 30 |
-
"ja-nanami": "ja-JP-NanamiNeural",
|
| 31 |
-
"ko-sunhi": "ko-KR-SunHiNeural",
|
| 32 |
-
"fr-denise": "fr-FR-DeniseNeural",
|
| 33 |
-
"de-katja": "de-DE-KatjaNeural",
|
| 34 |
-
"es-elvira": "es-ES-ElviraNeural",
|
| 35 |
}
|
| 36 |
|
| 37 |
|
|
@@ -41,10 +30,8 @@ def clean(s):
|
|
| 41 |
|
| 42 |
|
| 43 |
def _domain(url):
|
| 44 |
-
try:
|
| 45 |
-
|
| 46 |
-
except Exception:
|
| 47 |
-
return ''
|
| 48 |
|
| 49 |
|
| 50 |
def _strip_bullet_prefix(s):
|
|
@@ -52,34 +39,19 @@ def _strip_bullet_prefix(s):
|
|
| 52 |
|
| 53 |
|
| 54 |
def _source_badge_url_first(post):
|
| 55 |
-
"""Badge source must be the domain of the original URL, e.g. tienphong.vn."""
|
| 56 |
d=_domain(post.get('url',''))
|
| 57 |
if d:return d
|
| 58 |
for s in post.get('sources') or []:
|
| 59 |
d=_domain(s.get('url',''))
|
| 60 |
if d:return d
|
| 61 |
-
via=s.get('via')
|
| 62 |
-
if via and via not in ('direct','jina','generic'):
|
| 63 |
-
return via
|
| 64 |
return 'VNEWS'
|
| 65 |
|
| 66 |
|
| 67 |
-
def _draw_center(draw, lines, font, y, fill, W, line_h):
|
| 68 |
-
for ln in lines:
|
| 69 |
-
try:
|
| 70 |
-
box=draw.textbbox((0,0),ln,font=font);tw=box[2]-box[0]
|
| 71 |
-
except Exception:
|
| 72 |
-
tw=len(ln)*24
|
| 73 |
-
draw.text((max(30,(W-tw)//2),y),ln,fill=fill,font=font)
|
| 74 |
-
y+=line_h
|
| 75 |
-
return y
|
| 76 |
-
|
| 77 |
-
|
| 78 |
def _abs_url(src, base_url):
|
| 79 |
if not src:return ''
|
| 80 |
src=src.strip()
|
| 81 |
if src.startswith('//'):return 'https:'+src
|
| 82 |
-
if src.startswith('/'):
|
| 83 |
try:
|
| 84 |
p=urlparse(base_url);return f'{p.scheme}://{p.netloc}{src}'
|
| 85 |
except Exception:return src
|
|
@@ -87,161 +59,171 @@ def _abs_url(src, base_url):
|
|
| 87 |
|
| 88 |
|
| 89 |
def _article_content_block(soup):
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
cls=' '.join(el.get('class',[])); eid=el.get('id','')
|
| 97 |
-
if bad_re.search(cls) or bad_re.search(eid):
|
| 98 |
el.decompose()
|
| 99 |
-
selectors=[
|
| 100 |
-
'article', 'main article', 'main',
|
| 101 |
-
'.article-content','.article__body','.article-body','.article-detail','.detail-content','.content-detail',
|
| 102 |
-
'.singular-content','.news-content','.post-content','.entry-content','.knc-content',
|
| 103 |
-
'[class*=article-content]','[class*=detail-content]','[class*=singular-content]'
|
| 104 |
-
]
|
| 105 |
for sel in selectors:
|
| 106 |
el=soup.select_one(sel)
|
| 107 |
-
if el and (len(el.find_all('p'))>=2 or len(el.find_all('img'))>=1):
|
| 108 |
-
return el
|
| 109 |
-
# fallback: score by paragraphs but avoid huge whole-body related sections
|
| 110 |
best=None;score=0
|
| 111 |
for el in soup.find_all(['article','main','section','div']):
|
| 112 |
-
ps=el.find_all('p')
|
| 113 |
-
|
| 114 |
-
txt=' '.join(p.get_text(' ',strip=True) for p in ps)
|
| 115 |
-
sc=len(ps)*100+len(imgs)*20+min(len(txt),5000)
|
| 116 |
cls=' '.join(el.get('class',[])).lower()
|
| 117 |
-
if any(k in cls for k in ['article','content','detail','post','entry']):sc+=
|
| 118 |
-
if sc>score:
|
| 119 |
-
best=el;score=sc
|
| 120 |
return best or soup
|
| 121 |
|
| 122 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
def _article_only_images(url):
|
| 124 |
-
"""Collect images only inside
|
| 125 |
imgs=[]
|
| 126 |
try:
|
| 127 |
from bs4 import BeautifulSoup
|
| 128 |
r=requests.get(url,headers=getattr(base,'HEADERS',{}),timeout=18);r.encoding='utf-8'
|
| 129 |
soup=BeautifulSoup(r.text,'lxml')
|
| 130 |
block=_article_content_block(soup)
|
| 131 |
-
# Prefer figure/picture images inside content.
|
| 132 |
candidates=[]
|
| 133 |
-
|
| 134 |
-
|
|
|
|
| 135 |
if im:candidates.append(im)
|
|
|
|
|
|
|
| 136 |
seen=set()
|
| 137 |
for im in candidates:
|
| 138 |
src=(im.get('data-src') or im.get('data-original') or im.get('data-lazy-src') or im.get('data-srcset') or im.get('srcset') or im.get('src') or '')
|
| 139 |
if ',' in src:src=src.split(',')[0].strip().split(' ')[0]
|
| 140 |
else:src=src.strip().split(' ')[0]
|
| 141 |
src=_abs_url(src,url)
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
try:
|
| 147 |
-
w=int(im.get('width') or 0);h=int(im.get('height') or 0)
|
| 148 |
-
if (w and w<180) or (h and h<120):continue
|
| 149 |
-
except Exception:pass
|
| 150 |
seen.add(src);imgs.append(src)
|
| 151 |
if len(imgs)>=20:break
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
|
| 156 |
|
| 157 |
def _scrape_url_article_only(url):
|
| 158 |
data=base.scrape_any_url(url)
|
| 159 |
imgs=_article_only_images(url)
|
| 160 |
-
|
| 161 |
-
if not imgs:
|
| 162 |
-
def add(u):
|
| 163 |
-
u=_abs_url(u,url)
|
| 164 |
-
if u and 'base64' not in u and u not in imgs:imgs.append(u)
|
| 165 |
-
add(data.get('image') or data.get('og_image') or data.get('img'))
|
| 166 |
-
for u in data.get('images') or []:add(u)
|
| 167 |
-
data['images']=imgs[:20]
|
| 168 |
if imgs:data['image']=imgs[0]
|
|
|
|
| 169 |
return data
|
| 170 |
|
| 171 |
|
| 172 |
-
def
|
| 173 |
-
|
| 174 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
try:
|
| 176 |
-
|
| 177 |
-
return
|
| 178 |
except Exception:
|
| 179 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
try:
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
|
| 185 |
|
| 186 |
def final_make_frame(post,seg,idx,total,img_path,out_path):
|
| 187 |
-
if Image is None:
|
| 188 |
-
|
| 189 |
-
W,H=1080,1920;hero_h=760
|
| 190 |
-
bg=Image.new('RGB',(W,H),(12,12,12))
|
| 191 |
try:
|
| 192 |
-
im=Image.open(img_path).convert('RGB')
|
| 193 |
-
ratio
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
nw=W;nh=int(nw/ratio)
|
| 198 |
-
im=im.resize((nw,nh));left=(nw-W)//2;top=(nh-hero_h)//2
|
| 199 |
-
bg.paste(im.crop((left,top,left+W,top+hero_h)),(0,0))
|
| 200 |
-
except Exception:
|
| 201 |
-
pass
|
| 202 |
draw=ImageDraw.Draw(bg)
|
| 203 |
try:
|
| 204 |
-
fb=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',58)
|
| 205 |
-
|
| 206 |
-
fs=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',30)
|
| 207 |
-
fsmall=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',28)
|
| 208 |
-
except Exception:
|
| 209 |
-
fb=ft=fs=fsmall=None
|
| 210 |
badge='Nguồn: '+_source_badge_url_first(post)
|
| 211 |
-
try:
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
bw=len(badge)*16;bh=34
|
| 215 |
-
bx=W-bw-42;by=24
|
| 216 |
-
draw.rounded_rectangle((bx-16,by-8,W-24,by+bh+14),radius=18,fill=(0,0,0))
|
| 217 |
-
draw.text((bx,by),badge,fill=(255,255,255),font=fsmall)
|
| 218 |
draw.rectangle((0,hero_h-20,W,H),fill=(12,12,12))
|
| 219 |
total=max(1,total);total_w=total*38-14;start=(W-total_w)//2
|
| 220 |
-
for i in range(total):
|
| 221 |
-
fill=(92,184,122) if i==idx else (70,70,70)
|
| 222 |
-
draw.rounded_rectangle((start+i*38,820,start+i*38+24,832),radius=6,fill=fill)
|
| 223 |
brand='VNEWS AI SHORT'
|
| 224 |
-
try:
|
| 225 |
-
bb=draw.textbbox((0,0),brand,font=ft);tx=(W-(bb[2]-bb[0]))//2
|
| 226 |
except Exception:tx=360
|
| 227 |
draw.text((tx,870),brand,fill=(110,231,143),font=ft)
|
| 228 |
-
seg=_strip_bullet_prefix(seg)
|
| 229 |
-
|
| 230 |
-
block_h=len(lines)*74;y=max(980,1250-block_h//2)
|
| 231 |
-
_draw_center(draw,lines,fb,y,(255,255,255),W,74)
|
| 232 |
-
title_lines=rt.wrap_text(draw,_strip_bullet_prefix(post.get('title','')),fs,W-120,3)
|
| 233 |
-
y2=1640
|
| 234 |
-
draw.line((80,y2-26,W-80,y2-26),fill=(70,70,70),width=2)
|
| 235 |
-
_draw_center(draw,title_lines,fs,y2,(220,220,220),W,42)
|
| 236 |
bg.save(out_path,quality=92)
|
| 237 |
|
| 238 |
-
# Monkey patches
|
| 239 |
-
rt.make_frame
|
| 240 |
-
rt.make_tts = final_make_tts
|
| 241 |
-
rt._source_badge = _source_badge_url_first
|
| 242 |
|
| 243 |
-
# Override
|
| 244 |
-
_PATCH={('/api/url_wall','POST'),('/api/rewrite_share','POST'),('/api/ai/url','POST'),('/','GET')}
|
| 245 |
app.router.routes=[r for r in app.router.routes if not any(getattr(r,'path',None)==p and m in getattr(r,'methods',set()) for p,m in _PATCH)]
|
| 246 |
|
| 247 |
@app.post('/api/url_wall')
|
|
@@ -257,7 +239,7 @@ async def final_url_wall(request:Request):
|
|
| 257 |
Yêu cầu:
|
| 258 |
- Chỉ tóm tắt nội dung chính, không viết lại toàn bộ bài.
|
| 259 |
- Ngắn gọn, cụ thể, dễ hiểu.
|
| 260 |
-
- Không lặp
|
| 261 |
- Tối đa 5 ý chính hoặc 2 đoạn ngắn.
|
| 262 |
- Hạn chế dùng dấu đầu dòng.
|
| 263 |
|
|
@@ -271,36 +253,87 @@ Nội dung gốc:
|
|
| 271 |
src=[{'title':data.get('title'), 'url':url, 'excerpt':raw[:500], 'via':_domain(url)}]
|
| 272 |
if 'Nguồn tham khảo:' not in text:text+='\n\n'+rt.source_line(src)
|
| 273 |
imgs=data.get('images') or []
|
| 274 |
-
post=base.make_post(data.get('title') or 'Bài viết',text,imgs[0] if imgs else
|
| 275 |
post['images']=imgs
|
| 276 |
posts=base._load_ai_wall();posts.insert(0,post);base._save_ai_wall(posts)
|
| 277 |
return JSONResponse({'post':post})
|
| 278 |
|
| 279 |
@app.post('/api/rewrite_share')
|
| 280 |
-
async def final_rewrite_share(request:Request):
|
| 281 |
-
return await final_url_wall(request)
|
| 282 |
-
|
| 283 |
@app.post('/api/ai/url')
|
| 284 |
-
async def final_ai_url(request:Request):
|
| 285 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 286 |
|
| 287 |
FINAL_INJECT = r'''
|
| 288 |
<style>
|
| 289 |
-
|
| 290 |
-
#ai-topic-input{display:none!important}button[onclick*="createTopicPost"],*[onclick*="createTopicPost"]{display:none!important}.ai-topic-row,.topic-row,.ai-compose-topic{display:none!important}
|
| 291 |
-
.ai-compose{width:calc(100% - 8px)!important}.ai-compose-row:has(#ai-url-input){display:flex!important;flex-direction:column!important;gap:8px!important;width:100%!important}.ai-compose-row:has(#ai-url-input) button{width:100%!important;display:block!important}.ai-compose-row:has(#ai-url-input) input,#ai-url-input{display:block!important;width:100%!important;max-width:none!important;flex:1 1 100%!important;box-sizing:border-box!important}.ai-url-only-note{font-size:11px;color:#888;margin:5px 0 8px;width:100%}.ai-wall-gallery{display:grid;grid-template-columns:repeat(2,1fr);gap:6px;margin:10px 0}.ai-wall-gallery img{width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:8px;background:#222}.ai-wall-gallery img:first-child{grid-column:1/-1}
|
| 292 |
</style>
|
| 293 |
<script>
|
| 294 |
(function(){
|
| 295 |
function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));}
|
| 296 |
-
|
| 297 |
-
function
|
| 298 |
-
|
| 299 |
-
|
|
|
|
| 300 |
function galleryHtml(p){let imgs=(p.images||[]).filter(Boolean);if(!imgs.length&&p.img)imgs=[p.img];if(!imgs.length)return '';return '<div class="ai-wall-gallery">'+imgs.slice(0,12).map(u=>`<img src="${esc(u)}" loading="lazy">`).join('')+'</div>';}
|
| 301 |
-
async function
|
| 302 |
-
|
| 303 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 304 |
})();
|
| 305 |
</script>
|
| 306 |
'''
|
|
@@ -314,12 +347,10 @@ def _load_index_html():
|
|
| 314 |
r=requests.get(RESTORE_INDEX_URL,timeout=20)
|
| 315 |
if r.status_code==200 and '<!DOCTYPE html>' in r.text:html=r.text
|
| 316 |
except Exception:pass
|
| 317 |
-
if '<!DOCTYPE html>' not in html:
|
| 318 |
-
html='<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>VNEWS</title></head><body><div id="view-home">VNEWS</div></body></html>'
|
| 319 |
return html
|
| 320 |
|
| 321 |
@app.get('/')
|
| 322 |
async def index_final():
|
| 323 |
-
html=_load_index_html()
|
| 324 |
-
body=getattr(rt.old,'PATCH_INJECT','') + FINAL_INJECT
|
| 325 |
return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
|
|
|
|
| 1 |
+
"""Final runtime overrides for VNEWS AI UI, article-only images, shareable AI wall, and robust Vietnamese shorts."""
|
| 2 |
+
import os, re, requests, subprocess, time
|
| 3 |
+
from urllib.parse import urlparse, quote
|
| 4 |
import ai_runtime as rt
|
| 5 |
from ai_runtime import app
|
| 6 |
import ai_ext as base
|
| 7 |
+
from fastapi import Request, Query
|
| 8 |
+
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
|
| 9 |
try:
|
| 10 |
from PIL import Image, ImageDraw, ImageFont
|
| 11 |
except Exception:
|
| 12 |
Image = ImageDraw = ImageFont = None
|
| 13 |
|
| 14 |
RESTORE_INDEX_URL = "https://huggingface.co/spaces/bep40/vnews/raw/restore-33c3dda/static/index.html"
|
| 15 |
+
SPACE_URL = "https://bep40-vnews.hf.space"
|
| 16 |
+
DEFAULT_IMG = "https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg"
|
| 17 |
|
| 18 |
+
# Only voices that support Vietnamese reliably. Extra labels map to these Vietnamese neural voices.
|
| 19 |
VN_VOICES = {
|
| 20 |
+
"nu": "vi-VN-HoaiMyNeural", "female": "vi-VN-HoaiMyNeural", "hoaimy": "vi-VN-HoaiMyNeural",
|
| 21 |
+
"nu-tre": "vi-VN-HoaiMyNeural", "nu-truyen-cam": "vi-VN-HoaiMyNeural", "nu-tin-nhanh": "vi-VN-HoaiMyNeural",
|
| 22 |
+
"nam": "vi-VN-NamMinhNeural", "male": "vi-VN-NamMinhNeural", "namminh": "vi-VN-NamMinhNeural",
|
| 23 |
+
"nam-tram": "vi-VN-NamMinhNeural", "nam-ban-tin": "vi-VN-NamMinhNeural", "nam-nang-dong": "vi-VN-NamMinhNeural",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
}
|
| 25 |
|
| 26 |
|
|
|
|
| 30 |
|
| 31 |
|
| 32 |
def _domain(url):
|
| 33 |
+
try:return urlparse(url or '').netloc.replace('www.','')
|
| 34 |
+
except Exception:return ''
|
|
|
|
|
|
|
| 35 |
|
| 36 |
|
| 37 |
def _strip_bullet_prefix(s):
|
|
|
|
| 39 |
|
| 40 |
|
| 41 |
def _source_badge_url_first(post):
|
|
|
|
| 42 |
d=_domain(post.get('url',''))
|
| 43 |
if d:return d
|
| 44 |
for s in post.get('sources') or []:
|
| 45 |
d=_domain(s.get('url',''))
|
| 46 |
if d:return d
|
|
|
|
|
|
|
|
|
|
| 47 |
return 'VNEWS'
|
| 48 |
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
def _abs_url(src, base_url):
|
| 51 |
if not src:return ''
|
| 52 |
src=src.strip()
|
| 53 |
if src.startswith('//'):return 'https:'+src
|
| 54 |
+
if src.startswith('/'):
|
| 55 |
try:
|
| 56 |
p=urlparse(base_url);return f'{p.scheme}://{p.netloc}{src}'
|
| 57 |
except Exception:return src
|
|
|
|
| 59 |
|
| 60 |
|
| 61 |
def _article_content_block(soup):
|
| 62 |
+
for tag in soup.find_all(['script','style','nav','footer','aside','form','noscript','iframe']):tag.decompose()
|
| 63 |
+
# Aggressively remove related/ad/recommend containers before image collection.
|
| 64 |
+
bad_re=re.compile(r'(related|relate|recommend|suggest|sidebar|ads|advert|popular|more|xem-them|xemthem|tin-lien-quan|tinlienquan|doc-them|docthem|other-news|news-other|article-related|box-tin|box_related|story-related|recommend-news|same-category|cate-list|news-list|most-view|banner|qc|quang-cao|sponsor)',re.I)
|
| 65 |
+
for el in list(soup.find_all(True)):
|
| 66 |
+
cls=' '.join(el.get('class',[])); eid=el.get('id',''); role=el.get('role','')
|
| 67 |
+
if bad_re.search(cls) or bad_re.search(eid) or bad_re.search(role):
|
|
|
|
|
|
|
| 68 |
el.decompose()
|
| 69 |
+
selectors=['article','main article','.article-content','.article__body','.article-body','.article-detail','.detail-content','.content-detail','.singular-content','.news-content','.post-content','.entry-content','.knc-content','.fck_detail','.cms-body','.story-body','[class*=article-content]','[class*=detail-content]','[class*=singular-content]']
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
for sel in selectors:
|
| 71 |
el=soup.select_one(sel)
|
| 72 |
+
if el and (len(el.find_all('p'))>=2 or len(el.find_all(['figure','picture','img']))>=1):return el
|
|
|
|
|
|
|
| 73 |
best=None;score=0
|
| 74 |
for el in soup.find_all(['article','main','section','div']):
|
| 75 |
+
ps=el.find_all('p');imgs=el.find_all('img');txt=' '.join(p.get_text(' ',strip=True) for p in ps)
|
| 76 |
+
sc=len(ps)*120+len(imgs)*10+min(len(txt),4500)
|
|
|
|
|
|
|
| 77 |
cls=' '.join(el.get('class',[])).lower()
|
| 78 |
+
if any(k in cls for k in ['article','content','detail','post','entry','story']):sc+=800
|
| 79 |
+
if sc>score:best=el;score=sc
|
|
|
|
| 80 |
return best or soup
|
| 81 |
|
| 82 |
|
| 83 |
+
def _image_is_likely_article(im, src):
|
| 84 |
+
low=(src or '').lower()
|
| 85 |
+
if not src or src.startswith('data:') or 'base64' in low:return False
|
| 86 |
+
if any(x in low for x in ['logo','icon','avatar','sprite','banner','ads','advert','tracking','pixel','social','share','author','thumb-related']):return False
|
| 87 |
+
alt=(im.get('alt') or im.get('title') or '').lower()
|
| 88 |
+
if any(x in alt for x in ['logo','avatar','quảng cáo','advertisement','banner']):return False
|
| 89 |
+
try:
|
| 90 |
+
w=int(re.sub(r'\D','',str(im.get('width') or '0')) or 0);h=int(re.sub(r'\D','',str(im.get('height') or '0')) or 0)
|
| 91 |
+
if (w and w<220) or (h and h<140):return False
|
| 92 |
+
except Exception:pass
|
| 93 |
+
return True
|
| 94 |
+
|
| 95 |
+
|
| 96 |
def _article_only_images(url):
|
| 97 |
+
"""Collect images only inside main article content. If uncertain, return fewer/no images rather than related/ad images."""
|
| 98 |
imgs=[]
|
| 99 |
try:
|
| 100 |
from bs4 import BeautifulSoup
|
| 101 |
r=requests.get(url,headers=getattr(base,'HEADERS',{}),timeout=18);r.encoding='utf-8'
|
| 102 |
soup=BeautifulSoup(r.text,'lxml')
|
| 103 |
block=_article_content_block(soup)
|
|
|
|
| 104 |
candidates=[]
|
| 105 |
+
# Prefer figure/picture under article body; then direct img in body.
|
| 106 |
+
for el in block.find_all(['figure','picture'],recursive=True):
|
| 107 |
+
im=el.find('img')
|
| 108 |
if im:candidates.append(im)
|
| 109 |
+
for im in block.find_all('img',recursive=True):
|
| 110 |
+
if im not in candidates:candidates.append(im)
|
| 111 |
seen=set()
|
| 112 |
for im in candidates:
|
| 113 |
src=(im.get('data-src') or im.get('data-original') or im.get('data-lazy-src') or im.get('data-srcset') or im.get('srcset') or im.get('src') or '')
|
| 114 |
if ',' in src:src=src.split(',')[0].strip().split(' ')[0]
|
| 115 |
else:src=src.strip().split(' ')[0]
|
| 116 |
src=_abs_url(src,url)
|
| 117 |
+
if src in seen or not _image_is_likely_article(im,src):continue
|
| 118 |
+
# parent text guard: skip images from any remaining related block
|
| 119 |
+
parent_txt=' '.join((im.parent.get('class',[]) if im.parent else []))+' '+(im.parent.get('id','') if im.parent else '')
|
| 120 |
+
if re.search(r'(related|recommend|tin-lien-quan|doc-them|xem-them|popular|ads|banner)',parent_txt,re.I):continue
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
seen.add(src);imgs.append(src)
|
| 122 |
if len(imgs)>=20:break
|
| 123 |
+
# Use og:image ONLY as article main image fallback when no body image found.
|
| 124 |
+
if not imgs:
|
| 125 |
+
og=soup.find('meta',property='og:image') or soup.find('meta',attrs={'name':'twitter:image'})
|
| 126 |
+
if og:
|
| 127 |
+
src=_abs_url(og.get('content',''),url)
|
| 128 |
+
if src and 'logo' not in src.lower() and 'banner' not in src.lower():imgs.append(src)
|
| 129 |
+
except Exception:pass
|
| 130 |
+
return imgs[:20]
|
| 131 |
|
| 132 |
|
| 133 |
def _scrape_url_article_only(url):
|
| 134 |
data=base.scrape_any_url(url)
|
| 135 |
imgs=_article_only_images(url)
|
| 136 |
+
data['images']=imgs
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
if imgs:data['image']=imgs[0]
|
| 138 |
+
else:data['image']=''
|
| 139 |
return data
|
| 140 |
|
| 141 |
|
| 142 |
+
def _blank_image(path, title='VNEWS'):
|
| 143 |
+
if Image is None:return None
|
| 144 |
+
im=Image.new('RGB',(1080,760),(24,48,36));draw=ImageDraw.Draw(im)
|
| 145 |
+
try:f=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',48)
|
| 146 |
+
except Exception:f=None
|
| 147 |
+
draw.text((60,330),clean(title)[:40] or 'VNEWS',fill=(255,255,255),font=f)
|
| 148 |
+
im.save(path,quality=90);return path
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def _download_image_safe(url, fallback_title, out_path):
|
| 152 |
+
if url:
|
| 153 |
+
try:
|
| 154 |
+
r=requests.get(url,headers=getattr(base,'HEADERS',{}),timeout=18)
|
| 155 |
+
if r.status_code==200 and len(r.content)>1200:
|
| 156 |
+
with open(out_path,'wb') as f:f.write(r.content)
|
| 157 |
+
# verify PIL opens it
|
| 158 |
+
if Image:
|
| 159 |
+
Image.open(out_path).verify()
|
| 160 |
+
return out_path
|
| 161 |
+
except Exception:pass
|
| 162 |
try:
|
| 163 |
+
return base._download_image('',fallback_title,out_path)
|
|
|
|
| 164 |
except Exception:
|
| 165 |
+
return _blank_image(out_path,fallback_title)
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def final_make_tts(text,voice,out_path):
|
| 169 |
+
text=_strip_bullet_prefix(text) or 'Bản tin VNEWS.'
|
| 170 |
+
# Only Vietnamese voices. Unknown choices fall back to Vietnamese female.
|
| 171 |
+
edge_voice=VN_VOICES.get(str(voice or '').lower().strip(), 'vi-VN-HoaiMyNeural')
|
| 172 |
+
for ev in [edge_voice, 'vi-VN-HoaiMyNeural', 'vi-VN-NamMinhNeural']:
|
| 173 |
try:
|
| 174 |
+
subprocess.run(['python','-m','edge_tts','--voice',ev,'--text',text,'--write-media',out_path],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=180)
|
| 175 |
+
if os.path.exists(out_path) and os.path.getsize(out_path)>1000:return out_path
|
| 176 |
+
except Exception:pass
|
| 177 |
+
try:
|
| 178 |
+
base.gTTS(text,lang='vi',tld='com.vn',slow=False).save(out_path)
|
| 179 |
+
if os.path.exists(out_path) and os.path.getsize(out_path)>1000:return out_path
|
| 180 |
+
except Exception:pass
|
| 181 |
+
# Last-resort silent audio guarantees short generation succeeds.
|
| 182 |
+
subprocess.run(['ffmpeg','-y','-f','lavfi','-i','anullsrc=channel_layout=stereo:sample_rate=44100','-t','3','-q:a','9','-acodec','libmp3lame',out_path],stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=30)
|
| 183 |
+
return out_path
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def _draw_center(draw, lines, font, y, fill, W, line_h):
|
| 187 |
+
for ln in lines:
|
| 188 |
+
try:box=draw.textbbox((0,0),ln,font=font);tw=box[2]-box[0]
|
| 189 |
+
except Exception:tw=len(ln)*24
|
| 190 |
+
draw.text((max(30,(W-tw)//2),y),ln,fill=fill,font=font);y+=line_h
|
| 191 |
+
return y
|
| 192 |
|
| 193 |
|
| 194 |
def final_make_frame(post,seg,idx,total,img_path,out_path):
|
| 195 |
+
if Image is None:return rt.make_frame(post,seg,idx,total,img_path,out_path)
|
| 196 |
+
W,H=1080,1920;hero_h=760;bg=Image.new('RGB',(W,H),(12,12,12))
|
|
|
|
|
|
|
| 197 |
try:
|
| 198 |
+
im=Image.open(img_path).convert('RGB');ratio=im.width/max(1,im.height);tr=W/hero_h
|
| 199 |
+
if ratio>tr:nh=hero_h;nw=int(nh*ratio)
|
| 200 |
+
else:nw=W;nh=int(nw/ratio)
|
| 201 |
+
im=im.resize((nw,nh));left=(nw-W)//2;top=(nh-hero_h)//2;bg.paste(im.crop((left,top,left+W,top+hero_h)),(0,0))
|
| 202 |
+
except Exception:pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
draw=ImageDraw.Draw(bg)
|
| 204 |
try:
|
| 205 |
+
fb=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',58);ft=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',38);fs=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',30);fsmall=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',28)
|
| 206 |
+
except Exception:fb=ft=fs=fsmall=None
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
badge='Nguồn: '+_source_badge_url_first(post)
|
| 208 |
+
try:b=draw.textbbox((0,0),badge,font=fsmall);bw=b[2]-b[0];bh=b[3]-b[1]
|
| 209 |
+
except Exception:bw=len(badge)*16;bh=34
|
| 210 |
+
bx=W-bw-42;by=24;draw.rounded_rectangle((bx-16,by-8,W-24,by+bh+14),radius=18,fill=(0,0,0));draw.text((bx,by),badge,fill=(255,255,255),font=fsmall)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
draw.rectangle((0,hero_h-20,W,H),fill=(12,12,12))
|
| 212 |
total=max(1,total);total_w=total*38-14;start=(W-total_w)//2
|
| 213 |
+
for i in range(total):draw.rounded_rectangle((start+i*38,820,start+i*38+24,832),radius=6,fill=(92,184,122) if i==idx else (70,70,70))
|
|
|
|
|
|
|
| 214 |
brand='VNEWS AI SHORT'
|
| 215 |
+
try:bb=draw.textbbox((0,0),brand,font=ft);tx=(W-(bb[2]-bb[0]))//2
|
|
|
|
| 216 |
except Exception:tx=360
|
| 217 |
draw.text((tx,870),brand,fill=(110,231,143),font=ft)
|
| 218 |
+
seg=_strip_bullet_prefix(seg);lines=rt.wrap_text(draw,seg,fb,W-120,8);y=max(980,1250-(len(lines)*74)//2);_draw_center(draw,lines,fb,y,(255,255,255),W,74)
|
| 219 |
+
title_lines=rt.wrap_text(draw,_strip_bullet_prefix(post.get('title','')),fs,W-120,3);y2=1640;draw.line((80,y2-26,W-80,y2-26),fill=(70,70,70),width=2);_draw_center(draw,title_lines,fs,y2,(220,220,220),W,42)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
bg.save(out_path,quality=92)
|
| 221 |
|
| 222 |
+
# Monkey patches for old functions.
|
| 223 |
+
rt.make_frame=final_make_frame;rt.make_tts=final_make_tts;rt._source_badge=_source_badge_url_first
|
|
|
|
|
|
|
| 224 |
|
| 225 |
+
# Override endpoints.
|
| 226 |
+
_PATCH={('/api/url_wall','POST'),('/api/rewrite_share','POST'),('/api/ai/url','POST'),('/api/ai/short/{post_id}','POST'),('/','GET'),('/aw','GET')}
|
| 227 |
app.router.routes=[r for r in app.router.routes if not any(getattr(r,'path',None)==p and m in getattr(r,'methods',set()) for p,m in _PATCH)]
|
| 228 |
|
| 229 |
@app.post('/api/url_wall')
|
|
|
|
| 239 |
Yêu cầu:
|
| 240 |
- Chỉ tóm tắt nội dung chính, không viết lại toàn bộ bài.
|
| 241 |
- Ngắn gọn, cụ thể, dễ hiểu.
|
| 242 |
+
- Không lặp ý, không thêm chi tiết ngoài nguồn.
|
| 243 |
- Tối đa 5 ý chính hoặc 2 đoạn ngắn.
|
| 244 |
- Hạn chế dùng dấu đầu dòng.
|
| 245 |
|
|
|
|
| 253 |
src=[{'title':data.get('title'), 'url':url, 'excerpt':raw[:500], 'via':_domain(url)}]
|
| 254 |
if 'Nguồn tham khảo:' not in text:text+='\n\n'+rt.source_line(src)
|
| 255 |
imgs=data.get('images') or []
|
| 256 |
+
post=base.make_post(data.get('title') or 'Bài viết',text,imgs[0] if imgs else '',url,'url',sources=src)
|
| 257 |
post['images']=imgs
|
| 258 |
posts=base._load_ai_wall();posts.insert(0,post);base._save_ai_wall(posts)
|
| 259 |
return JSONResponse({'post':post})
|
| 260 |
|
| 261 |
@app.post('/api/rewrite_share')
|
| 262 |
+
async def final_rewrite_share(request:Request):return await final_url_wall(request)
|
|
|
|
|
|
|
| 263 |
@app.post('/api/ai/url')
|
| 264 |
+
async def final_ai_url(request:Request):return await final_url_wall(request)
|
| 265 |
+
|
| 266 |
+
@app.post('/api/ai/short/{post_id}')
|
| 267 |
+
async def final_short(post_id:str,request:Request):
|
| 268 |
+
try:body=await request.json()
|
| 269 |
+
except Exception:body={}
|
| 270 |
+
voice=str(body.get('voice','nu')).lower().strip();emotion=str(body.get('emotion','neutral')).lower().strip();speed=max(0.85,min(1.35,float(body.get('speed',1.2) or 1.2)))
|
| 271 |
+
posts=base._load_ai_wall();post=next((p for p in posts if str(p.get('id'))==str(post_id)),None)
|
| 272 |
+
if not post:return JSONResponse({'error':'post not found'},status_code=404)
|
| 273 |
+
segs=rt.split_segments(post,8) if hasattr(rt,'split_segments') else [_strip_bullet_prefix(post.get('text') or post.get('title') or 'VNEWS')]
|
| 274 |
+
imgs=[u for u in (post.get('images') or []) if u] or ([post.get('img')] if post.get('img') else [])
|
| 275 |
+
os.makedirs(base.SHORTS_DIR,exist_ok=True);suffix=f'_{voice}_{emotion}_{str(speed).replace(".","p")}_articleimgs_vivoice'
|
| 276 |
+
out=os.path.join(base.SHORTS_DIR,base._safe_name(post_id+suffix)+'.mp4')
|
| 277 |
+
if os.path.exists(out):
|
| 278 |
+
post['video']='/api/ai/short-file/'+post_id+suffix;base._save_ai_wall(posts);return JSONResponse({'video':post['video'],'segments':len(segs),'subtitles':False})
|
| 279 |
+
work=os.path.join(base.SHORTS_DIR,base._safe_name(post_id+suffix));os.makedirs(work,exist_ok=True)
|
| 280 |
+
clips=[]
|
| 281 |
+
try:
|
| 282 |
+
for i,seg in enumerate(segs):
|
| 283 |
+
img_url=imgs[i % len(imgs)] if imgs else ''
|
| 284 |
+
img=os.path.join(work,f'image_{i}.jpg');frame=os.path.join(work,f'f{i}.jpg');aud=os.path.join(work,f'a{i}.mp3');aud2=os.path.join(work,f'a{i}_fast.mp3');clip=os.path.join(work,f'c{i}.mp4')
|
| 285 |
+
_download_image_safe(img_url,post.get('title','AI news'),img)
|
| 286 |
+
seg=_strip_bullet_prefix(seg);final_make_frame(post,seg,i,len(segs),img,frame)
|
| 287 |
+
prefix={'urgent':'Tin nhanh.','warm':'Câu chuyện đáng chú ý.','serious':'Bản tin nghiêm túc.','energetic':'Cập nhật nổi bật.'}.get(emotion,'')
|
| 288 |
+
spoken=(prefix+' '+seg).strip() if i==0 and prefix else seg
|
| 289 |
+
final_make_tts(spoken,voice,aud)
|
| 290 |
+
try:subprocess.run(['ffmpeg','-y','-i',aud,'-filter:a',f'atempo={speed}','-vn',aud2],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=120)
|
| 291 |
+
except Exception:aud2=aud
|
| 292 |
+
try:
|
| 293 |
+
subprocess.run(['ffmpeg','-y','-loop','1','-i',frame,'-i',aud2,'-shortest','-c:v','libx264','-tune','stillimage','-pix_fmt','yuv420p','-c:a','aac','-b:a','128k','-vf','scale=1080:1920',clip],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=180)
|
| 294 |
+
except Exception:
|
| 295 |
+
# last-resort visual-only 4s clip
|
| 296 |
+
subprocess.run(['ffmpeg','-y','-loop','1','-t','4','-i',frame,'-f','lavfi','-i','anullsrc=channel_layout=stereo:sample_rate=44100','-shortest','-c:v','libx264','-pix_fmt','yuv420p','-c:a','aac','-vf','scale=1080:1920',clip],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=120)
|
| 297 |
+
clips.append(clip)
|
| 298 |
+
lf=os.path.join(work,'list.txt')
|
| 299 |
+
with open(lf,'w',encoding='utf-8') as f:
|
| 300 |
+
for c in clips:f.write("file '"+c.replace("'","'\\''")+"'\n")
|
| 301 |
+
subprocess.run(['ffmpeg','-y','-f','concat','-safe','0','-i',lf,'-c','copy',out],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=240)
|
| 302 |
+
post['video']='/api/ai/short-file/'+post_id+suffix;post['short_subtitles']=False;post['short_segments']=segs;post['short_speed']=speed;base._save_ai_wall(posts)
|
| 303 |
+
return JSONResponse({'video':post['video'],'segments':len(segs),'subtitles':False})
|
| 304 |
+
except Exception as e:return JSONResponse({'error':'Không tạo được shorts: '+str(e)[:220]},status_code=500)
|
| 305 |
+
|
| 306 |
+
@app.get('/aw')
|
| 307 |
+
def ai_wall_share(post:str=Query(default=''), short:int=Query(default=0)):
|
| 308 |
+
posts=base._load_ai_wall();p=next((x for x in posts if str(x.get('id'))==str(post)),None)
|
| 309 |
+
if not p:return HTMLResponse(f'<script>location.href="{SPACE_URL}"</script>')
|
| 310 |
+
title=p.get('title') or 'VNEWS AI';img=p.get('img') or DEFAULT_IMG
|
| 311 |
+
desc=(p.get('text') or '')[:220]
|
| 312 |
+
return HTMLResponse(f'''<!doctype html><html><head><meta charset="utf-8"><title>{title}</title><meta property="og:title" content="{title}"><meta property="og:description" content="{desc}"><meta property="og:image" content="{img}"><meta property="og:type" content="article"><meta name="twitter:card" content="summary_large_image"></head><body><script>localStorage.setItem('pending_ai_post','{post}');location.href='{SPACE_URL}'</script></body></html>''')
|
| 313 |
|
| 314 |
FINAL_INJECT = r'''
|
| 315 |
<style>
|
| 316 |
+
#ai-topic-input{display:none!important}button[onclick*="createTopicPost"],*[onclick*="createTopicPost"]{display:none!important}.ai-topic-row,.topic-row,.ai-compose-topic{display:none!important}.ai-compose{width:calc(100% - 8px)!important}.ai-compose-row:has(#ai-url-input){display:flex!important;flex-direction:column!important;gap:8px!important;width:100%!important}.ai-compose-row:has(#ai-url-input) button{width:100%!important;display:block!important}.ai-compose-row:has(#ai-url-input) input,#ai-url-input{display:block!important;width:100%!important;max-width:none!important;flex:1 1 100%!important;box-sizing:border-box!important}.ai-url-only-note{font-size:11px;color:#888;margin:5px 0 8px;width:100%}.ai-wall-gallery{display:grid;grid-template-columns:repeat(2,1fr);gap:6px;margin:10px 0}.ai-wall-gallery img{width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:8px;background:#222}.ai-wall-gallery img:first-child{grid-column:1/-1}.ai-wall-final{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}
|
|
|
|
|
|
|
| 317 |
</style>
|
| 318 |
<script>
|
| 319 |
(function(){
|
| 320 |
function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));}
|
| 321 |
+
let finalWall=[];
|
| 322 |
+
function hideTopicControls(){document.querySelectorAll('#ai-topic-input').forEach(e=>{let row=e.closest('.ai-compose-topic,.topic-row,.ai-topic-row')||e.parentElement;if(row&&!row.querySelector('#ai-url-input'))row.style.display='none';else e.style.display='none';});document.querySelectorAll('button,a').forEach(b=>{let t=(b.textContent||'').toLowerCase();let oc=b.getAttribute('onclick')||'';if(oc.includes('createTopicPost')||t.includes('chủ đề'))b.style.display='none';});let url=document.getElementById('ai-url-input');if(url&&!document.getElementById('ai-url-only-note')){let n=document.createElement('div');n.id='ai-url-only-note';n.className='ai-url-only-note';n.textContent='Dán URL bài viết; ảnh chỉ lấy từ nội dung bài, không lấy bài liên quan/quảng cáo.';url.insertAdjacentElement('afterend',n);}}
|
| 323 |
+
function voiceOptions(){return `<option value="nu">Nữ Việt - Hoài My</option><option value="nam">Nam Việt - Nam Minh</option><option value="nu-truyen-cam">Nữ Việt truyền cảm</option><option value="nu-tin-nhanh">Nữ Việt tin nhanh</option><option value="nam-tram">Nam Việt trầm</option><option value="nam-ban-tin">Nam Việt bản tin</option><option value="nam-nang-dong">Nam Việt năng động</option>`}
|
| 324 |
+
function addVoiceOptions(){document.querySelectorAll('#ai-short-voice, select[id*=voice]').forEach(sel=>{let old=sel.value||'nu';sel.innerHTML=voiceOptions();sel.value=[...sel.options].some(o=>o.value===old)?old:'nu';});}
|
| 325 |
+
function shareAI(p,isShort){let url=location.origin+'/aw?post='+encodeURIComponent(p.id)+(isShort?'&short=1':'');let title=(isShort?'🎬 Short AI: ':'🧱 Tường AI: ')+(p.title||'VNEWS');if(navigator.share)navigator.share({title,url}).catch(()=>{});else navigator.clipboard.writeText(url).then(()=>alert('Đã sao chép link chia sẻ!')).catch(()=>{});}
|
| 326 |
function galleryHtml(p){let imgs=(p.images||[]).filter(Boolean);if(!imgs.length&&p.img)imgs=[p.img];if(!imgs.length)return '';return '<div class="ai-wall-gallery">'+imgs.slice(0,12).map(u=>`<img src="${esc(u)}" loading="lazy">`).join('')+'</div>';}
|
| 327 |
+
async function loadWall(){try{finalWall=(await (await fetch('/api/ai_wall')).json()).posts||[];renderWall();let pend=localStorage.getItem('pending_ai_post');if(pend){localStorage.removeItem('pending_ai_post');let idx=finalWall.findIndex(p=>String(p.id)===String(pend));if(idx>=0)setTimeout(()=>readFinalWall(idx),300);}}catch(e){}}
|
| 328 |
+
function renderWall(){let home=document.getElementById('view-home');if(!home||!finalWall.length)return;document.getElementById('ai-wall-final')?.remove();let wrap=document.createElement('div');wrap.id='ai-wall-final';wrap.className='ai-wall-final';let h='<div class="slider-header"><span class="slider-label">🧱 Tường AI</span><span class="slider-note">URL summary</span></div><div class="slider-track">';finalWall.slice(0,30).forEach((p,i)=>{h+=`<div class="wall-item"><div class="wall-thumb">${p.img?`<img src="${esc(p.img)}">`:''}</div><div class="wall-title">${esc(p.title)}</div><div class="wall-text">${esc(p.text)}</div><div class="wall-actions"><button class="primary" onclick="readFinalWall(${i})">Xem</button><button onclick="shareFinalWall(${i})">📤 Chia sẻ</button>${p.video?`<button onclick="readFinalShort(${i})">Short</button>`:''}</div></div>`});h+='</div>';wrap.innerHTML=h;let compose=document.querySelector('.ai-compose');if(compose)compose.after(wrap);else home.prepend(wrap);}
|
| 329 |
+
window.shareFinalWall=function(i){let p=finalWall[i];if(p)shareAI(p,false)};
|
| 330 |
+
window.shareFinalShort=function(i){let p=finalWall[i];if(p)shareAI(p,true)};
|
| 331 |
+
window.readFinalWall=window.aiReadWallPatched=window.aiReadWall=function(i){let p=finalWall[i]||(window.patchedWall||window.aiWall||[])[i];if(!p)return;showView('view-article');let sources='';if(p.sources&&p.sources.length){sources='<div class="article-summary"><b>Nguồn tham khảo:</b><br>'+p.sources.slice(0,5).map(s=>`• ${esc(s.title||s.url||'Nguồn')} ${s.url?`(${esc(new URL(s.url).hostname.replace('www.',''))})`:''}`).join('<br>')+'</div>'}let voiceBox=`<div class="article-actions"><select id="ai-short-voice">${voiceOptions()}</select><select id="ai-short-emotion"><option value="neutral">Trung tính</option><option value="urgent">Tin nhanh</option><option value="warm">Ấm áp</option><option value="serious">Nghiêm túc</option><option value="energetic">Sôi nổi</option></select><button onclick="makeFinalShort(${i})">🎬 Tạo short</button><button onclick="shareFinalWall(${i})">📤 Chia sẻ bài</button>${p.video?`<button onclick="shareFinalShort(${i})">📤 Chia sẻ short</button>`:''}</div>`;let h=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><span class="badge badge-ai">AI</span><h1 class="article-title">${esc(p.title)}</h1>${galleryHtml(p)}${sources}<p class="article-p" style="white-space:pre-wrap">${esc(p.text)}</p>${p.video?`<video class="article-img" src="${p.video}" controls playsinline></video>`:''}<div class="article-actions">${p.url?`<button onclick="window.open('${p.url}','_blank')">🔗 Nguồn</button>`:''}</div>${voiceBox}</div>`;document.getElementById('view-article').innerHTML=h;addVoiceOptions();window.scrollTo(0,0)};
|
| 332 |
+
window.readFinalShort=function(i){let p=finalWall[i];if(!p||!p.video)return;showView('view-article');document.getElementById('view-article').innerHTML=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><span class="badge badge-ai">Short AI</span><h1 class="article-title">${esc(p.title)}</h1><video class="article-img" src="${p.video}" controls playsinline autoplay></video><div class="article-actions"><button onclick="shareFinalShort(${i})">📤 Chia sẻ short</button><button onclick="readFinalWall(${i})">Xem bài tường</button></div></div>`;window.scrollTo(0,0)};
|
| 333 |
+
window.makeFinalShort=window.aiMakeShortPatched=async function(i){let p=finalWall[i]||(window.patchedWall||window.aiWall||[])[i];if(!p)return;let voice=document.getElementById('ai-short-voice')?.value||'nu';let emotion=document.getElementById('ai-short-emotion')?.value||'neutral';let btn=event?.target;if(btn){btn.disabled=true;btn.textContent='Đang tạo...'}try{let r=await fetch('/api/ai/short/'+p.id,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({voice,emotion,speed:1.2})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi tạo short');p.video=j.video;let idx=finalWall.findIndex(x=>x.id===p.id);if(idx<0){finalWall.unshift(p);idx=0}renderWall();readFinalWall(idx);alert('Đã tạo short AI và cập nhật ngay trên tường.');}catch(e){alert('Không tạo được short: '+e.message)}finally{if(btn){btn.disabled=false;btn.textContent='🎬 Tạo short'}}};
|
| 334 |
+
window.createTopicPost=function(){alert('Đã tắt ô nhập chủ đề. Vui lòng dán URL bài viết.');};
|
| 335 |
+
window.createUrlPost=async function(){let inp=document.getElementById('ai-url-input');let url=(inp&&inp.value||'').trim();if(!url)return alert('Dán URL trước');if(!/^https?:\/\//i.test(url))return alert('URL cần bắt đầu bằng http:// hoặc https://');let btn=event?.target;if(btn){btn.disabled=true;btn.textContent='Đang tóm tắt...'}try{let r=await fetch('/api/url_wall',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi URL');finalWall.unshift(j.post);if(inp)inp.value='';renderWall();readFinalWall(0);alert('Đã đăng tường AI, không cần tải lại trang.');}catch(e){alert(e.message||'Lỗi URL')}finally{if(btn){btn.disabled=false;btn.textContent='Chèn URL'}}};
|
| 336 |
+
setTimeout(()=>{hideTopicControls();addVoiceOptions();loadWall();},300);setInterval(()=>{hideTopicControls();addVoiceOptions();},1200);
|
| 337 |
})();
|
| 338 |
</script>
|
| 339 |
'''
|
|
|
|
| 347 |
r=requests.get(RESTORE_INDEX_URL,timeout=20)
|
| 348 |
if r.status_code==200 and '<!DOCTYPE html>' in r.text:html=r.text
|
| 349 |
except Exception:pass
|
| 350 |
+
if '<!DOCTYPE html>' not in html:html='<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>VNEWS</title></head><body><div id="view-home">VNEWS</div></body></html>'
|
|
|
|
| 351 |
return html
|
| 352 |
|
| 353 |
@app.get('/')
|
| 354 |
async def index_final():
|
| 355 |
+
html=_load_index_html();body=getattr(rt.old,'PATCH_INJECT','') + FINAL_INJECT
|
|
|
|
| 356 |
return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
|