拾趣页的数据来自 NeoDB,但页面不是直接请求 NeoDB。
实际链路是:
NeoDB API -> scripts/sync-neodb.mjs -> public/data/neodb.json -> Astro 构建首页 -> /neodb/ 前端读取 JSON 渲染核心点只有一个:带 token 的请求只发生在构建前,浏览器只读取公开 JSON。
同步脚本
脚本入口是 scripts/sync-neodb.mjs,命令放在 package.json:
{ "sync:neodb": "node scripts/sync-neodb.mjs"}先定义要同步的 shelf 和分类:
const SHELF_TYPES = ['wishlist', 'progress', 'complete', 'dropped'];const CATEGORY_TYPES = ['book', 'movie', 'tv', 'music', 'game', 'podcast', 'performance'];// 换成自己的 NeoDB handle,或者通过 NEODB_HANDLE 环境变量传入。const DEFAULT_HANDLE = 'your-handle';const DEFAULT_LIMIT = 24;const PAGE_SIZE = 120;const OUTPUT_FILE = 'public/data/neodb.json';启动时读 .env、.env.local,然后取 token:
await loadDotEnv('.env');await loadDotEnv('.env.local');
const handle = readArg('handle') || process.env.NEODB_HANDLE || DEFAULT_HANDLE;const token = process.env.NEODB_ACCESS_TOKEN;
if (!token) { throw new Error('Missing NEODB_ACCESS_TOKEN.');}请求地址按 shelf 拼:
const url = new URL(`https://neodb.social/api/user/${encodeURIComponent(handle)}/shelf/${shelfType}`);url.searchParams.set('page', '1');url.searchParams.set('page_size', String(PAGE_SIZE));请求头里带 Bearer token:
const response = await fetch(url, { headers: { Accept: 'application/json', Authorization: `Bearer ${token}`, 'User-Agent': 'skcso-neodb-sync', }, signal: controller.signal,});四个 shelf 并发请求:
const responses = await Promise.all( SHELF_TYPES.map(async (shelfType) => ({ shelfType, response: await fetchShelf(handle, shelfType, token), })),);清洗字段
NeoDB 返回的对象比较大,站点只保留页面要用的字段。清洗函数是 toNeoDBItem():
function toNeoDBItem(mark, shelfType) { const rawItem = mark?.item; if (!rawItem || typeof rawItem !== 'object') return null;
const category = asText(rawItem.category); if (!CATEGORY_TYPES.includes(category)) return null;
const id = asText(rawItem.id) || asText(rawItem.uuid); if (!id) return null;
return { id, uuid: asOptionalText(rawItem.uuid), title: asText(rawItem.display_title) || asText(rawItem.title) || '未命名条目', url: normalizeNeoDBUrl(rawItem.url), coverImageUrl: normalizeNeoDBUrl(rawItem.cover_image_url), description: asOptionalText(rawItem.description), category, shelfType, createdTime: asText(mark.created_time), rating: asNullableNumber(rawItem.rating), ratingCount: asNullableNumber(rawItem.rating_count), ratingGrade: asNullableNumber(mark.rating_grade), commentText: asOptionalText(mark.comment_text), tags: asStringArray(mark.tags), externalResources: parseExternalResources(rawItem.external_resources), };}URL 做一次补全,避免 NeoDB 返回相对路径时前端失效:
function normalizeNeoDBUrl(value) { const text = asText(value); if (!text) return null; if (/^https?:\/\//i.test(text)) return text; if (text.startsWith('//')) return `https:${text}`; if (text.startsWith('/')) return `https://neodb.social${text}`; return `https://neodb.social/${text}`;}最后按 shelf 和 category 写入 bucket:
for (const { shelfType, response } of responses) { shelves[shelfType].total = response.count; shelves[shelfType].pages = response.pages;
for (const mark of response.data) { const item = toNeoDBItem(mark, shelfType); if (!item) continue;
const bucket = shelves[shelfType].items[item.category]; if (bucket.length < limit) bucket.push(item); }}输出 JSON
生成文件是 public/data/neodb.json:
const payload = { available: true, handle, limit, categories: CATEGORY_TYPES, shelfTypes: SHELF_TYPES, source: 'neodb', updatedAt: Date.now(), shelves, error: null,};
await writeFile(OUTPUT_FILE, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');结构大概是:
{ "available": true, "handle": "your-handle", "limit": 24, "categories": ["book", "movie", "tv", "music", "game", "podcast", "performance"], "shelfTypes": ["wishlist", "progress", "complete", "dropped"], "source": "neodb", "updatedAt": 1783255262154, "shelves": { "wishlist": { "total": 9, "pages": 1, "items": { "book": [] } } }, "error": null}这个 JSON 会被当成静态资源发布出去。里面没有 token。
构建顺序
部署时在 pnpm build 前同步:
- name: Sync NeoDB data run: | if [ -n "$NEODB_ACCESS_TOKEN" ]; then pnpm run sync:neodb else echo "::warning::NEODB_ACCESS_TOKEN is not configured; using committed NeoDB snapshot." fi env: NEODB_ACCESS_TOKEN: ${{ secrets.NEODB_ACCESS_TOKEN }} NEODB_HANDLE: your-handle
- run: pnpm build所以生产站有没有最新拾趣,取决于构建环境有没有 NEODB_ACCESS_TOKEN。
没有 token 时,部署不会重新同步,只会继续使用仓库里已有的 public/data/neodb.json。
首页读取
首页在 src/pages/index.astro 里直接读本地 JSON:
const neodbPath = resolve(process.cwd(), 'public/data/neodb.json');let neodbItems: HomeNeoDbItem[] = [];
if (existsSync(neodbPath)) { try { const payload = JSON.parse(readFileSync(neodbPath, 'utf-8')) as NeoDbPayload; neodbItems = collectNeoDbItems(payload); } catch { neodbItems = []; }}然后取最新五条:
const latestNeoDbItems = neodbItems.slice(0, 5);const neodbCount = neodbItems.length;这里是构建时读取,所以首页 HTML 里直接包含最近拾趣。页面打开后不再额外请求。
首页热力图也把 NeoDB 标记算进去:
for (const item of neodbItems) { if (!item.createdAtMs) continue;
const date = new Date(item.createdAtMs); date.setHours(0, 0, 0, 0); if (date < heatmapStart || date > heatmapEnd) continue;
const key = toDayKey(date); heatmapCounts.set(key, (heatmapCounts.get(key) ?? 0) + 1);}所以首页的活动热力图统计的是文章和拾趣两类动作。
拾趣页外壳
/neodb/ 页面本身只输出结构,不在 Astro 里循环生成所有卡片:
<section class="neodb-panel" data-neodb-root data-endpoint="/data/neodb.json" data-initial-category="all" data-initial-shelf="all"> <div class="neodb-grid" data-neodb-grid> <p class="neodb-empty">正在读取 NeoDB 数据...</p> </div></section>页面底部加载脚本:
<script> import '../../scripts/neodb-page.ts';</script>分类 tab 和状态 tab 也是静态按钮,通过 data-category、data-shelf 标记当前筛选值。
前端渲染
src/scripts/neodb-page.ts 里先找根节点,防止重复绑定:
const root = document.querySelector<HTMLElement>('[data-neodb-root]');if (!root || root.dataset.bound === 'true') return;root.dataset.bound = 'true';读取 JSON:
fetch(endpoint, { headers: { Accept: 'application/json' } }) .then(async (response) => { const payload = (await response.json().catch(() => null)) as NeoDbPayload | null; if (!response.ok || !payload || payload.available === false) { setRuntimeError(readPayloadError(payload)); return; } payloadCache = payload; renderCurrent(); }) .catch((error: unknown) => { setRuntimeError(error instanceof Error ? error.message : '暂时无法读取 NeoDB 数据。'); });请求成功后只缓存这份 payload。之后切换分类、切换状态都不再发请求,只在本地筛选。
筛选和排序
状态顺序:
const shelfOrder = ['progress', 'wishlist', 'complete', 'dropped'];const categoryOrder = ['book', 'movie', 'tv', 'music', 'game', 'podcast', 'performance'];同一个 shelf 在不同分类里显示不同文案:
const categoryShelfLabels = { book: { wishlist: '想读', progress: '在读', complete: '读过', dropped: '搁置' }, game: { wishlist: '想玩', progress: '在玩', complete: '玩过', dropped: '弃玩' }, music: { wishlist: '想听', progress: '在听', complete: '听过', dropped: '搁置' }, podcast: { wishlist: '想听', progress: '在听', complete: '听过', dropped: '搁置' },};收集数据时按当前 shelf 和 category 遍历:
const pickedShelves = shelf === 'all' ? shelfOrder : [shelf];const pickedCategories = category === 'all' ? categoryOrder : [category];排序按标记时间倒序,时间一样再按标题:
return items.sort((a, b) => { if (b.createdAtMs !== a.createdAtMs) return b.createdAtMs - a.createdAtMs; return `${a.title ?? ''}`.localeCompare(`${b.title ?? ''}`, 'zh-Hans-CN');});点击 tab 时只是更新状态并重渲染:
tab.addEventListener('click', () => { activeCategory = getCategory(tab.getAttribute('data-category') || 'all'); setActiveTabs(); closeDrawer(); renderCurrent();});时间线
渲染前先按 createdTime 分组:
const groups = new Map<string, { key: string; day: string; month: string; items: NeoDbRenderItem[] }>();
for (const item of renderedItems) { const parsed = readDateGroup(item.createdTime); const key = parsed?.key || 'unknown'; const group = groups.get(key) || { key, day: parsed?.day || '--', month: parsed?.month || '日期未知', items: [], };
group.items.push(item); groups.set(key, group);}每个分组输出一个 .neodb-day-group,里面是日期轴和卡片列表。
卡片是按钮,不是链接。这样点击卡片可以打开站内详情抽屉;真正跳到 NeoDB 的链接放在抽屉里。
详情抽屉
点击卡片时,通过 data-card-index 找到当前渲染列表里的条目:
grid.addEventListener('click', (event) => { const card = (event.target as HTMLElement | null)?.closest<HTMLElement>('[data-neodb-card]'); if (!card) return;
const index = Number.parseInt(card.dataset.cardIndex ?? '-1', 10); if (!Number.isInteger(index) || index < 0 || index >= renderedItems.length) return;
openDrawer(renderedItems[index]);});抽屉里展示封面、标题、分类、状态、日期、简介、评分、标签、来源链接。
打开和关闭只改两个状态:
drawer.dataset.open = 'true';drawer.setAttribute('aria-hidden', 'false');document.body.classList.add('neodb-drawer-open');drawer.dataset.open = 'false';drawer.setAttribute('aria-hidden', 'true');document.body.classList.remove('neodb-drawer-open');Escape 也能关闭:
document.addEventListener('keydown', (event) => { if (event.key === 'Escape') closeDrawer();});转义
拾趣页有不少 HTML 是字符串拼出来的,所以外部数据统一走 escapeHtml():
function escapeHtml(value: unknown) { return `${value ?? ''}` .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, ''');}标题、标签、简介、URL、评分文本都会转义后再写入 innerHTML。
初始化
Astro 客户端路由切换后要重新初始化:
document.addEventListener('astro:page-load', initNeoDbPage);
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initNeoDbPage, { once: true });} else { initNeoDbPage();}最后整理一下:
sync-neodb.mjs负责调用 NeoDB API。public/data/neodb.json是公开数据快照。- 首页构建时读取 JSON,生成最近拾趣。
/neodb/页面运行时读取 JSON,做筛选、时间线和详情抽屉。- token 只存在同步阶段,不进浏览器。
评论
GitHub 登录后可评论。
评论区会在滚动到这里时自动加载。