diff --git a/data/.gitignore b/data/.gitignore index aaadf73..e3251d1 100644 --- a/data/.gitignore +++ b/data/.gitignore @@ -24,6 +24,12 @@ profile.cov go.work go.work.sum +# Output +champions.json + +# Reference clone +LoLdleData/ + # env file .env diff --git a/data/config.go b/data/config.go new file mode 100644 index 0000000..6463254 --- /dev/null +++ b/data/config.go @@ -0,0 +1,3 @@ +package main + +const championsVersion = "16.2.1" diff --git a/data/go.mod b/data/go.mod new file mode 100644 index 0000000..2832332 --- /dev/null +++ b/data/go.mod @@ -0,0 +1,10 @@ +module github.com/tiennm99/loldle-data + +go 1.25.5 + +require github.com/PuerkitoBio/goquery v1.12.0 + +require ( + github.com/andybalholm/cascadia v1.3.3 // indirect + golang.org/x/net v0.52.0 // indirect +) diff --git a/data/go.sum b/data/go.sum new file mode 100644 index 0000000..bf43075 --- /dev/null +++ b/data/go.sum @@ -0,0 +1,71 @@ +github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo= +github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ= +github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= +github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/data/main.go b/data/main.go new file mode 100644 index 0000000..83dd5f4 --- /dev/null +++ b/data/main.go @@ -0,0 +1,51 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "os" + + "github.com/tiennm99/loldle-data/parser" +) + +func main() { + champions, err := parser.ParseChampions(championsVersion) + if err != nil { + log.Fatalf("Failed to parse champions: %v", err) + } + + champions, err = parser.EnrichReleaseDates(champions) + if err != nil { + log.Fatalf("Failed to enrich release dates: %v", err) + } + + champions, err = parser.EnrichRegions(champions) + if err != nil { + log.Fatalf("Failed to enrich regions: %v", err) + } + + champions, err = parser.EnrichLanes(champions) + if err != nil { + log.Fatalf("Failed to enrich lanes: %v", err) + } + + outputPath := "champions.json" + if err := saveJSON(outputPath, champions); err != nil { + log.Fatalf("Failed to save JSON: %v", err) + } + + fmt.Printf("Champion data has been saved to %s\n", outputPath) +} + +func saveJSON(path string, data []parser.ChampionResult) error { + file, err := os.Create(path) + if err != nil { + return err + } + defer file.Close() + + encoder := json.NewEncoder(file) + encoder.SetIndent("", " ") + return encoder.Encode(data) +} diff --git a/data/parser/champion.go b/data/parser/champion.go new file mode 100644 index 0000000..e593819 --- /dev/null +++ b/data/parser/champion.go @@ -0,0 +1,158 @@ +package parser + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "sync" + "time" +) + +// ddragonResponse represents the top-level ddragon championFull.json response. +type ddragonResponse struct { + Data map[string]ddragonChampion `json:"data"` +} + +type ddragonChampion struct { + ID string `json:"id"` + Name string `json:"name"` + Title string `json:"title"` + Partype string `json:"partype"` + Tags []string `json:"tags"` + Skins []json.RawMessage `json:"skins"` + Image interface{} `json:"image"` + Stats struct { + AttackRange float64 `json:"attackrange"` + } `json:"stats"` +} + +// ChampionResult holds the enriched data for a single champion. +type ChampionResult struct { + ID string `json:"id"` + Name string `json:"name"` + Title string `json:"title"` + Resource string `json:"resource"` + Genre string `json:"genre"` + SkinCount int `json:"skinCount"` + Image interface{} `json:"image"` + Gender string `json:"gender"` + AttackType string `json:"attackType"` + ReleaseDate int `json:"releaseDate,omitempty"` + Region string `json:"region,omitempty"` + Lane string `json:"lane,omitempty"` +} + +// ParseChampions fetches base champion data from ddragon and detects gender concurrently. +func ParseChampions(version string) ([]ChampionResult, error) { + url := fmt.Sprintf("https://ddragon.leagueoflegends.com/cdn/%s/data/en_US/championFull.json", version) + resp, err := http.Get(url) + if err != nil { + return nil, fmt.Errorf("fetching ddragon: %w", err) + } + defer resp.Body.Close() + + var ddResp ddragonResponse + if err := json.NewDecoder(resp.Body).Decode(&ddResp); err != nil { + return nil, fmt.Errorf("decoding ddragon: %w", err) + } + + // Fetch gender concurrently for all champions. + genderMap := make(map[string]string, len(ddResp.Data)) + var mu sync.Mutex + var wg sync.WaitGroup + sem := make(chan struct{}, 10) // limit to 10 concurrent requests + + for id := range ddResp.Data { + wg.Add(1) + go func(champID string) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + + gender := detectGender(champID) + mu.Lock() + genderMap[champID] = gender + mu.Unlock() + }(id) + } + wg.Wait() + + // Map data to result slice. + results := make([]ChampionResult, 0, len(ddResp.Data)) + for _, champ := range ddResp.Data { + attackType := "range" + if champ.Stats.AttackRange < 500 { + attackType = "close" + } + + results = append(results, ChampionResult{ + ID: champ.ID, + Name: champ.Name, + Title: champ.Title, + Resource: champ.Partype, + Genre: strings.Join(champ.Tags, ","), + SkinCount: len(champ.Skins), + Image: champ.Image, + Gender: genderMap[champ.ID], + AttackType: attackType, + }) + fmt.Printf("Data fetched for %s\n", champ.Name) + } + + return results, nil +} + +// universeChampionResponse represents the biography response from the Universe API. +type universeChampionResponse struct { + Champion struct { + Biography struct { + Full string `json:"full"` + } `json:"biography"` + } `json:"champion"` +} + +// detectGender infers champion gender from biography pronoun frequency. +func detectGender(championID string) string { + // Special case: Renata's URL slug differs from her ID. + urlID := strings.ToLower(championID) + if urlID == "renata" { + urlID = "renataglasc" + } + + url := fmt.Sprintf("https://universe-meeps.leagueoflegends.com/v1/en_us/champions/%s/index.json", urlID) + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Get(url) + if err != nil { + return "divers" + } + defer resp.Body.Close() + + var ucResp universeChampionResponse + if err := json.NewDecoder(resp.Body).Decode(&ucResp); err != nil { + return "divers" + } + + words := strings.Fields(strings.ToLower(ucResp.Champion.Biography.Full)) + maleKeywords := map[string]bool{"he": true, "him": true, "his": true} + femaleKeywords := map[string]bool{"she": true, "her": true, "hers": true} + + var maleCount, femaleCount int + for _, w := range words { + if maleKeywords[w] { + maleCount++ + } + if femaleKeywords[w] { + femaleCount++ + } + } + + switch { + case maleCount > femaleCount: + return "male" + case femaleCount > maleCount: + return "female" + default: + return "divers" + } +} diff --git a/data/parser/helpers.go b/data/parser/helpers.go new file mode 100644 index 0000000..3f6e474 --- /dev/null +++ b/data/parser/helpers.go @@ -0,0 +1,10 @@ +package parser + +// buildNameIndex creates a map from champion name to slice index for O(1) lookups. +func buildNameIndex(champions []ChampionResult) map[string]int { + idx := make(map[string]int, len(champions)) + for i, c := range champions { + idx[c.Name] = i + } + return idx +} diff --git a/data/parser/lane.go b/data/parser/lane.go new file mode 100644 index 0000000..2ce3743 --- /dev/null +++ b/data/parser/lane.go @@ -0,0 +1,70 @@ +package parser + +import ( + "fmt" + "net/http" + "strings" + + "github.com/PuerkitoBio/goquery" +) + +const wikiDraftPositionURL = "https://leagueoflegends.fandom.com/wiki/List_of_champions_by_draft_position" + +// columnLaneMap maps 1-indexed column positions to lane names. +var columnLaneMap = map[int]string{ + 1: "top", + 2: "jungle", + 3: "mid", + 4: "bottom", + 5: "support", +} + +// EnrichLanes scrapes the LoL Wiki draft position table to add lane data. +func EnrichLanes(champions []ChampionResult) ([]ChampionResult, error) { + resp, err := http.Get(wikiDraftPositionURL) + if err != nil { + return nil, fmt.Errorf("fetching wiki draft positions: %w", err) + } + defer resp.Body.Close() + + doc, err := goquery.NewDocumentFromReader(resp.Body) + if err != nil { + return nil, fmt.Errorf("parsing wiki HTML: %w", err) + } + + nameIndex := buildNameIndex(champions) + + // The second
contains the draft position table. + doc.Find("tbody").Eq(1).Find("tr").Each(func(_ int, row *goquery.Selection) { + cols := row.Find("td") + if cols.Length() == 0 { + return + } + + cols.Each(func(colIdx int, col *goquery.Selection) { + // Check if this column has a "Yes" indicator image. + if col.Find("img[alt='Yes']").Length() == 0 { + return + } + + // colIdx is 0-indexed; lane map uses the offset from the name column. + lane, ok := columnLaneMap[colIdx] + if !ok { + return + } + + name := strings.TrimSpace(cols.First().Find("a").AttrOr("title", "")) + name = strings.ReplaceAll(name, "/LoL", "") + + if idx, found := nameIndex[name]; found { + if champions[idx].Lane == "" { + champions[idx].Lane = lane + } else { + champions[idx].Lane += "," + lane + } + } + }) + }) + + return champions, nil +} diff --git a/data/parser/parser_test.go b/data/parser/parser_test.go new file mode 100644 index 0000000..e76670a --- /dev/null +++ b/data/parser/parser_test.go @@ -0,0 +1,59 @@ +package parser + +import ( + "fmt" + "net/http" + "testing" +) + +const testVersion = "16.2.1" + +func TestDdragonAPI(t *testing.T) { + url := fmt.Sprintf("https://ddragon.leagueoflegends.com/cdn/%s/data/en_US/championFull.json", testVersion) + resp, err := http.Get(url) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } +} + +func TestWikiDraftPosition(t *testing.T) { + resp, err := http.Get(wikiDraftPositionURL) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } +} + +func TestRegionAPI(t *testing.T) { + url := "https://universe-meeps.leagueoflegends.com/v1/en_us/factions/bilgewater/index.json" + resp, err := http.Get(url) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + ct := resp.Header.Get("Content-Type") + if ct != "application/json" { + t.Fatalf("expected application/json, got %s", ct) + } +} + +func TestWikiChampionList(t *testing.T) { + resp, err := http.Get(wikiChampionListURL) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } +} diff --git a/data/parser/region.go b/data/parser/region.go new file mode 100644 index 0000000..580cee7 --- /dev/null +++ b/data/parser/region.go @@ -0,0 +1,63 @@ +package parser + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" +) + +var regions = []string{ + "bandle-city", "bilgewater", "demacia", "ionia", "ixtal", "noxus", + "piltover", "shadow-isles", "shurima", "mount-targon", "freljord", "void", "zaun", +} + +// factionResponse represents the Universe API faction endpoint response. +type factionResponse struct { + AssociatedChampions []struct { + Name string `json:"name"` + } `json:"associated-champions"` +} + +// EnrichRegions fetches faction affiliations and adds region data to each champion. +func EnrichRegions(champions []ChampionResult) ([]ChampionResult, error) { + nameIndex := buildNameIndex(champions) + + for _, faction := range regions { + url := fmt.Sprintf("https://universe-meeps.leagueoflegends.com/v1/en_us/factions/%s/index.json", faction) + resp, err := http.Get(url) + if err != nil { + fmt.Printf("Can't load data for faction %s: %v\n", faction, err) + continue + } + + var fResp factionResponse + if err := json.NewDecoder(resp.Body).Decode(&fResp); err != nil { + resp.Body.Close() + fmt.Printf("Can't parse data for faction %s: %v\n", faction, err) + continue + } + resp.Body.Close() + + for _, assoc := range fResp.AssociatedChampions { + // Normalize Unicode apostrophe to ASCII. + champName := strings.ReplaceAll(assoc.Name, "\u2019", "'") + if idx, ok := nameIndex[champName]; ok { + if champions[idx].Region == "" { + champions[idx].Region = faction + } else { + champions[idx].Region += "," + faction + } + } + } + } + + // Default to "runeterra" for champions with no faction. + for i := range champions { + if champions[i].Region == "" { + champions[i].Region = "runeterra" + } + } + + return champions, nil +} diff --git a/data/parser/release_date.go b/data/parser/release_date.go new file mode 100644 index 0000000..5b20a0a --- /dev/null +++ b/data/parser/release_date.go @@ -0,0 +1,55 @@ +package parser + +import ( + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/PuerkitoBio/goquery" +) + +const wikiChampionListURL = "https://leagueoflegends.fandom.com/wiki/List_of_champions" + +// EnrichReleaseDates scrapes the LoL Wiki to add release year to each champion. +func EnrichReleaseDates(champions []ChampionResult) ([]ChampionResult, error) { + resp, err := http.Get(wikiChampionListURL) + if err != nil { + return nil, fmt.Errorf("fetching wiki champion list: %w", err) + } + defer resp.Body.Close() + + doc, err := goquery.NewDocumentFromReader(resp.Body) + if err != nil { + return nil, fmt.Errorf("parsing wiki HTML: %w", err) + } + + // Build a name-to-index map for O(1) lookups. + nameIndex := buildNameIndex(champions) + + // The first contains the champion list table. + doc.Find("tbody").First().Find("tr").Each(func(_ int, row *goquery.Selection) { + cols := row.Find("td") + if cols.Length() == 0 { + return + } + + name := strings.TrimSpace(cols.Eq(1).Find("a").AttrOr("title", "")) + name = strings.ReplaceAll(name, "/LoL", "") + + dateText := strings.TrimSpace(cols.Eq(3).Text()) + parts := strings.Split(dateText, "-") + yearStr := parts[len(parts)-1] + + year, err := strconv.Atoi(yearStr) + if err != nil { + return + } + + if idx, ok := nameIndex[name]; ok { + champions[idx].ReleaseDate = year + } + }) + + return champions, nil +}