Support new ChatGPT share paths
This commit is contained in:
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
V1 scope:
|
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`
|
- admin-only capture/delete UI at `${AISHARE_BASE_PATH}/admin`
|
||||||
- public reader pages generated as static artifacts under `/data/html/<slug>`
|
- public reader pages generated as static artifacts under `/data/html/<slug>`
|
||||||
- screenshots under `/data/screenshots/<slug>`
|
- screenshots under `/data/screenshots/<slug>`
|
||||||
|
|||||||
+1
-1
@@ -164,7 +164,7 @@ function renderShell(): void {
|
|||||||
|
|
||||||
<section class="panel capture-panel">
|
<section class="panel capture-panel">
|
||||||
<form id="capture-form" class="capture-form">
|
<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>' : ""}
|
${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>
|
<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>
|
<button class="primary" type="submit">Capture</button>
|
||||||
|
|||||||
+14
-3
@@ -13,6 +13,7 @@ import { artifactAssetsDir, artifactIndexPath, safeAssetName, screenshotPath, sc
|
|||||||
import { slugify } from "./slug.js";
|
import { slugify } from "./slug.js";
|
||||||
import { writeConversationArtifact, writeFailureArtifact } from "./render.js";
|
import { writeConversationArtifact, writeFailureArtifact } from "./render.js";
|
||||||
import { finalizeKatexAssets, prepareKatexAssets } from "./katex-assets.js";
|
import { finalizeKatexAssets, prepareKatexAssets } from "./katex-assets.js";
|
||||||
|
import { isChatGptSharePath } from "./url.js";
|
||||||
import type { CaptureRow, NormalizedConversation, NormalizedMessage, ScreenshotVariant } from "./types.js";
|
import type { CaptureRow, NormalizedConversation, NormalizedMessage, ScreenshotVariant } from "./types.js";
|
||||||
|
|
||||||
interface ExtractedConversation {
|
interface ExtractedConversation {
|
||||||
@@ -420,10 +421,17 @@ async function extractConversation(sourceUrl: string): Promise<ExtractedConversa
|
|||||||
await waitForCdp(port, chromiumProcess);
|
await waitForCdp(port, chromiumProcess);
|
||||||
browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`, { timeout: config.captureTimeoutMs });
|
browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`, { timeout: config.captureTimeoutMs });
|
||||||
const context = browser.contexts()[0];
|
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) {
|
for (let attempt = 0; !page && attempt < 100; attempt += 1) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
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.");
|
if (!page) throw new Error("Chromium CDP page target was not created.");
|
||||||
await page.setViewportSize({ width: 1360, height: 900 }).catch(() => undefined);
|
await page.setViewportSize({ width: 1360, height: 900 }).catch(() => undefined);
|
||||||
@@ -527,7 +535,10 @@ async function extractConversation(sourceUrl: string): Promise<ExtractedConversa
|
|||||||
|
|
||||||
const title =
|
const title =
|
||||||
document.querySelector("h1")?.textContent?.trim() ||
|
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";
|
"ChatGPT Share";
|
||||||
|
|
||||||
const roleNodes = Array.from(document.querySelectorAll("[data-message-author-role]"));
|
const roleNodes = Array.from(document.querySelectorAll("[data-message-author-role]"));
|
||||||
|
|||||||
+9
-2
@@ -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 {
|
export function validateChatGptShareUrl(raw: string): string {
|
||||||
let url: URL;
|
let url: URL;
|
||||||
try {
|
try {
|
||||||
@@ -12,8 +19,8 @@ export function validateChatGptShareUrl(raw: string): string {
|
|||||||
|
|
||||||
const host = url.hostname.toLowerCase();
|
const host = url.hostname.toLowerCase();
|
||||||
const allowedHost = host === "chatgpt.com" || host === "chat.openai.com";
|
const allowedHost = host === "chatgpt.com" || host === "chat.openai.com";
|
||||||
if (!allowedHost || !url.pathname.startsWith("/share/")) {
|
if (!allowedHost || !isChatGptSharePath(url.pathname)) {
|
||||||
throw new Error("Only public ChatGPT share URLs under /share/ are accepted.");
|
throw new Error("Only public ChatGPT share URLs under /share/ or /s/t_ are accepted.");
|
||||||
}
|
}
|
||||||
|
|
||||||
url.hash = "";
|
url.hash = "";
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user