Support new ChatGPT share paths

This commit is contained in:
Codex
2026-07-21 12:53:46 +00:00
parent 2020f8e356
commit 6a08638849
5 changed files with 57 additions and 7 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
V1 scope:
- accepts only public `https://chatgpt.com/share/...` and `https://chat.openai.com/share/...` URLs
- accepts public ChatGPT links under the legacy `/share/...` path and the current `/s/t_...` path
- admin-only capture/delete UI at `${AISHARE_BASE_PATH}/admin`
- public reader pages generated as static artifacts under `/data/html/<slug>`
- screenshots under `/data/screenshots/<slug>`
+1 -1
View File
@@ -164,7 +164,7 @@ function renderShell(): void {
<section class="panel capture-panel">
<form id="capture-form" class="capture-form">
<label class="url-field">ChatGPT share URL<input name="sourceUrl" required inputmode="url" autocomplete="url" placeholder="https://chatgpt.com/share/…" /></label>
<label class="url-field">ChatGPT share URL<input name="sourceUrl" required inputmode="url" autocomplete="url" placeholder="https://chatgpt.com/s/t_…" /></label>
${isAdmin ? '<label class="slug-field">Custom slug<input name="slug" placeholder="Optional" /></label>' : ""}
<label class="retention-field">Keep for<select name="retentionDays" aria-label="Retention period"><option value="3" selected>3 days</option><option value="7">7 days</option><option value="10">10 days</option><option value="30">30 days</option></select></label>
<button class="primary" type="submit">Capture</button>
+14 -3
View File
@@ -13,6 +13,7 @@ import { artifactAssetsDir, artifactIndexPath, safeAssetName, screenshotPath, sc
import { slugify } from "./slug.js";
import { writeConversationArtifact, writeFailureArtifact } from "./render.js";
import { finalizeKatexAssets, prepareKatexAssets } from "./katex-assets.js";
import { isChatGptSharePath } from "./url.js";
import type { CaptureRow, NormalizedConversation, NormalizedMessage, ScreenshotVariant } from "./types.js";
interface ExtractedConversation {
@@ -420,10 +421,17 @@ async function extractConversation(sourceUrl: string): Promise<ExtractedConversa
await waitForCdp(port, chromiumProcess);
browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`, { timeout: config.captureTimeoutMs });
const context = browser.contexts()[0];
let page = context.pages().find((candidate) => candidate.url().includes("/share/")) || context.pages()[0];
const isSharePage = (candidate: { url(): string }): boolean => {
try {
return isChatGptSharePath(new URL(candidate.url()).pathname);
} catch {
return false;
}
};
let page = context.pages().find(isSharePage) || context.pages()[0];
for (let attempt = 0; !page && attempt < 100; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 100));
page = context.pages().find((candidate) => candidate.url().includes("/share/")) || context.pages()[0];
page = context.pages().find(isSharePage) || context.pages()[0];
}
if (!page) throw new Error("Chromium CDP page target was not created.");
await page.setViewportSize({ width: 1360, height: 900 }).catch(() => undefined);
@@ -527,7 +535,10 @@ async function extractConversation(sourceUrl: string): Promise<ExtractedConversa
const title =
document.querySelector("h1")?.textContent?.trim() ||
document.title.replace(/\s*[|-]\s*ChatGPT\s*$/i, "").trim() ||
document.title
.replace(/^ChatGPT\s*[|-]\s*/i, "")
.replace(/\s*[|-]\s*ChatGPT\s*$/i, "")
.trim() ||
"ChatGPT Share";
const roleNodes = Array.from(document.querySelectorAll("[data-message-author-role]"));
+9 -2
View File
@@ -1,3 +1,10 @@
const legacySharePath = /^\/share\/[^/]+\/?$/;
const postSharePath = /^\/s\/t_[A-Za-z0-9_-]+\/?$/;
export function isChatGptSharePath(pathname: string): boolean {
return legacySharePath.test(pathname) || postSharePath.test(pathname);
}
export function validateChatGptShareUrl(raw: string): string {
let url: URL;
try {
@@ -12,8 +19,8 @@ export function validateChatGptShareUrl(raw: string): string {
const host = url.hostname.toLowerCase();
const allowedHost = host === "chatgpt.com" || host === "chat.openai.com";
if (!allowedHost || !url.pathname.startsWith("/share/")) {
throw new Error("Only public ChatGPT share URLs under /share/ are accepted.");
if (!allowedHost || !isChatGptSharePath(url.pathname)) {
throw new Error("Only public ChatGPT share URLs under /share/ or /s/t_ are accepted.");
}
url.hash = "";
+32
View File
@@ -0,0 +1,32 @@
import assert from "node:assert/strict";
import test from "node:test";
import { isChatGptSharePath, validateChatGptShareUrl } from "../dist/url.js";
test("accepts current ChatGPT post share URLs", () => {
const source = "https://chatgpt.com/s/t_6a5f58aa36d08191b4d794e9aac88146";
assert.equal(validateChatGptShareUrl(source), source);
assert.equal(isChatGptSharePath("/s/t_6a5f58aa36d08191b4d794e9aac88146"), true);
});
test("keeps accepting legacy ChatGPT share URLs", () => {
assert.equal(
validateChatGptShareUrl("https://chatgpt.com/share/12345678-abcd-4321-abcd-1234567890ab"),
"https://chatgpt.com/share/12345678-abcd-4321-abcd-1234567890ab"
);
assert.equal(
validateChatGptShareUrl("https://chat.openai.com/share/12345678-abcd-4321-abcd-1234567890ab"),
"https://chat.openai.com/share/12345678-abcd-4321-abcd-1234567890ab"
);
});
test("rejects non-share and deceptive ChatGPT paths", () => {
for (const source of [
"https://chatgpt.com/s/example",
"https://chatgpt.com/sharex/example",
"https://chatgpt.com/share/example/extra",
"https://example.com/s/t_6a5f58aa36d08191b4d794e9aac88146"
]) {
assert.throws(() => validateChatGptShareUrl(source), /Only public ChatGPT share URLs/);
}
});