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
92 changes: 68 additions & 24 deletions internal/site/match_parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"strings"

"github.com/PuerkitoBio/goquery"
"golang.org/x/net/html"
)

var matchMetaLineRe = regexp.MustCompile(`\d{1,2}\s+\p{L}+.*\d{1,2}:\d{2}`)
Expand Down Expand Up @@ -77,17 +78,19 @@ func parseMatchPage(doc *goquery.Document, url string) *MatchPage {
return
}

if player := parsePlayerCell(tds.Eq(0)); player != nil {
page.HomeLineup = append(page.HomeLineup, *player)
for _, event := range playerTimelineEvents(*player, "home") {
if parsed := parsePlayerCell(tds.Eq(0), "home"); parsed != nil {
page.HomeLineup = append(page.HomeLineup, *parsed.Player)
for _, event := range playerTimelineEvents(*parsed.Player, "home") {
page.Events = append(page.Events, event)
}
page.Events = append(page.Events, parsed.ExtraEvents...)
}
if player := parsePlayerCell(tds.Eq(2)); player != nil {
page.AwayLineup = append(page.AwayLineup, *player)
for _, event := range playerTimelineEvents(*player, "away") {
if parsed := parsePlayerCell(tds.Eq(2), "away"); parsed != nil {
page.AwayLineup = append(page.AwayLineup, *parsed.Player)
for _, event := range playerTimelineEvents(*parsed.Player, "away") {
page.Events = append(page.Events, event)
}
page.Events = append(page.Events, parsed.ExtraEvents...)
}
})

Expand Down Expand Up @@ -302,32 +305,59 @@ func substitutionEventText(outgoing, marker string) string {
return outgoing + " -> " + incoming
}

func parsePlayerCell(cell *goquery.Selection) *PlayerLine {
type parsedPlayerCell struct {
Player *PlayerLine
ExtraEvents []MatchEvent
}

func parsePlayerCell(cell *goquery.Selection, side string) *parsedPlayerCell {
raw := normalizeWhitespace(cell.Text())
if raw == "" {
return nil
}

name := raw
anchors := make([]string, 0, 3)
cell.Find("a").Each(func(_ int, a *goquery.Selection) {
name := normalizeWhitespace(a.Text())
if name != "" {
anchors = append(anchors, name)
markersByPlayer := make(map[string][]string, 2)
currentPlayer := ""

for _, node := range cell.Contents().Nodes {
switch node.Type {
case html.ElementNode:
switch strings.ToLower(node.Data) {
case "a":
playerName := normalizeWhitespace(goquery.NewDocumentFromNode(node).Text())
if playerName == "" {
continue
}
anchors = append(anchors, playerName)
if len(anchors) == 1 {
name = playerName
}
currentPlayer = playerName
case "img":
src := ""
for _, attr := range node.Attr {
if strings.EqualFold(attr.Key, "src") {
src = strings.ToLower(strings.TrimSpace(attr.Val))
break
}
}
switch {
case strings.Contains(src, "sub.gif"):
currentPlayer = ""
case currentPlayer == "":
continue
case strings.Contains(src, "yel.gif"):
markersByPlayer[currentPlayer] = append(markersByPlayer[currentPlayer], "YC")
case strings.Contains(src, "red.gif"), strings.Contains(src, "red2.gif"):
markersByPlayer[currentPlayer] = append(markersByPlayer[currentPlayer], "RC")
}
}
}
})

name := raw
if len(anchors) > 0 {
name = anchors[0]
}

events := make([]string, 0, 3)
if cell.Find("img[src*='yel.gif']").Length() > 0 {
events = append(events, "YC")
}
if cell.Find("img[src*='red.gif'], img[src*='red2.gif']").Length() > 0 {
events = append(events, "RC")
}
events := append([]string(nil), markersByPlayer[name]...)

if cell.Find("img[src*='sub.gif']").Length() > 0 && len(anchors) > 1 {
// In 90minut substitution cells, the last linked player is the replacement.
Expand All @@ -340,7 +370,21 @@ func parsePlayerCell(cell *goquery.Selection) *PlayerLine {
}
}

return &PlayerLine{Name: name, Events: events, RawText: raw}
extraEvents := make([]MatchEvent, 0, 2)
for i := 1; i < len(anchors); i++ {
for _, marker := range markersByPlayer[anchors[i]] {
extraEvents = append(extraEvents, MatchEvent{
Kind: marker,
TeamSide: side,
Text: anchors[i],
})
}
}

return &parsedPlayerCell{
Player: &PlayerLine{Name: name, Events: events, RawText: raw},
ExtraEvents: extraEvents,
}
}

func substitutionMinute(raw, replacement string) string {
Expand Down
50 changes: 50 additions & 0 deletions internal/site/parser_edgecases_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,53 @@ func TestParseMatchPageMissedPenaltyTimelineEvent(t *testing.T) {
t.Fatalf("unexpected missed penalty text: %#v", event)
}
}

func TestParseMatchPageSubstitutionCellAssignsCardsToBothPlayers(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>Arka Gdynia</td><td>3 - 1</td><td>Zaglebie Lubin</td></tr>
<tr height="20" valign="middle" align="center" bgcolor="#F5F5F5">
<td width="45%"><a href="/wystepy.php?id=1" class="main">Oskar Jakubczyk</a>&nbsp;<img src="http://img.90minut.pl/img/yel.gif" width="15" height="15" align="absmiddle" alt="ZK"><br>
<img src="http://img.90minut.pl/img/sub.gif" width="15" height="15" align="absmiddle">&nbsp;
72 <a href="/wystepy.php?id=2" class="main">Michal Rzuchowski</a>&nbsp;<img src="http://img.90minut.pl/img/yel.gif" width="15" height="15" align="absmiddle" alt="ZK"></td>
<td bgcolor="#FFFFFF"></td><td width="45%"></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=2023999")
if page == nil {
t.Fatalf("expected parsed match page")
}
if len(page.HomeLineup) != 1 {
t.Fatalf("expected one starter row, got %#v", page.HomeLineup)
}
if page.HomeLineup[0].Name != "Oskar Jakubczyk" {
t.Fatalf("unexpected starter: %#v", page.HomeLineup[0])
}

seenSub := false
seenStarterCard := false
seenEntrantCard := false
for _, event := range page.Events {
switch {
case event.Kind == "SUB" && event.TeamSide == "home" && event.Text == "Oskar Jakubczyk -> Michal Rzuchowski":
seenSub = true
case event.Kind == "YC" && event.TeamSide == "home" && event.Text == "Oskar Jakubczyk":
seenStarterCard = true
case event.Kind == "YC" && event.TeamSide == "home" && event.Text == "Michal Rzuchowski":
seenEntrantCard = true
}
}

if !seenSub || !seenStarterCard || !seenEntrantCard {
t.Fatalf("expected sub and both YC events, got %#v", page.Events)
}
}
51 changes: 31 additions & 20 deletions internal/ui/render_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -664,7 +664,11 @@ func playerEventIndex(events []site.MatchEvent, side string) map[string][]site.M
// cardAnnotation returns the YC/RC badge string for a lineup player, intended
// for the dedicated event column next to the centre separator. Empty when clean.
func cardAnnotation(player site.PlayerLine, idx map[string][]site.MatchEvent) string {
key := playerMatchKey(player.Name)
return cardAnnotationName(player.Name, idx)
}

func cardAnnotationName(name string, idx map[string][]site.MatchEvent) string {
key := playerMatchKey(name)
if key == "" {
return ""
}
Expand All @@ -690,11 +694,13 @@ func cardAnnotation(player site.PlayerLine, idx map[string][]site.MatchEvent) st
}

type lineupEntry struct {
player site.PlayerLine
enteredAt string
replaced string
leftAt string
replacedBy string
player site.PlayerLine
enteredAt string
replaced string
replacedYC string
leftAt string
replacedBy string
replacedByYC string
}

// A lineup row can carry both entry and exit notes for players who came on and were later replaced.
Expand Down Expand Up @@ -753,6 +759,9 @@ func entryNote(entry lineupEntry, side string, shortenNotes bool) string {
text := "(" + entry.enteredAt
if replaced != "" {
text += " for " + replaced
if entry.replacedYC != "" {
text += entry.replacedYC
}
}
return faintText(text + ")")
}
Expand All @@ -761,6 +770,9 @@ func entryNote(entry lineupEntry, side string, shortenNotes bool) string {
if replaced != "" {
text += replaced + " "
}
if entry.replacedYC != "" {
text += entry.replacedYC + " "
}
text += entry.enteredAt
return faintText(text + ")")
}
Expand All @@ -776,6 +788,9 @@ func exitNote(entry lineupEntry, side string, shortenNotes bool) string {
text += entry.leftAt + " "
}
text += replacement
if entry.replacedByYC != "" {
text += entry.replacedByYC
}
if side != "home" && entry.leftAt != "" {
text += " " + entry.leftAt
}
Expand Down Expand Up @@ -816,28 +831,19 @@ func annotateLineupPlayer(player site.PlayerLine, idx map[string][]site.MatchEve
if playerMatchKey(in) == key {
entry.enteredAt = minute
entry.replaced = out
entry.replacedYC = cardAnnotationName(out, idx)
}
if playerMatchKey(out) == key {
entry.leftAt = minute
entry.replacedBy = in
entry.replacedByYC = cardAnnotationName(in, idx)
}
}

return entry
}

func hasLineupBadgeEvent(key string, idx map[string][]site.MatchEvent) bool {
for _, event := range idx[key] {
switch event.Kind {
case "YC", "RC":
return true
}
}

return false
}

// Synthetic entrant rows inherit the same substitution annotations so later card badges keep context.
// Synthetic entrant rows are only added when a substitute was later replaced again.
func annotatedLineup(players []site.PlayerLine, idx map[string][]site.MatchEvent) []lineupEntry {
if len(players) == 0 {
return nil
Expand All @@ -858,11 +864,16 @@ func annotatedLineup(players []site.PlayerLine, idx map[string][]site.MatchEvent
if inKey == "" || addedSynthetic[inKey] {
continue
}
if _, exists := byKey[inKey]; exists || !hasLineupBadgeEvent(inKey, idx) {
if _, exists := byKey[inKey]; exists {
continue
}

synthetic := annotateLineupPlayer(site.PlayerLine{Name: entry.replacedBy}, idx)
if synthetic.replacedBy == "" {
continue
}

entries = append(entries, annotateLineupPlayer(site.PlayerLine{Name: entry.replacedBy}, idx))
entries = append(entries, synthetic)
addedSynthetic[inKey] = true
}

Expand Down
40 changes: 30 additions & 10 deletions internal/ui/view_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -614,25 +614,19 @@ func TestAnnotatedLineupDistinguishesSameInitialSameSurname(t *testing.T) {
}
}

func TestAnnotatedLineupAddsMissingEntrantWhenTheyHaveCardEvent(t *testing.T) {
func TestAnnotatedLineupKeepsBookedEntrantInReplacementNote(t *testing.T) {
idx := playerEventIndex([]site.MatchEvent{
{MinuteText: "66", Kind: "SUB", TeamSide: "home", Text: "Jason Lokilo -> Oskar Lesniak"},
{MinuteText: "84", Kind: "YC", TeamSide: "home", Text: "Oskar Lesniak 84"},
}, "home")

got := annotatedLineup([]site.PlayerLine{{Name: "Jason Lokilo"}}, idx)
if len(got) != 2 {
t.Fatalf("expected starter plus synthetic entrant, got %#v", got)
if len(got) != 1 {
t.Fatalf("expected only starter row, got %#v", got)
}
if got[0].player.Name != "Jason Lokilo" || got[0].replacedBy != "Oskar Lesniak" || got[0].leftAt != "66'" {
if got[0].player.Name != "Jason Lokilo" || got[0].replacedBy != "Oskar Lesniak" || got[0].leftAt != "66'" || ansi.Strip(got[0].replacedByYC) != "■" {
t.Fatalf("expected starter row to retain substitution note, got %#v", got)
}
if got[1].player.Name != "Oskar Lesniak" || got[1].replacedBy != "" {
t.Fatalf("expected entrant row added for card badge visibility, got %#v", got)
}
if got[1].enteredAt != "66'" || got[1].replaced != "Jason Lokilo" {
t.Fatalf("expected synthetic entrant row to retain entry note, got %#v", got)
}
}

func TestAnnotatedLineupSkipsMissingEntrantWithoutBadgeEvent(t *testing.T) {
Expand Down Expand Up @@ -668,6 +662,32 @@ func TestAnnotatedLineupSyntheticEntrantKeepsLaterSubstitutionOff(t *testing.T)
}
}

func TestFormatLineupPlayerShowsBookedReplacementInsideHomeNote(t *testing.T) {
home := formatLineupPlayer(lineupEntry{
player: site.PlayerLine{Name: "Oskar Jakubczyk"},
leftAt: "72'",
replacedBy: "Michal Rzuchowski",
replacedByYC: eventPrefix("YC"),
}, "home", 64)

if got := ansi.Strip(home); got != "(72' M. Rzuchowski■) O. Jakubczyk" {
t.Fatalf("unexpected home booked-replacement label: %q", got)
}
}

func TestFormatLineupPlayerShowsBookedReplacedPlayerInsideAwayNote(t *testing.T) {
away := formatLineupPlayer(lineupEntry{
player: site.PlayerLine{Name: "Jakub Sypek"},
enteredAt: "46'",
replaced: "Jan Kowalczyk",
replacedYC: eventPrefix("YC"),
}, "away", 64)

if got := ansi.Strip(away); got != "J. Sypek (for J. Kowalczyk ■ 46')" {
t.Fatalf("unexpected away booked-replaced label: %q", got)
}
}

func TestRenderLineupRowUsesCenteredSeparatorColumn(t *testing.T) {
row := renderLineupRow("K. Kubica", "B. Mrozek", 76)
rowMid := strings.Index(row, "|")
Expand Down