"""Models related to the Tibia.com character page."""
import datetime
import re
import urllib.parse
from collections import OrderedDict
from typing import List, Optional, TYPE_CHECKING
from tibiapy import abc
from tibiapy.enums import AccountStatus, Sex, Vocation
from tibiapy.errors import InvalidContent
from tibiapy.house import CharacterHouse
from tibiapy.utils import (parse_popup, parse_tibia_date, parse_tibia_datetime, parse_tibiacom_content, split_list,
try_datetime, try_enum)
if TYPE_CHECKING:
import bs4
# Extracts the scheduled deletion date of a character."""
deleted_regexp = re.compile(r'([^,]+), will be deleted at (.*)')
# Extracts the death's level and killers.
death_regexp = re.compile(r'Level (?P<level>\d+) by (?P<killers>.*)\.</td>')
# From the killers list, filters out the assists.
death_assisted = re.compile(r'(?P<killers>.+)\.<br/>Assisted by (?P<assists>.+)')
# From a killer entry, extracts the summoned creature
death_summon = re.compile(r'(?P<summon>an? .+) of (?P<name>[^<]+)')
link_search = re.compile(r'<a[^>]+>[^<]+</a>')
# Extracts the contents of a tag
link_content = re.compile(r'>([^<]+)<')
house_regexp = re.compile(r'paid until (.*)')
title_regexp = re.compile(r'(.*)\((\d+) titles? unlocked\)')
badge_popup_regexp = re.compile(r"\$\(this\),\s+'([^']+)',\s+'([^']+)',")
traded_label = "(traded)"
__all__ = (
"AccountBadge",
"AccountInformation",
"Achievement",
"Character",
"Death",
"GuildMembership",
"Killer",
"OtherCharacter",
"OnlineCharacter",
)
[docs]class AccountBadge(abc.Serializable):
"""A displayed account badge in the character's information.
Attributes
----------
name: :class:`str`
The name of the badge.
icon_url: :class:`str`
The URL to the badge's icon.
description: :class:`str`
The description of the badge.
"""
__slots__ = (
"name",
"icon_url",
"description",
)
def __init__(self, name, icon_url, description):
self.name: str = name
self.icon_url: str = icon_url
self.description: str = description
def __repr__(self):
return f"<{self.__class__.__name__} name={self.name!r} description={self.description!r}>"
[docs]class Achievement(abc.Serializable):
"""Represents an achievement listed on a character's page.
Attributes
----------
name: :class:`str`
The name of the achievement.
grade: :class:`int`
The grade of the achievement, also known as stars.
secret: :class:`bool`
Whether the achievement is secret or not.
"""
__slots__ = (
"name",
"grade",
"secret",
)
def __init__(self, name, grade, secret=False):
self.name: str = name
self.grade = int(grade)
self.secret: bool = secret
def __repr__(self):
return f"<{self.__class__.__name__} name={self.name!r} grade={self.grade} secret={self.secret}>"
[docs]class Character(abc.BaseCharacter, abc.Serializable):
"""A full character from Tibia.com, obtained from its character page.
Attributes
----------
name: :class:`str`
The name of the character.
traded: :class:`bool`
If the character was traded in the last 6 months.
deletion_date: :class:`datetime.datetime`, optional
The date when the character will be deleted if it is scheduled for deletion. Will be :obj:`None` otherwise.
former_names: :class:`list` of :class:`str`
Previous names of the character.
title: :class:`str`, optional
The character's selected title, if any.
unlocked_titles: :class:`int`
The number of titles the character has unlocked.
sex: :class:`Sex`
The character's sex.
vocation: :class:`Vocation`
The character's vocation.
level: :class:`int`
The character's level.
achievement_points: :class:`int`
The total of achievement points the character has.
world: :class:`str`
The character's current world.
former_world: :class:`str`, optional
The previous world the character was in, in the last 6 months.
residence: :class:`str`
The current hometown of the character.
married_to: :class:`str`, optional
The name of the character's spouse. It will be :obj:`None` if not married.
houses: :class:`list` of :class:`CharacterHouse`
The houses currently owned by the character.
guild_membership: :class:`GuildMembership`, optional
The guild the character is a member of. It will be :obj:`None` if the character is not in a guild.
last_login: :class:`datetime.datetime`, optional
The last time the character logged in. It will be :obj:`None` if the character has never logged in.
position: :class:`str`, optional
The position of the character (e.g. CipSoft Member), if any.
comment: :class:`str`, optional
The displayed comment.
account_status: :class:`AccountStatus`
Whether the character's account is Premium or Free.
account_badges: :class:`list` of :class:`AccountBadge`
The displayed account badges.
achievements: :class:`list` of :class:`Achievement`
The achievements chosen to be displayed.
deaths: :class:`list` of :class:`Death`
The character's recent deaths.
deaths_truncated: :class:`bool`
Whether the character's deaths are truncated or not.
In some cases, there are more deaths in the last 30 days than what can be displayed.
account_information: :class:`AccountInformation`, optional
The character's account information. If the character is hidden, this will be :obj:`None`.
other_characters: :class:`list` of :class:`OtherCharacter`
Other characters in the same account.
It will be empty if the character is hidden, otherwise, it will contain at least the character itself.
"""
__slots__ = (
"name",
"former_names",
"traded",
"sex",
"title",
"unlocked_titles",
"vocation",
"level",
"achievement_points",
"world",
"former_world",
"residence",
"married_to",
"houses",
"guild_membership",
"last_login",
"account_status",
"position",
"comment",
"account_badges",
"achievements",
"deaths",
"deaths_truncated",
"account_information",
"other_characters",
"deletion_date",
)
_serializable_properties = (
"deleted",
"hidden",
)
def __init__(self, name=None, world=None, vocation=None, level=0, sex=None, **kwargs):
self.name: str = name
self.traded: bool = kwargs.get("traded", False)
self.former_names: List[str] = kwargs.get("former_names", [])
self.title: Optional[str] = kwargs.get("title")
self.unlocked_titles = int(kwargs.get("unlocked_titles", 0))
self.sex = try_enum(Sex, sex)
self.vocation = try_enum(Vocation, vocation)
self.level = int(level)
self.achievement_points = int(kwargs.get("achievement_points", 0))
self.world: str = world
self.former_world: Optional[str] = kwargs.get("former_world")
self.residence: str = kwargs.get("residence")
self.married_to: Optional[str] = kwargs.get("married_to")
self.houses: List[CharacterHouse] = kwargs.get("houses", [])
self.guild_membership: Optional[GuildMembership] = kwargs.get("guild_membership")
self.last_login = try_datetime(kwargs.get("last_login"))
self.account_status = try_enum(AccountStatus, kwargs.get("account_status"))
self.position: Optional[str] = kwargs.get("position")
self.comment: Optional[str] = kwargs.get("comment")
self.account_badges: List[AccountBadge] = kwargs.get("account_badges", [])
self.achievements: List[Achievement] = kwargs.get("achievements", [])
self.deaths: List[Death] = kwargs.get("deaths", [])
self.deaths_truncated: bool = kwargs.get("deaths", False)
self.account_information: Optional[AccountInformation] = kwargs.get("account_information")
self.other_characters: List[OtherCharacter] = kwargs.get("other_characters", [])
self.deletion_date = try_datetime(kwargs.get("deletion_date"))
def __repr__(self):
return f"<{self.__class__.__name__} name={self.name!r} world={self.world!r} vocation={self.vocation!r} " \
f"level={self.level} sex={self.sex!r}>"
# region Properties
@property
def deleted(self) -> bool:
""":class:`bool`: Whether the character is scheduled for deletion or not."""
return self.deletion_date is not None
@property
def guild_name(self) -> Optional[str]:
""":class:`str`, optional: The name of the guild the character belongs to, or :obj:`None`."""
return self.guild_membership.name if self.guild_membership else None
@property
def guild_rank(self) -> Optional[str]:
""":class:`str`, optional: The character's rank in the guild they belong to, or :obj:`None`."""
return self.guild_membership.rank if self.guild_membership else None
@property
def guild_url(self) -> Optional[str]:
""":class:`str`, optional: The character's rank in the guild they belong to, or :obj:`None`."""
return abc.BaseGuild.get_url(self.guild_membership.name) if self.guild_membership else None
@property
def hidden(self) -> bool:
""":class:`bool`: Whether this is a hidden character or not."""
return len(self.other_characters) == 0
@property
def married_to_url(self) -> Optional[str]:
""":class:`str`, optional: The URL to the husband/spouse information page on Tibia.com, if applicable."""
return self.get_url(self.married_to) if self.married_to else None
# endregion
# region Public methods
[docs] @classmethod
def from_content(cls, content):
"""Create an instance of the class from the html content of the character's page.
Parameters
----------
content: :class:`str`
The HTML content of the page.
Returns
-------
:class:`Character`
The character contained in the page, or None if the character doesn't exist
Raises
------
InvalidContent
If content is not the HTML of a character's page.
"""
parsed_content = parse_tibiacom_content(content)
tables = cls._parse_tables(parsed_content)
char = Character()
if not tables:
messsage_table = parsed_content.find("div", {"class": "TableContainer"})
if messsage_table and "Could not find character" in messsage_table.text:
return None
if "Character Information" in tables.keys():
char._parse_character_information(tables["Character Information"])
else:
raise InvalidContent("content does not contain a tibia.com character information page.")
char._parse_achievements(tables.get("Account Achievements", []))
if "Account Badges" in tables:
char._parse_badges(tables["Account Badges"])
char._parse_deaths(tables.get("Character Deaths", []))
char._parse_account_information(tables.get("Account Information", []))
char._parse_other_characters(tables.get("Characters", []))
return char
# endregion
# region Private methods
def _parse_account_information(self, rows):
"""Parse the character's account information.
Parameters
----------
rows: :class:`list` of :class:`bs4.Tag`, optional
A list of all rows contained in the table.
"""
acc_info = {}
if not rows:
return
for row in rows:
cols_raw = row.find_all('td')
cols = [ele.text.strip() for ele in cols_raw]
field, value = cols
field = field.replace("\xa0", "_").replace(" ", "_").replace(":", "").lower()
value = value.replace("\xa0", " ")
acc_info[field] = value
created = parse_tibia_datetime(acc_info["created"])
loyalty_title = None if acc_info["loyalty_title"] == "(no title)" else acc_info["loyalty_title"]
position = acc_info.get("position")
self.account_information = AccountInformation(created, loyalty_title, position)
def _parse_achievements(self, rows):
"""Parse the character's displayed achievements.
Parameters
----------
rows: :class:`list` of :class:`bs4.Tag`
A list of all rows contained in the table.
"""
for row in rows:
cols = row.find_all('td')
if len(cols) != 2:
continue
field, value = cols
grade = str(field).count("achievement-grade-symbol")
name = value.text.strip()
secret_image = value.find("img")
secret = False
if secret_image:
secret = True
self.achievements.append(Achievement(name, grade, secret))
def _parse_badges(self, rows):
"""Parse the character's displayed badges.
Parameters
----------
rows: :class:`list` of :class:`bs4.Tag`
A list of all rows contained in the table.
"""
row = rows[0]
columns = row.find_all('td')
for column in columns:
popup_span = column.find("span", attrs={"class": "HelperDivIndicator"})
if not popup_span:
# Badges are visible, but none selected.
return
popup = parse_popup(popup_span['onmouseover'])
name = popup[0]
description = popup[1].text
icon_image = column.find("img")
icon_url = icon_image['src']
self.account_badges.append(AccountBadge(name, icon_url, description))
def _parse_character_information(self, rows):
"""
Parse the character's basic information and applies the found values.
Parameters
----------
rows: :class:`list` of :class:`bs4.Tag`
A list of all rows contained in the table.
"""
int_rows = ["level", "achievement_points"]
char = {}
houses = []
for row in rows:
cols_raw = row.find_all('td')
cols = [ele.text.strip() for ele in cols_raw]
field, value = cols
field = field.replace("\xa0", "_").replace(" ", "_").replace(":", "").lower()
value = value.replace("\xa0", " ")
# This is a special case cause we need to see the link
if field == "house":
house_text = value
m = house_regexp.search(house_text)
if not m:
continue
paid_until = m.group(1)
paid_until_date = parse_tibia_date(paid_until)
house_link = cols_raw[1].find('a')
if not house_link:
continue
url = urllib.parse.urlparse(house_link["href"])
query = urllib.parse.parse_qs(url.query)
houses.append({"id": int(query["houseid"][0]), "name": house_link.text.strip(),
"town": query["town"][0], "paid_until": paid_until_date})
continue
if field == "guild_membership":
guild_link = cols_raw[1].find('a')
rank = value.split("of the")[0]
char["guild_membership"] = GuildMembership(guild_link.text.replace("\xa0", " "), rank.strip())
continue
if field in int_rows:
value = int(value)
char[field] = value
# If the character is deleted, the information is fouund with the name, so we must clean it
m = deleted_regexp.match(char["name"])
if m:
char["name"] = m.group(1)
char["deletion_date"] = parse_tibia_datetime(m.group(2))
if traded_label in char["name"]:
char["name"] = char["name"].replace(traded_label, "").strip()
char["traded"] = True
if "former_names" in char:
former_names = [fn.strip() for fn in char["former_names"].split(",")]
char["former_names"] = former_names
if "never" in char["last_login"]:
char["last_login"] = None
else:
char["last_login"] = parse_tibia_datetime(char["last_login"])
m = title_regexp.match(char.get("title", ""))
if m:
name = m.group(1).strip()
unlocked = int(m.group(2))
if name == "None":
name = None
char["title"] = name
char["unlocked_titles"] = unlocked
char["vocation"] = try_enum(Vocation, char["vocation"])
char["sex"] = try_enum(Sex, char["sex"])
char["account_status"] = try_enum(AccountStatus, char["account_status"])
for k, v in char.items():
try:
setattr(self, k, v)
except AttributeError:
# This means that there is a attribute in the character's information table that does not have a
# corresponding class attribute.
pass
self.houses = [CharacterHouse(h["id"], h["name"], self.world, h["town"], self.name, h["paid_until"])
for h in houses]
def _parse_deaths(self, rows):
"""Parse the character's recent deaths.
Parameters
----------
rows: :class:`list` of :class:`bs4.Tag`
A list of all rows contained in the table.
"""
for row in rows:
cols = row.find_all('td')
if len(cols) != 2:
self.deaths_truncated = True
break
death_time_str = cols[0].text.replace("\xa0", " ").strip()
death_time = parse_tibia_datetime(death_time_str)
death = str(cols[1])
death_info = death_regexp.search(death)
if death_info:
level = int(death_info.group("level"))
killers_desc = death_info.group("killers")
else:
continue
death = Death(self.name, level, time=death_time)
assists_name_list = []
# Check if the killers list contains assists
assist_match = death_assisted.search(killers_desc)
if assist_match:
# Filter out assists
killers_desc = assist_match.group("killers")
# Split assists into a list.
assists_desc = assist_match.group("assists")
assists_name_list = link_search.findall(assists_desc)
killers_name_list = split_list(killers_desc)
for killer in killers_name_list:
killer = killer.replace("\xa0", " ")
killer_dict = self._parse_killer(killer)
death.killers.append(Killer(**killer_dict))
for assist in assists_name_list:
# Extract names from character links in assists list.
assist = assist.replace("\xa0", " ")
assist_dict = self._parse_killer(assist)
death.assists.append(Killer(**assist_dict))
try:
self.deaths.append(death)
except ValueError:
# Some pvp deaths have no level, so they are raising a ValueError, they will be ignored for now.
continue
@classmethod
def _parse_killer(cls, killer):
"""Parse a killer into a dictionary.
Parameters
----------
killer: :class:`str`
The killer's raw HTML string.
Returns
-------
:class:`dict`: A dictionary containing the killer's info.
"""
# If the killer contains a link, it is a player.
name = killer
player = False
traded = False
summon = None
if traded_label in killer:
name = killer.replace('\xa0', ' ').replace(traded_label, "").strip()
traded = True
player = True
if "href" in killer:
m = link_content.search(killer)
name = m.group(1)
player = True
# Check if it contains a summon.
m = death_summon.search(name)
if m:
summon = m.group("summon").replace('\xa0', ' ').strip()
name = m.group("name").replace('\xa0', ' ').strip()
return {"name": name, "player": player, "summon": summon, "traded": traded}
def _parse_other_characters(self, rows):
"""Parse the character's other visible characters.
Parameters
----------
rows: :class:`list` of :class:`bs4.Tag`
A list of all rows contained in the table.
"""
for row in rows[1:]:
cols_raw = row.find_all('td')
cols = [ele.text.strip() for ele in cols_raw]
if len(cols) != 4:
continue
name, world, status, *__ = cols
_, *name = name.replace("\xa0", " ").split(" ")
name = " ".join(name)
traded = False
if traded_label in name:
name = name.replace(traded_label, "").strip()
traded = True
main_img = cols_raw[0].find('img')
main = False
if main_img and main_img['title'] == "Main Character":
main = True
position = None
if "CipSoft Member" in status:
position = "CipSoft Member"
self.other_characters.append(OtherCharacter(name, world, "online" in status, "deleted" in status, main,
position, traded))
@classmethod
def _parse_tables(cls, parsed_content):
"""
Parse the information tables contained in a character's page.
Parameters
----------
parsed_content: :class:`bs4.BeautifulSoup`
A :class:`BeautifulSoup` object containing all the content.
Returns
-------
:class:`OrderedDict`[str, :class:`list`of :class:`bs4.Tag`]
A dictionary containing all the table rows, with the table headers as keys.
"""
tables = parsed_content.find_all('table', attrs={"width": "100%"})
output = OrderedDict()
for table in tables:
container = table.find_parent("div", {"class": "TableContainer"})
if container:
caption_container = container.find("div", {"class": "CaptionContainer"})
title = caption_container.text.strip()
offset = 0
else:
title = table.find("td").text.strip()
offset = 1
output[title] = table.find_all("tr")[offset:]
return output
# endregion
[docs]class Death(abc.Serializable):
"""A character's death.
Attributes
-----------
name: :class:`str`
The name of the character this death belongs to.
level: :class:`int`
The level at which the death occurred.
killers: :class:`list` of :class:`Killer`
A list of all the killers involved.
assists: :class:`list` of :class:`Killer`
A list of characters that were involved, without dealing damage.
time: :class:`datetime.datetime`
The time at which the death occurred.
"""
__slots__ = (
"level",
"killers",
"time",
"assists",
"name",
)
_serializable_properties = (
"by_player"
)
def __init__(self, name=None, level=0, **kwargs):
self.name: str = name
self.level: int = level
self.killers: List[Killer] = kwargs.get("killers", [])
self.assists: List[Killer] = kwargs.get("assists", [])
self.time: datetime.datetime = try_datetime(kwargs.get("time"))
def __repr__(self):
attributes = ""
for attr in self.__slots__:
v = getattr(self, attr)
if isinstance(v, int) and v == 0 and not isinstance(v, bool):
continue
if isinstance(v, list) and len(v) == 0:
continue
if v is None:
continue
attributes += f" {attr}={v!r}"
return f"<{self.__class__.__name__} {attributes})"
# region Properties
@property
def by_player(self):
""":class:`bool`: Whether the kill involves other characters."""
return any([k.player and self.name != k.name for k in self.killers])
@property
def killer(self):
""":class:`Killer`: The first killer in the list.
This is usually the killer that gave the killing blow.
"""
return self.killers[0] if self.killers else None
# endregion
[docs]class GuildMembership(abc.BaseGuild, abc.Serializable):
"""The guild information of a character.
Attributes
----------
name: :class:`str`
The name of the guild.
rank: :class:`str`
The name of the rank the member has.
title: :class:`str`, optional
The title of the member in the guild. This is only available for characters in the forums section.
"""
__slots__ = (
"name",
"rank",
"title",
)
def __init__(self, name, rank, title=None):
self.name: str = name
self.rank: str = rank
self.title: Optional[str] = title
def __repr__(self):
return f"<{self.__class__.__name__} name={self.name!r} rank={self.rank!r}>"
[docs]class Killer(abc.Serializable):
"""Represents a killer.
A killer can be:
a) A creature.
b) A character.
c) A creature summoned by a character.
Attributes
-----------
name: :class:`str`
The name of the killer. In the case of summons, the name belongs to the owner.
player: :class:`bool`
Whether the killer is a player or not.
summon: :class:`str`, optional
The name of the summoned creature, if applicable.
traded: :class:`str`, optional
If the killer was traded after this death happened.
"""
__slots__ = (
"name",
"player",
"summon",
"traded",
)
def __init__(self, name, player=False, summon=None, traded=False):
self.name: str = name
self.player: bool = player
self.summon: Optional[str] = summon
self.traded: bool = traded
def __repr__(self):
summon = f" summon={self.summon!r}" if self.summon else ""
return f"<{self.__class__.__name__} name={self.name!r} player={self.player}{summon}>"
@property
def url(self):
""":class:`str`, optional: The URL of the character’s information page on Tibia.com, if applicable."""
return Character.get_url(self.name) if self.player else None
[docs]class OnlineCharacter(abc.BaseCharacter, abc.Serializable):
"""An online character in the world's page.
Attributes
----------
name: :class:`str`
The name of the character.
world: :class:`str`
The name of the world.
vocation: :class:`Vocation`
The vocation of the character.
level: :class:`int`
The level of the character.
"""
__slots__ = (
"name",
"world",
"vocation",
"level",
)
def __init__(self, name, world, level, vocation):
self.name: str = name
self.world: str = world
self.level = int(level)
self.vocation = try_enum(Vocation, vocation)
def __repr__(self):
return f"<{self.__class__.__name__} name={self.name!r} level={self.level} vocation={self.vocation!r}>"
[docs]class OtherCharacter(abc.BaseCharacter, abc.Serializable):
"""A character listed in the characters section of a character's page.
These are only shown if the character is not hidden, and only characters that are not hidden are shown here.
Attributes
----------
name: :class:`str`
The name of the character.
world: :class:`str`
The name of the world.
online: :class:`bool`
Whether the character is online or not.
deleted: :class:`bool`
Whether the character is scheduled for deletion or not.
traded: :class:`bool`
Whether the character has been traded recently or not.
main: :class:`bool`
Whether this is the main character or not.
position: :class:`str`
The character's official position, if any.
"""
__slots__ = (
"name",
"world",
"online",
"deleted",
"traded",
"main",
"position",
)
def __init__(self, name, world, online=False, deleted=False, main=False, position=None, traded=False):
self.name: str = name
self.world: str = world
self.online: bool = online
self.deleted: bool = deleted
self.main: bool = main
self.traded: bool = traded
self.position: Optional[str] = position
def __repr__(self):
return f"<{self.__class__.__name__} name={self.name!r} world={self.world!r} online={self.online!r} " \
f"main={self.main} position={self.position!r}>"