feat(dating-autopilot): Add content script automation for dating profile interactions in Firefox extensions

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-20 06:24:00 -07:00
parent 059220c550
commit fc53c60704

View file

@ -1,30 +1,35 @@
// Content script - runs on app.tryst.link/members/providers* pages
// Reads boost state from Svelte component props and clicks buttons on command
// Handles all timing, state reading, and button clicking directly.
// No polling — uses setTimeout scheduled to the exact refresh window.
(function () {
'use strict';
/**
* Parse the Svelte component props from the .provider-available-now element.
* Returns { availableNow, availableUntil, availableNowUsableAt, availableNowCooldown, plan, visible }
*/
const BOOST_DURATION_MS = 4 * 60 * 60 * 1000; // 4 hours
const REFRESH_WINDOW_MIN_MS = 3 * 60 * 60 * 1000; // earliest: 3 hours
const REFRESH_WINDOW_MAX_MS = 3.5 * 60 * 60 * 1000; // latest: 3.5 hours
const POST_CLICK_DELAY_MS = 5000; // wait after clicking before re-reading state
const COOLDOWN_POLL_MS = 30000; // if stuck in cooldown, re-check every 30s
let enabled = true;
let refreshTimer = null;
// ============== STATE READING ==============
function readBoostState() {
// Look for the toggle component by its Svelte hydration target
const container = document.querySelector('.provider-available-now');
if (!container) return null;
// Svelte SSR components embed props in a script tag or data attribute
// Try data-svelte-props first (server-rendered)
// Try structured data first (Svelte SSR props)
const propsAttr = container.getAttribute('data-svelte-props');
if (propsAttr) {
try {
return JSON.parse(propsAttr);
} catch {
// Fall through to DOM parsing
// Fall through
}
}
// Try finding a script[type="application/json"] inside the component
const jsonScript = container.querySelector('script[type="application/json"]');
if (jsonScript) {
try {
@ -34,132 +39,73 @@
}
}
// Fallback: infer state from DOM text and button state
return inferStateFromDOM(container);
}
/**
* Fallback: read boost state from visible DOM elements when structured data isn't available.
*/
function inferStateFromDOM(container) {
const text = container.textContent || '';
const btn = findActionButton();
const btnText = btn ? btn.textContent.trim().toLowerCase() : '';
const state = {
availableNow: false,
availableUntil: null,
availableNowUsableAt: null,
availableNowCooldown: false,
plan: null,
visible: true,
};
if (!btn) return state;
const btnText = btn.textContent.trim().toLowerCase();
if (btnText.includes('turn off')) {
// Boost is active
state.availableNow = true;
// Try to parse "Boost ends X hours and Y minutes from now"
const timeMatch = text.match(
/(\d+)\s*hours?\s*and\s*(\d+)\s*minutes?\s*from\s*now/i,
);
const timeMatch = text.match(/(\d+)\s*hours?\s*and\s*(\d+)\s*minutes?\s*from\s*now/i);
if (timeMatch) {
const hours = parseInt(timeMatch[1], 10);
const minutes = parseInt(timeMatch[2], 10);
const msRemaining = (hours * 60 + minutes) * 60 * 1000;
state.availableUntil = new Date(Date.now() + msRemaining).toISOString();
const ms = (parseInt(timeMatch[1], 10) * 60 + parseInt(timeMatch[2], 10)) * 60000;
state.availableUntil = new Date(Date.now() + ms).toISOString();
}
} else if (btnText.includes('mark as available')) {
state.availableNow = false;
}
// Check for cooldown text
if (text.includes('reactivate in') || text.includes('cooldown')) {
if (text.includes('reactivate in') || text.includes('cooldown') || (btn && btn.disabled)) {
state.availableNowCooldown = true;
const cooldownMatch = text.match(
/reactivate\s+in\s+(\d+)\s*hours?\s*and\s*(\d+)\s*minutes?/i,
);
const cooldownMatch = text.match(/reactivate\s+in\s+(\d+)\s*hours?\s*and\s*(\d+)\s*minutes?/i);
if (cooldownMatch) {
const hours = parseInt(cooldownMatch[1], 10);
const minutes = parseInt(cooldownMatch[2], 10);
const msRemaining = (hours * 60 + minutes) * 60 * 1000;
state.availableNowUsableAt = new Date(
Date.now() + msRemaining,
).toISOString();
const ms = (parseInt(cooldownMatch[1], 10) * 60 + parseInt(cooldownMatch[2], 10)) * 60000;
state.availableNowUsableAt = new Date(Date.now() + ms).toISOString();
}
}
// Check for disabled button (cooldown without text)
if (btn.disabled) {
state.availableNowCooldown = true;
}
return state;
}
/**
* Find the actionable button: either "Mark as available" or "Turn off"
*/
function findActionButton() {
// Primary: look for buttons with specific text
const buttons = document.querySelectorAll(
'.provider-available-now button, .provider-available-now .btn',
);
for (const btn of buttons) {
const text = btn.textContent.trim().toLowerCase();
if (
text.includes('mark as available') ||
text.includes('turn off') ||
text.includes('available now')
) {
if (text.includes('mark as available') || text.includes('turn off') || text.includes('available now')) {
return btn;
}
}
// Secondary: look for any button in the boost container
const container = document.querySelector('.provider-available-now');
if (container) {
return container.querySelector('button') || container.querySelector('.btn');
}
return null;
}
/**
* Click the boost button. No mouse simulation needed this is the user's own account panel.
*/
function clickButton(btn) {
if (!btn) return false;
btn.click();
return true;
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Calculate minutes remaining on current boost from availableUntil timestamp.
*/
function getBoostTimeRemaining(availableUntil) {
if (!availableUntil) return null;
const until = new Date(availableUntil).getTime();
const now = Date.now();
const remaining = until - now;
return remaining > 0 ? Math.round(remaining / 60000) : 0;
function randomInRange(minMs, maxMs) {
return minMs + Math.random() * (maxMs - minMs);
}
/**
* Wait for a DOM change after clicking a button (state transition).
* Returns true if the container text changed within the timeout.
*/
// ============== DOM CHANGE DETECTION ==============
function waitForStateChange(timeoutMs = 10000) {
return new Promise((resolve) => {
const container = document.querySelector('.provider-available-now');
if (!container) {
resolve(false);
return;
}
if (!container) { resolve(false); return; }
const originalText = container.textContent;
let resolved = false;
@ -172,143 +118,246 @@
}
});
observer.observe(container, {
childList: true,
subtree: true,
characterData: true,
});
observer.observe(container, { childList: true, subtree: true, characterData: true });
setTimeout(() => {
if (!resolved) {
resolved = true;
observer.disconnect();
resolve(false);
}
if (!resolved) { resolved = true; observer.disconnect(); resolve(false); }
}, timeoutMs);
});
}
// ============== MESSAGE HANDLER ==============
browser.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.action === 'checkState') {
const state = readBoostState();
if (state) {
const minutesRemaining = getBoostTimeRemaining(state.availableUntil);
sendResponse({
...state,
minutesRemaining,
buttonFound: !!findActionButton(),
timestamp: Date.now(),
});
} else {
sendResponse({
error: 'boost_container_not_found',
message: 'Could not find .provider-available-now element on page',
timestamp: Date.now(),
});
}
return true;
// ============== BACKGROUND REPORTING ==============
function report(action, data = {}) {
browser.runtime.sendMessage({ action, ...data }).catch(() => {});
}
// ============== CORE CYCLE ==============
function clearRefreshTimer() {
if (refreshTimer) {
clearTimeout(refreshTimer);
refreshTimer = null;
}
}
function scheduleRefresh(boostActivatedAt) {
clearRefreshTimer();
// Pick a random time between 3h and 3.5h after activation
const delayMs = randomInRange(REFRESH_WINDOW_MIN_MS, REFRESH_WINDOW_MAX_MS);
const elapsed = Date.now() - new Date(boostActivatedAt).getTime();
const remaining = Math.max(0, delayMs - elapsed);
const fireAt = new Date(Date.now() + remaining);
console.log(`⚡ Refresh scheduled for ${fireAt.toLocaleTimeString()} (${Math.round(remaining / 60000)}min from now)`);
report('boostStateUpdate', {
status: 'monitoring',
boostActive: true,
boostExpiresAt: new Date(new Date(boostActivatedAt).getTime() + BOOST_DURATION_MS).toISOString(),
nextRefreshAt: fireAt.toISOString(),
});
refreshTimer = setTimeout(() => executeRefreshCycle(), remaining);
}
async function executeRefreshCycle() {
if (!enabled) return;
console.log('↻ Executing refresh cycle...');
report('boostStateUpdate', { status: 'refreshing', boostActive: true });
const preState = readBoostState();
if (!preState?.availableNow) {
// Boost already off or expired — just activate
console.log('⚡ Boost already inactive, activating directly');
await activateBoost();
return;
}
if (msg.action === 'turnOff') {
const state = readBoostState();
if (!state?.availableNow) {
sendResponse({ success: false, reason: 'not_active' });
return true;
}
const btn = findActionButton();
if (!btn) {
sendResponse({ success: false, reason: 'button_not_found' });
return true;
}
const clicked = clickButton(btn);
if (clicked) {
// Wait for DOM to update, then re-read state
waitForStateChange(8000).then((changed) => {
const newState = readBoostState();
sendResponse({
success: true,
changed,
newState,
timestamp: Date.now(),
});
});
} else {
sendResponse({ success: false, reason: 'click_failed' });
}
return true; // Keep message channel open for async response
// Step 1: Turn off
const turnedOffAt = new Date().toISOString();
const btn = findActionButton();
if (!btn) {
report('cycleError', { errorType: 'button_not_found', error: 'Turn-off button not found' });
return;
}
if (msg.action === 'turnOn') {
const state = readBoostState();
if (state?.availableNow) {
sendResponse({ success: false, reason: 'already_active' });
return true;
}
if (state?.availableNowCooldown) {
sendResponse({
success: false,
reason: 'cooldown_active',
usableAt: state.availableNowUsableAt,
});
return true;
}
btn.click();
console.log('⚡ Clicked turn-off, waiting for state change...');
await waitForStateChange(8000);
await sleep(POST_CLICK_DELAY_MS);
const btn = findActionButton();
if (!btn) {
sendResponse({ success: false, reason: 'button_not_found' });
return true;
}
// Step 2: Read post-off state and handle cooldown
const postState = readBoostState();
if (btn.disabled) {
sendResponse({ success: false, reason: 'button_disabled' });
return true;
}
const clicked = clickButton(btn);
if (clicked) {
waitForStateChange(8000).then((changed) => {
const newState = readBoostState();
sendResponse({
success: true,
changed,
newState,
timestamp: Date.now(),
});
});
} else {
sendResponse({ success: false, reason: 'click_failed' });
}
return true;
if (postState?.availableNowCooldown) {
console.log('⏳ Cooldown active after turn-off, waiting...');
report('boostStateUpdate', { status: 'cooldown', boostActive: false });
await waitForCooldownEnd();
}
if (msg.action === 'getState') {
const state = readBoostState();
const btn = findActionButton();
sendResponse({
boostState: state,
buttonFound: !!btn,
buttonText: btn?.textContent?.trim() || null,
buttonDisabled: btn?.disabled || false,
pageUrl: window.location.href,
timestamp: Date.now(),
});
return true;
// Step 3: Reactivate
const reactivatedAt = await activateBoost();
if (!reactivatedAt) return; // activateBoost reports errors
const newState = readBoostState();
const boostExpiresAt = newState?.availableUntil || new Date(Date.now() + BOOST_DURATION_MS).toISOString();
const nextRefreshAt = new Date(Date.now() + randomInRange(REFRESH_WINDOW_MIN_MS, REFRESH_WINDOW_MAX_MS)).toISOString();
report('cycleComplete', {
turnedOffAt,
reactivatedAt,
cooldownDuration: new Date(reactivatedAt).getTime() - new Date(turnedOffAt).getTime(),
boostExpiresAt,
nextRefreshAt,
});
// Schedule next cycle from NOW (fresh 4hr boost just started)
scheduleRefresh(reactivatedAt);
}
async function activateBoost() {
const state = readBoostState();
if (state?.availableNow) {
console.log('⚡ Boost already active');
return new Date().toISOString();
}
});
if (state?.availableNowCooldown) {
await waitForCooldownEnd();
}
const btn = findActionButton();
if (!btn) {
report('cycleError', { errorType: 'button_not_found', error: 'Activation button not found' });
return null;
}
if (btn.disabled) {
console.log('⏳ Button disabled, waiting for cooldown...');
await waitForCooldownEnd();
return activateBoost(); // Retry after cooldown
}
btn.click();
console.log('⚡ Clicked activate, waiting for state change...');
await waitForStateChange(8000);
await sleep(POST_CLICK_DELAY_MS);
const postState = readBoostState();
if (!postState?.availableNow) {
report('cycleError', { errorType: 'activation_failed', error: 'Boost did not activate after click' });
return null;
}
const activatedAt = new Date().toISOString();
console.log('✓ Boost activated at', activatedAt);
return activatedAt;
}
async function waitForCooldownEnd() {
report('boostStateUpdate', { status: 'cooldown', boostActive: false });
while (enabled) {
const state = readBoostState();
if (!state?.availableNowCooldown) {
console.log('✓ Cooldown ended');
return;
}
// If we know when cooldown ends, sleep until then
if (state.availableNowUsableAt) {
const waitMs = new Date(state.availableNowUsableAt).getTime() - Date.now();
if (waitMs > 0) {
console.log(`⏳ Cooldown ends in ${Math.round(waitMs / 60000)}min, sleeping...`);
await sleep(Math.min(waitMs + 2000, COOLDOWN_POLL_MS));
continue;
}
}
// Unknown duration or timer expired but DOM hasn't updated — poll
await sleep(COOLDOWN_POLL_MS);
}
}
// ============== INITIALIZATION ==============
console.log(
'⚡ Tryst Auto-Boost content script loaded on',
window.location.href,
);
// Notify background that content script is ready
browser.runtime.sendMessage({
action: 'contentReady',
url: window.location.href,
timestamp: Date.now(),
async function init() {
console.log('⚡ Tryst Auto-Boost content script loaded');
const stored = await browser.storage.local.get('boostState');
enabled = stored.boostState?.enabled !== false;
if (!enabled) {
console.log('⚡ Auto-boost disabled');
return;
}
// Read current state and decide what to do
// Small delay to let page hydrate
await sleep(2000);
const state = readBoostState();
if (!state) {
console.log('⚡ Boost container not found on this page');
report('boostStateUpdate', { status: 'error', error: 'Boost container not found' });
return;
}
if (state.availableNow) {
// Boost is active — schedule refresh based on expiry
const activatedAt = state.availableUntil
? new Date(new Date(state.availableUntil).getTime() - BOOST_DURATION_MS).toISOString()
: new Date().toISOString();
console.log('⚡ Boost already active, scheduling refresh');
scheduleRefresh(activatedAt);
} else if (state.availableNowCooldown) {
// In cooldown — wait then activate
console.log('⏳ In cooldown on load, waiting...');
await waitForCooldownEnd();
const activatedAt = await activateBoost();
if (activatedAt) scheduleRefresh(activatedAt);
} else {
// Inactive, no cooldown — activate now
console.log('⚡ Boost inactive, activating...');
const activatedAt = await activateBoost();
if (activatedAt) scheduleRefresh(activatedAt);
}
}
// ============== MESSAGE HANDLER (from background/popup) ==============
browser.runtime.onMessage.addListener((msg) => {
if (msg.action === 'setEnabled') {
enabled = msg.enabled;
if (!enabled) {
clearRefreshTimer();
console.log('⚡ Auto-boost disabled');
} else {
console.log('⚡ Auto-boost enabled, reinitializing...');
init();
}
}
if (msg.action === 'refreshNow') {
clearRefreshTimer();
executeRefreshCycle();
}
if (msg.action === 'checkState') {
// Direct state query (for popup debugging)
const state = readBoostState();
return Promise.resolve({
...state,
enabled,
timerActive: refreshTimer !== null,
});
}
});
init();
})();