- New Matches page with match scoring system - New OkCupid page and API integration - Enhanced Likes page with scanner improvements and enrichment - Updated Settings, Discover, Messages, and Chat pages - Improved auth, GraphQL client, and Stream Chat setup - Added new backend endpoints (matchScoring.js) - Removed old Proxyman capture logs - Updated nginx config and Vite proxy settings Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1708 lines
59 KiB
JavaScript
Executable File
1708 lines
59 KiB
JavaScript
Executable File
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();
|
|
});
|