diff --git a/.github/workflows/notion-page-reader.yml b/.github/workflows/notion-page-reader.yml new file mode 100644 index 00000000..fd1d686e --- /dev/null +++ b/.github/workflows/notion-page-reader.yml @@ -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 diff --git a/scripts/notion-page-reader.js b/scripts/notion-page-reader.js new file mode 100644 index 00000000..41b83535 --- /dev/null +++ b/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_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 '); + 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); + }); +} diff --git a/tests/smoke/notion-page-reader.test.js b/tests/smoke/notion-page-reader.test.js new file mode 100644 index 00000000..87134ba7 --- /dev/null +++ b/tests/smoke/notion-page-reader.test.js @@ -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'); + }); +});