Current status - seems to be working well enough
This commit is contained in:
commit
2ddefe1154
5 changed files with 204 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
venv/
|
||||
*.pyc
|
||||
__pycache__/
|
||||
settings.ini
|
21
LICENSE
Normal file
21
LICENSE
Normal file
|
@ -0,0 +1,21 @@
|
|||
Copyright (c) 2022, Kumi Mitterer <git@kumi.email>
|
||||
|
||||
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.
|
169
allkeyshop.py
Normal file
169
allkeyshop.py
Normal file
|
@ -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)
|
1
requirements.txt
Normal file
1
requirements.txt
Normal file
|
@ -0,0 +1 @@
|
|||
prometheus-client
|
9
settings.dist.ini
Normal file
9
settings.dist.ini
Normal file
|
@ -0,0 +1,9 @@
|
|||
[DEFAULT]
|
||||
Currency = eur
|
||||
Platform = pc
|
||||
Store = steam
|
||||
|
||||
[Persona 5 Royal]
|
||||
|
||||
[10539]
|
||||
Name = Cyberpunk 2077
|
Loading…
Reference in a new issue