bep40 commited on
Commit
abe2250
·
verified ·
1 Parent(s): 0efe1a3

Upload match_detail_v2.py

Browse files
Files changed (1) hide show
  1. match_detail_v2.py +525 -291
match_detail_v2.py CHANGED
@@ -1,309 +1,543 @@
 
 
 
1
  """
2
- Match Detail Scraper for bongda.com.vn
3
- """
4
- import requests, re, json, time, threading
5
  from bs4 import BeautifulSoup
6
 
7
- def _sp(html):
8
- try:
9
- return BeautifulSoup(html, 'lxml')
10
- except:
11
- return BeautifulSoup(html, 'html.parser')
12
-
13
- BH = {
14
- "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",
15
- "Accept": "application/json, text/javascript, */*; q=0.01",
16
- "Referer": "https://bongda.com.vn/",
17
- "X-Requested-With": "XMLHttpRequest",
18
- }
19
- HH = {
20
  "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
  "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
 
22
  "Referer": "https://bongda.com.vn/",
23
  }
24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  def _cl(s):
26
  return re.sub(r'\s+', ' ', str(s or '')).strip()
27
 
28
- def _api(ep, params=None):
29
- try:
30
- url = f"https://bongda.com.vn{ep}"
31
- if params:
32
- url += "?" + "&".join(f"{k}={v}" for k, v in params.items())
33
- r = requests.get(url, headers=BH, timeout=15)
34
- if r.status_code == 200:
35
- try: return r.json()
36
- except: pass
37
- except: pass
38
- return None
39
-
40
- def _get_teams(soup):
41
- info = {}
42
- tel = soup.select_one('.teams')
43
- if not tel:
44
- return info
45
- he = tel.select_one('.team.home, .home-team')
46
- if he:
47
- ne = he.select_one('p:not(.logo)') or he.find('p')
48
- if ne: info['home_team'] = _cl(ne.get_text())
49
- lo = he.select_one('img')
50
- if lo: info['home_logo'] = lo.get('src', '')
51
- le = he if he.name == 'a' else he.find('a')
52
- if le and le.get('href'):
53
- m = re.search(r'/doi-bong/(\d+)/', le['href'])
54
- if m: info['home_team_id'] = m.group(1)
55
- ae = tel.select_one('.team.away, .away-team')
56
- if ae:
57
- ne = ae.select_one('p:not(.logo)') or ae.find('p')
58
- if ne: info['away_team'] = _cl(ne.get_text())
59
- lo = ae.select_one('img')
60
- if lo: info['away_logo'] = lo.get('src', '')
61
- le = ae if ae.name == 'a' else ae.find('a')
62
- if le and le.get('href'):
63
- m = re.search(r'/doi-bong/(\d+)/', le['href'])
64
- if m: info['away_team_id'] = m.group(1)
65
- sc = tel.select_one('.score')
66
- if sc:
67
- parts = [_cl(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'] = _cl(lb.get_text())
71
- return info
72
-
73
- def _get_timeline(soup):
74
- tl = []
75
- el = soup.select_one('.timeline')
76
- if not el: return tl
77
- half = ''
78
- for c in el.children:
79
- if not hasattr(c, 'name') or not c.name: continue
80
- t = _cl(c.get_text())
81
- if not t: continue
82
- if t in ['H1','H2','Hiệp 1','Hiệp 2']:
83
- half = t; continue
84
- m = re.match(r"(\d+'\+?\d*)", t)
85
- if m:
86
- tl.append({'time': m.group(1), 'text': t[m.end():].strip(), 'half': half})
87
- elif len(t) > 5:
88
- tl.append({'time': '', 'text': t, 'half': half})
89
- return tl
90
-
91
- def _get_events(soup):
92
- evts = []
93
- for el in soup.select('.event'):
94
- e = {}
95
- cl = ' '.join(el.get('class', []))
96
- e['team'] = 'home' if 'home' in cl else ('away' if 'away' in cl else '')
97
- ps = [_cl(p.get_text()) for p in el.select('p')]
98
- ps = [p for p in ps if p]
99
- if ps: e['players'] = ps
100
- tl = el.select_one('.time, .minute, span')
101
- if tl: e['time'] = _cl(tl.get_text())
102
- evts.append(e)
103
- return evts
104
-
105
- def _get_stats(soup):
106
- st = {}
107
- for sel in ['.match-stats','[class*="stats"]']:
108
- el = soup.select_one(sel)
109
- if el and len(str(el)) > 50:
110
- for row in el.select('li,tr,.stat-row'):
111
- cells = row.select('td,span,p')
112
- if len(cells) >= 3:
113
- lb = _cl(cells[0].get_text())
114
- if lb: st[lb] = {'home': _cl(cells[1].get_text()), 'away': _cl(cells[2].get_text())}
115
- if st: break
116
- return st
117
-
118
- def _get_h2h(soup):
119
- h2h = {'matches': [], 'stats': {}}
120
- for sel in ['.head-to-head','[class*="h2h"]']:
121
- el = soup.select_one(sel)
122
- if el and len(str(el)) > 50:
123
- for it in el.select('li,tr,.match-item'):
124
- m = {}
125
- cells = it.select('td,span,p')
126
- if len(cells) >= 3:
127
- m['date'] = _cl(cells[0].get_text())
128
- m['home'] = _cl(cells[1].get_text())
129
- m['score'] = _cl(cells[2].get_text())
130
- if m.get('home'):
131
- if len(cells) > 3: m['away'] = _cl(cells[3].get_text())
132
- h2h['matches'].append(m)
133
- if h2h['matches']: break
134
- return h2h
135
-
136
- def _get_form(soup):
137
- f = {'home': [], 'away': []}
138
- for sel in ['.form-guide','[class*="form"]']:
139
- el = soup.select_one(sel)
140
- if el and len(str(el)) > 50:
141
- items = el.select('li,.form-item,tr')
142
- for it in items[:10]:
143
- t = _cl(it.get_text())
144
- if t: f['home'].append({'text': t})
145
- for it in items[10:20]:
146
- t = _cl(it.get_text())
147
- if t: f['away'].append({'text': t})
148
- break
149
- return f
150
-
151
- def _get_info(soup):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  info = {}
153
- mi = soup.select_one('.match-info')
154
- if mi:
155
- te = mi.select_one('.times,li')
156
- if te: info['datetime'] = _cl(te.get_text())
157
- le = soup.select_one('.league,.tournament,[class*="league"]')
158
- if le: info['league'] = _cl(le.get_text())
159
- return info
160
-
161
- def _scrape(url):
162
- print(f"[DEBUG] _scrape: {url[:80]}", flush=True)
163
- try:
164
- r = requests.get(url, headers=HH, timeout=15, allow_redirects=True)
165
- print(f"[DEBUG] HTTP={r.status_code}", flush=True)
166
- if r.status_code != 200:
167
- return False, {}
168
- sp = _sp(r.text)
169
- d = {}
170
-
171
- teams = _get_teams(sp)
172
- print(f"[DEBUG] teams={teams}", flush=True)
173
- if teams: d['info'] = teams
174
-
175
- mi = _get_info(sp)
176
- if mi:
177
- d.setdefault('info', {}).update(mi)
178
-
179
- tl = _get_timeline(sp)
180
- if tl:
181
- d['timeline'] = tl
182
- d['commentaries_html'] = '\n'.join([f"{t.get('time','')} {t.get('text','')}" for t in tl])
183
-
184
- ev = _get_events(sp)
185
- if ev: d['events'] = ev
186
 
187
- st = _get_stats(sp)
188
- if st:
189
- d['stats_parsed'] = st
190
- d['stats_html'] = str(st)
 
 
 
 
191
 
192
- h2h = _get_h2h(sp)
193
- if h2h.get('matches'): d['h2h_matches'] = h2h['matches']
194
- if h2h.get('stats'): d['h2h_stats'] = h2h['stats']
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
 
196
- if '/preview/' in url:
197
- fm = _get_form(sp)
198
- if fm.get('home'): d['home_form'] = fm['home']
199
- if fm.get('away'): d['away_form'] = fm['away']
 
 
 
 
200
 
201
- print(f"[DEBUG] success keys={list(d.keys())}", flush=True)
202
- return True, d
203
- except Exception as e:
204
- import traceback
205
- print(f"[DEBUG] error: {e}", flush=True)
206
- traceback.print_exc()
207
- return False, {}
208
-
209
- def fetch_match_detail_by_url(url):
210
- m = re.search(r'/tran-dau/(\d+)/', url)
211
- if not m: return {"error": "Could not extract event_id", "found": False}
212
- event_id = int(m.group(1))
213
- res = {"event_id": event_id, "found": False, "sections": []}
214
- _fetch_api(event_id, res)
215
- ok, d = _scrape(url)
216
- print(f"[DEBUG] by_url: ok={ok} d_keys={list(d.keys())}", flush=True)
217
- if ok: _merge(res, d)
218
- return res
219
-
220
- def fetch_match_detail(event_id):
221
- print(f"[DEBUG] fetch_match_detail({event_id})", flush=True)
222
- res = {"event_id": event_id, "found": False, "sections": []}
223
- _fetch_api(event_id, res)
224
-
225
- for pt in ["centre", "preview"]:
226
- url = f"https://bongda.com.vn/tran-dau/{event_id}/{pt}/"
227
- ok, d = _scrape(url)
228
- print(f"[DEBUG] {pt}: ok={ok}", flush=True)
229
- if ok:
230
- _merge(res, d)
231
- if res.get("found"): break
232
-
233
- print(f"[DEBUG] final: found={res['found']} sections={res['sections']}", flush=True)
234
- return res
235
 
236
- def _fetch_api(eid, res):
237
- pm = _api("/api/event-standing/pre-match", {"event_id": eid})
238
- res["pre_match"] = pm
239
- res["pre_match_html"] = pm.get("html","") if pm and pm.get("status")=="success" and len(pm.get("html","").strip())>10 else ""
240
-
241
- hm = _api("/api/fixtures/h2h-match", {"event_id": eid})
242
- res["h2h_match"] = hm
243
- if hm and hm.get("status")=="success":
244
- h = hm.get("html","")
245
- if len(h.strip())>10:
246
- res["h2h_html"] = h
247
- res["sections"].append("h2h")
248
- else: res["h2h_html"] = ""
249
-
250
- hs = _api("/api/fixtures/h2h-stats", {"event_id": eid})
251
- res["h2h_stats"] = hs
252
- if hs and hs.get("status")=="success":
253
- h = hs.get("html","")
254
- if len(h.strip())>10:
255
- res["h2h_stats_html"] = h
256
- res["sections"].append("h2h_stats")
257
- try:
258
- sp = _sp(h)
259
- stats = {}
260
- for row in sp.select('li,tr,.stat-row'):
261
- cells = row.select('td,span,p')
262
- if len(cells)>=3:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
263
  lb = _cl(cells[0].get_text())
264
- if lb: stats[lb] = {'home': _cl(cells[1].get_text()), 'away': _cl(cells[2].get_text())}
265
- if stats: res["h2h_stats_parsed"] = stats
266
- except: pass
267
- else: res["h2h_stats_html"] = ""
268
-
269
- pf = _api("/api/event-standing/player-performance", {"event_id": eid})
270
- res["performance"] = pf
271
- if pf and pf.get("status")=="success" and len(pf.get("html","").strip())>10:
272
- res["stats_html"] = pf["html"]
273
- res["sections"].append("stats")
274
- else: res["stats_html"] = ""
275
-
276
- cm = _api("/api/fixtures/commentaries", {"event_id": eid})
277
- if cm and cm.get("status")=="success" and len(cm.get("html","").strip())>10:
278
- res["commentaries_html"] = cm["html"]
279
- res["sections"].append("commentaries")
280
- elif not res.get("commentaries_html"): res["commentaries_html"] = ""
281
-
282
- def _merge(res, d):
283
- if d.get("info"):
284
- res.setdefault("info", {}).update(d["info"])
285
- res["found"] = True
286
- if "info" not in res["sections"]: res["sections"].append("info")
287
- if d.get("timeline"):
288
- res["timeline"] = d["timeline"]
289
- if not res.get("commentaries_html"): res["commentaries_html"] = d.get("commentaries_html","")
290
- res["sections"].append("commentaries")
291
- if d.get("events"):
292
- res["events"] = d["events"]
293
- res["sections"].append("events")
294
- if d.get("stats_parsed"):
295
- res["stats_parsed"] = d["stats_parsed"]
296
- if not res.get("stats_html"): res["stats_html"] = d.get("stats_html","")
297
- res["sections"].append("stats")
298
- if d.get("h2h_matches"):
299
- res["h2h"] = d["h2h_matches"]
300
- res["sections"].append("h2h")
301
- if d.get("h2h_stats"):
302
- res["h2h_stats_parsed"] = d["h2h_stats"]
303
- res["sections"].append("h2h_stats")
304
- if d.get("home_form"):
305
- res["home_form"] = d["home_form"]
306
- res["sections"].append("home_form")
307
- if d.get("away_form"):
308
- res["away_form"] = d["away_form"]
309
- res["sections"].append("away_form")
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """VNEWS — Match Detail Parser v2
2
+ Parses bongda.com.vn match detail pages with correct selectors.
3
+ Extracts timeline events (goals, cards, substitutions) and statistics.
4
  """
5
+ import re
6
+ import requests
 
7
  from bs4 import BeautifulSoup
8
 
9
+ HEADERS = {
 
 
 
 
 
 
 
 
 
 
 
 
10
  "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",
11
  "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
12
+ "Accept-Language": "vi-VN,vi;q=0.9",
13
  "Referer": "https://bongda.com.vn/",
14
  }
15
 
16
+ API_HEADERS = {
17
+ **HEADERS,
18
+ "Accept": "application/json, text/javascript, */*; q=0.01",
19
+ "X-Requested-With": "XMLHttpRequest",
20
+ }
21
+
22
+
23
+ def _mk(html):
24
+ try:
25
+ return BeautifulSoup(html, 'lxml')
26
+ except Exception:
27
+ return BeautifulSoup(html, 'html.parser')
28
+
29
+
30
  def _cl(s):
31
  return re.sub(r'\s+', ' ', str(s or '')).strip()
32
 
33
+
34
+ def _base_url(url_or_eid):
35
+ """Extract base match URL."""
36
+ if isinstance(url_or_eid, int) or url_or_eid.isdigit():
37
+ return f"https://bongda.com.vn/tran-dau/{int(url_or_eid)}"
38
+ url = url_or_eid.strip()
39
+ url = re.sub(r'/(preview|centre|bao-cao-nhanh|thong-ke|doi-hinh)/.*', '', url)
40
+ return url.rstrip('/')
41
+
42
+
43
+ def fetch_html(url, timeout=15):
44
+ """Fetch HTML from URL."""
45
+ resp = requests.get(url, headers=HEADERS, timeout=timeout, allow_redirects=True)
46
+ resp.raise_for_status()
47
+ return resp.text
48
+
49
+
50
+ def parse_events(sp):
51
+ """Parse timeline events from BeautifulSoup object.
52
+
53
+ Structure: .events > .period > .event.home-team|away-team
54
+
55
+ Each event:
56
+ .event-type > icon (goal/redcard/yellowcard/substitution)
57
+ .players.card|goal|subst > .event-time + player names
58
+
59
+ Returns list of event dicts with: type, time, team, players, player_in, player_out, scorer, assist
60
+ """
61
+ events = []
62
+ events_div = sp.select_one('.events')
63
+ if not events_div:
64
+ return events
65
+
66
+ current_period = ''
67
+ for child in events_div.children:
68
+ if not hasattr(child, 'name') or not child.name:
69
+ continue
70
+ cls = child.get('class', [])
71
+ cls_str = ' '.join(cls)
72
+
73
+ if 'period' in cls_str:
74
+ # Period header contains h2 with period name
75
+ h2 = child.find('h2')
76
+ if h2:
77
+ current_period = _cl(h2.get_text())
78
+
79
+ # Events inside period
80
+ for ev in child.children:
81
+ if not hasattr(ev, 'name') or not ev.name:
82
+ continue
83
+ ev_cls = ev.get('class', [])
84
+ ev_cls_str = ' '.join(ev_cls)
85
+ if 'event' not in ev_cls_str:
86
+ continue
87
+
88
+ team = 'home' if 'home' in ev_cls_str else 'away'
89
+ ev_data = {
90
+ 'team': team,
91
+ 'period': current_period,
92
+ 'type': 'unknown',
93
+ 'time': '',
94
+ 'players': '',
95
+ 'player_in': '',
96
+ 'player_out': '',
97
+ 'scorer': '',
98
+ 'assist': '',
99
+ 'card_type': '',
100
+ 'player': '',
101
+ }
102
+
103
+ # Determine event type from icon in .event-type
104
+ type_el = ev.select_one('.event-type')
105
+ if type_el:
106
+ # Check for specific SVG/icon patterns
107
+ if type_el.select_one('[class*="redcard"]'):
108
+ ev_data['type'] = 'redcard'
109
+ ev_data['card_type'] = 'red'
110
+ elif type_el.select_one('[class*="yellowcard"]'):
111
+ ev_data['type'] = 'yellowcard'
112
+ ev_data['card_type'] = 'yellow'
113
+ elif type_el.select_one('[class*="goal"]'):
114
+ ev_data['type'] = 'goal'
115
+ elif type_el.select_one('[class*="substitution"]'):
116
+ ev_data['type'] = 'substitution'
117
+ else:
118
+ # Check by SVG content
119
+ svg_rects = type_el.select('svg rect')
120
+ for rect in svg_rects:
121
+ fill = rect.get('fill', '')
122
+ if fill == '#E20007':
123
+ ev_data['type'] = 'redcard'
124
+ ev_data['card_type'] = 'red'
125
+ break
126
+ if ev_data['type'] == 'unknown':
127
+ svg_circles = type_el.select('svg circle')
128
+ for circle in svg_circles:
129
+ fill = circle.get('fill', '')
130
+ if fill == 'white' and circle.get('r') == '8':
131
+ ev_data['type'] = 'goal'
132
+ break
133
+ if ev_data['type'] == 'unknown':
134
+ if type_el.select_one('[class*="subst"]') or type_el.select('svg'):
135
+ # Check for substitution SVG pattern
136
+ svg = type_el.select_one('svg')
137
+ if svg and ev.select_one('.players.subst'):
138
+ ev_data['type'] = 'substitution'
139
+
140
+ # Also check .players class for type hint
141
+ players_el = ev.select_one('.players')
142
+ if players_el and ev_data['type'] == 'unknown':
143
+ pcls = ' '.join(players_el.get('class', []))
144
+ if 'card' in pcls and ev_data['type'] == 'unknown':
145
+ ev_data['type'] = 'redcard' # default to red if card class but no type detected
146
+ elif 'goal' in pcls:
147
+ ev_data['type'] = 'goal'
148
+ elif 'subst' in pcls:
149
+ ev_data['type'] = 'substitution'
150
+
151
+ # Time from .event-time
152
+ if players_el:
153
+ time_el = players_el.select_one('.event-time')
154
+ if time_el:
155
+ ev_data['time'] = _cl(time_el.get_text())
156
+
157
+ # Player text
158
+ ev_data['players'] = _cl(players_el.get_text(' ', strip=True))
159
+
160
+ # Parse player names based on event type
161
+ texts = []
162
+ for d in players_el.find_all('div', recursive=False):
163
+ t = _cl(d.get_text())
164
+ if t and t != ev_data['time']:
165
+ # Remove trailing ' if present
166
+ texts.append(t)
167
+
168
+ # Also get text from <p> elements directly in .players
169
+ for p in players_el.find_all('p', recursive=False):
170
+ t = _cl(p.get_text())
171
+ if t and t not in texts:
172
+ texts.append(t)
173
+
174
+ if ev_data['type'] == 'substitution':
175
+ if len(texts) >= 2:
176
+ ev_data['player_out'] = texts[0]
177
+ ev_data['player_in'] = texts[1]
178
+ elif len(texts) == 1:
179
+ ev_data['player_in'] = texts[0]
180
+ # Also try from combined text: "time' playerOut playerIn"
181
+ if not ev_data['player_in'] and ev_data['players']:
182
+ text = ev_data['players'].replace(ev_data['time'], '').strip()
183
+ words = text.split()
184
+ if len(words) >= 4:
185
+ # "46' Jose Sa Rui Silva" => out="Jose Sa", in="Rui Silva"
186
+ mid = len(words) // 2
187
+ ev_data['player_out'] = ' '.join(words[:mid])
188
+ ev_data['player_in'] = ' '.join(words[mid:])
189
+
190
+ elif ev_data['type'] == 'goal':
191
+ if len(texts) >= 1:
192
+ ev_data['scorer'] = texts[0]
193
+ if len(texts) >= 2:
194
+ ev_data['assist'] = texts[1]
195
+ if not ev_data['scorer'] and ev_data['players']:
196
+ text = ev_data['players'].replace(ev_data['time'], '').strip()
197
+ words = text.split()
198
+ if len(words) >= 2:
199
+ ev_data['scorer'] = ' '.join(words[:2])
200
+ elif len(words) == 1:
201
+ ev_data['scorer'] = words[0]
202
+
203
+ elif ev_data['type'] in ('redcard', 'yellowcard'):
204
+ if texts:
205
+ ev_data['player'] = ' '.join(texts)
206
+ elif ev_data['players']:
207
+ text = ev_data['players'].replace(ev_data['time'], '').strip()
208
+ if text:
209
+ ev_data['player'] = text
210
+
211
+ events.append(ev_data)
212
+
213
+ return events
214
+
215
+
216
+ def fetch_match_detail(event_id: int) -> dict:
217
+ """Fetch and parse match detail by event ID."""
218
+ result = {"event_id": event_id, "found": False, "sections": []}
219
+
220
+ html = None
221
+ base = f"https://bongda.com.vn/tran-dau/{event_id}"
222
+ for suffix in ['/centre/', '/preview/', '/bao-cao-nhanh/']:
223
+ url = base + suffix
224
+ try:
225
+ html = fetch_html(url, timeout=15)
226
+ if html and len(html) > 1000 and '.teams' in html:
227
+ break
228
+ except Exception:
229
+ html = None
230
+ continue
231
+
232
+ if not html:
233
+ return result
234
+
235
+ sp = _mk(html)
236
  info = {}
237
+
238
+ tel = sp.select_one('.teams')
239
+ if tel:
240
+ he = tel.select_one('.team.home') or tel.select_one('[class*="home"]')
241
+ if he:
242
+ ne = he.select_one('p:not(.logo)') or he.find('p')
243
+ if ne:
244
+ info['home_team'] = _cl(ne.get_text())
245
+ lo = he.select_one('img')
246
+ if lo:
247
+ info['home_logo'] = lo.get('src', '')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
248
 
249
+ ae = tel.select_one('.team.away') or tel.select_one('[class*="away"]')
250
+ if ae:
251
+ ne = ae.select_one('p:not(.logo)') or ae.find('p')
252
+ if ne:
253
+ info['away_team'] = _cl(ne.get_text())
254
+ lo = ae.select_one('img')
255
+ if lo:
256
+ info['away_logo'] = lo.get('src', '')
257
 
258
+ sc = tel.select_one('.score')
259
+ if sc:
260
+ parts = [_cl(p.get_text()) for p in sc.select('p')]
261
+ if len(parts) >= 2:
262
+ info['score'] = f"{parts[0]} - {parts[1]}"
263
+ lb = sc.select_one('.label')
264
+ if lb:
265
+ info['status_label'] = _cl(lb.get_text())
266
+
267
+ if info.get('home_team') and info.get('away_team'):
268
+ result['info'] = info
269
+ result['found'] = True
270
+ result['sections'].append('info')
271
+ else:
272
+ return result
273
+
274
+ mi = sp.select_one('.match-info')
275
+ if mi:
276
+ for sel_key in ['.times', 'li']:
277
+ el = mi.select_one(sel_key)
278
+ if el:
279
+ t = _cl(el.get_text())
280
+ if t:
281
+ info.setdefault('datetime', t)
282
+ break
283
+
284
+ events = parse_events(sp)
285
+ if events:
286
+ result['events'] = events
287
+ result['sections'].append('events')
288
+
289
+ # Prediction
290
+ pred = sp.select_one('.prediction-card')
291
+ if pred:
292
+ pred_data = {}
293
+ team_info = pred.select_one('.team-info')
294
+ if team_info:
295
+ teams = team_info.select('.team')
296
+ if len(teams) >= 2:
297
+ pred_data['home_name'] = _cl(teams[0].select_one('.team-name').get_text() if teams[0].select_one('.team-name') else '')
298
+ pred_data['away_name'] = _cl(teams[1].select_one('.team-name').get_text() if teams[1].select_one('.team-name') else '')
299
+ divider = team_info.select_one('.divider')
300
+ if divider:
301
+ pred_data['result'] = _cl(divider.get_text())
302
+ vote_count = pred.select_one('.vote-count')
303
+ if vote_count:
304
+ pred_data['vote_count'] = _cl(vote_count.get_text())
305
+ result['prediction'] = pred_data
306
+
307
+ # H2H stats from API
308
+ try:
309
+ ar = requests.get(
310
+ f"https://bongda.com.vn/api/fixtures/h2h-stats?event_id={event_id}",
311
+ headers=API_HEADERS,
312
+ timeout=10
313
+ )
314
+ if ar.status_code == 200:
315
+ ad = ar.json()
316
+ if ad.get('status') == 'success' and ad.get('html'):
317
+ asp = _mk(ad['html'])
318
+ ast = {}
319
+ for row in asp.select('li, tr, .stat-row'):
320
+ cells = row.select('td, span, p')
321
+ if len(cells) >= 3:
322
+ lb = _cl(cells[0].get_text())
323
+ if lb:
324
+ ast[lb] = {
325
+ 'home': _cl(cells[1].get_text()),
326
+ 'away': _cl(cells[2].get_text()),
327
+ }
328
+ if ast:
329
+ result['h2h_stats_parsed'] = ast
330
+ result['sections'].append('h2h_stats')
331
+ except Exception:
332
+ pass
333
+
334
+ # H2H standings
335
+ h2h_data = []
336
+ h2h_el = sp.select_one('.h2h-standings')
337
+ if h2h_el:
338
+ rows = h2h_el.select('.ranking-table .body > tr, .ranking-table tbody tr, .leaderboard tr')
339
+ for row in rows:
340
+ cells = row.select('td')
341
+ if len(cells) >= 4:
342
+ logo = row.select_one('img')
343
+ name_el = row.select_one('.team-name, p.link, .name')
344
+ h2h_data.append({
345
+ 'pos': _cl(cells[0].get_text()) if cells else '',
346
+ 'logo': logo.get('src', '') if logo else '',
347
+ 'name': _cl(name_el.get_text()) if name_el else '',
348
+ 'played': _cl(cells[1].get_text()) if len(cells) > 1 else '',
349
+ 'wins': _cl(cells[2].get_text()) if len(cells) > 2 else '',
350
+ 'draws': _cl(cells[3].get_text()) if len(cells) > 3 else '',
351
+ 'losses': _cl(cells[4].get_text()) if len(cells) > 4 else '',
352
+ 'gf': _cl(cells[5].get_text()) if len(cells) > 5 else '',
353
+ 'ga': _cl(cells[6].get_text()) if len(cells) > 6 else '',
354
+ 'points': _cl(cells[8].get_text()) if len(cells) > 8 else '',
355
+ })
356
+ if h2h_data:
357
+ result['h2h_standings'] = h2h_data
358
+ result['sections'].append('h2h_standings')
359
+
360
+ # Recent matches
361
+ recent_matches = []
362
+ matches_list = sp.select_one('.matches-list')
363
+ if matches_list:
364
+ for item in matches_list.select('.match-detail, .match-item, li'):
365
+ date_el = item.select_one('.date, .time, .match-time')
366
+ league_el = item.select_one('.league')
367
+ home_el = item.select_one('.home, .team-home')
368
+ away_el = item.select_one('.away, .team-away')
369
+ score_el = item.select_one('.score, .result')
370
+ if home_el or away_el:
371
+ recent_matches.append({
372
+ 'date': _cl(date_el.get_text()) if date_el else '',
373
+ 'league': _cl(league_el.get_text()) if league_el else '',
374
+ 'home': _cl(home_el.get_text()) if home_el else '',
375
+ 'away': _cl(away_el.get_text()) if away_el else '',
376
+ 'score': _cl(score_el.get_text()) if score_el else 'vs',
377
+ })
378
+ if recent_matches:
379
+ result['recent_matches'] = recent_matches
380
+ result['sections'].append('recent')
381
+
382
+ return result
383
+
384
+
385
+ def fetch_match_detail_by_url(url: str) -> dict:
386
+ """Fetch and parse match detail from a full bongda.com.vn URL."""
387
+ import os
388
+ eid_match = re.search(r'/tran-dau/(\d+)/', url)
389
+ if not eid_match:
390
+ return {"event_id": 0, "found": False, "error": "Cannot extract event_id from URL"}
391
+
392
+ event_id = int(eid_match.group(1))
393
+ result = {"event_id": event_id, "found": False, "sections": []}
394
+
395
+ html = None
396
+ try:
397
+ html = fetch_html(url, timeout=15)
398
+ except Exception:
399
+ pass
400
+
401
+ if not html or len(html) < 1000 or '.teams' not in html:
402
+ return fetch_match_detail(event_id)
403
+
404
+ sp = _mk(html)
405
+ info = {}
406
+
407
+ tel = sp.select_one('.teams')
408
+ if tel:
409
+ he = tel.select_one('.team.home') or tel.select_one('[class*="home"]')
410
+ if he:
411
+ ne = he.select_one('p:not(.logo)') or he.find('p')
412
+ if ne:
413
+ info['home_team'] = _cl(ne.get_text())
414
+ lo = he.select_one('img')
415
+ if lo:
416
+ info['home_logo'] = lo.get('src', '')
417
 
418
+ ae = tel.select_one('.team.away') or tel.select_one('[class*="away"]')
419
+ if ae:
420
+ ne = ae.select_one('p:not(.logo)') or ae.find('p')
421
+ if ne:
422
+ info['away_team'] = _cl(ne.get_text())
423
+ lo = ae.select_one('img')
424
+ if lo:
425
+ info['away_logo'] = lo.get('src', '')
426
 
427
+ sc = tel.select_one('.score')
428
+ if sc:
429
+ parts = [_cl(p.get_text()) for p in sc.select('p')]
430
+ if len(parts) >= 2:
431
+ info['score'] = f"{parts[0]} - {parts[1]}"
432
+ lb = sc.select_one('.label')
433
+ if lb:
434
+ info['status_label'] = _cl(lb.get_text())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
435
 
436
+ if info.get('home_team') and info.get('away_team'):
437
+ result['info'] = info
438
+ result['found'] = True
439
+ result['sections'].append('info')
440
+ else:
441
+ return fetch_match_detail(event_id)
442
+
443
+ mi = sp.select_one('.match-info')
444
+ if mi:
445
+ te = mi.select_one('.times, li')
446
+ if te:
447
+ info.setdefault('datetime', _cl(te.get_text()))
448
+
449
+ events = parse_events(sp)
450
+ if events:
451
+ result['events'] = events
452
+ result['sections'].append('events')
453
+
454
+ pred = sp.select_one('.prediction-card')
455
+ if pred:
456
+ pred_data = {}
457
+ team_info = pred.select_one('.team-info')
458
+ if team_info:
459
+ teams = team_info.select('.team')
460
+ if len(teams) >= 2:
461
+ pred_data['home_name'] = _cl(teams[0].select_one('.team-name').get_text() if teams[0].select_one('.team-name') else '')
462
+ pred_data['away_name'] = _cl(teams[1].select_one('.team-name').get_text() if teams[1].select_one('.team-name') else '')
463
+ divider = team_info.select_one('.divider')
464
+ if divider:
465
+ pred_data['result'] = _cl(divider.get_text())
466
+ vote_count = pred.select_one('.vote-count')
467
+ if vote_count:
468
+ pred_data['vote_count'] = _cl(vote_count.get_text())
469
+ result['prediction'] = pred_data
470
+
471
+ try:
472
+ ar = requests.get(
473
+ f"https://bongda.com.vn/api/fixtures/h2h-stats?event_id={event_id}",
474
+ headers=API_HEADERS,
475
+ timeout=10
476
+ )
477
+ if ar.status_code == 200:
478
+ ad = ar.json()
479
+ if ad.get('status') == 'success' and ad.get('html'):
480
+ asp = _mk(ad['html'])
481
+ ast = {}
482
+ for row in asp.select('li, tr, .stat-row'):
483
+ cells = row.select('td, span, p')
484
+ if len(cells) >= 3:
485
  lb = _cl(cells[0].get_text())
486
+ if lb:
487
+ ast[lb] = {
488
+ 'home': _cl(cells[1].get_text()),
489
+ 'away': _cl(cells[2].get_text()),
490
+ }
491
+ if ast:
492
+ result['h2h_stats_parsed'] = ast
493
+ result['sections'].append('h2h_stats')
494
+ except Exception:
495
+ pass
496
+
497
+ h2h_data = []
498
+ h2h_el = sp.select_one('.h2h-standings')
499
+ if h2h_el:
500
+ rows = h2h_el.select('.ranking-table .body > tr, .ranking-table tbody tr, .leaderboard tr')
501
+ for row in rows:
502
+ cells = row.select('td')
503
+ if len(cells) >= 4:
504
+ logo = row.select_one('img')
505
+ name_el = row.select_one('.team-name, p.link, .name')
506
+ h2h_data.append({
507
+ 'pos': _cl(cells[0].get_text()) if cells else '',
508
+ 'logo': logo.get('src', '') if logo else '',
509
+ 'name': _cl(name_el.get_text()) if name_el else '',
510
+ 'played': _cl(cells[1].get_text()) if len(cells) > 1 else '',
511
+ 'wins': _cl(cells[2].get_text()) if len(cells) > 2 else '',
512
+ 'draws': _cl(cells[3].get_text()) if len(cells) > 3 else '',
513
+ 'losses': _cl(cells[4].get_text()) if len(cells) > 4 else '',
514
+ 'gf': _cl(cells[5].get_text()) if len(cells) > 5 else '',
515
+ 'ga': _cl(cells[6].get_text()) if len(cells) > 6 else '',
516
+ 'points': _cl(cells[8].get_text()) if len(cells) > 8 else '',
517
+ })
518
+ if h2h_data:
519
+ result['h2h_standings'] = h2h_data
520
+ result['sections'].append('h2h_standings')
521
+
522
+ recent_matches = []
523
+ matches_list = sp.select_one('.matches-list')
524
+ if matches_list:
525
+ for item in matches_list.select('.match-detail, .match-item, li'):
526
+ date_el = item.select_one('.date, .time, .match-time')
527
+ league_el = item.select_one('.league')
528
+ home_el = item.select_one('.home, .team-home')
529
+ away_el = item.select_one('.away, .team-away')
530
+ score_el = item.select_one('.score, .result')
531
+ if home_el or away_el:
532
+ recent_matches.append({
533
+ 'date': _cl(date_el.get_text()) if date_el else '',
534
+ 'league': _cl(league_el.get_text()) if league_el else '',
535
+ 'home': _cl(home_el.get_text()) if home_el else '',
536
+ 'away': _cl(away_el.get_text()) if away_el else '',
537
+ 'score': _cl(score_el.get_text()) if score_el else 'vs',
538
+ })
539
+ if recent_matches:
540
+ result['recent_matches'] = recent_matches
541
+ result['sections'].append('recent')
542
+
543
+ return result