Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 40 additions & 4 deletions internal/site/match_parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,12 @@ func parseMatchPage(doc *goquery.Document, url string) *MatchPage {
return
}

if strings.Contains(middle, "-") {
// Goal rows keep one side empty; non-empty side maps event ownership.
if kind, ok := scoreRowEventKind(row); ok {
// Incident rows keep one side empty; non-empty side maps event ownership.
if left != "" && right == "" {
page.Events = append(page.Events, MatchEvent{
MinuteText: extractMinute(left),
Kind: "GOAL",
Kind: kind,
TeamSide: "home",
Text: left,
})
Expand All @@ -59,7 +59,7 @@ func parseMatchPage(doc *goquery.Document, url string) *MatchPage {
if right != "" && left == "" {
page.Events = append(page.Events, MatchEvent{
MinuteText: extractMinute(right),
Kind: "GOAL",
Kind: kind,
TeamSide: "away",
Text: right,
})
Expand Down Expand Up @@ -104,6 +104,42 @@ func parseMatchPage(doc *goquery.Document, url string) *MatchPage {
return page
}

func scoreRowEventKind(row *goquery.Selection) (string, bool) {
tds := row.Find("td")
if tds.Length() != 3 {
return "", false
}
middle := normalizeWhitespace(tds.Eq(1).Text())
if middle == "-" || isScoreLikeText(middle) {
return "GOAL", true
}

leftKind := scoreCellEventKind(tds.Eq(0))
rightKind := scoreCellEventKind(tds.Eq(2))
if leftKind != "" && leftKind == rightKind {
return leftKind, true
}
if leftKind != "" && normalizeWhitespace(tds.Eq(2).Text()) == "" {
return leftKind, true
}
if rightKind != "" && normalizeWhitespace(tds.Eq(0).Text()) == "" {
return rightKind, true
}

return "", false
}

func scoreCellEventKind(cell *goquery.Selection) string {
switch {
case cell.Find("img[src*='goal.gif']").Length() > 0:
return "GOAL"
case cell.Find("img[src*='missed.gif']").Length() > 0:
return "MISS"
default:
return ""
}
}

func findMatchMainTable(doc *goquery.Document) *goquery.Selection {
bestScore := -1
// Preserve legacy fallback for older 90minut pages that still use width=480.
Expand Down
39 changes: 39 additions & 0 deletions internal/site/parser_edgecases_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,42 @@ func TestParseMatchPageGoalSideAssignmentAndStoppageMinutes(t *testing.T) {
t.Fatalf("unexpected second goal: %#v", goals[1])
}
}

func TestParseMatchPageMissedPenaltyTimelineEvent(t *testing.T) {
html := `
<html><head><title>Match Test</title></head><body>
<table class="main" width="480">
<tr><td colspan="3"><b>Ekstraklasa</b></td></tr>
<tr><td colspan="3">20 marca 2026, 18:00</td></tr>
<tr><td>Piast Gliwice</td><td>3 - 1</td><td>Radomiak Radom</td></tr>
<tr><td align="right">&nbsp;<img src="http://img.90minut.pl/img/missed.gif" width="10" height="10" align="absmiddle" alt="(nk)"> Gierman Barkowskij 52 (nk)&nbsp;&nbsp;&nbsp;&nbsp;</td><td></td><td></td></tr>
</table>
</body></html>`

doc, err := goquery.NewDocumentFromReader(strings.NewReader(html))
if err != nil {
t.Fatalf("parse synthetic html: %v", err)
}

page := parseMatchPage(doc, "http://www.90minut.pl/mecz.php?id_mecz=2022961")
if page == nil {
t.Fatalf("expected parsed match page")
}
if len(page.Events) != 1 {
t.Fatalf("expected 1 event, got %d", len(page.Events))
}

event := page.Events[0]
if event.Kind != "MISS" {
t.Fatalf("unexpected event kind: %#v", event)
}
if event.TeamSide != "home" {
t.Fatalf("unexpected event side: %#v", event)
}
if event.MinuteText != "52" {
t.Fatalf("unexpected missed penalty minute: %#v", event)
}
if event.Text != "Gierman Barkowskij 52 (nk)" {
t.Fatalf("unexpected missed penalty text: %#v", event)
}
}
22 changes: 22 additions & 0 deletions internal/site/parser_match_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ var expectedCardSidesByFixture = map[string]map[string]string{
},
}

var expectedMissedPenaltiesByFixture = map[string][]MatchEvent{
"match_2022961": {
{MinuteText: "52", Kind: "MISS", TeamSide: "home", Text: "Gierman Barkowskij 52 (nk)"},
},
}

func TestParseMatchFixturesFromCorpus(t *testing.T) {
m := loadManifest(t)
matches := fixturesByKind(m, "match")
Expand Down Expand Up @@ -50,13 +56,22 @@ func TestParseMatchFixturesFromCorpus(t *testing.T) {
}

expectedFixtureCards := expectedCardSidesByFixture[fixture.Name]
expectedMissedPenalties := expectedMissedPenaltiesByFixture[fixture.Name]
seenExpectedCards := map[string]bool{}
seenExpectedMissed := make([]bool, len(expectedMissedPenalties))

for _, event := range page.Events {
if event.TeamSide != "home" && event.TeamSide != "away" {
t.Fatalf("invalid event side %q in %s", event.TeamSide, fixture.Name)
}

for i, want := range expectedMissedPenalties {
if event.Kind != want.Kind || event.TeamSide != want.TeamSide || event.MinuteText != want.MinuteText || event.Text != want.Text {
continue
}
seenExpectedMissed[i] = true
}

if event.Kind != "YC" && event.Kind != "RC" {
continue
}
Expand All @@ -82,6 +97,13 @@ func TestParseMatchFixturesFromCorpus(t *testing.T) {
}
t.Fatalf("expected YC/RC event not found for %q in %s", player, fixture.Name)
}

for i, seen := range seenExpectedMissed {
if seen {
continue
}
t.Fatalf("expected missed-penalty event not found in %s: %#v", fixture.Name, expectedMissedPenalties[i])
}
})
}

Expand Down
103 changes: 103 additions & 0 deletions internal/site/testdata/fixtures/match_2022961.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=iso-8859-2">
<title>Piast Gliwice 3-1 Radomiak Radom</title>
</head>
<body>
<table width="480" border="0" cellspacing="0" cellpadding="0" class="main" align="center">
<tr>
<td colspan="3" align="center"><br>&nbsp;<img src="http://img.90minut.pl/img/redarrowl.gif" width="15" height="15" align="absmiddle">
&nbsp;<b>PKO Bank Polski Ekstraklasa 2025/2026 - Kolejka 26</b>&nbsp;<img src="http://img.90minut.pl/img/redarrowr.gif" width="15" height="15" align="absmiddle">
</td>
</tr>
<tr>
<td valign="middle" align="center" colspan="3">&nbsp;&nbsp;<img src="http://img.90minut.pl/img/data.gif" width="25" height="22" valign="bottom">&nbsp;20 marca 2026, 18:00&nbsp;&nbsp;<img src="http://img.90minut.pl/img/att.gif" width="22" height="22" valign="bottom">&nbsp;4012&nbsp;&nbsp;<img src="http://img.90minut.pl/img/ref.jpg" width="31" height="22" valign="bottom">&nbsp;<a href="/sedzia.php?id=470&id_sezon=107" class="main">Bartosz Frankowski (Toruń)</a></td>
</tr>
<tr><td>&nbsp;</td></tr>
<tr><td valign="bottom" align="center" width="480" colspan="3"><img src="http://img.90minut.pl/img/pog_termo.gif" align="absmiddle" alt="temperatura"> 7 &deg;&nbsp;&nbsp;&nbsp;&nbsp;</td></tr>
<tr><td>&nbsp;</td></tr>
<tr>
<tr><td align="center" valign="bottom"><img src="http://img.90minut.pl/logo/dobazy/piast_gliwice.gif"></td><td></td><td align="center" valign="bottom"><img src="http://img.90minut.pl/logo/dobazy/radomiak.gif"></td></tr>
<td align="center" valign="bottom" width="220"><b><font size="2">Piast Gliwice</font></b></td>
<td align="center" valign="bottom" width="40"><b><font size="4"><nobr>3 - 1</nobr></font></b></td>
<td align="center" valign="bottom" width="220"><b><font size="2">Radomiak Radom</font></b></td>
</tr>
<tr><td>&nbsp;</td></tr>
<tr>
<tr>
<td align="right">&nbsp;<img src="http://img.90minut.pl/img/goal.gif" width="10" height="10" align="absmiddle">
<i>Jorge Félix</i> 7&nbsp;&nbsp;&nbsp;&nbsp;</td>
<td align="center"><b>1 - 0</b></td>
<td></td>
</tr>
<tr>
<td align="right">&nbsp;<img src="http://img.90minut.pl/img/missed.gif" width="10" height="10" align="absmiddle" alt="(nk)">
Gierman Barkowskij 52 (nk)&nbsp;&nbsp;&nbsp;&nbsp;</td>
<td></td>
<td></td>
</tr>
<tr>
<td></td>
<td align="center"><b>1 - 1</b></td>
<td align="left">&nbsp;&nbsp;&nbsp;&nbsp;Rafał Wolski 61&nbsp;<img src="http://img.90minut.pl/img/goal.gif" width="10" height="10" align="absmiddle">
</td>
</tr>
<tr>
<td align="right">&nbsp;<img src="http://img.90minut.pl/img/goal.gif" width="10" height="10" align="absmiddle">
Emmanuel Twumasi 63&nbsp;&nbsp;&nbsp;&nbsp;</td>
<td align="center"><b>2 - 1</b></td>
<td></td>
</tr>
<tr>
<td align="right">&nbsp;<img src="http://img.90minut.pl/img/goal.gif" width="10" height="10" align="absmiddle">
<i>Leandro Sanca</i> 89&nbsp;&nbsp;&nbsp;&nbsp;</td>
<td align="center"><b>3 - 1</b></td>
<td></td>
</tr>
<p><tr><td>&nbsp;</td></tr>
<tr height="20" valign="middle" align="center" bgcolor="#DFDFDF">
<td width="45%"><a href="/wystepy.php?id=19756&id_sezon=107" class="main">(33) Karol Szymański</a></td>
<td bgcolor="#FFFFFF"></td>
<td width="45%"><a href="/wystepy.php?id=33282&id_sezon=107" class="main">(1) Filip Majchrowicz</a></td>
</tr>
<tr height="20" valign="middle" align="center" bgcolor="#F5F5F5">
<td width="45%"><a href="/wystepy.php?id=51055&id_sezon=107" class="main">(55) Emmanuel Twumasi</a>&nbsp;<img src="http://img.90minut.pl/img/yel.gif" width="15" height="15" align="absmiddle" alt="ŻK">
</td>
<td bgcolor="#FFFFFF"></td>
<td width="45%"><a href="/wystepy.php?id=49014&id_sezon=107" class="main">(24) Zié Ouattara</a>&nbsp;<img src="http://img.90minut.pl/img/yel.gif" width="15" height="15" align="absmiddle" alt="ŻK">
</td>
</tr>
<tr height="20" valign="middle" align="center" bgcolor="#DFDFDF">
<td width="45%"><a href="/wystepy.php?id=11680&id_sezon=107" class="main">(4) Jakub Czerwiński</a>&nbsp;<img src="http://img.90minut.pl/img/yel.gif" width="15" height="15" align="absmiddle" alt="ŻK">
</td>
<td bgcolor="#FFFFFF"></td>
<td width="45%"><a href="/wystepy.php?id=50370&id_sezon=107" class="main">(14) Steve Kingué</a>&nbsp;<img src="http://img.90minut.pl/img/yel.gif" width="15" height="15" align="absmiddle" alt="ŻK">
</td>
</tr>
<tr height="20" valign="middle" align="center" bgcolor="#F5F5F5">
<td width="45%"><a href="/wystepy.php?id=43931&id_sezon=107" class="main">(98) Jason Lokilo</a>&nbsp;<img src="http://img.90minut.pl/img/yel.gif" width="15" height="15" align="absmiddle" alt="ŻK"><br>
<img src="http://img.90minut.pl/img/sub.gif" width="15" height="15" align="absmiddle">&nbsp;
66 <a href="/wystepy.php?id=45325&id_sezon=107" class="main">(31) Oskar Leśniak</a></td>
<td bgcolor="#FFFFFF"></td>
<td width="45%"><a href="/wystepy.php?id=36791&id_sezon=107" class="main">(82) <i>Luquinhas</i></a>&nbsp;<img src="http://img.90minut.pl/img/yel.gif" width="15" height="15" align="absmiddle" alt="ŻK">
</td>
</tr>
<tr height="20" valign="middle" align="center" bgcolor="#DFDFDF">
<td width="45%"><a href="/wystepy.php?id=35231&id_sezon=107" class="main">(7) <i>Jorge Félix</i></a><br>
<img src="http://img.90minut.pl/img/sub.gif" width="15" height="15" align="absmiddle">&nbsp;
73 <a href="/wystepy.php?id=51157&id_sezon=107" class="main">(11) <i>Leandro Sanca</i></a></td>
<td bgcolor="#FFFFFF"></td>
<td width="45%"><a href="/wystepy.php?id=28851&id_sezon=107" class="main">(77) Chrístos Dónis</a></td>
</tr>
<tr height="20" valign="middle" align="center" bgcolor="#F5F5F5">
<td width="45%"><a href="/wystepy.php?id=50409&id_sezon=107" class="main">(63) Gierman Barkowskij</a></td>
<td bgcolor="#FFFFFF"></td>
<td width="45%"><a href="/wystepy.php?id=43614&id_sezon=107" class="main">(25) <i>Maurides</i></a><br>
<img src="http://img.90minut.pl/img/sub.gif" width="15" height="15" align="absmiddle">&nbsp;
74 <a href="/wystepy.php?id=42632&id_sezon=107" class="main">(15) Abdoul Tapsoba</a></td>
</tr>
<tr><td>&nbsp;</td></tr>
<tr><td colspan="3">Przeczytaj news: <a href="/news/340/news3409584-PKO-BP-Ekstraklasa-Piast-3-1-Radomiak.html" class="main">PKO BP Ekstraklasa: Piast 3-1 Radomiak</a></td></tr>
</table>
</body>
</html>
10 changes: 9 additions & 1 deletion internal/site/testdata/manifest.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"generated_at": "sha256:3e120f99aa68d246aeea2dda990e65a2d434dd343546b15772a054c92a37229f",
"generated_at": "sha256:969307a13d9cda4b3df5545eb17073087987b5fed3fb53026767851fa12c67de",
"source": "http://www.90minut.pl/archsezon.php",
"fixtures": [
{
Expand Down Expand Up @@ -102,6 +102,14 @@
"season": "2025/26",
"file": "fixtures/match_2022745.html"
},
{
"name": "match_2022961",
"kind": "match",
"url": "http://www.90minut.pl/mecz.php?id_mecz=2022961",
"season": "2025/26",
"file": "fixtures/match_2022961.html",
"note": "missed penalty timeline row"
},
{
"name": "match_2023487",
"kind": "match",
Expand Down
13 changes: 10 additions & 3 deletions internal/ui/render_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -256,12 +256,14 @@ func eventWeight(kind string) int {
switch kind {
case "GOAL":
return 0
case "RC":
case "MISS":
return 1
case "YC":
case "RC":
return 2
case "SUB":
case "YC":
return 3
case "SUB":
return 4
default:
return 9
}
Expand Down Expand Up @@ -379,6 +381,8 @@ func eventPrefix(kind string) string {
switch kind {
case "GOAL":
return "⚽"
case "MISS":
return "❌"
case "SUB":
return "↕"
case "YC":
Expand Down Expand Up @@ -971,6 +975,9 @@ func trimEventMinute(event site.MatchEvent) string {
text = strings.TrimSpace(strings.TrimPrefix(text, "->"))
text = normalizeSubstitutionText(text)
}
if event.Kind == "MISS" {
text = strings.ReplaceAll(text, "(nk)", "(pen)")
}

return formatPlayerLabel(text)
}
Expand Down
34 changes: 34 additions & 0 deletions internal/ui/view_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ func TestMatchTimelineShowsSymbolsAndHalftimeDivider(t *testing.T) {
Score: "2-0",
Events: []site.MatchEvent{
{MinuteText: "39", Kind: "GOAL", TeamSide: "home", Text: "Wdowiak 39"},
{MinuteText: "52", Kind: "MISS", TeamSide: "away", Text: "Barkowskij 52 (nk)"},
{MinuteText: "46", Kind: "SUB", TeamSide: "away", Text: "O. Lesniak -> Pllana (4)"},
{MinuteText: "46", Kind: "SUB", TeamSide: "home", Text: "Igor Strzalek (86) -> Damian Nowak"},
{MinuteText: "60", Kind: "GOAL", TeamSide: "home", Text: "Szkurin 60"},
Expand All @@ -185,6 +186,8 @@ func TestMatchTimelineShowsSymbolsAndHalftimeDivider(t *testing.T) {
"I. Strzalek",
"D. Nowak",
"O. Lesniak",
"❌ Barkowskij (pen)",
"52'",
"Szkurin ⚽",
"60'",
} {
Expand All @@ -195,6 +198,37 @@ func TestMatchTimelineShowsSymbolsAndHalftimeDivider(t *testing.T) {
if strings.Contains(view, "Wdowiak 39', Szkurin 60'") {
t.Fatalf("expected scorers to render as separate rows\n%s", view)
}
timeline := view[strings.Index(view, "Timeline"):]

indexes := []int{
strings.Index(timeline, "39'"),
strings.Index(timeline, "Pllana ↕"),
strings.Index(timeline, "D. Nowak"),
strings.Index(timeline, "52'"),
strings.Index(timeline, "Szkurin ⚽"),
}
for _, idx := range indexes {
if idx < 0 {
t.Fatalf("expected ordered timeline markers in view\n%s", timeline)
}
}
for i := 1; i < len(indexes); i++ {
if indexes[i-1] >= indexes[i] {
t.Fatalf("expected timeline order 39 -> 46 -> 46 -> 52 -> 60\n%s", timeline)
}
}
}

func TestFormatEventLabelFormatsMissedPenalty(t *testing.T) {
home := formatEventLabel(site.MatchEvent{MinuteText: "52", Kind: "MISS", TeamSide: "home", Text: "Gierman Barkowskij 52 (nk)"})
away := formatEventLabel(site.MatchEvent{MinuteText: "52", Kind: "MISS", TeamSide: "away", Text: "Gierman Barkowskij 52 (nk)"})

if got := ansi.Strip(home); got != "G. Barkowskij (pen) ❌" {
t.Fatalf("unexpected home missed penalty label: %q", got)
}
if got := ansi.Strip(away); got != "❌ G. Barkowskij (pen)" {
t.Fatalf("unexpected away missed penalty label: %q", got)
}
}

func TestFormatEventLabelFormatsSubstitutionOrderAndStyles(t *testing.T) {
Expand Down