From 3a5c98c32c1a2ecb3fe4621379ad5c3b35ef7706 Mon Sep 17 00:00:00 2001 From: Nikolai Kolodziej Date: Tue, 7 Jul 2026 19:36:00 +0200 Subject: [PATCH] fix(steam): scope scraped mod IDs to the item's own Workshop ID Descriptions that advertise other mods by pasting their PZ footer (Workshop ID + Mod ID) leaked those IDs into the item, e.g. 3042138819 reported "TrueMusicJukebox, FunctionalAppliances2". Parse() now attributes each Mod ID / Map Folder to the nearest Workshop ID line (either order) and drops it only when that ID is foreign. Descriptions without the item's own Workshop ID are unchanged. --- pkg/steam/item.go | 79 +++++++++++++++++++++++---- pkg/steam/item_test.go | 118 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 188 insertions(+), 9 deletions(-) create mode 100644 pkg/steam/item_test.go diff --git a/pkg/steam/item.go b/pkg/steam/item.go index 7f30a40..f169b47 100644 --- a/pkg/steam/item.go +++ b/pkg/steam/item.go @@ -50,23 +50,84 @@ type ParsedItem struct { } // Parse scrapes the item description for the mod IDs and map folders the game -// uses. It tolerates the two casing variants Steam mod authors use in practice. +// uses. Some descriptions advertise *other* mods by reproducing their PZ footer +// (a "Workshop ID:" line paired with a "Mod ID:" / "Map Folder:" line), which +// would otherwise leak those foreign IDs into this item. To guard against that, +// each Mod ID / Map Folder is attributed to the nearest "Workshop ID:" line in +// either direction (authors write the pair in both orders, sometimes separated +// by a blank line; ties favour the item's own ID) and is dropped only when that +// nearest Workshop ID belongs to a *different* item. Attribution only kicks in +// once the item's own Workshop ID is seen; descriptions that never state it, or +// omit Workshop IDs entirely, keep every entry as before. It tolerates the +// casing variants ("Mod ID:"/"ModID:", "Workshop ID:"/"WorkshopID:") authors +// use in practice. func (w *WorkshopItem) Parse() *ParsedItem { + lines := strings.Split(w.Description, "\n") + + // First pass: locate every Workshop ID line and note whether our own appears. + type widLine struct { + idx int + own bool + } + var wids []widLine + ownSeen := false + for i, line := range lines { + if v, ok := fieldValue(strings.TrimSpace(line), "Workshop ID:", "WorkshopID:"); ok { + own := v != "" && v == w.PublishedFileID + wids = append(wids, widLine{idx: i, own: own}) + ownSeen = ownSeen || own + } + } + + // keep reports whether the footer entry on line idx should be attributed to + // this item. With no own Workshop ID to anchor against, keep everything. + keep := func(idx int) bool { + if !ownSeen { + return true + } + bestDist, nearestOwn := -1, true + for _, wl := range wids { + d := wl.idx - idx + if d < 0 { + d = -d + } + if bestDist == -1 || d < bestDist || (d == bestDist && wl.own) { + bestDist, nearestOwn = d, wl.own + } + } + return nearestOwn + } + var mods, maps []string - for _, line := range strings.Split(w.Description, "\n") { + for i, line := range lines { line = strings.TrimSpace(line) - switch { - case strings.HasPrefix(line, "Mod ID: "): - mods = append(mods, strings.TrimSpace(strings.TrimPrefix(line, "Mod ID: "))) - case strings.HasPrefix(line, "ModID: "): - mods = append(mods, strings.TrimSpace(strings.TrimPrefix(line, "ModID: "))) - case strings.HasPrefix(line, "Map Folder: "): - maps = append(maps, strings.TrimSpace(strings.TrimPrefix(line, "Map Folder: "))) + if v, ok := fieldValue(line, "Mod ID:", "ModID:"); ok { + if keep(i) { + mods = append(mods, v) + } + } else if v, ok := fieldValue(line, "Map Folder:"); ok { + if keep(i) { + maps = append(maps, v) + } } } return &ParsedItem{Mods: domain.Dedupe(mods), Maps: domain.Dedupe(maps)} } +// fieldValue returns the trimmed value of the first "Key value" line among keys, +// matching each key with a trailing space per the PZ footer convention (so +// "Mod ID:Foo" without the space is not treated as a footer line), and whether +// any key matched. +func fieldValue(line string, keys ...string) (string, bool) { + for _, k := range keys { + p := k + " " + if strings.HasPrefix(line, p) { + return strings.TrimSpace(strings.TrimPrefix(line, p)), true + } + } + return "", false +} + // IsCollection reports whether the item is a Workshop collection. func (w *WorkshopItem) IsCollection() bool { return w.FileType == FileTypeCollection } diff --git a/pkg/steam/item_test.go b/pkg/steam/item_test.go new file mode 100644 index 0000000..947384c --- /dev/null +++ b/pkg/steam/item_test.go @@ -0,0 +1,118 @@ +package steam + +import ( + "reflect" + "testing" +) + +func TestParseScopesToOwnWorkshopID(t *testing.T) { + tests := []struct { + name string + id string + desc string + wantMods []string + wantMaps []string + }{ + { + name: "advertised footer is ignored (real 3042138819 case)", + id: "3042138819", + desc: "The new multiplayer true music jukebox is now available!\n" + + "True Music Jukebox\n" + + "https://steamcommunity.com/sharedfiles/filedetails/?id=3118990099\n" + + "Workshop ID: 3118990099\n" + + "Mod ID: TrueMusicJukebox\n" + + "\n" + + "Workshop ID: 3042138819\n" + + "Mod ID: FunctionalAppliances2\n", + wantMods: []string{"FunctionalAppliances2"}, + wantMaps: []string{}, + }, + { + name: "several mod ids under the item's own workshop id are all kept", + id: "555", + desc: "A bundle.\n" + + "Workshop ID: 555\n" + + "Mod ID: ModA\n" + + "Mod ID: ModB\n", + wantMods: []string{"ModA", "ModB"}, + wantMaps: []string{}, + }, + { + name: "reversed footer (Mod ID before Workshop ID, blank between) is kept (real 3654929003 case)", + id: "3654929003", + desc: "Other batch action work:\n" + + "https://steamcommunity.com/sharedfiles/filedetails/?id=3584890848 batch recipe action\n" + + "https://steamcommunity.com/sharedfiles/filedetails/?id=3660401764 pick everything on floor\n" + + "\n" + + "Mod ID: vac_mod_b42_4\n" + + "\n" + + "Workshop ID: 3654929003\n", + wantMods: []string{"vac_mod_b42_4"}, + wantMaps: []string{}, + }, + { + name: "other mods advertised only as URLs do not cause drops", + id: "100", + desc: "Check out my other mods:\n" + + "https://steamcommunity.com/sharedfiles/filedetails/?id=999\n" + + "Mod ID: MyMod\n" + + "Workshop ID: 100\n", + wantMods: []string{"MyMod"}, + wantMaps: []string{}, + }, + { + name: "a separated foreign footer block is dropped, own block kept", + id: "100", + desc: "Workshop ID: 999\n" + + "Mod ID: ForeignMod\n" + + "Map Folder: ForeignMap\n" + + "\n" + + "\n" + + "Workshop ID: 100\n" + + "Mod ID: MyMod\n" + + "Map Folder: MyMap\n", + wantMods: []string{"MyMod"}, + wantMaps: []string{"MyMap"}, + }, + { + name: "no workshop id line falls back to keeping everything", + id: "42", + desc: "A simple mod.\n" + + "Mod ID: Simple\n" + + "Map Folder: SimpleMap\n", + wantMods: []string{"Simple"}, + wantMaps: []string{"SimpleMap"}, + }, + { + name: "casing variants are tolerated", + id: "88", + desc: "WorkshopID: 88\n" + + "ModID: CasedMod\n", + wantMods: []string{"CasedMod"}, + wantMaps: []string{}, + }, + { + name: "only a foreign workshop id present keeps everything (own id absent)", + id: "200", + desc: "Check out my other mod:\n" + + "Workshop ID: 999\n" + + "Mod ID: OtherMod\n" + + "Mod ID: BareMod\n", + wantMods: []string{"OtherMod", "BareMod"}, + wantMaps: []string{}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + w := WorkshopItem{PublishedFileID: tc.id, Description: tc.desc} + got := w.Parse() + if !reflect.DeepEqual(got.Mods, tc.wantMods) { + t.Errorf("mods = %#v, want %#v", got.Mods, tc.wantMods) + } + if !reflect.DeepEqual(got.Maps, tc.wantMaps) { + t.Errorf("maps = %#v, want %#v", got.Maps, tc.wantMaps) + } + }) + } +}