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)
         }
     }
 }