New tools: - tools/diablo2io_price_scraper.py: scrapes public diablo2.io trade listings (browsetrades.php), extracts item/WTS/WTB/desc/price mentions, merges into daily_prices.json as third source - tools/discord_price_report.py: posts daily price summary embed to Discord webhook (top FG, top trade, movers vs last report) - tools/discord_price_scraper.py: Discord channel price scraper (needs bot token; not wired into pipeline yet) Fix: - tools/improve_fg_estimates.py: parse post date from page HTML instead of file mtime (fixes day-bucketing on re-downloads) Data (2026-08-28): - d2jsp: 808 topics, 21 with parseable dates - FG: day_6 (Cham 7.5), day_7 (Aldur 20, Gul 65, Ist 70, Anni 575) - Traderie: 500 listings, 157 items - diablo2.io: 150 listings, 28 items - Pushed 229 prices to .96, prices.alw.dk rebuilt 08:14
260 lines
9.6 KiB
Python
260 lines
9.6 KiB
Python
#!/usr/bin/env python
|
|
"""Improve FG daily estimates by filtering spam/outliers and computing trimmed statistics.
|
|
|
|
The d2jsp forum scraper produces noisy data because:
|
|
1. Spam posts with "3 FG" for everything pull down medians
|
|
2. Some posts list multiple items at once, causing false matches
|
|
3. Multi-item topics distribute prices incorrectly
|
|
|
|
This script re-processes the saved HTML files with better filtering:
|
|
- Remove prices < 5 FG (likely spam)
|
|
- Use trimmed mean (remove bottom 20% and top 20%)
|
|
- Only count prices from the actual poster, not random mentions
|
|
"""
|
|
import argparse
|
|
import datetime as dt
|
|
import json
|
|
import re
|
|
import statistics
|
|
import sys
|
|
from collections import defaultdict
|
|
from html import unescape
|
|
from pathlib import Path
|
|
|
|
# Import from existing scraper
|
|
sys.path.insert(0, str(Path(__file__).parent))
|
|
from fg_market_scraper import (
|
|
ITEM_PATTERNS, normalize_text, parse_prices, is_blocked_page, is_topic_page,
|
|
extract_posters_with_prices, parse_topic_title, parse_topic_datetime
|
|
)
|
|
|
|
|
|
def trimmed_mean(values, trim_frac=0.2):
|
|
"""Compute mean after removing top and bottom trim_frac of values."""
|
|
if not values:
|
|
return 0.0
|
|
sorted_vals = sorted(values)
|
|
n = len(sorted_vals)
|
|
if n < 3:
|
|
return statistics.mean(sorted_vals)
|
|
trim_count = max(1, int(n * trim_frac))
|
|
trimmed = sorted_vals[trim_count:n - trim_count]
|
|
return statistics.mean(trimmed) if trimmed else statistics.mean(sorted_vals)
|
|
|
|
|
|
def trimmed_median(values, trim_frac=0.2):
|
|
"""Compute median after removing top and bottom trim_frac of values."""
|
|
if not values:
|
|
return 0.0
|
|
sorted_vals = sorted(values)
|
|
n = len(sorted_vals)
|
|
if n < 3:
|
|
return statistics.median(sorted_vals)
|
|
trim_count = max(1, int(n * trim_frac))
|
|
trimmed = sorted_vals[trim_count:n - trim_count]
|
|
return statistics.median(trimmed) if trimmed else statistics.median(sorted_vals)
|
|
|
|
|
|
def reprocess_offline(offline_dir, ladder_start, days):
|
|
"""Re-process saved HTML files with improved filtering."""
|
|
offline_path = Path(offline_dir)
|
|
html_files = sorted(list(offline_path.glob("*.html")) + list(offline_path.glob("*.htm")))
|
|
|
|
if not html_files:
|
|
return {
|
|
"generated_at": dt.datetime.now(dt.timezone.utc).isoformat(),
|
|
"mode": "offline-improved",
|
|
"offline_dir": str(offline_path),
|
|
"ladder_start_date": ladder_start.isoformat(),
|
|
"days": days,
|
|
"topics_scanned": 0,
|
|
"estimates": {f"day_{d}": {} for d in range(1, days + 1)},
|
|
"warning": "No HTML files found",
|
|
}
|
|
|
|
# Collect per-day per-item prices
|
|
# Structure: {day_idx: {item_name: [price_values]}}
|
|
buckets = {d: defaultdict(list) for d in range(1, days + 1)}
|
|
# Also track per-user prices for dedup
|
|
user_prices = {d: defaultdict(lambda: defaultdict(list)) for d in range(1, days + 1)}
|
|
|
|
processed = 0
|
|
skipped_blocked = 0
|
|
skipped_no_topic = 0
|
|
skipped_no_date = 0
|
|
skipped_no_item = 0
|
|
skipped_no_price = 0
|
|
|
|
min_valid_price = 5.0 # Filter out 1-3 FG spam
|
|
for html_file in html_files:
|
|
try:
|
|
html = html_file.read_text(encoding="utf-8", errors="ignore")
|
|
except Exception:
|
|
continue
|
|
|
|
if is_blocked_page(html):
|
|
skipped_blocked += 1
|
|
continue
|
|
if not is_topic_page(html):
|
|
skipped_no_topic += 1
|
|
continue
|
|
|
|
# Prefer the actual post date parsed from the page (d2jsp renders
|
|
# "Aug 27 2026 09:19am" in the post header); fall back to file mtime
|
|
# for older files that predate the in-page date format.
|
|
post_dt = parse_topic_datetime(html)
|
|
if post_dt is None:
|
|
post_dt = dt.datetime.fromtimestamp(html_file.stat().st_mtime)
|
|
day_idx = (post_dt.date() - ladder_start).days + 1
|
|
if day_idx < 1 or day_idx > days:
|
|
skipped_no_date += 1
|
|
continue
|
|
|
|
processed += 1
|
|
|
|
text = normalize_text(html)
|
|
title = parse_topic_title(html)
|
|
|
|
# Detect items
|
|
items_found = set()
|
|
for name, patterns in ITEM_PATTERNS.items():
|
|
for pat in patterns:
|
|
if re.search(pat, text, re.IGNORECASE):
|
|
items_found.add(name)
|
|
break
|
|
if name in items_found:
|
|
break
|
|
for name, patterns in ITEM_PATTERNS.items():
|
|
for pat in patterns:
|
|
if re.search(pat, title, re.IGNORECASE):
|
|
items_found.add(name)
|
|
break
|
|
if name in items_found:
|
|
break
|
|
|
|
if not items_found:
|
|
# Try filename
|
|
stem_item = None
|
|
for name, patterns in ITEM_PATTERNS.items():
|
|
for pat in patterns:
|
|
if re.search(pat, html_file.stem.replace("-", " "), re.IGNORECASE):
|
|
stem_item = name
|
|
break
|
|
if stem_item:
|
|
break
|
|
if stem_item:
|
|
items_found.add(stem_item)
|
|
else:
|
|
skipped_no_item += 1
|
|
continue
|
|
|
|
# Extract per-user prices (more accurate than global text scan)
|
|
user_blocks = extract_posters_with_prices(html, str(html_file))
|
|
|
|
# Also try global price extraction as fallback
|
|
global_prices = parse_prices(text)
|
|
if not global_prices:
|
|
global_prices = parse_prices(title)
|
|
|
|
# Filter out spam prices (too low)
|
|
|
|
for item in items_found:
|
|
# Use per-user prices if available
|
|
item_prices = []
|
|
for ub in user_blocks:
|
|
for p in ub["prices"]:
|
|
if p >= min_valid_price:
|
|
item_prices.append(p)
|
|
user_prices[day_idx][item][ub["user"]].append(p)
|
|
|
|
# If no per-user prices, use global (filtered)
|
|
if not item_prices:
|
|
for p in global_prices:
|
|
if p >= min_valid_price:
|
|
item_prices.append(p)
|
|
|
|
if item_prices:
|
|
buckets[day_idx][item].extend(item_prices)
|
|
|
|
# Build results with trimmed statistics
|
|
result = {
|
|
"generated_at": dt.datetime.now(dt.timezone.utc).isoformat(),
|
|
"mode": "offline-improved",
|
|
"offline_dir": str(offline_path),
|
|
"ladder_start_date": ladder_start.isoformat(),
|
|
"days": days,
|
|
"topics_scanned": processed,
|
|
"skipped": {
|
|
"blocked": skipped_blocked,
|
|
"no_topic": skipped_no_topic,
|
|
"no_date": skipped_no_date,
|
|
"no_item": skipped_no_item,
|
|
},
|
|
"filters": {
|
|
"min_price_fg": min_valid_price,
|
|
"trim_fraction": 0.2,
|
|
},
|
|
"estimates": {},
|
|
}
|
|
|
|
for day_idx in range(1, days + 1):
|
|
day_key = f"day_{day_idx}"
|
|
result["estimates"][day_key] = {}
|
|
for item, prices in buckets[day_idx].items():
|
|
if not prices:
|
|
continue
|
|
# Remove duplicates from same user (keep one per user)
|
|
user_dedup = {}
|
|
for user, uprices in user_prices[day_idx][item].items():
|
|
if uprices:
|
|
user_dedup[user] = min(uprices) # Take cheapest per user
|
|
|
|
# Use user-deduped prices if available, otherwise all prices
|
|
effective_prices = list(user_dedup.values()) if user_dedup else prices
|
|
|
|
if len(effective_prices) < 2:
|
|
continue
|
|
|
|
result["estimates"][day_key][item] = {
|
|
"median_fg": round(float(statistics.median(effective_prices)), 1),
|
|
"avg_fg": round(float(statistics.mean(effective_prices)), 1),
|
|
"trimmed_mean_fg": round(float(trimmed_mean(effective_prices, 0.2)), 1),
|
|
"trimmed_median_fg": round(float(trimmed_median(effective_prices, 0.2)), 1),
|
|
"min_fg": round(float(min(effective_prices)), 1),
|
|
"max_fg": round(float(max(effective_prices)), 1),
|
|
"samples": len(effective_prices),
|
|
"raw_samples": len(prices),
|
|
}
|
|
|
|
return result
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Improve FG daily estimates with spam filtering")
|
|
parser.add_argument("--ladder-start-date", default="2026-05-20", help="Ladder start date")
|
|
parser.add_argument("--days", type=int, default=21, help="Number of days")
|
|
parser.add_argument("--offline-dir", default="data/d2jsp_pages", help="Offline HTML dir")
|
|
parser.add_argument("--out", default="config/fg_daily_estimates.json", help="Output file")
|
|
args = parser.parse_args()
|
|
|
|
ladder_start = dt.date.fromisoformat(args.ladder_start_date)
|
|
data = reprocess_offline(args.offline_dir, ladder_start, args.days)
|
|
|
|
out_path = Path(args.out)
|
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
out_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
print(f"Wrote to {out_path} (topics_scanned={data['topics_scanned']})")
|
|
|
|
# Show summary
|
|
for dk in sorted(data["estimates"].keys()):
|
|
items = data["estimates"][dk]
|
|
if items:
|
|
print(f"\n{dk}: {len(items)} items")
|
|
for name in sorted(items.keys()):
|
|
d = items[name]
|
|
print(f" {name:<30} med={d['median_fg']:>7} avg={d['avg_fg']:>7} trim_med={d['trimmed_median_fg']:>7} trim_avg={d['trimmed_mean_fg']:>7} min={d['min_fg']:>6} max={d['max_fg']:>7} n={d['samples']:>4}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|