import BLOG from '@/blog.config' import { getAllCategories } from '@/lib/notion/getAllCategories' import getAllPageIds from '@/lib/notion/getAllPageIds' import { getAllTags } from '@/lib/notion/getAllTags' import { getConfigMapFromConfigPage } from '@/lib/notion/getNotionConfig' import getPageProperties, { adjustPageProperties } from '@/lib/notion/getPageProperties' import { fetchInBatches, getPage } from '@/lib/notion/getPostBlocks' import { compressImage, mapImgUrl } from '@/lib/notion/mapImage' import { deepClone } from '@/lib/utils' import { idToUuid } from 'notion-utils' import { siteConfig } from '../config' import { extractLangId, extractLangPrefix, getShortId } from '../utils/pageId' import { getOrSetDataWithCache } from '@/lib/cache/cache_manager' export { getAllTags } from '../notion/getAllTags' export { getPost } from '../notion/getNotionPost' export { getPage as getPostBlocks } from '../notion/getPostBlocks' /** * 获取博客数据; 基于Notion实现 * @param {*} pageId * @param {*} from * @param {*} locale 语言 zh|en|jp 等等 * @returns * */ export async function getGlobalData({ pageId = BLOG.NOTION_PAGE_ID, from, locale }) { // 获取站点数据 , 如果pageId有逗号隔开则分次取数据 const siteIds = pageId?.split(',') || [] let data = EmptyData(pageId) if (BLOG.BUNDLE_ANALYZER) { return data } try { for (let index = 0; index < siteIds.length; index++) { const siteId = siteIds[index] const id = extractLangId(siteId) const prefix = extractLangPrefix(siteId) // 第一个id站点默认语言 if (index === 0 || locale === prefix) { data = await getSiteDataByPageId({ pageId: id, from }) } } } catch (error) { console.error('异常', error) } return handleDataBeforeReturn(deepClone(data)) } /** * 获取指定notion的collection数据 * @param pageId * @param from 请求来源 * @returns {Promise} */ export async function getSiteDataByPageId({ pageId, from }) { // 获取NOTION原始数据,此接支持mem缓存。 return await getOrSetDataWithCache( `site_data_${pageId}`, async (pageId, from) => { const pageRecordMap = await getPage(pageId, from) return convertNotionToSiteDate(pageId, from, deepClone(pageRecordMap)) }, pageId, from ) } /** * 获取公告 */ async function getNotice(post) { if (!post) { return null } post.blockMap = await getPage(post.id, 'data-notice') return post } /** * 空的默认数据 * @param {*} pageId * @returns */ const EmptyData = pageId => { const empty = { notice: null, siteInfo: getSiteInfo({}), allPages: [ { id: 1, title: `无法获取Notion数据,请检查Notion_ID: \n 当前 ${pageId}`, summary: '访问文档获取帮助 → https://docs.tangly1024.com/article/vercel-deploy-notion-next', status: 'Published', type: 'Post', slug: 'oops', publishDay: '2024-11-13', pageCoverThumbnail: BLOG.HOME_BANNER_IMAGE, date: { start_date: '2023-04-24', lastEditedDay: '2023-04-24', tagItems: [] } } ], allNavPages: [], collection: [], collectionQuery: {}, collectionId: null, collectionView: {}, viewIds: [], block: {}, schema: {}, tagOptions: [], categoryOptions: [], rawMetadata: {}, customNav: [], customMenu: [], postCount: 1, pageIds: [], latestPosts: [] } return empty } /** * 将Notion数据转站点数据 * 这里统一对数据格式化 * @returns {Promise} */ async function convertNotionToSiteDate(pageId, from, pageRecordMap) { if (!pageRecordMap) { console.error('can`t get Notion Data ; Which id is: ', pageId) return {} } pageId = idToUuid(pageId) let block = pageRecordMap.block || {} const rawMetadata = block[pageId]?.value // Check Type Page-Database和Inline-Database if ( rawMetadata?.type !== 'collection_view_page' && rawMetadata?.type !== 'collection_view' ) { console.error(`pageId "${pageId}" is not a database`) return EmptyData(pageId) } const collection = Object.values(pageRecordMap.collection)[0]?.value || {} const collectionId = rawMetadata?.collection_id const collectionQuery = pageRecordMap.collection_query const collectionView = pageRecordMap.collection_view const schema = collection?.schema const viewIds = rawMetadata?.view_ids const collectionData = [] const pageIds = getAllPageIds( collectionQuery, collectionId, collectionView, viewIds ) if (pageIds?.length === 0) { console.error( '获取到的文章列表为空,请检查notion模板', collectionQuery, collection, collectionView, viewIds, pageRecordMap ) } else { // console.log('有效Page数量', pageIds?.length) } // 抓取主数据库最多抓取1000个blocks,溢出的数block这里统一抓取一遍 const blockIdsNeedFetch = [] for (let i = 0; i < pageIds.length; i++) { const id = pageIds[i] const value = block[id]?.value if (!value) { blockIdsNeedFetch.push(id) } } const fetchedBlocks = await fetchInBatches(blockIdsNeedFetch) block = Object.assign({}, block, fetchedBlocks) // 获取每篇文章基础数据 for (let i = 0; i < pageIds.length; i++) { const id = pageIds[i] const value = block[id]?.value || fetchedBlocks[id]?.value const properties = (await getPageProperties( id, value, schema, null, getTagOptions(schema) )) || null if (properties) { collectionData.push(properties) } } // 站点配置优先读取配置表格,否则读取blog.config.js 文件 const NOTION_CONFIG = (await getConfigMapFromConfigPage(collectionData)) || {} // 处理每一条数据的字段 collectionData.forEach(function (element) { adjustPageProperties(element, NOTION_CONFIG) }) // 站点基础信息 const siteInfo = getSiteInfo({ collection, block, NOTION_CONFIG }) // 文章计数 let postCount = 0 // 查找所有的Post和Page const allPages = collectionData.filter(post => { if (post?.type === 'Post' && post.status === 'Published') { postCount++ } return ( post && post?.slug && // !post?.slug?.startsWith('http') && (post?.status === 'Invisible' || post?.status === 'Published') ) }) // Sort by date if (siteConfig('POSTS_SORT_BY', '', NOTION_CONFIG) === 'date') { allPages.sort((a, b) => { return b?.publishDate - a?.publishDate }) } const notice = await getNotice( collectionData.filter(post => { return ( post && post?.type && post?.type === 'Notice' && post.status === 'Published' ) })?.[0] ) // 所有分类 const categoryOptions = getAllCategories({ allPages, categoryOptions: getCategoryOptions(schema) }) // 所有标签 const tagOptions = getAllTags({ allPages, tagOptions: getTagOptions(schema), NOTION_CONFIG }) || null // 旧的菜单 const customNav = getCustomNav({ allPages: collectionData.filter( post => post?.type === 'Page' && post.status === 'Published' ) }) // 新的菜单 const customMenu = await getCustomMenu({ collectionData, NOTION_CONFIG }) const latestPosts = getLatestPosts({ allPages, from, latestPostCount: 6 }) const allNavPages = getNavPages({ allPages }) return { NOTION_CONFIG, notice, siteInfo, allPages, allNavPages, collection, collectionQuery, collectionId, collectionView, viewIds, block, schema, tagOptions, categoryOptions, rawMetadata, customNav, customMenu, postCount, pageIds, latestPosts } } /** * 返回给浏览器前端的数据处理 * 适当脱敏 * 减少体积 * 其它处理 * @param {*} db */ function handleDataBeforeReturn(db) { // 清理多余数据 delete db.block delete db.schema delete db.rawMetadata delete db.pageIds delete db.viewIds delete db.collection delete db.collectionQuery delete db.collectionId delete db.collectionView // 清理多余的块 if (db?.notice) { db.notice = cleanBlock(db?.notice) delete db.notice?.id } db.tagOptions = cleanIds(db?.tagOptions) db.categoryOptions = cleanIds(db?.categoryOptions) db.customMenu = cleanIds(db?.customMenu) // db.latestPosts = shortenIds(db?.latestPosts) db.allNavPages = shortenIds(db?.allNavPages) // db.allPages = cleanBlocks(db?.allPages) db.allNavPages = cleanPages(db?.allNavPages, db.tagOptions) db.allPages = cleanPages(db.allPages, db.tagOptions) db.latestPosts = cleanPages(db.latestPosts, db.tagOptions) const POST_SCHEDULE_PUBLISH = siteConfig('POST_SCHEDULE_PUBLISH', null, db.NOTION_CONFIG) if(POST_SCHEDULE_PUBLISH){ // console.log('[定时发布] 开启检测') db.allPages?.forEach(p=>{ // 新特性,判断文章的发布和下架时间,如果不在有效期内则进行下架处理 const publish = isInRange(p.title, p.date) if (!publish) { console.log('[定时发布] 隐藏-->', p.title) // 隐藏 p.status = 'Invisible' } }) } return db } /** * 处理文章列表中的异常数据 * @param {Array} allPages - 所有页面数据 * @param {Array} tagOptions - 标签选项 * @returns {Array} 处理后的 allPages */ function cleanPages(allPages, tagOptions) { // 校验参数是否为数组 if (!Array.isArray(allPages) || !Array.isArray(tagOptions)) { console.warn('Invalid input: allPages and tagOptions should be arrays.') return allPages || [] // 返回空数组或原始值 } // 提取 tagOptions 中所有合法的标签名 const validTags = new Set( tagOptions .map(tag => (typeof tag.name === 'string' ? tag.name : null)) .filter(Boolean) // 只保留合法的字符串 ) // 遍历所有的 pages allPages.forEach(page => { // 确保 tagItems 是数组 if (Array.isArray(page.tagItems)) { // 对每个 page 的 tagItems 进行过滤 page.tagItems = page.tagItems.filter( tagItem => validTags.has(tagItem?.name) && typeof tagItem.name === 'string' // 校验 tagItem.name 是否是字符串 ) } }) return allPages } /** * 清理一组数据的id * @param {*} items * @returns */ function shortenIds(items) { if (items && Array.isArray(items)) { return deepClone( items.map(item => { item.short_id = getShortId(item.id) delete item.id return item }) ) } return items } /** * 清理一组数据的id * @param {*} items * @returns */ function cleanIds(items) { if (items && Array.isArray(items)) { return deepClone( items.map(item => { delete item.id return item }) ) } return items } /** * 清理block数据 */ function cleanBlock(item) { const post = deepClone(item) const pageBlock = post?.blockMap?.block // delete post?.id // delete post?.blockMap?.collection if (pageBlock) { for (const i in pageBlock) { pageBlock[i] = cleanBlock(pageBlock[i]) delete pageBlock[i]?.role delete pageBlock[i]?.value?.version delete pageBlock[i]?.value?.created_by_table delete pageBlock[i]?.value?.created_by_id delete pageBlock[i]?.value?.last_edited_by_table delete pageBlock[i]?.value?.last_edited_by_id delete pageBlock[i]?.value?.space_id delete pageBlock[i]?.value?.version delete pageBlock[i]?.value?.format?.copied_from_pointer delete pageBlock[i]?.value?.format?.block_locked_by delete pageBlock[i]?.value?.parent_table delete pageBlock[i]?.value?.copied_from_pointer delete pageBlock[i]?.value?.copied_from delete pageBlock[i]?.value?.created_by_table delete pageBlock[i]?.value?.created_by_id delete pageBlock[i]?.value?.last_edited_by_table delete pageBlock[i]?.value?.last_edited_by_id delete pageBlock[i]?.value?.permissions delete pageBlock[i]?.value?.alive } } return post } /** * 获取最新文章 根据最后修改时间倒序排列 * @param {*}} param0 * @returns */ function getLatestPosts({ allPages, from, latestPostCount }) { const allPosts = allPages?.filter( page => page.type === 'Post' && page.status === 'Published' ) const latestPosts = Object.create(allPosts).sort((a, b) => { const dateA = new Date(a?.lastEditedDate || a?.publishDate) const dateB = new Date(b?.lastEditedDate || b?.publishDate) return dateB - dateA }) return latestPosts.slice(0, latestPostCount) } /** * 获取用户自定义单页菜单 * 旧版本,不读取Menu菜单,而是读取type=Page生成菜单 * @param notionPageData * @returns {Promise<[]|*[]>} */ function getCustomNav({ allPages }) { const customNav = [] if (allPages && allPages.length > 0) { allPages.forEach(p => { p.to = p.slug customNav.push({ icon: p.icon || null, name: p.title || p.name || '', href: p.href, target: p.target, show: true }) }) } return customNav } /** * 获取自定义菜单 * @param {*} allPages * @returns */ function getCustomMenu({ collectionData, NOTION_CONFIG }) { const menuPages = collectionData.filter( post => post.status === 'Published' && (post?.type === 'Menu' || post?.type === 'SubMenu') ) const menus = [] if (menuPages && menuPages.length > 0) { menuPages.forEach(e => { e.show = true if (e.type === 'Menu') { menus.push(e) } else if (e.type === 'SubMenu') { const parentMenu = menus[menus.length - 1] if (parentMenu) { if (parentMenu.subMenus) { parentMenu.subMenus.push(e) } else { parentMenu.subMenus = [e] } } } }) } return menus } /** * 获取标签选项 * @param schema * @returns {undefined} */ function getTagOptions(schema) { if (!schema) return {} const tagSchema = Object.values(schema).find( e => e.name === BLOG.NOTION_PROPERTY_NAME.tags ) return tagSchema?.options || [] } /** * 获取分类选项 * @param schema * @returns {{}|*|*[]} */ function getCategoryOptions(schema) { if (!schema) return {} const categorySchema = Object.values(schema).find( e => e.name === BLOG.NOTION_PROPERTY_NAME.category ) return categorySchema?.options || [] } /** * 站点信息 * @param notionPageData * @param from * @returns {Promise<{title,description,pageCover,icon}>} */ function getSiteInfo({ collection, block, NOTION_CONFIG }) { const defaultTitle = NOTION_CONFIG?.TITLE || 'NotionNext BLOG' const defaultDescription = NOTION_CONFIG?.DESCRIPTION || '这是一个由NotionNext生成的站点' const defaultPageCover = NOTION_CONFIG?.HOME_BANNER_IMAGE || '/bg_image.jpg' const defaultIcon = NOTION_CONFIG?.AVATAR || '/avatar.svg' const defaultLink = NOTION_CONFIG?.LINK || BLOG.LINK // 空数据的情况返回默认值 if (!collection && !block) { return { title: defaultTitle, description: defaultDescription, pageCover: defaultPageCover, icon: defaultIcon, link: defaultLink } } const title = collection?.name?.[0][0] || defaultTitle const description = collection?.description ? Object.assign(collection).description[0][0] : defaultDescription const pageCover = collection?.cover ? mapImgUrl(collection?.cover, collection, 'collection') : defaultPageCover // 用户头像压缩一下 let icon = compressImage( collection?.icon ? mapImgUrl(collection?.icon, collection, 'collection') : defaultIcon ) // 站点网址 const link = NOTION_CONFIG?.LINK || defaultLink // 站点图标不能是emoji const emojiPattern = /\uD83C[\uDF00-\uDFFF]|\uD83D[\uDC00-\uDE4F]/g if (!icon || emojiPattern.test(icon)) { icon = defaultIcon } return { title, description, pageCover, icon, link } } /** * 判断文章是否在发布时间内 * @param {*} param0 * @returns */ function isInRange(title, date = {}) { const { start_date, start_time, end_date, end_time, time_zone } = date; // 默认使用北京时间 (Asia/Shanghai) const effectiveTimeZone = time_zone || "Asia/Shanghai"; // 辅助函数:根据时区和日期时间字符串创建 Date 对象 function parseDateTime(date, time, timeZone) { if (!date) return null; // 如果没有日期,返回 null const dateTimeString = `${date}T${time || "00:00"}:00`; // 默认时间为 00:00 // 创建时区正确的 Date 对象 return new Date( new Date(dateTimeString).toLocaleString("en-US", { timeZone }) ); } // 获取当前时间(基于目标时区) function getCurrentDateTimeInZone(timeZone) { const now = new Date(); const localString = now.toLocaleString("en-US", { timeZone }); return new Date(localString); } // 获取当前时间(转换为目标时区) const currentDateTimeInZone = getCurrentDateTimeInZone(effectiveTimeZone); // 判断开始时间范围 let startDateTime = null; if (start_date) { startDateTime = parseDateTime(start_date, start_time, effectiveTimeZone); } // 判断结束时间范围 let endDateTime = null; if (end_date) { endDateTime = parseDateTime(end_date, end_time || "23:59", effectiveTimeZone); } let isInRange = true; // 检查是否在开始时间之后 if (startDateTime && currentDateTimeInZone < startDateTime) { isInRange = false; console.log( "[定时发布] 未到发布时间:", title, "指定时间:", startDateTime, "当前时间:", currentDateTimeInZone ); } // 检查是否在结束时间之前 if (endDateTime && currentDateTimeInZone > endDateTime) { isInRange = false; console.log( "[定时发布] 超过下架时间:", title, "指定时间:", endDateTime, "当前时间:", currentDateTimeInZone ); } return isInRange; // 如果都符合条件,返回 true } /** * 获取导航用的精减文章列表 * gitbook主题用到,只保留文章的标题分类标签分类信息,精减掉摘要密码日期等数据 * 导航页面的条件,必须是Posts * @param {*} param0 */ export function getNavPages({ allPages }) { const allNavPages = allPages?.filter(post => { return ( post && post?.slug && post?.type === 'Post' && post?.status === 'Published' ) }) return allNavPages.map(item => ({ id: item.id, title: item.title || '', pageCoverThumbnail: item.pageCoverThumbnail || '', category: item.category || null, tags: item.tags || null, summary: item.summary || null, slug: item.slug, href: item.href, pageIcon: item.pageIcon || '', lastEditedDate: item.lastEditedDate, publishDate: item.publishDate, ext: item.ext || {} })) }