Files
my-botty/tools/traderie_price_scraper.py
2026-05-31 12:51:21 +02:00

589 lines
21 KiB
Python

#!/usr/bin/env python
"""Scrape daily trade prices from traderie.com for D2R items and merge with FG prices.
Produces a combined daily price report with:
- Traderie barter prices (runes/charms as currency)
- FG prices from d2jsp forum estimates
- Unified daily price comparison
Usage:
python tools/traderie_price_scraper.py # Scrape and save
python tools/traderie_price_scraper.py --top 20 # Top 20 most expensive
python tools/traderie_price_scraper.py --item Mara # Filter by item name
python tools/traderie_price_scraper.py --currency Jah # Filter by currency
python tools/traderie_price_scraper.py --trend # Price history
python tools/traderie_price_scraper.py --all # Show all items
Output:
config/traderie_prices.json - Latest traderie scrape
config/traderie_history.json - Traderie history
config/daily_prices.json - Combined traderie + FG prices
config/daily_prices_history.json - Combined history
"""
import argparse
import datetime as dt
import json
import statistics
import sys
import time
from collections import Counter
from pathlib import Path
import requests
BASE_URL = "https://traderie.com/api/diablo2resurrected/listings"
BROWSER_HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36",
"Accept": "application/json",
"Accept-Language": "en-DK,en;q=0.9,da-DK;q=0.8",
}
# Rune value ladder (relative to El Rune = 1)
RUNE_VALUES = {
"el rune": 1, "eld rune": 1, "tir rune": 1, "nef rune": 1,
"eth rune": 1, "ith rune": 1,
"tal rune": 2, "ral rune": 3, "ort rune": 4, "amn rune": 5,
"sol rune": 6, "shael rune": 3, "dol rune": 3, "hel rune": 3,
"io rune": 4, "ko rune": 4, "lum rune": 4,
"fal rune": 7, "lem rune": 8, "pul rune": 9, "um rune": 10,
"mal rune": 12, "ist rune": 15, "gul rune": 18, "vex rune": 22,
"ohm rune": 25, "lo rune": 30, "cham rune": 35,
"jah rune": 50, "ber rune": 60, "koh rune": 70,
}
CHARM_VALUES = {
"small charm": 0.5,
"medium charm": 1,
"large charm": 2,
"grand charm": 5,
}
CURRENCY_VALUES = {**RUNE_VALUES, **CHARM_VALUES}
# Map FG item names (from d2jsp) to traderie item names (from API)
FG_TO_TRADERIE = {
# Runes (direct match)
"Cham Rune": "Cham Rune",
"Lo Rune": "Lo Rune",
"Ohm Rune": "Ohm Rune",
"Vex Rune": "Vex Rune",
"Gul Rune": "Gul Rune",
"Ist Rune": "Ist Rune",
"Mal Rune": "Mal Rune",
"Um Rune": "Um Rune",
"Pul Rune": "Pul Rune",
"Lem Rune": "Lem Rune",
"Ber Rune": "Ber Rune",
"Jah Rune": "Jah Rune",
"Koh Rune": "Koh Rune",
# Unique armor
"Arach": "Arachnid Mesh",
"Griffon": "Griffon's Eye",
"Shako": "Harlequin Crest",
"Highlord": "Highlord's Wrath",
"Mara": "Mara's Kaleidoscope",
"Death's Fathom": "Death's Fathom",
"War Traveler": "War Traveler",
# Unique rings
"BK Ring": "Bul-Kathos' Wedding Band",
# Unique charms
"Unid Anni": "Annihilus",
"Unid Torch": "Hellfire Torch",
"Sorc Torch": "Hellfire Torch",
"Pala Torch": "Hellfire Torch",
# Runewords
"CTA": "Call to Arms",
"Stealth RW": "Stealth",
"Dual Leech Ring": "Dual Leech Ring",
"Aldur's Advance": "Aldur's Advance",
"Viper Gorge": "Viper's Gorge",
"Insight": "Insight",
"Fury": "Fury",
"Red Tape": "Red Tape",
"Thresher": "Thresher",
"Conviction": "Conviction",
"Sanctuary": "Sanctuary",
"Spirit": "Spirit",
"Hope's Fall": "Hope's Fall",
"Grief": "Grief",
"Witching Hour": "Witching Hour",
"Crow": "Crow",
"Wind": "Wind",
"Monarch": "Monarch",
"Sacred": "Sacred Armor",
"Guardian's Light": "Guardian's Light",
"Oculus": "The Oculus",
"Vampire Gaze": "Vampire Gaze",
"Hellfire": "Hellfire Torch",
"Torch of the Fanatic": "Torch of the Fanatic",
"Light Jewel": "Light Jewel",
"Omen": "Omen",
"Enigma": "Enigma",
"Ward": "Ward",
"Crescent": "Crescent Moon",
"Night": "Night",
"Blessed": "Blessed Helm",
"Fortitude": "Fortitude",
"Herod": "Herod's Rancor",
"Skin": "Skin of the Vipermagi",
"Treachery": "Treachery",
"Martyr": "Martyr",
"Cyclone": "Cyclone",
"Oath": "Oath",
"Kurtz": "Kurtzile",
"Moser": "Moser's",
"Mithril": "Mithril",
"Fist": "Fist of the Heavens",
"Grave": "Grave's in Embrace",
"Troll": "Troll",
"Honor": "Honor",
"Doom": "Doom",
"Famine": "Famine",
"Pestilence": "Pestilence",
"Bulwark": "Bulwark",
"Mjolnir": "Mjolnir",
"Giant": "Giant's",
"Manticore": "Manticore",
"Spiketh": "Spiketh",
"Bone": "Bone",
"Black": "Black",
"White": "White",
# Skill tomes
"Cold SK": "Cold SK",
"Light SK": "Light SK",
"Pcomb SK": "Paladin Combat SK",
"Java SK": "Javelin SK",
# Gems
"5/5 Facet": "Rainbow Facet",
# Keys
"Key of Terror": "Key of Terror",
"Key of Destruction": "Key of Destruction",
"Key of Hate": "Key of Hate",
"3x3 Key Set": "3x3 Key Set",
}
def scrape_listings(max_pages=20, delay=0.5):
"""Scrape all listings from traderie.com."""
session = requests.Session()
session.headers.update(BROWSER_HEADERS)
all_listings = []
page = 0
while page < max_pages:
params = {
"selling": "true",
"auction": "false",
"page": str(page),
"completed": "false",
"active": "all",
}
try:
r = session.get(BASE_URL, params=params, timeout=30)
if r.status_code != 200:
print(f"HTTP {r.status_code} on page {page}, stopping")
break
data = r.json()
except Exception as e:
print(f"Request error on page {page}: {e}")
break
listings = data.get("listings") or []
if not listings:
break
all_listings.extend(listings)
print(f" Page {page}: {len(listings)} listings (total: {len(all_listings)})")
if len(listings) < 50:
break
page += 1
time.sleep(delay)
return all_listings
def normalize_price_value(price_name, quantity):
"""Convert a price to normalized value."""
name_lower = price_name.lower().strip()
for rune_name, value in RUNE_VALUES.items():
if rune_name in name_lower:
return quantity * value, price_name
for charm_name, value in CHARM_VALUES.items():
if charm_name in name_lower:
return quantity * value, price_name
if "gold" in name_lower:
return quantity * 0.001, price_name
if "flare" in name_lower or "fg" in name_lower:
return quantity * 100, price_name
return quantity, price_name
def extract_prices(listing):
"""Extract all prices from a listing."""
prices = listing.get("prices") or []
result = []
for price in prices:
name = price.get("name", "")
qty = price.get("quantity", 0)
if not name or qty <= 0:
continue
norm_value = normalize_price_value(name, qty)
result.append((norm_value[0], norm_value[1], qty))
return result
def categorize_item(listing):
"""Categorize a listing into a friendly item name."""
item = listing.get("item") or {}
item_name = item.get("name", "")
properties = listing.get("properties") or []
for prop in properties:
prop_str = str(prop.get("property", "")).lower()
prop_string = str(prop.get("string", "")).lower()
for rw in ["insight", "fury", "red tape", "thresher", "conviction",
"sanctuary", "spirit", "hope's fall", "grief", "witching hour",
"crow", "wind", "monarch", "sacred", "enigma", "ward",
"crescent", "night", "blessed", "fortitude", "herod",
"treachery", "martyr", "cyclone", "oath", "kurtz",
"moser", "mithril", "fist", "grave", "troll", "honor",
"doom", "famine", "pestilence", "bulwark", "mjolnir",
"giant", "manticore", "spiketh", "bone", "black", "white"]:
if rw in prop_str or rw in prop_string:
return rw.capitalize()
return item_name
def analyze_prices(listings):
"""Analyze listings and compute statistics per item."""
item_data = {}
for listing in listings:
prices = extract_prices(listing)
if not prices:
continue
item_name = categorize_item(listing)
if item_name not in item_data:
item_data[item_name] = []
for norm_value, currency, qty in prices:
item_data[item_name].append({
"normalized": norm_value,
"currency": currency,
"quantity": qty,
"listing_id": listing.get("id"),
"seller": listing.get("seller", {}).get("username", ""),
})
results = {}
for item_name, entries in sorted(item_data.items()):
by_currency = {}
for entry in entries:
curr = entry["currency"]
if curr not in by_currency:
by_currency[curr] = []
by_currency[curr].append(entry["normalized"])
stats = {
"total_listings": len(set(e["listing_id"] for e in entries)),
"currencies": {},
}
for curr, values in by_currency.items():
stats["currencies"][curr] = {
"count": len(values),
"median": round(statistics.median(values), 1),
"mean": round(statistics.mean(values), 1),
"min": min(values),
"max": max(values),
}
all_norm = [e["normalized"] for e in entries]
stats["overall"] = {
"median": round(statistics.median(all_norm), 1),
"mean": round(statistics.mean(all_norm), 1),
"count": len(all_norm),
}
results[item_name] = stats
return results
def load_fg_estimates():
"""Load FG daily estimates from d2jsp scraper (improved version)."""
config_dir = Path(__file__).parent.parent / "config"
fg_file = config_dir / "fg_daily_estimates.json"
if not fg_file.exists():
return {}
try:
with open(fg_file, "r", encoding="utf-8") as f:
data = json.load(f)
# Get the latest day with data
estimates = data.get("estimates", {})
# Find the last day with actual data (numeric sort on day index)
latest_day = None
def day_num(k):
try:
return int(k.split("_")[1])
except (ValueError, IndexError):
return 0
for day_key in sorted(estimates.keys(), key=day_num, reverse=True):
if estimates[day_key]:
latest_day = day_key
break
if latest_day:
day_data = estimates[latest_day]
# Normalize: use trimmed_median if available, else median
result = {}
for item, d in day_data.items():
result[item] = {
"median_fg": d.get("trimmed_median_fg", d.get("median_fg", 0)),
"avg_fg": d.get("trimmed_mean_fg", d.get("avg_fg", 0)),
"min_fg": d.get("min_fg", 0),
"max_fg": d.get("max_fg", 0),
"samples": d.get("samples", d.get("raw_samples", 0)),
}
return result
return {}
except Exception:
return {}
def merge_prices(traderie_results, fg_estimates):
"""Merge traderie barter prices with FG prices into a unified view."""
merged = {}
# Build reverse lookup: traderie item name -> FG item name
traderie_to_fg = {v: k for k, v in FG_TO_TRADERIE.items()}
# Add traderie data
for item_name, stats in traderie_results.items():
fg_name = traderie_to_fg.get(item_name)
merged[item_name] = {
"traderie": stats,
"fg": None,
"fg_name": fg_name,
}
# Add FG data
for fg_item, fg_data in fg_estimates.items():
traderie_name = FG_TO_TRADERIE.get(fg_item)
if traderie_name and traderie_name in merged:
# Merge into existing entry
merged[traderie_name]["fg"] = fg_data
merged[traderie_name]["fg_name"] = fg_item
elif traderie_name:
# New entry for FG-only items
merged[traderie_name] = {
"traderie": None,
"fg": fg_data,
"fg_name": fg_item,
}
return merged
def save_results(traderie_results, total_listings, fg_estimates):
"""Save results to JSON files."""
config_dir = Path(__file__).parent.parent / "config"
config_dir.mkdir(parents=True, exist_ok=True)
now = dt.datetime.now(dt.timezone.utc)
# Save traderie data
traderie_output = {
"scraped_at": now.isoformat(),
"scrape_date": now.strftime("%Y-%m-%d"),
"total_listings_scanned": total_listings,
"items_found": len(traderie_results),
"prices": traderie_results,
}
latest_file = config_dir / "traderie_prices.json"
with open(latest_file, "w", encoding="utf-8") as f:
json.dump(traderie_output, f, indent=2, ensure_ascii=False)
print(f"Saved traderie prices to {latest_file}")
# Merge with FG
merged = merge_prices(traderie_results, fg_estimates)
# Save combined daily prices
daily_output = {
"generated_at": now.isoformat(),
"date": now.strftime("%Y-%m-%d"),
"traderie_listings_scanned": total_listings,
"traderie_items": len(traderie_results),
"fg_items": len(fg_estimates),
"merged_items": len(merged),
"prices": merged,
}
daily_file = config_dir / "daily_prices.json"
with open(daily_file, "w", encoding="utf-8") as f:
json.dump(daily_output, f, indent=2, ensure_ascii=False)
print(f"Saved combined daily prices to {daily_file}")
# Append to history
history_file = config_dir / "daily_prices_history.json"
history = []
if history_file.exists():
try:
with open(history_file, "r", encoding="utf-8") as f:
history = json.load(f)
except Exception:
history = []
existing_dates = {entry.get("date") for entry in history}
if now.strftime("%Y-%m-%d") not in existing_dates:
history.append(daily_output)
if len(history) > 90:
history = history[-90:]
with open(history_file, "w", encoding="utf-8") as f:
json.dump(history, f, indent=2, ensure_ascii=False)
print(f"History: {len(history)} entries in {history_file}")
return traderie_output, daily_output
def display_results(traderie_output, daily_output, args):
"""Display results."""
merged = daily_output.get("prices", {})
if args.item:
filtered = {k: v for k, v in merged.items() if args.item.lower() in k.lower()}
if not filtered:
print(f"No items matching '{args.item}'")
return
print(f"\nItems matching '{args.item}':")
print_merged_table(filtered)
return
if args.top:
items_with_traderie = [(k, v) for k, v in merged.items() if v.get("traderie")]
items_with_traderie.sort(key=lambda x: x[1]["traderie"]["overall"]["median"], reverse=True)
top_items = dict(items_with_traderie[:args.top])
print(f"\nTop {args.top} most expensive on traderie.com:")
print_merged_table(top_items)
return
if args.currency:
filtered = {}
for item, data in merged.items():
if data.get("traderie"):
for curr in data["traderie"].get("currencies", {}):
if args.currency.lower() in curr.lower():
filtered[item] = data
break
if filtered:
print(f"\nItems priced with '{args.currency}':")
print_merged_table(filtered)
else:
print(f"No items priced with '{args.currency}'")
return
if args.trend:
history_file = Path(__file__).parent.parent / "config" / "daily_prices_history.json"
if not history_file.exists():
print("No history. Run a scrape first.")
return
with open(history_file, "r", encoding="utf-8") as f:
history = json.load(f)
print(f"\nDaily price history ({len(history)} snapshots):")
print(f"{'Date':<12} {'Traderie':>10} {'FG':>8} {'Merged':>8}")
print("-" * 45)
for entry in history:
print(f"{entry['date']:<12} {entry['traderie_items']:>10} {entry['fg_items']:>8} {entry['merged_items']:>8}")
return
if args.all:
print(f"\nAll items (scraped {daily_output['generated_at']}):")
print(f"Traderie listings: {daily_output['traderie_listings_scanned']}, Items: {daily_output['merged_items']}")
print()
print_merged_table(merged)
return
# Default: show top 30
items_with_traderie = [(k, v) for k, v in merged.items() if v.get("traderie")]
items_with_traderie.sort(key=lambda x: x[1]["traderie"]["overall"]["median"], reverse=True)
top_items = dict(items_with_traderie[:30])
print(f"\nTop 30 items (scraped {daily_output['generated_at']}):")
print(f"Traderie listings: {daily_output['traderie_listings_scanned']}, Merged items: {daily_output['merged_items']}")
print()
print_merged_table(top_items)
if len(items_with_traderie) > 30:
print(f" ... and {len(items_with_traderie) - 30} more items (use --all to see all)")
def print_merged_table(merged):
"""Print a combined price table with traderie + FG prices."""
print(f"{'Item':<35} {'Trade Price':>20} {'FG Price':>10} {'Currency(s)'}")
print("-" * 90)
for item, data in sorted(merged.items(), key=lambda x: (x[1].get("traderie") or {}).get("overall", {}).get("median", 0), reverse=True):
# Show actual trade price as "1 Jah Rune" or "2 Jah + 1 Ber"
trade_price = ""
if data.get("traderie"):
currencies = data["traderie"].get("currencies", {})
parts = []
for curr, info in sorted(currencies.items(), key=lambda x: x[1]["count"], reverse=True):
qty = round(info["count"])
parts.append(f"{qty} {curr}")
trade_price = " + ".join(parts[:3])
if len(parts) > 3:
trade_price += f" +{len(parts)-3}"
fg_med = (data.get("fg") or {}).get("median_fg", "-") if data.get("fg") else "-"
# Show currency breakdown
currencies_str = ""
if data.get("traderie"):
currencies_str = ", ".join(f"{k}({v['count']})" for k, v in data["traderie"].get("currencies", {}).items())
print(f"{item:<35} {trade_price:>20} {fg_med:>10} {currencies_str}")
def show_currency_summary(traderie_output):
"""Show currency distribution."""
prices = traderie_output.get("prices", {})
currency_counts = Counter()
for item, stats in prices.items():
for curr, info in stats.get("currencies", {}).items():
currency_counts[curr] += info["count"]
print("\nCurrency distribution:")
print(f"{'Currency':<25} {'Listings':>10}")
print("-" * 40)
for curr, count in currency_counts.most_common():
print(f"{curr:<25} {count:>10}")
def main():
parser = argparse.ArgumentParser(description="Scrape D2R trade prices from traderie.com + FG prices")
parser.add_argument("--top", type=int, default=None, help="Show top N most expensive items")
parser.add_argument("--item", type=str, default=None, help="Filter by item name")
parser.add_argument("--currency", type=str, default=None, help="Filter by currency type")
parser.add_argument("--trend", action="store_true", help="Show price history")
parser.add_argument("--all", action="store_true", help="Show all items")
parser.add_argument("--max-pages", type=int, default=10, help="Max pages to scrape (default 10)")
parser.add_argument("--delay", type=float, default=0.5, help="Delay between requests (default 0.5s)")
args = parser.parse_args()
# If just querying, load existing data
if any([args.top, args.item, args.currency, args.trend, args.all]):
daily_file = Path(__file__).parent.parent / "config" / "daily_prices.json"
if not daily_file.exists():
print("No price data found. Run a scrape first: python tools/traderie_price_scraper.py")
sys.exit(1)
with open(daily_file, "r", encoding="utf-8") as f:
daily_output = json.load(f)
traderie_file = Path(__file__).parent.parent / "config" / "traderie_prices.json"
if traderie_file.exists():
with open(traderie_file, "r", encoding="utf-8") as f:
traderie_output = json.load(f)
else:
traderie_output = {}
display_results(traderie_output, daily_output, args)
show_currency_summary(traderie_output)
return
# Scrape mode
print("Scraping traderie.com listings...")
listings = scrape_listings(args.max_pages, args.delay)
print(f"\nTotal listings: {len(listings)}")
if not listings:
print("No listings found.")
sys.exit(1)
results = analyze_prices(listings)
fg_estimates = load_fg_estimates()
print(f"Loaded FG estimates: {len(fg_estimates)} items")
traderie_output, daily_output = save_results(results, len(listings), fg_estimates)
display_results(traderie_output, daily_output, args)
show_currency_summary(traderie_output)
if __name__ == "__main__":
main()