feat: rewrite LoLdleData in Go

4-stage pipeline: fetch ddragon champions, enrich with release dates
(wiki scrape), regions (Universe API), and lanes (wiki scrape).
Concurrent gender detection via goroutines with semaphore.
This commit is contained in:
2026-04-04 18:06:48 +07:00
parent 361e09910b
commit cfa664f217
11 changed files with 556 additions and 0 deletions
+158
View File
@@ -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"
}
}
+10
View File
@@ -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
}
+70
View File
@@ -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 <tbody> 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
}
+59
View File
@@ -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)
}
}
+63
View File
@@ -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
}
+55
View File
@@ -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 <tbody> 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
}