Skip to content

Commit c30eed8

Browse files
committed
simply character parser
1 parent 3d88373 commit c30eed8

File tree

3 files changed

+93
-150
lines changed

3 files changed

+93
-150
lines changed

tibiapy/builders/character.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,10 @@ def is_premium(self, is_premium: bool) -> Self:
126126
self._is_premium = is_premium
127127
return self
128128

129+
def add_account_badge(self, account_badge: AccountBadge) -> Self:
130+
self._account_badges.append(account_badge)
131+
return self
132+
129133
def account_badges(self, account_badges: List[AccountBadge]) -> Self:
130134
self._account_badges = account_badges
131135
return self
@@ -134,6 +138,10 @@ def achievements(self, achievements: List[Achievement]) -> Self:
134138
self._achievements = achievements
135139
return self
136140

141+
def add_achievement(self, achievement: Achievement) -> Self:
142+
self._achievements.append(achievement)
143+
return self
144+
137145
def deaths(self, deaths: List[Death]) -> Self:
138146
self._deaths = deaths
139147
return self
@@ -150,6 +158,10 @@ def account_information(self, account_information: Optional[AccountInformation])
150158
self._account_information = account_information
151159
return self
152160

161+
def add_other_character(self, other_character: OtherCharacter) -> Self:
162+
self._other_characters.append(other_character)
163+
return self
164+
153165
def other_characters(self, other_characters: List[OtherCharacter]) -> Self:
154166
self._other_characters = other_characters
155167
return self

tibiapy/models/character.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ class DeathParticipant(BaseModel):
8585
"""Whether the killer is a player or not."""
8686
summon: Optional[str] = None
8787
"""The name of the summoned creature, if applicable."""
88-
is_traded: bool
88+
is_traded: bool = False
8989
"""If the killer was traded after this death happened."""
9090

9191
@property

tibiapy/parsers/character.py

Lines changed: 80 additions & 149 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
"""Models related to the Tibia.com character page."""
22
from __future__ import annotations
33

4+
import logging
45
import re
56
from collections import OrderedDict
6-
from typing import List, Optional, TYPE_CHECKING
7+
from typing import Callable, Dict, List, Optional, TYPE_CHECKING
78

89
from tibiapy.builders import CharacterBuilder
910
from tibiapy.enums import Sex, Vocation
@@ -40,6 +41,8 @@
4041
"CharacterParser",
4142
)
4243

44+
logger = logging.getLogger(__name__)
45+
4346

4447
class CharacterParser:
4548
"""A parser for characters from Tibia.com."""
@@ -70,32 +73,31 @@ def from_content(cls, content: str) -> Optional[Character]:
7073
if messsage_table and "Could not find character" in messsage_table.text:
7174
return None
7275

73-
if "Character Information" in tables:
74-
cls._parse_character_information(builder, tables["Character Information"])
75-
else:
76+
table_parsers = {
77+
"Character Information": lambda t: cls._parse_character_information(builder, t),
78+
"Account Badges": lambda t: cls._parse_account_badges(builder, t),
79+
"Account Achievements": lambda t: cls._parse_achievements(builder, t),
80+
"Account Information": lambda t: cls._parse_account_information(builder, t),
81+
"Character Deaths": lambda t: cls._parse_deaths(builder, t),
82+
"Characters": lambda t: cls._parse_other_characters(builder, t),
83+
}
84+
85+
if "Character Information" not in tables:
7686
raise InvalidContent("content does not contain a tibia.com character information page.")
7787

78-
builder.achievements(cls._parse_achievements(tables.get("Account Achievements", [])))
79-
if "Account Badges" in tables:
80-
builder.account_badges(cls._parse_badges(tables["Account Badges"]))
88+
for title, table in tables.items():
89+
if title in table_parsers:
90+
action = table_parsers[title]
91+
action(table)
8192

82-
cls._parse_deaths(builder, tables.get("Character Deaths", []))
83-
builder.account_information(cls._parse_account_information(tables.get("Account Information", [])))
84-
builder.other_characters(cls._parse_other_characters(tables.get("Characters", [])))
8593
return builder.build()
8694

8795
@classmethod
88-
def _parse_account_information(cls, rows):
89-
"""Parse the character's account information.
90-
91-
Parameters
92-
----------
93-
rows: :class:`list` of :class:`bs4.Tag`, optional
94-
A list of all rows contained in the table.
95-
"""
96+
def _parse_account_information(cls, builder: CharacterBuilder, rows: list[bs4.Tag]):
97+
"""Parse the character's account information."""
9698
acc_info = {}
9799
if not rows:
98-
return None
100+
return
99101

100102
for row in rows:
101103
cols_raw = row.select("td")
@@ -108,18 +110,11 @@ def _parse_account_information(cls, rows):
108110
created = parse_tibia_datetime(acc_info["created"])
109111
loyalty_title = None if acc_info["loyalty_title"] == "(no title)" else acc_info["loyalty_title"]
110112
position = acc_info.get("position")
111-
return AccountInformation(created=created, loyalty_title=loyalty_title, position=position)
113+
builder.account_information(AccountInformation(created=created, loyalty_title=loyalty_title, position=position))
112114

113115
@classmethod
114-
def _parse_achievements(cls, rows):
115-
"""Parse the character's displayed achievements.
116-
117-
Parameters
118-
----------
119-
rows: :class:`list` of :class:`bs4.Tag`
120-
A list of all rows contained in the table.
121-
"""
122-
achievements = []
116+
def _parse_achievements(cls, builder: CharacterBuilder, rows: List[bs4.Tag]):
117+
"""Parse the character's displayed achievements."""
123118
for row in rows:
124119
cols = row.select("td")
125120
if len(cols) != 2:
@@ -129,94 +124,62 @@ def _parse_achievements(cls, rows):
129124
grade = str(field).count("achievement-grade-symbol")
130125
name = value.text.strip()
131126
secret_image = value.select_one("img")
132-
secret = False
133-
if secret_image:
134-
secret = True
135-
136-
achievements.append(Achievement(name=name, grade=grade, is_secret=secret))
127+
secret = secret_image is not None
137128

138-
return achievements
129+
builder.add_achievement(Achievement(name=name, grade=grade, is_secret=secret))
139130

140131
@classmethod
141-
def _parse_badges(cls, rows: List[bs4.Tag]):
142-
"""Parse the character's displayed badges.
143-
144-
Parameters
145-
----------
146-
rows: :class:`list` of :class:`bs4.Tag`
147-
A list of all rows contained in the table.
148-
"""
132+
def _parse_account_badges(cls, builder: CharacterBuilder, rows: List[bs4.Tag]):
133+
"""Parse the character's displayed badges."""
149134
row = rows[0]
150135
columns = row.select("td > span")
151-
account_badges = []
152136
for column in columns:
153137
popup_span = column.select_one("span.HelperDivIndicator")
154138
if not popup_span:
155139
# Badges are visible, but none selected.
156-
return []
140+
return
157141

158142
popup = parse_popup(popup_span["onmouseover"])
159143
name = popup[0]
160144
description = popup[1].text
161145
icon_image = column.select_one("img")
162146
icon_url = icon_image["src"]
163-
account_badges.append(AccountBadge(name=name, icon_url=icon_url, description=description))
164-
165-
return account_badges
147+
builder.add_account_badge(AccountBadge(name=name, icon_url=icon_url, description=description))
166148

167149
@classmethod
168-
def _parse_character_information(cls, builder: CharacterBuilder, rows):
169-
"""
170-
Parse the character's basic information and applies the found values.
150+
def _parse_character_information(cls, builder: CharacterBuilder, rows: List[bs4.Tag]):
151+
"""Parse the character's basic information and applies the found values."""
152+
field_actions: dict[str, Callable[[bs4.Tag, str], None]] = {
153+
"name": lambda rv, v: cls._parse_name_field(builder, v),
154+
"title": lambda rv, v: cls._parse_titles(builder, v),
155+
"former names": lambda rv, v: builder.former_names([fn.strip() for fn in v.split(",")]),
156+
"former world": lambda rv, v: builder.former_world(v),
157+
"sex": lambda rv, v: builder.sex(try_enum(Sex, v)),
158+
"vocation": lambda rv, v: builder.vocation(try_enum(Vocation, v)),
159+
"level": lambda rv, v: builder.level(parse_integer(v)),
160+
"achievement points": lambda rv, v: builder.achievement_points(parse_integer(v)),
161+
"world": lambda rv, v: builder.world(v),
162+
"residence": lambda rv, v: builder.residence(v),
163+
"last login": lambda rv, v: builder.last_login(None) if "never logged" in v.lower() else builder.last_login(
164+
parse_tibia_datetime(v),
165+
),
166+
"position": lambda rv, v: builder.position(v),
167+
"comment": lambda rv, v: builder.comment(v),
168+
"account status": lambda rv, v: builder.is_premium("premium" in v.lower()),
169+
"married to": lambda rv, v: builder.married_to(v),
170+
"house": lambda rv, v: cls._parse_house_column(builder, rv),
171+
"guild membership": lambda rv, v: cls._parse_guild_column(builder, rv),
172+
}
171173

172-
Parameters
173-
----------
174-
rows: :class:`list` of :class:`bs4.Tag`
175-
A list of all rows contained in the table.
176-
"""
177174
for row in rows:
178-
cols_raw = row.select("td")
179-
cols = [clean_text(ele) for ele in cols_raw]
180-
field, value = cols
175+
raw_field, raw_value = row.select("td")
176+
field, value = clean_text(raw_field), clean_text(raw_value)
181177
field = field.replace(":", "").lower()
182-
if field == "name":
183-
cls._parse_name_field(builder, value)
184-
elif field == "title":
185-
cls._parse_titles(builder, value)
186-
elif field == "former names":
187-
builder.former_names([fn.strip() for fn in value.split(",")])
188-
elif field == "former world":
189-
builder.former_world(value)
190-
elif field == "sex":
191-
builder.sex(try_enum(Sex, value))
192-
elif field == "vocation":
193-
builder.vocation(try_enum(Vocation, value))
194-
elif field == "level":
195-
builder.level(parse_integer(value))
196-
elif field == "achievement points":
197-
builder.achievement_points(parse_integer(value))
198-
elif field == "world":
199-
builder.world(value)
200-
elif field == "residence":
201-
builder.residence(value)
202-
elif field == "last login":
203-
if "never logged" in value.lower():
204-
builder.last_login(None)
205-
else:
206-
builder.last_login(parse_tibia_datetime(value))
207-
208-
elif field == "position":
209-
builder.position(value)
210-
elif field == "comment":
211-
builder.comment(value)
212-
elif field == "account status":
213-
builder.is_premium("premium" in value.lower())
214-
elif field == "married to":
215-
builder.married_to(value)
216-
elif field == "house":
217-
cls._parse_house_column(builder, cols_raw[1])
218-
elif field == "guild membership":
219-
cls._parse_guild_column(builder, cols_raw[1])
178+
if field in field_actions:
179+
action = field_actions[field]
180+
action(raw_value, value)
181+
else:
182+
logger.debug(f"Unhandled character information field found: {field}")
220183

221184
@classmethod
222185
def _parse_name_field(cls, builder: CharacterBuilder, value: str):
@@ -268,14 +231,8 @@ def _parse_guild_column(cls, builder: CharacterBuilder, column: bs4.Tag):
268231
builder.guild_membership(GuildMembership(name=clean_text(guild_link), rank=rank.strip()))
269232

270233
@classmethod
271-
def _parse_deaths(cls, builder: CharacterBuilder, rows):
272-
"""Parse the character's recent deaths.
273-
274-
Parameters
275-
----------
276-
rows: :class:`list` of :class:`bs4.Tag`
277-
A list of all rows contained in the table.
278-
"""
234+
def _parse_deaths(cls, builder: CharacterBuilder, rows: List[bs4.Tag]):
235+
"""Parse the character's recent deaths."""
279236
for row in rows:
280237
cols = row.select("td")
281238
if len(cols) != 2:
@@ -299,8 +256,8 @@ def _parse_deaths(cls, builder: CharacterBuilder, rows):
299256
assists_name_list = link_search.findall(assists_desc)
300257

301258
killers_name_list = split_list(killers_desc)
302-
killers_list = [cls._parse_killer(k) for k in killers_name_list]
303-
assists_list = [cls._parse_killer(k) for k in assists_name_list]
259+
killers_list = [cls._parse_participant(k) for k in killers_name_list]
260+
assists_list = [cls._parse_participant(k) for k in assists_name_list]
304261
builder.add_death(Death(
305262
level=level,
306263
killers=killers_list,
@@ -309,18 +266,8 @@ def _parse_deaths(cls, builder: CharacterBuilder, rows):
309266
))
310267

311268
@classmethod
312-
def _parse_killer(cls, killer):
313-
"""Parse a killer into a dictionary.
314-
315-
Parameters
316-
----------
317-
killer: :class:`str`
318-
The killer's raw HTML string.
319-
320-
Returns
321-
-------
322-
:class:`dict`: A dictionary containing the killer's info.
323-
"""
269+
def _parse_participant(cls, killer: str) -> DeathParticipant:
270+
"""Parse a participant's information from their raw HTML string."""
324271
# If the killer contains a link, it is a player.
325272
name = clean_text(killer)
326273
player = False
@@ -344,15 +291,8 @@ def _parse_killer(cls, killer):
344291
return DeathParticipant(name=name, is_player=player, summon=summon, is_traded=traded)
345292

346293
@classmethod
347-
def _parse_other_characters(cls, rows):
348-
"""Parse the character's other visible characters.
349-
350-
Parameters
351-
----------
352-
rows: :class:`list` of :class:`bs4.Tag`
353-
A list of all rows contained in the table.
354-
"""
355-
other_characters = []
294+
def _parse_other_characters(cls, builder: CharacterBuilder, rows: List[bs4.Tag]):
295+
"""Parse the character's other visible characters."""
356296
for row in rows[1:]:
357297
cols_raw = row.select("td")
358298
cols = [ele.text.strip() for ele in cols_raw]
@@ -376,32 +316,23 @@ def _parse_other_characters(cls, rows):
376316
if "CipSoft Member" in status:
377317
position = "CipSoft Member"
378318

379-
other_characters.append(OtherCharacter(name=name, world=world, is_online="online" in status,
380-
is_deleted="deleted" in status, is_main=main, position=position,
381-
is_traded=traded))
382-
383-
return other_characters
319+
builder.add_other_character(OtherCharacter(
320+
name=name,
321+
world=world,
322+
is_online="online" in status,
323+
is_deleted="deleted" in status,
324+
is_main=main,
325+
position=position,
326+
is_traded=traded,
327+
))
384328

385329
@classmethod
386-
def _parse_tables(cls, parsed_content):
387-
"""
388-
Parse the information tables contained in a character's page.
389-
390-
Parameters
391-
----------
392-
parsed_content: :class:`bs4.BeautifulSoup`
393-
A :class:`BeautifulSoup` object containing all the content.
394-
395-
Returns
396-
-------
397-
:class:`OrderedDict`[str, :class:`list`of :class:`bs4.Tag`]
398-
A dictionary containing all the table rows, with the table headers as keys.
399-
"""
330+
def _parse_tables(cls, parsed_content: bs4.BeautifulSoup) -> Dict[str, List[bs4.Tag]]:
331+
"""Parse the tables contained in a character's page and returns a mapping of their titles and rows."""
400332
tables = parsed_content.select('table[width="100%"]')
401333
output = OrderedDict()
402334
for table in tables:
403-
container = table.find_parent("div", {"class": "TableContainer"})
404-
if container:
335+
if container := table.find_parent("div", {"class": "TableContainer"}):
405336
caption_container = container.select_one("div.CaptionContainer")
406337
title = caption_container.text.strip()
407338
offset = 0

0 commit comments

Comments
 (0)