bep40 commited on
Commit
fc6d902
·
verified ·
1 Parent(s): 891891b

Upload app_v2_entry.py

Browse files
Files changed (1) hide show
  1. app_v2_entry.py +9 -1253
app_v2_entry.py CHANGED
@@ -29,1257 +29,13 @@ app.router.routes=[r for r in app.router.routes if not(getattr(r,'path',None)=='
29
  app.routes[:]=[r for r in app.routes if not isinstance(r, Mount)]
30
  app.router.routes=[r for r in app.router.routes if not isinstance(r, Mount)]
31
 
32
- def _clean(s): return re.sub(r"\s+"," ",html_lib.unescape(str(s or""))).strip()
33
-
34
- # Cache for match details (5 min TTL)
35
- _match_cache = {}
36
-
37
- # === FAST BONGDA PROXY ENDPOINT ===
38
- def _get_match_detail(event_id, slug=None):
39
- headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "text/html", "Referer": "https://bongda.com.vn/"}
40
- if slug:
41
- url = f"https://bongda.com.vn/tran-dau/{event_id}/centre/{slug}"
42
- else:
43
- url = f"https://bongda.com.vn/tran-dau/{event_id}"
44
- resp = req.get(url, headers=headers, timeout=15, allow_redirects=True)
45
- if resp.status_code != 200:
46
- return None
47
- soup = BeautifulSoup(resp.text, 'html.parser')
48
- result = {"event_id": event_id, "found": False, "sections": []}
49
- info = {}
50
- tel = soup.select_one('.teams')
51
- if tel:
52
- he = tel.select_one('.team.home')
53
- if he:
54
- p_tags = [p for p in he.select('p') if not p.get('class') or 'logo' not in p.get('class', [])]
55
- if p_tags: info['home_team'] = _clean(p_tags[0].get_text())
56
- lo = he.select_one('img')
57
- if lo: info['home_logo'] = lo.get('src', '')
58
- ae = tel.select_one('.team.away')
59
- if ae:
60
- p_tags = ae.select('p')
61
- team_ps = [p for p in p_tags if not p.get('class') or 'logo' not in p.get('class', [])]
62
- if team_ps: info['away_team'] = _clean(team_ps[-1].get_text())
63
- lo = ae.select_one('img')
64
- if lo: info['away_logo'] = lo.get('src', '')
65
- sc = tel.select_one('.score')
66
- if sc:
67
- parts = [_clean(p.get_text()) for p in sc.select('p')]
68
- if len(parts) >= 2: info['score'] = f"{parts[0]} - {parts[1]}"
69
- lb = sc.select_one('.label')
70
- if lb: info['status_label'] = _clean(lb.get_text())
71
- if info.get('home_team') and info.get('away_team'):
72
- result['info'] = info
73
- result['found'] = True
74
- result['sections'].append('info')
75
- events = []
76
- for ev in soup.select('.events .period .event'):
77
- ev_cls = ' '.join(ev.get('class', []))
78
- ev_data = {'team': 'home' if 'home' in ev_cls else 'away', 'period': '', 'type': 'unknown', 'time': '', 'players': ''}
79
- parent = ev.parent
80
- if parent:
81
- h2 = parent.find('h2')
82
- if h2: ev_data['period'] = _clean(h2.get_text())
83
- if ev.select_one('[class*="goal"]'): ev_data['type'] = 'goal'
84
- elif ev.select_one('[class*="redcard"]'): ev_data['type'] = 'redcard'
85
- elif ev.select_one('[class*="yellowcard"]'): ev_data['type'] = 'yellowcard'
86
- elif ev.select_one('[class*="substitution"]'): ev_data['type'] = 'substitution'
87
- players_el = ev.select_one('.players')
88
- if players_el:
89
- pl_text = _clean(players_el.get_text(' ', strip=True))
90
- m = re.match(r"(\d+)'(.*)", pl_text)
91
- if m:
92
- ev_data['time'] = f"{m.group(1)}'"
93
- ev_data['players'] = m.group(2)
94
- else:
95
- ev_data['players'] = pl_text
96
- events.append(ev_data)
97
- if events:
98
- result['events'] = events
99
- result['sections'].append('events')
100
- pred = soup.select_one('.prediction-card')
101
- if pred:
102
- team_info = pred.select_one('.team-info')
103
- if team_info:
104
- teams = team_info.select('.team')
105
- pred_data = {}
106
- if len(teams) >= 2:
107
- pred_data['home_name'] = _clean(teams[0].select_one('.team-name').get_text()) if teams[0].select_one('.team-name') else ''
108
- pred_data['away_name'] = _clean(teams[1].select_one('.team-name').get_text()) if teams[1].select_one('.team-name') else ''
109
- divider = team_info.select_one('.divider')
110
- if divider: pred_data['result'] = _clean(divider.get_text())
111
- vc = pred.select_one('.vote-count')
112
- if vc: pred_data['vote_count'] = _clean(vc.get_text())
113
- result['prediction'] = pred_data
114
- recent = []
115
- ml = soup.select_one('.matches-list')
116
- if ml:
117
- for item in ml.select('.match-detail, .match-item, li'):
118
- de = item.select_one('.date, .time')
119
- le = item.select_one('.league')
120
- he_item = item.select_one('.home, .team-home')
121
- ae_item = item.select_one('.away, .team-away')
122
- se = item.select_one('.score, .result')
123
- if he_item or ae_item:
124
- recent.append({'date': _clean(de.get_text()) if de else '', 'league': _clean(le.get_text()) if le else '', 'home': _clean(he_item.get_text()) if he_item else '', 'away': _clean(ae_item.get_text()) if ae_item else '', 'score': _clean(se.get_text()) if se else 'vs'})
125
- if recent:
126
- result['recent_matches'] = recent
127
- result['sections'].append('recent')
128
- try:
129
- api_h = {"User-Agent": "Mozilla/5.0", "Accept": "application/json", "X-Requested-With": "XMLHttpRequest", "Referer": "https://bongda.com.vn/"}
130
- ar = req.get(f"https://bongda.com.vn/api/fixtures/h2h-stats?event_id={event_id}", headers=api_h, timeout=10)
131
- if ar.status_code == 200:
132
- ad = ar.json()
133
- if ad.get('status') == 'success' and ad.get('html'):
134
- asp = BeautifulSoup(ad['html'], 'html.parser')
135
- ast = {}
136
- for row in asp.select('li, tr'):
137
- cells = row.select('td, span, p')
138
- if len(cells) >= 3:
139
- lb = _clean(cells[0].get_text())
140
- if lb: ast[lb] = {'home': _clean(cells[1].get_text()), 'away': _clean(cells[2].get_text())}
141
- if ast:
142
- result['h2h_stats_parsed'] = ast
143
- result['sections'].append('h2h_stats')
144
- except: pass
145
- return result
146
-
147
- @app.get('/api/proxy/bongda')
148
- def proxy_bongda(event_id: int = Query(default=None), slug: str = Query(default=None)):
149
- if event_id is None:
150
- return JSONResponse({'error': 'event_id required'}, status_code=400)
151
- cache_key = f"{event_id}_{slug}"
152
- now = time.time()
153
- cached = _match_cache.get(cache_key)
154
- if cached and now - cached.get('_ts', 0) < 300:
155
- return JSONResponse(cached)
156
- try:
157
- result = _get_match_detail(event_id, slug)
158
- if result:
159
- result['_ts'] = now
160
- _match_cache[cache_key] = result
161
- return JSONResponse(result)
162
- except Exception as e:
163
- err = {"event_id": event_id, "found": False, "error": str(e), "_ts": now}
164
- _match_cache[cache_key] = err
165
- return JSONResponse(err)
166
- return JSONResponse({"event_id": event_id, "found": False})
167
-
168
- @app.get('/api/match/{event_id}/detail')
169
- def api_match_detail(event_id: int, url: str = Query(default=None)):
170
- slug = None
171
- if url:
172
- m = re.match(r'.+/tran-dau/\d+/(?:centre|preview)/(.+)', url)
173
- if m:
174
- slug = m.group(1)
175
- cache_key = f"{event_id}_{slug or ''}"
176
- now = time.time()
177
- cached = _match_cache.get(cache_key)
178
- if cached and now - cached.get('_ts', 0) < 300:
179
- return JSONResponse(cached)
180
- try:
181
- if not slug:
182
- try:
183
- home_r = req.get("https://bongda.com.vn/", headers={"User-Agent": "Mozilla/5.0"}, timeout=10)
184
- if home_r.status_code == 200:
185
- home_soup = BeautifulSoup(home_r.text, 'html.parser')
186
- for a in home_soup.select(f'a[href*="/tran-dau/{event_id}/"]'):
187
- href = a.get('href', '')
188
- m = re.match(r'/tran-dau/\d+/(?:centre|preview)/(.+)', href)
189
- if m:
190
- slug = m.group(1)
191
- cache_key = f"{event_id}_{slug}"
192
- break
193
- except: pass
194
- result = _get_match_detail(event_id, slug)
195
- if result:
196
- result['_ts'] = now
197
- _match_cache[cache_key] = result
198
- return JSONResponse(result)
199
- except Exception as e:
200
- err = {"event_id": event_id, "found": False, "error": str(e), "_ts": now}
201
- _match_cache[cache_key] = err
202
- return JSONResponse(err)
203
- return JSONResponse({"event_id": event_id, "found": False})
204
-
205
- _STOP=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 theo từ đến là có thì này đã để'.split())
206
-
207
- def _has_kw(topic,title):
208
- tl=topic.lower();tt=(title or'').lower()
209
- if tl in tt:return True
210
- words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',tl) if len(w)>2 and w not in _STOP]
211
- if not words:return True
212
- return any(w in tt for w in words)
213
-
214
- def _s_vnexpress(topic,limit=8):
215
- items=[]
216
- try:
217
- r=req.get(f"https://timkiem.vnexpress.net/?q={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=10);soup=BeautifulSoup(r.text,'lxml')
218
- for art in soup.select('article.item-news')[:limit]:
219
- a=art.select_one('h2 a, h3 a')
220
- if a and a.get('href'):
221
- t=_clean(a.get('title','') or a.get_text(strip=True))
222
- if _has_kw(topic,t):items.append({'title':t,'url':a['href'],'via':'VnExpress'})
223
- except:pass
224
- return items
225
-
226
- def _s_dantri(topic,limit=8):
227
- items=[]
228
- try:
229
- r=req.get(f"https://dantri.com.vn/tim-kiem/{quote(topic)}.htm",headers={'User-Agent':'Mozilla/5.0'},timeout=10);soup=BeautifulSoup(r.text,'lxml')
230
- for a in soup.select('h3 a[href], .article-title a[href]')[:limit*2]:
231
- t=_clean(a.get_text(strip=True));href=a.get('href','')
232
- if t and len(t)>15 and _has_kw(topic,t):
233
- if not href.startswith('http'):href='https://dantri.com.vn'+href
234
- items.append({'title':t,'url':href,'via':'Dân Trí'})
235
- if len(items)>=limit:break
236
- except:pass
237
- return items
238
-
239
- def _s_vietnamnet(topic,limit=6):
240
- items=[]
241
- try:
242
- r=req.get(f"https://vietnamnet.vn/tim-kiem?q={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=10);soup=BeautifulSoup(r.text,'lxml')
243
- for a in soup.select('h3 a[href], .vnn-title a')[:limit*2]:
244
- t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
245
- if t and len(t)>15 and _has_kw(topic,t):
246
- if not href.startswith('http'):href='https://vietnamnet.vn'+href
247
- items.append({'title':t,'url':href,'via':'VietNamNet'})
248
- if len(items)>=limit:break
249
- except:pass
250
- return items
251
-
252
- def _s_bongda(topic,limit=5):
253
- items=[]
254
- try:
255
- r=req.get(f"https://bongda.com.vn/tim-kiem.html?q={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=8);soup=BeautifulSoup(r.text,'lxml')
256
- for a in soup.select('h3 a[href], .title a[href]')[:limit*2]:
257
- t=_clean(a.get_text(strip=True));href=a.get('href','')
258
- if t and len(t)>15 and _has_kw(topic,t):
259
- if not href.startswith('http'):href='https://bongda.com.vn'+href
260
- items.append({'title':t,'url':href,'via':'Bóng Đá'})
261
- if len(items)>=limit:break
262
- except:pass
263
- return items
264
-
265
- def _s_genk(topic,limit=5):
266
- items=[]
267
- try:
268
- r=req.get(f"https://genk.vn/tim-kiem?q={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=8);soup=BeautifulSoup(r.text,'lxml')
269
- for a in soup.select('a[href$=".chn"]')[:limit*3]:
270
- t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
271
- if t and len(t)>15 and _has_kw(topic,t):
272
- if href.startswith('/'):href='https://genk.vn'+href
273
- items.append({'title':t,'url':href,'via':'GenK'})
274
- if len(items)>=limit:break
275
- except:pass
276
- return items
277
-
278
- def _s_thanhnien(topic,limit=6):
279
- items=[]
280
- try:
281
- r=req.get(f"https://thanhnien.vn/tim-kiem?q={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=10);soup=BeautifulSoup(r.text,'lxml')
282
- for a in soup.select('h3 a[href], .box-title a')[:limit*2]:
283
- t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
284
- if t and len(t)>15 and _has_kw(topic,t):
285
- if not href.startswith('http'):href='https://thanhnien.vn'+href
286
- items.append({'title':t,'url':href,'via':'Thanh Niên'})
287
- if len(items)>=limit:break
288
- except:pass
289
- return items
290
-
291
- def _s_tuoitre(topic,limit=6):
292
- items=[]
293
- try:
294
- r=req.get(f"https://tuoitre.vn/tim-kiem.htm?keywords={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=10);soup=BeautifulSoup(r.text,'lxml')
295
- for a in soup.select('h3 a[href], .box-title-text a')[:limit*2]:
296
- t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
297
- if t and len(t)>15 and _has_kw(topic,t):
298
- if not href.startswith('http'):href='https://tuoitre.vn'+href
299
- items.append({'title':t,'url':href,'via':'Tuổi Trẻ'})
300
- if len(items)>=limit:break
301
- except:pass
302
- return items
303
-
304
- def _s_thethaovanhoa(topic,limit=5):
305
- items=[]
306
- try:
307
- r=req.get(f"https://thethaovanhoa.vn/tim-kiem.htm?keyword={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=8);soup=BeautifulSoup(r.text,'lxml')
308
- for a in soup.select('h3 a[href], .title a[href]')[:limit*2]:
309
- t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
310
- if t and len(t)>15 and _has_kw(topic,t):
311
- if not href.startswith('http'):href='https://thethaovanhoa.vn'+href
312
- items.append({'title':t,'url':href,'via':'TT&VH'})
313
- if len(items)>=limit:break
314
- except:pass
315
- return items
316
-
317
- def _search_all(topic,limit=36):
318
- results={}
319
- with ThreadPoolExecutor(8) as ex:
320
- futs={ex.submit(_s_vnexpress,topic,8):'vne',ex.submit(_s_dantri,topic,8):'dt',ex.submit(_s_vietnamnet,topic,6):'vnn',ex.submit(_s_bongda,topic,5):'bd',ex.submit(_s_genk,topic,5):'gk',ex.submit(_s_thanhnien,topic,6):'tn',ex.submit(_s_tuoitre,topic,6):'tt',ex.submit(_s_thethaovanhoa,topic,5):'tvh'}
321
- for f in as_completed(futs,timeout=14):
322
- try:results[futs[f]]=f.result()
323
- except:results[futs[f]]=[]
324
- srcs=list(results.values());out=[];seen=set()
325
- for i in range(max((len(s) for s in srcs),default=0)):
326
- for s in srcs:
327
- if i<len(s) and s[i].get('url') and s[i]['url'] not in seen:seen.add(s[i]['url']);out.append(s[i])
328
- return out[:limit]
329
-
330
- for _path in ['/api/article', '/api/hot_topics', '/api/categories', '/api/storage_status', '/s']:
331
- app.router.routes=[r for r in app.router.routes if not(getattr(r,'path',None)==_path and 'GET' in getattr(r,'methods',set()))]
332
-
333
- _article_cache = {}
334
- _article_cache_ttl = 1800
335
-
336
- _art_session = None
337
- _art_lock = threading.Lock()
338
- def _get_art_session():
339
- global _art_session
340
- if _art_session is None:
341
- with _art_lock:
342
- if _art_session is None:
343
- _art_session = req.Session()
344
- _art_session.headers.update({
345
- "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",
346
- "Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8",
347
- "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
348
- })
349
- return _art_session
350
-
351
- def _scrape_article_fast(url):
352
- from urllib.parse import urlparse
353
- domain = urlparse(url).netloc
354
- sess = _get_art_session()
355
- uas = [
356
- {"User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"},
357
- {"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"},
358
- ]
359
- for ua in uas:
360
- try:
361
- r = sess.get(url, headers=ua, timeout=6, allow_redirects=True)
362
- if not r or r.status_code != 200:
363
- continue
364
- r.encoding = 'utf-8'
365
- soup = BeautifulSoup(r.text, 'lxml')
366
- for tag in soup.find_all(['script','style','nav','footer','aside','form','noscript','iframe','.ads','.ad','.banner-ads','.fb-comments','.fb-root','.social-share','.related-news','.tag','.breadcrumb']):
367
- tag.decompose()
368
- title = summary = og_img = ""
369
- ogt = soup.find('meta', property='og:title')
370
- if ogt: title = ogt.get('content', '')
371
- ogd = soup.find('meta', property='og:description') or soup.find('meta', attrs={'name': 'description'})
372
- if ogd: summary = ogd.get('content', '')[:500]
373
- ogi = soup.find('meta', property='og:image')
374
- if ogi:
375
- og_img = ogi.get('content', '')
376
- if og_img.startswith('//'): og_img = 'https:' + og_img
377
- h1 = soup.find('h1')
378
- if not title and h1: title = h1.get_text(strip=True)[:200]
379
- body = []
380
- selectors = [
381
- '.fck_detail', '.sidebar-1',
382
- '.singular-content', '.dt__content', '.article-content', '.content-detail', '#divNewsContent',
383
- '.content-detail', '.main-content-detail', '.box-content',
384
- '.knc-content', '.article-body', '.detail-body',
385
- '.article-detail', '.detail-content',
386
- 'article', 'main', '.cms-body', '.article__body', '.post-content',
387
- '.entry-content', '#content', '.article-text', '.story-body',
388
- ]
389
- for sel in selectors:
390
- el = soup.select_one(sel)
391
- if el and len(el.find_all('p')) >= 2:
392
- seen_imgs = set()
393
- for child in el.find_all(['p','h2','h3','figure','img'], recursive=True):
394
- if child.name == 'p':
395
- t = child.get_text(strip=True)
396
- if t and len(t) > 15:
397
- body.append({'type': 'p', 'text': t})
398
- elif child.name in ('h2','h3'):
399
- t = child.get_text(strip=True)
400
- if t:
401
- body.append({'type': 'heading', 'text': t})
402
- elif child.name in ('figure','img'):
403
- im = child if child.name == 'img' else child.find('img')
404
- if im:
405
- src = im.get('data-src') or im.get('src') or im.get('data-lazy') or ''
406
- if src and 'base64' not in src and src not in seen_imgs:
407
- seen_imgs.add(src)
408
- if src.startswith('//'): src = 'https:' + src
409
- body.append({'type': 'img', 'src': src})
410
- if child.name == 'figure':
411
- cap = child.find('figcaption')
412
- if cap:
413
- ct = cap.get_text(strip=True)
414
- if ct: body.append({'type': 'p', 'text': ct})
415
- if len(body) >= 2:
416
- return {'title': _clean(title), 'summary': _clean(summary), 'og_image': og_img,
417
- 'body': body[:50], 'source': domain, 'url': url}
418
- if title and (summary or og_img):
419
- fallback = []
420
- if og_img: fallback.append({'type': 'img', 'src': og_img})
421
- if summary: fallback.append({'type': 'p', 'text': summary})
422
- if fallback:
423
- return {'title': _clean(title), 'summary': _clean(summary), 'og_image': og_img,
424
- 'body': fallback, 'source': domain, 'url': url, 'fallback': True}
425
- if title:
426
- return {'title': _clean(title), 'summary': '', 'og_image': '',
427
- 'body': [{'type': 'p', 'text': 'Nội dung đang được tải...'}],
428
- 'source': domain, 'url': url, 'fallback': True}
429
- break
430
- except Exception:
431
- continue
432
- return None
433
-
434
- @app.get('/api/article')
435
- def api_article_v2(url: str = Query(...)):
436
- from urllib.parse import unquote
437
- safe_url = unquote(url)
438
- try:
439
- now = time.time()
440
- cached = _article_cache.get(safe_url)
441
- if cached and now - cached['t'] < _article_cache_ttl:
442
- resp = JSONResponse(cached['d'])
443
- resp.headers["Cache-Control"] = "public, max-age=1800"
444
- return resp
445
- data = _scrape_article_fast(safe_url)
446
- if data and data.get('body'):
447
- _article_cache[safe_url] = {'d': data, 't': now}
448
- resp = JSONResponse(data)
449
- resp.headers["Cache-Control"] = "public, max-age=1800"
450
- return resp
451
- result = {'error': 'Không đọc được', 'url': safe_url}
452
- resp = JSONResponse(result)
453
- resp.headers["Cache-Control"] = "public, max-age=60"
454
- return resp
455
- except Exception as e:
456
- return JSONResponse({'error': f'Server error: {str(e)[:100]}', 'url': safe_url}, status_code=200)
457
-
458
- _hot_cache={'t':0,'d':[]}
459
- def _get_hot_topics():
460
- now=time.time()
461
- if _hot_cache['d'] and now-_hot_cache['t']<600:return _hot_cache['d']
462
- freq={};display={}
463
- feeds=['https://vnexpress.net/rss/tin-moi-nhat.rss','https://dantri.com.vn/rss/home.rss','https://vietnamnet.vn/rss/tin-moi-nhat.rss','https://thanhnien.vn/rss/home.rss','https://tuoitre.vn/rss/tin-moi-nhat.rss','https://genk.vn/rss','https://vnexpress.net/rss/the-thao.rss','https://thethaovanhoa.vn/rss/tin-nong.rss']
464
- for feed_url in feeds:
465
- try:
466
- r=req.get(feed_url,headers={'User-Agent':'Mozilla/5.0'},timeout=6);r.encoding='utf-8';soup=BeautifulSoup(r.text,'xml')
467
- for item in soup.find_all('item')[:12]:
468
- title=_clean(item.find('title').get_text() if item.find('title') else '')
469
- if not title:continue
470
- title=re.sub(r'\s*[-|].*$','',title);words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',title) if len(w)>2 and w.lower() not in _STOP]
471
- if len(words)<2:continue
472
- for n in(3,4,2):
473
- for i in range(max(0,len(words)-n+1)):
474
- phrase=' '.join(words[i:i+n])
475
- if 8<=len(phrase)<=45:key=phrase.lower();freq[key]=freq.get(key,0)+1;display[key]=phrase
476
- except:continue
477
- ranked=sorted(freq.items(),key=lambda x:x[1],reverse=True);topics=[];seen=set()
478
- for key,count in ranked:
479
- is_dup=any(len(set(e.split())&set(key.split()))/max(len(set(e.split())),len(set(key.split())),1)>0.6 for e in seen)
480
- if is_dup:continue
481
- seen.add(key);topics.append({'label':'#'+re.sub(r'\s+','',display[key].title()),'topic':display[key],'count':count})
482
- if len(topics)>=20:break
483
- for kw in['World Cup 2026','Kinh tế Việt Nam','Bóng đá châu Âu','Công nghệ AI','Giá vàng','Thời tiết']:
484
- if len(topics)>=24:break
485
- if not any(kw.lower() in s for s in seen):topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw,'count':0})
486
- _hot_cache.update({'t':now,'d':topics[:24]});return topics[:24]
487
-
488
- @app.get('/api/hot_topics')
489
- def api_hot_topics():
490
- resp = JSONResponse({'topics':_get_hot_topics()})
491
- resp.headers["Cache-Control"] = "public, max-age=120"
492
- return resp
493
- @app.get('/')
494
- async def serve_index():
495
- p=os.path.join(STATIC_DIR,'index_v2.html')
496
- if os.path.exists(p):return FileResponse(p,media_type='text/html')
497
- return HTMLResponse('<h1>VNEWS</h1>')
498
- @app.get('/api/hashtag/sources')
499
- def _ht(topic:str=Query(...),page:int=Query(default=0)):
500
- items=_search_all(topic,36);per_page=8;start=page*per_page;end=start+per_page
501
- return JSONResponse({'sources':items[start:end],'topic':topic,'page':page,'has_more':end<len(items),'total':len(items)})
502
- @app.get('/api/categories')
503
- def _cat():return JSONResponse([])
504
- @app.get('/api/storage_status')
505
- def _st():return JSONResponse({'persistent':os.path.isdir('/data') and os.access('/data',os.W_OK)})
506
- @app.get('/s')
507
- async def _sh(url:str='',title:str='',img:str=''):return HTMLResponse(f'<!DOCTYPE html><html><head><meta property="og:title" content="{_clean(title)}"><meta property="og:image" content="{_clean(img)}"><meta http-equiv="refresh" content="0;url={_clean(url) or "/"}"></head><body></body></html>')
508
-
509
- from wc2026_scraper import scrape_summary,scrape_fixtures,scrape_standings,scrape_stats,scrape_wc_news,scrape_road_to_wc,get_wc2026_all,scrape_history,scrape_h2h,scrape_lineups,scrape_match_detail
510
-
511
- _xlb_cache = {}
512
- _xlb_lock = threading.Lock()
513
-
514
- def _xlb_scrape(path):
515
- url = f"https://xemlaibongda.top/{path}"
516
- r = req.get(url, headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}, timeout=15, allow_redirects=True)
517
- if r.status_code != 200:
518
- return []
519
- soup = BeautifulSoup(r.text, 'lxml')
520
- vids = []
521
- seen = set()
522
- for a in soup.select('a[href*="/video/"]'):
523
- href = a.get('href', '')
524
- if not href or href in seen:
525
- continue
526
- seen.add(href)
527
- if not href.startswith('http'):
528
- href = 'https://xemlaibongda.top' + href
529
- img = a.select_one('img')
530
- p = a.parent
531
- for _ in range(4):
532
- if img:
533
- break
534
- if p:
535
- img = p.select_one('img')
536
- p = p.parent
537
- img_src = ''
538
- if img:
539
- img_src = img.get('data-src','') or img.get('src','') or img.get('data-lazy','') or img.get('data-original','')
540
- if img_src.startswith('//'):
541
- img_src = 'https:' + img_src
542
- elif img_src.startswith('/'):
543
- img_src = 'https://xemlaibongda.top' + img_src
544
- title = ''
545
- for sel in ['.title', 'h3', 'h2', '.name', '.post-title', '.entry-title', '.video-title']:
546
- t = a.select_one(sel)
547
- if t:
548
- title = _clean(t.get_text())
549
- break
550
- if not title:
551
- title = _clean(a.get('title',''))
552
- if not title:
553
- img_alt = a.select_one('img')
554
- if img_alt:
555
- title = _clean(img_alt.get('alt',''))
556
- if not title:
557
- parent = a.parent
558
- if parent:
559
- pt = _clean(parent.get_text(' ',strip=True))
560
- if 5 < len(pt) < 120:
561
- title = pt
562
- if not title or len(title) < 3:
563
- continue
564
- vids.append({"link": href, "img": img_src, "title": title})
565
- if len(vids) >= 30:
566
- break
567
- return vids
568
-
569
- @app.get('/api/proxy/xlb')
570
- def proxy_xlb(path: str = Query(default="")):
571
- now = time.time()
572
- cache_key = f"xlb:{path}"
573
- with _xlb_lock:
574
- cached = _xlb_cache.get(cache_key)
575
- if cached and now - cached['t'] < 120:
576
- return JSONResponse(cached['d'])
577
- try:
578
- vids = _xlb_scrape(path)
579
- result = {"videos": vids, "count": len(vids)}
580
- with _xlb_lock:
581
- _xlb_cache[cache_key] = {'t': now, 'd': result}
582
- return JSONResponse(result)
583
- except Exception as e:
584
- return JSONResponse({"videos": [], "count": 0, "error": str(e)}, status_code=500)
585
-
586
- @app.get('/api/wc2026')
587
- def _w():return JSONResponse(get_wc2026_all())
588
- @app.get('/api/wc2026/fixtures')
589
- def _wf():return JSONResponse(scrape_fixtures())
590
- @app.get('/api/wc2026/standings')
591
- def _ws():return JSONResponse(scrape_standings())
592
- @app.get('/api/wc2026/stats')
593
- def _wst():return JSONResponse(scrape_stats())
594
- @app.get('/api/wc2026/history')
595
- def _whi():return JSONResponse(scrape_history())
596
- @app.get('/api/wc2026/news')
597
- def _wn():return JSONResponse(scrape_wc_news())
598
- @app.get('/api/wc2026/road')
599
- def _wr():return JSONResponse(scrape_road_to_wc())
600
- @app.get('/api/wc2026/h2h/{eid}')
601
- def _wh2(eid:int):return JSONResponse(scrape_h2h(eid))
602
- @app.get('/api/wc2026/lineups/{eid}')
603
- def _wl(eid:int):return JSONResponse(scrape_lineups(eid))
604
- @app.get('/api/wc2026/match/{eid}')
605
- def _wm(eid:int):return JSONResponse(scrape_match_detail(eid))
606
-
607
- DATA_DIR='/data' if os.path.isdir('/data') else os.path.join(os.path.dirname(os.path.abspath(__file__)),'data')
608
- os.makedirs(DATA_DIR,exist_ok=True)
609
- IF=os.path.join(DATA_DIR,'interactions_v2.json')
610
- CF=os.path.join(DATA_DIR,'comments_v2.json')
611
- WALL_FILE=os.path.join(DATA_DIR,'wall_posts.json')
612
- WALL_VIDEO_DIR=os.path.join(DATA_DIR,'wall_videos')
613
- os.makedirs(WALL_VIDEO_DIR,exist_ok=True)
614
-
615
- _il=threading.Lock();_cl=threading.Lock();_wl_lock=threading.Lock()
616
- def _lj(p):
617
- try:
618
- if os.path.exists(p):return json.load(open(p,'r',encoding='utf-8'))
619
- except:pass
620
- return{}
621
- def _sj(p,d):
622
- try:open(p+'.tmp','w',encoding='utf-8').write(json.dumps(d,ensure_ascii=False));os.replace(p+'.tmp',p)
623
- except:pass
624
-
625
- @app.post('/api/v2/interact')
626
- async def _int(request:Request):
627
- b=await request.json();v=str(b.get('id','')).strip();t=str(b.get('type','')).strip()
628
- if not v or t not in('view','like'):return JSONResponse({'error':'x'},status_code=400)
629
- with _il:db=_lj(IF);db.setdefault(v,{'views':0,'likes':0,'comments':0});db[v][t+'s']+=1;_sj(IF,db);return JSONResponse(db[v])
630
-
631
- @app.get('/api/v2/interactions')
632
- def _gi(id:str=Query(...)):
633
- with _il:return JSONResponse(_lj(IF).get(id.strip(),{'views':0,'likes':0,'comments':0}))
634
-
635
- @app.get('/api/v2/comments')
636
- def _gc(id:str=Query(...)):
637
- with _cl:return JSONResponse({'comments':_lj(CF).get(id.strip(),[])})
638
-
639
- @app.post('/api/v2/comment')
640
- async def _pc(request:Request):
641
- b=await request.json();v=str(b.get('id','')).strip();tx=str(b.get('text','')).strip()[:500]
642
- if not v or not tx:return JSONResponse({'error':'x'},status_code=400)
643
- c={'text':tx,'time':time.strftime('%H:%M %d/%m',time.localtime()),'ts':int(time.time())}
644
- with _cl:db=_lj(CF);db.setdefault(v,[]);db[v].append(c);db[v]=db[v][-200:];_sj(CF,db);cms=db[v]
645
- with _il:idb=_lj(IF);idb.setdefault(v,{'views':0,'likes':0,'comments':0});idb[v]['comments']=len(cms);_sj(IF,idb)
646
- return JSONResponse({'comments':cms})
647
-
648
- def _load_wall_posts():
649
- with _wl_lock:
650
- return _lj(WALL_FILE)
651
-
652
- def _save_wall_posts(posts):
653
- with _wl_lock:
654
- _sj(WALL_FILE, posts)
655
-
656
- @app.get('/api/wall')
657
- def api_wall():
658
- posts = _load_wall_posts()
659
- if not posts:
660
- return JSONResponse({"posts": []})
661
- return JSONResponse({"posts": posts})
662
-
663
- @app.post('/api/wall')
664
- async def api_wall_post(request: Request):
665
- content_type = request.headers.get('content-type', '')
666
- if 'multipart/form-data' in content_type:
667
- try:
668
- form = await request.form()
669
- except Exception as e:
670
- return JSONResponse({"error": f"Form parse error: {str(e)}"}, status_code=400)
671
- title = form.get('title', 'Video mới') or 'Video mới'
672
- text = form.get('text', '') or ''
673
- source = form.get('source', 'vtv_recorder') or 'vtv_recorder'
674
- video_file = form.get('video')
675
- post_id = str(uuid.uuid4())[:12]
676
- video_url = None
677
- if video_file and hasattr(video_file, 'filename') and video_file.filename:
678
- fname = video_file.filename.lower()
679
- if fname.endswith('.mp4'):
680
- ext = '.mp4'
681
- elif fname.endswith('.webm'):
682
- ext = '.webm'
683
- else:
684
- ext = '.webm'
685
- video_filename = f"wall_{post_id}{ext}"
686
- video_path = os.path.join(WALL_VIDEO_DIR, video_filename)
687
- try:
688
- content = await video_file.read()
689
- if not content:
690
- return JSONResponse({"error": "Empty video file"}, status_code=400)
691
- with open(video_path, 'wb') as f:
692
- f.write(content)
693
- file_size_mb = len(content) / 1024 / 1024
694
- if file_size_mb > 50:
695
- os.remove(video_path)
696
- return JSONResponse({"error": f"Video quá lớn ({file_size_mb:.1f}MB). Tối đa 50MB."}, status_code=400)
697
- video_url = f"/api/wall/video/{video_filename}"
698
- except Exception as e:
699
- return JSONResponse({"error": f"Lỗi lưu video: {str(e)}"}, status_code=500)
700
- post = {
701
- "id": post_id,
702
- "title": title[:200],
703
- "text": text[:2000],
704
- "source": source,
705
- "video": video_url,
706
- "img": None,
707
- "images": [],
708
- "created": int(time.time()),
709
- "created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()),
710
- }
711
- posts = _load_wall_posts()
712
- if not isinstance(posts, list):
713
- posts = []
714
- posts.insert(0, post)
715
- posts = posts[:200]
716
- _save_wall_posts(posts)
717
- return JSONResponse({"post": post, "ok": True})
718
- try:
719
- body = await request.json()
720
- except:
721
- body = {}
722
- title = body.get('title', 'Bài mới') or 'Bài mới'
723
- text = body.get('text', '') or ''
724
- img = body.get('img', None)
725
- source = body.get('source', 'user') or 'user'
726
- post_id = str(uuid.uuid4())[:12]
727
- post = {
728
- "id": post_id,
729
- "title": title[:200],
730
- "text": text[:2000],
731
- "source": source,
732
- "video": None,
733
- "img": img,
734
- "images": [],
735
- "created": int(time.time()),
736
- "created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()),
737
- }
738
- posts = _load_wall_posts()
739
- if not isinstance(posts, list):
740
- posts = []
741
- posts.insert(0, post)
742
- posts = posts[:200]
743
- _save_wall_posts(posts)
744
- return JSONResponse({"post": post, "ok": True})
745
-
746
- @app.get('/api/wall/video/{filename}')
747
- def api_wall_video(filename: str):
748
- if '..' in filename or '/' in filename:
749
- return Response(status_code=403)
750
- video_path = os.path.join(WALL_VIDEO_DIR, filename)
751
- if not os.path.exists(video_path):
752
- return Response(status_code=404)
753
- ext = os.path.splitext(filename)[1].lower()
754
- media_type = 'video/mp4' if ext == '.mp4' else 'video/webm'
755
- return FileResponse(video_path, media_type=media_type)
756
-
757
- @app.delete('/api/wall/{post_id}')
758
- def api_wall_delete(post_id: str):
759
- posts = _load_wall_posts()
760
- if not isinstance(posts, list):
761
- return JSONResponse({"error": "No posts"}, status_code=404)
762
- for i, p in enumerate(posts):
763
- if p.get('id') == post_id:
764
- if p.get('video'):
765
- video_name = p['video'].split('/')[-1]
766
- video_path = os.path.join(WALL_VIDEO_DIR, video_name)
767
- if os.path.exists(video_path):
768
- os.remove(video_path)
769
- posts.pop(i)
770
- _save_wall_posts(posts)
771
- return JSONResponse({"ok": True})
772
- return JSONResponse({"error": "Post not found"}, status_code=404)
773
-
774
- # ===== LANGUAGE & EMOTION DETECTION =====
775
- import random as _random2
776
- from urllib.parse import quote as _quote2
777
-
778
- _UA_RW = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept-Language': 'vi-VN,vi;q=0.9'}
779
-
780
- # Unique character markers for language detection
781
- _UNIQUE_CHARS = {
782
- 'vietnamese': set('đăâêôơưàảãạáằẳẵặắầẩẫậấèẻẽẹéềễểệếìỉĩịíòỏõọóồổỗộốờởỡợớùủũụúừửữựứỳỷỹỵý'),
783
- 'spanish': set('ñáéíóúü¿¡'),
784
- 'portuguese': set('ãõçáéíóúâêôà'),
785
- }
786
-
787
- _STOPWORDS = {
788
- 'english': {'the', 'is', 'at', 'which', 'on', 'a', 'an', 'and', 'or', 'but', 'in', 'with', 'to', 'for', 'of', 'not', 'no', 'can', 'had', 'have', 'has', 'was', 'were', 'are', 'be', 'been', 'this', 'that', 'it', 'he', 'she', 'they', 'his', 'her', 'my', 'your', 'our', 'we', 'you', 'i'},
789
- 'vietnamese': {'là', 'của', 'và', 'có', 'được', 'cho', 'không', 'với', 'này', 'đó', 'từ', 'trong', 'đã', 'sẽ', 'một', 'các', 'những', 'về', 'tại', 'người', 'năm', 'đến', 'ra', 'lại', 'như', 'khi', 'để', 'rất', 'cũng', 'mà', 'nếu', 'sau', 'trên', 'theo', 'vì', 'do', 'nên', 'thì', 'mình', 'tôi', 'bạn', 'anh', 'chị', 'em'},
790
- 'portuguese': {'de', 'um', 'que', 'e', 'do', 'da', 'em', 'para', 'com', 'não', 'uma', 'os', 'no', 'se', 'na', 'por', 'mais', 'as', 'dos', 'como', 'mas', 'ao', 'ele', 'das', 'tem', 'seu', 'sua', 'ou', 'quando', 'muito', 'nos', 'já', 'eu', 'também', 'só', 'pelo', 'pela', 'até', 'isso', 'ela', 'entre', 'depois', 'sem', 'mesmo', 'aos', 'são', 'está', 'ter', 'ser', 'foi', 'era', 'há', 'estão', 'você', 'nós', 'eles', 'elas'},
791
- 'spanish': {'de', 'que', 'el', 'en', 'y', 'a', 'los', 'del', 'se', 'las', 'por', 'un', 'para', 'con', 'no', 'una', 'su', 'al', 'es', 'lo', 'como', 'más', 'pero', 'sus', 'le', 'ya', 'o', 'fue', 'este', 'ha', 'si', 'porque', 'esta', 'son', 'entre', 'está', 'cuando', 'muy', 'sin', 'sobre', 'ser', 'también', 'me', 'hasta', 'hay', 'donde', 'han', 'quien', 'están', 'desde', 'todo', 'nos', 'durante', 'todos', 'uno', 'les', 'ni', 'contra', 'otros', 'fueron', 'ese', 'eso', 'ante', 'ellos', 'yo', 'tú', 'él', 'ella', 'nosotros', 'usted', 'ustedes'},
792
- }
793
-
794
- def detect_language(text):
795
- """Detect language from text content using stopword + character analysis."""
796
- if not text:
797
- return 'vietnamese'
798
- text_lower = text.lower()
799
- text_chars = set(text_lower)
800
-
801
- # Strong signal: Vietnamese unique characters
802
- vn_chars = len(text_chars & _UNIQUE_CHARS['vietnamese'])
803
- if vn_chars >= 2:
804
- return 'vietnamese'
805
-
806
- # Spanish unique chars (ñ, ¿, ¡)
807
- es_chars = len(text_chars & _UNIQUE_CHARS['spanish'])
808
- pt_chars = len(text_chars & _UNIQUE_CHARS['portuguese'])
809
-
810
- # Stopword scoring
811
- words = set(re.findall(r'\b\w+\b', text_lower))
812
- scores = {}
813
- for lang, stops in _STOPWORDS.items():
814
- scores[lang] = len(words & stops) / max(len(stops), 1)
815
-
816
- # Disambiguate Portuguese vs Spanish
817
- pt_markers = {'não', 'pelo', 'pela', 'isso', 'há', 'estão', 'num', 'numa', 'tenho', 'posso', 'você', 'nós', 'eles', 'elas', 'também', 'muito', 'já', 'só', 'até', 'entre', 'depois', 'sem', 'mesmo', 'aos', 'serão'}
818
- es_markers = {'pero', 'está', 'están', 'porque', 'también', 'hasta', 'donde', 'quien', 'fue', 'son', 'fueron', 'ese', 'eso', 'ante', 'ellos', 'ella', 'nosotros', 'usted', 'ustedes', 'tú', 'él', 'desde', 'todo', 'durante', 'todos', 'uno', 'les', 'ni', 'contra', 'otros', 'fueron'}
819
-
820
- pt_overlap = len(words & pt_markers)
821
- es_overlap = len(words & es_markers)
822
-
823
- if scores.get('portuguese', 0) > 0 and pt_overlap > es_overlap:
824
- return 'portuguese'
825
- if scores.get('spanish', 0) > 0 and es_overlap > pt_overlap:
826
- return 'spanish'
827
- if scores.get('english', 0) > 0.15:
828
- return 'english'
829
-
830
- best = max(scores, key=scores.get)
831
- return best if scores[best] > 0.05 else 'vietnamese'
832
-
833
- # Emotion keyword-based detection
834
- _EMOTION_KEYWORDS = {
835
- 'happy': {
836
- 'en': ['happy', 'joy', 'wonderful', 'great', 'amazing', 'fantastic', 'love', 'excellent', 'beautiful', 'glad', 'delighted', 'pleased', 'cheerful', 'celebrate', 'victory', 'win', 'success'],
837
- 'pt': ['feliz', 'alegria', 'maravilhoso', 'ótimo', 'incrível', 'fantástico', 'amor', 'excelente', 'lindo', 'contente', 'encantado', 'vitória', 'sucesso'],
838
- 'es': ['feliz', 'alegria', 'maravilloso', 'genial', 'increíble', 'fantástico', 'amor', 'excelente', 'hermoso', 'contento', 'encantado', 'victoria', 'éxito'],
839
- 'vi': ['vui', 'hạnh phúc', 'tuyệt vời', 'tuyệt', 'ý nghĩa', 'đẹp', 'thích', 'yêu', 'vui vẻ', 'hân hoan', 'phấn khích', 'chiến thắng', 'thành công'],
840
- },
841
- 'sad': {
842
- 'en': ['sad', 'unhappy', 'terrible', 'awful', 'horrible', 'miserable', 'depressed', 'grief', 'sorrow', 'tragic', 'unfortunate', 'painful', 'death', 'die', 'kill'],
843
- 'pt': ['triste', 'infeliz', 'terrível', 'horrível', 'miserável', 'deprimido', 'dor', 'trágico', 'infelizmente', 'penoso', 'morte', 'morrer'],
844
- 'es': ['triste', 'infeliz', 'terrible', 'horrible', 'miserable', 'deprimido', 'dolor', 'trágico', 'desafortunado', 'penoso', 'muerte', 'morir'],
845
- 'vi': ['buồn', 'không vui', 'tồi tệ', 'kinh khủng', 'đau khổ', 'đau buồn', 'bi thương', 'khốn nạn', 'đau đớn', 'thảm họa', 'chết', 'mất'],
846
- },
847
- 'excited': {
848
- 'en': ['excited', 'thrilling', 'amazing', 'wow', 'incredible', 'unbelievable', 'awesome', 'exhilarating', 'electrifying', 'breathtaking', 'breakthrough', 'record'],
849
- 'pt': ['animado', 'emocionante', 'incrível', 'impressionante', 'sensacional', 'eletrizante', 'empolgante', 'recorde'],
850
- 'es': ['emocionante', 'increíble', 'impresionante', 'sensacional', 'electrizante', 'emocionado', 'entusiasmado', 'récord'],
851
- 'vi': ['hào hứng', 'phấn khích', 'thú vị', 'tuyệt cú mèo', 'đỉnh cao', 'ngoạn mục', 'sục sôi', 'kỷ lục', 'đột phá'],
852
- },
853
- 'humorous': {
854
- 'en': ['funny', 'hilarious', 'joke', 'laugh', 'comedy', 'humor', 'amusing', 'witty', 'sarcastic', 'ironic', 'ridiculous', 'absurd', 'lol', 'haha'],
855
- 'pt': ['engraçado', 'hilário', 'piada', 'rir', 'comédia', 'humor', 'divertido', 'irônico', 'ridículo', 'absurdo', 'kkk'],
856
- 'es': ['gracioso', 'hilarante', 'broma', 'risa', 'comedia', 'humor', 'divertido', 'irónico', 'ridículo', 'absurdo', 'jaja'],
857
- 'vi': ['hài hước', 'buồn cười', 'đùa', 'cười', 'hài', 'vui nhộn', 'hóm hỉnh', 'mỉa mai', 'lố bịch', 'vô lý', 'haha'],
858
- },
859
- 'serious': {
860
- 'en': ['serious', 'critical', 'important', 'urgent', 'severe', 'grave', 'significant', 'crucial', 'vital', 'essential', 'alarming', 'concerning', 'crisis', 'war', 'conflict'],
861
- 'pt': ['sério', 'crítico', 'importante', 'urgente', 'grave', 'significativo', 'crucial', 'vital', 'essencial', 'preocupante', 'crise', 'guerra', 'conflito'],
862
- 'es': ['serio', 'crítico', 'importante', 'urgente', 'grave', 'significativo', 'crucial', 'vital', 'esencial', 'preocupante', 'crisis', 'guerra', 'conflicto'],
863
- 'vi': ['nghiêm trọng', 'quan trọng', 'khẩn cấp', 'nghiêm túc', 'đáng kể', 'thiết yếu', 'cần thiết', 'báo động', 'lo ngại', 'khủng hoảng', 'chiến tranh', 'xung đột'],
864
- },
865
- }
866
-
867
- def detect_emotion(text, language='vietnamese'):
868
- """Detect emotion from text using keyword matching."""
869
- if not text:
870
- return 'neutral'
871
- text_lower = text.lower()
872
-
873
- scores = {}
874
- for emotion, lang_keywords in _EMOTION_KEYWORDS.items():
875
- keywords = lang_keywords.get(language, lang_keywords.get('en', []))
876
- score = sum(1 for kw in keywords if kw in text_lower)
877
- scores[emotion] = score
878
-
879
- if max(scores.values()) == 0:
880
- return 'neutral'
881
-
882
- return max(scores, key=scores.get)
883
-
884
- def detect_language_and_emotion(title, text):
885
- """Detect both language and emotion from article content."""
886
- combined = f"{title} {text}"
887
- lang = detect_language(combined)
888
- emotion = detect_emotion(combined, lang)
889
- return lang, emotion
890
-
891
- # Voice selection based on language and emotion (using MultilingualNeural voices)
892
- VOICE_BY_LANG_EMOTION = {
893
- 'vietnamese': {
894
- 'happy': ('vi-VN-HoaiMyNeural', 'vui'),
895
- 'sad': ('vi-VN-NamMinhNeural', 'buồn'),
896
- 'excited': ('vi-VN-HoaiMyNeural', 'hào hứng'),
897
- 'humorous': ('vi-VN-HoaiMyNeural', 'vui'),
898
- 'serious': ('vi-VN-NamMinhNeural', 'nghiêm túc'),
899
- 'neutral': ('vi-VN-HoaiMyNeural', 'trung_tinh'),
900
- },
901
- 'portuguese': {
902
- 'happy': ('pt-BR-ThalitaMultilingualNeural', 'feliz'),
903
- 'sad': ('pt-BR-ThalitaMultilingualNeural', 'triste'),
904
- 'excited': ('pt-BR-ThalitaMultilingualNeural', 'animado'),
905
- 'humorous': ('pt-BR-ThalitaMultilingualNeural', 'engraçado'),
906
- 'serious': ('pt-BR-ThalitaMultilingualNeural', 'sério'),
907
- 'neutral': ('pt-BR-ThalitaMultilingualNeural', 'neutro'),
908
- },
909
- 'english': {
910
- 'happy': ('en-US-AndrewMultilingualNeural', 'happy'),
911
- 'sad': ('en-AU-WilliamMultilingualNeural', 'sad'),
912
- 'excited': ('en-US-AndrewMultilingualNeural', 'excited'),
913
- 'humorous': ('en-US-AndrewMultilingualNeural', 'funny'),
914
- 'serious': ('en-AU-WilliamMultilingualNeural', 'serious'),
915
- 'neutral': ('en-US-AndrewMultilingualNeural', 'neutral'),
916
- },
917
- 'french': {
918
- 'happy': ('fr-FR-VivienneMultilingualNeural', 'heureux'),
919
- 'sad': ('fr-FR-RemyMultilingualNeural', 'triste'),
920
- 'excited': ('fr-FR-VivienneMultilingualNeural', 'excité'),
921
- 'humorous': ('fr-FR-VivienneMultilingualNeural', 'drôle'),
922
- 'serious': ('fr-FR-RemyMultilingualNeural', 'sérieux'),
923
- 'neutral': ('fr-FR-VivienneMultilingualNeural', 'neutre'),
924
- },
925
- 'german': {
926
- 'happy': ('de-DE-SeraphinaMultilingualNeural', 'glücklich'),
927
- 'sad': ('de-DE-FlorianMultilingualNeural', 'traurig'),
928
- 'excited': ('de-DE-SeraphinaMultilingualNeural', 'aufgeregt'),
929
- 'humorous': ('de-DE-SeraphinaMultilingualNeural', 'lustig'),
930
- 'serious': ('de-DE-FlorianMultilingualNeural', 'ernst'),
931
- 'neutral': ('de-DE-SeraphinaMultilingualNeural', 'neutral'),
932
- },
933
- 'korean': {
934
- 'happy': ('ko-KR-HyunsuMultilingualNeural', '행복'),
935
- 'sad': ('ko-KR-HyunsuMultilingualNeural', '슬픔'),
936
- 'excited': ('ko-KR-HyunsuMultilingualNeural', '흥분'),
937
- 'humorous': ('ko-KR-HyunsuMultilingualNeural', '유쾌'),
938
- 'serious': ('ko-KR-HyunsuMultilingualNeural', '진지'),
939
- 'neutral': ('ko-KR-HyunsuMultilingualNeural', '중립'),
940
- },
941
- 'italian': {
942
- 'happy': ('it-IT-GiuseppeMultilingualNeural', 'felice'),
943
- 'sad': ('it-IT-GiuseppeMultilingualNeural', 'triste'),
944
- 'excited': ('it-IT-GiuseppeMultilingualNeural', 'emozionato'),
945
- 'humorous': ('it-IT-GiuseppeMultilingualNeural', 'divertente'),
946
- 'serious': ('it-IT-GiuseppeMultilingualNeural', 'serio'),
947
- 'neutral': ('it-IT-GiuseppeMultilingualNeural', 'neutro'),
948
- },
949
- }
950
-
951
- # All valid voice IDs (new MultilingualNeural format)
952
- VALID_VOICES = {
953
- 'vi-VN-HoaiMyNeural', 'vi-VN-NamMinhNeural',
954
- 'en-US-AndrewMultilingualNeural', 'en-AU-WilliamMultilingualNeural',
955
- 'pt-BR-ThalitaMultilingualNeural',
956
- 'fr-FR-VivienneMultilingualNeural', 'fr-FR-RemyMultilingualNeural',
957
- 'de-DE-SeraphinaMultilingualNeural', 'de-DE-FlorianMultilingualNeural',
958
- 'ko-KR-HyunsuMultilingualNeural',
959
- 'it-IT-GiuseppeMultilingualNeural',
960
- }
961
-
962
- def get_voice_for_content(title, text, preferred_voice=None):
963
- """Get appropriate voice based on content language and emotion."""
964
- # Accept the new MultilingualNeural voices directly
965
- if preferred_voice and preferred_voice in VALID_VOICES:
966
- return preferred_voice
967
-
968
- # Also accept old shorthand voice IDs and map them to new format
969
- old_voice_map = {
970
- 'hoaimy': 'vi-VN-HoaiMyNeural',
971
- 'namminh': 'vi-VN-NamMinhNeural',
972
- 'andrew': 'en-US-AndrewMultilingualNeural',
973
- 'jenny': 'en-US-AndrewMultilingualNeural',
974
- 'thalita': 'pt-BR-ThalitaMultilingualNeural',
975
- 'pt_thalita': 'pt-BR-ThalitaMultilingualNeural',
976
- 'pt_francisco': 'pt-BR-ThalitaMultilingualNeural',
977
- 'ela': 'en-US-AndrewMultilingualNeural',
978
- 'es_carlos': 'en-US-AndrewMultilingualNeural',
979
- 'denise': 'fr-FR-VivienneMultilingualNeural',
980
- 'katja': 'de-DE-SeraphinaMultilingualNeural',
981
- 'nanami': 'en-US-AndrewMultilingualNeural',
982
- 'sunhee': 'ko-KR-HyunsuMultilingualNeural',
983
- 'xiaochen': 'en-US-AndrewMultilingualNeural',
984
- }
985
- if preferred_voice and preferred_voice in old_voice_map:
986
- return old_voice_map[preferred_voice]
987
-
988
- lang, emotion = detect_language_and_emotion(title, text)
989
- lang_map = VOICE_BY_LANG_EMOTION.get(lang, VOICE_BY_LANG_EMOTION['vietnamese'])
990
- voice, _ = lang_map.get(emotion, lang_map['neutral'])
991
- return voice
992
-
993
-
994
- def _is_relevant_image(img_url, title, text):
995
- """Check if an image is relevant to the article content."""
996
- if not img_url:
997
- return False
998
- skip_patterns = ['pixel', 'analytics', 'tracking', '1x1.gif', 'spacer.gif',
999
- 'logo', 'icon', 'avatar', 'emoji', 'smiley', 'sprite',
1000
- 'advertisement', 'ad-banner', 'sponsored', 'banner-ads']
1001
- img_lower = img_url.lower()
1002
- for p in skip_patterns:
1003
- if p in img_lower:
1004
- return False
1005
- if not any(img_lower.endswith(ext) for ext in ['.jpg', '.jpeg', '.png', '.webp', '.gif']):
1006
- return False
1007
- return True
1008
-
1009
-
1010
- def _filter_relevant_images(images, title, text, max_images=8):
1011
- """Filter and rank images by relevance to article content."""
1012
- if not images:
1013
- return []
1014
- seen = set()
1015
- relevant = []
1016
- for img in images:
1017
- if img in seen:
1018
- continue
1019
- seen.add(img)
1020
- if _is_relevant_image(img, title, text):
1021
- relevant.append(img)
1022
- return relevant[:max_images]
1023
-
1024
-
1025
- def _scrape_article_for_rewrite(url):
1026
- """Scrape article: extract title, paragraphs, RELEVANT images, OG image."""
1027
- try:
1028
- r = req.get(url, headers=_UA_RW, timeout=15, allow_redirects=True)
1029
- r.encoding = 'utf-8'
1030
- soup = BeautifulSoup(r.text, 'lxml')
1031
- for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form']):
1032
- tag.decompose()
1033
- h1 = soup.find('h1')
1034
- ogt = soup.find('meta', property='og:title')
1035
- title = (h1.get_text(strip=True) if h1 else '') or (ogt.get('content', '') if ogt else '')
1036
- ogi = soup.find('meta', property='og:image')
1037
- og_img = ogi.get('content', '') if ogi else ''
1038
- if og_img and og_img.startswith('//'):
1039
- og_img = 'https:' + og_img
1040
- block = None
1041
- for sel in ['article', '.singular-content', '.detail-content', '.fck_detail', '.content-detail', '.knc-content', 'main', '.cms-body', '.article__body']:
1042
- el = soup.select_one(sel)
1043
- if el and len(el.find_all('p')) >= 2:
1044
- block = el
1045
- break
1046
- if not block:
1047
- block = soup.body or soup
1048
- paragraphs = []
1049
- all_images = []
1050
- seen_imgs = set()
1051
- if og_img and og_img not in seen_imgs:
1052
- all_images.append(og_img)
1053
- seen_imgs.add(og_img)
1054
- for el in block.find_all(['p', 'h2', 'h3', 'figure', 'img'], recursive=True):
1055
- if el.name == 'p':
1056
- t = _clean(el.get_text(strip=True))
1057
- if t and len(t) > 40:
1058
- paragraphs.append(t)
1059
- elif el.name in ('figure', 'img'):
1060
- im = el if el.name == 'img' else el.find('img')
1061
- if im:
1062
- src = im.get('data-src') or im.get('src') or im.get('data-original') or ''
1063
- if src and 'base64' not in src:
1064
- if src.startswith('//'):
1065
- src = 'https:' + src
1066
- if src not in seen_imgs:
1067
- all_images.append(src)
1068
- seen_imgs.add(src)
1069
- # Filter to relevant images only
1070
- relevant_images = _filter_relevant_images(all_images, title, ' '.join(paragraphs[:5]))
1071
- return {'title': _clean(title), 'paragraphs': paragraphs, 'images': relevant_images, 'og_img': og_img}
1072
- except Exception:
1073
- return None
1074
-
1075
-
1076
- def _extract_key_points_rw(paragraphs, max_points=5):
1077
- """Extract key points from paragraphs - extracts ALL sentences, not just first one.
1078
-
1079
- Fixes: Original regex `^(.+?[.!?])\s` only captured first sentence per paragraph.
1080
- Now splits on all sentence boundaries and takes valid sentences until max_points.
1081
- """
1082
- points = []
1083
-
1084
- for p in paragraphs:
1085
- if len(points) >= max_points:
1086
- break
1087
-
1088
- p = _clean(p)
1089
- if not p:
1090
- continue
1091
-
1092
- # Split paragraph into sentences using Vietnamese + English punctuation
1093
- sentences = re.split(r'(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])', p)
1094
- sentences = [s.strip() for s in sentences if s.strip()]
1095
-
1096
- for sentence in sentences:
1097
- if len(points) >= max_points:
1098
- break
1099
-
1100
- # Clean sentence - remove extra whitespace
1101
- sentence = _clean(sentence)
1102
-
1103
- if len(sentence) < 30:
1104
- continue
1105
-
1106
- # Check for duplicates
1107
- if any(sentence[:60] in existing for existing in points):
1108
- continue
1109
-
1110
- # Ensure sentence ends with punctuation
1111
- if not sentence.endswith(('.', '!', '?')):
1112
- sentence = sentence + '.'
1113
-
1114
- points.append(sentence)
1115
-
1116
- # If no valid sentences found, take chunks from raw text
1117
- if not points:
1118
- raw = '\n'.join(paragraphs)
1119
- for i in range(0, min(len(raw), max_points * 300), 280):
1120
- chunk = _clean(raw[i:i+280])
1121
- if len(chunk) >= 30 and chunk not in points:
1122
- points.append(chunk + ('.' if not chunk.endswith('.') else ''))
1123
- if len(points) >= max_points:
1124
- break
1125
-
1126
- return points
1127
-
1128
-
1129
- @app.post("/api/rewrite_slide")
1130
- async def api_rewrite_slide(request: Request):
1131
- """Fast rewrite as SLIDES - no AI needed, instant response."""
1132
- body = await request.json()
1133
- url = _clean(body.get("url", ""))
1134
- context = body.get("context", "")
1135
- preferred_voice = body.get("voice", "") # Accept custom voice selection
1136
- if not url and not context:
1137
- return JSONResponse({"error": "Cần URL hoặc nội dung"}, status_code=400)
1138
- data = None
1139
- if url and url.startswith("http"):
1140
- data = _scrape_article_for_rewrite(url)
1141
- if not data and context:
1142
- paragraphs = [_clean(p) for p in context.split('\n') if len(_clean(p)) > 40]
1143
- data = {'title': paragraphs[0][:80] if paragraphs else 'Bài viết', 'paragraphs': paragraphs, 'images': [], 'og_img': ''}
1144
- if not data or not data.get('paragraphs'):
1145
- return JSONResponse({"error": "Không đọc được bài viết"}, status_code=422)
1146
- points = _extract_key_points_rw(data['paragraphs'], max_points=12)
1147
- if not points:
1148
- return JSONResponse({"error": "Không tìm được ý chính"}, status_code=422)
1149
- images = data.get('images', [])
1150
- slides = []
1151
- for i, point in enumerate(points):
1152
- img = images[i] if i < len(images) else (images[-1] if images else '')
1153
- if img and 'cdnphoto.dantri' in img:
1154
- img = '/api/proxy/img?url=' + _quote2(img, safe='')
1155
- slides.append({'text': point, 'image': img, 'index': i + 1})
1156
- summary_text = '\n\n'.join([f"• {s['text']}" for s in slides])
1157
-
1158
- # Auto-detect language and emotion
1159
- lang, emotion = detect_language_and_emotion(data['title'], summary_text)
1160
- # Use preferred voice if provided, otherwise auto-detect
1161
- voice = preferred_voice if preferred_voice else get_voice_for_content(data['title'], summary_text)
1162
-
1163
- post = {
1164
- "id": str(int(time.time() * 1000)) + str(_random2.randint(100, 999)),
1165
- "title": data['title'],
1166
- "text": summary_text,
1167
- "img": images[0] if images else '',
1168
- "url": url,
1169
- "kind": "slide_summary",
1170
- "slides": slides,
1171
- "images": images[:10],
1172
- "video": "",
1173
- "voice": voice,
1174
- "emotion": emotion,
1175
- "language": lang,
1176
- "ts": int(time.time())
1177
- }
1178
- posts = _load_wall_posts()
1179
- posts.insert(0, post)
1180
- _save_wall_posts(posts)
1181
- return JSONResponse({"post": post, "slides": slides})
1182
-
1183
-
1184
- @app.post("/api/rewrite_share")
1185
- async def api_rewrite_share(request: Request):
1186
- """Rewrite article and post to Tường AI with SLIDES + AI text."""
1187
- body = await request.json()
1188
- url = _clean(body.get("url", ""))
1189
- ctx = _clean(body.get("context", ""))
1190
- preferred_voice = body.get("voice", "") # Accept custom voice selection
1191
- if not url and not ctx:
1192
- return JSONResponse({"error": "Cần URL hoặc nội dung"}, status_code=400)
1193
- data = None
1194
- if url and url.startswith("http"):
1195
- data = _scrape_article_for_rewrite(url)
1196
- if not data and ctx:
1197
- paragraphs = [_clean(p) for p in ctx.split('\n') if len(_clean(p)) > 40]
1198
- data = {'title': paragraphs[0][:80] if paragraphs else 'Bài viết', 'paragraphs': paragraphs, 'images': [], 'og_img': ''}
1199
- if not data or not data.get('paragraphs'):
1200
- return JSONResponse({"error": "Không đọc được bài viết"}, status_code=422)
1201
- raw_text = '\n'.join(data['paragraphs'])
1202
- if len(raw_text) < 50:
1203
- raw_text = ctx[:14000]
1204
- if len(raw_text) < 50:
1205
- return JSONResponse({"error": "Bài viết quá ngắn"}, status_code=422)
1206
- domain = ''
1207
- try:
1208
- from urllib.parse import urlparse
1209
- domain = urlparse(url).netloc.replace('www.', '')
1210
- except:
1211
- pass
1212
-
1213
- # Generate AI summary text
1214
- ai_text = None
1215
- try:
1216
- import ai_ext
1217
- if hasattr(ai_ext, 'qwen_generate'):
1218
- prompt = f'Tóm tắt đăng Tường AI:\nTiêu đề: {data["title"]}\n{raw_text[:14000]}\n\n4-6 ý chính. Cuối ghi nguồn.'
1219
- ai_text = await ai_ext.qwen_generate(prompt, max_tokens=1000)
1220
- except Exception:
1221
- pass
1222
- if not ai_text or len(ai_text) < 80:
1223
- key_pts = _extract_key_points_rw(data['paragraphs'], max_points=12)
1224
- if key_pts:
1225
- ai_text = '\n\n'.join([f"• {p}" for p in key_pts])
1226
- else:
1227
- ai_text = f"Tóm tắt: {data['title']}\n\n{raw_text[:1200]}\n\nNguồn: {domain}"
1228
-
1229
- # Build slides from key points (FIX: include slides in rewrite_share too!)
1230
- points = _extract_key_points_rw(data['paragraphs'], max_points=12)
1231
- images = data.get('images', [])
1232
- slides = []
1233
- for i, point in enumerate(points):
1234
- img = images[i] if i < len(images) else (images[-1] if images else '')
1235
- if img and 'cdnphoto.dantri' in img:
1236
- img = '/api/proxy/img?url=' + _quote2(img, safe='')
1237
- slides.append({'text': point, 'image': img, 'index': i + 1})
1238
-
1239
- # Auto-detect language and emotion
1240
- lang, emotion = detect_language_and_emotion(data['title'], ai_text)
1241
- # Use preferred voice if provided, otherwise auto-detect
1242
- voice = preferred_voice if preferred_voice else get_voice_for_content(data['title'], ai_text)
1243
-
1244
- post = {
1245
- "id": str(int(time.time() * 1000)) + str(_random2.randint(100, 999)),
1246
- "title": data['title'],
1247
- "text": ai_text,
1248
- "img": images[0] if images else '',
1249
- "url": url,
1250
- "kind": "rewrite",
1251
- "slides": slides,
1252
- "images": images[:10],
1253
- "video": "",
1254
- "voice": voice,
1255
- "emotion": emotion,
1256
- "language": lang,
1257
- "ts": int(time.time())
1258
- }
1259
- posts = _load_wall_posts()
1260
- posts.insert(0, post)
1261
- _save_wall_posts(posts)
1262
- return JSONResponse({"post": post, "slides": slides})
1263
-
1264
-
1265
- @app.post("/api/url_wall")
1266
- async def api_url_wall(request: Request):
1267
- """Submit URL to add to Tường AI."""
1268
- body = await request.json()
1269
- url = _clean(body.get("url", ""))
1270
- if not url or not url.startswith('http'):
1271
- return JSONResponse({"error": "URL không hợp lệ"}, status_code=400)
1272
- # Reuse rewrite_share logic
1273
- req._body = json.dumps({"url": url}).encode()
1274
- return await api_rewrite_share(request)
1275
-
1276
-
1277
- def _bg():
1278
- time.sleep(15)
1279
- while True:
1280
- try:get_wc2026_all()
1281
- except:pass
1282
- time.sleep(90)
1283
- threading.Thread(target=_bg,daemon=True).start()
1284
 
1285
- app.mount('/static',StaticFiles(directory=STATIC_DIR),name='vnews_static')
 
 
29
  app.routes[:]=[r for r in app.routes if not isinstance(r, Mount)]
30
  app.router.routes=[r for r in app.router.routes if not isinstance(r, Mount)]
31
 
32
+ # === SETUP YOUTUBE RSS PROXY FOR SHORTS ===
33
+ try:
34
+ from shorts_rss_proxy import setup_rss_proxy
35
+ setup_rss_proxy(app)
36
+ print("[SHORTS] RSS proxy enabled")
37
+ except Exception as e:
38
+ print(f"[SHORTS] RSS proxy setup failed: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
 
40
+ # === REST OF ORIGINAL FILE ===
41
+ # (keeping all existing code below)