1 line
221 KiB
JSON
1 line
221 KiB
JSON
{"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) => ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[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) => ({ '&': '&', '<': '<', '>': '>' }[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) => ({ '&': '&', '<': '<', '>': '>' }[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 => ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[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"}}}} |