Add current code

This commit is contained in:
Nexus 2024-03-06 11:18:00 +00:00
parent 093f44edf5
commit 4b80b7353b
5 changed files with 591 additions and 0 deletions

315
server.py Executable file
View file

@ -0,0 +1,315 @@
#!/bin/env python3
# This code is licensed under GNU GPLv3.
import fastapi
import os
import time
import httpx
import typing
import hashlib
import pydantic
from pathlib import Path
from fastapi.staticfiles import StaticFiles
from fastapi.responses import Response, JSONResponse
from fastapi.middleware.cors import CORSMiddleware
class AppIDList(pydantic.BaseModel):
app_ids: list[int] | int
app = fastapi.FastAPI(
title="How much does The Sims 4 cost?",
description="A simple API to get the price of The Sims 4 (and technically other games) on Steam. Source @ /server.py",
license_info={
"name": "GNU AGPLv3",
"identifier": "AGPL-3.0"
},
contact={
"name": "[Matrix] @nex",
"url": "https://matrix.to/#/@nex:nexy7574.co.uk"
},
version="1.2.0",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=False,
allow_methods=["GET", "HEAD", "POST"],
allow_headers=["*"]
)
app.state.session = httpx.AsyncClient(http2=False)
app.state.cache = {}
app.state.ratelimit = {}
app.state.etags = {}
@app.post(
"/api/appinfo",
responses={
200: {
"description": "Successfully fetched all of the app info IDs.",
"headers": {
"X-Ratelimit-Count": {
"schema": {
"type": "integer"
},
"description": "The number of requests you have sent to the API in the last 30 seconds."
},
"X-Ratelimit-Remaining": {
"schema": {
"type": "integer"
},
"description": "The number of requests you have left in the next 30 seconds."
},
"X-Ratelimit-Reset": {
"schema": {
"type": "float"
},
"description": "The unix timestamp of when the ratelimit resets."
},
}
},
304: {
"description": "The ETag provided was valid and no data was returned.",
},
429: {
"description": "You hit the anti-abuse ratelimit. Check the `Retry-After` header for more information.",
"headers": {
"Retry-After": {
"schema": {
"type": "float"
},
"description": "The amount of seconds you should wait before retrying."
}
}
},
500: {
"description": "There was an upstream error."
}
}
)
async def appinfo(
req: fastapi.Request,
body: AppIDList,
cc: str = fastapi.Query(None),
user_agent: str = fastapi.Header(..., alias="User-Agent"),
if_none_match: str = fastapi.Header(None, alias="If-None-Match"),
):
"""
This API endpoint proxies the call to the steam web API, and returns the data in a more usable format.
This also gets around the CORS issue that prevents this being a serverless solution.
This endpoint:
- Caches the data internally for 24 hours, regardless of etag and cache control.
- Has a ratelimit of 10 requests per 20 seconds, shared across all endpoints.
"""
if req.client.host in app.state.ratelimit:
expires = app.state.ratelimit[req.client.host]["expires"]
count = app.state.ratelimit[req.client.host]["count"]
rl_remaining = 10 - count
now = time.time()
if expires >= now:
if count >= 10:
app.state.ratelimit[req.client.host]["expires"] += (2 * (1.125 ** count))
return Response(
status_code=429,
headers={"Retry-After": str(app.state.ratelimit[req.client.host]["expires"] - now)}
)
else:
app.state.ratelimit[req.client.host]["count"] += 1
else:
del app.state.ratelimit[req.client.host]
else:
expires = time.time() + 20
app.state.ratelimit[req.client.host] = {
"expires": expires,
"count": 1
}
rl_remaining = 9
count = 1
auto_cc = "N/A"
if if_none_match:
if_none_match = if_none_match.replace("W/", "").strip('"')
if not cc:
_ccr = await app.state.session.get(f"https://ipinfo.io/{req.client.host}/country")
cc = _ccr.text.strip().lower()
auto_cc = cc
app_ids = body.app_ids
if isinstance(app_ids, int):
app_ids = [app_ids]
multi_fetch = len(app_ids) > 1
cache_hit = "MISS"
if if_none_match in app.state.etags:
if app.state.etags[if_none_match] > time.time():
cache_hit = "FULL-HIT"
return Response(status_code=304)
else:
del app.state.etags[if_none_match]
_md5 = hashlib.md5((",".join(map(str, app_ids)) + cc).encode(), usedforsecurity=False).hexdigest()
values = {}
remaining = app_ids.copy()
for app_id in app_ids:
if int(app_id) in app.state.cache:
data, expires = app.state.cache[app_id]
if expires > time.time():
cache_hit = "PARTIAL-HIT"
values[app_id] = data
remaining.pop(remaining.index(app_id))
else:
del app.state.cache[app_id]
values[app_id] = None
else:
values[app_id] = None
if len(remaining) == 0:
return JSONResponse(
values,
headers={
"cache-control": "public, max-age=806400",
"X-Cache": "FULL-HIT",
"X-Ratelimit-Remaining": str(rl_remaining),
"X-Ratelimit-Count": str(count),
"X-Ratelimit-Reset": str(expires),
"X-Ratelimit-Reset-After": str(expires),
},
)
elif len(remaining) == len(app_ids):
cache_hit = "MISS"
params = {
"appids": ",".join(map(str, remaining))
}
if cc is not None:
params["cc"] = cc
if multi_fetch:
params["filters"] = "price_overview"
headers = {
"User-Agent": user_agent,
}
result = original_response = await app.state.session.get(
"https://store.steampowered.com/api/appdetails",
params=params,
headers=headers,
)
if result.status_code == 429:
return Response(status_code=429)
elif result.status_code not in [304, 200]:
raise fastapi.HTTPException(result.status_code, result.text)
else:
app.state.etag = result.headers.get("etag")
app.state.last_modified = result.headers.get("last-modified")
result = result.json()
for _app_id, data in result.items():
if "data" in data:
app.state.cache[int(_app_id)] = (data["data"], time.time() + 86400)
values[int(_app_id)] = data["data"]
app.state.etags[_md5] = time.time() + 86400
return JSONResponse(
values,
original_response.status_code,
headers={
"ETag": _md5,
"cache-control": "public, max-age=806400",
"X-Cache": str(cache_hit),
"X-IP": str(req.client.host),
"X-Ratelimit-Remaining": str(rl_remaining),
"X-Ratelimit-Count": str(count),
"X-Ratelimit-Reset": str(expires),
"X-Ratelimit-Reset-After": str(expires),
},
)
RL_HEADERS = {
"X-Ratelimit-Throttled": {
"schema": {
"type": "boolean"
},
"description": "Whether or not you are actively being throttled."
},
"X-Ratelimit-Count": {
"schema": {
"type": "integer"
},
"description": "The number of requests you have sent to the API in the last 30 seconds."
},
"X-Ratelimit-Remaining": {
"schema": {
"type": "integer"
},
"description": "The number of requests you have left in the next 30 seconds."
},
"X-Ratelimit-Reset": {
"schema": {
"type": "float"
},
"description": "The unix timestamp of when the ratelimit resets."
}
}
RL_DOC = {
204: {
"description": "Successfully checked the ratelimit.",
"headers": RL_HEADERS
},
429: {
"description": "You hit the anti-abuse ratelimit. Check the `Retry-After` header for more information.",
"headers": {
**RL_HEADERS,
"Retry-After": {
"schema": {
"type": "float"
},
"description": "The amount of seconds you should wait before retrying."
}
}
}
}
@app.get("/api/ratelimit", status_code=204, responses=RL_DOC)
@app.head("/api/ratelimit", status_code=204, responses=RL_DOC)
async def check_ratelimit(req: fastapi.Request):
"""
Checks the current ratelimit. See GET /api/appinfo for more info.
"""
headers = {
"X-Ratelimit-Throttled": "False",
"X-Ratelimit-Remaining": "10",
"X-Ratelimit-Count": "0",
"X-Ratelimit-Reset": "0",
}
if req.client.host in app.state.ratelimit:
expires = app.state.ratelimit[req.client.host]["expires"]
count = app.state.ratelimit[req.client.host]["count"]
rl_remaining = 10 - count
headers["X-Ratelimit-Remaining"] = str(rl_remaining)
headers["X-Ratelimit-Count"] = str(count)
headers["X-Ratelimit-Reset"] = str(expires)
headers["X-Ratelimit-Throttled"] = str(rl_remaining >= 10 and expires >= time.time())
if headers["X-Ratelimit-Throttled"] == "True":
headers["Retry-After"] = str(expires - time.time())
return Response(None, 204 if headers["X-Ratelimit-Throttled"] == "False" else 429, headers=headers)
app.mount(
"/",
StaticFiles(directory=Path.cwd() / "static", html=True, check_dir=True, follow_symlink=True),
)
if __name__ == "__main__":
import uvicorn
import os
print("Starting in", Path.cwd())
uvicorn.run(app, host="0.0.0.0", port=1037, forwarded_allow_ips="*")

BIN
static/favicon.ico Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

35
static/index.html Executable file
View file

@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>How much does the entire The Sims 4 game cost right now?</title>
<meta name="description" content="See how much the entirety of the game 'The Sims 4' costs right now if you were to purchase it with all DLCs!">
<!-- OpenGraph tags -->
<meta property="og:title" content="How much does the entire The Sims 4 game cost right now?">
<meta property="og:description" content="See how much the entirety of the game 'The Sims 4' costs right now if you were to purchase it with all DLCs!">
<link rel="stylesheet" href="/style.css"/>
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon">
</head>
<body style="text-align: center">
<div id="retrying" hidden>
<div>
<h1>There was an issue loading the data.</h1>
<p>This page will reload in <span id="retry-after">0.0</span> seconds to try again.</p>
<p><i>Error: Ratelimited (HTTP_429)</i></p>
</div>
</div>
<main>
<h1>Currently <span id="name">The Sims 4</span> costs<span id="dlcs" hidden>&nbsp;(with all <span id="dlc-count"></span>DLCs)</span></h1>
<h2 id="price">£1000.00</h2>
<div hidden id="comparison">
<p>That's equivalent to...</p>
<p><i>Note: Prices may not be accurate. See script.js for more details.</i></p>
<ul></ul>
</div>
</main>
<noscript><p>(You need to enable JavaScript to load this)</p></noscript>
<script src="./script.js"></script>
</body>
</html>

179
static/script.js Executable file
View file

@ -0,0 +1,179 @@
// This code is licensed under GNU GPLv3.
const BASE_URL = "/api/appinfo";
var TS4_APP_ID = 1222670;
const APPID_REGEX = /#(\d{5,10})/
if(APPID_REGEX.test(window.location.hash)===true) {
let matches = APPID_REGEX.exec(window.location.hash);
TS4_APP_ID = parseInt(matches[1])
console.debug("Set app ID to " + TS4_APP_ID);
} else {
console.debug("No custom ID set or does not match regex.");
}
const PRICE_ELEMENT = document.getElementById("price");
const NAME_ELEMENT = document.getElementById("name");
/*
* These prices are gathered from a range of sources, such as Tesco, Amazon, and CeX.
* Some prices may no longer be correct. They do not include discounts.
*
* Last updated: 10/12/2023
*/
const PRICES = {
"Freddo (18g)": 0.25,
"Skittles (136G)": 1.25,
"Cadbury Fingers (114G)": 1.70,
"howmuchdoesthesims4cost.lol": 1.78,
"Pepsi Max (2L)": 2.00,
"Pringles (Salt & Vinegar, 185g)": 2.25,
"BLÅHAJ Soft toy, shark (40 cm)": 22.0,
"Tenda AC10 V3.0 AC1200 Dual Band Gigabit Wireless Cable Router": 27.99,
"KIOXIA EXERIA NVMe M.2 SSD (1TB)": 43.20,
"Seagate BarraCuda,Internal Hard Drive 2.5 Inch (1TB)": 49.99,
"Kingston NV2 NVMe PCIe 4.0 Internal SSD (1TB)": 59.99,
"NVIDIA GeForce GTX 1650": 117.96,
"AMD Radeon RX 5700 8GB (Second hand from CeX)": 150.00,
"TP-Link AX5400 WiFi 6 Router": 159.99,
"Intel Core i5-13400F": 191.99,
"iPhone SE (3rd generation/5G/2022)": 499.00,
"My custom PC (https://uk.pcpartpicker.com/user/nexy7574/saved/#view=cKYV4D)": 800.0,
"NVIDIA GeForce RTX 4090": 1519.00,
}
function compare(price, item) {
let n = Math.floor((price / PRICES[item]) * 100) / 100;
if(n>=1) {
n = Math.floor(n);
};
return n
}
function update(n, unit="$") {
PRICE_ELEMENT.textContent = unit + n;
console.debug("Updated price to: " + n);
}
function ids_to_body(ids) {
return JSON.stringify({"app_ids": ids})
}
function ratelimited(response) {
let retry_after = response.headers.get("retry-after");
console.warn("Rate-limited for " + retry_after + " seconds.");
setTimeout(() => {window.location.reload()}, retry_after * 1000);
setInterval(
() => {
let element = document.getElementById("retry-after");
let value = parseFloat(element.textContent);
value -= 0.1;
element.textContent = value.toFixed(1);
},
100
)
let element = document.getElementById("retrying");
element.hidden = false;
document.getElementById("retry-after").textContent = (retry_after * 1).toFixed(1);
document.querySelector("main").hidden = true;
document.querySelector("main").style.display = "none";
}
async function get_app_info(id) {
const response = await fetch(BASE_URL, {method: "POST", body: ids_to_body([id]), headers: {"Content-Type": "application/json"}});
if(response.status === 429) {
ratelimited(response);
return {id: {"name": "<rate limited>"}}
}
const data = await response.json();
console.debug(data);
return data;
}
async function calculate_price(ids) {
console.debug("Calculating price for: " + ids);
const response = await fetch(BASE_URL, {method: "POST", body: ids_to_body(ids), headers: {"Content-Type": "application/json"}});
if(response.status === 429) {
ratelimited(response);
let o = {}
for (let id of ids) {
o[id] = {"price_overview": {"final": 0.0}}
}
return o
}
const data = await response.json();
console.debug("mass price data:", data);
let _price = 0.0;
let unit = "£";
for (let value of Object.values(data)) {
console.debug("Value:", value)
if (value.price_overview == null) {
continue;
}
let new_unit = value.price_overview.final_formatted.slice(0, 1);
if((/\d/).test(new_unit)) {
//new_unit = value.price_overview.final_formatted.slice(-2, -1);
new_unit = value.price_overview.currency || '£'
}
unit = new_unit
_price += parseFloat(value.price_overview.final / 100);
}
return [_price, unit];
}
function find_unit(formatted) {
let unit = "£";
if((/\d/).test(formatted.slice(0, 1))) {
unit = formatted.slice(-2, -1);
}
return unit;
}
async function main() {
console.debug("Fetching app info for base game");
let root;
try {
root = await get_app_info(TS4_APP_ID);
} catch (e) {
alert(`Error fetching information from Steam: ${e}`);
window.location.reload();
};
console.debug("Fetching app info for DLCs");
let key = Object.keys(root)[0];
NAME_ELEMENT.textContent = root[key].name;
console.debug("Checking key: " + key);
let dlc_price = (root[key].price_overview || {final: 0.0}).final / 100;
let unit = find_unit((root[key].price_overview || {final_formatted: "£0.00"}).final_formatted);
if(root[key].dlc && root[key].dlc.length >= 0) {
document.getElementById("dlcs").hidden = false;
document.getElementById("dlc-count").textContent = root[key].dlc.length + " ";
let _result= await calculate_price(root[key].dlc)
dlc_price += _result[0];
}
if(root[key].header_image && root[key].capsule_image) {
let element2 = document.createElement("img");
element2.src = root[key].header_image;
element2.alt = root[key].name;
element2.style.height = "2em"
NAME_ELEMENT.textContent = null;
NAME_ELEMENT.appendChild(element2);
}
update(dlc_price.toFixed(2), unit);
let element = document.getElementById("comparison");
element.hidden = false;
for(let item of Object.keys(PRICES)) {
let price = compare(dlc_price, item);
if(price >= 0.95) {
let li = document.createElement("li");
li.textContent = `${price}x ${item} (${unit}${PRICES[item]}/pc)`;
element.children[1].appendChild(li);
}
};
}
main();

62
static/style.css Executable file
View file

@ -0,0 +1,62 @@
:root {
--var-background: #fff;
--var-color: #000;
--var-background-inverted: #000
}
@media (prefers-color-scheme: dark) {
:root {
--var-background: #000;
--var-color: #fff;
--var-background-inverted: #fff
}
}
html, body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
background-color: var(--var-background);
color: var(--var-color);
text-align: center;
font-size: larger;
margin: 0;
padding: 0;
width: 100%;
height: 100%;
}
@media screen and (max-width: 600px) {
html, body {
font-size: medium;
}
}
h1, span img {
vertical-align: middle;
}
#comparison {
text-align: left;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
#name {
background-size: cover;
}
#retrying[hidden=false] {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
width: 100%;
z-index: 100;
background: var(--var-background-inverted);
color: inverted(var(--var-color));
}