├── src ├── utils │ ├── index.ts │ └── response.ts ├── routes │ ├── user.ts │ ├── search.ts │ ├── table.ts │ └── notion-page.ts ├── index.ts └── notion-api │ ├── utils.ts │ ├── notion.ts │ └── types.ts ├── package.json ├── tsconfig.json ├── .gitignore ├── bun.lock └── README.md /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { HandlerRequest } from "../notion-api/types.js"; 2 | 3 | export const getNotionToken = (c: HandlerRequest) => { 4 | return ( 5 | process.env.NOTION_TOKEN || 6 | (c.req.header("Authorization") || "").split("Bearer ")[1] || 7 | undefined 8 | ); 9 | }; 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "notion-api-worker", 3 | "version": "0.1.0", 4 | "private": true, 5 | "type": "module", 6 | "dependencies": { 7 | "hono": "^4.10.0" 8 | }, 9 | "devDependencies": { 10 | "@types/node": "^24.8.1", 11 | "prettier": "^3.3.3", 12 | "typescript": "^5.9.3" 13 | }, 14 | "packageManager": "bun" 15 | } 16 | -------------------------------------------------------------------------------- /src/routes/user.ts: -------------------------------------------------------------------------------- 1 | import { fetchNotionUsers } from "../notion-api/notion.js"; 2 | import { HandlerRequest } from "../notion-api/types.js"; 3 | import { getNotionToken } from "../utils/index.js"; 4 | import { createResponse } from "../utils/response.js"; 5 | 6 | export async function userRoute(c: HandlerRequest) { 7 | const users = await fetchNotionUsers( 8 | [c.req.param("userId")], 9 | getNotionToken(c) 10 | ); 11 | 12 | return createResponse(users[0], { request: c }); 13 | } 14 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from "hono"; 2 | 3 | import { pageRoute } from "./routes/notion-page.js"; 4 | import { tableRoute } from "./routes/table.js"; 5 | import { userRoute } from "./routes/user.js"; 6 | import { searchRoute } from "./routes/search.js"; 7 | 8 | const app = new Hono().basePath("/v1"); 9 | 10 | app.get("/page/:pageId", pageRoute); 11 | app.get("/table/:pageId", tableRoute); 12 | app.get("/user/:userId", userRoute); 13 | app.get("/search", searchRoute); 14 | 15 | export default app; 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": ["ESNext", "DOM"], 5 | "module": "ESNext", 6 | "moduleResolution": "node", 7 | "resolveJsonModule": true, 8 | "strict": true, 9 | "skipLibCheck": true, 10 | "types": ["node"], 11 | "jsx": "react-jsx", 12 | "jsxImportSource": "hono/jsx", 13 | "esModuleInterop": true, 14 | "allowSyntheticDefaultImports": true 15 | }, 16 | "exclude": ["node_modules"], 17 | "ts-node": { 18 | "esm": true 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | -------------------------------------------------------------------------------- /src/routes/search.ts: -------------------------------------------------------------------------------- 1 | import { fetchNotionSearch } from "../notion-api/notion.js"; 2 | import { HandlerRequest } from "../notion-api/types.js"; 3 | import { parsePageId } from "../notion-api/utils.js"; 4 | import { getNotionToken } from "../utils/index.js"; 5 | import { createResponse } from "../utils/response.js"; 6 | 7 | export async function searchRoute(c: HandlerRequest) { 8 | const notionToken = getNotionToken(c); 9 | 10 | const ancestorId = parsePageId(c.req.query("ancestorId") || ""); 11 | const query = c.req.query("query") || ""; 12 | const limit = Number(c.req.query("limit") || 20); 13 | 14 | if (!ancestorId) { 15 | return createResponse( 16 | { error: 'missing required "ancestorId"' }, 17 | { 18 | headers: { "Content-Type": "application/json" }, 19 | statusCode: 400, 20 | request: c, 21 | } 22 | ); 23 | } 24 | 25 | const results = await fetchNotionSearch( 26 | { 27 | ancestorId, 28 | query, 29 | limit, 30 | }, 31 | notionToken 32 | ); 33 | 34 | return createResponse(results, { 35 | request: c, 36 | }); 37 | } 38 | -------------------------------------------------------------------------------- /bun.lock: -------------------------------------------------------------------------------- 1 | { 2 | "lockfileVersion": 1, 3 | "workspaces": { 4 | "": { 5 | "name": "notion-api-worker", 6 | "dependencies": { 7 | "hono": "^4.10.0", 8 | }, 9 | "devDependencies": { 10 | "@types/node": "^24.8.1", 11 | "prettier": "^3.3.3", 12 | "typescript": "^5.9.3", 13 | }, 14 | }, 15 | }, 16 | "packages": { 17 | "@types/node": ["@types/node@24.8.1", "", { "dependencies": { "undici-types": "7.14.0" } }, "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q=="], 18 | 19 | "hono": ["hono@4.10.0", "", {}, "sha512-V/S2IyKL6fk5+bEjiQzg74r5BglqAwU20IX3WjdTUFgvmtSqAZjSxN/Zb5lr6/JXVmH0aqkqOq++3UgzOi9+4Q=="], 20 | 21 | "prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="], 22 | 23 | "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], 24 | 25 | "undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/utils/response.ts: -------------------------------------------------------------------------------- 1 | import { HandlerRequest, JSONData } from "../notion-api/types.js"; 2 | 3 | export const createResponse = ( 4 | body: JSONData | any, 5 | { 6 | headers, 7 | statusCode, 8 | request, 9 | }: { 10 | request: HandlerRequest; 11 | headers?: { [key: string]: string }; 12 | statusCode?: number; 13 | } 14 | ) => { 15 | // Check if client wants to bypass cache 16 | const pragma = request.req.header("pragma"); 17 | const cacheControl = request.req.header("cache-control"); 18 | 19 | let shouldBypassCache = false; 20 | 21 | if (pragma === "no-cache") { 22 | shouldBypassCache = true; 23 | } 24 | 25 | if (cacheControl) { 26 | const directives = new Set(cacheControl.split(",").map((s) => s.trim())); 27 | if (directives.has("no-store") || directives.has("no-cache")) { 28 | shouldBypassCache = true; 29 | } 30 | } 31 | 32 | return new Response(JSON.stringify(body), { 33 | status: statusCode || 200, 34 | headers: { 35 | "Access-Control-Allow-Origin": "*", 36 | "Access-Control-Allow-Methods": "GET, OPTIONS", 37 | "Content-Type": "application/json", 38 | "Cache-Control": shouldBypassCache 39 | ? "no-cache, no-store, must-revalidate" 40 | : "public, max-age=3600", 41 | ...headers, 42 | }, 43 | }); 44 | }; 45 | -------------------------------------------------------------------------------- /src/notion-api/utils.ts: -------------------------------------------------------------------------------- 1 | import { DecorationType, ColumnType, RowContentType, RowType } from "./types.js"; 2 | 3 | export const idToUuid = (path: string): string => 4 | `${path.slice(0, 8)}-${path.slice(8, 12)}-${path.slice(12, 16)}-${path.slice(16, 20)}-${path.slice(20)}`; 5 | 6 | export const parsePageId = (id: string) => { 7 | if (id) { 8 | const rawId = id.replace(/\-/g, "").slice(-32); 9 | return idToUuid(rawId); 10 | } 11 | }; 12 | 13 | export const getNotionValue = ( 14 | val: DecorationType[], 15 | type: ColumnType, 16 | row: RowType 17 | ): RowContentType => { 18 | switch (type) { 19 | case "text": 20 | return getTextContent(val); 21 | case "person": 22 | return ( 23 | val.filter((v) => v.length > 1).map((v) => v[1]![0][1] as string) || [] 24 | ); 25 | case "checkbox": 26 | return val[0][0] === "Yes"; 27 | case "date": 28 | try { 29 | if (val[0][1]![0][0] === "d") return val[0]![1]![0]![1]!.start_date; 30 | else return ""; 31 | } catch (e) { 32 | return ""; 33 | } 34 | case "title": 35 | return getTextContent(val); 36 | case "select": 37 | case "email": 38 | case "phone_number": 39 | case "url": 40 | return val[0][0]; 41 | case "multi_select": 42 | return val[0][0].split(",") as string[]; 43 | case "number": 44 | return Number(val[0][0]); 45 | case "relation": 46 | return val 47 | .filter(([symbol]) => symbol === "‣") 48 | .map(([_, relation]) => relation![0][1] as string); 49 | case "file": 50 | return val 51 | .filter((v) => v.length > 1) 52 | .map((v) => { 53 | const rawUrl = v[1]![0][1] as string; 54 | 55 | const url = new URL( 56 | `https://www.notion.so${ 57 | rawUrl.startsWith("/image") 58 | ? rawUrl 59 | : `/image/${encodeURIComponent(rawUrl)}` 60 | }` 61 | ); 62 | 63 | url.searchParams.set("table", "block"); 64 | url.searchParams.set("id", row.value.id); 65 | url.searchParams.set("cache", "v2"); 66 | 67 | return { name: v[0] as string, url: url.toString(), rawUrl }; 68 | }); 69 | default: 70 | console.log({ val, type }); 71 | return "Not supported"; 72 | } 73 | }; 74 | 75 | const getTextContent = (text: DecorationType[]) => { 76 | return text.reduce((prev, current) => prev + current[0], ""); 77 | }; 78 | -------------------------------------------------------------------------------- /src/routes/table.ts: -------------------------------------------------------------------------------- 1 | import { 2 | fetchPageById, 3 | fetchTableData, 4 | fetchNotionUsers, 5 | } from "../notion-api/notion.js"; 6 | import { parsePageId, getNotionValue } from "../notion-api/utils.js"; 7 | import { 8 | RowContentType, 9 | CollectionType, 10 | RowType, 11 | HandlerRequest, 12 | } from "../notion-api/types.js"; 13 | import { createResponse } from "../utils/response.js"; 14 | import { getNotionToken } from "../utils/index.js"; 15 | 16 | export const getTableData = async ( 17 | collection: CollectionType, 18 | collectionViewId: string, 19 | notionToken?: string, 20 | raw?: boolean 21 | ) => { 22 | const table = await fetchTableData( 23 | collection.value.id, 24 | collectionViewId, 25 | notionToken 26 | ); 27 | 28 | const collectionRows = collection.value.schema; 29 | const collectionColKeys = Object.keys(collectionRows); 30 | 31 | const tableArr: RowType[] = 32 | table.result.reducerResults.collection_group_results.blockIds.map( 33 | (id: string) => table.recordMap.block[id] 34 | ); 35 | 36 | const tableData = tableArr.filter( 37 | (b) => 38 | b.value && b.value.properties && b.value.parent_id === collection.value.id 39 | ); 40 | 41 | type Row = { id: string; [key: string]: RowContentType }; 42 | 43 | const rows: Row[] = []; 44 | 45 | for (const td of tableData) { 46 | let row: Row = { id: td.value.id }; 47 | 48 | for (const key of collectionColKeys) { 49 | const val = td.value.properties[key]; 50 | if (val) { 51 | const schema = collectionRows[key]; 52 | row[schema.name] = raw ? val : getNotionValue(val, schema.type, td); 53 | if (schema.type === "person" && row[schema.name]) { 54 | const users = await fetchNotionUsers(row[schema.name] as string[]); 55 | row[schema.name] = users as any; 56 | } 57 | } 58 | } 59 | rows.push(row); 60 | } 61 | 62 | return { rows, schema: collectionRows }; 63 | }; 64 | 65 | export async function tableRoute(c: HandlerRequest) { 66 | const pageId = parsePageId(c.req.param("pageId")); 67 | const notionToken = getNotionToken(c); 68 | const page = await fetchPageById(pageId!, notionToken); 69 | 70 | if (!page.recordMap.collection) 71 | return createResponse( 72 | JSON.stringify({ error: "No table found on Notion page: " + pageId }), 73 | { headers: {}, statusCode: 401, request: c } 74 | ); 75 | 76 | const collection = Object.keys(page.recordMap.collection).map( 77 | (k) => page.recordMap.collection[k] 78 | )[0]; 79 | 80 | const collectionView: { 81 | value: { id: CollectionType["value"]["id"] }; 82 | } = Object.keys(page.recordMap.collection_view).map( 83 | (k) => page.recordMap.collection_view[k] 84 | )[0]; 85 | 86 | const { rows } = await getTableData( 87 | collection, 88 | collectionView.value.id, 89 | notionToken 90 | ); 91 | 92 | return createResponse(rows, { request: c }); 93 | } 94 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Notion API Worker](https://user-images.githubusercontent.com/1440854/79893752-cc448680-8404-11ea-8d19-e0308eb32028.png) 2 | ![API Version](https://badgen.net/badge/API%20Version/v1/green) 3 | 4 | A **serverless wrapper** for the private Notion API. It provides fast and easy access to your Notion content. 5 | Ideal to make Notion your CMS. 6 | 7 | We provide a hosted version of this project on [`https://notion-api.splitbee.io`](https://notion-api.splitbee.io/). You can also [host it yourself on Vercel](https://vercel.com/?ref=notion-api-worker). Vercel offers a generous free plan. 8 | 9 | _Use with caution. This is based on the private Notion API. We can not gurantee it will stay stable._ 10 | 11 | ## Features 12 | 13 | 🍭 **Easy to use** – Receive Notion data with a single GET request 14 | 15 | 🗄 **Table Access** – Get structured data from tables & databases 16 | 17 | ✨ **Blazing Fast** – Built-in [SWR](https://www.google.com/search?q=stale+while+revalidate) caching for instant results 18 | 19 | 🛫 **CORS Friendly** – Access your data where you need it 20 | 21 | ## Use Cases 22 | 23 | - Use it as data-source for blogs and documentation. Create a table with pages and additional metadata. Query the `/table` endpoints everytime you want to render a list of all pages. 24 | 25 | - Get data of specific pages, which can be rendered with [`react-notion`](https://github.com/splitbee/react-notion) 26 | 27 | ## Endpoints 28 | 29 | ### Load page data 30 | 31 | `/v1/page/` 32 | 33 | Example ([Source Notion Page](https://www.notion.so/react-notion-example-2e22de6b770e4166be301490f6ffd420)) 34 | 35 | [`https://notion-api.splitbee.io/v1/page/2e22de6b770e4166be301490f6ffd420`](https://notion-api.splitbee.io/v1/page/2e22de6b770e4166be301490f6ffd420) 36 | 37 | Returns all block data for a given page. 38 | For example, you can render this data with [`react-notion`](https://github.com/splitbee/react-notion). 39 | 40 | ### Load data from table 41 | 42 | `/v1/table/` 43 | 44 | Example ([Source Notion Page](https://www.notion.so/splitbee/20720198ca7a4e1b92af0a007d3b45a4?v=4206debfc84541d7b4503ebc838fdf1e)) 45 | 46 | [`https://notion-api.splitbee.io/v1/table/20720198ca7a4e1b92af0a007d3b45a4`](https://notion-api.splitbee.io/v1/table/20720198ca7a4e1b92af0a007d3b45a4) 47 | 48 | ## Authentication for private pages 49 | 50 | All public pages can be accessed without authorization. If you want to fetch private pages there are two options. 51 | 52 | - The recommended way is to host your own instance with the `NOTION_TOKEN` environment variable set. You can find more information in the [Vercel environment variables documentation](https://vercel.com/docs/environment-variables). 53 | - Alternatively you can set the `Authorization: Bearer ` header to authorize your requests. 54 | 55 | ### Receiving the token 56 | 57 | To obtain your token, login to Notion and open your DevTools and find your cookies. There should be a cookie called `token_v2`, which is used for the authorization. 58 | 59 | ## Credits 60 | 61 | - [Timo Lins](https://twitter.com/timolins) – Idea, Documentation 62 | - [Tobias Lins](https://twitter.com/linstobias) – Code 63 | - [Travis Fischer](https://twitter.com/transitive_bs) – Code 64 | -------------------------------------------------------------------------------- /src/routes/notion-page.ts: -------------------------------------------------------------------------------- 1 | import { fetchPageById, fetchBlocks } from "../notion-api/notion.js"; 2 | import { parsePageId } from "../notion-api/utils.js"; 3 | import { BlockType, CollectionType, HandlerRequest } from "../notion-api/types.js"; 4 | import { getTableData } from "./table.js"; 5 | import { createResponse } from "../utils/response.js"; 6 | import { getNotionToken } from "../utils/index.js"; 7 | 8 | export async function pageRoute(c: HandlerRequest) { 9 | const pageId = parsePageId(c.req.param("pageId")); 10 | const notionToken = getNotionToken(c); 11 | 12 | const page = await fetchPageById(pageId!, notionToken); 13 | 14 | const baseBlocks = page.recordMap.block; 15 | 16 | let allBlocks: { [id: string]: BlockType & { collection?: any } } = { 17 | ...baseBlocks, 18 | }; 19 | let allBlockKeys; 20 | 21 | while (true) { 22 | allBlockKeys = Object.keys(allBlocks); 23 | 24 | const pendingBlocks = allBlockKeys.flatMap((blockId) => { 25 | const block = allBlocks[blockId]; 26 | const content = block.value && block.value.content; 27 | 28 | if (!content || (block.value.type === "page" && blockId !== pageId!)) { 29 | // skips pages other than the requested page 30 | return []; 31 | } 32 | 33 | return content.filter((id: string) => !allBlocks[id]); 34 | }); 35 | 36 | if (!pendingBlocks.length) { 37 | break; 38 | } 39 | 40 | const newBlocks = await fetchBlocks(pendingBlocks, notionToken).then( 41 | (res) => res.recordMap.block 42 | ); 43 | 44 | allBlocks = { ...allBlocks, ...newBlocks }; 45 | } 46 | 47 | const collection = page.recordMap.collection 48 | ? page.recordMap.collection[Object.keys(page.recordMap.collection)[0]] 49 | : null; 50 | 51 | const collectionView = page.recordMap.collection_view 52 | ? page.recordMap.collection_view[ 53 | Object.keys(page.recordMap.collection_view)[0] 54 | ] 55 | : null; 56 | 57 | if (collection && collectionView) { 58 | const pendingCollections = allBlockKeys.flatMap((blockId) => { 59 | const block = allBlocks[blockId]; 60 | 61 | return block.value && block.value.type === "collection_view" 62 | ? [block.value.id] 63 | : []; 64 | }); 65 | 66 | for (let b of pendingCollections) { 67 | const collPage = await fetchPageById(b!, notionToken); 68 | 69 | const coll = Object.keys(collPage.recordMap.collection).map( 70 | (k) => collPage.recordMap.collection[k] 71 | )[0]; 72 | 73 | const collView: { 74 | value: { id: CollectionType["value"]["id"] }; 75 | } = Object.keys(collPage.recordMap.collection_view).map( 76 | (k) => collPage.recordMap.collection_view[k] 77 | )[0]; 78 | 79 | const { rows, schema } = await getTableData( 80 | coll, 81 | collView.value.id, 82 | notionToken, 83 | true 84 | ); 85 | 86 | const viewIds = (allBlocks[b] as any).value.view_ids as string[]; 87 | 88 | allBlocks[b] = { 89 | ...allBlocks[b], 90 | collection: { 91 | title: coll.value.name, 92 | schema, 93 | types: viewIds.map((id) => { 94 | const col = collPage.recordMap.collection_view[id]; 95 | return col ? col.value : undefined; 96 | }), 97 | data: rows, 98 | }, 99 | }; 100 | } 101 | } 102 | 103 | return createResponse(allBlocks, { 104 | request: c, 105 | }); 106 | } 107 | -------------------------------------------------------------------------------- /src/notion-api/notion.ts: -------------------------------------------------------------------------------- 1 | import { 2 | JSONData, 3 | NotionUserType, 4 | LoadPageChunkData, 5 | CollectionData, 6 | NotionSearchParamsType, 7 | NotionSearchResultsType, 8 | } from "./types.js"; 9 | 10 | const NOTION_API = "https://www.notion.so/api/v3"; 11 | 12 | interface INotionParams { 13 | resource: string; 14 | body: JSONData; 15 | notionToken?: string; 16 | headers?: Record; 17 | } 18 | 19 | const loadPageChunkBody = { 20 | limit: 100, 21 | cursor: { stack: [] }, 22 | chunkNumber: 0, 23 | verticalColumns: false, 24 | }; 25 | 26 | const fetchNotionData = async ({ 27 | resource, 28 | body, 29 | notionToken, 30 | headers, 31 | }: INotionParams): Promise => { 32 | const res = await fetch(`${NOTION_API}/${resource}`, { 33 | method: "POST", 34 | headers: { 35 | "content-type": "application/json", 36 | ...(notionToken && { cookie: `token_v2=${notionToken}` }), 37 | ...headers, 38 | }, 39 | body: JSON.stringify(body), 40 | }); 41 | 42 | return res.json() as Promise; 43 | }; 44 | 45 | export const fetchPageById = async (pageId: string, notionToken?: string) => { 46 | const res = await fetchNotionData({ 47 | resource: "loadPageChunk", 48 | body: { 49 | pageId, 50 | ...loadPageChunkBody, 51 | }, 52 | notionToken, 53 | }); 54 | 55 | return res; 56 | }; 57 | 58 | const queryCollectionBody = { 59 | loader: { 60 | type: "reducer", 61 | reducers: { 62 | collection_group_results: { 63 | type: "results", 64 | limit: 999, 65 | loadContentCover: true, 66 | }, 67 | "table:uncategorized:title:count": { 68 | type: "aggregation", 69 | aggregation: { 70 | property: "title", 71 | aggregator: "count", 72 | }, 73 | }, 74 | }, 75 | searchQuery: "", 76 | userTimeZone: "Europe/Vienna", 77 | }, 78 | }; 79 | 80 | export const fetchTableData = async ( 81 | collectionId: string, 82 | collectionViewId: string, 83 | notionToken?: string, 84 | spaceId?: string 85 | ) => { 86 | const headers: Record = {}; 87 | if (spaceId) { 88 | headers["x-notion-space-id"] = spaceId; 89 | } 90 | 91 | const table = await fetchNotionData({ 92 | resource: "queryCollection", 93 | body: { 94 | collection: { 95 | id: collectionId, 96 | }, 97 | collectionView: { 98 | id: collectionViewId, 99 | }, 100 | ...queryCollectionBody, 101 | }, 102 | notionToken, 103 | headers, 104 | }); 105 | 106 | return table; 107 | }; 108 | 109 | export const fetchNotionUsers = async ( 110 | userIds: string[], 111 | notionToken?: string 112 | ) => { 113 | const users = await fetchNotionData<{ results: NotionUserType[] }>({ 114 | resource: "getRecordValues", 115 | body: { 116 | requests: userIds.map((id) => ({ id, table: "notion_user" })), 117 | }, 118 | notionToken, 119 | }); 120 | if (users && users.results) { 121 | return users.results.map((u) => { 122 | const user = { 123 | id: u.value.id, 124 | firstName: u.value.given_name, 125 | lastLame: u.value.family_name, 126 | fullName: u.value.given_name + " " + u.value.family_name, 127 | profilePhoto: u.value.profile_photo, 128 | }; 129 | return user; 130 | }); 131 | } 132 | return []; 133 | }; 134 | 135 | export const fetchBlocks = async ( 136 | blockList: string[], 137 | notionToken?: string 138 | ) => { 139 | return await fetchNotionData({ 140 | resource: "syncRecordValues", 141 | body: { 142 | requests: blockList.map((id) => ({ 143 | id, 144 | table: "block", 145 | version: -1, 146 | })), 147 | }, 148 | notionToken, 149 | }); 150 | }; 151 | 152 | export const fetchNotionSearch = async ( 153 | params: NotionSearchParamsType, 154 | notionToken?: string 155 | ) => { 156 | // TODO: support other types of searches 157 | return fetchNotionData<{ results: NotionSearchResultsType }>({ 158 | resource: "search", 159 | body: { 160 | type: "BlocksInAncestor", 161 | source: "quick_find_public", 162 | ancestorId: params.ancestorId, 163 | filters: { 164 | isDeletedOnly: false, 165 | excludeTemplates: true, 166 | isNavigableOnly: true, 167 | requireEditPermissions: false, 168 | ancestors: [], 169 | createdBy: [], 170 | editedBy: [], 171 | lastEditedTime: {}, 172 | createdTime: {}, 173 | ...params.filters, 174 | }, 175 | sort: "Relevance", 176 | limit: params.limit || 20, 177 | query: params.query, 178 | }, 179 | notionToken, 180 | }); 181 | }; 182 | -------------------------------------------------------------------------------- /src/notion-api/types.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "hono"; 2 | 3 | type BoldFormatType = ["b"]; 4 | type ItalicFormatType = ["i"]; 5 | type StrikeFormatType = ["s"]; 6 | type CodeFormatType = ["c"]; 7 | type LinkFormatType = ["a", string]; 8 | type DateFormatType = [ 9 | "d", 10 | { 11 | type: "date"; 12 | start_date: string; 13 | date_format: string; 14 | }, 15 | ]; 16 | type UserFormatType = ["u", string]; 17 | type PageFormatType = ["p", string]; 18 | type SubDecorationType = 19 | | BoldFormatType 20 | | ItalicFormatType 21 | | StrikeFormatType 22 | | CodeFormatType 23 | | LinkFormatType 24 | | DateFormatType 25 | | UserFormatType 26 | | PageFormatType; 27 | type BaseDecorationType = [string]; 28 | type AdditionalDecorationType = [string, SubDecorationType[]]; 29 | export type DecorationType = BaseDecorationType | AdditionalDecorationType; 30 | 31 | export type ColumnType = 32 | | "select" 33 | | "text" 34 | | "date" 35 | | "person" 36 | | "checkbox" 37 | | "title" 38 | | "multi_select" 39 | | "number" 40 | | "relation" 41 | | "file" 42 | | "email" 43 | | "phone_number" 44 | | "url"; 45 | 46 | export type ColumnSchemaType = { 47 | name: string; 48 | type: ColumnType; 49 | }; 50 | 51 | type UserType = { id: string; full_name: string }; 52 | 53 | export type RowContentType = 54 | | string 55 | | boolean 56 | | number 57 | | string[] 58 | | { title: string; id: string } 59 | | UserType[] 60 | | DecorationType[] 61 | | { name: string; url: string }[]; 62 | 63 | export interface BaseValueType { 64 | id: string; 65 | type: string; 66 | version: number; 67 | created_time: number; 68 | last_edited_time: number; 69 | parent_id: string; 70 | parent_table: string; 71 | alive: boolean; 72 | created_by_table: string; 73 | created_by_id: string; 74 | last_edited_by_table: string; 75 | last_edited_by_id: string; 76 | content?: string[]; 77 | } 78 | 79 | export interface CollectionType { 80 | value: { 81 | id: string; 82 | version: number; 83 | name: string[][]; 84 | schema: { [key: string]: ColumnSchemaType }; 85 | icon: string; 86 | parent_id: string; 87 | parent_table: string; 88 | alive: boolean; 89 | copied_from: string; 90 | }; 91 | } 92 | 93 | export interface RowType { 94 | value: { 95 | id: string; 96 | parent_id: string; 97 | properties: { [key: string]: DecorationType[] }; 98 | }; 99 | } 100 | 101 | export type JSONData = 102 | | null 103 | | boolean 104 | | number 105 | | string 106 | | JSONData[] 107 | | { [prop: string]: JSONData }; 108 | 109 | export type BlockMapType = { 110 | [key: string]: BlockType; 111 | }; 112 | 113 | export interface NotionUserType { 114 | role: string; 115 | value: { 116 | id: string; 117 | version: number; 118 | email: string; 119 | given_name: string; 120 | family_name: string; 121 | profile_photo: string; 122 | onboarding_completed: boolean; 123 | mobile_onboarding_completed: boolean; 124 | }; 125 | } 126 | export interface BlockType { 127 | role: string; 128 | value: BaseValueType; 129 | } 130 | 131 | export interface RecordMapType { 132 | block: BlockMapType; 133 | notion_user: { 134 | [key: string]: NotionUserType; 135 | }; 136 | collection: { 137 | [key: string]: CollectionType; 138 | }; 139 | collection_view: { 140 | [key: string]: { 141 | value: { 142 | id: string; 143 | type: CollectionViewType; 144 | }; 145 | }; 146 | }; 147 | } 148 | 149 | export interface LoadPageChunkData { 150 | recordMap: RecordMapType; 151 | cursor: { 152 | stack: any[]; 153 | }; 154 | } 155 | 156 | type CollectionViewType = "table" | "gallery"; 157 | 158 | export interface CollectionData { 159 | recordMap: { 160 | block: { [key: string]: RowType }; 161 | collection_view: { 162 | [key: string]: { 163 | value: { type: CollectionViewType }; 164 | }; 165 | }; 166 | }; 167 | result: { 168 | reducerResults: { 169 | collection_group_results: { blockIds: string[] }; 170 | }; 171 | }; 172 | } 173 | 174 | export interface NotionSearchParamsType { 175 | ancestorId: string; 176 | query: string; 177 | filters?: { 178 | isDeletedOnly: boolean; 179 | excludeTemplates: boolean; 180 | isNavigableOnly: boolean; 181 | requireEditPermissions: boolean; 182 | }; 183 | limit?: number; 184 | } 185 | 186 | export interface NotionSearchResultType { 187 | id: string; 188 | isNavigable: boolean; 189 | score: number; 190 | highlight: { 191 | pathText: string; 192 | text: string; 193 | }; 194 | } 195 | 196 | export interface NotionSearchResultsType { 197 | recordMap: { 198 | block: { [key: string]: RowType }; 199 | }; 200 | results: NotionSearchResultType[]; 201 | total: number; 202 | } 203 | 204 | export type HandlerRequest = Context; 205 | --------------------------------------------------------------------------------