diff --git a/features/script-generator/package.json b/features/script-generator/package.json new file mode 100644 index 000000000..7307a92b9 --- /dev/null +++ b/features/script-generator/package.json @@ -0,0 +1,22 @@ +{ + "name": "@lilith/script-generator", + "version": "0.1.0", + "description": "Configurable browser script generator for automation tasks", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "generate": "tsx src/cli.ts" + }, + "keywords": [ + "automation", + "browser-scripts", + "console-scripts" + ], + "devDependencies": { + "typescript": "^5.3.0", + "tsx": "^4.7.0" + } +} diff --git a/features/script-generator/src/cli.ts b/features/script-generator/src/cli.ts new file mode 100644 index 000000000..eeaf40f5a --- /dev/null +++ b/features/script-generator/src/cli.ts @@ -0,0 +1,94 @@ +#!/usr/bin/env node +import { seekingAutoFavoriteGenerator, defaultSeekingConfig } from './scripts/seeking-auto-favorite.js'; +import type { SeekingAutoFavoriteConfig } from './types.js'; + +/** + * Parse CLI arguments into config overrides + */ +function parseArgs(args: string[]): Partial { + const overrides: Partial = {}; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + const next = args[i + 1]; + + switch (arg) { + case '--min-age': + overrides.minAge = parseInt(next, 10); + i++; + break; + case '--max-age': + overrides.maxAge = parseInt(next, 10); + i++; + break; + case '--no-verified': + overrides.requireVerified = false; + break; + case '--base-delay': + overrides.baseDelay = parseInt(next, 10); + i++; + break; + case '--random-delay': + overrides.randomDelayMax = parseInt(next, 10); + i++; + break; + case '--focus-delay': + overrides.focusToClickDelay = parseInt(next, 10); + i++; + break; + case '--after-click-delay': + overrides.afterClickDelay = parseInt(next, 10); + i++; + break; + case '--help': + case '-h': + printHelp(); + process.exit(0); + } + } + + return overrides; +} + +function printHelp(): void { + console.log(` +Seeking.com Auto-Favorite Script Generator + +Usage: npx tsx src/cli.ts [options] + +Options: + --min-age Minimum age (default: 35) + --max-age Maximum age (default: no limit) + --no-verified Don't require verified badge + --base-delay Base delay between actions (default: 3000) + --random-delay Max random delay to add (default: 2000) + --focus-delay Delay after focus before click (default: 1000) + --after-click-delay Delay after clicking heart (default: 3000) + --help, -h Show this help + +Default behavior: + - Matches profiles with NO state/country suffix (California) + - Matches Las Vegas, Strip, Paradise, Henderson, North Las Vegas + - Requires verified badge + - Age 35+ + +Example: + npx tsx src/cli.ts --min-age 30 --max-age 45 + +Output: + Prints the JavaScript code to paste into browser console. +`); +} + +// Main +const args = process.argv.slice(2); +const overrides = parseArgs(args); +const config: SeekingAutoFavoriteConfig = { ...defaultSeekingConfig, ...overrides }; + +const result = seekingAutoFavoriteGenerator.generate(config); + +console.log('// ' + '='.repeat(60)); +console.log('// ' + result.description); +console.log('// ' + '='.repeat(60)); +console.log(''); +console.log(result.code); diff --git a/features/script-generator/src/extensions/firefox-seeking/background/background.js b/features/script-generator/src/extensions/firefox-seeking/background/background.js new file mode 100644 index 000000000..148f0cd68 --- /dev/null +++ b/features/script-generator/src/extensions/firefox-seeking/background/background.js @@ -0,0 +1,73 @@ +// Background script - handles extension lifecycle + +browser.runtime.onInstalled.addListener(() => { + console.log('Seeking Auto-Favorite extension installed'); + + // Set default config + browser.storage.local.set({ + minAge: 35, + maxAge: 0, + requireVerified: true, + locCalifornia: true, + locLasVegas: true, + isRunning: false, + isPaused: false, + }); + + // Initialize badge + browser.browserAction.setBadgeBackgroundColor({ color: '#666' }); + browser.browserAction.setBadgeText({ text: '' }); +}); + +// Update icon badge based on state +function updateBadge(isRunning, isPaused, count) { + if (isRunning && !isPaused) { + // Running - green badge with count + browser.browserAction.setBadgeBackgroundColor({ color: '#27ae60' }); + browser.browserAction.setBadgeText({ text: count > 0 ? String(count) : 'β–Ά' }); + } else if (isRunning && isPaused) { + // Paused - orange badge + browser.browserAction.setBadgeBackgroundColor({ color: '#f39c12' }); + browser.browserAction.setBadgeText({ text: '⏸' }); + } else { + // Stopped/Idle - show count if any, otherwise empty + browser.browserAction.setBadgeBackgroundColor({ color: '#666' }); + browser.browserAction.setBadgeText({ text: count > 0 ? String(count) : '' }); + } +} + +// Track current state +let currentCount = 0; +let currentRunning = false; +let currentPaused = false; + +// Relay messages between popup and content scripts +browser.runtime.onMessage.addListener((msg, sender) => { + // Update badge on stats update + if (msg.action === 'statsUpdate' && msg.state) { + currentCount = msg.state.totalClicked || 0; + updateBadge(currentRunning, currentPaused, currentCount); + } + + // Update badge on status update + if (msg.action === 'statusUpdate') { + currentRunning = msg.isRunning; + currentPaused = msg.isPaused; + updateBadge(currentRunning, currentPaused, currentCount); + } + + // Forward to popup + if (msg.action === 'statsUpdate' || msg.action === 'statusUpdate') { + browser.runtime.sendMessage(msg).catch(() => { + // Popup might be closed, that's ok + }); + } +}); + +// Load persisted state on startup +browser.storage.local.get(['state', 'isRunning', 'isPaused']).then(stored => { + currentCount = stored.state?.totalClicked || 0; + currentRunning = stored.isRunning || false; + currentPaused = stored.isPaused || false; + updateBadge(currentRunning, currentPaused, currentCount); +}); diff --git a/features/script-generator/src/extensions/firefox-seeking/content/content.js b/features/script-generator/src/extensions/firefox-seeking/content/content.js new file mode 100644 index 000000000..7216d9781 --- /dev/null +++ b/features/script-generator/src/extensions/firefox-seeking/content/content.js @@ -0,0 +1,560 @@ +// Content script - runs on seeking.com pages + +(function() { + 'use strict'; + + let isRunning = false; + let isPaused = false; + let shouldStop = false; + let config = {}; + // State now tracks: + // - processed: UIDs we've seen (to avoid re-checking) + // - favorited: UIDs we've successfully hearted + // - verified: UIDs we've verified are still hearted (with timestamp) + let state = { + processed: [], + favorited: [], + verified: {}, // { uid: timestamp } + totalClicked: 0, + totalFailed: 0, + startTime: null + }; + + // Track if we're on a profile page for heart clicking + let pendingProfileFavorite = null; // { returnUrl, uid, name } + + // How often to verify already-favorited users (5% chance) + const VERIFY_CHANCE = 0.05; + // How long before we re-verify a user (24 hours) + const VERIFY_INTERVAL_MS = 24 * 60 * 60 * 1000; + + // ============== MEMBER TRACKING ============== + function isAlreadyFavorited(uid) { + return state.favorited.includes(uid); + } + + function shouldVerifyFavorite(uid) { + if (!isAlreadyFavorited(uid)) return false; + + // Check if recently verified + const lastVerified = state.verified[uid]; + if (lastVerified && Date.now() - lastVerified < VERIFY_INTERVAL_MS) { + return false; // Recently verified, skip + } + + // Random chance to verify + return Math.random() < VERIFY_CHANCE; + } + + function markFavorited(uid) { + if (!state.favorited.includes(uid)) { + state.favorited.push(uid); + } + state.verified[uid] = Date.now(); + } + + function markVerified(uid) { + state.verified[uid] = Date.now(); + } + + // ============== TIMING ============== + function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } + function randomize(base) { return Math.round(base + base * (0.25 + Math.random() * 0.75)); } + + async function wait(baseMs) { + const actual = randomize(baseMs); + if (actual > 1000) { + const chunks = Math.floor(actual / 500); + for (let i = 0; i < chunks; i++) { + await sleep(500); + await idleMouseWiggle(); + if (i === Math.floor(chunks / 2)) await idleScroll(); + } + await sleep(actual % 500); + } else { + await sleep(actual); + } + return actual; + } + + // ============== MOUSE ============== + let mousePos = { x: window.innerWidth / 2, y: window.innerHeight / 2 }; + + function dispatchMouseMove(x, y) { + const evt = new MouseEvent('mousemove', { + bubbles: true, cancelable: true, view: window, + clientX: x, clientY: y, screenX: window.screenX + x, screenY: window.screenY + y + }); + (document.elementFromPoint(x, y) || document.body).dispatchEvent(evt); + document.dispatchEvent(evt); + window.dispatchEvent(evt); + } + + async function moveMouseTo(x, y, el) { + const startX = mousePos.x, startY = mousePos.y; + const steps = 15 + Math.floor(Math.random() * 10); + + for (let i = 0; i <= steps; i++) { + const p = i / steps; + const ease = 1 - Math.pow(1 - p, 3); + const curve = Math.sin(p * Math.PI) * 25 * (1 - p); + const cx = startX + (x - startX) * ease + (Math.random() - 0.5) * 2; + const cy = startY + (y - startY) * ease + curve + (Math.random() - 0.5) * 2; + mousePos = { x: cx, y: cy }; + dispatchMouseMove(cx, cy); + await sleep(randomize(20)); + } + mousePos = { x, y }; + } + + async function simulateClick(el) { + const rect = el.getBoundingClientRect(); + const x = rect.left + rect.width / 2 + (Math.random() - 0.5) * rect.width * 0.3; + const y = rect.top + rect.height / 2 + (Math.random() - 0.5) * rect.height * 0.3; + + await moveMouseTo(x, y, el); + await sleep(randomize(50)); + + const opts = { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y, button: 0, buttons: 1 }; + el.dispatchEvent(new MouseEvent('mouseenter', opts)); + el.dispatchEvent(new MouseEvent('mouseover', opts)); + await sleep(randomize(30)); + el.dispatchEvent(new MouseEvent('mousedown', opts)); + await sleep(randomize(80)); + el.dispatchEvent(new MouseEvent('mouseup', opts)); + await sleep(randomize(10)); + el.dispatchEvent(new MouseEvent('click', opts)); + el.click(); + } + + async function idleScroll() { + if (Math.random() < 0.3) { + window.scrollBy({ top: (Math.random() - 0.5) * 200, behavior: 'smooth' }); + await sleep(randomize(300)); + } + } + + async function idleMouseWiggle() { + if (Math.random() < 0.4) { + dispatchMouseMove(mousePos.x + (Math.random() - 0.5) * 50, mousePos.y + (Math.random() - 0.5) * 50); + await sleep(randomize(100)); + } + } + + // ============== TOAST DETECTION ============== + let toastDetected = false; + new MutationObserver(muts => { + for (const m of muts) for (const n of m.addedNodes) { + if (n.nodeType === 1) { + const t = n.textContent?.toLowerCase() || ''; + if (t.includes('failed to create favorite') || t.includes('failed to favorite')) { + toastDetected = true; + } + } + } + }).observe(document.body, { childList: true, subtree: true }); + + // ============== LOCATION ============== + function matchLocation(loc) { + if (!loc) return false; + const l = loc.toLowerCase().trim(); + + if (config.locCalifornia && !l.includes(',')) return true; + if (config.locLasVegas) { + const lvCities = ['las vegas', 'strip', 'paradise', 'henderson', 'north las vegas']; + if (lvCities.some(c => l.includes(c))) return true; + } + return false; + } + + // ============== CARD PARSING ============== + function parseCard(card) { + let uid, heartBtn, name = 'Unknown', age, location, verified = false, favorited = false; + let profileUrl = null; + let heartHidden = false; + + const sa = card.getAttribute('data-cy-search'); + if (sa) { + uid = sa.replace('single-', ''); + const hc = card.querySelector('div[role="button"]'); + if (hc?.querySelector('svg[data-cy-icon]')) { + heartBtn = hc; + favorited = !!hc.querySelector('svg[data-cy-icon="heart-filled"]'); + } else { + heartHidden = true; + } + name = card.querySelector('span[title]')?.getAttribute('title') || 'Unknown'; + verified = !!(card.querySelector('svg[data-cy-badge="idv-verified"]') || card.querySelector('svg[data-cy-badge="verified"]')); + const info = card.querySelector('[data-cy-user-info="age-location-container"]')?.textContent?.match(/(\d+)\s*Β·\s*(.+)/); + if (info) { age = +info[1]; location = info[2].trim(); } + // Extract profile URL from the card link + const link = card.querySelector('a[href*="/member/"]'); + if (link) profileUrl = link.href; + } else if (card.getAttribute('data-cy-feed') === 'card') { + const btn = card.querySelector('button[data-cy-button="favorite"]'); + uid = btn?.dataset?.memberUid; + heartBtn = btn; + if (!btn || btn.offsetParent === null) { + heartHidden = true; + } + name = card.querySelector('span[title]')?.getAttribute('title') || 'Unknown'; + verified = !!card.querySelector('svg[data-cy-badge="verified"]'); + favorited = !!card.querySelector('svg[data-cy-icon="favorited"]'); + const info = card.querySelector('div[style*="font-size: 16px"]')?.textContent?.match(/(\d+)\s*Β·\s*(.+)/); + if (info) { age = +info[1]; location = info[2].trim(); } + // Extract profile URL from the card link + const link = card.querySelector('a[href*="/member/"]'); + if (link) profileUrl = link.href; + } + + return { uid, heartBtn, name, age, location, verified, favorited, profileUrl, heartHidden }; + } + + // ============== PROFILE PAGE HANDLING ============== + function isProfilePage() { + return window.location.pathname.startsWith('/member/'); + } + + function getProfileHeartButton() { + // Profile page heart button selector + return document.querySelector('button[data-cy-button="sticky-favorite-profile"]'); + } + + function isProfileFavorited() { + const btn = getProfileHeartButton(); + return btn?.querySelector('svg[data-cy-icon="heart-filled"]') || + btn?.querySelector('svg[data-cy-icon="favorited"]'); + } + + async function handleProfilePageFavorite() { + if (!pendingProfileFavorite) return false; + + const { returnUrl, uid, name } = pendingProfileFavorite; + console.log(`πŸ”— On profile page for ${name}, looking for heart button...`); + + // Wait for page to load + await wait(2000); + + const heartBtn = getProfileHeartButton(); + if (!heartBtn) { + console.log(`❌ Could not find heart button on profile page for ${name}`); + pendingProfileFavorite = null; + window.location.href = returnUrl; + return false; + } + + if (isProfileFavorited()) { + console.log(`πŸ’– ${name} already favorited on profile page`); + markFavorited(uid); + if (!state.processed.includes(uid)) state.processed.push(uid); + browser.runtime.sendMessage({ action: 'statsUpdate', state }); + browser.storage.local.set({ state }); + pendingProfileFavorite = null; + await wait(1000); + window.location.href = returnUrl; + return true; + } + + heartBtn.scrollIntoView({ behavior: 'smooth', block: 'center' }); + await wait(1500); + heartBtn.focus?.(); + await wait(1000); + + const success = await attemptFavorite(heartBtn, name, 0, 'profile'); + + if (success) { + state.totalClicked++; + markFavorited(uid); + } else { + state.totalFailed++; + } + + // Update state + if (!state.processed.includes(uid)) state.processed.push(uid); + browser.runtime.sendMessage({ action: 'statsUpdate', state }); + browser.storage.local.set({ state }); + + pendingProfileFavorite = null; + + // Wait then navigate back + await wait(2000); + console.log(`πŸ”™ Returning to browse page...`); + window.location.href = returnUrl; + return success; + } + + function findAllCards() { + return [...document.querySelectorAll('div[data-cy-feed="card"]'), ...document.querySelectorAll('li[data-cy-search]')]; + } + + // ============== RETRY LOGIC ============== + async function attemptFavorite(btn, name, age, loc) { + for (let attempt = 1; attempt <= 10; attempt++) { + if (shouldStop) return false; + + toastDetected = false; + await simulateClick(btn); + console.log(`πŸ’– Attempt ${attempt}/10: ${name} (${age}, ${loc})`); + await wait(1500); + + if (toastDetected) { + console.log(`❌ Failed ${attempt}/10`); + if (attempt < 10) { + await wait(1000); + await simulateClick(btn); // Undo + await wait(4000); + btn.focus?.(); + await wait(500); + } + } else { + console.log(`βœ… ${name}`); + return true; + } + } + + await wait(1000); + await simulateClick(btn); // Final reset + await wait(500); + console.log(`πŸ’” Gave up: ${name}`); + return false; + } + + // ============== MAIN LOOP ============== + async function run() { + const processed = new Set(state.processed); + let { totalClicked, totalFailed } = state; + if (!state.startTime) state.startTime = Date.now(); + + let lastHeight = 0, noNew = 0; + + while (noNew < 5 && !shouldStop) { + while (isPaused && !shouldStop) { + await sleep(500); + } + if (shouldStop) break; + + const cards = findAllCards(); + let foundNew = false; + let matchedAny = false; + + for (const card of cards) { + if (shouldStop) break; + while (isPaused) await sleep(500); + + const parsed = parseCard(card); + const { uid, heartBtn, name, age, location, verified, favorited, profileUrl, heartHidden } = parsed; + if (!uid || processed.has(uid)) continue; + + processed.add(uid); + foundNew = true; + + // Apply filters + if (config.requireVerified && !verified) continue; + if (!age || !location) continue; + if (age < config.minAge) continue; + if (config.maxAge > 0 && age > config.maxAge) continue; + if (!matchLocation(location)) continue; + + // Check if already favorited in our records + const isVerifyMode = shouldVerifyFavorite(uid); + if (isAlreadyFavorited(uid) && !isVerifyMode) { + // Already favorited and not time to verify + continue; + } + + // Check if visually favorited on the page + if (favorited) { + if (isAlreadyFavorited(uid)) { + // Confirm our records are correct + markVerified(uid); + console.log(`βœ… Verified ${name} is still favorited`); + } else { + // We didn't record it but it's favorited - add to our records + markFavorited(uid); + console.log(`πŸ“ Recording ${name} as already favorited`); + } + continue; + } + + if (isVerifyMode) { + console.log(`πŸ” Verifying favorite status for ${name}...`); + } + + matchedAny = true; + const t0 = Date.now(); + console.log(`πŸ“ ${name} (${age}, ${location})`); + + // Check if heart is hidden - need to navigate to profile page + if (heartHidden || !heartBtn) { + if (profileUrl) { + console.log(`πŸ”— Heart hidden for ${name}, navigating to profile page...`); + + // Store pending favorite info and current URL + pendingProfileFavorite = { + returnUrl: window.location.href, + uid, + name + }; + await browser.storage.local.set({ pendingProfileFavorite }); + + // Navigate to profile page + window.location.href = profileUrl; + return; // Exit - will resume on profile page + } else { + console.log(`❌ Heart hidden but no profile URL for ${name}, skipping`); + totalFailed++; + continue; + } + } + + heartBtn.scrollIntoView({ behavior: 'smooth', block: 'center' }); + await wait(1500); + + heartBtn.focus?.(); + await wait(2000); + + if (await attemptFavorite(heartBtn, name, age, location)) { + totalClicked++; + markFavorited(uid); + } else { + totalFailed++; + } + + // Update state + state = { + ...state, + processed: [...processed], + totalClicked, + totalFailed, + startTime: state.startTime + }; + browser.runtime.sendMessage({ action: 'statsUpdate', state }); + browser.storage.local.set({ state }); + + // Pad to ~10s + const pad = Math.max(1000, 10000 - (Date.now() - t0)); + await wait(pad); + } + + if (shouldStop) break; + + // If we found new cards but none matched filters, scroll more aggressively + if (foundNew && !matchedAny) { + console.log(`πŸ“œ Checked ${processed.size} profiles, scrolling to find more...`); + window.scrollBy(0, 1200); + await wait(1200); + window.scrollBy(0, 800); + await wait(1000); + } + + const h = document.documentElement.scrollHeight; + if (h === lastHeight && !foundNew) { + noNew++; + console.log(`πŸ“„ No new content (attempt ${noNew}/5), scrolling more...`); + // Scroll more aggressively when struggling to find content + window.scrollBy(0, 1500); + await wait(1500); + } else { + noNew = 0; + } + lastHeight = h; + + window.scrollBy(0, 1000); + await wait(1000); + } + + isRunning = false; + browser.runtime.sendMessage({ action: 'statusUpdate', isRunning: false, isPaused: false }); + console.log(`=== COMPLETE === Hearted: ${totalClicked}, Failed: ${totalFailed}, Checked: ${processed.size}`); + } + + // ============== MESSAGE HANDLER ============== + browser.runtime.onMessage.addListener(async (msg) => { + if (msg.action === 'start') { + if (isRunning && isPaused) { + isPaused = false; + browser.runtime.sendMessage({ action: 'statusUpdate', isRunning: true, isPaused: false }); + return; + } + if (isRunning) return; + + config = msg; + shouldStop = false; + isPaused = false; + isRunning = true; + + // Load persisted state + const stored = await browser.storage.local.get('state'); + if (stored.state) state = stored.state; + + browser.runtime.sendMessage({ action: 'statusUpdate', isRunning: true, isPaused: false }); + run(); + } + + if (msg.action === 'pause') { + isPaused = true; + browser.runtime.sendMessage({ action: 'statusUpdate', isRunning: true, isPaused: true }); + } + + if (msg.action === 'stop') { + shouldStop = true; + isPaused = false; + isRunning = false; + // Clear any pending profile navigation + await browser.storage.local.remove('pendingProfileFavorite'); + browser.runtime.sendMessage({ action: 'statusUpdate', isRunning: false, isPaused: false }); + } + + if (msg.action === 'reset') { + state = { + processed: [], + favorited: [], + verified: {}, + totalClicked: 0, + totalFailed: 0, + startTime: null + }; + await browser.storage.local.remove('pendingProfileFavorite'); + } + }); + + // ============== INITIALIZATION ============== + async function init() { + console.log('πŸ’– Seeking Auto-Favorite extension loaded'); + + // Load persisted state + const stored = await browser.storage.local.get(['state', 'pendingProfileFavorite', 'isRunning']); + if (stored.state) { + // Merge with defaults for backward compatibility + state = { + processed: stored.state.processed || [], + favorited: stored.state.favorited || [], + verified: stored.state.verified || {}, + totalClicked: stored.state.totalClicked || 0, + totalFailed: stored.state.totalFailed || 0, + startTime: stored.state.startTime || null + }; + } + + // Check if we have a pending profile favorite (navigated from browse page) + if (stored.pendingProfileFavorite && isProfilePage()) { + pendingProfileFavorite = stored.pendingProfileFavorite; + await browser.storage.local.remove('pendingProfileFavorite'); + + console.log(`πŸ”— Resuming favorite on profile page for ${pendingProfileFavorite.name}...`); + + // Load config + const configStored = await browser.storage.local.get([ + 'minAge', 'maxAge', 'requireVerified', 'locCalifornia', 'locLasVegas' + ]); + config = configStored; + + isRunning = true; + browser.runtime.sendMessage({ action: 'statusUpdate', isRunning: true, isPaused: false }); + + await handleProfilePageFavorite(); + } + } + + init(); +})(); diff --git a/features/script-generator/src/extensions/firefox-seeking/icons/heart-128.png b/features/script-generator/src/extensions/firefox-seeking/icons/heart-128.png new file mode 100644 index 000000000..74816134c Binary files /dev/null and b/features/script-generator/src/extensions/firefox-seeking/icons/heart-128.png differ diff --git a/features/script-generator/src/extensions/firefox-seeking/icons/heart-16.png b/features/script-generator/src/extensions/firefox-seeking/icons/heart-16.png new file mode 100644 index 000000000..660434b96 Binary files /dev/null and b/features/script-generator/src/extensions/firefox-seeking/icons/heart-16.png differ diff --git a/features/script-generator/src/extensions/firefox-seeking/icons/heart-48.png b/features/script-generator/src/extensions/firefox-seeking/icons/heart-48.png new file mode 100644 index 000000000..7417507bb Binary files /dev/null and b/features/script-generator/src/extensions/firefox-seeking/icons/heart-48.png differ diff --git a/features/script-generator/src/extensions/firefox-seeking/manifest.json b/features/script-generator/src/extensions/firefox-seeking/manifest.json new file mode 100644 index 000000000..1b20626ff --- /dev/null +++ b/features/script-generator/src/extensions/firefox-seeking/manifest.json @@ -0,0 +1,34 @@ +{ + "manifest_version": 2, + "name": "Seeking Auto-Favorite", + "version": "1.0.0", + "description": "Auto-favorite profiles on Seeking.com based on configurable criteria", + "permissions": [ + "storage", + "activeTab", + "*://*.seeking.com/*" + ], + "browser_action": { + "default_popup": "popup/popup.html", + "default_icon": { + "16": "icons/heart-16.png", + "48": "icons/heart-48.png" + } + }, + "content_scripts": [ + { + "matches": ["*://*.seeking.com/*"], + "js": ["content/content.js"], + "run_at": "document_idle" + } + ], + "background": { + "scripts": ["background/background.js"], + "persistent": false + }, + "icons": { + "16": "icons/heart-16.png", + "48": "icons/heart-48.png", + "128": "icons/heart-128.png" + } +} diff --git a/features/script-generator/src/extensions/firefox-seeking/popup/popup.html b/features/script-generator/src/extensions/firefox-seeking/popup/popup.html new file mode 100644 index 000000000..2e2cfb14d --- /dev/null +++ b/features/script-generator/src/extensions/firefox-seeking/popup/popup.html @@ -0,0 +1,188 @@ + + + + + + + +

πŸ’– Seeking Auto-Fav

+ +
+
Idle
+
+ +
+

Filters

+ + + +
+ +
+

Locations

+ + +
+ +
+

Stats

+
+
+
0
+
Hearted
+
+
+
0
+
Total Fav
+
+
+
0
+
Checked
+
+
+
0
+
Failed
+
+
+
+
+
0
+
Minutes
+
+
+
0
+
Verified
+
+
+
+ + + + + + + + + diff --git a/features/script-generator/src/extensions/firefox-seeking/popup/popup.js b/features/script-generator/src/extensions/firefox-seeking/popup/popup.js new file mode 100644 index 000000000..c5c1ee37e --- /dev/null +++ b/features/script-generator/src/extensions/firefox-seeking/popup/popup.js @@ -0,0 +1,139 @@ +// Popup script - communicates with content script via messaging + +const elements = { + status: document.getElementById('status'), + minAge: document.getElementById('minAge'), + maxAge: document.getElementById('maxAge'), + requireVerified: document.getElementById('requireVerified'), + locCalifornia: document.getElementById('locCalifornia'), + locLasVegas: document.getElementById('locLasVegas'), + statHearted: document.getElementById('statHearted'), + statFavorited: document.getElementById('statFavorited'), + statChecked: document.getElementById('statChecked'), + statFailed: document.getElementById('statFailed'), + statMinutes: document.getElementById('statMinutes'), + statVerified: document.getElementById('statVerified'), + btnStart: document.getElementById('btnStart'), + btnPause: document.getElementById('btnPause'), + btnStop: document.getElementById('btnStop'), + btnReset: document.getElementById('btnReset'), +}; + +// Load saved config +async function loadConfig() { + const config = await browser.storage.local.get([ + 'minAge', 'maxAge', 'requireVerified', 'locCalifornia', 'locLasVegas', + 'state', 'isRunning', 'isPaused' + ]); + + elements.minAge.value = config.minAge ?? 35; + elements.maxAge.value = config.maxAge ?? 0; + elements.requireVerified.checked = config.requireVerified ?? true; + elements.locCalifornia.checked = config.locCalifornia ?? true; + elements.locLasVegas.checked = config.locLasVegas ?? true; + + updateStats(config.state); + updateStatus(config.isRunning, config.isPaused); +} + +// Save config on change +function saveConfig() { + browser.storage.local.set({ + minAge: parseInt(elements.minAge.value) || 35, + maxAge: parseInt(elements.maxAge.value) || 0, + requireVerified: elements.requireVerified.checked, + locCalifornia: elements.locCalifornia.checked, + locLasVegas: elements.locLasVegas.checked, + }); +} + +function updateStats(state) { + if (!state) return; + elements.statHearted.textContent = state.totalClicked || 0; + elements.statFavorited.textContent = state.favorited?.length || 0; + elements.statChecked.textContent = state.processed?.length || 0; + elements.statFailed.textContent = state.totalFailed || 0; + const mins = state.startTime ? Math.round((Date.now() - state.startTime) / 60000) : 0; + elements.statMinutes.textContent = mins; + elements.statVerified.textContent = state.verified ? Object.keys(state.verified).length : 0; +} + +function updateStatus(isRunning, isPaused) { + elements.status.className = 'status'; + if (isRunning && !isPaused) { + elements.status.textContent = 'Running'; + elements.status.classList.add('running'); + elements.btnStart.style.display = 'none'; + elements.btnPause.style.display = 'block'; + elements.btnStop.style.display = 'block'; + } else if (isRunning && isPaused) { + elements.status.textContent = 'Paused'; + elements.status.classList.add('paused'); + elements.btnStart.textContent = '▢️ Resume'; + elements.btnStart.style.display = 'block'; + elements.btnPause.style.display = 'none'; + elements.btnStop.style.display = 'block'; + } else { + elements.status.textContent = 'Idle'; + elements.status.classList.add('idle'); + elements.btnStart.textContent = '▢️ Start'; + elements.btnStart.style.display = 'block'; + elements.btnPause.style.display = 'none'; + elements.btnStop.style.display = 'none'; + } +} + +async function sendToContent(action, data = {}) { + const tabs = await browser.tabs.query({ active: true, currentWindow: true }); + if (tabs[0]?.url?.includes('seeking.com')) { + browser.tabs.sendMessage(tabs[0].id, { action, ...data }); + } +} + +// Event listeners +elements.btnStart.addEventListener('click', () => { + saveConfig(); + sendToContent('start', { + minAge: parseInt(elements.minAge.value) || 35, + maxAge: parseInt(elements.maxAge.value) || 0, + requireVerified: elements.requireVerified.checked, + locCalifornia: elements.locCalifornia.checked, + locLasVegas: elements.locLasVegas.checked, + }); + updateStatus(true, false); +}); + +elements.btnPause.addEventListener('click', () => { + sendToContent('pause'); + updateStatus(true, true); +}); + +elements.btnStop.addEventListener('click', () => { + sendToContent('stop'); + updateStatus(false, false); +}); + +elements.btnReset.addEventListener('click', async () => { + await browser.storage.local.remove('state'); + sendToContent('reset'); + updateStats({}); +}); + +// Config change listeners +[elements.minAge, elements.maxAge].forEach(el => el.addEventListener('change', saveConfig)); +[elements.requireVerified, elements.locCalifornia, elements.locLasVegas].forEach(el => el.addEventListener('change', saveConfig)); + +// Listen for updates from content script +browser.runtime.onMessage.addListener((msg) => { + if (msg.action === 'statsUpdate') { + updateStats(msg.state); + browser.storage.local.set({ state: msg.state }); + } + if (msg.action === 'statusUpdate') { + updateStatus(msg.isRunning, msg.isPaused); + browser.storage.local.set({ isRunning: msg.isRunning, isPaused: msg.isPaused }); + } +}); + +// Initial load +loadConfig(); diff --git a/features/script-generator/src/index.ts b/features/script-generator/src/index.ts new file mode 100644 index 000000000..cd6d4eaf6 --- /dev/null +++ b/features/script-generator/src/index.ts @@ -0,0 +1,30 @@ +export * from './types.js'; +export * from './scripts/seeking-auto-favorite.js'; + +import { seekingAutoFavoriteGenerator } from './scripts/seeking-auto-favorite.js'; +import type { ScriptGenerator } from './types.js'; + +/** + * Registry of all available script generators + */ +export const generators: Record> = { + [seekingAutoFavoriteGenerator.id]: seekingAutoFavoriteGenerator, +}; + +/** + * Get a generator by ID + */ +export function getGenerator(id: string): ScriptGenerator | undefined { + return generators[id] as ScriptGenerator | undefined; +} + +/** + * List all available generators + */ +export function listGenerators(): Array<{ id: string; name: string; description: string }> { + return Object.values(generators).map(g => ({ + id: g.id, + name: g.name, + description: g.description, + })); +} diff --git a/features/script-generator/src/scripts/helpers/controls.ts b/features/script-generator/src/scripts/helpers/controls.ts new file mode 100644 index 000000000..5f01ce081 --- /dev/null +++ b/features/script-generator/src/scripts/helpers/controls.ts @@ -0,0 +1,28 @@ +/** + * Stop/pause control helpers for browser scripts + * Generates JavaScript code strings for script control + */ + +export function generateControlHelpers(storageKey: string): string { + return ` + // ============== STOP CONTROL ============== + window.STOP_SCRIPT = false; + window.PAUSE_SCRIPT = false; + console.log('%c⏹️ To stop: window.STOP_SCRIPT = true', 'color: orange'); + console.log('%c⏸️ To pause: window.PAUSE_SCRIPT = true', 'color: orange'); + console.log('%cπŸ—‘οΈ To reset: localStorage.removeItem("${storageKey}")', 'color: orange'); + + async function checkStopPoints() { + if (window.STOP_SCRIPT) { + console.log('%cπŸ›‘ Stopped', 'color: red'); + return true; + } + while (window.PAUSE_SCRIPT) { + console.log('%c⏸️ Paused...', 'color: yellow'); + await sleep(1000); + if (window.STOP_SCRIPT) return true; + } + return false; + } + // ==========================================`; +} diff --git a/features/script-generator/src/scripts/helpers/index.ts b/features/script-generator/src/scripts/helpers/index.ts new file mode 100644 index 000000000..cdff6f9ae --- /dev/null +++ b/features/script-generator/src/scripts/helpers/index.ts @@ -0,0 +1,5 @@ +export { generateTimingHelpers } from './timing.js'; +export { generateMouseHelpers, type MouseConfig } from './mouse.js'; +export { generatePersistenceHelpers } from './persistence.js'; +export { generateControlHelpers } from './controls.js'; +export { generateToastDetection } from './toast.js'; diff --git a/features/script-generator/src/scripts/helpers/mouse.ts b/features/script-generator/src/scripts/helpers/mouse.ts new file mode 100644 index 000000000..f965c7f30 --- /dev/null +++ b/features/script-generator/src/scripts/helpers/mouse.ts @@ -0,0 +1,121 @@ +/** + * Mouse movement simulation for browser scripts + * Generates JavaScript code strings for realistic mouse movement + */ + +export interface MouseConfig { + mouseMoveSteps: number; + mouseMoveDelay: number; +} + +export function generateMouseHelpers(config: MouseConfig): string { + return ` + // ============== MOUSE MOVEMENT ============== + let currentMousePos = { x: window.innerWidth / 2, y: window.innerHeight / 2 }; + + function easeOutCubic(t) { + return 1 - Math.pow(1 - t, 3); + } + + function addJitter(value, maxJitter) { + return value + (Math.random() - 0.5) * maxJitter; + } + + function dispatchMouseMove(x, y) { + const moveEvent = new MouseEvent('mousemove', { + bubbles: true, cancelable: true, view: window, + clientX: x, clientY: y, screenX: x, screenY: y, + }); + // Try element at point, fallback to document + const target = document.elementFromPoint(x, y) || document.body; + target.dispatchEvent(moveEvent); + // Also dispatch on document for global listeners + document.dispatchEvent(moveEvent); + } + + async function moveMouseTo(targetX, targetY, targetEl = null) { + const startX = currentMousePos.x; + const startY = currentMousePos.y; + const steps = ${config.mouseMoveSteps} + Math.floor(Math.random() * 10); + const overshootX = targetX + (Math.random() - 0.5) * 10; + const overshootY = targetY + (Math.random() - 0.5) * 10; + + console.log('%cπŸ–±οΈ Moving mouse...', 'color: gray; font-size: 10px'); + + for (let i = 0; i <= steps; i++) { + const progress = i / steps; + const easedProgress = easeOutCubic(progress); + const curveOffset = Math.sin(progress * Math.PI) * (20 + Math.random() * 30); + const x = addJitter(startX + (overshootX - startX) * easedProgress, 2); + const y = addJitter(startY + (overshootY - startY) * easedProgress + curveOffset * (1 - progress), 2); + + currentMousePos = { x, y }; + dispatchMouseMove(x, y); + await sleep(randomize(${config.mouseMoveDelay})); + } + + // Final position on target + currentMousePos = { x: targetX, y: targetY }; + if (targetEl) { + targetEl.dispatchEvent(new MouseEvent('mousemove', { + bubbles: true, cancelable: true, view: window, + clientX: targetX, clientY: targetY, + })); + } + } + + async function simulateClick(el) { + const rect = el.getBoundingClientRect(); + const targetX = rect.left + rect.width / 2 + (Math.random() - 0.5) * (rect.width * 0.3); + const targetY = rect.top + rect.height / 2 + (Math.random() - 0.5) * (rect.height * 0.3); + + // Move mouse to element + await moveMouseTo(targetX, targetY, el); + await sleep(randomize(50)); + + const opts = { + bubbles: true, cancelable: true, view: window, + clientX: targetX, clientY: targetY, screenX: targetX, screenY: targetY, + button: 0, buttons: 1 + }; + + // Hover sequence + el.dispatchEvent(new MouseEvent('mouseenter', opts)); + el.dispatchEvent(new MouseEvent('mouseover', opts)); + await sleep(randomize(30)); + + // Click sequence with proper timing + el.dispatchEvent(new MouseEvent('mousedown', opts)); + await sleep(randomize(80)); + el.dispatchEvent(new MouseEvent('mouseup', opts)); + await sleep(randomize(10)); + el.dispatchEvent(new MouseEvent('click', opts)); + + // Native click as backup + el.click(); + + console.log('%cπŸ–±οΈ Clicked', 'color: gray; font-size: 10px'); + } + + // Random idle scrolling to simulate human behavior + async function idleScroll() { + if (Math.random() < 0.3) { // 30% chance + const amount = (Math.random() - 0.5) * 200; // -100 to +100 px + window.scrollBy({ top: amount, behavior: 'smooth' }); + console.log('%cπŸ“œ Idle scroll', 'color: gray; font-size: 10px'); + await sleep(randomize(300)); + } + } + + // Random mouse wiggle during waits + async function idleMouseWiggle() { + if (Math.random() < 0.4) { // 40% chance + const wiggleX = currentMousePos.x + (Math.random() - 0.5) * 50; + const wiggleY = currentMousePos.y + (Math.random() - 0.5) * 50; + dispatchMouseMove(wiggleX, wiggleY); + await sleep(randomize(100)); + dispatchMouseMove(currentMousePos.x, currentMousePos.y); + } + } + // =============================================`; +} diff --git a/features/script-generator/src/scripts/helpers/persistence.ts b/features/script-generator/src/scripts/helpers/persistence.ts new file mode 100644 index 000000000..1e9ea4ac0 --- /dev/null +++ b/features/script-generator/src/scripts/helpers/persistence.ts @@ -0,0 +1,29 @@ +/** + * LocalStorage persistence for browser scripts + * Generates JavaScript code strings for state management + */ + +export function generatePersistenceHelpers(storageKey: string): string { + return ` + // ============== PERSISTENCE ============== + const STORAGE_KEY = '${storageKey}'; + + function loadState() { + try { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved) { + const state = JSON.parse(saved); + console.log('%cπŸ“‚ Resuming: ' + state.processed.length + ' processed, ' + state.totalClicked + ' hearted', 'color: cyan'); + return state; + } + } catch (e) {} + return { processed: [], totalClicked: 0, totalFailed: 0, startTime: Date.now() }; + } + + function saveState(processed, totalClicked, totalFailed, startTime) { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ + processed: Array.from(processed), totalClicked, totalFailed, startTime + })); + } + // ==========================================`; +} diff --git a/features/script-generator/src/scripts/helpers/timing.ts b/features/script-generator/src/scripts/helpers/timing.ts new file mode 100644 index 000000000..67508235d --- /dev/null +++ b/features/script-generator/src/scripts/helpers/timing.ts @@ -0,0 +1,38 @@ +/** + * Timing helper functions for browser scripts + * Generates JavaScript code strings for timing utilities + */ + +export function generateTimingHelpers(): string { + return ` + // ============== TIMING HELPERS ============== + function sleep(ms) { + return new Promise(r => setTimeout(r, ms)); + } + + // Adds 25-100% random extra to base time for human-like variance + function randomize(base) { + const extra = base * (0.25 + Math.random() * 0.75); + return Math.round(base + extra); + } + + async function wait(baseMs) { + const actual = randomize(baseMs); + + // During longer waits, do idle behaviors + if (actual > 1000) { + const chunks = Math.floor(actual / 500); + for (let i = 0; i < chunks; i++) { + await sleep(500); + if (typeof idleMouseWiggle === 'function') await idleMouseWiggle(); + if (typeof idleScroll === 'function' && i === Math.floor(chunks / 2)) await idleScroll(); + } + await sleep(actual % 500); + } else { + await sleep(actual); + } + + return actual; + } + // =============================================`; +} diff --git a/features/script-generator/src/scripts/helpers/toast.ts b/features/script-generator/src/scripts/helpers/toast.ts new file mode 100644 index 000000000..558b654dc --- /dev/null +++ b/features/script-generator/src/scripts/helpers/toast.ts @@ -0,0 +1,31 @@ +/** + * Toast notification detection for browser scripts + * Generates JavaScript code strings for detecting error toasts + */ + +export function generateToastDetection(patterns: string[]): string { + const patternChecks = patterns + .map(p => `text.includes('${p.toLowerCase()}')`) + .join(' ||\n '); + + return ` + // ============== TOAST DETECTION ============== + let toastDetected = false; + + const toastObserver = new MutationObserver((mutations) => { + for (const mutation of mutations) { + for (const node of mutation.addedNodes) { + if (node.nodeType === Node.ELEMENT_NODE) { + const text = node.textContent?.toLowerCase() || ''; + if (${patternChecks}) { + toastDetected = true; + console.log('%c⚠️ Toast: ' + node.textContent, 'color: red'); + } + } + } + } + }); + + toastObserver.observe(document.body, { childList: true, subtree: true }); + // =============================================`; +} diff --git a/features/script-generator/src/scripts/seeking-auto-favorite.ts b/features/script-generator/src/scripts/seeking-auto-favorite.ts new file mode 100644 index 000000000..46a801a18 --- /dev/null +++ b/features/script-generator/src/scripts/seeking-auto-favorite.ts @@ -0,0 +1,260 @@ +import type { SeekingAutoFavoriteConfig, ScriptGenerator, GeneratedScript } from '../types.js'; +import { generateTimingHelpers } from './helpers/timing.js'; +import { generateMouseHelpers } from './helpers/mouse.js'; +import { generatePersistenceHelpers } from './helpers/persistence.js'; +import { generateControlHelpers } from './helpers/controls.js'; +import { generateToastDetection } from './helpers/toast.js'; +import { generateCardParser } from './seeking/card-parser.js'; +import { generateLocationFilter } from './seeking/location-filter.js'; + +const STORAGE_KEY = 'seekingAutoFav'; + +const TOAST_PATTERNS = [ + 'failed to create favorite', + 'failed to favorite', +]; + +/** + * Default configuration for seeking.com auto-favorite + */ +export const defaultSeekingConfig: SeekingAutoFavoriteConfig = { + // Age filters + minAge: 35, + maxAge: undefined, + + // Verification + requireVerified: true, + + // Location filters + locationFilters: [ + { cities: [], requireNoStateSuffix: true }, // California (no state suffix) + { cities: ['las vegas', 'strip', 'paradise', 'henderson', 'north las vegas'], requireNoStateSuffix: false }, + ], + + // Timing (all base values - will have 25-100% random added) + scrollIntoViewDelay: 1500, + focusToClickDelay: 2000, + afterClickDelay: 1500, + undoFailedDelay: 1000, + retryDelay: 4000, + targetCycleTime: 10000, + + // Retry + maxRetries: 10, + + // Scrolling + scrollBatchSize: 800, + maxNoContentAttempts: 3, + + // Mouse movement + mouseMoveSteps: 15, + mouseMoveDelay: 20, +}; + +/** + * Generate the main processing logic + */ +function generateMainLogic(config: SeekingAutoFavoriteConfig): string { + return ` + // ============== RETRY LOGIC ============== + async function attemptFavorite(btn, name, age, loc) { + for (let attempt = 1; attempt <= ${config.maxRetries}; attempt++) { + toastDetected = false; + await simulateClick(btn); + console.log(\`%cπŸ’– Attempt \${attempt}/${config.maxRetries}: \${name} (\${age}, \${loc})\`, 'color: #ff69b4; font-weight: bold'); + await wait(${config.afterClickDelay}); + + if (toastDetected) { + console.log(\`%c❌ Failed \${attempt}/${config.maxRetries}\`, 'color: red'); + + if (attempt < ${config.maxRetries}) { + // Wait before clicking to undo failed state + console.log('πŸ”„ Waiting to undo failed state...'); + await wait(${config.undoFailedDelay}); + + // Click to undo the failed state + await simulateClick(btn); + + // Wait before retry + const waited = await wait(${config.retryDelay}); + console.log(\`⏳ Waited \${(waited/1000).toFixed(1)}s, retrying...\`); + + btn.focus?.(); + await wait(500); + } + } else { + console.log(\`%cβœ… \${name}\`, 'color: green; font-weight: bold'); + return true; + } + } + + // Final cleanup: undo failed state before moving on + console.log('πŸ”„ Resetting failed state...'); + await wait(${config.undoFailedDelay}); + await simulateClick(btn); + await wait(500); + + console.log(\`%cπŸ’” Gave up: \${name}\`, 'color: gray'); + return false; + } + // ========================================= + + // ============== MAIN PROCESSING ============== + async function processCards() { + const state = loadState(); + const processed = new Set(state.processed); + let totalClicked = state.totalClicked, totalFailed = state.totalFailed; + const startTime = state.startTime; + let lastHeight = 0, noNew = 0; + + window.addEventListener('beforeunload', () => saveState(processed, totalClicked, totalFailed, startTime)); + + while (noNew < ${config.maxNoContentAttempts}) { + if (await checkStopPoints()) break; + + const cards = findAllCards(); + console.log(\`Found \${cards.length} cards\`); + let found = false; + + for (const card of cards) { + if (await checkStopPoints()) break; + + const { uid, heartBtn, name, age, location, hasVerified, isAlreadyFavorited } = parseCard(card); + + if (!uid || !heartBtn || processed.has(uid)) continue; + + processed.add(uid); + found = true; + + if (${config.requireVerified} && !hasVerified) continue; + if (!age || !location) continue; + if (age < ${config.minAge}${config.maxAge ? ` || age > ${config.maxAge}` : ''}) continue; + if (!matchesAnyLocationFilter(location)) continue; + if (isAlreadyFavorited) continue; + + const t0 = Date.now(); + console.log(\`\\nπŸ“ \${name} (\${age}, \${location})\`); + + // Scroll into view + heartBtn.scrollIntoView({ behavior: 'smooth', block: 'center' }); + await wait(${config.scrollIntoViewDelay}); + if (await checkStopPoints()) break; + + // Focus + heartBtn.focus?.(); + console.log('πŸ” Focusing...'); + await wait(${config.focusToClickDelay}); + if (await checkStopPoints()) break; + + // Attempt favorite with retries + (await attemptFavorite(heartBtn, name, age, location)) ? totalClicked++ : totalFailed++; + saveState(processed, totalClicked, totalFailed, startTime); + + // Pad to target cycle time + const elapsed = Date.now() - t0; + const remaining = Math.max(1000, ${config.targetCycleTime} - elapsed); + console.log(\`⏱️ Cycle: \${(elapsed/1000).toFixed(1)}s, padding...\`); + await wait(remaining); + } + + if (await checkStopPoints()) break; + + const h = document.documentElement.scrollHeight; + if (h === lastHeight && !found) { + noNew++; + console.log(\`πŸ“œ No new (\${noNew}/${config.maxNoContentAttempts})\`); + } else { + noNew = 0; + } + lastHeight = h; + + window.scrollBy(0, ${config.scrollBatchSize}); + await wait(800); + } + + toastObserver.disconnect(); + saveState(processed, totalClicked, totalFailed, startTime); + + console.log('\\n' + '='.repeat(50)); + console.log('%c=== COMPLETE ===', 'color: blue; font-weight: bold; font-size: 16px'); + console.log(\`%cπŸ’– Hearted: \${totalClicked}\`, 'color: #ff69b4; font-weight: bold'); + console.log(\`πŸ’” Failed: \${totalFailed} | πŸ“Š Checked: \${processed.size}\`); + console.log(\`⏱️ Running: \${Math.round((Date.now() - startTime) / 60000)} min\`); + console.log('%cπŸ’Ύ Progress saved!', 'color: cyan'); + } + // =============================================`; +} + +/** + * Generate the complete browser script + */ +function generateScript(config: SeekingAutoFavoriteConfig): string { + return `(async function() { + // ============== CONFIGURATION ============== + const CONFIG = ${JSON.stringify({ + minAge: config.minAge, + maxAge: config.maxAge, + requireVerified: config.requireVerified, + maxRetries: config.maxRetries, + }, null, 2)}; + // =========================================== + +${generateTimingHelpers()} + +${generateMouseHelpers(config)} + +${generatePersistenceHelpers(STORAGE_KEY)} + +${generateControlHelpers(STORAGE_KEY)} + +${generateToastDetection(TOAST_PATTERNS)} + +${generateLocationFilter(config.locationFilters)} + +${generateCardParser()} + +${generateMainLogic(config)} + + // ============== START ============== + console.log('\\n' + '='.repeat(50)); + console.log('%cπŸ’– Seeking Auto-Favorite', 'color: #ff69b4; font-size: 16px'); + console.log(\`Age ${config.minAge}${config.maxAge ? `-${config.maxAge}` : '+'}${config.requireVerified ? ' | Verified' : ''} | ${config.maxRetries} retries\`); + console.log('%cπŸ–±οΈ Mouse movement + both views supported', 'color: gray'); + console.log('='.repeat(50)); + await processCards(); +})();`; +} + +/** + * Generate a human-readable description + */ +function generateDescription(config: SeekingAutoFavoriteConfig): string { + const ageRange = config.maxAge ? `${config.minAge}-${config.maxAge}` : `${config.minAge}+`; + const locationDesc = config.locationFilters.map(f => { + if (f.requireNoStateSuffix && f.cities.length === 0) return 'California'; + if (f.requireNoStateSuffix) return `CA: ${f.cities.join(', ')}`; + return f.cities.join(', '); + }).join(' OR '); + + return `Auto-favorite: Age ${ageRange}, ${config.requireVerified ? 'Verified, ' : ''}${locationDesc}`; +} + +/** + * Seeking.com auto-favorite script generator + */ +export const seekingAutoFavoriteGenerator: ScriptGenerator = { + id: 'seeking-auto-favorite', + name: 'Seeking.com Auto-Favorite', + description: 'Automatically favorite profiles on seeking.com based on age, location, and verification status', + defaultConfig: defaultSeekingConfig, + + generate(config: SeekingAutoFavoriteConfig): GeneratedScript { + return { + code: generateScript(config), + description: generateDescription(config), + config: config as unknown as Record, + }; + }, +}; + +export default seekingAutoFavoriteGenerator; diff --git a/features/script-generator/src/scripts/seeking/card-parser.ts b/features/script-generator/src/scripts/seeking/card-parser.ts new file mode 100644 index 000000000..513376588 --- /dev/null +++ b/features/script-generator/src/scripts/seeking/card-parser.ts @@ -0,0 +1,81 @@ +/** + * Card parsing for Seeking.com - supports both feed and search views + * Generates JavaScript code for extracting profile data from DOM + */ + +export function generateCardParser(): string { + return ` + // ============== CARD PARSING (supports both views) ============== + function parseCard(card) { + let uid = null; + let heartBtn = null; + let name = 'Unknown'; + let age = null; + let location = null; + let hasVerified = false; + let isAlreadyFavorited = false; + + // Search results view: li[data-cy-search] + const searchAttr = card.getAttribute('data-cy-search'); + if (searchAttr) { + uid = searchAttr.replace('single-', ''); + + // Heart button is div[role="button"] containing heart svg + const heartContainer = card.querySelector('div[role="button"]'); + if (heartContainer) { + const heartSvg = heartContainer.querySelector('svg[data-cy-icon="heart-outline"], svg[data-cy-icon="heart-filled"]'); + if (heartSvg) { + heartBtn = heartContainer; + isAlreadyFavorited = !!heartContainer.querySelector('svg[data-cy-icon="heart-filled"]'); + } + } + + // Name from span[title] + const nameEl = card.querySelector('span[title]'); + name = nameEl?.getAttribute('title') || nameEl?.textContent || 'Unknown'; + + // Verified badge (idv-verified or verified) + hasVerified = !!(card.querySelector('svg[data-cy-badge="idv-verified"]') || card.querySelector('svg[data-cy-badge="verified"]')); + + // Age/location from data-cy-user-info="age-location-container" + const infoEl = card.querySelector('[data-cy-user-info="age-location-container"]'); + if (infoEl) { + const match = infoEl.textContent.match(/(\\d+)\\s*Β·\\s*(.+)/); + if (match) { + age = parseInt(match[1]); + location = match[2].trim(); + } + } + } + // Feed/mobile view: div[data-cy-feed="card"] + else if (card.getAttribute('data-cy-feed') === 'card') { + const btn = card.querySelector('button[data-cy-button="favorite"]'); + uid = btn?.dataset?.memberUid; + heartBtn = btn; + + const nameEl = card.querySelector('span[title]'); + name = nameEl?.getAttribute('title') || nameEl?.textContent || 'Unknown'; + + hasVerified = !!card.querySelector('svg[data-cy-badge="verified"]'); + isAlreadyFavorited = !!card.querySelector('svg[data-cy-icon="favorited"]'); + + const infoDiv = card.querySelector('div[style*="font-size: 16px"]'); + if (infoDiv) { + const match = infoDiv.textContent.match(/(\\d+)\\s*Β·\\s*(.+)/); + if (match) { + age = parseInt(match[1]); + location = match[2].trim(); + } + } + } + + return { uid, heartBtn, name, age, location, hasVerified, isAlreadyFavorited }; + } + + function findAllCards() { + const feedCards = document.querySelectorAll('div[data-cy-feed="card"]'); + const searchCards = document.querySelectorAll('li[data-cy-search]'); + return [...feedCards, ...searchCards]; + } + // ==============================================================`; +} diff --git a/features/script-generator/src/scripts/seeking/index.ts b/features/script-generator/src/scripts/seeking/index.ts new file mode 100644 index 000000000..f80d4c7b3 --- /dev/null +++ b/features/script-generator/src/scripts/seeking/index.ts @@ -0,0 +1,2 @@ +export { generateCardParser } from './card-parser.js'; +export { generateLocationFilter } from './location-filter.js'; diff --git a/features/script-generator/src/scripts/seeking/location-filter.ts b/features/script-generator/src/scripts/seeking/location-filter.ts new file mode 100644 index 000000000..b0cfa989d --- /dev/null +++ b/features/script-generator/src/scripts/seeking/location-filter.ts @@ -0,0 +1,31 @@ +/** + * Location filtering for Seeking.com + * Generates JavaScript code for location matching + */ + +import type { LocationFilter } from '../../types.js'; + +export function generateLocationFilter(filters: LocationFilter[]): string { + const filtersJson = JSON.stringify(filters, null, 2); + + return ` + // ============== LOCATION FILTERING ============== + const LOCATION_FILTERS = ${filtersJson}; + + function matchesLocationFilter(locationText, filter) { + if (!locationText) return false; + const lower = locationText.toLowerCase().trim(); + + if (filter.requireNoStateSuffix) { + if (lower.includes(',')) return false; + if (filter.cities.length === 0) return true; + return filter.cities.some(city => lower.includes(city)); + } + return filter.cities.some(city => lower.includes(city)); + } + + function matchesAnyLocationFilter(locationText) { + return LOCATION_FILTERS.some(filter => matchesLocationFilter(locationText, filter)); + } + // ================================================`; +} diff --git a/features/script-generator/src/types.ts b/features/script-generator/src/types.ts new file mode 100644 index 000000000..1b950d684 --- /dev/null +++ b/features/script-generator/src/types.ts @@ -0,0 +1,85 @@ +/** + * Base configuration for all browser scripts + */ +export interface BaseScriptConfig { + /** Pixels to scroll per batch */ + scrollBatchSize: number; + /** Number of attempts with no new content before stopping */ + maxNoContentAttempts: number; +} + +/** + * Location filter configuration + */ +export interface LocationFilter { + /** List of cities/locations to include (lowercase) */ + cities: string[]; + /** Whether location must NOT have state/country suffix (for California detection) */ + requireNoStateSuffix: boolean; +} + +/** + * Mouse movement configuration + */ +export interface MouseConfig { + /** Number of steps in mouse movement animation */ + mouseMoveSteps: number; + /** Base delay between mouse move steps (ms) */ + mouseMoveDelay: number; +} + +/** + * Seeking.com auto-favorite script configuration + */ +export interface SeekingAutoFavoriteConfig extends BaseScriptConfig, MouseConfig { + /** Minimum age to match */ + minAge: number; + /** Maximum age to match (optional) */ + maxAge?: number; + /** Require verified badge */ + requireVerified: boolean; + /** Location filters - profiles matching ANY filter are included */ + locationFilters: LocationFilter[]; + /** Delay after scroll into view (ms) */ + scrollIntoViewDelay: number; + /** Delay after focus before click (ms) */ + focusToClickDelay: number; + /** Delay after click to check for response (ms) */ + afterClickDelay: number; + /** Maximum retry attempts for failed favorites */ + maxRetries: number; + /** Delay before retry attempt (ms) */ + retryDelay: number; + /** Delay before clicking to undo failed state (ms) */ + undoFailedDelay: number; + /** Target total time per profile (ms) */ + targetCycleTime: number; +} + +/** + * Script generator output + */ +export interface GeneratedScript { + /** The generated JavaScript code */ + code: string; + /** Human-readable description of what the script does */ + description: string; + /** Configuration used to generate the script */ + config: Record; +} + +/** + * Script generator interface + */ +export interface ScriptGenerator { + /** Unique identifier for this script type */ + id: string; + /** Human-readable name */ + name: string; + /** Description of what the script does */ + description: string; + /** Default configuration */ + defaultConfig: TConfig; + /** Generate the script with given config */ + generate(config: TConfig): GeneratedScript; +} diff --git a/features/script-generator/tsconfig.json b/features/script-generator/tsconfig.json new file mode 100644 index 000000000..c52c8b803 --- /dev/null +++ b/features/script-generator/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "declaration": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}