Spaces:
Running
Running
Fix fixtures download headers + add github fallback + fix news image extraction"
Browse files- wc2026_scraper.py +80 -64
wc2026_scraper.py
CHANGED
|
@@ -1,9 +1,8 @@
|
|
| 1 |
"""
|
| 2 |
-
World Cup 2026 Data
|
| 3 |
-
- Fixtures: fixturedownload.com JSON (
|
| 4 |
-
- BXH: bongda.com.vn API
|
| 5 |
-
- News:
|
| 6 |
-
- Livescore: when match is live, score updates from bongda API
|
| 7 |
"""
|
| 8 |
import requests, re, time, threading
|
| 9 |
from bs4 import BeautifulSoup
|
|
@@ -18,7 +17,7 @@ BONGDA_HEADERS = {
|
|
| 18 |
"Referer": "https://bongda.com.vn/giai-dau/24254/standings/world-cup",
|
| 19 |
"X-Requested-With": "XMLHttpRequest"
|
| 20 |
}
|
| 21 |
-
UA = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'}
|
| 22 |
WC_ID = 24254
|
| 23 |
VN_TZ = timezone(timedelta(hours=7))
|
| 24 |
CACHE = {}
|
|
@@ -49,47 +48,59 @@ def _fetch(url, timeout=15):
|
|
| 49 |
except: return ''
|
| 50 |
|
| 51 |
def _utc_to_vn(date_str):
|
| 52 |
-
"""Convert UTC datetime string to Vietnam time string."""
|
| 53 |
try:
|
| 54 |
-
if
|
| 55 |
-
|
|
|
|
| 56 |
if dt.tzinfo is None:
|
| 57 |
dt = dt.replace(tzinfo=timezone.utc)
|
| 58 |
vn = dt.astimezone(VN_TZ)
|
| 59 |
return vn.strftime('%H:%M %d/%m/%Y')
|
| 60 |
-
return date_str
|
| 61 |
except:
|
| 62 |
-
return date_str
|
| 63 |
|
| 64 |
-
# ==================== FIXTURES
|
| 65 |
def scrape_fixtures():
|
| 66 |
-
|
| 67 |
-
c = _cached('wc_fix', 3600) # 1h cache - data doesn't change often
|
| 68 |
if c is not None: return c
|
| 69 |
|
| 70 |
matches = []
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
'date_vn': _utc_to_vn(m.get('DateUtc', '')),
|
| 80 |
-
'date_utc': m.get('DateUtc', ''),
|
| 81 |
-
'location': m.get('Location', ''),
|
| 82 |
-
'home': m.get('HomeTeam', ''),
|
| 83 |
-
'away': m.get('AwayTeam', ''),
|
| 84 |
-
'group': m.get('Group', ''),
|
| 85 |
-
'home_score': m.get('HomeTeamScore'),
|
| 86 |
-
'away_score': m.get('AwayTeamScore'),
|
| 87 |
-
})
|
| 88 |
-
except: pass
|
| 89 |
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
|
| 94 |
# ==================== BXH ====================
|
| 95 |
def scrape_standings():
|
|
@@ -118,13 +129,34 @@ def scrape_lineups(event_id): return {'html': _bongda(f"/api/fixtures/lineups?ev
|
|
| 118 |
def scrape_match_detail(event_id): return {'html': _bongda(f"/api/fixtures/commentaries?event_id={event_id}")}
|
| 119 |
def scrape_summary(): return scrape_standings()
|
| 120 |
|
| 121 |
-
# ==================== NEWS
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
def scrape_wc_news():
|
| 123 |
-
"""Get WC2026 news WITH images from multiple sources."""
|
| 124 |
c = _cached('wc_news', 300)
|
| 125 |
if c is not None: return c
|
| 126 |
news = []
|
| 127 |
-
# VnExpress
|
| 128 |
try:
|
| 129 |
html = _fetch(f'https://timkiem.vnexpress.net/?q={quote("World Cup 2026")}')
|
| 130 |
if html:
|
|
@@ -134,42 +166,28 @@ def scrape_wc_news():
|
|
| 134 |
if not a: continue
|
| 135 |
title = _clean(a.get('title', '') or a.get_text())
|
| 136 |
href = a.get('href', '')
|
| 137 |
-
|
| 138 |
-
img = ''
|
| 139 |
-
img_el = art.select_one('img')
|
| 140 |
-
if img_el:
|
| 141 |
-
img = img_el.get('data-src', '') or img_el.get('src', '')
|
| 142 |
-
if not img:
|
| 143 |
-
source_el = art.select_one('source')
|
| 144 |
-
if source_el:
|
| 145 |
-
srcset = source_el.get('srcset', '')
|
| 146 |
-
if srcset: img = srcset.split(',')[0].strip().split(' ')[0]
|
| 147 |
-
if not img:
|
| 148 |
-
# Try picture element
|
| 149 |
-
pic = art.select_one('picture img, .thumb-art img')
|
| 150 |
-
if pic: img = pic.get('data-src', '') or pic.get('src', '')
|
| 151 |
if title and href:
|
| 152 |
news.append({'title': title, 'link': href, 'img': img, 'source': 'VnExpress'})
|
| 153 |
except: pass
|
| 154 |
-
# Thanh Niên
|
| 155 |
try:
|
| 156 |
html = _fetch('https://worldcup2026.thanhnien.vn/')
|
| 157 |
if html:
|
| 158 |
soup = BeautifulSoup(html, 'lxml')
|
| 159 |
-
for item in soup.select('.story, .news-item, article, .item')[:12]:
|
| 160 |
a = item.select_one('a[href]')
|
| 161 |
if not a: continue
|
| 162 |
title = _clean(a.get('title', '') or a.get_text())
|
| 163 |
if not title or len(title) < 15: continue
|
| 164 |
href = a.get('href', '')
|
| 165 |
if not href.startswith('http'): href = 'https://worldcup2026.thanhnien.vn' + href
|
| 166 |
-
|
| 167 |
-
img = (img_el.get('data-src', '') or img_el.get('src', '')) if img_el else ''
|
| 168 |
if href not in [n['link'] for n in news]:
|
| 169 |
news.append({'title': title, 'link': href, 'img': img, 'source': 'Thanh Niên'})
|
| 170 |
if len(news) >= 20: break
|
| 171 |
except: pass
|
| 172 |
-
# Dân Trí
|
| 173 |
try:
|
| 174 |
html = _fetch('https://dantri.com.vn/the-thao/world-cup.htm')
|
| 175 |
if html:
|
|
@@ -180,8 +198,10 @@ def scrape_wc_news():
|
|
| 180 |
title = _clean(a.get('title', '') or a.get_text())
|
| 181 |
href = a.get('href', '')
|
| 182 |
if not href.startswith('http'): href = 'https://dantri.com.vn' + href
|
| 183 |
-
|
| 184 |
-
|
|
|
|
|
|
|
| 185 |
if title and len(title) > 15 and href not in [n['link'] for n in news]:
|
| 186 |
news.append({'title': title, 'link': href, 'img': img, 'source': 'Dân Trí'})
|
| 187 |
except: pass
|
|
@@ -202,11 +222,7 @@ def scrape_road_to_wc():
|
|
| 202 |
if not a: continue
|
| 203 |
title = _clean(a.get('title', '') or a.get_text())
|
| 204 |
href = a.get('href', '')
|
| 205 |
-
|
| 206 |
-
img = (img_el.get('data-src', '') or img_el.get('src', '')) if img_el else ''
|
| 207 |
-
if not img:
|
| 208 |
-
source_el = art.select_one('source')
|
| 209 |
-
if source_el: img = source_el.get('srcset', '').split(',')[0].strip().split(' ')[0]
|
| 210 |
if title and href and href not in [x['link'] for x in articles]:
|
| 211 |
articles.append({'title': title, 'link': href, 'img': img, 'source': 'VnExpress', 'type': 'road'})
|
| 212 |
except: continue
|
|
|
|
| 1 |
"""
|
| 2 |
+
World Cup 2026 Data
|
| 3 |
+
- Fixtures: fixturedownload.com JSON (with Referer header) + GitHub fallback
|
| 4 |
+
- BXH: bongda.com.vn API
|
| 5 |
+
- News: VnExpress + Thanh Niên + Dân Trí (with proper image extraction)
|
|
|
|
| 6 |
"""
|
| 7 |
import requests, re, time, threading
|
| 8 |
from bs4 import BeautifulSoup
|
|
|
|
| 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 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36'}
|
| 21 |
WC_ID = 24254
|
| 22 |
VN_TZ = timezone(timedelta(hours=7))
|
| 23 |
CACHE = {}
|
|
|
|
| 48 |
except: return ''
|
| 49 |
|
| 50 |
def _utc_to_vn(date_str):
|
|
|
|
| 51 |
try:
|
| 52 |
+
if not date_str: return ''
|
| 53 |
+
if 'T' in str(date_str):
|
| 54 |
+
dt = datetime.fromisoformat(str(date_str).replace('Z', '+00:00'))
|
| 55 |
if dt.tzinfo is None:
|
| 56 |
dt = dt.replace(tzinfo=timezone.utc)
|
| 57 |
vn = dt.astimezone(VN_TZ)
|
| 58 |
return vn.strftime('%H:%M %d/%m/%Y')
|
| 59 |
+
return str(date_str)
|
| 60 |
except:
|
| 61 |
+
return str(date_str)
|
| 62 |
|
| 63 |
+
# ==================== FIXTURES ====================
|
| 64 |
def scrape_fixtures():
|
| 65 |
+
c = _cached('wc_fix', 3600)
|
|
|
|
| 66 |
if c is not None: return c
|
| 67 |
|
| 68 |
matches = []
|
| 69 |
+
urls = [
|
| 70 |
+
('https://fixturedownload.com/download/json/fifa-world-cup-2026', {
|
| 71 |
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
| 72 |
+
'Accept': 'application/json, text/plain, */*',
|
| 73 |
+
'Referer': 'https://fixturedownload.com/view/json/fifa-world-cup-2026'
|
| 74 |
+
}),
|
| 75 |
+
('https://raw.githubusercontent.com/mjwebmaster/world-cup-2026-schedule-data/main/world-cup-2026.json', UA),
|
| 76 |
+
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
|
| 78 |
+
for url, headers in urls:
|
| 79 |
+
if matches: break
|
| 80 |
+
try:
|
| 81 |
+
r = requests.get(url, headers=headers, timeout=20)
|
| 82 |
+
if r.status_code == 200:
|
| 83 |
+
data = r.json()
|
| 84 |
+
if isinstance(data, list) and len(data) > 10:
|
| 85 |
+
for m in data:
|
| 86 |
+
date_raw = m.get('DateUtc') or m.get('date_utc') or m.get('date') or ''
|
| 87 |
+
matches.append({
|
| 88 |
+
'match_number': m.get('MatchNumber') or m.get('match_number') or '',
|
| 89 |
+
'round': m.get('RoundNumber') or m.get('round') or '',
|
| 90 |
+
'date_vn': _utc_to_vn(date_raw),
|
| 91 |
+
'date_utc': date_raw,
|
| 92 |
+
'location': m.get('Location') or m.get('location') or m.get('venue') or '',
|
| 93 |
+
'home': m.get('HomeTeam') or m.get('home_team') or m.get('home') or '',
|
| 94 |
+
'away': m.get('AwayTeam') or m.get('away_team') or m.get('away') or '',
|
| 95 |
+
'group': m.get('Group') or m.get('group') or m.get('stage') or '',
|
| 96 |
+
'home_score': m.get('HomeTeamScore') or m.get('home_score'),
|
| 97 |
+
'away_score': m.get('AwayTeamScore') or m.get('away_score'),
|
| 98 |
+
})
|
| 99 |
+
except: continue
|
| 100 |
+
|
| 101 |
+
result = {'matches': matches, 'total': len(matches)}
|
| 102 |
+
_set('wc_fix', result)
|
| 103 |
+
return result
|
| 104 |
|
| 105 |
# ==================== BXH ====================
|
| 106 |
def scrape_standings():
|
|
|
|
| 129 |
def scrape_match_detail(event_id): return {'html': _bongda(f"/api/fixtures/commentaries?event_id={event_id}")}
|
| 130 |
def scrape_summary(): return scrape_standings()
|
| 131 |
|
| 132 |
+
# ==================== NEWS ====================
|
| 133 |
+
def _get_img_from_article(art):
|
| 134 |
+
"""Extract image URL from a VnExpress/generic article element."""
|
| 135 |
+
# Try img tag
|
| 136 |
+
for img in art.select('img'):
|
| 137 |
+
src = img.get('data-src') or img.get('data-original') or img.get('src') or ''
|
| 138 |
+
if src and 'blank' not in src and 'data:image' not in src and len(src) > 20:
|
| 139 |
+
if src.startswith('//'): src = 'https:' + src
|
| 140 |
+
return src
|
| 141 |
+
# Try source srcset (VnExpress picture element)
|
| 142 |
+
for source in art.select('source[srcset]'):
|
| 143 |
+
srcset = source.get('srcset', '')
|
| 144 |
+
if srcset:
|
| 145 |
+
first = srcset.split(',')[0].strip().split(' ')[0]
|
| 146 |
+
if first and len(first) > 20:
|
| 147 |
+
if first.startswith('//'): first = 'https:' + first
|
| 148 |
+
return first
|
| 149 |
+
# Try background-image in style
|
| 150 |
+
for el in art.select('[style*="background"]'):
|
| 151 |
+
m = re.search(r'url\(["\']?([^"\')\s]+)', el.get('style', ''))
|
| 152 |
+
if m: return m.group(1)
|
| 153 |
+
return ''
|
| 154 |
+
|
| 155 |
def scrape_wc_news():
|
|
|
|
| 156 |
c = _cached('wc_news', 300)
|
| 157 |
if c is not None: return c
|
| 158 |
news = []
|
| 159 |
+
# VnExpress
|
| 160 |
try:
|
| 161 |
html = _fetch(f'https://timkiem.vnexpress.net/?q={quote("World Cup 2026")}')
|
| 162 |
if html:
|
|
|
|
| 166 |
if not a: continue
|
| 167 |
title = _clean(a.get('title', '') or a.get_text())
|
| 168 |
href = a.get('href', '')
|
| 169 |
+
img = _get_img_from_article(art)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
if title and href:
|
| 171 |
news.append({'title': title, 'link': href, 'img': img, 'source': 'VnExpress'})
|
| 172 |
except: pass
|
| 173 |
+
# Thanh Niên
|
| 174 |
try:
|
| 175 |
html = _fetch('https://worldcup2026.thanhnien.vn/')
|
| 176 |
if html:
|
| 177 |
soup = BeautifulSoup(html, 'lxml')
|
| 178 |
+
for item in soup.select('.story, .news-item, article, .item, .zone--timeline .story')[:12]:
|
| 179 |
a = item.select_one('a[href]')
|
| 180 |
if not a: continue
|
| 181 |
title = _clean(a.get('title', '') or a.get_text())
|
| 182 |
if not title or len(title) < 15: continue
|
| 183 |
href = a.get('href', '')
|
| 184 |
if not href.startswith('http'): href = 'https://worldcup2026.thanhnien.vn' + href
|
| 185 |
+
img = _get_img_from_article(item)
|
|
|
|
| 186 |
if href not in [n['link'] for n in news]:
|
| 187 |
news.append({'title': title, 'link': href, 'img': img, 'source': 'Thanh Niên'})
|
| 188 |
if len(news) >= 20: break
|
| 189 |
except: pass
|
| 190 |
+
# Dân Trí
|
| 191 |
try:
|
| 192 |
html = _fetch('https://dantri.com.vn/the-thao/world-cup.htm')
|
| 193 |
if html:
|
|
|
|
| 198 |
title = _clean(a.get('title', '') or a.get_text())
|
| 199 |
href = a.get('href', '')
|
| 200 |
if not href.startswith('http'): href = 'https://dantri.com.vn' + href
|
| 201 |
+
img = _get_img_from_article(art)
|
| 202 |
+
# Proxy dantri images
|
| 203 |
+
if img and 'cdnphoto.dantri' in img:
|
| 204 |
+
img = '/api/proxy/img?url=' + quote(img, safe='')
|
| 205 |
if title and len(title) > 15 and href not in [n['link'] for n in news]:
|
| 206 |
news.append({'title': title, 'link': href, 'img': img, 'source': 'Dân Trí'})
|
| 207 |
except: pass
|
|
|
|
| 222 |
if not a: continue
|
| 223 |
title = _clean(a.get('title', '') or a.get_text())
|
| 224 |
href = a.get('href', '')
|
| 225 |
+
img = _get_img_from_article(art)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
if title and href and href not in [x['link'] for x in articles]:
|
| 227 |
articles.append({'title': title, 'link': href, 'img': img, 'source': 'VnExpress', 'type': 'road'})
|
| 228 |
except: continue
|