bep40 commited on
Commit
f34d700
·
verified ·
1 Parent(s): 0f65579

Upload ai_runtime_final6.py

Browse files
Files changed (1) hide show
  1. ai_runtime_final6.py +803 -348
ai_runtime_final6.py CHANGED
@@ -1,394 +1,849 @@
1
- """Final6 runtime: fast homepage, fast shorts, Qwen topic with source details, hashtag sources, rewrite auto-title."""
2
- import os, re, time, json, hashlib, asyncio, threading, requests
3
- from urllib.parse import urlparse, quote
 
 
 
 
 
4
  import ai_runtime_final5 as f5
5
- from ai_runtime_final5 import app, base, rt, HTMLResponse, JSONResponse, Request, Query
6
- import html as html_lib
7
 
8
- SPACE_URL = "https://bep40-vnews.hf.space"
9
- SHORT_CHANNELS = ["baodantri7941", "baosuckhoedoisongboyte"]
10
- YOUTUBE_HANDLES = SHORT_CHANNELS
11
- DATA_DIR = "/data" if os.path.isdir("/data") else "/app/data"
12
- os.makedirs(DATA_DIR, exist_ok=True)
13
- SHORTS_CACHE = {"t": 0, "d": []}
14
- AI_INTERACTIONS_FILE = os.path.join(DATA_DIR, "ai_interactions.json")
15
- SHORT_COMMENTS_FILE = os.path.join(DATA_DIR, "short_comments.json")
16
 
17
- def clean(s):
18
- return re.sub(r"\s+", " ", html_lib.unescape(s or "")).strip()
 
 
 
 
 
 
 
19
 
 
20
  def _domain(u):
21
- try: return urlparse(u or '').netloc.replace('www.', '')
22
- except: return ''
23
 
24
- def _lj(p, d):
25
  try:
26
- if os.path.exists(p): return json.load(open(p, 'r', encoding='utf-8'))
27
- except: pass
28
- return d
29
-
30
- def _sj(p, d):
31
  try:
32
- os.makedirs(os.path.dirname(p), exist_ok=True)
33
- open(p + '.tmp', 'w', encoding='utf-8').write(json.dumps(d, ensure_ascii=False))
34
- os.replace(p + '.tmp', p)
35
- except: pass
36
 
37
- def _yt_ytdlp(handle, count=20):
 
 
 
 
 
 
 
 
 
 
 
38
  try:
39
- import yt_dlp
40
- url = f"https://www.youtube.com/@{handle}/shorts"
41
- opts = {'quiet': True, 'extract_flat': True, 'skip_download': True, 'playlist-end': count, 'ignoreerrors': True, 'no_warnings': True}
42
- with yt_dlp.YoutubeDL(opts) as ydl:
43
- info = ydl.extract_info(url, download=False)
44
- out = []
45
- for e in (info or {}).get('entries') or []:
46
- vid = e.get('id') or ''
47
- if not re.match(r'^[A-Za-z0-9_-]{11}$', vid): continue
48
- title = e.get('title') or 'YouTube Short'
49
- 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})
50
- return out
51
- except: return []
52
 
53
- def _yt_html(handle, count=20):
54
- try:
55
- r = requests.get(f"https://www.youtube.com/@{handle}/shorts", headers=getattr(base, 'HEADERS', {}), timeout=15)
56
- ids = []; out = []
57
- for m in re.finditer(r'"videoId":"([A-Za-z0-9_-]{11})"', r.text):
58
- vid = m.group(1)
59
- if vid in ids: continue
60
- ids.append(vid)
61
- snip = r.text[max(0, m.start()-1000):m.start()+1800]
62
- title = 'YouTube Short'
63
- mt = re.search(r'"title":\{"runs":\[\{"text":"([^"]+)"', snip) or re.search(r'"accessibilityText":"([^"]+)"', snip)
64
- if mt: title = clean(mt.group(1).replace('\\n', ' '))
65
- 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})
66
- if len(out) >= count: break
67
- return out
68
- except: return []
69
 
70
- def _fallback_shorts():
71
- out = []; seen = set()
72
- hard = [
73
- ('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'),
74
- ('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'),
75
- ('7Pd6vZ2Lz1M', 'Hành động ấm lòng trong tìm kiếm học sinh tử vong ở sông Lô | SKĐS', 'baosuckhoedoisongboyte'),
76
- ('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'),
 
 
 
77
  ]
78
- for vid, title, ch in hard:
79
- if vid not in seen:
80
- seen.add(vid)
81
- 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'})
82
- return out
83
-
84
- def _fresh_shorts():
85
- items = []; seen = set()
86
- for ch in YOUTUBE_HANDLES:
87
- got = _yt_ytdlp(ch, 24) or _yt_html(ch, 24)
88
- for v in got:
89
- if v['id'] not in seen:
90
- seen.add(v['id']); items.append(v)
91
- for v in _fallback_shorts():
92
- if v['id'] not in seen:
93
- seen.add(v['id']); items.append(v)
94
- return items[:50]
 
 
 
95
 
96
- def _topic_image(topic):
97
- try: return base.pollinations_image_url(topic)
98
- except: return "https://image.pollinations.ai/prompt/" + quote("Vietnamese news editorial illustration " + topic) + "?width=1024&height=576&nologo=true"
 
 
 
 
99
 
100
- def _web_research_context(topic, limit=5):
 
101
  try:
102
- ctx, sources = base.web_context(topic, limit=limit)
103
- return {"context": ctx or "", "sources": sources or []}
104
- except:
105
- return {"context": "", "sources": []}
106
-
107
- def _fast_context(topic, limit=5):
108
- return _web_research_context(topic, limit)
109
-
110
- def _extract_source_details_from_context(ctx, sources):
111
- """Extract detailed source info from web context for source_details field."""
112
- details = []
113
- if not ctx or not sources:
114
- return details
115
- # Parse sources from context
116
- for s in sources[:8]:
117
- url = s.get('url', '')
118
- if not url: continue
119
- details.append({
120
- 'title': s.get('title', ''),
121
- 'url': url,
122
- 'via': s.get('via', _domain(url)),
123
- 'content': s.get('excerpt', s.get('description', ''))
124
- })
125
- return details
126
 
127
- _bg_home = {"t": 0, "d": []}
128
- _bg_shorts = {"t": 0, "d": []}
129
- _bg_lock = False
130
-
131
- def _bg():
132
- global _bg_lock
133
- if _bg_lock: return
134
- _bg_lock = True
135
  try:
136
- # Fast homepage: just return empty, let frontend handle it
137
- _bg_home.update({"t": time.time(), "d": []})
138
- # Shorts
139
- raw = []
140
- for h in YOUTUBE_HANDLES:
141
- raw.extend(_yt_ytdlp(h, 20) or _yt_html(h, 20))
142
- raw.extend(_fallback_shorts())
143
- seen = set()
144
- out = [v for v in raw if v.get('id') and v['id'] not in seen and not seen.add(v['id'])]
145
- if out: _bg_shorts.update({"t": time.time(), "d": out[:40]})
146
- except: pass
147
- finally: _bg_lock = False
148
-
149
- @app.on_event("startup")
150
- async def _s():
151
- threading.Thread(target=_bg, daemon=True).start()
152
- threading.Thread(target=lambda: [time.sleep(600) or _bg() for _ in iter(int, 1)], daemon=True).start()
153
-
154
- # Remove endpoints to override
155
- app.router.routes = [r for r in app.router.routes if not (
156
- 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
157
- any(m in getattr(r, 'methods', set()) for m in ('GET', 'POST'))
158
- )]
159
 
160
- @app.get('/api/homepage')
161
- def _h():
162
- n = time.time()
163
- if _bg_home['d']:
164
- if n - _bg_home['t'] > 300: threading.Thread(target=_bg, daemon=True).start()
165
- return JSONResponse(_bg_home['d'])
166
- threading.Thread(target=_bg, daemon=True).start()
167
- return JSONResponse([])
 
 
 
 
 
 
 
 
168
 
169
- @app.get('/api/shorts')
170
- def _sh(refresh: int = Query(default=0)):
171
- n = time.time()
172
- if _bg_shorts['d'] and (not refresh or n - _bg_shorts['t'] < 120):
173
- if n - _bg_shorts['t'] > 600: threading.Thread(target=_bg, daemon=True).start()
174
- return JSONResponse(_bg_shorts['d'])
175
- data = _fresh_shorts()
176
- _bg_shorts.update({'t': n, 'd': data})
177
- return JSONResponse(data)
178
-
179
- @app.get('/api/ai_wall')
180
- def _w():
181
- n = int(time.time())
182
- 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')})
183
-
184
- @app.get('/api/storage_status')
185
- def _st():
186
- return JSONResponse({'persistent': os.path.isdir('/data')})
187
-
188
- @app.get('/api/short/comments')
189
- def _gc(id: str = Query(...)):
190
- return JSONResponse({'comments': _lj(SHORT_COMMENTS_FILE, {}).get(id, [])})
191
-
192
- @app.post('/api/short/comment')
193
- async def _pc(request: Request):
194
- b = await request.json()
195
- v = str(b.get('id', '')).strip()
196
- t = clean(b.get('text', ''))
197
- if not v or not t: return JSONResponse({'error': 'missing'}, status_code=400)
198
- db = _lj(SHORT_COMMENTS_FILE, {})
199
- c = db.get(v, [])
200
- c.insert(0, {'text': t[:300], 'ts': int(time.time())})
201
- db[v] = c[:100]
202
- _sj(SHORT_COMMENTS_FILE, db)
203
- return JSONResponse({'comments': db[v]})
204
-
205
- @app.post('/api/article/ask')
206
- async def _ask(request: Request):
207
- b = await request.json()
208
- q = clean(b.get('question', ''))
209
- ctx = clean(b.get('context', ''))
210
- url = clean(b.get('url', ''))
211
- if not q: return JSONResponse({'error': 'missing question'}, status_code=400)
212
- title = ''; raw = ''
213
- if url:
214
- try:
215
- d = f5.base.scrape_any_url(url)
216
- title = d.get('title', '')
217
- raw = (d.get('summary', '') + '\n' + d.get('text', '')).strip()
218
- except: pass
219
- if not raw: raw = ctx[:12000]
220
- ans = await f5.base.qwen_generate(
221
- 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.',
222
- max_tokens=1200)
223
- return JSONResponse({'answer': ans or 'Chưa trả lời được.', 'title': title})
224
-
225
- @app.post('/api/rewrite_share')
226
- @app.post('/api/url_wall')
227
- async def _rw(request: Request):
228
- b = await request.json()
229
- url = clean(b.get('url', ''))
230
- ctx = clean(b.get('context', ''))
231
- if not url.startswith('http'): return JSONResponse({'error': 'URL không hợp lệ'}, status_code=400)
232
- try:
233
- d = f5.base.scrape_any_url(url)
234
- title = d.get('title', '')
235
- raw = (d.get('summary', '') + '\n' + d.get('text', '')).strip()
236
- img = d.get('image') or ''
237
- except:
238
- title = ''; raw = ctx[:14000]; img = ''
239
- if len(raw) < 50: return JSONResponse({'error': 'Không đọc được bài'}, status_code=422)
240
- text = None
241
  try:
242
- text = await asyncio.wait_for(f5.base.qwen_generate(
243
- 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.',
244
- image_url=img or None, max_tokens=1000), timeout=30)
245
- except: pass
246
- if not text or len(text) < 80:
247
- text = f"Tóm tắt: {title}\n\n{raw[:1200]}\n\nNguồn: {_domain(url)}"
248
- post = f5.base.make_post(title or 'Bài viết', text, img, url, 'rewrite',
249
- sources=[{'title': title, 'url': url, 'via': _domain(url)}])
250
- ps = f5.base._load_ai_wall(); ps.insert(0, post); f5.base._save_ai_wall(ps)
251
- return JSONResponse({'post': post})
252
-
253
- @app.post('/api/topic/rewrite')
254
- async def _tr(request: Request):
255
- b = await request.json()
256
- pid = str(b.get('post_id', '')).strip()
257
- if not pid: return JSONResponse({'error': 'missing post_id'}, status_code=400)
258
- ps = f5.base._load_ai_wall()
259
- p = next((x for x in ps if str(x.get('id')) == pid), None)
260
- if not p: return JSONResponse({'error': 'Bài không tồn tại'}, status_code=404)
261
- urls = list(dict.fromkeys(
262
- [s['url'] for s in (p.get('source_details') or []) if s.get('url')] +
263
- [s['url'] for s in (p.get('sources') or []) if s.get('url')]
264
- ))[:5]
265
- parts = []
266
- for u in urls:
267
- try:
268
- d = f5.base.scrape_any_url(u)
269
- t = d.get('title', '')
270
- r = (d.get('summary', '') + '\n' + d.get('text', '')).strip()
271
- if r and len(r) > 150:
272
- parts.append(f"[{_domain(u)}] {t}\n{r}")
273
- except: pass
274
- ac = '\n---\n'.join(parts) if parts else (p.get('text') or '')
275
- title = p.get('title', '')
276
- text = None
277
  try:
278
- text = await asyncio.wait_for(f5.base.qwen_generate(
279
- f'Viết lại:\nChủ đề: {title}\n{ac[:16000]}\n\nTiêu đề mới + 4-6 ý + nguồn.',
280
- image_url=p.get('img'), max_tokens=1200), timeout=35)
281
- except: pass
282
- if not text or len(text) < 100:
283
- text = f"Tóm tắt: {title}\n\n{ac[:1500]}\n\nNguồn: VNEWS AI"
284
- np = f5.base.make_post('Rewrite: ' + title, text, p.get('img', ''), '', 'rewrite_topic', sources=p.get('sources', []))
285
- np['images'] = p.get('images', [])
286
- all_p = f5.base._load_ai_wall(); all_p.insert(0, np); f5.base._save_ai_wall(all_p)
287
- return JSONResponse({'post': np})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
288
 
289
  @app.post('/api/topic_post')
290
- async def _tp(request: Request):
291
- b = await request.json()
292
- topic = clean(b.get('topic', ''))
293
- if not topic: return JSONResponse({'error': 'missing topic'}, status_code=400)
294
- img = _topic_image(topic)
295
- research = _fast_context(topic)
296
- ctx = research.get('context', '')
297
- src = research.get('sources', [])
298
- det = _extract_source_details_from_context(ctx, src)
299
- if not ctx or not src:
300
- return JSONResponse({'error': 'Không tìm được nội dung.'}, status_code=422)
301
- 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]
302
- text = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
303
  try:
304
- text = await asyncio.wait_for(f5.base.qwen_generate(
305
- 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.',
306
- image_url=img, max_tokens=1700), timeout=35)
307
- except: pass
308
- if not text or len(text) < 300:
309
- 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')}))
310
- post = f5.base.make_post(topic, text, img, '', 'topic_focused', sources=[s for s in src if s.get('url')])
311
- post['images'] = [img]; post['source_details'] = det
312
- ps = f5.base._load_ai_wall(); ps.insert(0, post); f5.base._save_ai_wall(ps)
313
- return JSONResponse({'post': post})
314
-
315
- FINAL6_INJECT = r'''
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
316
  <style>
317
- /* Livescore */
318
- .ls-content{max-height:480px;overflow-y:auto;padding:0 6px 8px;font-size:12px;color:#ddd}
319
- .ls-content ul{list-style:none;padding:0;margin:0}
320
- .ls-content .title-content{display:flex;gap:6px;align-items:center;background:#222;border-radius:4px;margin:4px 0;padding:5px 8px}
321
- .ls-content .title-content img{width:18px;height:18px}
322
- .ls-content .title-content strong{font-size:11px;color:#ccc}
323
- .ls-content .match-detail{padding:6px;border-bottom:1px solid #262626;cursor:pointer}
324
- .ls-content .match-detail:hover{background:#1a2a1f}
325
- .ls-content .match{display:flex;flex-wrap:wrap;align-items:center;gap:4px}
326
- .ls-content .datetime{width:100%;font-size:9px;color:#888}
327
- .ls-content .teams{display:flex;width:100%;align-items:center;gap:4px}
328
- .ls-content .team{flex:1;display:flex;align-items:center;gap:4px;min-width:0}
329
- .ls-content .team .name{font-size:11px;color:#ddd;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
330
- .ls-content .team .logo img{width:18px;height:18px}
331
- .ls-content .home-team{justify-content:flex-end;text-align:right}
332
- .ls-content .status{flex:0 0 54px;text-align:center}
333
- .ls-content .status a{color:#fff;text-decoration:none;font-weight:800;font-size:12px}
334
- .ls-content .status .label{font-size:8px;color:#888;display:block}
335
- .ls-content .status .label.live{color:#e74c3c}
336
- .ls-content .info,.ls-content .btns{display:none}
337
  </style>
338
  <script>
339
  (function(){
340
  function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));}
341
- // Block slow YouTube refresh on first load
342
- var _origFetch=window.fetch,_allowRefresh=false;
343
- 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);};
344
- setTimeout(function(){_allowRefresh=true;},8000);
 
 
 
345
  })();
346
  </script>
347
  '''
348
 
349
- FINAL6_FAST_HOME_INJECT = r'''
350
- <style>
351
- .storage-warn{background:#332200;border:1px solid #664400;color:#ffcc00;padding:8px 12px;border-radius:8px;font-size:11px;margin:6px 4px}
352
- </style>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
353
  <script>
354
  (function(){
355
- fetch('/api/storage_status').then(function(r){return r.json()}).then(function(j){
356
- if(!j.persistent){var h=document.getElementById('view-home');if(h){var w=document.createElement('div');w.className='storage-warn';w.innerHTML='⚠️ <b>Persistent Storage chưa bật.</b> Bật: Space Settings → Persistent Storage → Small.';h.prepend(w);}}
357
- });
 
358
  })();
359
  </script>
360
- '''
 
 
 
 
 
 
 
361
 
362
- FINAL6E_INJECT = r'''
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
363
  <style>
364
- /* Kill ALL duplicate slides/walls from old layers */
365
- #ai-short-home,.ai-short-home,.ai-short-card-final,[id*="ai-shorts-patched"]{display:none!important}
366
  </style>
367
  <script>
368
  (function(){
369
- // Kill old renderers
370
- window.renderTopicWallE=function(){};
371
- window.renderAIShortHome=function(){};
372
- window.renderAIShorts7=function(){};
373
- window.renderPatchedWall=function(){};
374
- window.renderAiShorts=function(){};
375
- window.renderWall=function(){};
376
- window.renderAIShorts=function(){};
377
- window.loadPatchedWall=function(){};
378
- window.refreshFinalWall3=function(){};
379
- // Remove duplicate slides
380
- setInterval(function(){
381
- document.querySelectorAll('#ai-short-home,.ai-short-home,[id*="ai-shorts-patched"]').forEach(function(el){el.remove()});
382
- },2000);
383
  })();
384
  </script>
385
  '''
386
-
387
- @app.get('/')
388
- async def _index():
389
- html = f5.f4.f3.f2.f1._load_index_html()
390
- body = ''
391
- body += getattr(rt.old, 'PATCH_INJECT', '')
392
- body += f5.f4.f3.f2.f1.FINAL_INJECT + f5.f4.f3.FINAL3_INJECT + f5.f4.FINAL4_INJECT + f5.FINAL5_INJECT
393
- body += FINAL6_INJECT + FINAL6_FAST_HOME_INJECT + FINAL6E_INJECT
394
- return HTMLResponse(html.replace('</body>', body + '\n</body>') if '</body>' in html else html + body)
 
1
+ """Final6: robust topic synthesis, stable shorts, hot topic hashtags.
2
+
3
+ This runtime intentionally overrides only the topic/shorts/root endpoints from the restored app.
4
+ """
5
+ import re, time, json, os, threading, html as html_lib
6
+ from urllib.parse import quote, urlparse, parse_qs, unquote
7
+ import requests
8
+ from bs4 import BeautifulSoup
9
  import ai_runtime_final5 as f5
10
+ from ai_runtime_final5 import app, rt, HTMLResponse, JSONResponse, Request, Query
 
11
 
12
+ _PATCH={('/api/topic_post','POST'),('/api/shorts','GET'),('/api/hot_topics','GET'),('/api/topic_sources','GET'),('/','GET')}
13
+ 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)]
 
 
 
 
 
 
14
 
15
+ _TOPIC_CACHE={}
16
+ _HOT_CACHE={"t":0,"d":[]}
17
+ _SHORTS_CACHE_FINAL6={"t":0,"d":[]}
18
+ _TRANSLATE_CACHE_PATH="/data/title_vi_cache.json" if os.path.isdir('/data') else "/app/data/title_vi_cache.json"
19
+ _translate_lock=threading.Lock()
20
+ YOUTUBE_HANDLES=["baodantri7941","baosuckhoedoisongboyte"]
21
+ UA={"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36","Accept-Language":"vi,en;q=0.8"}
22
+ STOP_WORDS=set('và của các những một được trong với cho tại sau trước khi không người việt nam hôm nay mới nhất nóng tin tức cập nhật'.split())
23
+ TRUSTED_SITES=['vnexpress.net','dantri.com.vn','vietnamnet.vn','tuoitre.vn','thanhnien.vn','laodong.vn','vov.vn','vtv.vn','genk.vn','cafef.vn','thethaovanhoa.vn']
24
 
25
+ def clean(s):return re.sub(r"\s+"," ",html_lib.unescape(str(s or ""))).strip()
26
  def _domain(u):
27
+ try:return urlparse(u or '').netloc.replace('www.','')
28
+ except Exception:return ''
29
 
30
+ def _load_title_cache():
31
  try:
32
+ if os.path.exists(_TRANSLATE_CACHE_PATH):
33
+ with open(_TRANSLATE_CACHE_PATH,'r',encoding='utf-8') as f:return json.load(f)
34
+ except Exception:pass
35
+ return {}
36
+ def _save_title_cache(db):
37
  try:
38
+ os.makedirs(os.path.dirname(_TRANSLATE_CACHE_PATH),exist_ok=True);tmp=_TRANSLATE_CACHE_PATH+'.tmp'
39
+ with open(tmp,'w',encoding='utf-8') as f:json.dump(db,f,ensure_ascii=False)
40
+ os.replace(tmp,_TRANSLATE_CACHE_PATH)
41
+ except Exception:pass
42
 
43
+ def _looks_vietnamese(s):
44
+ s=s or ''
45
+ if re.search(r'[àáạảãâầấậẩẫăằắặẳẵèéẹẻẽêềếệểễìíịỉĩòóọỏõôồốộổỗơờớợởỡùúụủũưừứựửữỳýỵỷỹđ]',s,re.I):return True
46
+ low=' '+s.lower()+' '
47
+ return any(w in low for w in [' và ',' của ',' người ',' tại ',' trong ',' với ',' không ',' được ',' công an ',' bệnh viện ',' học sinh ',' tài xế ',' bóng đá ',' tin tức ',' sức khỏe '])
48
+ def _translate_title_vi(title):
49
+ title=clean(title)
50
+ if not title or _looks_vietnamese(title):return title
51
+ with _translate_lock:
52
+ db=_load_title_cache()
53
+ if title in db:return db[title]
54
+ vi=title
55
  try:
56
+ r=requests.get('https://translate.googleapis.com/translate_a/single',params={'client':'gtx','sl':'auto','tl':'vi','dt':'t','q':title},headers=UA,timeout=8)
57
+ if r.status_code==200:
58
+ data=r.json();vi=''.join(part[0] for part in data[0] if part and part[0]).strip() or title
59
+ except Exception:pass
60
+ vi=clean(vi)
61
+ with _translate_lock:
62
+ db=_load_title_cache();db[title]=vi;_save_title_cache(db)
63
+ return vi
 
 
 
 
 
64
 
65
+ # ===== Hot topics / hashtags =====
66
+ def _keywords_from_title(title):
67
+ title=clean(re.sub(r'\s+-\s+.*$','',title))
68
+ words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',title) if len(w)>2 and w.lower() not in STOP_WORDS]
69
+ phrases=[]
70
+ for n in (4,3,2):
71
+ for i in range(0,max(0,len(words)-n+1)):
72
+ ph=' '.join(words[i:i+n]).strip()
73
+ if len(ph)>=8:phrases.append(ph)
74
+ if words:phrases.append(' '.join(words[:5]))
75
+ return phrases[:4]
 
 
 
 
 
76
 
77
+ def _hot_topics():
78
+ now=time.time()
79
+ if _HOT_CACHE['d'] and now-_HOT_CACHE['t']<900:return _HOT_CACHE['d']
80
+ topics=[];seen=set()
81
+ feeds=[
82
+ 'https://news.google.com/rss?hl=vi&gl=VN&ceid=VN:vi',
83
+ 'https://news.google.com/rss/headlines/section/topic/NATION?hl=vi&gl=VN&ceid=VN:vi',
84
+ 'https://news.google.com/rss/headlines/section/topic/BUSINESS?hl=vi&gl=VN&ceid=VN:vi',
85
+ 'https://news.google.com/rss/headlines/section/topic/SPORTS?hl=vi&gl=VN&ceid=VN:vi',
86
+ 'https://news.google.com/rss/headlines/section/topic/TECHNOLOGY?hl=vi&gl=VN&ceid=VN:vi'
87
  ]
88
+ for feed in feeds:
89
+ try:
90
+ r=requests.get(feed,headers=UA,timeout=10);r.encoding='utf-8'
91
+ soup=BeautifulSoup(r.text,'xml')
92
+ for it in soup.find_all('item')[:15]:
93
+ title=clean(it.find('title').get_text(' ',strip=True) if it.find('title') else '')
94
+ for kw in _keywords_from_title(title):
95
+ key=kw.lower()
96
+ if key not in seen and len(kw)<=60:
97
+ seen.add(key);topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw})
98
+ if len(topics)>=24:break
99
+ if len(topics)>=24:break
100
+ except Exception:pass
101
+ if len(topics)>=24:break
102
+ for kw in ['AI trong giáo dục','World Cup 2026','kinh tế Việt Nam','biến đổi khí hậu','giá vàng','bóng đá Việt Nam','an ninh mạng','xe điện','sức khỏe tinh thần','thị trường chứng khoán']:
103
+ if kw.lower() not in seen:topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw})
104
+ _HOT_CACHE.update({'t':now,'d':topics[:24]})
105
+ return _HOT_CACHE['d']
106
+ @app.get('/api/hot_topics')
107
+ def api_hot_topics():return JSONResponse({'topics':_hot_topics()})
108
 
109
+ # ===== Topic web research =====
110
+ def _unwrap_ddg_href(href):
111
+ if not href:return ''
112
+ if href.startswith('//duckduckgo.com/l/?') or 'duckduckgo.com/l/?' in href:
113
+ qs=parse_qs(urlparse('https:'+href if href.startswith('//') else href).query)
114
+ return unquote(qs.get('uddg',[''])[0])
115
+ return href
116
 
117
+ def _ddg_search(query, limit=10):
118
+ items=[];seen=set()
119
  try:
120
+ url='https://html.duckduckgo.com/html/?q='+quote(query)
121
+ r=requests.get(url,headers=UA,timeout=14);r.encoding='utf-8'
122
+ soup=BeautifulSoup(r.text,'lxml')
123
+ for res in soup.select('.result'):
124
+ a=res.select_one('.result__title a') or res.find('a',href=True)
125
+ if not a:continue
126
+ link=_unwrap_ddg_href(a.get('href',''));title=clean(a.get_text(' ',strip=True));snippet=clean((res.select_one('.result__snippet') or res).get_text(' ',strip=True))
127
+ if not link.startswith('http') or link in seen:continue
128
+ if any(bad in link for bad in ['duckduckgo.com','youtube.com','facebook.com','tiktok.com','twitter.com','x.com']):continue
129
+ seen.add(link);items.append({'title':title,'url':link,'source':_domain(link),'snippet':snippet})
130
+ if len(items)>=limit:break
131
+ except Exception:pass
132
+ return items
 
 
 
 
 
 
 
 
 
 
 
133
 
134
+ def _google_news_items(topic, limit=8):
135
+ items=[];seen=set()
 
 
 
 
 
 
136
  try:
137
+ rss='https://news.google.com/rss/search?q='+quote(topic)+'&hl=vi&gl=VN&ceid=VN:vi'
138
+ r=requests.get(rss,headers=UA,timeout=12);r.encoding='utf-8'
139
+ soup=BeautifulSoup(r.text,'xml')
140
+ for it in soup.find_all('item')[:limit*2]:
141
+ title=clean(it.find('title').get_text(' ',strip=True) if it.find('title') else '')
142
+ link=clean(it.find('link').get_text(strip=True) if it.find('link') else '')
143
+ src=clean(it.find('source').get_text(' ',strip=True) if it.find('source') else _domain(link))
144
+ if title and link and link not in seen:
145
+ seen.add(link);items.append({'title':title,'url':link,'source':src,'snippet':''})
146
+ if len(items)>=limit:break
147
+ except Exception:pass
148
+ return items
 
 
 
 
 
 
 
 
 
 
 
149
 
150
+ def _candidate_urls(topic):
151
+ seen=set();items=[]
152
+ queries=[topic+' tin tức Việt Nam', topic+' phân tích bối cảnh', topic+' site:vnexpress.net OR site:dantri.com.vn OR site:vietnamnet.vn']
153
+ for q in queries:
154
+ for it in _ddg_search(q,8):
155
+ if it['url'] not in seen:
156
+ seen.add(it['url']);items.append(it)
157
+ if len(items)>=12:break
158
+ for site in TRUSTED_SITES[:8]:
159
+ for it in _ddg_search(f'{topic} site:{site}',3):
160
+ if it['url'] not in seen:
161
+ seen.add(it['url']);items.append(it)
162
+ for it in _google_news_items(topic,8):
163
+ if it['url'] not in seen:
164
+ seen.add(it['url']);items.append(it)
165
+ return items[:24]
166
 
167
+ def _extract_article_text_bs(url, max_chars=9000):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
  try:
169
+ r=requests.get(url,headers=UA,timeout=16,allow_redirects=True)
170
+ if r.status_code>=400:return ''
171
+ r.encoding='utf-8';soup=BeautifulSoup(r.text,'lxml')
172
+ for tag in soup.find_all(['script','style','nav','footer','aside','form','noscript','iframe','svg']):tag.decompose()
173
+ candidates=[]
174
+ for sel in ['article','main','.article-content','.detail-content','.singular-content','.fck_detail','.content-detail','.entry-content','.story-body','.knc-content']:
175
+ el=soup.select_one(sel)
176
+ if el:candidates.append(el)
177
+ if not candidates:candidates=[soup.body or soup]
178
+ best=max(candidates,key=lambda el:len(el.find_all('p')) if el else 0)
179
+ ps=[]
180
+ for el in best.find_all(['p','h2','h3'],recursive=True):
181
+ t=clean(el.get_text(' ',strip=True))
182
+ if len(t)>45 and not any(x in t.lower() for x in ['đăng ký nhận tin','theo dõi chúng tôi','chuyên mục','xem thêm','tin liên quan','advertisement']):ps.append(t)
183
+ if sum(len(x) for x in ps)>max_chars:break
184
+ return '\n'.join(ps)[:max_chars]
185
+ except Exception:return ''
186
+
187
+ def _jina_read_text(url, max_chars=9000):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  try:
189
+ ju='https://r.jina.ai/http://'+url
190
+ r=requests.get(ju,headers=UA,timeout=28);r.encoding='utf-8'
191
+ if r.status_code!=200 or not r.text:return ''
192
+ lines=[]
193
+ for ln in r.text.splitlines():
194
+ t=clean(ln)
195
+ if not t or t.startswith(('Title:','URL Source:','Published Time:','Markdown Content:','Image:','Description:')):continue
196
+ if len(t)>45:lines.append(t)
197
+ if sum(len(x) for x in lines)>max_chars:break
198
+ return '\n'.join(lines)[:max_chars]
199
+ except Exception:return ''
200
+
201
+ def _scrape_article_text(url, max_chars=9000):
202
+ text=_extract_article_text_bs(url,max_chars)
203
+ if len(text)<350:text=_jina_read_text(url,max_chars)
204
+ return text
205
+
206
+ def _score_relevance(topic, title, text, snippet=''):
207
+ keys=[w.lower() for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',topic) if len(w)>2 and w.lower() not in STOP_WORDS]
208
+ hay=(title+' '+snippet+' '+text[:2500]).lower()
209
+ if not keys:return 1
210
+ return sum(1 for k in keys if k in hay)
211
+
212
+ def _web_research_context(topic):
213
+ now=time.time();key=topic.lower().strip()
214
+ if key in _TOPIC_CACHE and now-_TOPIC_CACHE[key]['t']<900:return _TOPIC_CACHE[key]['d']
215
+ items=_candidate_urls(topic)
216
+ crawled=[]
217
+ for it in items:
218
+ text=_scrape_article_text(it['url'],9000)
219
+ rel=_score_relevance(topic,it.get('title',''),text,it.get('snippet',''))
220
+ if text and len(text)>300 and rel>0:
221
+ crawled.append({**it,'text':text,'rel':rel})
222
+ elif it.get('snippet') and rel>0:
223
+ crawled.append({**it,'text':it['snippet'],'rel':rel,'snippet_only':True})
224
+ crawled=sorted(crawled,key=lambda x:(x.get('rel',0),len(x.get('text',''))),reverse=True)[:6]
225
+ blocks=[];sources=[]
226
+ for it in crawled:
227
+ label='ĐOẠN MÔ TẢ TỪ KẾT QUẢ TÌM KIẾM' if it.get('snippet_only') else 'NỘI DUNG BÀI VIẾT ĐÃ CRAWL'
228
+ blocks.append(f"NGUỒN: {it['source']}\nTIÊU ĐỀ: {it['title']}\n{label}:\n{it['text'][:8500]}")
229
+ sources.append({'title':it['title'],'url':it['url'],'via':it['source']})
230
+ data={'context':'\n\n---\n\n'.join(blocks),'sources':sources[:8],'count':len(blocks)}
231
+ _TOPIC_CACHE[key]={'t':now,'d':data}
232
+ return data
233
+
234
+ def _topic_image(topic):
235
+ try:return f5.base.pollinations_image_url(topic)
236
+ except Exception:return 'https://image.pollinations.ai/prompt/'+quote('Vietnamese editorial illustration, '+topic)+'?width=1024&height=576&nologo=true'
237
+
238
+ @app.get('/api/topic_sources')
239
+ def api_topic_sources(topic:str=Query(...)):
240
+ data=_web_research_context(clean(topic))
241
+ return JSONResponse({'count':data.get('count',0),'sources':data.get('sources',[]),'has_context':bool(data.get('context'))})
242
 
243
  @app.post('/api/topic_post')
244
+ async def topic_post_synthesis(request:Request):
245
+ body=await request.json();topic=clean(body.get('topic',''))
246
+ if not topic:return JSONResponse({'error':'missing topic'},status_code=400)
247
+ img=_topic_image(topic);research=_web_research_context(topic);context=research.get('context','');sources=research.get('sources',[])
248
+ if not context or research.get('count',0)==0:
249
+ return JSONResponse({'error':'Không tìm/crawl được đủ nội dung về chủ đề này. Hãy thử chủ đề cụ thể hơn hoặc dùng hashtag gợi ý.'},status_code=422)
250
+ prompt=f"""Bạn là biên tập viên VNEWS. Người dùng chọn chủ đề: "{topic}".
251
+
252
+ Dưới đây là NỘI DUNG các bài viết/đoạn mô tả đã crawl từ internet. Hãy đọc hiểu và TỔNG HỢP thành MỘT BÀI VIẾT HOÀN CHỈNH. Tuyệt đối không bê nguyên văn, không xếp danh sách tiêu đề thành bài viết, không viết kiểu trả lời chat.
253
+
254
+ DỮ LIỆU CRAWL:
255
+ {context[:30000]}
256
+
257
+ Yêu cầu bắt buộc:
258
+ - Viết bằng tiếng Việt, văn phong báo điện tử/tạp chí.
259
+ - Tiêu đề mới, rõ, hấp dẫn.
260
+ - Sapo 2-3 câu nêu vấn đề chính.
261
+ - 5-8 đoạn nội dung tổng hợp: bối cảnh, diễn biến/khái niệm, phân tích, tác động, điểm cần lưu ý.
262
+ - Dùng thông tin từ nội dung đã crawl để tổng hợp ý; nếu chỉ có mô tả tìm kiếm thì viết thận trọng.
263
+ - KHÔNG liệt kê các tiêu đề nguồn. KHÔNG mở đầu bằng "Dưới đây là" hay "Tôi sẽ".
264
+ - Cuối bài thêm mục "Nguồn tham khảo" gồm tên nguồn ngắn gọn.
265
+ """
266
+ text=await f5.base.qwen_generate(prompt,image_url=img,max_tokens=2800)
267
+ if not text or len(text)<500:
268
+ parts=[]
269
+ for block in context.split('---'):
270
+ body=block.split('NỘI DUNG BÀI VIẾT ĐÃ CRAWL:')[-1].split('ĐOẠN MÔ TẢ TỪ KẾT QUẢ TÌM KIẾM:')[-1].strip()
271
+ if len(body)>120:parts.append(body)
272
+ joined='\n\n'.join(parts)[:8500]
273
+ text=(f"{topic}: những điểm chính cần biết\n\n{topic} đang thu hút sự chú ý vì liên quan đến nhiều khía cạnh thực tế. Tổng hợp từ các nội dung thu thập được, có thể nhìn vấn đề qua bối cảnh, tác động và những điểm cần theo dõi.\n\n"+joined+"\n\nNguồn tham khảo: "+', '.join(sorted({s.get('via','') for s in sources if s.get('via')})))
274
+ post=f5.base.make_post(topic,text,img,'','topic_web_synthesis',sources=[s for s in sources if s.get('url')]);post['images']=[img]
275
+ posts=f5.base._load_ai_wall();posts.insert(0,post);f5.base._save_ai_wall(posts)
276
+ return JSONResponse({'post':post})
277
+
278
+ # ===== Stable newest Dantri/SKDS Shorts =====
279
+ def _yt_ytdlp(handle,count=30):
280
  try:
281
+ import yt_dlp
282
+ urls=[f'https://www.youtube.com/@{handle}/shorts',f'https://www.youtube.com/@{handle}/videos']
283
+ out=[];seen=set();opts={'quiet':True,'extract_flat':True,'skip_download':True,'playlistend':count,'ignoreerrors':True,'no_warnings':True,'extractor_args':{'youtube':{'player_client':['web']}}}
284
+ for url in urls:
285
+ with yt_dlp.YoutubeDL(opts) as ydl:info=ydl.extract_info(url,download=False)
286
+ for e in (info or {}).get('entries') or []:
287
+ vid=e.get('id') or ''
288
+ if not re.match(r'^[A-Za-z0-9_-]{11}$',vid) or vid in seen:continue
289
+ title=e.get('title') or 'YouTube Short'
290
+ if url.endswith('/videos') and '#short' not in title.lower() and 'shorts' not in title.lower():continue
291
+ seen.add(vid);out.append({'title':title,'link':'https://www.youtube.com/watch?v='+vid,'img':'https://i.ytimg.com/vi/'+vid+'/hqdefault.jpg','source':'yt','id':vid,'channel':handle})
292
+ if len(out)>=count:break
293
+ if len(out)>=count:break
294
+ return out
295
+ except Exception:return []
296
+ def _yt_html(handle,count=30):
297
+ out=[];seen=set()
298
+ for suffix in ['shorts','videos']:
299
+ try:
300
+ r=requests.get(f'https://www.youtube.com/@{handle}/{suffix}',headers=UA,timeout=15);html=r.text
301
+ for m in re.finditer(r'"videoId":"([A-Za-z0-9_-]{11})"',html):
302
+ vid=m.group(1)
303
+ if vid in seen:continue
304
+ snip=html[max(0,m.start()-1200):m.start()+2200];title='YouTube Short'
305
+ mt=re.search(r'"title":\{"runs":\[\{"text":"([^"]+)"',snip) or re.search(r'"accessibilityText":"([^"]+)"',snip)
306
+ if mt:title=clean(mt.group(1).replace('\\n',' '))
307
+ if suffix=='videos' and '#short' not in title.lower() and 'shorts' not in title.lower():continue
308
+ seen.add(vid);out.append({'title':title,'link':'https://www.youtube.com/watch?v='+vid,'img':'https://i.ytimg.com/vi/'+vid+'/hqdefault.jpg','source':'yt','id':vid,'channel':handle})
309
+ if len(out)>=count:break
310
+ except Exception:pass
311
+ if len(out)>=count:break
312
+ return out[:count]
313
+ def _fallback_shorts():
314
+ try:return f5._fallback_shorts()
315
+ except Exception:return []
316
+ @app.get('/api/shorts')
317
+ def api_shorts_final6(refresh:int=Query(default=0)):
318
+ now=time.time()
319
+ if not refresh and _SHORTS_CACHE_FINAL6['d'] and now-_SHORTS_CACHE_FINAL6['t']<600:return JSONResponse(_SHORTS_CACHE_FINAL6['d'])
320
+ raw=[]
321
+ for h in YOUTUBE_HANDLES:raw.extend(_yt_ytdlp(h,30) or _yt_html(h,30))
322
+ raw.extend(_fallback_shorts())
323
+ seen=set();out=[]
324
+ for v in raw:
325
+ vid=v.get('id') or ''
326
+ if not vid:
327
+ m=re.search(r'(?:v=|shorts/|youtu\.be/)([A-Za-z0-9_-]{11})',v.get('link',''));vid=m.group(1) if m else ''
328
+ title=_translate_title_vi(v.get('title') or 'YouTube Short');key=vid or re.sub(r'\W+','',title.lower())[:80]
329
+ if not key or key in seen:continue
330
+ seen.add(key);item=dict(v);item['id']=vid;item['title']=title
331
+ if vid:item['link']='https://www.youtube.com/watch?v='+vid;item['img']='https://i.ytimg.com/vi/'+vid+'/hqdefault.jpg'
332
+ item['source']='yt';out.append(item)
333
+ if len(out)>=40:break
334
+ _SHORTS_CACHE_FINAL6.update({'t':now,'d':out})
335
+ return JSONResponse(out)
336
+
337
+ FINAL6_INJECT=r'''
338
  <style>
339
+ #ai-topic-input-final3,.topic-final3,#ai-topic-input-final4,.topic-final4{display:none!important}.topic-final5{display:flex!important}.ai-wall-topic-live{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}.hot-topic-row{display:flex;gap:6px;overflow-x:auto;padding:4px 0}.hot-chip{flex:0 0 auto;background:#222;border:1px solid #333;color:#ddd;border-radius:16px;padding:5px 10px;font-size:11px;cursor:pointer}.hot-chip:active{transform:scale(.96)}.topic-source-note{font-size:10px;color:#777;margin-top:4px;line-height:1.3}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
340
  </style>
341
  <script>
342
  (function(){
343
  function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));}
344
+ let liveTopicWall=[];
345
+ async function ensureHotTopics(){let inp=document.getElementById('ai-topic-input-final5');if(!inp||document.getElementById('hot-topic-row-final6'))return;let row=document.createElement('div');row.id='hot-topic-row-final6';row.className='hot-topic-row';row.innerHTML='<span style="color:#777;font-size:11px;padding:5px 0">Đang tải t��� khóa nóng...</span>';inp.insertAdjacentElement('afterend',row);let note=document.createElement('div');note.id='topic-source-note';note.className='topic-source-note';note.textContent='AI sẽ tìm nhiều nguồn, crawl nội dung bài viết rồi tổng hợp thành bài mới.';row.insertAdjacentElement('afterend',note);let j=await fetch('/api/hot_topics').then(r=>r.json()).catch(()=>({topics:[]}));let topics=j.topics||[];row.innerHTML=topics.slice(0,18).map(t=>`<button class="hot-chip" onclick="document.getElementById('ai-topic-input-final5').value='${esc(t.topic).replace(/'/g,'\\\'')}';document.getElementById('ai-topic-input-final5').focus();">${esc(t.label)}</button>`).join('')||'';}
346
+ async function ensureNewsShortsHome(){if(!document.getElementById('view-home')?.classList.contains('active'))return;let labels=[...document.querySelectorAll('.slider-wrap .slider-label')];let wraps=labels.filter(l=>/shorts|short /i.test(l.textContent||'')&&!/short ai/i.test(l.textContent||'')).map(l=>l.closest('.slider-wrap')).filter(Boolean);wraps.forEach((w,i)=>{if(i>0)w.remove();});let w=wraps[0];if(w){let seen=new Set();[...w.querySelectorAll('.slider-item')].forEach(it=>{let img=it.querySelector('img')?.src||'';let tt=(it.querySelector('.slider-title')?.textContent||'').trim().toLowerCase();let k=img||tt;if(k&&seen.has(k))it.remove();else if(k)seen.add(k);});if(w.querySelectorAll('.slider-item').length>=6)return;w.remove();}let sh=await fetch('/api/shorts?refresh=1').then(r=>r.json()).catch(()=>[]);if(!sh.length)return;let wrap=document.createElement('div');wrap.className='slider-wrap';wrap.id='shorts-final6-stable';let h='<div class="slider-header"><span class="slider-label">📱 Shorts Dân trí & SKĐS</span><span class="slider-note">Mới nhất</span></div><div class="slider-track">';sh.slice(0,30).forEach((a,i)=>{h+=`<div class="slider-item shorts-item" onclick="openTikTok('shorts',${i})"><div class="slider-thumb shorts-thumb">${a.img?`<img src="${esc(a.img)}">`:''}<div class="card-play">▶</div></div><div class="slider-title">${esc(a.title)}</div></div>`});h+='</div>';wrap.innerHTML=h;let comp=document.querySelector('.ai-compose')||document.getElementById('view-home').firstChild;if(comp)comp.after(wrap);else document.getElementById('view-home').prepend(wrap);}
347
+ function renderLiveTopicWall(){let home=document.getElementById('view-home');if(!home||!liveTopicWall.length)return;document.getElementById('ai-wall-topic-live')?.remove();let wrap=document.createElement('div');wrap.id='ai-wall-topic-live';wrap.className='ai-wall-topic-live';let h='<div class="slider-header"><span class="slider-label">🧱 Tường AI mới</span><span class="slider-note">Tổng hợp từ web</span></div><div class="slider-track">';liveTopicWall.slice(0,20).forEach((p,i)=>{h+=`<div class="wall-item"><div class="wall-thumb">${p.img?`<img src="${esc(p.img)}">`:''}</div><div class="wall-title">${esc(p.title)}</div><div class="wall-text">${esc(p.text)}</div><div class="wall-actions"><button class="primary" onclick="readLiveTopicWall(${i})">Xem</button></div></div>`});h+='</div>';wrap.innerHTML=h;let comp=document.querySelector('.ai-compose');if(comp)comp.after(wrap);else home.prepend(wrap);}
348
+ window.readLiveTopicWall=function(i){let p=liveTopicWall[i];if(!p)return;showView('view-article');let imgs=(p.images||[]).filter(Boolean);let gal=imgs.length?'<div class="ai-wall-gallery">'+imgs.slice(0,12).map(u=>`<img src="${esc(u)}" loading="lazy">`).join('')+'</div>':(p.img?`<img class="article-img" src="${esc(p.img)}">`:'');document.getElementById('view-article').innerHTML=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><span class="badge badge-ai">AI</span><h1 class="article-title">${esc(p.title)}</h1>${gal}<p class="article-p" style="white-space:pre-wrap">${esc(p.text)}</p><div class="article-actions"><button onclick="shareAI?shareAI(${JSON.stringify(p).replace(/"/g,'&quot;')},false):navigator.clipboard.writeText(location.href)">📤 Chia sẻ</button></div></div>`;window.scrollTo(0,0)};
349
+ window.createTopicPostFinal5=async function(){let inp=document.getElementById('ai-topic-input-final5');let topic=(inp&&inp.value||'').trim();if(!topic)return alert('Nhập chủ đề trước');let btn=document.getElementById('ai-topic-btn-final5');if(btn){btn.disabled=true;btn.textContent='Đang tìm nguồn...'}try{let src=await fetch('/api/topic_sources?topic='+encodeURIComponent(topic)).then(r=>r.json()).catch(()=>null);if(btn&&src)btn.textContent='Đã tìm '+(src.count||0)+' nguồn, đang tổng hợp...';let r=await fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');liveTopicWall.unshift(j.post);if(inp)inp.value='';renderLiveTopicWall();readLiveTopicWall(0);alert('Đã tạo bài tổng hợp từ nội dung web và đăng lên Tường AI.');}catch(e){alert(e.message)}finally{if(btn){btn.disabled=false;btn.textContent='✨ Tạo bài tổng hợp từ web bằng Qwen'}}};
350
+ setInterval(()=>{document.querySelectorAll('#ai-topic-input-final3,.topic-final3,#ai-topic-input-final4,.topic-final4').forEach(e=>(e.closest('.topic-final3,.topic-final4,.ai-compose-row')||e).remove());let b=document.getElementById('ai-topic-btn-final5');if(b){b.style.display='block';b.textContent='✨ Tạo bài tổng hợp từ web bằng Qwen';}ensureHotTopics();ensureNewsShortsHome();},1200);setTimeout(()=>{ensureHotTopics();ensureNewsShortsHome();},1200);
351
  })();
352
  </script>
353
  '''
354
 
355
+ @app.get('/')
356
+ async def index_final6():
357
+ html=f5.f4.f3.f2.f1._load_index_html()
358
+ body=getattr(rt.old,'PATCH_INJECT','')+f5.f4.f3.f2.f1.FINAL_INJECT+f5.f4.f3.FINAL3_INJECT+f5.f4.FINAL4_INJECT+f5.FINAL5_INJECT+FINAL6_INJECT
359
+ return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
360
+
361
+
362
+ # ===== FINAL6B: Vietnam hot hashtags + reliable VN RSS/source retrieval =====
363
+ VN_RSS_FEEDS = [
364
+ ('VnExpress Thời sự','https://vnexpress.net/rss/thoi-su.rss'),
365
+ ('VnExpress Thế giới','https://vnexpress.net/rss/the-gioi.rss'),
366
+ ('VnExpress Kinh doanh','https://vnexpress.net/rss/kinh-doanh.rss'),
367
+ ('VnExpress Công nghệ','https://vnexpress.net/rss/so-hoa.rss'),
368
+ ('VnExpress Thể thao','https://vnexpress.net/rss/the-thao.rss'),
369
+ ('VnExpress Giải trí','https://vnexpress.net/rss/giai-tri.rss'),
370
+ ('VnExpress Sức khỏe','https://vnexpress.net/rss/suc-khoe.rss'),
371
+ ('VnExpress Giáo dục','https://vnexpress.net/rss/giao-duc.rss'),
372
+ ('Dân trí Xã hội','https://dantri.com.vn/rss/xa-hoi.rss'),
373
+ ('Dân trí Thế giới','https://dantri.com.vn/rss/the-gioi.rss'),
374
+ ('Dân trí Kinh doanh','https://dantri.com.vn/rss/kinh-doanh.rss'),
375
+ ('Dân trí Sức khỏe','https://dantri.com.vn/rss/suc-khoe.rss'),
376
+ ('Dân trí Thể thao','https://dantri.com.vn/rss/the-thao.rss'),
377
+ ('Dân trí Công nghệ','https://dantri.com.vn/rss/suc-manh-so.rss'),
378
+ ('Vietnamnet Thời sự','https://vietnamnet.vn/thoi-su.rss'),
379
+ ('Vietnamnet Kinh doanh','https://vietnamnet.vn/kinh-doanh.rss'),
380
+ ('Vietnamnet Công nghệ','https://vietnamnet.vn/cong-nghe.rss'),
381
+ ('Vietnamnet Thể thao','https://vietnamnet.vn/the-thao.rss'),
382
+ ]
383
+
384
+ def _fetch_rss_items(feed_name, feed_url, max_items=15):
385
+ items=[]
386
+ try:
387
+ r=requests.get(feed_url,headers=UA,timeout=10);r.encoding='utf-8'
388
+ soup=BeautifulSoup(r.text,'xml')
389
+ for it in soup.find_all('item')[:max_items]:
390
+ title=clean(it.find('title').get_text(' ',strip=True) if it.find('title') else '')
391
+ link=clean(it.find('link').get_text(strip=True) if it.find('link') else '')
392
+ desc=it.find('description').get_text(' ',strip=True) if it.find('description') else ''
393
+ desc_txt=clean(BeautifulSoup(desc,'lxml').get_text(' ',strip=True))
394
+ if title and link:
395
+ items.append({'title':title,'url':link,'source':feed_name,'snippet':desc_txt})
396
+ except Exception:pass
397
+ return items
398
+
399
+ def _vn_rss_pool():
400
+ now=time.time();key='vn_rss_pool'
401
+ if key in _TOPIC_CACHE and now-_TOPIC_CACHE[key]['t']<600:return _TOPIC_CACHE[key]['d']
402
+ pool=[];seen=set()
403
+ for name,url in VN_RSS_FEEDS:
404
+ for it in _fetch_rss_items(name,url,12):
405
+ if it['url'] not in seen:
406
+ seen.add(it['url']);pool.append(it)
407
+ _TOPIC_CACHE[key]={'t':now,'d':pool}
408
+ return pool
409
+
410
+ def _topic_tokens(topic):
411
+ toks=[w.lower() for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',topic or '') if len(w)>1]
412
+ return [t for t in toks if t not in STOP_WORDS]
413
+
414
+ def _score_topic_item(topic,item):
415
+ toks=_topic_tokens(topic)
416
+ hay=(item.get('title','')+' '+item.get('snippet','')+' '+item.get('source','')).lower()
417
+ if not toks:return 0
418
+ score=0
419
+ for t in toks:
420
+ if t in hay:score+=2 if len(t)>3 else 1
421
+ phrase=topic.lower().strip()
422
+ if phrase and phrase in hay:score+=8
423
+ return score
424
+
425
+ # Override: hashtags must be Việt Nam-focused, using VN news RSS directly.
426
+ def _hot_topics():
427
+ now=time.time()
428
+ if _HOT_CACHE['d'] and now-_HOT_CACHE['t']<600:return _HOT_CACHE['d']
429
+ pool=_vn_rss_pool()
430
+ freq={};display={}
431
+ for it in pool[:180]:
432
+ title=re.sub(r'\s+-\s+.*$','',it.get('title',''))
433
+ # Extract compact Vietnamese hot phrases from current VN headlines.
434
+ kws=[]
435
+ # quoted/name phrases first
436
+ for m in re.findall(r'([A-ZĐÀ-Ỹ][A-Za-zÀ-ỹ0-9]+(?:\s+[A-ZĐÀ-ỸA-Za-zÀ-ỹ0-9][A-Za-zÀ-ỹ0-9]+){1,4})',title):
437
+ if len(m)>=6:kws.append(m)
438
+ kws += _keywords_from_title(title)
439
+ for kw in kws[:5]:
440
+ kw=clean(kw)
441
+ words=[w for w in kw.split() if w.lower() not in STOP_WORDS]
442
+ if len(words)<2:continue
443
+ kw=' '.join(words[:5])
444
+ if len(kw)<6 or len(kw)>55:continue
445
+ key=kw.lower()
446
+ freq[key]=freq.get(key,0)+1
447
+ display[key]=kw
448
+ ranked=sorted(freq.items(),key=lambda x:x[1],reverse=True)
449
+ topics=[];seen=set()
450
+ for key,_ in ranked:
451
+ kw=display[key]
452
+ if key in seen:continue
453
+ seen.add(key)
454
+ label='#'+re.sub(r'\s+','',kw.title())
455
+ topics.append({'label':label,'topic':kw})
456
+ if len(topics)>=24:break
457
+ # VN fallback, not generic global.
458
+ for kw in ['Giá vàng trong nước','Bão và mưa lũ','Bóng đá Việt Nam','Kinh tế Việt Nam','AI tại Việt Nam','Giá xăng dầu','Thị trường chứng khoán Việt Nam','Tuyển Việt Nam','Sức khỏe cộng đồng','An ninh mạng Việt Nam']:
459
+ if kw.lower() not in seen:topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw})
460
+ _HOT_CACHE.update({'t':now,'d':topics[:24]})
461
+ return _HOT_CACHE['d']
462
+
463
+ def _candidate_urls(topic):
464
+ seen=set();items=[]
465
+ # 1) VN RSS pool relevance is most reliable and has direct URLs.
466
+ scored=[]
467
+ for it in _vn_rss_pool():
468
+ sc=_score_topic_item(topic,it)
469
+ if sc>0:scored.append((sc,it))
470
+ for sc,it in sorted(scored,key=lambda x:x[0],reverse=True)[:12]:
471
+ if it['url'] not in seen:
472
+ seen.add(it['url']);items.append(it)
473
+ # 2) Search trusted web if RSS not enough.
474
+ queries=[topic+' Việt Nam tin tức',topic+' phân tích Việt Nam',topic+' mới nhất']
475
+ for q in queries:
476
+ for it in _ddg_search(q,8):
477
+ if it['url'] not in seen:
478
+ seen.add(it['url']);items.append(it)
479
+ if len(items)>=14:break
480
+ # 3) Google News as supplemental titles/direct links.
481
+ for it in _google_news_items(topic,10):
482
+ if it['url'] not in seen:
483
+ seen.add(it['url']);items.append(it)
484
+ return items[:24]
485
+
486
+ def _web_research_context(topic):
487
+ now=time.time();key='ctx2:'+topic.lower().strip()
488
+ if key in _TOPIC_CACHE and now-_TOPIC_CACHE[key]['t']<900:return _TOPIC_CACHE[key]['d']
489
+ items=_candidate_urls(topic)
490
+ crawled=[]
491
+ for it in items:
492
+ text=_scrape_article_text(it['url'],9000)
493
+ rel=_score_relevance(topic,it.get('title',''),text,it.get('snippet','')) or _score_topic_item(topic,it)
494
+ # If RSS item has good snippet, keep it even when full text blocks.
495
+ if text and len(text)>300 and rel>0:
496
+ crawled.append({**it,'text':text,'rel':rel})
497
+ elif it.get('snippet') and len(it['snippet'])>120 and rel>0:
498
+ crawled.append({**it,'text':it['snippet'],'rel':rel,'snippet_only':True})
499
+ crawled=sorted(crawled,key=lambda x:(x.get('rel',0),len(x.get('text',''))),reverse=True)[:7]
500
+ blocks=[];sources=[]
501
+ for it in crawled:
502
+ label='ĐOẠN MÔ TẢ TỪ RSS/TÌM KIẾM' if it.get('snippet_only') else 'NỘI DUNG BÀI VIẾT ĐÃ CRAWL'
503
+ blocks.append(f"NGUỒN: {it['source']}\nTIÊU ĐỀ: {it['title']}\n{label}:\n{it['text'][:8500]}")
504
+ sources.append({'title':it['title'],'url':it['url'],'via':it['source']})
505
+ data={'context':'\n\n---\n\n'.join(blocks),'sources':sources[:8],'count':len(blocks)}
506
+ _TOPIC_CACHE[key]={'t':now,'d':data}
507
+ return data
508
+
509
+
510
+ # ===== FINAL6C: FAST topic generation (RSS cache first, no slow full-page crawling) =====
511
+ import asyncio
512
+ _FAST_TOPIC_CACHE={}
513
+ FAST_RSS_FEEDS=[
514
+ ('VnExpress','https://vnexpress.net/rss/tin-moi-nhat.rss'),
515
+ ('VnExpress Thời sự','https://vnexpress.net/rss/thoi-su.rss'),
516
+ ('VnExpress Thế giới','https://vnexpress.net/rss/the-gioi.rss'),
517
+ ('VnExpress Kinh doanh','https://vnexpress.net/rss/kinh-doanh.rss'),
518
+ ('VnExpress Công nghệ','https://vnexpress.net/rss/so-hoa.rss'),
519
+ ('VnExpress Thể thao','https://vnexpress.net/rss/the-thao.rss'),
520
+ ('Dân trí','https://dantri.com.vn/rss/home.rss'),
521
+ ('Dân trí Xã hội','https://dantri.com.vn/rss/xa-hoi.rss'),
522
+ ('Dân trí Kinh doanh','https://dantri.com.vn/rss/kinh-doanh.rss'),
523
+ ('Dân trí Thể thao','https://dantri.com.vn/rss/the-thao.rss'),
524
+ ('Dân trí Công nghệ','https://dantri.com.vn/rss/suc-manh-so.rss'),
525
+ ('Vietnamnet','https://vietnamnet.vn/rss/tin-moi-nhat.rss'),
526
+ ('Vietnamnet Thời sự','https://vietnamnet.vn/thoi-su.rss'),
527
+ ('Vietnamnet Kinh doanh','https://vietnamnet.vn/kinh-doanh.rss'),
528
+ ('Vietnamnet Công nghệ','https://vietnamnet.vn/cong-nghe.rss'),
529
+ ('Vietnamnet Thể thao','https://vietnamnet.vn/the-thao.rss'),
530
+ ]
531
+
532
+ def _fast_fetch_rss(feed_name, feed_url, max_items=20):
533
+ items=[]
534
+ try:
535
+ r=requests.get(feed_url,headers=UA,timeout=6);r.encoding='utf-8'
536
+ soup=BeautifulSoup(r.text,'xml')
537
+ for it in soup.find_all('item')[:max_items]:
538
+ title=clean(it.find('title').get_text(' ',strip=True) if it.find('title') else '')
539
+ link=clean(it.find('link').get_text(strip=True) if it.find('link') else '')
540
+ desc_raw=it.find('description').get_text(' ',strip=True) if it.find('description') else ''
541
+ desc=clean(BeautifulSoup(desc_raw,'lxml').get_text(' ',strip=True))
542
+ if title and link:
543
+ items.append({'title':title,'url':link,'source':feed_name,'snippet':desc})
544
+ except Exception:pass
545
+ return items
546
+
547
+ def _fast_rss_pool():
548
+ now=time.time();key='fast_rss_pool'
549
+ if key in _FAST_TOPIC_CACHE and now-_FAST_TOPIC_CACHE[key]['t']<600:return _FAST_TOPIC_CACHE[key]['d']
550
+ pool=[];seen=set()
551
+ # Sequential with short timeouts is predictable; RSS is small.
552
+ for name,url in FAST_RSS_FEEDS:
553
+ for it in _fast_fetch_rss(name,url,16):
554
+ if it['url'] not in seen:
555
+ seen.add(it['url']);pool.append(it)
556
+ _FAST_TOPIC_CACHE[key]={'t':now,'d':pool}
557
+ return pool
558
+
559
+ def _fast_topic_tokens(topic):
560
+ toks=[w.lower() for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',topic or '') if len(w)>1]
561
+ return [t for t in toks if t not in STOP_WORDS]
562
+
563
+ def _fast_score(topic,item):
564
+ toks=_fast_topic_tokens(topic)
565
+ hay=(item.get('title','')+' '+item.get('snippet','')+' '+item.get('source','')).lower()
566
+ if not toks:return 0
567
+ score=0
568
+ for t in toks:
569
+ if t in hay:score+=3 if len(t)>3 else 1
570
+ phrase=topic.lower().strip()
571
+ if phrase and phrase in hay:score+=12
572
+ return score
573
+
574
+ def _fast_sources(topic, limit=8):
575
+ pool=_fast_rss_pool()
576
+ scored=[]
577
+ for it in pool:
578
+ sc=_fast_score(topic,it)
579
+ if sc>0:scored.append((sc,it))
580
+ scored=sorted(scored,key=lambda x:(x[0],len(x[1].get('snippet',''))),reverse=True)
581
+ out=[];seen=set()
582
+ for sc,it in scored:
583
+ if it['url'] in seen:continue
584
+ seen.add(it['url']);out.append({**it,'score':sc})
585
+ if len(out)>=limit:break
586
+ # If topic too narrow and no match, use top latest from VN RSS as weak context instead of slow crawling.
587
+ if not out:
588
+ out=pool[:min(limit,8)]
589
+ return out
590
+
591
+ def _fast_context(topic):
592
+ now=time.time();key='fast_ctx:'+topic.lower().strip()
593
+ if key in _FAST_TOPIC_CACHE and now-_FAST_TOPIC_CACHE[key]['t']<600:return _FAST_TOPIC_CACHE[key]['d']
594
+ sources=_fast_sources(topic,8)
595
+ blocks=[];src=[]
596
+ for it in sources:
597
+ text=(it.get('snippet') or '').strip()
598
+ # Use title + RSS description only: fast and reliable.
599
+ blocks.append(f"NGUỒN: {it.get('source','')}\nTIÊU ĐỀ: {it.get('title','')}\nTÓM TẮT RSS:\n{text}")
600
+ src.append({'title':it.get('title',''),'url':it.get('url',''),'via':it.get('source','')})
601
+ data={'context':'\n\n---\n\n'.join(blocks),'sources':src,'count':len(blocks)}
602
+ _FAST_TOPIC_CACHE[key]={'t':now,'d':data}
603
+ return data
604
+
605
+ def _fallback_fast_article(topic, sources):
606
+ lines=[]
607
+ for s in sources[:7]:
608
+ title=s.get('title','')
609
+ if title:lines.append(title)
610
+ body='\n'.join('• '+x for x in lines[:7])
611
+ vias=', '.join(sorted({s.get('via','') for s in sources if s.get('via')}))
612
+ return (f"{topic}: những điểm đáng chú ý\n\n"
613
+ f"{topic} đang là chủ đề được quan tâm trong dòng tin tức hiện nay. Dựa trên các nguồn tin mới nhất, có thể tổng hợp nhanh một số điểm nổi bật để người đọc nắm bối cảnh và theo dõi tiếp diễn biến.\n\n"
614
+ f"Các nguồn tin liên quan cho thấy chủ đề này gắn với những diễn biến sau:\n{body}\n\n"
615
+ f"Nhìn chung, đây là vấn đề cần được theo dõi theo nhiều góc độ: bối cảnh, tác động thực tế, phản ứng của các bên liên quan và những thông tin cập nhật tiếp theo. Người đọc nên đối chiếu thêm các nguồn chính thống khi cần quyết định hoặc đánh giá chi tiết.\n\n"
616
+ f"Nguồn tham khảo: {vias}")
617
+
618
+ # Remove previous slow topic routes and register fast versions last.
619
+ 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 {('/api/topic_post','POST'),('/api/topic_sources','GET')})]
620
+
621
+ @app.get('/api/topic_sources')
622
+ def api_topic_sources_fast(topic:str=Query(...)):
623
+ data=_fast_context(clean(topic))
624
+ return JSONResponse({'count':data.get('count',0),'sources':data.get('sources',[]),'has_context':bool(data.get('context')),'mode':'fast_rss'})
625
+
626
+ @app.post('/api/topic_post')
627
+ async def topic_post_fast(request:Request):
628
+ body=await request.json();topic=clean(body.get('topic',''))
629
+ if not topic:return JSONResponse({'error':'missing topic'},status_code=400)
630
+ img=_topic_image(topic)
631
+ research=_fast_context(topic);context=research.get('context','');sources=research.get('sources',[])
632
+ prompt=f"""Bạn là biên tập viên VNEWS. Hãy viết MỘT BÀI VIẾT HOÀN CHỈNH bằng tiếng Việt về chủ đề: {topic}
633
+
634
+ Dữ liệu nhanh từ RSS nguồn Việt Nam:
635
+ {context[:12000]}
636
+
637
+ Yêu cầu:
638
+ - Không liệt kê tiêu đề nguồn thành bài viết.
639
+ - Tổng hợp thành bài báo/tạp chí hoàn chỉnh.
640
+ - Có tiêu đề mới, sapo 2-3 câu, 4-6 đoạn phân tích/bối cảnh/tác động.
641
+ - Diễn đạt lại, không sao chép nguyên văn.
642
+ - Nếu dữ liệu ít, viết thận trọng và nêu các điểm cần theo dõi.
643
+ - Cuối bài có mục Nguồn tham khảo.
644
+ """
645
+ text=None
646
+ try:
647
+ text=await asyncio.wait_for(f5.base.qwen_generate(prompt,image_url=img,max_tokens=1300),timeout=28)
648
+ except Exception:
649
+ text=None
650
+ if not text or len(text)<350:
651
+ text=_fallback_fast_article(topic,sources)
652
+ post=f5.base.make_post(topic,text,img,'','topic_fast_rss',sources=[s for s in sources if s.get('url')])
653
+ post['images']=[img]
654
+ posts=f5.base._load_ai_wall();posts.insert(0,post);f5.base._save_ai_wall(posts)
655
+ return JSONResponse({'post':post,'mode':'fast_rss','sources_count':len(sources)})
656
+
657
+
658
+ # ===== FINAL6D: FAST HOME LOAD =====
659
+ _FAST_HOME_CACHE={"t":0,"d":[]}
660
+ _FAST_DT_CACHE={"t":0,"d":[]}
661
+ _FAST_VNEGO_CACHE={"t":0,"d":[]}
662
+ _FAST_HL_CACHE={"t":0,"d":[]}
663
+
664
+ def _rss_articles_fast(feed_url, group, source='vne', limit=6):
665
+ out=[]
666
+ try:
667
+ r=requests.get(feed_url,headers=UA,timeout=4);r.encoding='utf-8'
668
+ soup=BeautifulSoup(r.text,'xml')
669
+ for it in soup.find_all('item')[:limit*2]:
670
+ title=clean(it.find('title').get_text(' ',strip=True) if it.find('title') else '')
671
+ link=clean(it.find('link').get_text(strip=True) if it.find('link') else '')
672
+ desc_raw=it.find('description').get_text(' ',strip=True) if it.find('description') else ''
673
+ ds=BeautifulSoup(desc_raw,'lxml')
674
+ im=ds.find('img'); img=im.get('src','') if im else ''
675
+ desc=clean(ds.get_text(' ',strip=True))[:160]
676
+ if title and link:
677
+ out.append({'title':title,'link':link,'img':img,'summary':desc,'source':source,'group':group})
678
+ if len(out)>=limit:break
679
+ except Exception:pass
680
+ return out
681
+
682
+ def _fast_homepage():
683
+ now=time.time()
684
+ if _FAST_HOME_CACHE['d'] and now-_FAST_HOME_CACHE['t']<600:return _FAST_HOME_CACHE['d']
685
+ feeds=[('Thời Sự','https://vnexpress.net/rss/thoi-su.rss'),('Thế Giới','https://vnexpress.net/rss/the-gioi.rss'),('Kinh Doanh','https://vnexpress.net/rss/kinh-doanh.rss'),('Công Nghệ','https://vnexpress.net/rss/so-hoa.rss'),('Thể Thao','https://vnexpress.net/rss/the-thao.rss'),('Giải Trí','https://vnexpress.net/rss/giai-tri.rss'),('Sức Khỏe','https://vnexpress.net/rss/suc-khoe.rss'),('Giáo Dục','https://vnexpress.net/rss/giao-duc.rss'),('Pháp Luật','https://vnexpress.net/rss/phap-luat.rss'),('Du Lịch','https://vnexpress.net/rss/du-lich.rss')]
686
+ arts=[]
687
+ try:
688
+ from concurrent.futures import ThreadPoolExecutor, as_completed
689
+ with ThreadPoolExecutor(max_workers=6) as ex:
690
+ futs=[ex.submit(_rss_articles_fast,u,g,'vne',6) for g,u in feeds]
691
+ for f in as_completed(futs,timeout=7):
692
+ try:arts.extend(f.result() or [])
693
+ except Exception:pass
694
+ except Exception:
695
+ for g,u in feeds[:5]:arts.extend(_rss_articles_fast(u,g,'vne',4))
696
+ if arts:_FAST_HOME_CACHE.update({'t':now,'d':arts})
697
+ return _FAST_HOME_CACHE['d'] or arts
698
+
699
+ def _fast_dantri_hot():
700
+ now=time.time()
701
+ if _FAST_DT_CACHE['d'] and now-_FAST_DT_CACHE['t']<900:return _FAST_DT_CACHE['d']
702
+ data=_rss_articles_fast('https://dantri.com.vn/rss/home.rss','Tin Nổi Bật','dantri',12)
703
+ if data:_FAST_DT_CACHE.update({'t':now,'d':data})
704
+ return data
705
+
706
+ def _fast_vnego():
707
+ now=time.time()
708
+ if _FAST_VNEGO_CACHE['d'] and now-_FAST_VNEGO_CACHE['t']<900:return _FAST_VNEGO_CACHE['d']
709
+ out=[]
710
+ try:
711
+ r=requests.get('https://vnexpress.net/vne-go',headers=UA,timeout=4);r.encoding='utf-8'
712
+ soup=BeautifulSoup(r.text,'lxml');seen=set()
713
+ for a in soup.find_all('a',href=True):
714
+ href=a.get('href','');title=clean(a.get('title','') or a.get_text(' ',strip=True))
715
+ if not title or len(title)<8 or not href.startswith('http') or href in seen:continue
716
+ if '/vne-go' not in href and '/video/' not in href:continue
717
+ seen.add(href);img='';im=a.find('img') or (a.parent.find('img') if a.parent else None)
718
+ if im:img=im.get('data-src') or im.get('src','')
719
+ out.append({'title':title,'link':href,'img':img,'source':'vne-video'})
720
+ if len(out)>=10:break
721
+ except Exception:pass
722
+ _FAST_VNEGO_CACHE.update({'t':now,'d':out})
723
+ return out
724
+
725
+ def _fast_highlights():
726
+ now=time.time()
727
+ if _FAST_HL_CACHE['d'] and now-_FAST_HL_CACHE['t']<900:return _FAST_HL_CACHE['d']
728
+ _FAST_HL_CACHE.update({'t':now,'d':[]})
729
+ return []
730
+
731
+ for _p in ['/api/homepage','/api/dantri_hot','/api/vne_video','/api/highlights']:
732
+ app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)==_p and 'GET' in getattr(r,'methods',set()))]
733
+ @app.get('/api/homepage')
734
+ def api_homepage_fast():return JSONResponse(_fast_homepage())
735
+ @app.get('/api/dantri_hot')
736
+ def api_dantri_hot_fast():return JSONResponse(_fast_dantri_hot())
737
+ @app.get('/api/vne_video')
738
+ def api_vne_video_fast():return JSONResponse(_fast_vnego())
739
+ @app.get('/api/highlights')
740
+ def api_highlights_fast():return JSONResponse(_fast_highlights())
741
+
742
+ FINAL6_FAST_HOME_INJECT = """
743
  <script>
744
  (function(){
745
+ const oldFetch=window.fetch;
746
+ window.__allowShortRefresh=false;
747
+ window.fetch=function(url,opts){try{let u=String(url||'');if(u.includes('/api/shorts?refresh=1')&&!window.__allowShortRefresh)url='/api/shorts';}catch(e){}return oldFetch.call(this,url,opts)};
748
+ setTimeout(()=>{window.__allowShortRefresh=true;},7000);
749
  })();
750
  </script>
751
+ """
752
+ app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
753
+ @app.get('/')
754
+ async def index_final6_fast_home():
755
+ html=f5.f4.f3.f2.f1._load_index_html()
756
+ body=getattr(rt.old,'PATCH_INJECT','')+f5.f4.f3.f2.f1.FINAL_INJECT+f5.f4.f3.FINAL3_INJECT+f5.f4.FINAL4_INJECT+f5.FINAL5_INJECT+FINAL6_INJECT+FINAL6_FAST_HOME_INJECT
757
+ return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
758
+
759
 
760
+ # ===== FINAL6E: SHOW SOURCE CONTENTS IN TOPIC ARTICLE =====
761
+ def _extract_source_details_from_context(context, sources):
762
+ details=[]
763
+ # Map source urls by title for URL/via enrichment
764
+ src_by_title={clean(s.get('title','')):s for s in (sources or [])}
765
+ for block in (context or '').split('---'):
766
+ block=block.strip()
767
+ if not block:continue
768
+ via='';title='';content=''
769
+ m=re.search(r'NGUỒN:\s*(.*)',block)
770
+ if m:via=clean(m.group(1))
771
+ m=re.search(r'TIÊU ĐỀ:\s*(.*)',block)
772
+ if m:title=clean(m.group(1))
773
+ if 'NỘI DUNG BÀI VIẾT ĐÃ CRAWL:' in block:
774
+ content=block.split('NỘI DUNG BÀI VIẾT ĐÃ CRAWL:',1)[1]
775
+ elif 'TÓM TẮT RSS:' in block:
776
+ content=block.split('TÓM TẮT RSS:',1)[1]
777
+ elif 'ĐOẠN MÔ TẢ' in block:
778
+ content=re.split(r'ĐOẠN MÔ TẢ[^:]*:',block,1)[-1]
779
+ content=clean(content)
780
+ if not title and not content:continue
781
+ s=src_by_title.get(title,{})
782
+ details.append({'title':title or s.get('title','Nguồn tham khảo'),'url':s.get('url',''),'via':via or s.get('via',''),'content':content[:1800]})
783
+ if len(details)>=8:break
784
+ return details
785
+
786
+ # Remove prior topic endpoint and register one that stores source_details in post.
787
+ app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)=='/api/topic_post' and 'POST' in getattr(r,'methods',set()))]
788
+
789
+ @app.post('/api/topic_post')
790
+ async def topic_post_with_source_contents(request:Request):
791
+ body=await request.json();topic=clean(body.get('topic',''))
792
+ if not topic:return JSONResponse({'error':'missing topic'},status_code=400)
793
+ img=_topic_image(topic)
794
+ research=_fast_context(topic) if '_fast_context' in globals() else _web_research_context(topic)
795
+ context=research.get('context','');sources=research.get('sources',[])
796
+ details=_extract_source_details_from_context(context,sources)
797
+ if not context or not details:
798
+ return JSONResponse({'error':'Không tìm/crawl được đủ nội dung về chủ đề này. Hãy thử chủ đề cụ thể hơn hoặc dùng hashtag gợi ý.'},status_code=422)
799
+ source_brief='\n\n'.join([f"[{i+1}] {d.get('title','')} ({d.get('via','')})\n{d.get('content','')[:1400]}" for i,d in enumerate(details)])
800
+ prompt=f"""Bạn là biên tập viên VNEWS. Hãy viết MỘT BÀI VIẾT HOÀN CHỈNH bằng tiếng Việt về chủ đề: {topic}
801
+
802
+ Dưới đây là nội dung từng nguồn đã thu thập. Hãy tổng hợp ý chính, không sao chép nguyên văn, không biến các tiêu đề thành danh sách.
803
+
804
+ NỘI DUNG NGUỒN:
805
+ {source_brief[:18000]}
806
+
807
+ Yêu cầu:
808
+ - Tiêu đề mới, rõ, hấp dẫn.
809
+ - Sapo 2-3 câu.
810
+ - 5-8 đoạn phân tích/bối cảnh/tác động/điểm cần lưu ý.
811
+ - Không dùng câu "Dưới đây là" hoặc "Tôi sẽ".
812
+ - Cuối bài có mục "Nguồn tham khảo" nêu tên nguồn.
813
+ """
814
+ text=None
815
+ try:
816
+ import asyncio
817
+ text=await asyncio.wait_for(f5.base.qwen_generate(prompt,image_url=img,max_tokens=1700),timeout=35)
818
+ except Exception:
819
+ text=None
820
+ if not text or len(text)<350:
821
+ bullets='\n'.join([f"• {d['title']}: {d.get('content','')[:320]}" for d in details[:6]])
822
+ vias=', '.join(sorted({d.get('via','') for d in details if d.get('via')}))
823
+ text=(f"{topic}: tổng hợp những điểm đáng chú ý\n\n"
824
+ f"{topic} đang được nhiều nguồn tin đề cập với các góc nhìn khác nhau. Dưới đây là phần tổng hợp nhanh từ những nội dung đã thu thập được.\n\n"
825
+ f"{bullets}\n\n"
826
+ f"Nhìn chung, chủ đề này cần được theo dõi thêm ở các khía cạnh: bối cảnh, tác động thực tế, phản ứng của các bên liên quan và các diễn biến mới trong thời gian tới.\n\n"
827
+ f"Nguồn tham khảo: {vias}")
828
+ post=f5.base.make_post(topic,text,img,'','topic_fast_rss_with_sources',sources=[s for s in sources if s.get('url')])
829
+ post['images']=[img]
830
+ post['source_details']=details
831
+ posts=f5.base._load_ai_wall();posts.insert(0,post);f5.base._save_ai_wall(posts)
832
+ return JSONResponse({'post':post,'mode':'fast_rss_with_source_details','sources_count':len(details)})
833
+
834
+ FINAL6E_INJECT = """
835
  <style>
836
+ .source-detail-box{margin-top:14px;background:#151515;border:1px solid #2b2b2b;border-radius:10px;padding:10px}.source-detail-box h3{font-size:14px;color:#5cb87a;margin-bottom:8px}.source-detail-item{background:#202020;border-radius:8px;padding:9px;margin:7px 0}.source-detail-title{font-size:12px;font-weight:700;color:#eee;line-height:1.35}.source-detail-meta{font-size:10px;color:#888;margin:3px 0}.source-detail-content{font-size:12px;color:#bbb;line-height:1.5;white-space:pre-wrap;max-height:220px;overflow:auto}.source-detail-item a{color:#5cb87a;font-size:11px;text-decoration:none}
 
837
  </style>
838
  <script>
839
  (function(){
840
+ function escE(s){return String(s||'').replace(/[&<>\"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',"'":'&#39;'}[m]));}
841
+ window.__topicWallE=[];
842
+ function sourceDetailsHtml(p){let arr=p.source_details||[];if(!arr.length)return '';let h='<div class="source-detail-box"><h3>📚 Nội dung từng nguồn đã dùng</h3>';arr.forEach((s,i)=>{h+=`<div class="source-detail-item"><div class="source-detail-title">${i+1}. ${escE(s.title)}</div><div class="source-detail-meta">${escE(s.via||'Nguồn')}</div><div class="source-detail-content">${escE(s.content||'')}</div>${s.url?`<a href="${escE(s.url)}" target="_blank">Mở nguồn gốc</a>`:''}</div>`});h+='</div>';return h;}
843
+ function renderTopicWallE(){let home=document.getElementById('view-home');if(!home||!window.__topicWallE.length)return;document.getElementById('ai-wall-topic-live')?.remove();let wrap=document.createElement('div');wrap.id='ai-wall-topic-live';wrap.className='ai-wall-topic-live';let h='<div class="slider-header"><span class="slider-label">🧱 Tường AI mới</span><span class="slider-note">Tổng hợp từ web</span></div><div class="slider-track">';window.__topicWallE.slice(0,20).forEach((p,i)=>{h+=`<div class="wall-item"><div class="wall-thumb">${p.img?`<img src="${escE(p.img)}">`:''}</div><div class="wall-title">${escE(p.title)}</div><div class="wall-text">${escE(p.text)}</div><div class="wall-actions"><button class="primary" onclick="readTopicWallE(${i})">Xem</button></div></div>`});h+='</div>';wrap.innerHTML=h;let comp=document.querySelector('.ai-compose');if(comp)comp.after(wrap);else home.prepend(wrap);}
844
+ window.readTopicWallE=function(i){let p=window.__topicWallE[i];if(!p)return;showView('view-article');let imgs=(p.images||[]).filter(Boolean);let gal=imgs.length?'<div class="ai-wall-gallery">'+imgs.slice(0,12).map(u=>`<img src="${escE(u)}" loading="lazy">`).join('')+'</div>':(p.img?`<img class="article-img" src="${escE(p.img)}">`:'');let srcDetails=sourceDetailsHtml(p);document.getElementById('view-article').innerHTML=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><span class="badge badge-ai">AI</span><h1 class="article-title">${escE(p.title)}</h1>${gal}<p class="article-p" style="white-space:pre-wrap">${escE(p.text)}</p>${srcDetails}<div class="article-actions"><button onclick="shareAI?shareAI(${JSON.stringify(p).replace(/"/g,'&quot;')},false):navigator.clipboard.writeText(location.href)">📤 Chia sẻ</button></div></div>`;window.scrollTo(0,0)};
845
+ window.createTopicPostFinal5=async function(){let inp=document.getElementById('ai-topic-input-final5');let topic=(inp&&inp.value||'').trim();if(!topic)return alert('Nhập chủ đề trước');let btn=document.getElementById('ai-topic-btn-final5');if(btn){btn.disabled=true;btn.textContent='Đang tìm nguồn...'}try{let src=await fetch('/api/topic_sources?topic='+encodeURIComponent(topic)).then(r=>r.json()).catch(()=>null);if(btn&&src)btn.textContent='Đã tìm '+(src.count||0)+' nguồn, đang tổng hợp...';let r=await fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');window.__topicWallE.unshift(j.post);if(inp)inp.value='';renderTopicWallE();readTopicWallE(0);alert('Đã tạo bài tổng hợp từ nội dung web và đăng lên Tường AI.');}catch(e){alert(e.message)}finally{if(btn){btn.disabled=false;btn.textContent='✨ Tạo bài tổng hợp từ web bằng Qwen'}}};
846
+ setInterval(()=>{document.querySelectorAll('#ai-topic-input-final3,.topic-final3,#ai-topic-input-final4,.topic-final4').forEach(e=>(e.closest('.topic-final3,.topic-final4,.ai-compose-row')||e).remove());let b=document.getElementById('ai-topic-btn-final5');if(b){b.style.display='block';b.textContent='✨ Tạo bài tổng hợp từ web bằng Qwen';}},1200);
 
 
 
 
 
 
 
847
  })();
848
  </script>
849
  '''