Repository: benborgers/potion Branch: master Commit: 68fbb1bbad58 Files: 14 Total size: 18.0 KB Directory structure: gitextract_c3udltrp/ ├── .gitignore ├── LICENSE ├── README.md ├── api/ │ ├── asset.js │ ├── html.js │ ├── table-description.js │ └── table.js ├── helpers/ │ └── escape.js ├── notion/ │ ├── call.js │ ├── getAssetUrl.js │ ├── normalizeId.js │ └── textArrayToHtml.js ├── now.json └── package.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ node_modules .now .vercel ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2022 Benjamin Borgers Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Potion Potion is a reverse-engineered API for [Notion](https://notion.so). Write your content in Notion, and use Potion's hosted API endpoints to read your content. **I no longer maintain Potion, since the [official Notion API](https://developers.notion.com) has been released and is more stable than Notion’s internal API, which Potion relies on.** ## Guides I've written a couple of blog posts on my website for using this API. * [How to use Notion as your blog's CMS](https://benborgers.com/blog/notion-blog) * [API to read a Notion table](https://benborgers.com/blog/notion-table) * [How to turn a Notion doc into a website](https://benborgers.com/blog/notion-to-website) ## Endpoints All endpoints are relative to the base URL: `https://potion-api.now.sh` Responses are cached for 10 seconds, so it'll take up to 10 seconds for changes made in Notion to show up. *`` refers to the 32 character alphanumeric string in the URL of a Notion doc (but not a query parameter, so not the string after `?v=`).* ### /table Lists all entries in a full-page Notion table, along with additional details about each page. The only query parameter is `?id=`. ### /table-description Generates HTML for the description of a table. The only query parameter is `?id=`. ### /html Generates HTML for a given Notion page. You can insert it as the contents of a blog post, for example. The only query parameter is `?id=`, which can be obtained from the `/table` endpoint or just by copy-and-pasting from the URL. ## Syntax Highlighting Potion gives you syntax highlighting of Notion code blocks for free, when using the `/html` endpoint! ### How to use syntax highlighting You'll notice that the code block HTML that Potion returns is given CSS classes that make it compatible with [Prism.js](https://prismjs.com/). 1. Pick a theme you like from [this README](https://github.com/PrismJS/prism-themes/blob/master/README.md). 2. Select the CSS file for that theme [from this list](https://unpkg.com/browse/prism-themes@latest/themes/) and click **View Raw**. 3. Include that stylesheet in the `head` of your HTML page to activate syntax highlighting. For example: ```html ``` ### Language support Potion supports syntax highlighting for most popular languages, and you can open an issue if you'd like to see a language supported that isn't currently. ## Limitations Most, but not all, of the common Notion blocks are supported at the moment: - [x] Text - [x] To-do List - [x] Heading 1 - [x] Heading 2 - [x] Heading 3 - [x] Bulleted List - [x] Numbered List - [ ] Toggle List - [x] Quote - [x] Divider - [ ] Link to Page - [x] Callout - [x] Image - [x] Embed - [ ] Web Bookmark - [x] Video - [ ] Audio - [x] Code - [ ] File - [x] Math Equation - [x] Inline Equation ## Development and Deployment This project is built to be deployed on [Vercel](https://vercel.com/home). For local development, install [Vercel's CLI](https://vercel.com/download) and run `vercel dev`. ================================================ FILE: api/asset.js ================================================ /* Get signed asset URL for Notion S3 files */ const call = require("../notion/call") module.exports = async (req, res) => { const { url, blockId } = req.query if(!url) { return res.json({ error: "No asset URL provided." }) } if(!blockId) { return res.json({ error: "No block ID provided." }) } const assetRes = await call("getSignedFileUrls", { urls: [ { url, permissionRecord: { table: "block", id: blockId } } ] }) res.status(307) res.setHeader("location", assetRes.signedUrls[0]) res.end() } ================================================ FILE: api/html.js ================================================ /* Returns reconstructed HTML for a given Notion doc */ const katex = require("katex") const prism = require("prismjs") require("prismjs/components/prism-markup-templating") require("prismjs/components/prism-php") require("prismjs/components/prism-python") require("prismjs/components/prism-ruby") require("prismjs/components/prism-json") require("prismjs/components/prism-java") require("prismjs/components/prism-yaml") require("prismjs/components/prism-bash") const call = require("../notion/call") const normalizeId = require("../notion/normalizeId") const textArrayToHtml = require("../notion/textArrayToHtml.js") module.exports = async (req, res) => { const { id:queryId } = req.query const id = normalizeId(queryId) if(!id) { return res.json({ error: "no Notion doc ID provided as `id` parameter" }) } const overview = await call("syncRecordValues", { requests: [ { id, table: "block", version: -1 } ] }) if(!overview.recordMap.block[id].value) { return res.json({ error: "could not read Notion doc with this ID - make sure public access is enabled" }) } const contentIds = overview.recordMap.block[id].value.content if(!contentIds) { return res.json({ error: "this doc has no content" }) } const contents = [] let recordMap = {} let lastChunk let hasMorePageChunks = true while(hasMorePageChunks) { const cursor = lastChunk && lastChunk.cursor || ({ stack: [] }) const chunk = await call("loadPageChunk", { pageId: id, limit: 100, cursor, chunkNumber: 0, verticalColumns: false }) recordMap = { ...recordMap, ...chunk.recordMap.block } lastChunk = chunk if(chunk.cursor.stack.length === 0) hasMorePageChunks = false } contentIds.forEach(id => { const block = recordMap[id] if(block) contents.push(block.value) }) const html = [] contents.forEach(block => { const type = block.type if(["header", "sub_header", "sub_sub_header", "text"].includes(type)) { /* Headers (H1 - H3) and plain text */ const el = { header: "h1", sub_header: "h2", sub_sub_header: "h3", text: "p" }[type] if(!block.properties) { // This is an empty text block. return } html.push(`<${el}>${textArrayToHtml(block.properties.title)}`) } else if(["numbered_list", "bulleted_list"].includes(type)) { /* Numbered and bulleted lists */ const el = { "numbered_list": "ol", "bulleted_list": "ul" }[type] html.push(`<${el}>
  • ${textArrayToHtml(block.properties && block.properties.title)}
  • `) } else if(["to_do"].includes(type)) { /* To do list represented by a list of checkbox inputs */ const checked = Boolean(block.properties.checked) html.push(`
    `) } else if(["code"].includes(type)) { /* Full code blocks with language */ const language = block.properties.language[0][0].toLowerCase().replace(/ /g, "") const text = block.properties.title || [[""]] // Inject unescaped HTML if code block's language is set to LiveScript const showLive = language === "livescript" if(showLive) { html.push(text.map(clip => clip[0]).join("")) // Ignore styling, just take the text } else { const code = textArrayToHtml(text, { br: false, escape: false }) let highlighted = code try { // try/catch because this fails when prism doesn't know the language highlighted = prism.highlight(code, prism.languages[language], language) } catch{} html.push(`
    ${highlighted}
    `) } } else if(["callout"].includes(type)) { /* Callout formatted with emoji from emojicdn.elk.sh or just image */ const icon = block.format.page_icon const imageLink = icon.startsWith("http") ? `https://www.notion.so/image/${encodeURIComponent(icon)}?table=block&id=${block.id}` : `https://emojicdn.elk.sh/${icon}` const color = block.format.block_color.split("_")[0] const isBackground = block.format.block_color.split("_").length > 1 const text = block.properties.title html.push(`

    ${textArrayToHtml(text)}

    `) } else if(["quote"].includes(type)) { html.push(`
    ${textArrayToHtml(block.properties.title)}
    `) } else if(["divider"].includes(type)) { html.push(`
    `) } else if(["image"].includes(type)) { html.push(``) } else if(["equation"].includes(type)) { if(!block.properties) { // Equation block is empty return } const equation = block.properties.title[0][0] const equationHtml = katex.renderToString(equation, { throwOnError: false }) html.push(`
    ${equationHtml}
    `) } else if(["embed"].includes(type)) { html.push(``) } else if(["video"].includes(type)) { html.push(``) } else { /* Catch blocks without handler method */ console.log(`Unhandled block type "${block.type}"`, block) } }) // Only add Katex stylesheet if there's Katex elements. if(html.join("").includes(`class="katex"`)) { html.push(``) } const joinedHtml = html.join("") const cleanedHtml = joinedHtml .replace(/<\/ol>
      /g, "") .replace(/<\/ul>
        /g, "") .replace(/<\/div>
        /g, "") res.send(cleanedHtml) } ================================================ FILE: api/table-description.js ================================================ /* Return the description of a Notion collection */ const call = require("../notion/call") const normalizeId = require("../notion/normalizeId") const textArrayToHtml = require("../notion/textArrayToHtml") module.exports = async (req, res) => { const { id:queryId } = req.query const id = normalizeId(queryId) if(!id) { return res.json({ error: "no Notion doc ID provided as `id` parameter" }) } const {recordMap} = await call("loadPageChunk", { pageId: id, limit: 100, cursor: { stack: [ [ { table: "block", id: id, index: 0 } ] ] }, chunkNumber: 0, verticalColumns: false }); const collectionBlock = recordMap.block[id].value; if(!collectionBlock) { return res.json({ error: "invalid Notion doc ID, or public access is not enabled on this doc" }) } if(!collectionBlock.type.startsWith("collection_view")) { return res.json({ error: "this Notion doc is not a collection" }) } const descriptionArray = recordMap.collection[collectionBlock.collection_id].value.description; res.send(textArrayToHtml(descriptionArray)) } ================================================ FILE: api/table.js ================================================ /* Return the entries of a table in Notion */ const call = require("../notion/call") const normalizeId = require("../notion/normalizeId") const textArrayToHtml = require("../notion/textArrayToHtml.js") const getAssetUrl = require("../notion/getAssetUrl") module.exports = async (req, res) => { return res.send("This API has been deprecated. Please use the official Notion API (developers.notion.com) instead.") const { id:queryId } = req.query const id = normalizeId(queryId) if(!id) { return res.json({ error: "no Notion doc ID provided as `id` parameter" }) } const pageData = await call("getRecordValues", { requests: [ { id: id, table: "block" } ] }) if(!pageData.results[0].value) { return res.json({ error: "invalid Notion doc ID, or public access is not enabled on this doc" }) } if(!pageData.results[0].value.type.startsWith("collection_view")) { return res.json({ error: "this Notion doc is not a collection" }) } const collectionId = pageData.results[0].value.collection_id const collectionViewId = pageData.results[0].value.view_ids[0] const tableData = await call("queryCollection", { collectionId, collectionViewId, query: {}, loader: { type: 'table', limit: 99999, userTimeZone: 'UTC', loadContentCover: true } }) const subPages = tableData.result.blockIds const schema = tableData.recordMap.collection[collectionId].value.schema const output = [] subPages.forEach(id => { const page = tableData.recordMap.block[id] const fields = {} for(const s in schema) { const schemaDefinition = schema[s] const type = schemaDefinition.type let value = page.value.properties && page.value.properties[s] && page.value.properties[s][0][0] if(type === "checkbox") { value = value === "Yes" ? true : false } else if(value && type === "date") { try { value = page.value.properties[s][0][1][0][1] } catch { // it seems the older Notion date format is [[ string ]] value = page.value.properties[s][0][0] } } else if(value && type === "text") { value = textArrayToHtml(page.value.properties[s]) } else if(value && type === "file") { const files = page.value.properties[s].filter(f => f.length > 1) // some items in the files array are for some reason just [","] const outputFiles = [] files.forEach(file => { const s3Url = file[1][0][1] outputFiles.push(getAssetUrl(s3Url, page.value.id)) }) value = outputFiles } else if(value && type === "multi_select") { value = value.split(",") } fields[schemaDefinition.name] = value || undefined } output.push({ fields, id: page.value.id, emoji: page.value.format && page.value.format.page_icon, created: page.value.created_time, last_edited: page.value.last_edited_time }) }) return res.json(output) } ================================================ FILE: helpers/escape.js ================================================ /* Excape HTML */ module.exports = text => text .replace(//g, ">") ================================================ FILE: notion/call.js ================================================ /* Call a method on Notion's API */ const fetch = require("node-fetch") module.exports = (methodName, body) => new Promise(resolve => { fetch(`https://www.notion.so/api/v3/${methodName}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }) .then(res => res.json()) .then(json => resolve(json)) }) ================================================ FILE: notion/getAssetUrl.js ================================================ module.exports = (url, blockId) => { const BASE = process.env.NODE_ENV === "development" ? "http://localhost:3000" : "https://potion-api.now.sh" return `${BASE}/api/asset?url=${encodeURIComponent(url)}&blockId=${blockId}` } ================================================ FILE: notion/normalizeId.js ================================================ /* Normalize a UUID for use in Notion's API */ module.exports = (id) => { if(!id) return id if(id.length === 36) return id // Already normalized return `${id.substr(0, 8)}-${id.substr(8, 4)}-${id.substr(12, 4)}-${id.substr(16, 4)}-${id.substr(20)}` } ================================================ FILE: notion/textArrayToHtml.js ================================================ /* Turns an array of text, returned by Notion's API, into HTML */ const katex = require("katex") const escapeText = require("../helpers/escape") module.exports = (source, options={ br: true, escape: true }) => { const output = [] if(!source) return "" source.forEach(clip => { let text = options.escape ? escapeText(clip[0]) : clip[0] if(clip.length === 1) { output.push(text) } else { const modifiers = clip[1] modifiers.forEach(mod => { const modCode = mod[0] if(modCode === "b") { text = `${text}` } else if(modCode === "i") { text = `${text}` } else if(modCode === "a") { text = `${text}` } else if(modCode === "s") { text = `${text}` } else if(modCode === "h") { const color = mod[1].split("_")[0] const isBackground = mod[1].split("_").length > 1 text = `${text}` } else if(modCode === "c") { text = `${text}` } else if(modCode === "e") { text = `${katex.renderToString(mod[1], { throwOnError: false })}` } else { console.error("Unhandled modification in textArrayToHtml()", mod) } }) output.push(text) } }) return options.br ? output.join("").replace(/\n/g, "
        ") : output.join("") } ================================================ FILE: now.json ================================================ { "redirects": [ { "source": "/", "destination": "https://github.com/benborgers/potion" } ], "rewrites": [ { "source": "/(.*)", "destination": "/api/$1" } ], "headers": [ { "source": "/(.*)", "headers": [ { "key": "access-control-allow-origin", "value": "*" }, { "key": "Cache-Control", "value": "s-maxage=10" } ] } ] } ================================================ FILE: package.json ================================================ { "name": "potion", "author": "Ben Borgers ", "license": "ISC", "dependencies": { "katex": "^0.12.0", "node-fetch": "^2.6.1", "prismjs": "^1.25.0" } }