davidpcm commited on
Commit
2e5b17a
Β·
verified Β·
1 Parent(s): fd6cf71

Upload folder using huggingface_hub

Browse files
Files changed (4) hide show
  1. README.md +31 -13
  2. app.py +413 -0
  3. requirements.txt +3 -0
  4. tracker.py +591 -0
README.md CHANGED
@@ -1,13 +1,31 @@
1
- ---
2
- title: Ns Card Tracker
3
- emoji: πŸš€
4
- colorFrom: purple
5
- colorTo: yellow
6
- sdk: gradio
7
- sdk_version: 6.14.0
8
- python_version: '3.13'
9
- app_file: app.py
10
- pinned: false
11
- ---
12
-
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: NS High-Value Card Tracker
3
+ emoji: πŸ†
4
+ colorFrom: yellow
5
+ colorTo: red
6
+ sdk: gradio
7
+ sdk_version: 5.29.0
8
+ app_file: app.py
9
+ python_version: 3.11.0
10
+ pinned: false
11
+ ---
12
+
13
+ # NationStates High-Value Trading Card Tracker
14
+
15
+ Identifies elite-tier undervalued trading cards from the NationStates daily data dump.
16
+
17
+ ## Features
18
+ - Downloads and parses daily card & nation XML dumps
19
+ - Three valuation filters: Low ID, CTE (dead nations), Historical Names
20
+ - Live market price checking with strict rate limiting (1.2s/request)
21
+ - Undervalued card alerts with direct purchase links
22
+ - SQLite storage for efficient re-queries
23
+
24
+ ## Compliance
25
+ ⚠️ This tool is **purely read-only**. No automated trades, buys, or bids are executed.
26
+ All transactions must be completed manually by clicking the generated URL link.
27
+
28
+ ## Filters
29
+ - **Low ID:** Legendary/Epic cards with ID ≀ 1000
30
+ - **CTE:** Legendary/Epic cards whose nation no longer exists (Ceased to Exist)
31
+ - **Historical Name:** Cards matching famous NationStates names/regions
app.py ADDED
@@ -0,0 +1,413 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ import requests
3
+ import sqlite3
4
+ import time
5
+ import os
6
+ import xml.etree.ElementTree as ET
7
+ import pandas as pd
8
+ from datetime import datetime
9
+
10
+ # ==========================================
11
+ # CONFIGURATION
12
+ # ==========================================
13
+ USER_AGENT = "HighValueCardTracker (Operational puppet managed by thunderpuppetempires1)"
14
+ API_BASE = "https://www.nationstates.net/cgi-bin/api.cgi"
15
+ DB_PATH = "cards_tracker.db"
16
+ RATE_LIMIT_SECONDS = 1.2
17
+ SEASON = 4
18
+
19
+ # Price thresholds β€” alert if ask is below these values
20
+ THRESHOLDS = {
21
+ "legendary": 20.0,
22
+ "epic": 5.0,
23
+ "ultra-rare": 1.0,
24
+ }
25
+
26
+ # Historical/famous names in NationStates
27
+ HISTORICAL_NAMES = [
28
+ "testlandia", "the pacific", "the north pacific", "the south pacific",
29
+ "the east pacific", "the west pacific", "the rejected realms",
30
+ "lazarus", "balder", "osiris", "the black hawks",
31
+ "the grey wardens", "10000 islands", "europe",
32
+ "max barry", "eluvatar", "sedgistan", "violet",
33
+ "the free nations region", "the communist bloc",
34
+ "nationstates", "maxtopia", "francos spain", "gatesville",
35
+ "the pacific army", "jennifer government", "the north pacific army",
36
+ "feeder", "raider", "defender",
37
+ ]
38
+
39
+ # ==========================================
40
+ # DATABASE
41
+ # ==========================================
42
+
43
+ def init_db():
44
+ conn = sqlite3.connect(DB_PATH)
45
+ c = conn.cursor()
46
+ c.execute("""
47
+ CREATE TABLE IF NOT EXISTS cards (
48
+ card_id INTEGER,
49
+ season INTEGER,
50
+ name TEXT,
51
+ rarity TEXT,
52
+ region TEXT,
53
+ market_value REAL,
54
+ flag TEXT,
55
+ PRIMARY KEY (card_id, season)
56
+ )
57
+ """)
58
+ c.execute("""
59
+ CREATE TABLE IF NOT EXISTS targets (
60
+ card_id INTEGER,
61
+ season INTEGER,
62
+ name TEXT,
63
+ rarity TEXT,
64
+ flag_reason TEXT,
65
+ last_checked TEXT,
66
+ lowest_ask REAL,
67
+ card_url TEXT,
68
+ PRIMARY KEY (card_id, season)
69
+ )
70
+ """)
71
+ conn.commit()
72
+ conn.close()
73
+
74
+ # ==========================================
75
+ # API FUNCTIONS
76
+ # ==========================================
77
+
78
+ def fetch_card_info(card_id, season=SEASON):
79
+ """Fetch a single card's info from the API."""
80
+ url = f"{API_BASE}?q=card+info;cardid={card_id};season={season}"
81
+ headers = {"User-Agent": USER_AGENT}
82
+ try:
83
+ resp = requests.get(url, headers=headers, timeout=10)
84
+ if resp.status_code == 200:
85
+ root = ET.fromstring(resp.text)
86
+ card = root if root.tag == "CARD" else root.find("CARD")
87
+ if card is not None:
88
+ return {
89
+ "card_id": int(card.findtext("CARDID", str(card_id))),
90
+ "season": int(card.findtext("SEASON", str(season))),
91
+ "name": card.findtext("NAME", "Unknown"),
92
+ "rarity": card.findtext("CATEGORY", "unknown"),
93
+ "region": card.findtext("REGION", ""),
94
+ "market_value": float(card.findtext("MARKET_VALUE", "0") or "0"),
95
+ "flag": card.findtext("FLAG", ""),
96
+ }
97
+ return None
98
+ except Exception:
99
+ return None
100
+
101
+
102
+ def fetch_card_market(card_id, season=SEASON):
103
+ """Fetch a card's market data (asks/bids)."""
104
+ url = f"{API_BASE}?q=card+markets;cardid={card_id};season={season}"
105
+ headers = {"User-Agent": USER_AGENT}
106
+ try:
107
+ resp = requests.get(url, headers=headers, timeout=10)
108
+ if resp.status_code == 200:
109
+ root = ET.fromstring(resp.text)
110
+ markets = root.find(".//MARKETS")
111
+ if markets is None:
112
+ return None
113
+ lowest_ask = None
114
+ for market in markets.findall("MARKET"):
115
+ if market.findtext("TYPE", "") == "ask":
116
+ price = float(market.findtext("PRICE", "0") or "0")
117
+ if lowest_ask is None or price < lowest_ask:
118
+ lowest_ask = price
119
+ return lowest_ask
120
+ return None
121
+ except Exception:
122
+ return None
123
+
124
+
125
+ def check_nation_exists(nation_name):
126
+ """Check if a nation exists via the API."""
127
+ url = f"{API_BASE}?nation={nation_name.replace(' ', '_')}&q=name"
128
+ headers = {"User-Agent": USER_AGENT}
129
+ try:
130
+ resp = requests.get(url, headers=headers, timeout=10)
131
+ return resp.status_code == 200 and "<NAME>" in resp.text
132
+ except Exception:
133
+ return False
134
+
135
+
136
+ # ==========================================
137
+ # SCAN FUNCTIONS
138
+ # ==========================================
139
+
140
+ def scan_low_id_cards(max_id=1000, progress=None):
141
+ """Scan cards 1 to max_id for Legendary/Epic cards."""
142
+ init_db()
143
+ conn = sqlite3.connect(DB_PATH)
144
+ c = conn.cursor()
145
+
146
+ found = []
147
+ for card_id in range(1, max_id + 1):
148
+ if progress:
149
+ progress((card_id) / max_id, desc=f"Scanning card {card_id}/{max_id}")
150
+
151
+ card = fetch_card_info(card_id, SEASON)
152
+ if card and card["rarity"] in ("legendary", "epic"):
153
+ c.execute("INSERT OR REPLACE INTO cards VALUES (?, ?, ?, ?, ?, ?, ?)",
154
+ (card["card_id"], card["season"], card["name"], card["rarity"],
155
+ card["region"], card["market_value"], card["flag"]))
156
+ conn.commit()
157
+
158
+ card_url = f"https://www.nationstates.net/page=deck/card={card_id}/season={SEASON}"
159
+ c.execute("INSERT OR REPLACE INTO targets VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
160
+ (card["card_id"], card["season"], card["name"], card["rarity"],
161
+ "Low ID (<=1000)", None, None, card_url))
162
+ conn.commit()
163
+ found.append(card)
164
+
165
+ time.sleep(RATE_LIMIT_SECONDS)
166
+
167
+ conn.close()
168
+ return found
169
+
170
+
171
+ def scan_cte_cards(progress=None):
172
+ """Check existing targets for CTE (dead nation) status."""
173
+ conn = sqlite3.connect(DB_PATH)
174
+ c = conn.cursor()
175
+ c.execute("SELECT card_id, season, name, rarity FROM cards WHERE rarity IN ('legendary', 'epic')")
176
+ cards = c.fetchall()
177
+
178
+ cte_found = []
179
+ for i, (card_id, season, name, rarity) in enumerate(cards):
180
+ if progress:
181
+ progress((i + 1) / len(cards), desc=f"Checking CTE: {name} ({i+1}/{len(cards)})")
182
+
183
+ exists = check_nation_exists(name)
184
+ if not exists:
185
+ card_url = f"https://www.nationstates.net/page=deck/card={card_id}/season={season}"
186
+ c.execute("""INSERT OR REPLACE INTO targets VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
187
+ (card_id, season, name, rarity, "CTE (Dead Nation)", None, None, card_url))
188
+ conn.commit()
189
+ cte_found.append({"card_id": card_id, "name": name, "rarity": rarity})
190
+
191
+ time.sleep(RATE_LIMIT_SECONDS)
192
+
193
+ conn.close()
194
+ return cte_found
195
+
196
+
197
+ def scan_historical_cards(progress=None):
198
+ """Check cards in DB for historical name matches."""
199
+ conn = sqlite3.connect(DB_PATH)
200
+ c = conn.cursor()
201
+ c.execute("SELECT card_id, season, name, rarity FROM cards")
202
+ cards = c.fetchall()
203
+
204
+ hist_found = []
205
+ for card_id, season, name, rarity in cards:
206
+ for hist_name in HISTORICAL_NAMES:
207
+ if hist_name in name.lower():
208
+ card_url = f"https://www.nationstates.net/page=deck/card={card_id}/season={season}"
209
+ c.execute("""INSERT OR REPLACE INTO targets VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
210
+ (card_id, season, name, rarity,
211
+ f"Historical ({hist_name})", None, None, card_url))
212
+ conn.commit()
213
+ hist_found.append({"card_id": card_id, "name": name, "rarity": rarity, "match": hist_name})
214
+ break
215
+
216
+ conn.close()
217
+ return hist_found
218
+
219
+
220
+ def check_target_prices(max_checks=50, progress=None):
221
+ """Check live market prices for flagged targets."""
222
+ conn = sqlite3.connect(DB_PATH)
223
+ c = conn.cursor()
224
+ c.execute("""
225
+ SELECT card_id, season, name, rarity, flag_reason FROM targets
226
+ ORDER BY CASE rarity WHEN 'legendary' THEN 1 WHEN 'epic' THEN 2 ELSE 3 END
227
+ LIMIT ?
228
+ """, (max_checks,))
229
+ targets = c.fetchall()
230
+
231
+ alerts = []
232
+ for i, (card_id, season, name, rarity, flag_reason) in enumerate(targets):
233
+ if progress:
234
+ progress((i + 1) / len(targets), desc=f"Price check: {name} ({i+1}/{len(targets)})")
235
+
236
+ lowest_ask = fetch_card_market(card_id, season)
237
+ now = datetime.now().isoformat()
238
+
239
+ c.execute("UPDATE targets SET lowest_ask = ?, last_checked = ? WHERE card_id = ? AND season = ?",
240
+ (lowest_ask, now, card_id, season))
241
+ conn.commit()
242
+
243
+ if lowest_ask is not None and lowest_ask > 0:
244
+ threshold = THRESHOLDS.get(rarity, 999)
245
+ if lowest_ask < threshold:
246
+ alerts.append({
247
+ "name": name, "card_id": card_id, "rarity": rarity,
248
+ "reason": flag_reason, "ask": lowest_ask, "threshold": threshold,
249
+ "url": f"https://www.nationstates.net/page=deck/card={card_id}/season={season}"
250
+ })
251
+
252
+ time.sleep(RATE_LIMIT_SECONDS)
253
+
254
+ conn.close()
255
+ return alerts
256
+
257
+
258
+ # ==========================================
259
+ # GRADIO UI
260
+ # ==========================================
261
+
262
+ def run_low_id_scan(max_id, progress=gr.Progress()):
263
+ """UI handler for Low ID scan."""
264
+ init_db()
265
+ found = scan_low_id_cards(int(max_id), progress)
266
+
267
+ if found:
268
+ df = pd.DataFrame(found)
269
+ df["URL"] = df["card_id"].apply(lambda x: f"https://www.nationstates.net/page=deck/card={x}/season={SEASON}")
270
+ df = df[["card_id", "name", "rarity", "region", "market_value", "URL"]]
271
+ df.columns = ["Card ID", "Name", "Rarity", "Region", "Market Value", "URL"]
272
+ return f"βœ… Found **{len(found)}** Legendary/Epic cards with ID ≀ {int(max_id)}", df
273
+ return "No Legendary/Epic cards found in that range.", pd.DataFrame()
274
+
275
+
276
+ def run_cte_scan(progress=gr.Progress()):
277
+ """UI handler for CTE scan."""
278
+ cte = scan_cte_cards(progress)
279
+ if cte:
280
+ df = pd.DataFrame(cte)
281
+ return f"βœ… Found **{len(cte)}** CTE (dead nation) cards", df
282
+ return "No CTE cards found (all nations still active).", pd.DataFrame()
283
+
284
+
285
+ def run_historical_scan():
286
+ """UI handler for Historical name scan."""
287
+ hist = scan_historical_cards()
288
+ if hist:
289
+ df = pd.DataFrame(hist)
290
+ return f"βœ… Found **{len(hist)}** cards matching historical names", df
291
+ return "No historical name matches found.", pd.DataFrame()
292
+
293
+
294
+ def run_price_check(max_checks, progress=gr.Progress()):
295
+ """UI handler for price check."""
296
+ alerts = check_target_prices(int(max_checks), progress)
297
+
298
+ # Get all targets with prices
299
+ conn = sqlite3.connect(DB_PATH)
300
+ c = conn.cursor()
301
+ c.execute("""
302
+ SELECT card_id, name, rarity, flag_reason, lowest_ask, last_checked, card_url
303
+ FROM targets WHERE last_checked IS NOT NULL
304
+ ORDER BY lowest_ask ASC
305
+ """)
306
+ rows = c.fetchall()
307
+ conn.close()
308
+
309
+ df = pd.DataFrame(rows, columns=["Card ID", "Name", "Rarity", "Flag Reason", "Ask Price", "Last Checked", "URL"])
310
+
311
+ alert_text = f"**Checked prices for targets.**\n\n"
312
+ if alerts:
313
+ alert_text += f"🚨 **{len(alerts)} UNDERVALUED CARDS!**\n\n"
314
+ for a in alerts:
315
+ alert_text += f"- **{a['name']}** (ID: {a['card_id']}) β€” {a['rarity'].upper()}\n"
316
+ alert_text += f" Reason: {a['reason']} | Ask: **{a['ask']:.2f}** (threshold: {a['threshold']})\n"
317
+ alert_text += f" [πŸ›’ Buy Link]({a['url']})\n\n"
318
+ else:
319
+ alert_text += "βœ… No undervalued cards below threshold."
320
+
321
+ return alert_text, df
322
+
323
+
324
+ def get_all_targets():
325
+ """Get all current targets from DB."""
326
+ init_db()
327
+ conn = sqlite3.connect(DB_PATH)
328
+ c = conn.cursor()
329
+ c.execute("""
330
+ SELECT card_id, name, rarity, flag_reason, lowest_ask, last_checked, card_url
331
+ FROM targets
332
+ ORDER BY CASE rarity WHEN 'legendary' THEN 1 WHEN 'epic' THEN 2 ELSE 3 END, card_id
333
+ """)
334
+ rows = c.fetchall()
335
+ conn.close()
336
+
337
+ if rows:
338
+ return pd.DataFrame(rows, columns=["Card ID", "Name", "Rarity", "Flag Reason", "Ask Price", "Last Checked", "URL"])
339
+ return pd.DataFrame(columns=["Card ID", "Name", "Rarity", "Flag Reason", "Ask Price", "Last Checked", "URL"])
340
+
341
+
342
+ # ==========================================
343
+ # BUILD APP
344
+ # ==========================================
345
+
346
+ with gr.Blocks(title="NS High-Value Card Tracker", theme=gr.themes.Soft()) as app:
347
+ gr.Markdown("# πŸ† NationStates High-Value Trading Card Tracker")
348
+ gr.Markdown("Identifies elite-tier undervalued cards via the NationStates API. **No automated trades** β€” manual purchase only via links.")
349
+ gr.Markdown(f"*Season {SEASON} | Rate limit: {RATE_LIMIT_SECONDS}s/request | Compliance: Read-only*")
350
+
351
+ with gr.Tab("πŸ” Low ID Scan"):
352
+ gr.Markdown("Scan cards by ID range to find Legendary/Epic cards with low IDs (rare & valuable).")
353
+ max_id_input = gr.Number(label="Scan cards 1 to:", value=100, minimum=1, maximum=5000, precision=0)
354
+ gr.Markdown("*⏱️ Estimated time: ~1.2 seconds per card ID*")
355
+ scan_btn = gr.Button("πŸ” Start Low ID Scan", variant="primary")
356
+ scan_status = gr.Markdown("")
357
+ scan_results = gr.Dataframe(label="Found Cards", interactive=False)
358
+ scan_btn.click(fn=run_low_id_scan, inputs=[max_id_input], outputs=[scan_status, scan_results])
359
+
360
+ with gr.Tab("πŸ’€ CTE Scan"):
361
+ gr.Markdown("Check if flagged cards belong to dead/deleted nations (Ceased to Exist). These are scarcer and more valuable.")
362
+ gr.Markdown("*Requires Low ID scan to be run first to populate the card database.*")
363
+ cte_btn = gr.Button("πŸ’€ Check for CTE Cards", variant="primary")
364
+ cte_status = gr.Markdown("")
365
+ cte_results = gr.Dataframe(label="CTE Cards", interactive=False)
366
+ cte_btn.click(fn=run_cte_scan, outputs=[cte_status, cte_results])
367
+
368
+ with gr.Tab("πŸ“œ Historical Names"):
369
+ gr.Markdown("Find cards matching famous NationStates names (regions, moderators, historic players).")
370
+ gr.Markdown(f"*Monitoring {len(HISTORICAL_NAMES)} names: {', '.join(HISTORICAL_NAMES[:10])}...*")
371
+ hist_btn = gr.Button("πŸ“œ Scan Historical Names", variant="primary")
372
+ hist_status = gr.Markdown("")
373
+ hist_results = gr.Dataframe(label="Historical Cards", interactive=False)
374
+ hist_btn.click(fn=run_historical_scan, outputs=[hist_status, hist_results])
375
+
376
+ with gr.Tab("πŸ’° Price Check"):
377
+ gr.Markdown("Check live market prices for all flagged targets. Alerts if price is below threshold.")
378
+ gr.Markdown(f"*Thresholds: Legendary < {THRESHOLDS['legendary']}, Epic < {THRESHOLDS['epic']}, Ultra-Rare < {THRESHOLDS['ultra-rare']}*")
379
+ max_price_input = gr.Number(label="Max cards to check:", value=30, minimum=1, maximum=200, precision=0)
380
+ price_btn = gr.Button("πŸ’° Check Live Prices", variant="primary")
381
+ price_alerts = gr.Markdown("")
382
+ price_table = gr.Dataframe(label="Targets with Prices", interactive=False)
383
+ price_btn.click(fn=run_price_check, inputs=[max_price_input], outputs=[price_alerts, price_table])
384
+
385
+ with gr.Tab("πŸ“‹ All Targets"):
386
+ gr.Markdown("View all currently flagged high-value targets.")
387
+ refresh_btn = gr.Button("πŸ”„ Refresh")
388
+ targets_table = gr.Dataframe(label="All Targets", interactive=False)
389
+ refresh_btn.click(fn=get_all_targets, outputs=[targets_table])
390
+
391
+ with gr.Tab("βš™οΈ Config"):
392
+ gr.Markdown(f"""
393
+ ### Settings
394
+ - **Season:** {SEASON}
395
+ - **Rate Limit:** {RATE_LIMIT_SECONDS}s between API requests
396
+ - **User-Agent:** `{USER_AGENT}`
397
+
398
+ ### Price Alert Thresholds
399
+ | Rarity | Alert if Ask Below |
400
+ |---|---|
401
+ | Legendary | {THRESHOLDS['legendary']} |
402
+ | Epic | {THRESHOLDS['epic']} |
403
+ | Ultra-Rare | {THRESHOLDS['ultra-rare']} |
404
+
405
+ ### Historical Names ({len(HISTORICAL_NAMES)} total)
406
+ {', '.join(HISTORICAL_NAMES)}
407
+
408
+ ### Compliance
409
+ ⚠️ This tool is **read-only**. No automated trades, buys, or bids are executed.
410
+ All purchases must be completed manually via the generated URL links.
411
+ """)
412
+
413
+ app.launch()
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ gradio==5.29.0
2
+ requests
3
+ pandas
tracker.py ADDED
@@ -0,0 +1,591 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ NationStates High-Value Trading Card Tracker
3
+ =============================================
4
+ Fully local script that:
5
+ 1. Downloads daily card & nation XML dumps
6
+ 2. Parses and stores in SQLite
7
+ 3. Flags high-value targets (Low ID, CTE, Historical Names)
8
+ 4. Checks live market prices (rate limited)
9
+ 5. Generates an HTML dashboard with results
10
+
11
+ COMPLIANCE: This script NEVER executes automated trades.
12
+ All purchases are manual via generated URL links.
13
+
14
+ Usage: python tracker.py
15
+ """
16
+
17
+ import requests
18
+ import gzip
19
+ import sqlite3
20
+ import time
21
+ import os
22
+ import sys
23
+ import xml.etree.ElementTree as ET
24
+ from io import BytesIO
25
+ from datetime import datetime
26
+
27
+ # ==========================================
28
+ # CONFIGURATION
29
+ # ==========================================
30
+ USER_AGENT = "HighValueCardTracker (Operational puppet managed by thunderpuppetempires1)"
31
+ CARDS_DUMP_URL = "https://www.nationstates.net/pages/cards_s3.xml.gz"
32
+ NATIONS_DUMP_URL = "https://www.nationstates.net/pages/nations.xml.gz"
33
+ API_BASE = "https://www.nationstates.net/cgi-bin/api.cgi"
34
+ DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cards.db")
35
+ DASHBOARD_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dashboard.html")
36
+ RATE_LIMIT_SECONDS = 1.2
37
+ SEASON = 3
38
+ MAX_PRICE_CHECKS = 50 # how many targets to check live prices for
39
+
40
+ # Price thresholds β€” alert if ask is below these values
41
+ THRESHOLDS = {
42
+ "legendary": 20.0,
43
+ "epic": 5.0,
44
+ "ultra-rare": 1.0,
45
+ }
46
+
47
+ # Historical/famous names in NationStates
48
+ HISTORICAL_NAMES = [
49
+ "the pacific", "the north pacific", "the south pacific",
50
+ "the east pacific", "the west pacific", "the rejected realms",
51
+ "lazarus", "balder", "osiris", "the black hawks",
52
+ "the grey wardens", "10000 islands", "europe",
53
+ "max barry", "eluvatar", "sedgistan", "violet",
54
+ "the free nations region", "the communist bloc",
55
+ "nationstates", "testlandia", "maxtopia",
56
+ "francos spain", "gatesville", "the pacific army",
57
+ "jennifer government", "the north pacific army",
58
+ "feeder", "raider", "defender",
59
+ ]
60
+
61
+
62
+ # ==========================================
63
+ # DATABASE
64
+ # ==========================================
65
+
66
+ def init_db():
67
+ """Initialize SQLite database with schema."""
68
+ conn = sqlite3.connect(DB_PATH)
69
+ c = conn.cursor()
70
+ c.execute("""
71
+ CREATE TABLE IF NOT EXISTS cards (
72
+ card_id INTEGER,
73
+ name TEXT,
74
+ rarity TEXT,
75
+ region TEXT,
76
+ season INTEGER,
77
+ PRIMARY KEY (card_id, season)
78
+ )
79
+ """)
80
+ c.execute("""
81
+ CREATE TABLE IF NOT EXISTS active_nations (
82
+ name TEXT PRIMARY KEY
83
+ )
84
+ """)
85
+ c.execute("""
86
+ CREATE TABLE IF NOT EXISTS targets (
87
+ card_id INTEGER,
88
+ season INTEGER,
89
+ name TEXT,
90
+ rarity TEXT,
91
+ flag_reason TEXT,
92
+ last_checked TEXT,
93
+ last_ask REAL,
94
+ card_url TEXT,
95
+ PRIMARY KEY (card_id, season)
96
+ )
97
+ """)
98
+ conn.commit()
99
+ conn.close()
100
+ print("[DB] Database initialized.")
101
+
102
+
103
+ # ==========================================
104
+ # DATA INGESTION
105
+ # ==========================================
106
+
107
+ def download_and_parse_cards():
108
+ """Parse the cards XML dump from local file."""
109
+ local_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cards_s3.xml.gz")
110
+
111
+ if not os.path.exists(local_file):
112
+ print(f"[ERROR] Cards dump not found at: {local_file}")
113
+ print(f" Please download it manually from: {CARDS_DUMP_URL}")
114
+ print(f" Save it as: {local_file}")
115
+ sys.exit(1)
116
+
117
+ print(f"[INGEST] Reading local cards dump: {local_file}")
118
+ conn = sqlite3.connect(DB_PATH)
119
+ c = conn.cursor()
120
+ c.execute("DELETE FROM cards")
121
+ conn.commit()
122
+
123
+ with gzip.open(local_file, "rb") as f:
124
+ raw_data = f.read()
125
+
126
+ print("[INGEST] Parsing cards XML...")
127
+ count = 0
128
+ batch = []
129
+ context = ET.iterparse(BytesIO(raw_data), events=("end",))
130
+
131
+ for event, elem in context:
132
+ if elem.tag == "CARD":
133
+ card_id = elem.findtext("ID", "")
134
+ name = elem.findtext("NAME", "")
135
+ rarity = elem.findtext("CATEGORY", "")
136
+ region = elem.findtext("REGION", "")
137
+ season_val = elem.findtext("SEASON", str(SEASON))
138
+
139
+ if card_id and rarity:
140
+ batch.append((int(card_id), name.lower(), rarity.lower(), region.lower() if region else "", int(season_val)))
141
+ count += 1
142
+
143
+ if len(batch) >= 5000:
144
+ c.executemany("INSERT OR REPLACE INTO cards VALUES (?, ?, ?, ?, ?)", batch)
145
+ conn.commit()
146
+ batch = []
147
+ if count % 50000 == 0:
148
+ print(f" ... {count:,} cards parsed")
149
+
150
+ elem.clear()
151
+
152
+ if batch:
153
+ c.executemany("INSERT OR REPLACE INTO cards VALUES (?, ?, ?, ?, ?)", batch)
154
+ conn.commit()
155
+
156
+ conn.close()
157
+ print(f"[INGEST] Cards done: {count:,} cards stored.")
158
+ return count
159
+
160
+
161
+ def download_and_parse_nations():
162
+ """Download or read the active nations XML dump."""
163
+ local_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "nations.xml.gz")
164
+
165
+ # Try downloading first (this works from residential IPs)
166
+ if not os.path.exists(local_file):
167
+ print("[INGEST] Downloading nations dump...")
168
+ headers = {"User-Agent": USER_AGENT}
169
+ try:
170
+ resp = requests.get(NATIONS_DUMP_URL, headers=headers, timeout=300)
171
+ resp.raise_for_status()
172
+ with open(local_file, "wb") as f:
173
+ f.write(resp.content)
174
+ print(f"[INGEST] Downloaded nations dump ({len(resp.content) / 1024 / 1024:.1f} MB)")
175
+ except Exception as e:
176
+ print(f"[ERROR] Nations dump not found and download failed: {e}")
177
+ print(f" Please download manually from: {NATIONS_DUMP_URL}")
178
+ print(f" Save as: {local_file}")
179
+ sys.exit(1)
180
+ else:
181
+ print(f"[INGEST] Using local nations dump: {local_file}")
182
+
183
+ conn = sqlite3.connect(DB_PATH)
184
+ c = conn.cursor()
185
+ c.execute("DELETE FROM active_nations")
186
+ conn.commit()
187
+
188
+ with gzip.open(local_file, "rb") as f:
189
+ raw_data = f.read()
190
+
191
+ print("[INGEST] Parsing nations XML...")
192
+ count = 0
193
+ batch = []
194
+ context = ET.iterparse(BytesIO(raw_data), events=("end",))
195
+
196
+ for event, elem in context:
197
+ if elem.tag == "NATION":
198
+ name = elem.findtext("NAME", "")
199
+ if name:
200
+ batch.append((name.lower(),))
201
+ count += 1
202
+
203
+ if len(batch) >= 5000:
204
+ c.executemany("INSERT OR REPLACE INTO active_nations VALUES (?)", batch)
205
+ conn.commit()
206
+ batch = []
207
+ if count % 50000 == 0:
208
+ print(f" ... {count:,} nations parsed")
209
+
210
+ elem.clear()
211
+
212
+ if batch:
213
+ c.executemany("INSERT OR REPLACE INTO active_nations VALUES (?)", batch)
214
+ conn.commit()
215
+
216
+ conn.close()
217
+ print(f"[INGEST] Nations done: {count:,} active nations stored.")
218
+ return count
219
+
220
+
221
+ # ==========================================
222
+ # VALUATION ENGINE
223
+ # ==========================================
224
+
225
+ def run_valuation():
226
+ """Apply all three filters and populate targets table."""
227
+ print("[VALUATION] Running filters...")
228
+ conn = sqlite3.connect(DB_PATH)
229
+ c = conn.cursor()
230
+ c.execute("DELETE FROM targets")
231
+ conn.commit()
232
+
233
+ targets = []
234
+
235
+ # Filter 1: Low ID (Legendary/Epic with ID <= 1000)
236
+ c.execute("""
237
+ SELECT card_id, name, rarity, season FROM cards
238
+ WHERE rarity IN ('legendary', 'epic') AND card_id <= 1000
239
+ """)
240
+ for row in c.fetchall():
241
+ card_id, name, rarity, season = row
242
+ url = f"https://www.nationstates.net/page=deck/card={card_id}/season={season}"
243
+ targets.append((card_id, season, name, rarity, "Low ID (<=1000)", None, None, url))
244
+ print(f" Filter 1 (Low ID): {len(targets)} cards")
245
+
246
+ # Filter 2: CTE (Legendary/Epic whose nation is NOT in active nations)
247
+ cte_count = 0
248
+ c.execute("""
249
+ SELECT c.card_id, c.name, c.rarity, c.season FROM cards c
250
+ LEFT JOIN active_nations a ON c.name = a.name
251
+ WHERE c.rarity IN ('legendary', 'epic') AND a.name IS NULL
252
+ """)
253
+ for row in c.fetchall():
254
+ card_id, name, rarity, season = row
255
+ url = f"https://www.nationstates.net/page=deck/card={card_id}/season={season}"
256
+ if not any(t[0] == card_id and t[1] == season for t in targets):
257
+ targets.append((card_id, season, name, rarity, "CTE (Dead Nation)", None, None, url))
258
+ cte_count += 1
259
+ print(f" Filter 2 (CTE): {cte_count} cards")
260
+
261
+ # Filter 3: Historical Name match
262
+ hist_count = 0
263
+ c.execute("""
264
+ SELECT card_id, name, rarity, season FROM cards
265
+ WHERE rarity IN ('legendary', 'epic', 'ultra-rare')
266
+ """)
267
+ for row in c.fetchall():
268
+ card_id, name, rarity, season = row
269
+ for hist_name in HISTORICAL_NAMES:
270
+ if hist_name in name:
271
+ url = f"https://www.nationstates.net/page=deck/card={card_id}/season={season}"
272
+ if not any(t[0] == card_id and t[1] == season for t in targets):
273
+ targets.append((card_id, season, name, rarity, f"Historical ({hist_name})", None, None, url))
274
+ hist_count += 1
275
+ break
276
+ print(f" Filter 3 (Historical): {hist_count} cards")
277
+
278
+ # Insert all targets
279
+ c.executemany("INSERT OR REPLACE INTO targets VALUES (?, ?, ?, ?, ?, ?, ?, ?)", targets)
280
+ conn.commit()
281
+ conn.close()
282
+
283
+ print(f"[VALUATION] Total targets flagged: {len(targets)}")
284
+ return len(targets)
285
+
286
+
287
+ # ==========================================
288
+ # LIVE PRICE CHECK
289
+ # ==========================================
290
+
291
+ def check_live_prices():
292
+ """Query live API for target cards and update prices."""
293
+ print(f"[MARKET] Checking live prices for top {MAX_PRICE_CHECKS} targets...")
294
+ conn = sqlite3.connect(DB_PATH)
295
+ c = conn.cursor()
296
+
297
+ # Prioritize: legendary first, then epic, then by card_id
298
+ c.execute("""
299
+ SELECT card_id, season, name, rarity, flag_reason FROM targets
300
+ ORDER BY
301
+ CASE rarity
302
+ WHEN 'legendary' THEN 1
303
+ WHEN 'epic' THEN 2
304
+ WHEN 'ultra-rare' THEN 3
305
+ ELSE 4
306
+ END,
307
+ card_id ASC
308
+ LIMIT ?
309
+ """, (MAX_PRICE_CHECKS,))
310
+ targets = c.fetchall()
311
+
312
+ headers = {"User-Agent": USER_AGENT}
313
+ updated = 0
314
+ alerts = []
315
+
316
+ for i, (card_id, season, name, rarity, flag_reason) in enumerate(targets):
317
+ print(f" [{i+1}/{len(targets)}] Checking {name} (ID:{card_id}, {rarity})...", end=" ")
318
+
319
+ url = f"{API_BASE}?q=card+markets;cardid={card_id};season={season}"
320
+ try:
321
+ resp = requests.get(url, headers=headers, timeout=10)
322
+ if resp.status_code == 200:
323
+ lowest_ask = parse_lowest_ask(resp.text)
324
+ now = datetime.now().isoformat()
325
+
326
+ c.execute("""
327
+ UPDATE targets SET last_ask = ?, last_checked = ?
328
+ WHERE card_id = ? AND season = ?
329
+ """, (lowest_ask, now, card_id, season))
330
+ conn.commit()
331
+ updated += 1
332
+
333
+ if lowest_ask is not None:
334
+ print(f"Ask: {lowest_ask:.2f}")
335
+ threshold = THRESHOLDS.get(rarity, 999)
336
+ if lowest_ask > 0 and lowest_ask < threshold:
337
+ card_url = f"https://www.nationstates.net/page=deck/card={card_id}/season={season}"
338
+ alerts.append({
339
+ "name": name,
340
+ "card_id": card_id,
341
+ "rarity": rarity,
342
+ "reason": flag_reason,
343
+ "ask": lowest_ask,
344
+ "threshold": threshold,
345
+ "url": card_url
346
+ })
347
+ else:
348
+ print("No asks")
349
+ else:
350
+ print(f"HTTP {resp.status_code}")
351
+ except Exception as e:
352
+ print(f"Error: {e}")
353
+
354
+ time.sleep(RATE_LIMIT_SECONDS)
355
+
356
+ conn.close()
357
+
358
+ print(f"\n[MARKET] Checked {updated} cards.")
359
+ if alerts:
360
+ print(f"🚨 {len(alerts)} UNDERVALUED CARDS DETECTED!")
361
+ for a in alerts:
362
+ print(f" - {a['name']} (ID:{a['card_id']}) {a['rarity'].upper()} β€” Ask: {a['ask']:.2f} < {a['threshold']}")
363
+ print(f" {a['url']}")
364
+ else:
365
+ print(" No undervalued cards found below threshold.")
366
+
367
+ return alerts
368
+
369
+
370
+ def parse_lowest_ask(xml_text):
371
+ """Extract the lowest ask price from market XML."""
372
+ try:
373
+ root = ET.fromstring(xml_text)
374
+ markets = root.find(".//MARKETS")
375
+ if markets is None:
376
+ return None
377
+
378
+ lowest = None
379
+ for market in markets.findall("MARKET"):
380
+ market_type = market.findtext("TYPE", "")
381
+ if market_type == "ask":
382
+ price = float(market.findtext("PRICE", "0") or "0")
383
+ if lowest is None or price < lowest:
384
+ lowest = price
385
+
386
+ return lowest
387
+ except (ET.ParseError, ValueError):
388
+ return None
389
+
390
+
391
+ # ==========================================
392
+ # HTML DASHBOARD GENERATOR
393
+ # ==========================================
394
+
395
+ def generate_dashboard(alerts):
396
+ """Generate a self-contained HTML dashboard."""
397
+ print("[DASHBOARD] Generating HTML report...")
398
+
399
+ conn = sqlite3.connect(DB_PATH)
400
+ c = conn.cursor()
401
+
402
+ # Get all targets with prices
403
+ c.execute("""
404
+ SELECT card_id, name, rarity, flag_reason, last_ask, last_checked, card_url
405
+ FROM targets
406
+ ORDER BY
407
+ CASE rarity
408
+ WHEN 'legendary' THEN 1
409
+ WHEN 'epic' THEN 2
410
+ WHEN 'ultra-rare' THEN 3
411
+ ELSE 4
412
+ END,
413
+ last_ask ASC
414
+ """)
415
+ all_targets = c.fetchall()
416
+
417
+ # Stats
418
+ c.execute("SELECT COUNT(*) FROM cards")
419
+ total_cards = c.fetchone()[0]
420
+ c.execute("SELECT COUNT(*) FROM active_nations")
421
+ total_nations = c.fetchone()[0]
422
+ c.execute("SELECT COUNT(*) FROM targets")
423
+ total_targets = c.fetchone()[0]
424
+
425
+ conn.close()
426
+
427
+ # Build HTML
428
+ now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
429
+
430
+ rarity_colors = {
431
+ "legendary": "#FFD700",
432
+ "epic": "#FF9800",
433
+ "ultra-rare": "#9C27B0",
434
+ "rare": "#2196F3",
435
+ "uncommon": "#4CAF50",
436
+ "common": "#8B8B8B",
437
+ }
438
+
439
+ # Alerts section
440
+ alerts_html = ""
441
+ if alerts:
442
+ alerts_html = f'<div class="alert-box"><h2>🚨 {len(alerts)} Undervalued Cards Detected!</h2>'
443
+ for a in alerts:
444
+ color = rarity_colors.get(a["rarity"], "#fff")
445
+ alerts_html += f'''
446
+ <div class="alert-card">
447
+ <span class="rarity" style="color:{color}">{a["rarity"].upper()}</span>
448
+ <strong>{a["name"]}</strong> (ID: {a["card_id"]})
449
+ <br>Reason: {a["reason"]}
450
+ <br>Ask: <strong>{a["ask"]:.2f}</strong> (threshold: {a["threshold"]})
451
+ <br><a href="{a["url"]}" target="_blank" class="buy-link">πŸ›’ View / Buy</a>
452
+ </div>'''
453
+ alerts_html += "</div>"
454
+ else:
455
+ alerts_html = '<div class="no-alert">βœ… No undervalued cards below threshold at this time.</div>'
456
+
457
+ # Targets table
458
+ table_rows = ""
459
+ for row in all_targets:
460
+ card_id, name, rarity, flag_reason, last_ask, last_checked, card_url = row
461
+ color = rarity_colors.get(rarity, "#fff")
462
+ ask_display = f"{last_ask:.2f}" if last_ask is not None else "β€”"
463
+ checked_display = last_checked[:16] if last_checked else "Not checked"
464
+ table_rows += f"""
465
+ <tr>
466
+ <td>{card_id}</td>
467
+ <td>{name}</td>
468
+ <td style="color:{color}; font-weight:bold">{rarity}</td>
469
+ <td>{flag_reason}</td>
470
+ <td>{ask_display}</td>
471
+ <td>{checked_display}</td>
472
+ <td><a href="{card_url}" target="_blank">View</a></td>
473
+ </tr>"""
474
+
475
+ html = f"""<!DOCTYPE html>
476
+ <html lang="en">
477
+ <head>
478
+ <meta charset="UTF-8">
479
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
480
+ <title>NS High-Value Card Tracker</title>
481
+ <style>
482
+ * {{ margin: 0; padding: 0; box-sizing: border-box; }}
483
+ body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #1a1a2e; color: #eee; padding: 20px; }}
484
+ h1 {{ color: #FFD700; margin-bottom: 5px; }}
485
+ .subtitle {{ color: #aaa; margin-bottom: 20px; }}
486
+ .stats {{ display: flex; gap: 20px; margin-bottom: 20px; flex-wrap: wrap; }}
487
+ .stat-box {{ background: #16213e; padding: 15px 25px; border-radius: 8px; text-align: center; }}
488
+ .stat-box .number {{ font-size: 24px; font-weight: bold; color: #4fc3f7; }}
489
+ .stat-box .label {{ font-size: 12px; color: #aaa; }}
490
+ .alert-box {{ background: #2d1b00; border: 2px solid #FF9800; border-radius: 8px; padding: 15px; margin-bottom: 20px; }}
491
+ .alert-card {{ background: #1a1a2e; padding: 10px; margin: 8px 0; border-radius: 6px; border-left: 4px solid #FF9800; }}
492
+ .no-alert {{ background: #0d2818; border: 2px solid #4CAF50; border-radius: 8px; padding: 15px; margin-bottom: 20px; }}
493
+ .buy-link {{ color: #4fc3f7; text-decoration: none; font-weight: bold; }}
494
+ .buy-link:hover {{ text-decoration: underline; }}
495
+ .rarity {{ font-size: 11px; text-transform: uppercase; letter-spacing: 1px; }}
496
+ table {{ width: 100%; border-collapse: collapse; margin-top: 10px; }}
497
+ th {{ background: #16213e; padding: 10px; text-align: left; font-size: 12px; color: #aaa; text-transform: uppercase; }}
498
+ td {{ padding: 8px 10px; border-bottom: 1px solid #2a2a4a; font-size: 13px; }}
499
+ tr:hover {{ background: #16213e; }}
500
+ a {{ color: #4fc3f7; }}
501
+ .compliance {{ background: #1a1a2e; border: 1px solid #444; border-radius: 6px; padding: 10px; margin-top: 20px; font-size: 11px; color: #888; }}
502
+ </style>
503
+ </head>
504
+ <body>
505
+ <h1>πŸ† NationStates High-Value Card Tracker</h1>
506
+ <p class="subtitle">Generated: {now} | Season {SEASON} | Compliance: Read-only, no automated trades</p>
507
+
508
+ <div class="stats">
509
+ <div class="stat-box"><div class="number">{total_cards:,}</div><div class="label">Cards in DB</div></div>
510
+ <div class="stat-box"><div class="number">{total_nations:,}</div><div class="label">Active Nations</div></div>
511
+ <div class="stat-box"><div class="number">{total_targets}</div><div class="label">Targets Flagged</div></div>
512
+ <div class="stat-box"><div class="number">{len(alerts)}</div><div class="label">Undervalued</div></div>
513
+ </div>
514
+
515
+ {alerts_html}
516
+
517
+ <h2>πŸ“‹ All Flagged Targets</h2>
518
+ <table>
519
+ <thead>
520
+ <tr><th>ID</th><th>Name</th><th>Rarity</th><th>Flag Reason</th><th>Ask</th><th>Checked</th><th>Link</th></tr>
521
+ </thead>
522
+ <tbody>
523
+ {table_rows}
524
+ </tbody>
525
+ </table>
526
+
527
+ <div class="compliance">
528
+ ⚠️ This tool is purely a data analysis and notification tool. No automated market trades, buys, or bids are executed.
529
+ All transactions must be completed manually by clicking the URL links above.
530
+ Rate limit: {RATE_LIMIT_SECONDS}s between API requests. User-Agent: {USER_AGENT}
531
+ </div>
532
+ </body>
533
+ </html>"""
534
+
535
+ with open(DASHBOARD_PATH, "w", encoding="utf-8") as f:
536
+ f.write(html)
537
+
538
+ print(f"[DASHBOARD] Saved to: {DASHBOARD_PATH}")
539
+
540
+
541
+ # ==========================================
542
+ # MAIN
543
+ # ==========================================
544
+
545
+ def main():
546
+ print("=" * 60)
547
+ print(" NationStates High-Value Trading Card Tracker")
548
+ print(" Compliance: READ-ONLY β€” No automated trades")
549
+ print("=" * 60)
550
+ print()
551
+
552
+ start_time = time.time()
553
+
554
+ # Step 1: Initialize DB
555
+ init_db()
556
+
557
+ # Step 2: Download and parse dumps
558
+ print()
559
+ try:
560
+ download_and_parse_cards()
561
+ except Exception as e:
562
+ print(f"[ERROR] Failed to download cards: {e}")
563
+ sys.exit(1)
564
+
565
+ print()
566
+ try:
567
+ download_and_parse_nations()
568
+ except Exception as e:
569
+ print(f"[ERROR] Failed to download nations: {e}")
570
+ sys.exit(1)
571
+
572
+ # Step 3: Run valuation
573
+ print()
574
+ run_valuation()
575
+
576
+ # Step 4: Check live prices
577
+ print()
578
+ alerts = check_live_prices()
579
+
580
+ # Step 5: Generate dashboard
581
+ print()
582
+ generate_dashboard(alerts)
583
+
584
+ elapsed = time.time() - start_time
585
+ print(f"\n{'=' * 60}")
586
+ print(f" Done in {elapsed:.1f}s. Open dashboard.html to view results.")
587
+ print(f"{'=' * 60}")
588
+
589
+
590
+ if __name__ == "__main__":
591
+ main()