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.
*`<notion-page-id>` 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=<notion-page-id>`.
### /table-description
Generates HTML for the description of a table.
The only query parameter is `?id=<notion-page-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=<notion-page-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
<link rel="stylesheet" href="https://unpkg.com/prism-themes@1.4.0/themes/prism-ghcolors.css" />
```
### 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)}</${el}>`)
} else if(["numbered_list", "bulleted_list"].includes(type)) {
/* Numbered and bulleted lists */
const el = {
"numbered_list": "ol",
"bulleted_list": "ul"
}[type]
html.push(`<${el}><li>${textArrayToHtml(block.properties && block.properties.title)}</li></${el}>`)
} else if(["to_do"].includes(type)) {
/* To do list represented by a list of checkbox inputs */
const checked = Boolean(block.properties.checked)
html.push(`<div class="checklist"><label><input type="checkbox" disabled${checked ? " checked" : ""}>${textArrayToHtml(block.properties.title)}</label></div>`)
} 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(`<pre><code class="language-${language}">${highlighted}</code></pre>`)
}
} 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(`<div class="callout${isBackground ? " background" : " color"}-${color}"><img src="${imageLink}"><p>${textArrayToHtml(text)}</p></div>`)
} else if(["quote"].includes(type)) {
html.push(`<blockquote>${textArrayToHtml(block.properties.title)}</blockquote>`)
} else if(["divider"].includes(type)) {
html.push(`<hr>`)
} else if(["image"].includes(type)) {
html.push(`<img src="https://www.notion.so/image/${encodeURIComponent(block.format.display_source)}?table=block&id=${block.id}">`)
} 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(`<div class="equation">${equationHtml}</div>`)
} else if(["embed"].includes(type)) {
html.push(`<iframe src="${block.properties.source[0][0]}"></iframe>`)
} else if(["video"].includes(type)) {
html.push(`<iframe src="${block.format.display_source}"></iframe>`)
} 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(`<link rel="stylesheet" href="https://unpkg.com/katex@0.12.0/dist/katex.min.css">`)
}
const joinedHtml = html.join("")
const cleanedHtml = joinedHtml
.replace(/<\/ol><ol>/g, "")
.replace(/<\/ul><ul>/g, "")
.replace(/<\/div><div class="checklist">/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, "<")
.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 = `<strong>${text}</strong>`
} else if(modCode === "i") {
text = `<em>${text}</em>`
} else if(modCode === "a") {
text = `<a href="${mod[1]}">${text}</a>`
} else if(modCode === "s") {
text = `<strike>${text}</strike>`
} else if(modCode === "h") {
const color = mod[1].split("_")[0]
const isBackground = mod[1].split("_").length > 1
text = `<span class="${isBackground ? "background" : "color"}-${color}">${text}</span>`
} else if(modCode === "c") {
text = `<code>${text}</code>`
} else if(modCode === "e") {
text = `<span class="equation">${katex.renderToString(mod[1], { throwOnError: false })}</span>`
} else {
console.error("Unhandled modification in textArrayToHtml()", mod)
}
})
output.push(text)
}
})
return options.br ? output.join("").replace(/\n/g, "<br>") : 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 <borgersbenjamin@gmail.com>",
"license": "ISC",
"dependencies": {
"katex": "^0.12.0",
"node-fetch": "^2.6.1",
"prismjs": "^1.25.0"
}
}
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
Condensed preview — 14 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (20K chars).
[
{
"path": ".gitignore",
"chars": 25,
"preview": "node_modules\n.now\n.vercel"
},
{
"path": "LICENSE",
"chars": 1073,
"preview": "MIT License\n\nCopyright (c) 2022 Benjamin Borgers\n\nPermission is hereby granted, free of charge, to any person obtaining "
},
{
"path": "README.md",
"chars": 3147,
"preview": "# Potion\n\nPotion is a reverse-engineered API for [Notion](https://notion.so). Write your content in Notion, and use Poti"
},
{
"path": "api/asset.js",
"chars": 616,
"preview": "/* Get signed asset URL for Notion S3 files */\n\nconst call = require(\"../notion/call\")\n\nmodule.exports = async (req, res"
},
{
"path": "api/html.js",
"chars": 6160,
"preview": "/* Returns reconstructed HTML for a given Notion doc */\n\nconst katex = require(\"katex\")\nconst prism = require(\"prismjs\")"
},
{
"path": "api/table-description.js",
"chars": 1204,
"preview": "/* Return the description of a Notion collection */\n\nconst call = require(\"../notion/call\")\nconst normalizeId = require("
},
{
"path": "api/table.js",
"chars": 3087,
"preview": "/* Return the entries of a table in Notion */\n\nconst call = require(\"../notion/call\")\nconst normalizeId = require(\"../no"
},
{
"path": "helpers/escape.js",
"chars": 146,
"preview": "/* Excape HTML */\n\nmodule.exports = text => text\n .replace(/</g, \"<\")\n "
},
{
"path": "notion/call.js",
"chars": 377,
"preview": "/* Call a method on Notion's API */\n\nconst fetch = require(\"node-fetch\")\n\nmodule.exports = (methodName, body) => new Pro"
},
{
"path": "notion/getAssetUrl.js",
"chars": 228,
"preview": "module.exports = (url, blockId) => {\n const BASE = process.env.NODE_ENV === \"development\" ? \"http://localhost:3000\" : \""
},
{
"path": "notion/normalizeId.js",
"chars": 257,
"preview": "/* Normalize a UUID for use in Notion's API */\n\nmodule.exports = (id) => {\n if(!id) return id\n if(id.length === 36) re"
},
{
"path": "notion/textArrayToHtml.js",
"chars": 1522,
"preview": "/* Turns an array of text, returned by Notion's API, into HTML */\n\nconst katex = require(\"katex\")\n\nconst escapeText = re"
},
{
"path": "now.json",
"chars": 437,
"preview": "{\n \"redirects\": [\n { \"source\": \"/\", \"destination\": \"https://github.com/benborgers/potion\" }\n ],\n \"rewrites\": [\n "
},
{
"path": "package.json",
"chars": 200,
"preview": "{\n \"name\": \"potion\",\n \"author\": \"Ben Borgers <borgersbenjamin@gmail.com>\",\n \"license\": \"ISC\",\n \"dependencies\": {\n "
}
]
About this extraction
This page contains the full source code of the benborgers/potion GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 14 files (18.0 KB), approximately 5.0k tokens. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.