mirror of
https://github.com/tiennm99/loldle.git
synced 2026-09-03 18:16:56 +00:00
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:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user