Document XDG/OpenBao credential custody (no secrets in chat) and add Playwright inventory export using storage-state for automated read access.
248 lines
7.6 KiB
JavaScript
Executable file
248 lines
7.6 KiB
JavaScript
Executable file
#!/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);
|
|
});
|