- 原生 Next.js App Router + 严格 TypeScript 实现花魁短剧 PC/H5 页面,按 src/features/{catalog,content,playback} 组织,保留完整 SEO(metadata、JSON-LD、canonical、robots、llms、sitemap、IndexNow)。
- 主域名通过 SITE_URL=https://huakuidj.com 强制覆盖后端 foreverUrlAddr(config/*.env、src/server/config.ts、src/server/seo/shared.ts、src/server/services/shared.ts),支持邮箱改为 support@huakuidj.com。
- 移除登录与账户体系:删除 /account 全部页面、登录/注册/充值弹窗、收藏点赞、评论、金币解锁及对应 BFF 接口;旧账户地址 GET/HEAD 统一 301 到首页(src/lib/routes.ts REMOVED_ACCOUNT_PATHS),保留游客会话、反馈、图片上传、观看上报与搜索联想。
- 播放页:评论位改为同类推荐 + 热播榜单,锁定集改为 App 观看遮罩与底部弹层,新增简介标签、分集图例与独立视觉皮肤(src/features/playback、src/styles/pages/playback.css)。
- 卡片:短剧/视频卡状态与时长移至信息行胶囊标签,封面角标移至左上角(src/components/catalog/Cards.tsx、src/styles/foundations/base.css)。
- 同步更新单元测试、浏览器冒烟脚本、路由清单与 README。
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
175 lines
11 KiB
TypeScript
175 lines
11 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { copyFile, mkdir, mkdtemp, readFile, readdir, rm, stat, utimes, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
import { readSitemapSyncConfig, syncSitemaps, type SitemapSyncConfig, type SitemapTransfer } from "../scripts/sync-sitemap";
|
|
import nextConfig from "../next.config";
|
|
|
|
const index = (names: string[]) => Buffer.from(`\ufeff<?xml version="1.0" encoding="UTF-8"?>\r\n<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\r\n${names.map((name) => `<sitemap><loc>https://old.example/sitemaps/${name}</loc><lastmod>2026-10-12</lastmod></sitemap>`).join("\r\n")}\r\n</sitemapindex>\r\n`);
|
|
const shard = Buffer.from(`<?xml version="1.0" encoding="UTF-8"?>\r\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><!-- 原始后端产物 --><url><loc>https://old.example/play/original/?a=1&b=2</loc><lastmod>2026-10-12</lastmod><priority>0.80</priority></url><url><loc><![CDATA[https://old.example/series-play/a/]]></loc></url></urlset>\r\n`);
|
|
|
|
async function fixture() {
|
|
const root = await mkdtemp(join(tmpdir(), "huakui-sitemap-sync-test-"));
|
|
const remote = join(root, "remote"), destination = join(root, "local");
|
|
await mkdir(remote);
|
|
const requests: string[] = [];
|
|
const config: SitemapSyncConfig = { host: "readonly@example.test", port: 22889, sourceDirectory: "/backend/h5/template/pages/sitemaps", destinationDirectory: destination };
|
|
const put = async (name: string, bytes: string | Buffer, modification = new Date("2026-10-12T03:04:05Z")) => {
|
|
const file = join(remote, name);
|
|
await writeFile(file, bytes);
|
|
await utimes(file, modification, modification);
|
|
return file;
|
|
};
|
|
const transfer: SitemapTransfer = async (name, target) => {
|
|
requests.push(name);
|
|
const source = join(remote, name), info = await stat(source);
|
|
await copyFile(source, target);
|
|
await utimes(target, info.atime, info.mtime);
|
|
};
|
|
return { root, remote, destination, requests, config, put, transfer, close: () => rm(root, { recursive: true, force: true }) };
|
|
}
|
|
|
|
test("sync configuration uses development, local overrides and then process values", async () => {
|
|
const f = await fixture();
|
|
try {
|
|
await mkdir(join(f.root, "config"));
|
|
await writeFile(join(f.root, "config/development.env"), "SITEMAP_SYNC_HOST=readonly@base.test\nSITEMAP_SYNC_PORT=22889\nSITEMAP_SYNC_DIR=/base/sitemaps\nSITEMAP_DIR=./template/pages/sitemaps\n");
|
|
await writeFile(join(f.root, "config/development.local.env"), "SITEMAP_SYNC_HOST=readonly@local.test\nSITEMAP_SYNC_DIR=/local/sitemaps\n");
|
|
await writeFile(join(f.root, "config/production.env"), "SITEMAP_SYNC_HOST=wrong@production.test\n");
|
|
assert.deepEqual(await readSitemapSyncConfig(f.root, { APP_ENV: "development", SITEMAP_SYNC_HOST: undefined }), {
|
|
host: "readonly@local.test", port: 22889, sourceDirectory: "/local/sitemaps", destinationDirectory: join(f.root, "template/pages/sitemaps"),
|
|
});
|
|
assert.deepEqual(await readSitemapSyncConfig(f.root, { SITEMAP_SYNC_HOST: "readonly@process.test", SITEMAP_SYNC_PORT: "2222", SITEMAP_SYNC_DIR: "/process/sitemaps", SITEMAP_DIR: "./snapshot" }), {
|
|
host: "readonly@process.test", port: 2222, sourceDirectory: "/process/sitemaps", destinationDirectory: join(f.root, "snapshot"),
|
|
});
|
|
for (const APP_ENV of ["test", "production"]) await assert.rejects(readSitemapSyncConfig(f.root, { APP_ENV, SITEMAP_DIR: "/backend/generated" }), /only supports APP_ENV=development/);
|
|
} finally { await f.close(); }
|
|
});
|
|
|
|
test("SSH configuration rejects unsafe parameters before transfer or local writes", async () => {
|
|
const f = await fixture();
|
|
try {
|
|
const cases: Partial<SitemapSyncConfig>[] = [
|
|
{ host: "-oProxyCommand=evil" }, { host: "user@host;echo" }, { host: "user@$(command)" },
|
|
{ port: 0 }, { port: 65536 }, { port: 22.5 },
|
|
{ sourceDirectory: "relative/path" }, { sourceDirectory: "/backend/../outside" },
|
|
{ sourceDirectory: "/backend/$(command)" }, { sourceDirectory: "/backend/space here" },
|
|
{ destinationDirectory: "/" }, { destinationDirectory: "bad\npath" },
|
|
];
|
|
for (const values of cases) await assert.rejects(syncSitemaps({ ...f.config, ...values }, f.transfer));
|
|
assert.deepEqual(f.requests, []);
|
|
await assert.rejects(stat(f.destination), { code: "ENOENT" });
|
|
} finally { await f.close(); }
|
|
});
|
|
|
|
test("sync reads only indexed shards and preserves bytes, original URLs, mtimes and unrelated local files", async () => {
|
|
const f = await fixture();
|
|
try {
|
|
const names = ["sitemap-pages.xml", "sitemap-playletdetail.xml", "sitemap-seriesplay.xml"];
|
|
await f.put("sitemap.xml", index([...names, names[0]!]));
|
|
for (const [number, name] of names.entries()) await f.put(name, shard, new Date(Date.UTC(2026, 9, 12, 3, 4, number + 1)));
|
|
await f.put("sitemap-not-indexed.xml", "do not copy remote directory");
|
|
await mkdir(f.destination);
|
|
await writeFile(join(f.destination, "README.md"), "keep local documentation");
|
|
await writeFile(join(f.destination, "sitemap-old.xml"), "keep unreferenced generation");
|
|
const result = await syncSitemaps(f.config, f.transfer);
|
|
assert.deepEqual(result, { directory: f.destination, files: [...names, "sitemap.xml"] });
|
|
assert.deepEqual(f.requests, ["sitemap.xml", ...names, "sitemap.xml"]);
|
|
for (const name of result.files) {
|
|
assert.deepEqual(await readFile(join(f.destination, name)), await readFile(join(f.remote, name)));
|
|
assert.equal((await stat(join(f.destination, name))).mtimeMs, (await stat(join(f.remote, name))).mtimeMs);
|
|
}
|
|
assert.equal(await readFile(join(f.destination, "README.md"), "utf8"), "keep local documentation");
|
|
assert.equal(await readFile(join(f.destination, "sitemap-old.xml"), "utf8"), "keep unreferenced generation");
|
|
assert.deepEqual((await readdir(f.destination)).sort(), [...result.files, "README.md", "sitemap-old.xml"].sort());
|
|
} finally { await f.close(); }
|
|
});
|
|
|
|
test("invalid, non-XML and unsafe index references never replace an existing local generation", async () => {
|
|
const f = await fixture();
|
|
try {
|
|
await mkdir(f.destination);
|
|
await writeFile(join(f.destination, "sitemap.xml"), "existing local index");
|
|
const invalid = [
|
|
"", "<html><body>server error</body></html>", "<sitemapindex><sitemap></sitemapindex>",
|
|
'<!DOCTYPE sitemapindex [<!ENTITY external SYSTEM "file:///secret">]><sitemapindex/>'+"\n",
|
|
"<sitemapindex><sitemap><loc>https://old.example/sitemaps/sitemap-a.xml?token=secret</loc></sitemap></sitemapindex>",
|
|
"<sitemapindex><sitemap><loc>https://old.example/sitemaps/sitemap-a.xml#hash</loc></sitemap></sitemapindex>",
|
|
"<sitemapindex><sitemap><loc>https://user:password@old.example/sitemaps/sitemap-a.xml</loc></sitemap></sitemapindex>",
|
|
"<sitemapindex><sitemap><loc>file:///sitemaps/sitemap-a.xml</loc></sitemap></sitemapindex>",
|
|
"<sitemapindex><sitemap><loc>https://old.example/sitemaps/../private.xml</loc></sitemap></sitemapindex>",
|
|
"<sitemapindex><sitemap><loc>https://old.example/sitemaps/sitemap-%2Fa.xml</loc></sitemap></sitemapindex>",
|
|
"<sitemapindex><sitemap><loc>https://old.example/sitemaps/sitemap-a.xml</loc><loc>https://old.example/sitemaps/sitemap-b.xml</loc></sitemap></sitemapindex>",
|
|
"<sitemapindex>&unknown;</sitemapindex>", '<sitemapindex a="1" a="2"/>', "<sitemapindex>]]></sitemapindex>",
|
|
"<sitemapindex/>trailing text", Buffer.from([0xff, 0xfe]),
|
|
];
|
|
for (const contents of invalid) {
|
|
await f.put("sitemap.xml", contents);
|
|
await assert.rejects(syncSitemaps(f.config, f.transfer));
|
|
assert.equal(await readFile(join(f.destination, "sitemap.xml"), "utf8"), "existing local index");
|
|
assert.deepEqual(await readdir(f.destination), ["sitemap.xml"]);
|
|
}
|
|
} finally { await f.close(); }
|
|
});
|
|
|
|
test("missing or invalid referenced shards abort without replacing local files", async () => {
|
|
const f = await fixture();
|
|
try {
|
|
await mkdir(f.destination);
|
|
await writeFile(join(f.destination, "sitemap.xml"), "existing index");
|
|
await writeFile(join(f.destination, "sitemap-pages.xml"), "existing shard");
|
|
await f.put("sitemap.xml", index(["sitemap-pages.xml", "sitemap-seriesplay.xml"]));
|
|
await f.put("sitemap-pages.xml", shard);
|
|
await assert.rejects(syncSitemaps(f.config, f.transfer), { code: "ENOENT" });
|
|
const invalid = ["", "<html/>", "<urlset><url/></urlset>", "<urlset><url><loc>not an absolute URL</loc></url></urlset>", "<urlset><url><loc>https://valid.test/</loc></url></urlsets>", "<urlset>unexpected</urlset>"];
|
|
for (const contents of invalid) {
|
|
await f.put("sitemap-seriesplay.xml", contents);
|
|
await assert.rejects(syncSitemaps(f.config, f.transfer));
|
|
}
|
|
assert.equal(await readFile(join(f.destination, "sitemap.xml"), "utf8"), "existing index");
|
|
assert.equal(await readFile(join(f.destination, "sitemap-pages.xml"), "utf8"), "existing shard");
|
|
assert.deepEqual((await readdir(f.destination)).sort(), ["sitemap-pages.xml", "sitemap.xml"]);
|
|
} finally { await f.close(); }
|
|
});
|
|
|
|
test("an index changing during download fails before creating the local output directory", async () => {
|
|
const f = await fixture();
|
|
try {
|
|
await f.put("sitemap.xml", index(["sitemap-pages.xml"]));
|
|
await f.put("sitemap-pages.xml", shard);
|
|
let reads = 0;
|
|
const transfer: SitemapTransfer = async (name, destination) => {
|
|
if (name === "sitemap.xml" && ++reads === 2) await f.put("sitemap.xml", index(["sitemap-seriesplay.xml"]));
|
|
await f.transfer(name, destination);
|
|
};
|
|
await assert.rejects(syncSitemaps(f.config, transfer), /index changed during synchronization/);
|
|
await assert.rejects(stat(f.destination), { code: "ENOENT" });
|
|
} finally { await f.close(); }
|
|
});
|
|
|
|
test("valid empty indexes and empty URL sets remain byte-identical instead of generating content", async () => {
|
|
const f = await fixture();
|
|
try {
|
|
await f.put("sitemap.xml", index([]));
|
|
assert.deepEqual((await syncSitemaps(f.config, f.transfer)).files, ["sitemap.xml"]);
|
|
assert.deepEqual(await readFile(join(f.destination, "sitemap.xml")), index([]));
|
|
await f.put("sitemap.xml", index(["sitemap-empty.xml"]));
|
|
const empty = '<sm:urlset xmlns:sm="http://www.sitemaps.org/schemas/sitemap/0.9"/>';
|
|
await f.put("sitemap-empty.xml", empty);
|
|
await syncSitemaps(f.config, f.transfer);
|
|
assert.equal(await readFile(join(f.destination, "sitemap-empty.xml"), "utf8"), empty);
|
|
} finally { await f.close(); }
|
|
});
|
|
|
|
test("Next traces robots and media keys while explicitly excluding backend and developer sitemap files", () => {
|
|
const includes = nextConfig.outputFileTracingIncludes?.["/*"] || [];
|
|
const excludes = nextConfig.outputFileTracingExcludes?.["/*"] || [];
|
|
assert.ok(includes.includes("./config/secrets/m3u8.key"));
|
|
assert.ok(includes.includes("./template/pages/robots.txt"));
|
|
assert.equal(includes.some((pattern) => /sitemap/.test(pattern)), false);
|
|
assert.ok(excludes.includes("./template/pages/sitemaps/**/*"));
|
|
assert.ok(excludes.includes("./template/pages/sitemap.xml"));
|
|
assert.ok(excludes.includes("./data/sitemaps/**/*"));
|
|
});
|