PoE2Trade update

- Vastly improved on rate-limiting tolerance
  - Dynamically fetches and assigns any server-imposed rate-limits.
  - Implemented a rolling queue system for filter checks.
    - This effectively means each filter for all users will run one after the other. Increasing time between checks, but prevents rate-limiting.
- Improved notification message embeds to contain more information in a cleaner format
  - Added "implicit mods" to the stats list for better stats report
- Security implementations
  - PoE2 session token is now encrypted in storage
  - Somewhat sensitive data is now obscufated in settings reply
- Improved on listing pricing display
  - Now says "1 x Exalted Orb" instead of simply "1 exalted"
- Added approximated next check times for filters, viewable in notification messages and filters list
- General minor other improvements to readibility, commands, structure, cache, and functionality

Support for per-user-definable session tokens is in the works.
This will allow users to define their own tokens, which are encrypted for security, to reduce on ratelimits imposed by using the global token.
Once this is implemented, those users will benefit from faster checks, providing earlier listing notifications.
This should reduce the risk of missing valuable listings.
experimental
Kami 2025-03-30 14:46:23 +02:00
parent f717ad0f14
commit 58270b1fbe
3 changed files with 867 additions and 128 deletions

View File

@ -3,17 +3,23 @@ from discord.ext import commands
import globals
from globals import logger
from utility.PoE2_wishlist import PoeTradeWatcher
import re
import re, json, asyncio, os
from pathlib import Path
from urllib.parse import quote
watcher = PoeTradeWatcher()
# Load currency metadata from JSON
storage_path = Path(__file__).resolve().parent.parent / "local_storage"
currency_lookup_path = storage_path / "poe_currency_data.json"
with open(currency_lookup_path, "r", encoding="utf-8") as f:
CURRENCY_METADATA = json.load(f)
class PoE2TradeCog(commands.Cog):
def __init__(self, bot):
self.bot = bot
watcher.set_update_callback(self.handle_poe2_updates)
self.bot.loop.create_task(watcher.start_auto_poll())
@commands.command(name="poe2trade")
@ -41,29 +47,51 @@ class PoE2TradeCog(commands.Cog):
if result["status"] == "success":
await ctx.reply(f"✅ Filter `{filter_id}` was successfully added to your watchlist.")
logger.info(f"User {user_id} added filter {filter_id} ({custom_name or 'no name'})")
elif result["status"] == "exists":
await ctx.reply(f"⚠️ Filter `{filter_id}` is already on your watchlist.")
logger.info(f"User {user_id} attempted to add existing filter {filter_id} ({custom_name or 'no name'})")
elif result["status"] == "rate_limited":
await ctx.reply("⏱️ You're adding filters too quickly. Please wait a bit before trying again.")
logger.warning(f"User {user_id} adding filters too quickly! Filter {filter_id} ({custom_name or 'no name'})")
elif result["status"] == "limit_reached":
await ctx.reply("🚫 You've reached the maximum number of filters. Remove one before adding another.")
logger.info(f"User {user_id} already at max filters when attempting to add filter {filter_id} ({custom_name or 'no name'})")
elif result["status"] == "warning":
await ctx.reply("⚠️ That filter returns too many results. Add it with better filters or use force mode (not currently supported).")
logger.info(f"User {user_id} attempted to add too broad filter: {filter_id} ({custom_name or 'no name'})")
elif result["status"] == "invalid_session":
await ctx.reply("🔒 The session token is invalid or expired. Please update it via `!poe2trade set POESESSID <token>` (owner-only).")
logger.error(f"POESESSID token invalid when user {user_id} attempted to add filter {filter_id} ({custom_name or 'no name'})")
elif result["status"] == "queued":
await ctx.reply("🕑 The filter has been queued for addition")
logger.error(f"User {user_id} queued filter {filter_id} ({custom_name or 'no name'}) for addition.")
else:
await ctx.reply(f"❌ Failed to add filter `{filter_id}`. Status: {result['status']}")
logger.error(f"An unknown error occured when user {user_id} attempted to add filter {filter_id} ({custom_name or 'no name'})")
elif subcommand == "remove" and len(args) >= 2:
filter_id = args[1]
if filter_id.lower() == "all":
result = watcher.remove_all_filters(user_id)
if result["status"] == "removed":
await ctx.reply("✅ All filters have been removed from your watchlist.")
logger.info(f"User {user_id} removed all filters.")
else:
await ctx.reply("⚠️ You don't have any filters to remove.")
else:
result = watcher.remove_filter(user_id, filter_id)
if result["status"] == "removed":
await ctx.reply(f"✅ Filter `{filter_id}` was successfully removed.")
logger.info(f"User {user_id} removed filter {filter_id}")
elif result["status"] == "not_found":
await ctx.reply(f"⚠️ Filter `{filter_id}` was not found on your watchlist.")
else:
await ctx.reply(f"❌ Failed to remove filter `{filter_id}`. Status: {result['status']}")
elif subcommand == "list":
filters = watcher.watchlists.get(user_id, [])
if not filters:
@ -74,15 +102,22 @@ class PoE2TradeCog(commands.Cog):
name = watcher.get_filter_name(user_id, fid)
label = f"{fid} — *{name}*" if name else fid
next_time = watcher.get_next_query_time(user_id, fid)
if next_time:
time_line = f"Next check: ~<t:{next_time}:R>\n"
else:
time_line = ""
embed.add_field(
name=label,
value=f"[Open in Trade Site](https://www.pathofexile.com/trade2/search/poe2/Standard/{fid})\n`!poe2trade remove {fid}`",
value=f"{time_line}[Open in Trade Site](https://www.pathofexile.com/trade2/search/poe2/Standard/{fid})\n`!poe2trade remove {fid}`",
inline=False
)
await ctx.reply(embed=embed)
logger.info(f"User {user_id} requested their watchlist.")
elif subcommand == "set" and len(args) >= 3:
if ctx.author.id != ctx.guild.owner_id:
if not self._is_owner(user_id):
await ctx.reply("Only the server owner can modify settings.")
return
key, value = args[1], args[2]
@ -99,24 +134,87 @@ class PoE2TradeCog(commands.Cog):
await ctx.reply("⚠️ Something went wrong while updating the setting.")
elif subcommand == "settings":
if ctx.author.id != ctx.guild.owner_id:
if not self._is_owner(user_id):
await ctx.reply("Only the server owner can view settings.")
logger.info(f"User {user_id} requested (but not allowed) to see current settings.")
return
result = watcher.get_settings()
await ctx.reply(str(result))
embed = discord.Embed(title="Current `PoE 2 Trade` Settings", color=discord.Color.teal())
embed.set_footer(text="Use `!poe2trade set <key> <value>` to change a setting (owner-only)")
status = result.get("status", "unknown")
settings = result.get("settings", {})
embed = discord.Embed(title="Current PoE 2 Trade Settings", color=discord.Color.teal())
embed.add_field(name="Status", value=f"`{status}`", inline=False)
user = self.bot.get_user(int(user_id))
if user:
owner_display_name = user.display_name # or user.name
else:
owner_display_name = f"Unknown User"
for key, value in settings.items():
if key == "POESESSID":
value = f"`{value[:4]}...{value[-4:]}` (*Obfuscated*)"
if key == "AUTO_POLL_INTERVAL":
value = f"`{value}` seconds"
if key == "MAX_SAFE_RESULTS":
value = f"`{value}` max results"
if key == "USER_ADD_INTERVAL":
value = f"`{value}` seconds"
if key == "MAX_FILTERS_PER_USER":
value = f"`{value}` filters"
if key == "RATE_LIMIT_INTERVAL":
value = f"`{value}` seconds"
if key == "OWNER_ID":
value = f"`{value}` (**{owner_display_name}**)"
if key == "LEGACY_WEBHOOK_URL":
value = f"`{value[:23]}...{value[-10:]}` (*Obfuscated*)"
embed.add_field(name=key, value=f"{value}", inline=False)
embed.set_footer(text="Use `!poe2trade set <key> <value>` to change a setting (owner-only)")
await ctx.reply(embed=embed)
logger.info(f"User {user_id} requested and received current settings.")
elif subcommand == "pause":
if watcher.pause_user(user_id):
await ctx.reply("⏸️ Your filter notifications are now paused.")
logger.info(f"User {user_id} paused their filters.")
else:
await ctx.reply("⏸️ Your filters were already paused.")
elif subcommand == "resume":
if watcher.resume_user(user_id):
await ctx.reply("▶️ Filter notifications have been resumed.")
logger.info(f"User {user_id} resumed their filters.")
else:
await ctx.reply("▶️ Your filters were not paused.")
elif subcommand == "clearcache":
if not self._is_owner(user_id):
await ctx.reply("❌ Only the bot owner can use this command.")
return
if "-y" in args:
result = watcher.clear_seen_cache(user_id)
if result["status"] == "success":
await ctx.reply("🧹 All cached seen listings have been cleared.")
logger.info(f"User {user_id} cleared their listing cache.")
else:
await ctx.reply("⚠️ Cache clearing failed or confirmation window expired.")
else:
watcher.initiate_cache_clear(user_id)
await ctx.reply(
"⚠️ Are you sure you want to clear all listing cache?\n"
"Run `!poe2trade clearcache -y` within 60 seconds to confirm."
)
logger.info(f"User {user_id} requested cache clear confirmation.")
else:
await ctx.reply("Unknown subcommand. Try `!poe2trade` to see help.")
@ -136,14 +234,20 @@ class PoE2TradeCog(commands.Cog):
"`!poe2trade pause` — Temporarily stop notifications for your filters.\n"
"`!poe2trade resume` — Resume notifications for your filters.\n"
"`!poe2trade set <key> <value>` — *(Owner only)* Change a system setting.\n"
"`!poe2trade settings` — *(Owner only)* View current settings.\n\n"
"`!poe2trade settings` — *(Owner only)* View current settings.\n"
"`!poe2trade clearcache` — *(Owner only)* Clear current data cache\n\n"
"**How to use filters:**\n"
"Go to the official trade site: https://www.pathofexile.com/trade2/search/poe2/Standard\n"
"Apply the filters you want (stat filters, name, price, etc).\n"
"Copy the **filter ID** at the end of the URL.\n"
"Example: `https://www.pathofexile.com/trade2/search/poe2/Standard/Ezn0zLzf5`\n"
"Filter ID = `Ezn0zLzf5`.\n"
"Then run: `!poe2trade add Ezn0zLzf5`\n"
"Then run: `!poe2trade add Ezn0zLzf5`\n\n"
"**Important note**\n"
"Only the first 10 results from the filter is checked.\n"
"This means if you sort by eg. price, only the 10 cheapest results will be checked.\n"
"If you sort by a stat from highest to lowest on the other hand, only the 10 results \n"
"with the highest stat will be checked. So double-check your filter sorting!"
)
return embed
@ -173,24 +277,39 @@ class PoE2TradeCog(commands.Cog):
item_name = item_data.get("name") or item_data.get("typeLine", "Unknown Item")
item_type = item_data.get("typeLine", "Unknown")
full_name = f"{item_name} ({item_type})"
full_name = item_name if item_name.strip().lower() == item_type.strip().lower() else f"{item_name} ({item_type})"
rarity = item_data.get("frameType", 2)
rarity_colors = {
0: discord.Color.light_grey(), # Normal
1: discord.Color.blue(), # Magic
2: discord.Color.gold(), # Rare
3: discord.Color.dark_orange(), # Unique
}
embed = discord.Embed(
title=full_name,
url=f"https://www.pathofexile.com/trade2/search/poe2/Standard/{search_id}/item/{item.get('id')}",
color=discord.Color.gold()
url=f"https://www.pathofexile.com/trade2/search/poe2/Standard/{filter_id}",
color=rarity_colors.get(rarity, discord.Color.gold())
)
icon_url = item_data.get("icon")
if icon_url:
embed.set_thumbnail(url=icon_url)
embed.add_field(name="Price", value=f"{price_data.get('amount', '?')} {price_data.get('currency', '?')}", inline=True)
amount = price_data.get("amount", "?")
currency = price_data.get("currency", "?")
if currency in CURRENCY_METADATA:
name = CURRENCY_METADATA[currency]["name"]
currency_field = f"{amount} × {name}"
else:
currency_field = f"{amount} {currency}"
embed.add_field(name="Price", value=currency_field, inline=True)
seller_name = account_data.get("name", "Unknown Seller")
seller_url = f"https://www.pathofexile.com/account/view-profile/{quote(seller_name)}"
embed.add_field(name="Seller", value=f"[{seller_name}]({seller_url})", inline=True)
embed.add_field(name="Item Level", value=str(item_data.get("ilvl", "?")), inline=True)
def clean_stat(stat: str) -> str:
@ -198,22 +317,33 @@ class PoE2TradeCog(commands.Cog):
stat = re.sub(r"\[(.+?)\]", r"\1", stat)
return stat
stats = item_data.get("explicitMods", [])
stats = []
if "implicitMods" in item["item"]:
stats.append("__**Implicit Mods**__")
stats.extend(item["item"]["implicitMods"])
if "explicitMods" in item["item"]:
stats.append("__**Explicit Mods**__")
stats.extend(item["item"]["explicitMods"])
if stats:
stats_str = "\n".join(clean_stat(stat) for stat in stats)
embed.add_field(name="Stats", value=stats_str, inline=False)
embed.add_field(name="*Remove from watchlist*:", value=f"`!poe2trade remove {filter_id}`", inline=False)
next_check = watcher.get_next_query_time(user_id, filter_id)
timestamp_str = f"\nNext check: ~<t:{next_check}:R>" if next_check else "Unknown"
embed.add_field(
name="*Remove from watchlist*:",
value=f"`!poe2trade remove {filter_id}`{timestamp_str}",
inline=False
)
filter_name = watcher.get_filter_name(str(user_id), filter_id)
footer_lines = [f"New PoE 2 trade listing"]
footer_lines = ["New PoE 2 trade listing"]
if filter_name:
footer_lines.insert(0, f"Filter name: {filter_name}")
embed.set_footer(text="\n".join(footer_lines))
est_char_len = len(embed.title or "") + sum(len(f.name or "") + len(f.value or "") for f in embed.fields)
# Send batch if limit exceeded
if (len(embeds) + 1 > MAX_EMBEDS_PER_MESSAGE) or (current_char_count + est_char_len > EMBED_CHAR_LIMIT):
await user.send(embeds=embeds)
embeds = []
@ -228,9 +358,23 @@ class PoE2TradeCog(commands.Cog):
if embeds:
try:
await user.send(embeds=embeds)
logger.info(f"Sent {len(embeds)} embeds to user {user_id} for filter {filter_id}")
except Exception as e:
logger.warning(f"Failed to send batched embeds to user {user_id}: {e}")
def _is_owner(self, user_id):
"""
Returns True or False depending on user ID comparison to owner ID
"""
owner_id = watcher.settings.get("OWNER_ID")
if str(user_id) != owner_id:
return False
elif str(user_id) == owner_id:
return True
else:
logger.error(f"Failed to verify author as owner! user_id: '{user_id}', owner_id: '{owner_id}'")
async def setup(bot):
await bot.add_cog(PoE2TradeCog(bot))

View File

@ -0,0 +1,374 @@
{
"alt": {
"name": "Orb of Alteration",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lSZXJvbGxNYWdpYyIsInNjYWxlIjoxfV0/6308fc8ca2/CurrencyRerollMagic.png"
},
"fusing": {
"name": "Orb of Fusing",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lSZXJvbGxTb2NrZXRMaW5rcyIsInNjYWxlIjoxfV0/c5e1959880/CurrencyRerollSocketLinks.png"
},
"alch": {
"name": "Orb of Alchemy",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lVcGdyYWRlVG9SYXJlIiwic2NhbGUiOjF9XQ/0c72cd1d44/CurrencyUpgradeToRare.png"
},
"chaos": {
"name": "Chaos Orb",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lSZXJvbGxSYXJlIiwic2NhbGUiOjF9XQ/46a2347805/CurrencyRerollRare.png"
},
"gcp": {
"name": "Gemcutter's Prism",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lHZW1RdWFsaXR5Iiwic2NhbGUiOjF9XQ/dbe9678a28/CurrencyGemQuality.png"
},
"exalted": {
"name": "Exalted Orb",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lBZGRNb2RUb1JhcmUiLCJzY2FsZSI6MX1d/33f2656aea/CurrencyAddModToRare.png"
},
"chrome": {
"name": "Chromatic Orb",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lSZXJvbGxTb2NrZXRDb2xvdXJzIiwic2NhbGUiOjF9XQ/19c8ddae20/CurrencyRerollSocketColours.png"
},
"jewellers": {
"name": "Jeweller's Orb",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lSZXJvbGxTb2NrZXROdW1iZXJzIiwic2NhbGUiOjF9XQ/ba411ff58a/CurrencyRerollSocketNumbers.png"
},
"engineers": {
"name": "Engineer's Orb",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvRW5naW5lZXJzT3JiIiwic2NhbGUiOjF9XQ/114b671d41/EngineersOrb.png"
},
"infused-engineers-orb": {
"name": "Infused Engineer's Orb",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSW5mdXNlZEVuZ2luZWVyc09yYiIsInNjYWxlIjoxfV0/55774baf2f/InfusedEngineersOrb.png"
},
"chance": {
"name": "Orb of Chance",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lVcGdyYWRlUmFuZG9tbHkiLCJzY2FsZSI6MX1d/a3f9bf0917/CurrencyUpgradeRandomly.png"
},
"chisel": {
"name": "Cartographer's Chisel",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lNYXBRdWFsaXR5Iiwic2NhbGUiOjF9XQ/0246313b99/CurrencyMapQuality.png"
},
"scour": {
"name": "Orb of Scouring",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lDb252ZXJ0VG9Ob3JtYWwiLCJzY2FsZSI6MX1d/a0981d67fe/CurrencyConvertToNormal.png"
},
"blessed": {
"name": "Blessed Orb",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lJbXBsaWNpdE1vZCIsInNjYWxlIjoxfV0/48e700cc20/CurrencyImplicitMod.png"
},
"regret": {
"name": "Orb of Regret",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lQYXNzaXZlU2tpbGxSZWZ1bmQiLCJzY2FsZSI6MX1d/32d499f562/CurrencyPassiveSkillRefund.png"
},
"regal": {
"name": "Regal Orb",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lVcGdyYWRlTWFnaWNUb1JhcmUiLCJzY2FsZSI6MX1d/0ded706f57/CurrencyUpgradeMagicToRare.png"
},
"divine": {
"name": "Divine Orb",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lNb2RWYWx1ZXMiLCJzY2FsZSI6MX1d/ec48896769/CurrencyModValues.png"
},
"vaal": {
"name": "Vaal Orb",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lWYWFsIiwic2NhbGUiOjF9XQ/593fe2e22e/CurrencyVaal.png"
},
"annul": {
"name": "Orb of Annulment",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQW5udWxsT3JiIiwic2NhbGUiOjF9XQ/0858a418ac/AnnullOrb.png"
},
"orb-of-binding": {
"name": "Orb of Binding",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQmluZGluZ09yYiIsInNjYWxlIjoxfV0/aac9579bd2/BindingOrb.png"
},
"ancient-orb": {
"name": "Ancient Orb",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQW5jaWVudE9yYiIsInNjYWxlIjoxfV0/83015d0dc9/AncientOrb.png"
},
"orb-of-horizons": {
"name": "Orb of Horizons",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSG9yaXpvbk9yYiIsInNjYWxlIjoxfV0/0891338fb0/HorizonOrb.png"
},
"harbingers-orb": {
"name": "Harbinger's Orb",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGFyYmluZ2VyT3JiIiwic2NhbGUiOjF9XQ/0a26e01f15/HarbingerOrb.png"
},
"fracturing-orb": {
"name": "Fracturing Orb",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvRnJhY3R1cmluZ09yYkNvbWJpbmVkIiwic2NhbGUiOjF9XQ/3fb18e8a5b/FracturingOrbCombined.png"
},
"wisdom": {
"name": "Scroll of Wisdom",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lJZGVudGlmaWNhdGlvbiIsInNjYWxlIjoxfV0/c2d03ed3fd/CurrencyIdentification.png"
},
"portal": {
"name": "Portal Scroll",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lQb3J0YWwiLCJzY2FsZSI6MX1d/d92d3478a0/CurrencyPortal.png"
},
"scrap": {
"name": "Armourer's Scrap",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lBcm1vdXJRdWFsaXR5Iiwic2NhbGUiOjF9XQ/fc4e26afbc/CurrencyArmourQuality.png"
},
"whetstone": {
"name": "Blacksmith's Whetstone",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lXZWFwb25RdWFsaXR5Iiwic2NhbGUiOjF9XQ/c9cd72719e/CurrencyWeaponQuality.png"
},
"bauble": {
"name": "Glassblower's Bauble",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lGbGFza1F1YWxpdHkiLCJzY2FsZSI6MX1d/59e57027e5/CurrencyFlaskQuality.png"
},
"transmute": {
"name": "Orb of Transmutation",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lVcGdyYWRlVG9NYWdpYyIsInNjYWxlIjoxfV0/ded9e8ee63/CurrencyUpgradeToMagic.png"
},
"aug": {
"name": "Orb of Augmentation",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lBZGRNb2RUb01hZ2ljIiwic2NhbGUiOjF9XQ/d879c15321/CurrencyAddModToMagic.png"
},
"mirror": {
"name": "Mirror of Kalandra",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lEdXBsaWNhdGUiLCJzY2FsZSI6MX1d/8d7fea29d1/CurrencyDuplicate.png"
},
"eternal": {
"name": "Eternal Orb",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lJbXByaW50T3JiIiwic2NhbGUiOjF9XQ/49500c70ba/CurrencyImprintOrb.png"
},
"rogues-marker": {
"name": "Rogue's Marker",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGVpc3QvSGVpc3RDb2luQ3VycmVuY3kiLCJzY2FsZSI6MX1d/335e66630d/HeistCoinCurrency.png"
},
"facetors": {
"name": "Facetor's Lens",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lHZW1FeHBlcmllbmNlIiwic2NhbGUiOjF9XQ/7011b1ed48/CurrencyGemExperience.png"
},
"tempering-orb": {
"name": "Tempering Orb",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvRGl2aW5lRW5jaGFudEJvZHlBcm1vdXJDdXJyZW5jeSIsInNjYWxlIjoxfV0/37681eda1c/DivineEnchantBodyArmourCurrency.png"
},
"tailoring-orb": {
"name": "Tailoring Orb",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvRGl2aW5lRW5jaGFudFdlYXBvbkN1cnJlbmN5Iiwic2NhbGUiOjF9XQ/d417654a23/DivineEnchantWeaponCurrency.png"
},
"orb-of-unmaking": {
"name": "Orb of Unmaking",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvUmVncmV0T3JiIiwic2NhbGUiOjF9XQ/beae1b00c7/RegretOrb.png"
},
"veiled-orb": {
"name": "Veiled Orb",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvVmVpbGVkQ2hhb3NPcmIiLCJzY2FsZSI6MX1d/fd913b89d0/VeiledChaosOrb.png"
},
"enkindling-orb": {
"name": "Enkindling Orb",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvRXhwZWRpdGlvbi9GbGFza1BsYXRlIiwic2NhbGUiOjF9XQ/7c1a584a8d/FlaskPlate.png"
},
"instilling-orb": {
"name": "Instilling Orb",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvRXhwZWRpdGlvbi9GbGFza0luamVjdG9yIiwic2NhbGUiOjF9XQ/efc518b1be/FlaskInjector.png"
},
"sacred-orb": {
"name": "Sacred Orb",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvU2FjcmVkT3JiIiwic2NhbGUiOjF9XQ/0380fd0dba/SacredOrb.png"
},
"stacked-deck": {
"name": "Stacked Deck",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvRGl2aW5hdGlvbi9EZWNrIiwic2NhbGUiOjF9XQ/8e83aea79a/Deck.png"
},
"veiled-scarab": {
"name": "Veiled Scarab",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvU2NhcmFicy9TdGFja2VkU2NhcmFiIiwic2NhbGUiOjF9XQ/4674c86ff2/StackedScarab.png"
},
"crusaders-exalted-orb": {
"name": "Crusader's Exalted Orb",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSW5mbHVlbmNlIEV4YWx0cy9DcnVzYWRlck9yYiIsInNjYWxlIjoxfV0/8b48230188/CrusaderOrb.png"
},
"redeemers-exalted-orb": {
"name": "Redeemer's Exalted Orb",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSW5mbHVlbmNlIEV4YWx0cy9FeXJpZU9yYiIsInNjYWxlIjoxfV0/8ec9b52d65/EyrieOrb.png"
},
"hunters-exalted-orb": {
"name": "Hunter's Exalted Orb",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSW5mbHVlbmNlIEV4YWx0cy9CYXNpbGlza09yYiIsInNjYWxlIjoxfV0/cd2131d564/BasiliskOrb.png"
},
"warlords-exalted-orb": {
"name": "Warlord's Exalted Orb",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSW5mbHVlbmNlIEV4YWx0cy9Db25xdWVyb3JPcmIiLCJzY2FsZSI6MX1d/57f0d85951/ConquerorOrb.png"
},
"awakeners-orb": {
"name": "Awakener's Orb",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvVHJhbnNmZXJPcmIiLCJzY2FsZSI6MX1d/f3b1c1566f/TransferOrb.png"
},
"mavens-orb": {
"name": "Orb of Dominance",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvTWF2ZW5PcmIiLCJzY2FsZSI6MX1d/f307d80bfd/MavenOrb.png"
},
"eldritch-chaos-orb": {
"name": "Eldritch Chaos Orb",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvRWxkcml0Y2hDaGFvc09yYiIsInNjYWxlIjoxfV0/98091fc653/EldritchChaosOrb.png"
},
"eldritch-exalted-orb": {
"name": "Eldritch Exalted Orb",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvRWxkcml0Y2hFeGFsdGVkT3JiIiwic2NhbGUiOjF9XQ/2da131e652/EldritchExaltedOrb.png"
},
"eldritch-orb-of-annulment": {
"name": "Eldritch Orb of Annulment",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvRWxkcml0Y2hBbm51bG1lbnRPcmIiLCJzY2FsZSI6MX1d/b58add03eb/EldritchAnnulmentOrb.png"
},
"lesser-eldritch-ember": {
"name": "Lesser Eldritch Ember",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ2xlYW5zaW5nRmlyZU9yYlJhbmsxIiwic2NhbGUiOjF9XQ/c7df0e0316/CleansingFireOrbRank1.png"
},
"greater-eldritch-ember": {
"name": "Greater Eldritch Ember",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ2xlYW5zaW5nRmlyZU9yYlJhbmsyIiwic2NhbGUiOjF9XQ/698817b93d/CleansingFireOrbRank2.png"
},
"grand-eldritch-ember": {
"name": "Grand Eldritch Ember",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ2xlYW5zaW5nRmlyZU9yYlJhbmszIiwic2NhbGUiOjF9XQ/0486f1ac82/CleansingFireOrbRank3.png"
},
"exceptional-eldritch-ember": {
"name": "Exceptional Eldritch Ember",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ2xlYW5zaW5nRmlyZU9yYlJhbms0Iiwic2NhbGUiOjF9XQ/c2c828fa16/CleansingFireOrbRank4.png"
},
"lesser-eldritch-ichor": {
"name": "Lesser Eldritch Ichor",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvVGFuZ2xlT3JiUmFuazEiLCJzY2FsZSI6MX1d/70e5e53590/TangleOrbRank1.png"
},
"greater-eldritch-ichor": {
"name": "Greater Eldritch Ichor",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvVGFuZ2xlT3JiUmFuazIiLCJzY2FsZSI6MX1d/689d1897c7/TangleOrbRank2.png"
},
"grand-eldritch-ichor": {
"name": "Grand Eldritch Ichor",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvVGFuZ2xlT3JiUmFuazMiLCJzY2FsZSI6MX1d/199f3b36f3/TangleOrbRank3.png"
},
"exceptional-eldritch-ichor": {
"name": "Exceptional Eldritch Ichor",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvVGFuZ2xlT3JiUmFuazQiLCJzY2FsZSI6MX1d/dcf73ecd8e/TangleOrbRank4.png"
},
"orb-of-conflict": {
"name": "Orb of Conflict",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ29uZmxpY3RPcmJSYW5rMSIsInNjYWxlIjoxfV0/7e02c990fc/ConflictOrbRank1.png"
},
"tainted-chromatic-orb": {
"name": "Tainted Chromatic Orb",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGVsbHNjYXBlL0hlbGxzY2FwZUNocm9tYXRpY09yYiIsInNjYWxlIjoxfV0/702d29c7ab/HellscapeChromaticOrb.png"
},
"tainted-orb-of-fusing": {
"name": "Tainted Orb of Fusing",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGVsbHNjYXBlL0hlbGxzY2FwZU9yYk9mRnVzaW5nIiwic2NhbGUiOjF9XQ/845f3c20ed/HellscapeOrbOfFusing.png"
},
"tainted-jewellers-orb": {
"name": "Tainted Jeweller's Orb",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGVsbHNjYXBlL0hlbGxzY2FwZUpld2VsbGVyc09yYiIsInNjYWxlIjoxfV0/f146c29db2/HellscapeJewellersOrb.png"
},
"tainted-chaos-orb": {
"name": "Tainted Chaos Orb",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGVsbHNjYXBlL0hlbGxzY2FwZUNoYW9zT3JiIiwic2NhbGUiOjF9XQ/64d1f4db99/HellscapeChaosOrb.png"
},
"tainted-exalted-orb": {
"name": "Tainted Exalted Orb",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGVsbHNjYXBlL0hlbGxzY2FwZUV4YWx0ZWRPcmIiLCJzY2FsZSI6MX1d/68a0ea3020/HellscapeExaltedOrb.png"
},
"tainted-mythic-orb": {
"name": "Tainted Mythic Orb",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGVsbHNjYXBlL0hlbGxzY2FwZU15dGhpY09yYiIsInNjYWxlIjoxfV0/72ba97d1a8/HellscapeMythicOrb.png"
},
"tainted-armourers-scrap": {
"name": "Tainted Armourer's Scrap",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGVsbHNjYXBlL0hlbGxzY2FwZUFybW91cmVyc1NjcmFwIiwic2NhbGUiOjF9XQ/9ee0a10625/HellscapeArmourersScrap.png"
},
"tainted-blacksmiths-whetstone": {
"name": "Tainted Blacksmith's Whetstone",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGVsbHNjYXBlL0hlbGxzY2FwZUJsYWNrc21pdGhXaGV0c3RvbmUiLCJzY2FsZSI6MX1d/0309648ccb/HellscapeBlacksmithWhetstone.png"
},
"tainted-divine-teardrop": {
"name": "Tainted Divine Teardrop",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGVsbHNjYXBlL0hlbGxzY2FwZVRlYXJkcm9wT3JiIiwic2NhbGUiOjF9XQ/0d251b9d52/HellscapeTeardropOrb.png"
},
"wild-lifeforce": {
"name": "Wild Crystallised Lifeforce",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGFydmVzdC9XaWxkTGlmZWZvcmNlIiwic2NhbGUiOjF9XQ/e3d0b372b0/WildLifeforce.png"
},
"vivid-lifeforce": {
"name": "Vivid Crystallised Lifeforce",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGFydmVzdC9WaXZpZExpZmVmb3JjZSIsInNjYWxlIjoxfV0/a355b8a5a2/VividLifeforce.png"
},
"primal-lifeforce": {
"name": "Primal Crystallised Lifeforce",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGFydmVzdC9QcmltYWxMaWZlZm9yY2UiLCJzY2FsZSI6MX1d/c498cdfd7f/PrimalLifeforce.png"
},
"sacred-lifeforce": {
"name": "Sacred Crystallised Lifeforce",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGFydmVzdC9TYWNyZWRMaWZlZm9yY2UiLCJzY2FsZSI6MX1d/edfba3c893/SacredLifeforce.png"
},
"hinekoras-lock": {
"name": "Hinekora's Lock",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGluZWtvcmFzTG9jayIsInNjYWxlIjoxfV0/b188026e7f/HinekorasLock.png"
},
"mavens-chisel-of-procurement": {
"name": "Maven's Chisel of Procurement",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvTWF2ZW5DaGlzZWwxIiwic2NhbGUiOjF9XQ/a21d7d73da/MavenChisel1.png"
},
"mavens-chisel-of-proliferation": {
"name": "Maven's Chisel of Proliferation",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvTWF2ZW5DaGlzZWwyIiwic2NhbGUiOjF9XQ/bb82bb4150/MavenChisel2.png"
},
"mavens-chisel-of-divination": {
"name": "Maven's Chisel of Divination",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvTWF2ZW5DaGlzZWw1Iiwic2NhbGUiOjF9XQ/ff3e7f02eb/MavenChisel5.png"
},
"mavens-chisel-of-scarabs": {
"name": "Maven's Chisel of Scarabs",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvTWF2ZW5DaGlzZWwzIiwic2NhbGUiOjF9XQ/a7a9ac8f01/MavenChisel3.png"
},
"mavens-chisel-of-avarice": {
"name": "Maven's Chisel of Avarice",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvTWF2ZW5DaGlzZWw0Iiwic2NhbGUiOjF9XQ/d878502dc7/MavenChisel4.png"
},
"reflecting-mist": {
"name": "Reflecting Mist",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvUmVmbGVjdGl2ZU1pc3QiLCJzY2FsZSI6MX1d/26956f795e/ReflectiveMist.png"
},
"chaos-shard": {
"name": "Chaos Shard",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ2hhb3NTaGFyZCIsInNjYWxlIjoxfV0/db7041e193/ChaosShard.png"
},
"exalted-shard": {
"name": "Exalted Shard",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvRXhhbHRlZFNoYXJkIiwic2NhbGUiOjF9XQ/b9e4013af5/ExaltedShard.png"
},
"engineers-shard": {
"name": "Engineer's Shard",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvRW5naW5lZXJzU2hhcmQiLCJzY2FsZSI6MX1d/9fe1384ff9/EngineersShard.png"
},
"regal-shard": {
"name": "Regal Shard",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvUmVnYWxTaGFyZCIsInNjYWxlIjoxfV0/6f7fc44a91/RegalShard.png"
},
"annulment-shard": {
"name": "Annulment Shard",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQW5udWxsU2hhcmQiLCJzY2FsZSI6MX1d/1cf9962d97/AnnullShard.png"
},
"binding-shard": {
"name": "Binding Shard",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQmluZGluZ1NoYXJkIiwic2NhbGUiOjF9XQ/569d09ac86/BindingShard.png"
},
"ancient-shard": {
"name": "Ancient Shard",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQW5jaWVudFNoYXJkIiwic2NhbGUiOjF9XQ/3695589639/AncientShard.png"
},
"horizon-shard": {
"name": "Horizon Shard",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSG9yaXpvblNoYXJkIiwic2NhbGUiOjF9XQ/627ae5f273/HorizonShard.png"
},
"harbingers-shard": {
"name": "Harbinger's Shard",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGFyYmluZ2VyU2hhcmQiLCJzY2FsZSI6MX1d/ad19e27b2f/HarbingerShard.png"
},
"fracturing-shard": {
"name": "Fracturing Shard",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvRnJhY3R1cmluZ09yYlNoYXJkIiwic2NhbGUiOjF9XQ/34fdc6a813/FracturingOrbShard.png"
},
"mirror-shard": {
"name": "Mirror Shard",
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvTWlycm9yU2hhcmQiLCJzY2FsZSI6MX1d/698183ea2b/MirrorShard.png"
}
}

View File

@ -5,7 +5,14 @@ import json
import logging
from pathlib import Path
from collections import defaultdict
import base64
# --- Base64 encode/decode helpers ---
def encrypt_token(token: str) -> str:
return base64.b64encode(token.encode()).decode()
def decrypt_token(encoded: str) -> str:
return base64.b64decode(encoded.encode()).decode()
class PoeTradeWatcher:
"""
@ -35,11 +42,13 @@ class PoeTradeWatcher:
"MAX_SAFE_RESULTS": 100,
"USER_ADD_INTERVAL": 60,
"MAX_FILTERS_PER_USER": 5,
"RATE_LIMIT_INTERVAL": 5,
"RATE_LIMIT_INTERVAL": 90,
"POESESSID": "",
"OWNER_ID": "203190147582394369",
"LEGACY_WEBHOOK_URL": "https://discord.com/api/webhooks/1354003262709305364/afkTjeXcu1bfZXsQzFl-QqSb3R1MmQ4hdZhosR3vm4I__QVEyZ0jO9cqndUTQwb1mt5Z"
}
self.dynamic_rate_limit = self.settings.get("RATE_LIMIT_INTERVAL", 90)
base_path = Path(__file__).resolve().parent.parent / "local_storage"
base_path.mkdir(parents=True, exist_ok=True)
self.storage_file = base_path / "poe_trade_state.json"
@ -51,10 +60,16 @@ class PoeTradeWatcher:
self.last_api_time = 0
self.session_valid = True
self.filter_names = defaultdict(dict) # {user_id: {filter_id: custom_name}}
self.last_query_time = defaultdict(dict) # user_id -> filter_id -> timestamp
self.paused_users = set()
self.verification_queue = asyncio.Queue()
self._load_state()
asyncio.create_task(self._validate_session())
asyncio.create_task(self._start_verification_worker())
asyncio.create_task(self.start_auto_poll())
def _init_logger(self):
@ -87,19 +102,39 @@ class PoeTradeWatcher:
async def start_auto_poll(self):
"""
Starts the automatic polling loop using AUTO_POLL_INTERVAL.
This will call the registered update callback when new items are found.
Starts a rolling polling system where each user/filter is queried one at a time.
Automatically adjusts wait time based on rate limit headers.
"""
if self._poll_task is not None:
return # Already running
async def poll_loop():
base_delay = self.settings.get("RATE_LIMIT_INTERVAL", 90)
safety_margin = 2
while True:
for user_id, filters in self.watchlists.items():
if str(user_id) in self.paused_users:
self.logger.debug(f"Skipping paused user {user_id}")
continue
for filter_id in filters:
try:
await self.query_all()
# Always wait at least base_delay
delay = max(base_delay, self.dynamic_rate_limit) + safety_margin
result = await self.query_single(user_id, filter_id)
if result == "ratelimited":
# dynamic_rate_limit has already been adjusted within query_single
delay = max(base_delay, self.dynamic_rate_limit) + safety_margin
except Exception as e:
self.logger.error(f"Error in auto poll loop: {e}")
await asyncio.sleep(self.settings.get("AUTO_POLL_INTERVAL", 300))
self.logger.warning(f"Failed query for {filter_id} of user {user_id}: {e}")
delay = base_delay + safety_margin
await asyncio.sleep(delay)
self._poll_task = asyncio.create_task(poll_loop())
@ -184,26 +219,85 @@ class PoeTradeWatcher:
"""
Performs a test request to verify if the current POESESSID is valid.
Updates internal `session_valid` flag.
Handles rate limit headers to avoid overloading the API at startup.
"""
test_url = f"{self.BASE_URL}/search/poe2/{self.LEAGUE}/OzKEO5ltE"
try:
connector = aiohttp.TCPConnector(ssl=True)
async with aiohttp.ClientSession(headers=self.HEADERS, cookies=self.COOKIES, connector=connector) as session:
async with session.get(test_url) as resp:
if resp.status == 403:
rate_info = {
"Status": resp.status,
"Retry-After": resp.headers.get("Retry-After"),
"X-Rate-Limit-Rules": resp.headers.get("X-Rate-Limit-Rules"),
"X-Rate-Limit-Ip": resp.headers.get("X-Rate-Limit-Ip"),
"X-Rate-Limit-State": resp.headers.get("X-Rate-Limit-State"),
}
self.logger.debug(f"[{resp.status}] GET {test_url} | Rate Info: {rate_info}")
if resp.status == 429:
retry_after = int(resp.headers.get("Retry-After", 10))
self.session_valid = False
self.logger.warning(f"Rate limited during session validation. Sleeping for {retry_after + 2} seconds.")
await asyncio.sleep(retry_after + 2)
return
elif resp.status == 403:
self.session_valid = False
self.logger.error("POESESSID validation failed: status 403")
elif resp.status == 200:
self.session_valid = True
self.logger.info("POESESSID validated successfully.")
else:
self.session_valid = False
self.logger.error(f"POESESSID validation returned unexpected status: {resp.status}")
except Exception as e:
self.session_valid = False
self.logger.error(f"Session validation request failed: {e}")
def get_next_query_time(self, user_id: str, filter_id: str) -> int | None:
"""
Estimate the next time this filter will be queried, accounting for queued filters,
current ratelimit interval, and safety margins.
This version ensures the returned time is in the future based on queue order.
"""
if str(user_id) in self.paused_users:
return None
base_interval = self.dynamic_rate_limit
safety_margin = 2
total_delay = base_interval + safety_margin
# Build a linear list of all filters in scheduled order
full_queue = [
(uid, fid)
for uid, flist in self.watchlists.items()
if str(uid) not in self.paused_users
for fid in flist
]
if (user_id, filter_id) not in full_queue:
return None
# Filter position in the total queue
position = full_queue.index((user_id, filter_id))
# Always start from last API time and simulate future ticks
now = time.time()
last = self.last_api_time or now
# Keep incrementing until we find a future timestamp
next_query = last + (position * total_delay)
while next_query <= now:
next_query += len(full_queue) * total_delay
return int(next_query)
def _template(self):
return {
"status": None,
@ -230,6 +324,16 @@ class PoeTradeWatcher:
self.filter_names = defaultdict(dict, data.get("filter_names", {}))
self.last_seen_items = defaultdict(lambda: defaultdict(dict), data.get("seen", {}))
self.settings.update(data.get("settings", {}))
# Decode POESESSID
enc_token = self.settings.get("POESESSID")
if enc_token:
try:
self.settings["POESESSID"] = decrypt_token(enc_token)
self.COOKIES = {"POESESSID": self.settings["POESESSID"]}
except Exception as de:
self.logger.warning(f"Could not decode POESESSID: {de}")
self.paused_users = set(data.get("paused_users", []))
self.logger.info("State loaded. Active filters: %s", sum(len(v) for v in self.watchlists.values()))
except Exception as e:
@ -238,31 +342,74 @@ class PoeTradeWatcher:
def _save_state(self):
try:
settings_copy = self.settings.copy()
if "POESESSID" in settings_copy:
raw_token = settings_copy["POESESSID"]
settings_copy["POESESSID"] = encrypt_token(raw_token)
data = {
"watchlists": dict(self.watchlists),
"filter_names": self.filter_names,
"seen": self.last_seen_items,
"settings": self.settings,
"settings": settings_copy,
"paused_users": list(self.paused_users)
}
with open(self.storage_file, "w") as f:
json.dump(data, f)
except Exception as e:
self.logger.error(f"Failed to save state: {e}")
async def add_filter(self, user_id: str, filter_id: str, custom_name: str = None, force: bool = False) -> dict:
"""
Adds a filter to the user's watchlist.
async def _start_verification_worker(self):
while True:
user_id, filter_id, custom_name, response_callback = await self.verification_queue.get()
result = await self._verify_and_add_filter(user_id, filter_id, custom_name)
if callable(response_callback):
await response_callback(result)
await asyncio.sleep(self.settings["RATE_LIMIT_INTERVAL"])
Args:
user_id (str): Discord user ID.
filter_id (str): PoE trade filter ID.
force (bool, optional): Whether to force add the filter even if it's broad. Defaults to False.
Returns:
dict: Result template with status, user, filter_id, and optional warnings.
"""
async def _verify_and_add_filter(self, user_id, filter_id, custom_name):
result = self._template()
result["user"] = user_id
result["filter_id"] = filter_id
query_url = f"{self.BASE_URL}/search/poe2/{self.LEAGUE}/{filter_id}"
try:
connector = aiohttp.TCPConnector(ssl=False)
async with aiohttp.ClientSession(headers=self.HEADERS, cookies=self.COOKIES, connector=connector) as session:
async with session.get(query_url) as resp:
if resp.status == 403:
self.session_valid = False
result["status"] = "invalid_session"
return result
elif resp.status != 200:
result["status"] = "error"
return result
data = await resp.json()
except Exception as e:
self.logger.error(f"Error while verifying filter {filter_id}: {e}")
result["status"] = "error"
return result
total_results = data.get("total", 0)
result["result_count"] = total_results
if total_results > self.settings["MAX_SAFE_RESULTS"]:
result["status"] = "too_broad"
return result
self.watchlists[user_id].append(filter_id)
if custom_name:
self.filter_names[user_id][filter_id] = custom_name
self._save_state()
result["status"] = "success"
return result
async def add_filter(self, user_id: str, filter_id: str, custom_name: str = None, response_callback=None) -> dict:
result = self._template()
result["user"] = user_id
result["filter_id"] = filter_id
@ -285,43 +432,15 @@ class PoeTradeWatcher:
result["next_allowed_time"] = self.last_add_time[user_id] + self.settings["USER_ADD_INTERVAL"]
return result
query_url = f"{self.BASE_URL}/search/poe2/{self.LEAGUE}/{filter_id}"
try:
connector = aiohttp.TCPConnector(ssl=False)
async with aiohttp.ClientSession(headers=self.HEADERS, cookies=self.COOKIES, connector=connector) as session:
async with session.get(query_url) as resp:
if resp.status == 403:
self.session_valid = False
result["status"] = "invalid_session"
return result
elif resp.status != 200:
result["status"] = "error"
return result
data = await resp.json()
except Exception as e:
self.logger.error(f"Error while checking filter {filter_id}: {e}")
result["status"] = "error"
return result
total_results = data.get("total", 0)
result["result_count"] = total_results
if total_results > self.settings["MAX_SAFE_RESULTS"] and not force:
result["status"] = "too_broad"
return result
self.watchlists[user_id].append(filter_id)
self.last_add_time[user_id] = now
result["status"] = "success"
if custom_name:
self.filter_names[user_id][filter_id] = custom_name
self._save_state()
await self.verification_queue.put((user_id, filter_id, custom_name, response_callback))
result["status"] = "queued"
return result
def remove_filter(self, user_id: str, filter_id: str) -> dict:
"""
Removes a specific filter from a user's watchlist.
Removes a specific filter from a user's watchlist and cleans up associated metadata.
Args:
user_id (str): Discord user ID.
@ -336,6 +455,9 @@ class PoeTradeWatcher:
if filter_id in self.watchlists[user_id]:
self.watchlists[user_id].remove(filter_id)
self.filter_names[user_id].pop(filter_id, None)
self.last_seen_items[user_id].pop(filter_id, None)
self.last_query_time[user_id].pop(filter_id, None)
result["status"] = "removed"
else:
result["status"] = "not_found"
@ -344,6 +466,35 @@ class PoeTradeWatcher:
return result
def remove_all_filters(self, user_id: str) -> dict:
"""
Removes all filters for the specified user and clears associated metadata.
Args:
user_id (str): Discord user ID.
Returns:
dict: Result template with summary of removed filters.
"""
result = self._template()
result["user"] = user_id
removed = self.watchlists.pop(user_id, [])
self.filter_names.pop(user_id, None)
self.last_seen_items.pop(user_id, None)
self.last_add_time.pop(user_id, None)
self.last_query_time.pop(user_id, None)
if removed:
result["status"] = "removed"
result["results"] = removed
result["summary"] = f"Removed {len(removed)} filters from your watchlist."
else:
result["status"] = "empty"
result["summary"] = "You have no filters to remove."
self._save_state()
return result
def get_filters(self, user_id: str) -> dict:
"""
Returns all active filters for a user.
@ -361,48 +512,65 @@ class PoeTradeWatcher:
return result
async def query_all(self):
async def query_single(self, user_id, filter_id):
if not self.session_valid:
self.logger.warning("Skipping query: POESESSID invalid.")
return
if str(user_id) in self.paused_users:
return
found_items = {}
async with aiohttp.ClientSession(headers=self.HEADERS, cookies=self.COOKIES) as session:
async def fetch_with_handling(url, method="GET", **kwargs):
async with getattr(session, method.lower())(url, **kwargs) as res:
rate_info = {
"Status": res.status,
"Retry-After": res.headers.get("Retry-After"),
"X-Rate-Limit-Rules": res.headers.get("X-Rate-Limit-Rules"),
"X-Rate-Limit-Ip": res.headers.get("X-Rate-Limit-Ip"),
"X-Rate-Limit-State": res.headers.get("X-Rate-Limit-State"),
}
self.logger.debug(f"[{res.status}] {method} {url} | Rate Info: {rate_info}")
if res.status == 429:
retry_after = int(res.headers.get("Retry-After", 10))
self.dynamic_rate_limit = retry_after + 2
self.logger.warning(f"Rate limited on {url}. Sleeping for {self.dynamic_rate_limit} seconds.")
await asyncio.sleep(self.dynamic_rate_limit)
return "ratelimited"
elif res.status >= 400:
self.logger.warning(f"HTTP {res.status} on {url}")
return None
return await res.json()
filter_data = await fetch_with_handling(
f"{self.BASE_URL}/search/poe2/{self.LEAGUE}/{filter_id}", method="GET"
)
if not filter_data:
return
for user_id, filters in self.watchlists.items():
if str(user_id) in self.paused_users:
self.logger.debug(f"Skipping paused user {user_id}")
continue
for filter_id in filters:
try:
async with session.get(f"{self.BASE_URL}/search/poe2/{self.LEAGUE}/{filter_id}") as res:
if res.status != 200:
self.logger.warning(f"Failed to fetch filter {filter_id} for user {user_id}. Status: {res.status}")
continue
filter_data = await res.json()
query = {"query": filter_data.get("query", {})}
async with session.post(f"{self.BASE_URL}/search/poe2/{self.LEAGUE}", json=query) as search_res:
if search_res.status != 200:
self.logger.warning(f"Query failed for {filter_id}. Status: {search_res.status}")
continue
result_data = await search_res.json()
result_data = await fetch_with_handling(
f"{self.BASE_URL}/search/poe2/{self.LEAGUE}", method="POST", json=query
)
if not result_data:
return
search_id = result_data.get("id")
result_ids = result_data.get("result", [])[:10]
self.logger.info(f"Filter {filter_id} returned {len(result_ids)} item IDs")
if not result_ids:
continue
return
joined_ids = ",".join(result_ids)
fetch_url = f"{self.BASE_URL}/fetch/{joined_ids}?query={search_id}"
async with session.get(fetch_url) as detail_res:
if detail_res.status != 200:
self.logger.warning(f"Failed to fetch items for {filter_id}. Status: {detail_res.status}")
continue
item_data = await detail_res.json()
item_data = await fetch_with_handling(fetch_url, method="GET")
if not item_data:
return "ratelimited"
items = item_data.get("result", [])
filtered = []
@ -422,13 +590,12 @@ class PoeTradeWatcher:
"items": filtered
}
except Exception as e:
self.logger.error(f"Error while querying filter {filter_id}: {e}")
now = time.time()
self.last_query_time[user_id][filter_id] = now
self.last_api_time = now
if found_items and self._on_update_callback:
await self._on_update_callback(found_items)
elif not found_items:
self.logger.info("No new results; fallback webhook active.")
def cleanup_filters(self, max_age_seconds: int = 86400) -> dict:
@ -481,3 +648,57 @@ class PoeTradeWatcher:
def is_paused(self, user_id: str) -> bool:
return user_id in self.paused_users
def clear_cache(self, user_id: str, confirm: bool = False) -> dict:
"""
Clears all persistent and in-memory cache for a user after confirmation.
Args:
user_id (str): Discord user ID (must be the owner).
confirm (bool): If True, actually clears cache; otherwise sends confirmation.
Returns:
dict: Result with status and message.
"""
owner_id = self.settings.get("OWNER_ID", "203190147582394369")
result = self._template()
if str(user_id) != owner_id:
result["status"] = "unauthorized"
result["summary"] = "Only the bot owner may use this command."
return result
now = time.time()
if not hasattr(self, "_cache_clear_confirmations"):
self._cache_clear_confirmations = {}
if not confirm:
self._cache_clear_confirmations[user_id] = now
result["status"] = "confirm"
result["summary"] = "⚠️ This action will clear all filters, names, and seen cache.\nRun the same command again with `-y` within 60s to confirm."
return result
last = self._cache_clear_confirmations.get(user_id, 0)
if now - last > 60:
result["status"] = "expired"
result["summary"] = "Confirmation expired. Please run the command again."
return result
elif confirm:
result["status"] = "invalid"
result["summary"] = "⚠️ Run the command without the `-y` flag first!"
return result
# Reset all critical in-memory and persistent states
self.watchlists.clear()
self.filter_names.clear()
self.last_seen_items.clear()
self.last_add_time.clear()
self.last_query_time.clear()
self.paused_users.clear()
self._cache_clear_confirmations.pop(user_id, None)
self._save_state()
result["status"] = "cleared"
result["summary"] = "🧹 Cache successfully cleared for all users."
return result