给书签导航加悬停预览:复刻友链流程,多踩三个坑

1780 字
9 分钟
给书签导航加悬停预览:复刻友链流程,多踩三个坑
给书签导航加悬停预览:复刻友链流程,多踩三个坑

友链页那个悬停预览做完之后,我自己挺喜欢:鼠标划过去卡片顶部长出一条对方首页的截图,不用点进去就知道对面长什么样。

在书签导航页翻的时候发现这边就没这待遇 —— 一排图标加标题,干巴巴的。

于是就有了这篇。想法很简单:书签页和友链页结构几乎一样,都是「一个链接一张卡片」,把友链那套搬过来不就行了。

结果搬是搬过来了,坑比预期多。

先把友链那套抄一遍#

友链那套实现上次已经写过一篇(给友链页加了悬停预览),这里只列一下文件对应关系:

友链书签
scripts/generate-friends-covers.tsscripts/generate-booknav-covers.ts
public/assets/images/friends/covers/public/assets/images/booknav/covers/
src/data/friends-covers.jsonsrc/data/booknav-covers.json
src/pages/api/friends-covers.json.tssrc/pages/api/booknav-covers.json.ts
public/assets/js/friends-covers.jspublic/assets/js/booknav-covers.js
public/assets/css/friends-covers.csspublic/assets/css/booknav-covers.css

数据源从 friendsConfig 换成 booknavConfig,匹配的字段从友链的 siteurl 换成书签的 item.url,其余逻辑一行没改。

截出来大概是这样,一张 640 宽的 webp:

脚本截出来的站点首页封面,640 宽
脚本截出来的站点首页封面,640 宽

到这里为止都很顺。下面三个坑全是「书签和友链不一样」的地方。

坑一:同一个域名挂了好几条书签#

友链基本一站一个域名,文件名直接用域名扫就行:

blog.cuteleaf.cn → blog-cuteleaf-cn.webp

书签不是。我这边 github.com 底下挂了 7 个仓库 —— Firefly 上游、我自己的 fork、CC Switch、BewlyCat 之类的,全都在同一个域名下。

按域名取文件名,这 7 条会互相覆盖,最后只剩一张,而且还不确定剩的是哪张。

改法是把路径也编进文件名:

github.com → github-com.webp
github.com/keleus/BewlyCat → github-com-keleus-bewlycat.webp
github.com/hy4962/Firefly → github-com-hy4962-firefly.webp

万一连路径都一样,再补 -2、-3。清单里的 key 还是原始 URL,所以文件名怎么变都不影响匹配。

GitHub 仓库页截出来的封面,同一域名多条各自独立
GitHub 仓库页截出来的封面,同一域名多条各自独立

坑二:403 页伪装成一张正常的截图#

这个是最阴的。

第一轮跑完我随手翻产物,发现 deviantart 那张是 Cloudflare 的 403 页。

截出来是一张白底黑字、排版很规整的页面:

403 ERROR
The request could not be satisfied.
Request blocked.

问题在于 —— 它长得跟一张正常截图没有任何区别。文件大小正常(21 KB),宽高正常,打开是张像模像样的图。光看产物根本发现不了,得悬停展开才知道是个报错页。

所以必须在截图那一步就把它筛掉。

好在 chrome 的 --dump-dom 和 --screenshot 可以在同一次调用里同时用 —— 一个把渲染后的 HTML 打到 stdout,一个写图片文件,一次请求两样都拿到,不用多跑一遍:

Terminal window
--screenshot=<png 路径> --dump-dom <url>

拿到 HTML 之后,只看前 4000 个字符,命中这些特征就当失败:

const BLOCKED_MARKERS = [
/the request could not be satisfied/i,
/\b403\s+error\b/i,
/you have been blocked/i,
/attention required/i,
/just a moment\.\.\./i,
/enable javascript and cookies to continue/i,
/checking your browser before accessing/i,
/verify you are human/i,
];

只看开头是有意的:正文里偶尔出现「access denied」不算,标题和首屏出现才算。

另外还补了一道,用 sharp 算一下三个通道的标准差,整页一个颜色的空白图直接丢:

const stats = await sharp(pngPath).stats();
if (stats.channels.every((channel) => channel.stdev < 3)) {
throw new Error("截图几乎是纯色空白页,已丢弃");
}

这一道抓的是另一种情况:站点还在渲染,图就先截下来了,出来是张纯白的。后来查失败清单,動漫花園、TouchGal 那几个就是栽在这。

坑三:headless 的 UA 会被直接拒#

chrome-headless-shell 的默认 UA 里带 HeadlessChrome 字样,Cloudflare 那批站点认得出来,直接回验证页。

换成正常的 Chrome UA 之后通过率明显上来:

Terminal window
--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36

卡片得改成网格才装得下封面#

前面都是截图的事,这一步是布局。

书签卡片原来是个横向 flex:左边 14×14 的图标,右边标题和描述。要让顶部能展开一条通栏封面,得把布局改成两列网格:

.booknav-grid .booknav-card {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
column-gap: 0.75rem;
row-gap: 0;
}
.booknav-grid .booknav-card > .ff-booknav-cover {
grid-column: 1 / -1; /* 占满整行 */
}

三个细节和友链不一样:

row-gap 要显式归零。 卡片上原本有 Tailwind 的 gap-3,在 flex 下只影响横向间距,改成 grid 之后它会同时变成行间距,封面和下面那行之间会凭空多出 0.75rem。所以得单独写 row-gap: 0。

负边距的数值跟着卡片 padding 走。 封面要顶到卡片边缘靠的是负边距,这个值必须等于卡片的 padding。友链那边是 p-2.5(0.625rem),书签这边是 p-3(0.75rem),照抄友链的会露一圈边。

网格要 align-items: start。 默认是 stretch,一张卡片展开会把同一行的邻居一起拉高。

「.booknav-card 是 flex」这件事本身是写在 Tailwind 类里的,我的样式是运行时注入的 <link>,未分层,压得过 @layer utilities,所以直接覆写就行,不用加 !important。

脚本怎么进到页面上#

这部分和友链完全一样,顺手做了个小整理。

页脚(src/config/FooterConfig.html)里原本挂着一段友链专用的加载器,只在 /friends/ 才去拉真正的脚本。这次要再加一个书签的,与其复制一段,不如改成数组:

var PREVIEW_SCRIPTS = [
{ path: /^\/friends\/?$/, id: "ff-friend-covers-js", src: "/assets/js/friends-covers.js" },
{ path: /^\/booknav\/?$/, id: "ff-booknav-covers-js", src: "/assets/js/booknav-covers.js" },
];

按 pathname 正则匹配,命中的才插 <script>。以后哪页还要加同款预览,往数组里补一条就行。

映射表走 src/pages/api/booknav-covers.json.ts,加了 export const prerender = true,构建时生成一份静态 JSON,访客拿到的是文件不是接口。

封面图首屏一张都不加载 —— 脚本给 img 挂的是 data-src,卡片真被悬停或聚焦过才写进 src。

结果#

64 个书签,56 个拿到了能看的封面,加起来 812 KB。

失败的 8 个:

站点原因
DeviantArtCloudflare 403,被特征匹配拦下
末日動漫資源庫同上
動漫花園纯色空白页
GYING 动漫筛选 / 发布页纯色空白页
TouchGal纯色空白页
NekoGAL超时
DeepSeek 控制台超时(要登录)

没有封面的书签卡片保持原样,悬停不展开,也不会留个破图。

全量跑一遍大概 9 分钟,主要是等每个站加载。已经截过的会跳过,所以加完新书签再跑一次,只补新的那几张,两三分钟的事。

以后加书签#

Terminal window
npx tsx scripts/generate-booknav-covers.ts

想单独补某一条:

Terminal window
npx tsx scripts/generate-booknav-covers.ts --only=touchgal

全量重来加 --force,只想试水加 --limit=5。

总结#

  • 书签和友链最大的区别是域名会重复,文件名必须带路径段,不然多条书签会共用一张封面。
  • 截图脚本一定要校验产物。Cloudflare 那种 403 页截出来就是一张「看起来很正常」的白底黑字图,不主动筛就会混进线上。
  • --dump-dom 和 --screenshot 能一次调用同时拿 DOM 和图片,校验几乎零成本。
  • 卡片布局从 flex 改 grid 是让封面独占一整行的最省事做法,但记得把 row-gap 归零、负边距对齐卡片 padding。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!

打赏
给书签导航加悬停预览:复刻友链流程,多踩三个坑
https://www.9ll.uk/posts/blog/booknavcoverpreview/
作者
HY
发布于
2026-10-05
许可协议
CC BY-NC-SA 4.0
相关文章智能推荐
1
给友链页加了悬停预览:12 张截图离线截好,主题文件一行没动
博客在 blog.amamo.top 的友链页看到鼠标悬停弹首页截图的效果。我这边的约束是不能动主题源文件,所以换了个做法:截图用本机浏览器离线截、转 webp 存进仓库,卡片用一段独立脚本在浏览器里现场拼。12 张封面一共 190 KB,悬停之前一张都不加载。
2
给博客加了张更新日志页
博客羡慕 rainzt.cn 的博客日志页,把它的页面结构扒下来,用 Firefly 自己的组件体系重做了一遍。全程只新增三个文件,不碰主题源码,以后合并上游更新不受影响。顺便把折叠动画从 max-height 换成了 grid-template-rows。
3
把 Firefly 魔改了五十一版:一份完整的功能清单
博客博客是 fork 的 CuteLeaf/Firefly,从 V1.0 到 V1.49 一共 51 版改动。这篇不再讲故事,直接把改过的功能全部列出来:首页那套贴纸卡片、自己加出来的那批页面、友链与书签的悬停预览和自助申请、全站卡片描边与页脚统计、评论区的折叠与引用、右栏那几个自建组件、一长串性能优化,还有内容与主题分离这类基础设施。每条都附上它到底干什么、关键数字是多少。
4
给博客装了个朋友圈:把友链的更新聚成一条时间线
博客羡慕 rainzt.cn 的朋友圈,把它搬进自己的 Firefly:构建时抓取所有友链的 RSS 合流成时间线,快照兜底,GitHub Actions 每天自动刷新。纯静态,不用服务器,只新增文件不碰主题源码。后来又给时间线加了层来源配额,不让聚合站霸榜。
5
给博客加了个 Steam 游戏库:没后端,蹭朋友圈的定时任务喂数据
博客参考站用 Halo 插件在服务端拉 Steam Web API,我这静态站没后端,改成跟朋友圈同一套定时快照,顺带补了没 Key 时的降级路径。
随机文章随机推荐

评论区

Profile Image of the Author
HY
Hello, I'm HY.
公告
迷茫.... 为了访问速度,本站将会在11月上旬至中旬迁移到0dm.cn并完成备案
分类
标签
最新动态
站点统计
文章
74
分类
8
标签
192
总字数
161,979
运行时长
0 天
最后活动
0 天前
站点信息
构建平台
Vercel
博客版本
Firefly v6.16.8
文章许可
CC BY-NC-SA 4.0
文章目录