Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/alituzun_common-project.json
T

1 line
221 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "alituzun/common-project", "files": {"src/server.js": {"d77062fb1b8f61e01c57502742a9e07711d17a9e": {"t": 1785595783.5115194, "n": 220429, "h": "aca1517f8c91cd3d", "c": "import express from 'express';\nimport { promises as fs } from 'fs';\nimport fsSync from 'fs';\nimport path from 'path';\nimport { setTimeout as wait } from 'timers/promises';\nimport 'dotenv/config';\nimport { getSupabase } from './supabaseClient.js';\n\nconst app = express();\nconst PORT = process.env.PORT || 3001;\n\n// OpenSea API key must be provided via environment (OPENSEA_API_KEY). No hardcoded fallback in production.\n\n// Basic CORS (adjust as needed). Allows common headers and credentials if needed.\napp.use((req, res, next) => {\n res.setHeader('Access-Control-Allow-Origin', '*');\n res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS');\n res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Cookie, X-Admin-Token, X-Admin');\n if (req.method === 'OPTIONS') return res.sendStatus(204);\n next();\n});\n\napp.get('/health', (_req, res) => res.json({ ok: true }));\n\n// Helper: require admin token in production\nfunction requireAdminInProd(req, res) {\n const isProd = (process.env.VERCEL || process.env.NODE_ENV === 'production');\n if (!isProd) return true;\n const token = req.headers['x-admin-token'];\n if (!process.env.ADMIN_TOKEN || token !== process.env.ADMIN_TOKEN) {\n res.status(401).json({ ok: false, error: 'Unauthorized: missing or invalid X-Admin-Token' });\n return false;\n }\n return true;\n}\n\n// Simple Sybil game endpoints\n// GET /api/games/sybil/start -> starts a session (stateless placeholder)\napp.get('/api/games/sybil/start', (req, res) => {\n try {\n const ip = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.socket.remoteAddress || 'unknown';\n const now = new Date().toISOString();\n const phase = { phase1: 0.5, phase2: 0.5 };\n return res.status(200).json({ ok: true, game: 'sybil', action: 'start', phase, ip, startedAt: now });\n } catch (e) {\n return res.status(500).json({ ok: false, error: e?.message || String(e) });\n }\n});\n\n// GET /xps-ranked/sync-tiers\n// Reads output/concat.json (or ?file=) and updates user_profile_stats.tier for each user_id found.\n// Security: require X-Admin-Token header to match process.env.ADMIN_TOKEN in production.\napp.get('/xps-ranked/sync-tiers', async (req, res) => {\n try {\n const adminToken = req.headers['x-admin-token'];\n const isProd = (process.env.VERCEL || process.env.NODE_ENV === 'production');\n if (isProd) {\n if (!process.env.ADMIN_TOKEN || adminToken !== process.env.ADMIN_TOKEN) {\n return res.status(401).json({ ok: false, error: 'Unauthorized: missing or invalid X-Admin-Token' });\n }\n }\n\n const filePath = req.query.file ? String(req.query.file) : path.join(process.cwd(), 'output', 'concat.json');\n const dryRun = String(req.query.dryRun ?? 'false').toLowerCase() === 'true';\n\n async function readJsonSafe(file) {\n try {\n const txt = await fs.readFile(file, 'utf8');\n return JSON.parse(txt);\n } catch (e) {\n return undefined;\n }\n }\n const data = await readJsonSafe(filePath);\n if (!data) return res.status(400).json({ ok: false, error: 'Could not read or parse JSON file', filePath });\n\n const arr = Array.isArray(data) ? data : (Array.isArray(data?.results) ? data.results : []);\n if (!Array.isArray(arr) || arr.length === 0) return res.json({ ok: true, filePath, totalItems: 0, uniqueUsers: 0, updated: 0, skipped: 0, dryRun });\n\n // Build per-user tier frequency and choose the most frequent tier\n const tierCountsByUser = new Map(); // userId:number -> Map(tier:number -> count)\n let itemsScanned = 0;\n for (const item of arr) {\n if (!item || typeof item !== 'object') continue;\n const idInfo = getIdFromObject(item, 'user_id');\n if (!idInfo) continue;\n const uidNum = Number(idInfo.value);\n if (!Number.isFinite(uidNum) || uidNum <= 0) continue;\n const t = toNumber(item.tier);\n if (!Number.isFinite(t)) continue;\n const ti = Math.trunc(t);\n if (!tierCountsByUser.has(uidNum)) tierCountsByUser.set(uidNum, new Map());\n const m = tierCountsByUser.get(uidNum);\n m.set(ti, (m.get(ti) || 0) + 1);\n itemsScanned++;\n }\n\n // Decide final tier per user: most frequent; tie-breaker -> largest tier number\n const finalTierByUser = new Map();\n for (const [uid, m] of tierCountsByUser.entries()) {\n let bestTier = null;\n let bestCount = -1;\n for (const [tierVal, cnt] of m.entries()) {\n if (cnt > bestCount || (cnt === bestCount && tierVal > (bestTier ?? -Infinity))) {\n bestTier = tierVal;\n bestCount = cnt;\n }\n }\n if (bestTier != null) finalTierByUser.set(uid, bestTier);\n }\n\n const sb = getSupabase();\n let updated = 0;\n let failed = 0;\n const errors = [];\n\n async function updateOne(uid, tier) {\n try {\n if (dryRun) return true;\n const r = await sb.from('user_profile_stats').update({ tier }).eq('user_id', uid);\n if (r.error) throw new Error(r.error.message || 'Supabase update error');\n return true;\n } catch (e) {\n errors.push({ uid, tier, error: e?.message || String(e) });\n return false;\n }\n }\n\n // Concurrency control\n const entries = Array.from(finalTierByUser.entries());\n const concurrency = Number(req.query.concurrency ?? 10);\n let i = 0;\n async function nextBatch() {\n const batch = [];\n for (let k = 0; k < concurrency && i < entries.length; k++, i++) {\n const [uid, tier] = entries[i];\n batch.push(updateOne(uid, tier).then((ok) => { if (ok) updated++; else failed++; }));\n }\n await Promise.all(batch);\n if (i < entries.length) await nextBatch();\n }\n await nextBatch();\n\n return res.json({\n ok: true,\n filePath,\n dryRun,\n totalItems: arr.length,\n itemsWithTier: itemsScanned,\n uniqueUsers: finalTierByUser.size,\n updated,\n failed,\n errorsSample: errors.slice(0, 10),\n });\n } catch (e) {\n return res.status(500).json({ ok: false, error: e?.message || String(e) });\n }\n});\n\n// Helper: sanitize and prepare headers for upstream call\nfunction buildUpstreamHeaders(reqHeaders) {\n const drop = new Set(['host', 'connection', 'content-length', 'transfer-encoding', 'keep-alive', 'upgrade', 'via', 'x-forwarded-for']);\n const h = {}; \n for (const [k, v] of Object.entries(reqHeaders || {})) {\n const key = String(k).toLowerCase();\n if (!drop.has(key) && v !== undefined && v !== null) h[key] = v;\n }\n //\n // Provide some sensible defaults if caller didn't set them\n h['accept'] ||= 'application/json, text/plain, */*';\n h['accept-encoding'] ||= 'gzip, deflate, br, zstd';\n h['accept-language'] ||= 'en-US,en;q=0.8';\n h['user-agent'] ||= 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36';\n h['origin'] ||= 'https://common.xyz';\n h['referer'] ||= 'https://common.xyz/leaderboard';\n return h;\n}\n\n// Common Checker helpers (top-level)\nconst parsePct = (val, defVal) => {\n if (val == null) return defVal;\n const n = Number(String(val).replace(/[^0-9.+-]/g, ''));\n return Number.isFinite(n) ? n : defVal;\n};\nconst fmtPct = (x) => (typeof x === 'number' && Number.isFinite(x) ? x.toLocaleString('en-US', { maximumFractionDigits: 2 }) + '%' : '—');\nconst filterUserMsgs = (arr) => {\n const HIDE = [\n /^(no )?common credentials/i,\n /^common api error/i,\n /^no wallet address found/i,\n ];\n return (Array.isArray(arr) ? arr : []).filter((m) => !HIDE.some((rx) => rx.test(String(m))));\n};\n\n// Simple in-memory rate limiting (best-effort; resets per process)\nconst RATE_LIMITS = {\n global: { windowMs: 60_000, max: 120 },\n write: { windowMs: 60_000, max: 20 },\n};\nconst rateStore = new Map(); // key -> { resetAt, remaining }\nfunction checkLimit(key, conf) {\n const now = Date.now();\n let st = rateStore.get(key);\n if (!st || now >= st.resetAt) {\n st = { resetAt: now + conf.windowMs, remaining: conf.max };\n }\n if (st.remaining <= 0) {\n rateStore.set(key, st);\n return { allowed: false, retryAfter: Math.ceil((st.resetAt - now) / 1000) };\n }\n st.remaining -= 1;\n rateStore.set(key, st);\n return { allowed: true, remaining: st.remaining, resetAt: st.resetAt };\n}\nfunction limiter(conf) {\n return (req, res, next) => {\n const ip = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.socket.remoteAddress || 'unknown';\n const key = `${conf.max}:${ip}`;\n const resLimit = checkLimit(key, conf);\n if (!resLimit.allowed) {\n res.setHeader('Retry-After', String(resLimit.retryAfter));\n return res.status(429).json({ error: 'Too many requests', retryAfter: resLimit.retryAfter });\n }\n next();\n };\n}\n\n// Apply global limiter to all routes\napp.use(limiter(RATE_LIMITS.global));\n\n// Helper: read auth/cookie from headers or query params\nfunction getAuthCookieFromRequest(req) {\n // Headers are lower-cased by Express\n const hdrAuth = req.headers['authorization'];\n const hdrCookie = req.headers['cookie'];\n const q = req.query || {};\n // Accept query fallbacks like ?authorization=...&cookie=...\n const qAuth = typeof q.authorization === 'string' ? q.authorization : undefined;\n const qCookie = typeof q.cookie === 'string' ? q.cookie : undefined;\n return {\n authorization: hdrAuth || qAuth,\n cookie: hdrCookie || qCookie,\n };\n}\n\n// GET /xps-ranked/save\n// Reads Authorization and Cookie from incoming headers, fetches upstream once, and writes body to output/leaderboard.txt\napp.get('/xps-ranked/save', async (req, res) => {\n try {\n const { authorization: auth, cookie } = getAuthCookieFromRequest(req);\n if (!auth && !cookie) {\n return res.status(400).json({ error: 'Missing credentials: provide Authorization and/or Cookie (header or query param)' });\n }\n\n const limit = Number(req.query.limit ?? 50) || 50; // Default limit for pagination\n const direction = String(req.query.direction ?? 'forward');\n const cursorRaw = req.query.cursor;\n\n const payload = { limit, direction };\n if (cursorRaw !== undefined && cursorRaw !== '') {\n const curNum = Number(cursorRaw);\n if (!Number.isNaN(curNum)) payload.cursor = curNum;\n }\n const inputParam = JSON.stringify(payload);\n const upstreamUrl = 'https://common.xyz/api/internal/trpc/user.getXpsRanked?input=' + encodeURIComponent(inputParam);\n\n const headersOut = buildUpstreamHeaders(req.headers);\n if (auth) headersOut['authorization'] = auth;\n if (cookie) headersOut['cookie'] = cookie;\n\n const resp = await fetch(upstreamUrl, { method: 'GET', headers: headersOut });\n const text = await resp.text();\n\n const outDir = path.join(process.cwd(), 'output');\n await fs.mkdir(outDir, { recursive: true });\n const outPath = path.join(outDir, 'leaderboard.txt');\n await fs.writeFile(outPath, text, 'utf8');\n\n return res.status(resp.status).json({ ok: resp.ok, status: resp.status, saved: outPath });\n } catch (err) {\n return res.status(500).json({ error: 'Save failure', message: err?.message || String(err) });\n }\n});\n\n// Helper: recursively find the maximum array length within a JSON structure\nfunction findMaxArrayLen(node) {\n let maxLen = 0;\n const stack = [node];\n while (stack.length) {\n const cur = stack.pop();\n if (Array.isArray(cur)) {\n if (cur.length > maxLen) maxLen = cur.length;\n for (const v of cur) stack.push(v);\n } else if (cur && typeof cur === 'object') {\n for (const v of Object.values(cur)) stack.push(v);\n }\n }\n return maxLen;\n}\n\n// GET /xps-ranked/save-all\n// Iterates cursor from start (default 1) upwards until an empty page is detected or max requests reached.\n// Saves each page response body to output/pages/page_<cursor>.txt and returns a summary.\napp.get('/xps-ranked/save-all', async (req, res) => {\n try {\n const { authorization: auth, cookie } = getAuthCookieFromRequest(req);\n if (!auth && !cookie) {\n return res.status(400).json({ error: 'Missing credentials: provide Authorization and/or Cookie (header or query param)' });\n }\n\n const start = Number(req.query.start ?? 1) || 1; // Starting cursor for pagination\n const limit = Number(req.query.limit ?? 50) || 50;\n const direction = String(req.query.direction ?? 'forward');\n // Increase default page scan and hard cap; allow overrides via env or query\n const maxDefault = Number(process.env.SAVE_ALL_MAX_DEFAULT ?? 5000) || 5000; // previous default was 1000\n const hardCap = Math.max(1000, Number(process.env.SAVE_ALL_HARD_CAP ?? 100000) || 100000); // previous cap was 10000\n const max = Math.min(Number(req.query.max ?? maxDefault) || maxDefault, hardCap);\n const delayMs = Math.max(0, Number(req.query.delayMs ?? 0) || 0);\n const stopOnEmpty = String(req.query.stopOnEmpty ?? 'true').toLowerCase() !== 'false';\n\n const outDir = path.join(process.cwd(), 'output', 'pages');\n await fs.mkdir(outDir, { recursive: true });\n\n const headersOut = buildUpstreamHeaders(req.headers);\n if (auth) headersOut['authorization'] = auth;\n if (cookie) headersOut['cookie'] = cookie;\n\n let pages = 0;\n let lastCursor = start - 1;\n let stoppedBecause = 'maxReached';\n for (let cursor = start; cursor < start + max; cursor++) {\n const payload = { limit, direction, cursor };\n const inputParam = JSON.stringify(payload);\n const upstreamUrl = 'https://common.xyz/api/internal/trpc/user.getXpsRanked?input=' + encodeURIComponent(inputParam);\n const resp = await fetch(upstreamUrl, { method: 'GET', headers: headersOut });\n const text = await resp.text();\n\n const pagePathBase = path.join(outDir, `page_${cursor}`);\n const filePath = pagePathBase + (resp.ok ? '.txt' : '.error.txt');\n await fs.writeFile(filePath, text, 'utf8');\n\n if (!resp.ok) {\n stoppedBecause = `httpError:${resp.status}`;\n lastCursor = cursor;\n break;\n }\n\n // Try to detect emptiness\n let isEmpty = false;\n try {\n const json = JSON.parse(text);\n const maxArrLen = findMaxArrayLen(json);\n isEmpty = maxArrLen === 0;\n } catch {\n // If parsing fails, assume not empty so we keep going\n isEmpty = false;\n }\n\n pages++;\n lastCursor = cursor;\n\n if (stopOnEmpty && isEmpty) {\n stoppedBecause = 'emptyPage';\n break;\n }\n\n if (delayMs > 0) await wait(delayMs);\n }\n\n if (pages >= max && stoppedBecause === 'maxReached') {\n // No earlier break; reached the cap\n stoppedBecause = 'maxReached';\n }\n\n const summary = {\n ok: true,\n start,\n lastCursor,\n pagesSaved: pages,\n limit,\n direction,\n max,\n delayMs,\n stopOnEmpty,\n stoppedBecause,\n outputDir: outDir,\n };\n\n // Write a manifest for convenience\n try {\n await fs.writeFile(path.join(outDir, 'manifest.json'), JSON.stringify(summary, null, 2), 'utf8');\n } catch {}\n\n return res.json(summary);\n } catch (err) {\n return res.status(500).json({ error: 'Save-all failure', message: err?.message || String(err) });\n }\n});\n\n// Utility: parse a value into a finite number if possible\nfunction toNumber(val) {\n if (typeof val === 'number' && Number.isFinite(val)) return val;\n if (typeof val === 'string') {\n const n = Number(val.replace(/[,\\s]/g, ''));\n if (Number.isFinite(n)) return n;\n }\n return undefined;\n}\n\n// Utility: extract xp_points numeric value from an object\nfunction getXpPointsFromObject(obj) {\n const keys = ['xp_points', 'xpPoints', 'xp'];\n for (const k of keys) {\n if (Object.prototype.hasOwnProperty.call(obj, k)) {\n const n = toNumber(obj[k]);\n if (n !== undefined) return { key: k, value: n };\n }\n }\n return undefined;\n}\n\n// Utility: find a plausible identifier key for deduplication\nfunction getIdFromObject(obj, customKey) {\n const candidates = customKey\n ? [customKey]\n : [\n 'user_id',\n 'userId',\n 'id',\n 'user_name',\n 'username',\n 'handle',\n 'profileId',\n 'address',\n 'wallet',\n 'account',\n 'slug',\n ];\n for (const k of candidates) {\n if (Object.prototype.hasOwnProperty.call(obj, k)) {\n const v = obj[k];\n if (v !== null && v !== undefined) return { key: k, value: String(v) };\n }\n }\n return undefined;\n}\n\n// GET /xps-ranked/aggregate\n// Scans output/pages (*.txt) files, extracts entries with an xp_points value and counts unique persons.\n// Query params: dir (optional), idKey (optional), unique=true|false, includeErrors=false|true, limitFiles (number)\napp.get('/xps-ranked/aggregate', async (req, res) => {\n try {\n const baseDir = req.query.dir ? String(req.query.dir) : path.join(process.cwd(), 'output', 'pages');\n const includeErrors = String(req.query.includeErrors ?? 'false').toLowerCase() === 'true';\n const unique = String(req.query.unique ?? 'true').toLowerCase() !== 'false';\n const idKey = req.query.idKey ? String(req.query.idKey) : undefined;\n const limitFiles = req.query.limitFiles ? Number(req.query.limitFiles) : undefined;\n\n const entries = await fs.readdir(baseDir);\n const pageFiles = entries\n .filter((f) => f.endsWith('.txt'))\n .filter((f) => includeErrors || !f.endsWith('.error.txt'))\n .filter((f) => f !== 'leaderboard.txt');\n\n const sortedFiles = pageFiles.slice().sort((a, b) => {\n // Sort by numeric cursor in filename if present\n const na = Number((a.match(/page_(\\d+)/) || [])[1]);\n const nb = Number((b.match(/page_(\\d+)/) || [])[1]);\n if (Number.isFinite(na) && Number.isFinite(nb)) return na - nb;\n return a.localeCompare(b);\n });\n\n const filesToRead = typeof limitFiles === 'number' && limitFiles > 0 ? sortedFiles.slice(0, limitFiles) : sortedFiles;\n\n let filesProcessed = 0;\n let filesFailed = 0;\n let totalXp = 0;\n let entriesCount = 0;\n const uniqueIds = new Set();\n let dedupeKeyUsed = idKey || null;\n\n for (const fname of filesToRead) {\n const fullPath = path.join(baseDir, fname);\n try {\n const text = await fs.readFile(fullPath, 'utf8');\n let data;\n try {\n data = JSON.parse(text);\n } catch {\n filesFailed++;\n continue;\n }\n\n // Traverse breadth-first to find objects with xp_points\n const queue = [data];\n while (queue.length) {\n const cur = queue.shift();\n if (!cur) continue;\n if (Array.isArray(cur)) {\n for (const v of cur) queue.push(v);\n } else if (typeof cur === 'object') {\n // Check this object\n const xp = getXpPointsFromObject(cur);\n if (xp) {\n entriesCount++;\n totalXp += xp.value;\n if (unique) {\n const idInfo = getIdFromObject(cur, idKey);\n if (idInfo) {\n uniqueIds.add(`${idInfo.key}::${idInfo.value}`);\n if (!dedupeKeyUsed) dedupeKeyUsed = idInfo.key;\n }\n }\n }\n // enqueue children\n for (const v of Object.values(cur)) queue.push(v);\n }\n }\n filesProcessed++;\n } catch {\n filesFailed++;\n }\n }\n\n const personCount = unique ? (uniqueIds.size || entriesCount) : entriesCount;\n\n const summary = {\n ok: true,\n dir: baseDir,\n filesProcessed,\n filesFailed,\n entriesWithXpPoints: entriesCount,\n personCount,\n totalXpPoints: totalXp,\n unique,\n dedupeKeyUsed,\n };\n\n // Persist a copy for convenience\n try {\n const outDir = path.join(process.cwd(), 'output');\n await fs.mkdir(outDir, { recursive: true });\n await fs.writeFile(path.join(outDir, 'aggregate.json'), JSON.stringify(summary, null, 2), 'utf8');\n } catch {}\n\n return res.json(summary);\n } catch (err) {\n return res.status(500).json({ error: 'Aggregate failure', message: err?.message || String(err) });\n }\n});\n\n// GET /xps-ranked/concat\n// Concatenates all .txt page files into a single text file.\n// Query params: dir (default output/pages), includeErrors=false|true, outFile (default output/concat.txt)\napp.get('/xps-ranked/concat', async (req, res) => {\n try {\n const baseDir = req.query.dir ? String(req.query.dir) : path.join(process.cwd(), 'output', 'pages');\n const includeErrors = String(req.query.includeErrors ?? 'false').toLowerCase() === 'true';\n const outFile = req.query.outFile ? String(req.query.outFile) : path.join(process.cwd(), 'output', 'concat.txt');\n\n const files = (await fs.readdir(baseDir))\n .filter((f) => f.endsWith('.txt'))\n .filter((f) => includeErrors || !f.endsWith('.error.txt'))\n .sort((a, b) => {\n const na = Number((a.match(/page_(\\d+)/) || [])[1]);\n const nb = Number((b.match(/page_(\\d+)/) || [])[1]);\n if (Number.isFinite(na) && Number.isFinite(nb)) return na - nb;\n return a.localeCompare(b);\n });\n\n // Stream-concat for memory efficiency\n const outputDir = path.dirname(outFile);\n await fs.mkdir(outputDir, { recursive: true });\n const chunks = [];\n for (const fname of files) {\n const content = await fs.readFile(path.join(baseDir, fname), 'utf8');\n chunks.push(`\\n--- FILE: ${fname} ---\\n`);\n chunks.push(content);\n chunks.push('\\n');\n }\n await fs.writeFile(outFile, chunks.join(''), 'utf8');\n\n return res.json({ ok: true, files: files.length, outFile });\n } catch (err) {\n return res.status(500).json({ error: 'Concat failure', message: err?.message || String(err) });\n }\n});\n\n// Helpers for JSON concatenation\nfunction getByPath(obj, pathStr) {\n if (!pathStr) return undefined;\n const parts = String(pathStr).split('.').filter(Boolean);\n let cur = obj;\n for (const p of parts) {\n if (!cur || typeof cur !== 'object' || !(p in cur)) return undefined;\n cur = cur[p];\n }\n return cur;\n}\n\nfunction findArrayInJson(data) {\n // If the whole document is an array\n if (Array.isArray(data)) return data;\n // BFS: prefer first non-empty array; fallback to the largest array seen\n let best = undefined;\n let bestLen = -1;\n const queue = [data];\n while (queue.length) {\n const cur = queue.shift();\n if (!cur) continue;\n if (Array.isArray(cur)) {\n if (cur.length > 0) return cur; // early return on first non-empty\n if (cur.length > bestLen) {\n best = cur;\n bestLen = cur.length;\n }\n } else if (typeof cur === 'object') {\n for (const v of Object.values(cur)) queue.push(v);\n }\n }\n return best || [];\n}\n\n// GET /xps-ranked/concat-json\n// Merges JSON arrays from page files into a single JSON array file without file name markers.\n// Query params: dir (default output/pages), includeErrors=false|true, outFile (default output/concat.json), arrayPath (dot path), limitFiles (number)\napp.get('/xps-ranked/concat-json', async (req, res) => {\n try {\n const baseDir = req.query.dir ? String(req.query.dir) : path.join(process.cwd(), 'output', 'pages');\n const includeErrors = String(req.query.includeErrors ?? 'false').toLowerCase() === 'true';\n const outFile = req.query.outFile ? String(req.query.outFile) : path.join(process.cwd(), 'output', 'concat.json');\n const arrayPath = req.query.arrayPath ? String(req.query.arrayPath) : undefined;\n const limitFiles = req.query.limitFiles ? Number(req.query.limitFiles) : undefined;\n\n const files = (await fs.readdir(baseDir))\n .filter((f) => f.endsWith('.txt'))\n .filter((f) => includeErrors || !f.endsWith('.error.txt'))\n .sort((a, b) => {\n const na = Number((a.match(/page_(\\d+)/) || [])[1]);\n const nb = Number((b.match(/page_(\\d+)/) || [])[1]);\n if (Number.isFinite(na) && Number.isFinite(nb)) return na - nb;\n return a.localeCompare(b);\n });\n\n const readList = typeof limitFiles === 'number' && limitFiles > 0 ? files.slice(0, limitFiles) : files;\n\n await fs.mkdir(path.dirname(outFile), { recursive: true });\n const ws = fsSync.createWriteStream(outFile, { encoding: 'utf8' });\n let wroteAny = false;\n let filesProcessed = 0;\n let filesFailed = 0;\n let itemsWritten = 0;\n\n await new Promise(async (resolve, reject) => {\n ws.on('error', reject);\n ws.write('[');\n try {\n for (const fname of readList) {\n const fullPath = path.join(baseDir, fname);\n let text;\n try {\n text = await fs.readFile(fullPath, 'utf8');\n } catch {\n filesFailed++;\n continue;\n }\n let json;\n try {\n json = JSON.parse(text);\n } catch {\n filesFailed++;\n continue;\n }\n let arr;\n if (arrayPath) {\n const val = getByPath(json, arrayPath);\n arr = Array.isArray(val) ? val : undefined;\n }\n if (!arr) arr = findArrayInJson(json);\n if (!Array.isArray(arr)) arr = [];\n for (const item of arr) {\n if (wroteAny) ws.write(',');\n ws.write(JSON.stringify(item));\n wroteAny = true;\n itemsWritten++;\n }\n filesProcessed++;\n }\n ws.write(']');\n ws.end(resolve);\n } catch (e) {\n reject(e);\n }\n });\n\n return res.json({ ok: true, filesProcessed, filesFailed, itemsWritten, outFile });\n } catch (err) {\n return res.status(500).json({ error: 'Concat JSON failure', message: err?.message || String(err) });\n }\n});\n\n// Helper: scan entire JSON and return the maximum xp_points value found\nfunction findMaxXpPoints(node) {\n let max = undefined;\n const stack = [node];\n while (stack.length) {\n const cur = stack.pop();\n if (!cur) continue;\n if (Array.isArray(cur)) {\n for (const v of cur) stack.push(v);\n } else if (typeof cur === 'object') {\n const xp = getXpPointsFromObject(cur);\n if (xp && (max === undefined || xp.value > max)) max = xp.value;\n for (const v of Object.values(cur)) stack.push(v);\n }\n }\n return max;\n}\n\n// Helpers: extract activity counts from user profile JSON\nfunction findMaxArrayLenByKeys(node, keys) {\n const keySet = new Set((keys || []).map((k) => String(k).toLowerCase()));\n let maxLen = 0;\n const stack = [node];\n while (stack.length) {\n const cur = stack.pop();\n if (!cur) continue;\n if (Array.isArray(cur)) {\n for (const v of cur) stack.push(v);\n } else if (typeof cur === 'object') {\n for (const [k, v] of Object.entries(cur)) {\n if (Array.isArray(v)) {\n const lk = String(k).toLowerCase();\n if (keySet.has(lk)) {\n if (v.length > maxLen) maxLen = v.length;\n }\n }\n if (v && (typeof v === 'object')) stack.push(v);\n }\n }\n }\n return maxLen;\n}\n\nfunction findNumericMaxByKeys(node, keys) {\n const keySet = new Set((keys || []).map((k) => String(k).toLowerCase()));\n let maxVal = undefined;\n const stack = [node];\n while (stack.length) {\n const cur = stack.pop();\n if (!cur) continue;\n if (Array.isArray(cur)) {\n for (const v of cur) stack.push(v);\n } else if (typeof cur === 'object') {\n for (const [k, v] of Object.entries(cur)) {\n const lk = String(k).toLowerCase();\n if (keySet.has(lk)) {\n const n = toNumber(v);\n if (n !== undefined) {\n if (maxVal === undefined || n > maxVal) maxVal = n;\n }\n }\n if (v && (typeof v === 'object')) stack.push(v);\n }\n }\n }\n return maxVal;\n}\n\nfunction computeProfileActivityCounts(json) {\n // Heuristics: prefer arrays named explicitly; take max length seen to avoid double counting mirrors\n const addresses = findMaxArrayLenByKeys(json, ['addresses', 'addressList', 'wallets', 'accounts']);\n const threads = findMaxArrayLenByKeys(json, ['threads', 'userThreads', 'createdThreads']);\n const comments = findMaxArrayLenByKeys(json, ['comments', 'userComments']);\n const commentThreads = findMaxArrayLenByKeys(json, ['commentThreads', 'commentedThreads']);\n const totalUpvotes = findNumericMaxByKeys(json, ['totalUpvotes', 'total_upvotes']);\n return {\n addresses: Number.isFinite(addresses) ? addresses : 0,\n threads: Number.isFinite(threads) ? threads : 0,\n comments: Number.isFinite(comments) ? comments : 0,\n totalUpvotes: totalUpvotes ?? null,\n commentThreads: Number.isFinite(commentThreads) ? commentThreads : 0,\n };\n}\n\n// Extract profile meta block if present: { name, email, bio, avatar_url, socials[], background_image{url,imageBehavior} }\nfunction extractProfileInfo(json) {\n if (!json) return undefined;\n // Prefer an explicit \"profile\" object anywhere in the tree\n let profileObj = undefined;\n const stack = [json];\n while (stack.length && !profileObj) {\n const cur = stack.pop();\n if (!cur) continue;\n if (Array.isArray(cur)) {\n for (const v of cur) stack.push(v);\n } else if (typeof cur === 'object') {\n if (cur && typeof cur.profile === 'object' && !Array.isArray(cur.profile)) {\n profileObj = cur.profile;\n break;\n }\n for (const v of Object.values(cur)) stack.push(v);\n }\n }\n // Fallback: find an object that looks like a profile by keys\n if (!profileObj) {\n const keysHint = new Set(['name', 'email', 'bio', 'avatar_url', 'socials', 'background_image']);\n const seen = new Set();\n const st2 = [json];\n while (st2.length && !profileObj) {\n const cur = st2.pop();\n if (!cur || seen.has(cur)) continue;\n seen.add(cur);\n if (Array.isArray(cur)) { for (const v of cur) st2.push(v); }\n else if (typeof cur === 'object') {\n const keys = Object.keys(cur);\n const score = keys.reduce((acc, k) => acc + (keysHint.has(String(k)) ? 1 : 0), 0);\n if (score >= 2) { profileObj = cur; break; }\n for (const v of Object.values(cur)) st2.push(v);\n }\n }\n }\n if (!profileObj || typeof profileObj !== 'object') return undefined;\n const name = profileObj.name != null ? String(profileObj.name) : undefined;\n const email = profileObj.email != null ? String(profileObj.email) : undefined;\n const bio = profileObj.bio != null ? String(profileObj.bio) : undefined;\n const avatar_url = profileObj.avatar_url != null ? String(profileObj.avatar_url) : undefined;\n const socials = Array.isArray(profileObj.socials) ? profileObj.socials : undefined;\n let background_image = undefined;\n const bg = profileObj.background_image;\n if (bg && typeof bg === 'object') {\n background_image = {\n url: bg.url != null ? String(bg.url) : undefined,\n imageBehavior: bg.imageBehavior != null ? String(bg.imageBehavior) : undefined,\n };\n }\n return {\n profile: {\n ...(name !== undefined ? { name } : {}),\n ...(email !== undefined ? { email } : {}),\n ...(bio !== undefined ? { bio } : {}),\n ...(avatar_url !== undefined ? { avatar_url } : {}),\n ...(socials !== undefined ? { socials } : {}),\n ...(background_image !== undefined ? { background_image } : {}),\n }\n };\n}\n\n// Extract referral and related summary fields if present\nfunction extractReferralAndXp(json) {\n if (!json) return {};\n let referred_by_address, referral_count, referral_eth_earnings;\n const seekKeys = new Set(['referred_by_address', 'referral_count', 'referral_eth_earnings']);\n const visited = new Set();\n const stack = [json];\n while (stack.length) {\n const cur = stack.pop();\n if (!cur || visited.has(cur)) continue;\n visited.add(cur);\n if (Array.isArray(cur)) { for (const v of cur) stack.push(v); }\n else if (typeof cur === 'object') {\n for (const [k,v] of Object.entries(cur)) {\n const lk = String(k);\n if (lk === 'referred_by_address' && referred_by_address === undefined && v != null) referred_by_address = String(v);\n if (lk === 'referral_count' && referral_count === undefined) {\n const n = Number(v); if (Number.isFinite(n)) referral_count = n;\n }\n if (lk === 'referral_eth_earnings' && referral_eth_earnings === undefined) {\n const n = Number(v); if (Number.isFinite(n)) referral_eth_earnings = n;\n }\n }\n for (const v of Object.values(cur)) stack.push(v);\n }\n }\n const xp = findMaxXpPoints(json);\n const out = {};\n if (referred_by_address !== undefined) out.referred_by_address = referred_by_address;\n if (referral_count !== undefined) out.referral_count = referral_count;\n if (referral_eth_earnings !== undefined) out.referral_eth_earnings = referral_eth_earnings;\n if (xp !== undefined) out.xp_points = xp;\n return out;\n}\n\n// Extract the first address from an \"addresses\" array anywhere in the payload\n// and expose it as { user_evm: string }. If not found, returns {}.\nfunction extractPrimaryEvmAddress(json) {\n if (!json) return {};\n const visited = new Set();\n const stack = [json];\n let addr = undefined;\n while (stack.length && addr === undefined) {\n const cur = stack.pop();\n if (!cur || visited.has(cur)) continue;\n visited.add(cur);\n if (Array.isArray(cur)) {\n for (const v of cur) stack.push(v);\n } else if (typeof cur === 'object') {\n for (const [k, v] of Object.entries(cur)) {\n if (String(k).toLowerCase() === 'addresses' && Array.isArray(v) && v.length) {\n const first = v[0];\n if (first && typeof first === 'object' && 'address' in first) {\n const a = first.address;\n if (a != null) { addr = String(a); break; }\n }\n }\n }\n // Continue traversal in case we didn't find it at this level\n for (const v of Object.values(cur)) stack.push(v);\n }\n }\n return addr !== undefined ? { user_evm: addr } : {};\n}\n\n// GET /xps-ranked/profile-xp\n// Reads IDs from a merged JSON file (default output/concat.json), calls user.getUserProfile per ID,\n// extracts xp_points from the response JSON, writes results to output/profile-xp.json, and returns a summary.\n// Query: jsonFile, idKey, limit, offset, concurrency, delayMs, outFile\napp.get('/xps-ranked/profile-xp', async (req, res) => {\n try {\n const { authorization: auth, cookie } = getAuthCookieFromRequest(req);\n if (!auth && !cookie) {\n return res.status(400).json({ error: 'Missing credentials: provide Authorization and/or Cookie (header or query param)' });\n }\n\n const jsonFile = req.query.jsonFile ? String(req.query.jsonFile) : path.join(process.cwd(), 'output', 'concat.json');\n const idKey = req.query.idKey ? String(req.query.idKey) : 'user_id';\n const limit = req.query.limit ? Number(req.query.limit) : undefined;\n const offset = Number(req.query.offset ?? 0) || 0;\n const concurrency = Math.max(1, Number(req.query.concurrency ?? 2) || 2);\n const delayMs = Math.max(0, Number(req.query.delayMs ?? 250) || 250);\n const outFile = req.query.outFile ? String(req.query.outFile) : path.join(process.cwd(), 'output', 'profile-xp.json');\n const maxRetries = Math.max(0, Number(req.query.maxRetries ?? 4) || 4);\n const baseDelayMs = Math.max(50, Number(req.query.baseDelayMs ?? 500) || 500);\n const skipExisting = String(req.query.skipExisting ?? 'true').toLowerCase() !== 'false';\n const outMode = (req.query.outMode ? String(req.query.outMode) : 'merge').toLowerCase(); // overwrite | merge\n const backfill = String(req.query.backfill ?? 'true').toLowerCase() !== 'false';\n const mutateSource = String(req.query.mutateSource ?? 'false').toLowerCase() === 'true';\n\n const text = await fs.readFile(jsonFile, 'utf8');\n const arr = JSON.parse(text);\n if (!Array.isArray(arr)) return res.status(400).json({ error: 'Invalid JSON: expected top-level array' });\n\n // Build unique ordered ID list\n const seen = new Set();\n const ids = [];\n for (const item of arr) {\n if (!item || typeof item !== 'object') continue;\n const idInfo = getIdFromObject(item, idKey);\n if (!idInfo) continue;\n const v = idInfo.value;\n const num = Number(v);\n if (!Number.isFinite(num) || num <= 0) continue; // userId must be positive number\n if (!seen.has(num)) {\n seen.add(num);\n ids.push(num);\n }\n }\n\n // Load existing output if we need to skip already processed\n let existing = undefined;\n const processedIds = new Set();\n if (skipExisting || outMode === 'merge') {\n try {\n const prev = JSON.parse(await fs.readFile(outFile, 'utf8'));\n existing = prev;\n if (Array.isArray(prev?.results)) {\n for (const r of prev.results) {\n if (r && (r.userId !== undefined && r.userId !== null)) {\n processedIds.add(Number(r.userId));\n }\n }\n }\n } catch {}\n }\n\n let slice;\n if (limit === undefined) {\n // Take all beyond offset (optionally skip existing)\n const all = ids.slice(offset);\n slice = skipExisting ? all.filter((id) => !processedIds.has(id)) : all;\n } else if (backfill) {\n // Fill up to 'limit' unprocessed ids by scanning beyond offset\n slice = [];\n for (let i = offset; i < ids.length && slice.length < limit; i++) {\n const id = ids[i];\n if (skipExisting && processedIds.has(id)) continue;\n slice.push(id);\n }\n } else {\n const sliceRaw = ids.slice(offset, offset + limit);\n slice = skipExisting ? sliceRaw.filter((id) => !processedIds.has(id)) : sliceRaw;\n }\n\n const headersOut = buildUpstreamHeaders(req.headers);\n if (auth) headersOut['authorization'] = auth;\n if (cookie) headersOut['cookie'] = cookie;\n\n const results = [];\n const errors = [];\n let completed = 0;\n let totalRetries = 0;\n\n // Backoff-aware fetch\n async function fetchWithRetries(url, headersOutLocal) {\n let attempt = 0;\n while (true) {\n try {\n const resp = await fetch(url, { method: 'GET', headers: headersOutLocal });\n if (resp.status === 429 || resp.status === 503) {\n if (attempt >= maxRetries) return { resp };\n // Respect Retry-After if present\n const ra = resp.headers.get('retry-after');\n let waitMs = baseDelayMs * Math.pow(2, attempt);\n if (ra) {\n const raNum = Number(ra);\n if (Number.isFinite(raNum)) waitMs = Math.max(waitMs, raNum * 1000);\n }\n // jitter\n waitMs = Math.round(waitMs * (1 + Math.random() * 0.25));\n attempt++;\n totalRetries++;\n await wait(waitMs);\n continue;\n }\n return { resp };\n } catch (e) {\n if (attempt >= maxRetries) return { error: e };\n const waitMs = Math.round(baseDelayMs * Math.pow(2, attempt) * (1 + Math.random() * 0.25));\n attempt++;\n totalRetries++;\n await wait(waitMs);\n }\n }\n }\n\n async function fetchOne(userId) {\n try {\n const inputParam = JSON.stringify({ userId });\n const url = 'https://common.xyz/api/internal/trpc/user.getUserProfile?input=' + encodeURIComponent(inputParam);\n const { resp, error } = await fetchWithRetries(url, headersOut);\n if (error) {\n errors.push({ userId, error: error?.message || String(error) });\n return;\n }\n const bodyText = await resp.text();\n let xp = undefined;\n try {\n const json = JSON.parse(bodyText);\n xp = findMaxXpPoints(json);\n } catch {}\n results.push({ userId, status: resp.status, ok: resp.ok, xp_points: xp ?? null });\n if (!resp.ok) {\n errors.push({ userId, status: resp.status, bodySample: bodyText.slice(0, 500) });\n }\n } catch (e) {\n errors.push({ userId, error: e?.message || String(e) });\n } finally {\n completed++;\n if (delayMs > 0) await wait(delayMs);\n }\n }\n\n // Simple promise pool\n const queue = slice.slice();\n const workers = Array.from({ length: Math.min(concurrency, queue.length || 0) }, async () => {\n while (queue.length) {\n const id = queue.shift();\n if (id === undefined) break;\n await fetchOne(id);\n }\n });\n await Promise.all(workers);\n\n // Persist results (merge or overwrite)\n await fs.mkdir(path.dirname(outFile), { recursive: true });\n if (outMode === 'merge' && existing && Array.isArray(existing.results)) {\n const byId = new Map();\n for (const r of existing.results) {\n if (r && (r.userId !== undefined && r.userId !== null)) byId.set(Number(r.userId), r);\n }\n for (const r of results) {\n if (r && (r.userId !== undefined && r.userId !== null)) byId.set(Number(r.userId), r);\n }\n const merged = {\n total: (existing.total || 0) + slice.length,\n completed: (existing.completed || 0) + completed,\n results: Array.from(byId.values()),\n errors: Array.isArray(existing.errors) ? existing.errors.concat(errors) : errors,\n };\n await fs.writeFile(outFile, JSON.stringify(merged, null, 2), 'utf8');\n } else {\n await fs.writeFile(outFile, JSON.stringify({ total: slice.length, completed, results, errors }, null, 2), 'utf8');\n }\n\n // Optionally mutate source JSON by removing processed IDs\n let removedFromSource = 0;\n if (mutateSource && slice.length > 0) {\n try {\n const srcText = await fs.readFile(jsonFile, 'utf8');\n const srcArr = JSON.parse(srcText);\n if (Array.isArray(srcArr)) {\n const toRemove = new Set(slice);\n const filtered = [];\n for (const item of srcArr) {\n if (!item || typeof item !== 'object') { filtered.push(item); continue; }\n const idInfo = getIdFromObject(item, idKey);\n if (!idInfo) { filtered.push(item); continue; }\n const num = Number(idInfo.value);\n if (!Number.isFinite(num)) { filtered.push(item); continue; }\n if (toRemove.has(num)) {\n removedFromSource++;\n continue;\n }\n filtered.push(item);\n }\n await fs.writeFile(jsonFile, JSON.stringify(filtered, null, 2), 'utf8');\n }\n } catch {}\n }\n\n return res.json({ ok: true, requested: slice.length, completed, successes: results.length - errors.length, failures: errors.length, skipped: Math.max(0, (limit ?? (ids.length - offset)) - slice.length), retries: totalRetries, outFile, outMode, skipExisting, backfill, mutateSource, removedFromSource });\n } catch (err) {\n return res.status(500).json({ error: 'Profile XP fetch failure', message: err?.message || String(err) });\n }\n});\n\n// GET /xps-ranked/profile-activity-runall\n// concat.json içindeki tüm userId'ler için user.getUserProfile çağırır,\n// addresses, threads, comments, totalUpvotes, commentThreads sayılarını çıkarır.\n// Rate limit'e uyumlu, periyodik yazan ve resume edilebilir tasarım.\n// Query: jsonFile (default output/concat.json), idKey (default user_id), outFile (default output/profile-activity.json),\n// concurrency (2), targetRps (1), flushEvery (100), baseDelayMs (500), maxRetries (6)\napp.get('/xps-ranked/profile-activity-runall', async (req, res) => {\n try {\n const { authorization: auth, cookie } = getAuthCookieFromRequest(req);\n if (!auth && !cookie) {\n return res.status(400).json({ error: 'Missing credentials: provide Authorization and/or Cookie (header or query param)' });\n }\n\n const jsonFile = req.query.jsonFile ? String(req.query.jsonFile) : path.join(process.cwd(), 'output', 'concat.json');\n const idKey = req.query.idKey ? String(req.query.idKey) : 'user_id';\n const outFile = req.query.outFile ? String(req.query.outFile) : path.join(process.cwd(), 'output', 'profile-activity.json');\n const skipExisting = String(req.query.skipExisting ?? 'true').toLowerCase() !== 'false';\n const concurrency = Math.max(1, Number(req.query.concurrency ?? 2) || 2);\n const targetRps = Math.max(0.2, Number(req.query.targetRps ?? 1) || 1);\n const flushEvery = Math.max(10, Number(req.query.flushEvery ?? 100) || 100);\n const baseDelayMs = Math.max(100, Number(req.query.baseDelayMs ?? 500) || 500);\n const maxRetries = Math.max(0, Number(req.query.maxRetries ?? 6) || 6);\n\n const text = await fs.readFile(jsonFile, 'utf8');\n const arr = JSON.parse(text);\n if (!Array.isArray(arr)) return res.status(400).json({ error: 'Invalid JSON: expected top-level array' });\n\n // Unique userIds\n const seen = new Set();\n const allIds = [];\n for (const item of arr) {\n if (!item || typeof item !== 'object') continue;\n const idInfo = getIdFromObject(item, idKey);\n if (!idInfo) continue;\n const num = Number(idInfo.value);\n if (!Number.isFinite(num) || num <= 0) continue;\n if (!seen.has(num)) { seen.add(num); allIds.push(num); }\n }\n\n // Resume from existing (skip IDs already present in outFile results)\n let existing = undefined;\n const doneIds = new Set();\n try {\n const prev = JSON.parse(await fs.readFile(outFile, 'utf8'));\n existing = prev;\n const resultsPrev = Array.isArray(prev?.results) ? prev.results : [];\n for (const r of resultsPrev) {\n if (!r) continue;\n // Be robust to different key casings\n const maybeId = r.userId ?? r.user_id ?? r.id;\n const num = Number(maybeId);\n if (Number.isFinite(num)) doneIds.add(num);\n }\n } catch {}\n\n const queue = skipExisting ? allIds.filter((id) => !doneIds.has(id)) : allIds.slice();\n\n const headersOut = buildUpstreamHeaders(req.headers);\n if (auth) headersOut['authorization'] = auth;\n if (cookie) headersOut['cookie'] = cookie;\n\n // Throttle logic\n let lastRequestTs = 0;\n const minInterval = 1000 / Math.max(0.1, targetRps);\n async function throttle() {\n const now = Date.now();\n const elapsed = now - lastRequestTs;\n if (elapsed < minInterval) await wait(minInterval - elapsed);\n lastRequestTs = Date.now();\n }\n\n async function fetchWithRetries(url) {\n let attempt = 0;\n while (true) {\n await throttle();\n try {\n const resp = await fetch(url, { method: 'GET', headers: headersOut });\n if (resp.status === 429 || resp.status === 503) {\n if (attempt >= maxRetries) return { resp };\n const ra = resp.headers.get('retry-after');\n let waitMs = baseDelayMs * Math.pow(2, attempt);\n if (ra) {\n const raNum = Number(ra);\n if (Number.isFinite(raNum)) waitMs = Math.max(waitMs, raNum * 1000);\n }\n await wait(waitMs);\n attempt++;\n continue;\n }\n return { resp };\n } catch (e) {\n if (attempt >= maxRetries) return { error: e };\n await wait(baseDelayMs * Math.pow(2, attempt));\n attempt++;\n }\n }\n }\n\n const results = existing && Array.isArray(existing.results) ? existing.results.slice() : [];\n const errors = existing && Array.isArray(existing.errors) ? existing.errors.slice() : [];\n let completed = existing?.completed || 0;\n let processedSinceFlush = 0;\n\n async function processOne(userId) {\n const inputParam = JSON.stringify({ userId });\n const url = 'https://common.xyz/api/internal/trpc/user.getUserProfile?input=' + encodeURIComponent(inputParam);\n const { resp, error } = await fetchWithRetries(url);\n if (error) {\n errors.push({ userId, error: error?.message || String(error) });\n completed++;\n processedSinceFlush++;\n return;\n }\n const bodyText = await resp.text();\n let activity = undefined;\n let profileMeta = undefined;\n let refer = undefined;\n let evm = undefined;\n try {\n const json = JSON.parse(bodyText);\n activity = computeProfileActivityCounts(json);\n profileMeta = extractProfileInfo(json);\n refer = extractReferralAndXp(json);\n evm = extractPrimaryEvmAddress(json);\n } catch {}\n results.push({ userId, status: resp.status, ok: resp.ok, ...activity, ...(profileMeta || {}), ...(refer || {}), ...(evm || {}) });\n if (!resp.ok) {\n errors.push({ userId, status: resp.status, bodySample: bodyText.slice(0, 500) });\n }\n completed++;\n processedSinceFlush++;\n }\n\n async function flushIfNeeded(force = false) {\n if (!force && processedSinceFlush < flushEvery) return;\n await fs.mkdir(path.dirname(outFile), { recursive: true });\n const payload = { total: results.length + errors.length, completed, results, errors };\n await fs.writeFile(outFile, JSON.stringify(payload, null, 2), 'utf8');\n processedSinceFlush = 0;\n }\n\n const workers = Array.from({ length: Math.min(concurrency, queue.length || 0) }, async () => {\n while (true) {\n const id = queue.shift();\n if (id === undefined) break;\n await processOne(id);\n await flushIfNeeded(false);\n }\n });\n await Promise.all(workers);\n await flushIfNeeded(true);\n\n return res.json({ ok: true, processed: completed, remaining: queue.length, results: results.length, errors: errors.length, outFile, skippedExisting: skipExisting ? doneIds.size : 0 });\n } catch (err) {\n return res.status(500).json({ error: 'Profile activity runall failure', message: err?.message || String(err) });\n }\n});\n\n// GET /xps-ranked/profile-xp-retry-errors\n// Retries failed userIds found in output/profile-xp.json errors[] and updates results/errors in-place.\n// Query: outFile (default output/profile-xp.json), limit (default 100), concurrency (2), targetRps (1),\n// baseDelayMs (500), maxRetries (6), filterStatus (comma-separated), flushEvery (50)\napp.get('/xps-ranked/profile-xp-retry-errors', async (req, res) => {\n try {\n const { authorization: auth, cookie } = getAuthCookieFromRequest(req);\n if (!auth && !cookie) {\n return res.status(400).json({ error: 'Missing credentials: provide Authorization and/or Cookie (header or query param)' });\n }\n\n const outFile = req.query.outFile ? String(req.query.outFile) : path.join(process.cwd(), 'output', 'profile-xp.json');\n const limit = Math.max(1, Number(req.query.limit ?? 100) || 100);\n const concurrency = Math.max(1, Number(req.query.concurrency ?? 2) || 2);\n const targetRps = Math.max(0.2, Number(req.query.targetRps ?? 1) || 1);\n const baseDelayMs = Math.max(100, Number(req.query.baseDelayMs ?? 500) || 500);\n const maxRetries = Math.max(0, Number(req.query.maxRetries ?? 6) || 6);\n const flushEvery = Math.max(10, Number(req.query.flushEvery ?? 50) || 50);\n const filterStatusRaw = req.query.filterStatus ? String(req.query.filterStatus) : undefined; // e.g., \"429,503\"\n const filterStatuses = filterStatusRaw ? new Set(filterStatusRaw.split(',').map((s) => Number(s.trim())).filter((n) => Number.isFinite(n))) : undefined;\n\n // Load existing output\n let data;\n try {\n data = JSON.parse(await fs.readFile(outFile, 'utf8'));\n } catch (e) {\n return res.status(400).json({ error: 'Cannot read output file', message: e?.message || String(e), outFile });\n }\n const results = Array.isArray(data?.results) ? data.results : [];\n const errors = Array.isArray(data?.errors) ? data.errors : [];\n\n // Build retry ID list from errors\n const idsSet = new Set();\n for (const e of errors) {\n if (!e) continue;\n const id = e.userId ?? e.userid ?? e.id;\n const num = Number(id);\n if (!Number.isFinite(num)) continue;\n if (filterStatuses && e.status !== undefined && e.status !== null) {\n const st = Number(e.status);\n if (!filterStatuses.has(st)) continue;\n }\n idsSet.add(num);\n if (idsSet.size >= limit) break;\n }\n const queue = Array.from(idsSet);\n if (queue.length === 0) {\n return res.json({ ok: true, message: 'No error IDs to retry (or filtered out).', outFile });\n }\n\n const headersOut = buildUpstreamHeaders(req.headers);\n if (auth) headersOut['authorization'] = auth;\n if (cookie) headersOut['cookie'] = cookie;\n\n // Timing control\n let lastRequestTs = 0;\n const minInterval = 1000 / Math.max(0.1, targetRps);\n async function throttle() {\n const now = Date.now();\n const elapsed = now - lastRequestTs;\n if (elapsed < minInterval) await wait(minInterval - elapsed);\n lastRequestTs = Date.now();\n }\n\n async function fetchWithRetries(userId) {\n let attempt = 0;\n while (true) {\n await throttle();\n try {\n const inputParam = JSON.stringify({ userId });\n const url = 'https://common.xyz/api/internal/trpc/user.getUserProfile?input=' + encodeURIComponent(inputParam);\n const resp = await fetch(url, { method: 'GET', headers: headersOut });\n if (resp.status === 429 || resp.status === 503) {\n if (attempt >= maxRetries) return { resp };\n const ra = resp.headers.get('retry-after');\n let waitMs = baseDelayMs * Math.pow(2, attempt);\n if (ra) {\n const raNum = Number(ra);\n if (Number.isFinite(raNum)) waitMs = Math.max(waitMs, raNum * 1000);\n }\n await wait(waitMs);\n attempt++;\n continue;\n }\n return { resp };\n } catch (e) {\n if (attempt >= maxRetries) return { error: e };\n await wait(baseDelayMs * Math.pow(2, attempt));\n attempt++;\n }\n }\n }\n\n // Index for quick update\n const resultById = new Map();\n for (const r of results) {\n if (r && (r.userId !== undefined && r.userId !== null)) resultById.set(Number(r.userId), r);\n }\n\n function removeErrorsFor(id) {\n for (let i = errors.length - 1; i >= 0; i--) {\n const e = errors[i];\n const num = Number(e?.userId ?? e?.userid ?? e?.id);\n if (Number.isFinite(num) && num === id) errors.splice(i, 1);\n }\n }\n\n let processed = 0;\n let fixed = 0;\n let stillFail = 0;\n\n async function processOne(id) {\n const { resp, error } = await fetchWithRetries(id);\n if (error) {\n removeErrorsFor(id);\n errors.push({ userId: id, error: error?.message || String(error) });\n stillFail++;\n } else {\n const bodyText = await resp.text();\n let xp = undefined;\n try {\n const json = JSON.parse(bodyText);\n xp = findMaxXpPoints(json);\n } catch {}\n const entry = { userId: id, status: resp.status, ok: resp.ok, xp_points: xp ?? null };\n resultById.set(id, entry);\n removeErrorsFor(id);\n if (!resp.ok) {\n errors.push({ userId: id, status: resp.status, bodySample: bodyText.slice(0, 500) });\n stillFail++;\n } else {\n fixed++;\n }\n }\n processed++;\n }\n\n async function flush() {\n const newResults = Array.from(resultById.values());\n const payload = {\n total: data.total ?? newResults.length,\n completed: data.completed ?? newResults.length,\n results: newResults,\n errors,\n };\n await fs.writeFile(outFile, JSON.stringify(payload, null, 2), 'utf8');\n }\n\n const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {\n let sinceFlush = 0;\n while (true) {\n const id = queue.shift();\n if (id === undefined) break;\n await processOne(id);\n sinceFlush++;\n if (sinceFlush >= flushEvery) {\n await flush();\n sinceFlush = 0;\n }\n }\n });\n await Promise.all(workers);\n await flush();\n\n return res.json({ ok: true, attempted: processed, fixed, stillFail, outFile });\n } catch (err) {\n return res.status(500).json({ error: 'Profile XP retry-errors failure', message: err?.message || String(err) });\n }\n});\n\n// GET /xps-ranked/profile-activity-retry-errors\n// output/profile-activity.json içindeki errors[] listesinden userId'leri alır (opsiyonel status filtreli),\n// user.getUserProfile'ı tekrar çağırır, activity/metalar/referral ve user_evm'i yeniden hesaplar,\n// results/errors dizilerini günceller ve dosyaya yazar.\n// Query: outFile (default output/profile-activity.json), limit (100), concurrency (2), targetRps (1),\n// baseDelayMs (500), maxRetries (6), filterStatus (comma-separated), flushEvery (50)\napp.get('/xps-ranked/profile-activity-retry-errors', async (req, res) => {\n try {\n const { authorization: auth, cookie } = getAuthCookieFromRequest(req);\n if (!auth && !cookie) {\n return res.status(400).json({ error: 'Missing credentials: provide Authorization and/or Cookie (header or query param)' });\n }\n\n const outFile = req.query.outFile ? String(req.query.outFile) : path.join(process.cwd(), 'output', 'profile-activity.json');\n const limit = Math.max(1, Number(req.query.limit ?? 100) || 100);\n const concurrency = Math.max(1, Number(req.query.concurrency ?? 2) || 2);\n const targetRps = Math.max(0.2, Number(req.query.targetRps ?? 1) || 1);\n const baseDelayMs = Math.max(100, Number(req.query.baseDelayMs ?? 500) || 500);\n const maxRetries = Math.max(0, Number(req.query.maxRetries ?? 6) || 6);\n const flushEvery = Math.max(10, Number(req.query.flushEvery ?? 50) || 50);\n const filterStatusRaw = req.query.filterStatus ? String(req.query.filterStatus) : undefined; // e.g., \"429,503,500\"\n const filterStatuses = filterStatusRaw ? new Set(filterStatusRaw.split(',').map((s) => Number(s.trim())).filter((n) => Number.isFinite(n))) : undefined;\n\n // Mevcut çıktıyı yükle\n let data;\n try {\n data = JSON.parse(await fs.readFile(outFile, 'utf8'));\n } catch (e) {\n return res.status(400).json({ error: 'Cannot read output file', message: e?.message || String(e), outFile });\n }\n const results = Array.isArray(data?.results) ? data.results : [];\n const errors = Array.isArray(data?.errors) ? data.errors : [];\n\n // Retry ID listesi (errors içinden)\n const idsSet = new Set();\n for (const e of errors) {\n if (!e) continue;\n const id = e.userId ?? e.userid ?? e.id;\n const num = Number(id);\n if (!Number.isFinite(num)) continue;\n if (filterStatuses && e.status !== undefined && e.status !== null) {\n const st = Number(e.status);\n if (!filterStatuses.has(st)) continue;\n }\n idsSet.add(num);\n if (idsSet.size >= limit) break;\n }\n const queue = Array.from(idsSet);\n if (queue.length === 0) {\n return res.json({ ok: true, message: 'No error IDs to retry (or filtered out).', outFile });\n }\n\n const headersOut = buildUpstreamHeaders(req.headers);\n if (auth) headersOut['authorization'] = auth;\n if (cookie) headersOut['cookie'] = cookie;\n\n // Zamanlama kontrolü\n let lastRequestTs = 0;\n const minInterval = 1000 / Math.max(0.1, targetRps);\n async function throttle() {\n const now = Date.now();\n const elapsed = now - lastRequestTs;\n if (elapsed < minInterval) await wait(minInterval - elapsed);\n lastRequestTs = Date.now();\n }\n\n async function fetchWithRetries(userId) {\n let attempt = 0;\n while (true) {\n await throttle();\n try {\n const inputParam = JSON.stringify({ userId });\n const url = 'https://common.xyz/api/internal/trpc/user.getUserProfile?input=' + encodeURIComponent(inputParam);\n const resp = await fetch(url, { method: 'GET', headers: headersOut });\n if (resp.status === 429 || resp.status === 503) {\n if (attempt >= maxRetries) return { resp };\n const ra = resp.headers.get('retry-after');\n let waitMs = baseDelayMs * Math.pow(2, attempt);\n if (ra) {\n const raNum = Number(ra);\n if (Number.isFinite(raNum)) waitMs = Math.max(waitMs, raNum * 1000);\n }\n await wait(waitMs);\n attempt++;\n continue;\n }\n return { resp };\n } catch (e) {\n if (attempt >= maxRetries) return { error: e };\n await wait(baseDelayMs * Math.pow(2, attempt));\n attempt++;\n }\n }\n }\n\n // Güncelleme için index\n const resultById = new Map();\n for (const r of results) {\n if (r && (r.userId !== undefined && r.userId !== null)) resultById.set(Number(r.userId), r);\n }\n\n function removeErrorsFor(id) {\n for (let i = errors.length - 1; i >= 0; i--) {\n const e = errors[i];\n const num = Number(e?.userId ?? e?.userid ?? e?.id);\n if (Number.isFinite(num) && num === id) errors.splice(i, 1);\n }\n }\n\n let processed = 0;\n let fixed = 0;\n let stillFail = 0;\n\n async function processOne(id) {\n const { resp, error } = await fetchWithRetries(id);\n if (error) {\n removeErrorsFor(id);\n errors.push({ userId: id, error: error?.message || String(error) });\n stillFail++;\n } else {\n const bodyText = await resp.text();\n let activity, profileMeta, refer, evm;\n try {\n const json = JSON.parse(bodyText);\n activity = computeProfileActivityCounts(json);\n profileMeta = extractProfileInfo(json);\n refer = extractReferralAndXp(json);\n evm = extractPrimaryEvmAddress(json);\n } catch {}\n const entry = { userId: id, status: resp.status, ok: resp.ok, ...(activity || {}), ...(profileMeta || {}), ...(refer || {}), ...(evm || {}) };\n resultById.set(id, entry);\n removeErrorsFor(id);\n if (!resp.ok) {\n errors.push({ userId: id, status: resp.status, bodySample: bodyText.slice(0, 500) });\n stillFail++;\n } else {\n fixed++;\n }\n }\n processed++;\n }\n\n async function flush() {\n const newResults = Array.from(resultById.values());\n const payload = {\n total: data.total ?? newResults.length,\n completed: data.completed ?? newResults.length,\n results: newResults,\n errors,\n };\n await fs.writeFile(outFile, JSON.stringify(payload, null, 2), 'utf8');\n }\n\n const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {\n let sinceFlush = 0;\n while (true) {\n const id = queue.shift();\n if (id === undefined) break;\n await processOne(id);\n sinceFlush++;\n if (sinceFlush >= flushEvery) {\n await flush();\n sinceFlush = 0;\n }\n }\n });\n await Promise.all(workers);\n await flush();\n\n return res.json({ ok: true, attempted: processed, fixed, stillFail, outFile });\n } catch (err) {\n return res.status(500).json({ error: 'Profile activity retry-errors failure', message: err?.message || String(err) });\n }\n});\n\n// GET /xps-ranked/opensea-lamumu-runall\n// profile-activity.json içinden user_evm ve userId alır; OpenSea v2 API ile cüzdandaki NFT'leri çeker,\n// hedef contract (Lamumu) için toplam NFT sayısını hesaplar. Sonuçları JSON dosyasına yazar.\n// Query: activityFile (default output/profile-activity.json), outFile (default output/opensea-lamumu.json),\n// contract (default 0x47d7b6116c2303f4d0232c767f71e00db166b67a), chain (default ethereum),\n// onlyOk=true|false, limit, offset, concurrency (2), targetRps (1), baseDelayMs (500), maxRetries (6),\n// flushEvery (50), maxPages (10). API key: header 'x-api-key' veya 'x-opensea-api-key' ya da query 'openseaApiKey'.\napp.get('/xps-ranked/opensea-lamumu-runall', async (req, res) => {\n try {\n // API key'i al\n const hdrApiKey = req.headers['x-api-key'] || req.headers['x-opensea-api-key'];\n const qKey = typeof req.query.openseaApiKey === 'string' ? req.query.openseaApiKey : undefined;\n const apiKey = (hdrApiKey || qKey || process.env.OPENSEA_API_KEY);\n\n const activityFile = req.query.activityFile ? String(req.query.activityFile) : path.join(process.cwd(), 'output', 'profile-activity.json');\n const outFile = req.query.outFile ? String(req.query.outFile) : path.join(process.cwd(), 'output', 'opensea-lamumu.json');\n const contract = (req.query.contract ? String(req.query.contract) : '0x47d7b6116c2303f4d0232c767f71e00db166b67a').toLowerCase();\n const chain = (req.query.chain ? String(req.query.chain) : 'ethereum').toLowerCase();\n const onlyOk = String(req.query.onlyOk ?? 'true').toLowerCase() !== 'false';\n const limit = req.query.limit ? Number(req.query.limit) : undefined;\n const offset = Number(req.query.offset ?? 0) || 0;\n const concurrency = Math.max(1, Number(req.query.concurrency ?? 2) || 2);\n const targetRps = Math.max(0.2, Number(req.query.targetRps ?? 1) || 1);\n const baseDelayMs = Math.max(100, Number(req.query.baseDelayMs ?? 500) || 500);\n const maxRetries = Math.max(0, Number(req.query.maxRetries ?? 6) || 6);\n const flushEvery = Math.max(10, Number(req.query.flushEvery ?? 50) || 50);\n const maxPages = Math.max(1, Number(req.query.maxPages ?? 10) || 10);\n\n // Girdi: profile-activity.json\n let activity;\n try {\n activity = JSON.parse(await fs.readFile(activityFile, 'utf8'));\n } catch (e) {\n return res.status(400).json({ error: 'Cannot read activity file', message: e?.message || String(e), activityFile });\n }\n const arr = Array.isArray(activity?.results) ? activity.results : [];\n if (arr.length === 0) {\n return res.status(400).json({ error: 'No results in activity file', activityFile });\n }\n\n // Çıktıdan resume\n let existing = undefined;\n const doneKeys = new Set();\n try {\n existing = JSON.parse(await fs.readFile(outFile, 'utf8'));\n const prev = Array.isArray(existing?.results) ? existing.results : [];\n for (const r of prev) {\n const k = r && r.userId != null ? `${r.userId}|${String(r.user_evm || '').toLowerCase()}` : undefined;\n if (k) doneKeys.add(k);\n }\n } catch {}\n\n // İş kuyruğu: userId + user_evm olanlar\n const items = [];\n for (const r of arr) {\n if (!r || typeof r !== 'object') continue;\n if (onlyOk && r.ok === false) continue;\n const userId = r.userId ?? r.userid ?? r.id;\n const user_evm = r.user_evm;\n const uid = Number(userId);\n if (!Number.isFinite(uid) || uid <= 0) continue;\n if (!user_evm || typeof user_evm !== 'string') continue;\n const key = `${uid}|${user_evm.toLowerCase()}`;\n if (doneKeys.has(key)) continue;\n items.push({ userId: uid, user_evm });\n }\n\n // limit/offset uygula\n let slice = items.slice(offset);\n if (typeof limit === 'number') slice = slice.slice(0, limit);\n if (slice.length === 0) {\n return res.json({ ok: true, message: 'Nothing to process (empty slice after resume/filters).', outFile });\n }\n\n // RPS kontrolü\n let lastRequestTs = 0;\n const minInterval = 1000 / Math.max(0.1, targetRps);\n async function throttle() {\n const now = Date.now();\n const elapsed = now - lastRequestTs;\n if (elapsed < minInterval) await wait(minInterval - elapsed);\n lastRequestTs = Date.now();\n }\n\n function normalizeAddr(a) {\n if (!a) return undefined;\n let s = String(a).trim();\n if (!s.startsWith('0x') && /^[0-9a-fA-F]+$/.test(s)) s = '0x' + s;\n return s.toLowerCase();\n }\n\n function countForContractInPage(json, target) {\n let list = undefined;\n if (Array.isArray(json?.nfts)) list = json.nfts;\n else if (Array.isArray(json?.assets)) list = json.assets;\n else if (Array.isArray(json?.items)) list = json.items;\n if (!Array.isArray(list)) return 0;\n let c = 0;\n for (const it of list) {\n if (!it || typeof it !== 'object') continue;\n // possible shapes: { contract: '0x...' } or { contract: { address: '0x...' } } or { collection: { address: '0x...' } }\n let addr;\n if (typeof it.contract === 'string') addr = it.contract;\n else if (it.contract && typeof it.contract === 'object' && typeof it.contract.address === 'string') addr = it.contract.address;\n else if (it.collection && typeof it.collection === 'object' && typeof it.collection.address === 'string') addr = it.collection.address;\n if (addr && normalizeAddr(addr) === target) c++;\n }\n return c;\n }\n\n async function fetchOpenSeaPage(address, nextParam) {\n await throttle();\n const baseUrl = `https://api.opensea.io/api/v2/chain/${encodeURIComponent(chain)}/account/${encodeURIComponent(address)}/nfts`;\n const url = new URL(baseUrl);\n if (nextParam && typeof nextParam === 'string') {\n // Most v2 endpoints use 'next' as the cursor param\n url.searchParams.set('next', nextParam);\n }\n const headers = { 'accept': 'application/json', 'x-api-key': String(apiKey) };\n let attempt = 0;\n while (true) {\n try {\n const resp = await fetch(url.toString(), { method: 'GET', headers });\n if (resp.status === 429 || resp.status === 503) {\n if (attempt >= maxRetries) return { resp };\n const ra = resp.headers.get('retry-after');\n let waitMs = baseDelayMs * Math.pow(2, attempt);\n if (ra) {\n const n = Number(ra); if (Number.isFinite(n)) waitMs = Math.max(waitMs, n * 1000);\n }\n await wait(waitMs);\n attempt++;\n continue;\n }\n const text = await resp.text();\n let json = undefined;\n try { json = JSON.parse(text); } catch {}\n return { resp, json, body: text };\n } catch (e) {\n if (attempt >= maxRetries) return { error: e };\n await wait(baseDelayMs * Math.pow(2, attempt));\n attempt++;\n }\n }\n }\n\n async function processOne(userId, user_evm) {\n const addr = normalizeAddr(user_evm);\n if (!addr) return { entry: { userId, user_evm, ok: false }, error: { userId, user_evm, error: 'invalid evm address' } };\n let total = 0;\n let pages = 0;\n let cursor = undefined;\n let lastStatus = undefined;\n for (let i = 0; i < maxPages; i++) {\n const { resp, json, body, error } = await fetchOpenSeaPage(addr, cursor);\n if (error) {\n return { entry: { userId, user_evm: addr, ok: false }, error: { userId, user_evm: addr, error: error?.message || String(error) } };\n }\n lastStatus = resp?.status;\n if (!resp?.ok) {\n return { entry: { userId, user_evm: addr, ok: false, status: lastStatus }, error: { userId, user_evm: addr, status: lastStatus, bodySample: (body || '').slice(0, 500) } };\n }\n pages++;\n if (json) total += countForContractInPage(json, contract);\n // Cursor detection: common keys 'next', 'next_cursor', 'continuation'\n const nextCur = (typeof json?.next === 'string' && json.next)\n || (typeof json?.next_cursor === 'string' && json.next_cursor)\n || (typeof json?.continuation === 'string' && json.continuation)\n || undefined;\n if (!nextCur) break;\n cursor = nextCur;\n }\n return { entry: { userId, user_evm: addr, ok: true, lamumu_count: total, pages, status: lastStatus } };\n }\n\n const results = existing && Array.isArray(existing.results) ? existing.results.slice() : [];\n const errors = existing && Array.isArray(existing.errors) ? existing.errors.slice() : [];\n let completed = existing?.completed || 0;\n let sinceFlush = 0;\n\n async function flush(force = false) {\n if (!force && sinceFlush < flushEvery) return;\n await fs.mkdir(path.dirname(outFile), { recursive: true });\n const payload = { total: results.length + errors.length, completed, contract, chain, results, errors };\n await fs.writeFile(outFile, JSON.stringify(payload, null, 2), 'utf8');\n sinceFlush = 0;\n }\n\n const queue = slice.slice();\n const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {\n while (true) {\n const item = queue.shift();\n if (!item) break;\n const { entry, error } = await processOne(item.userId, item.user_evm);\n if (entry) results.push(entry);\n if (error) errors.push(error);\n completed++;\n sinceFlush++;\n await flush(false);\n }\n });\n await Promise.all(workers);\n await flush(true);\n\n return res.json({ ok: true, processed: completed, results: results.length, errors: errors.length, outFile, contract, chain });\n } catch (err) {\n return res.status(500).json({ error: 'OpenSea Lamumu runall failure', message: err?.message || String(err) });\n }\n});\n\n// GET /xps-ranked/profile-stats-lamumu-build\n// Joins activity stats with Lamumu counts and writes a merged JSON file.\n// Params:\n// - activityFile: input activity file (default output/profile-activity.json)\n// - lamumuFile: input lamumu results file (default output/opensea-lamumu.json)\n// - outFile: output merged file (default output/profile_stats_lamumu.json)\n// - onlyOk: filter activity by ok=true (default true)\n// - limit, offset: optional slicing over activity entries\napp.get('/xps-ranked/profile-stats-lamumu-build', async (req, res) => {\n try {\n const activityFile = req.query.activityFile ? String(req.query.activityFile) : path.join(process.cwd(), 'output', 'profile-activity.json');\n const lamumuFile = req.query.lamumuFile ? String(req.query.lamumuFile) : path.join(process.cwd(), 'output', 'opensea-lamumu.json');\n const outFile = req.query.outFile ? String(req.query.outFile) : path.join(process.cwd(), 'output', 'profile_stats_lamumu.json');\n const onlyOk = String(req.query.onlyOk ?? 'true').toLowerCase() !== 'false';\n const limit = req.query.limit ? Number(req.query.limit) : undefined;\n const offset = Number(req.query.offset ?? 0) || 0;\n\n // Read activity file\n let activity;\n try {\n activity = JSON.parse(await fs.readFile(activityFile, 'utf8'));\n } catch (e) {\n return res.status(400).json({ error: 'Cannot read activity file', message: e?.message || String(e), activityFile });\n }\n const actArr = Array.isArray(activity?.results) ? activity.results : [];\n if (actArr.length === 0) return res.status(400).json({ error: 'No results in activity file', activityFile });\n\n // Read lamumu file\n let lamumu;\n try {\n lamumu = JSON.parse(await fs.readFile(lamumuFile, 'utf8'));\n } catch (e) {\n lamumu = undefined; // allow missing; default counts to 0\n }\n const lamArr = Array.isArray(lamumu?.results) ? lamumu.results : [];\n // Build map of userId -> total lamumu_count (sum in case multiple addresses)\n const lamByUser = new Map();\n for (const r of lamArr) {\n if (!r || typeof r !== 'object') continue;\n const uid = Number(r.userId ?? r.userid ?? r.id);\n if (!Number.isFinite(uid)) continue;\n const c = Number(r.lamumu_count) || 0;\n lamByUser.set(uid, (lamByUser.get(uid) || 0) + c);\n }\n\n // Prepare slice of activity entries\n let items = actArr.filter((r) => {\n if (!r || typeof r !== 'object') return false;\n if (onlyOk && r.ok === false) return false;\n const uid = Number(r.userId ?? r.userid ?? r.id);\n return Number.isFinite(uid) && uid > 0;\n });\n items = items.slice(offset, typeof limit === 'number' ? offset + limit : undefined);\n\n const results = [];\n for (const r of items) {\n const user_id = Number(r.userId ?? r.userid ?? r.id) || 0;\n const display_name = r?.profile?.name ?? null;\n const avatar_url = r?.profile?.avatar_url ?? null;\n const xp_points = toNumber(r.xp_points) || 0;\n const addresses = toNumber(r.addresses) || 0;\n const threads = toNumber(r.threads) || 0;\n const comments = toNumber(r.comments) || 0;\n const total_upvotes = toNumber(r.totalUpvotes) || 0;\n const comment_threads = toNumber(r.commentThreads) || 0;\n const user_evm = typeof r.user_evm === 'string' ? r.user_evm : null;\n const lamumu_count = lamByUser.get(user_id) || 0;\n results.push({ user_id, display_name, avatar_url, xp_points, addresses, threads, comments, total_upvotes, comment_threads, user_evm, lamumu_count });\n }\n\n // Persist output\n await fs.mkdir(path.dirname(outFile), { recursive: true });\n const payload = { total: results.length, completed: results.length, results };\n await fs.writeFile(outFile, JSON.stringify(payload, null, 2), 'utf8');\n\n return res.json({ ok: true, outFile, total: results.length });\n } catch (err) {\n return res.status(500).json({ error: 'profile-stats-lamumu-build failure', message: err?.message || String(err) });\n }\n});\n\n// GET /xps-ranked/profile-xp-stats\n// Reads output/profile-xp.json and reports total xp_points and count of users over a threshold.\n// Query: jsonFile (default output/profile-xp.json), threshold (default 1000), onlyOk (default true)\napp.get('/xps-ranked/profile-xp-stats', async (req, res) => {\n try {\n const jsonFile = req.query.jsonFile ? String(req.query.jsonFile) : path.join(process.cwd(), 'output', 'profile-xp.json');\n const threshold = Number(req.query.threshold ?? 1000) || 1000;\n const onlyOk = String(req.query.onlyOk ?? 'true').toLowerCase() !== 'false';\n\n const text = await fs.readFile(jsonFile, 'utf8');\n const data = JSON.parse(text);\n const results = Array.isArray(data?.results) ? data.results : undefined;\n if (!results) {\n return res.status(400).json({ error: 'Invalid profile-xp JSON: results array not found' });\n }\n\n let totalXp = 0;\n let usersCounted = 0;\n let overThreshold = 0;\n for (const r of results) {\n if (!r || typeof r !== 'object') continue;\n if (onlyOk && r.ok === false) continue;\n const n = toNumber(r.xp_points);\n if (n === undefined) continue;\n usersCounted++;\n totalXp += n;\n if (n > threshold) overThreshold++;\n }\n\n const avgXp = usersCounted > 0 ? totalXp / usersCounted : 0;\n return res.json({ ok: true, jsonFile, usersCounted, totalXpPoints: totalXp, threshold, overThreshold, avgXpPoints: avgXp });\n } catch (err) {\n return res.status(500).json({ error: 'Profile XP stats failure', message: err?.message || String(err) });\n }\n});\n\n// POST/GET /xps-ranked/update-referred-by-from-merged\n// Reads output/profile_stats_lamumu.json (merged file) and updates\n// Supabase user_profile_stats.referred_by_address for each user_id using user_evm.\n// Query/body params:\n// file: path to input JSON (default output/profile_stats_lamumu.json)\n// overwrite: true|false (default false) — overwrite non-empty values\n// dryRun: true|false (default false) — do not write, only return plan\n// batchSize: number (default 100)\n// offset: number (default 0), limit: number (optional)\n// onlyValidAddr: true|false (default true) — require 0x...40 format\napp.all('/xps-ranked/update-referred-by-from-merged', async (req, res) => {\n if (!requireAdminInProd(req, res)) return;\n try {\n const file = String((req.body?.file ?? req.query.file) || path.join(process.cwd(), 'output', 'profile_stats_lamumu.json'));\n const overwrite = String(req.body?.overwrite ?? req.query.overwrite ?? 'false').toLowerCase() === 'true';\n const dryRun = String(req.body?.dryRun ?? req.query.dryRun ?? 'false').toLowerCase() === 'true';\n const onlyValidAddr = String(req.body?.onlyValidAddr ?? req.query.onlyValidAddr ?? 'true').toLowerCase() !== 'false';\n const batchSize = Math.max(1, Number(req.body?.batchSize ?? req.query.batchSize ?? 100) || 100);\n const sleepMs = Math.max(0, Number(req.body?.sleepMs ?? req.query.sleepMs ?? process.env.UPDATE_SLEEP_MS ?? 0) || 0);\n const concurrency = Math.max(1, Number(req.body?.concurrency ?? req.query.concurrency ?? process.env.UPDATE_CONCURRENCY ?? 5) || 5);\n const offset = Number(req.body?.offset ?? req.query.offset ?? 0) || 0;\n const limit = req.body?.limit != null || req.query.limit != null ? Number(req.body?.limit ?? req.query.limit) : undefined;\n\n // Read file\n let json;\n try {\n json = JSON.parse(await fs.readFile(file, 'utf8'));\n } catch (e) {\n return res.status(400).json({ error: 'Cannot read input file', file, message: e?.message || String(e) });\n }\n const arr = Array.isArray(json?.results) ? json.results : Array.isArray(json) ? json : [];\n if (!Array.isArray(arr) || arr.length === 0) {\n return res.status(400).json({ error: 'No rows found in input', file });\n }\n\n // Slice\n const slice = arr.slice(offset, typeof limit === 'number' ? offset + limit : undefined);\n // Normalize entries\n const items = slice.map((r) => {\n const user_id = Number(r.user_id ?? r.userId ?? r.id);\n let user_evm = typeof r.user_evm === 'string' ? r.user_evm : (typeof r.userEvm === 'string' ? r.userEvm : null);\n if (typeof user_evm === 'string') user_evm = user_evm.trim().toLowerCase();\n return { user_id, user_evm };\n }).filter(r => Number.isFinite(r.user_id) && r.user_id > 0 && (r.user_evm || !onlyValidAddr));\n\n const isValidAddr = (a) => /^0x[a-f0-9]{40}$/.test(String(a || ''));\n const valid = items.filter(r => onlyValidAddr ? isValidAddr(r.user_evm) : true);\n\n const sb = getSupabase();\n\n // Simple concurrency pool runner\n const runPool = async (fns, c) => {\n let i = 0;\n const workers = Array(Math.min(c, fns.length)).fill(0).map(async () => {\n while (true) {\n const idx = i++;\n if (idx >= fns.length) break;\n await fns[idx]();\n }\n });\n await Promise.all(workers);\n };\n\n const results = [];\n let toUpdate = 0, updated = 0, skipped = 0, errors = 0;\n\n // Process in batches to reduce query sizes when selecting existing\n for (let i = 0; i < valid.length; i += batchSize) {\n const chunk = valid.slice(i, i + batchSize);\n const ids = chunk.map(x => x.user_id);\n\n // Fetch existing referred_by_address for this chunk when needed\n let existing = new Map();\n if (!overwrite) {\n try {\n const r = await sb.from('user_profile_stats').select('user_id,referred_by_address').in('user_id', ids);\n if (!r.error && Array.isArray(r.data)) {\n for (const row of r.data) {\n existing.set(Number(row.user_id), typeof row.referred_by_address === 'string' ? row.referred_by_address.toLowerCase() : (row.referred_by_address ?? null));\n }\n }\n } catch {}\n }\n\n const tasks = chunk.map((it) => async () => {\n const current = overwrite ? null : existing.get(it.user_id);\n const shouldUpdate = overwrite ? true : (!current || String(current).trim() === '');\n if (!shouldUpdate) { skipped++; results.push({ user_id: it.user_id, action: 'skip', current }); return; }\n toUpdate++;\n if (dryRun) { results.push({ user_id: it.user_id, action: 'would-update', value: it.user_evm }); return; }\n try {\n const up = await sb.from('user_profile_stats').update({ referred_by_address: it.user_evm }).eq('user_id', it.user_id);\n if (up.error) { errors++; results.push({ user_id: it.user_id, action: 'error', message: up.error.message }); }\n else { updated++; results.push({ user_id: it.user_id, action: 'updated', value: it.user_evm }); }\n } catch (e) {\n errors++; results.push({ user_id: it.user_id, action: 'error', message: e?.message || String(e) });\n }\n if (sleepMs > 0) await wait(sleepMs);\n });\n\n await runPool(tasks, concurrency);\n }\n\n const summary = {\n ok: true,\n file,\n totalInFile: arr.length,\n processed: valid.length,\n toUpdate,\n updated,\n skipped,\n errors,\n overwrite,\n dryRun,\n batchSize,\n offset,\n limit: typeof limit === 'number' ? limit : null,\n sample: results.slice(0, 20),\n sleepMs,\n concurrency,\n };\n return res.json(summary);\n } catch (err) {\n return res.status(500).json({ error: 'update-referred-by-from-merged failure', message: err?.message || String(err) });\n }\n});\n\n// GET /xps-ranked/profile-xp-sum\n// Sums all xp_points from output/profile-xp.json (optionally only ok=true entries)\n// Query: jsonFile (default output/profile-xp.json), onlyOk=true|false\napp.get('/xps-ranked/profile-xp-sum', async (req, res) => {\n try {\n const jsonFile = req.query.jsonFile ? String(req.query.jsonFile) : path.join(process.cwd(), 'output', 'profile-xp.json');\n const onlyOk = String(req.query.onlyOk ?? 'true').toLowerCase() !== 'false';\n\n const text = await fs.readFile(jsonFile, 'utf8');\n const data = JSON.parse(text);\n const results = Array.isArray(data?.results) ? data.results : undefined;\n if (!results) return res.status(400).json({ error: 'Invalid file: results array missing', jsonFile });\n\n let totalXp = 0;\n let counted = 0;\n for (const r of results) {\n if (!r || typeof r !== 'object') continue;\n if (onlyOk && r.ok === false) continue;\n const n = toNumber(r.xp_points);\n if (n === undefined) continue;\n totalXp += n;\n counted++;\n }\n return res.json({ ok: true, jsonFile, onlyOk, counted, totalXpPoints: totalXp });\n } catch (err) {\n return res.status(500).json({ error: 'Profile XP sum failure', message: err?.message || String(err) });\n }\n});\n\n// GET /xps-ranked/profile-xp-sum-20250926\n// Sums all xp_points from output/profile-xp-20250926.json (optionally only ok=true entries)\n// Query: onlyOk=true|false, jsonFile (optional override; default output/profile-xp-20250926.json)\napp.get('/xps-ranked/profile-xp-sum-20250926', async (req, res) => {\n try {\n const defaultFile = path.join(process.cwd(), 'output', 'profile-xp-20250926.json');\n const jsonFile = req.query.jsonFile ? String(req.query.jsonFile) : defaultFile;\n const onlyOk = String(req.query.onlyOk ?? 'true').toLowerCase() !== 'false';\n\n const text = await fs.readFile(jsonFile, 'utf8');\n const data = JSON.parse(text);\n const results = Array.isArray(data?.results) ? data.results : undefined;\n if (!results) return res.status(400).json({ error: 'Invalid file: results array missing', jsonFile });\n\n let totalXp = 0;\n let counted = 0;\n for (const r of results) {\n if (!r || typeof r !== 'object') continue;\n if (onlyOk && r.ok === false) continue;\n const n = toNumber(r.xp_points);\n if (n === undefined) continue;\n totalXp += n;\n counted++;\n }\n return res.json({ ok: true, jsonFile, onlyOk, counted, totalXpPoints: totalXp });\n } catch (err) {\n return res.status(500).json({ error: 'Profile XP 20250926 sum failure', message: err?.message || String(err) });\n }\n});\n\n// GET /xps-ranked/activity-xp-sum\n// Sums all xp_points from output/profile-activity.json (optionally only ok=true entries)\n// Also returns totals for addresses, threads, comments, totalUpvotes, commentThreads.\n// Query: activityFile (default output/profile-activity.json), onlyOk=true|false, threshold (default 1000)\napp.get('/xps-ranked/activity-xp-sum', async (req, res) => {\n try {\n const activityFile = req.query.activityFile ? String(req.query.activityFile) : path.join(process.cwd(), 'output', 'profile-activity.json');\n const onlyOk = String(req.query.onlyOk ?? 'true').toLowerCase() !== 'false';\n const threshold = Number(req.query.threshold ?? 1000) || 1000;\n\n const text = await fs.readFile(activityFile, 'utf8');\n const data = JSON.parse(text);\n const results = Array.isArray(data?.results) ? data.results : undefined;\n if (!results) return res.status(400).json({ error: 'Invalid file: results array missing', activityFile });\n\n let totalXp = 0;\n let counted = 0;\n let zeroXpCount = 0;\n let overThreshold = 0;\n let totalAddresses = 0;\n let totalThreads = 0;\n let totalComments = 0;\n let totalUpvotes = 0;\n let totalCommentThreads = 0;\n for (const r of results) {\n if (!r || typeof r !== 'object') continue;\n if (onlyOk && r.ok === false) continue;\n const n = toNumber(r.xp_points);\n if (n === undefined) continue;\n totalXp += n;\n counted++;\n if (n === 0) zeroXpCount++;\n if (n > threshold) overThreshold++;\n totalAddresses += toNumber(r.addresses) || 0;\n totalThreads += toNumber(r.threads) || 0;\n totalComments += toNumber(r.comments) || 0;\n totalUpvotes += toNumber(r.totalUpvotes) || 0;\n totalCommentThreads += toNumber(r.commentThreads) || 0;\n }\n return res.json({ ok: true, activityFile, onlyOk, counted, totalXpPoints: totalXp, zeroXpCount, threshold, overThreshold, totalAddresses, totalThreads, totalComments, totalUpvotes, totalCommentThreads });\n } catch (err) {\n return res.status(500).json({ error: 'Activity XP sum failure', message: err?.message || String(err) });\n }\n});\n\n// Root → summary (no -html)\napp.get('/', (req, res) => {\n const q = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';\n return res.redirect(308, '/summary' + q);\n});\n\n// Root-level page routes will serve content directly (no /xps-ranked prefix)\n// Canonical pages without -html\napp.get('/common-checker', async (req, res) => {\n try { await renderCommonCheckerPage(req, res); } catch (err) { console.error('common-checker error:', err); return res.status(500).send('Internal Server Error'); }\n});\napp.get('/avg-allocation', (req, res) => {\n const q = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';\n return res.redirect(308, '/common-checker' + q);\n});\napp.get('/summary', async (req, res) => {\n try {\n // Directly invoke the summary handler logic without changing the visible URL\n const xpFile = req.query.xpFile ? String(req.query.xpFile) : path.join(process.cwd(), 'output', 'profile-xp.json');\n const activityFile = req.query.activityFile ? String(req.query.activityFile) : path.join(process.cwd(), 'output', 'profile-activity.json');\n const onlyOk = String(req.query.onlyOk ?? 'true').toLowerCase() !== 'false';\n const outFile = req.query.outFile ? String(req.query.outFile) : path.join(process.cwd(), 'output', 'summary.html');\n const writeFile = String(req.query.writeFile ?? 'true').toLowerCase() !== 'false';\n const canWrite = writeFile && !(process.env.VERCEL || process.env.NODE_ENV === 'production');\n const threshold = Number(req.query.threshold ?? 1000) || 1000;\n const useSupabase = String(req.query.useSupabase ?? 'true').toLowerCase() !== 'false';\n\n // Reuse the same logic as /xps-ranked/summary-html by calling its implementation\n // We simulate a call by requiring the function body inline here using a small wrapper\n // to avoid changing the URL or causing a redirect.\n // Implementation: call the existing handler function by invoking a local function.\n const handler = app._router.stack.find(layer => layer.route && layer.route.path === '/xps-ranked/summary-html')?.route?.stack?.[0]?.handle;\n if (typeof handler === 'function') {\n // Construct a shallow-cloned req with same query but without URL rewrite\n const proxyReq = Object.assign(Object.create(Object.getPrototypeOf(req)), req);\n await handler(proxyReq, res);\n return;\n }\n\n // Fallback: if we couldn't find the existing handler, read and return the static file\n try {\n const html = await fs.readFile(outFile, 'utf8');\n res.setHeader('content-type', 'text/html; charset=utf-8');\n return res.status(200).send(html);\n } catch {\n return res.status(500).send('Summary handler not available');\n }\n } catch (err) { console.error('summary error:', err); return res.status(500).send('Internal Server Error'); }\n});\napp.get('/users', async (req, res) => {\n try {\n req._internalForward = true;\n req.url = req.originalUrl.replace('/users', '/xps-ranked/users-html');\n await app._router.handle(req, res, () => {});\n } catch (err) { console.error('users error:', err); return res.status(500).send('Internal Server Error'); }\n});\n\n// Backwards compatibility: redirect old -html routes to new canonical\napp.get('/common-checker-html', (req, res) => {\n const q = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';\n return res.redirect(308, '/common-checker' + q);\n});\napp.get('/avg-allocation-html', (req, res) => {\n const q = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';\n return res.redirect(308, '/common-checker' + q);\n});\napp.get('/summary-html', (req, res) => {\n const q = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';\n return res.redirect(308, '/summary' + q);\n});\napp.get('/users-html', (req, res) => {\n const q = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';\n return res.redirect(308, '/users' + q);\n});\n// Hide lamumu-holders page: redirect to users\napp.get('/lamumu-holders-html', (req, res) => {\n const q = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';\n return res.redirect(308, '/users' + q);\n});\napp.get('/xps-ranked/lamumu-holders-html', (req, res) => {\n const q = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';\n return res.redirect(308, '/users' + q);\n});\n\n// Helpers for Common Checker\nfunction normalizeEvm(addr) {\n if (!addr) return undefined;\n let s = String(addr).trim();\n if (!s) return undefined;\n if (!s.startsWith('0x')) {\n // If it's hex without 0x, add it\n if (/^[0-9a-fA-F]+$/.test(s)) s = '0x' + s;\n }\n if (!/^0x[0-9a-fA-F]{40}$/.test(s)) return undefined;\n return s.toLowerCase();\n}\n\nasync function findUserIdByEvmFromLocal(address, activityFile) {\n try {\n const txt = await fs.readFile(activityFile, 'utf8');\n const data = JSON.parse(txt);\n const arr = Array.isArray(data?.results) ? data.results : [];\n for (const r of arr) {\n const evm = r?.user_evm && String(r.user_evm).toLowerCase();\n if (evm === address) {\n const uid = Number(r.userId ?? r.userid ?? r.id);\n if (Number.isFinite(uid) && uid > 0) return uid;\n }\n }\n } catch {}\n return undefined;\n}\n\nasync function countLamumuForAddress(address, opts = {}) {\n const chain = (opts.chain || 'ethereum').toLowerCase();\n const contract = (opts.contract || '0x47d7b6116c2303f4d0232c767f71e00db166b67a').toLowerCase();\n const apiKey = opts.apiKey;\n const maxPages = Math.max(1, Math.min(15, Number(opts.maxPages || 5)));\n const baseDelayMs = Math.max(100, Number(opts.baseDelayMs || 500));\n const maxRetries = Math.max(0, Number(opts.maxRetries || 4));\n\n function countForContractInPage(json, target) {\n let list = undefined;\n if (Array.isArray(json?.nfts)) list = json.nfts;\n else if (Array.isArray(json?.assets)) list = json.assets;\n else if (Array.isArray(json?.items)) list = json.items;\n if (!Array.isArray(list)) return 0;\n let c = 0;\n for (const it of list) {\n if (!it || typeof it !== 'object') continue;\n let addr;\n if (typeof it.contract === 'string') addr = it.contract;\n else if (it.contract && typeof it.contract === 'object' && typeof it.contract.address === 'string') addr = it.contract.address;\n else if (it.collection && typeof it.collection === 'object' && typeof it.collection.address === 'string') addr = it.collection.address;\n if (addr && String(addr).toLowerCase() === target) c++;\n }\n return c;\n }\n\n async function fetchOpenSeaPage(nextParam) {\n const baseUrl = `https://api.opensea.io/api/v2/chain/${encodeURIComponent(chain)}/account/${encodeURIComponent(address)}/nfts`;\n const url = new URL(baseUrl);\n if (nextParam) url.searchParams.set('next', nextParam);\n const headers = { 'accept': 'application/json' };\n if (apiKey) headers['x-api-key'] = String(apiKey);\n let attempt = 0;\n while (true) {\n try {\n const resp = await fetch(url.toString(), { method: 'GET', headers });\n if (resp.status === 429 || resp.status === 503) {\n if (attempt >= maxRetries) return { resp };\n const ra = resp.headers.get('retry-after');\n let waitMs = baseDelayMs * Math.pow(2, attempt);\n if (ra) {\n const n = Number(ra); if (Number.isFinite(n)) waitMs = Math.max(waitMs, n * 1000);\n }\n await wait(waitMs);\n attempt++;\n continue;\n }\n const body = await resp.text();\n let json = undefined;\n try { json = JSON.parse(body); } catch {}\n return { resp, json, body };\n } catch (e) {\n if (attempt >= maxRetries) return { error: e };\n await wait(baseDelayMs * Math.pow(2, attempt));\n attempt++;\n }\n }\n }\n\n let total = 0;\n let cursor = undefined;\n let pages = 0;\n let lastStatus = undefined;\n for (let i = 0; i < maxPages; i++) {\n const { resp, json, body, error } = await fetchOpenSeaPage(cursor);\n if (error) return { ok: false, error: error?.message || String(error) };\n lastStatus = resp?.status;\n if (!resp?.ok) return { ok: false, status: lastStatus, bodySample: (body || '').slice(0, 500) };\n pages++;\n if (json) total += countForContractInPage(json, contract);\n const nextCur = (typeof json?.next === 'string' && json.next)\n || (typeof json?.next_cursor === 'string' && json.next_cursor)\n || (typeof json?.continuation === 'string' && json.continuation)\n || undefined;\n if (!nextCur) break;\n cursor = nextCur;\n }\n return { ok: true, count: total, pages, status: lastStatus };\n}\n\n// Fetch Lamumu token holdings (token identifiers) for an address from OpenSea API\nasync function fetchLamumuHoldingsForAddress(address, opts = {}) {\n const chain = (opts.chain || 'ethereum').toLowerCase();\n const contract = (opts.contract || '0x47d7b6116c2303f4d0232c767f71e00db166b67a').toLowerCase();\n const apiKey = opts.apiKey;\n const maxPages = Math.max(1, Math.min(10, Number(opts.maxPages || 2)));\n const baseDelayMs = Math.max(100, Number(opts.baseDelayMs || 500));\n const maxRetries = Math.max(0, Number(opts.maxRetries || 4));\n\n function tokensForContractInPage(json, target) {\n let list = undefined;\n if (Array.isArray(json?.nfts)) list = json.nfts;\n else if (Array.isArray(json?.assets)) list = json.assets;\n else if (Array.isArray(json?.items)) list = json.items;\n if (!Array.isArray(list)) return [];\n const out = [];\n for (const it of list) {\n if (!it || typeof it !== 'object') continue;\n let addr;\n if (typeof it.contract === 'string') addr = it.contract;\n else if (it.contract && typeof it.contract === 'object' && typeof it.contract.address === 'string') addr = it.contract.address;\n else if (it.collection && typeof it.collection === 'object' && typeof it.collection.address === 'string') addr = it.collection.address;\n if (addr && String(addr).toLowerCase() !== target) continue;\n // identifier fields may differ; try common keys\n const idKeys = ['identifier', 'token_id', 'tokenId', 'nft_id', 'id'];\n let tokenId;\n for (const k of idKeys) { if (it[k] != null) { tokenId = String(it[k]); break; } }\n // Some responses nest identifier under it.identifier or it.nft.identifier\n if (!tokenId && it.nft && typeof it.nft === 'object') {\n for (const k of idKeys) { if (it.nft[k] != null) { tokenId = String(it.nft[k]); break; } }\n }\n if (!tokenId) continue;\n const name = (typeof it.name === 'string' && it.name) || (typeof it.nft?.name === 'string' && it.nft.name) || undefined;\n out.push({ token_id: tokenId, name });\n }\n return out;\n }\n\n async function fetchOpenSeaPage(nextParam) {\n const baseUrl = `https://api.opensea.io/api/v2/chain/${encodeURIComponent(chain)}/account/${encodeURIComponent(address)}/nfts`;\n const url = new URL(baseUrl);\n if (nextParam) url.searchParams.set('next', nextParam);\n const headers = { 'accept': 'application/json' };\n if (apiKey) headers['x-api-key'] = String(apiKey);\n let attempt = 0;\n while (true) {\n try {\n const resp = await fetch(url.toString(), { method: 'GET', headers });\n if (resp.status === 429 || resp.status === 503) {\n if (attempt >= maxRetries) return { resp };\n const ra = resp.headers.get('retry-after');\n let waitMs = baseDelayMs * Math.pow(2, attempt);\n if (ra) { const n = Number(ra); if (Number.isFinite(n)) waitMs = Math.max(waitMs, n * 1000); }\n await wait(waitMs);\n attempt++; continue;\n }\n const body = await resp.text();\n let json = undefined; try { json = JSON.parse(body); } catch {}\n return { resp, json, body };\n } catch (e) {\n if (attempt >= maxRetries) return { error: e };\n await wait(baseDelayMs * Math.pow(2, attempt)); attempt++;\n }\n }\n }\n\n const tokens = [];\n let cursor = undefined;\n let pages = 0;\n let lastStatus = undefined;\n for (let i = 0; i < maxPages; i++) {\n const { resp, json, body, error } = await fetchOpenSeaPage(cursor);\n if (error) return { ok: false, error: error?.message || String(error) };\n lastStatus = resp?.status;\n if (!resp?.ok) return { ok: false, status: lastStatus, bodySample: (body || '').slice(0, 500) };\n pages++;\n if (json) tokens.push(...tokensForContractInPage(json, contract));\n const nextCur = (typeof json?.next === 'string' && json.next)\n || (typeof json?.next_cursor === 'string' && json.next_cursor)\n || (typeof json?.continuation === 'string' && json.continuation)\n || undefined;\n if (!nextCur) break; cursor = nextCur;\n }\n return { ok: true, tokens, pages, status: lastStatus };\n}\n\n// Fetch rarity rank for a single Lamumu token via OpenSea v2 token endpoint\nasync function fetchLamumuTokenRarity({ chain = 'ethereum', contract = '0x47d7b6116c2303f4d0232c767f71e00db166b67a', tokenId, apiKey, baseDelayMs = 400, maxRetries = 3 }) {\n if (!tokenId) return { ok: false, error: 'missing tokenId' };\n const url = `https://api.opensea.io/api/v2/chain/${encodeURIComponent(chain)}/contract/${encodeURIComponent(contract)}/nfts/${encodeURIComponent(String(tokenId))}`;\n const headers = { 'accept': 'application/json' };\n if (apiKey) headers['x-api-key'] = String(apiKey);\n let attempt = 0;\n while (true) {\n try {\n const resp = await fetch(url, { method: 'GET', headers });\n if (resp.status === 429 || resp.status === 503) {\n if (attempt >= maxRetries) return { ok: false, status: resp.status };\n const ra = resp.headers.get('retry-after');\n let waitMs = baseDelayMs * Math.pow(2, attempt);\n if (ra) { const n = Number(ra); if (Number.isFinite(n)) waitMs = Math.max(waitMs, n * 1000); }\n await wait(waitMs); attempt++; continue;\n }\n const body = await resp.text();\n let json = undefined; try { json = JSON.parse(body); } catch {}\n if (!resp.ok) return { ok: false, status: resp.status, bodySample: (body||'').slice(0,400) };\n // Probe common fields for rarity rank\n const rar = (json && (json.rarity || json.nft?.rarity || json.data?.rarity)) || {};\n let rank = toNumber(rar.rank);\n if (!Number.isFinite(rank)) {\n // Some payloads may have rarity_rank or ranking.rank\n rank = toNumber(json?.rarity_rank) ?? toNumber(json?.nft?.rarity_rank) ?? toNumber(json?.ranking?.rank) ?? undefined;\n }\n return { ok: true, rank: Number.isFinite(rank) ? rank : undefined, json };\n } catch (e) {\n if (attempt >= maxRetries) return { ok: false, error: e?.message || String(e) };\n await wait(baseDelayMs * Math.pow(2, attempt)); attempt++;\n }\n }\n}\n\n// Common Checker page renderer (shared by both canonical paths)\nasync function renderCommonCheckerPage(req, res) {\n const q = typeof req.query.q === 'string' ? req.query.q.trim() : '';\n const qType = (() => {\n if (!q) return '';\n const isAddr = /^0x[a-fA-F0-9]{40}$/.test(q);\n return isAddr ? 'EVM Address' : 'Unknown';\n })();\n const esc = (s) => String(s ?? '').replace(/[&<>\"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[c]));\n\n // Global: compute Per Aura with split pool (default 32,500,000 / summary_stats.total_aura)\n let perAuraVal = null;\n // Global: compute Per Activity Unit with split pool (default 32,500,000 / (total_threads+total_comments+total_upvotes))\n let perActivityUnitVal = null;\n let perAuraBlock = '';\n // Optional override for total Aura denominator (e.g., use Tier 4+ users total)\n const totalAuraOverride = (() => {\n const raw = (process.env.AURA_TOTAL_AURA_OVERRIDE ?? req.query.totalAura);\n if (raw == null) return null;\n const n = Number(String(raw).replace(/[,_\\s]/g, ''));\n return (Number.isFinite(n) && n > 0) ? n : null;\n })();\n // Global: compute $COMMON price using total supply 10,000,000,000 and optional valuation\n const TOTAL_COMMON_SUPPLY = 10_000_000_000;\n const valuationRaw = req.query.valuationUsd ?? process.env.COMMON_VALUATION_USD;\n const valuationUsd = valuationRaw != null ? Number(String(valuationRaw).replace(/[,\\s$]/g, '')) : null;\n const pricePerCommon = (valuationUsd != null && Number.isFinite(valuationUsd) && valuationUsd > 0)\n ? (valuationUsd / TOTAL_COMMON_SUPPLY)\n : null;\n const fmtPrice = (x) => (typeof x === 'number' && Number.isFinite(x) ? x.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 8 }) : '—');\n let priceBlock = '';\n // Global: compute Per Lamumu (env-configurable; defaults 85,000,000 / 4,500)\n const LAMUMU_SUPPLY_GLOBAL = Number(process.env.LAMUMU_POOL_SUPPLY ?? 85_000_000);\n const LAMUMU_TOTAL_GLOBAL = Number(process.env.LAMUMU_TOTAL_COUNT ?? 4_500);\n const lamumuPerVal = LAMUMU_SUPPLY_GLOBAL / LAMUMU_TOTAL_GLOBAL;\n const fmtDecGlobal = (x) => (typeof x === 'number' && Number.isFinite(x) ? x.toLocaleString('en-US', { maximumFractionDigits: 6 }) : '—');\n let lamumuPerBlock = '';\n let perUnitBlock = '';\n // Read allocation percentages from env for the notice (defaults if missing)\n const lamumuAllocPct = parsePct(process.env.LAMUMU_ALLOCATION_PERCENT, 0.9);\n const auraAllocPct = parsePct(process.env.AURA_ALLOCATION_PERCENT, 0.6);\n // Track the total Aura denominator actually used for Per Aura (for UI hint)\n let totalAuraUsed = null;\n try {\n const sb = getSupabase();\n let totalAura = null;\n let sumRow = null;\n // Pull total_aura plus activity totals for activity-based allocation\n let ss = await sb.from('summary_stats').select('id,total_aura,total_threads,total_comments,total_upvotes,updated_at').eq('id', 1).maybeSingle();\n if (!ss.error && ss.data) {\n sumRow = ss.data;\n } else {\n const ss2 = await sb.from('summary_stats').select('id,total_aura,total_threads,total_comments,total_upvotes,updated_at').limit(1);\n if (!ss2.error && Array.isArray(ss2.data) && ss2.data[0]) sumRow = ss2.data[0];\n }\n if (sumRow && sumRow.total_aura != null) totalAura = Number(sumRow.total_aura);\n totalAuraUsed = totalAuraOverride ?? totalAura;\n // Per Aura (Aura pool share)\n if (Number.isFinite(totalAuraUsed) && totalAuraUsed > 0) {\n const AURA_AURA_POOL_SUPPLY = Number(process.env.AURA_AURA_POOL_SUPPLY ?? 32_500_000);\n perAuraVal = AURA_AURA_POOL_SUPPLY / totalAuraUsed;\n }\n // Per Activity Unit (Activity pool share)\n const actTotals = sumRow || {};\n const tThreads = Number(actTotals.total_threads);\n const tComments = Number(actTotals.total_comments);\n const tUpvotes = Number(actTotals.total_upvotes);\n const denom = [tThreads, tComments, tUpvotes]\n .map((n) => (Number.isFinite(n) && n > 0 ? n : 0))\n .reduce((a, b) => a + b, 0);\n if (denom > 0) {\n const AURA_ACTIVITY_POOL_SUPPLY = Number(process.env.AURA_ACTIVITY_POOL_SUPPLY ?? 32_500_000);\n perActivityUnitVal = AURA_ACTIVITY_POOL_SUPPLY / denom;\n }\n } catch {}\n\n // Combined Per-Unit block (Per Aura only) — Per Lamumu removed per request\n perUnitBlock = `\n <div class=\"row\" style=\"display:flex; gap:14px; flex-wrap:wrap; margin-top:14px\">\n <div class=\"card\" style=\"flex:1; min-width:260px\">\n <div class=\"title\">Per Aura</div>\n <div class=\"title\" style=\"margin-top:6px\">${fmtDecGlobal(perAuraVal)}</div>\n ${(Number.isFinite(totalAuraUsed) && totalAuraUsed > 0) ? `<div class=\\\"hint\\\">Total Aura used in calc: ${Number(totalAuraUsed).toLocaleString('en-US')} ${totalAuraOverride != null ? '(Tier 4+ users)' : ''}</div>` : ''}\n </div>\n </div>`;\n\n // Results blocks\n let resultBlock = '';\n // EVM address flow\n if (q && qType === 'EVM Address') {\n const isProd = (process.env.VERCEL || process.env.NODE_ENV === 'production');\n const addr = normalizeEvm(q);\n const msgs = [];\n let lamumuCount = null;\n let lamumuTokens = null; // [{token_id, name?}]\n let userId = null;\n let aura = null;\n // For activity-based allocation\n let userActivityPoints = null; // threads + comments + total_upvotes\n\n // 1) OpenSea Lamumu count\n // Use server-side key only (env); if missing, skip OpenSea calls and stay silent in production\n const apiKey = (process.env.OPENSEA_API_KEY);\n try {\n if (apiKey) {\n const lam = await countLamumuForAddress(addr, {\n apiKey,\n chain: String(req.query.chain || 'ethereum'),\n contract: String(req.query.contract || '0x47d7b6116c2303f4d0232c767f71e00db166b67a'),\n maxPages: Number(req.query.maxPages || 5),\n });\n if (lam.ok) {\n lamumuCount = lam.count;\n } else {\n // Do not surface raw OpenSea errors in UI; log server-side for debugging\n if (!isProd) msgs.push('OpenSea verisi alınamadı.');\n console.warn('OpenSea count error', { status: lam.status, err: lam.bodySample || lam.error });\n }\n if (lam.ok && lam.count > 0) {\n const det = await fetchLamumuHoldingsForAddress(addr, {\n apiKey,\n chain: String(req.query.chain || 'ethereum'),\n contract: String(req.query.contract || '0x47d7b6116c2303f4d0232c767f71e00db166b67a'),\n maxPages: Number(req.query.lamPages || 2),\n });\n if (det.ok) {\n lamumuTokens = det.tokens;\n // If no local rarity file, enrich first few tokens with rank via OpenSea token endpoint\n const envFetchDefault = (process.env.LAMUMU_FETCH_RARITY_DEFAULT ?? null);\n const defaultFetch = (envFetchDefault != null)\n ? String(envFetchDefault).toLowerCase() !== 'false'\n : (apiKey ? true : (!isProd));\n const fetchRarity = String(req.query.fetchRarity ?? String(defaultFetch)).toLowerCase() !== 'false';\n if (fetchRarity && Array.isArray(lamumuTokens) && lamumuTokens.length) {\n const rarityFile = req.query.rarityFile ? String(req.query.rarityFile) : path.join(process.cwd(), 'output', 'lamumu-rarity.json');\n let hasLocal = false;\n // Treat HTTP/HTTPS URL as available source to avoid unnecessary API calls\n if (/^https?:\\/\\//i.test(rarityFile)) {\n hasLocal = true;\n } else {\n try { await fs.access(rarityFile); hasLocal = true; } catch {}\n }\n if (!hasLocal) {\n const limitEnv = process.env.LAMUMU_RARITY_FETCH_LIMIT;\n // Cap the per-address rarity fetch to a maximum of 200 tokens\n let limit = Math.max(1, Number(req.query.rarityLimit ?? (limitEnv ?? 10)) || 10);\n limit = Math.min(200, limit);\n const chain = String(req.query.chain || 'ethereum');\n const contract = String(req.query.contract || '0x47d7b6116c2303f4d0232c767f71e00db166b67a');\n for (const t of lamumuTokens.slice(0, limit)) {\n if (!t || t.token_id == null) continue;\n const r = await fetchLamumuTokenRarity({ chain, contract, tokenId: String(t.token_id), apiKey });\n if (r.ok && Number.isFinite(r.rank)) t.rank = Number(r.rank);\n }\n }\n }\n } else {\n if (!isProd) msgs.push('OpenSea detayları alınamadı.');\n console.warn('OpenSea holdings error', { status: det.status, err: det.bodySample || det.error });\n }\n }\n } else {\n // No API key configured; skip OpenSea entirely (avoid 401s in production)\n if (!isProd) msgs.push('OpenSea API key yok; Lamumu verisi atlandı.');\n }\n } catch (e) {\n if (!isProd) msgs.push('OpenSea lookup hata: ' + esc(e?.message || String(e)));\n console.warn('OpenSea lookup exception', e);\n }\n\n // 2) Map EVM -> user_id via local file\n const activityFile = req.query.activityFile ? String(req.query.activityFile) : path.join(process.cwd(), 'output', 'profile-activity.json');\n try {\n const uid = await findUserIdByEvmFromLocal(addr, activityFile);\n if (uid) userId = uid; // if not found, stay silent\n } catch (e) {\n msgs.push('Local mapping lookup failed: ' + esc(e?.message || String(e)));\n }\n\n // 3) Supabase fallback by EVM: try referred_by_address -> resolve user_id and/or Aura\n if (!userId || aura == null) {\n try {\n const sb = getSupabase();\n const r = await sb.from('user_profile_stats').select('user_id,xp_points,referred_by_address').ilike('referred_by_address', addr).maybeSingle();\n if (!r.error && r.data) {\n if (!userId) {\n const uid = Number(r.data.user_id);\n if (Number.isFinite(uid) && uid > 0) userId = uid;\n }\n if (aura == null && r.data.xp_points != null) {\n const n = Number(r.data.xp_points);\n if (Number.isFinite(n)) aura = n;\n }\n }\n } catch (e) {\n msgs.push('Supabase address lookup failed: ' + esc(e?.message || String(e)));\n }\n }\n\n // 4) Fetch xp_points (Aura) from Supabase by user_id if still needed\n if (userId && aura == null) {\n try {\n const sb = getSupabase();\n let got = null;\n // Prefer user_profile_stats.xp_points\n const r1 = await sb.from('user_profile_stats').select('xp_points').eq('user_id', userId).maybeSingle();\n if (!r1.error && r1.data && r1.data.xp_points != null) {\n got = Number(r1.data.xp_points);\n } else {\n // Fallback to user_xp\n const r2 = await sb.from('user_xp').select('xp_points').eq('user_id', userId).maybeSingle();\n if (!r2.error && r2.data && r2.data.xp_points != null) got = Number(r2.data.xp_points);\n }\n if (got != null && Number.isFinite(got)) aura = got; else msgs.push('Aura (xp_points) not found in Supabase for this user.');\n } catch (e) {\n msgs.push('Supabase lookup failed: ' + esc(e?.message || String(e)));\n }\n }\n\n // 5) Fetch user activity (threads + comments + total_upvotes) for activity-based allocation\n if (userId && userActivityPoints == null) {\n try {\n const sb = getSupabase();\n // Prefer user_profile_stats\n const r = await sb.from('user_profile_stats').select('threads, comments, total_upvotes').eq('user_id', userId).maybeSingle();\n let thr, com, upv;\n if (!r.error && r.data) {\n thr = Number(r.data.threads);\n com = Number(r.data.comments);\n upv = Number(r.data.total_upvotes);\n } else {\n // Fallback to user_activity\n const r2 = await sb.from('user_activity').select('threads, comments, total_upvotes').eq('user_id', userId).maybeSingle();\n if (!r2.error && r2.data) {\n thr = Number(r2.data.threads);\n com = Number(r2.data.comments);\n upv = Number(r2.data.total_upvotes);\n }\n }\n const okThr = Number.isFinite(thr) && thr > 0 ? thr : 0;\n const okCom = Number.isFinite(com) && com > 0 ? com : 0;\n const okUpv = Number.isFinite(upv) && upv > 0 ? upv : 0;\n userActivityPoints = okThr + okCom + okUpv;\n } catch (e) {\n msgs.push('Supabase activity lookup failed: ' + esc(e?.message || String(e)));\n }\n }\n\n // 6) Combined per-user allocation (Aura, Lamumu, Common Activity, Total) shown after search\n let allocationBlock = '';\n try {\n const fmtDec = (x) => (typeof x === 'number' && Number.isFinite(x) ? x.toLocaleString('en-US', { maximumFractionDigits: 6 }) : '—');\n const fmtUsd = (x) => (typeof x === 'number' && Number.isFinite(x) ? x.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 2 }) : '—');\n const fmtPrice6 = (x) => (typeof x === 'number' && Number.isFinite(x) ? x.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 6 }) : '—');\n const auraAlloc = (perAuraVal != null && aura != null && Number.isFinite(aura)) ? aura * perAuraVal : null;\n\n // Lamumu allocation: rarity-weighted if token-level ranks available, else uniform perLamumu\n let lamAlloc = null;\n let lamAllocHint = '';\n if (Array.isArray(lamumuTokens) && lamumuTokens.length > 0) {\n // Build rarity map once\n const rarityFile = req.query.rarityFile ? String(req.query.rarityFile) : path.join(process.cwd(), 'output', 'lamumu-rarity.json');\n let rarityMap = null; // token_id -> { rank, score }\n try {\n const rarityDataTxt = await fs.readFile(rarityFile, 'utf8');\n const rarityData = JSON.parse(rarityDataTxt);\n rarityMap = new Map();\n if (Array.isArray(rarityData)) {\n for (const r of rarityData) {\n if (!r || typeof r !== 'object') continue;\n const tid = r.token_id != null ? String(r.token_id) : (r.id != null ? String(r.id) : undefined);\n if (!tid) continue;\n const rank = toNumber(r.rank);\n const score = toNumber(r.score) ?? toNumber(r.rarity_score);\n rarityMap.set(tid, { rank, score });\n }\n } else if (rarityData && typeof rarityData === 'object') {\n for (const [k, v] of Object.entries(rarityData)) {\n if (!k) continue; const tid = String(k);\n const rank = toNumber(v?.rank);\n const score = toNumber(v?.score) ?? toNumber(v?.rarity_score);\n rarityMap.set(tid, { rank, score });\n }\n }\n } catch {}\n\n const perTokenByRank = (rank) => (Number.isFinite(rank) && rank <= 350) ? 21100 : (Number.isFinite(rank) && rank <= 750) ? 18500 : 16200;\n let sum = 0;\n for (const t of lamumuTokens) {\n const tid = t?.token_id != null ? String(t.token_id) : undefined;\n let rank = Number.isFinite(toNumber(t?.rank)) ? toNumber(t.rank) : (tid && rarityMap ? toNumber(rarityMap.get(tid)?.rank) : undefined);\n if (!Number.isFinite(rank)) {\n // Fallback: treat token_id as rank if numeric (e.g., #2867 means rank 2867)\n const n = tid != null ? Number(String(tid).replace(/[^0-9.-]/g, '')) : NaN;\n if (Number.isFinite(n)) rank = n;\n }\n sum += perTokenByRank(rank);\n }\n lamAlloc = sum;\n lamAllocHint = 'rarity-weighted';\n } else {\n lamAlloc = (typeof lamumuCount === 'number' && Number.isFinite(lamumuCount)) ? lamumuCount * lamumuPerVal : null;\n lamAllocHint = 'uniform';\n }\n\n const actAlloc = (perActivityUnitVal != null && userActivityPoints != null && Number.isFinite(userActivityPoints)) ? userActivityPoints * perActivityUnitVal : null;\n const totalAlloc = (auraAlloc ?? 0) + (lamAlloc ?? 0) + (actAlloc ?? 0);\n if (auraAlloc != null || lamAlloc != null || actAlloc != null) {\n const FDV_SCENARIOS = [250_000_000, 500_000_000, 1_000_000_000];\n const scenarioHtml = (() => {\n if (!Number.isFinite(totalAlloc) || totalAlloc <= 0) return '';\n const rows = FDV_SCENARIOS.map(fdv => {\n const price = fdv / 10_000_000_000;\n const valTotal = totalAlloc * price;\n const valP1 = (totalAlloc * 0.5) * price;\n const valP2 = (totalAlloc * 0.5) * price;\n const label = fdv >= 1_000_000_000 ? 'FDV 1B' : `FDV ${Math.round(fdv/1_000_000)}M`;\n return `\n <div class=\"card\" style=\"flex:1\">\n <div class=\"label\">${label}</div>\n <div class=\"hint\">Price: ${fmtPrice6(price)}</div>\n <div class=\"row\" style=\"margin-top:8px; gap:10px\">\n <div style=\"flex:1\">\n <div class=\"label\">Phase 1 (50%)</div>\n <div class=\"title\">${fmtUsd(valP1)}</div>\n </div>\n <div style=\"flex:1\">\n <div class=\"label\">Phase 2 (50%)</div>\n <div class=\"title\">${fmtUsd(valP2)}</div>\n </div>\n </div>\n <div class=\"hint\" style=\"margin-top:6px\">Total (100%): ${fmtUsd(valTotal)}</div>\n </div>`;\n }).join('');\n return `\n <div class=\"label\" style=\"margin-top:8px\">Scenario values (2 phases @ 50%/50%)</div>\n <div class=\"row\" style=\"margin-top:8px\">${rows}</div>`;\n })();\n allocationBlock = `\n <div class=\"card\" style=\"margin-top:14px\">\n <div class=\"title\">Your Allocation</div>\n \n <div class=\"row\" style=\"margin-top:10px\">\n <div class=\"card\" style=\"flex:1\">\n <div class=\"label\">Aura Allocation</div>\n <div class=\"title\">${fmtDec(auraAlloc)}</div>\n ${pricePerCommon != null ? `<div class=\"hint\">${fmtUsd(auraAlloc * pricePerCommon)}</div>` : ''}\n </div>\n <div class=\"card\" style=\"flex:1\">\n <div class=\"label\">Lamumu Allocation</div>\n <div class=\"title\">${fmtDec(lamAlloc)}</div>\n ${pricePerCommon != null ? `<div class=\"hint\">${fmtUsd(lamAlloc * pricePerCommon)}</div>` : ''}\n <div class=\"hint\">${lamAllocHint}</div>\n </div>\n <div class=\"card\" style=\"flex:1\">\n <div class=\"label\">Common Activity Allocation</div>\n <div class=\"title\">${fmtDec(actAlloc)}</div>\n ${pricePerCommon != null ? `<div class=\"hint\">${fmtUsd(actAlloc * pricePerCommon)}</div>` : ''}\n </div>\n <div class=\"card\" style=\"flex:1\">\n <div class=\"label\">Total Allocation</div>\n <div class=\"title\">${fmtDec((auraAlloc==null && lamAlloc==null) ? null : totalAlloc)}</div>\n ${pricePerCommon != null ? `<div class=\"hint\">${fmtUsd(totalAlloc * pricePerCommon)}</div>` : ''}\n </div>\n </div>\n ${scenarioHtml}\n </div>`;\n }\n } catch {}\n\n const fmt = (n) => (typeof n === 'number' && Number.isFinite(n) ? n.toLocaleString('en-US') : '—');\n // Optional rarity mapping: output/lamumu-rarity.json (object or array). Each entry should provide token_id and rank/score.\n let lamumuRarityBlock = '';\n try {\n if (Array.isArray(lamumuTokens) && lamumuTokens.length) {\n const rarityFile = req.query.rarityFile ? String(req.query.rarityFile) : path.join(process.cwd(), 'output', 'lamumu-rarity.json');\n let rarityData = null;\n try {\n if (/^https?:\\/\\//i.test(rarityFile)) {\n const resp = await fetch(rarityFile, { headers: { 'accept': 'application/json' } });\n if (resp.ok) {\n const text = await resp.text();\n try { rarityData = JSON.parse(text); } catch {}\n }\n } else {\n rarityData = JSON.parse(await fs.readFile(rarityFile, 'utf8'));\n }\n } catch {}\n let rarityMap = null; // token_id -> { rank?, score? }\n if (rarityData) {\n if (Array.isArray(rarityData)) {\n rarityMap = new Map();\n for (const r of rarityData) {\n if (!r || typeof r !== 'object') continue;\n const tid = r.token_id != null ? String(r.token_id) : (r.id != null ? String(r.id) : undefined);\n if (!tid) continue;\n const rank = toNumber(r.rank);\n const score = toNumber(r.score) ?? toNumber(r.rarity_score);\n rarityMap.set(tid, { rank, score });\n }\n } else if (typeof rarityData === 'object') {\n rarityMap = new Map();\n for (const [k, v] of Object.entries(rarityData)) {\n if (!k) continue; const tid = String(k);\n const rank = toNumber(v?.rank);\n const score = toNumber(v?.score) ?? toNumber(v?.rarity_score);\n rarityMap.set(tid, { rank, score });\n }\n }\n }\n const totalSupply = Number(process.env.LAMUMU_TOTAL_COUNT ?? 4500) || 4500;\n const rows = lamumuTokens.slice(0, 20).map(t => {\n const tid = t.token_id;\n const info = rarityMap?.get(String(tid));\n let rankVal = Number.isFinite(toNumber(t?.rank)) ? toNumber(t.rank) : (info?.rank);\n const rankTxt = (Number.isFinite(rankVal)) ? `Rank ${rankVal} / ${totalSupply}` : '—';\n const scoreTxt = (info?.score && Number.isFinite(info.score)) ? info.score.toLocaleString('en-US', { maximumFractionDigits: 4 }) : '—';\n return `<div class=\"row\" style=\"justify-content:space-between\"><div>#${String(tid)}</div><div class=\"hint\">${rankTxt}${info?.score?` • Score ${scoreTxt}`:''}</div></div>`;\n }).join('');\n const hasAnyApiRank = lamumuTokens.some(t => Number.isFinite(toNumber(t?.rank)));\n lamumuRarityBlock = `\n <div class=\"card\" style=\"margin-top:10px\">\n <div class=\"label\">Lamumu holdings${lamumuTokens.length>20?` (first 20 shown)`:''}</div>\n <div style=\"margin-top:8px; display:flex; flex-direction:column; gap:6px\">${rows || '<div class=\"hint\">No tokens found</div>'}</div>\n ${( !rarityData && !hasAnyApiRank ) ? '<div class=\"hint\" style=\"margin-top:8px\">Rarity data not available. Provide output/lamumu-rarity.json to show ranks.</div>' : ''}\n </div>`;\n }\n } catch {}\n resultBlock = `\n <div class=\"card\" style=\"margin-top:14px\">\n <div class=\"title\">Result</div>\n <div class=\"row\" style=\"margin-top:10px\">\n <div class=\"card\" style=\"flex:1\">\n <div class=\"label\">Lamumu count</div>\n <div class=\"title\">${fmt(lamumuCount)}</div>\n </div>\n <div class=\"card\" style=\"flex:1\">\n <div class=\"label\">Aura (xp_points)</div>\n <div class=\"title\">${fmt(aura)}</div>\n </div>\n <div class=\"card\" style=\"flex:1\">\n <div class=\"label\">Common activity points</div>\n <div class=\"title\">${fmt(userActivityPoints)}</div>\n <div class=\"hint\">threads + comments + upvotes</div>\n </div>\n </div>\n ${(() => { const out = filterUserMsgs(msgs); return out.length ? `<div class=\"hint\" style=\"margin-top:8px\">${out.map(m => esc(m)).join('<br/>')}</div>` : ''; })()}\n </div>` + lamumuRarityBlock + allocationBlock;\n }\n\n const html = `<!DOCTYPE html>\n <html lang=\"en\">\n <head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <title>Common Checker</title>\n <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap\" rel=\"stylesheet\">\n <style>\n :root { --bg:#0b1220; --card:#0f172a; --text:#e5e7eb; --muted:#94a3b8; --accent:#22d3ee; --line:#1e293b; }\n *{box-sizing:border-box}\n body{margin:0; font-family:Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica, Arial, \"Apple Color Emoji\", \"Segoe UI Emoji\"; background:var(--bg); color:var(--text)}\n header{padding:20px 22px; border-bottom:1px solid var(--line); text-align:center}\n .topnav{display:flex; gap:10px; align-items:center; justify-content:center; flex-wrap:wrap}\n .topnav .sep{color:var(--muted)}\n .btn{display:inline-block; padding:8px 12px; border:1px solid var(--line); border-radius:10px; background:transparent; color:var(--text); text-decoration:none; font-size:14px}\n .btn:hover{border-color:var(--accent); color:var(--accent)}\n .btn.current{pointer-events:none; opacity:0.8}\n .container{padding:16px 18px; max-width:1000px; margin:0 auto}\n .card{background:var(--card); border:1px solid var(--line); border-radius:12px; padding:18px}\n .label{color:var(--muted); font-size:13px; text-transform:uppercase; letter-spacing:0.06em}\n .title{font-size:24px; font-weight:700; margin:0 0 10px}\n .row{display:flex; gap:10px; align-items:center; flex-wrap:wrap; margin-top:10px}\n input[type=\"text\"]{flex:1; min-width:300px; padding:10px 12px; border:1px solid var(--line); border-radius:10px; background:#0f172a; color:var(--text); font-size:14px}\n .hint{color:var(--muted); font-size:13px; margin-top:6px}\n .notice{border:2px solid var(--accent); background:rgba(34,211,238,0.12); color:var(--text); padding:14px 16px; border-radius:12px; font-size:15px; font-weight:700;}\n .notice .em{color:var(--accent)}\n </style>\n </head>\n <body>\n <header>\n <div class=\"topnav\">\n <a class=\"btn\" href=\"/summary\">Common Stats</a>\n <span class=\"sep\">•</span>\n <a class=\"btn\" href=\"/users\">Users stats»</a>\n <span class=\"sep\">•</span>\n \n <a class=\"btn current\" href=\"/common-checker\">Common Checker</a>\n </div>\n <div class=\"meta\" style=\"color:#ffffff; font-size:12px;\">Created by <a href=\"https://x.com/0xMelkoreth\" target=\"_blank\" rel=\"noopener\" style=\"color:#ffffff; text-decoration:underline\">Melkor.eth</a></div>\n </header>\n <div class=\"container\">\n <div class=\"card\">\n <div class=\"title\">Common Checker</div>\n <div class=\"label\">Enter EVM address</div>\n <form method=\"GET\" action=\"${esc(req.path)}\" class=\"row\">\n <input type=\"text\" name=\"q\" value=\"${esc(q)}\" placeholder=\"e.g. 0xabc...\" />\n <button class=\"btn\" type=\"submit\">Check</button>\n </form>\n \n </div>\n ${priceBlock}\n ${perUnitBlock}\n ${resultBlock}\n </div>\n </body>\n </html>`;\n res.setHeader('content-type', 'text/html; charset=utf-8');\n return res.status(200).send(html);\n}\n\n// Canonical pages for Common Checker\napp.get('/xps-ranked/common-checker-html', (req, res) => {\n const q = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';\n return res.redirect(308, '/common-checker' + q);\n});\napp.get('/xps-ranked/avg-allocation-html', (req, res) => {\n const q = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';\n return res.redirect(308, '/common-checker' + q);\n});\n\n// GET /xps-ranked/summary-html\n// profile-xp.json ve profile-activity.json dosyalarından toplamları hesaplar ve HTML sayfası döner.\n// Query: xpFile (default output/profile-xp.json), activityFile (default output/profile-activity.json),\n// onlyOk=true|false, outFile (default output/summary.html), writeFile=true|false\napp.get('/xps-ranked/summary-html', async (req, res) => {\n try {\n const xpFile = req.query.xpFile ? String(req.query.xpFile) : path.join(process.cwd(), 'output', 'profile-xp.json');\n const activityFile = req.query.activityFile ? String(req.query.activityFile) : path.join(process.cwd(), 'output', 'profile-activity.json');\n const onlyOk = String(req.query.onlyOk ?? 'true').toLowerCase() !== 'false';\n const outFile = req.query.outFile ? String(req.query.outFile) : path.join(process.cwd(), 'output', 'summary.html');\n const writeFile = String(req.query.writeFile ?? 'true').toLowerCase() !== 'false';\n const canWrite = writeFile && !(process.env.VERCEL || process.env.NODE_ENV === 'production');\n const threshold = Number(req.query.threshold ?? 1000) || 1000;\n const useSupabase = String(req.query.useSupabase ?? 'true').toLowerCase() !== 'false';\n\n // Helpers\n async function readJsonSafe(file) {\n try {\n const txt = await fs.readFile(file, 'utf8');\n return JSON.parse(txt);\n } catch {\n return undefined;\n }\n }\n\n function sumXpPoints(data) {\n const results = Array.isArray(data?.results) ? data.results : [];\n let total = 0;\n let counted = 0;\n let maxXp = 0;\n let overThreshold = 0;\n for (const r of results) {\n if (!r || typeof r !== 'object') continue;\n if (onlyOk && r.ok === false) continue;\n const n = toNumber(r.xp_points);\n if (n === undefined) continue;\n total += n;\n counted++;\n if (n > maxXp) maxXp = n;\n if (n > threshold) overThreshold++;\n }\n const avg = counted > 0 ? total / counted : 0;\n return { total, counted, avg, max: maxXp, overThreshold };\n }\n\n function sumActivity(data) {\n const results = Array.isArray(data?.results) ? data.results : [];\n let addresses = 0;\n let threads = 0;\n let comments = 0;\n let totalUpvotes = 0;\n let commentThreads = 0;\n let counted = 0;\n for (const r of results) {\n if (!r || typeof r !== 'object') continue;\n if (onlyOk && r.ok === false) continue;\n addresses += toNumber(r.addresses) || 0;\n threads += toNumber(r.threads) || 0;\n comments += toNumber(r.comments) || 0;\n totalUpvotes += toNumber(r.totalUpvotes) || 0;\n commentThreads += toNumber(r.commentThreads) || 0;\n counted++;\n }\n return { addresses, threads, comments, totalUpvotes, commentThreads, counted };\n }\n\n let xp = { total: 0, counted: 0, avg: 0, max: 0, overThreshold: 0 };\n let act = { addresses: 0, threads: 0, comments: 0, totalUpvotes: 0, commentThreads: 0, counted: 0 };\n let summaryRow = null;\n let zeroAuraCountCalc = null;\n\n if (useSupabase) {\n // Pull from Supabase tables (default anon key from env)\n const sb = getSupabase();\n // First try summary_stats table for a single-row snapshot\n const { data: sumData, error: sumErr } = await sb.from('summary_stats').select('*').eq('id', 1).maybeSingle();\n if (!sumErr && sumData) {\n summaryRow = sumData;\n }\n // xp (include user_id to optionally filter activity with onlyOk)\n const xpRows = [];\n const pageSize = 2000;\n let from = 0;\n while (true) {\n const to = from + pageSize - 1;\n const { data, error } = await sb.from('user_xp').select('user_id, xp_points, ok').range(from, to);\n if (error) throw error;\n const arr = Array.isArray(data) ? data : [];\n xpRows.push(...arr);\n if (arr.length < pageSize) break;\n from += pageSize;\n if (from > 200000) break;\n }\n if (!summaryRow) {\n const xpDataLike = { results: xpRows.map((r) => ({ xp_points: r.xp_points, ok: r.ok })) };\n xp = sumXpPoints(xpDataLike);\n // compute zero aura users from xpRows when summary snapshot is not available\n zeroAuraCountCalc = xpRows.reduce((acc, r) => {\n if (!r || (onlyOk && r.ok !== true)) return acc;\n const n = toNumber(r.xp_points);\n return acc + (n === 0 ? 1 : 0);\n }, 0);\n }\n\n // activity (no 'ok' column; filter by xp.ok if onlyOk=true)\n const okIds = new Set();\n if (onlyOk) {\n for (const r of xpRows) {\n if (r && r.ok === true && r.user_id != null) okIds.add(Number(r.user_id));\n }\n }\n const actRows = [];\n from = 0;\n while (true) {\n const to = from + pageSize - 1;\n const { data, error } = await sb.from('user_activity').select('user_id, addresses, threads, comments, total_upvotes, comment_threads').range(from, to);\n if (error) throw error;\n const arr = Array.isArray(data) ? data : [];\n actRows.push(...arr);\n if (arr.length < pageSize) break;\n from += pageSize;\n if (from > 200000) break;\n }\n if (!summaryRow) {\n const actFiltered = onlyOk ? actRows.filter((r) => okIds.has(Number(r.user_id))) : actRows;\n const actDataLike = { results: actFiltered.map((r) => ({ addresses: r.addresses, threads: r.threads, comments: r.comments, totalUpvotes: r.total_upvotes, commentThreads: r.comment_threads, ok: true })) };\n act = sumActivity(actDataLike);\n }\n } else {\n // Fallback to file-based\n const xpData = await readJsonSafe(xpFile);\n const activityData = await readJsonSafe(activityFile);\n xp = xpData ? sumXpPoints(xpData) : xp;\n act = activityData ? sumActivity(activityData) : act;\n if (xpData && Array.isArray(xpData.results)) {\n zeroAuraCountCalc = xpData.results.reduce((acc, r) => {\n if (!r || (onlyOk && r.ok === false)) return acc;\n const n = toNumber(r.xp_points);\n return acc + (n === 0 ? 1 : 0);\n }, 0);\n }\n }\n\n const fmt = (n) => (typeof n === 'number' ? n.toLocaleString('en-US') : String(n ?? ''));\n\n const usersCount = summaryRow?.total_users ?? ((act && act.counted) ? act.counted : (xp && xp.counted ? xp.counted : 0));\n const totalAura = summaryRow?.total_aura ?? xp.total;\n const maxAura = summaryRow?.max_aura ?? xp.max;\n const overTh = summaryRow?.users_over_1000 ?? xp.overThreshold;\n const zeroAuraUsers = summaryRow?.zero_aura_count ?? zeroAuraCountCalc ?? 0;\n const sumAddresses = summaryRow?.total_addresses ?? act.addresses;\n const sumThreads = summaryRow?.total_threads ?? act.threads;\n const sumComments = summaryRow?.total_comments ?? act.comments;\n const sumUpvotes = summaryRow?.total_upvotes ?? act.totalUpvotes;\n const sumCommentThreads = summaryRow?.total_comment_threads ?? act.commentThreads;\n const html = `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <title>Common Stats</title>\n <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap\" rel=\"stylesheet\">\n <style>\n :root { --bg:#0b1220; --card:#0f172a; --text:#e5e7eb; --muted:#94a3b8; --accent:#22d3ee; --line:#1e293b; }\n *{box-sizing:border-box}\n body{margin:0; font-family:Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica, Arial, \"Apple Color Emoji\", \"Segoe UI Emoji\"; background:var(--bg); color:var(--text)}\n header{padding:clamp(16px, 3vw, 28px) 20px; border-bottom:1px solid var(--line); text-align:center}\n h1{margin:0; font-size:clamp(18px, 2.5vw, 26px); letter-spacing:0.2px}\n .topnav{display:flex; gap:10px; align-items:center; justify-content:center; flex-wrap:wrap}\n .topnav .sep{color:var(--muted)}\n .btn{display:inline-block; padding:6px 10px; border:1px solid var(--line); border-radius:8px; background:transparent; color:var(--text); text-decoration:none; font-size:13px}\n .btn:hover{border-color:var(--accent); color:var(--accent)}\n .btn.current{pointer-events:none; opacity:0.8}\n .meta{color:var(--muted); font-size:12px; margin-top:6px}\n .container{padding:clamp(12px, 2.2vw, 20px); max-width:1200px; margin:0 auto}\n .grid{display:grid; grid-template-columns: repeat(auto-fit, minmax(230px,1fr)); gap:clamp(10px, 1.6vw, 18px)}\n .card{background:var(--card); border:1px solid var(--line); border-radius:14px; padding:clamp(12px, 2vw, 18px)}\n .label{color:var(--muted); font-size:12px; text-transform:uppercase; letter-spacing:0.06em}\n .value{font-size:clamp(22px, 4.2vw, 34px); font-weight:700; margin-top:8px}\n a{color:var(--accent); text-decoration:none}\n @media (max-width: 520px){ .value{font-weight:700} }\n </style>\n </head>\n <body>\n <header>\n <div class=\"topnav\">\n <a class=\"btn current\" href=\"/summary\">Common Stats</a>\n <span class=\"sep\">•</span>\n <a class=\"btn\" href=\"/users\">Users stats»</a>\n <span class=\"sep\">•</span>\n \n <a class=\"btn\" href=\"/common-checker\">Common Checker</a>\n </div>\n <div class=\"meta\">Data as of 10.10.2025</div>\n <div class=\"meta\" style=\"color:#ffffff; font-size:12px;\">Created by <a href=\"https://x.com/0xMelkoreth\" target=\"_blank\" rel=\"noopener\" style=\"color:#ffffff; text-decoration:underline\">Melkor.eth</a></div>\n </header>\n <div class=\"container\">\n <div class=\"grid\">\n <div class=\"card\">\n <div class=\"label\">Total Users</div>\n <div class=\"value\">${fmt(usersCount)}</div>\n </div>\n <div class=\"card\">\n <div class=\"label\">Total Aura</div>\n <div class=\"value\">${fmt(totalAura)}</div>\n </div>\n <div class=\"card\">\n <div class=\"label\">Max Aura</div>\n <div class=\"value\">${fmt(maxAura)}</div>\n </div>\n <div class=\"card\">\n <div class=\"label\">Users > ${fmt(threshold)} Aura</div>\n <div class=\"value\">${fmt(overTh)}</div>\n </div>\n <div class=\"card\">\n <div class=\"label\">Zero Aura Users</div>\n <div class=\"value\">${fmt(zeroAuraUsers)}</div>\n </div>\n <div class=\"card\">\n <div class=\"label\">Total coin created by users</div>\n <div class=\"value\">${fmt(sumAddresses)}</div>\n </div>\n <div class=\"card\">\n <div class=\"label\">Threads</div>\n <div class=\"value\">${fmt(sumThreads)}</div>\n </div>\n <div class=\"card\">\n <div class=\"label\">Comments</div>\n <div class=\"value\">${fmt(sumComments)}</div>\n </div>\n <div class=\"card\">\n <div class=\"label\">Total Upvotes</div>\n <div class=\"value\">${fmt(sumUpvotes)}</div>\n </div>\n <div class=\"card\">\n <div class=\"label\">Comment Threads</div>\n <div class=\"value\">${fmt(sumCommentThreads)}</div>\n </div>\n </div>\n </div>\n </body>\n </html>`;\n\n if (canWrite) {\n await fs.mkdir(path.dirname(outFile), { recursive: true });\n await fs.writeFile(outFile, html, 'utf8');\n }\n\n res.setHeader('content-type', 'text/html; charset=utf-8');\n return res.status(200).send(html);\n } catch (err) {\n return res.status(500).json({ error: 'Summary HTML failure', message: err?.message || String(err) });\n }\n});\n\n// GET /xps-ranked/users-html\n// Lists users from concat.json with avatar, username, id, Aura (xp_points from profile-xp.json),\n// and activity counts (addresses/threads/comments/upvotes/commentThreads) from profile-activity.json.\n// Query: concatFile (default output/concat.json), xpFile (default output/profile-xp.json),\n// activityFile (default output/profile-activity.json), onlyOk=true|false,\n// page (default 1), limit (default 50), sortBy=xp|id|username (default xp), order=desc|asc (default desc),\n// outFile (default output/users.html), writeFile=true|false\napp.get('/xps-ranked/users-html', async (req, res) => {\n try {\n const concatFile = req.query.concatFile ? String(req.query.concatFile) : path.join(process.cwd(), 'output', 'concat.json');\n const xpFile = req.query.xpFile ? String(req.query.xpFile) : path.join(process.cwd(), 'output', 'profile-xp.json');\n const activityFile = req.query.activityFile ? String(req.query.activityFile) : path.join(process.cwd(), 'output', 'profile-activity.json');\n const lamumuFile = req.query.lamumuFile ? String(req.query.lamumuFile) : path.join(process.cwd(), 'output', 'profile_stats_lamumu.json');\n const onlyOk = String(req.query.onlyOk ?? 'true').toLowerCase() !== 'false';\n const page = Math.max(1, Number(req.query.page ?? 1) || 1);\n const limit = Math.max(1, Math.min(200, Number(req.query.limit ?? 50) || 50));\n const sortBy = (req.query.sortBy ? String(req.query.sortBy) : 'xp').toLowerCase();\n const order = (req.query.order ? String(req.query.order) : 'desc').toLowerCase();\n const outFile = req.query.outFile ? String(req.query.outFile) : path.join(process.cwd(), 'output', 'users.html');\n const writeFile = String(req.query.writeFile ?? 'true').toLowerCase() !== 'false';\n const canWrite = writeFile && !(process.env.VERCEL || process.env.NODE_ENV === 'production');\n const q = (req.query.q ? String(req.query.q) : '').trim();\n\n async function readJsonSafe(file) {\n try {\n const txt = await fs.readFile(file, 'utf8');\n return JSON.parse(txt);\n } catch {\n return undefined;\n }\n }\n\n // Data source: Supabase (default) or file-based (useSupabase=false)\n const useSupabase = String(req.query.useSupabase ?? 'true').toLowerCase() !== 'false';\n let concatArr, xpResults, actResults;\n if (useSupabase) {\n // Sunucu taraflı paging/sort/filter. Artık tek kaynak user_profile_stats.\n const sb = getSupabase();\n const effectiveOrder = (sortBy === 'xp') ? 'desc' : order; // xp için her zaman azalan\n const fromIdx = (page - 1) * limit;\n const toIdx = fromIdx + limit - 1;\n\n // Ana sorgu: user_profile_stats + users (left join) — username ve fallback avatar için\n const baseSelect = 'user_id, display_name, avatar_url, xp_points, addresses, threads, comments, total_upvotes, comment_threads, lamumu_count, users!left(id, username, avatar_url)';\n const fallbackSelect = 'user_id, display_name, avatar_url, xp_points, addresses, threads, comments, total_upvotes, comment_threads, users!left(id, username, avatar_url)';\n let lamumuExists = true;\n let qStats = sb\n .from('user_profile_stats')\n .select(baseSelect, { count: 'exact' });\n\n // Search:\n // - q tamamen rakamsa user_id eşitliği\n // - aksi halde display_name veya users.username üzerinde ILIKE\n if (q) {\n const digits = q.replace(/[^0-9]/g, '');\n if (digits && digits.length === q.length) {\n qStats = qStats.eq('user_id', digits);\n } else {\n const like = `%${q}%`;\n // display_name üzerinde arama (user_profile_stats.display_name)\n qStats = qStats.ilike('display_name', like);\n }\n }\n\n // xp_points null olmayanlar\n qStats = qStats.not('xp_points', 'is', null);\n\n // Sıralama: xp_points DESC zorunlu, eşitlikte user_id ASC bağlaç\n const ascXp = (effectiveOrder === 'asc');\n qStats = qStats\n .order('xp_points', { ascending: ascXp, nullsFirst: false })\n .order('user_id', { ascending: !ascXp });\n\n // Sayfalama\n qStats = qStats.range(fromIdx, toIdx);\n\n let { data: statsRows, count, error: statsErr } = await qStats;\n if (statsErr && /column .*lamumu_count.* does not exist|42703/i.test(statsErr.message || '')) {\n lamumuExists = false;\n // re-run without lamumu_count\n let q2 = sb\n .from('user_profile_stats')\n .select(fallbackSelect, { count: 'exact' })\n .not('xp_points', 'is', null)\n .order('xp_points', { ascending: (effectiveOrder === 'asc'), nullsFirst: false })\n .order('user_id', { ascending: !(effectiveOrder === 'asc') })\n .range(fromIdx, toIdx);\n const res2 = await q2;\n statsErr = res2.error;\n statsRows = res2.data;\n count = res2.count;\n }\n if (statsErr) throw statsErr;\n\n const baseRows = Array.isArray(statsRows) ? statsRows : [];\n\n // Render veri modeli\n const users = baseRows.map((r) => {\n const userObj = Array.isArray(r.users) ? r.users[0] : r.users;\n const id = Number(r.user_id ?? userObj?.id);\n const name = String(r.display_name || userObj?.username || `user-${id}`);\n const avatar = String(r.avatar_url || userObj?.avatar_url || '');\n const xp = toNumber(r?.xp_points);\n const addresses = toNumber(r?.addresses) || 0;\n const threads = toNumber(r?.threads) || 0;\n const comments = toNumber(r?.comments) || 0;\n const totalUpvotes = toNumber(r?.total_upvotes) || 0;\n const commentThreads = toNumber(r?.comment_threads) || 0;\n const lamumu = lamumuExists ? (toNumber(r?.lamumu_count) || 0) : undefined;\n return { id, name, avatar, xp, addresses, threads, comments, totalUpvotes, commentThreads, lamumu };\n });\n\n // Toplam ve sayfa bilgisi\n const total = count ?? users.length;\n const totalPages = Math.max(1, Math.ceil(total / limit));\n const curPage = Math.min(page, totalPages);\n\n const esc = (s) => String(s ?? '').replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));\n const fmt = (n) => (typeof n === 'number' ? n.toLocaleString('en-US') : (n === undefined || n === null ? '—' : String(n)));\n\n function buildLink(targetPage) {\n const params = new URLSearchParams();\n params.set('page', String(targetPage));\n params.set('limit', String(limit));\n params.set('sortBy', sortBy);\n params.set('order', effectiveOrder);\n params.set('onlyOk', String(onlyOk));\n if (q) params.set('q', q);\n return `/users?${params.toString()}`;\n }\n\n const rowsHtml = users.map((u, idx) => `\n <tr>\n <td class=\"cell rank\" data-label=\"Rank\">${fmt(fromIdx + idx + 1)}</td>\n <td class=\"cell id\" data-label=\"ID\">${fmt(u.id)}</td>\n <td class=\"cell user\" data-label=\"User\">\n <div class=\"userbox\">\n ${u.avatar ? `<img class=\"avatar\" src=\"${esc(u.avatar)}\" alt=\"${esc(u.name)}\" onerror=\"this.style.display='none'\" />` : ''}\n <div class=\"meta\" style=\"min-width:0\">\n <div class=\"name\" style=\"word-break:break-word; overflow-wrap:anywhere\">${esc(u.name)}</div>\n </div>\n </div>\n </td>\n <td class=\"cell xp\" data-label=\"Aura\">${fmt(u.xp)}</td>\n <td class=\"cell addresses\" data-label=\"Total coin created by users\">${fmt(u.addresses)}</td>\n <td class=\"cell threads\" data-label=\"Threads\">${fmt(u.threads)}</td>\n <td class=\"cell comments\" data-label=\"Comments\">${fmt(u.comments)}</td>\n <td class=\"cell upvotes\" data-label=\"Total Upvotes\">${fmt(u.totalUpvotes)}</td>\n <td class=\"cell cthreads\" data-label=\"Comment Threads\">${fmt(u.commentThreads)}</td>\n </tr>`).join('');\n\n const html = `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <title>Common Users</title>\n <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap\" rel=\"stylesheet\">\n <style>\n :root { --bg:#0b1220; --card:#0f172a; --text:#e5e7eb; --muted:#94a3b8; --accent:#22d3ee; --line:#1e293b; }\n *{box-sizing:border-box}\n body{margin:0; font-family:Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica, Arial, \"Apple Color Emoji\", \"Segoe UI Emoji\"; background:var(--bg); color:var(--text)}\n header{padding:16px 20px; border-bottom:1px solid var(--line); text-align:center}\n h1{margin:0; font-size:22px; letter-spacing:0.2px}\n .topnav{display:flex; gap:10px; align-items:center; justify-content:center; flex-wrap:wrap}\n .topnav .sep{color:var(--muted)}\n .btn{display:inline-block; padding:6px 10px; border:1px solid var(--line); border-radius:8px; background:transparent; color:var(--text); text-decoration:none; font-size:13px}\n .btn:hover{border-color:var(--accent); color:var(--accent)}\n .btn.current{pointer-events:none; opacity:0.8}\n .container{padding:14px 16px; max-width:1280px; margin:0 auto}\n .toolbar{display:flex; align-items:center; justify-content:space-between; color:var(--muted); font-size:12px; margin-bottom:10px}\n .pager a{color:var(--accent); text-decoration:none; margin:0 6px}\n .table-wrap{width:100%; overflow-x:auto}\n table{width:100%; border-collapse:collapse; background:var(--card); border:1px solid var(--line); border-radius:12px; overflow:hidden; min-width:720px}\n thead th{font-size:11px; color:var(--muted); text-transform:uppercase; letter-spacing:0.06em; padding:10px; text-align:left; background:#0d1526}\n tbody td{padding:10px; border-top:1px solid var(--line)}\n .userbox{display:flex; align-items:center; gap:10px}\n .avatar{width:36px; height:36px; border-radius:50%; object-fit:cover; border:1px solid var(--line); background:#1e293b}\n .name{font-weight:600}\n .sub{color:var(--muted); font-size:11px}\n .cell.xp,.cell.addresses,.cell.threads,.cell.comments,.cell.upvotes,.cell.cthreads{font-variant-numeric:tabular-nums}\n .pager{margin-top:12px; text-align:center}\n @media (max-width:760px){ .sub{display:none} thead{display:none} table,tbody,tr,td{display:block;width:100%} tbody td{border:none; padding:8px 10px} tr{border:1px solid var(--line); border-radius:12px; margin-bottom:10px; background:var(--card)} .cell{display:flex; justify-content:space-between; gap:10px} .cell::before{content:attr(data-label); color:var(--muted); flex:0 0 auto} .name{word-break:break-word; overflow-wrap:anywhere} }\n </style>\n </head>\n <body>\n <header>\n <div class=\"topnav\">\n <a class=\"btn\" href=\"/summary\">Common Stats</a>\n <span class=\"sep\">•</span>\n <a class=\"btn current\" href=\"/users\">Users stats»</a>\n <span class=\"sep\">•</span>\n \n <a class=\"btn\" href=\"/common-checker\">Common Checker</a>\n </div>\n <div class=\"meta\" style=\"color:#94a3b8; font-size:12px; margin-top:6px\">Data as of 10.10.2025</div>\n <div class=\"meta\" style=\"color:#ffffff; font-size:12px;\">Created by <a href=\"https://x.com/0xMelkoreth\" target=\"_blank\" rel=\"noopener\" style=\"color:#ffffff; text-decoration:underline\">Melkor.eth</a></div>\n </header>\n <div class=\"container\">\n <div class=\"toolbar\">\n <div>\n <div>Totals: ${fmt(total)} • Page ${fmt(curPage)} / ${fmt(totalPages)} • Sorted by ${esc(sortBy)} ${esc(effectiveOrder)}${q ? ` • Search: ${esc(q)}` : ''}</div>\n <form method=\"GET\" action=\"/users\" style=\"display:flex; gap:6px; align-items:center; margin-top:6px\">\n <input type=\"hidden\" name=\"sortBy\" value=\"${esc(sortBy)}\" />\n <input type=\"hidden\" name=\"order\" value=\"${esc(effectiveOrder)}\" />\n <input type=\"hidden\" name=\"limit\" value=\"${fmt(limit)}\" />\n <input type=\"hidden\" name=\"onlyOk\" value=\"${onlyOk ? 'true' : 'false'}\" />\n <input name=\"q\" value=\"${esc(q)}\" placeholder=\"Search by ID or username\" style=\"padding:6px 8px; border:1px solid var(--line); border-radius:8px; background:#0f172a; color:var(--text); width:220px\" />\n <button class=\"btn\" type=\"submit\">Search</button>\n </form>\n </div>\n <div class=\"pager\">\n ${curPage > 1 ? `<a href=\"${buildLink(1)}\">First</a>` : ''}\n ${curPage > 1 ? `<a href=\"${buildLink(curPage - 1)}\">Prev</a>` : ''}\n ${curPage < totalPages ? `<a href=\"${buildLink(curPage + 1)}\">Next</a>` : ''}\n ${curPage < totalPages ? `<a href=\"${buildLink(totalPages)}\">Last</a>` : ''}\n </div>\n </div>\n <div class=\"table-wrap\">\n <table>\n <thead>\n <tr>\n <th>Rank</th>\n <th>ID</th>\n <th>User</th>\n <th>Aura</th>\n <th>Total coin created by users</th>\n <th>Threads</th>\n <th>Comments</th>\n <th>Total Upvotes</th>\n <th>Comment Threads</th>\n </tr>\n </thead>\n <tbody>\n ${rowsHtml}\n </tbody>\n </table>\n </div>\n <div class=\"pager\">\n ${curPage > 1 ? `<a href=\"${buildLink(curPage - 1)}\">Prev</a>` : ''}\n <span>Page ${fmt(curPage)} / ${fmt(totalPages)}</span>\n ${curPage < totalPages ? `<a href=\"${buildLink(curPage + 1)}\">Next</a>` : ''}\n </div>\n </div>\n </body>\n </html>`;\n\n if (canWrite) {\n await fs.mkdir(path.dirname(outFile), { recursive: true });\n await fs.writeFile(outFile, html, 'utf8');\n }\n res.setHeader('content-type', 'text/html; charset=utf-8');\n return res.status(200).send(html);\n } else {\n // Default file-based source\n concatArr = await readJsonSafe(concatFile);\n if (!Array.isArray(concatArr)) {\n return res.status(400).json({ error: 'Invalid concat JSON: expected top-level array', concatFile });\n }\n const xpData = await readJsonSafe(xpFile);\n const actData = await readJsonSafe(activityFile);\n const lamData = await readJsonSafe(lamumuFile);\n xpResults = Array.isArray(xpData?.results) ? xpData.results : [];\n actResults = Array.isArray(actData?.results) ? actData.results : [];\n var lamMap = new Map();\n if (Array.isArray(lamData?.results)) {\n for (const r of lamData.results) {\n const uid = Number(r.user_id ?? r.userId ?? r.id);\n if (!Number.isFinite(uid)) continue;\n lamMap.set(uid, toNumber(r.lamumu_count) || 0);\n }\n }\n }\n\n // Build maps: userId -> xp / activity (file-based modda)\n const xpMap = new Map();\n for (const r of xpResults) {\n if (!r || typeof r !== 'object') continue;\n if (onlyOk && r.ok === false) continue;\n const id = Number(r.userId);\n if (!Number.isFinite(id)) continue;\n const xp = toNumber(r.xp_points);\n if (xp === undefined) continue;\n xpMap.set(id, xp);\n }\n const actMap = new Map();\n for (const r of actResults) {\n if (!r || typeof r !== 'object') continue;\n if (onlyOk && r.ok === false) continue;\n const id = Number(r.userId);\n if (!Number.isFinite(id)) continue;\n actMap.set(id, {\n addresses: toNumber(r.addresses) || 0,\n threads: toNumber(r.threads) || 0,\n comments: toNumber(r.comments) || 0,\n totalUpvotes: toNumber(r.totalUpvotes) || 0,\n commentThreads: toNumber(r.commentThreads) || 0,\n });\n }\n\n // Collect unique users from concat.json with basic fields\n const seen = new Set();\n const users = [];\n for (const item of concatArr) {\n if (!item || typeof item !== 'object') continue;\n const id = Number(item.user_id ?? item.userId ?? item.id);\n if (!Number.isFinite(id)) continue;\n if (seen.has(id)) continue;\n seen.add(id);\n const username = String(item.user_name ?? item.username ?? item.handle ?? '') || `user-${id}`;\n const avatar = String(item.avatar_url ?? item.avatar ?? '') || '';\n const xp = xpMap.has(id) ? xpMap.get(id) : undefined;\n const act = actMap.get(id) || { addresses: 0, threads: 0, comments: 0, totalUpvotes: 0, commentThreads: 0 };\n const lamumu = lamMap ? lamMap.get(id) : undefined;\n users.push({ id, username, avatar, xp, lamumu, ...act });\n }\n\n // Search filter (by id substring or username case-insensitive substring)\n let filtered = users;\n if (q) {\n const qLower = q.toLowerCase();\n const digits = q.replace(/[^0-9]/g, '');\n const hasDigits = digits.length > 0;\n filtered = users.filter((u) => {\n const nameHit = (u.username || '').toLowerCase().includes(qLower);\n const idHit = hasDigits ? String(u.id).includes(digits) : false;\n return nameHit || idHit;\n });\n }\n\n // Sort\n const dir = order === 'asc' ? 1 : -1;\n filtered.sort((a, b) => {\n let va, vb;\n if (sortBy === 'id') { va = a.id; vb = b.id; }\n else if (sortBy === 'username') { va = String(a.username).toLowerCase(); vb = String(b.username).toLowerCase(); }\n else { va = a.xp ?? -1; vb = b.xp ?? -1; } // default xp\n if (va < vb) return -1 * dir;\n if (va > vb) return 1 * dir;\n return 0;\n });\n\n // Pagination\n const total = filtered.length;\n const totalPages = Math.max(1, Math.ceil(total / limit));\n const curPage = Math.min(page, totalPages);\n const start = (curPage - 1) * limit;\n const slice = filtered.slice(start, start + limit);\n\n const esc = (s) => String(s ?? '').replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));\n const fmt = (n) => (typeof n === 'number' ? n.toLocaleString('en-US') : (n === undefined || n === null ? '—' : String(n)));\n\n // Build pagination URLs preserving base path and selected sort/order/limit\n function buildLink(targetPage) {\n const params = new URLSearchParams();\n params.set('page', String(targetPage));\n params.set('limit', String(limit));\n params.set('sortBy', sortBy);\n params.set('order', order);\n params.set('onlyOk', String(onlyOk));\n if (q) params.set('q', q);\n return `/users?${params.toString()}`;\n }\n\n const rows = slice.map((u, idx) => `\n <tr>\n <td class=\"cell rank\" data-label=\"Rank\">${fmt(start + idx + 1)}</td>\n <td class=\"cell id\" data-label=\"ID\">${fmt(u.id)}</td>\n <td class=\"cell user\" data-label=\"User\">\n <div class=\"userbox\">\n ${u.avatar ? `<img class=\"avatar\" src=\"${esc(u.avatar)}\" alt=\"${esc(u.username)}\" onerror=\"this.style.display='none'\" />` : ''}\n <div class=\"meta\" style=\"min-width:0\">\n <div class=\"name\" style=\"word-break:break-word; overflow-wrap:anywhere\">${esc(u.username)}</div>\n </div>\n </div>\n </td>\n <td class=\"cell xp\" data-label=\"Aura\">${fmt(u.xp)}</td>\n <td class=\"cell addresses\" data-label=\"Total coin created by users\">${fmt(u.addresses)}</td>\n <td class=\"cell threads\" data-label=\"Threads\">${fmt(u.threads)}</td>\n <td class=\"cell comments\" data-label=\"Comments\">${fmt(u.comments)}</td>\n <td class=\"cell upvotes\" data-label=\"Total Upvotes\">${fmt(u.totalUpvotes)}</td>\n <td class=\"cell cthreads\" data-label=\"Comment Threads\">${fmt(u.commentThreads)}</td>\n </tr>`).join('');\n\n const html = `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <title>Common Users</title>\n <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap\" rel=\"stylesheet\">\n <style>\n :root { --bg:#0b1220; --card:#0f172a; --text:#e5e7eb; --muted:#94a3b8; --accent:#22d3ee; --line:#1e293b; }\n *{box-sizing:border-box}\n body{margin:0; font-family:Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica, Arial, \"Apple Color Emoji\", \"Segoe UI Emoji\"; background:var(--bg); color:var(--text)}\n header{padding:16px 20px; border-bottom:1px solid var(--line); text-align:center}\n h1{margin:0; font-size:22px; letter-spacing:0.2px}\n .topnav{display:flex; gap:10px; align-items:center; justify-content:center; flex-wrap:wrap}\n .topnav .sep{color:var(--muted)}\n .btn{display:inline-block; padding:6px 10px; border:1px solid var(--line); border-radius:8px; background:transparent; color:var(--text); text-decoration:none; font-size:13px}\n .btn:hover{border-color:var(--accent); color:var(--accent)}\n .btn.current{pointer-events:none; opacity:0.8}\n .container{padding:14px 16px; max-width:1280px; margin:0 auto}\n .toolbar{display:flex; align-items:center; justify-content:space-between; color:var(--muted); font-size:12px; margin-bottom:10px}\n .pager a{color:var(--accent); text-decoration:none; margin:0 6px}\n .table-wrap{width:100%; overflow-x:auto}\n table{width:100%; border-collapse:collapse; background:var(--card); border:1px solid var(--line); border-radius:12px; overflow:hidden; min-width:720px}\n thead th{font-size:11px; color:var(--muted); text-transform:uppercase; letter-spacing:0.06em; padding:10px; text-align:left; background:#0d1526}\n tbody td{padding:10px; border-top:1px solid var(--line)}\n .userbox{display:flex; align-items:center; gap:10px}\n .avatar{width:36px; height:36px; border-radius:50%; object-fit:cover; border:1px solid var(--line); background:#1e293b}\n .name{font-weight:600}\n .sub{color:var(--muted); font-size:11px}\n .cell.xp,.cell.addresses,.cell.threads,.cell.comments,.cell.upvotes,.cell.cthreads{font-variant-numeric:tabular-nums}\n .pager{margin-top:12px; text-align:center}\n @media (max-width:760px){ .sub{display:none} thead{display:none} table,tbody,tr,td{display:block;width:100%} tbody td{border:none; padding:8px 10px} tr{border:1px solid var(--line); border-radius:12px; margin-bottom:10px; background:var(--card)} .cell{display:flex; justify-content:space-between; gap:10px} .cell::before{content:attr(data-label); color:var(--muted); flex:0 0 auto} .name{word-break:break-word; overflow-wrap:anywhere} }\n </style>\n </head>\n <body>\n <header>\n <div class=\"topnav\">\n <a class=\"btn\" href=\"/summary\">Common Stats</a>\n <span class=\"sep\">•</span>\n <a class=\"btn current\" href=\"/users\">Users stats»</a>\n <span class=\"sep\">•</span>\n \n <a class=\"btn\" href=\"/common-checker\">Common Checker</a>\n </div>\n <div class=\"meta\" style=\"color:#94a3b8; font-size:12px; margin-top:6px\">Data as of 10.10.2025</div>\n <div class=\"meta\" style=\"color:#ffffff; font-size:12px;\">Created by <a href=\"https://x.com/0xMelkoreth\" target=\"_blank\" rel=\"noopener\" style=\"color:#ffffff; text-decoration:underline\">Melkor.eth</a></div>\n </header>\n <div class=\"container\">\n <div class=\"toolbar\">\n <div>\n <div>Totals: ${fmt(total)} • Page ${fmt(curPage)} / ${fmt(totalPages)} • Sorted by ${esc(sortBy)} ${esc(order)}${q ? ` • Search: ${esc(q)}` : ''}</div>\n <form method=\"GET\" action=\"/users\" style=\"display:flex; gap:6px; align-items:center; margin-top:6px\">\n <input type=\"hidden\" name=\"sortBy\" value=\"${esc(sortBy)}\" />\n <input type=\"hidden\" name=\"order\" value=\"${esc(order)}\" />\n <input type=\"hidden\" name=\"limit\" value=\"${fmt(limit)}\" />\n <input type=\"hidden\" name=\"onlyOk\" value=\"${onlyOk ? 'true' : 'false'}\" />\n <input name=\"q\" value=\"${esc(q)}\" placeholder=\"Search by ID or username\" style=\"padding:6px 8px; border:1px solid var(--line); border-radius:8px; background:#0f172a; color:var(--text); width:220px\" />\n <button class=\"btn\" type=\"submit\">Search</button>\n </form>\n </div>\n <div class=\"pager\">\n ${curPage > 1 ? `<a href=\"${buildLink(1)}\">First</a>` : ''}\n ${curPage > 1 ? `<a href=\"${buildLink(curPage - 1)}\">Prev</a>` : ''}\n ${curPage < totalPages ? `<a href=\"${buildLink(curPage + 1)}\">Next</a>` : ''}\n ${curPage < totalPages ? `<a href=\"${buildLink(totalPages)}\">Last</a>` : ''}\n </div>\n </div>\n <div class=\"table-wrap\">\n <table>\n <thead>\n <tr>\n <th>Rank</th>\n <th>ID</th>\n <th>User</th>\n <th>Aura</th>\n <th>Total coin created by users</th>\n <th>Threads</th>\n <th>Comments</th>\n <th>Total Upvotes</th>\n <th>Comment Threads</th>\n </tr>\n </thead>\n <tbody>\n ${rows}\n </tbody>\n </table>\n </div>\n <div class=\"pager\">\n ${curPage > 1 ? `<a href=\"${buildLink(curPage - 1)}\">Prev</a>` : ''}\n <span>Page ${fmt(curPage)} / ${fmt(totalPages)}</span>\n ${curPage < totalPages ? `<a href=\"${buildLink(curPage + 1)}\">Next</a>` : ''}\n </div>\n </div>\n </body>\n </html>`;\n\n if (writeFile) {\n await fs.mkdir(path.dirname(outFile), { recursive: true });\n await fs.writeFile(outFile, html, 'utf8');\n }\n res.setHeader('content-type', 'text/html; charset=utf-8');\n return res.status(200).send(html);\n } catch (err) {\n return res.status(500).json({ error: 'Users HTML failure', message: err?.message || String(err) });\n }\n});\n\n// Lamumu holder stats — same table as users but sorted by lamumu_count desc\napp.get('/xps-ranked/lamumu-holders-html', async (req, res) => {\n try {\n const page = Math.max(1, toNumber(req.query.page) || 1);\n const limit = Math.min(200, Math.max(10, toNumber(req.query.limit) || 50));\n const order = (String(req.query.order || 'desc').toLowerCase() === 'asc') ? 'asc' : 'desc';\n const useSupabase = String(req.query.useSupabase || '').toLowerCase() === 'true' || process.env.SUPABASE_URL;\n const q = (req.query.q ? String(req.query.q) : '').trim();\n\n const concatFile = req.query.concatFile ? String(req.query.concatFile) : path.join(process.cwd(), 'output', 'concat.json');\n const xpFile = req.query.xpFile ? String(req.query.xpFile) : path.join(process.cwd(), 'output', 'profile-xp.json');\n const activityFile = req.query.activityFile ? String(req.query.activityFile) : path.join(process.cwd(), 'output', 'profile-activity.json');\n const lamumuFile = req.query.lamumuFile ? String(req.query.lamumuFile) : path.join(process.cwd(), 'output', 'profile_stats_lamumu.json');\n\n let users = [];\n let totalCount = 0;\n const fromIdx = (page - 1) * limit;\n const toIdx = fromIdx + limit - 1;\n\n if (useSupabase) {\n const sb = getSupabase();\n // Try to select and order by lamumu_count; gracefully fallback if column is missing\n const baseSelect = 'user_id, display_name, avatar_url, xp_points, addresses, threads, comments, total_upvotes, comment_threads, lamumu_count, users!left(id, username, avatar_url)';\n const fallbackSelect = 'user_id, display_name, avatar_url, xp_points, addresses, threads, comments, total_upvotes, comment_threads, users!left(id, username, avatar_url)';\n let lamumuExists = true;\n\n let qStats = sb\n .from('user_profile_stats')\n .select(baseSelect, { count: 'exact' })\n .not('xp_points', 'is', null)\n .gte('lamumu_count', 1)\n .order('lamumu_count', { ascending: (order === 'asc'), nullsFirst: false })\n .order('user_id', { ascending: !(order === 'asc') })\n .range(fromIdx, toIdx);\n\n // Apply search (q is the search string)\n if (q) {\n const digits = q.replace(/[^0-9]/g, '');\n if (digits && digits.length === q.length) {\n qStats = qStats.eq('user_id', digits);\n } else {\n const like = `%${q}%`;\n qStats = qStats.ilike('display_name', like);\n }\n }\n\n let { data, count, error } = await qStats;\n if (error && /column .*lamumu_count.* does not exist|42703/i.test(error.message || '')) {\n lamumuExists = false;\n let q2 = sb\n .from('user_profile_stats')\n .select(fallbackSelect, { count: 'exact' })\n .not('xp_points', 'is', null)\n .order('xp_points', { ascending: (order === 'asc'), nullsFirst: false })\n .order('user_id', { ascending: !(order === 'asc') })\n .range(fromIdx, toIdx);\n if (q) {\n const digits = q.replace(/[^0-9]/g, '');\n if (digits && digits.length === q.length) {\n q2 = q2.eq('user_id', digits);\n } else {\n const like = `%${q}%`;\n q2 = q2.ilike('display_name', like);\n }\n }\n const r2 = await q2;\n data = r2.data; count = r2.count; error = r2.error;\n }\n if (error) throw error;\n\n totalCount = count || 0;\n users = (data || []).map((r) => {\n const u = Array.isArray(r.users) ? r.users[0] : r.users;\n const id = Number(r.user_id ?? u?.id);\n const name = String(r.display_name || u?.username || `user-${id}`);\n const avatar = String(r.avatar_url || u?.avatar_url || '');\n const xp = toNumber(r?.xp_points);\n const addresses = toNumber(r?.addresses) || 0;\n const threads = toNumber(r?.threads) || 0;\n const comments = toNumber(r?.comments) || 0;\n const totalUpvotes = toNumber(r?.total_upvotes) || 0;\n const commentThreads = toNumber(r?.comment_threads) || 0;\n const lamumu = lamumuExists ? (toNumber(r?.lamumu_count) || 0) : undefined;\n return { id, name, avatar, xp, addresses, threads, comments, totalUpvotes, commentThreads, lamumu };\n });\n } else {\n // File mode: read concat/xp/activity and optional lamumu json\n const concatArr = await readJsonSafe(concatFile);\n const xpData = await readJsonSafe(xpFile);\n const actData = await readJsonSafe(activityFile);\n const lamData = await readJsonSafe(lamumuFile);\n if (!Array.isArray(concatArr)) {\n return res.status(400).json({ error: 'Invalid concat JSON: expected top-level array', concatFile });\n }\n\n const xpMap = new Map();\n for (const r of (Array.isArray(xpData?.results) ? xpData.results : [])) {\n const uid = Number(r.user_id ?? r.userId ?? r.id);\n if (!Number.isFinite(uid)) continue;\n xpMap.set(uid, toNumber(r.xp_points ?? r.xp ?? 0));\n }\n const actMap = new Map();\n for (const r of (Array.isArray(actData?.results) ? actData.results : [])) {\n const uid = Number(r.user_id ?? r.userId ?? r.id);\n if (!Number.isFinite(uid)) continue;\n actMap.set(uid, {\n addresses: toNumber(r.addresses) || 0,\n threads: toNumber(r.threads) || 0,\n comments: toNumber(r.comments) || 0,\n totalUpvotes: toNumber(r.total_upvotes) || 0,\n commentThreads: toNumber(r.comment_threads) || 0,\n });\n }\n const lamMap = new Map();\n for (const r of (Array.isArray(lamData?.results) ? lamData.results : [])) {\n const uid = Number(r.user_id ?? r.userId ?? r.id);\n if (!Number.isFinite(uid)) continue;\n lamMap.set(uid, toNumber(r.lamumu_count) || 0);\n }\n\n const seen = new Set();\n for (const item of concatArr) {\n if (!item || typeof item !== 'object') continue;\n const id = Number(item.user_id ?? item.userId ?? item.id);\n if (!Number.isFinite(id)) continue;\n if (seen.has(id)) continue;\n seen.add(id);\n const username = String(item.user_name ?? item.username ?? item.handle ?? '') || `user-${id}`;\n const avatar = String(item.avatar_url ?? item.avatar ?? '') || '';\n const xp = xpMap.get(id);\n const act = actMap.get(id) || { addresses: 0, threads: 0, comments: 0, totalUpvotes: 0, commentThreads: 0 };\n const lamumu = lamMap.get(id) || 0;\n if ((lamumu ?? 0) >= 1) users.push({ id, name: username, avatar, xp, lamumu, ...act });\n }\n\n // Search filter similar to /users\n let filtered = users;\n if (q) {\n const qLower = q.toLowerCase();\n const digits = q.replace(/[^0-9]/g, '');\n const hasDigits = digits.length > 0;\n filtered = users.filter((u) => {\n const nameHit = (u.name || '').toLowerCase().includes(qLower);\n const idHit = hasDigits ? String(u.id).includes(digits) : false;\n return nameHit || idHit;\n });\n }\n filtered.sort((a, b) => (toNumber(b.lamumu) - toNumber(a.lamumu)) || (toNumber(b.xp) - toNumber(a.xp)) || (a.id - b.id));\n totalCount = filtered.length;\n users = filtered.slice(fromIdx, toIdx + 1);\n }\n\n const fmt = (v) => (v === undefined || v === null || Number.isNaN(v) ? '-' : String(v));\n const esc = (s) => String(s ?? '').replace(/[&<>\"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[c]));\n\n const rowsHtml = users.map((u, idx) => `\n <tr>\n <td class=\"cell rank\" data-label=\"Rank\">${fmt(fromIdx + idx + 1)}</td>\n <td class=\"cell id\" data-label=\"ID\">${fmt(u.id)}</td>\n <td class=\"cell user\" data-label=\"User\">\n <div class=\"userbox\">\n ${u.avatar ? `<img class=\"avatar\" src=\"${esc(u.avatar)}\" alt=\"${esc(u.name)}\" onerror=\"this.style.display='none'\" />` : ''}\n <div class=\"meta\" style=\"min-width:0\">\n <div class=\"name\" style=\"word-break:break-word; overflow-wrap:anywhere\">${esc(u.name)}</div>\n </div>\n </div>\n </td>\n <td class=\"cell xp\" data-label=\"Aura\">${fmt(u.xp)}</td>\n <td class=\"cell lamumu\" data-label=\"Lamumu count\">${fmt(u.lamumu)}</td>\n <td class=\"cell addresses\" data-label=\"Total coin created by users\">${fmt(u.addresses)}</td>\n <td class=\"cell threads\" data-label=\"Threads\">${fmt(u.threads)}</td>\n <td class=\"cell comments\" data-label=\"Comments\">${fmt(u.comments)}</td>\n <td class=\"cell upvotes\" data-label=\"Total Upvotes\">${fmt(u.totalUpvotes)}</td>\n <td class=\"cell cthreads\" data-label=\"Comment Threads\">${fmt(u.commentThreads)}</td>\n </tr>`).join('');\n\n const totalPages = Math.max(1, Math.ceil(totalCount / limit));\n const nav = (label, p) => `<a href=\"?page=${p}&limit=${limit}&order=${order}${q ? `&q=${encodeURIComponent(q)}` : ''}${useSupabase ? '&useSupabase=true' : ''}\" class=\"btn\">${label}</a>`;\n const controls = `\n <div class=\"controls\">\n <div>Page ${fmt(page)} / ${fmt(totalPages)} | Total ${fmt(totalCount)}</div>\n <div>\n ${page > 1 ? nav('Prev', page - 1) : ''}\n ${page < totalPages ? nav('Next', page + 1) : ''}\n </div>\n </div>`;\n\n const html = `<!DOCTYPE html>\n <html lang=\"en\">\n <head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <title>Lamumu Holder Stats</title>\n <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap\" rel=\"stylesheet\">\n <style>\n :root { --bg:#0b1220; --card:#0f172a; --text:#e5e7eb; --muted:#94a3b8; --accent:#22d3ee; --line:#1e293b; }\n *{box-sizing:border-box}\n body{margin:0; font-family:Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica, Arial, \"Apple Color Emoji\", \"Segoe UI Emoji\"; background:var(--bg); color:var(--text)}\n header{padding:16px 20px; border-bottom:1px solid var(--line); text-align:center}\n h1{margin:0; font-size:22px; letter-spacing:0.2px}\n .topnav{display:flex; gap:10px; align-items:center; justify-content:center; flex-wrap:wrap}\n .topnav .sep{color:var(--muted)}\n .btn{display:inline-block; padding:6px 10px; border:1px solid var(--line); border-radius:8px; background:transparent; color:var(--text); text-decoration:none; font-size:13px}\n .btn:hover{border-color:var(--accent); color:var(--accent)}\n .btn.current{pointer-events:none; opacity:0.8}\n .container{padding:14px 16px; max-width:1280px; margin:0 auto}\n .toolbar{display:flex; align-items:center; justify-content:space-between; color:var(--muted); font-size:12px; margin-bottom:10px}\n .pager a{color:var(--accent); text-decoration:none; margin:0 6px}\n .table-wrap{width:100%; overflow-x:auto}\n table{width:100%; border-collapse:collapse; background:var(--card); border:1px solid var(--line); border-radius:12px; overflow:hidden; min-width:720px}\n thead th{font-size:11px; color:var(--muted); text-transform:uppercase; letter-spacing:0.06em; padding:10px; text-align:left; background:#0d1526}\n tbody td{padding:10px; border-top:1px solid var(--line)}\n .userbox{display:flex; align-items:center; gap:10px}\n .avatar{width:36px; height:36px; border-radius:50%; object-fit:cover; border:1px solid var(--line); background:#1e293b}\n .name{font-weight:600}\n .sub{color:var(--muted); font-size:11px}\n .cell.xp,.cell.addresses,.cell.threads,.cell.comments,.cell.upvotes,.cell.cthreads,.cell.lholder{font-variant-numeric:tabular-nums}\n .pager{margin-top:12px; text-align:center}\n @media (max-width:760px){ .sub{display:none} thead{display:none} table,tbody,tr,td{display:block;width:100%} tbody td{border:none; padding:8px 10px} tr{border:1px solid var(--line); border-radius:12px; margin-bottom:10px; background:var(--card)} .cell{display:flex; justify-content:space-between; gap:10px} .cell::before{content:attr(data-label); color:var(--muted); flex:0 0 auto} .name{word-break:break-word; overflow-wrap:anywhere} }\n </style>\n </head>\n <body>\n <header>\n <div class=\"topnav\">\n <a class=\"btn\" href=\"/summary\">Common Stats</a>\n <span class=\"sep\">•</span>\n <a class=\"btn\" href=\"/users\">Users stats»</a>\n <span class=\"sep\">•</span>\n <a class=\"btn current\" href=\"/users\">Lamumu holder stats»</a>\n <span class=\"sep\">•</span>\n <a class=\"btn\" href=\"/common-checker\">Common Checker</a>\n </div>\n <div class=\"meta\" style=\"color:#94a3b8; font-size:12px; margin-top:6px\">Sorted by Lamumu count (desc)</div>\n <div class=\"meta\" style=\"color:#ffffff; font-size:12px;\">Created by <a href=\"https://x.com/0xMelkoreth\" target=\"_blank\" rel=\"noopener\" style=\"color:#ffffff; text-decoration:underline\">Melkor.eth</a></div>\n </header>\n <div class=\"container\">\n <div class=\"toolbar\">\n <div>\n <div>Page ${fmt(page)} / ${fmt(totalPages)} • Total ${fmt(totalCount)}</div>\n <form method=\"GET\" action=\"/users\" style=\"display:flex; gap:6px; align-items:center; margin-top:6px\">\n <input type=\"hidden\" name=\"order\" value=\"${order}\" />\n <input type=\"hidden\" name=\"limit\" value=\"${limit}\" />\n ${useSupabase ? '<input type=\"hidden\" name=\"useSupabase\" value=\"true\" />' : ''}\n <input name=\"q\" value=\"${esc(q)}\" placeholder=\"Search by ID or username\" style=\"padding:6px 8px; border:1px solid var(--line); border-radius:8px; background:#0f172a; color:var(--text); width:220px\" />\n <button class=\"btn\" type=\"submit\">Search</button>\n </form>\n </div>\n <div class=\"pager\">\n ${page > 1 ? nav('Prev', page - 1) : ''}\n ${page < totalPages ? nav('Next', page + 1) : ''}\n </div>\n </div>\n <div class=\"table-wrap\">\n <table>\n <thead>\n <tr>\n <th>Rank</th>\n <th>ID</th>\n <th>User</th>\n <th>Aura</th>\n <th>Lamumu count</th>\n <th>Total coin created by users</th>\n <th>Threads</th>\n <th>Comments</th>\n <th>Total Upvotes</th>\n <th>Comment Threads</th>\n </tr>\n </thead>\n <tbody>\n ${rowsHtml}\n </tbody>\n </table>\n </div>\n <div class=\"pager\">\n ${page > 1 ? `<a href=\"?page=${page - 1}&limit=${limit}&order=${order}${useSupabase ? '&useSupabase=true' : ''}\">Prev</a>` : ''}\n <span>Page ${fmt(page)} / ${fmt(totalPages)}</span>\n ${page < totalPages ? `<a href=\"?page=${page + 1}&limit=${limit}&order=${order}${useSupabase ? '&useSupabase=true' : ''}\">Next</a>` : ''}\n </div>\n </div>\n </body>\n </html>`;\n\n res.setHeader('Content-Type', 'text/html; charset=utf-8');\n return res.status(200).send(html);\n } catch (err) {\n console.error('lamumu-holders-html error:', err);\n return res.status(500).json({ error: String(err?.message || err) });\n }\n});\n// GET /xps-ranked/stats\n// Reads a merged JSON array file (default output/concat.json), deduplicates by idKey, and reports counts\n// for xp_points > threshold and xp_points == 0.\n// Query params: jsonFile (path), idKey (default tries common keys), threshold (default 1000)\napp.get('/xps-ranked/stats', async (req, res) => {\n try {\n const jsonFile = req.query.jsonFile ? String(req.query.jsonFile) : path.join(process.cwd(), 'output', 'concat.json');\n const idKey = req.query.idKey ? String(req.query.idKey) : undefined;\n const threshold = Number(req.query.threshold ?? 1000) || 1000;\n\n const text = await fs.readFile(jsonFile, 'utf8');\n const arr = JSON.parse(text);\n if (!Array.isArray(arr)) {\n return res.status(400).json({ error: 'Invalid concat JSON: root is not an array' });\n }\n\n const picked = new Map(); // id -> item with parsed xp\n for (const item of arr) {\n if (!item || typeof item !== 'object') continue;\n const idInfo = getIdFromObject(item, idKey);\n if (!idInfo) continue;\n const id = `${idInfo.key}::${idInfo.value}`;\n const xpInfo = getXpPointsFromObject(item);\n const xp = xpInfo ? xpInfo.value : undefined;\n if (xp === undefined) continue;\n // Prefer the max xp if multiple entries per id\n const prev = picked.get(id);\n if (!prev || xp > prev.xp) {\n picked.set(id, { idKey: idInfo.key, idValue: idInfo.value, xp });\n }\n }\n\n let overThreshold = 0;\n let zeroXp = 0;\n for (const entry of picked.values()) {\n if (entry.xp > threshold) overThreshold++;\n if (entry.xp === 0) zeroXp++;\n }\n\n return res.json({ ok: true, totalPersons: picked.size, threshold, overThreshold, zeroXp, jsonFile, idKeyUsed: (idKey || (picked.size ? [...picked.values()][0].idKey : null)) });\n } catch (err) {\n return res.status(500).json({ error: 'Stats failure', message: err?.message || String(err) });\n }\n});\n\n// GET /xps-ranked/sum-xp\n// Sums xp_points across unique users in a merged JSON file (default output/concat.json).\n// Deduplicates by detected id key (or explicit idKey) and uses the maximum xp_points per user if duplicates exist.\n// Query params: jsonFile (path), idKey (optional)\napp.get('/xps-ranked/sum-xp', async (req, res) => {\n try {\n const jsonFile = req.query.jsonFile ? String(req.query.jsonFile) : path.join(process.cwd(), 'output', 'concat.json');\n const idKey = req.query.idKey ? String(req.query.idKey) : undefined;\n\n const text = await fs.readFile(jsonFile, 'utf8');\n const arr = JSON.parse(text);\n if (!Array.isArray(arr)) {\n return res.status(400).json({ error: 'Invalid concat JSON: root is not an array' });\n }\n\n const picked = new Map(); // id -> { idKey, idValue, xp, tier }\n for (const item of arr) {\n if (!item || typeof item !== 'object') continue;\n const idInfo = getIdFromObject(item, idKey);\n if (!idInfo) continue;\n const id = `${idInfo.key}::${idInfo.value}`;\n const xpInfo = getXpPointsFromObject(item);\n const xp = xpInfo ? xpInfo.value : undefined;\n if (xp === undefined) continue;\n const tierNum = toNumber(item?.tier);\n const prev = picked.get(id);\n if (!prev || xp > prev.xp) picked.set(id, { idKey: idInfo.key, idValue: idInfo.value, xp, tier: (Number.isFinite(tierNum) ? tierNum : undefined) });\n }\n\n let total_aura = 0; // inclusive of all tiers\n let total_aura_excl_t4 = 0; // excludes Tier 4\n let minXp = Number.POSITIVE_INFINITY;\n let maxXp = 0;\n const tierCounts = new Map(); // tier -> count\n let tieredUsers = 0;\n for (const v of picked.values()) {\n total_aura += v.xp;\n if (v.tier === 4) {\n // exclude tier 4 from this subtotal\n } else {\n total_aura_excl_t4 += v.xp;\n }\n if (v.xp < minXp) minXp = v.xp;\n if (v.xp > maxXp) maxXp = v.xp;\n if (Number.isFinite(v.tier)) {\n tieredUsers++;\n const t = v.tier;\n tierCounts.set(t, (tierCounts.get(t) || 0) + 1);\n }\n }\n const count = picked.size;\n const avgXp = count > 0 ? total_aura / count : 0;\n\n // Convert tierCounts map to a plain object sorted by tier ascending for readability\n const tier_counts = {};\n const sortedTiers = Array.from(tierCounts.entries()).sort((a, b) => a[0] - b[0]);\n for (const [t, c] of sortedTiers) tier_counts[t] = c;\n\n return res.json({ ok: true, jsonFile, idKeyUsed: (idKey || (count ? [...picked.values()][0].idKey : null)),\n total_users: count,\n total_aura, // inclusive\n total_aura_excl_tier4: total_aura_excl_t4,\n min_xp: (count ? minXp : 0), max_xp: maxXp, avg_xp: avgXp,\n tier_counts, tiered_users: tieredUsers });\n } catch (err) {\n return res.status(500).json({ error: 'sum-xp failure', message: err?.message || String(err) });\n }\n});\n\n// Only start the server when running locally; Vercel will import and handle the app\nif (!process.env.VERCEL) {\n app.listen(PORT, () => {\n console.log(`Local API listening on http://localhost:${PORT}`);\n });\n}\n\nexport default app;\n\n// GET /supabase/import-from-files\n// Local dosyalardan (concat.json, profile-xp.json, profile-activity.json) Supabase tablolarına veri aktarır.\n// Query: concatFile, xpFile, activityFile, chunkSize (default 500)\napp.get('/supabase/import-from-files', limiter(RATE_LIMITS.write), async (req, res) => {\n try {\n const concatFile = req.query.concatFile ? String(req.query.concatFile) : path.join(process.cwd(), 'output', 'concat.json');\n const xpFile = req.query.xpFile ? String(req.query.xpFile) : path.join(process.cwd(), 'output', 'profile-xp.json');\n const activityFile = req.query.activityFile ? String(req.query.activityFile) : path.join(process.cwd(), 'output', 'profile-activity.json');\n const chunkSize = Math.max(50, Math.min(1000, Number(req.query.chunkSize ?? 500) || 500));\n\n async function readJsonSafe(file) {\n try {\n const txt = await fs.readFile(file, 'utf8');\n return JSON.parse(txt);\n } catch {\n return undefined;\n }\n }\n\n function chunk(arr, size) {\n const out = [];\n for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));\n return out;\n }\n\n const keyPref = String(req.query.key || '').toLowerCase();\n const hasService = !!process.env.SUPABASE_SERVICE_ROLE_KEY;\n const adminToken = req.headers['x-admin-token'] || req.headers['x-admin'] || '';\n let keyMode = 'anon';\n if (keyPref === 'service' || (keyPref !== 'anon' && hasService)) {\n if (!hasService) return res.status(400).json({ error: 'Service key not configured', message: 'Set SUPABASE_SERVICE_ROLE_KEY or use key=anon' });\n if (!process.env.ADMIN_TOKEN || adminToken !== process.env.ADMIN_TOKEN) {\n return res.status(403).json({ error: 'Forbidden', message: 'Admin token required for service key operations.' });\n }\n keyMode = 'service';\n }\n const sb = getSupabase(keyMode);\n\n // USERS\n const concatArr = await readJsonSafe(concatFile);\n if (!Array.isArray(concatArr)) return res.status(400).json({ error: 'Invalid concat JSON', concatFile });\n const seen = new Set();\n const usersRows = [];\n for (const item of concatArr) {\n if (!item || typeof item !== 'object') continue;\n const id = Number(item.user_id ?? item.userId ?? item.id);\n if (!Number.isFinite(id) || id <= 0) continue;\n if (seen.has(id)) continue;\n seen.add(id);\n const username = String(item.user_name ?? item.username ?? item.handle ?? '') || `user-${id}`;\n const avatar_url = String(item.avatar_url ?? item.avatar ?? '') || null;\n usersRows.push({ id, username, avatar_url });\n }\n\n let usersInserted = 0;\n for (const batch of chunk(usersRows, chunkSize)) {\n const { error, count } = await sb.from('users').upsert(batch, { onConflict: 'id', ignoreDuplicates: false, count: 'exact' });\n if (error) return res.status(500).json({ error: 'Supabase users upsert failed', message: error.message });\n usersInserted += (count ?? batch.length);\n }\n\n // XP\n const xpData = await readJsonSafe(xpFile);\n const xpResults = Array.isArray(xpData?.results) ? xpData.results : [];\n const xpRows = [];\n for (const r of xpResults) {\n if (!r || typeof r !== 'object') continue;\n const user_id = Number(r.userId ?? r.userid ?? r.id);\n if (!Number.isFinite(user_id)) continue;\n const xp_points = (typeof r.xp_points === 'number') ? r.xp_points : (r.xp_points == null ? null : Number(r.xp_points));\n const ok = r.ok === undefined ? null : Boolean(r.ok);\n const status = r.status === undefined ? null : Number(r.status);\n xpRows.push({ user_id, xp_points, ok, status });\n }\n let xpUpserts = 0;\n for (const batch of chunk(xpRows, chunkSize)) {\n const { error, count } = await sb.from('user_xp').upsert(batch, { onConflict: 'user_id', ignoreDuplicates: false, count: 'exact' });\n if (error) return res.status(500).json({ error: 'Supabase user_xp upsert failed', message: error.message });\n xpUpserts += (count ?? batch.length);\n }\n\n // ACTIVITY\n const actData = await readJsonSafe(activityFile);\n const actResults = Array.isArray(actData?.results) ? actData.results : [];\n const actRows = [];\n for (const r of actResults) {\n if (!r || typeof r !== 'object') continue;\n const user_id = Number(r.userId ?? r.userid ?? r.id);\n if (!Number.isFinite(user_id)) continue;\n const addresses = Number(r.addresses) || 0;\n const threads = Number(r.threads) || 0;\n const comments = Number(r.comments) || 0;\n const total_upvotes = Number(r.totalUpvotes) || 0;\n const comment_threads = Number(r.commentThreads) || 0;\n actRows.push({ user_id, addresses, threads, comments, total_upvotes, comment_threads });\n }\n let actUpserts = 0;\n for (const batch of chunk(actRows, chunkSize)) {\n const { error, count } = await sb.from('user_activity').upsert(batch, { onConflict: 'user_id', ignoreDuplicates: false, count: 'exact' });\n if (error) return res.status(500).json({ error: 'Supabase user_activity upsert failed', message: error.message });\n actUpserts += (count ?? batch.length);\n }\n\n return res.json({ ok: true, keyMode, usersInserted, xpUpserts, actUpserts, concatFile, xpFile, activityFile });\n } catch (err) {\n return res.status(500).json({ error: 'Import from files failed', message: err?.message || String(err) });\n }\n});\n\n// GET /supabase/import-from-xp-activity\n// Tek bir birleşik JSON'dan (profile-xp-activity.json) verileri Supabase'e aktarır.\n// Query: file, chunkSize (default 500)\napp.get('/supabase/import-from-xp-activity', limiter(RATE_LIMITS.write), async (req, res) => {\n try {\n const file = req.query.file ? String(req.query.file) : path.join(process.cwd(), 'output', 'profile-activity.json');\n const chunkSize = Math.max(50, Math.min(1000, Number(req.query.chunkSize ?? 500) || 500));\n const safe = String(req.query.safe ?? req.query.compat ?? 'false').toLowerCase() === 'true';\n\n async function readJsonSafe(file) {\n try { const txt = await fs.readFile(file, 'utf8'); return JSON.parse(txt); } catch { return undefined; }\n }\n function chunk(arr, size) { const out = []; for (let i=0;i<arr.length;i+=size) out.push(arr.slice(i,i+size)); return out; }\n\n const data = await readJsonSafe(file);\n const rows = Array.isArray(data?.results) ? data.results : Array.isArray(data) ? data : [];\n if (!Array.isArray(rows) || rows.length === 0) return res.status(400).json({ error: 'Invalid or empty JSON', file });\n\n const keyPref = String(req.query.key || '').toLowerCase();\n const hasService = !!process.env.SUPABASE_SERVICE_ROLE_KEY;\n const adminToken = req.headers['x-admin-token'] || req.headers['x-admin'] || '';\n let keyMode = 'anon';\n if (keyPref === 'service' || (keyPref !== 'anon' && hasService)) {\n if (!hasService) return res.status(400).json({ error: 'Service key not configured', message: 'Set SUPABASE_SERVICE_ROLE_KEY or use key=anon' });\n if (!process.env.ADMIN_TOKEN || adminToken !== process.env.ADMIN_TOKEN) {\n return res.status(403).json({ error: 'Forbidden', message: 'Admin token required for service key operations.' });\n }\n keyMode = 'service';\n }\n const sb = getSupabase(keyMode);\n\n // Prepare rows for each table\n const usersRows = [];\n const xpRows = [];\n const actRows = [];\n\n const seenUsers = new Set();\n for (const r of rows) {\n if (!r || typeof r !== 'object') continue;\n const id = Number(r.userId ?? r.userid ?? r.id);\n if (!Number.isFinite(id) || id <= 0) continue;\n if (!seenUsers.has(id)) {\n seenUsers.add(id);\n const display_name = r?.profile?.name ? String(r.profile.name) : null;\n const avatar_url = r?.profile?.avatar_url ? String(r.profile.avatar_url) : null;\n // username bilinmiyorsa dokunma; yalnızca varsa set edelim\n const urow = { id };\n if (!safe && display_name) urow.display_name = display_name;\n if (avatar_url) urow.avatar_url = avatar_url;\n usersRows.push(urow);\n }\n // xp\n const xp_points = r?.xp_points == null ? null : Number(r.xp_points);\n if (xp_points !== null && Number.isFinite(xp_points)) {\n xpRows.push({ user_id: id, xp_points });\n }\n // activity\n const addresses = Number(r.addresses) || 0;\n const threads = Number(r.threads) || 0;\n const comments = Number(r.comments) || 0;\n const total_upvotes = Number(r.totalUpvotes) || 0;\n const comment_threads = Number(r.commentThreads) || 0;\n const referred_by_address = r.referred_by_address ? String(r.referred_by_address) : null;\n const arow = { user_id: id, addresses, threads, comments, total_upvotes, comment_threads };\n if (!safe && referred_by_address) arow.referred_by_address = referred_by_address;\n actRows.push(arow);\n }\n\n // Upserts\n let usersUpserts = 0;\n if (usersRows.length) {\n for (const batch of chunk(usersRows, chunkSize)) {\n const { error, count } = await sb.from('users').upsert(batch, { onConflict: 'id', ignoreDuplicates: false, count: 'exact' });\n if (error) return res.status(500).json({ error: 'Supabase users upsert failed', message: error.message });\n usersUpserts += (count ?? batch.length);\n }\n }\n\n let xpUpserts = 0;\n if (xpRows.length) {\n for (const batch of chunk(xpRows, chunkSize)) {\n const { error, count } = await sb.from('user_xp').upsert(batch, { onConflict: 'user_id', ignoreDuplicates: false, count: 'exact' });\n if (error) return res.status(500).json({ error: 'Supabase user_xp upsert failed', message: error.message });\n xpUpserts += (count ?? batch.length);\n }\n }\n\n let actUpserts = 0;\n if (actRows.length) {\n for (const batch of chunk(actRows, chunkSize)) {\n const { error, count } = await sb.from('user_activity').upsert(batch, { onConflict: 'user_id', ignoreDuplicates: false, count: 'exact' });\n if (error) return res.status(500).json({ error: 'Supabase user_activity upsert failed', message: error.message });\n actUpserts += (count ?? batch.length);\n }\n }\n\n return res.json({ ok: true, file, usersUpserts, xpUpserts, actUpserts, keyMode });\n } catch (err) {\n return res.status(500).json({ error: 'Import from xp-activity failed', message: err?.message || String(err) });\n }\n});\n\n// GET /supabase/import-user-profile-stats\n// profile-activity.json'dan user_profile_stats tablosuna upsert eder.\n// Query: file, chunkSize (default 500)\napp.get('/supabase/import-user-profile-stats', limiter(RATE_LIMITS.write), async (req, res) => {\n try {\n const file = req.query.file ? String(req.query.file) : path.join(process.cwd(), 'output', 'profile-activity.json');\n const chunkSize = Math.max(50, Math.min(1000, Number(req.query.chunkSize ?? 500) || 500));\n\n async function readJsonSafe(file) {\n try { const txt = await fs.readFile(file, 'utf8'); return JSON.parse(txt); } catch { return undefined; }\n }\n function chunk(arr, size) { const out = []; for (let i=0;i<arr.length;i+=size) out.push(arr.slice(i,i+size)); return out; }\n\n const data = await readJsonSafe(file);\n const rows = Array.isArray(data?.results) ? data.results : Array.isArray(data) ? data : [];\n if (!Array.isArray(rows) || rows.length === 0) return res.status(400).json({ error: 'Invalid or empty JSON', file });\n\n const keyPref = String(req.query.key || '').toLowerCase();\n const hasService = !!process.env.SUPABASE_SERVICE_ROLE_KEY;\n let keyMode = 'anon';\n if (keyPref === 'service') {\n if (!hasService) return res.status(400).json({ error: 'Service key not configured', message: 'Set SUPABASE_SERVICE_ROLE_KEY or use key=anon' });\n keyMode = 'service';\n } else if (keyPref === 'anon') {\n keyMode = 'anon';\n } else {\n keyMode = hasService ? 'service' : 'anon';\n }\n const sb = getSupabase(keyMode);\n // Preflight: table existence check\n try {\n const { error: tErr } = await sb.from('user_profile_stats').select('user_id').limit(1);\n // If table doesn't exist, PostgREST returns a 42P01 error; report friendly message\n if (tErr && /relation .* does not exist|42P01/i.test(tErr.message || '')) {\n return res.status(400).json({ error: 'Missing table user_profile_stats', message: 'Run SQL migration to create user_profile_stats before importing.' });\n }\n } catch {}\n\n // Build stats rows\n const statsRows = [];\n const seen = new Set();\n for (const r of rows) {\n if (!r || typeof r !== 'object') continue;\n const user_id = Number(r.userId ?? r.userid ?? r.id);\n if (!Number.isFinite(user_id) || user_id <= 0) continue;\n if (seen.has(user_id)) continue;\n seen.add(user_id);\n const display_name = r?.profile?.name ? String(r.profile.name) : null;\n const avatar_url = r?.profile?.avatar_url ? String(r.profile.avatar_url) : null;\n const xp_points = r?.xp_points == null ? null : Number(r.xp_points);\n const addresses = Number(r.addresses) || 0;\n const threads = Number(r.threads) || 0;\n const comments = Number(r.comments) || 0;\n const total_upvotes = Number(r.totalUpvotes) || 0;\n const comment_threads = Number(r.commentThreads) || 0;\n const referred_by_address = r?.referred_by_address ? String(r.referred_by_address) : null;\n statsRows.push({ user_id, display_name, avatar_url, xp_points, addresses, threads, comments, total_upvotes, comment_threads, referred_by_address });\n }\n\n // Upsert into user_profile_stats\n let upserts = 0;\n for (const batch of chunk(statsRows, chunkSize)) {\n const { error, count } = await sb.from('user_profile_stats').upsert(batch, { onConflict: 'user_id', ignoreDuplicates: false, count: 'exact' });\n if (error) return res.status(500).json({ error: 'Supabase user_profile_stats upsert failed', message: error.message });\n upserts += (count ?? batch.length);\n }\n\n return res.json({ ok: true, file, upserts, keyMode });\n } catch (err) {\n return res.status(500).json({ error: 'Import user_profile_stats failed', message: err?.message || String(err) });\n }\n});\n\n// GET /supabase/import-user-profile-stats-lamumu\n// profile_stats_lamumu.json'dan user_profile_stats tablosuna upsert eder (varsa update, yoksa insert).\n// Query: file (default output/profile_stats_lamumu.json), chunkSize (default 500), key=service|anon\napp.get('/supabase/import-user-profile-stats-lamumu', limiter(RATE_LIMITS.write), async (req, res) => {\n if (!requireAdminInProd(req, res)) return;\n try {\n const file = req.query.file ? String(req.query.file) : path.join(process.cwd(), 'output', 'profile_stats_lamumu.json');\n const chunkSize = Math.max(50, Math.min(1000, Number(req.query.chunkSize ?? 500) || 500));\n const insertOnly = String(req.query.insertOnly ?? 'false').toLowerCase() === 'true'; // if true, ON CONFLICT DO NOTHING (no UPDATE)\n const ensureUsers = String(req.query.ensureUsers ?? 'true').toLowerCase() !== 'false'; // insert minimal users before stats upsert\n\n async function readJsonSafe(file) {\n try { const txt = await fs.readFile(file, 'utf8'); return JSON.parse(txt); } catch { return undefined; }\n }\n function chunk(arr, size) { const out = []; for (let i=0;i<arr.length;i+=size) out.push(arr.slice(i,i+size)); return out; }\n\n const data = await readJsonSafe(file);\n const rows = Array.isArray(data?.results) ? data.results : Array.isArray(data) ? data : [];\n if (!Array.isArray(rows) || rows.length === 0) return res.status(400).json({ error: 'Invalid or empty JSON', file });\n\n // Key mode selection\n const keyPref = String(req.query.key || '').toLowerCase();\n const hasService = !!process.env.SUPABASE_SERVICE_ROLE_KEY;\n let keyMode = 'anon';\n if (keyPref === 'service') {\n if (!hasService) return res.status(400).json({ error: 'Service key not configured', message: 'Set SUPABASE_SERVICE_ROLE_KEY or use key=anon' });\n keyMode = 'service';\n } else if (keyPref === 'anon') {\n keyMode = 'anon';\n } else {\n keyMode = hasService ? 'service' : 'anon';\n }\n const sb = getSupabase(keyMode);\n\n // Preflight: table and optional column (lamumu_count) existence check\n let hasLamumu = true;\n try {\n const { error: tErr } = await sb.from('user_profile_stats').select('user_id').limit(1);\n if (tErr && /relation .* does not exist|42P01/i.test(tErr.message || '')) {\n return res.status(400).json({ error: 'Missing table user_profile_stats', message: 'Create user_profile_stats before importing.' });\n }\n const { error: cErr } = await sb.from('user_profile_stats').select('user_id, lamumu_count').limit(1);\n if (cErr && /column .* does not exist|42703/i.test(cErr.message || '')) {\n hasLamumu = false;\n }\n } catch {}\n\n // Detect available columns in users table to avoid schema cache errors\n let usersCols = { username: true, display_name: true, avatar_url: true };\n try {\n const { error: uErr } = await sb.from('users').select('id, username, avatar_url, display_name').limit(1);\n if (uErr) {\n const msg = String(uErr.message || '');\n if (/display_name/i.test(msg)) usersCols.display_name = false;\n if (/username/i.test(msg)) usersCols.username = false;\n if (/avatar_url/i.test(msg)) usersCols.avatar_url = false;\n }\n } catch {}\n\n // Build upsert rows\n const upRows = [];\n const usersRows = [];\n const seen = new Set();\n for (const r of rows) {\n if (!r || typeof r !== 'object') continue;\n const user_id = Number(r.user_id ?? r.userId ?? r.id);\n if (!Number.isFinite(user_id) || user_id <= 0) continue;\n if (seen.has(user_id)) continue;\n seen.add(user_id);\n const obj = {\n user_id,\n display_name: r.display_name ?? (r.profile && r.profile.name ? String(r.profile.name) : null),\n avatar_url: r.avatar_url ?? (r.profile && r.profile.avatar_url ? String(r.profile.avatar_url) : null),\n xp_points: r.xp_points == null ? null : Number(r.xp_points) || 0,\n addresses: Number(r.addresses) || 0,\n threads: Number(r.threads) || 0,\n comments: Number(r.comments) || 0,\n total_upvotes: Number(r.total_upvotes ?? r.totalUpvotes) || 0,\n comment_threads: Number(r.comment_threads ?? r.commentThreads) || 0,\n };\n if (hasLamumu) obj.lamumu_count = Number(r.lamumu_count) || 0;\n upRows.push(obj);\n\n // Minimal users row to satisfy FK — include only columns that exist\n const urow = { id: user_id };\n if (usersCols.display_name && obj.display_name != null) urow.display_name = obj.display_name;\n if (usersCols.username && obj.display_name != null) {\n // optionally map display_name to username if username column exists and value not provided elsewhere\n // keep it conservative: do not override if no good source\n }\n if (usersCols.avatar_url && obj.avatar_url != null) urow.avatar_url = obj.avatar_url;\n usersRows.push(urow);\n }\n if (upRows.length === 0) return res.status(400).json({ error: 'No valid rows to upsert', file });\n\n // Ensure users exist (FK) before upserting stats\n let usersUpserts = 0;\n if (ensureUsers && usersRows.length) {\n for (const batch of chunk(usersRows, chunkSize)) {\n const { error, count } = await sb\n .from('users')\n .upsert(batch, { onConflict: 'id', ignoreDuplicates: insertOnly, count: 'exact' });\n if (error) return res.status(500).json({ error: 'Supabase users upsert failed', message: error.message, keyMode, insertOnly });\n usersUpserts += (count ?? (insertOnly ? 0 : batch.length));\n }\n }\n\n // Upsert in chunks\n let upserts = 0;\n for (const batch of chunk(upRows, chunkSize)) {\n const { error, count } = await sb\n .from('user_profile_stats')\n .upsert(batch, { onConflict: 'user_id', ignoreDuplicates: insertOnly, count: 'exact' });\n if (error) return res.status(500).json({ error: 'Supabase user_profile_stats upsert failed', message: error.message, keyMode, insertOnly });\n upserts += (count ?? (insertOnly ? 0 : batch.length));\n }\n\n return res.json({ ok: true, file, upserts, usersUpserts, keyMode, hasLamumu, insertOnly, ensureUsers });\n } catch (err) {\n return res.status(500).json({ error: 'Import user_profile_stats from lamumu failed', message: err?.message || String(err) });\n }\n});\n\n// GET /supabase/ping\n// Supabase bağlantısını ve env yapılandırmasını doğrular (gizli anahtarları göstermez).\napp.get('/supabase/ping', async (req, res) => {\n try {\n if (process.env.NODE_ENV === 'production' || process.env.VERCEL) {\n return res.status(404).json({ error: 'Not found' });\n }\n const forceKey = '';\n const hasUrl = !!process.env.SUPABASE_URL;\n const hasAnon = !!process.env.SUPABASE_ANON_KEY;\n const hasService = !!process.env.SUPABASE_SERVICE_ROLE_KEY;\n const keyInUse = hasAnon ? 'anon' : (hasService ? 'service' : null);\n let projectRef = null;\n try {\n if (process.env.SUPABASE_URL) {\n const u = new URL(process.env.SUPABASE_URL);\n // Expect <ref>.supabase.co\n const host = u.hostname;\n projectRef = host?.split('.')?.[0] || null;\n }\n } catch {}\n\n // Try a light query if possible\n let queryOk = false;\n let queryError = null;\n try {\n const sb = getSupabase();\n const { error } = await sb.from('users').select('id').limit(1);\n if (!error) queryOk = true; else queryError = error.message || String(error);\n } catch (e) {\n queryError = e?.message || String(e);\n }\n\n // Minimize details on production/Vercel\n if (process.env.NODE_ENV === 'production' || process.env.VERCEL) {\n return res.json({ ok: queryOk });\n }\n return res.json({ ok: queryOk, keyInUse, forced: false, hasUrl, hasAnon, hasService, projectRef, error: queryError });\n } catch (err) {\n return res.status(500).json({ ok: false, error: err?.message || String(err) });\n }\n});\n\n// GET /supabase/summary-upsert-from-files\n// Reads local JSONs (concat.json, profile-xp.json, profile-activity.json), computes summary metrics,\n// and upserts a single row into summary_stats (default id=1) in Supabase.\n// Query: concatFile (for total_users), xpFile, activityFile, id (default 1), onlyOk=true|false, threshold (default 1000),\n// key=anon|service, totalUsers (optional override)\napp.get('/supabase/summary-upsert-from-files', limiter(RATE_LIMITS.write), async (req, res) => {\n try {\n const concatFile = req.query.concatFile ? String(req.query.concatFile) : path.join(process.cwd(), 'output', 'concat.json');\n const xpFile = req.query.xpFile ? String(req.query.xpFile) : path.join(process.cwd(), 'output', 'profile-xp.json');\n const activityFile = req.query.activityFile ? String(req.query.activityFile) : path.join(process.cwd(), 'output', 'profile-activity.json');\n const rowId = Number(req.query.id ?? 1) || 1;\n const onlyOk = String(req.query.onlyOk ?? 'true').toLowerCase() !== 'false';\n const threshold = Number(req.query.threshold ?? 1000) || 1000;\n const totalUsersOverride = req.query.totalUsers !== undefined ? Number(req.query.totalUsers) : undefined;\n\n async function readJsonSafe(file) {\n try {\n const txt = await fs.readFile(file, 'utf8');\n return JSON.parse(txt);\n } catch { return undefined; }\n }\n\n const [concatArr, xpData, actData] = await Promise.all([\n readJsonSafe(concatFile),\n readJsonSafe(xpFile),\n readJsonSafe(activityFile),\n ]);\n\n // total_users: prefer explicit override; else dedupe users in concat array; else fall back to xp results count\n let total_users = Number.isFinite(totalUsersOverride) ? totalUsersOverride : undefined;\n if (total_users === undefined && Array.isArray(concatArr)) {\n const seen = new Set();\n for (const item of concatArr) {\n if (!item || typeof item !== 'object') continue;\n const id = Number(item.user_id ?? item.userId ?? item.id);\n if (!Number.isFinite(id)) continue;\n seen.add(id);\n }\n total_users = seen.size;\n }\n\n const xpResults = Array.isArray(xpData?.results) ? xpData.results : [];\n let total_aura = 0;\n let max_aura = 0;\n let max_aura_user_id = null;\n let users_over_1000 = 0;\n let zero_aura_count = 0;\n const okIds = new Set();\n for (const r of xpResults) {\n if (!r || typeof r !== 'object') continue;\n if (onlyOk && r.ok === false) continue;\n const uid = Number(r.userId ?? r.userid ?? r.id);\n const n = toNumber(r.xp_points);\n if (!Number.isFinite(uid) || n === undefined) continue;\n okIds.add(uid);\n total_aura += n;\n if (n > max_aura) { max_aura = n; max_aura_user_id = uid; }\n if (n > threshold) users_over_1000++;\n if (n === 0) zero_aura_count++;\n }\n if (total_users === undefined) total_users = okIds.size;\n\n const actResults = Array.isArray(actData?.results) ? actData.results : [];\n let total_addresses = 0, total_threads = 0, total_comments = 0, total_upvotes = 0, total_comment_threads = 0;\n for (const r of actResults) {\n if (!r || typeof r !== 'object') continue;\n const uid = Number(r.userId ?? r.userid ?? r.id);\n if (!Number.isFinite(uid)) continue;\n if (onlyOk && !okIds.has(uid)) continue;\n total_addresses += toNumber(r.addresses) || 0;\n total_threads += toNumber(r.threads) || 0;\n total_comments += toNumber(r.comments) || 0;\n total_upvotes += toNumber(r.totalUpvotes) || 0;\n total_comment_threads += toNumber(r.commentThreads) || 0;\n }\n\n // Key selection: anon by default; service allowed only with ADMIN_TOKEN\n const keyPref = String(req.query.key || '').toLowerCase();\n const hasService = !!process.env.SUPABASE_SERVICE_ROLE_KEY;\n const adminToken = req.headers['x-admin-token'] || req.headers['x-admin'] || '';\n let keyMode = 'anon';\n if (keyPref === 'service') {\n if (!hasService) return res.status(400).json({ error: 'Service key not configured', message: 'Set SUPABASE_SERVICE_ROLE_KEY or use key=anon' });\n if (!process.env.ADMIN_TOKEN || adminToken !== process.env.ADMIN_TOKEN) {\n return res.status(403).json({ error: 'Forbidden', message: 'Admin token required for service key operations.' });\n }\n keyMode = 'service';\n }\n const sb = getSupabase(keyMode);\n // Check if zero_aura_count column exists; include conditionally\n let hasZeroAura = true;\n try {\n const { error: cErr } = await sb.from('summary_stats').select('zero_aura_count').limit(1);\n if (cErr && /column .* does not exist|42703/i.test(cErr.message || '')) hasZeroAura = false;\n } catch { hasZeroAura = false; }\n\n const payload = {\n id: rowId,\n total_users,\n total_aura,\n max_aura,\n max_aura_user_id,\n users_over_1000,\n total_addresses,\n total_threads,\n total_comments,\n total_upvotes,\n total_comment_threads,\n ...(hasZeroAura ? { zero_aura_count } : {}),\n };\n const { error } = await sb.from('summary_stats').upsert([payload], { onConflict: 'id', ignoreDuplicates: false });\n if (error) return res.status(500).json({ error: 'Supabase summary_stats upsert failed', message: error.message, payload });\n\n return res.json({ ok: true, keyMode, payload });\n } catch (err) {\n return res.status(500).json({ error: 'Summary upsert from files failed', message: err?.message || String(err) });\n }\n});\n\n// GET /supabase/summary-upsert-from-stats\n// Aggregates metrics directly from user_profile_stats and upserts into summary_stats (default id=1).\n// Query: id (default 1), threshold (default 1000), key=anon|service\napp.get('/supabase/summary-upsert-from-stats', limiter(RATE_LIMITS.write), async (req, res) => {\n try {\n const rowId = Number(req.query.id ?? 1) || 1;\n const threshold = Number(req.query.threshold ?? 1000) || 1000;\n // PostgREST genelde tek istekte max ~1000 satır döndürür; bu nedenle güvenli varsayılan 1000.\n const pageSize = Math.max(1, Math.min(1000, Number(req.query.pageSize ?? 1000) || 1000));\n\n // Key selection (anon by default, service if available or explicitly requested)\n const keyPref = String(req.query.key || '').toLowerCase();\n const hasService = !!process.env.SUPABASE_SERVICE_ROLE_KEY;\n const adminToken = req.headers['x-admin-token'] || req.headers['x-admin'] || '';\n // Default to anon unless explicitly requested as service with ADMIN_TOKEN\n let keyMode = 'anon';\n if (keyPref === 'service') {\n if (!hasService) return res.status(400).json({ error: 'Service key not configured', message: 'Set SUPABASE_SERVICE_ROLE_KEY or use key=anon' });\n if (!process.env.ADMIN_TOKEN || adminToken !== process.env.ADMIN_TOKEN) {\n return res.status(403).json({ error: 'Forbidden', message: 'Admin token required for service key operations.' });\n }\n keyMode = 'service';\n }\n const sb = getSupabase(keyMode);\n\n // Scan table in pages and compute aggregates client-side (avoids PostgREST aggregate restrictions)\n let total_users = 0;\n let sum_xp_points = 0;\n let max_xp_points = 0;\n let max_aura_user_id = null;\n let total_addresses = 0;\n let total_threads = 0;\n let total_comments = 0;\n let total_upvotes = 0;\n let total_comment_threads = 0;\n let users_over_1000 = 0;\n let zero_aura_count = 0;\n\n let from = 0;\n for (;;) {\n const to = from + pageSize - 1;\n const { data, error } = await sb\n .from('user_profile_stats')\n .select('user_id, xp_points, addresses, threads, comments, total_upvotes, comment_threads')\n .order('user_id', { ascending: true, nullsFirst: false })\n .range(from, to);\n if (error) return res.status(500).json({ error: 'scan failed', message: error.message });\n const rows = Array.isArray(data) ? data : [];\n if (!rows.length) break;\n for (const r of rows) {\n total_users += 1;\n const uid = Number(r.user_id);\n const xp = r.xp_points == null ? 0 : Number(r.xp_points);\n const addr = Number(r.addresses) || 0;\n const thr = Number(r.threads) || 0;\n const com = Number(r.comments) || 0;\n const upv = Number(r.total_upvotes) || 0;\n const cthr = Number(r.comment_threads) || 0;\n sum_xp_points += xp;\n total_addresses += addr;\n total_threads += thr;\n total_comments += com;\n total_upvotes += upv;\n total_comment_threads += cthr;\n if (xp >= threshold) users_over_1000 += 1;\n if (xp === 0) zero_aura_count += 1;\n if (xp > max_xp_points) { max_xp_points = xp; max_aura_user_id = uid; }\n }\n from += rows.length;\n // rows.length < pageSize ise son sayfadayız; yoksa devam et\n if (rows.length < pageSize) break;\n }\n\n // Upsert summary row\n // Optional column check for zero_aura_count\n let hasZeroAura = true;\n try {\n const { error: cErr } = await sb.from('summary_stats').select('zero_aura_count').limit(1);\n if (cErr && /column .* does not exist|42703/i.test(cErr.message || '')) hasZeroAura = false;\n } catch { hasZeroAura = false; }\n\n const payload = {\n id: rowId,\n total_users,\n total_aura: sum_xp_points,\n max_aura: max_xp_points,\n max_aura_user_id,\n users_over_1000,\n total_addresses,\n total_threads,\n total_comments,\n total_upvotes,\n total_comment_threads,\n ...(hasZeroAura ? { zero_aura_count } : {}),\n updated_at: new Date().toISOString(),\n };\n const { error: upErr } = await sb.from('summary_stats').upsert(payload, { onConflict: 'id' });\n if (upErr) return res.status(500).json({ error: 'summary upsert failed', message: upErr.message });\n\n return res.json({ ok: true, id: rowId, keyMode, summary: payload });\n } catch (err) {\n return res.status(500).json({ error: 'summary from stats failed', message: err?.message || String(err) });\n }\n});\n"}}}}