import datetime
import json
import re
import urllib.parse
from collections import OrderedDict
from typing import Optional, List
import bs4
from . import abc, InvalidContent, GUILD_LIST_URL
from .const import GUILD_URL
from .utils import parse_tibia_date
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([^.]+).')
title_regex = re.compile(r'([\w\s]+)\s\(([^)]+)\)')
[docs]class Guild:
"""
Represents a Tibia guild.
Attributes
------------
name: :class:`str`
The name of the guild. Names are case sensitive.
logo_url: :class:`str`
The URL to the guild's logo.
description: Optional[:class:`str`]
The description of the guild.
world: :class:`str`
The world where this guild is in.
founded: :class:`datetime.date`
The day the guild was founded.
active: :class:`bool`
Whether the guild is active or still in formation.
guildhall: Optional[:class:`dict`]
The guild's guildhall.
open_applications: :class:`bool`
Whether applications are open or not.
disband_condition: Optional[:class:`str`]
The reason why the guild will get disbanded.
disband_date: Optional[:class:`str`]
The date when the guild will be disbanded if the condition hasn't been meet.
homepage: :class:`str`
The guild's homepage
members: List[:class:`GuildMember`]
List of guild members.
invites: List[:class:`GuildInvite`]
List of invited characters.
"""
__slots__ = ("name", "logo_url", "description", "world", "founded", "active", "guildhall", "open_applications",
"disband_condition", "disband_date", "homepage", "members", "invites")
def __init__(self, name=None, world=None,**kwargs):
self.name = name
self.world = world
self.logo_url = kwargs.get("logo_url")
self.description = kwargs.get("description")
_founded = kwargs.get("founded")
if isinstance(_founded, datetime.datetime):
self.founded = _founded.date()
elif isinstance(_founded, datetime.date):
self.founded = _founded
elif isinstance(_founded, str):
self.founded = parse_tibia_date(_founded)
else:
self.founded = None
self.active = kwargs.get("active", False)
self.guildhall = kwargs.get("guildhall")
self.open_applications = kwargs.get("open_applications", False)
self.disband_condition = kwargs.get("disband_condition")
self.disband_date = kwargs.get("disband_date")
self.homepage = kwargs.get("homepage")
self.members = kwargs.get("members", [])
self.invites = kwargs.get("invites", [])
def __repr__(self) -> str:
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 member_count(self):
""":class:`int`: The number of members in the guild."""
return len(self.members)
@property
def online_members(self):
"""List[:class:`GuildMember`]: List of currently online members."""
return list(filter(lambda m: m.online, self.members))
@property
def ranks(self):
"""List[:class:`str`]: Ranks in their hierarchical order."""
return list(OrderedDict.fromkeys((m.rank for m in self.members)))
@property
def url(self):
""":class:`str`: The URL to the guild's information page."""
return GUILD_URL + urllib.parse.quote(self.name.encode('iso-8859-1'))
@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.replace('ISO-8859-1', 'utf-8'), 'lxml',
parse_only=bs4.SoupStrainer("div", class_="BoxContent"))
@staticmethod
def _parse(content):
"""
Parses the guild's page HTML content into a dictionary.
Parameters
----------
content: :class:`str`
The HTML content of the guild's page.
Returns
-------
:class:`dict[str, Any]`
A dictionary containing all the guild's information.
"""
if "An internal error has occurred" in content:
return {}
parsed_content = Guild._beautiful_soup(content)
guild = {}
if not Guild._parse_guild_logo(guild, parsed_content):
return {}
Guild._parse_guild_name(guild, parsed_content)
info_container = parsed_content.find("div", id="GuildInformationContainer")
Guild._parse_guild_info(guild, info_container)
Guild._parse_guild_applications(guild, info_container)
Guild._parse_guild_homepage(guild, info_container)
Guild._parse_guild_guildhall(guild, info_container)
Guild._parse_guild_disband_info(guild, info_container)
Guild._parse_guild_members(guild, parsed_content)
return guild
@staticmethod
def _parse_current_member(guild, previous_rank, values):
"""
Parses the column texts of a member row into a member dictionary.
Parameters
----------
guild: :class:`dict`[str, Any]
Dictionary where information will be stored.
previous_rank: :class:`dict`[int, str]
The last rank present in the rows.
values: tuple[:class:`str`]
A list of row contents.
"""
rank, name, vocation, level, joined, status = values
rank = previous_rank[1] if rank == " " else rank
previous_rank[1] = rank
m = title_regex.match(name)
if m:
name = m.group(1)
title = m.group(2)
else:
title = None
guild["members"].append({
"rank": rank,
"name": name,
"title": title,
"vocation": vocation,
"level": int(level),
"joined": joined,
"online": status == "online"
})
@staticmethod
def _parse_guild_applications(guild, info_container):
"""
Parses the guild's application info.
Parameters
----------
guild: :class:`dict`[str, Any]
Dictionary where information will be stored.
info_container: :class:`bs4.Tag`
The parsed content of the information container.
"""
m = applications_regex.search(info_container.text)
if m:
guild["open_applications"] = m.group(1) == "opened"
@staticmethod
def _parse_guild_disband_info(guild, info_container):
"""
Parses the guild's disband info, if available.
Parameters
----------
guild: :class:`dict`[str, Any]
Dictionary where information will be stored.
info_container: :class:`bs4.Tag`
The parsed content of the information container.
"""
m = disband_regex.search(info_container.text)
if m:
guild["disband_condition"] = m.group(2)
guild["disband_date"] = m.group(1).replace("\xa0", " ")
else:
guild["disband_condition"] = None
guild["disband_date"] = None
@staticmethod
def _parse_guild_guildhall(guild, info_container):
"""
Parses the guild's guildhall info.
Parameters
----------
guild: :class:`dict`[str, Any]
Dictionary where information will be stored.
info_container: :class:`bs4.Tag`
The parsed content of the information container.
"""
m = guildhall_regex.search(info_container.text)
if m:
guild["guildhall"] = {"name": m.group("name"), "paid_until": m.group("date").replace("\xa0", " ")}
else:
guild["guildhall"] = None
@staticmethod
def _parse_guild_homepage(guild, info_container):
"""
Parses the guild's homepage info.
Parameters
----------
guild: :class:`dict`[str, Any]
Dictionary where information will be stored.
info_container: :class:`bs4.Tag`
The parsed content of the information container.
"""
m = homepage_regex.search(info_container.text)
if m:
guild["homepage"] = m.group(1)
else:
guild["homepage"] = None
@staticmethod
def _parse_guild_info(guild, info_container):
"""
Parses the guild's general information.
Parameters
----------
guild: :class:`dict`[str, Any]
Dictionary where information will be stored.
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()
guild["description"] = description if description else None
guild["world"] = m.group("world")
guild["founded"] = m.group("date").replace("\xa0", " ")
guild["active"] = "currently active" in m.group("status")
@staticmethod
def _parse_guild_list(content, active_only=False):
"""
Parses the contents of a world's guild list page.
Parameters
----------
content: :class:`str`
The HTML content of the page.
active_only: :class:`bool`
Whether to only show active guilds.
Returns
-------
List[:class:`dict`[str, Any]]
A list of guild dictionaries.
"""
parsed_content = Guild._beautiful_soup(content)
selected_world = parsed_content.find('option', selected=True)
try:
if "choose world" in selected_world.text:
return None
world = selected_world.text
except AttributeError:
raise InvalidContent("Content does not belong to world guild list.")
containers = parsed_content.find_all('div', class_="TableContainer")
try:
# First TableContainer contains world selector.
containers = containers[1:]
except IndexError:
raise InvalidContent("Content does not belong to world guild list.")
guilds = []
for container in containers:
header = container.find('div', class_="Text")
active = "Active" in header.text
if active_only and not active:
return guilds
rows = container.find_all("tr", {'bgcolor': ["#D4C0A1", "#F1E0C6"]})
for row in rows:
columns = row.find_all('td')
if columns[0].text == "Logo":
continue
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"," ")
guilds.append({"logo_url": logo_img, "name": name, "description": description, "active": active,
"world": world})
return guilds
@staticmethod
def _parse_guild_logo(guild, parsed_content):
"""
Parses the guild's logo.
Parameters
----------
guild: :class:`dict`[str, Any]
Dictionary where information will be stored.
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
guild["logo_url"] = logo_img["src"]
return True
@staticmethod
def _parse_guild_members(guild, parsed_content):
"""
Parses the guild's member and invited list.
Parameters
----------
guild: :class:`dict`[str, Any]
Dictionary where information will be stored.
parsed_content: :class:`bs4.Tag`
The parsed content of the guild's page
"""
member_rows = parsed_content.find_all("tr", {'bgcolor': ["#D4C0A1", "#F1E0C6"]})
guild["members"] = []
guild["invites"] = []
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:
Guild._parse_current_member(guild, previous_rank, values)
if len(columns) == COLS_INVITED_MEMBER:
Guild._parse_invited_member(guild, values)
@staticmethod
def _parse_guild_name(guild, parsed_content):
"""
Parses the guild's name.
Parameters
----------
guild: :class:`dict`[str, Any]
Dictionary where information will be stored.
parsed_content: :class:`bs4.Tag`
The parsed content of guild's page.
"""
header = parsed_content.find('h1')
guild["name"] = header.text
@staticmethod
def _parse_invited_member(guild, values):
"""
Parses the column texts of an invited row into a invited dictionary.
Parameters
----------
guild: :class:`dict`[str, Any]
Dictionary where information will be stored.
values: tuple[:class:`str`]
A list of row contents.
"""
name, date = values
if date != "Invitation Date":
guild["invites"].append({
"name": name,
"date": date
})
[docs] @staticmethod
def list_from_content(content, active_only=False):
"""
Gets a list of guilds from the html content of the world guilds' page.
The :class:`Guild` objects in the list only contain the attributes:
:attr:`name`, :attr:`logo_url`, :attr:`world` and if available, :attr:`description`
Parameters
----------
content: :class:`str`
The html content of the page.
active_only: :class:`bool`
Whether to only show active guilds or not.
Returns
-------
List[:class:`Guild`]
List of guilds in the current world.
"""
guild_list = Guild._parse_guild_list(content, active_only)
return [Guild(**g) for g in guild_list]
[docs] @staticmethod
def from_content(content) -> Optional['Guild']:
"""Creates 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
----------
Optional[:class:`Guild`]
The guild contained in the page or None if it doesn't exist.
"""
guild_json = Guild._parse(content)
if not guild_json:
return None
members = []
for member in guild_json["members"]:
members.append(GuildMember(**member))
guild_json["members"] = members
invites = []
for invite in guild_json["invites"]:
invites.append(GuildInvite(**invite))
guild_json["invites"] = invites
guild = Guild(**guild_json)
return guild
[docs] @staticmethod
def get_url(name):
"""Gets the Tibia.com URL for a given guild name.
Parameters
------------
name: :class:`str`
The name of the guild
Returns
--------
:class:`str`
The URL to the guild's page"""
return GUILD_URL + urllib.parse.quote(name.encode('iso-8859-1'))
[docs] @staticmethod
def get_world_list_url(world):
"""Gets 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 GUILD_LIST_URL + urllib.parse.quote(world.title().encode('iso-8859-1'))
[docs] @staticmethod
def json_list_from_content(content, active_only=False, indent=None):
"""
Creates a JSON string from the html content of the world guilds' page.
Parameters
----------
content: :class:`str`
The html content of the page.
active_only: :class:`bool`
Whether to only show active guilds or not.
indent: :class:`int`
The number of spaces to indent the output with.
Returns
-------
:class:`str`
A string in JSON format.
"""
list_json = Guild._parse_guild_list(content, active_only)
return json.dumps(list_json, indent=indent)
[docs] @staticmethod
def parse_to_json(content, indent=None):
"""Creates a JSON string from the html content of the guild'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 = Guild._parse(content)
return json.dumps(char_dict, indent=indent)
[docs]class GuildMember(abc.Character):
"""
Represents a guild member.
Attributes
--------------
rank: :class:`str`
The rank the member belongs to
name: :class:`str`
The name of the guild member.
title: Optional[:class:`str`]
The member's title.
level: :class:`int`
The member's level.
vocation: :class:`str`
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, joined=None, online=False):
self.name = name
self.rank = rank
self.title = title
self.vocation = vocation
self.level = level
self.joined = joined
self.online = online
if isinstance(joined, datetime.datetime):
self.joined = joined.date()
elif isinstance(joined, datetime.date):
self.joined = joined
elif isinstance(joined, str):
self.joined = parse_tibia_date(joined)
else:
self.joined = None
[docs]class GuildInvite(abc.Character):
"""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__ = ("date", )
def __init__(self, name=None, date=None):
self.name = name
if isinstance(date, datetime.datetime):
self.date = date.date()
elif isinstance(date, datetime.date):
self.date = date
elif isinstance(date, str):
self.date = parse_tibia_date(date)
else:
self.date = None