Source code for tibiapy.world

"""Models related to the worlds section in Tibia.com."""
import re
from collections import OrderedDict
from typing import List, TYPE_CHECKING

from tibiapy import abc
from tibiapy.character import OnlineCharacter
from tibiapy.enums import BattlEyeType, PvpType, TournamentWorldType, TransferType, WorldLocation
from tibiapy.errors import InvalidContent
from tibiapy.utils import (get_tibia_url, parse_integer, parse_tibia_datetime, parse_tibia_full_date,
                           parse_tibiacom_content, try_date, try_datetime, try_enum)

if TYPE_CHECKING:
    import bs4

__all__ = (
    "WorldEntry",
    "World",
    "WorldOverview",
)

record_regexp = re.compile(r'(?P<count>[\d.,]+) players \(on (?P<date>[^)]+)\)')
battleye_regexp = re.compile(r'since ([^.]+).')


[docs]class WorldEntry(abc.BaseWorld, abc.Serializable): """Represents a game server listed in the World Overview section. Attributes ---------- name: :class:`str` The name of the world. status: :class:`str` The current status of the world. online_count: :class:`int` The number of currently online players in the world. location: :class:`WorldLocation` The physical location of the game servers. pvp_type: :class:`PvpType` The type of PvP in the world. transfer_type: :class:`TransferType` The type of transfer restrictions this world has. battleye_date: :class:`datetime.date` The date when BattlEye was added to this world. If this is :obj:`None` and the world is protected, it means the world was protected from the beginning. battleye_type: :class:`BattlEyeType` The type of BattlEye protection this world has. .. versionadded:: 4.0.0 experimental: :class:`bool` Whether the world is experimental or not. premium_only: :class:`bool` Whether only premium account players are allowed to play in this server. tournament_world_type: :class:`TournamentWorldType` The type of tournament world. :obj:`None` if this is not a tournament world. """ __slots__ = ( "name", "status", "location", "online_count", "pvp_type", "battleye_date", "battleye_type", "experimental", "premium_only", "tournament_world_type", "transfer_type", ) _serializable_properties = ( "battleye_protected", ) def __init__(self, name, location=None, pvp_type=None, **kwargs): self.name: str = name self.location = try_enum(WorldLocation, location) self.pvp_type = try_enum(PvpType, pvp_type) self.status: str = kwargs.get("status") self.online_count: int = kwargs.get("online_count", 0) self.transfer_type = try_enum(TransferType, kwargs.get("transfer_type", TransferType.REGULAR)) self.battleye_date = try_date(kwargs.get("battleye_date")) self.battleye_type = try_enum(BattlEyeType, kwargs.get("battleye_type"), BattlEyeType.UNPROTECTED) self.experimental: bool = kwargs.get("experimental", False) self.premium_only: bool = kwargs.get("premium_only", False) self.tournament_world_type = try_enum(TournamentWorldType, kwargs.get("tournament_world_type"), None) @property def battleye_protected(self): """:class:`bool`: Whether the server is currently protected with BattlEye or not. .. versionchanged:: 4.0.0 Now a calculated property instead of a field. """ return self.battleye_type and self.battleye_type != BattlEyeType.UNPROTECTED # region Public methods
[docs] @classmethod def get_list_url(cls): """Get the URL to the World Overview page in Tibia.com. Returns ------- :class:`str` The URL to the World Overview's page. """ return WorldOverview.get_url()
[docs] @classmethod def list_from_content(cls, content): """Parse the content of the World Overview section from Tibia.com and returns only the list of worlds. Parameters ---------- content: :class:`str` The HTML content of the World Overview page in Tibia.com Returns ------- :class:`list` of :class:`WorldEntry` A list of the worlds and their current information. Raises ------ InvalidContent If the provided content is not the HTML content of the worlds section in Tibia.com """ world_overview = WorldOverview.from_content(content) return world_overview.worlds
# endregion # region Private methods def _parse_additional_info(self, additional_info, tournament=False): if "blocked" in additional_info: self.transfer_type = TransferType.BLOCKED elif "locked" in additional_info: self.transfer_type = TransferType.LOCKED else: self.transfer_type = TransferType.REGULAR self.experimental = "experimental" in additional_info self.premium_only = "premium" in additional_info if tournament: if "restricted Store products" in additional_info: self.tournament_world_type = TournamentWorldType.RESTRICTED else: self.tournament_world_type = TournamentWorldType.REGULAR
# endregion
[docs]class World(abc.BaseWorld, abc.Serializable): """Represents a Tibia game server. Attributes ---------- name: :class:`str` The name of the world. status: :class:`str` The current status of the world. online_count: :class:`int` The number of currently online players in the world. record_count: :class:`int` The server's online players record. record_date: :class:`datetime.datetime` The date when the online record was achieved. location: :class:`WorldLocation` The physical location of the game servers. pvp_type: :class:`PvpType` The type of PvP in the world. creation_date: :class:`str` The month and year the world was created. In YYYY-MM format. transfer_type: :class:`TransferType` The type of transfer restrictions this world has. world_quest_titles: :obj:`list` of :class:`str` List of world quest titles the server has achieved. battleye_date: :class:`datetime.date` The date when BattlEye was added to this world. If this is :obj:`None` and the world is protected, it means the world was protected from the beginning. battleye_type: :class:`BattlEyeType` The type of BattlEye protection this world has. .. versionadded:: 4.0.0 experimental: :class:`bool` Whether the world is experimental or not. tournament_world_type: :class:`TournamentWorldType` The type of tournament world. :obj:`None` if this is not a tournament world. online_players: :obj:`list` of :class:`OnlineCharacter`. A list of characters currently online in the server. premium_only: :class:`bool` Whether only premium account players are allowed to play in this server. """ __slots__ = ( "name", "status", "location", "pvp_type", "battleye_date", "battleye_type", "experimental", "premium_only", "tournament_world_type", "transfer_type", "online_count", "record_count", "record_date", "creation_date", "world_quest_titles", "online_players", ) _serializable_properties = ( "battleye_protected", ) def __init__(self, name, location=None, pvp_type=None, **kwargs): self.name: str = name self.location = try_enum(WorldLocation, location) self.pvp_type = try_enum(PvpType, pvp_type) self.status: bool = kwargs.get("status") self.online_count: int = kwargs.get("online_count", 0) self.record_count: int = kwargs.get("record_count", 0) self.record_date = try_datetime(kwargs.get("record_date")) self.creation_date: str = kwargs.get("creation_date") self.transfer_type = try_enum(TransferType, kwargs.get("transfer_type", TransferType.REGULAR)) self.world_quest_titles: List[str] = kwargs.get("world_quest_titles", []) self.battleye_date = try_date(kwargs.get("battleye_date")) self.battleye_type = try_enum(BattlEyeType, kwargs.get("battleye_type"), BattlEyeType.UNPROTECTED) self.experimental: bool = kwargs.get("experimental", False) self.online_players: List[OnlineCharacter] = kwargs.get("online_players", []) self.premium_only: bool = kwargs.get("premium_only", False) self.tournament_world_type = try_enum(TournamentWorldType, kwargs.get("tournament_world_type"), None) # region Properties @property def battleye_protected(self): """:class:`bool`: Whether the server is currently protected with BattlEye or not. .. versionchanged:: 4.0.0 Now a calculated property instead of a field. """ return self.battleye_type and self.battleye_type != BattlEyeType.UNPROTECTED @property def creation_year(self): """:class:`int`: Returns the year when the world was created.""" return int(self.creation_date.split("-")[0]) if self.creation_date else None @property def creation_month(self): """:class:`int`: Returns the month when the world was created.""" return int(self.creation_date.split("-")[1])if self.creation_date else None # endregion # region Public methods
[docs] @classmethod def from_content(cls, content): """Parse a Tibia.com response into a :class:`World`. Parameters ---------- content: :class:`str` The raw HTML from the server's information page. Returns ------- :class:`World` The World described in the page, or :obj:`None`. Raises ------ InvalidContent If the provided content is not the HTML content of the world section in Tibia.com """ parsed_content = parse_tibiacom_content(content) tables = cls._parse_tables(parsed_content) try: error = tables.get("Error") if error and error[0].text == "World with this name doesn't exist!": return None selected_world = parsed_content.find('option', selected=True) world = cls(selected_world.text) world._parse_world_info(tables.get("World Information", [])) online_table = tables.get("Players Online", []) world.online_players = [] for row in online_table[1:]: cols_raw = row.find_all('td') name, level, vocation = (c.text.replace('\xa0', ' ').strip() for c in cols_raw) world.online_players.append(OnlineCharacter(name, world.name, int(level), vocation)) except AttributeError: raise InvalidContent("content is not from the world section in Tibia.com") return world
# endregion # region Private methods def _parse_world_info(self, world_info_table): """ Parse the World Information table from Tibia.com and adds the found values to the object. Parameters ---------- world_info_table: :class:`list`[:class:`bs4.Tag`] """ world_info = {} for row in world_info_table: 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", " ") world_info[field] = value try: self.online_count = parse_integer(world_info.pop("players_online")) except KeyError: self.online_count = 0 self.location = try_enum(WorldLocation, world_info.pop("location")) self.pvp_type = try_enum(PvpType, world_info.pop("pvp_type")) self.transfer_type = try_enum(TransferType, world_info.pop("transfer_type", None), TransferType.REGULAR) m = record_regexp.match(world_info.pop("online_record")) if m: self.record_count = parse_integer(m.group("count")) self.record_date = parse_tibia_datetime(m.group("date")) if "world_quest_titles" in world_info: self.world_quest_titles = [q.strip() for q in world_info.pop("world_quest_titles").split(",")] if self.world_quest_titles and "currently has no title" in self.world_quest_titles[0]: self.world_quest_titles = [] self.experimental = world_info.pop("game_world_type", None) == "Experimental" self.tournament_world_type = try_enum(TournamentWorldType, world_info.pop("tournament_world_type", None)) self._parse_battleye_status(world_info.pop("battleye_status")) self.premium_only = "premium_type" in world_info month, year = world_info.pop("creation_date").split("/") month = int(month) year = int(year) if year > 90: year += 1900 else: year += 2000 self.creation_date = f"{year:d}-{month:02d}" for k, v in world_info.items(): try: setattr(self, k, v) except AttributeError: pass def _parse_battleye_status(self, battleye_string): """Parse the BattlEye string and applies the results. Parameters ---------- battleye_string: :class:`str` String containing the world's Battleye Status. """ m = battleye_regexp.search(battleye_string) if m: self.battleye_date = parse_tibia_full_date(m.group(1)) self.battleye_type = BattlEyeType.PROTECTED if self.battleye_date else BattlEyeType.INITIALLY_PROTECTED else: self.battleye_date = None self.battleye_type = BattlEyeType.UNPROTECTED @classmethod def _parse_tables(cls, parsed_content): """ Parse the information tables found in a world's information page. Parameters ---------- parsed_content: :class:`bs4.BeautifulSoup` A :class:`BeautifulSoup` object containing all the content. Returns ------- :class:`OrderedDict`[:class:`str`, :class:`list`[:class:`bs4.Tag`]] A dictionary containing all the table rows, with the table headers as keys. """ tables = parsed_content.find_all('div', attrs={'class': 'TableContainer'}) output = OrderedDict() for table in tables: title = table.find("div", attrs={'class': 'Text'}).text title = title.split("[")[0].strip() inner_table = table.find("div", attrs={'class': 'InnerTableContainer'}) output[title] = inner_table.find_all("tr") return output
# endregion
[docs]class WorldOverview(abc.Serializable): """Container class for the World Overview section. Attributes ---------- record_count: :class:`int` The overall player online record. record_date: :class:`datetime.datetime` The date when the record was achieved. worlds: :class:`list` of :class:`WorldEntry` List of worlds, with limited info. """ __slots__ = ( "record_count", "record_date", "worlds", ) serializable_properties = ('total_online',) def __init__(self, **kwargs): self.record_count: int = kwargs.get("record_count", 0) self.record_date = try_datetime(kwargs.get("record_date")) self.worlds: List[WorldEntry] = kwargs.get("worlds", []) def __repr__(self): return f"<{self.__class__.__name__} total_online={self.total_online:d}>" @property def total_online(self): """:class:`int`: Total players online across all worlds.""" return sum(w.online_count for w in self.worlds) @property def tournament_worlds(self): """:class:`list` of :class:`GuildMember`: List of tournament worlds. Note that tournament worlds are not listed when there are no active or upcoming tournaments. """ return [w for w in self.worlds if w.tournament_world_type is not None] @property def regular_worlds(self): """:class:`list` of :class:`WorldEntry`: List of worlds that are not tournament worlds.""" return [w for w in self.worlds if w.tournament_world_type is None]
[docs] @classmethod def get_url(cls): """Get the URL to the World Overview page in Tibia.com. Returns ------- :class:`str` The URL to the World Overview's page. """ return get_tibia_url("community", "worlds")
[docs] @classmethod def from_content(cls, content): """Parse the content of the World Overview section from Tibia.com into an object of this class. Parameters ---------- content: :class:`str` The HTML content of the World Overview page in Tibia.com Returns ------- :class:`WorldOverview` An instance of this class containing all the information. Raises ------ InvalidContent If the provided content is not the HTML content of the worlds section in Tibia.com """ parsed_content = parse_tibiacom_content(content) world_overview = WorldOverview() try: record_table, *tables \ = parsed_content.find_all("table", {"class": "TableContent"}) m = record_regexp.search(record_table.text) world_overview.record_count = parse_integer(m.group("count")) world_overview.record_date = parse_tibia_datetime(m.group("date")) world_overview._parse_worlds_tables(tables) return world_overview except (AttributeError, KeyError, ValueError) as e: raise InvalidContent("content does not belong to the World Overview section in Tibia.com", e)
def _parse_worlds(self, world_rows, tournament=False): """Parse the world columns and adds the results to :py:attr:`worlds`. Parameters ---------- world_rows: :class:`list` of :class:`bs4.Tag` A list containing the rows of each world. tournament: :class:`bool` Whether these are tournament worlds or not. """ for world_row in world_rows: cols = world_row.find_all("td") name = cols[0].text.strip() status = "Online" online = parse_integer(cols[1].text.strip(), None) if online is None: online = 0 status = "Offline" location = cols[2].text.replace("\u00a0", " ").strip() pvp = cols[3].text.strip() world = WorldEntry(name, location, pvp, online_count=online, status=status) # Check Battleye icon to get information battleye_icon = cols[4].find("span", attrs={"class": "HelperDivIndicator"}) if battleye_icon is not None: m = battleye_regexp.search(battleye_icon["onmouseover"]) if m: world.battleye_date = parse_tibia_full_date(m.group(1)) world.battleye_type = BattlEyeType.PROTECTED if world.battleye_date else BattlEyeType.INITIALLY_PROTECTED additional_info = cols[5].text.strip() world._parse_additional_info(additional_info, tournament) self.worlds.append(world) def _parse_worlds_tables(self, tables): """Parse the world columns and adds the results to :py:attr:`worlds`. Parameters ---------- world_rows: :class:`list` of :class:`bs4.Tag` A list containing the rows of each world. tournament: :class:`bool` Whether these are tournament worlds or not. """ for title_table, worlds_table in zip(tables, tables[1:]): title = title_table.text.lower() regular_world_rows = worlds_table.find_all("tr", attrs={"class": ["Odd", "Even"]}) self._parse_worlds(regular_world_rows, "tournament" in title)