Spaces:
Running
Running
Debug: add /api/wc2026/debug endpoint to check what tienphong.vn returns"
Browse files- wc2026_scraper.py +173 -72
wc2026_scraper.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
"""
|
| 2 |
World Cup 2026 Data Module
|
| 3 |
-
- Lịch thi đấu: Parse từ tienphong.vn →
|
| 4 |
- BXH: bongda.com.vn API tournament_id=24254
|
| 5 |
-
- News:
|
| 6 |
"""
|
| 7 |
import requests, re, time, threading
|
| 8 |
from bs4 import BeautifulSoup
|
|
@@ -45,122 +45,223 @@ def _fetch(url, timeout=15):
|
|
| 45 |
return r.text if r.status_code == 200 else ''
|
| 46 |
except: return ''
|
| 47 |
|
| 48 |
-
# ==================== LỊCH THI ĐẤU
|
| 49 |
def scrape_fixtures():
|
| 50 |
-
"""Parse lịch thi đấu WC2026 từ tienphong.vn
|
| 51 |
c = _cached('wc_fix', 3600)
|
| 52 |
if c is not None: return c
|
| 53 |
|
| 54 |
-
groups = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
-
page = _fetch('https://tienphong.vn/lich-thi-dau-world-cup-2026-moi-nhat-theo-gio-viet-nam-post1837627.tpo')
|
| 57 |
if page:
|
| 58 |
soup = BeautifulSoup(page, 'lxml')
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
if content:
|
| 61 |
-
#
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
text = _clean(el.get_text())
|
| 67 |
-
if re.search(r'bảng\s+[A-L]|group\s+[A-L]|vòng\s+
|
| 68 |
-
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
| 70 |
rows = el.select('tr')
|
| 71 |
if len(rows) < 2: continue
|
| 72 |
group_matches = []
|
| 73 |
-
for tr in rows
|
| 74 |
tds = tr.select('td')
|
| 75 |
-
if len(tds) <
|
| 76 |
cells = [_clean(td.get_text()) for td in tds]
|
| 77 |
-
|
| 78 |
-
# Or: Trận | Ngày | Giờ | Đội 1 vs Đội 2 | Sân
|
| 79 |
-
match = _parse_match_row(cells)
|
| 80 |
if match:
|
| 81 |
group_matches.append(match)
|
| 82 |
if group_matches:
|
| 83 |
-
groups.append({'group': current_group
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
# Fallback: parse from text patterns if no tables
|
| 87 |
if not groups:
|
| 88 |
-
|
| 89 |
-
|
|
|
|
| 90 |
current_matches = []
|
| 91 |
-
|
|
|
|
| 92 |
line = _clean(line)
|
| 93 |
-
if not line: continue
|
|
|
|
| 94 |
# Group header
|
| 95 |
-
if re.search(r'bảng\s+[A-L]|group\s+[A-L]|vòng\s+
|
| 96 |
-
if current_matches
|
| 97 |
groups.append({'group': current_group, 'matches': current_matches})
|
| 98 |
current_matches = []
|
| 99 |
current_group = line[:50]
|
| 100 |
continue
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
})
|
| 109 |
-
continue
|
| 110 |
-
# Simpler pattern: "Team1 vs Team2"
|
| 111 |
-
m2 = re.search(r'^(.{2,25})\s+(?:vs|VS|–)\s+(.{2,25})', line)
|
| 112 |
-
if m2:
|
| 113 |
-
tm = re.search(r'(\d{1,2}[h:]\d{2})', line)
|
| 114 |
-
dt = re.search(r'(\d{1,2}/\d{1,2})', line)
|
| 115 |
-
current_matches.append({
|
| 116 |
-
'time': tm.group(1) if tm else '', 'date': dt.group(1) if dt else '',
|
| 117 |
-
'home': _clean(m2.group(1)), 'away': _clean(m2.group(2)),
|
| 118 |
-
'venue': '', 'score': ''
|
| 119 |
-
})
|
| 120 |
if current_matches:
|
| 121 |
-
groups.append({'group': current_group
|
| 122 |
-
|
| 123 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
_set('wc_fix', r)
|
| 125 |
return r
|
| 126 |
|
| 127 |
-
def
|
| 128 |
-
"""
|
| 129 |
-
if len(cells) <
|
| 130 |
-
|
| 131 |
date = ''; time_str = ''; home = ''; away = ''; venue = ''; score = ''
|
| 132 |
|
| 133 |
for c in cells:
|
| 134 |
-
|
| 135 |
if re.match(r'^\d{1,2}/\d{1,2}(/\d{2,4})?$', c) and not date:
|
| 136 |
date = c; continue
|
| 137 |
-
# Time pattern
|
| 138 |
if re.match(r'^\d{1,2}[h:]\d{2}$', c) and not time_str:
|
| 139 |
time_str = c; continue
|
| 140 |
-
# Score pattern
|
| 141 |
if re.match(r'^\d+\s*[-–]\s*\d+$', c):
|
| 142 |
score = c; continue
|
| 143 |
-
|
| 144 |
-
if
|
| 145 |
-
|
| 146 |
-
if
|
|
|
|
|
|
|
| 147 |
venue = c; continue
|
| 148 |
-
|
| 149 |
-
if len(c) > 1 and not c.isdigit():
|
| 150 |
if not home: home = c
|
| 151 |
elif not away: away = c
|
| 152 |
|
| 153 |
-
# Handle "Team1 vs Team2" in single cell
|
| 154 |
-
if home and not away:
|
| 155 |
-
m = re.search(r'^(.+?)\s+(?:vs|VS|–|-|v)\s+(.+?)$', home)
|
| 156 |
-
if m:
|
| 157 |
-
home = _clean(m.group(1))
|
| 158 |
-
away = _clean(m.group(2))
|
| 159 |
-
|
| 160 |
if home and away:
|
| 161 |
return {'date': date, 'time': time_str, 'home': home, 'away': away, 'venue': venue, 'score': score}
|
| 162 |
return None
|
| 163 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
# ==================== BXH ====================
|
| 165 |
def scrape_standings():
|
| 166 |
c = _cached('wc_bxh', 180)
|
|
|
|
| 1 |
"""
|
| 2 |
World Cup 2026 Data Module
|
| 3 |
+
- Lịch thi đấu: Parse từ tienphong.vn → JSON → frontend render CSS
|
| 4 |
- BXH: bongda.com.vn API tournament_id=24254
|
| 5 |
+
- News: Thanh Niên + TT&VH + VnExpress
|
| 6 |
"""
|
| 7 |
import requests, re, time, threading
|
| 8 |
from bs4 import BeautifulSoup
|
|
|
|
| 45 |
return r.text if r.status_code == 200 else ''
|
| 46 |
except: return ''
|
| 47 |
|
| 48 |
+
# ==================== LỊCH THI ĐẤU ====================
|
| 49 |
def scrape_fixtures():
|
| 50 |
+
"""Parse lịch thi đấu WC2026 từ tienphong.vn."""
|
| 51 |
c = _cached('wc_fix', 3600)
|
| 52 |
if c is not None: return c
|
| 53 |
|
| 54 |
+
groups = []
|
| 55 |
+
debug_info = {'url': '', 'html_len': 0, 'tables_found': 0, 'text_matches': 0}
|
| 56 |
+
|
| 57 |
+
# Try tienphong.vn
|
| 58 |
+
url = 'https://tienphong.vn/lich-thi-dau-world-cup-2026-moi-nhat-theo-gio-viet-nam-post1837627.tpo'
|
| 59 |
+
debug_info['url'] = url
|
| 60 |
+
page = _fetch(url)
|
| 61 |
+
debug_info['html_len'] = len(page) if page else 0
|
| 62 |
|
|
|
|
| 63 |
if page:
|
| 64 |
soup = BeautifulSoup(page, 'lxml')
|
| 65 |
+
# Remove junk
|
| 66 |
+
for s in soup.select('script, style, nav, footer, .ads, .banner, .social-share, .related'):
|
| 67 |
+
s.decompose()
|
| 68 |
+
|
| 69 |
+
# Find article content
|
| 70 |
+
content = (
|
| 71 |
+
soup.select_one('.article__body') or
|
| 72 |
+
soup.select_one('.cms-body') or
|
| 73 |
+
soup.select_one('.article-content') or
|
| 74 |
+
soup.select_one('article') or
|
| 75 |
+
soup.select_one('.content-detail') or
|
| 76 |
+
soup.select_one('#main-detail') or
|
| 77 |
+
soup.select_one('.main-content')
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
if content:
|
| 81 |
+
# Method 1: Parse tables
|
| 82 |
+
tables = content.select('table')
|
| 83 |
+
debug_info['tables_found'] = len(tables)
|
| 84 |
+
|
| 85 |
+
current_group = 'Vòng bảng'
|
| 86 |
+
for el in content.descendants:
|
| 87 |
+
if not hasattr(el, 'name') or not el.name: continue
|
| 88 |
+
|
| 89 |
+
# Detect group headers from bold/strong/h tags
|
| 90 |
+
if el.name in ('h2', 'h3', 'h4', 'strong', 'b', 'p'):
|
| 91 |
text = _clean(el.get_text())
|
| 92 |
+
if re.search(r'bảng\s+[A-L]|group\s+[A-L]|vòng\s+1/16|vòng\s+16|tứ kết|bán kết|chung kết|vòng\s+bảng|ngày\s+\d', text, re.I):
|
| 93 |
+
if len(text) < 60:
|
| 94 |
+
current_group = text
|
| 95 |
+
|
| 96 |
+
# Parse table
|
| 97 |
+
if el.name == 'table':
|
| 98 |
rows = el.select('tr')
|
| 99 |
if len(rows) < 2: continue
|
| 100 |
group_matches = []
|
| 101 |
+
for tr in rows:
|
| 102 |
tds = tr.select('td')
|
| 103 |
+
if len(tds) < 2: continue
|
| 104 |
cells = [_clean(td.get_text()) for td in tds]
|
| 105 |
+
match = _try_parse_cells(cells)
|
|
|
|
|
|
|
| 106 |
if match:
|
| 107 |
group_matches.append(match)
|
| 108 |
if group_matches:
|
| 109 |
+
groups.append({'group': current_group, 'matches': group_matches})
|
| 110 |
+
|
| 111 |
+
# Method 2: Parse text if no tables found
|
|
|
|
| 112 |
if not groups:
|
| 113 |
+
text_content = content.get_text('\n')
|
| 114 |
+
lines = text_content.split('\n')
|
| 115 |
+
current_group = 'Lịch thi đấu'
|
| 116 |
current_matches = []
|
| 117 |
+
|
| 118 |
+
for line in lines:
|
| 119 |
line = _clean(line)
|
| 120 |
+
if not line or len(line) < 5: continue
|
| 121 |
+
|
| 122 |
# Group header
|
| 123 |
+
if re.search(r'bảng\s+[A-L]|group\s+[A-L]|vòng\s+1/16|tứ kết|bán kết|chung kết', line, re.I):
|
| 124 |
+
if current_matches:
|
| 125 |
groups.append({'group': current_group, 'matches': current_matches})
|
| 126 |
current_matches = []
|
| 127 |
current_group = line[:50]
|
| 128 |
continue
|
| 129 |
+
|
| 130 |
+
# Try parse match from line
|
| 131 |
+
match = _try_parse_line(line)
|
| 132 |
+
if match:
|
| 133 |
+
current_matches.append(match)
|
| 134 |
+
debug_info['text_matches'] += 1
|
| 135 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
if current_matches:
|
| 137 |
+
groups.append({'group': current_group, 'matches': current_matches})
|
| 138 |
+
|
| 139 |
+
# Fallback: try thethao247.vn
|
| 140 |
+
if not groups:
|
| 141 |
+
groups = _try_thethao247()
|
| 142 |
+
|
| 143 |
+
r = {'groups': groups, 'total_matches': sum(len(g['matches']) for g in groups), 'debug': debug_info}
|
| 144 |
_set('wc_fix', r)
|
| 145 |
return r
|
| 146 |
|
| 147 |
+
def _try_parse_cells(cells):
|
| 148 |
+
"""Try to parse table cells into a match."""
|
| 149 |
+
if len(cells) < 2: return None
|
|
|
|
| 150 |
date = ''; time_str = ''; home = ''; away = ''; venue = ''; score = ''
|
| 151 |
|
| 152 |
for c in cells:
|
| 153 |
+
if not c: continue
|
| 154 |
if re.match(r'^\d{1,2}/\d{1,2}(/\d{2,4})?$', c) and not date:
|
| 155 |
date = c; continue
|
|
|
|
| 156 |
if re.match(r'^\d{1,2}[h:]\d{2}$', c) and not time_str:
|
| 157 |
time_str = c; continue
|
|
|
|
| 158 |
if re.match(r'^\d+\s*[-–]\s*\d+$', c):
|
| 159 |
score = c; continue
|
| 160 |
+
if c.lower() in ('vs', '-', '–', 'v', 'ft'): continue
|
| 161 |
+
# Check if cell contains "vs" pattern
|
| 162 |
+
vm = re.search(r'^(.+?)\s+(?:vs|VS|–|-)\s+(.+?)$', c)
|
| 163 |
+
if vm:
|
| 164 |
+
home = _clean(vm.group(1)); away = _clean(vm.group(2)); continue
|
| 165 |
+
if any(w in c.lower() for w in ['stadium', 'sân', 'arena', 'park', 'metlife', 'sofi', 'hard rock']):
|
| 166 |
venue = c; continue
|
| 167 |
+
if len(c) > 2 and not c.isdigit() and len(c) < 30:
|
|
|
|
| 168 |
if not home: home = c
|
| 169 |
elif not away: away = c
|
| 170 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
if home and away:
|
| 172 |
return {'date': date, 'time': time_str, 'home': home, 'away': away, 'venue': venue, 'score': score}
|
| 173 |
return None
|
| 174 |
|
| 175 |
+
def _try_parse_line(line):
|
| 176 |
+
"""Try to parse a text line into a match."""
|
| 177 |
+
# Pattern: "21h00 ngày 12/6: Mexico vs Honduras (SoFi Stadium)"
|
| 178 |
+
m = re.search(r'(\d{1,2}[h:]\d{2})\s*(?:ngày\s*)?(\d{1,2}/\d{1,2}(?:/\d{2,4})?)?[:\s\-–]*(.+?)\s+(?:vs|VS|–|-|v\.?s\.?)\s+(.+?)(?:\s*[\(\[](.+?)[\)\]])?$', line)
|
| 179 |
+
if m:
|
| 180 |
+
return {
|
| 181 |
+
'time': _clean(m.group(1)), 'date': _clean(m.group(2) or ''),
|
| 182 |
+
'home': _clean(m.group(3)), 'away': _clean(m.group(4)),
|
| 183 |
+
'venue': _clean(m.group(5) or ''), 'score': ''
|
| 184 |
+
}
|
| 185 |
+
# Pattern: "Mexico - Honduras 21h 12/6"
|
| 186 |
+
m2 = re.search(r'^([A-ZÀ-Ỹ][a-zà-ỹA-ZÀ-Ỹ\s\.]+?)\s+(?:vs|VS|–|-)\s+([A-ZÀ-Ỹ][a-zà-ỹA-ZÀ-Ỹ\s\.]+)', line)
|
| 187 |
+
if m2:
|
| 188 |
+
tm = re.search(r'(\d{1,2}[h:]\d{2})', line)
|
| 189 |
+
dt = re.search(r'(\d{1,2}/\d{1,2})', line)
|
| 190 |
+
return {
|
| 191 |
+
'time': tm.group(1) if tm else '', 'date': dt.group(1) if dt else '',
|
| 192 |
+
'home': _clean(m2.group(1)), 'away': _clean(m2.group(2)),
|
| 193 |
+
'venue': '', 'score': ''
|
| 194 |
+
}
|
| 195 |
+
return None
|
| 196 |
+
|
| 197 |
+
def _try_thethao247():
|
| 198 |
+
"""Fallback: parse from thethao247.vn"""
|
| 199 |
+
groups = []
|
| 200 |
+
page = _fetch('https://thethao247.vn/world-cup/426-lich-thi-dau-world-cup-2026-d399919.html')
|
| 201 |
+
if not page: return groups
|
| 202 |
+
soup = BeautifulSoup(page, 'lxml')
|
| 203 |
+
content = soup.select_one('article, .detail-content, .content-detail, main')
|
| 204 |
+
if not content: return groups
|
| 205 |
+
|
| 206 |
+
current_group = 'Lịch thi đấu'
|
| 207 |
+
current_matches = []
|
| 208 |
+
for el in content.find_all(['table', 'h2', 'h3', 'h4', 'strong']):
|
| 209 |
+
if el.name in ('h2', 'h3', 'h4', 'strong'):
|
| 210 |
+
text = _clean(el.get_text())
|
| 211 |
+
if re.search(r'bảng|group|vòng|tứ kết|bán kết|chung kết', text, re.I) and len(text) < 60:
|
| 212 |
+
if current_matches:
|
| 213 |
+
groups.append({'group': current_group, 'matches': current_matches})
|
| 214 |
+
current_matches = []
|
| 215 |
+
current_group = text
|
| 216 |
+
elif el.name == 'table':
|
| 217 |
+
for tr in el.select('tr')[1:]:
|
| 218 |
+
tds = tr.select('td')
|
| 219 |
+
if len(tds) >= 2:
|
| 220 |
+
cells = [_clean(td.get_text()) for td in tds]
|
| 221 |
+
match = _try_parse_cells(cells)
|
| 222 |
+
if match: current_matches.append(match)
|
| 223 |
+
if current_matches:
|
| 224 |
+
groups.append({'group': current_group, 'matches': current_matches})
|
| 225 |
+
return groups
|
| 226 |
+
|
| 227 |
+
def debug_fixtures():
|
| 228 |
+
"""Debug endpoint to see raw data."""
|
| 229 |
+
page = _fetch('https://tienphong.vn/lich-thi-dau-world-cup-2026-moi-nhat-theo-gio-viet-nam-post1837627.tpo')
|
| 230 |
+
if not page:
|
| 231 |
+
return {'error': 'Cannot fetch tienphong.vn', 'page_len': 0}
|
| 232 |
+
|
| 233 |
+
soup = BeautifulSoup(page, 'lxml')
|
| 234 |
+
for s in soup.select('script, style'): s.decompose()
|
| 235 |
+
|
| 236 |
+
content = (
|
| 237 |
+
soup.select_one('.article__body') or soup.select_one('.cms-body') or
|
| 238 |
+
soup.select_one('article') or soup.select_one('.content-detail')
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
info = {
|
| 242 |
+
'page_len': len(page),
|
| 243 |
+
'content_found': bool(content),
|
| 244 |
+
'content_selector': '',
|
| 245 |
+
'tables': 0,
|
| 246 |
+
'images': 0,
|
| 247 |
+
'text_preview': '',
|
| 248 |
+
'first_500_chars': ''
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
if content:
|
| 252 |
+
info['content_selector'] = content.name + '.' + '.'.join(content.get('class', []))
|
| 253 |
+
info['tables'] = len(content.select('table'))
|
| 254 |
+
info['images'] = len(content.select('img'))
|
| 255 |
+
info['text_preview'] = content.get_text()[:2000]
|
| 256 |
+
info['first_500_chars'] = str(content)[:500]
|
| 257 |
+
else:
|
| 258 |
+
# Try body
|
| 259 |
+
body = soup.find('body')
|
| 260 |
+
if body:
|
| 261 |
+
info['text_preview'] = body.get_text()[:2000]
|
| 262 |
+
|
| 263 |
+
return info
|
| 264 |
+
|
| 265 |
# ==================== BXH ====================
|
| 266 |
def scrape_standings():
|
| 267 |
c = _cached('wc_bxh', 180)
|