commit 2ddefe1154e6687a3fe0bd3c298b2d22e0373f45 Author: Kumi Date: Mon Oct 24 14:19:17 2022 +0000 Current status - seems to be working well enough diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1c8f647 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +venv/ +*.pyc +__pycache__/ +settings.ini \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f94b684 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +Copyright (c) 2022, Kumi Mitterer + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/allkeyshop.py b/allkeyshop.py new file mode 100644 index 0000000..c13c5b1 --- /dev/null +++ b/allkeyshop.py @@ -0,0 +1,169 @@ +from prometheus_client import start_http_server, Gauge + +from configparser import ConfigParser +from pathlib import Path +from urllib.request import urlopen, Request +from html.parser import HTMLParser +from typing import List, Tuple, Optional + +import re +import json +import time + + +class AllKeyShop: + PLATFORM_PC = "cd-key" + PLATFORM_PS5 = "ps5" + PLATFORM_PS4 = "ps4" + PLATFORM_XB1 = "xbox-one" + PLATFORM_XBSX = "xbox-series-x" + PLATFORM_SWITCH = "nintendo-switch" + + class ProductParser(HTMLParser): + def __init__(self): + super().__init__() + self.reset() + self.result: int + + def handle_starttag(self, tag: str, attrs: List[Tuple[str, str]]): + for attr in attrs: + if attr[0] == "data-product-id": + try: + self.result = int(attr[1]) + except: + pass + + class HTTPRequest(Request): + def __init__(self, url: str, *args, **kwargs): + super().__init__(url, *args, **kwargs) + self.headers["user-agent"] = "allkeyshop.com prometheus exporter (https://kumig.it/kumitterer/prometheus-allkeyshop)" + + class ProductPageRequest(HTTPRequest): + @staticmethod + def to_slug(string: str) -> str: + + # Shamelessly stolen from https://www.30secondsofcode.org/python/s/slugify + # Website, name & logo © 2017-2022 30 seconds of code (https://github.com/30-seconds) + # Individual snippets licensed under CC-BY-4.0 (https://creativecommons.org/licenses/by/4.0/) + + string = string.lower().strip() + string = re.sub(r'[^\w\s-]', '', string) + string = re.sub(r'[\s_-]+', '-', string) + string = re.sub(r'^-+|-+$', '', string) + + return string + + def __init__(self, product: str, platform: str, *args, **kwargs): + slug = self.__class__.to_slug(product) + + if platform == "pc": + platform = AllKeyShop.PLATFORM_PC + + url = f"https://www.allkeyshop.com/blog/buy-{slug}-{platform}-compare-prices/" + + super().__init__(url, *args, **kwargs) + + class OffersRequest(HTTPRequest): + def __init__(self, product: int, *args, currency: str, **kwargs): + region: str = kwargs.pop("region", "") + edition: str = kwargs.pop("edition", "") + moreq: str = kwargs.pop("moreq", "") + + url = f"https://www.allkeyshop.com/blog/wp-admin/admin-ajax.php?action=get_offers&product={product}¤cy={currency}®ion={region}&edition={edition}&moreq={moreq}&use_beta_offers_display=1" + + super().__init__(url, *args, **kwargs) + + def __init__(self, product: int | str, platform: Optional[str] = None, **kwargs): + self.product: int + self.kwargs: dict = kwargs + + if isinstance(product, int): + self.product = product + else: + assert platform + self.product = self.__class__.resolve_product(product, platform) + + @classmethod + def resolve_product(cls, product: str, platform: str) -> int: + content = urlopen(cls.ProductPageRequest(product, platform)) + html = content.read().decode() + + parser = cls.ProductParser() + parser.feed(html) + + assert parser.result + return parser.result + + def get_offers(self) -> dict: + content = urlopen(self.__class__.OffersRequest( + self.product, **self.kwargs)) + raw = content.read() + + content = json.loads(raw) + assert content["success"] + return content["offers"] + + +config = ConfigParser() +config.read(Path(__file__).parent / "settings.ini") + +gauges: List[Tuple[Gauge, AllKeyShop]] = list() + +if config.has_section("DEFAULT"): + defaults = config["DEFAULT"] +else: + defaults = dict() + +for section, settings in filter(lambda x: x[0] != "DEFAULT", config.items()): + try: + currency: int + assert (currency := settings.get( + "Currency", fallback=defaults.get("Currency")).lower()) + + region: str = settings.get( + "Region", fallback=defaults.get("Region", "")) + edition: str = settings.get("Edition", fallback="") + + product: str | int + platform: Optional[str] + name: str + + try: + product = int(section) + name = settings.get("Name", fallback=section) + aks: AllKeyShop = AllKeyShop( + product, currency=currency, region=region, edition=edition) + except ValueError: + product = name = section + platform = settings.get( + "Platform", fallback=defaults.get("Platform")) + assert platform + aks: AllKeyShop = AllKeyShop( + product, platform, currency=currency, region=region, edition=edition) + + gauge = Gauge( + f"allkeyshop_{AllKeyShop.ProductPageRequest.to_slug(name).replace('-', '_')}_{currency}", "Best price for {name}") + gauges.append((gauge, aks)) + + except Exception as e: + print(f"Error setting up gauge for section {section}: {e}") + + +start_http_server(8090) + +while True: + for gauge, aks in gauges: + offers: List[dict] = aks.get_offers() + available_offers: List[dict] = filter( + lambda x: x["stock"] == "InStock", offers) + store: Optional[str] + + if (store := settings.get("Store", fallback=defaults.get("Store", ""))): + available_offers: List[dict] = filter( + lambda x: x["platform"] == store, available_offers) + + best_offer: dict = min( + available_offers, key=lambda x: x["price"][currency]["price"]) + gauge.set(best_offer["price"][currency]["price"]) + + time.sleep(60) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9ca76ff --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +prometheus-client \ No newline at end of file diff --git a/settings.dist.ini b/settings.dist.ini new file mode 100644 index 0000000..2f6bd4c --- /dev/null +++ b/settings.dist.ini @@ -0,0 +1,9 @@ +[DEFAULT] +Currency = eur +Platform = pc +Store = steam + +[Persona 5 Royal] + +[10539] +Name = Cyberpunk 2077 \ No newline at end of file