- 原生 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>
210 lines
11 KiB
TypeScript
210 lines
11 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { mkdtemp, rm, utimes, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import test from "node:test";
|
|
import { serveSEOFile } from "../src/server/seo/file-response";
|
|
|
|
const modified = new Date("2024-01-02T03:04:05.789Z");
|
|
const same = "Tue, 02 Jan 2024 03:04:05 GMT";
|
|
const older = "Tue, 02 Jan 2024 03:04:04 GMT";
|
|
const newer = "Tue, 02 Jan 2024 03:04:06 GMT";
|
|
const request = (values: Record<string, string | undefined> = {}, method = "GET") => {
|
|
const headers = new Headers();
|
|
for (const [key, value] of Object.entries(values)) if (value !== undefined) headers.set(key, value);
|
|
return new Request("https://site.test/sitemap.xml", { method, headers });
|
|
};
|
|
async function mounted(bytes: string | Uint8Array, work: (file: string) => Promise<void>, timestamp = modified) {
|
|
const directory = await mkdtemp(join(tmpdir(), "huakui-seo-file-"));
|
|
const file = join(directory, "sitemap.xml");
|
|
try { await writeFile(file, bytes); await utimes(file, timestamp, timestamp); await work(file); }
|
|
finally { await rm(directory, { recursive: true, force: true }); }
|
|
}
|
|
|
|
test("mounted XML is streamed unchanged with Go file metadata and no invented cache validators", async () => {
|
|
const raw = Buffer.from('<?xml version="1.0"?>\n<loc>https://source.test/play/中文/?raw=1&keep=2</loc>\n');
|
|
await mounted(raw, async (file) => {
|
|
const response = await serveSEOFile(request(), file);
|
|
assert.equal(response.status, 200);
|
|
assert.deepEqual(Object.fromEntries(response.headers), {
|
|
"accept-ranges": "bytes", "content-length": String(raw.length), "content-type": "text/xml; charset=utf-8", "last-modified": same,
|
|
});
|
|
assert.deepEqual(Buffer.from(await response.arrayBuffer()), raw);
|
|
});
|
|
});
|
|
|
|
test("empty and non-XML .xml files remain raw file responses", async () => {
|
|
for (const raw of [Buffer.alloc(0), Buffer.from([0, 255, 1, 128]), Buffer.from("not XML")])
|
|
await mounted(raw, async (file) => {
|
|
const response = await serveSEOFile(request(), file);
|
|
assert.equal(response.status, 200);
|
|
assert.equal(response.headers.get("content-type"), "text/xml; charset=utf-8");
|
|
assert.equal(response.headers.get("content-length"), String(raw.length));
|
|
assert.deepEqual(Buffer.from(await response.arrayBuffer()), raw);
|
|
});
|
|
});
|
|
|
|
test("conditional dates use seconds and preserve Go precondition ordering before ranges", async () => {
|
|
await mounted("0123456789", async (file) => {
|
|
for (const headers of [
|
|
{ "if-modified-since": same }, { "if-modified-since": newer },
|
|
{ "if-modified-since": same, range: "invalid" },
|
|
{ "if-unmodified-since": "invalid", "if-modified-since": same },
|
|
]) {
|
|
const response = await serveSEOFile(request(headers), file);
|
|
assert.equal(response.status, 304);
|
|
assert.deepEqual(Object.fromEntries(response.headers), { "last-modified": same });
|
|
assert.equal(await response.text(), "");
|
|
}
|
|
for (const headers of [
|
|
{ "if-unmodified-since": older },
|
|
{ "if-unmodified-since": older, "if-modified-since": newer, range: "invalid" },
|
|
]) {
|
|
const response = await serveSEOFile(request(headers), file);
|
|
assert.equal(response.status, 412);
|
|
assert.equal(response.headers.get("last-modified"), same);
|
|
assert.equal(response.headers.get("content-length"), "0");
|
|
assert.equal(response.headers.has("content-type"), false);
|
|
assert.equal(await response.text(), "");
|
|
}
|
|
for (const headers of [{ "if-modified-since": older }, { "if-unmodified-since": same }, { "if-unmodified-since": newer }]) {
|
|
const response = await serveSEOFile(request(headers), file);
|
|
assert.equal(response.status, 200);
|
|
assert.equal(await response.text(), "0123456789");
|
|
}
|
|
});
|
|
});
|
|
|
|
test("HTTP dates accept RFC850 and asctime while invalid dates are ignored", async () => {
|
|
await mounted("0123456789", async (file) => {
|
|
for (const date of ["Tuesday, 02-Jan-24 03:04:05 GMT", "Tue Jan 2 03:04:05 2024", "Tue, 02 Jan 2024 03:04:05.123 GMT", "Tue, 02 Jan 2024 03:04:05 GMT", "Tue, 02 Jan 2024 3:04:05 GMT"])
|
|
assert.equal((await serveSEOFile(request({ "if-modified-since": date }), file)).status, 304, date);
|
|
for (const date of ["2024-01-02T03:04:05Z", "Tue, 31 Feb 2024 03:04:05 GMT", "Tue, 02 Jan 2024 03:60:05 GMT", "garbage"]) {
|
|
const response = await serveSEOFile(request({ "if-modified-since": date }), file);
|
|
assert.equal(response.status, 200, date);
|
|
await response.arrayBuffer();
|
|
}
|
|
});
|
|
});
|
|
|
|
test("ETag wildcard preconditions still work without generating an ETag", async () => {
|
|
await mounted("0123456789", async (file) => {
|
|
for (const [headers, method, status] of [
|
|
[{ "if-match": '"unknown"', "if-modified-since": newer }, "GET", 412],
|
|
[{ "if-match": "*", "if-unmodified-since": older }, "GET", 200],
|
|
[{ "if-none-match": '"unknown"', "if-modified-since": newer }, "GET", 200],
|
|
[{ "if-none-match": "malformed", "if-modified-since": newer }, "GET", 200],
|
|
[{ "if-none-match": "*" }, "GET", 304], [{ "if-none-match": "*" }, "HEAD", 304],
|
|
[{ "if-none-match": "*" }, "POST", 412],
|
|
] as const) {
|
|
const response = await serveSEOFile(request(headers, method), file);
|
|
assert.equal(response.status, status);
|
|
assert.equal(response.headers.has("etag"), false);
|
|
await response.arrayBuffer();
|
|
}
|
|
});
|
|
});
|
|
|
|
test("single byte ranges, suffixes, clamping and mixed non-overlaps match Go", async () => {
|
|
await mounted("0123456789", async (file) => {
|
|
for (const [range, body, contentRange] of [
|
|
["bytes=0-2", "012", "bytes 0-2/10"], ["bytes=2-", "23456789", "bytes 2-9/10"],
|
|
["bytes=-3", "789", "bytes 7-9/10"], ["bytes=-99", "0123456789", "bytes 0-9/10"],
|
|
["bytes=+0-+2", "012", "bytes 0-2/10"], ["bytes= 0 - 2", "012", "bytes 0-2/10"],
|
|
["bytes=0--0", "0", "bytes 0-0/10"],
|
|
["bytes=9-999", "9", "bytes 9-9/10"], ["bytes=99-invalid,2-3", "23", "bytes 2-3/10"],
|
|
["bytes=-0", "", "bytes 10-9/10"],
|
|
]) {
|
|
const response = await serveSEOFile(request({ range }), file);
|
|
assert.equal(response.status, 206, range);
|
|
assert.equal(response.headers.get("accept-ranges"), "bytes");
|
|
assert.equal(response.headers.get("content-range"), contentRange);
|
|
assert.equal(response.headers.get("content-length"), String(Buffer.byteLength(body!)));
|
|
assert.equal(await response.text(), body);
|
|
}
|
|
});
|
|
});
|
|
|
|
test("malformed and non-overlapping ranges produce Go's distinct 416 responses", async () => {
|
|
await mounted("0123456789", async (file) => {
|
|
for (const range of ["items=0-2", "Bytes=0-2", "bytes=0--1", "bytes=0-2,invalid", "bytes=9223372036854775808-", "bytes=-9223372036854775808", "bytes=0-9223372036854775808", "bytes=10-"]) {
|
|
const response = await serveSEOFile(request({ range }), file);
|
|
const overlap = range === "bytes=10-", body = overlap ? "invalid range: failed to overlap\n" : "invalid range\n";
|
|
assert.equal(response.status, 416, range);
|
|
assert.equal(response.headers.get("content-range"), overlap ? "bytes */10" : null);
|
|
assert.equal(response.headers.has("last-modified"), false);
|
|
assert.equal(response.headers.has("accept-ranges"), false);
|
|
assert.equal(response.headers.get("content-type"), "text/plain; charset=utf-8");
|
|
assert.equal(response.headers.get("x-content-type-options"), "nosniff");
|
|
assert.equal(response.headers.get("content-length"), String(Buffer.byteLength(body)));
|
|
assert.equal(await response.text(), body);
|
|
}
|
|
});
|
|
});
|
|
|
|
test("multipart ranges retain order, exact MIME framing and computed content length", async () => {
|
|
await mounted("0123456789", async (file) => {
|
|
const response = await serveSEOFile(request({ range: "bytes=5-6,0-1" }), file);
|
|
assert.equal(response.status, 206);
|
|
const boundary = response.headers.get("content-type")!.match(/^multipart\/byteranges; boundary=([0-9a-f]{60})$/)?.[1];
|
|
assert.ok(boundary);
|
|
assert.equal(response.headers.has("content-range"), false);
|
|
const body = await response.text();
|
|
assert.equal(response.headers.get("content-length"), String(Buffer.byteLength(body)));
|
|
assert.equal(body, `--${boundary}\r\nContent-Range: bytes 5-6/10\r\nContent-Type: text/xml; charset=utf-8\r\n\r\n56\r\n--${boundary}\r\nContent-Range: bytes 0-1/10\r\nContent-Type: text/xml; charset=utf-8\r\n\r\n01\r\n--${boundary}--\r\n`);
|
|
});
|
|
});
|
|
|
|
test("ranges larger in aggregate than the file are ignored; empty ranges are ordinary 200", async () => {
|
|
await mounted("0123456789", async (file) => {
|
|
for (const range of ["bytes=0-5,0-5", "bytes=, ,", "bytes="]) {
|
|
const response = await serveSEOFile(request({ range }), file);
|
|
assert.equal(response.status, 200);
|
|
assert.equal(response.headers.has("content-range"), false);
|
|
assert.equal(await response.text(), "0123456789");
|
|
}
|
|
});
|
|
});
|
|
|
|
test("If-Range requires the same modification second and never matches an absent ETag", async () => {
|
|
await mounted("0123456789", async (file) => {
|
|
for (const value of [same, older, newer, '"unknown"', 'W/"unknown"', "invalid"]) {
|
|
const response = await serveSEOFile(request({ range: "bytes=0-2", "if-range": value }), file);
|
|
assert.equal(response.status, value === same ? 206 : 200, value);
|
|
assert.equal(await response.text(), value === same ? "012" : "0123456789");
|
|
}
|
|
});
|
|
});
|
|
|
|
test("HEAD retains GET metadata for full, single/multipart ranges and errors with no body", async () => {
|
|
await mounted("0123456789", async (file) => {
|
|
for (const headers of [{}, { range: "bytes=0-2" }, { range: "bytes=0-1,5-6" }, { range: "invalid" }, { range: "bytes=99-" }]) {
|
|
const get = await serveSEOFile(request(headers), file), head = await serveSEOFile(request(headers, "HEAD"), file);
|
|
assert.equal(head.status, get.status);
|
|
assert.equal(head.headers.get("content-length"), get.headers.get("content-length"));
|
|
assert.equal(head.headers.get("content-range"), get.headers.get("content-range"));
|
|
assert.equal(await head.text(), "");
|
|
await get.arrayBuffer();
|
|
}
|
|
const failedHead = await serveSEOFile(request({ "if-unmodified-since": older }, "HEAD"), file);
|
|
assert.equal(failedHead.status, 412);
|
|
assert.equal(failedHead.headers.has("content-length"), false);
|
|
assert.equal(await failedHead.text(), "");
|
|
});
|
|
});
|
|
|
|
test("zero-sized files ignore non-overlapping ranges and Unix epoch omits Last-Modified", async () => {
|
|
await mounted("", async (file) => {
|
|
const response = await serveSEOFile(request({ range: "bytes=0-" }), file);
|
|
assert.equal(response.status, 200);
|
|
assert.equal(response.headers.get("content-length"), "0");
|
|
assert.equal(await response.text(), "");
|
|
});
|
|
await mounted("0123456789", async (file) => {
|
|
const response = await serveSEOFile(request({ "if-modified-since": newer }), file);
|
|
assert.equal(response.status, 200);
|
|
assert.equal(response.headers.has("last-modified"), false);
|
|
await response.body!.cancel();
|
|
}, new Date(0));
|
|
});
|