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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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)