guanghulab/scripts/notion-page-reader.js
2026-05-10 13:12:44 +08:00

327 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 fs = require('fs');
const path = require('path');
// ══════════════════════════════════════════════════════════
// 常量
// ══════════════════════════════════════════════════════════
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 '1. ' + 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;
var outputDir = process.env.NOTION_OUTPUT_DIR || '';
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 + ' 个内容块');
// 5. 可选:保存到文件
if (outputDir) {
var safeTitle = title.replace(/[^a-zA-Z0-9\u4e00-\u9fa5_-]/g, '_').slice(0, 80);
var filename = safeTitle + '-' + pageId.replace(/-/g, '') + '.md';
var outputPath = path.join(outputDir, filename);
var fileContent = '# ' + title + '\n\n';
fileContent += '> 📖 Notion 页面 ID: `' + pageId + '`\n';
fileContent += '> 🕐 读取时间: ' + new Date().toISOString() + '\n\n';
fileContent += '---\n\n';
fileContent += markdown + '\n';
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
fs.writeFileSync(outputPath, fileContent, 'utf-8');
console.log('');
console.log('💾 已保存到: ' + outputPath);
}
}
// ══════════════════════════════════════════════════════════
// 模块导出(供其他脚本引用)
// ══════════════════════════════════════════════════════════
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);
});
}