VoxOfTheMists is a local full-stack analytics app for Guild Wars 2 WvW kills tracking (GW2Mists leaderboard data).
It snapshots leaderboard data into SQLite, then provides an interactive dashboard for:
- leaderboard tracking,
- progression analysis,
- account comparison,
- momentum/anomaly detection,
- and automated scheduled refresh.
scraper/: Python 3.10+ (requests)server.js: Node.js 22+ + Fastifydata/vox.db: SQLite (source of truth)src/: React + Vite + Chart.js
- Scraper calls GW2Mists API and stores snapshot rows.
- Backend serves validated/cached endpoints from SQLite.
- Frontend reads
/api/*, renders charts/tables, and supports exports/filters. - Optional hourly automation runs snapshots and invalidates API cache.
- Optional Appwrite sync can import remote snapshots into local SQLite.
scraper/scrape_gw2mists.py: data ingestion (manual or watch mode)server.js: API routes, caching, scheduled scraper, retention/vacuum maintenancesrc/App.jsx: dashboard logic, filters, charts, controls, exportssrc/styles.css: UI theme/style systemdata/: runtime DB and optional snapshot JSON backup
- Scrapes
Top 300by default (3 pages x 100). - Supports regions:
eu,na. - Persists snapshots in SQLite.
- Optional JSON backup export.
- Leaderboard: latest ranking table with search + CSV export.
- Top Movers: diff between latest snapshot and immediately previous snapshot in the selected scope.
- Anomaly Alerts: highlights unusual latest delta vs recent baseline.
- Top Progression: multi-series time chart for latest top players.
- Compare Accounts: selected players with baseline modes:
RawDelta from startIndexed (100 at start)
- Select zoom drag
- Pan mode
- Wheel zoom toggle
- Presets + reset
- Brush (range sliders)
- PNG export
- Timezone selector (persisted)
- Theme toggle (persisted)
Hide anonymizedtoggle (persisted, global)
- Auto-snapshot runs hourly.
- Snapshot status endpoint.
- Retention cleanup + optional SQLite vacuum.
pip install -r requirements.txt
npm installCreate local env from template:
cp .env.example .envWindows PowerShell:
Copy-Item .env.example .envThe server auto-loads .env at startup using dotenv.
npm run dev- Frontend (Vite):
http://127.0.0.1:5173 - API (Fastify):
http://127.0.0.1:3000
npm startOpen: http://127.0.0.1:3000
npm start automatically builds frontend first via prestart.
python scraper/scrape_gw2mists.py --pages 3 --per-page 100 --region eu --no-jsonCommon options:
- remove
--no-jsonto write JSON backups, --region nafor NA,--db-path data/vox.dbto use another DB path.
All supported server variables are listed in .env.example.
PORT=3000: API/web portHOST=0.0.0.0: bind host for the HTTP server (use0.0.0.0in containers/Coolify)WRITE_API_TOKEN=: required token for local write routes (x-admin-tokenheader). If empty, server generates one at startup (recommended to set explicitly in.env)TRUSTED_LOCAL_ORIGINS=http://127.0.0.1:3000,http://localhost:3000,http://127.0.0.1:5173,http://localhost:5173: allowed browser Origin/Referer for local write/read protected routesREMOTE_ADMIN_TRUSTED_ORIGIN_ENABLED=0: set to1only when you need write/auth routes from trusted non-loopback origins behind a reverse proxy (Coolify)AUTO_SCRAPE=1: enable hourly auto-snapshot (0disables)PYTHON_CMD=python: python executable used by server snapshot processAPPWRITE_SYNC_ENABLED=0: enable Appwrite -> local SQLite sync (1enables)APPWRITE_ENDPOINT=https://cloud.appwrite.io: your Appwrite endpointAPPWRITE_PROJECT_ID=: Appwrite project idAPPWRITE_API_KEY=: Appwrite server API keyAPPWRITE_DATABASE_ID=: Appwrite database idAPPWRITE_SNAPSHOTS_COLLECTION_ID=: collection for snapshot metadataAPPWRITE_ENTRIES_COLLECTION_ID=: collection for snapshot rowsAPPWRITE_SYNC_INTERVAL_MINUTES=60: local pull interval (used if hourly aligned sync is disabled)APPWRITE_SYNC_HOURLY_ALIGNED=1: run one sync per hour aligned to a fixed UTC minuteAPPWRITE_SYNC_TARGET_MINUTE=12: UTC minute used by aligned hourly syncAPPWRITE_SYNC_ENTRY_BATCH_SIZE=20: snapshot IDs grouped per entries fetch during Appwrite importAPPWRITE_SYNC_STARTUP_MIN_STALE_MINUTES=50: skip startup sync when local snapshot is recent (0disables skip)APPWRITE_BACKFILL_ENABLED=0: optional server-side function trigger guard (disabled by default)APPWRITE_BACKFILL_TARGET_MINUTE=30: UTC minute used by backfill guardAPPWRITE_FUNCTION_ID=: Appwrite Function ID used by backfill guard executionAPI_CACHE_MAX_ENTRIES=1000: max in-memory API cache entries before pruningRETENTION_DAYS=0: keep all snapshots forever (>0enables age-based cleanup)AUTO_VACUUM=1: enable SQLite vacuum flow (0disables)VACUUM_MIN_HOURS=24: minimum delay between vacuum runs
Share/Webhook notes:
- Discord webhook URLs are only used at request time via local write endpoint relay (
POST /api/share/discord). - The webhook URL is not persisted in server env; client-side persistence is browser-local.
- Share relay routes remain protected by trusted origin +
x-admin-tokenand are loopback-only unlessREMOTE_ADMIN_TRUSTED_ORIGIN_ENABLED=1.
Use this mode if you want hourly snapshots to continue while your local machine is offline.
- Appwrite Function runs hourly and writes snapshots to Appwrite.
- Local server sync pulls Appwrite snapshots into local SQLite.
- Dashboard keeps using local SQLite for analytics/charts.
Create one database with:
-
Collection
snapshotssnapshotId(string, indexed)createdAt(string, indexed)source(string)region(string)pages(integer)perPage(integer)totalAvailable(integer)count(integer)
-
Collection
entriessnapshotId(string, indexed)rank(integer, indexed)accountName(string, indexed)weeklyKills(integer)totalKills(integer)wvwGuildName(string, optional)wvwGuildTag(string, optional, indexed)allianceGuildName(string, optional)allianceGuildTag(string, optional, indexed)
Set local env vars:
APPWRITE_SYNC_ENABLED=1
APPWRITE_ENDPOINT=
APPWRITE_PROJECT_ID=
APPWRITE_API_KEY=
APPWRITE_DATABASE_ID=
APPWRITE_SNAPSHOTS_COLLECTION_ID=
APPWRITE_ENTRIES_COLLECTION_ID=
APPWRITE_SYNC_INTERVAL_MINUTES=60
APPWRITE_SYNC_HOURLY_ALIGNED=1
APPWRITE_SYNC_TARGET_MINUTE=12
APPWRITE_SYNC_ENTRY_BATCH_SIZE=20
APPWRITE_SYNC_STARTUP_MIN_STALE_MINUTES=50
API_CACHE_MAX_ENTRIES=1000Notes:
- Local scraping is automatically disabled when
APPWRITE_SYNC_ENABLED=1. - Keep
APPWRITE_SYNC_HOURLY_ALIGNED=1to minimize Appwrite requests. - In this mode, local
POST /api/snapshot/runis intentionally blocked. - Optional:
POST /api/sync/run(loopback only) can trigger an immediate Appwrite pull. - In Appwrite mode, manual snapshot actions are disabled in the UI.
Use appwrite-function.env.example as template. Function variables:
APPWRITE_ENDPOINT=
APPWRITE_PROJECT_ID=
APPWRITE_API_KEY=
APPWRITE_DATABASE_ID=
APPWRITE_SNAPSHOTS_COLLECTION_ID=
APPWRITE_ENTRIES_COLLECTION_ID=
GW2MISTS_REGION=eu
GW2MISTS_PAGES=3
GW2MISTS_PER_PAGE=100
DEDUPE_HOURLY=1
SNAPSHOT_TIMEZONE=UTC
RESET_POLICY_ENABLED=0
RESET_WEEKDAY=4
RESET_HOUR_LOCAL=19
PRE_RESET_OFFSET_MINUTES=15
PRE_RESET_WINDOW_START_MINUTES=20
POST_RESET_SKIP_HOURS=2
MANUAL_OVERRIDE_TOKEN=
APPWRITE_WRITE_CONCURRENCY=6Recommended:
- Schedule:
*/15 * * * *(function checks hourly slot and only writes if missing) - Runtime: Python 3.12
- Function timeout: 120s
- Execute access: no public role
Optional GW2 reset policy (recommended for EU reset behavior):
SNAPSHOT_TIMEZONE=Europe/BrusselsRESET_POLICY_ENABLED=1RESET_WEEKDAY=4RESET_HOUR_LOCAL=19PRE_RESET_OFFSET_MINUTES=15(captures18:45)PRE_RESET_WINDOW_START_MINUTES=20(runs from18:40..18:59map to18:45)POST_RESET_SKIP_HOURS=2(skips19:00and20:00, resumes21:00)
Manual one-off override (without changing function env flags):
- Set
MANUAL_OVERRIDE_TOKENin function variables. - Trigger execution with a payload body like:
{
"overrideToken": "your-secret-token",
"forceBypassDedupe": false,
"forceCaptureTimeUtc": "2026-02-18T17:00:00+00:00"
}forceCaptureTimeUtc is optional; if set, that exact UTC slot is used for the run.
Before deploying the updated function, run the one-time Appwrite entries schema migration:
python appwrite-function/migrate_entries_schema.pyNotes:
- Script uses
APPWRITE_*env vars and updates theentriescollection only. - In GW2Mists payload mapping,
selectedGuild*is stored aswvwGuild*, andguild*is stored asallianceGuild*.
Create backup:
mkdir -p backups
cp data/vox.db backups/vox-$(date +%Y%m%d-%H%M%S).dbWindows PowerShell:
New-Item -ItemType Directory -Force backups | Out-Null
Copy-Item data/vox.db ("backups/vox-{0}.db" -f (Get-Date -Format "yyyyMMdd-HHmmss"))Restore backup:
- Stop server process.
- Replace
data/vox.dbwith your backup file. - Start server again (
npm startornpm run dev).
- Run one manual Function execution in Appwrite.
- Restart local server.
- Check
GET /api/health:appwriteSyncEnabled: trueappwriteSyncConfigured: trueappwriteSync.lastError: null
- Never commit real keys in
.env, README, or templates. - Rotate keys immediately if they were ever shared.
For scope=week, data is filtered to GW2 reset week:
- Start: Friday
19:00(Europe/Brussels) - End: next Friday
19:00(Europe/Brussels)
GET /api/latest?top=100GET /api/snapshotsGET /api/weeks(selectable archived weeks anchored by Friday18:45Brussels snapshot)GET /api/accounts?query=...&limit=...GET /api/player/:account/history
GET /api/progression/top?top=10&scope=week|all&days=30&weekEnd=ISOGET /api/compare?accounts=A,B&scope=week|all&days=30&weekEnd=ISO
Notes:
daysis optional and used to keep all-time queries fast.- UI defaults to
Current Weekfor speed. weekEndis optional; when provided, week-scoped analytics use that archived week window instead of the live current week.
GET /api/leaderboard/delta?top=30&metric=weeklyKills|totalKills&scope=week|all&weekEnd=ISOGET /api/anomalies?top=20&minDeltaAbs=80&lookbackHours=72&scope=week|all&weekEnd=ISOGET /api/reset-impact?top=20&windowHours=1..24&weekEnd=ISOGET /api/consistency?top=20&scope=week|all&days=30&weekEnd=ISOGET /api/watchlist?accounts=A,B&minGain=30&minRankUp=3&scope=week|all&weekEnd=ISOGET /api/report/weekly?weekEnd=ISO
GET /api/snapshot/statusPOST /api/snapshot/run(loopback only)POST /api/sync/run(loopback only, Appwrite mode)GET /api/healthPOST /api/maintenance/run(loopback only)
In-memory cache with in-flight deduplication for read-heavy routes:
/api/snapshots/api/latest/api/progression/top/api/compare/api/accounts- plus analytics routes (
delta,anomalies, report)
Cache invalidates after successful snapshot and maintenance cleanup.
- Fastify schema validation on endpoints.
- Helmet headers + CSP.
- Snapshot/maintenance write endpoints restricted to loopback IP.
- Prepared SQL statements.
- Input sanitization for account names.
- Added chart zoom/pan/brush controls and PNG export.
- Added Top Movers + Anomaly Alerts modules.
- Added weekly analytics report API.
- Added CSV exports.
- Added global
Hide anonymizedfilter (persisted). - Added all-time dynamic range loading (
30d/90d/full) for faster defaults. - Added retention + vacuum maintenance flow.
- Improved auto-refresh behavior after hourly snapshots.
- Refined dark-mode contrast and top stat cards (including week-reset countdown).
-
GET /not found in production:- run
npm start(includes build) ornpm run buildthen start.
- run
-
Snapshots not updating UI:
- check
GET /api/snapshot/statusandGET /api/snapshots.
- check
-
Server snapshot fails:
- ensure Python exists in PATH or set
PYTHON_CMD.
- ensure Python exists in PATH or set
-
Discord share/test fails:
- verify webhook URL format (
https://discord.com/api/webhooks/...), - verify local write auth (
x-admin-token) and trusted local origin, - inspect server logs for
/api/share/discordor/api/share/discord/teststatus details.
- verify webhook URL format (
- Confirm Node runtime:
node -v(required: Node22+, see.nvmrc). - Verify
WRITE_API_TOKENexists in.env. - Start server:
npm start(prod local) ornpm run dev. - Validate health:
GET /api/health. - If Appwrite sync mode is enabled, confirm
appwriteSync.lastErrorisnulland next run is scheduled.
npm run dev
npm run build
npm start
npm test
npm audit
python scraper/scrape_gw2mists.py --pages 3 --per-page 100 --region eu --no-jsonIf you copy data/vox.db from Windows into WSL, ownership can become root:root and writes may fail with:
attempt to write a readonly database
Fix ownership/permissions:
sudo chown -R $USER:$USER data
chmod u+rwx data
chmod u+rw data/vox.db data/vox.db-wal data/vox.db-shm 2>/dev/null || trueRecommendation:
- Do not run the app with
sudo. - Keep DB files owned by your regular WSL user.
MIT