#!/usr/bin/env node /** * Authenticated Bubble inventory export for CSOC-WP-0001-T02. * * Uses Playwright storageState (never prints secrets). Captures routes, page * text/HTML samples, and network JSON-ish responses for offline mapping. * * Usage: * ./scripts/capture-auth-state.sh # once * node ./scripts/export-bubble-inventory.mjs * * Env: * COULOMB_SOCIAL_STORAGE_STATE default ~/.config/coulomb-social/auth/storage-state.json * COULOMB_SOCIAL_URL default https://coulomb.social * COULOMB_SOCIAL_EXPORT_DIR default ~/.config/coulomb-social/auth/exports/ */ import { chromium } from "playwright"; import { mkdirSync, writeFileSync, chmodSync, existsSync, appendFileSync, } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; const BASE_URL = process.env.COULOMB_SOCIAL_URL || "https://coulomb.social"; const AUTH_DIR = process.env.COULOMB_SOCIAL_AUTH_DIR || join(homedir(), ".config", "coulomb-social", "auth"); const STATE_PATH = process.env.COULOMB_SOCIAL_STORAGE_STATE || join(AUTH_DIR, "storage-state.json"); const ROUTES = [ "/", "/vw_pages", "/vw_spaces", "/vw_chunks", "/vw_search", "/vw_feed", "/vw_invites", "/vw_likes", "/vw_new", "/reset_pw", ]; function stamp() { const d = new Date(); const p = (n) => String(n).padStart(2, "0"); return ( d.getUTCFullYear() + p(d.getUTCMonth() + 1) + p(d.getUTCDate()) + "-" + p(d.getUTCHours()) + p(d.getUTCMinutes()) + p(d.getUTCSeconds()) ); } function ensureDir(path, mode = 0o700) { mkdirSync(path, { recursive: true, mode }); try { chmodSync(path, mode); } catch { /* best effort */ } } function writeText(path, text) { writeFileSync(path, text, { mode: 0o600 }); try { chmodSync(path, 0o600); } catch { /* best effort */ } } async function main() { if (!existsSync(STATE_PATH)) { console.error(`ERROR: No session file at ${STATE_PATH}`); console.error("Capture a session first (do not paste passwords into chat):"); console.error(" ./scripts/capture-auth-state.sh"); console.error("Docs: docs/migration/bubble-auth-and-export.md"); process.exit(1); } const outRoot = process.env.COULOMB_SOCIAL_EXPORT_DIR || join(AUTH_DIR, "exports", stamp()); const routesDir = join(outRoot, "routes"); const netDir = join(outRoot, "network"); ensureDir(outRoot); ensureDir(routesDir); ensureDir(netDir); console.log(`Session: ${STATE_PATH} (present; not printed)`); console.log(`Export → ${outRoot}`); const networkLog = []; const browser = await chromium.launch({ headless: true }); try { const context = await browser.newContext({ storageState: STATE_PATH }); const page = await context.newPage(); page.on("response", async (response) => { try { const url = response.url(); const ct = (response.headers()["content-type"] || "").toLowerCase(); const status = response.status(); if (status < 200 || status >= 400) return; const interesting = ct.includes("json") || url.includes("/api/") || url.includes("elasticsearch") || url.includes("userdata") || url.includes("obj"); if (!interesting) return; let body = ""; try { body = await response.text(); } catch { return; } if (!body || body.length > 2_000_000) return; const entry = { url: url.slice(0, 500), status, contentType: ct.slice(0, 120), bytes: body.length, at: new Date().toISOString(), }; networkLog.push(entry); const safe = String(networkLog.length).padStart(3, "0"); writeText(join(netDir, `${safe}.meta.json`), JSON.stringify(entry, null, 2)); writeText(join(netDir, `${safe}.body.txt`), body); } catch { /* ignore single response failures */ } }); // Warm session await page.goto(BASE_URL, { waitUntil: "domcontentloaded", timeout: 60_000 }); await page.waitForTimeout(2500); const probeUrl = page.url(); const probeTitle = await page.title().catch(() => ""); writeText( join(outRoot, "probe.txt"), `url=${probeUrl}\ntitle=${probeTitle}\n`, ); console.log(`Probe: ${probeUrl} — ${probeTitle}`); try { const path = new URL(probeUrl).pathname; if (path === "/" || path === "") { console.warn( "WARNING: Still on site root — session may be guest/expired. Re-run capture-auth-state.sh", ); } } catch { /* ignore */ } const summary = []; summary.push(`# Bubble export ${stamp()}`); summary.push(""); summary.push(`- base: ${BASE_URL}`); summary.push(`- probe: ${probeUrl}`); summary.push(`- title: ${probeTitle}`); summary.push(`- session file: present (path not echoed in summary for safety)`); summary.push(""); summary.push("## Routes"); summary.push(""); for (const route of ROUTES) { const url = route.startsWith("http") ? route : `${BASE_URL.replace(/\/$/, "")}${route}`; const slug = route.replace(/\W+/g, "_").replace(/^_|_$/g, "") || "root"; console.log(` GET ${route}`); try { const resp = await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60_000, }); await page.waitForTimeout(3500); const finalUrl = page.url(); const title = await page.title().catch(() => ""); const status = resp?.status() ?? 0; const html = await page.content(); const text = await page.evaluate(() => document.body?.innerText || ""); writeText(join(routesDir, `${slug}.html`), html); writeText(join(routesDir, `${slug}.txt`), text); writeText( join(routesDir, `${slug}.meta.json`), JSON.stringify( { route, url, finalUrl, title, status, htmlBytes: html.length, textBytes: text.length }, null, 2, ), ); summary.push( `- \`${route}\` → ${status} ${title} (${text.length} chars text, final ${finalUrl})`, ); } catch (err) { summary.push(`- \`${route}\` → ERROR ${err.message}`); console.warn(` failed: ${err.message}`); } } // Public meta (no auth needed, still useful) try { const metaResp = await page.request.get(`${BASE_URL.replace(/\/$/, "")}/api/1.1/meta`); const metaText = await metaResp.text(); writeText(join(outRoot, "api-meta.json"), metaText); summary.push(""); summary.push("## API meta"); summary.push(`- status ${metaResp.status()} (${metaText.length} bytes)`); } catch (err) { summary.push(`- api meta ERROR ${err.message}`); } writeText( join(outRoot, "network-index.json"), JSON.stringify(networkLog, null, 2), ); summary.push(""); summary.push("## Network captures"); summary.push(`- ${networkLog.length} JSON/API-ish responses under network/`); summary.push(""); summary.push("## Next"); summary.push("- Review routes/*.txt for Title/Abstractor/Visual/copy cues"); summary.push("- Map into docs/migration/schema-mapping (redacted notes only in git)"); summary.push("- Do not commit this export directory"); summary.push(""); writeText(join(outRoot, "SUMMARY.md"), summary.join("\n")); console.log(""); console.log(`Done. Summary: ${join(outRoot, "SUMMARY.md")}`); console.log(`Network hits: ${networkLog.length}`); } finally { await browser.close(); } } main().catch((err) => { console.error(err.message || err); process.exit(1); });