Source code for tibiapy.highscores

"""Models related to the highscores section in Tibia.com."""
import datetime
import re
from collections import OrderedDict
from typing import List, Optional

from tibiapy import abc
from tibiapy.enums import Category, Vocation, VocationFilter, BattlEyeTypeFilter, PvpTypeFilter, \
    BattlEyeHighscoresFilter
from tibiapy.errors import InvalidContent
from tibiapy.utils import get_tibia_url, parse_form_data, parse_tibiacom_content, try_enum, parse_integer

__all__ = (
    "Highscores",
    "HighscoresEntry",
    "LoyaltyHighscoresEntry",
)

results_pattern = re.compile(r'Results: ([\d,]+)')
numeric_pattern = re.compile(r'(\d+)')


[docs]class Highscores(abc.Serializable): """Represents the highscores of a world. .. versionadded:: 1.1.0 Attributes ---------- world: :class:`str` The world the highscores belong to. If this is :obj:`None`, the highscores shown are for all worlds. category: :class:`Category` The selected category to displays the highscores of. vocation: :class:`VocationFilter` The selected vocation to filter out values. battleye_filter: :class:`BattlEyeHighscoresFilter` The selected BattlEye filter. If :obj:`None`, all worlds will be displayed. Only applies for global highscores. Only characters from worlds with the matching BattlEye protection will be shown. pvp_types_filter: :class:`list` of :class:`PvpTypeFilter` The selected PvP types filter. If :obj:`None`, all world will be displayed. Only applies for global highscores. Only characters from worlds with the matching PvP type will be shown. page: :class:`int` The page number being displayed. total_pages: :class:`int` The total number of pages. results_count: :class:`int` The total amount of highscores entries in this category. These may be shown in another page. last_updated: :class:`datetime.timedelta` How long ago were this results updated. The resolution is 1 minute. entries: :class:`list` of :class:`HighscoresEntry` The highscores entries found. available_worlds: :class:`list` of :class:`str` The worlds available for selection. """ _ENTRIES_PER_PAGE = 50 def __init__(self, world, category=Category.EXPERIENCE, **kwargs): self.world: Optional[str] = world self.category: Category = try_enum(Category, category, Category.EXPERIENCE) self.vocation: VocationFilter = try_enum(VocationFilter, kwargs.get("vocation"), VocationFilter.ALL) self.battleye_filter: Optional[BattlEyeTypeFilter] = try_enum(BattlEyeTypeFilter, kwargs.get("battleye_filter")) self.pvp_types_filter: List[PvpTypeFilter] = kwargs.get("pvp_types_filter", []) self.entries: List[HighscoresEntry] = kwargs.get("entries", []) self.results_count: int = kwargs.get("results_count", 0) self.page: int = kwargs.get("page", 1) self.total_pages: int = kwargs.get("total_pages", 1) __slots__ = ( 'world', 'category', 'vocation', 'battleye_filter', "pvp_types_filter", 'page', 'total_pages', 'results_count', 'last_updated', 'entries', 'available_worlds', ) _serializable_properties = ( ) def __repr__(self): return f"<{self.__class__.__name__} world={self.world!r} category={self.category!r} vocation={self.vocation!r}>" @property def from_rank(self): """:class:`int`: The starting rank of the provided entries.""" return self.entries[0].rank if self.entries else 0 @property def to_rank(self): """:class:`int`: The last rank of the provided entries.""" return self.entries[-1].rank if self.entries else 0 @property def url(self): """:class:`str`: The URL to the highscores page on Tibia.com containing the results.""" return self.get_url(self.world, self.category, self.vocation, self.page, self.battleye_filter, self.pvp_types_filter) @property def previous_page_url(self): """:class:`str`: The URL to the previous page of the current highscores, if there's any.""" return self.get_page_url(self.page - 1) if self.page > 1 else None @property def next_page_url(self): """:class:`str`: The URL to the next page of the current highscores, if there's any.""" return self.get_page_url(self.page + 1) if self.page < self.total_pages else None
[docs] def get_page_url(self, page): """Get the URL to a specific page for the current highscores. Parameters ---------- page: :class:`int` The page to get the URL for. Returns ------- :class:`str` The URL to the page of the current highscores. Raises ------ ValueError The provided page is less or equals than zero. """ if page <= 0: raise ValueError("page cannot be less or equals than zero") return self.get_url(self.world, self.category, self.vocation, page, self.battleye_filter, self.pvp_types_filter)
[docs] @classmethod def from_content(cls, content): """Create an instance of the class from the html content of a highscores page. Notes ----- Tibia.com only shows up to 50 entries per page, so in order to obtain the full highscores, all pages must be obtained individually and merged into one. Parameters ---------- content: :class:`str` The HTML content of the page. Returns ------- :class:`Highscores` The highscores results contained in the page. Raises ------ InvalidContent If content is not the HTML of a highscore's page. """ parsed_content = parse_tibiacom_content(content) form = parsed_content.find("form") tables = cls._parse_tables(parsed_content) if form is None or "Highscores" not in tables: if "Error" in tables and "The world doesn't exist!" in tables["Error"].text: return None raise InvalidContent("content does is not from the highscores section of Tibia.com") highscores = cls(None) highscores._parse_filters_table(form) last_update_container = parsed_content.find("span", attrs={"class": "RightArea"}) if last_update_container: m = numeric_pattern.search(last_update_container.text) highscores.last_updated = datetime.timedelta(minutes=int(m.group(1))) if m else datetime.timedelta() entries_table = tables.get("Highscores") highscores._parse_entries_table(entries_table) return highscores
[docs] @classmethod def get_url(cls, world=None, category=Category.EXPERIENCE, vocation=VocationFilter.ALL, page=1, battleye_type=None, pvp_types=None): """Get the Tibia.com URL of the highscores for the given parameters. Parameters ---------- world: :class:`str`, optional The game world of the desired highscores. If no world is passed, ALL worlds are shown. category: :class:`Category` The desired highscores category. vocation: :class:`VocationFilter` The vocation filter to apply. By default all vocations will be shown. page: :class:`int` The page of highscores to show. battleye_type: :class:`BattlEyeHighscoresFilter`, optional The battleEye filters to use. pvp_types: :class:`list` of :class:`PvpTypeFilter`, optional The list of PvP types to filter the results for. Returns ------- The URL to the Tibia.com highscores. """ pvp_types = pvp_types or [] pvp_params = [("worldtypes[]", p.value) for p in pvp_types] return get_tibia_url("community", "highscores", *pvp_params, world=world, category=category.value, profession=vocation.value, currentpage=page, beprotection=battleye_type.value if battleye_type else None)
# region Private methods def _parse_entries_table(self, table): """Parse the table containing the highscore entries. Parameters ---------- table: :class:`bs4.Tag` The table containing the entries. """ entries = table.find_all("tr") if entries is None: return _, header, *rows = entries info_row = rows.pop() pages_div, results_div = info_row.find_all("div") page_links = pages_div.find_all("a") listed_pages = [int(p.text) for p in page_links] if listed_pages: self.page = next((x for x in range(1, listed_pages[-1] + 1) if x not in listed_pages), listed_pages[-1] + 1) self.total_pages = max(int(page_links[-1].text), self.page) self.results_count = parse_integer(results_pattern.search(results_div.text).group(1)) for row in rows: cols_raw = row.find_all('td') if "There is currently no data" in cols_raw[0].text: break if len(cols_raw) <= 2: break self._parse_entry(cols_raw) def _parse_filters_table(self, form): """ Parse the filters table found in a highscores page. Parameters ---------- form: :class:`bs4.Tag` The table containing the filters. """ data = parse_form_data(form, include_options=True) self.world = data["world"] if data.get("world") else None self.battleye_filter = try_enum(BattlEyeHighscoresFilter, parse_integer(data.get("beprotection"), None)) self.category = try_enum(Category, parse_integer(data.get("category"), None)) self.vocation = try_enum(VocationFilter, parse_integer(data.get("profession"), None), VocationFilter.ALL) checkboxes = form.find_all("input", {"type": "checkbox", "checked": "checked"}) values = [int(c["value"]) for c in checkboxes] self.pvp_types_filter = [try_enum(PvpTypeFilter, v) for v in values] self.available_words = [v for v in data["__options__"]["world"].values() if v] @classmethod def _parse_tables(cls, parsed_content): """ Parse the information tables found in a highscores page. Parameters ---------- parsed_content: :class:`bs4.BeautifulSoup` A :class:`BeautifulSoup` object containing all the content. Returns ------- :class:`OrderedDict`[:class:`str`, :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() title = re.sub(r'Last Update.*', '', title) inner_table = table.find("div", attrs={'class': 'InnerTableContainer'}) output[title] = inner_table return output def _parse_entry(self, cols): """Parse an entry's row and adds the result to py:attr:`entries`. Parameters ---------- cols: :class:`bs4.ResultSet` The list of columns for that entry. """ rank, name, *values = [c.text.replace('\xa0', ' ').strip() for c in cols] rank = int(rank) extra = None if self.category == Category.LOYALTY_POINTS: extra, vocation, world, level, value = values else: vocation, world, level, value = values value = int(value.replace(',', '')) level = int(level) if self.category == Category.LOYALTY_POINTS: entry = LoyaltyHighscoresEntry(rank, name, vocation, world, level, value, extra) else: entry = HighscoresEntry(rank, name, vocation, world, level, value) self.entries.append(entry)
# endregion
[docs]class HighscoresEntry(abc.BaseCharacter, abc.Serializable): """Represents a entry for the highscores. Attributes ---------- name: :class:`str` The name of the character. rank: :class:`int` The character's rank in the respective highscores. vocation: :class:`Vocation` The character's vocation. world: :class:`str` The character's world. level: :class:`int` The character's level. value: :class:`int` The character's value for the highscores. """ def __init__(self, rank, name, vocation, world, level, value): self.name: str = name self.rank: int = rank self.vocation = try_enum(Vocation, vocation) self.value: int = value self.world: str = world self.level: int = level __slots__ = ( 'rank', 'name', 'vocation', 'world', 'level', 'value', ) def __repr__(self) -> str: return f"<{self.__class__.__name__} rank={self.rank} name={self.name!r} value={self.value}>"
[docs]class LoyaltyHighscoresEntry(HighscoresEntry): """Represents a entry for the highscores loyalty points category. This is a subclass of :class:`HighscoresEntry`, adding an extra field for title. Attributes ---------- name: :class:`str` The name of the character. rank: :class:`int` The character's rank in the respective highscores. vocation: :class:`Vocation` The character's vocation. world: :class:`str` The character's world. level: :class:`int` The character's level. value: :class:`int` The character's loyalty points. title: :class:`str` The character's loyalty title. """ def __init__(self, rank, name, vocation, world, level, value, title): super().__init__(rank, name, vocation, world, level, value) self.title: str = title __slots__ = ( 'title', )