Spaces:
Running
Running
| """Fast rewrite as slides - no AI needed, extracts key points + images from article.""" | |
| from main import app | |
| from fastapi import Request | |
| from fastapi.responses import JSONResponse | |
| import requests, re, time, random, json, os | |
| from bs4 import BeautifulSoup | |
| from urllib.parse import quote | |
| UA = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept-Language': 'vi-VN,vi;q=0.9'} | |
| try: | |
| from main import _load_wall, _save_wall | |
| except: | |
| _data_dir = "/data" if os.path.isdir("/data") else "/app/data" | |
| _wall_file = os.path.join(_data_dir, "wall_posts.json") | |
| def _load_wall(): | |
| try: | |
| if os.path.exists(_wall_file): | |
| with open(_wall_file, 'r', encoding='utf-8') as f: return json.load(f) | |
| except: pass | |
| return [] | |
| def _save_wall(posts): | |
| try: | |
| os.makedirs(os.path.dirname(_wall_file), exist_ok=True) | |
| with open(_wall_file+'.tmp', 'w', encoding='utf-8') as f: json.dump(posts[:100], f, ensure_ascii=False) | |
| os.replace(_wall_file+'.tmp', _wall_file) | |
| except: pass | |
| def _clean(s): return re.sub(r'\s+', ' ', str(s or '')).strip() | |
| def _scrape_article_full(url): | |
| """Scrape article: extract paragraphs + ALL images.""" | |
| try: | |
| r = requests.get(url, headers=UA, timeout=15, allow_redirects=True) | |
| r.encoding = 'utf-8' | |
| soup = BeautifulSoup(r.text, 'lxml') | |
| for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form']): tag.decompose() | |
| # Title | |
| h1 = soup.find('h1') | |
| ogt = soup.find('meta', property='og:title') | |
| title = (h1.get_text(strip=True) if h1 else '') or (ogt.get('content', '') if ogt else '') | |
| # OG image | |
| ogi = soup.find('meta', property='og:image') | |
| og_img = ogi.get('content', '') if ogi else '' | |
| if og_img and og_img.startswith('//'): og_img = 'https:' + og_img | |
| # Find content block | |
| block = None | |
| for sel in ['article', '.singular-content', '.detail-content', '.fck_detail', '.content-detail', '.knc-content', 'main', '.cms-body', '.article__body']: | |
| el = soup.select_one(sel) | |
| if el and len(el.find_all('p')) >= 2: block = el; break | |
| if not block: block = soup.body or soup | |
| # Extract paragraphs and images IN ORDER | |
| paragraphs = [] | |
| images = [] | |
| seen_imgs = set() | |
| if og_img and og_img not in seen_imgs: | |
| images.append(og_img) | |
| seen_imgs.add(og_img) | |
| for el in block.find_all(['p', 'h2', 'h3', 'figure', 'img'], recursive=True): | |
| if el.name == 'p': | |
| t = _clean(el.get_text(strip=True)) | |
| if t and len(t) > 40: | |
| paragraphs.append(t) | |
| elif el.name in ('figure', 'img'): | |
| im = el if el.name == 'img' else el.find('img') | |
| if im: | |
| src = im.get('data-src') or im.get('src') or im.get('data-original') or '' | |
| if src and 'base64' not in src: | |
| if src.startswith('//'): src = 'https:' + src | |
| if src not in seen_imgs: | |
| images.append(src) | |
| seen_imgs.add(src) | |
| return {'title': _clean(title), 'paragraphs': paragraphs, 'images': images, 'og_img': og_img} | |
| except Exception as e: | |
| return None | |
| def _extract_key_points(paragraphs, max_points=5): | |
| """Extract key points: take first sentence of each significant paragraph.""" | |
| points = [] | |
| for p in paragraphs: | |
| if len(points) >= max_points: break | |
| # Take first complete sentence (ends with . ! ?) | |
| m = re.match(r'^(.+?[.!?])\s', p) | |
| if m: | |
| sentence = m.group(1) | |
| else: | |
| sentence = p[:150] + ('.' if not p.endswith('.') else '') | |
| # Skip if too short or duplicate | |
| if len(sentence) < 30: continue | |
| if any(sentence[:50] in existing for existing in points): continue | |
| points.append(sentence) | |
| return points | |
| async def api_rewrite_slide(request: Request): | |
| """ | |
| Fast rewrite as SLIDES: | |
| - Extract key points from article (1 sentence each, full and complete) | |
| - Pair each point with an image from the article | |
| - Return as slides array for frontend to display | |
| - Save to Tường AI | |
| NO AI NEEDED - instant response. | |
| """ | |
| body = await request.json() | |
| url = _clean(body.get("url", "")) | |
| context = body.get("context", "") | |
| if not url and not context: | |
| return JSONResponse({"error": "Cần URL hoặc nội dung"}, status_code=400) | |
| # Scrape article | |
| data = None | |
| if url and url.startswith("http"): | |
| data = _scrape_article_full(url) | |
| if not data and context: | |
| # Use context passed from frontend | |
| paragraphs = [_clean(p) for p in context.split('\n') if len(_clean(p)) > 40] | |
| data = {'title': paragraphs[0][:80] if paragraphs else 'Bài viết', 'paragraphs': paragraphs, 'images': [], 'og_img': ''} | |
| if not data or not data.get('paragraphs'): | |
| return JSONResponse({"error": "Không đọc được bài viết"}, status_code=422) | |
| # Extract key points | |
| points = _extract_key_points(data['paragraphs'], max_points=6) | |
| if not points: | |
| return JSONResponse({"error": "Không tìm được ý chính"}, status_code=422) | |
| # Build slides: pair each point with an image | |
| images = data.get('images', []) | |
| slides = [] | |
| for i, point in enumerate(points): | |
| img = images[i] if i < len(images) else (images[-1] if images else '') | |
| # Proxy dantri images | |
| if img and 'cdnphoto.dantri' in img: | |
| img = '/api/proxy/img?url=' + quote(img, safe='') | |
| slides.append({ | |
| 'text': point, | |
| 'image': img, | |
| 'index': i + 1 | |
| }) | |
| # Create post for Tường AI | |
| summary_text = '\n\n'.join([f"• {s['text']}" for s in slides]) | |
| # Auto voice + emotion based on topic (reuse ai_ext detector if available) | |
| try: | |
| from ai_ext import _detect_voice_emotion | |
| _voice, _emotion = _detect_voice_emotion(data['title'], summary_text) | |
| except Exception: | |
| _voice, _emotion = "hoaimy", "trung_tinh" | |
| post = { | |
| "id": str(int(time.time() * 1000)) + str(random.randint(100, 999)), | |
| "title": data['title'], | |
| "text": summary_text, | |
| "img": images[0] if images else '', | |
| "url": url, | |
| "kind": "slide_summary", | |
| "slides": slides, | |
| "images": images[:10], | |
| "video": "", | |
| "voice": _voice, | |
| "emotion": _emotion, | |
| "ts": int(time.time()) | |
| } | |
| # Save to wall | |
| posts = _load_wall() | |
| posts.insert(0, post) | |
| _save_wall(posts) | |
| return JSONResponse({"post": post, "slides": slides}) | |