diff --git a/internal/site/match_parser.go b/internal/site/match_parser.go index b0ff86b..0f59725 100644 --- a/internal/site/match_parser.go +++ b/internal/site/match_parser.go @@ -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}`) @@ -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...) } }) @@ -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. @@ -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 { diff --git a/internal/site/parser_edgecases_test.go b/internal/site/parser_edgecases_test.go index bcb484d..cd5268b 100644 --- a/internal/site/parser_edgecases_test.go +++ b/internal/site/parser_edgecases_test.go @@ -135,3 +135,53 @@ func TestParseMatchPageMissedPenaltyTimelineEvent(t *testing.T) { t.Fatalf("unexpected missed penalty text: %#v", event) } } + +func TestParseMatchPageSubstitutionCellAssignsCardsToBothPlayers(t *testing.T) { + html := ` + Match Test + + + + + + + +
Ekstraklasa
20 marca 2026, 18:00
Arka Gdynia3 - 1Zaglebie Lubin
Oskar Jakubczyk ZK
+   + 72 Michal Rzuchowski ZK
+ ` + + 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) + } +} diff --git a/internal/ui/render_helpers.go b/internal/ui/render_helpers.go index d2f60d8..e720afd 100644 --- a/internal/ui/render_helpers.go +++ b/internal/ui/render_helpers.go @@ -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 "" } @@ -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. @@ -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 + ")") } @@ -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 + ")") } @@ -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 } @@ -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 @@ -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 } diff --git a/internal/ui/view_test.go b/internal/ui/view_test.go index 54fbe5c..e5601b6 100644 --- a/internal/ui/view_test.go +++ b/internal/ui/view_test.go @@ -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) { @@ -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, "|")