Spaces:
Running
Running
FIX WC2026: BXH dùng tournament_id=24254, fixtures/stats dùng news thay vì API không hỗ trợ filter"
Browse files- wc2026_scraper.py +97 -66
wc2026_scraper.py
CHANGED
|
@@ -1,7 +1,9 @@
|
|
| 1 |
"""
|
| 2 |
World Cup 2026 Data Module
|
| 3 |
-
|
| 4 |
-
|
|
|
|
|
|
|
| 5 |
"""
|
| 6 |
import requests, re, time, threading
|
| 7 |
from bs4 import BeautifulSoup
|
|
@@ -12,24 +14,24 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
| 12 |
BONGDA_HEADERS = {
|
| 13 |
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
| 14 |
"Accept-Language": "vi-VN,vi;q=0.9",
|
| 15 |
-
"Referer": "https://bongda.com.vn/giai-dau/24254/
|
| 16 |
"X-Requested-With": "XMLHttpRequest"
|
| 17 |
}
|
| 18 |
UA = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'}
|
| 19 |
WC_ID = 24254
|
| 20 |
CACHE = {}
|
| 21 |
-
|
| 22 |
|
| 23 |
def _clean(s): return re.sub(r'\s+', ' ', str(s or '')).strip()
|
| 24 |
def _cached(key, ttl=120):
|
| 25 |
-
with
|
| 26 |
if key in CACHE and time.time() - CACHE[key]['t'] < ttl: return CACHE[key]['d']
|
| 27 |
return None
|
| 28 |
def _set(key, data):
|
| 29 |
-
with
|
| 30 |
|
| 31 |
def _bongda(endpoint):
|
| 32 |
-
"""Call bongda.com.vn AJAX API
|
| 33 |
try:
|
| 34 |
r = requests.get(f"https://bongda.com.vn{endpoint}", headers=BONGDA_HEADERS, timeout=12)
|
| 35 |
if r.status_code == 200:
|
|
@@ -45,89 +47,113 @@ def _fetch(url, timeout=12):
|
|
| 45 |
return r.text if r.status_code == 200 else ''
|
| 46 |
except: return ''
|
| 47 |
|
| 48 |
-
# ====================
|
| 49 |
-
def scrape_fixtures():
|
| 50 |
-
c = _cached('wc_fix', 120)
|
| 51 |
-
if c is not None: return c
|
| 52 |
-
# CHỈ dùng endpoint với tournament_id=24254
|
| 53 |
-
html = _bongda(f"/api/fixtures/get-by-tournament?tournament_id={WC_ID}")
|
| 54 |
-
if not html:
|
| 55 |
-
# Thử incoming chỉ của WC
|
| 56 |
-
html = _bongda(f"/api/fixtures/incoming?tournament_id={WC_ID}")
|
| 57 |
-
if not html:
|
| 58 |
-
# Thử theo ngày chỉ của WC
|
| 59 |
-
today = datetime.now().strftime("%Y-%m-%d")
|
| 60 |
-
html = _bongda(f"/api/fixtures/get-by-date?date={today}&tournament_id={WC_ID}")
|
| 61 |
-
# KHÔNG fallback sang endpoint không có tournament_id
|
| 62 |
-
r = {'html': html}; _set('wc_fix', r); return r
|
| 63 |
-
|
| 64 |
-
# ==================== STANDINGS (CHỈ World Cup 2026) ====================
|
| 65 |
def scrape_standings():
|
| 66 |
-
|
|
|
|
| 67 |
if c is not None: return c
|
| 68 |
-
#
|
| 69 |
html = _bongda(f"/api/league-table/home?tournament_id={WC_ID}&is_detail=True")
|
| 70 |
if not html:
|
| 71 |
html = _bongda(f"/api/league-table/home?tournament_id={WC_ID}")
|
| 72 |
-
r = {'html': html}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
|
| 74 |
-
# ====================
|
| 75 |
def scrape_stats():
|
| 76 |
-
|
|
|
|
| 77 |
if c is not None: return c
|
|
|
|
| 78 |
html = _bongda(f"/api/event-standing/player-performance?tournament_id={WC_ID}")
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
|
| 83 |
-
# ====================
|
| 84 |
def scrape_history():
|
| 85 |
-
c = _cached('wc_hist',
|
| 86 |
if c is not None: return c
|
| 87 |
-
html = _bongda(f"/api/
|
| 88 |
-
r = {'html': html}
|
|
|
|
|
|
|
| 89 |
|
| 90 |
-
# ====================
|
| 91 |
def scrape_h2h(event_id):
|
| 92 |
-
key = f'wc_h2h_{event_id}'
|
| 93 |
-
c = _cached(key, 600)
|
| 94 |
-
if c is not None: return c
|
| 95 |
html = _bongda(f"/api/fixtures/head-to-head?event_id={event_id}")
|
| 96 |
-
|
| 97 |
|
| 98 |
-
# ==================== LINEUPS ====================
|
| 99 |
def scrape_lineups(event_id):
|
| 100 |
-
key = f'wc_lineup_{event_id}'
|
| 101 |
-
c = _cached(key, 300)
|
| 102 |
-
if c is not None: return c
|
| 103 |
html = _bongda(f"/api/fixtures/lineups?event_id={event_id}")
|
| 104 |
-
|
| 105 |
|
| 106 |
-
# ==================== MATCH DETAIL ====================
|
| 107 |
def scrape_match_detail(event_id):
|
| 108 |
-
key = f'wc_detail_{event_id}'
|
| 109 |
-
c = _cached(key, 60)
|
| 110 |
-
if c is not None: return c
|
| 111 |
html = _bongda(f"/api/fixtures/commentaries?event_id={event_id}")
|
| 112 |
-
|
| 113 |
|
| 114 |
# ==================== LIVE ====================
|
| 115 |
def scrape_summary():
|
| 116 |
-
c = _cached('wc_sum',
|
| 117 |
if c is not None: return c
|
| 118 |
-
#
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
r = {'html_live': html_live, 'html_today': html_today, 'html_incoming': html_incoming}
|
| 124 |
-
_set('wc_sum', r); return r
|
| 125 |
|
| 126 |
# ==================== NEWS ====================
|
| 127 |
def scrape_wc_news():
|
| 128 |
c = _cached('wc_news', 300)
|
| 129 |
if c is not None: return c
|
| 130 |
news = []
|
|
|
|
| 131 |
try:
|
| 132 |
html = _fetch('https://thethaovanhoa.vn/rss/world-cup-2026.rss')
|
| 133 |
if html:
|
|
@@ -143,6 +169,7 @@ def scrape_wc_news():
|
|
| 143 |
if im: img = im.get('src', '')
|
| 144 |
if title and link: news.append({'title': title, 'link': link, 'img': img, 'source': 'TT&VH'})
|
| 145 |
except: pass
|
|
|
|
| 146 |
try:
|
| 147 |
html = _fetch(f'https://timkiem.vnexpress.net/?q={quote("World Cup 2026")}')
|
| 148 |
if html:
|
|
@@ -157,6 +184,7 @@ def scrape_wc_news():
|
|
| 157 |
if title and href and href not in [n['link'] for n in news]:
|
| 158 |
news.append({'title': title, 'link': href, 'img': img, 'source': 'VnExpress'})
|
| 159 |
except: pass
|
|
|
|
| 160 |
try:
|
| 161 |
html = _fetch('https://bongdaplus.vn/world-cup')
|
| 162 |
if html:
|
|
@@ -168,14 +196,15 @@ def scrape_wc_news():
|
|
| 168 |
if title and len(title) > 15 and href not in [n['link'] for n in news]:
|
| 169 |
news.append({'title': title, 'link': href, 'img': '', 'source': 'BongDaPlus'})
|
| 170 |
except: pass
|
| 171 |
-
_set('wc_news', news[:30])
|
|
|
|
| 172 |
|
| 173 |
# ==================== ROAD TO WC ====================
|
| 174 |
def scrape_road_to_wc():
|
| 175 |
c = _cached('wc_road', 600)
|
| 176 |
if c is not None: return c
|
| 177 |
articles = []
|
| 178 |
-
for q in ['đường tới World Cup 2026', 'vòng loại World Cup 2026'
|
| 179 |
try:
|
| 180 |
html = _fetch(f'https://timkiem.vnexpress.net/?q={quote(q)}')
|
| 181 |
if not html: continue
|
|
@@ -190,16 +219,16 @@ def scrape_road_to_wc():
|
|
| 190 |
if title and href and href not in [x['link'] for x in articles]:
|
| 191 |
articles.append({'title': title, 'link': href, 'img': img, 'source': 'VnExpress', 'type': 'road'})
|
| 192 |
except: continue
|
| 193 |
-
_set('wc_road', articles[:20])
|
|
|
|
| 194 |
|
| 195 |
# ==================== ALL ====================
|
| 196 |
def get_wc2026_all():
|
| 197 |
c = _cached('wc_all', 90)
|
| 198 |
if c is not None: return c
|
| 199 |
data = {}
|
| 200 |
-
with ThreadPoolExecutor(
|
| 201 |
futs = {
|
| 202 |
-
ex.submit(scrape_summary): 'summary',
|
| 203 |
ex.submit(scrape_fixtures): 'fixtures',
|
| 204 |
ex.submit(scrape_standings): 'standings',
|
| 205 |
ex.submit(scrape_stats): 'stats',
|
|
@@ -209,5 +238,7 @@ def get_wc2026_all():
|
|
| 209 |
for f in as_completed(futs, timeout=18):
|
| 210 |
key = futs[f]
|
| 211 |
try: data[key] = f.result()
|
| 212 |
-
except: data[key] = {} if key in ('
|
| 213 |
-
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
World Cup 2026 Data Module
|
| 3 |
+
- BXH: bongda.com.vn API /api/league-table/home?tournament_id=24254 (WORKS - same as NHA/LaLiga)
|
| 4 |
+
- Lịch thi đấu: CANNOT use bongda.com.vn fixtures API (no tournament filter) → use 24h.com.vn
|
| 5 |
+
- Stats: bongda.com.vn player-performance endpoint
|
| 6 |
+
- News: VnExpress + TT&VH + BongDaPlus
|
| 7 |
"""
|
| 8 |
import requests, re, time, threading
|
| 9 |
from bs4 import BeautifulSoup
|
|
|
|
| 14 |
BONGDA_HEADERS = {
|
| 15 |
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
| 16 |
"Accept-Language": "vi-VN,vi;q=0.9",
|
| 17 |
+
"Referer": "https://bongda.com.vn/giai-dau/24254/standings/world-cup",
|
| 18 |
"X-Requested-With": "XMLHttpRequest"
|
| 19 |
}
|
| 20 |
UA = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'}
|
| 21 |
WC_ID = 24254
|
| 22 |
CACHE = {}
|
| 23 |
+
LOCK = threading.Lock()
|
| 24 |
|
| 25 |
def _clean(s): return re.sub(r'\s+', ' ', str(s or '')).strip()
|
| 26 |
def _cached(key, ttl=120):
|
| 27 |
+
with LOCK:
|
| 28 |
if key in CACHE and time.time() - CACHE[key]['t'] < ttl: return CACHE[key]['d']
|
| 29 |
return None
|
| 30 |
def _set(key, data):
|
| 31 |
+
with LOCK: CACHE[key] = {'t': time.time(), 'd': data}
|
| 32 |
|
| 33 |
def _bongda(endpoint):
|
| 34 |
+
"""Call bongda.com.vn internal AJAX API."""
|
| 35 |
try:
|
| 36 |
r = requests.get(f"https://bongda.com.vn{endpoint}", headers=BONGDA_HEADERS, timeout=12)
|
| 37 |
if r.status_code == 200:
|
|
|
|
| 47 |
return r.text if r.status_code == 200 else ''
|
| 48 |
except: return ''
|
| 49 |
|
| 50 |
+
# ==================== BXH (WORKS with bongda.com.vn) ====================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
def scrape_standings():
|
| 52 |
+
"""BXH World Cup 2026 - uses SAME method as NHA/La Liga livescore."""
|
| 53 |
+
c = _cached('wc_bxh', 180)
|
| 54 |
if c is not None: return c
|
| 55 |
+
# This is the PROVEN endpoint (same as LEAGUE_IDS in main.py)
|
| 56 |
html = _bongda(f"/api/league-table/home?tournament_id={WC_ID}&is_detail=True")
|
| 57 |
if not html:
|
| 58 |
html = _bongda(f"/api/league-table/home?tournament_id={WC_ID}")
|
| 59 |
+
r = {'html': html}
|
| 60 |
+
_set('wc_bxh', r)
|
| 61 |
+
return r
|
| 62 |
+
|
| 63 |
+
# ==================== LỊCH THI ĐẤU (24h.com.vn - server rendered) ====================
|
| 64 |
+
def scrape_fixtures():
|
| 65 |
+
"""Lịch thi đấu WC2026 from 24h.com.vn (server-rendered HTML, scrapeable)."""
|
| 66 |
+
c = _cached('wc_fix', 300)
|
| 67 |
+
if c is not None: return c
|
| 68 |
+
|
| 69 |
+
html_out = ''
|
| 70 |
+
try:
|
| 71 |
+
page = _fetch('https://www.24h.com.vn/bong-da/lich-thi-dau-bong-da-world-cup-2026-moi-nhat-c48a1747402.html')
|
| 72 |
+
if page:
|
| 73 |
+
soup = BeautifulSoup(page, 'lxml')
|
| 74 |
+
# 24h.com.vn renders schedule in tables
|
| 75 |
+
schedule = soup.select_one('.schedule-container, .cate-24h-foot-table-content, .tbl-schedule, table.tblData')
|
| 76 |
+
if schedule:
|
| 77 |
+
# Clean up and use directly
|
| 78 |
+
for s in schedule.select('script, style, .ads, .banner'):
|
| 79 |
+
s.decompose()
|
| 80 |
+
html_out = str(schedule)
|
| 81 |
+
else:
|
| 82 |
+
# Try all tables
|
| 83 |
+
tables = soup.select('table')
|
| 84 |
+
for t in tables:
|
| 85 |
+
if t.select('tr') and len(t.select('tr')) > 3:
|
| 86 |
+
text = t.get_text().lower()
|
| 87 |
+
if 'world cup' in text or 'bảng' in text or 'vs' in text:
|
| 88 |
+
html_out = str(t)
|
| 89 |
+
break
|
| 90 |
+
except: pass
|
| 91 |
+
|
| 92 |
+
# Fallback: build from bongdaplus
|
| 93 |
+
if not html_out:
|
| 94 |
+
try:
|
| 95 |
+
page = _fetch('https://bongdaplus.vn/world-cup/lich-thi-dau')
|
| 96 |
+
if page:
|
| 97 |
+
soup = BeautifulSoup(page, 'lxml')
|
| 98 |
+
content = soup.select_one('.schedule-content, .match-schedule, table, .list-match')
|
| 99 |
+
if content:
|
| 100 |
+
for s in content.select('script, style'): s.decompose()
|
| 101 |
+
html_out = str(content)
|
| 102 |
+
except: pass
|
| 103 |
+
|
| 104 |
+
r = {'html': html_out}
|
| 105 |
+
_set('wc_fix', r)
|
| 106 |
+
return r
|
| 107 |
|
| 108 |
+
# ==================== STATS ====================
|
| 109 |
def scrape_stats():
|
| 110 |
+
"""Thống kê WC2026."""
|
| 111 |
+
c = _cached('wc_stats', 600)
|
| 112 |
if c is not None: return c
|
| 113 |
+
# Try bongda API
|
| 114 |
html = _bongda(f"/api/event-standing/player-performance?tournament_id={WC_ID}")
|
| 115 |
+
r = {'html': html}
|
| 116 |
+
_set('wc_stats', r)
|
| 117 |
+
return r
|
| 118 |
|
| 119 |
+
# ==================== HISTORY ====================
|
| 120 |
def scrape_history():
|
| 121 |
+
c = _cached('wc_hist', 600)
|
| 122 |
if c is not None: return c
|
| 123 |
+
html = _bongda(f"/api/league-table/home?tournament_id={WC_ID}")
|
| 124 |
+
r = {'html': html}
|
| 125 |
+
_set('wc_hist', r)
|
| 126 |
+
return r
|
| 127 |
|
| 128 |
+
# ==================== MATCH DETAIL ====================
|
| 129 |
def scrape_h2h(event_id):
|
|
|
|
|
|
|
|
|
|
| 130 |
html = _bongda(f"/api/fixtures/head-to-head?event_id={event_id}")
|
| 131 |
+
return {'html': html}
|
| 132 |
|
|
|
|
| 133 |
def scrape_lineups(event_id):
|
|
|
|
|
|
|
|
|
|
| 134 |
html = _bongda(f"/api/fixtures/lineups?event_id={event_id}")
|
| 135 |
+
return {'html': html}
|
| 136 |
|
|
|
|
| 137 |
def scrape_match_detail(event_id):
|
|
|
|
|
|
|
|
|
|
| 138 |
html = _bongda(f"/api/fixtures/commentaries?event_id={event_id}")
|
| 139 |
+
return {'html': html}
|
| 140 |
|
| 141 |
# ==================== LIVE ====================
|
| 142 |
def scrape_summary():
|
| 143 |
+
c = _cached('wc_sum', 90)
|
| 144 |
if c is not None: return c
|
| 145 |
+
# BXH as summary (fixtures API doesn't support tournament filter)
|
| 146 |
+
html = _bongda(f"/api/league-table/home?tournament_id={WC_ID}&is_detail=True")
|
| 147 |
+
r = {'html': html}
|
| 148 |
+
_set('wc_sum', r)
|
| 149 |
+
return r
|
|
|
|
|
|
|
| 150 |
|
| 151 |
# ==================== NEWS ====================
|
| 152 |
def scrape_wc_news():
|
| 153 |
c = _cached('wc_news', 300)
|
| 154 |
if c is not None: return c
|
| 155 |
news = []
|
| 156 |
+
# TT&VH RSS
|
| 157 |
try:
|
| 158 |
html = _fetch('https://thethaovanhoa.vn/rss/world-cup-2026.rss')
|
| 159 |
if html:
|
|
|
|
| 169 |
if im: img = im.get('src', '')
|
| 170 |
if title and link: news.append({'title': title, 'link': link, 'img': img, 'source': 'TT&VH'})
|
| 171 |
except: pass
|
| 172 |
+
# VnExpress
|
| 173 |
try:
|
| 174 |
html = _fetch(f'https://timkiem.vnexpress.net/?q={quote("World Cup 2026")}')
|
| 175 |
if html:
|
|
|
|
| 184 |
if title and href and href not in [n['link'] for n in news]:
|
| 185 |
news.append({'title': title, 'link': href, 'img': img, 'source': 'VnExpress'})
|
| 186 |
except: pass
|
| 187 |
+
# BongDaPlus
|
| 188 |
try:
|
| 189 |
html = _fetch('https://bongdaplus.vn/world-cup')
|
| 190 |
if html:
|
|
|
|
| 196 |
if title and len(title) > 15 and href not in [n['link'] for n in news]:
|
| 197 |
news.append({'title': title, 'link': href, 'img': '', 'source': 'BongDaPlus'})
|
| 198 |
except: pass
|
| 199 |
+
_set('wc_news', news[:30])
|
| 200 |
+
return news[:30]
|
| 201 |
|
| 202 |
# ==================== ROAD TO WC ====================
|
| 203 |
def scrape_road_to_wc():
|
| 204 |
c = _cached('wc_road', 600)
|
| 205 |
if c is not None: return c
|
| 206 |
articles = []
|
| 207 |
+
for q in ['đường tới World Cup 2026', 'vòng loại World Cup 2026']:
|
| 208 |
try:
|
| 209 |
html = _fetch(f'https://timkiem.vnexpress.net/?q={quote(q)}')
|
| 210 |
if not html: continue
|
|
|
|
| 219 |
if title and href and href not in [x['link'] for x in articles]:
|
| 220 |
articles.append({'title': title, 'link': href, 'img': img, 'source': 'VnExpress', 'type': 'road'})
|
| 221 |
except: continue
|
| 222 |
+
_set('wc_road', articles[:20])
|
| 223 |
+
return articles[:20]
|
| 224 |
|
| 225 |
# ==================== ALL ====================
|
| 226 |
def get_wc2026_all():
|
| 227 |
c = _cached('wc_all', 90)
|
| 228 |
if c is not None: return c
|
| 229 |
data = {}
|
| 230 |
+
with ThreadPoolExecutor(5) as ex:
|
| 231 |
futs = {
|
|
|
|
| 232 |
ex.submit(scrape_fixtures): 'fixtures',
|
| 233 |
ex.submit(scrape_standings): 'standings',
|
| 234 |
ex.submit(scrape_stats): 'stats',
|
|
|
|
| 238 |
for f in as_completed(futs, timeout=18):
|
| 239 |
key = futs[f]
|
| 240 |
try: data[key] = f.result()
|
| 241 |
+
except: data[key] = {} if key in ('fixtures', 'standings', 'stats') else []
|
| 242 |
+
data['summary'] = data.get('standings', {})
|
| 243 |
+
_set('wc_all', data)
|
| 244 |
+
return data
|