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:
parent
059220c550
commit
fc53c60704
1 changed files with 254 additions and 205 deletions
|
|
@ -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();
|
||||
})();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue