Files
OmniRoute/open-sse/executors/notion-web.ts
Diego Rodrigues de Sa e Souza df1ed57876 feat(sse): add Notion AI Web (Unofficial/Experimental) provider (#6758) (#7600)
Notion AI has no public inference API (see closed request #3272), so this
adds it as a new entry in the established web-cookie provider category
(chatgpt-web, claude-web, grok-web, ...): cookie-based auth via the
token_v2 session cookie posted to Notion's undocumented internal
POST /api/v3/runInferenceTranscript endpoint, translating its NDJSON
transcript-patch stream into OpenAI-compatible chat completions.

- NotionWebExecutor (open-sse/executors/notion-web.ts): resolves the
  token_v2 cookie (+ optional space_id/notion_browser_id), builds a
  Notion transcript from the chat messages, parses the NDJSON response
  (cumulative-snapshot semantics, mirroring gemini-web.ts's handling of
  #7163), and returns a chat.completion or pseudo-streamed SSE response.
  All error paths route through makeExecutorErrorResult (sanitized).
- RegistryEntry under open-sse/config/providers/registry/notion-web/,
  registered in providers/index.ts REGISTRY and executors/index.ts
  (alias "nw").
- WEB_COOKIE_PROVIDERS entry (src/shared/constants/providers/web-cookie.ts)
  with subscriptionRisk + webCookie risk notice, clearly labeled
  "(Unofficial/Experimental)".
- Cookie-probe validator (validateNotionWebProvider) against Notion's
  getSpaces endpoint, and a webSessionCredentials.ts UI entry for the
  "Add session cookie" flow.
- Regenerated docs/reference/PROVIDER_REFERENCE.md and the
  provider/translate-path golden snapshot (purely additive diffs); synced
  the "251 providers" count across README/AGENTS/CLAUDE.md
  (check:docs-counts STRICT gate).

Tests: tests/unit/executor-notion-web.test.ts (22 cases — registry
consistency, mocked-upstream request/response translation, NDJSON
snapshot parsing, cookie resolution, sanitized error paths) plus the
existing executor-web-cookie-sweep, provider-alias-uniqueness,
check-provider-consistency, web-session-credentials, and
provider-translate-path-golden suites all pass with notion-web included.
2026-07-17 11:32:09 -03:00

311 lines
12 KiB
TypeScript

/**
* NotionWebExecutor — Notion AI Web Session Provider (Unofficial/Experimental)
*
* Notion AI has no public, documented inference API (see issue #3272, closed
* by the owner for that reason). This executor instead reverse-engineers the
* same cookie-authenticated internal endpoint two independent open-source
* projects already ship (`notion2api`, `Notion-AI-to-OpenAI-Compatible`, both
* cited in issue #6758): a `token_v2` session cookie posted to
* `POST /api/v3/runInferenceTranscript`, whose response is a newline-delimited
* JSON (NDJSON) stream of transcript-patch records. Each record's `value`
* field carries Notion's standard rich-text tuple shape (`[[text, marks?]]`,
* the same shape used by Notion's public page-property API) holding the
* *current* (cumulative, not delta) assistant text — mirroring the snapshot
* semantics `gemini-web.ts` already handles, so only the last non-empty frame
* is kept rather than concatenating every frame (see #7163 for why
* concatenating cumulative snapshots duplicates text).
*
* Because the endpoint is undocumented and can change without notice
* (acknowledged risk in issue #6758), streaming here is pseudo-streaming —
* the full response is read, parsed, then sent as a single SSE chunk. This is
* the same conservative tradeoff `gemini-web.ts` makes and is safer than
* assuming unverified incremental-delta semantics on a live, undocumented API.
*
* Auth: Cookie-based (token_v2 [+ optional space_id, notion_browser_id])
* Method: Direct fetch — no browser automation required.
*/
import { randomUUID } from "node:crypto";
import { BaseExecutor, type ExecuteInput } from "./base.ts";
import { makeExecutorErrorResult as makeErrorResult } from "../utils/error.ts";
// ─── Constants ──────────────────────────────────────────────────────────────
const BASE_URL = "https://www.notion.so";
const NOTION_URL = `${BASE_URL}/api/v3/runInferenceTranscript`;
const USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
// ─── Types ──────────────────────────────────────────────────────────────────
interface NotionMessage {
role: string;
content: string;
}
interface NotionRequestBody {
messages?: NotionMessage[];
model?: string;
}
// ─── Helpers — credential resolution ───────────────────────────────────────
function readCredentialString(value: unknown): string {
if (typeof value !== "string") return "";
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : "";
}
function readProviderSpecificString(
providerSpecificData: unknown,
keys: readonly string[]
): string {
if (
!providerSpecificData ||
typeof providerSpecificData !== "object" ||
Array.isArray(providerSpecificData)
) {
return "";
}
const data = providerSpecificData as Record<string, unknown>;
for (const key of keys) {
const value = readCredentialString(data[key]);
if (value) return value;
}
return "";
}
/** Normalize a pasted credential to a `name=value` cookie pair. Accepts a bare
* token or an already-prefixed `token_v2=...` value. */
export function normalizeNotionCookieInput(raw: string, cookieName = "token_v2"): string {
const trimmed = raw.trim();
if (!trimmed) return "";
return trimmed.includes("=") ? trimmed : `${cookieName}=${trimmed}`;
}
/**
* Resolve the Cookie header to send upstream. Accepts, in priority order:
* 1. A full cookie header pasted as `apiKey` or `credentials.cookie`.
* 2. `providerSpecificData.cookie` (full header).
* 3. Structured `providerSpecificData.token_v2` (+ optional `space_id`,
* `notion_browser_id`), assembled into a cookie header.
*/
export function resolveNotionWebCookie(credentials: ExecuteInput["credentials"]): string {
const directCookie =
readCredentialString(credentials?.apiKey) ||
readCredentialString((credentials as Record<string, unknown> | undefined)?.cookie);
if (directCookie) return normalizeNotionCookieInput(directCookie);
const providerSpecificData = credentials?.providerSpecificData;
const cookie = readProviderSpecificString(providerSpecificData, ["cookie"]);
if (cookie) return normalizeNotionCookieInput(cookie);
const tokenV2 = readProviderSpecificString(providerSpecificData, ["token_v2", "tokenV2"]);
const spaceId = readProviderSpecificString(providerSpecificData, ["space_id", "spaceId"]);
const browserId = readProviderSpecificString(providerSpecificData, [
"notion_browser_id",
"notionBrowserId",
]);
return [
tokenV2 ? normalizeNotionCookieInput(tokenV2) : "",
spaceId ? `space_id=${spaceId}` : "",
browserId ? `notion_browser_id=${browserId}` : "",
]
.filter(Boolean)
.join("; ");
}
/** Pull `space_id` out of an assembled cookie header, if present. Notion's
* transcript endpoint accepts an explicit `spaceId` field in the body; when
* the operator supplied it via cookie we forward it rather than relying on
* Notion to infer it from the session alone. */
export function extractSpaceIdFromCookie(cookie: string): string {
const match = cookie.match(/(?:^|;\s*)space_id=([^;]+)/i);
return match ? match[1].trim() : "";
}
// ─── Helpers — request/response translation ────────────────────────────────
/**
* Build a Notion `runInferenceTranscript` transcript array from OpenAI-style
* chat messages. Each entry uses Notion's rich-text tuple value shape
* (`[[text]]`) and a role tag Notion's own web client sends.
*/
export function buildNotionTranscript(
messages: NotionMessage[]
): Array<Record<string, unknown>> {
return messages
.filter((m) => typeof m?.content === "string" && m.content.length > 0)
.map((m) => ({
id: randomUUID(),
type: m.role === "assistant" ? "ai" : m.role === "system" ? "context" : "human",
value: [[m.content]],
}));
}
/** Extract plain text from Notion's rich-text tuple value: `[[text, marks?]]`. */
function extractRichText(value: unknown): string {
if (!Array.isArray(value)) return "";
return value
.map((segment) => (Array.isArray(segment) && typeof segment[0] === "string" ? segment[0] : ""))
.join("");
}
/**
* Parse Notion's NDJSON `runInferenceTranscript` response body. Each line is
* an independent JSON record; the assistant text lives under a `value` field
* using the rich-text tuple shape. Frames are cumulative snapshots (mirroring
* `gemini-web.ts`'s `parseStreamResponse`), so only the last non-empty frame
* is kept — never concatenated.
*/
export function parseNotionInferenceStream(raw: string): string {
if (!raw) return "";
const lines = raw.split("\n");
let lastText = "";
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line) continue;
let record: unknown;
try {
record = JSON.parse(line);
} catch {
continue; // Skip unparseable lines (keep-alive pings, partial frames)
}
if (!record || typeof record !== "object" || Array.isArray(record)) continue;
const text = extractRichText((record as Record<string, unknown>).value);
if (text) lastText = text;
}
return lastText;
}
function chatCompletionResponse(content: string, model: string) {
return new Response(
JSON.stringify({
id: `chatcmpl-notion-${Date.now()}`,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model,
choices: [
{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" },
],
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
}
function pseudoStreamResponse(content: string, model: string) {
const encoder = new TextEncoder();
const chunk = (delta: string, finishReason: string | null) => ({
id: `chatcmpl-notion-${Date.now()}`,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model,
choices: [{ index: 0, delta: delta ? { content: delta } : {}, finish_reason: finishReason }],
});
const readable = new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk(content, null))}\n\n`));
controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk("", "stop"))}\n\n`));
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
controller.close();
},
});
return new Response(readable, {
status: 200,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
// ─── Executor ───────────────────────────────────────────────────────────────
export class NotionWebExecutor extends BaseExecutor {
constructor() {
super("notion-web", { id: "notion-web", baseUrl: NOTION_URL });
}
async execute(input: ExecuteInput) {
const { model, body, stream: wantStream, credentials, signal } = input;
const requestBody = (body || {}) as NotionRequestBody;
const cookie = resolveNotionWebCookie(credentials);
if (!cookie) {
return makeErrorResult(
401,
"Missing Notion token_v2 cookie — paste it from notion.so DevTools → Application → Cookies",
body,
NOTION_URL
);
}
const messages = requestBody.messages || [];
if (!messages.some((m) => m.role === "user")) {
return makeErrorResult(400, "No user message found", body, NOTION_URL);
}
const spaceId = extractSpaceIdFromCookie(cookie);
const modelId = model || "notion-ai";
const reqBody: Record<string, unknown> = {
traceId: randomUUID(),
transcript: buildNotionTranscript(messages),
createThread: false,
};
if (spaceId) reqBody.spaceId = spaceId;
const reqHeaders: Record<string, string> = {
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
Accept: "application/x-ndjson",
Cookie: cookie,
Origin: BASE_URL,
Referer: `${BASE_URL}/`,
};
let upstream: Response;
try {
upstream = await fetch(NOTION_URL, {
method: "POST",
headers: reqHeaders,
body: JSON.stringify(reqBody),
signal: signal ?? undefined,
});
} catch (err) {
return makeErrorResult(
502,
`Notion fetch failed: ${err instanceof Error ? err.message : "unknown error"}`,
reqBody,
NOTION_URL
);
}
if (upstream.status === 401 || upstream.status === 403) {
return makeErrorResult(
upstream.status,
"Notion session expired or invalid — re-paste token_v2 from notion.so",
reqBody,
NOTION_URL
);
}
if (!upstream.ok) {
const errText = await upstream.text().catch(() => "");
return makeErrorResult(upstream.status, `Notion error: ${errText}`, reqBody, NOTION_URL);
}
const rawText = await upstream.text();
const finalText = parseNotionInferenceStream(rawText);
if (!finalText) {
return makeErrorResult(502, "No response from Notion AI", reqBody, NOTION_URL);
}
const response = wantStream
? pseudoStreamResponse(finalText, modelId)
: chatCompletionResponse(finalText, modelId);
return { response, url: NOTION_URL, headers: reqHeaders, transformedBody: reqBody };
}
}