Skip to content

Instantly share code, notes, and snippets.

@compwron
Created April 25, 2026 00:29
Show Gist options
  • Select an option

  • Save compwron/bcb1d21e506151ef956eec062779a8d1 to your computer and use it in GitHub Desktop.

Select an option

Save compwron/bcb1d21e506151ef956eec062779a8d1 to your computer and use it in GitHub Desktop.
// Paste this into the Firefox console on https://production.tuple.app/profile/usage
// It scrapes all pages and downloads a CSV file.
(async () => {
const allRows = [];
const totalPages = 47; // from pagination: Last » links to page=47
const TIMEOUT_MS = 10000;
const DELAY_MS = 1000;
let consecutiveEmpty = 0;
for (let page = 1; page <= totalPages; page++) {
console.log(`Fetching page ${page}/${totalPages}...`);
const url = `/profile/usage?page=${page}`;
let resp;
try {
resp = await Promise.race([
fetch(url, { credentials: 'same-origin' }),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('timeout')), TIMEOUT_MS)
),
]);
} catch (err) {
console.error(`Page ${page} fetch failed: ${err.message} — skipping`);
continue;
}
if (!resp.ok) {
console.error(`Page ${page} returned HTTP ${resp.status} — skipping`);
continue;
}
const html = await resp.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
// Detect login redirect: if page has no call items AND has a login form, bail
if (doc.querySelector('input[type="password"]') || doc.querySelector('form[action*="login"]')) {
console.error('Session expired — got redirected to login. Re-login and retry.');
break;
}
const calls = doc.querySelectorAll('[data-call-item]');
console.log(` Page ${page}: found ${calls.length} calls`);
if (calls.length === 0) {
consecutiveEmpty++;
if (consecutiveEmpty >= 3) {
console.warn(`3 consecutive empty pages — stopping early at page ${page}`);
break;
}
} else {
consecutiveEmpty = 0;
}
for (const call of calls) {
const isoDate = call.getAttribute('data-date');
const dt = new Date(isoDate);
const dateStr = dt.toLocaleDateString('en-CA'); // YYYY-MM-DD
const timeEl = call.querySelector('time');
const timeStr = timeEl ? timeEl.textContent.trim() : '';
// Normalize "3:20 pm" → "3:20 PM"
const timeNorm = timeStr.replace(/am/i, 'AM').replace(/pm/i, 'PM');
// Duration from the visible (non-mobile) duration element
const durationEl = call.querySelector('.hidden.xs\\:block p');
const duration = durationEl ? durationEl.textContent.trim() : '';
// Participants from the facepile tooltip
const participantEls = call.querySelectorAll(
'.group\\/facepile .bg-gray-50 .font-medium, .group\\/facepile .dark\\:bg-gray-800 .font-medium'
);
let participants = [];
if (participantEls.length > 0) {
for (const el of participantEls) {
// Text is duplicated (name shown twice) — take first word-chunk that looks like an email
const text = el.textContent.trim();
const email = text.split(/\s+/)[0];
if (email.includes('@')) {
participants.push(email);
}
}
}
const participantStr = participants.join(';');
allRows.push(`${dateStr},${timeNorm},${duration},"${participantStr}"`);
}
// Delay between fetches
if (page < totalPages) {
await new Promise(r => setTimeout(r, DELAY_MS));
}
}
if (allRows.length === 0) {
console.error('No calls found — check that you are on https://production.tuple.app/profile/usage and logged in.');
return;
}
const csv = 'date,time,duration,participants\n' + allRows.join('\n') + '\n';
const blob = new Blob([csv], { type: 'text/csv' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'tuple-calls.csv';
document.body.appendChild(a);
a.click();
a.remove();
console.log(`Done! ${allRows.length} calls exported.`);
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment