博客相关 NeoDBAstroJavaScript前端

拾趣页的数据来自 NeoDB,但页面不是直接请求 NeoDB。

实际链路是:

txt
NeoDB API
-> scripts/sync-neodb.mjs
-> public/data/neodb.json
-> Astro 构建首页
-> /neodb/ 前端读取 JSON 渲染

核心点只有一个:带 token 的请求只发生在构建前,浏览器只读取公开 JSON。

同步脚本

脚本入口是 scripts/sync-neodb.mjs,命令放在 package.json

json
{
"sync:neodb": "node scripts/sync-neodb.mjs"
}

先定义要同步的 shelf 和分类:

js
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:

js
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 拼:

js
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:

js
const response = await fetch(url, {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`,
'User-Agent': 'skcso-neodb-sync',
},
signal: controller.signal,
});

四个 shelf 并发请求:

js
const responses = await Promise.all(
SHELF_TYPES.map(async (shelfType) => ({
shelfType,
response: await fetchShelf(handle, shelfType, token),
})),
);

清洗字段

NeoDB 返回的对象比较大,站点只保留页面要用的字段。清洗函数是 toNeoDBItem()

js
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 返回相对路径时前端失效:

js
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:

js
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

js
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');

结构大概是:

json
{
"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 前同步:

yaml
- 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:

ts
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 = [];
}
}

然后取最新五条:

ts
const latestNeoDbItems = neodbItems.slice(0, 5);
const neodbCount = neodbItems.length;

这里是构建时读取,所以首页 HTML 里直接包含最近拾趣。页面打开后不再额外请求。

首页热力图也把 NeoDB 标记算进去:

ts
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 里循环生成所有卡片:

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>

页面底部加载脚本:

astro
<script>
import '../../scripts/neodb-page.ts';
</script>

分类 tab 和状态 tab 也是静态按钮,通过 data-categorydata-shelf 标记当前筛选值。

前端渲染

src/scripts/neodb-page.ts 里先找根节点,防止重复绑定:

ts
const root = document.querySelector<HTMLElement>('[data-neodb-root]');
if (!root || root.dataset.bound === 'true') return;
root.dataset.bound = 'true';

读取 JSON:

ts
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。之后切换分类、切换状态都不再发请求,只在本地筛选。

筛选和排序

状态顺序:

ts
const shelfOrder = ['progress', 'wishlist', 'complete', 'dropped'];
const categoryOrder = ['book', 'movie', 'tv', 'music', 'game', 'podcast', 'performance'];

同一个 shelf 在不同分类里显示不同文案:

ts
const categoryShelfLabels = {
book: { wishlist: '想读', progress: '在读', complete: '读过', dropped: '搁置' },
game: { wishlist: '想玩', progress: '在玩', complete: '玩过', dropped: '弃玩' },
music: { wishlist: '想听', progress: '在听', complete: '听过', dropped: '搁置' },
podcast: { wishlist: '想听', progress: '在听', complete: '听过', dropped: '搁置' },
};

收集数据时按当前 shelf 和 category 遍历:

ts
const pickedShelves = shelf === 'all' ? shelfOrder : [shelf];
const pickedCategories = category === 'all' ? categoryOrder : [category];

排序按标记时间倒序,时间一样再按标题:

ts
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 时只是更新状态并重渲染:

ts
tab.addEventListener('click', () => {
activeCategory = getCategory(tab.getAttribute('data-category') || 'all');
setActiveTabs();
closeDrawer();
renderCurrent();
});

时间线

渲染前先按 createdTime 分组:

ts
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 找到当前渲染列表里的条目:

ts
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]);
});

抽屉里展示封面、标题、分类、状态、日期、简介、评分、标签、来源链接。

打开和关闭只改两个状态:

ts
drawer.dataset.open = 'true';
drawer.setAttribute('aria-hidden', 'false');
document.body.classList.add('neodb-drawer-open');
ts
drawer.dataset.open = 'false';
drawer.setAttribute('aria-hidden', 'true');
document.body.classList.remove('neodb-drawer-open');

Escape 也能关闭:

ts
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') closeDrawer();
});

转义

拾趣页有不少 HTML 是字符串拼出来的,所以外部数据统一走 escapeHtml()

ts
function escapeHtml(value: unknown) {
return `${value ?? ''}`
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

标题、标签、简介、URL、评分文本都会转义后再写入 innerHTML

初始化

Astro 客户端路由切换后要重新初始化:

ts
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 只存在同步阶段,不进浏览器。