feat: add Notion page reader script and workflow for reading page content from URLs
Co-authored-by: qinfendebingshuo <207279273+qinfendebingshuo@users.noreply.github.com>
This commit is contained in:
parent
7ad93e511e
commit
b570eac59c
|
|
@ -0,0 +1,40 @@
|
|||
name: 铸渊 · Notion 页面阅读器
|
||||
|
||||
# 从 Notion 页面链接读取内容,输出 Markdown 格式文本
|
||||
#
|
||||
# 依赖 Secrets:
|
||||
# NOTION_API_TOKEN Notion API token
|
||||
#
|
||||
# dispatch payload / inputs:
|
||||
# notion_page_url Notion 页面链接或页面 ID
|
||||
|
||||
on:
|
||||
repository_dispatch:
|
||||
types: [read-notion-page]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
notion_page_url:
|
||||
description: 'Notion 页面链接或页面 ID'
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
read-page:
|
||||
name: 📖 读取 Notion 页面
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: 📖 读取 Notion 页面内容
|
||||
env:
|
||||
NOTION_TOKEN: ${{ secrets.NOTION_API_TOKEN }}
|
||||
NOTION_PAGE_URL: ${{ github.event.client_payload.notion_page_url || github.event.inputs.notion_page_url }}
|
||||
run: node scripts/notion-page-reader.js
|
||||
|
|
@ -0,0 +1,303 @@
|
|||
// scripts/notion-page-reader.js
|
||||
// 铸渊 · Notion 页面阅读器
|
||||
//
|
||||
// 从 Notion 页面链接或 ID 读取内容,输出 Markdown 格式纯文本。
|
||||
//
|
||||
// 用法:
|
||||
// node scripts/notion-page-reader.js <NOTION_URL_OR_PAGE_ID>
|
||||
//
|
||||
// 环境变量:
|
||||
// NOTION_TOKEN Notion API token(必须)
|
||||
//
|
||||
// 支持的 URL 格式:
|
||||
// https://www.notion.so/workspace/Page-Title-abc123def456...
|
||||
// https://www.notion.so/abc123def456...
|
||||
// https://notion.so/abc123def456...
|
||||
// https://workspace.notion.site/Page-Title-abc123def456...
|
||||
// 直接传入 32 位十六进制 ID 或带连字符的 UUID
|
||||
|
||||
'use strict';
|
||||
|
||||
const https = require('https');
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// 常量
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
const NOTION_VERSION = '2022-06-28';
|
||||
const NOTION_API_HOSTNAME = 'api.notion.com';
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// URL 解析
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 从 Notion URL 或原始 ID 中提取页面 ID
|
||||
* @param {string} input - Notion URL 或页面 ID
|
||||
* @returns {string|null} 标准化的页面 ID(带连字符的 UUID)或 null
|
||||
*/
|
||||
function extractPageId(input) {
|
||||
if (!input || typeof input !== 'string') return null;
|
||||
|
||||
var cleaned = input.trim();
|
||||
|
||||
// 去掉尾部查询参数和锚点
|
||||
cleaned = cleaned.split('?')[0].split('#')[0];
|
||||
|
||||
var hex32;
|
||||
|
||||
// 已经是带连字符的 UUID 格式
|
||||
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(cleaned)) {
|
||||
return cleaned.toLowerCase();
|
||||
}
|
||||
|
||||
// 已经是 32 位纯十六进制
|
||||
if (/^[0-9a-f]{32}$/i.test(cleaned)) {
|
||||
hex32 = cleaned.toLowerCase();
|
||||
return hex32.slice(0, 8) + '-' + hex32.slice(8, 12) + '-' + hex32.slice(12, 16) + '-' + hex32.slice(16, 20) + '-' + hex32.slice(20);
|
||||
}
|
||||
|
||||
// URL 格式:提取路径末尾的 32 位十六进制
|
||||
var match = cleaned.match(/([0-9a-f]{32})$/i);
|
||||
if (match) {
|
||||
hex32 = match[1].toLowerCase();
|
||||
return hex32.slice(0, 8) + '-' + hex32.slice(8, 12) + '-' + hex32.slice(12, 16) + '-' + hex32.slice(16, 20) + '-' + hex32.slice(20);
|
||||
}
|
||||
|
||||
// URL 中嵌入的带连字符 UUID
|
||||
var uuidMatch = cleaned.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i);
|
||||
if (uuidMatch) {
|
||||
return uuidMatch[1].toLowerCase();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// HTTP 请求工具
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
function httpsRequest(options) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var req = https.request(options, function (res) {
|
||||
var data = '';
|
||||
res.on('data', function (chunk) { data += chunk; });
|
||||
res.on('end', function () {
|
||||
try {
|
||||
var parsed = JSON.parse(data);
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
resolve(parsed);
|
||||
} else {
|
||||
reject(new Error('Notion API ' + res.statusCode + ': ' + (parsed.message || data)));
|
||||
}
|
||||
} catch (e) {
|
||||
reject(new Error('Notion API 响应解析失败: ' + data.slice(0, 200)));
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.setTimeout(30000, function () {
|
||||
req.destroy(new Error('请求超时 (30s)'));
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// Notion API
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
function notionGet(endpoint, token) {
|
||||
return httpsRequest({
|
||||
hostname: NOTION_API_HOSTNAME,
|
||||
port: 443,
|
||||
path: endpoint,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Notion-Version': NOTION_VERSION,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Notion 页面元数据(标题、属性等)
|
||||
*/
|
||||
async function getNotionPage(pageId, token) {
|
||||
return notionGet('/v1/pages/' + pageId, token);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 Notion 页面的所有子块(递归分页)
|
||||
*/
|
||||
async function getNotionPageBlocks(pageId, token) {
|
||||
var blocks = [];
|
||||
var cursor = undefined;
|
||||
do {
|
||||
var qs = cursor ? '?start_cursor=' + cursor : '';
|
||||
var result = await notionGet('/v1/blocks/' + pageId + '/children' + qs, token);
|
||||
blocks.push.apply(blocks, result.results || []);
|
||||
cursor = result.has_more ? result.next_cursor : undefined;
|
||||
} while (cursor);
|
||||
return blocks;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// 内容提取
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 从 Notion 页面属性中提取标题
|
||||
*/
|
||||
function extractPageTitle(page) {
|
||||
var props = page.properties || {};
|
||||
var keys = Object.keys(props);
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
var prop = props[keys[i]];
|
||||
if (prop.type === 'title' && prop.title) {
|
||||
return prop.title.map(function (t) { return t.plain_text || ''; }).join('');
|
||||
}
|
||||
}
|
||||
return '(无标题)';
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Notion 块中提取纯文本
|
||||
*/
|
||||
function extractBlockText(block) {
|
||||
var type = block.type;
|
||||
if (!block[type]) return '';
|
||||
|
||||
var richTexts = block[type].rich_text || block[type].text || [];
|
||||
return richTexts.map(function (rt) { return rt.plain_text || ''; }).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Notion 块列表转为 Markdown 格式纯文本
|
||||
*/
|
||||
function blocksToMarkdown(blocks) {
|
||||
return blocks.map(function (block) {
|
||||
var type = block.type;
|
||||
var text = extractBlockText(block);
|
||||
|
||||
if (type === 'heading_1') return '\n# ' + text;
|
||||
if (type === 'heading_2') return '\n## ' + text;
|
||||
if (type === 'heading_3') return '\n### ' + text;
|
||||
if (type === 'bulleted_list_item') return '- ' + text;
|
||||
if (type === 'numbered_list_item') return '• ' + text;
|
||||
if (type === 'to_do') {
|
||||
var checked = block.to_do && block.to_do.checked ? '☑' : '☐';
|
||||
return checked + ' ' + text;
|
||||
}
|
||||
if (type === 'code') {
|
||||
var lang = (block.code && block.code.language) || '';
|
||||
return '```' + lang + '\n' + text + '\n```';
|
||||
}
|
||||
if (type === 'divider') return '---';
|
||||
if (type === 'callout') return '> ' + text;
|
||||
if (type === 'quote') return '> ' + text;
|
||||
if (type === 'toggle') return '▸ ' + text;
|
||||
if (type === 'table_row') {
|
||||
var cells = (block.table_row && block.table_row.cells) || [];
|
||||
return '| ' + cells.map(function (cell) {
|
||||
return cell.map(function (rt) { return rt.plain_text || ''; }).join('');
|
||||
}).join(' | ') + ' |';
|
||||
}
|
||||
if (type === 'image') {
|
||||
var src = '';
|
||||
if (block.image) {
|
||||
if (block.image.type === 'external') src = block.image.external && block.image.external.url;
|
||||
if (block.image.type === 'file') src = block.image.file && block.image.file.url;
|
||||
}
|
||||
var caption = (block.image && block.image.caption) || [];
|
||||
var captionText = caption.map(function (rt) { return rt.plain_text || ''; }).join('');
|
||||
return ' + ')';
|
||||
}
|
||||
if (type === 'bookmark') {
|
||||
var url = (block.bookmark && block.bookmark.url) || '';
|
||||
return '🔗 ' + url;
|
||||
}
|
||||
if (type === 'child_page') {
|
||||
var childTitle = (block.child_page && block.child_page.title) || '';
|
||||
return '📄 ' + childTitle;
|
||||
}
|
||||
if (type === 'child_database') {
|
||||
var dbTitle = (block.child_database && block.child_database.title) || '';
|
||||
return '🗃️ ' + dbTitle;
|
||||
}
|
||||
return text;
|
||||
}).filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// 主流程
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
async function main() {
|
||||
var input = process.env.NOTION_PAGE_URL || process.argv[2];
|
||||
var token = process.env.NOTION_TOKEN;
|
||||
|
||||
if (!token) {
|
||||
console.error('❌ 缺少 NOTION_TOKEN 环境变量');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!input) {
|
||||
console.error('❌ 用法: node scripts/notion-page-reader.js <NOTION_URL_OR_PAGE_ID>');
|
||||
console.error(' 或设置环境变量 NOTION_PAGE_URL');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
var pageId = extractPageId(input);
|
||||
if (!pageId) {
|
||||
console.error('❌ 无法从输入中提取页面 ID: ' + input);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('📖 铸渊 · Notion 页面阅读器');
|
||||
console.log(' → 页面 ID: ' + pageId);
|
||||
console.log('');
|
||||
|
||||
// 1. 获取页面元数据
|
||||
console.log('⏳ 正在获取页面信息...');
|
||||
var page = await getNotionPage(pageId, token);
|
||||
var title = extractPageTitle(page);
|
||||
console.log(' → 标题: ' + title);
|
||||
|
||||
// 2. 获取页面内容块
|
||||
console.log('⏳ 正在读取页面内容...');
|
||||
var blocks = await getNotionPageBlocks(pageId, token);
|
||||
console.log(' → 获取到 ' + blocks.length + ' 个内容块');
|
||||
|
||||
// 3. 转换为可读文本
|
||||
var markdown = blocksToMarkdown(blocks);
|
||||
|
||||
// 4. 输出
|
||||
console.log('');
|
||||
console.log('════════════════════════════════════════');
|
||||
console.log('📄 ' + title);
|
||||
console.log('════════════════════════════════════════');
|
||||
console.log(markdown);
|
||||
console.log('');
|
||||
console.log('════════════════════════════════════════');
|
||||
console.log('✅ 读取完成 · 共 ' + blocks.length + ' 个内容块');
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// 模块导出(供其他脚本引用)
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
module.exports = {
|
||||
extractPageId: extractPageId,
|
||||
extractBlockText: extractBlockText,
|
||||
blocksToMarkdown: blocksToMarkdown,
|
||||
extractPageTitle: extractPageTitle,
|
||||
};
|
||||
|
||||
// 直接运行时执行主流程
|
||||
if (require.main === module) {
|
||||
main().catch(function (err) {
|
||||
console.error('❌ 读取失败: ' + err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
/**
|
||||
* Unit test · Notion 页面阅读器 URL 解析
|
||||
*
|
||||
* 测试 extractPageId 函数对各种 Notion URL 格式的解析
|
||||
*
|
||||
* 运行: node tests/smoke/notion-page-reader.test.js
|
||||
*/
|
||||
|
||||
const { extractPageId, extractBlockText, blocksToMarkdown } = require('../../scripts/notion-page-reader');
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// extractPageId 测试
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
describe('extractPageId', () => {
|
||||
test('extracts from 32-char hex string', () => {
|
||||
expect(extractPageId('abc123def4561234567890abcdef1234'))
|
||||
.toBe('abc123de-f456-1234-5678-90abcdef1234');
|
||||
});
|
||||
|
||||
test('extracts from UUID with dashes', () => {
|
||||
expect(extractPageId('abc123de-f456-1234-5678-90abcdef1234'))
|
||||
.toBe('abc123de-f456-1234-5678-90abcdef1234');
|
||||
});
|
||||
|
||||
test('extracts from notion.so URL with title', () => {
|
||||
expect(extractPageId('https://www.notion.so/workspace/My-Page-Title-abc123def4561234567890abcdef1234'))
|
||||
.toBe('abc123de-f456-1234-5678-90abcdef1234');
|
||||
});
|
||||
|
||||
test('extracts from notion.so URL without title', () => {
|
||||
expect(extractPageId('https://www.notion.so/abc123def4561234567890abcdef1234'))
|
||||
.toBe('abc123de-f456-1234-5678-90abcdef1234');
|
||||
});
|
||||
|
||||
test('extracts from notion.site URL', () => {
|
||||
expect(extractPageId('https://myworkspace.notion.site/Page-Title-abc123def4561234567890abcdef1234'))
|
||||
.toBe('abc123de-f456-1234-5678-90abcdef1234');
|
||||
});
|
||||
|
||||
test('handles URL with query parameters', () => {
|
||||
expect(extractPageId('https://www.notion.so/abc123def4561234567890abcdef1234?v=xxx&p=123'))
|
||||
.toBe('abc123de-f456-1234-5678-90abcdef1234');
|
||||
});
|
||||
|
||||
test('handles URL with anchor', () => {
|
||||
expect(extractPageId('https://www.notion.so/abc123def4561234567890abcdef1234#section'))
|
||||
.toBe('abc123de-f456-1234-5678-90abcdef1234');
|
||||
});
|
||||
|
||||
test('handles uppercase hex', () => {
|
||||
expect(extractPageId('ABC123DEF4561234567890ABCDEF1234'))
|
||||
.toBe('abc123de-f456-1234-5678-90abcdef1234');
|
||||
});
|
||||
|
||||
test('returns null for empty input', () => {
|
||||
expect(extractPageId('')).toBeNull();
|
||||
expect(extractPageId(null)).toBeNull();
|
||||
expect(extractPageId(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null for invalid input', () => {
|
||||
expect(extractPageId('not-a-valid-id')).toBeNull();
|
||||
expect(extractPageId('https://google.com')).toBeNull();
|
||||
});
|
||||
|
||||
test('handles whitespace around input', () => {
|
||||
expect(extractPageId(' abc123def4561234567890abcdef1234 '))
|
||||
.toBe('abc123de-f456-1234-5678-90abcdef1234');
|
||||
});
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// extractBlockText 测试
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
describe('extractBlockText', () => {
|
||||
test('extracts text from paragraph block', () => {
|
||||
const block = {
|
||||
type: 'paragraph',
|
||||
paragraph: {
|
||||
rich_text: [{ plain_text: 'Hello ' }, { plain_text: 'World' }]
|
||||
}
|
||||
};
|
||||
expect(extractBlockText(block)).toBe('Hello World');
|
||||
});
|
||||
|
||||
test('returns empty string for block without content', () => {
|
||||
const block = { type: 'divider', divider: {} };
|
||||
expect(extractBlockText(block)).toBe('');
|
||||
});
|
||||
|
||||
test('handles block with text property', () => {
|
||||
const block = {
|
||||
type: 'paragraph',
|
||||
paragraph: {
|
||||
text: [{ plain_text: 'Legacy text' }]
|
||||
}
|
||||
};
|
||||
expect(extractBlockText(block)).toBe('Legacy text');
|
||||
});
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// blocksToMarkdown 测试
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
describe('blocksToMarkdown', () => {
|
||||
test('converts heading blocks', () => {
|
||||
const blocks = [
|
||||
{ type: 'heading_1', heading_1: { rich_text: [{ plain_text: 'Title' }] } },
|
||||
{ type: 'heading_2', heading_2: { rich_text: [{ plain_text: 'Subtitle' }] } },
|
||||
{ type: 'heading_3', heading_3: { rich_text: [{ plain_text: 'Section' }] } },
|
||||
];
|
||||
const md = blocksToMarkdown(blocks);
|
||||
expect(md).toContain('# Title');
|
||||
expect(md).toContain('## Subtitle');
|
||||
expect(md).toContain('### Section');
|
||||
});
|
||||
|
||||
test('converts list items', () => {
|
||||
const blocks = [
|
||||
{ type: 'bulleted_list_item', bulleted_list_item: { rich_text: [{ plain_text: 'Bullet' }] } },
|
||||
{ type: 'numbered_list_item', numbered_list_item: { rich_text: [{ plain_text: 'Number' }] } },
|
||||
];
|
||||
const md = blocksToMarkdown(blocks);
|
||||
expect(md).toContain('- Bullet');
|
||||
expect(md).toContain('• Number');
|
||||
});
|
||||
|
||||
test('converts code blocks', () => {
|
||||
const blocks = [
|
||||
{ type: 'code', code: { rich_text: [{ plain_text: 'console.log("hi")' }], language: 'javascript' } },
|
||||
];
|
||||
const md = blocksToMarkdown(blocks);
|
||||
expect(md).toContain('```javascript');
|
||||
expect(md).toContain('console.log("hi")');
|
||||
});
|
||||
|
||||
test('converts to_do blocks', () => {
|
||||
const blocks = [
|
||||
{ type: 'to_do', to_do: { rich_text: [{ plain_text: 'Done' }], checked: true } },
|
||||
{ type: 'to_do', to_do: { rich_text: [{ plain_text: 'Todo' }], checked: false } },
|
||||
];
|
||||
const md = blocksToMarkdown(blocks);
|
||||
expect(md).toContain('☑ Done');
|
||||
expect(md).toContain('☐ Todo');
|
||||
});
|
||||
|
||||
test('converts divider', () => {
|
||||
const blocks = [
|
||||
{ type: 'paragraph', paragraph: { rich_text: [{ plain_text: 'Before' }] } },
|
||||
{ type: 'divider', divider: {} },
|
||||
{ type: 'paragraph', paragraph: { rich_text: [{ plain_text: 'After' }] } },
|
||||
];
|
||||
const md = blocksToMarkdown(blocks);
|
||||
expect(md).toContain('---');
|
||||
});
|
||||
|
||||
test('converts quote and callout', () => {
|
||||
const blocks = [
|
||||
{ type: 'quote', quote: { rich_text: [{ plain_text: 'A quote' }] } },
|
||||
{ type: 'callout', callout: { rich_text: [{ plain_text: 'A callout' }] } },
|
||||
];
|
||||
const md = blocksToMarkdown(blocks);
|
||||
expect(md).toContain('> A quote');
|
||||
expect(md).toContain('> A callout');
|
||||
});
|
||||
|
||||
test('filters empty blocks', () => {
|
||||
const blocks = [
|
||||
{ type: 'paragraph', paragraph: { rich_text: [] } },
|
||||
{ type: 'paragraph', paragraph: { rich_text: [{ plain_text: 'Content' }] } },
|
||||
];
|
||||
const md = blocksToMarkdown(blocks);
|
||||
expect(md).toBe('Content');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue