diff --git a/src/capture.ts b/src/capture.ts index dcc669e..6a2ccc8 100644 --- a/src/capture.ts +++ b/src/capture.ts @@ -26,6 +26,9 @@ function publicErrorFor(error: unknown): string { if (/captcha|verify|robot/i.test(message)) { return "ChatGPT asked for verification, so this public share could not be captured automatically."; } + if (/unavailable|no longer public|not public|redirected away/i.test(message)) { + return "This ChatGPT share is not available publicly anymore, so it cannot be captured."; + } if (/timeout/i.test(message)) { return "The public ChatGPT share took too long to load."; } @@ -271,9 +274,11 @@ async function extractConversation(sourceUrl: string): Promise undefined); page.setDefaultTimeout(config.captureTimeoutMs); + const expectedPath = new URL(sourceUrl).pathname; await page .waitForFunction( - () => { + (sharePath) => { + if (!location.pathname.startsWith(sharePath)) return true; const bodyText = document.body?.innerText || document.body?.textContent || ""; if (/verify you are human|captcha|unusual activity/i.test(bodyText)) return true; if (document.querySelectorAll("[data-message-author-role]").length > 0) return true; @@ -281,12 +286,18 @@ async function extractConversation(sourceUrl: string): Promise 0) return true; return /(?:^|\s)(You said:|ChatGPT said:)/.test(bodyText); }, + expectedPath, { timeout: config.captureTimeoutMs } ) .catch(() => undefined); await page.waitForLoadState("networkidle", { timeout: 5_000 }).catch(() => undefined); await page.waitForTimeout(1000); + const actualUrl = page.url(); + if (!new URL(actualUrl).pathname.startsWith(expectedPath)) { + throw new Error(`ChatGPT share is unavailable or no longer public: page redirected away to ${actualUrl}.`); + } + const extracted = await withTimeout(page.evaluate(() => { function cleanHtml(root: Element): string { const clone = root.cloneNode(true) as Element; diff --git a/src/db.ts b/src/db.ts index d4185b3..ad44398 100644 --- a/src/db.ts +++ b/src/db.ts @@ -90,6 +90,17 @@ export class Store { .run(status, detail?.errorPublic ?? null, detail?.errorDetail ?? null, new Date().toISOString(), id); } + retryCapture(id: string): void { + this.db + .prepare( + `UPDATE captures + SET status = 'queued', title = NULL, error_public = NULL, error_detail = NULL, + data_json = NULL, metadata_json = NULL, captured_at = NULL, updated_at = ? + WHERE id = ?` + ) + .run(new Date().toISOString(), id); + } + completeCapture(id: string, input: { slug: string; title: string; dataJson: string; metadataJson: string }): void { const now = new Date().toISOString(); this.db diff --git a/src/server.ts b/src/server.ts index b6ceba5..4919185 100644 --- a/src/server.ts +++ b/src/server.ts @@ -130,7 +130,7 @@ app.post(route("/api/captures/:id/retry"), requireAdmin, (req, res) => { res.status(409).json({ error: "Successful captures are immutable. Create a new capture instead." }); return; } - store.updateStatus(row.id, "queued"); + store.retryCapture(row.id); captureQueue.enqueue(row.id); res.json({ capture: publicCapture(store.getCapture(row.id)) }); });