Source code for tibiapy.guild

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

import bs4

from tibiapy import abc
from tibiapy.enums import Vocation
from tibiapy.errors import InvalidContent
from tibiapy.house import GuildHouse
from tibiapy.utils import (get_tibia_url, parse_form_data, parse_tibia_date, parse_tibiacom_content, try_date,
                           try_datetime, try_enum)

__all__ = (
    "Guild",
    "GuildMember",
    "GuildInvite",
    "GuildsSection",
    "GuildWars",
    "GuildWarEntry",
    "GuildEntry",
)

COLS_INVITED_MEMBER = 2
COLS_GUILD_MEMBER = 6

founded_regex = re.compile(
    r'(?P<desc>.*)The guild was founded on (?P<world>\w+) on (?P<date>[^.]+)\.\nIt is (?P<status>[^.]+).', re.DOTALL)
applications_regex = re.compile(r'Guild is (\w+) for applications\.')
homepage_regex = re.compile(r'The official homepage is at ([\w.]+)\.')
guildhall_regex = re.compile(r'Their home on \w+ is (?P<name>[^.]+). The rent is paid until (?P<date>[^.]+)')
disband_regex = re.compile(r'It will be disbanded on (\w+\s\d+\s\d+)\s([^.]+).')
disband_tibadata_regex = re.compile(r'It will be disbanded, ([^.]+).')
title_regex = re.compile(r'([^(]+)\(([^)]+)\)')

war_guilds_regegx = re.compile(r'The guild ([\w\s]+) is at war with the guild ([^.]+).')
war_score_regex = re.compile(r'scored ([\d,]+) kills? against')
war_fee_regex = re.compile(r'the guild [\w\s]+ wins the war, they will receive ([\d,]+) gold.')
war_score_limit_regex = re.compile(r'guild scores ([\d,]+) kills against')
war_end_regex = re.compile(r'war will end on (\w{3}\s\d{2}\s\d{4})')

war_history_header_regex = re.compile(r'guild ([\w\s]+) fought against ([\w\s]+).')
war_start_duration_regex = re.compile(r'started on (\w{3}\s\d{2}\s\d{4}) and had been set for a duration of (\w+) days')
kills_needed_regex = re.compile(r'(\w+) kills were needed')
war_history_fee_regex = re.compile(r'agreed on a fee of (\w+) gold for the guild [\w\s]+ and a fee of (\d+) gold')
surrender_regex = re.compile(r'(?:The guild ([\w\s]+)|A disbanded guild) surrendered on (\w{3}\s\d{2}\s\d{4})')
war_ended_regex = re.compile(r'war ended on (\w{3}\s\d{2}\s\d{4}) when the guild ([\w\s]+) had reached the')
war_score_end_regex = re.compile(r'scored (\d+) kills against')

war_current_empty = re.compile(r'The guild ([\w\s]+) is currently not')


[docs]class GuildsSection(abc.Serializable): """The guilds section in Tibia.com. .. versionadded:: 5.0.0 Attributes ---------- world: :class:`str` The name of the world. If :obj:`None`, the section belongs to a world that doesn't exist. entries: :class:`list` of :class:`GuildEntry` The list of guilds in the world. available_worlds: :class:`list` of :class:`str` The list of worlds available for selection. """ __slots__ = ( "world", "entries", "available_worlds", ) def __init__(self, world, entries=None, available_worlds=None): self.world: str = world self.entries: List[GuildEntry] = entries or [] self.available_worlds: List[str] = available_worlds or [] def __repr__(self): return f"<{self.__class__.__name__} world={self.world!r} len(entries)={len(self.entries)}>" @property def active_guilds(self): """:class:`list` of :class:`GuildEntry`: Get a list of the guilds that are active.""" return [g for g in self.entries if g.active] @property def in_formation_guilds(self): """:class:`list` of :class:`GuildEntry`: Get a list of the guilds that are in course of formation.""" return [g for g in self.entries if not g.active] @property def url(self): """:class:`str`: Get the URL to this guild section.""" return self.get_url(self.world)
[docs] @classmethod def from_content(cls, content): """Get a list of guilds from the HTML content of the world guilds' page. Parameters ---------- content: :class:`str` The HTML content of the page. Returns ------- :class:`GuildsSection` List of guilds in the current world. :obj:`None` if it's the list of a world that doesn't exist. Raises ------ InvalidContent If content is not the HTML of a guild's page. """ try: parsed_content = parse_tibiacom_content(content) form = parsed_content.find("form") data = parse_form_data(form, include_options=True) selected_world = data["world"] if data["world"] else None available_worlds = [w for w in data["__options__"]["world"].values() if w] guilds = cls(selected_world, available_worlds=available_worlds) except (AttributeError, KeyError) as e: raise InvalidContent("Content does not belong to world guild list.", e) # First TableContainer contains world selector. _, *containers = parsed_content.find_all('div', class_="TableContainer") for container in containers: header = container.find('div', class_="Text") active = "Active" in header.text header, *rows = container.find_all("tr", {'bgcolor': ["#D4C0A1", "#F1E0C6"]}) for row in rows: columns = row.find_all('td') logo_img = columns[0].find('img')["src"] description_lines = columns[1].get_text("\n").split("\n", 1) name = description_lines[0] description = None if len(description_lines) > 1: description = description_lines[1].replace("\r", "").replace("\n", " ") guild = GuildEntry(name, guilds.world, logo_img, description, active) guilds.entries.append(guild) return guilds
[docs] @classmethod def get_url(cls, world): """Get the Tibia.com URL for the guild section of a specific world. Parameters ---------- world: :class:`str` The name of the world. Returns ------- :class:`str` The URL to the guild's page """ return get_tibia_url("community", "guilds", world=world)
[docs]class Guild(abc.BaseGuild, abc.Serializable): """A Tibia guild, viewed from its guild's page. Attributes ------------ name: :class:`str` The name of the guild. logo_url: :class:`str` The URL to the guild's logo. description: :class:`str`, optional The description of the guild. world: :class:`str` The world this guild belongs to. founded: :class:`datetime.date` The day the guild was founded. active: :class:`bool` Whether the guild is active or still in formation. guildhall: :class:`GuildHouse`, optional The guild's guildhall if any. open_applications: :class:`bool` Whether applications are open or not. active_war: :class:`bool` Whether the guild is currently in an active war or not. .. versionadded:: 3.0.0 disband_date: :class:`datetime.datetime`, optional The date when the guild will be disbanded if the condition hasn't been meet. disband_condition: :class:`str`, optional The reason why the guild will get disbanded. homepage: :class:`str`, optional The guild's homepage, if any. members: :class:`list` of :class:`GuildMember` List of guild members. invites: :class:`list` of :class:`GuildInvite` List of invited characters. """ __slots__ = ( "world", "logo_url", "description", "founded", "active", "guildhall", "open_applications", "active_war", "disband_condition", "disband_date", "homepage", "members", "invites", ) _serializable_properties = ( "member_count", "online_count", "ranks", ) def __init__(self, name=None, world=None, **kwargs): self.name: str = name self.world: str = world self.logo_url: str = kwargs.get("logo_url") self.description: Optional[str] = kwargs.get("description") self.founded = try_date(kwargs.get("founded")) self.active: bool = kwargs.get("active", False) self.guildhall: Optional[GuildHouse] = kwargs.get("guildhall") self.open_applications: bool = kwargs.get("open_applications", False) self.active_war: bool = kwargs.get("active_war", False) self.disband_condition: Optional[str] = kwargs.get("disband_condition") self.disband_date = try_datetime(kwargs.get("disband_date")) self.homepage: Optional[str] = kwargs.get("homepage") self.members: List[GuildMember] = kwargs.get("members", []) self.invites: List[GuildInvite] = kwargs.get("invites", []) def __repr__(self): return f"<{self.__class__.__name__} name={self.name!r} world={self.world!r}>" # region Properties @property def member_count(self): """:class:`int`: The number of members in the guild.""" return len(self.members) @property def online_count(self): """:class:`int`: The number of online members in the guild.""" return len(self.online_members) @property def online_members(self): """:class:`list` of :class:`GuildMember`: List of currently online members.""" return list(filter(lambda m: m.online, self.members)) @property def ranks(self) -> List[str]: """:class:`list` of :class:`str`: Ranks in their hierarchical order.""" return list(OrderedDict.fromkeys((m.rank for m in self.members))) @property def members_by_rank(self) -> Dict[str, List['GuildMember']]: """:class:`dict`: Get a mapping of members, grouped by their guild rank.""" rank_dict = defaultdict(list) [rank_dict[m.rank].append(m) for m in self.members] return dict(rank_dict) # endregion # region Public methods
[docs] @classmethod def from_content(cls, content): """Create an instance of the class from the HTML content of the guild's page. Parameters ----------- content: :class:`str` The HTML content of the page. Returns ---------- :class:`Guild` The guild contained in the page or None if it doesn't exist. Raises ------ InvalidContent If content is not the HTML of a guild's page. """ if "An internal error has occurred" in content: return None parsed_content = parse_tibiacom_content(content) try: name_header = parsed_content.find('h1') guild = Guild(name_header.text.strip()) except AttributeError: raise InvalidContent("content does not belong to a Tibia.com guild page.") if not guild._parse_logo(parsed_content): raise InvalidContent("content does not belong to a Tibia.com guild page.") info_container = parsed_content.find("div", id="GuildInformationContainer") guild._parse_guild_info(info_container) guild._parse_application_info(info_container) guild._parse_guild_homepage(info_container) guild._parse_guild_guildhall(info_container) guild._parse_guild_disband_info(info_container) guild._parse_guild_members(parsed_content) if guild.guildhall and guild.members: guild.guildhall.owner = guild.members[0].name return guild
# endregion # region Private methods def _parse_current_member(self, previous_rank, values): """Parse the column texts of a member row into a member dictionary. Parameters ---------- previous_rank: :class:`dict`[int, str] The last rank present in the rows. values: :class:`tuple` of :class:`str` A list of row contents. """ rank, name, vocation, level, joined, status = values rank = previous_rank[1] if rank == " " else rank title = None previous_rank[1] = rank m = title_regex.match(name) if m: name = m.group(1) title = m.group(2) self.members.append(GuildMember(name.strip(), rank.strip(), title, int(level), vocation, joined=joined, online=status == "online")) def _parse_application_info(self, info_container): """ Parse the guild's application info. Parameters ---------- info_container: :class:`bs4.Tag` The parsed content of the information container. """ m = applications_regex.search(info_container.text) if m: self.open_applications = m.group(1) == "opened" self.active_war = "during war" in info_container.text def _parse_guild_disband_info(self, info_container): """ Parse the guild's disband info, if available. Parameters ---------- info_container: :class:`bs4.Tag` The parsed content of the information container. """ m = disband_regex.search(info_container.text) if m: self.disband_condition = m.group(2) self.disband_date = parse_tibia_date(m.group(1).replace("\xa0", " ")) def _parse_guild_guildhall(self, info_container): """ Parse the guild's guildhall info. Parameters ---------- info_container: :class:`bs4.Tag` The parsed content of the information container. """ m = guildhall_regex.search(info_container.text) if m: paid_until = parse_tibia_date(m.group("date").replace("\xa0", " ")) self.guildhall = GuildHouse(m.group("name"), self.world, paid_until_date=paid_until) def _parse_guild_homepage(self, info_container): """Parse the guild's homepage info. Parameters ---------- info_container: :class:`bs4.Tag` The parsed content of the information container. """ m = homepage_regex.search(info_container.text) if m: self.homepage = m.group(1) def _parse_guild_info(self, info_container): """ Parse the guild's general information and applies the found values. Parameters ---------- info_container: :class:`bs4.Tag` The parsed content of the information container. """ m = founded_regex.search(info_container.text) if m: description = m.group("desc").strip() self.description = description if description else None self.world = m.group("world") self.founded = parse_tibia_date(m.group("date").replace("\xa0", " ")) self.active = "currently active" in m.group("status") def _parse_logo(self, parsed_content): """Parse the guild logo and saves it to the instance. Parameters ---------- parsed_content: :class:`bs4.Tag` The parsed content of the page. Returns ------- :class:`bool` Whether the logo was found or not. """ logo_img = parsed_content.find('img', {'height': '64'}) if logo_img is None: return False self.logo_url = logo_img["src"] return True def _parse_guild_members(self, parsed_content): """ Parse the guild's member and invited list. Parameters ---------- parsed_content: :class:`bs4.Tag` The parsed content of the guild's page """ member_rows = parsed_content.find_all("tr", {'bgcolor': ["#D4C0A1", "#F1E0C6"]}) previous_rank = {} for row in member_rows: columns = row.find_all('td') values = tuple(c.text.replace("\u00a0", " ") for c in columns) if len(columns) == COLS_GUILD_MEMBER: self._parse_current_member(previous_rank, values) if len(columns) == COLS_INVITED_MEMBER: self._parse_invited_member(values) def _parse_invited_member(self, values): """Parse the column texts of an invited row into a invited dictionary. Parameters ---------- values: tuple[:class:`str`] A list of row contents. """ name, date = values if date != "Invitation Date": self.invites.append(GuildInvite(name, date))
# endregion
[docs]class GuildMember(abc.BaseCharacter, abc.Serializable): """Represents a guild member. Attributes -------------- rank: :class:`str` The rank the member belongs to name: :class:`str` The name of the guild member. title: :class:`str`, optional The member's title. level: :class:`int` The member's level. vocation: :class:`Vocation` The member's vocation. joined: :class:`datetime.date` The day the member joined the guild. online: :class:`bool` Whether the member is online or not. """ __slots__ = ( "name", "rank", "title", "level", "vocation", "joined", "online", ) def __init__(self, name=None, rank=None, title=None, level=0, vocation=None, **kwargs): self.name: str = name self.rank: str = rank self.title: Optional[str] = title self.vocation: Vocation = try_enum(Vocation, vocation) self.level = int(level) self.online: bool = kwargs.get("online", False) self.joined: datetime.date = try_date(kwargs.get("joined")) def __repr__(self): return f"<{self.__class__.__name__} name={self.name!r} rank={self.rank!r} level={self.level} " \ f"vocation={self.vocation!r}>"
[docs]class GuildInvite(abc.BaseCharacter, abc.Serializable): """Represents an invited character. Attributes ------------ name: :class:`str` The name of the character date: :class:`datetime.date` The day when the character was invited. """ __slots__ = ( "name", "date", ) def __init__(self, name=None, date=None): self.name: str = name self.date: datetime.date = try_date(date) def __repr__(self): return f"<{self.__class__.__name__} name={self.name!r} date={self.date!r}>"
[docs]class GuildWars(abc.Serializable): """Represents a guild's wars. .. versionadded:: 3.0.0 Attributes ---------- name: :class:`str` The name of the guild. current: :class:`GuildWarEntry` The current war the guild is involved in. history: :class:`list` of :class:`GuildWarEntry` The previous wars the guild has been involved in. """ __slots__ = ( 'name', 'current', 'history', ) def __init__(self, name, current=None, history=None): self.name: str = name self.current: Optional[GuildWarEntry] = current self.history: List[GuildWarEntry] = history or [] def __repr__(self): return f"<{self.__class__.__name__} name={self.name!r}>" @property def url(self): """:class:`str`: The URL of this guild's war page on Tibia.com.""" return self.get_url(self.name)
[docs] @classmethod def get_url(cls, name): """Get the URL to the guild's war page of a guild with the given name. Parameters ---------- name: :class:`str` The name of the guild. Returns ------- :class:`str` The URL to the guild's war page. """ return Guild.get_url_wars(name)
[docs] @classmethod def from_content(cls, content): """Get a guild's war information from Tibia.com's content. Parameters ---------- content: :class:`str` The HTML content of a guild's war section in Tibia.com Returns ------- :class:`GuildWars` The guild's war information. """ try: parsed_content = parse_tibiacom_content(content) table_current, table_history = parsed_content.find_all("div", attrs={"class": "TableContainer"}) current_table_content = table_current.find("table", attrs={"class": "TableContent"}) current_war = None guild_name = None if current_table_content is not None: for br in current_table_content.find_all("br"): br.replace_with("\n") current_war = cls._parse_current_war_information(current_table_content.text) else: current_war_text = table_current.text current_war_match = war_current_empty.search(current_war_text) guild_name = current_war_match.group(1) history_entries = [] history_contents = table_history.find_all("table", attrs={"class": "TableContent"}) for history_content in history_contents: for br in history_content.find_all("br"): br.replace_with("\n") entry = cls._parse_war_history_entry(history_content.text) history_entries.append(entry) if current_war: guild_name = current_war.guild_name elif history_entries: guild_name = history_entries[0].guild_name return cls(guild_name, current=current_war, history=history_entries) except ValueError as e: raise InvalidContent("content does not belong to the guild wars section", e)
@classmethod def _parse_current_war_information(cls, text): """Parse the guild's current war information. Parameters ---------- text: :class:`str` The text describing the current war's information. Returns ------- :class:`GuildWarEntry` The guild's war entry for the current war. """ text = text.replace('\xa0', ' ').strip() names_match = war_guilds_regegx.search(text) guild_name, opposing_name = names_match.groups() scores_match = war_score_regex.findall(text) guild_score, opposing_score = scores_match fee_match = war_fee_regex.findall(text) guild_fee, opposing_fee = fee_match score_limit_match = war_score_limit_regex.search(text) score_limit = score_limit_match.group(1) end_date_match = war_end_regex.search(text) end_date_str = end_date_match.group(1) end_date = parse_tibia_date(end_date_str) return GuildWarEntry(guild_name=guild_name, opponent_name=opposing_name, guild_score=int(guild_score), opponent_score=int(opposing_score), guild_fee=int(guild_fee), opponent_fee=int(opposing_fee), score_limit=int(score_limit), end_date=end_date) @classmethod def _parse_war_history_entry(cls, text): """Parse a guild's war information. Parameters ---------- text: :class:`str` The text describing the war's information. Returns ------- :class:`GuildWarEntry` The guild's war entry described in the text.. """ text = text.replace('\xa0', ' ').strip() header_match = war_history_header_regex.search(text) guild_name, opposing_name = header_match.groups() if "disbanded guild" in opposing_name: opposing_name = None start_duration_match = war_start_duration_regex.search(text) start_str, duration_str = start_duration_match.groups() start_date = parse_tibia_date(start_str) duration = datetime.timedelta(days=int(duration_str)) kills_match = kills_needed_regex.search(text) kills_needed = int(kills_match.group(1)) fee_match = war_history_fee_regex.search(text) guild_fee, opponent_fee = fee_match.groups() winner = None surrender = False end_date = None guild_score = opponent_score = 0 surrender_match = surrender_regex.search(text) if surrender_match: surrending_guild = surrender_match.group(1) end_date = parse_tibia_date(surrender_match.group(2)) winner = guild_name if surrending_guild != guild_name else opposing_name surrender = True war_score_match = war_score_regex.findall(text) if war_score_match and len(war_score_match) == 2: guild_score, opponent_score = war_score_match guild_score = int(guild_score) opponent_score = int(guild_score) war_end_match = war_ended_regex.search(text) if war_end_match: end_date = parse_tibia_date(war_end_match.group(1)) winning_guild = war_end_match.group(2) if "disbanded guild" in winning_guild: winning_guild = None winner = guild_name if winning_guild == guild_name else opposing_name loser_score_match = war_score_end_regex.search(text) loser_score = int(loser_score_match.group(1)) if loser_score_match else 0 guild_score = kills_needed if guild_name == winner else loser_score opponent_score = kills_needed if guild_name != winner else loser_score if "no guild had reached the needed kills" in text: winner = guild_name if guild_score > opponent_score else opposing_name return GuildWarEntry(guild_name=guild_name, opponent_name=opposing_name, start_date=start_date, duration=duration, score_limit=kills_needed, guild_fee=int(guild_fee), opponent_fee=int(opponent_fee), surrender=surrender, winner=winner, end_date=end_date, opponent_score=opponent_score, guild_score=guild_score)
[docs]class GuildWarEntry(abc.Serializable): """Represents a guild war entry. .. versionadded:: 3.0.0 Attributes ---------- guild_name: :class:`str` The name of the guild. guild_score: :class:`int` The number of kills the guild has scored. guild_fee: :class:`int` The number of gold coins the guild will pay if they lose the war. opponent_name: :class:`str` The name of the opposing guild. If the guild no longer exist, this will be :obj:`None`. opponent_score: :class:`int` The number of kills the opposing guild has scored. opponent_fee: :class:`int` The number of gold coins the opposing guild will pay if they lose the war. start_date: :class:`datetime.date` The date when the war started. When a war is in progress, the start date is not visible. score_limit: :class:`int` The number of kills needed to win the war. duration: :class:`datetime.timedelta` The set duration of the war. When a war is in progress, the duration is not visible. end_date: :class:`datetime.date` The deadline for the war to finish if the score is not reached for wars in progress, or the date when the war ended. winner: :class:`str` The name of the guild that won. Note that if the winning guild is disbanded, this may be :obj:`None`. surrender: :class:`bool` Whether the losing guild surrendered or not. """ __slots__ = ( "guild_name", "guild_score", "guild_fee", "opponent_name", "opponent_score", "opponent_fee", "start_date", "score_limit", "duration", "end_date", "winner", "surrender", ) def __init__(self, **kwargs): self.guild_name: str = kwargs.get("guild_name") self.guild_score: int = kwargs.get("guild_score", 0) self.guild_fee: int = kwargs.get("guild_fee", 0) self.opponent_name: Optional[str] = kwargs.get("opponent_name") self.opponent_score: str = kwargs.get("opponent_score", 0) self.opponent_fee: int = kwargs.get("opponent_fee", 0) self.start_date: datetime.datetime = kwargs.get("start_date") self.score_limit: int = kwargs.get("score_limit", 0) self.duration: datetime.timedelta = kwargs.get("duration") self.end_date: datetime.datetime = kwargs.get("end_date") self.winner: Optional[str] = kwargs.get("winner") self.surrender: bool = kwargs.get("surrender", False) def __repr__(self): return f"<{self.__class__.__name__} guild_name={self.guild_name!r} opponent_name={self.opponent_name!r}>" @property def guild_url(self): """:class:`str`: The URL to the guild's information page on Tibia.com.""" return Guild.get_url(self.guild_name) @property def opponent_guild_url(self): """:class:`str`: The URL to the opposing guild's information page on Tibia.com.""" return Guild.get_url(self.opponent_name) if self.opponent_name else None
[docs]class GuildEntry(abc.BaseGuild, abc.Serializable): """Represents a Tibia guild in the guild list of a world. Attributes ------------ name: :class:`str` The name of the guild. logo_url: :class:`str` The URL to the guild's logo. description: :class:`str`, optional The description of the guild. world: :class:`str` The world this guild belongs to. active: :class:`bool` Whether the guild is active or still in formation. """ __slots__ = ( "name", "logo_url", "description", "world", "active", ) def __init__(self, name, world, logo_url=None, description=None, active=False): self.name: str = name self.world: str = world self.logo_url: str = logo_url self.description: Optional[str] = description self.active: bool = active