import express from 'express'; import cors from 'cors'; import fs from 'fs'; import path from 'path'; import crypto from 'crypto'; import { fileURLToPath } from 'url'; import { DEFAULT_WEIGHTS, scoreProfile, safeStr as scoreSafeStr } from './matchScoring.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Always use ../data (relative to server/) so Docker and local dev use the same directory const DATA_DIR = process.env.DATA_PATH || path.join(__dirname, '..', 'data'); const PORT = process.env.PORT || 3001; const app = express(); app.use(cors()); app.use(express.json({ limit: '5mb' })); // Ensure data directory exists if (!fs.existsSync(DATA_DIR)) { fs.mkdirSync(DATA_DIR, { recursive: true }); } // Auth credentials file const AUTH_FILE = path.join(DATA_DIR, 'auth.json'); // Read auth credentials const getAuthCredentials = () => { if (fs.existsSync(AUTH_FILE)) { try { return JSON.parse(fs.readFileSync(AUTH_FILE, 'utf8')); } catch (e) { console.error('Failed to read auth file:', e); } } // Default credentials if file doesn't exist return { username: 'admin', password: 'feeld123' }; }; // Simple session store (in-memory, resets on restart) const sessions = new Map(); // Helper to get user data file path const getUserDataPath = (userId) => { // Sanitize userId to prevent path traversal const safeId = userId.replace(/[^a-zA-Z0-9-_]/g, '_'); return path.join(DATA_DIR, `${safeId}.json`); }; // Helper to read user data const readUserData = (userId) => { const filePath = getUserDataPath(userId); if (fs.existsSync(filePath)) { try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch (e) { console.error('Failed to read user data:', e); return {}; } } return {}; }; // Helper to write user data const writeUserData = (userId, data) => { const filePath = getUserDataPath(userId); fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); }; // GET /api/data/:userId - Get all user data app.get('/api/data/:userId', (req, res) => { const { userId } = req.params; const data = readUserData(userId); res.json(data); }); // PUT /api/data/:userId - Update all user data app.put('/api/data/:userId', (req, res) => { const { userId } = req.params; const data = req.body; writeUserData(userId, data); res.json({ success: true, data }); }); // GET /api/data/:userId/:key - Get specific key app.get('/api/data/:userId/:key', (req, res) => { const { userId, key } = req.params; const data = readUserData(userId); res.json({ [key]: data[key] || null }); }); // PUT /api/data/:userId/:key - Update specific key app.put('/api/data/:userId/:key', (req, res) => { const { userId, key } = req.params; const { value } = req.body; const data = readUserData(userId); data[key] = value; data.updatedAt = new Date().toISOString(); writeUserData(userId, data); res.json({ success: true, [key]: value }); }); // DELETE /api/data/:userId/:key - Delete specific key app.delete('/api/data/:userId/:key', (req, res) => { const { userId, key } = req.params; const data = readUserData(userId); delete data[key]; writeUserData(userId, data); res.json({ success: true }); }); // POST /api/data/:userId/liked-profiles - Add a liked profile app.post('/api/data/:userId/liked-profiles', (req, res) => { const { userId } = req.params; const { id, name } = req.body; const data = readUserData(userId); if (!data.likedProfiles) { data.likedProfiles = []; } // Don't add duplicates if (!data.likedProfiles.some(p => p.id === id)) { data.likedProfiles.unshift({ id, name, likedAt: Date.now(), }); data.updatedAt = new Date().toISOString(); writeUserData(userId, data); } res.json({ success: true, likedProfiles: data.likedProfiles }); }); // DELETE /api/data/:userId/liked-profiles/:profileId - Remove a liked profile app.delete('/api/data/:userId/liked-profiles/:profileId', (req, res) => { const { userId, profileId } = req.params; const data = readUserData(userId); if (data.likedProfiles) { data.likedProfiles = data.likedProfiles.filter(p => p.id !== profileId); writeUserData(userId, data); } res.json({ success: true, likedProfiles: data.likedProfiles || [] }); }); // GET /api/who-liked-you - Get profiles who liked you (discovered via scanning) app.get('/api/who-liked-you', (req, res) => { const filePath = path.join(DATA_DIR, 'whoLikedYou.json'); if (fs.existsSync(filePath)) { try { const data = JSON.parse(fs.readFileSync(filePath, 'utf8')); res.json(data); } catch (e) { console.error('Failed to read whoLikedYou.json:', e); res.json({ profiles: [] }); } } else { res.json({ profiles: [] }); } }); // POST /api/who-liked-you - Add or update a profile who liked you app.post('/api/who-liked-you', (req, res) => { const { profile } = req.body; const filePath = path.join(DATA_DIR, 'whoLikedYou.json'); let data = { profiles: [], updatedAt: null }; if (fs.existsSync(filePath)) { try { data = JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch (e) { console.error('Failed to read whoLikedYou.json:', e); } } // Find existing profile by ID const existingIndex = data.profiles.findIndex(p => p.id === profile.id); if (existingIndex >= 0) { // Update existing profile, preserving original discoveredAt const originalDiscoveredAt = data.profiles[existingIndex].discoveredAt; data.profiles[existingIndex] = { ...profile, discoveredAt: originalDiscoveredAt, updatedAt: new Date().toISOString(), }; console.log('Updated existing profile:', profile.imaginaryName); } else { // Add new profile data.profiles.unshift({ ...profile, discoveredAt: new Date().toISOString(), }); console.log('Added new profile:', profile.imaginaryName); } data.updatedAt = new Date().toISOString(); fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); res.json({ success: true, profile }); }); // DELETE /api/who-liked-you/:profileId - Remove a discovered profile app.delete('/api/who-liked-you/:profileId', (req, res) => { const { profileId } = req.params; const filePath = path.join(DATA_DIR, 'whoLikedYou.json'); if (fs.existsSync(filePath)) { try { const data = JSON.parse(fs.readFileSync(filePath, 'utf8')); data.profiles = data.profiles.filter(p => p.id !== profileId); data.updatedAt = new Date().toISOString(); fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); res.json({ success: true, profiles: data.profiles }); } catch (e) { console.error('Failed to update whoLikedYou.json:', e); res.status(500).json({ success: false, error: e.message }); } } else { res.json({ success: true, profiles: [] }); } }); // GET /api/sent-pings - Get all sent pings app.get('/api/sent-pings', (req, res) => { const filePath = path.join(DATA_DIR, 'sentPings.json'); if (fs.existsSync(filePath)) { try { const data = JSON.parse(fs.readFileSync(filePath, 'utf8')); res.json(data); } catch (e) { console.error('Failed to read sentPings.json:', e); res.json({ pings: [] }); } } else { res.json({ pings: [] }); } }); // POST /api/sent-pings - Add a sent ping app.post('/api/sent-pings', (req, res) => { const { targetProfileId, targetName, message } = req.body; const filePath = path.join(DATA_DIR, 'sentPings.json'); let data = { pings: [], updatedAt: null }; if (fs.existsSync(filePath)) { try { data = JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch (e) { console.error('Failed to read sentPings.json:', e); } } // Don't add duplicates if (!data.pings.some(p => p.targetProfileId === targetProfileId)) { data.pings.unshift({ targetProfileId, targetName, message, sentAt: Date.now(), status: 'SENT', }); data.updatedAt = new Date().toISOString(); fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); } res.json({ success: true, pings: data.pings }); }); // PUT /api/sent-pings/:targetProfileId - Update a sent ping status app.put('/api/sent-pings/:targetProfileId', (req, res) => { const { targetProfileId } = req.params; const { status } = req.body; const filePath = path.join(DATA_DIR, 'sentPings.json'); if (fs.existsSync(filePath)) { try { const data = JSON.parse(fs.readFileSync(filePath, 'utf8')); const ping = data.pings.find(p => p.targetProfileId === targetProfileId); if (ping) { ping.status = status; data.updatedAt = new Date().toISOString(); fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); } res.json({ success: true, pings: data.pings }); } catch (e) { console.error('Failed to update sentPings.json:', e); res.status(500).json({ success: false, error: e.message }); } } else { res.json({ success: false, error: 'No sent pings found' }); } }); // DELETE /api/sent-pings/:targetProfileId - Remove a sent ping app.delete('/api/sent-pings/:targetProfileId', (req, res) => { const { targetProfileId } = req.params; const filePath = path.join(DATA_DIR, 'sentPings.json'); if (fs.existsSync(filePath)) { try { const data = JSON.parse(fs.readFileSync(filePath, 'utf8')); data.pings = data.pings.filter(p => p.targetProfileId !== targetProfileId); data.updatedAt = new Date().toISOString(); fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); res.json({ success: true, pings: data.pings }); } catch (e) { console.error('Failed to update sentPings.json:', e); res.status(500).json({ success: false, error: e.message }); } } else { res.json({ success: true, pings: [] }); } }); // GET /api/disliked-profiles - Get all disliked profiles app.get('/api/disliked-profiles', (req, res) => { const filePath = path.join(DATA_DIR, 'dislikedProfiles.json'); if (fs.existsSync(filePath)) { try { const data = JSON.parse(fs.readFileSync(filePath, 'utf8')); res.json(data); } catch (e) { console.error('Failed to read dislikedProfiles.json:', e); res.json({ profiles: [] }); } } else { res.json({ profiles: [] }); } }); // POST /api/disliked-profiles - Add a disliked profile app.post('/api/disliked-profiles', (req, res) => { const { profile } = req.body; const filePath = path.join(DATA_DIR, 'dislikedProfiles.json'); let data = { profiles: [], updatedAt: null }; if (fs.existsSync(filePath)) { try { data = JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch (e) { console.error('Failed to read dislikedProfiles.json:', e); } } // Don't add duplicates - check by ID if (!data.profiles.some(p => p.id === profile.id)) { data.profiles.unshift({ ...profile, dislikedAt: new Date().toISOString(), }); data.updatedAt = new Date().toISOString(); fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); console.log('Added disliked profile:', profile.imaginaryName); } res.json({ success: true, profiles: data.profiles }); }); // DELETE /api/disliked-profiles/:profileId - Remove a disliked profile app.delete('/api/disliked-profiles/:profileId', (req, res) => { const { profileId } = req.params; const filePath = path.join(DATA_DIR, 'dislikedProfiles.json'); if (fs.existsSync(filePath)) { try { const data = JSON.parse(fs.readFileSync(filePath, 'utf8')); data.profiles = data.profiles.filter(p => p.id !== profileId); data.updatedAt = new Date().toISOString(); fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); res.json({ success: true, profiles: data.profiles }); } catch (e) { console.error('Failed to update dislikedProfiles.json:', e); res.status(500).json({ success: false, error: e.message }); } } else { res.json({ success: true, profiles: [] }); } }); // GET /api/discovered-profiles - Get all discovered profiles cache app.get('/api/discovered-profiles', (req, res) => { const filePath = path.join(DATA_DIR, 'discoveredProfiles.json'); if (fs.existsSync(filePath)) { try { const data = JSON.parse(fs.readFileSync(filePath, 'utf8')); res.json(data); } catch (e) { console.error('Failed to read discoveredProfiles.json:', e); res.json({ profiles: [] }); } } else { res.json({ profiles: [] }); } }); // GET /api/discovered-profiles/lookup/:id - Get a single cached profile app.get('/api/discovered-profiles/lookup/:id', (req, res) => { const filePath = path.join(DATA_DIR, 'discoveredProfiles.json'); if (!fs.existsSync(filePath)) return res.json({ profile: null }); try { const data = JSON.parse(fs.readFileSync(filePath, 'utf8')); const target = (data.profiles || []).find(p => p.id === req.params.id); res.json({ profile: target || null }); } catch (e) { res.json({ profile: null }); } }); // POST /api/discovered-profiles/batch - Batch upsert discovered profiles app.post('/api/discovered-profiles/batch', (req, res) => { const { profiles: incoming } = req.body; if (!Array.isArray(incoming) || incoming.length === 0) { return res.status(400).json({ success: false, error: 'profiles array required' }); } const filePath = path.join(DATA_DIR, 'discoveredProfiles.json'); let data = { profiles: [], updatedAt: null }; if (fs.existsSync(filePath)) { try { data = JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch (e) { console.error('Failed to read discoveredProfiles.json:', e); } } const existingMap = new Map(data.profiles.map(p => [p.id, p])); let added = 0; let updated = 0; for (const profile of incoming) { if (!profile.id) continue; const existing = existingMap.get(profile.id); if (existing) { // Last-seen wins for discoveredLocation; fall back to prior value if // this batch didn't carry one. existingMap.set(profile.id, { ...existing, ...profile, discoveredLocation: profile.discoveredLocation ?? existing.discoveredLocation ?? null, discoveredAt: existing.discoveredAt, updatedAt: new Date().toISOString(), }); updated++; } else { existingMap.set(profile.id, { ...profile, discoveredLocation: profile.discoveredLocation ?? null, discoveredAt: profile.discoveredAt || new Date().toISOString(), }); added++; } } // Convert back to array, sort by discoveredAt descending (newest first) let allProfiles = Array.from(existingMap.values()); allProfiles.sort((a, b) => { const dateA = a.discoveredAt ? new Date(a.discoveredAt).getTime() : 0; const dateB = b.discoveredAt ? new Date(b.discoveredAt).getTime() : 0; return dateB - dateA; }); // Cap at 2000 profiles, evict oldest if (allProfiles.length > 2000) { allProfiles = allProfiles.slice(0, 2000); } data.profiles = allProfiles; data.updatedAt = new Date().toISOString(); fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); console.log(`Discovered profiles batch: +${added} new, ${updated} updated, ${allProfiles.length} total`); res.json({ success: true, added, updated, total: allProfiles.length }); }); // DELETE /api/discovered-profiles/:profileId - Remove a discovered profile app.delete('/api/discovered-profiles/:profileId', (req, res) => { const { profileId } = req.params; const filePath = path.join(DATA_DIR, 'discoveredProfiles.json'); if (fs.existsSync(filePath)) { try { const data = JSON.parse(fs.readFileSync(filePath, 'utf8')); data.profiles = data.profiles.filter(p => p.id !== profileId); data.updatedAt = new Date().toISOString(); fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); res.json({ success: true, profiles: data.profiles }); } catch (e) { console.error('Failed to update discoveredProfiles.json:', e); res.status(500).json({ success: false, error: e.message }); } } else { res.json({ success: true, profiles: [] }); } }); // PUT /api/discovered-profiles/update-photos - Update photos for a cached profile app.put('/api/discovered-profiles/update-photos', (req, res) => { const { profileId, photos } = req.body; if (!profileId || !Array.isArray(photos)) { return res.status(400).json({ success: false, error: 'profileId and photos array required' }); } const filePath = path.join(DATA_DIR, 'discoveredProfiles.json'); if (fs.existsSync(filePath)) { try { const data = JSON.parse(fs.readFileSync(filePath, 'utf8')); const idx = (data.profiles || []).findIndex(p => p.id === profileId); if (idx >= 0) { data.profiles[idx].photos = photos; data.profiles[idx].photosRefreshedAt = new Date().toISOString(); data.updatedAt = new Date().toISOString(); fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); return res.json({ success: true, updated: true }); } return res.json({ success: true, updated: false, reason: 'profile not found' }); } catch (e) { console.error('Failed to update photos:', e); return res.status(500).json({ success: false, error: e.message }); } } res.json({ success: true, updated: false, reason: 'no cache file' }); }); // ============================================================ // Smart Matches Endpoints // ============================================================ const MATCH_WEIGHTS_FILE = path.join(DATA_DIR, 'matchWeights.json'); function readMatchWeights() { if (fs.existsSync(MATCH_WEIGHTS_FILE)) { try { return { ...DEFAULT_WEIGHTS, ...JSON.parse(fs.readFileSync(MATCH_WEIGHTS_FILE, 'utf8')) }; } catch (e) { console.error('Failed to read matchWeights.json:', e); } } return { ...DEFAULT_WEIGHTS }; } function readJsonFile(filename, fallback) { const filePath = path.join(DATA_DIR, filename); if (fs.existsSync(filePath)) { try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch (e) { console.error(`Failed to read ${filename}:`, e); } } return fallback; } // GET /api/matches — Score, filter, and rank discovered profiles app.get('/api/matches', (req, res) => { try { const { minAge, maxAge, maxDistance, gender, sexuality, desires, verifiedOnly, search, theyLikedOnly, sort, limit = '50', offset = '0', } = req.query; const limitNum = Math.min(parseInt(limit) || 50, 200); const offsetNum = parseInt(offset) || 0; // 1. Read discovered profiles const discovered = readJsonFile('discoveredProfiles.json', { profiles: [] }); let profiles = discovered.profiles || []; // 2. Build exclusion sets const disliked = readJsonFile('dislikedProfiles.json', { profiles: [] }); const dislikedIds = new Set((disliked.profiles || []).map(p => p.id)); const sentPings = readJsonFile('sentPings.json', { pings: [] }); const sentPingIds = new Set((sentPings.pings || []).map(p => p.targetProfileId)); // Read user.json to get liked profiles const userFiles = fs.readdirSync(DATA_DIR).filter(f => f.endsWith('.json') && !['discoveredProfiles', 'dislikedProfiles', 'sentPings', 'whoLikedYou', 'auth', 'auth-tokens', 'locationRotation', 'savedLocations', 'matchWeights'].some(n => f.startsWith(n))); let likedIds = new Set(); for (const file of userFiles) { try { const data = JSON.parse(fs.readFileSync(path.join(DATA_DIR, file), 'utf8')); if (data.likedProfiles) { data.likedProfiles.forEach(p => likedIds.add(p.id)); } } catch (e) { /* skip */ } } // 3. Filter out excluded profiles profiles = profiles.filter(p => !dislikedIds.has(p.id) && !sentPingIds.has(p.id) && !likedIds.has(p.id)); // 4. Apply query param filters if (minAge) { const min = parseInt(minAge); profiles = profiles.filter(p => typeof p.age === 'number' && p.age >= min); } if (maxAge) { const max = parseInt(maxAge); profiles = profiles.filter(p => typeof p.age === 'number' && p.age <= max); } if (maxDistance) { const maxDist = parseInt(maxDistance); profiles = profiles.filter(p => { const mi = p.distance?.mi; return typeof mi === 'number' && mi <= maxDist; }); } if (gender) { const genders = gender.split(',').map(g => g.trim().toUpperCase()); profiles = profiles.filter(p => { const pg = (typeof p.gender === 'string' ? p.gender : '').toUpperCase(); return genders.includes(pg); }); } if (sexuality) { const sexualities = sexuality.split(',').map(s => s.trim().toUpperCase()); profiles = profiles.filter(p => { const ps = (typeof p.sexuality === 'string' ? p.sexuality : '').toUpperCase(); return sexualities.includes(ps); }); } if (desires) { const desireList = desires.split(',').map(d => d.trim().toUpperCase()); profiles = profiles.filter(p => { const pd = Array.isArray(p.desires) ? p.desires.map(d => (typeof d === 'string' ? d : '').toUpperCase()) : []; return desireList.some(d => pd.includes(d)); }); } if (verifiedOnly === 'true') { profiles = profiles.filter(p => { const vs = typeof p.verificationStatus === 'string' ? p.verificationStatus : ''; return vs.toUpperCase() === 'VERIFIED'; }); } if (theyLikedOnly === 'true') { profiles = profiles.filter(p => p.interactionStatus?.theirs === 'LIKED'); } // 5. Text search on name + bio if (search) { const searchLower = search.toLowerCase(); profiles = profiles.filter(p => { const name = (typeof p.imaginaryName === 'string' ? p.imaginaryName : '').toLowerCase(); const bio = (typeof p.bio === 'string' ? p.bio : '').toLowerCase(); return name.includes(searchLower) || bio.includes(searchLower); }); } // 6. Score each profile const weights = readMatchWeights(); const scored = profiles.map(p => { const { total, breakdown } = scoreProfile(p, weights); return { ...p, _score: total, _scoreBreakdown: breakdown }; }); // 7. Sort if (sort === 'distance') { scored.sort((a, b) => (a.distance?.mi ?? 9999) - (b.distance?.mi ?? 9999)); } else if (sort === 'recent') { scored.sort((a, b) => { const da = a.discoveredAt ? new Date(a.discoveredAt).getTime() : 0; const db = b.discoveredAt ? new Date(b.discoveredAt).getTime() : 0; return db - da; }); } else { // Default: score descending scored.sort((a, b) => b._score - a._score); } // 8. Paginate const total = scored.length; const paginated = scored.slice(offsetNum, offsetNum + limitNum); res.json({ matches: paginated, total, filters: { minAge, maxAge, maxDistance, gender, sexuality, desires, verifiedOnly, search, theyLikedOnly, sort }, updatedAt: discovered.updatedAt, }); } catch (e) { console.error('Failed to compute matches:', e); res.status(500).json({ error: e.message }); } }); // GET /api/matches/weights — Return current scoring weights app.get('/api/matches/weights', (req, res) => { res.json({ weights: readMatchWeights(), updatedAt: new Date().toISOString() }); }); // PUT /api/matches/weights — Update scoring weights app.put('/api/matches/weights', (req, res) => { const { weights } = req.body; if (!weights || typeof weights !== 'object') { return res.status(400).json({ success: false, error: 'weights object required' }); } const merged = { ...DEFAULT_WEIGHTS, ...weights }; fs.writeFileSync(MATCH_WEIGHTS_FILE, JSON.stringify(merged, null, 2)); res.json({ success: true, weights: merged }); }); // GET /api/matches/summary — Discord-friendly text summary app.get('/api/matches/summary', (req, res) => { try { const { limit = '5', ...filterParams } = req.query; // Reuse the matches logic by building the same pipeline const discovered = readJsonFile('discoveredProfiles.json', { profiles: [] }); let profiles = discovered.profiles || []; // Exclusions const disliked = readJsonFile('dislikedProfiles.json', { profiles: [] }); const dislikedIds = new Set((disliked.profiles || []).map(p => p.id)); const sentPings = readJsonFile('sentPings.json', { pings: [] }); const sentPingIds = new Set((sentPings.pings || []).map(p => p.targetProfileId)); profiles = profiles.filter(p => !dislikedIds.has(p.id) && !sentPingIds.has(p.id)); // Apply filters if (filterParams.theyLikedOnly === 'true') { profiles = profiles.filter(p => p.interactionStatus?.theirs === 'LIKED'); } if (filterParams.verifiedOnly === 'true') { profiles = profiles.filter(p => (typeof p.verificationStatus === 'string' ? p.verificationStatus : '').toUpperCase() === 'VERIFIED'); } if (filterParams.maxAge) { profiles = profiles.filter(p => typeof p.age === 'number' && p.age <= parseInt(filterParams.maxAge)); } if (filterParams.minAge) { profiles = profiles.filter(p => typeof p.age === 'number' && p.age >= parseInt(filterParams.minAge)); } if (filterParams.maxDistance) { profiles = profiles.filter(p => typeof p.distance?.mi === 'number' && p.distance.mi <= parseInt(filterParams.maxDistance)); } // Score & sort const weights = readMatchWeights(); const scored = profiles.map(p => ({ ...p, _score: scoreProfile(p, weights).total, })); scored.sort((a, b) => b._score - a._score); const topN = scored.slice(0, parseInt(limit) || 5); const total = scored.length; // Build summary text const lines = topN.map((p, i) => { const name = typeof p.imaginaryName === 'string' ? p.imaginaryName : 'Unknown'; const age = p.age || '?'; const dist = p.distance?.mi != null ? `${Math.round(p.distance.mi)}mi` : '?mi'; const verified = (typeof p.verificationStatus === 'string' && p.verificationStatus.toUpperCase() === 'VERIFIED') ? 'Verified' : ''; const liked = p.interactionStatus?.theirs === 'LIKED' ? 'they liked you' : ''; const tags = [verified, liked, dist].filter(Boolean).join(', '); return `${i + 1}. ${name}, ${age} (Score: ${p._score}) — ${tags}`; }); const summary = topN.length > 0 ? `Top ${topN.length} Matches:\n${lines.join('\n')}` : 'No matches found with current filters.'; res.json({ summary, matches: topN.map(p => ({ id: p.id, imaginaryName: typeof p.imaginaryName === 'string' ? p.imaginaryName : '', age: p.age, score: p._score, distance: p.distance, verified: (typeof p.verificationStatus === 'string' && p.verificationStatus.toUpperCase() === 'VERIFIED'), theyLikedYou: p.interactionStatus?.theirs === 'LIKED', })), total, }); } catch (e) { console.error('Failed to compute matches summary:', e); res.status(500).json({ error: e.message }); } }); // Health check app.get('/api/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); // Login endpoint app.post('/api/auth/login', (req, res) => { const { username, password } = req.body; const creds = getAuthCredentials(); if (username === creds.username && password === creds.password) { // Generate a simple session token const token = crypto.randomUUID(); sessions.set(token, { username, createdAt: Date.now() }); res.json({ success: true, token }); } else { res.status(401).json({ success: false, error: 'Invalid credentials' }); } }); // Verify session endpoint app.get('/api/auth/verify', (req, res) => { const token = req.headers.authorization?.replace('Bearer ', ''); if (token && sessions.has(token)) { res.json({ success: true, authenticated: true }); } else { res.status(401).json({ success: false, authenticated: false }); } }); // Logout endpoint app.post('/api/auth/logout', (req, res) => { const token = req.headers.authorization?.replace('Bearer ', ''); if (token) { sessions.delete(token); } res.json({ success: true }); }); // GET /api/auth/token — Single source of truth: backend manages the refresh token, // frontend just gets a ready-to-use access token. No more race conditions. app.get('/api/auth/token', async (req, res) => { try { // Ensure feeldAPI has credentials loaded if (!feeldAPI.refreshToken) { feeldAPI.loadCredentials(); } if (!feeldAPI.refreshToken) { return res.status(503).json({ error: 'No refresh token available. Seed from mobile app first.' }); } const accessToken = await feeldAPI.getToken(); res.json({ accessToken, profileId: feeldAPI.profileId, analyticsId: feeldAPI.analyticsId, expiresAt: feeldAPI.expiresAt, }); } catch (e) { console.error('[Auth] Token request failed:', e.message); res.status(500).json({ error: e.message }); } }); // POST /api/auth/seed — Seed a refresh token (from mobile app Proxyman capture, etc.) app.post('/api/auth/seed', (req, res) => { const { refreshToken, profileId, analyticsId } = req.body; if (!refreshToken) return res.status(400).json({ error: 'refreshToken required' }); feeldAPI.saveCredentials( profileId || feeldAPI.profileId, refreshToken, analyticsId || feeldAPI.analyticsId ); // Clear cached access token so next request gets a fresh one feeldAPI.accessToken = null; feeldAPI.expiresAt = 0; res.json({ success: true }); }); // ============================================================ // Emulate App Open — replicate the mobile app's launch sequence // ============================================================ app.post('/api/emulate-open', async (req, res) => { const { latitude, longitude, locationName } = req.body; if (latitude == null || longitude == null) { return res.status(400).json({ error: 'latitude and longitude required' }); } if (!feeldAPI.profileId) { feeldAPI.loadCredentials(); if (!feeldAPI.profileId) { return res.status(503).json({ error: 'No Feeld credentials. Seed from browser first.' }); } } const steps = []; try { // Step 1: Set device location (mimics GPS update on app open) await feeldAPI.updateLocation(latitude, longitude); steps.push({ step: 'DeviceLocationUpdate', status: 'ok', location: locationName || `${latitude},${longitude}` }); // Step 2: Fetch search settings let filters = { ageRange: [22, 59], maxDistance: 100, lookingFor: ['WOMAN', 'MAN_WOMAN_COUPLE', 'WOMAN_WOMAN_COUPLE'], recentlyOnline: false, desiringFor: [], }; try { const settings = await feeldAPI.getSearchSettings(); const profile = settings?.profile; if (profile) { filters = { ageRange: profile.ageRange || filters.ageRange, maxDistance: profile.distanceMax || filters.maxDistance, lookingFor: profile.lookingFor || filters.lookingFor, recentlyOnline: profile.recentlyOnline || false, desiringFor: profile.desiringFor || [], }; } steps.push({ step: 'SearchSettings', status: 'ok', filters }); } catch (e) { steps.push({ step: 'SearchSettings', status: 'fallback', error: e.message }); } // Step 3: Discover profiles at this location const discovery = await feeldAPI.discoverProfiles(filters); const profiles = discovery?.discovery?.nodes || []; steps.push({ step: 'DiscoverProfiles', status: 'ok', count: profiles.length, hasNextBatch: discovery?.discovery?.hasNextBatch }); // Step 4: Cache discovered profiles and detect who liked us let newProfiles = 0; let likedMeFound = 0; if (profiles.length > 0) { const discoveredFile = path.join(DATA_DIR, 'discoveredProfiles.json'); let existing = { profiles: [], updatedAt: null }; try { existing = JSON.parse(fs.readFileSync(discoveredFile, 'utf8')); } catch {} const existingMap = new Map(existing.profiles.map(p => [p.id, p])); for (const p of profiles) { const safeStr = v => (typeof v === 'string' ? v : ''); const cached = { id: p.id, imaginaryName: safeStr(p.imaginaryName), age: p.age, gender: safeStr(p.gender), sexuality: safeStr(p.sexuality), bio: safeStr(p.bio), desires: Array.isArray(p.desires) ? p.desires.filter(d => typeof d === 'string') : [], connectionGoals: Array.isArray(p.connectionGoals) ? p.connectionGoals.filter(g => typeof g === 'string') : [], verificationStatus: safeStr(p.verificationStatus), interactionStatus: p.interactionStatus, discoveredLocation: locationName || `${latitude},${longitude}`, discoveredAt: existingMap.has(p.id) ? existingMap.get(p.id).discoveredAt : new Date().toISOString(), updatedAt: new Date().toISOString(), }; if (!existingMap.has(p.id)) newProfiles++; existingMap.set(p.id, cached); // Detect who liked us if (p.interactionStatus?.theirs === 'LIKED') { likedMeFound++; // Save to whoLikedYou cache const whoFile = path.join(DATA_DIR, 'whoLikedYou.json'); let whoData = { profiles: [], updatedAt: null }; try { whoData = JSON.parse(fs.readFileSync(whoFile, 'utf8')); } catch {} if (!whoData.profiles.some(w => w.id === p.id)) { whoData.profiles.push({ id: p.id, imaginaryName: safeStr(p.imaginaryName), age: p.age, gender: safeStr(p.gender), sexuality: safeStr(p.sexuality), photos: p.photos, discoveredAt: new Date().toISOString(), }); whoData.updatedAt = new Date().toISOString(); fs.writeFileSync(whoFile, JSON.stringify(whoData, null, 2)); } } } existing.profiles = Array.from(existingMap.values()); existing.updatedAt = new Date().toISOString(); fs.writeFileSync(discoveredFile, JSON.stringify(existing, null, 2)); console.log(`Discovered profiles batch: +${newProfiles} new, ${profiles.length} updated, ${existing.profiles.length} total`); } res.json({ success: true, location: locationName || `${latitude},${longitude}`, steps, summary: { profilesFound: profiles.length, newProfiles, likedMeFound, }, }); } catch (e) { console.error('[EmulateOpen] Error:', e.message); res.status(500).json({ error: e.message, steps }); } }); // ============================================================ // OKCupid API Proxy — server-side fetch to bypass Cloudflare // ============================================================ const OKC_TOKEN_FILE = path.join(DATA_DIR, 'okc-token.json'); const OKC_CREDS_FILE = path.join(DATA_DIR, 'okc-credentials.json'); const OKC_HEADERS = { 'Content-Type': 'application/json', 'User-Agent': 'OkCupid/111.1.0 iOS/26.2.1', 'x-okcupid-locale': 'en', 'x-okcupid-platform': 'ios', 'x-okcupid-auth-v': '1', 'x-okcupid-version': '111.1.0', 'x-okcupid-device-id': '40022B89-7089-4969-85CC-94843116EEE9', 'apollographql-client-name': 'com.okcupid.app-apollo-ios', 'apollographql-client-version': '111.1.0-1625', 'Accept': 'application/json', }; // OKC token is valid for 45 min. We login ONCE and refresh proactively before expiry. // NEVER login reactively in response to a failed request. let _okcLoginInProgress = null; async function okcLogin() { // Deduplicate: if a login is already in progress, wait for it if (_okcLoginInProgress) return _okcLoginInProgress; _okcLoginInProgress = _doOkcLogin(); const result = await _okcLoginInProgress; _okcLoginInProgress = null; return result; } async function _doOkcLogin() { if (!fs.existsSync(OKC_CREDS_FILE)) return false; try { const creds = JSON.parse(fs.readFileSync(OKC_CREDS_FILE, 'utf8')); if (!creds.email || !creds.password) return false; // Step 1: Anonymous token const anonResp = await fetch('https://e2p-okapi.api.okcupid.com/graphql/AnonAuthToken', { method: 'POST', headers: OKC_HEADERS, body: JSON.stringify({ operationName: 'AnonAuthToken', query: 'mutation AnonAuthToken($input: AuthAnonymousInput!) { authAnonymous(input: $input) { token } }', variables: { input: { deviceId: '40022B89-7089-4969-85CC-94843116EEE9', siteCode: 36 } }, extensions: { clientLibrary: { name: 'apollo-ios', version: '1.23.0' } }, }), }); const anonData = await anonResp.json(); let anonToken = anonData?.data?.authAnonymous?.token; if (!anonToken) { console.error('[OKC] Anon auth failed:', JSON.stringify(anonData).substring(0, 200)); return false; } anonToken = anonToken.replace(/^Bearer\s+/i, ''); // Step 2: Login with anon token const loginResp = await fetch('https://e2p-okapi.api.okcupid.com/graphql/AuthLogin', { method: 'POST', headers: { ...OKC_HEADERS, 'Authorization': 'Bearer ' + anonToken }, body: JSON.stringify({ operationName: 'AuthLogin', query: 'mutation AuthLogin($input: AuthEmailLoginInput!) { authEmailLogin(input: $input) { token encryptedUserId status } }', variables: { input: { email: creds.email, password: creds.password } }, extensions: { clientLibrary: { name: 'apollo-ios', version: '1.23.0' } }, }), }); const loginData = await loginResp.json(); const newToken = loginData?.data?.authEmailLogin?.token; if (newToken) { const clean = newToken.replace(/^Bearer\s+/i, ''); fs.writeFileSync(OKC_TOKEN_FILE, JSON.stringify({ token: clean, updatedAt: new Date().toISOString() }, null, 2)); console.log('[OKC] Token refreshed, valid for 45 min'); return true; } console.error('[OKC] Login failed (status ' + loginData?.data?.authEmailLogin?.status + ')'); return false; } catch (e) { console.error('[OKC] Login error:', e.message); return false; } } function getOkcToken() { if (fs.existsSync(OKC_TOKEN_FILE)) { try { const token = JSON.parse(fs.readFileSync(OKC_TOKEN_FILE, 'utf8')).token; return token ? token.replace(/^Bearer\s+/i, '') : null; } catch (e) {} } return null; } function getOkcTokenExpiry() { const token = getOkcToken(); if (!token) return 0; try { const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()); return payload.exp * 1000; } catch (e) { return 0; } } // Proactive refresh: check every 5 min, login if < 10 min remaining setInterval(async () => { const expiry = getOkcTokenExpiry(); if (!expiry) return; const remaining = expiry - Date.now(); if (remaining < 10 * 60 * 1000 && remaining > -5 * 60 * 1000) { // Between 10 min before expiry and 5 min after — refresh console.log('[OKC] Proactive refresh: token expires in', Math.round(remaining / 60000), 'min'); await okcLogin(); } }, 5 * 60 * 1000); // Login on startup if token is expired or missing (async () => { const expiry = getOkcTokenExpiry(); if (!expiry || Date.now() > expiry) { console.log('[OKC] Token expired or missing on startup, logging in...'); await okcLogin(); } else { console.log('[OKC] Token valid, expires in', Math.round((expiry - Date.now()) / 60000), 'min'); } })(); // POST /api/okcupid/graphql/:operation — Proxy GraphQL requests to OKCupid // NO reactive login — just forward the request with current token app.post('/api/okcupid/graphql/:operation', async (req, res) => { const token = getOkcToken(); if (!token) { return res.status(401).json({ error: 'No OKCupid token. Login not configured.' }); } const operation = req.params.operation; // Log vote operations for debugging if (operation === 'UserVote') { console.log('[OKC] UserVote request:', JSON.stringify(req.body?.variables || {}).substring(0, 500)); } try { const response = await fetch(`https://e2p-okapi.api.okcupid.com/graphql/${operation}`, { method: 'POST', headers: { ...OKC_HEADERS, 'Authorization': `Bearer ${token}`, 'x-match-useragent': 'OkCupid/111.1.0 iOS/26.2.1', 'X-APOLLO-OPERATION-TYPE': req.body?.query?.trim().startsWith('mutation') ? 'mutation' : 'query', 'X-APOLLO-OPERATION-NAME': operation, 'Accept-Language': 'en-US,en;q=0.9', 'Connection': 'keep-alive', }, body: JSON.stringify({ ...req.body, extensions: { clientLibrary: { name: 'apollo-ios', version: '1.23.0' }, ...req.body.extensions }, }), }); const data = await response.text(); if (operation === 'UserVote') { console.log('[OKC] UserVote response:', data.substring(0, 300)); } res.status(response.status).type('application/json').send(data); } catch (e) { console.error('[OKC Proxy] Error:', e.message); res.status(500).json({ error: e.message }); } }); // GET /api/okcupid/token — Get stored OKC token app.get('/api/okcupid/token', (req, res) => { const token = getOkcToken(); res.json({ token: token ? token.substring(0, 20) + '...' : null, hasToken: !!token }); }); // PUT /api/okcupid/token — Save OKC token app.put('/api/okcupid/token', (req, res) => { const { token } = req.body; if (!token) return res.status(400).json({ error: 'token required' }); fs.writeFileSync(OKC_TOKEN_FILE, JSON.stringify({ token, updatedAt: new Date().toISOString() }, null, 2)); res.json({ success: true }); }); // ============================================================ // FeeldAPI Client — makes direct GraphQL calls to Feeld backend // ============================================================ const FIREBASE_API_KEY = 'AIzaSyD9o9mzulN50-hqOwF6ww9pxUNUxwVOCXA'; const FIREBASE_REFRESH_URL = `https://securetoken.googleapis.com/v1/token?key=${FIREBASE_API_KEY}`; const GRAPHQL_ENDPOINT = 'https://core.api.fldcore.com/graphql'; const APP_VERSION = '8.11.0'; const OS_VERSION = '26.2.1'; const AUTH_TOKENS_FILE = path.join(DATA_DIR, 'auth-tokens.json'); const ROTATION_STATE_FILE = path.join(DATA_DIR, 'locationRotation.json'); const SAVED_LOCATIONS_FILE = path.join(DATA_DIR, 'savedLocations.json'); class FeeldAPIClient { constructor() { this.accessToken = null; this.expiresAt = 0; this.profileId = null; this.refreshToken = null; this.analyticsId = null; } loadCredentials() { if (fs.existsSync(AUTH_TOKENS_FILE)) { try { const data = JSON.parse(fs.readFileSync(AUTH_TOKENS_FILE, 'utf8')); this.profileId = data.profileId || null; this.refreshToken = data.refreshToken || null; this.analyticsId = data.analyticsId || null; return !!this.profileId && !!this.refreshToken; } catch (e) { console.error('[FeeldAPI] Failed to load credentials:', e.message); return false; } } return false; } saveCredentials(profileId, refreshToken, analyticsId) { this.profileId = profileId; this.refreshToken = refreshToken; this.analyticsId = analyticsId || this.analyticsId; fs.writeFileSync(AUTH_TOKENS_FILE, JSON.stringify({ profileId, refreshToken, analyticsId: this.analyticsId, updatedAt: new Date().toISOString(), }, null, 2)); console.log('[FeeldAPI] Credentials saved'); } async refreshAccessToken() { if (!this.refreshToken) { throw new Error('No refresh token available — seed from browser first'); } const response = await fetch(FIREBASE_REFRESH_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ grant_type: 'refresh_token', refresh_token: this.refreshToken, }), }); if (!response.ok) { const text = await response.text(); throw new Error(`Firebase token refresh failed: ${response.status} ${text}`); } const data = await response.json(); this.accessToken = data.access_token; this.expiresAt = Date.now() + parseInt(data.expires_in) * 1000; // Update stored refresh token (Firebase rotates them) if (data.refresh_token && data.refresh_token !== this.refreshToken) { this.refreshToken = data.refresh_token; this.saveCredentials(this.profileId, this.refreshToken, this.analyticsId); } console.log('[FeeldAPI] Token refreshed, expires in', data.expires_in, 'seconds'); return this.accessToken; } async getToken() { if (!this.accessToken || Date.now() >= this.expiresAt - 60000) { await this.refreshAccessToken(); } return this.accessToken; } async graphql(operationName, query, variables = {}, _retried = false) { const token = await this.getToken(); const transactionId = crypto.randomUUID(); const response = await fetch(GRAPHQL_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, 'x-profile-id': this.profileId, 'x-app-version': APP_VERSION, 'x-device-os': 'ios', 'x-os-version': OS_VERSION, 'x-transaction-id': transactionId, 'x-event-analytics-id': this.analyticsId || crypto.randomUUID(), 'User-Agent': 'feeld-mobile', 'Accept': '*/*', }, body: JSON.stringify({ operationName, query, variables }), }); if (!response.ok) { const text = await response.text(); throw new Error(`GraphQL request failed: ${response.status} ${text}`); } const result = await response.json(); // Auto-retry on UNAUTHENTICATED: force-refresh token and replay if (result.errors && !_retried) { const isAuthError = result.errors.some(e => e.extensions?.code === 'UNAUTHENTICATED'); if (isAuthError) { console.log(`[FeeldAPI] Auth error on ${operationName}, force-refreshing token...`); this.accessToken = null; this.expiresAt = 0; // Re-read credentials from disk in case another process updated them this.loadCredentials(); await this.refreshAccessToken(); return this.graphql(operationName, query, variables, true); } } if (result.errors) { throw new Error(`GraphQL errors: ${JSON.stringify(result.errors)}`); } return result.data; } async updateLocation(lat, lng) { return this.graphql('DeviceLocationUpdate', ` mutation DeviceLocationUpdate($input: DeviceLocationInput!) { deviceLocationUpdate(input: $input) { id location { device { latitude longitude geocode { city country __typename } __typename } __typename } __typename } } `, { input: { latitude: lat, longitude: lng } }); } async getSearchSettings() { return this.graphql('DiscoverSearchSettingsQuery', ` query DiscoverSearchSettingsQuery($profileId: String!) { profile(id: $profileId) { id ageRange distanceMax lookingFor desiringFor recentlyOnline __typename } } `, { profileId: this.profileId }); } async discoverProfiles(filters = {}) { return this.graphql('DiscoverProfiles', ` query DiscoverProfiles($input: ProfileDiscoveryInput!) { discovery(input: $input) { nodes { id age imaginaryName gender sexuality isIncognito isMajestic verificationStatus connectionGoals desires bio interests distance { km mi __typename } photos { id publicId pictureIsSafe pictureIsPrivate pictureUrl pictureUrls { small medium large __typename } pictureType __typename } interactionStatus { message mine theirs __typename } __typename } hasNextBatch __typename } } `, { input: { filters } }); } } const feeldAPI = new FeeldAPIClient(); // ============================================================ // Location Rotation Cron // ============================================================ function readRotationState() { if (fs.existsSync(ROTATION_STATE_FILE)) { try { return JSON.parse(fs.readFileSync(ROTATION_STATE_FILE, 'utf8')); } catch (e) { console.error('[Rotation] Failed to read state:', e.message); } } return { groups: [], activeGroupId: null, intervalHours: 4, enabled: false, currentLocationIdx: 0, lastRotation: null, lastResult: null, history: [], }; } function writeRotationState(state) { fs.writeFileSync(ROTATION_STATE_FILE, JSON.stringify(state, null, 2)); } function readSavedLocations() { if (fs.existsSync(SAVED_LOCATIONS_FILE)) { try { return JSON.parse(fs.readFileSync(SAVED_LOCATIONS_FILE, 'utf8')); } catch (e) { console.error('[Rotation] Failed to read saved locations:', e.message); } } return []; } function writeSavedLocations(locations) { fs.writeFileSync(SAVED_LOCATIONS_FILE, JSON.stringify(locations, null, 2)); } // Helper to sanitize profile fields that may be {__typename: "..."} objects function safeStr(v) { return typeof v === 'string' ? v : ''; } function sanitizeProfile(p) { return { id: p.id, imaginaryName: safeStr(p.imaginaryName), age: p.age, gender: safeStr(p.gender), sexuality: safeStr(p.sexuality), bio: safeStr(p.bio), desires: p.desires, connectionGoals: p.connectionGoals, interests: p.interests, isMajestic: p.isMajestic, isIncognito: p.isIncognito, verificationStatus: safeStr(p.verificationStatus), distance: p.distance, photos: p.photos, interactionStatus: p.interactionStatus, }; } async function performRotation() { const state = readRotationState(); if (!state.enabled || !state.activeGroupId) return; const now = Date.now(); if (state.lastRotation && (now - new Date(state.lastRotation).getTime()) < state.intervalHours * 3600000) { return; // Not time yet } const group = state.groups.find(g => g.id === state.activeGroupId); if (!group || group.locationIds.length === 0) { console.log('[Rotation] Active group not found or empty'); return; } // Load credentials if (!feeldAPI.loadCredentials()) { console.log('[Rotation] No credentials — seed from browser first'); state.lastResult = { status: 'error', error: 'No credentials', timestamp: new Date().toISOString() }; writeRotationState(state); return; } // Resolve next location const savedLocations = readSavedLocations(); const nextIdx = (state.currentLocationIdx + 1) % group.locationIds.length; const locationId = group.locationIds[nextIdx]; const loc = savedLocations.find(l => l.id === locationId); if (!loc) { console.log('[Rotation] Location ID not found:', locationId); state.lastResult = { status: 'error', error: `Location ${locationId} not found`, timestamp: new Date().toISOString() }; writeRotationState(state); return; } console.log(`[Rotation] Rotating to: ${loc.name} (${loc.latitude}, ${loc.longitude})`); try { // 1. Update location await feeldAPI.updateLocation(loc.latitude, loc.longitude); console.log('[Rotation] Location updated'); // 2. Fetch search settings let filters = { ageRange: [22, 59], maxDistance: 100, lookingFor: ['WOMAN', 'MAN_WOMAN_COUPLE', 'WOMAN_WOMAN_COUPLE'], recentlyOnline: false, desiringFor: [], }; try { const settings = await feeldAPI.getSearchSettings(); const profile = settings?.profile; if (profile) { filters = { ageRange: profile.ageRange || filters.ageRange, maxDistance: profile.distanceMax || filters.maxDistance, lookingFor: profile.lookingFor || filters.lookingFor, recentlyOnline: profile.recentlyOnline || false, desiringFor: profile.desiringFor || [], }; } } catch (e) { console.log('[Rotation] Failed to fetch search settings, using defaults:', e.message); } // 3. Discover profiles (one batch) const discoveryResult = await feeldAPI.discoverProfiles(filters); const profiles = discoveryResult?.discovery?.nodes || []; console.log(`[Rotation] Discovered ${profiles.length} profiles at ${loc.name}`); // 4. Save who-liked-you profiles let likedMeCount = 0; const whoLikedYouPath = path.join(DATA_DIR, 'whoLikedYou.json'); let whoLikedYouData = { profiles: [], updatedAt: null }; if (fs.existsSync(whoLikedYouPath)) { try { whoLikedYouData = JSON.parse(fs.readFileSync(whoLikedYouPath, 'utf8')); } catch (e) {} } for (const p of profiles) { if (p.interactionStatus?.theirs === 'LIKED') { const sanitized = sanitizeProfile(p); const existingIdx = whoLikedYouData.profiles.findIndex(ep => ep.id === p.id); if (existingIdx >= 0) { const orig = whoLikedYouData.profiles[existingIdx].discoveredAt; whoLikedYouData.profiles[existingIdx] = { ...sanitized, discoveredAt: orig, updatedAt: new Date().toISOString() }; } else { whoLikedYouData.profiles.unshift({ ...sanitized, discoveredAt: new Date().toISOString() }); } likedMeCount++; } } if (likedMeCount > 0) { whoLikedYouData.updatedAt = new Date().toISOString(); fs.writeFileSync(whoLikedYouPath, JSON.stringify(whoLikedYouData, null, 2)); console.log(`[Rotation] Saved ${likedMeCount} who-liked-you profiles`); } // 5. Batch-save all discovered profiles if (profiles.length > 0) { const discoveredPath = path.join(DATA_DIR, 'discoveredProfiles.json'); let discoveredData = { profiles: [], updatedAt: null }; if (fs.existsSync(discoveredPath)) { try { discoveredData = JSON.parse(fs.readFileSync(discoveredPath, 'utf8')); } catch (e) {} } const existingMap = new Map(discoveredData.profiles.map(p => [p.id, p])); for (const p of profiles) { const sanitized = sanitizeProfile(p); sanitized.discoveredLocation = loc.name; const existing = existingMap.get(p.id); if (existing) { existingMap.set(p.id, { ...sanitized, discoveredAt: existing.discoveredAt, updatedAt: new Date().toISOString() }); } else { existingMap.set(p.id, { ...sanitized, discoveredAt: new Date().toISOString() }); } } let allProfiles = Array.from(existingMap.values()); allProfiles.sort((a, b) => new Date(b.discoveredAt || 0).getTime() - new Date(a.discoveredAt || 0).getTime()); if (allProfiles.length > 2000) allProfiles = allProfiles.slice(0, 2000); discoveredData.profiles = allProfiles; discoveredData.updatedAt = new Date().toISOString(); fs.writeFileSync(discoveredPath, JSON.stringify(discoveredData, null, 2)); } // 6. Update state const result = { status: 'success', profilesFound: profiles.length, likedMeFound: likedMeCount, location: loc.name, timestamp: new Date().toISOString(), }; state.currentLocationIdx = nextIdx; state.lastRotation = new Date().toISOString(); state.lastResult = result; state.history = [result, ...(state.history || [])].slice(0, 10); writeRotationState(state); console.log(`[Rotation] Complete: ${profiles.length} profiles, ${likedMeCount} liked me at ${loc.name}`); } catch (e) { console.error('[Rotation] Error:', e.message); const result = { status: 'error', error: e.message, location: loc.name, timestamp: new Date().toISOString() }; state.lastResult = result; state.history = [result, ...(state.history || [])].slice(0, 10); writeRotationState(state); } } // Check every 5 minutes if it's time to rotate let rotationInterval = null; function startRotationCron() { if (rotationInterval) clearInterval(rotationInterval); rotationInterval = setInterval(() => { performRotation().catch(e => console.error('[Rotation] Cron error:', e.message)); }, 5 * 60 * 1000); // 5 minutes console.log('[Rotation] Cron started (checks every 5 min)'); } // ============================================================ // Location Rotation API Endpoints // ============================================================ // GET /api/location-rotation — Return current rotation state app.get('/api/location-rotation', (req, res) => { res.json(readRotationState()); }); // PUT /api/location-rotation — Update rotation config app.put('/api/location-rotation', (req, res) => { const { groups, activeGroupId, intervalHours, enabled } = req.body; const state = readRotationState(); if (groups !== undefined) state.groups = groups; if (activeGroupId !== undefined) state.activeGroupId = activeGroupId; if (intervalHours !== undefined) state.intervalHours = intervalHours; if (enabled !== undefined) state.enabled = enabled; writeRotationState(state); res.json({ success: true, state }); }); // POST /api/location-rotation/rotate-now — Force immediate rotation app.post('/api/location-rotation/rotate-now', async (req, res) => { const state = readRotationState(); if (!state.enabled || !state.activeGroupId) { return res.status(400).json({ success: false, error: 'Rotation not enabled or no active group' }); } // Clear lastRotation to force immediate execution state.lastRotation = null; writeRotationState(state); try { await performRotation(); res.json({ success: true, state: readRotationState() }); } catch (e) { res.status(500).json({ success: false, error: e.message }); } }); // POST /api/location-rotation/seed-token — Browser sends its current refresh token app.post('/api/location-rotation/seed-token', (req, res) => { const { refreshToken, profileId, analyticsId } = req.body; if (!refreshToken || !profileId) { return res.status(400).json({ success: false, error: 'refreshToken and profileId required' }); } feeldAPI.saveCredentials(profileId, refreshToken, analyticsId); res.json({ success: true }); }); // GET /api/location-rotation/status — Quick status for UI app.get('/api/location-rotation/status', (req, res) => { const state = readRotationState(); const savedLocations = readSavedLocations(); let currentLocation = null; let nextRotation = null; if (state.enabled && state.activeGroupId) { const group = state.groups.find(g => g.id === state.activeGroupId); if (group && group.locationIds.length > 0) { const locId = group.locationIds[state.currentLocationIdx % group.locationIds.length]; currentLocation = savedLocations.find(l => l.id === locId) || null; } if (state.lastRotation) { const nextTime = new Date(state.lastRotation).getTime() + state.intervalHours * 3600000; nextRotation = new Date(nextTime).toISOString(); } } res.json({ enabled: state.enabled, activeGroup: state.activeGroupId ? state.groups.find(g => g.id === state.activeGroupId) : null, currentLocation, nextRotation, lastResult: state.lastResult, intervalHours: state.intervalHours, }); }); // GET /api/saved-locations — Return saved locations list app.get('/api/saved-locations', (req, res) => { res.json(readSavedLocations()); }); // PUT /api/saved-locations — Browser syncs its saved locations here app.put('/api/saved-locations', (req, res) => { const { locations } = req.body; if (!Array.isArray(locations)) { return res.status(400).json({ success: false, error: 'locations array required' }); } writeSavedLocations(locations); res.json({ success: true, count: locations.length }); }); // ============================================================ // Start server + rotation cron // ============================================================ app.listen(PORT, () => { console.log(`Data server running on http://localhost:${PORT}`); console.log(`Data stored in: ${DATA_DIR}`); // Load credentials and start rotation cron feeldAPI.loadCredentials(); startRotationCron(); });