refactor: 主题系统 → monorepo 五个独立网站
- packages/core: @aidir/core 底座库(src/pages 页面组件体 1:1 镜像路由树 / kit 站点外壳工厂 / content/blog / scripts) - sites/: 5 个独立 Next.js 网站(ai-stack / launchai / the-ai-ledger / devai-tools / findai) - 每站独立 package.json / next.config / tailwind / messages / .env / 构建 - src/app 约 56 个薄路由文件(re-export 底座页面 + 内联路由段配置 revalidate/dynamic) - src/site/brand.tsx 站点配置 + src/site/home.tsx 站点首页(createMarketingLayout / createHomePage 工厂) - generate-site.mjs: 站点生成器(新增网站 = SITES 加条目,零 Core 修改) - start-all-sites.sh: 一键启动 3001-3005 - next-config.mjs: 底座统一 Next 配置工厂(next-intl + MDX + standalone) - 五站共享同一套 PostgreSQL / Better Auth / Admin(设计文档 §42) - 验证: 5 站 build OK / typecheck OK / 55 路由冒烟 200 / 品牌-内容-截图 差异确认 / 登录-投票-重复检测 e2e PASS
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import {
|
||||
trendingScore,
|
||||
bayesianRating,
|
||||
timeDecayFactor,
|
||||
popularScore,
|
||||
parseToolSlugRoute,
|
||||
normalizeDomain,
|
||||
slugify,
|
||||
type ToolSignals,
|
||||
} from "../src/lib/ranking/score";
|
||||
|
||||
// ========== Trending 评分 ==========
|
||||
|
||||
describe("trendingScore", () => {
|
||||
const baseSignals: ToolSignals = {
|
||||
votesInWindow: 10,
|
||||
favoritesInWindow: 5,
|
||||
reviewsInWindow: 2,
|
||||
viewsInWindow: 100,
|
||||
ageHours: 24,
|
||||
};
|
||||
|
||||
test("互动越多分数越高", () => {
|
||||
const more: ToolSignals = { ...baseSignals, votesInWindow: 50 };
|
||||
expect(trendingScore(more)).toBeGreaterThan(trendingScore(baseSignals));
|
||||
});
|
||||
|
||||
test("无互动分数为 0(避免空榜填充)", () => {
|
||||
const empty: ToolSignals = {
|
||||
votesInWindow: 0,
|
||||
favoritesInWindow: 0,
|
||||
reviewsInWindow: 0,
|
||||
viewsInWindow: 0,
|
||||
ageHours: 100,
|
||||
};
|
||||
expect(trendingScore(empty)).toBe(0);
|
||||
});
|
||||
|
||||
test("新工具衰减慢:相同互动下,新工具 > 老工具(防止老工具永久霸榜)", () => {
|
||||
const fresh = trendingScore({ ...baseSignals, ageHours: 24 });
|
||||
const old = trendingScore({ ...baseSignals, ageHours: 24 * 365 });
|
||||
expect(fresh).toBeGreaterThan(old);
|
||||
});
|
||||
|
||||
test("评论权重高于投票", () => {
|
||||
const reviewHeavy = trendingScore({ ...baseSignals, reviewsInWindow: 3, votesInWindow: 0 });
|
||||
const voteHeavy = trendingScore({ ...baseSignals, reviewsInWindow: 0, votesInWindow: 3 });
|
||||
expect(reviewHeavy).toBeGreaterThan(voteHeavy);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== 贝叶斯评分 ==========
|
||||
|
||||
describe("bayesianRating", () => {
|
||||
test("评论极少时被先验拉低", () => {
|
||||
const oneFiveStar = bayesianRating(5, 1, 4.0);
|
||||
expect(oneFiveStar).toBeLessThan(5);
|
||||
});
|
||||
|
||||
test("评论很多时接近观测均值", () => {
|
||||
const manyReviews = bayesianRating(4.8, 200, 4.0);
|
||||
expect(manyReviews).toBeGreaterThan(4.7);
|
||||
expect(manyReviews).toBeLessThanOrEqual(4.8);
|
||||
});
|
||||
|
||||
test("样本相同的两工具,均值高者胜", () => {
|
||||
expect(bayesianRating(4.8, 10, 4.0)).toBeGreaterThan(bayesianRating(4.2, 10, 4.0));
|
||||
});
|
||||
|
||||
test("无评论返回 0", () => {
|
||||
expect(bayesianRating(5, 0, 4)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== 时间衰减 / Popular ==========
|
||||
|
||||
describe("timeDecayFactor", () => {
|
||||
test("未衰减为 1,随时间单调递减", () => {
|
||||
expect(timeDecayFactor(0)).toBe(1);
|
||||
expect(timeDecayFactor(100)).toBeLessThan(1);
|
||||
expect(timeDecayFactor(200)).toBeLessThan(timeDecayFactor(100));
|
||||
});
|
||||
|
||||
test("半衰期处恰好为 0.5", () => {
|
||||
expect(timeDecayFactor(168, 168)).toBeCloseTo(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("popularScore", () => {
|
||||
test("全量互动加权求和", () => {
|
||||
expect(popularScore(10, 10, 10, 1000)).toBe(10 * 2 + 10 * 1.5 + 10 * 3 + 1000 * 0.1);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== 复合路由解析(Alternatives / Comparison SEO) ==========
|
||||
|
||||
describe("parseToolSlugRoute", () => {
|
||||
test("普通工具", () => {
|
||||
expect(parseToolSlugRoute("chatgpt")).toEqual({ kind: "tool", slug: "chatgpt" });
|
||||
});
|
||||
|
||||
test("alternatives 页", () => {
|
||||
expect(parseToolSlugRoute("chatgpt-alternatives")).toEqual({
|
||||
kind: "alternatives",
|
||||
base: "chatgpt",
|
||||
});
|
||||
});
|
||||
|
||||
test("vs 对比页", () => {
|
||||
expect(parseToolSlugRoute("cursor-vs-windsurf")).toEqual({
|
||||
kind: "comparison",
|
||||
a: "cursor",
|
||||
b: "windsurf",
|
||||
});
|
||||
});
|
||||
|
||||
test("名称中带连字符的对比页", () => {
|
||||
expect(parseToolSlugRoute("github-copilot-vs-tabnine")).toEqual({
|
||||
kind: "comparison",
|
||||
a: "github-copilot",
|
||||
b: "tabnine",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ========== 域名归一化(重复工具检测) ==========
|
||||
|
||||
describe("normalizeDomain", () => {
|
||||
test("https URL 提取主机", () => {
|
||||
expect(normalizeDomain("https://chatgpt.com/")).toBe("chatgpt.com");
|
||||
});
|
||||
|
||||
test("去 www", () => {
|
||||
expect(normalizeDomain("www.anthropic.com")).toBe("anthropic.com");
|
||||
});
|
||||
|
||||
test("带路径与查询参数", () => {
|
||||
expect(normalizeDomain("https://openai.com/chatgpt?ref=x")).toBe("openai.com");
|
||||
});
|
||||
|
||||
test("子域保留(重复检测按主机比较)", () => {
|
||||
expect(normalizeDomain("http://chat.openai.com")).toBe("chat.openai.com");
|
||||
});
|
||||
|
||||
test("非法输入返回 null", () => {
|
||||
expect(normalizeDomain("")).toBeNull();
|
||||
expect(normalizeDomain("not a url")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ========== slug 生成 ==========
|
||||
|
||||
describe("slugify", () => {
|
||||
test("空格转连字符 + 小写", () => {
|
||||
expect(slugify("Best AI Writing Tools")).toBe("best-ai-writing-tools");
|
||||
});
|
||||
|
||||
test("去除特殊字符", () => {
|
||||
expect(slugify("DALL·E 3!")).toBe("dalle-3");
|
||||
});
|
||||
|
||||
test("合并连续连字符", () => {
|
||||
expect(slugify("a -- b")).toBe("a-b");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { generateSeoPage, generateToolFaqs } from "../src/lib/seo/page-generator";
|
||||
|
||||
describe("generateSeoPage", () => {
|
||||
const base = {
|
||||
name: "Blogging",
|
||||
kind: "use-case" as const,
|
||||
brandName: "AI Stack",
|
||||
toolCount: 12,
|
||||
freeCount: 4,
|
||||
topToolNames: ["ChatGPT", "Claude", "Grammarly"],
|
||||
year: 2026,
|
||||
};
|
||||
|
||||
test("生成 title / h1 / intro / faqs", () => {
|
||||
const page = generateSeoPage(base);
|
||||
expect(page.title).toContain("Best AI Tools for Blogging in 2026");
|
||||
expect(page.h1).toBe("Best AI Tools for Blogging");
|
||||
expect(page.intro).toContain("12");
|
||||
expect(page.faqs.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
test("description 含工具数与排序说明", () => {
|
||||
const page = generateSeoPage(base);
|
||||
expect(page.description).toContain("12");
|
||||
expect(page.description).toContain("votes");
|
||||
});
|
||||
|
||||
test("FAQ 具体化:引用真实工具名", () => {
|
||||
const page = generateSeoPage(base);
|
||||
const firstAnswer = page.faqs[0].answer;
|
||||
expect(firstAnswer).toContain("ChatGPT");
|
||||
});
|
||||
|
||||
test("free 数量反映到 FAQ", () => {
|
||||
const page = generateSeoPage(base);
|
||||
const freeFaq = page.faqs.find((f) => f.question.startsWith("Are there free"));
|
||||
expect(freeFaq?.answer).toContain("4");
|
||||
});
|
||||
|
||||
test("use case + audience 生成交叉引用 FAQ", () => {
|
||||
const page = generateSeoPage({ ...base, audienceName: "Marketers" });
|
||||
const crossFaq = page.faqs.find((f) => f.question.toLowerCase().includes("marketer"));
|
||||
expect(crossFaq).toBeDefined();
|
||||
});
|
||||
|
||||
test("零数据实体也能安全生成(无 NaN/undefined)", () => {
|
||||
const page = generateSeoPage({
|
||||
name: "Research",
|
||||
kind: "audience",
|
||||
brandName: "X",
|
||||
toolCount: 0,
|
||||
});
|
||||
expect(page.intro).not.toContain("undefined");
|
||||
expect(page.title).not.toContain("NaN");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateToolFaqs", () => {
|
||||
test("由定价生成免费问答", () => {
|
||||
const faqs = generateToolFaqs({ name: "ChatGPT", pricing: "freemium" });
|
||||
const freeFaq = faqs.find((f) => f.question.includes("free"));
|
||||
expect(freeFaq?.answer).toContain("freemium");
|
||||
});
|
||||
|
||||
test("含替代品问答", () => {
|
||||
const faqs = generateToolFaqs(
|
||||
{ name: "Cursor", pricing: "freemium" },
|
||||
["Windsurf", "Copilot"]
|
||||
);
|
||||
const altFaq = faqs.find((f) => f.question.includes("alternatives"));
|
||||
expect(altFaq?.answer).toContain("Windsurf");
|
||||
});
|
||||
|
||||
test("描述自动补句号", () => {
|
||||
const faqs = generateToolFaqs({
|
||||
name: "X",
|
||||
description: "It does things",
|
||||
});
|
||||
expect(faqs[0].answer).toBe("It does things.");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user