Add safe Bubble session export path for CSOC-WP-0001-T02
Document XDG/OpenBao credential custody (no secrets in chat) and add Playwright inventory export using storage-state for automated read access.
This commit is contained in:
parent
638166cff4
commit
a160ad400b
5 changed files with 390 additions and 1 deletions
248
scripts/export-bubble-inventory.mjs
Executable file
248
scripts/export-bubble-inventory.mjs
Executable file
|
|
@ -0,0 +1,248 @@
|
|||
#!/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/<stamp>
|
||||
*/
|
||||
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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue