Current scope: tibiakt-core| all classes
|
com.galarzaa.tibiakt.core.section.community.character.parser
Coverage Summary for Class: CharacterParser (com.galarzaa.tibiakt.core.section.community.character.parser)
| Class | Class, % | Method, % | Branch, % | Line, % | Instruction, % |
|---|---|---|---|---|---|
| CharacterParser | 100% (1/1) | 100% (13/13) | 71.9% (110/153) | 98.7% (157/159) | 95.3% (1361/1428) |
/*
* Copyright © 2025 Allan Galarza
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.galarzaa.tibiakt.core.section.community.character.parser
import com.galarzaa.tibiakt.core.enums.StringEnum
import com.galarzaa.tibiakt.core.exceptions.ParsingException
import com.galarzaa.tibiakt.core.html.cleanText
import com.galarzaa.tibiakt.core.html.getLinkInformation
import com.galarzaa.tibiakt.core.html.parsePopup
import com.galarzaa.tibiakt.core.html.parseTables
import com.galarzaa.tibiakt.core.parser.Parser
import com.galarzaa.tibiakt.core.section.community.character.builder.CharacterBuilder
import com.galarzaa.tibiakt.core.section.community.character.builder.character
import com.galarzaa.tibiakt.core.section.community.character.model.CharacterInfo
import com.galarzaa.tibiakt.core.section.community.character.model.DeathParticipant
import com.galarzaa.tibiakt.core.text.clean
import com.galarzaa.tibiakt.core.text.remove
import com.galarzaa.tibiakt.core.text.removeLast
import com.galarzaa.tibiakt.core.text.splitList
import com.galarzaa.tibiakt.core.time.parseTibiaDate
import com.galarzaa.tibiakt.core.time.parseTibiaDateTime
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import org.jsoup.select.Elements
import kotlin.time.Instant
/** Parser for character information pages. */
public object CharacterParser : Parser<CharacterInfo?> {
private val deletedRegexp = Regex("""([^,]+), will be deleted at (.*)""")
private val titlesRegexp = Regex("""(.*)\((\d+) titles? unlocked\)""")
private val houseRegexp = Regex("""\(([^)]+)\) is paid until (.*)""")
private val deathsRegex = Regex("""Level (\d+) by (.*)\.</td>""")
private val deathAssistsRegex = Regex("""(?:(?<killers>.+)\.<br\s?/>)?Assisted by (?<assists>.+)""")
private val linkSearch = Regex("""<a[^>]+>[^<]+</a>""")
private val linkContent = Regex(""">([^<]+)<""")
private val deathSummon = Regex("""^(?<summon>.+) of (?<name>[^.]+?)\.?$""")
private const val tradedLabel = "(traded)"
override fun fromContent(content: String): CharacterInfo? {
val document: Document = Jsoup.parse(content, "", org.jsoup.parser.Parser.xmlParser())
val boxContent =
document.selectFirst("div.BoxContent") ?: throw ParsingException("BoxContent container not found")
val tables = boxContent.parseTables()
if (tables.keys.any { it.startsWith("Could not find character") }) return null
return character {
parseCharacterInformation(tables["Character Information"] ?: return null)
tables["Account Badges"]?.apply { parseAccountBadges(this) }
tables["Account Achievements"]?.apply { parseAccountAchievements(this) }
tables["Account Information"]?.apply { parseAccountInformation(this) }
tables["Character Deaths (Last 30 Days)"]?.apply { parseCharacterDeaths(this) }
tables["Characters"]?.apply { parseCharacters(this) }
}
}
private fun CharacterBuilder.parseCharacterInformation(rows: Elements) {
for (row: Element in rows) {
val columns = row.select("td")
var (field, value) = columns.map { it.wholeText().clean() }
field = field.replace(" ", "_").remove(":").lowercase()
when (field) {
"name" -> parseNameField(value)
"title" -> parseTitles(value)
"former_names" -> formerNames = value.split(",").map { it.trim() }
"former_world" -> formerWorld = value
"sex" -> sex = StringEnum.Companion.fromValue(value)!!
"vocation" -> vocation =
StringEnum.Companion.fromValue(value) ?: throw ParsingException("Unknown vocation: $value")
"level" -> level = value.toInt()
"achievement_points" -> achievementPoints = value.toInt()
"world" -> world = value
"residence" -> residence = value
"last_login" -> if (!value.contains("never logged", true)) {
lastLoginAt = parseTibiaDateTime(value)
}
"position" -> position = value
"comment" -> comment = value
"account_status" -> isPremium = "premium" in value.lowercase()
"married_to" -> marriedTo = value
"house" -> parseHouseColumn(columns[1])
"guild_membership" -> parseGuildColumn(columns[1])
}
}
}
private fun CharacterBuilder.parseGuildColumn(valueColumn: Element) {
val guildName = valueColumn.selectFirst("a")?.text() ?: return
val rankName = valueColumn.text().split("of the").first().trim()
guild(rankName, guildName)
}
private fun CharacterBuilder.parseHouseColumn(valueColumn: Element) {
val match = houseRegexp.find(valueColumn.ownText()) ?: return
val link = valueColumn.selectFirst("a")?.getLinkInformation() ?: return
val (_, town, paidUntilStr) = match.groupValues
addHouse(
name = link.title,
houseId = link.queryParams["houseid"]?.first()?.toInt() ?: return,
town = town,
paidUntil = parseTibiaDate(paidUntilStr),
world = link.queryParams["world"]?.first() ?: return
)
}
private fun CharacterBuilder.parseTitles(value: String) {
val match = titlesRegexp.find(value) ?: return
val (_, currentTitle, unlockedTitlesStr) = match.groupValues
title = if (currentTitle.contains("none", true)) null else currentTitle.trim()
unlockedTitles = unlockedTitlesStr.toInt()
}
private fun CharacterBuilder.parseNameField(value: String) {
val match = deletedRegexp.matchEntire(value)
name = if (match != null) {
val (_, cleanName, deletionDateStr) = match.groupValues
deletionScheduledAt = parseTibiaDateTime(deletionDateStr)
cleanName
} else {
value
}
if (name.contains(tradedLabel)) {
isRecentlyTraded = true
name = name.remove(tradedLabel).trim()
}
}
private fun CharacterBuilder.parseAccountBadges(rows: Elements) {
val row = rows[0]
for (column: Element in row.select("td > span")) {
val popupSpan = column.selectFirst("span.HelperDivIndicator") ?: return
val (title: String, popupContent: Document) = parsePopup(popupSpan.attr("onmouseover"))
val description = popupContent.text()
val imageUrl = column.selectFirst("img")?.attr("src")
addBadge(title, description, imageUrl ?: continue)
}
}
private fun CharacterBuilder.parseAccountAchievements(rows: Elements) {
for (row: Element in rows) {
val columns = row.select("td")
if (columns.size != 2) continue
val (gradeColumn, nameColumn) = columns
val grade = gradeColumn.select("img").size
val name = nameColumn.text()
val isSecret = nameColumn.selectFirst("img") != null
addAchievement(name, grade, isSecret)
}
}
private fun CharacterBuilder.parseAccountInformation(rows: Elements) {
val valueMap = mutableMapOf<String, String>()
for (row: Element in rows) {
val columns = row.select("td")
var (field, value) = columns.map { it.wholeText().clean() }
field = field.replace(" ", "_").remove(":").lowercase()
valueMap[field] = value
}
accountInformation(
created = parseTibiaDateTime(valueMap["created"] ?: return),
loyaltyTitle = valueMap["loyalty_title"],
position = valueMap["position"],
)
}
private fun CharacterBuilder.parseCharacterDeaths(rows: Elements) {
for (row: Element in rows) {
val columns = row.select("td")
if (columns.size != 2) continue
val (dateColumn, descriptionColumn) = columns
val deathDateTime: Instant = parseTibiaDateTime(dateColumn.text())
val deathMatch = deathsRegex.find(descriptionColumn.toString())
var (_, levelStr, killersDesc) = deathMatch?.groupValues ?: Triple(
"", "0", descriptionColumn.toString()
).toList()
var assistNameList: List<String> = mutableListOf()
deathAssistsRegex.find(killersDesc)?.apply {
killersDesc = this.groups["killers"]?.value.orEmpty()
val assistsDec = this.groups["assists"]?.value ?: return
assistNameList = linkSearch.findAll(assistsDec).map { it.value }.toList()
}
val killerNameList = killersDesc.splitList()
val killerList = killerNameList.mapNotNull { parseKiller(it) }
val assistsList = assistNameList.mapNotNull { parseKiller(it) }
addDeath(deathDateTime, levelStr.toInt(), killerList, assistsList)
}
}
internal fun parseKiller(killerHtml: String): DeathParticipant? {
val doc = Jsoup.parse(killerHtml)
val characterLink = doc.selectFirst("a")
characterLink?.remove()
val text = doc.wholeText().trim()
val tradedInText = text.contains(tradedLabel, ignoreCase = true)
// There is a character link -> it's definitely a player, possibly a summoner.
if (characterLink != null) {
val summonerName = characterLink.cleanText()
// Remove "(traded)" from the remaining text, so we can reliably detect " of".
val remainingText = text.remove(tradedLabel).trim()
// No remaining text (or only "(traded)") => plain player
if (remainingText.isBlank()) {
return DeathParticipant.Player(
name = summonerName,
isTraded = tradedInText,
)
}
// Remaining text => "<summon> of" (the "of" separator is here)
val summonName = remainingText.removeLast(" of").clean()
return DeathParticipant.Summon(
name = summonName,
summonerName = summonerName,
summonerIsTraded = tradedInText,
)
}
// No link, no traded label => plain creature (even if it has "of" in the name).
if (!tradedInText) {
return DeathParticipant.Creature(text.clean())
}
// No link, but has "(traded)".
// Try to parse as "summon of NAME (traded)" using your regex.
deathSummon.find(text)?.apply {
val summonedCreature = groups["summon"]!!.value.clean()
val summonerNameRaw = groups["name"]!!.value.clean()
return DeathParticipant.Summon(
summonerName = summonerNameRaw.remove(tradedLabel).trim(),
name = summonedCreature,
summonerIsTraded = true,
)
}
// If regex doesn’t match, then it’s just a traded player without a link.
val playerName = text.remove(tradedLabel).trim().clean()
return DeathParticipant.Player(
name = playerName,
isTraded = true,
)
}
private fun CharacterBuilder.parseCharacters(rows: Elements) {
for (row: Element in rows.subList(1, rows.size)) {
val columns = row.select("td")
val (nameColumn, worldColumn, statusColumn, _) = columns
var isTraded = false
var name = nameColumn.text().splitList(".").last().clean()
if (name.contains(tradedLabel, true)) {
name = name.remove(tradedLabel).trim()
isTraded = true
}
val isMain = nameColumn.selectFirst("img") != null
val world = worldColumn.text().clean()
val status = statusColumn.text().clean()
val isOnline = status.contains("online")
val isDeleted = status.contains("deleted")
val position = if (status.contains("CipSoft Member")) "CipSoft Member" else null
addOtherCharacter(name, world, isMain, isOnline, isDeleted, isTraded, position)
}
}
}