"""Final6 runtime: fast homepage, fast shorts, Qwen topic with source details, hashtag sources, rewrite auto-title.""" import os, re, time, json, hashlib, asyncio, threading, requests from urllib.parse import urlparse, quote import ai_runtime_final5 as f5 from ai_runtime_final5 import app, base, rt, HTMLResponse, JSONResponse, Request, Query import html as html_lib SPACE_URL = "https://bep40-vnews.hf.space" SHORT_CHANNELS = ["baodantri7941", "baosuckhoedoisongboyte"] YOUTUBE_HANDLES = SHORT_CHANNELS DATA_DIR = "/data" if os.path.isdir("/data") else "/app/data" os.makedirs(DATA_DIR, exist_ok=True) SHORTS_CACHE = {"t": 0, "d": []} AI_INTERACTIONS_FILE = os.path.join(DATA_DIR, "ai_interactions.json") SHORT_COMMENTS_FILE = os.path.join(DATA_DIR, "short_comments.json") def clean(s): return re.sub(r"\s+", " ", html_lib.unescape(s or "")).strip() def _domain(u): try: return urlparse(u or '').netloc.replace('www.', '') except: return '' def _lj(p, d): try: if os.path.exists(p): return json.load(open(p, 'r', encoding='utf-8')) except: pass return d def _sj(p, d): try: os.makedirs(os.path.dirname(p), exist_ok=True) open(p + '.tmp', 'w', encoding='utf-8').write(json.dumps(d, ensure_ascii=False)) os.replace(p + '.tmp', p) except: pass def _yt_ytdlp(handle, count=20): try: import yt_dlp url = f"https://www.youtube.com/@{handle}/shorts" opts = {'quiet': True, 'extract_flat': True, 'skip_download': True, 'playlist-end': count, 'ignoreerrors': True, 'no_warnings': True} with yt_dlp.YoutubeDL(opts) as ydl: info = ydl.extract_info(url, download=False) out = [] for e in (info or {}).get('entries') or []: vid = e.get('id') or '' if not re.match(r'^[A-Za-z0-9_-]{11}$', vid): continue title = e.get('title') or 'YouTube Short' out.append({'title': title, 'link': f'https://www.youtube.com/watch?v={vid}', 'img': f'https://i.ytimg.com/vi/{vid}/hqdefault.jpg', 'source': 'yt', 'id': vid, 'channel': handle}) return out except: return [] def _yt_html(handle, count=20): try: r = requests.get(f"https://www.youtube.com/@{handle}/shorts", headers=getattr(base, 'HEADERS', {}), timeout=15) ids = []; out = [] for m in re.finditer(r'"videoId":"([A-Za-z0-9_-]{11})"', r.text): vid = m.group(1) if vid in ids: continue ids.append(vid) snip = r.text[max(0, m.start()-1000):m.start()+1800] title = 'YouTube Short' mt = re.search(r'"title":\{"runs":\[\{"text":"([^"]+)"', snip) or re.search(r'"accessibilityText":"([^"]+)"', snip) if mt: title = clean(mt.group(1).replace('\\n', ' ')) out.append({'title': title, 'link': f'https://www.youtube.com/watch?v={vid}', 'img': f'https://i.ytimg.com/vi/{vid}/hqdefault.jpg', 'source': 'yt', 'id': vid, 'channel': handle}) if len(out) >= count: break return out except: return [] def _fallback_shorts(): out = []; seen = set() hard = [ ('Lu_iCQ5YwNM', 'Công an lập hồ sơ xử lý người phụ nữ chửi bới, tát tài xế ô tô | Dân trí', 'baodantri7941'), ('CwWvijF8BOA', 'Chú rể bật khóc nhận món quà bí mật người cha quá cố gửi 26 năm trước | Dân trí', 'baodantri7941'), ('7Pd6vZ2Lz1M', 'Hành động ấm lòng trong tìm kiếm học sinh tử vong ở sông Lô | SKĐS', 'baosuckhoedoisongboyte'), ('SlHLt_ZyPiE', 'Xử phạt người đàn ông xóa số điện thoại cứu hộ trên cao tốc Bắc - Nam | SKĐS', 'baosuckhoedoisongboyte'), ] for vid, title, ch in hard: if vid not in seen: seen.add(vid) out.append({'id': vid, 'title': title, 'channel': ch, 'link': f'https://www.youtube.com/watch?v={vid}', 'img': f'https://i.ytimg.com/vi/{vid}/hqdefault.jpg', 'source': 'yt'}) return out def _fresh_shorts(): items = []; seen = set() for ch in YOUTUBE_HANDLES: got = _yt_ytdlp(ch, 24) or _yt_html(ch, 24) for v in got: if v['id'] not in seen: seen.add(v['id']); items.append(v) for v in _fallback_shorts(): if v['id'] not in seen: seen.add(v['id']); items.append(v) return items[:50] def _topic_image(topic): try: return base.pollinations_image_url(topic) except: return "https://image.pollinations.ai/prompt/" + quote("Vietnamese news editorial illustration " + topic) + "?width=1024&height=576&nologo=true" def _web_research_context(topic, limit=5): try: ctx, sources = base.web_context(topic, limit=limit) return {"context": ctx or "", "sources": sources or []} except: return {"context": "", "sources": []} def _fast_context(topic, limit=5): return _web_research_context(topic, limit) def _extract_source_details_from_context(ctx, sources): """Extract detailed source info from web context for source_details field.""" details = [] if not ctx or not sources: return details # Parse sources from context for s in sources[:8]: url = s.get('url', '') if not url: continue details.append({ 'title': s.get('title', ''), 'url': url, 'via': s.get('via', _domain(url)), 'content': s.get('excerpt', s.get('description', '')) }) return details _bg_home = {"t": 0, "d": []} _bg_shorts = {"t": 0, "d": []} _bg_lock = False def _bg(): global _bg_lock if _bg_lock: return _bg_lock = True try: # Fast homepage: just return empty, let frontend handle it _bg_home.update({"t": time.time(), "d": []}) # Shorts raw = [] for h in YOUTUBE_HANDLES: raw.extend(_yt_ytdlp(h, 20) or _yt_html(h, 20)) raw.extend(_fallback_shorts()) seen = set() out = [v for v in raw if v.get('id') and v['id'] not in seen and not seen.add(v['id'])] if out: _bg_shorts.update({"t": time.time(), "d": out[:40]}) except: pass finally: _bg_lock = False @app.on_event("startup") async def _s(): threading.Thread(target=_bg, daemon=True).start() threading.Thread(target=lambda: [time.sleep(600) or _bg() for _ in iter(int, 1)], daemon=True).start() # Remove endpoints to override app.router.routes = [r for r in app.router.routes if not ( getattr(r, 'path', None) in ('/api/homepage', '/api/shorts', '/api/ai_wall', '/api/topic_post', '/api/article/ask', '/api/topic/rewrite', '/api/rewrite_share', '/api/url_wall', '/api/short/comments', '/api/short/comment', '/api/storage_status', '/') and any(m in getattr(r, 'methods', set()) for m in ('GET', 'POST')) )] @app.get('/api/homepage') def _h(): n = time.time() if _bg_home['d']: if n - _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 _sh(refresh: int = Query(default=0)): n = time.time() if _bg_shorts['d'] and (not refresh or n - _bg_shorts['t'] < 120): if n - _bg_shorts['t'] > 600: threading.Thread(target=_bg, daemon=True).start() return JSONResponse(_bg_shorts['d']) data = _fresh_shorts() _bg_shorts.update({'t': n, 'd': data}) return JSONResponse(data) @app.get('/api/ai_wall') def _w(): n = int(time.time()) return JSONResponse({'posts': [p for p in f5.base._load_ai_wall() if n - int(p.get('ts') or 0) < 86400], 'persistent': os.path.isdir('/data')}) @app.get('/api/storage_status') def _st(): return JSONResponse({'persistent': os.path.isdir('/data')}) @app.get('/api/short/comments') def _gc(id: str = Query(...)): return JSONResponse({'comments': _lj(SHORT_COMMENTS_FILE, {}).get(id, [])}) @app.post('/api/short/comment') async def _pc(request: Request): b = await request.json() v = str(b.get('id', '')).strip() t = clean(b.get('text', '')) if not v or not t: return JSONResponse({'error': 'missing'}, status_code=400) db = _lj(SHORT_COMMENTS_FILE, {}) c = db.get(v, []) c.insert(0, {'text': t[:300], 'ts': int(time.time())}) db[v] = c[:100] _sj(SHORT_COMMENTS_FILE, db) return JSONResponse({'comments': db[v]}) @app.post('/api/article/ask') async def _ask(request: Request): b = await request.json() q = clean(b.get('question', '')) ctx = clean(b.get('context', '')) url = clean(b.get('url', '')) if not q: return JSONResponse({'error': 'missing question'}, status_code=400) title = ''; raw = '' if url: try: d = f5.base.scrape_any_url(url) title = d.get('title', '') raw = (d.get('summary', '') + '\n' + d.get('text', '')).strip() except: pass if not raw: raw = ctx[:12000] ans = await f5.base.qwen_generate( f'Bạn là VNEWS AI. Nội dung: "{title}"\n{raw[:9000]}\n\nHỏi: "{q}"\n\nTrả lời tự nhiên bằng tiếng Việt.', max_tokens=1200) return JSONResponse({'answer': ans or 'Chưa trả lời được.', 'title': title}) @app.post('/api/rewrite_share') @app.post('/api/url_wall') async def _rw(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) try: d = f5.base.scrape_any_url(url) title = d.get('title', '') raw = (d.get('summary', '') + '\n' + d.get('text', '')).strip() img = d.get('image') or '' except: title = ''; raw = ctx[:14000]; img = '' if len(raw) < 50: return JSONResponse({'error': 'Không đọc được bài'}, status_code=422) text = None try: text = await asyncio.wait_for(f5.base.qwen_generate( f'Tóm tắt đăng Tường AI:\nTiêu đề: {title}\n{raw[:14000]}\n\n4-6 ý chính. Cuối ghi nguồn.', image_url=img or None, max_tokens=1000), timeout=30) except: pass if not text or len(text) < 80: text = f"Tóm tắt: {title}\n\n{raw[:1200]}\n\nNguồn: {_domain(url)}" post = f5.base.make_post(title or 'Bài viết', text, 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 _tr(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 = [] for u in urls: try: d = f5.base.scrape_any_url(u) t = d.get('title', '') r = (d.get('summary', '') + '\n' + d.get('text', '')).strip() if r and len(r) > 150: parts.append(f"[{_domain(u)}] {t}\n{r}") except: pass ac = '\n---\n'.join(parts) if parts else (p.get('text') or '') title = p.get('title', '') text = None try: text = await asyncio.wait_for(f5.base.qwen_generate( f'Viết lại:\nChủ đề: {title}\n{ac[:16000]}\n\nTiêu đề mới + 4-6 ý + nguồn.', image_url=p.get('img'), max_tokens=1200), timeout=35) except: pass if not text or len(text) < 100: text = f"Tóm tắt: {title}\n\n{ac[:1500]}\n\nNguồn: VNEWS AI" np = f5.base.make_post('Rewrite: ' + title, text, p.get('img', ''), '', 'rewrite_topic', sources=p.get('sources', [])) np['images'] = p.get('images', []) all_p = f5.base._load_ai_wall(); all_p.insert(0, np); f5.base._save_ai_wall(all_p) return JSONResponse({'post': np}) @app.post('/api/topic_post') async def _tp(request: Request): b = await request.json() topic = clean(b.get('topic', '')) if not topic: return JSONResponse({'error': 'missing topic'}, status_code=400) img = _topic_image(topic) research = _fast_context(topic) ctx = research.get('context', '') src = research.get('sources', []) det = _extract_source_details_from_context(ctx, src) if not ctx or not src: return JSONResponse({'error': 'Không tìm được nội dung.'}, status_code=422) 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')})) 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}) FINAL6_INJECT = r''' ''' FINAL6_FAST_HOME_INJECT = r''' ''' FINAL6E_INJECT = r''' ''' @app.get('/') async def _index(): html = f5.f4.f3.f2.f1._load_index_html() 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 += FINAL6_INJECT + FINAL6_FAST_HOME_INJECT + FINAL6E_INJECT return HTMLResponse(html.replace('', body + '\n') if '' in html else html + body)