dragon-item-parser: introduce item parser library
Some checks failed
Dragon Item Parser CI / build-and-test (push) Successful in 1m3s
pipeline / lint-and-format (push) Failing after 4m8s
pipeline / build-and-push-images (push) Has been skipped

This commit is contained in:
2026-04-25 19:48:06 +02:00
parent a98e3c6589
commit e82ad73de1
17 changed files with 7689 additions and 5022 deletions

3
dragon-item-parser/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
dist/
*.log

View File

@@ -0,0 +1,10 @@
{
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "none",
"printWidth": 100,
"bracketSpacing": true,
"arrowParens": "avoid",
"endOfLine": "lf"
}

View File

@@ -0,0 +1,19 @@
import { defineConfig } from 'eslint/config'
import js from '@eslint/js'
import tseslint from 'typescript-eslint'
import prettier from 'eslint-config-prettier'
export default defineConfig([
js.configs.recommended,
tseslint.configs.recommended,
prettier,
{
ignores: ['dist/**', 'node_modules/**']
},
{
rules: {
semi: 'off',
'prefer-const': 'error'
}
}
])

2886
dragon-item-parser/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,53 @@
{
"name": "dragon-item-parser",
"version": "1.0.0",
"description": "Parse League of Legends item stats from CommunityDragon data",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"format": "prettier --write .",
"format:check": "prettier --check .",
"prepublishOnly": "npm run build"
},
"keywords": [
"league-of-legends",
"cdragon",
"communitydragon",
"item",
"parser",
"lol"
],
"author": "",
"license": "MIT",
"repository": {
"type": "git",
"url": ""
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@typescript-eslint/eslint-plugin": "^8.53.1",
"@typescript-eslint/parser": "^8.53.1",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"prettier": "^3.8.3",
"typescript": "^5.0.0",
"typescript-eslint": "^8.53.1",
"vitest": "^3.0.0"
}
}

View File

@@ -0,0 +1,2 @@
export type { ItemStats, CDragonItem, ItemWithStats } from './item.js'
export { parseItemStats, parseItem, parseItems } from './item.js'

View File

@@ -0,0 +1,202 @@
/**
* Item stats parsed from CommunityDragon item description HTML
*/
export interface ItemStats {
// Offensive stats
attackDamage?: number
abilityPower?: number
attackSpeed?: number
criticalStrikeChance?: number
lifeSteal?: number
omnivamp?: number
physicalVamp?: number
spellVamp?: number
// Defensive stats
health?: number
armor?: number
magicResist?: number
// Resource stats
mana?: number
baseManaRegen?: number
baseHealthRegen?: number
// Movement stats
moveSpeed?: number
// Ability stats
abilityHaste?: number
// Penetration stats (usually percentages)
armorPenetration?: number
magicPenetration?: number
lethality?: number
magicPenetrationFlat?: number
// Other percentage stats
healAndShieldPower?: number
tenacity?: number
slowResist?: number
}
/**
* Stat name mappings from CDragon description text to stat keys
*/
const STAT_MAPPINGS: Record<string, keyof ItemStats> = {
'Attack Damage': 'attackDamage',
'Ability Power': 'abilityPower',
'Attack Speed': 'attackSpeed',
'Critical Strike Chance': 'criticalStrikeChance',
'Life Steal': 'lifeSteal',
Omnivamp: 'omnivamp',
'Physical Vamp': 'physicalVamp',
'Spell Vamp': 'spellVamp',
Health: 'health',
Armor: 'armor',
'Magic Resist': 'magicResist',
Mana: 'mana',
'Base Mana Regen': 'baseManaRegen',
'Base Health Regen': 'baseHealthRegen',
'Move Speed': 'moveSpeed',
'Ability Haste': 'abilityHaste',
'Armor Penetration': 'armorPenetration',
'Magic Penetration': 'magicPenetration',
Lethality: 'lethality',
'Heal and Shield Power': 'healAndShieldPower',
Tenacity: 'tenacity',
'Slow Resist': 'slowResist'
}
/**
* Parse a stat value string to a number
* Handles both flat values (e.g., "25") and percentages (e.g., "25%")
*/
function parseStatValue(valueStr: string): { value: number; isPercentage: boolean } {
const trimmed = valueStr.trim()
const isPercentage = trimmed.includes('%')
const numStr = trimmed.replace('%', '').replace(',', '').trim()
const value = parseFloat(numStr)
return { value, isPercentage }
}
/**
* Extract stats section from the description HTML
*/
function extractStatsSection(description: string): string | null {
const statsMatch = description.match(/<stats>(.*?)<\/stats>/s)
return statsMatch ? statsMatch[1] : null
}
/**
* Parse individual stat lines from the stats section
* Format: <attention> value </attention> statName
*/
function parseStatLines(
statsSection: string
): Array<{ value: number; isPercentage: boolean; statName: string }> {
const results: Array<{ value: number; isPercentage: boolean; statName: string }> = []
// Match patterns like: <attention> 25</attention> Move Speed
// or: <attention> 25%</attention> Attack Speed
const statRegex = /<attention>\s*([^<]+)<\/attention>\s*([^<]+)/g
let match
while ((match = statRegex.exec(statsSection)) !== null) {
const valueStr = match[1]
const statName = match[2].trim()
const { value, isPercentage } = parseStatValue(valueStr)
if (!isNaN(value) && statName) {
results.push({ value, isPercentage, statName })
}
}
return results
}
/**
* Parse item stats from CDragon description HTML
*
* @param description - The HTML description string from CDragon items.json
* @returns Parsed ItemStats object with all recognized stats
*
* @example
* ```ts
* const stats = parseItemStats(
* '<mainText><stats><attention> 25</attention> Move Speed</stats><br><br></mainText>'
* )
* // Returns: { moveSpeed: 25 }
* ```
*/
export function parseItemStats(description: string): ItemStats {
const stats: ItemStats = {}
if (!description) {
return stats
}
const statsSection = extractStatsSection(description)
if (!statsSection) {
return stats
}
const statLines = parseStatLines(statsSection)
for (const { value, statName } of statLines) {
const statKey = STAT_MAPPINGS[statName]
if (statKey) {
// For percentage stats that are stored as decimals (e.g., 25% -> 25)
// We store the percentage value as-is for consistency with game data
stats[statKey] = value
}
}
return stats
}
/**
* Item data structure from CDragon
*/
export interface CDragonItem {
id: number
name: string
description: string
active?: boolean
inStore?: boolean
from?: number[]
to?: number[]
categories?: string[]
maxStacks?: number
requiredChampion?: string
requiredAlly?: string
price?: number
priceTotal?: number
iconPath: string
}
/**
* Item with parsed stats
*/
export interface ItemWithStats extends CDragonItem {
stats: ItemStats
}
/**
* Parse a CDragon item and add parsed stats
*/
export function parseItem(item: CDragonItem): ItemWithStats {
return {
...item,
stats: parseItemStats(item.description)
}
}
/**
* Parse an array of CDragon items and add parsed stats
*/
export function parseItems(items: CDragonItem[]): ItemWithStats[] {
return items.map(parseItem)
}

View File

@@ -0,0 +1,235 @@
import { describe, it, expect } from 'vitest'
import { parseItemStats, parseItem, parseItems } from '../src/item.js'
describe('parseItemStats', () => {
describe('basic stats', () => {
it('should parse Move Speed', () => {
// Boots (id: 1001)
const description =
'<mainText><stats><attention> 25</attention> Move Speed</stats><br><br></mainText>'
const stats = parseItemStats(description)
expect(stats.moveSpeed).toBe(25)
})
it('should parse Attack Damage', () => {
// Long Sword (id: 1036)
const description =
'<mainText><stats><attention> 10</attention> Attack Damage</stats><br><br></mainText>'
const stats = parseItemStats(description)
expect(stats.attackDamage).toBe(10)
})
it('should parse Ability Power', () => {
// Amplifying Tome (id: 1052)
const description =
'<mainText><stats><attention> 20</attention> Ability Power</stats><br><br></mainText>'
const stats = parseItemStats(description)
expect(stats.abilityPower).toBe(20)
})
it('should parse Health', () => {
// Ruby Crystal (id: 1028)
const description =
'<mainText><stats><attention> 150</attention> Health</stats><br><br></mainText>'
const stats = parseItemStats(description)
expect(stats.health).toBe(150)
})
it('should parse Armor', () => {
// Cloth Armor (id: 1029)
const description =
'<mainText><stats><attention> 15</attention> Armor</stats><br><br></mainText>'
const stats = parseItemStats(description)
expect(stats.armor).toBe(15)
})
it('should parse Magic Resist', () => {
// Null-Magic Mantle (id: 1033)
const description =
'<mainText><stats><attention> 20</attention> Magic Resist</stats><br><br></mainText>'
const stats = parseItemStats(description)
expect(stats.magicResist).toBe(20)
})
it('should parse Mana', () => {
// Sapphire Crystal (id: 1027)
const description =
'<mainText><stats><attention> 300</attention> Mana</stats><br><br></mainText>'
const stats = parseItemStats(description)
expect(stats.mana).toBe(300)
})
})
describe('percentage stats', () => {
it('should parse Attack Speed percentage', () => {
// Dagger (id: 1042)
const description =
'<mainText><stats><attention> 10%</attention> Attack Speed</stats><br><br></mainText>'
const stats = parseItemStats(description)
expect(stats.attackSpeed).toBe(10)
})
it('should parse Critical Strike Chance', () => {
// Cloak of Agility (id: 1018)
const description =
'<mainText><stats><attention> 15%</attention> Critical Strike Chance</stats><br><br></mainText>'
const stats = parseItemStats(description)
expect(stats.criticalStrikeChance).toBe(15)
})
it('should parse Life Steal', () => {
// Vampiric Scepter (id: 1053)
const description =
'<mainText><stats><attention> 15</attention> Attack Damage<br><attention> 7%</attention> Life Steal</stats><br><br></mainText>'
const stats = parseItemStats(description)
expect(stats.attackDamage).toBe(15)
expect(stats.lifeSteal).toBe(7)
})
it('should parse Base Mana Regen percentage', () => {
// Faerie Charm (id: 1004)
const description =
'<mainText><stats><attention> 50%</attention> Base Mana Regen</stats><br><br></mainText>'
const stats = parseItemStats(description)
expect(stats.baseManaRegen).toBe(50)
})
it('should parse Base Health Regen percentage', () => {
// Rejuvenation Bead (id: 1006)
const description =
'<mainText><stats><attention> 100%</attention> Base Health Regen</stats><br><br></mainText>'
const stats = parseItemStats(description)
expect(stats.baseHealthRegen).toBe(100)
})
})
describe('multiple stats', () => {
it("should parse multiple stats from Doran's Blade", () => {
// Doran's Blade (id: 1055)
const description =
'<mainText><stats><attention> 10</attention> Attack Damage<br><attention> 80</attention> Health<br><attention> 2.5%</attention> Omnivamp</stats><br><br></mainText>'
const stats = parseItemStats(description)
expect(stats.attackDamage).toBe(10)
expect(stats.health).toBe(80)
expect(stats.omnivamp).toBe(2.5)
})
it("should parse multiple stats from Doran's Ring", () => {
// Doran's Ring (id: 1056)
const description =
'<mainText><stats><attention> 18</attention> Ability Power<br><attention> 90</attention> Health</stats><br><br></mainText>'
const stats = parseItemStats(description)
expect(stats.abilityPower).toBe(18)
expect(stats.health).toBe(90)
})
it("should parse multiple stats from Seeker's Armguard", () => {
// Seeker's Armguard (id: 2420)
const description =
'<mainText><stats><attention> 40</attention> Ability Power<br><attention> 25</attention> Armor</stats><br><br></mainText>'
const stats = parseItemStats(description)
expect(stats.abilityPower).toBe(40)
expect(stats.armor).toBe(25)
})
it('should parse complex item with many stats', () => {
// Overlord's Bloodmail (id: 2501)
const description =
'<mainText><stats><attention> 30</attention> Attack Damage<br><attention> 550</attention> Health</stats><br><br></mainText>'
const stats = parseItemStats(description)
expect(stats.attackDamage).toBe(30)
expect(stats.health).toBe(550)
})
it('should parse item with Ability Haste', () => {
// Unending Despair (id: 2502)
const description =
'<mainText><stats><attention> 400</attention> Health<br><attention> 50</attention> Armor<br><attention> 15</attention> Ability Haste</stats><br><br></mainText>'
const stats = parseItemStats(description)
expect(stats.health).toBe(400)
expect(stats.armor).toBe(50)
expect(stats.abilityHaste).toBe(15)
})
it('should parse item with Mana and Ability Haste', () => {
// Blackfire Torch (id: 2503)
const description =
'<mainText><stats><attention> 80</attention> Ability Power<br><attention> 600</attention> Mana<br><attention> 20</attention> Ability Haste</stats><br><br></mainText>'
const stats = parseItemStats(description)
expect(stats.abilityPower).toBe(80)
expect(stats.mana).toBe(600)
expect(stats.abilityHaste).toBe(20)
})
})
describe('edge cases', () => {
it('should return empty object for empty description', () => {
const stats = parseItemStats('')
expect(stats).toEqual({})
})
it('should return empty object for description without stats', () => {
const description = '<mainText><stats></stats><br><br></mainText>'
const stats = parseItemStats(description)
expect(stats).toEqual({})
})
it('should handle description with only passive text', () => {
// Emberknife (id: 1035) - no stats, only passives
const description =
'<mainText><stats></stats><br><br> <passive>7%</passive> Omnivamp against jungle monsters<br><li><passive>Sear:</passive> Damaging jungle monsters burns them for <magicDamage> magic damage</magicDamage> over 5 seconds.</mainText>'
const stats = parseItemStats(description)
expect(stats).toEqual({})
})
it('should handle decimal values', () => {
const description =
'<mainText><stats><attention> 2.5%</attention> Omnivamp</stats><br><br></mainText>'
const stats = parseItemStats(description)
expect(stats.omnivamp).toBe(2.5)
})
})
})
describe('parseItem', () => {
it('should parse item and add stats', () => {
const item = {
id: 1001,
name: 'Boots',
description:
'<mainText><stats><attention> 25</attention> Move Speed</stats><br><br></mainText>',
iconPath: '/path/to/icon.png'
}
const result = parseItem(item)
expect(result.id).toBe(1001)
expect(result.name).toBe('Boots')
expect(result.stats.moveSpeed).toBe(25)
})
})
describe('parseItems', () => {
it('should parse multiple items', () => {
const items = [
{
id: 1001,
name: 'Boots',
description:
'<mainText><stats><attention> 25</attention> Move Speed</stats><br><br></mainText>',
iconPath: '/path/to/boots.png'
},
{
id: 1036,
name: 'Long Sword',
description:
'<mainText><stats><attention> 10</attention> Attack Damage</stats><br><br></mainText>',
iconPath: '/path/to/sword.png'
}
]
const results = parseItems(items)
expect(results).toHaveLength(2)
expect(results[0].stats.moveSpeed).toBe(25)
expect(results[1].stats.attackDamage).toBe(10)
})
})

View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test"]
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['test/**/*.test.ts']
}
})