import datetime
import json
import re
import urllib.parse
from collections import OrderedDict
from typing import Optional, List
import bs4
from . import abc
from .const import CHARACTER_URL
from .utils import parse_tibia_datetime
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>.+) of <a[^>]+>(?P<name>[^<]+)</a>')
# Extracts the contents of a tag
link_content = re.compile(r'>([^<]+)<')
house_regexp = re.compile(r'paid until (.*)')
guild_regexp = re.compile(r'([\s\w]+)\sof the\s(.+)')
[docs]class Character(abc.Character):
"""Represents a Tibia character
Attributes
---------------
name: :class:`str`
The name of the character.
deletion_date: Optional[:class:`datetime.datetime`]
The date where the character will be deleted if it is scheduled for deletion.
former_names: List[:class:`str`]
Previous names of this character.
sex: :class:`str`
The character's gender, either "male" or "female"
vocation: :class:`str`
The character's vocation.
level: :class:`int`
The character's level.
achievement_points: :class:`int`
The total of points the character has.
world: :class:`str`
The character's current world
former_world: Optional[:class:`str`]
The previous world where the character was in, in the last 6 months.
residence: :class:`str`
The current hometown of the character.
married_to: Optional[:class:`str`]
The name of the character's spouse/husband.
house: Optional[:class:`dict`]
The house currently owned by the character.
guild_membership: Optional[:class:`dict`]
The guild the character is a member of. The dictionary contains a key for the rank and a key for the name.
last_login: Optional[:class:`datetime.datetime`]
The last time the character logged in. It will be None if the character has never logged in.
comment: Optional[:class:`str`]
The displayed comment.
account_status: :class:`str`
Whether the character's account is Premium or Free.
achievements: List[:class:`dict`]
The achievements chosen to be displayed.
deaths: List[:class:`Death`]
The character's recent deaths.
account_information: :class:`dict`
The character's account information, if visible.
other_characters: List[:class:`OtherCharacter`]
Other characters in the same account, if visible.
"""
__slots__ = ("former_names", "sex", "vocation", "level", "achievement_points", "world", "former_world", "residence",
"married_to", "house", "guild_membership", "last_login", "account_status", "comment", "achievements",
"deaths", "account_information", "other_characters", "deletion_date")
def __init__(self, name=None, world=None, vocation=None, level=0, sex=None, **kwargs):
self.name = name
self.former_names = kwargs.get("former_names", [])
self.sex = sex
self.vocation = vocation
self.level = level
self.achievement_points = kwargs.get("achievement_points", 0)
self.world = world
self.former_world = kwargs.get("former_world")
self.residence = kwargs.get("residence")
self.married_to = kwargs.get("married_to")
self.house = kwargs.get("house")
self.guild_membership = kwargs.get("guild_membership")
self.last_login = kwargs.get("last_login")
self.account_status = kwargs.get("account_status")
self.comment = kwargs.get("comment")
self.achievements = kwargs.get("achievements",[])
self.deaths = kwargs.get("deaths", [])
self.account_information = kwargs.get("account_information")
self.other_characters = kwargs.get("other_characters", [])
self.deletion_date = kwargs.get("deletion_date")
@property
def guild_name(self):
"""Optional[:class:`str`]: The name of the guild the character belongs to, or `None`."""
return self.guild_membership["guild"] if self.guild_membership else None
@property
def guild_rank(self):
"""Optional[:class:`str`]: The character's rank in the guild they belong to, or `None`."""
return self.guild_membership["rank"] if self.guild_membership else None
@staticmethod
def _beautiful_soup(content):
"""
Parses HTML content into a BeautifulSoup object.
Parameters
----------
content: :class:`str`
The HTML content.
Returns
-------
:class:`bs4.BeautifulSoup`: The parsed content.
"""
return bs4.BeautifulSoup(content, 'html.parser', parse_only=bs4.SoupStrainer("div", class_="BoxContent"))
@staticmethod
def _parse(content):
"""
Parses the character's page HTML content into a dictionary.
Parameters
----------
content: :class:`str`
The HTML content of the character's page.
Returns
-------
:class:`dict[str, Any]`
A dictionary containing all the character's information.
"""
parsed_content = Character._beautiful_soup(content)
tables = Character._parse_tables(parsed_content)
char = {}
if "Character Information" in tables.keys():
Character._parse_character_information(char, tables["Character Information"])
else:
return {}
Character._parse_achievements(char, tables.get("Account Achievements", []))
Character._parse_deaths(char, tables.get("Character Deaths", []))
Character._parse_account_information(char, tables.get("Account Information", []))
Character._parse_other_characters(char, tables.get("Characters", []))
return char
@staticmethod
def _parse_account_information(char, rows):
"""
Parses the character's account information
Parameters
----------
char: :class:`dict`[str,Any]
Dictionary where information will be stored.
rows: List[:class:`bs4.Tag`]
A list of all rows contained in the table.
"""
char["account_information"] = {}
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", " ")
char["account_information"][field] = value
@staticmethod
def _parse_achievements(char, rows):
"""
Parses the character's displayed achievements
Parameters
----------
char: :class:`dict`[str,Any]
Dictionary where information will be stored.
rows: List[:class:`bs4.Tag`]
A list of all rows contained in the table.
"""
achievements = []
for row in rows:
cols = row.find_all('td')
if len(cols) != 2:
continue
field, value = cols
grade = str(field).count("achievement-grade-symbol")
achievement = value.text.strip()
achievements.append({
"grade": grade,
"name": achievement
})
char["achievements"] = achievements
@staticmethod
def _parse_character_information(char, rows):
"""
Parses the character's basic information.
Parameters
----------
char: :class:`dict`[str,Any]
Dictionary where information will be stored.
rows: List[:class:`bs4.Tag`]
A list of all rows contained in the table.
"""
int_rows = ["level", "achievement_points"]
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
paid_until = house_regexp.search(house_text).group(1)
house_link = cols_raw[1].find('a')
url = urllib.parse.urlparse(house_link["href"])
query = urllib.parse.parse_qs(url.query)
char["house"] = {
"town": query["town"][0],
"id": int(query["houseid"][0]),
"name": house_link.text.strip(),
"paid_until": paid_until
}
continue
if field in int_rows:
value = int(value)
char[field] = value
m = deleted_regexp.match(char["name"])
if m:
char["name"] = m.group(1)
char["deletion_date"] = m.group(2)
else:
char["deletion_date"] = None
if "guild_membership" in char:
m = guild_regexp.match(char["guild_membership"])
char["guild_membership"] = {
'rank': m.group(1),
'guild': m.group(2)
}
else:
char["guild_membership"] = None
if "former_names" in char:
former_names = [fn.strip() for fn in char["former_names"].split(",")]
char["former_names"] = former_names
else:
char["former_names"] = []
if "married_to" not in char:
char["married_to"] = None
@staticmethod
def _parse_deaths(char, rows):
"""
Parses the character's recent deaths
Parameters
----------
char: :class:`dict`[str,Any]
Dictionary where information will be stored.
rows: List[:class:`bs4.Tag`]
A list of all rows contained in the table.
"""
deaths = []
for row in rows:
cols = row.find_all('td')
death_time = cols[0].text.strip()
death = str(cols[1]).replace("\xa0", " ")
death_time = death_time.replace("\xa0", " ")
death_info = death_regexp.search(death)
if death_info:
level = int(death_info.group("level"))
killers_str = death_info.group("killers")
else:
continue
assists = []
# Check if the killers list contains assists
assist_match = death_assisted.search(killers_str)
if assist_match:
# Filter out assists
killers_str = assist_match.group("killers")
# Split assists into a list.
assists = Character._split_list(assist_match.group("assists"))
killers = Character._split_list(killers_str)
for (i, killer) in enumerate(killers):
# If the killer contains a link, it is a player.
if "href" in killer:
killer_dict = {"name": link_content.search(killer).group(1), "player": True}
else:
killer_dict = {"name": killer, "player": False}
# Check if it contains a summon.
m = death_summon.search(killer)
if m:
killer_dict["summon"] = m.group("summon")
killers[i] = killer_dict
for (i, assist) in enumerate(assists):
# Extract names from character links in assists list.
assists[i] = {"name": link_content.search(assist).group(1), "player": True}
try:
deaths.append({'time': death_time, 'level': level, 'killers': killers, 'assists': assists})
except ValueError:
# Some pvp deaths have no level, so they are raising a ValueError, they will be ignored for now.
continue
char["deaths"] = deaths
@staticmethod
def _parse_other_characters(char, rows):
"""
Parses the character's other visible characters.
Parameters
----------
char: :class:`dict`[str,Any]
Dictionary where information will be stored.
rows: List[:class:`bs4.Tag`]
A list of all rows contained in the table.
"""
char["other_characters"] = []
for row in rows:
cols_raw = row.find_all('td')
cols = [ele.text.strip() for ele in cols_raw]
if len(cols) != 5:
continue
_name, world, status, __, __ = cols
_name = _name.replace("\xa0", " ").split(". ")[1]
char["other_characters"].append(
{'name': _name, 'world': world, 'online': status == "online", 'deleted': status == "deleted"})
@staticmethod
def _parse_tables(parsed_content):
"""
Parses 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, List[: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:
title = table.find("td").text
output[title] = table.find_all("tr")[1:]
return output
@staticmethod
def _split_list(items, separator=",", last_separator=" and "):
"""
Splits a string listing elements into an actual list.
Parameters
----------
items: :class:`str`
A string listing elements.
separator: :class:`str`
The separator between each item. A comma by default.
last_separator: :class:`str`
The separator used for the last item. ' and ' by default.
Returns
-------
List[:class:`str`]
A list containing each one of the items.
"""
if items is None:
return None
items = items.split(separator)
last_item = items[-1]
last_split = last_item.split(last_separator)
if len(last_split) > 1:
items[-1] = last_split[0]
items.append(last_split[1])
return [e.strip() for e in items]
[docs] @staticmethod
def get_url(name):
"""Gets the Tibia.com URl for a given character name.
Parameters
------------
name: str
The name of the character
Returns
--------
str
The URL to the character's page"""
return CHARACTER_URL + urllib.parse.quote(name.encode('iso-8859-1'))
[docs] @staticmethod
def from_content(content) -> Optional['Character']:
"""Creates 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
----------
Optional[:class:`Character`]
The character contained in the page, or None if the character doesn't exist.
"""
char_json = Character._parse(content)
if not char_json:
return None
try:
if char_json["deletion_date"]:
char_json["deletion_date"] = parse_tibia_datetime(char_json["deletion_date"])
else:
char_json["deletion_date"] = None
# Some attributes require converting
if "never" in char_json["last_login"]:
char_json["last_login"] = None
else:
char_json["last_login"] = parse_tibia_datetime(char_json["last_login"])
deaths = []
for d in char_json["deaths"]:
death = Death(**d)
death.name = char_json["name"]
deaths.append(death)
char_json["deaths"] = deaths
other_characters = []
if char_json["other_characters"]:
for o_char in char_json["other_characters"]:
other_characters.append(OtherCharacter(**o_char))
char_json["other_characters"] = other_characters
char = Character(**char_json)
except KeyError as e:
print(e)
return None
return char
[docs] @staticmethod
def parse_to_json(content, indent=None):
"""Static method that creates a JSON string from the html content of the character's page.
Parameters
-------------
content: :class:`str`
The HTML content of the page.
indent: :class:`int`
The number of spaces to indent the output with.
Returns
------------
:class:`str`
A string in JSON format.
"""
char_dict = Character._parse(content)
return json.dumps(char_dict, indent=indent)
[docs]class Death:
"""
Represents a death by a character
Attributes
-----------
name: :class:`str`
The name of the character this death belongs to.
level: :class:`int`
The level at which the death occurred.
killers: List[:class:`Killer`]
A list of all the killers involved.
assists: List[: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")
def __init__(self, name=None, level=0, **kwargs):
self.name = name
self.level = level
self.killers = kwargs.get("killers", [])
if self.killers and isinstance(self.killers[0], dict):
self.killers = [Killer(**k) for k in self.killers]
self.assists = kwargs.get("assists", [])
if self.assists and isinstance(self.assists[0], dict):
self.assists = [Killer(**k) for k in self.assists]
time = kwargs.get("time")
if isinstance(time, datetime.datetime):
self.time = time
elif isinstance(time, str):
self.time = parse_tibia_datetime(time)
else:
self.time = None
def __repr__(self):
attributes = ""
for attr in self.__slots__:
if attr in ["name", "level"]:
continue
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 += ",%s=%r" % (attr, v)
return "{0.__class__.__name__}({0.name!r},{0.level!r}{1})".format(self, attributes)
@property
def killer(self):
"""Optional[: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
@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])
[docs]class Killer:
"""
Represents a killer.
A killer can be:
a) Another character.
b) A creature.
c) A creature summoned by a character.
Attributes
-----------
name: :class:`str`
The name of the killer.
player: :class:`bool`
Whether the killer is a player or not.
summon: Optional[:class:`str`]
The name of the summoned creature, if applicable.
"""
__slots__ = ("name", "player", "summon")
def __init__(self, name=None, player=False, summon=None):
self.name = name
self.player = player
self.summon = summon
def __repr__(self):
attributes = ""
for attr in self.__slots__:
if attr in ["name"]:
continue
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 += ",%s=%r" % (attr, v)
return "{0.__class__.__name__}({0.name!r}{1})".format(self, attributes)
@property
def url(self):
"""
Optional[:class:`str`]: 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 OtherCharacter(abc.Character):
"""
Represents other character's displayed in the Character's information page.
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.
"""
__slots__ = ("world", "online", "deleted")
def __init__(self, name=None, world=None, online = False, deleted = False):
self.name = name
self.world = world
self.online = online
self.deleted = deleted