Source code for tibiapy.house

"""Models related to the houses section in Tibia.com."""
import datetime
import re
from typing import Dict, List, Optional

from tibiapy import abc
from tibiapy.enums import HouseOrder, HouseStatus, HouseType, Sex
from tibiapy.errors import InvalidContent
from tibiapy.utils import get_tibia_url, parse_tibia_datetime, parse_tibia_money, \
    parse_tibiacom_content, parse_tibiacom_tables, try_date, try_datetime, try_enum

__all__ = (
    "HousesSection",
    "House",
    "CharacterHouse",
    "GuildHouse",
    "HouseEntry",
)

id_regex = re.compile(r'house_(\d+)\.')
bed_regex = re.compile(r'This (?P<type>\w+) can have up to (?P<beds>[\d-]+) bed')
info_regex = \
    re.compile(r'The house has a size of (?P<size>\d+) square meter[s]?. '
               r'The monthly rent is (?P<rent>\d+k?) gold and will be debited to the bank account on (?P<world>\w+).')

rented_regex = re.compile(r'The house has been rented by (?P<owner>[^.]+)\.'
                          r' (?P<pronoun>\w+) has paid the rent until (?P<paid_until>[^.]+)\.')
transfer_regex = re.compile(r'\w+ will move out on (?P<transfer_date>[^(]+)\([^)]+\)(?: and (?P<verb>wants to|will)'
                            r' pass the house to (?P<transferee>[\w\s]+) for (?P<transfer_price>\d+) gold coin)?')
moving_regex = re.compile(r'\w+ will move out on (?P<move_date>[^(]+)')
bid_regex = \
    re.compile(r'The highest bid so far is (?P<highest_bid>\d+) gold and has been submitted by (?P<bidder>[^.]+)')
auction_regex = re.compile(r'The auction (?P<auction_state>has ended|will end) at (?P<auction_end>[^.]+).')

list_header_regex = re.compile(r'Available (?P<type>[\w\s]+) in (?P<town>[\w\s\']+) on (?P<world>\w+)')
list_auction_regex = re.compile(r'\((?P<bid>\d+) gold; (?P<time_left>\w)+ (?P<time_unit>day|hour)s? left\)')


[docs]class HousesSection(abc.Serializable): """Represents the house section. .. versionadded:: 5.0.0 Attributes ---------- world: :class:`str` The selected world to show houses for. town: :class:`str` The town to show houses for. status: :class:`HouseStatus` The status to show. If :obj:`None`, any status is shown. house_type: :class:`HouseType` The type of houses to show. order: :class:`HouseOrder` The ordering to use for the results. entries: :class:`list` of :class:`HouseEntry` The houses matching the filters. available_worlds: :class:`list` of :class:`str` The list of available worlds to choose from. available_towns: :class:`list` of :class:`str` The list of available towns to choose from. """ __slots__ = ( "world", "town", "status", "house_type", "order", "entries", "available_worlds", "available_towns", ) def __init__(self, **kwargs): self.world: str = kwargs.get("world") self.town: str = kwargs.get("town") self.status: Optional[HouseStatus] = kwargs.get("status") self.house_type: HouseType = kwargs.get("house_type") self.order: HouseOrder = kwargs.get("order", HouseOrder.NAME) self.available_worlds: List[str] = kwargs.get("available_worlds", []) self.available_towns: List[str] = kwargs.get("available_towns", []) self.entries: List[HouseEntry] = kwargs.get("entries", []) def __repr__(self): return f"<{self.__class__.__name__} world={self.world!r} town={self.town!r} house_type={self.house_type!r}>" @property def url(self): """:class:`str`: Get the URL to the houses section with the current parameters.""" return self.get_url(self.world, self.town, self.house_type, self.status, self.order) @classmethod def _get_query_params(cls, world, town, house_type, status=None, order=None) -> Dict[str, str]: """Build the query parameters for a house search. Parameters ---------- world: :class:`str` The world to search for houses in. town: :class:`str` The town to search houses in. house_type: :class:`HouseType` The type of houses to filter. status: :class:`HouseStatus`, optional The status of the house to filter. If undefined, all status will be included. order: :class:`HouseOrder` The field to order houses by. Returns ------- :class:`dict` The query parameters representing for a house search. """ params = { "world": world, "town": town, "type": house_type.plural if house_type else None, "status": status.value if status else None, "order": order.value if order else None, } return {k: v for k, v in params.items() if v is not None}
[docs] @classmethod def get_url(cls, world, town, house_type, status=None, order=None): """Get the URL to the house list on Tibia.com with the specified filters. Parameters ---------- world: :class:`str` The world to search in. town: :class:`str` The town to show results for. house_type: :class:`HouseType` The type of houses to show. status: :class:`HouseStatus` The status of the houses to show. order: :class:`HouseOrder` The sorting parameter to use for the results. Returns ------- :class:`str` The URL to the list matching the parameters. """ query = cls._get_query_params(world, town, house_type, status, order) return get_tibia_url("community", "houses", **query)
[docs] @classmethod def from_content(cls, content): """Parse the content of a house list from Tibia.com into a list of houses. Parameters ---------- content: :class:`str` The raw HTML response from the house list. Returns ------- :class:`HouseSection` The houses found in the page. Raises ------ InvalidContent` Content is not the house list from Tibia.com """ try: parsed_content = parse_tibiacom_content(content) tables = parse_tibiacom_tables(parsed_content) house_results = cls() house_results._parse_filters(tables["House Search"]) if len(tables) < 2: return house_results houses_table = tables[list(tables.keys())[0]] _, *rows = houses_table.find_all("tr") for row in rows[1:]: cols = row.find_all("td") if len(cols) != 5: continue name = cols[0].text.replace('\u00a0', ' ') house = HouseEntry(name, house_results.world, 0, town=house_results.town, type=house_results.house_type) size = cols[1].text.replace('sqm', '') house.size = int(size) rent = cols[2].text.replace('gold', '') house.rent = parse_tibia_money(rent) status = cols[3].text.replace('\xa0', ' ') house._parse_status(status) id_input = cols[4].find("input", {'name': 'houseid'}) house.id = int(id_input["value"]) house_results.entries.append(house) return house_results except (ValueError, AttributeError, KeyError) as e: raise InvalidContent("content does not belong to a Tibia.com house list", e)
def _parse_filters(self, filters_table): world_select = filters_table.find("select", {"name": "world"}) for world_option in world_select.find_all("option"): world_name = world_option.text if "(" in world_name: continue self.available_worlds.append(world_name) if world_option.attrs.get("selected"): self.world = world_name for town_option in filters_table.find_all("input", {"name": "town"}): town_name = town_option.attrs.get("value") self.available_towns.append(town_name) if "checked" in town_option.attrs: self.town = town_name checked_status = filters_table.find("input", {"name": "state", "checked": True}) if checked_status.attrs.get("value"): self.status = try_enum(HouseStatus, checked_status.attrs.get("value")) checked_type = filters_table.find("input", {"name": "type", "checked": True}) if checked_type.attrs.get("value"): self.house_type = try_enum(HouseType, checked_type.attrs.get("value")[:-1]) checked_order = filters_table.find("input", {"name": "order", "checked": True}) if checked_order and checked_order.attrs.get("value"): self.order = try_enum(HouseOrder, checked_order.attrs.get("value"), HouseOrder.NAME) return self
[docs]class House(abc.BaseHouse, abc.HouseWithId, abc.Serializable): """Represents a house in a specific world. Attributes ---------- id: :class:`int` The internal ID of the house. This is used on the website to identify houses. name: :class:`str` The name of the house. world: :class:`str` The name of the world the house belongs to. status: :class:`HouseStatus` The current status of the house. type: :class:`HouseType` The type of the house. image_url: :class:`str` The URL to the house's minimap image. beds: :class:`int` The number of beds the house has. size: :class:`int` The number of SQM the house has. rent: :class:`int` The monthly cost paid for the house, in gold coins. owner: :class:`str` The current owner of the house, if any. owner_sex: :class:`Sex` The sex of the owner of the house, if applicable. paid_until: :class:`datetime.datetime`, optional The date the last paid rent is due. transfer_date: :class:`datetime.datetime`, optional The date when the owner will move out of the house, if applicable. transferee: :class:`str`, optional The character who will receive the house when the owner moves, if applicable. transfer_price: :class:`int` The price that will be paid from the transferee to the owner for the house transfer. transfer_accepted: :class:`bool` Whether the house transfer has already been accepted or not. highest_bid: :class:`int` The currently highest bid on the house if it is being auctioned. highest_bidder: :class:`str`, optional The character that holds the highest bid. auction_end: :class:`datetime.datetime`, optional The date when the auction will end. """ __slots__ = ( "id", "name", "world", "status", "type", "image_url", "beds", "type", "size", "rent", "owner", "owner_sex", "paid_until", "transfer_date", "transferee", "transfer_price", "transfer_accepted", "highest_bid", "highest_bidder", "auction_end", ) def __init__(self, name, world=None, **kwargs): self.id: int = kwargs.get("id", 0) self.name: str = name self.world: str = world self.image_url: str = kwargs.get("image_url") self.beds: int = kwargs.get("beds", 0) self.type: HouseType = try_enum(HouseType, kwargs.get("type"), HouseType.HOUSE) self.size: int = kwargs.get("size", 0) self.rent: int = kwargs.get("rent", 0) self.status: HouseStatus = try_enum(HouseStatus, kwargs.get("status"), None) self.owner: Optional[str] = kwargs.get("owner") self.owner_sex: Optional[str] = try_enum(Sex, kwargs.get("owner_sex")) self.paid_until: datetime.datetime = try_datetime(kwargs.get("paid_until")) self.transfer_date: datetime.datetime = try_datetime(kwargs.get("transfer_date")) self.transferee: Optional[str] = kwargs.get("transferee") self.transfer_price: int = kwargs.get("transfer_price", 0) self.transfer_accepted: bool = kwargs.get("transfer_accepted", False) self.highest_bid: int = kwargs.get("highest_bid", 0) self.highest_bidder: Optional[str] = kwargs.get("highest_bidder") self.auction_end: datetime.datetime = try_datetime(kwargs.get("auction_end")) # region Properties @property def owner_url(self): """:class:`str`: The URL to the Tibia.com page of the house's owner, if applicable.""" return abc.BaseCharacter.get_url(self.owner) if self.owner is not None else None @property def transferee_url(self): """:class:`str`: The URL to the Tibia.com page of the character receiving the house, if applicable.""" return abc.BaseCharacter.get_url(self.transferee) if self.transferee is not None else None @property def highest_bidder_url(self): """:class:`str`: The URL to the Tibia.com page of the character with the highest bid, if applicable.""" return abc.BaseCharacter.get_url(self.highest_bidder) if self.highest_bidder is not None else None # endregion # region Public methods
[docs] @classmethod def from_content(cls, content): """Parse a Tibia.com response into a House object. Parameters ---------- content: :class:`str` HTML content of the page. Returns ------- :class:`House` The house contained in the page, or None if the house doesn't exist. Raises ------ InvalidContent If the content is not the house section on Tibia.com """ try: parsed_content = parse_tibiacom_content(content) image_column, desc_column, *_ = parsed_content.find_all('td') if "Error" in image_column.text: return None image = image_column.find('img') for br in desc_column.find_all("br"): br.replace_with("\n") description = desc_column.text.replace("\u00a0", " ").replace("\n\n", "\n") lines = description.splitlines() try: name, beds, info, state, *_ = lines except ValueError: raise InvalidContent("content does is not from the house section of Tibia.com") house = cls(name.strip()) house.image_url = image["src"] house.id = int(id_regex.search(house.image_url).group(1)) m = bed_regex.search(beds) if m: if m.group("type").lower() in ["guildhall", "clanhall"]: house.type = HouseType.GUILDHALL else: house.type = HouseType.HOUSE house.beds = int(m.group("beds")) m = info_regex.search(info) if m: house.world = m.group("world") house.rent = parse_tibia_money(m.group("rent")) house.size = int(m.group("size")) house._parse_status(state) return house except (ValueError, TypeError) as e: raise InvalidContent("content does not belong to a house page", e)
# endregion def _parse_status(self, status): """Parse the house's state description and applies the corresponding values. Parameters ---------- status: :class:`str` Plain text string containing the current renting state of the house. """ m = rented_regex.search(status) if m: self.status = HouseStatus.RENTED self.owner = m.group("owner") self.owner_sex = Sex.MALE if m.group("pronoun") == "He" else Sex.FEMALE self.paid_until = parse_tibia_datetime(m.group("paid_until")) else: self.status = HouseStatus.AUCTIONED m = transfer_regex.search(status) if m: self.transfer_date = parse_tibia_datetime(m.group("transfer_date")) self.transfer_accepted = m.group("verb") == "will" self.transferee = m.group("transferee") price = m.group("transfer_price") self.transfer_price = int(price) if price is not None else 0 m = auction_regex.search(status) if m: self.auction_end = parse_tibia_datetime(m.group("auction_end")) m = bid_regex.search(status) if m: self.highest_bid = int(m.group("highest_bid")) self.highest_bidder = m.group("bidder")
[docs]class CharacterHouse(abc.BaseHouse, abc.HouseWithId, abc.Serializable): """A house owned by a character. Attributes ---------- id: :class:`int` The internal ID of the house. This is used on the website to identify houses. name: :class:`str` The name of the house. world: :class:`str` The name of the world the house belongs to. status: :class:`HouseStatus` The current status of the house. type: :class:`HouseType` The type of the house. town: :class:`str` The town where the city is located in. owner: :class:`str` The owner of the house. paid_until_date: :class:`datetime.date` The date the last paid rent is due. """ __slots__ = ( "id", "name", "world", "status", "type", "town", "owner", "paid_until_date", ) def __init__(self, _id, name, world=None, town=None, owner=None, paid_until_date=None): self.id = int(_id) self.name: str = name self.town: str = town self.world: str = world self.owner: str = owner self.paid_until_date = try_date(paid_until_date) self.status = HouseStatus.RENTED self.type = HouseType.HOUSE
[docs]class GuildHouse(abc.BaseHouse, abc.Serializable): """A guildhall owned by a guild. By limitation of Tibia.com, the ID of the guildhall is not available. Attributes ---------- name: :class:`str` The name of the house. world: :class:`str` The name of the world the house belongs to. status: :class:`HouseStatus` The current status of the house. This is kept for compatibility with house objects and will always be :obj:`HouseStatus.RENTED`. type: :class:`HouseType` The type of the house. This is kept for compatibility with house objects and will always be :obj:`HouseType.GUILDHALL`. owner: :class:`str` The owner of the guildhall. paid_until_date: :class:`datetime.date` The date the last paid rent is due. """ __slots__ = ( "name", "world", "status", "owner", "paid_until_date", ) def __init__(self, name, world=None, owner=None, paid_until_date=None): self.name: str = name self.world: str = world self.owner: str = owner self.paid_until_date: datetime.date = try_date(paid_until_date) self.status: HouseStatus = HouseStatus.RENTED self.type: HouseType = HouseType.GUILDHALL def __repr__(self): return "<%s name=%r>" % (self.__class__.__name__, self.name)
[docs]class HouseEntry(abc.BaseHouse, abc.HouseWithId, abc.Serializable): """Represents a house from the house list in Tibia.com. Attributes ---------- id: :class:`int` The internal ID of the house. This is used on the website to identify houses. name: :class:`str` The name of the house. world: :class:`str` The name of the world the house belongs to. status: :class:`HouseStatus` The current status of the house. type: :class:`HouseType` The type of house. town: :class:`str` The town where the house is located. size: :class:`int` The size of the house in SQM. rent: :class:`int` The monthly cost of the house, in gold coins. time_left: :class:`datetime.timedelta`, optional The number of days or hours left until the bid ends, if it has started. This is not an exact measure, it is rounded to hours or days. highest_bid: :class:`int`, optional. The highest bid so far, if the auction has started. """ __slots__ = ( "id", "name", "world", "status", "type", "town", "size", "rent", "time_left", "highest_bid", ) def __init__(self, name, world, houseid, **kwargs): self.name: str = name self.id = int(houseid) self.world: str = world self.status = try_enum(HouseStatus, kwargs.get("status")) self.type = try_enum(HouseType, kwargs.get("type")) self.town: str = kwargs.get("town") self.size: int = kwargs.get("size", 0) self.rent: int = kwargs.get("rent", 0) self.time_left: Optional[datetime.timedelta] = kwargs.get("time_left") self.highest_bid: Optional[int] = kwargs.get("highest_bid", None) # region Private methods def _parse_status(self, status): """ Parse the status string found in the table and applies the corresponding values. Parameters ---------- status: :class:`str` The string containing the status. """ if "rented" in status: self.status = HouseStatus.RENTED else: m = list_auction_regex.search(status) if m: self.highest_bid = int(m.group('bid')) if m.group("time_unit") == "day": self.time_left = datetime.timedelta(days=int(m.group("time_left"))) else: self.time_left = datetime.timedelta(hours=int(m.group("time_left"))) self.status = HouseStatus.AUCTIONED
# endregion