[Ai] feat: 花魁短剧 Next.js 站点初始提交
- 原生 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>
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
decryptImageBytes,
|
||||
imageMime,
|
||||
shouldDecryptImage,
|
||||
} from "../src/lib/media/images";
|
||||
import { isHLSURL, nativeHLS, resolvePlayURL } from "../src/lib/media/playback";
|
||||
import { trackingContext } from "../src/features/playback/model";
|
||||
import {
|
||||
normalizeChannel,
|
||||
normalizeUID,
|
||||
} from "../src/components/analytics/tracker";
|
||||
|
||||
test("legacy image XOR is immutable, symmetric and changes only the first 100 bytes", () => {
|
||||
const plain = Uint8Array.from({ length: 150 }, (_, index) => index),
|
||||
original = plain.slice();
|
||||
const encrypted = decryptImageBytes(plain.buffer);
|
||||
assert.deepEqual(plain, original);
|
||||
assert.notDeepEqual(encrypted.slice(0, 100), plain.slice(0, 100));
|
||||
assert.deepEqual(encrypted.slice(100), plain.slice(100));
|
||||
assert.deepEqual(decryptImageBytes(encrypted.buffer as ArrayBuffer), plain);
|
||||
for (const length of [0, 1, 12, 99, 100]) {
|
||||
const bytes = plain.slice(0, length);
|
||||
assert.deepEqual(
|
||||
decryptImageBytes(decryptImageBytes(bytes.buffer).buffer as ArrayBuffer),
|
||||
bytes,
|
||||
);
|
||||
}
|
||||
});
|
||||
test("image signatures and static exemptions preserve the original loader contract", () => {
|
||||
assert.equal(imageMime(new Uint8Array([71, 73, 70, 56])), "image/gif");
|
||||
assert.equal(imageMime(new Uint8Array([137, 80, 78, 71])), "image/png");
|
||||
assert.equal(
|
||||
imageMime(new Uint8Array([82, 73, 70, 70, 0, 0, 0, 0, 87, 69, 66, 80])),
|
||||
"image/webp",
|
||||
);
|
||||
assert.equal(imageMime(new Uint8Array([71, 73, 70, 0])), "image/jpeg");
|
||||
for (const src of [
|
||||
"/static/brand/placeholder.svg?v=1",
|
||||
"blob:abc",
|
||||
"data:image/png;base64,abc",
|
||||
"https://placehold.co/400",
|
||||
])
|
||||
assert.equal(shouldDecryptImage(src), false);
|
||||
assert.equal(
|
||||
shouldDecryptImage("https://cdn.example/encrypted.webp?token=secret"),
|
||||
true,
|
||||
);
|
||||
});
|
||||
test("signed relative and absolute media URLs retain every query byte", () => {
|
||||
const signed = "archive/main.m3u8?ticket=a%2Bb%3D&expires=99&path=%2Ffoo";
|
||||
assert.equal(resolvePlayURL(signed), "/h5/m3u8/" + signed);
|
||||
assert.equal(resolvePlayURL("/h5/m3u8/" + signed), "/h5/m3u8/" + signed);
|
||||
assert.equal(
|
||||
resolvePlayURL("https://cdn.example/" + signed),
|
||||
"https://cdn.example/" + signed,
|
||||
);
|
||||
assert(isHLSURL(resolvePlayURL(signed)));
|
||||
assert(!isHLSURL("https://cdn.example/movie.mp4?token=1"));
|
||||
});
|
||||
test("native HLS only uses Safari and shell browsers keep the inline MSE player", () => {
|
||||
const video = {
|
||||
canPlayType: () => "probably",
|
||||
} as unknown as HTMLVideoElement;
|
||||
assert(
|
||||
nativeHLS(video, "Mozilla/5.0 (iPhone) Version/18.0 Mobile Safari/604.1"),
|
||||
);
|
||||
for (const browser of [
|
||||
"Quark",
|
||||
"MQQBrowser",
|
||||
"QQ/9",
|
||||
"MicroMessenger",
|
||||
"Chrome/130",
|
||||
])
|
||||
assert(!nativeHLS(video, "Mozilla iPhone Safari/604.1 " + browser));
|
||||
});
|
||||
test("drama tracking and timestamps preserve upstream field meaning", () => {
|
||||
assert.equal(trackingContext({ DramaID: 0 }), null);
|
||||
const context = trackingContext({
|
||||
DramaID: 42,
|
||||
VideoID: 101,
|
||||
Episode: 2,
|
||||
TotalEpisode: 8,
|
||||
DramaTypeID: 3,
|
||||
DramaTypeName: "都市",
|
||||
DramaTagKey: "1,2",
|
||||
DramaTagName: "剧情,原创",
|
||||
});
|
||||
assert.equal(context?.drama_id, "42");
|
||||
assert.equal(context?.episode, 2);
|
||||
assert.equal(context?.drama_content_type, "short_drama");
|
||||
assert.equal(context?.drama_tag_key, "1,2");
|
||||
assert.equal(normalizeChannel("official"), "organic");
|
||||
assert.equal(normalizeChannel("channel_A-2"), "channel_A-2");
|
||||
assert.equal(normalizeUID("stress_user"), "");
|
||||
assert.equal(normalizeUID("real_1001"), "real_1001");
|
||||
});
|
||||
Reference in New Issue
Block a user