import os, re, subprocess, json, time, hashlib import ai_patch as old from ai_patch import app import ai_ext as base from fastapi import Request from fastapi.responses import JSONResponse, HTMLResponse, FileResponse try: from PIL import Image, ImageDraw, ImageFont except Exception: Image = ImageDraw = ImageFont = None def clean(s): import html as html_lib return re.sub(r"\s+", " ", html_lib.unescape(s or "")).strip() def _domain(url): try: from urllib.parse import urlparse return urlparse(url or '').netloc.replace('www.','') except Exception: return '' def _strip_bullet_prefix(s): # remove bullets, numbered prefixes, leading dots commonly produced by AI summaries return clean(re.sub(r'^[\s•\-\*·▪▫●○\d\.\)\(]+', '', s or '')) def source_line(sources): names=[] for s in (sources or [])[:5]: via=s.get('via') or _domain(s.get('url','')) or s.get('title','') if via and via not in names:names.append(via) return 'Nguồn tham khảo: '+', '.join(names[:5]) if names else 'Nguồn tham khảo: tổng hợp internet' def _source_badge(post): sources=post.get('sources') or [] for s in sources: via=s.get('via') or _domain(s.get('url','')) if via:return via return _domain(post.get('url','')) or post.get('source') or 'VNEWS' def _collect_all_images(data): imgs=[] def add(u): u=(u or '').strip() if not u or u.startswith('data:') or 'base64' in u:return if u.startswith('//'):u='https:'+u if u not in imgs:imgs.append(u) add(data.get('image') or data.get('og_image') or data.get('img')) for u in data.get('images') or []:add(u) for b in data.get('body') or []: if isinstance(b,dict) and b.get('type')=='img':add(b.get('src')) return imgs[:20] def _scrape_url_with_images(url): data=base.scrape_any_url(url) # extra pass: collect every useful image from original HTML, because some readers only return one image try: import requests from bs4 import BeautifulSoup r=requests.get(url,headers=base.HEADERS,timeout=18);r.encoding='utf-8' soup=BeautifulSoup(r.text,'lxml') extra=[] for im in soup.find_all('img'): src=im.get('data-src') or im.get('data-original') or im.get('data-lazy-src') or im.get('src') or '' if src.startswith('//'):src='https:'+src if src and 'base64' not in src and src not in extra: # skip tiny icons/logos as much as possible low=src.lower() if any(x in low for x in ['logo','icon','avatar','sprite']): continue extra.append(src) if len(extra)>=20:break data['images']=_collect_all_images(data)+[u for u in extra if u not in _collect_all_images(data)] except Exception: data['images']=_collect_all_images(data) data['images']=_collect_all_images(data) if data['images'] and not data.get('image'): data['image']=data['images'][0] return data def rich_context(topic, limit=5): try: ctx,sources=base.web_context(topic, limit=limit) except Exception: ctx,sources='',[] rich=[];rs=[];seen=set() for s in (sources or [])[:limit*2]: url=s.get('url') or '' if not url.startswith('http') or url in seen:continue seen.add(url) try: data=base.scrape_any_url(url) raw=(data.get('summary','')+'\n'+data.get('text','')).strip() if len(raw)<180:continue title=data.get('title') or s.get('title') or url via=data.get('via') or s.get('via') or _domain(url) rich.append(f"### {title} ({via})\n{raw[:2600]}") rs.append({'title':title,'url':url,'excerpt':raw[:700],'via':via}) if len(rich)>=limit:break except Exception:continue if rich:return '\n\n'.join(rich),rs return ctx or f'Chủ đề: {topic}', sources or [] def postprocess(text): if hasattr(old,'_postprocess_ai_text'): out=old._postprocess_ai_text(text, max_units=7) else: out=clean(text) # keep wall text readable, but ensure short generation later won't show bullets return out # Remove old routes we must override. _PATCH={('/api/topic_post','POST'),('/api/url_wall','POST'),('/api/rewrite_share','POST'),('/api/ai/url','POST'),('/api/ai/short/{post_id}','POST'),('/api/ai/short-file/{file_id}','GET'),('/','GET')} 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)] @app.post('/api/url_wall') async def url_wall_only(request:Request): body=await request.json();url=base._clean_text(body.get('url','')) if not url.startswith('http'):return JSONResponse({'error':'missing url'},status_code=400) try:data=_scrape_url_with_images(url) except Exception as e:return JSONResponse({'error':'Không scrape được URL: '+str(e)[:180]},status_code=422) raw=(data.get('summary','')+'\n'+data.get('text','')).strip() if len(raw)<120:return JSONResponse({'error':'URL không có đủ nội dung để tóm tắt'},status_code=422) prompt=f"""Tóm tắt bài viết nguồn dưới đây để đăng lên Tường AI VNEWS. Yêu cầu bắt buộc: - Chỉ tóm tắt nội dung chính, không viết lại toàn bộ bài. - Ngắn gọn, cụ thể, dễ hiểu. - Không lặp lại ý và không thêm chi tiết ngoài nguồn. - Tối đa 5 ý chính hoặc 2 đoạn ngắn. - Tránh dùng dấu đầu dòng nếu không thật cần thiết. Tiêu đề gốc: {data.get('title','')} Nguồn: {data.get('via','') or _domain(url)} Nội dung gốc: {raw[:16000]}""" text=await base.qwen_generate(prompt,image_url=(data.get('image') or None),max_tokens=900) if not text:text=old._fallback_summary_from_prompt(prompt,max_units=5) if hasattr(old,'_fallback_summary_from_prompt') else raw[:900] text=postprocess(text) src=[{'title':data.get('title'), 'url':url, 'excerpt':raw[:500], 'via':data.get('via') or _domain(url)}] if 'Nguồn tham khảo:' not in text:text+='\n\n'+source_line(src) images=_collect_all_images(data) post=base.make_post(data.get('title') or 'Bài viết',text,images[0] if images else (data.get('image') or ''),url,'url',sources=src) post['images']=images posts=base._load_ai_wall();posts.insert(0,post);base._save_ai_wall(posts) return JSONResponse({'post':post}) @app.post('/api/rewrite_share') async def rewrite_share_url_only(request:Request): return await url_wall_only(request) @app.post('/api/ai/url') async def ai_url_compat(request:Request): return await url_wall_only(request) @app.post('/api/topic_post') async def topic_disabled(request:Request): return JSONResponse({'error':'Đã tắt tạo bài theo chủ đề. Vui lòng dán URL bài viết để AI tóm tắt.'},status_code=410) def split_segments(post,max_segments=8): text=clean(post.get('text') or post.get('title') or '') text=re.sub(r'Nguồn tham khảo:.*$','',text,flags=re.I|re.S).strip() lines=[] for ln in text.splitlines(): ln=_strip_bullet_prefix(ln) if len(ln)>=18:lines.append(ln) if len(lines)<2: lines=[_strip_bullet_prefix(s) for s in re.split(r'(?<=[\.\!\?])\s+',text) if len(_strip_bullet_prefix(s))>=25] segs=[];cur='' for ln in lines: ln=_strip_bullet_prefix(ln) if not ln:continue if len(cur)+len(ln)<180:cur=(cur+' '+ln).strip() else: if cur:segs.append(_strip_bullet_prefix(cur)) cur=ln if cur:segs.append(_strip_bullet_prefix(cur)) return segs[:max_segments] or [_strip_bullet_prefix(post.get('title','VNEWS'))] def wrap_text(draw,text,font,maxw,max_lines): words=clean(text).split();lines=[];cur='' for w in words: test=(cur+' '+w).strip() try:width=draw.textbbox((0,0),test,font=font)[2] except Exception:width=len(test)*20 if width<=maxw:cur=test else: if cur:lines.append(cur) cur=w if len(lines)>=max_lines:break if cur and len(lines)tr:nh=target[1];nw=int(nh*ratio) else:nw=target[0];nh=int(nw/ratio) im=im.resize((nw,nh));left=(nw-target[0])//2;top=(nh-target[1])//2 bg.paste(im.crop((left,top,left+target[0],top+target[1])),(0,0)) except Exception:pass draw=ImageDraw.Draw(bg) try: 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) except Exception:fb=ft=fs=fsmall=None # source badge on top image corner badge='Nguồn: '+_source_badge(post) try: b=draw.textbbox((0,0),badge,font=fsmall);bw=b[2]-b[0];bh=b[3]-b[1] except Exception: bw=len(badge)*16;bh=34 bx=W-bw-42;by=24 draw.rounded_rectangle((bx-16,by-8,W-24,by+bh+14),radius=18,fill=(0,0,0,170)) draw.text((bx,by),badge,fill=(255,255,255),font=fsmall) # bottom text area draw.rectangle((0,hero_h-20,W,H),fill=(12,12,12)) # progress bars centered total_w=total*38-14;start=(W-total_w)//2 for i in range(total): fill=(92,184,122) if i==idx else (70,70,70) draw.rounded_rectangle((start+i*38,820,start+i*38+24,832),radius=6,fill=fill) brand='VNEWS AI SHORT' try: bb=draw.textbbox((0,0),brand,font=ft);tx=(W-(bb[2]-bb[0]))//2 except Exception:tx=360 draw.text((tx,870),brand,fill=(110,231,143),font=ft) clean_seg=_strip_bullet_prefix(seg) lines=wrap_text(draw,clean_seg,fb,W-120,8) block_h=len(lines)*74 y=max(980, 1250-block_h//2) _draw_center(draw,lines,fb,y,(255,255,255),W,74) # small title centered near bottom title_lines=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) bg.save(out_path,quality=92) def make_tts(text,voice,out_path): v={'nam':'vi-VN-NamMinhNeural','male':'vi-VN-NamMinhNeural','nu':'vi-VN-HoaiMyNeural','female':'vi-VN-HoaiMyNeural','mien-nam':'vi-VN-HoaiMyNeural'}.get(voice,'vi-VN-HoaiMyNeural') text=_strip_bullet_prefix(text) try:subprocess.run(['python','-m','edge_tts','--voice',v,'--text',text,'--write-media',out_path],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=160) except Exception: tld='com.vn' if voice in ('nu','female','mien-nam') else 'com' try:base.gTTS(text,lang='vi',tld=tld,slow=False).save(out_path) except TypeError:base.gTTS(text,lang='vi',slow=False).save(out_path) @app.post('/api/ai/short/{post_id}') async def short_segments(post_id:str,request:Request): try:body=await request.json() except Exception:body={} 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))) posts=base._load_ai_wall();post=next((p for p in posts if str(p.get('id'))==str(post_id)),None) if not post:return JSONResponse({'error':'post not found'},status_code=404) segs=split_segments(post,8) os.makedirs(base.SHORTS_DIR,exist_ok=True);suffix=f'_{voice}_{emotion}_{str(speed).replace(".","p")}_centered_source_nobullet' out=os.path.join(base.SHORTS_DIR,base._safe_name(post_id+suffix)+'.mp4') if os.path.exists(out):post['video']='/api/ai/short-file/'+post_id+suffix;base._save_ai_wall(posts);return JSONResponse({'video':post['video'],'segments':len(segs),'subtitles':False}) work=os.path.join(base.SHORTS_DIR,base._safe_name(post_id+suffix));os.makedirs(work,exist_ok=True) img=os.path.join(work,'image.jpg');base._download_image(post.get('img'),post.get('title','AI news'),img) clips=[] try: for i,seg in enumerate(segs): 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') seg=_strip_bullet_prefix(seg) make_frame(post,seg,i,len(segs),img,frame) 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,'') spoken=(prefix+' '+seg).strip() if i==0 and prefix else seg make_tts(spoken,voice,aud) subprocess.run(['ffmpeg','-y','-i',aud,'-filter:a',f'atempo={speed}','-vn',aud2],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=120) 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) clips.append(clip) lf=os.path.join(work,'list.txt') with open(lf,'w',encoding='utf-8') as f: for c in clips:f.write("file '{}".format(c.replace("'","'\\''"))+"'\n") subprocess.run(['ffmpeg','-y','-f','concat','-safe','0','-i',lf,'-c','copy',out],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=240) 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) return JSONResponse({'video':post['video'],'segments':len(segs),'subtitles':False}) except Exception as e:return JSONResponse({'error':'Không tạo được shorts: '+str(e)[:200]},status_code=500) @app.get('/api/ai/short-file/{file_id}') def short_file(file_id:str): path=os.path.join(base.SHORTS_DIR,base._safe_name(file_id)+'.mp4') if not os.path.exists(path):return JSONResponse({'error':'not found'},status_code=404) return FileResponse(path,media_type='video/mp4',filename=f'vnews-ai-{file_id}.mp4') # Rebuild / with old UI injection plus final UI overrides. app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))] @app.get('/') async def index_runtime(): with open('/app/static/index.html','r',encoding='utf-8') as f:html=f.read() inject=getattr(old,'PATCH_INJECT','')+r''' ''' return HTMLResponse(html.replace('',inject+'\n') if '' in html else html+inject)