Spaces:
Running
Running
File size: 20,195 Bytes
4b6e868 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 | """
VNEWS Unified Patch v2
======================
Single file replacing app_entry.py + patch_extra.py functionality.
No conflicts, no duplicate slides, no DOM destruction.
Features:
1. Tường AI persistent (fix FINAL6E destroying DOM)
2. Source details with image + description + "Xem trên VNEWS"
3. Highlight = TikTok fullheight 1:1 crop center with interaction buttons
4. Rewrite auto-title, no "xem trên VNEWS" junk
5. Topic post uses source og:image instead of AI image
6. Fast homepage load (non-blocking)
"""
from ai_runtime_patch_fast import *
from ai_runtime_patch_fast import app, f5, f6, rt, PATCH_INJECT, _scrape, _domain, clean, _bg, _bg_home, _bg_shorts
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi import Request, Query
import asyncio, re, threading, time
DEFAULT_IMG = "https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg"
# ============================================================
# REMOVE ALL CONFLICTING ROUTES — we redefine them cleanly
# ============================================================
_OVERRIDE_PATHS = {'/api/homepage','/api/shorts','/api/topic_post','/api/topic/rewrite','/api/rewrite_share','/api/url_wall','/'}
app.router.routes = [r for r in app.router.routes if not (getattr(r,'path',None) in _OVERRIDE_PATHS and any(m in getattr(r,'methods',set()) for m in ('GET','POST')))]
# ============================================================
# FAST HOMEPAGE + SHORTS (non-blocking)
# ============================================================
@app.get('/api/homepage')
def _homepage():
if _bg_home['d']:
if time.time()-_bg_home['t']>300:threading.Thread(target=_bg,daemon=True).start()
return JSONResponse(_bg_home['d'])
threading.Thread(target=_bg,daemon=True).start()
return JSONResponse([])
@app.get('/api/shorts')
def _shorts(refresh:int=Query(default=0)):
if _bg_shorts['d']:
if time.time()-_bg_shorts['t']>600:threading.Thread(target=_bg,daemon=True).start()
return JSONResponse(_bg_shorts['d'])
threading.Thread(target=_bg,daemon=True).start()
return JSONResponse([])
# ============================================================
# HELPERS
# ============================================================
def _extract_title(text):
if not text:return 'Bài viết AI'
lines=[l.strip() for l in text.strip().split('\n') if l.strip()]
if lines:
first=re.sub(r'^[#*\-•\d\.\)\s]+','',lines[0]).strip()
if 10<=len(first)<=120:return first
return lines[0][:100] if lines else 'Bài viết AI'
def _clean_text(text):
if not text:return text
for junk in ['xem trên VNEWS','Xem trên VNEWS','📖 Xem trên VNEWS','đọc trên VNEWS','Đọc trên VNEWS','Mở nguồn gốc','mở nguồn gốc','📖 Đọc trên']:
text=text.replace(junk,'')
return re.sub(r'\n{3,}','\n\n',text).strip()
def _source_image(sources, details):
for s in (details or [])+(sources or []):
url=s.get('url','')
if not url:continue
try:_,_,img=_scrape(url,500)
except:img=''
if img and 'pollinations' not in img and len(img)>20:return img
return ''
def _ensure_img(img):
return img if (img and len(img)>20 and img.startswith('http')) else DEFAULT_IMG
# ============================================================
# TOPIC POST (source image instead of AI image)
# ============================================================
@app.post('/api/topic_post')
async def _topic(request:Request):
b=await request.json();topic=clean(b.get('topic',''))
if not topic:return JSONResponse({'error':'missing topic'},status_code=400)
research=f6._fast_context(topic) if hasattr(f6,'_fast_context') else f6._web_research_context(topic)
ctx=research.get('context','');src=research.get('sources',[])
det=f6._extract_source_details_from_context(ctx,src) if hasattr(f6,'_extract_source_details_from_context') else []
if not ctx or not src:return JSONResponse({'error':'Không tìm được nội dung.'},status_code=422)
img=_ensure_img(_source_image(src,det) or f6._topic_image(topic))
sb='\n\n'.join([f"[{i+1}] {d.get('title','')} ({d.get('via','')})\n{d.get('content','')[:1400]}" for i,d in enumerate(det)]) if det else ctx[:18000]
text=None
try:text=await asyncio.wait_for(f5.base.qwen_generate(f'Viết bài tiếng Việt VỀ: "{topic}"\nNGUỒN:\n{sb[:18000]}\nCHỈ viết về "{topic}". 5-8 đoạn. Cuối có nguồn.',image_url=img,max_tokens=1700),timeout=35)
except:pass
if not text or len(text)<300:
text=f"{topic}: tổng hợp\n\n"+'\n'.join([f"• {d['title']}: {d.get('content','')[:300]}" for d in (det or [])[:6]])+"\n\nNguồn: "+', '.join(sorted({d.get('via','') for d in (det or []) if d.get('via')}))
text=_clean_text(text)
post=f5.base.make_post(topic,text,img,'','topic_focused',sources=[s for s in src if s.get('url')])
post['images']=[img];post['source_details']=det
ps=f5.base._load_ai_wall();ps.insert(0,post);f5.base._save_ai_wall(ps)
return JSONResponse({'post':post})
# ============================================================
# REWRITE (auto-title, clean text)
# ============================================================
@app.post('/api/rewrite_share')
@app.post('/api/url_wall')
async def _rewrite(request:Request):
b=await request.json();url=clean(b.get('url',''));ctx=clean(b.get('context',''))
if not url.startswith('http'):return JSONResponse({'error':'URL không hợp lệ'},status_code=400)
title,raw,img=_scrape(url,14000)
if len(raw)<50:raw=ctx[:14000]
if len(raw)<50:return JSONResponse({'error':'Không đọc được bài'},status_code=422)
img=_ensure_img(img)
prompt=f"""Tóm tắt bài viết thành bản tin ngắn. Dòng đầu tiên là tiêu đề mới hấp dẫn (tự đặt, không copy gốc).
Tiêu đề gốc: {title}
Nội dung:
{raw[:14000]}
Yêu cầu:
- Dòng 1: Tiêu đề MỚI ngắn gọn hấp dẫn.
- Tiếp: 4-6 ý chính.
- Cuối: nguồn.
- KHÔNG viết bất kỳ cụm điều hướng nào."""
text=None
try:text=await asyncio.wait_for(f5.base.qwen_generate(prompt,image_url=img,max_tokens=1000),timeout=30)
except:pass
if not text or len(text)<80:text=f"{title}\n\n{raw[:1200]}\n\nNguồn: {_domain(url)}"
text=_clean_text(text)
ai_title=_extract_title(text)
lines=text.strip().split('\n')
body='\n'.join(lines[1:]).strip() if lines and lines[0].strip()==ai_title else text
post=f5.base.make_post(ai_title,_clean_text(body),img,url,'rewrite',sources=[{'title':title,'url':url,'via':_domain(url)}])
ps=f5.base._load_ai_wall();ps.insert(0,post);f5.base._save_ai_wall(ps)
return JSONResponse({'post':post})
@app.post('/api/topic/rewrite')
async def _topic_rewrite(request:Request):
b=await request.json();pid=str(b.get('post_id','')).strip()
if not pid:return JSONResponse({'error':'missing post_id'},status_code=400)
ps=f5.base._load_ai_wall();p=next((x for x in ps if str(x.get('id'))==pid),None)
if not p:return JSONResponse({'error':'Bài không tồn tại'},status_code=404)
urls=list(dict.fromkeys([s['url'] for s in (p.get('source_details') or []) if s.get('url')]+[s['url'] for s in (p.get('sources') or []) if s.get('url')]))[:5]
parts=[];best_img=''
for u in urls:
t,r,uimg=_scrape(u,6000)
if r and len(r)>150:parts.append(f"[{_domain(u)}] {t}\n{r}")
if not best_img and uimg and len(uimg)>20:best_img=uimg
ac='\n---\n'.join(parts) if parts else (p.get('text') or '')
img=_ensure_img(best_img or p.get('img',''))
prompt=f"""Viết lại thành bản tóm tắt mới. Dòng đầu là tiêu đề mới hấp dẫn.
Chủ đề: {p.get('title','')}
Nguồn:
{ac[:16000]}
Yêu cầu: Dòng 1 = tiêu đề mới. Tiếp: 4-6 ý. Cuối: nguồn. KHÔNG viết cụm điều hướng."""
text=None
try:text=await asyncio.wait_for(f5.base.qwen_generate(prompt,image_url=img,max_tokens=1200),timeout=35)
except:pass
if not text or len(text)<100:text=f"Tóm tắt: {p.get('title','')}\n\n{ac[:1500]}\n\nNguồn: VNEWS AI"
text=_clean_text(text)
ai_title=_extract_title(text)
lines=text.strip().split('\n')
body='\n'.join(lines[1:]).strip() if lines and lines[0].strip()==ai_title else text
np=f5.base.make_post(ai_title,_clean_text(body),img,'','rewrite_topic',sources=p.get('sources',[]));np['images']=[img]
all_p=f5.base._load_ai_wall();all_p.insert(0,np);f5.base._save_ai_wall(all_p)
return JSONResponse({'post':np})
# ============================================================
# UNIFIED INJECT: everything in one clean block
# ============================================================
UNIFIED_INJECT = r'''
<script>
// === PRE-KILL: prevent old code from destroying Tường AI and Short AI slides ===
Object.defineProperty(window,'renderTopicWallE',{get:function(){return function(){}},set:function(){},configurable:true});
Object.defineProperty(window,'renderAIShortHome',{get:function(){return function(){}},set:function(){},configurable:true});
Object.defineProperty(window,'renderAIShorts7',{get:function(){return function(){}},set:function(){},configurable:true});
</script>
<style>
/* Tiktok right panel for shorts/highlights */
.tiktok-slide{position:relative!important}
.tiktok-right{position:absolute!important;right:8px!important;bottom:100px!important;display:flex!important;flex-direction:column!important;align-items:center!important;gap:14px!important;z-index:5!important}
.tiktok-right-btn{display:flex!important;flex-direction:column!important;align-items:center!important;gap:2px!important;background:none!important;border:0!important;color:#fff!important;cursor:pointer!important}
.tiktok-right-btn .icon{width:42px!important;height:42px!important;border-radius:50%!important;background:rgba(255,255,255,.12)!important;display:flex!important;align-items:center!important;justify-content:center!important;font-size:20px!important}
.tiktok-right-btn .count{font-size:10px!important;color:#ddd!important}
/* Highlight: TikTok feed with 1:1 crop center */
.tiktok-slide video{object-fit:cover!important}
/* Hide duplicate slides/walls from old layers */
#ai-short-home,.ai-short-home,.ai-short-card-final,[id*="ai-shorts-patched"]{display:none!important}
/* Progress toast */
#short-progress-toast{position:fixed;bottom:70px;left:50%;transform:translateX(-50%);background:#2d8659;color:#fff;padding:10px 20px;border-radius:20px;font-size:12px;z-index:99998;box-shadow:0 4px 12px rgba(0,0,0,.4);display:none}
/* Source details */
.source-detail-box{margin-top:14px;background:#151515;border:1px solid #2b2b2b;border-radius:10px;padding:10px}
.source-detail-item{background:#202020;border-radius:8px;padding:9px;margin:7px 0;cursor:pointer}
.source-detail-item:active{opacity:.8}
.source-detail-title{font-size:12px;font-weight:700;color:#eee}
.source-detail-content{font-size:11px;color:#bbb;line-height:1.4;max-height:80px;overflow:hidden;margin-top:4px}
.source-detail-item img{width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:6px;margin-bottom:6px;background:#222}
.source-vnews-btn{display:inline-block;margin-top:6px;background:#2d8659;color:#fff;padding:4px 10px;border-radius:10px;font-size:10px;font-weight:700}
/* Livescore */
.ls-content{max-height:480px;overflow-y:auto;padding:0 6px 8px;font-size:12px;color:#ddd}.ls-content ul{list-style:none;padding:0;margin:0}.ls-content .title-content{display:flex;gap:6px;align-items:center;background:#222;border-radius:4px;margin:4px 0;padding:5px 8px}.ls-content .title-content img{width:18px;height:18px}.ls-content .title-content strong{font-size:11px;color:#ccc}.ls-content .match-detail{padding:6px;border-bottom:1px solid #262626;cursor:pointer}.ls-content .match-detail:hover{background:#1a2a1f}.ls-content .match{display:flex;flex-wrap:wrap;align-items:center;gap:4px}.ls-content .datetime{width:100%;font-size:9px;color:#888}.ls-content .teams{display:flex;width:100%;align-items:center;gap:4px}.ls-content .team{flex:1;display:flex;align-items:center;gap:4px;min-width:0}.ls-content .team .name{font-size:11px;color:#ddd;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ls-content .team .logo img{width:18px;height:18px}.ls-content .home-team{justify-content:flex-end;text-align:right}.ls-content .status{flex:0 0 54px;text-align:center}.ls-content .status a{color:#fff;text-decoration:none;font-weight:800;font-size:12px}.ls-content .status .label{font-size:8px;color:#888;display:block}.ls-content .status .label.live{color:#e74c3c}.ls-content .info,.ls-content .btns{display:none}.ls-content table,.mo-body table{width:100%;border-collapse:collapse;font-size:11px;color:#ccc}.ls-content table th,.mo-body table th{background:#222;color:#999;padding:5px 4px;font-size:10px;border-bottom:1px solid #333}.ls-content table td,.mo-body table td{padding:4px 3px;border-bottom:1px solid #1a1a1a}.ls-content table .team-name,.mo-body table .team-name{display:flex;align-items:center;gap:4px}.ls-content table .team-name img,.mo-body table .team-name img{width:16px;height:16px}.ls-content table .pts{font-weight:800;color:#f0c040}.mo-body{padding:8px;font-size:12px;color:#ddd}.mo-body ul{list-style:none;padding:0}.mo-body li{padding:5px 0;border-bottom:1px solid #222}
</style>
<div id="short-progress-toast"></div>
<script>
(function(){
function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));}
// === Progress toast ===
window.showShortProgress=function(msg){var t=document.getElementById('short-progress-toast');if(t){t.textContent=msg;t.style.display='block';}};
window.hideShortProgress=function(){var t=document.getElementById('short-progress-toast');if(t)t.style.display='none';};
window.makeShortFromPost=async function(pid,btn){
showShortProgress('⏳ Đang tạo Short AI...');if(btn){btn.disabled=true;btn.textContent='Đang tạo...';}
try{var r=await fetch('/api/ai/short/'+pid,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({voice:'nu',emotion:'neutral',speed:1.2})});var j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');showShortProgress('✅ Đã tạo!');setTimeout(hideShortProgress,3000);if(typeof renderShortAISlide==='function')renderShortAISlide();}catch(e){showShortProgress('❌ '+e.message);setTimeout(hideShortProgress,4000);}finally{if(btn){btn.disabled=false;btn.textContent='🎬 Tạo Short AI';}}
};
// === Remove duplicate slides ===
setInterval(function(){document.querySelectorAll('#ai-short-home,.ai-short-home,[id*="ai-shorts-patched"]').forEach(function(el){if(el.id!=='short-ai-final-slide')el.remove();});},3000);
// === Override openLeaguePlayer: TikTok vertical feed, 1:1 crop center ===
window.openLeaguePlayer=async function(league,idx){
showView('view-tiktok');document.querySelectorAll('.cat').forEach(x=>x.classList.remove('active'));
var el=document.getElementById('view-tiktok');el.innerHTML='<div class="loading">Đang tải...</div>';
var cfg=(window.HL_CONFIG||{})[league]||{name:league,emoji:'🎬'};
var articles=(window._hlLeagueData||{})[league]||[];
if(!articles.length){el.innerHTML='<div class="loading">Không có video</div>';return;}
var vids=[];
var results=await Promise.all(articles.map(async function(a,i){try{var r=await fetch('/api/video_url?url='+encodeURIComponent(a.link));var v=await r.json();if(v&&v.src)return Object.assign({},a,v,{_idx:i});}catch(e){}return null;}));
results.forEach(function(r){if(r)vids.push(r);});
vids.sort(function(a,b){return a._idx-b._idx;});
if(!vids.length){el.innerHTML='<div class="loading">Không tìm thấy video</div>';return;}
var ti=vids.findIndex(function(v){return v._idx===idx;});if(ti<0)ti=0;
var ordered=ti>0?vids.slice(ti).concat(vids.slice(0,ti)):vids;
var h='<button class="back-btn" onclick="switchCat(\'home\')">← '+cfg.emoji+' '+cfg.name+'</button><div class="tiktok-container"><div class="tiktok-feed" id="tiktok-feed">';
ordered.forEach(function(v,i){
var isYT=v.type==='youtube';var isHLS=!isYT&&v.src&&v.src.indexOf('.m3u8')>-1;
var poster=v.poster?' poster="'+v.poster+'"':'';
var vtag=isYT?'<iframe data-yt-src="'+v.src+'" allowfullscreen allow="accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture" style="width:100%;height:100%;border:none"></iframe>':isHLS?'<video playsinline preload="none"'+poster+' data-hls="'+v.src+'" loop controls style="width:100%;height:100%;object-fit:cover"></video>':'<video playsinline preload="none"'+poster+' loop controls style="width:100%;height:100%;object-fit:cover"><source src="'+v.src+'" type="video/mp4"></video>';
h+='<div class="tiktok-slide" id="tslide-'+i+'">'+vtag+'<div class="tiktok-bottom"><span class="badge badge-fpt">'+esc(cfg.name)+'</span><p class="tiktok-title">'+esc(v.title)+'</p></div><div class="tiktok-right"><button class="tiktok-right-btn" onclick="event.stopPropagation()"><div class="icon">👁</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation()"><div class="icon">❤️</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();if(typeof doShareVideo===\'function\')doShareVideo(\''+esc(v.title)+'\',\''+esc(v.link||'')+'\',\''+esc(v.poster||v.img||'')+'\',\'highlights\')"><div class="icon">📤</div></button></div><span class="tiktok-counter">'+(i+1)+'/'+ordered.length+'</span></div>';
});
h+='</div></div>';el.innerHTML=h;
// Init feed
var feed=document.getElementById('tiktok-feed');if(!feed)return;
var slides=feed.querySelectorAll('.tiktok-slide');var cur=-1;
function act(i){if(i===cur)return;slides.forEach(function(sl,idx){var v=sl.querySelector('video');var fr=sl.querySelector('iframe');if(idx===i){if(v&&v.dataset.hls){if(!v._hls&&typeof Hls!=='undefined'&&Hls.isSupported()){var hls=new Hls();hls.loadSource(v.dataset.hls);hls.attachMedia(v);hls.on(Hls.Events.MANIFEST_PARSED,function(){v.play().catch(function(){});});v._hls=hls;}else if(v._hls)v.play().catch(function(){});}else if(v)v.play().catch(function(){});if(fr&&!fr.src&&fr.dataset.ytSrc)fr.src=fr.dataset.ytSrc;}else{if(v){v.pause();if(v._hls){v._hls.destroy();v._hls=null;}}if(fr&&fr.src)fr.src='';}});cur=i;}
var sT;feed.addEventListener('scroll',function(){clearTimeout(sT);sT=setTimeout(function(){var rect=feed.getBoundingClientRect(),ctr=rect.top+rect.height/2,best=-1,bestD=1e9;slides.forEach(function(sl,i){var d=Math.abs(sl.getBoundingClientRect().top+sl.getBoundingClientRect().height/2-ctr);if(d<bestD){bestD=d;best=i;}});if(best>=0)act(best);},150);});
setTimeout(function(){act(0);},400);
slides.forEach(function(sl){var v=sl.querySelector('video');if(v)v.addEventListener('click',function(e){e.preventDefault();v.paused?v.play().catch(function(){}):v.pause();});});
};
// === Block slow YouTube refresh on first load ===
var _origFetch=window.fetch,_allowRefresh=false;
window.fetch=function(url,opts){try{if(String(url).indexOf('/api/shorts?refresh=1')>-1&&!_allowRefresh)url='/api/shorts';}catch(e){}return _origFetch.call(this,url,opts);};
setTimeout(function(){_allowRefresh=true;},8000);
})();
</script>
'''
# ============================================================
# ROOT ROUTE: inject order matters
# ============================================================
@app.get('/')
async def _index():
html = f5.f4.f3.f2.f1._load_index_html()
# Inject order: PRE_KILL (in UNIFIED) → old injects → PATCH_INJECT → UNIFIED
body = ''
body += getattr(rt.old,'PATCH_INJECT','')
body += f5.f4.f3.f2.f1.FINAL_INJECT + f5.f4.f3.FINAL3_INJECT + f5.f4.FINAL4_INJECT + f5.FINAL5_INJECT
body += getattr(f6,'FINAL6_INJECT','')
body += getattr(f6,'FINAL6_FAST_HOME_INJECT','')
body += getattr(f6,'FINAL6E_INJECT','') # Keep it — our PRE_KILL in UNIFIED neutralizes its destructive parts
body += PATCH_INJECT
body += UNIFIED_INJECT # This goes LAST and contains PRE_KILL at the TOP (runs first in browser)
return HTMLResponse(html.replace('</body>', body + '\n</body>') if '</body>' in html else html + body)
|