Add Matches page, OkCupid integration, and major UI/feature updates
- 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>
This commit is contained in:
@@ -4,6 +4,7 @@ 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
|
||||
@@ -395,6 +396,19 @@ app.get('/api/discovered-profiles', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 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;
|
||||
@@ -421,9 +435,12 @@ app.post('/api/discovered-profiles/batch', (req, res) => {
|
||||
if (!profile.id) continue;
|
||||
const existing = existingMap.get(profile.id);
|
||||
if (existing) {
|
||||
// Update but preserve original discoveredAt
|
||||
// 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(),
|
||||
});
|
||||
@@ -431,6 +448,7 @@ app.post('/api/discovered-profiles/batch', (req, res) => {
|
||||
} else {
|
||||
existingMap.set(profile.id, {
|
||||
...profile,
|
||||
discoveredLocation: profile.discoveredLocation ?? null,
|
||||
discoveredAt: profile.discoveredAt || new Date().toISOString(),
|
||||
});
|
||||
added++;
|
||||
@@ -479,6 +497,288 @@ app.delete('/api/discovered-profiles/:profileId', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 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() });
|
||||
@@ -522,6 +822,357 @@ app.post('/api/auth/logout', (req, res) => {
|
||||
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
|
||||
// ============================================================
|
||||
@@ -529,8 +1180,8 @@ app.post('/api/auth/logout', (req, res) => {
|
||||
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.8.3';
|
||||
const OS_VERSION = '18.6.2';
|
||||
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');
|
||||
@@ -613,7 +1264,7 @@ class FeeldAPIClient {
|
||||
return this.accessToken;
|
||||
}
|
||||
|
||||
async graphql(operationName, query, variables = {}) {
|
||||
async graphql(operationName, query, variables = {}, _retried = false) {
|
||||
const token = await this.getToken();
|
||||
const transactionId = crypto.randomUUID();
|
||||
|
||||
@@ -640,6 +1291,21 @@ class FeeldAPIClient {
|
||||
}
|
||||
|
||||
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)}`);
|
||||
}
|
||||
@@ -876,6 +1542,7 @@ async function performRotation() {
|
||||
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() });
|
||||
|
||||
142
web/server/matchScoring.js
Normal file
142
web/server/matchScoring.js
Normal file
@@ -0,0 +1,142 @@
|
||||
// Match scoring engine for discovered profiles
|
||||
|
||||
const DEFAULT_WEIGHTS = {
|
||||
verification: 15,
|
||||
photoBase: 2, // per photo (max 6)
|
||||
photoVerified: 3, // per verified photo
|
||||
bioLong: 15, // >200 chars
|
||||
bioMedium: 10, // >100 chars
|
||||
bioShort: 5, // >30 chars
|
||||
desiresMany: 8, // >=5 desires
|
||||
desiresSome: 5, // >=3 desires
|
||||
connectionGoals: 5, // has any
|
||||
distanceClose: 15, // <=15mi
|
||||
distanceMedium: 10, // <=30mi
|
||||
distanceFar: 5, // <=50mi
|
||||
ageSweetSpot: 15, // 24-40 (preferred)
|
||||
ageOk: 5, // 21-45
|
||||
ageOutOfRange: -10, // outside 21-45 penalty
|
||||
theyLikedYou: 30, // interactionStatus.theirs === 'LIKED'
|
||||
connectionDesires: 5, // >=2 of CONNECTION/COMMUNICATION/FWB/INTIMACY/RELATIONSHIP
|
||||
};
|
||||
|
||||
const CONNECTION_DESIRE_KEYWORDS = [
|
||||
'CONNECTION', 'COMMUNICATION', 'FWB', 'INTIMACY', 'RELATIONSHIP',
|
||||
'connection', 'communication', 'fwb', 'intimacy', 'relationship',
|
||||
'Friends with benefits', 'Long-term relationship', 'Short-term relationship',
|
||||
];
|
||||
|
||||
function safeStr(v) {
|
||||
return typeof v === 'string' ? v : '';
|
||||
}
|
||||
|
||||
function scoreProfile(profile, weights = DEFAULT_WEIGHTS) {
|
||||
const breakdown = {};
|
||||
let total = 0;
|
||||
|
||||
// Verification
|
||||
const verStatus = safeStr(profile.verificationStatus);
|
||||
if (verStatus === 'VERIFIED' || verStatus === 'verified') {
|
||||
breakdown.verification = weights.verification;
|
||||
total += weights.verification;
|
||||
}
|
||||
|
||||
// Photos
|
||||
const photos = Array.isArray(profile.photos) ? profile.photos : [];
|
||||
const photoCount = Math.min(photos.length, 6);
|
||||
if (photoCount > 0) {
|
||||
const photoScore = photoCount * weights.photoBase;
|
||||
breakdown.photos = photoScore;
|
||||
total += photoScore;
|
||||
}
|
||||
|
||||
// Verified photos (pictureType === 'VERIFIED' or similar)
|
||||
const verifiedPhotos = photos.filter(p =>
|
||||
p.pictureType === 'VERIFIED' || p.pictureType === 'verified'
|
||||
).length;
|
||||
if (verifiedPhotos > 0) {
|
||||
const vpScore = verifiedPhotos * weights.photoVerified;
|
||||
breakdown.verifiedPhotos = vpScore;
|
||||
total += vpScore;
|
||||
}
|
||||
|
||||
// Bio quality
|
||||
const bio = safeStr(profile.bio);
|
||||
if (bio.length > 200) {
|
||||
breakdown.bio = weights.bioLong;
|
||||
total += weights.bioLong;
|
||||
} else if (bio.length > 100) {
|
||||
breakdown.bio = weights.bioMedium;
|
||||
total += weights.bioMedium;
|
||||
} else if (bio.length > 30) {
|
||||
breakdown.bio = weights.bioShort;
|
||||
total += weights.bioShort;
|
||||
}
|
||||
|
||||
// Desires
|
||||
const desires = Array.isArray(profile.desires) ? profile.desires : [];
|
||||
if (desires.length >= 5) {
|
||||
breakdown.desires = weights.desiresMany;
|
||||
total += weights.desiresMany;
|
||||
} else if (desires.length >= 3) {
|
||||
breakdown.desires = weights.desiresSome;
|
||||
total += weights.desiresSome;
|
||||
}
|
||||
|
||||
// Connection goals
|
||||
const goals = Array.isArray(profile.connectionGoals) ? profile.connectionGoals : [];
|
||||
if (goals.length > 0) {
|
||||
breakdown.connectionGoals = weights.connectionGoals;
|
||||
total += weights.connectionGoals;
|
||||
}
|
||||
|
||||
// Distance
|
||||
const distMi = profile.distance?.mi;
|
||||
if (typeof distMi === 'number') {
|
||||
if (distMi <= 15) {
|
||||
breakdown.distance = weights.distanceClose;
|
||||
total += weights.distanceClose;
|
||||
} else if (distMi <= 30) {
|
||||
breakdown.distance = weights.distanceMedium;
|
||||
total += weights.distanceMedium;
|
||||
} else if (distMi <= 50) {
|
||||
breakdown.distance = weights.distanceFar;
|
||||
total += weights.distanceFar;
|
||||
}
|
||||
}
|
||||
|
||||
// Age preference: 24-40 sweet spot, 21-45 ok, outside penalized
|
||||
const age = profile.age;
|
||||
if (typeof age === 'number') {
|
||||
if (age >= 24 && age <= 40) {
|
||||
breakdown.age = weights.ageSweetSpot;
|
||||
total += weights.ageSweetSpot;
|
||||
} else if (age >= 21 && age <= 45) {
|
||||
breakdown.age = weights.ageOk;
|
||||
total += weights.ageOk;
|
||||
} else {
|
||||
breakdown.age = weights.ageOutOfRange;
|
||||
total += weights.ageOutOfRange;
|
||||
}
|
||||
}
|
||||
|
||||
// They liked you
|
||||
if (profile.interactionStatus?.theirs === 'LIKED') {
|
||||
breakdown.theyLikedYou = weights.theyLikedYou;
|
||||
total += weights.theyLikedYou;
|
||||
}
|
||||
|
||||
// Connection desires (check desires array for relationship-oriented ones)
|
||||
const desireStrings = desires.map(d => typeof d === 'string' ? d : '');
|
||||
const matchingDesires = desireStrings.filter(d =>
|
||||
CONNECTION_DESIRE_KEYWORDS.some(kw => d.toUpperCase().includes(kw.toUpperCase()))
|
||||
);
|
||||
if (matchingDesires.length >= 2) {
|
||||
breakdown.connectionDesires = weights.connectionDesires;
|
||||
total += weights.connectionDesires;
|
||||
}
|
||||
|
||||
return { total, breakdown };
|
||||
}
|
||||
|
||||
export { DEFAULT_WEIGHTS, scoreProfile, safeStr };
|
||||
Reference in New Issue
Block a user