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): 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) 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: 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) return out _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.\n\nYêu cầu bắt buộc:\n- Chỉ tóm tắt nội dung chính, không viết lại toàn bộ bài.\n- Ngắn gọn, cụ thể, dễ hiểu.\n- Không lặp lại ý và không thêm chi tiết ngoài nguồn.\n- Tối đa 5 ý chính hoặc 2 đoạn ngắn.\n- Tránh dùng dấu đầu dòng nếu không thật cần thiết.\n\nTiêu đề gốc: {data.get('title','')}\nNguồn: {data.get('via','') or _domain(url)}\nNội dung gốc:\n{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)