feat: 服务器迁移后的全量演进(依赖升级/安全审计/设计统一/截图/SEO 底座化)
在原仓库历史之上同步本服务器上的全部演进: - 迁移至新服务器并重建 PG 数据库 + ddnc 开发域名绑定 - 全部依赖升级至最新(Next 16.3.5 / React 19.3 / better-auth 1.7.5 / Drizzle 0.45 / TS 7), overrides 消除已知 CVE,安全审计 19 项发现全部修复闭环 - 设计统一:各站宽度 token(--container + site-container)、classic hero 重设计、 头部换行修复、页面过渡与 View Transitions - 截图功能:api.adx9.com 截图服务接入(ow=800 本地化 webp、cf=1、去重/限流/占位图) - SEO 底座化:canonical/hreflang/sitemap/JSON-LD/robots 全部收进 core,新站自动继承 - 加载体验:五站五形态路由骨架(data-theme 切换、shimmer 微光、延迟淡入)+ 过滤 useTransition 交叉淡入淡出(card-enter 错峰入场) - 构建:experimental.cpus 限流防 PG 连接打满;清理死组件链
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* # 脚本 capture-screenshots
|
||||
* 说明:批量预热工具网站截图 —— 调用截图 API 并本地化到
|
||||
* SCREENSHOT_STORAGE_DIR(默认 <cwd>/.screenshots)。
|
||||
* 工具详情页的 <img src="/api/screenshots/<slug>"> 命中时即可直接回本地文件。
|
||||
* 用法:bun scripts/capture-screenshots.ts [--force] [--limit N]
|
||||
* --force 忽略已有文件强制重截
|
||||
* --limit 最多处理 N 个工具(默认全部)
|
||||
*/
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import dotenv from "dotenv";
|
||||
import { resolve } from "path";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { tool } from "../src/lib/db/schema";
|
||||
import { ensureScreenshot, isValidSlug } from "../src/lib/screenshot";
|
||||
|
||||
dotenv.config({ path: resolve(process.cwd(), ".env.local") });
|
||||
|
||||
const force = process.argv.includes("--force");
|
||||
const limitIdx = process.argv.indexOf("--limit");
|
||||
const limit = limitIdx > -1 ? Number(process.argv[limitIdx + 1]) : Infinity;
|
||||
|
||||
const client = postgres(process.env.DATABASE_URL!, { prepare: false });
|
||||
const db = drizzle(client);
|
||||
|
||||
const tools = await db
|
||||
.select({ slug: tool.slug, name: tool.nameEn, websiteUrl: tool.websiteUrl })
|
||||
.from(tool)
|
||||
.where(eq(tool.status, "published"));
|
||||
|
||||
let ok = 0;
|
||||
let skip = 0;
|
||||
let fail = 0;
|
||||
let n = 0;
|
||||
|
||||
for (const t of tools) {
|
||||
if (n >= limit) break;
|
||||
n++;
|
||||
if (!t.websiteUrl) {
|
||||
skip++;
|
||||
console.log(`○ ${t.slug}: 无 websiteUrl,跳过`);
|
||||
continue;
|
||||
}
|
||||
if (!isValidSlug(t.slug)) {
|
||||
skip++;
|
||||
console.log(`○ ${t.slug}: slug 不合法,跳过`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const started = Date.now();
|
||||
await ensureScreenshot(t.slug, t.websiteUrl, { force });
|
||||
ok++;
|
||||
console.log(`✓ ${t.slug} (${Date.now() - started}ms)`);
|
||||
} catch (err) {
|
||||
fail++;
|
||||
console.error(`✗ ${t.slug}:`, err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n完成:成功 ${ok} / 跳过 ${skip} / 失败 ${fail},共 ${n}`);
|
||||
client.end();
|
||||
process.exit(fail > 0 ? 1 : 0);
|
||||
@@ -39,6 +39,7 @@ async function main() {
|
||||
|
||||
const lines = [];
|
||||
lines.push("// This file is auto-generated by scripts/generate-blog-manifest.mjs");
|
||||
lines.push("/// <reference path=\"../types/mdx.d.ts\" />");
|
||||
lines.push("// Do not edit manually.\n");
|
||||
lines.push("export const blogModuleLoaders = {");
|
||||
|
||||
|
||||
@@ -39,6 +39,12 @@ import { hashPassword } from "better-auth/crypto";
|
||||
|
||||
dotenv.config({ path: resolve(process.cwd(), ".env.local") });
|
||||
|
||||
// 生产/共享库防误清: NODE_ENV=production 时必须显式 SEED_ALLOW=1(--force 会 TRUNCATE 全库)
|
||||
if (process.env.NODE_ENV === "production" && process.env.SEED_ALLOW !== "1") {
|
||||
console.error("拒绝执行: NODE_ENV=production 下运行 seed 需显式 SEED_ALLOW=1");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = postgres(process.env.DATABASE_URL!, { prepare: false });
|
||||
const db = drizzle(client);
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* # 脚本 verify-skeleton
|
||||
* 说明:验证 5 站的加载体验改造 ——
|
||||
* 1) /tools 过滤:旧内容变暗 → shimmer 骨架覆盖层淡入 → 新卡片错峰入场(交叉淡入淡出);
|
||||
* 2) 路由级骨架屏:各站按 data-theme 只显示自己的形态(.loading-variant)。
|
||||
* 通过给导航请求加 1200ms 延迟让过渡可见;中断 prefetch 请求保证路由骨架能触发。
|
||||
* 截图输出到 /tmp/skel/。
|
||||
* 运行:bun packages/core/scripts/verify-skeleton.ts
|
||||
*/
|
||||
import { chromium } from "playwright";
|
||||
import { mkdirSync } from "node:fs";
|
||||
|
||||
const OUT = "/tmp/skel";
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
|
||||
/** # 常量 SITES —— 站点 → 端口 → 期望的路由骨架形态 */
|
||||
const SITES = [
|
||||
{ name: "ai-stack", port: 3001, variant: "lv-ai-stack" },
|
||||
{ name: "launchai", port: 3002, variant: "lv-launchai" },
|
||||
{ name: "the-ai-ledger", port: 3003, variant: "lv-the-ai-ledger" },
|
||||
{ name: "devai-tools", port: 3004, variant: "lv-devai-tools" },
|
||||
{ name: "findai", port: 3005, variant: "lv-findai" },
|
||||
];
|
||||
|
||||
const results: string[] = [];
|
||||
const browser = await chromium.launch({
|
||||
executablePath: "/opt/brave.com/brave/brave",
|
||||
args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
||||
});
|
||||
|
||||
for (const site of SITES) {
|
||||
const base = `http://localhost:${site.port}`;
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
const r: string[] = [];
|
||||
r.push(`■ ${site.name} (:${site.port})`);
|
||||
|
||||
try {
|
||||
// ── 1. 过滤交叉过渡 ─────────────────────────────
|
||||
await page.goto(`${base}/tools`, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForSelector("a.card-enter", { timeout: 15000 });
|
||||
|
||||
// 过滤导航(带 query 的 /tools 请求)加延迟;prefetch 请求直接中断
|
||||
await page.route("**/tools?*", async (route) => {
|
||||
if (route.request().headers()["next-router-prefetch"]) {
|
||||
return route.abort();
|
||||
}
|
||||
await new Promise((s) => setTimeout(s, 1200));
|
||||
return route.continue();
|
||||
});
|
||||
|
||||
const chip = page
|
||||
.locator("button.rounded-lg")
|
||||
.filter({ hasText: /\(\d+\)/ })
|
||||
.first();
|
||||
await chip.click();
|
||||
await page.waitForTimeout(600); // 骨架覆盖层应已挂载(延迟 200ms + 余量)
|
||||
|
||||
const mid = await page.evaluate(() => ({
|
||||
shimmer: document.querySelectorAll(".shimmer").length,
|
||||
overlay: !!document.querySelector(".skeleton-in"),
|
||||
dimmed: !!document.querySelector('[aria-busy="true"]'),
|
||||
}));
|
||||
r.push(` 过滤中: shimmer×${mid.shimmer} overlay=${mid.overlay} 变暗=${mid.dimmed}`);
|
||||
await page.screenshot({ path: `${OUT}/${site.name}-filter-mid.png` });
|
||||
|
||||
// 等待骨架层卸载(数据到达 + 340ms 退出窗口)
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector(".skeleton-in"),
|
||||
{ timeout: 15000 }
|
||||
);
|
||||
await page.waitForTimeout(500);
|
||||
const after = await page.evaluate(() => ({
|
||||
shimmer: document.querySelectorAll(".shimmer").length,
|
||||
url: location.search,
|
||||
cards: document.querySelectorAll("a.card-enter").length,
|
||||
}));
|
||||
r.push(` 完成后: shimmer×${after.shimmer} 卡片×${after.cards} URL=${after.url}`);
|
||||
await page.screenshot({ path: `${OUT}/${site.name}-filter-after.png` });
|
||||
|
||||
// ── 2. 路由级骨架屏(首页 → /tools)─────────────
|
||||
// 说明:VT 浏览器由 View Transition 覆盖整个等待期;Next 16 在流式响应
|
||||
// 开始后才挂载 loading 兜底。prefetch 全部中断后,用 CDP 限速拖慢 RSC
|
||||
// 流式响应,使兜底骨架窗口可持续数秒,便于校验与截图。
|
||||
await page.goto(`${base}/`, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForTimeout(400);
|
||||
await page.route("**/tools*", async (route) => {
|
||||
if (route.request().headers()["next-router-prefetch"]) {
|
||||
return route.abort();
|
||||
}
|
||||
return route.continue();
|
||||
});
|
||||
const cdp = await ctx.newCDPSession(page);
|
||||
await cdp.send("Network.enable");
|
||||
await cdp.send("Network.emulateNetworkConditions", {
|
||||
offline: false,
|
||||
latency: 0,
|
||||
downloadThroughput: 30 * 1024,
|
||||
uploadThroughput: 30 * 1024,
|
||||
});
|
||||
|
||||
const nav = page.locator('header a[href*="/tools"]').first();
|
||||
await nav.click();
|
||||
|
||||
const routeSkel = await page.evaluate(
|
||||
(expected) =>
|
||||
new Promise<{ shell: boolean; shown: string[]; expected: string }>((resolve) => {
|
||||
let done = false;
|
||||
// 每 60ms 轮询一次,捕捉 .loading-shell 挂载窗口
|
||||
const timer = setInterval(() => {
|
||||
const shell = document.querySelector(".loading-shell");
|
||||
if (shell && !done) {
|
||||
done = true;
|
||||
const shown: string[] = [];
|
||||
document.querySelectorAll(".loading-variant").forEach((el) => {
|
||||
if (getComputedStyle(el).display !== "none") {
|
||||
shown.push(el.className);
|
||||
}
|
||||
});
|
||||
clearInterval(timer);
|
||||
resolve({ shell: true, shown, expected });
|
||||
}
|
||||
}, 60);
|
||||
// 8 秒仍未挂载则报告失败
|
||||
setTimeout(() => {
|
||||
if (!done) {
|
||||
clearInterval(timer);
|
||||
resolve({ shell: false, shown: [], expected });
|
||||
}
|
||||
}, 8000);
|
||||
}),
|
||||
site.variant
|
||||
);
|
||||
const shown = routeSkel.shown.join(",");
|
||||
r.push(
|
||||
` 路由骨架: shell=${routeSkel.shell} 可见类=${shown || "无"} (期望 ${site.variant})`
|
||||
);
|
||||
if (routeSkel.shell) {
|
||||
// 等 .loading-shell 淡入完成(0.3s 动画 + 0.15s 延迟),保持限速让骨架窗口持续
|
||||
await page.waitForTimeout(600);
|
||||
await page.screenshot({ path: `${OUT}/${site.name}-route-skeleton.png` });
|
||||
}
|
||||
await cdp.send("Network.emulateNetworkConditions", {
|
||||
offline: false,
|
||||
latency: 0,
|
||||
downloadThroughput: -1,
|
||||
uploadThroughput: -1,
|
||||
});
|
||||
|
||||
await page.waitForSelector("a.card-enter", { timeout: 30000 });
|
||||
r.push(" 路由导航完成: /tools 已渲染");
|
||||
const ok =
|
||||
routeSkel.shell &&
|
||||
routeSkel.shown.length === 1 &&
|
||||
routeSkel.shown[0].includes(site.variant);
|
||||
results.push(r.join("\n") + (ok ? "\n ✓ 通过" : "\n ✗ 校验不符"));
|
||||
} catch (e) {
|
||||
r.push(` ✗ 失败: ${e instanceof Error ? e.message.split("\n")[0] : e}`);
|
||||
results.push(r.join("\n"));
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log(results.join("\n---\n"));
|
||||
Reference in New Issue
Block a user