138 lines
5.3 KiB
Python
Executable File
138 lines
5.3 KiB
Python
Executable File
import logging
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from typing import Generator
|
|
|
|
import bs4
|
|
import cloudscraper
|
|
|
|
from updater.site import CURSE_UA
|
|
from updater.site.abstract_site import AbstractSite, SiteError
|
|
from updater.site.enum import AddonVersion, GameVersion
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class CurseAddonVersion:
|
|
type: AddonVersion
|
|
name: str
|
|
size: str
|
|
uploaded: str
|
|
game_version: str
|
|
downloads: int
|
|
download_link: str
|
|
|
|
@classmethod
|
|
def from_tr(cls, tr: bs4.element.Tag):
|
|
cells = tr.find_all('td')
|
|
name = cells[1].text.strip()
|
|
name = name if '\n' not in name else name.split('\n')[0]
|
|
size = cells[2].text.strip()
|
|
uploaded = datetime.fromtimestamp(int(cells[3].find('abbr').attrs.get('data-epoch'))).isoformat()
|
|
game_version = cells[4].text.strip()
|
|
downloads = int(cells[5].text.replace(',', '').strip())
|
|
|
|
return cls(type=cls.get_type(cells[0]), name=name, size=size,
|
|
uploaded=uploaded, game_version=game_version, downloads=downloads,
|
|
download_link=cls.get_link(cells[6]))
|
|
|
|
@staticmethod
|
|
def get_type(td: bs4.element.Tag) -> AddonVersion:
|
|
class_fields = td.find('div').attrs.get('class')
|
|
bg_field = next(field for field in class_fields if field.startswith('bg-'))
|
|
if 'blue' in bg_field:
|
|
return AddonVersion.release #beta
|
|
elif 'green' in bg_field:
|
|
return AddonVersion.release
|
|
elif 'offset' in bg_field:
|
|
return AddonVersion.alpha
|
|
else:
|
|
raise ValueError
|
|
|
|
@staticmethod
|
|
def get_link(td: bs4.element.Tag) -> str:
|
|
relative_link = td.find('a').attrs.get('href')
|
|
return f'https://www.curseforge.com{relative_link}/file'
|
|
|
|
|
|
class Curse(AbstractSite):
|
|
_OLD_URL = 'https://mods.curse.com/addons/wow/'
|
|
_OLD_PROJECT_URL = 'https://wow.curseforge.com/projects/'
|
|
|
|
_URLS = [
|
|
'https://www.curseforge.com/wow/addons/',
|
|
'https://curseforge.com/wow/addons/',
|
|
_OLD_URL,
|
|
_OLD_PROJECT_URL
|
|
]
|
|
|
|
session = cloudscraper.create_scraper(browser=CURSE_UA,delay=10)
|
|
|
|
def __init__(self, url: str, game_version: GameVersion, addon_version: AddonVersion = AddonVersion.release):
|
|
url = Curse._normalize_curse_urls(url)
|
|
super().__init__(url, game_version)
|
|
self.addon_version = addon_version
|
|
|
|
def find_zip_url(self):
|
|
try:
|
|
return self.latest_release().download_link
|
|
except Exception as e:
|
|
raise self.download_error() from e
|
|
|
|
def versions(self, *, page=1) -> Generator[CurseAddonVersion, None, None]:
|
|
"""Yields a sequence of CurseAddonVersions corresponding to addon releases
|
|
|
|
Ordered descending in time, so the first version yielded is the most recent.
|
|
Will page through until exhausted.
|
|
"""
|
|
if self.game_version == GameVersion.classic:
|
|
game_version_filter = '1738749986:67408'
|
|
elif self.game_version == GameVersion.retail:
|
|
game_version_filter = '1738749986:517'
|
|
else: # Agnostic version
|
|
game_version_filter = ''
|
|
request_params = {'filter-game-version': game_version_filter, 'page': page}
|
|
try:
|
|
p = Curse.session.get(f'{self.url}/files/all', params=request_params)
|
|
soup = bs4.BeautifulSoup(p.text, 'html.parser')
|
|
versions_table = soup.find('table', {'class': 'listing listing-project-file project-file-listing b-table b-table-a'})
|
|
# Header row consumed by _
|
|
_, *version_rows = versions_table.find_all('tr')
|
|
yield from (CurseAddonVersion.from_tr(row) for row in version_rows)
|
|
|
|
# determine if there are more pages of versions, recurse if so
|
|
pages_exist = soup.find('div', {'class': 'pagination pagination-top flex items-center'})
|
|
inactive_next_page = soup.find('div', {'class': 'pagination-next h-6 w-6 flex items-center justify-center pagination-next--inactive'})
|
|
if pages_exist and not inactive_next_page:
|
|
yield from self.versions(page=page+1)
|
|
except Exception as e:
|
|
raise self.version_error() from e
|
|
|
|
def latest_release(self) -> CurseAddonVersion:
|
|
latest = next(version for version in self.versions() if version.type >= self.addon_version)
|
|
return latest
|
|
|
|
def get_latest_version(self) -> str:
|
|
"""Returns the latest version released for retail/classic
|
|
|
|
The `version.type >= self.addon_version` logic chooses the most recent
|
|
addon according to the ordering that release > beta > alpha. So if you
|
|
are following the beta track and a new alpha version is release, you won't
|
|
get it, but a new release version you will.
|
|
|
|
Returns the name of the most recent release.
|
|
"""
|
|
return self.latest_release().name
|
|
|
|
@classmethod
|
|
def _normalize_curse_urls(cls, url: str) -> str:
|
|
try:
|
|
# Some old URLs may point to nonexistent pages. Rather than guess at what the new
|
|
# name and URL is, just try to load the old URL and see where Curse redirects us to.
|
|
page = Curse.session.get(url)
|
|
page.raise_for_status()
|
|
return page.url
|
|
except Exception as e:
|
|
raise SiteError(f"Failed to find the current page for old URL: {url}") from e
|