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:
copilot-swe-agent[bot] 2026-03-16 14:18:41 +00:00
parent 7ad93e511e
commit b570eac59c
3 changed files with 521 additions and 0 deletions

View File

@ -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

View File

@ -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 '![' + (captionText || 'image') + '](' + (src || '') + ')';
}
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);
});
}

View File

@ -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');
});
});