[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:
谢宇宁
2026-10-16 11:33:31 +07:00
co-authored by Claude Opus 5.5
commit d2ddb1c321
423 changed files with 52152 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
import test from "node:test";
import assert from "node:assert/strict";
import { appDownloadURL } from "../src/lib/app-download";
import { createContext } from "../src/server/context";
import { loadSharedData } from "../src/server/services/shared";
test("download destination keeps the primary address ahead of the official fallback", () => {
assert.equal(appDownloadURL({ appAddr: " https://downloads.example/app?channel=web ", officialAppAddr: "https://official.example/app" }), "https://downloads.example/app?channel=web");
assert.equal(appDownloadURL({ config: { appAddr: "https://downloads.example/config" }, newAddressInfo: { appAddr: "https://downloads.example/new" } }), "https://downloads.example/config");
});
test("empty config fields cannot erase valid address data or its official fallback", () => {
assert.equal(appDownloadURL({ appAddr: "", config: { appAddr: " " }, newAddressInfo: { appAddr: "https://downloads.example/new" }, officialAppAddr: "https://official.example/app" }), "https://downloads.example/new");
assert.equal(appDownloadURL({ appAddr: " ", config: { officialAppAddr: " https://official.example/app " } }), "https://official.example/app");
assert.equal(appDownloadURL({ newAddressInfo: { officialAppAddr: "https://official.example/new" } }), "https://official.example/new");
assert.equal(appDownloadURL({ AppAddr: "https://downloads.example/legacy" }), "https://downloads.example/legacy");
});
test("missing download configuration stays empty rather than inventing a QR destination", () => {
for (const value of [undefined, null, "https://downloads.example/", {}, { appAddr: " ", officialAppAddr: "" }, { appAddr: 123, config: null, newAddressInfo: { officialAppAddr: false } }]) {
assert.equal(appDownloadURL(value), "");
}
});
test("the installation dialog receives the same primary or official destination as the QR", async () => {
for (const appConfig of [
{ appAddr: "https://downloads.example/app", officialAppAddr: "https://official.example/app" },
{ appAddr: "", officialAppAddr: "https://official.example/app" },
{ config: { appAddr: "" }, newAddressInfo: { appAddr: "https://downloads.example/new" } },
{ appAddr: "", officialAppAddr: "" },
]) {
const ctx = createContext(new Request("http://site.test/"));
const data = await loadSharedData(ctx, {
appConfig, categoryList: [{ name: "首页", urlValue: "/", categoryType: 1 }],
themeList: [], homeConfigData: [], searchTrends: [],
});
assert.equal(data.installGuide.fallbackURL, appDownloadURL(appConfig) || "/app/download/");
}
});
+46
View File
@@ -0,0 +1,46 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createContext } from '../src/server/context';
import type { Data } from '../src/server/services/model';
import { loadSharedData } from '../src/server/services/shared';
const sharedFixture = (): Data => ({
appConfig: { config: {} }, categoryList: [{ name: '首页', urlValue: '/', categoryType: 1 }],
themeList: [], homeConfigData: [], searchTrends: [],
});
const context = (avatar: string, cookieAvatar = avatar) => {
const ctx = createContext(new Request('http://site.test/profile/', {
headers: { cookie: `token=guest-session; avatar=${encodeURIComponent(cookieAvatar)}` },
}));
ctx.user = { userId: 7, nickName: '游客', avatar, type: 0, token: 'guest-session' };
return ctx;
};
test('shared avatars preserve API image keys and usable URLs, reject other relative paths, and never rewrite auth values', async () => {
const relative = 'v3/image/guest/avatar.enc';
for (const [avatar, cookieAvatar, expected] of [
[relative, relative, relative],
['/v3/image/guest/avatar.enc', '/v3/image/guest/avatar.enc', '/v3/image/guest/avatar.enc'],
['ordinary-relative-avatar.png', 'ordinary-relative-avatar.png', ''],
['//cdn.test/avatar.png', '//cdn.test/avatar.png', ''],
['javascript:alert(1)', 'javascript:alert(1)', ''],
['https://', 'https://', ''],
[' https://cdn.test/avatar.enc?key=abc ', relative, 'https://cdn.test/avatar.enc?key=abc'],
['http://cdn.test/avatar.png', relative, 'http://cdn.test/avatar.png'],
['/static/images/profile/avatar.png', relative, '/static/images/profile/avatar.png'],
[relative, 'https://cdn.test/cookie-avatar.png', relative],
['ordinary-relative-avatar.png', 'https://cdn.test/cookie-avatar.png', 'https://cdn.test/cookie-avatar.png'],
] as const) {
const ctx = context(avatar, cookieAvatar);
const requestCookie = ctx.headers.get('cookie');
const data = await loadSharedData(ctx, sharedFixture());
assert.equal(data.avatar, expected, avatar);
assert.equal(ctx.user?.avatar, avatar);
assert.equal(ctx.user?.token, 'guest-session');
assert.equal(ctx.token, 'guest-session');
assert.equal(ctx.getCookie('avatar'), cookieAvatar);
assert.equal(ctx.headers.get('cookie'), requestCookie);
assert.deepEqual(ctx.responseHeaders.getSetCookie(), []);
}
});
+52
View File
@@ -0,0 +1,52 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createContext } from '../src/server/context';
import { buildPage } from '../src/server/pages';
import { apiData } from '../src/server/services/data';
import { applyBrandCopy, BRAND_NAME, replaceBrandText } from '../src/server/services/brand';
import { model } from '../src/server/services/model';
import { buildLLMSTXT, enrichSEO, normalizeSEO, resolvePlayPageSEO, toNextMetadata, verificationCodes } from '../src/server/seo';
const context=(path='/')=>createContext(new Request('http://internal:3000'+path));
test('brand replacement changes the full display phrase while preserving unrelated names and operational values',()=>{
const value:Record<string,any>=model({title:'黄果恋人 - 黄果短剧',name:'黄果短剧',description:'黄果短剧精彩推荐',keywords:['黄果短剧','黄果恋人'],canonicalURL:'https://canonical.drama.test/黄果短剧/',url:'/黄果短剧/',slug:'黄果短剧',token:'黄果短剧-token',reportSdkConfig:'黄果短剧-signed',email:'support@huangguo.com',verification:'黄果短剧-verify',text:'用户说黄果短剧',announcements:[{content:'欢迎访问黄果短剧'}]});
const result=applyBrandCopy(value);
assert.equal(result,value);assert.equal(result.title,'黄果恋人 - 花魁短剧');assert.equal(result.Name,BRAND_NAME);assert.equal(result.DisplayName,BRAND_NAME);assert.deepEqual(result.keywords,['花魁短剧','黄果恋人']);
assert.equal(result.canonicalURL,'https://canonical.drama.test/黄果短剧/');assert.equal(result.url,'/黄果短剧/');assert.equal(result.slug,'黄果短剧');assert.equal(result.token,'黄果短剧-token');assert.equal(result.reportSdkConfig,'黄果短剧-signed');assert.equal(result.email,'support@huangguo.com');assert.equal(result.verification,'黄果短剧-verify');assert.equal(result.text,'用户说黄果短剧');assert.equal(result.announcements[0].content,'欢迎访问花魁短剧');
assert.equal(replaceBrandText('黄果、黄果恋人、黄果短剧、黄果短剧'),'黄果、黄果恋人、花魁短剧、花魁短剧');
});
test('upstream TDK aliases, navigation metadata and llms expose the new brand without changing canonical settings',()=>{
const canonical='https://canonical.drama.test/黄果短剧/?source=original';
const seo=normalizeSEO(JSON.stringify({WebTitle:' 黄果短剧首页 ',WebDescription:'黄果短剧介绍',WebKeyworld:'黄果短剧,花魁短剧,黄果恋人',CanonicalURL:canonical,WebSiteName:'黄果短剧'}));
assert.equal(seo.title,'花魁短剧首页');assert.equal(seo.description,'花魁短剧介绍');assert.equal(seo.webSiteName,BRAND_NAME);assert.deepEqual(seo.keywords,['花魁短剧','黄果恋人']);assert.equal(seo.canonicalUrl,canonical);
const request=context('/list/');
const codes=verificationCodes();
const data=enrichSEO(request,{appConfig:{foreverUrlAddr:'https://canonical.drama.test/permanent?keep=1',seoInfo:{webSiteName:'黄果短剧',description:'黄果短剧全站描述'}},categoryList:[{name:'短剧',categoryType:1,urlValue:'/list',seoInfo:{title:'黄果短剧分类',description:'黄果短剧分类介绍',keyworld:['黄果短剧','都市']}}],pagination:{Page:2,Visible:true,HasPrev:true,PrevURL:'/list/'}});
assert.equal(data.metaTitle,'花魁短剧分类 - 第2页');assert.equal(data.metaDescription,'花魁短剧分类介绍 - 第2页');assert.equal(data.metaKeywords,'花魁短剧,都市,第2页');assert.equal(data.siteName,BRAND_NAME);
assert.equal(data.siteURL,'https://canonical.drama.test');assert.equal(data.canonicalURL,'https://canonical.drama.test/dramas/');assert.equal(data.paginationPrevURL,'https://canonical.drama.test/dramas/');assert.equal(data.robots,'index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1');assert.deepEqual(data.seoVerify,codes);
const graph=JSON.parse(data.jsonLD)['@graph'];assert.equal(graph.find((item:any)=>item['@type']==='Organization').name,BRAND_NAME);assert.equal(graph.find((item:any)=>item['@type']==='WebSite').name,BRAND_NAME);
const metadata=toNextMetadata(data);assert.equal(metadata.openGraph?.siteName,BRAND_NAME);assert.equal(metadata.alternates?.canonical,data.canonicalURL);
const llms=buildLLMSTXT('https://canonical.drama.test',{seoInfo:{webSiteName:'黄果短剧',description:'黄果短剧介绍'}});assert.match(llms,/^# 花魁短剧/);assert.match(llms,/> 花魁短剧介绍/);assert.match(llms,/https:\/\/canonical\.drama\.test\/sitemap\.xml/);
const play=resolvePlayPageSEO({}, {playName:'黄果恋人',desc:'黄果恋人的故事,由黄果短剧提供'},2);assert.equal(play.title,'黄果恋人 - 第二集');assert.equal(play.description,'黄果恋人的故事,由花魁短剧提供');
});
test('page injection and cached upstream configuration cannot restore the former brand',async()=>{
const originalFetch=globalThis.fetch,originalUpstream=process.env.UPSTREAM_API_URL;
process.env.UPSTREAM_API_URL='http://brand-contract.test';const calls=new Map<string,number>();
globalThis.fetch=async(input)=>{
const path=new URL(String(input)).pathname;calls.set(path,(calls.get(path)||0)+1);let data:unknown={list:[],totalCount:0};
if(path==='/api/app/config/info')data={config:{foreverUrlAddr:'https://canonical.drama.test',gmailAddr:'support@huangguo.com',brandName:'黄果短剧'},seoInfo:{title:'黄果短剧首页',description:'黄果短剧介绍',webSiteName:'黄果短剧',keywords:['黄果短剧']},seoNavInfo:{dhTitle:'黄果短剧导航',dhDescription:'黄果短剧最新线路',dhSiteName:'黄果短剧'},reportSdkConfig:'黄果短剧-signed-sdk'};
if(path==='/api/app/translation/category/list')data={categoryList:[{name:'黄果短剧首页',urlValue:'/',categoryType:1,seoInfo:{title:'黄果短剧首页',description:'黄果短剧介绍'}}]};
if(path==='/api/app/playlet/home')data={seoInfo:{title:'黄果短剧首页'},announcements:[{content:'欢迎来到黄果短剧'}],homeConfigData:[{name:'黄果短剧精选',type:1,status:true}],homeBannerData:[{id:1,playName:'黄果恋人'}]};
return Response.json({code:200,data});
};
try{
const page=await buildPage(context('/'));const config=page.data.appConfig;
assert.equal(page.data.title,'花魁短剧首页');assert.equal(config.config.brandName,BRAND_NAME);assert.equal(config.seoInfo.webSiteName,BRAND_NAME);assert.equal(config.seoNavInfo.dhTitle,'花魁短剧导航');assert.equal(page.data.categoryList[0].name,'花魁短剧首页');assert.equal(page.data.announcements[0].content,'欢迎来到花魁短剧');assert.equal(page.data.homeConfigPlaylets[0].name,'花魁短剧精选');assert.equal(page.data.homeBannerData[0].playName,'黄果恋人');assert.equal(config.gmailAddr,'support@huangguo.com');assert.equal(config.reportSdkConfig,'黄果短剧-signed-sdk');
const cached=await apiData(context('/'),'/api/app/config/info',{}, {public:true,ttl:60000});assert.equal(cached.seoInfo.webSiteName,BRAND_NAME);assert.equal(cached.config.brandName,BRAND_NAME);assert.equal(calls.get('/api/app/config/info'),1);
const rendered=enrichSEO(context('/'),page.data);assert.equal(rendered.siteName,BRAND_NAME);assert.equal(rendered.canonicalURL,'https://canonical.drama.test/');assert.equal(rendered.metaTitle,'花魁短剧首页');
const help=await buildPage(context('/help/'));assert.equal(help.data.faqItems[0].Question,'花魁短剧网页版可以看全集吗?');
}finally{globalThis.fetch=originalFetch;if(originalUpstream===undefined)delete process.env.UPSTREAM_API_URL;else process.env.UPSTREAM_API_URL=originalUpstream;}
});
+185
View File
@@ -0,0 +1,185 @@
import assert from "node:assert/strict";
import test from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import {
DramaCard,
PlayRecommendationCard,
SeriesCard,
} from "../src/components/catalog/Cards";
import { Pagination } from "../src/components/catalog/Pagination";
import { PromoGrid } from "../src/components/catalog/Promotions";
import {
CatalogGrid,
ListPage,
SeriesPage,
} from "../src/features/catalog/BrowsePages";
import {
allListURL,
array,
cardTags,
moreListURL,
moreSeriesListURL,
searchResultsURL,
seriesListURL,
tagPageURL,
topicDetailURL,
} from "../src/features/catalog/model";
const item = {
id: 7,
slug: "都市 剧",
playName: "作品名",
title: "备用标题",
cover: "/static/brand/header.svg",
totalEpisode: 8,
updateStatus: 1,
isFeatured: true,
tagIds: [2],
tagNames: ["爱情"],
tagSlugs: ["romance"],
themeIds: [3],
themeNames: ["爱情", "都市"],
themeSlugs: ["love", "city"],
};
const ads = [
{
id: 1,
images: "/static/brand/header.svg",
link: "https://promo.test/",
title: "推荐",
},
];
test("React card variants preserve watch URLs, episode gates, badges and linked deduplicated tags", () => {
const card = renderToStaticMarkup(<DramaCard item={item} />);
assert.match(card, /class="hk-drama-card hk-drama-card--list"/);
assert.match(
card,
/href="\/drama\/都市%20剧\/"|href="\/drama\/%E9%83%BD%E5%B8%82%20%E5%89%A7\/"/,
);
assert.match(
card,
/<p class="hk-drama-card__meta hk-drama-card__meta--ep"><span class="hk-drama-card__meta-ep" data-status="serial">连载 · 8 集<\/span><span class="hk-drama-card__meta-tags">/,
);
assert.doesNotMatch(card, /hk-drama-card__ep"/);
assert.match(card, /hk-badge hk-badge--hot/);
assert.match(card, /href="\/tags\/romance\/"/);
assert.deepEqual(
cardTags(item).map((tag) => tag.Name),
["爱情", "都市"],
);
const recommendation = renderToStaticMarkup(
<PlayRecommendationCard item={item} />,
);
assert.match(recommendation, /hk-drama-card hk-drama-card--play/);
assert.match(recommendation, /hk-drama-card__meta-ep" data-status="serial">连载 · 8 集</);
assert.doesNotMatch(recommendation, /hk-badge--hot/);
const video = renderToStaticMarkup(
<SeriesCard
item={{
id: 9,
slug: "movie",
title: "剧集",
mediaType: 2,
videoCount: 12,
topicNames: ["动画"],
coverImg: "/static/brand/header.svg",
}}
/>,
);
assert.match(video, /href="\/series\/watch\/movie\/"/);
assert.match(video, /全 12 集/);
assert.match(video, /动画/);
});
test("React list advertising keeps independent desktop and mobile row positions and clone attribution", () => {
const items = array(
Array.from({ length: 25 }, (_, index) => ({ ...item, id: index + 1 })),
);
const grid = renderToStaticMarkup(<CatalogGrid items={items} ads={ads} />);
assert.equal((grid.match(/class="hk-drama-card hk-drama-card--list"/g) || []).length, 25);
assert.equal((grid.match(/class="hk-drama-browse-mid-promo hk-desktop-only"/g) || []).length, 2);
assert.equal((grid.match(/class="hk-drama-browse-mid-promo hk-mobile-only"/g) || []).length, 4);
const videoGrid = renderToStaticMarkup(
<CatalogGrid items={items.slice(0, 16)} ads={ads} series />,
);
assert.equal(
(videoGrid.match(/class="hk-series-list-promo hk-desktop-only"/g) || []).length,
1,
);
assert.equal(
(videoGrid.match(/class="hk-series-list-promo hk-mobile-only"/g) || []).length,
3,
);
const promo = renderToStaticMarkup(<PromoGrid items={ads} />);
assert.equal((promo.match(/data-track-ad="external"/g) || []).length, 2);
assert.equal((promo.match(/data-ad-clone="1"/g) || []).length, 1);
assert.match(promo, /rel="sponsored nofollow noopener"/);
});
test("React filter and pagination links preserve the source path-only canonical route contracts", () => {
assert.equal(allListURL("", 0, 0, 1, 1), "/dramas/");
assert.equal(
allListURL("都市", 3, 2, 3, 2),
"/dramas/%E9%83%BD%E5%B8%82/2/3/page/2/",
);
assert.equal(seriesListURL(2, 4, 3, 2), "/series/anime/4/3/page/2/");
assert.equal(moreListURL("featured", 1, 0, "", 0, 1), "/curated/dramas/featured/");
assert.equal(
moreSeriesListURL("", 3, 1, "city", 0, 2),
"/curated/series/3/1/city/page/2/",
);
assert.equal(tagPageURL("romance", 1, 2, 1), "/tags/romance/sort/2/");
assert.equal(
topicDetailURL("city", 1, 3, 2),
"/collection/city/sort/3/page/2/",
);
assert.equal(searchResultsURL("a/b"), "/search/a%2Fb/");
const html = renderToStaticMarkup(
<Pagination
pagination={{
Visible: true,
HasPrev: false,
HasNext: true,
NextURL: "/dramas/all/0/1/page/2/",
Items: [
{ Label: "1", URL: "/dramas/", Active: true },
{ Label: "…", Dots: true },
],
TotalText: "共 3 页",
}}
/>,
);
assert.match(html, /hk-pagination__btn--prev hk-is-disabled/);
assert.match(html, /aria-current="page"/);
assert.match(html, /href="\/dramas\/all\/0\/1\/page\/2\/"/);
assert.match(html, /hk-pagination__ellipsis/);
assert.match(html, /共 3 页/);
const list = renderToStaticMarkup(
<ListPage
data={{
list: [],
themeList: [{ id: 3, slug: "city", name: "都市" }],
themeId: 3,
themeSlug: "city",
updateStatus: 2,
sortType: 3,
}}
/>,
);
assert.match(list, /href="\/dramas\/city\/2\/3\/"/);
assert.match(list, /暂无短剧/);
const series = renderToStaticMarkup(
<SeriesPage
data={{
list: [],
topicList: [{ id: 4, name: "爱情" }],
topicId: 4,
mediaType: 2,
sortType: 1,
}}
/>,
);
assert.match(series, /href="\/series\/anime\/4\/1\/"/);
assert.match(series, /暂无剧集/);
});
+135
View File
@@ -0,0 +1,135 @@
import assert from 'node:assert/strict';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { createContext } from '../src/server/context';
import { resolveSiteURL, usableSiteURL } from '../src/server/seo/shared';
import {
BUNDLED_ROBOTS_TXT, BUILTIN_ROBOTS_TXT, ROBOTS_TEMPLATE_PATHS,
buildLLMSTXT, buildRobotsTXT, loadDiscoveryConfig, readRobotsTXT,
} from '../src/server/seo/discovery-text';
const site = 'https://canonical.drama.test';
test('robots follows the four original disk paths, skips all read errors/empty text, then uses embedded and builtin text', async () => {
const cwd = await mkdtemp(path.join(os.tmpdir(), 'huakui-go-robots-'));
const files = ROBOTS_TEMPLATE_PATHS.map(relative => path.join(cwd, relative));
try {
await Promise.all(files.map(file => mkdir(path.dirname(file), { recursive: true })));
await mkdir(files[0]!); // os.ReadFile(directory) fails; the next candidate must still be considered.
await writeFile(files[1]!, ' \r\n\t\u0085\u00a0');
await writeFile(files[2]!, ' User-agent: *\r\n# third template\r\nSitemap: /old.xml\r\nHost: intentional.drama.test\r\n');
await writeFile(files[3]!, 'User-agent: *\n# fourth template');
await mkdir(path.join(cwd, 'config/seo'), { recursive: true });
await writeFile(path.join(cwd, 'config/seo/robots.txt'), 'Disallow: /');
assert.equal(await readRobotsTXT(site, { cwd }), 'User-agent: *\n# third template\nSitemap: ' + site + '/sitemap.xml\nHost: intentional.drama.test\n');
await rm(files[0]!, { recursive: true });
await writeFile(files[0]!, 'User-agent: *\n# first template');
assert.equal(await readRobotsTXT(site, { cwd }), 'User-agent: *\n# first template\n\nSitemap: ' + site + '/sitemap.xml\nHost: canonical.drama.test\n');
await Promise.all([files[0]!, files[2]!, files[3]!].map(file => rm(file)));
assert.equal(await readRobotsTXT(site, { cwd }), buildRobotsTXT(site, BUNDLED_ROBOTS_TXT));
assert.equal(await readRobotsTXT(site, { cwd, bundledText: null }), buildRobotsTXT(site, BUILTIN_ROBOTS_TXT));
assert.equal(await readRobotsTXT(site, { cwd, bundledText: '\r\n\t' }), buildRobotsTXT(site, BUILTIN_ROBOTS_TXT));
} finally { await rm(cwd, { recursive: true, force: true }); }
});
test('robots preserves Go Sitemap/Host case, comments, empty-origin and final-newline behavior', () => {
assert.equal(buildRobotsTXT(site, 'User-agent: *\r\nSitemap: /a\r\nSitemap: /b\r\nHost: keep.drama.test\r\n'),
'User-agent: *\nSitemap: ' + site + '/sitemap.xml\nSitemap: ' + site + '/sitemap.xml\nHost: keep.drama.test\n');
assert.equal(buildRobotsTXT('', 'User-agent: *\nSitemap: /old'), 'User-agent: *\nSitemap: /sitemap.xml\n');
assert.equal(buildRobotsTXT(site, 'User-agent: *\n# Sitemap: managed separately\n# Host: managed separately'),
'User-agent: *\n# Sitemap: managed separately\n# Host: managed separately\n');
assert.equal(buildRobotsTXT(site, 'User-agent: *\nsitemap: /lower\nhost: lower.drama.test'),
'User-agent: *\nsitemap: /lower\nhost: lower.drama.test\n\nSitemap: ' + site + '/sitemap.xml\nHost: canonical.drama.test\n');
assert.equal(buildRobotsTXT(site, '\u0085\u00a0User-agent: *\u0085'),
'User-agent: *\n\nSitemap: ' + site + '/sitemap.xml\nHost: canonical.drama.test\n');
assert.ok(buildRobotsTXT(site, '\ufeffUser-agent: *').startsWith('\ufeffUser-agent: *'), 'Go TrimSpace preserves BOM');
assert.equal(buildRobotsTXT('https://Case.drama.test:443', 'User-agent: *'),
'User-agent: *\n\nSitemap: https://Case.drama.test:443/sitemap.xml\nHost: Case.drama.test:443\n');
});
test('llms restores upstream SEO copy and Go Unicode Fields while keeping the approved brand and public links', () => {
const text = buildLLMSTXT(' ' + site + '/// ', { SeoInfo: { WebSiteName: '\u0085黄果短剧\t 官方\n站点\u00a0', Description: ' 最新\n\t短剧\u0085更新 ' } });
assert.equal(text, '# 花魁短剧 官方 站点\n\n> 最新 短剧 更新\n\n## 核心页面\n\n' +
'- [首页](' + site + '/)\n' +
'- [短剧列表](' + site + '/dramas/)\n' +
'- [剧集](' + site + '/series/)\n' +
'- [热门推荐](' + site + '/featured/)\n' +
'- [今日更新](' + site + '/latest/)\n' +
'- [专题](' + site + '/collections/)\n' +
'\n## 站点发现\n\n- [Sitemap](' + site + '/sitemap.xml)\n- [Robots](' + site + '/robots.txt)\n');
assert.match(buildLLMSTXT('', {}), /^# 花魁短剧\n\n> 汇集热门短剧/);
assert.ok(buildLLMSTXT(site, { seoInfo: { webSiteName: '\ufeff花魁短剧' } }).startsWith('# \ufeff花魁短剧'));
});
test('origin resolution matches Go permanent-site priority and request fallback without new deployment overrides', () => {
const original = process.env.PUBLIC_SITE_URL;
process.env.PUBLIC_SITE_URL = 'https://must-not-override.drama.test';
try {
const ctx = createContext(new Request('http://internal:26935/robots.txt', { headers: { 'X-Forwarded-Proto': ' https, http ', 'X-Forwarded-Host': ' edge.drama.test, private.internal ', Host: 'request.drama.test', Forwarded: 'host=ignored.drama.test;proto=http', 'CF-Visitor': '{"scheme":"http"}' } }));
assert.equal(resolveSiteURL(ctx, { foreverUrlAddr: site + '/path?discard=1' }), site);
assert.equal(resolveSiteURL(ctx, { foreverUrlAddr: 'https://example.com' }), 'https://edge.drama.test');
assert.equal(resolveSiteURL(createContext(new Request('http://10.0.0.4:26935/robots.txt'))), 'http://10.0.0.4:26935');
assert.equal(resolveSiteURL(createContext(new Request('https://request.drama.test/robots.txt', { headers: { Forwarded: 'host=ignored.drama.test;proto=http', 'CF-Visitor': '{"scheme":"http"}' } }))), 'https://request.drama.test');
assert.equal(resolveSiteURL(createContext(new Request('http://request.drama.test/robots.txt', { headers: { 'X-Forwarded-Host': ', later.drama.test' } }))), '');
assert.equal(resolveSiteURL(createContext(new Request('https://request.drama.test/robots.txt', { headers: { 'X-Forwarded-Proto': ', https' } }))), '://request.drama.test');
assert.equal(usableSiteURL(' HTTPS://user:password@Case.drama.test:443/path?x=1#part '), 'https://Case.drama.test:443');
assert.equal(usableSiteURL('canonical.drama.test/path'), site);
assert.equal(usableSiteURL('https://foo\n.drama.test'), '');
for (const value of ['https://a%25.drama.test', 'https://[fe80::1%25eth0]']) {
assert.equal(usableSiteURL(value), '', value);
assert.equal(resolveSiteURL(ctx, { foreverUrlAddr: value }), 'https://edge.drama.test', value);
}
assert.equal(usableSiteURL('https://a%2525.drama.test/path'), 'https://a%25.drama.test');
assert.equal(usableSiteURL('https://[fe80::1%2525eth0]:443/path'), 'https://[fe80::1%25eth0]:443');
for (const value of ['https://localhost', 'http://127.0.0.1', 'https://example.com', 'https://placeholder.example.com', 'https://placeholder.local', 'javascript:alert(1)']) assert.equal(usableSiteURL(value), '', value);
} finally {
if (original === undefined) delete process.env.PUBLIC_SITE_URL; else process.env.PUBLIC_SITE_URL = original;
}
});
test('public discovery config shares the 60-second success cache, stale fallback and concurrent refresh without visitor cookies', async () => {
const originalFetch = globalThis.fetch;
const originalURL = process.env.UPSTREAM_API_URL;
const originalNow = Date.now;
process.env.UPSTREAM_API_URL = 'https://go-discovery-cache.drama.test';
let now = originalNow(), calls = 0, fail = false;
Date.now = () => now;
globalThis.fetch = async (url, init) => {
calls++;
assert.equal(String(url), process.env.UPSTREAM_API_URL + '/api/app/config/info');
const headers = new Headers(init?.headers);
assert.equal(headers.has('authorization'), false);
assert.equal(headers.has('cookie'), false);
if (fail) return new Response('offline', { status: 503 });
return Response.json({ code: 200, data: { config: { foreverUrlAddr: 'https://upstream.drama.test' }, seoInfo: { webSiteName: '黄果短剧 官方', description: '后台\n动态描述' } } }, { headers: { 'Set-Cookie': 'upstream-private=must-not-return; Path=/' } });
};
const context = () => createContext(new Request(site + '/robots.txt', { headers: { Cookie: 'token=registered-private', Authorization: 'private-session' } }));
try {
const visitors = [context(), context(), context()];
const configs = await Promise.all(visitors.map(loadDiscoveryConfig));
assert.equal(calls, 1);
assert.equal(resolveSiteURL(visitors[0]!, configs[0]), 'https://upstream.drama.test');
assert.match(buildLLMSTXT(site, configs[0]), /^# 花魁短剧 官方\n\n> 后台 动态描述/);
assert.ok(visitors.every(ctx => ctx.responseHeaders.getSetCookie().length === 0));
await loadDiscoveryConfig(context());
assert.equal(calls, 1, 'successful config remains cached for 60 seconds');
now += 60001;
fail = true;
const staleContext = context();
const stale = await loadDiscoveryConfig(staleContext);
assert.equal(calls, 2);
assert.equal(stale.foreverUrlAddr, 'https://upstream.drama.test');
assert.match(buildLLMSTXT(site, stale), /后台 动态描述/);
assert.deepEqual(staleContext.responseHeaders.getSetCookie(), []);
process.env.UPSTREAM_API_URL = 'https://go-discovery-cold-offline.drama.test';
const empty = await loadDiscoveryConfig(context());
assert.equal(calls, 3);
assert.match(buildLLMSTXT('', empty), /^# 花魁短剧\n\n> 汇集热门短剧/);
} finally {
globalThis.fetch = originalFetch;
Date.now = originalNow;
if (originalURL === undefined) delete process.env.UPSTREAM_API_URL; else process.env.UPSTREAM_API_URL = originalURL;
}
});
+286
View File
@@ -0,0 +1,286 @@
[
[
"/",
"Index"
],
[
"/search-mobile/",
"SearchMobile"
],
[
"/search/",
"Search"
],
[
"/search/:q/",
"Search"
],
[
"/search/:q/page/:page/",
"Search"
],
[
"/profile/",
"Profile"
],
[
"/history/",
"History"
],
[
"/history/page/:page/",
"History"
],
[
"/favorites/",
"Favorites"
],
[
"/favorites/page/:page/",
"Favorites"
],
[
"/favorites/:filter/",
"Favorites"
],
[
"/favorites/:filter/page/:page/",
"Favorites"
],
[
"/purchased/",
"PurchasedSeries"
],
[
"/purchased/page/:page/",
"PurchasedSeries"
],
[
"/setting/",
"Setting"
],
[
"/recharge/",
"Recharge"
],
[
"/about/",
"About"
],
[
"/help/",
"Help"
],
[
"/terms/",
"Terms"
],
[
"/privacy/",
"Privacy"
],
[
"/2257/",
"Compliance2257"
],
[
"/dmca/",
"DMCA"
],
[
"/download-app/",
"DownloadApp"
],
[
"/nav/",
"Nav"
],
[
"/dh/",
"Nav"
],
[
"/list/",
"List"
],
[
"/list/:theme/",
"List"
],
[
"/list/:theme/:updateStatus/:sortType/",
"List"
],
[
"/list/:theme/:updateStatus/:sortType/page/:page/",
"List"
],
[
"/more-list/:configId/:timeRange/:theme/page/:page/",
"MoreList"
],
[
"/more-list/:configId/:timeRange/:theme/",
"MoreList"
],
[
"/more-list/:configId/page/:page/",
"MoreList"
],
[
"/more-list/:configId/",
"MoreList"
],
[
"/more-series-list/:configId/:timeRange/:theme/page/:page/",
"MoreSeriesList"
],
[
"/more-series-list/:configId/:timeRange/:theme/",
"MoreSeriesList"
],
[
"/more-series-list/:configId/page/:page/",
"MoreSeriesList"
],
[
"/more-series-list/:configId/",
"MoreSeriesList"
],
[
"/series/",
"Series"
],
[
"/series/:media/:topic/:sortType/",
"Series"
],
[
"/series/:media/:topic/:sortType/page/:page/",
"Series"
],
[
"/topic/",
"Topic"
],
[
"/topic/page/:page/",
"Topic"
],
[
"/topic/sort/:sortType/",
"Topic"
],
[
"/topic/sort/:sortType/page/:page/",
"Topic"
],
[
"/topic-detail/:slug/sort/:sortType/page/:page/",
"TopicDetail"
],
[
"/topic-detail/:slug/sort/:sortType/",
"TopicDetail"
],
[
"/topic-detail/:slug/page/:page/",
"TopicDetail"
],
[
"/topic-detail/:slug/",
"TopicDetail"
],
[
"/rank/",
"Rank"
],
[
"/rank/page/:page/",
"Rank"
],
[
"/rank/:sortOrder/",
"Rank"
],
[
"/rank/:sortOrder/page/:page/",
"Rank"
],
[
"/tags/:slug/sort/:sortType/page/:page/",
"Tags"
],
[
"/tags/:slug/sort/:sortType/",
"Tags"
],
[
"/tags/:slug/page/:page/",
"Tags"
],
[
"/tags/:slug/",
"Tags"
],
[
"/recently-added/",
"RecentlyAdded"
],
[
"/recently-added/page/:page/",
"RecentlyAdded"
],
[
"/popular-recommend/",
"PopularRecommend"
],
[
"/popular-recommend/page/:page/",
"PopularRecommend"
],
[
"/popular-recommend/:rankType/",
"PopularRecommend"
],
[
"/popular-recommend/:rankType/page/:page/",
"PopularRecommend"
],
[
"/play/:slug/",
"Play"
],
[
"/play/:slug/:ep/",
"Play"
],
[
"/play/:slug/recommendations/page/:guessPage/",
"Play"
],
[
"/play/:slug/:ep/recommendations/page/:guessPage/",
"Play"
],
[
"/series-play/:slug/",
"SeriesPlay"
],
[
"/series-play/:slug/recommendations/page/:guessPage/",
"SeriesPlay"
],
[
"/login/",
"LoginPage"
],
[
"/register/",
"RegisterPage"
],
[
"/feedback/",
"Feedback"
]
]
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
{"metaTitle": "花魁 & \"标题\" <test>", "metaDescription": "短剧'说明 & 安全", "trackerAppId": "app'\"</script>", "pageKey": "tags", "pageName": "标签", "siteName": "花魁", "siteURL": "https://drama.example", "canonicalURL": "https://drama.example/tags/", "ogType": "website", "robots": "index, follow", "appConfig": {"ReportSDKConfig": ""}, "listCount": "共 1 部", "list": [{"id": 1, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2]}], "tagSlug": "", "tagId": 0, "sortType": 1}
+717
View File
@@ -0,0 +1,717 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>花魁 &amp; &#34;标题&#34; &lt;test&gt;</title>
<meta name="description" content="短剧&#39;说明 &amp; 安全">
<meta name="robots" content="index, follow">
<meta name="googlebot" content="index, follow">
<meta name="google-site-verification" content="VEhl0na6ZwYZgkdcdz8BcCR4LpYGFm97zbp62YqJHX4">
<meta name="yandex-verification" content="56acf08532be3060">
<meta name="yandex-verification" content="acaf652efa6cd920">
<meta name="msvalidate.01" content="57A5999E9B5167EE9039265EBCA2AE3A">
<link rel="canonical" href="https://drama.example/tags/">
<meta property="og:locale" content="zh_CN">
<meta property="og:type" content="website">
<meta property="og:site_name" content="花魁">
<meta property="og:title" content="花魁 &amp; &#34;标题&#34; &lt;test&gt;">
<meta property="og:description" content="短剧&#39;说明 &amp; 安全">
<meta property="og:url" content="https://drama.example/tags/">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="花魁 &amp; &#34;标题&#34; &lt;test&gt;">
<meta name="twitter:description" content="短剧&#39;说明 &amp; 安全">
<link rel="icon" href="/static/favicon.svg?v=huakui-20261010" type="image/svg+xml" sizes="any">
<link rel="icon" href="/static/favicon.png?v=huakui-20261010" type="image/png">
<link rel="apple-touch-icon" href="/static/favicon.png?v=huakui-20261010">
<script src="/static/js/common/channel.js?v=huakui-20261010"></script>
<script async src="https://www.googletagmanager.com/gtag/js?id=G-Z582WY8LN9"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-Z582WY8LN9');
</script>
<script type="text/javascript">
(function(m,e,t,r,i,k,a){
m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
m[i].l=1*new Date();
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)
})(window, document,'script','https://mc.yandex.ru/metrika/tag.js?id=112951899', 'ym');
ym(112951899, 'init', {ssr:true, webvisor:true, clickmap:true, ecommerce:"dataLayer", referrer: document.referrer, url: location.href, accurateTrackBounce:true, trackLinks:true});
</script>
<script>
window.__TRACKER_BOOT__ = {
appId: "app\u0027\u0022\u003c\/script\u003e",
encryptedConfig: "",
appVersion: "1.0.0",
pageKey: "tags",
pageName: "标签",
userType: "normal"
};
</script>
<script defer src="/static/common/web-sdk-v1.2.0.js?v=huakui-20261010"></script>
<script defer src="/static/js/tracker.js?v=huakui-20261010"></script>
<link rel="stylesheet" href="/static/css/base.css?v=huakui-20261010">
<link rel="stylesheet" href="/static/css/list.css?v=huakui-20261010">
<link rel="stylesheet" href="/static/css/tags.css?v=huakui-20261010">
</head>
<body class="page-list page-tags">
<link rel="stylesheet" href="/static/css/header.css?v=huakui-20261010">
<header class="site-header">
<div class="site-header__left">
<button class="site-header__icon-btn only-h5" type="button" aria-label="菜单" data-drawer-open>
<img decoding="async" loading="lazy" class="icon" src="/static/brand/placeholder.svg?v=huakui-20261010" data-src="/static/images/menu.svg?v=huakui-20261010" alt="菜单">
</button>
<a class="brand" href="/" data-navigation-key="home" data-navigation-name="首页">
<picture>
<source media="(max-width: 768px)" srcset="/static/brand/mobile.svg?v=huakui-20261010">
<img class="brand__logo" src="/static/brand/header.svg?v=huakui-20261010" width="104" height="36" alt="花魁短剧">
</picture>
</a>
<nav class="nav only-pc" aria-label="主导航">
</nav>
</div>
<div class="site-header__right">
<form class="search only-pc" action="/search/" method="get" role="search" data-path-search data-header-search>
<img decoding="async" loading="lazy" class="icon" src="/static/brand/placeholder.svg?v=huakui-20261010" data-src="/static/images/search.svg?v=huakui-20261010" alt="搜索">
<input class="search__input" type="search" placeholder="搜索剧名 / 演员 / 标签" autocomplete="off" data-header-search-input aria-autocomplete="list" aria-expanded="false" aria-controls="header-search-dropdown">
<button class="search__clear" type="button" aria-label="清除搜索词" data-header-search-clear hidden>
<img decoding="async" loading="lazy" src="/static/brand/placeholder.svg?v=huakui-20261010" data-src="/static/images/close.svg?v=huakui-20261010" alt="关闭">
</button>
<div class="search-dropdown" id="header-search-dropdown" data-header-search-dropdown hidden>
<div class="search-dropdown__idle" data-header-search-idle>
<section class="search-dropdown__history" aria-label="搜索历史" data-header-search-history hidden>
<div class="search-dropdown__head">
<h2 class="search-dropdown__label">搜索历史</h2>
<button class="search-dropdown__clear" type="button" aria-label="清除搜索历史" data-header-history-clear>
<img decoding="async" loading="lazy" src="/static/brand/placeholder.svg?v=huakui-20261010" data-src="/static/images/close.svg?v=huakui-20261010" alt="清除">
</button>
</div>
<div class="search-dropdown__chips" data-header-history-chips></div>
</section>
</div>
<div class="search-dropdown__suggest" data-header-search-suggest hidden aria-label="搜索联想"></div>
</div>
</form>
<a class="site-header__icon-btn only-h5" href="/search-mobile/" aria-label="搜索" data-navigation-key="header_search" data-navigation-name="搜索">
<img decoding="async" loading="lazy" class="icon" src="/static/brand/placeholder.svg?v=huakui-20261010" data-src="/static/images/search_h5.svg?v=huakui-20261010" alt="搜索">
</a>
<a class="btn btn--ghost site-header__download" data-download-open href="/download-app/" data-navigation-key="header_download" data-navigation-name="下载 APP">
<img decoding="async" loading="lazy" class="icon" src="/static/brand/placeholder.svg?v=huakui-20261010" data-src="/static/images/download.svg?v=huakui-20261010" alt="下载">
<span>下载 App</span>
</a>
<button class="btn btn--primary site-header__login" type="button" data-login-open>登录</button>
</div>
</header>
<div class="drawer" data-drawer>
<div class="drawer__mask" data-drawer-close></div>
<aside class="drawer__panel" id="site-drawer" role="dialog" aria-modal="true" aria-label="侧边导航">
<div class="drawer__head">
<div class="drawer__brand-row">
<a class="brand" href="/" data-navigation-key="drawer_home" data-navigation-name="首页">
<img class="brand__logo" src="/static/brand/mobile.svg?v=huakui-20261010" width="88" height="28" alt="花魁短剧">
</a>
<button class="drawer__close" type="button" aria-label="关闭" data-drawer-close>
<img decoding="async" loading="lazy" class="icon" src="/static/brand/placeholder.svg?v=huakui-20261010" data-src="/static/images/close.svg?v=huakui-20261010" alt="关闭">
</button>
</div>
<button class="drawer__user drawer__user--login" type="button" data-login-open>
<img decoding="async" loading="lazy" class="drawer__avatar" src="/static/brand/placeholder.svg?v=huakui-20261010" alt="默认用户头像">
<div class="drawer__user-info">
<strong>登录 / 注册</strong>
<span class="drawer__coin">登录后同步收藏与进度</span>
</div>
<img decoding="async" loading="lazy" class="icon icon--sm drawer__user-arrow" src="/static/brand/placeholder.svg?v=huakui-20261010" data-src="/static/images/chevron-right.svg?v=huakui-20261010" alt="前往下一项">
</button>
</div>
<hr class="drawer__rule">
<nav class="drawer__nav" aria-label="页面导航">
</nav>
<hr class="drawer__rule">
<div class="drawer__tags">
<p class="drawer__tags-label">题材</p>
<div class="drawer__tag-list">
</div>
</div>
<div class="drawer__links">
<a class="drawer__link" href="/favorites/" data-navigation-key="drawer_favorites" data-navigation-name="我的收藏">
<img decoding="async" loading="lazy" class="icon" src="/static/brand/placeholder.svg?v=huakui-20261010" data-src="/static/images/menu_sc.svg?v=huakui-20261010" alt="我的收藏">
我的收藏
</a>
<a class="drawer__link" href="/history/" data-navigation-key="drawer_history" data-navigation-name="观看历史">
<img decoding="async" loading="lazy" class="icon" src="/static/brand/placeholder.svg?v=huakui-20261010" data-src="/static/images/menu_gkls.svg?v=huakui-20261010" alt="观看历史">
观看历史
</a>
</div>
<div class="drawer__foot">
<a class="btn btn--ghost drawer__download" data-download-open href="/download-app/" data-navigation-key="drawer_download" data-navigation-name="下载 APP">
<img decoding="async" loading="lazy" class="icon" src="/static/brand/placeholder.svg?v=huakui-20261010" data-src="/static/images/download.svg?v=huakui-20261010" alt="下载">
下载 App
</a>
</div>
</aside>
</div>
<script defer src="/static/js/common/cookie.js?v=huakui-20261010"></script>
<script defer src="/static/js/common/request.js?v=huakui-20261010"></script>
<script defer src="/static/js/common/auth.js?v=huakui-20261010"></script>
<script defer src="/static/common/imgDecypt.js?v=huakui-20261010"></script>
<script defer src="/static/js/header.js?v=huakui-20261010"></script>
<script defer src="/static/js/pages/loginPop.js?v=huakui-20261010"></script>
<script defer src="/static/js/pages/registerPop.js?v=huakui-20261010"></script>
<link rel="stylesheet" href="/static/css/eighteen.css?v=huakui-20261010">
<script>
(function () {
try {
if (localStorage.getItem("hg_age_ok") === "1") {
document.documentElement.classList.add("eighteen-ok");
}
} catch (e) {}
})();
</script>
<div class="eighteen" data-eighteen role="dialog" aria-modal="true" aria-labelledby="eighteen-title">
<div class="eighteen__mask" aria-hidden="true"></div>
<div class="eighteen__dialog">
<div class="eighteen__badge" aria-hidden="true">18+</div>
<h2 class="eighteen__title" id="eighteen-title">年龄确认</h2>
<p class="eighteen__desc">本网站部分内容仅适合成年人观看。点击继续即表示您确认您已年满 18 岁,或在您访问本网站时所在的司法管辖区已是成年人。</p>
<button class="btn btn--primary eighteen__enter" type="button" data-eighteen-enter>
我已年满 18 岁,进入
</button>
<button class="eighteen__exit" type="button" data-eighteen-exit>
我未满 18 岁,退出
</button>
</div>
</div>
<script src="/static/js/eighteen.js?v=huakui-20261010" defer></script>
<link rel="stylesheet" href="/static/css/loginPop.css?v=huakui-20261010">
<div
class="login-pop"
data-login-pop
role="dialog"
aria-modal="true"
aria-labelledby="login-pop-title"
>
<div class="login-pop__mask" data-login-close aria-hidden="true"></div>
<div class="login-pop__panel">
<button class="login-pop__close" type="button" data-login-close aria-label="关闭">
<img decoding="async" loading="lazy" class="icon" src="/static/brand/placeholder.svg?v=huakui-20261010" data-src="/static/images/close.svg?v=huakui-20261010" alt="关闭">
</button>
<div class="login-pop__intro">
<h2 class="login-pop__title" id="login-pop-title" data-login-title>登录花魁短剧</h2>
<p class="login-pop__desc" data-login-desc>登录后可收藏作品、解锁付费剧集并查看观看历史</p>
</div>
<form
class="login-pop__form"
action="/api/auth/login"
method="post"
data-login-form
novalidate
>
<label class="login-pop__field" data-field="account">
<span class="login-pop__label">账号</span>
<span class="login-pop__control">
<img decoding="async" loading="lazy" class="icon" src="/static/brand/placeholder.svg?v=huakui-20261010" data-src="/static/images/user.svg?v=huakui-20261010" alt="用户" aria-hidden="true">
<input
class="login-pop__input"
type="text"
name="account"
autocomplete="username"
placeholder="请输入账号"
maxlength="16"
required
>
</span>
<span class="login-pop__error" data-field-error="account" hidden>
<img decoding="async" loading="lazy" class="login-pop__error-icon" src="/static/brand/placeholder.svg?v=huakui-20261010" data-src="/static/images/error-tip.svg?v=huakui-20261010" alt="错误提示" aria-hidden="true">
<span data-field-error-text></span>
</span>
</label>
<label class="login-pop__field" data-field="password">
<span class="login-pop__label">密码</span>
<span class="login-pop__control">
<img decoding="async" loading="lazy" class="icon" src="/static/brand/placeholder.svg?v=huakui-20261010" data-src="/static/images/lock-dim.svg?v=huakui-20261010" alt="密码" aria-hidden="true">
<input
class="login-pop__input"
type="password"
name="password"
autocomplete="current-password"
placeholder="请输入登录密码"
maxlength="16"
data-login-password
required
>
<button class="login-pop__toggle" type="button" data-login-toggle aria-label="显示密码">
<img decoding="async" loading="lazy" class="icon" src="/static/brand/placeholder.svg?v=huakui-20261010" data-src="/static/images/eye.svg?v=huakui-20261010" alt="显示或隐藏密码" data-login-toggle-icon>
</button>
</span>
<span class="login-pop__error" data-field-error="password" hidden>
<img decoding="async" loading="lazy" class="login-pop__error-icon" src="/static/brand/placeholder.svg?v=huakui-20261010" data-src="/static/images/error-tip.svg?v=huakui-20261010" alt="错误提示" aria-hidden="true">
<span data-field-error-text></span>
</span>
</label>
<div class="login-pop__meta">
<label class="login-pop__remember">
<input class="login-pop__checkbox" type="checkbox" name="remember" checked>
<span class="login-pop__check" aria-hidden="true"></span>
<span>记住登录状态</span>
</label>
</div>
<button class="btn btn--primary login-pop__submit" type="submit" data-login-submit>登录</button>
</form>
<p class="login-pop__switch"><span>还没有账号?</span><a href="/register/" data-register-open>立即注册</a></p>
<div class="login-pop__rule" aria-hidden="true"></div>
<p class="login-pop__legal">
登录即表示你已年满 18 岁并同意
<a href="/terms/">《用户条款》</a>
与
<a href="/privacy/">《隐私政策》</a>
</p>
</div>
</div>
<link rel="stylesheet" href="/static/css/registerPop.css?v=huakui-20261010">
<div
class="register-pop"
data-register-pop
role="dialog"
aria-modal="true"
aria-labelledby="register-pop-title"
>
<div class="register-pop__mask" data-register-close aria-hidden="true"></div>
<div class="register-pop__panel">
<button class="register-pop__close" type="button" data-register-close aria-label="关闭">
<img decoding="async" loading="lazy" class="icon" src="/static/brand/placeholder.svg?v=huakui-20261010" data-src="/static/images/close.svg?v=huakui-20261010" alt="关闭">
</button>
<div class="register-pop__intro">
<h2 class="register-pop__title" id="register-pop-title">创建账号</h2>
<p class="register-pop__desc">注册后即可解锁付费剧集并收藏作品</p>
</div>
<form
class="register-pop__form"
action="/api/auth/register"
method="post"
data-register-form
novalidate
>
<label class="register-pop__field" data-field="account">
<span class="register-pop__label">账号</span>
<span class="register-pop__control">
<img decoding="async" loading="lazy" class="icon" src="/static/brand/placeholder.svg?v=huakui-20261010" data-src="/static/images/user.svg?v=huakui-20261010" alt="用户" aria-hidden="true">
<input
class="register-pop__input"
type="text"
name="account"
autocomplete="username"
placeholder="6 到 20 位字母或数字"
minlength="6"
maxlength="20"
pattern="[A-Za-z0-9]{6,20}"
required
>
</span>
<span class="register-pop__error" data-field-error="account" hidden>
<img decoding="async" loading="lazy" class="register-pop__error-icon" src="/static/brand/placeholder.svg?v=huakui-20261010" data-src="/static/images/error-tip.svg?v=huakui-20261010" alt="错误提示" aria-hidden="true">
<span data-field-error-text></span>
</span>
</label>
<label class="register-pop__field" data-field="password">
<span class="register-pop__label">密码</span>
<span class="register-pop__control">
<img decoding="async" loading="lazy" class="icon" src="/static/brand/placeholder.svg?v=huakui-20261010" data-src="/static/images/lock-dim.svg?v=huakui-20261010" alt="密码" aria-hidden="true">
<input
class="register-pop__input"
type="password"
name="password"
autocomplete="new-password"
placeholder="8 位以上,含字母与数字"
minlength="8"
maxlength="64"
data-register-password
required
>
<button class="register-pop__toggle" type="button" data-register-toggle="password" aria-label="显示密码">
<img decoding="async" loading="lazy" class="icon" src="/static/brand/placeholder.svg?v=huakui-20261010" data-src="/static/images/eye.svg?v=huakui-20261010" alt="显示或隐藏密码" data-register-toggle-icon="password">
</button>
</span>
<span class="register-pop__error" data-field-error="password" hidden>
<img decoding="async" loading="lazy" class="register-pop__error-icon" src="/static/brand/placeholder.svg?v=huakui-20261010" data-src="/static/images/error-tip.svg?v=huakui-20261010" alt="错误提示" aria-hidden="true">
<span data-field-error-text></span>
</span>
</label>
<label class="register-pop__field" data-field="passwordConfirm">
<span class="register-pop__label">确认密码</span>
<span class="register-pop__control">
<img decoding="async" loading="lazy" class="icon" src="/static/brand/placeholder.svg?v=huakui-20261010" data-src="/static/images/lock-dim.svg?v=huakui-20261010" alt="密码" aria-hidden="true">
<input
class="register-pop__input"
type="password"
name="passwordConfirm"
autocomplete="new-password"
placeholder="再次输入密码"
minlength="8"
maxlength="64"
data-register-password-confirm
required
>
<button class="register-pop__toggle" type="button" data-register-toggle="confirm" aria-label="显示密码">
<img decoding="async" loading="lazy" class="icon" src="/static/brand/placeholder.svg?v=huakui-20261010" data-src="/static/images/eye.svg?v=huakui-20261010" alt="显示或隐藏密码" data-register-toggle-icon="confirm">
</button>
</span>
<span class="register-pop__error" data-field-error="passwordConfirm" hidden>
<img decoding="async" loading="lazy" class="register-pop__error-icon" src="/static/brand/placeholder.svg?v=huakui-20261010" data-src="/static/images/error-tip.svg?v=huakui-20261010" alt="错误提示" aria-hidden="true">
<span data-field-error-text></span>
</span>
</label>
<label class="register-pop__field" data-field="agentInviteCode">
<span class="register-pop__label">邀请码(选填)</span>
<span class="register-pop__control">
<img class="icon" src="/static/images/auth/award.svg?v=huakui-20261010" alt="" aria-hidden="true" width="18" height="18">
<input class="register-pop__input" type="text" name="agentInviteCode" autocomplete="off" placeholder="如有邀请码,请在此填写">
</span>
<span class="register-pop__error" data-field-error="agentInviteCode" hidden><img class="register-pop__error-icon" src="/static/images/error-tip.svg?v=huakui-20261010" alt="" aria-hidden="true"><span data-field-error-text></span></span>
</label>
<label class="register-pop__agree" data-field="agree">
<input class="register-pop__checkbox" type="checkbox" name="agree" required>
<span class="register-pop__check" aria-hidden="true"></span>
<span>
我已年满 18 岁,并同意
<a href="/terms/">《用户条款》</a>
与
<a href="/privacy/">《隐私政策》</a>
</span>
</label>
<span class="register-pop__error" data-field-error="agree" hidden>
<img decoding="async" loading="lazy" class="register-pop__error-icon" src="/static/brand/placeholder.svg?v=huakui-20261010" data-src="/static/images/error-tip.svg?v=huakui-20261010" alt="错误提示" aria-hidden="true">
<span data-field-error-text></span>
</span>
<button class="btn btn--primary register-pop__submit" type="submit" data-register-submit>注册</button>
</form>
<p class="register-pop__switch">
<span>已有账号?</span>
<a href="/login/" data-login-open>立即登录</a>
</p>
</div>
</div>
<link rel="stylesheet" href="/static/css/downloadPop.css?v=huakui-20261010">
<div
class="download-pop"
data-download-pop
data-app-addr=""
role="dialog"
aria-modal="true"
aria-labelledby="download-pop-title"
>
<div class="download-pop__mask" data-download-close aria-hidden="true"></div>
<div class="download-pop__panel">
<button class="download-pop__close" type="button" data-download-close aria-label="关闭">
<img decoding="async" loading="lazy" class="icon" src="/static/brand/placeholder.svg?v=huakui-20261010" data-src="/static/images/close.svg?v=huakui-20261010" alt="">
</button>
<div class="download-pop__copy">
<h2 class="download-pop__title" id="download-pop-title">下载花魁短剧 App</h2>
<p class="download-pop__desc">点击后前往下载页,手机打开会自动识别设备并推荐安装方式</p>
</div>
<a class="download-pop__download" href="/download-app/">
<img decoding="async" loading="lazy" class="download-pop__download-icon" src="/static/brand/placeholder.svg?v=huakui-20261010" data-src="/static/images/download.svg?v=huakui-20261010" alt="">
点击下载
</a>
</div>
</div>
<script defer src="/static/js/pages/downloadPop.js?v=huakui-20261010"></script>
<link rel="stylesheet" href="/static/css/installGuide.css?v=huakui-20261010">
<div class="install-guide" data-install-guide hidden>
<div class="install-guide__mask" data-install-close data-download-close aria-hidden="true"></div>
<section class="install-guide__sheet" role="dialog" aria-modal="true" aria-labelledby="install-guide-title" aria-describedby="install-guide-description" tabindex="-1">
<div class="install-guide__handle" aria-hidden="true"><span></span></div>
<header class="install-guide__head">
<div class="install-guide__heading">
<h2 id="install-guide-title">下载花魁短剧 App</h2>
<p id="install-guide-description" data-install-description
data-ios-description="已识别为 iOS 设备,可前往官方下载页查看安装方式"
data-android-description="已识别为 Android 设备,可前往官方下载页查看安装方式">请选择适合当前设备的安装方式</p>
</div>
<button class="install-guide__close" type="button" data-install-close data-download-close aria-label="关闭安装引导"><img src="/static/images/close.svg?v=huakui-20261010" alt="" width="18" height="18"></button>
</header>
<div class="install-guide__tabs" role="tablist" aria-label="iOS 安装方式" data-install-tabs>
<button id="install-guide-store-tab" type="button" role="tab" aria-selected="true" aria-controls="install-guide-store" tabindex="0" data-install-tab="store">商店版</button>
<button id="install-guide-profile-tab" type="button" role="tab" aria-selected="false" aria-controls="install-guide-profile" tabindex="-1" data-install-tab="profile">轻量版</button>
</div>
<div class="install-guide__content" id="install-guide-store" role="tabpanel" aria-labelledby="install-guide-store-tab" data-install-panel="store">
<div class="install-guide__store-card">
<span class="install-guide__store-icon"><img src="/static/images/brand-apple.svg?v=huakui-20261010" alt="" width="20" height="20"></span>
<div class="install-guide__store-copy">
<div class="install-guide__store-title">花魁短剧 App Store 版</div>
<p>前往官方下载页,查看商店版是否可用及当前设备的安装方式</p>
</div>
</div>
<div class="install-guide__step install-guide__store-tip">
<span class="install-guide__badge install-guide__badge--tip">提示</span>
<div class="install-guide__step-copy"><h3>所在地区未上架时</h3><p>请切换到轻量版,用 Safari 安装描述文件</p></div>
</div>
<p class="install-guide__notice"><img src="/static/images/install/notice.svg?v=huakui-20261010" alt="" width="14" height="14"><span>需登录 App Store 账号;若显示「不可用」,请返回选择轻量版</span></p>
<a class="install-guide__action" data-install-action="store" data-navigation-key="install_ios_store" data-navigation-name="前往下载页" href="/download-app/" target="_blank" rel="sponsored nofollow noopener">
<img src="/static/images/install/external-link.svg?v=huakui-20261010" alt="" width="20" height="20"><span>前往下载页</span>
</a>
</div>
<div class="install-guide__content" id="install-guide-profile" role="tabpanel" aria-labelledby="install-guide-profile-tab" data-install-panel="profile" hidden>
<ol class="install-guide__steps">
<li class="install-guide__step"><span class="install-guide__badge">1</span><div class="install-guide__step-copy"><h3>前往官方下载页</h3><p>用 Safari 打开本页,点击「前往下载页」,查看轻量版是否可用及安装说明</p></div></li>
<li class="install-guide__step"><span class="install-guide__badge">2</span><div class="install-guide__step-copy"><h3>安装描述文件</h3><p>前往 设置 / 已下载描述文件,选择「安装」并输入密码</p></div></li>
<li class="install-guide__step"><span class="install-guide__badge">3</span><div class="install-guide__step-copy"><h3>返回桌面打开</h3><p>桌面出现花魁短剧图标即安装完成,建议保存账号凭证</p></div></li>
</ol>
<p class="install-guide__notice"><img src="/static/images/install/notice.svg?v=huakui-20261010" alt="" width="14" height="14"><span>仅支持 Safari;若在微信 / Telegram 内打开,请先点右上角「在浏览器中打开」</span></p>
<a class="install-guide__action" data-install-action="profile" data-navigation-key="install_ios_profile" data-navigation-name="前往下载页" href="/download-app/" target="_blank" rel="sponsored nofollow noopener"><img src="/static/images/brand-apple.svg?v=huakui-20261010" alt="" width="20" height="20"><span>前往下载页</span></a>
</div>
<div class="install-guide__content" id="install-guide-android" data-install-panel="android" hidden>
<ol class="install-guide__steps">
<li class="install-guide__step"><span class="install-guide__badge">1</span><div class="install-guide__step-copy"><h3>前往官方下载页</h3><p>点击「前往下载页」,查看 Android 安装包及适用于当前设备的安装说明</p></div></li>
<li class="install-guide__step"><span class="install-guide__badge">2</span><div class="install-guide__step-copy"><h3>允许安装未知来源</h3><p>系统提示「禁止安装来源不明的应用」时,点击「设置」开启允许</p></div></li>
<li class="install-guide__step"><span class="install-guide__badge">3</span><div class="install-guide__step-copy"><h3>打开花魁短剧</h3><p>安装完成后点击「打开」,或在桌面找到花魁短剧图标</p></div></li>
</ol>
<p class="install-guide__notice"><img src="/static/images/install/notice.svg?v=huakui-20261010" alt="" width="14" height="14"><span>如系统提示风险,请核实安装包来源后再继续</span></p>
<a class="install-guide__action" data-install-action="android" data-navigation-key="install_android_apk" data-navigation-name="前往下载页" href="/download-app/" target="_blank" rel="sponsored nofollow noopener"><img src="/static/images/brand-android.svg?v=huakui-20261010" alt="" width="20" height="20"><span>前往下载页</span></a>
</div>
</section>
</div>
<script defer src="/static/js/components/install-guide.js?v=huakui-20261010"></script>
<link rel="stylesheet" href="/static/css/toast.css?v=huakui-20261010">
<div
class="site-toast"
id="site-toast"
data-toast
role="status"
aria-live="polite"
aria-atomic="true"
hidden
>
<div class="site-toast__panel" data-toast-panel>
<img decoding="async" loading="lazy" class="site-toast__icon" data-toast-icon-success src="/static/brand/placeholder.svg?v=huakui-20261010" data-src="/static/images/check-circle.svg?v=huakui-20261010" alt="操作成功" hidden>
<img decoding="async" loading="lazy" class="site-toast__icon" data-toast-icon-error src="/static/brand/placeholder.svg?v=huakui-20261010" data-src="/static/images/error-tip.svg?v=huakui-20261010" alt="错误提示" hidden>
<p class="site-toast__text" data-toast-text></p>
</div>
</div>
<script defer src="/static/js/common/toast.js?v=huakui-20261010"></script>
<script defer src="/static/js/common/sharePage.js?v=huakui-20261010"></script>
<header class="page-subheader only-h5" aria-label="标签页顶栏">
<a class="page-subheader__btn" href="javascript:history.back()" aria-label="返回">
<img decoding="async" loading="lazy" class="icon" src="/static/brand/placeholder.svg?v=huakui-20261010" data-src="/static/images/chevron-left.svg?v=huakui-20261010" alt="返回">
</a>
<div class="page-subheader__title">标签</div>
<a class="page-subheader__btn" href="/search-mobile/" aria-label="搜索">
<img decoding="async" loading="lazy" class="icon" src="/static/brand/placeholder.svg?v=huakui-20261010" data-src="/static/images/search_h5.svg?v=huakui-20261010" alt="搜索">
</a>
</header>
<div class="page list-page">
<main class="main">
<section class="list-hero list-hero--tags" aria-label="标签频道">
<nav class="breadcrumb only-pc" aria-label="面包屑">
<a class="breadcrumb__item" href="/">首页</a>
<img decoding="async" loading="lazy" class="breadcrumb__sep" src="/static/brand/placeholder.svg?v=huakui-20261010" data-src="/static/images/chevron-right.svg?v=huakui-20261010" alt="前往下一项">
<a class="breadcrumb__item" href="/list/">短剧</a>
<img decoding="async" loading="lazy" class="breadcrumb__sep" src="/static/brand/placeholder.svg?v=huakui-20261010" data-src="/static/images/chevron-right.svg?v=huakui-20261010" alt="前往下一项">
<span class="breadcrumb__item is-current">标签</span>
</nav>
<div class="list-hero__intro">
<p class="eyebrow list-hero__eyebrow">
<span class="eyebrow__bar" aria-hidden="true"></span>
标签
</p>
<h1 class="list-hero__title">标签短剧</h1>
<p class="list-hero__desc"></p>
</div>
</section>
<section class="list-filters" aria-label="筛选">
<div class="filter-row">
<span class="filter-row__label only-pc">排序</span>
<div class="filter-row__options">
<a class="filter-chip is-active" href="/tags/">最新</a>
<a class="filter-chip" href="/tags/sort/2/">最热</a>
<a class="filter-chip" href="/tags/sort/3/">收藏最多</a>
</div>
</div>
</section>
<section class="list-body" aria-label="标签作品">
<h2 class="visually-hidden">标签短剧作品列表</h2>
<p class="list-count">共 1 部</p>
<div class="grid--list">
<article class="card card--list">
<a class="card__cover lazy" href="/play/%E7%83%AD%E9%97%A8%20%E5%89%A7/" style="--cover:url(/static/brand/placeholder.svg)" data-src="https://example.com/%e5%9b%be%e7%89%87?a=1&amp;b=2">
<span class="card__ep">全 4 集</span>
</a>
<h3 class="card__title"><a href="/play/%E7%83%AD%E9%97%A8%20%E5%89%A7/">花魁 &amp; 第一部</a></h3>
<p class="card__meta"><a href="/tags/%E9%83%BD%E5%B8%82/" >都市</a></p>
</article>
</div>
<link rel="stylesheet" href="/static/css/pagination.css?v=huakui-20261010">
</section>
</main>
</div>
<link rel="stylesheet" href="/static/css/footer.css?v=huakui-20261010">
<footer class="site-footer">
<div class="site-footer__inner">
<div class="site-footer__top">
<div class="site-footer__brand">
<div class="brand">
<picture>
<source media="(max-width: 768px)" srcset="/static/brand/header.svg?v=huakui-20261010">
<img class="brand__logo brand__logo--lg" src="/static/brand/footer.svg?v=huakui-20261010" width="124" height="44" alt="花魁短剧">
</picture>
</div>
<p class="block__subtitle">每一帧都值得细看</p>
<div class="social">
</div>
</div>
<div class="site-footer__links">
<nav class="footer-col" aria-label="浏览">
<h3>浏览</h3>
</nav>
<nav class="footer-col" aria-label="帮助">
<h3>帮助</h3>
<a href="/about/" data-navigation-key="footer_about" data-navigation-name="关于我们">关于我们</a>
<a href="/help/" data-navigation-key="footer_help" data-navigation-name="常见问题">常见问题</a>
<a href="/feedback/" data-navigation-key="footer_feedback" data-navigation-name="意见反馈">意见反馈</a>
<a href="" data-navigation-key="footer_contact" data-navigation-name="联系我们">联系我们</a>
</nav>
<nav class="footer-col" aria-label="条款">
<h3>条款</h3>
<a href="/terms/" data-navigation-key="footer_terms" data-navigation-name="用户条款">用户条款</a>
<a href="/privacy/" data-navigation-key="footer_privacy" data-navigation-name="隐私政策">隐私政策</a>
<a href="/2257/" data-navigation-key="footer_2257" data-navigation-name="2257 合规声明">2257 合规声明</a>
<a href="/dmca/" data-navigation-key="footer_dmca" data-navigation-name="DMCA 版权声明">DMCA 版权声明</a>
</nav>
<a class="footer-download-mobile" data-download-open href="/download-app/" data-navigation-key="footer_download_mobile" data-navigation-name="下载 App">下载 App</a>
<div class="footer-download">
<div class="qr-box" aria-label="扫码下载">
<img decoding="async" loading="lazy" src="/static/brand/placeholder.svg?v=huakui-20261010" alt="扫码下载">
</div>
<div class="footer-download__copy">
<strong>扫码下载 App</strong>
<p>iOS / Android 海量短剧每日更新</p>
<a class="btn btn--primary btn--sm" data-download-open href="/download-app/" data-navigation-key="footer_download" data-navigation-name="下载 APP">
<img decoding="async" loading="lazy" class="icon icon--sm" src="/static/brand/placeholder.svg?v=huakui-20261010" data-src="/static/images/download.svg?v=huakui-20261010" alt="下载">
下载 App
</a>
</div>
</div>
</div>
</div>
<hr class="site-footer__rule">
<div class="site-footer__legal">
<p>© 2026 花魁短剧 HUAKUI&nbsp; <span class="site-footer__rights">All rights reserved.</span><span class="site-footer__mobile-adult">仅限 18 岁以上成年人</span></p>
<p><span class="site-footer__adult">仅限 18 岁以上成年人&nbsp; </span>本网站所呈现之所有人员,于拍摄当时均已年满 18 岁,符合 18 U.S.C. 第 2257 条规定。</p>
</div>
</div>
</footer>
<noscript><div><img src="https://mc.yandex.ru/watch/112951899" style="position:absolute; left:-9999px;" alt=""></div></noscript>
<script defer src="/static/common/qrcode.min.js?v=huakui-20261010"></script>
<script defer src="/static/js/common/qr.js?v=huakui-20261010"></script>
</body>
</html>
+275
View File
@@ -0,0 +1,275 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
imageCDNBases,
imageSourceURLs,
validProfileImage,
} from "../src/lib/media/image-source";
import {
decodeImageBytes,
decryptImageBytes,
imageMime,
resolveImage,
} from "../src/lib/media/images";
const primary = "https://primary.images.test/assets/";
const backup = "https://backup.images.test/media/";
const signedPath = "v3/image/cover.jpg?ticket=a%2Bb%3D&expires=99&path=%2Ffoo";
const bytesOf = (path: string): Uint8Array =>
new Uint8Array(readFileSync(new URL(path, import.meta.url)));
const png = bytesOf("../public/static/favicon.png");
const fixtures = [
["PNG", "image/png", png],
[
"JPEG",
"image/jpeg",
bytesOf("../public/static/images/profile/water-margin.jpg"),
],
[
"WebP",
"image/webp",
new Uint8Array(
Buffer.from("UklGRhwAAABXRUJQVlA4TBAAAAAvAAAAAAcQ/S9g/wMR0f8A", "base64"),
),
],
[
"GIF",
"image/gif",
new Uint8Array(
Buffer.from("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", "base64"),
),
],
] as const;
const bufferOf = (bytes: Uint8Array): ArrayBuffer =>
Uint8Array.from(bytes).buffer;
test("CDN bases retain deployment paths, deduplicate, and reject unsafe configuration", () => {
assert.deepEqual(
imageCDNBases(
" https://primary.images.test/assets ",
primary,
"http://backup.images.test/media/",
"https://user:secret@private.images.test/",
"https://query.images.test/?token=secret",
"https://hash.images.test/#fragment",
"javascript:alert(1)",
"data:image/png;base64,abc",
"//relative.images.test/",
"https://",
"",
null,
42,
{},
),
[primary, "http://backup.images.test/media/"],
);
});
test("relative API images use the CDN base or the site root on every page", () => {
for (const source of [signedPath, "/" + signedPath]) {
assert.deepEqual(imageSourceURLs(source), ["/" + signedPath]);
assert.deepEqual(imageSourceURLs(source, [primary, backup]), [
primary + signedPath,
backup + signedPath,
]);
}
});
test("absolute signed image URLs remain byte-for-byte intact and only configured CDN paths gain fallbacks", () => {
const source = primary + signedPath;
assert.deepEqual(imageSourceURLs(source, [primary, backup]), [
source,
backup + signedPath,
]);
assert.deepEqual(imageSourceURLs(backup + signedPath, [primary, backup]), [
backup + signedPath,
source,
]);
for (const outside of [
"https://external.images.test/" + signedPath,
"https://primary.images.test/assets-other/" + signedPath,
])
assert.deepEqual(imageSourceURLs(outside, [primary, backup]), [outside]);
});
test("static, inline and blob sources bypass rewriting while executable sources are rejected", () => {
for (const source of [
"/static/brand/placeholder.svg?v=1",
"data:image/png;base64,iVBORw0KGgo=",
"blob:https://site.test/image-id",
])
assert.deepEqual(imageSourceURLs(source, [primary, backup]), [source]);
for (const source of [
"javascript:alert(1)",
"data:text/html,<script>alert(1)</script>",
"file:///private/image.png",
"vbscript:msgbox(1)",
"https://",
"",
])
assert.deepEqual(imageSourceURLs(source, [primary, backup]), []);
});
test("profile avatars accept real API image paths without accepting executable or malformed HTTP URLs", () => {
for (const source of [
"https://avatars.images.test/face.jpg?signature=a%2Bb%3D",
"http://avatars.images.test/face.png",
"/static/images/profile/avatar.png?v=1",
signedPath,
"/" + signedPath,
])
assert.equal(validProfileImage(source), source);
for (const source of [
null,
42,
{},
"",
"javascript:alert(1)",
"data:text/html,unsafe",
"https://",
"http://[malformed",
"https://bad host/image.png",
"ordinary-relative-avatar.png",
])
assert.equal(validProfileImage(source), "");
});
test("image decoding accepts complete raw and legacy XOR images without mutating their input", () => {
for (const [name, mime, original] of fixtures) {
const plainBuffer = bufferOf(original);
assert.deepEqual(decodeImageBytes(plainBuffer), original, name + " raw");
assert.equal(imageMime(decodeImageBytes(plainBuffer)), mime, name + " type");
assert.deepEqual(new Uint8Array(plainBuffer), original, name + " raw input");
const encrypted = decryptImageBytes(bufferOf(original));
const encryptedBuffer = bufferOf(encrypted);
assert.deepEqual(
decodeImageBytes(encryptedBuffer),
original,
name + " encrypted",
);
assert.deepEqual(
new Uint8Array(encryptedBuffer),
encrypted,
name + " encrypted input",
);
}
});
test("image decoding rejects empty bodies and non-image API error responses", () => {
for (const value of [
"",
'{"code":404,"data":null,"message":"image missing"}',
"<!doctype html><html><body>CDN unavailable</body></html>",
"upstream unavailable",
]) {
const bytes = new TextEncoder().encode(value);
assert.throws(() => decodeImageBytes(bufferOf(bytes)), value || "empty");
if (bytes.length)
assert.throws(() =>
decodeImageBytes(bufferOf(decryptImageBytes(bufferOf(bytes)))),
);
}
});
test("image fetching validates bytes, retries alternate CDNs, and only caches successful images", async (t) => {
const originalFetch = globalThis.fetch;
const created: string[] = [];
const responseFor = (bytes: Uint8Array, contentType = "image/png") =>
new Response(bufferOf(bytes), { headers: { "Content-Type": contentType } });
const captureBlob = async (url: string, expected: Uint8Array, mime: string) => {
assert.match(url, /^blob:/);
created.push(url);
const response = await originalFetch(url);
assert.equal(response.headers.get("content-type"), mime);
assert.deepEqual(new Uint8Array(await response.arrayBuffer()), expected);
};
try {
await t.test("HTTP failure uses the next CDN and retains its successful cache", async () => {
const path = "v3/image/http-fallback-20261012.png?signature=x%2By%3D";
const calls: string[] = [];
globalThis.fetch = async (input) => {
const url = String(input);
calls.push(url);
return url === primary + path
? new Response("CDN unavailable", { status: 503 })
: responseFor(decryptImageBytes(bufferOf(png)));
};
const first = await resolveImage(path, true, [primary, backup]);
await captureBlob(first, png, "image/png");
assert.deepEqual(calls, [primary + path, backup + path]);
assert.equal(await resolveImage(path, false, [primary, backup]), first);
assert.equal(calls.length, 2);
});
await t.test("a JSON 200 response is rejected before trying the alternate CDN", async () => {
const path = "v3/image/json-fallback-20261012.png";
const calls: string[] = [];
globalThis.fetch = async (input) => {
const url = String(input);
calls.push(url);
return url === primary + path
? new Response('{"code":404,"data":null}', {
headers: { "Content-Type": "application/json" },
})
: responseFor(png);
};
await captureBlob(
await resolveImage(primary + path, false, [primary, backup]),
png,
"image/png",
);
assert.deepEqual(calls, [primary + path, backup + path]);
});
await t.test("a missing CDN roots the API path before requesting valid bytes", async () => {
const path = "v3/image/no-cdn-20261012.png?signature=a%2Bb%3D";
const calls: string[] = [];
globalThis.fetch = async (input) => {
calls.push(String(input));
return responseFor(png);
};
await captureBlob(await resolveImage(path), png, "image/png");
assert.deepEqual(calls, ["/" + path]);
});
await t.test("invalid image responses are not cached and the same URL can recover", async () => {
const source = "https://unconfigured.images.test/no-cache-20261012.png";
let count = 0;
let recovered = false;
globalThis.fetch = async () => {
count++;
return recovered
? responseFor(png)
: new Response('{"code":500,"tip":"failed"}', {
headers: { "Content-Type": "image/png" },
});
};
await assert.rejects(resolveImage(source));
await assert.rejects(resolveImage(source));
assert.equal(count, 2, "invalid responses must trigger a fresh request");
recovered = true;
const resolved = await resolveImage(source);
await captureBlob(resolved, png, "image/png");
assert.equal(count, 3);
assert.equal(await resolveImage(source), resolved);
assert.equal(count, 3, "successful bytes may be cached");
});
await t.test("non-image sources and illegal protocols never enter the image fetcher", async () => {
let count = 0;
globalThis.fetch = async () => {
count++;
throw new Error("Unexpected image request");
};
const source = "/static/brand/placeholder.svg?v=image-test";
assert.equal(await resolveImage(source), source);
assert.equal(count, 0);
await assert.rejects(resolveImage("javascript:alert(1)"));
assert.equal(count, 0);
});
} finally {
globalThis.fetch = originalFetch;
for (const url of created) URL.revokeObjectURL(url);
}
});
+32
View File
@@ -0,0 +1,32 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { installationURL } from '../src/server/config';
import { installGuideData } from '../src/server/services/install-guide';
test('installation URLs require a valid HTTP(S) platform destination', () => {
assert.equal(installationURL(' https://apps.apple.com/app/id123?campaign=site '), 'https://apps.apple.com/app/id123?campaign=site');
assert.equal(installationURL('http://downloads.example/app.apk'), 'http://downloads.example/app.apk');
for (const value of ['', '/download-app/', '//downloads.example/app', 'javascript:alert(1)', 'data:text/html,test', 'file:///tmp/app.apk', 'vbscript:foo', 'huakui://open', 'https://user:password@example.com/', 'https://example.com/\napp.apk']) assert.equal(installationURL(value), '', value);
});
test('deep links allow universal links and app schemes while rejecting executable protocols', () => {
assert.equal(installationURL('huakui://open/home', true), 'huakui://open/home');
assert.equal(installationURL('https://app.example/open', true), 'https://app.example/open');
for (const value of ['javascript://alert(1)', 'JaVaScRiPt:alert(1)', 'data://text/html,test', 'file://localhost/etc/passwd', 'vbscript://execute', 'blob://test', 'about://blank', 'chrome://settings', 'huakui:open', 'not a URL']) assert.equal(installationURL(value, true), '', value);
});
test('unconfigured installation capabilities retain backend AppAddr and truthful fallback state', () => {
const empty = { iosStoreURL: '', iosProfileURL: '', androidAPKURL: '', deepLinkURL: '' };
const data = installGuideData('https://download.example/real-backend-page', empty);
assert.equal(data.iosStoreURL, data.fallbackURL); assert.equal(data.iosProfileURL, data.fallbackURL); assert.equal(data.androidAPKURL, data.fallbackURL);
assert.equal(data.fallbackURL, 'https://download.example/real-backend-page');
assert.equal(data.hasIOSStore, false); assert.equal(data.hasIOSProfile, false); assert.equal(data.hasAndroidAPK, false); assert.equal(data.deepLinkURL, '');
assert.equal(installGuideData(undefined, empty).fallbackURL, '/app/download/');
});
test('each configured installation capability replaces only its own fallback', () => {
const data = installGuideData('https://download.example/', { iosStoreURL: 'https://apps.apple.com/app/id123', iosProfileURL: '', androidAPKURL: 'https://download.example/app.apk', deepLinkURL: 'huakui://open' });
assert.equal(data.iosStoreURL, 'https://apps.apple.com/app/id123'); assert.equal(data.hasIOSStore, true);
assert.equal(data.iosProfileURL, 'https://download.example/'); assert.equal(data.hasIOSProfile, false);
assert.equal(data.androidAPKURL, 'https://download.example/app.apk'); assert.equal(data.hasAndroidAPK, true); assert.equal(data.deepLinkURL, 'huakui://open');
});
+35
View File
@@ -0,0 +1,35 @@
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { MobileTabbar } from '../src/components/layout/MobileTabbar';
const render = (requestPath?: string, favorites?: unknown[]) => renderToStaticMarkup(createElement(MobileTabbar, { data: { requestPath, favorites } }));
test('mobile navigation follows the Figma page whitelist and current route', () => {
for (const [path, current] of [['/', '/'], ["/dramas/", "/dramas/"], ["/dramas/all/page/2/", "/dramas/"], ['/series/', '/series/'], ['/series/anime/12/2/', '/series/']] as const) {
const html = render(path, []);
assert.equal((html.match(/data-navigation-key=/g) || []).length, 3, path);
assert.doesNotMatch(html, /mobile_mine|我的|\/account\//, path);
assert.equal((html.match(/aria-current="page"/g) || []).length, 1, path);
assert.ok(html.includes(`href="${current}" aria-current="page"`), path);
assert.match(html, /mobile-tabbar\/\w+-active\.svg/);
}
for (const path of ["/account/", "/account/favorites/", "/account/login/", "/account/register/", "/discover/search/", "/drama/1/", "/series/watch/1/", "/account/recharge/", "/account/history/", "/account/purchases/", "/account/settings/", '/about/', '/help/', '/terms/', '/privacy/', '/feedback/', "/collections/", "/rankings/", '/404/']) {
assert.doesNotMatch(render(path), /<nav/, path);
}
assert.doesNotMatch(render(), /<nav/);
});
test('mobile navigation uses the unmodified active and muted Figma icon exports', async () => {
const manifest = JSON.parse(await readFile(new URL('../scripts/figma-icon-manifest.json', import.meta.url), 'utf8')) as { assets: { file: string; sha256: string }[] };
const assets = manifest.assets.filter(asset => asset.file.includes('/mobile-tabbar/'));
assert.equal(assets.length, 8);
for (const asset of assets) {
const bytes = await readFile(new URL('../' + asset.file, import.meta.url));
assert.equal(createHash('sha256').update(bytes).digest('hex'), asset.sha256, asset.file);
assert.ok(bytes.toString().includes(asset.file.includes('-active') ? '#FF0F53' : '#7E818B'), asset.file);
}
});
+86
View File
@@ -0,0 +1,86 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createContext } from '../src/server/context';
import { buildPage, matchPageRoute } from '../src/server/pages';
import { list } from '../src/server/pages/catalog';
import { pagination } from '../src/server/pages/pagination';
import { pageRoutes } from '../src/server/pages/routes';
import { play } from '../src/server/pages/playback';
import { applyLegacyContract } from '../src/server/services/contracts';
import { choose, normalizePayload, playURL, positive, profileImage, videoURL } from '../src/server/services/model';
const ctx = (path: string) => {
const context=createContext(new Request('http://localhost'+path,{headers:{cookie:'token=test-session; isRegister=1'}}));
context.user={userId:7,accountNo:'member',recharge:12345,isRegister:true,type:1};context.state.set('auth:fresh',true);return context;
};
test('legacy route families retain exact dynamic parameters and reject repeated slashes',()=>{
const routes:[string,string,Record<string,string>][]=[
['/','Index',{}],['/list/drama/2/3/page/4/','List',{theme:'drama',updateStatus:'2',sortType:'3',page:'4'}],
['/series/anime/12/2/page/3/','Series',{media:'anime',topic:'12',sortType:'2',page:'3'}],
['/more-list/recommend/1/drama/page/2/','MoreList',{configId:'recommend',timeRange:'1',theme:'drama',page:'2'}],
['/more-series-list/recommend/page/2/','MoreSeriesList',{configId:'recommend',page:'2'}],
['/topic/sort/2/page/3/','Topic',{sortType:'2',page:'3'}],
['/topic-detail/title/sort/3/page/2/','TopicDetail',{slug:'title',sortType:'3',page:'2'}],
['/tags/tag/sort/2/page/4/','Tags',{slug:'tag',sortType:'2',page:'4'}],
['/search/%E7%9F%AD%E5%89%A7/page/2/','Search',{q:'短剧',page:'2'}],
['/play/drama/100/recommendations/page/2/','Play',{slug:'drama',ep:'100',guessPage:'2'}],
['/series-play/video/recommendations/page/2/','SeriesPlay',{slug:'video',guessPage:'2'}],
['/popular-recommend/3/page/2/','PopularRecommend',{rankType:'3',page:'2'}],
['/rank/2/page/3/','Rank',{sortOrder:'2',page:'3'}],['/recently-added/page/3/','RecentlyAdded',{page:'3'}],
];
for(const [path,handler,params]of routes)assert.deepEqual(matchPageRoute(path),{handler,params},path);
for(const path of ['/favorites/chapter/page/3/','/history/page/2/','/purchased/page/2/','/profile/','/setting/','/recharge/','/login/','/register/'])assert.equal(matchPageRoute(path),null,path);
for(const path of ['/list//','//list/','/settings//','/list/theme/extra/','/list/drama/2/3/page/2/extra/','/series/anime/'])assert.equal(matchPageRoute(path),null,path);
});
test('the 58 surviving original public HTML route registrations resolve with unchanged parameter names',()=>{
const removedAccountHandlers=new Set(['Profile','History','Favorites','PurchasedSeries','Setting','Recharge','LoginPage','RegisterPage']);
const legacy=JSON.parse(readFileSync(new URL('./fixtures/legacy-page-routes.json',import.meta.url),'utf8')) as [string,string][];
assert.equal(legacy.length,71);const original=legacy.filter(([,handler])=>!removedAccountHandlers.has(handler));assert.equal(original.length,58);
assert.deepEqual(pageRoutes.filter(([,handler])=>handler!=='HomeRoad'),original);assert.equal(pageRoutes.some(([,handler])=>removedAccountHandlers.has(handler)),false);
assert.deepEqual(pageRoutes.filter(([,handler])=>handler==='HomeRoad'),[['/home-road/','HomeRoad']]);
for(const path of ['/home-road/','/return-home/'])assert.deepEqual(matchPageRoute(path),{handler:'HomeRoad',params:{}},path);
const values:Record<string,string>={q:'短剧',theme:'drama',slug:'drama',configId:'recommend',media:'anime',topic:'12',updateStatus:'2',sortType:'3',sortOrder:'2',rankType:'3',timeRange:'1',page:'2',guessPage:'2',ep:'100',filter:'chapter'};
for(const [pattern,handler]of original){const params:Record<string,string>={};const path=pattern.replace(/:(\w+)/g,(_,name)=>{params[name]=values[name];return encodeURIComponent(values[name]);});assert.deepEqual(matchPageRoute(path),{handler,params},pattern);}
});
test('pagination keeps source zero fields and route numbers use decimal integer parsing',()=>{
const small=pagination(n=>'/page/'+n+'/',2,25,0);assert.equal(small.Page,0);assert.equal(small.TotalPages,0);assert.equal(small.Visible,false);assert.equal(small.Items,null);
const many=pagination(n=>'/page/'+n+'/',50,25,250);assert.equal(many.Page,10);assert.equal(many.HasNext,false);assert.equal(many.PrevURL,'/page/9/');assert.equal(many.Items.at(-1).Active,true);
assert.equal(choose('+2',[2,3],1),2);for(const value of ['2.0','2e0','0x2'])assert.equal(choose(value,[2,3],1),1);assert.equal(positive('+2'),2);
});
test('API model aliases preserve JSON fields, latest mutable values and chapter gates',()=>{
const raw=normalizePayload({id:10,videoUrl:'signed-media.m3u8',seoInfo:'{"webTitle":"页面","webKeyworld":"a,b;a"}',payType:1,needUnlock:true,costCoins:25});
assert.equal(raw.ID,10);assert.equal(raw.VideoURL,'signed-media.m3u8');assert.deepEqual(raw.seoInfo.keywords,['a','b']);assert.equal(raw.NeedCoinGate,true);assert.equal(raw.AllowWatch,false);assert.equal(raw.UnlockCost,25);
raw.seoInfo.title='新标题';assert.equal(raw.seoInfo.Title,'新标题');assert.equal(JSON.stringify(raw).includes('"VideoURL"'),false);
});
test('missing upstream fields retain legacy Go zero values rather than blank attributes',()=>{
const detail=normalizePayload(applyLegacyContract('/api/app/playlet/detail',{id:10,playletChapterInfo:[{id:100}]}));
assert.equal(detail.CountLike,0);assert.equal(detail.IsLike,0);assert.equal(detail.Preview,'');assert.equal(detail.ThemeNames,null);assert.equal(detail.PlayletChapterInfo[0].MediaTime,0);assert.equal(detail.PlayletChapterInfo[0].NeedUnlock,false);assert.equal(detail.PlayletChapterInfo[0].AllowWatch,true);
});
test('list and playback page loaders preserve filters, canonical paths, locked media and tracking',async()=>{
const originalFetch=globalThis.fetch,originalUpstream=process.env.UPSTREAM_API_URL;process.env.UPSTREAM_API_URL='http://page-contract-test';const calls:{url:URL;headers:Headers}[]=[];
globalThis.fetch=async(input,init)=>{
const url=new URL(String(input));calls.push({url,headers:new Headers(init?.headers)});let data:any={list:[],totalCount:0};
if(url.pathname==='/api/app/theme/list')data={list:[{id:12,slug:'romance',name:'爱情',status:true,rank:1}]};
if(url.pathname==='/api/app/playlet/all/list')data={list:[{id:10,title:'剧'}],totalCount:100,pageNum:Number(url.searchParams.get('pageNum')),pageSize:Number(url.searchParams.get('pageSize')),seoInfo:{title:'全部短剧'}};
if(url.pathname==='/api/app/playlet/detail')data={id:10,slug:'drama',playName:'剧名',desc:'剧情介绍',themeIds:[12],themeNames:['爱情'],tagIds:[1],tagNames:['甜宠'],totalEpisode:2,preview:'preview.m3u8',playletChapterInfo:[{id:101,currentEpisode:1,payType:0},{id:102,currentEpisode:2,payType:1,needUnlock:true,costCoins:20}],seoInfo:{keywords:['剧名']}};
if(url.pathname==='/api/app/playlet/chapter/detail')data={id:102,currentEpisode:2,payType:1,needUnlock:true,canWatch:false,videoUrl:'protected.m3u8',costCoins:20,balance:12345,mediaTime:60};
if(url.pathname==='/api/app/recharge/package/list')data={vipList:[],coinList:[]};
return Response.json({code:200,data});
};
try{
const listed=await list(ctx('/list/12/2/3/page/2/'),{theme:'12',updateStatus:'2',sortType:'3',page:'2'});
assert.equal(listed.themeId,12);assert.equal(listed.themeSlug,'romance');assert.equal(listed.canonicalPath,'/list/romance/2/3/page/2/');assert.equal(listed.pagination.NextURL,'/list/romance/2/3/page/3/');assert.equal(listed.pageHeading,'爱情短剧');
const catalogue=calls.find(call=>call.url.pathname==='/api/app/playlet/all/list')!;assert.equal(catalogue.url.searchParams.get('themeSlug'),'romance');assert.equal(catalogue.headers.has('authorization'),false);
const played=await play(ctx('/play/drama/102/'),{slug:'drama',ep:'102'});assert.ok(played);assert.equal(played.chapterID,102);assert.equal(played.episode,2);assert.equal(played.canWatch,false);assert.equal(played.mainURL,'');assert.equal(played.previewURL,'preview.m3u8');assert.equal(played.balanceText,'12,345');assert.equal(played.seoInfo.title,'剧名 - 第二集');assert.equal(played.canonicalPath,'/drama/drama/');assert.equal(played.trackerDrama.VideoID,102);assert.equal(played.trackerDrama.DramaTypeID,'12');assert.equal(played.comments,undefined);assert.ok(!calls.some(call=>call.url.pathname==='/api/app/comment/list'));
const chapter=calls.find(call=>call.url.pathname==='/api/app/playlet/chapter/detail')!;assert.equal(chapter.url.searchParams.get('chapterId'),'102');assert.equal(chapter.headers.get('authorization'),'test-session');
assert.equal(await play(ctx('/play/drama/999/'),{slug:'drama',ep:'999'}),null);
for(const path of ['/search/?q=legacy','/search/?page=2','/play/drama/?guessPage=2','/favorites/?filter=all','/favorites/invalid/','/history/page/10001/','/settings//'])assert.equal((await buildPage(ctx(path))).status,404,path);
}finally{globalThis.fetch=originalFetch;if(originalUpstream===undefined)delete process.env.UPSTREAM_API_URL;else process.env.UPSTREAM_API_URL=originalUpstream;}
});
+33
View File
@@ -0,0 +1,33 @@
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { readFile, stat } from 'node:fs/promises';
import test from 'node:test';
import { IMAGE_PLACEHOLDER } from '../src/lib/view-helpers';
import { IMAGE_PLACEHOLDER as REACT_IMAGE_PLACEHOLDER } from '../src/lib/media/images';
import { coverImage } from '../src/server/services/model';
test('the shared image fallback preserves the complete Figma brand export', async () => {
const svg = await readFile(new URL('../public/static/brand/placeholder.svg', import.meta.url));
const brand = await readFile(new URL('../public/static/brand/footer.svg', import.meta.url));
const manifest = JSON.parse(await readFile(new URL('../public/static/brand/placeholder.manifest.json', import.meta.url), 'utf8')) as {
width: number; height: number; sha256: string;
composition: { embeddedImage: { sha256: string; unmodified: boolean; width: number; height: number } };
};
const embedded = svg.toString().match(/href="data:image\/svg\+xml;base64,([^"]+)"/);
assert.ok(embedded);
assert.deepEqual(Buffer.from(embedded[1], 'base64'), brand);
assert.match(svg.toString(), /width="480" height="480" viewBox="0 0 480 480"/);
assert.match(svg.toString(), /<rect width="480" height="480" fill="#26272f"\/>/);
assert.match(svg.toString(), /<image\b[^>]*\bopacity="0\.2"/);
assert.equal(manifest.width, 480);
assert.equal(manifest.height, 480);
assert.equal(manifest.sha256, createHash('sha256').update(svg).digest('hex'));
assert.equal(manifest.composition.embeddedImage.sha256, createHash('sha256').update(brand).digest('hex'));
assert.equal(manifest.composition.embeddedImage.unmodified, true);
assert.equal(manifest.composition.embeddedImage.width, 240);
assert.ok(Math.abs(manifest.composition.embeddedImage.height - 240 * 44 / 124) < 0.000001);
assert.equal(IMAGE_PLACEHOLDER, '/static/brand/placeholder.svg');
assert.equal(coverImage(undefined), IMAGE_PLACEHOLDER);
assert.equal(new URL(REACT_IMAGE_PLACEHOLDER, 'https://site.example').pathname, IMAGE_PLACEHOLDER);
await assert.rejects(stat(new URL('../public/static/images/placeholder.png', import.meta.url)), { code: 'ENOENT' });
});
+98
View File
@@ -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");
});
+167
View File
@@ -0,0 +1,167 @@
import test, { after, before } from 'node:test';
import assert from 'node:assert/strict';
import { createServer, type Server } from 'node:http';
import { apiRoutes, handleAPI } from '../src/server/api';
import { createContext } from '../src/server/context';
import { resolveChannel } from '../src/server/auth';
import { handleRequest } from '../src/server/handler';
// Source contracts: app/api/web/{history,recharge,setting,comment}.go and
// app/service/playletser/chapter_watch.go. All requests stay on a local server.
type Received = { path: string; body: string };
let server: Server;
let scenario = '';
const received: Received[] = [];
const originalURL = process.env.UPSTREAM_API_URL;
before(async () => {
server = createServer(async (request, response) => {
let body = ''; for await (const chunk of request) body += chunk;
const path = request.url || '';
received.push({ path, body });
response.setHeader('Content-Type', 'application/json');
if (scenario === 'offline') { response.writeHead(503).end('unavailable'); return; }
if (path === '/api/app/recharge/package/list') {
response.end(JSON.stringify({ code: 200, data: { coinList: [{ id: 9, payMethods: [{ type: 'wechat' }] }] } })); return;
}
if (path.startsWith('/api/app/playlet/search/suggest?')) {
response.end(JSON.stringify({ code: 200, data: { keyword: '短剧搜索', list: [
{ id: 1, slug: '恋/爱%2F?片#段', playName: '搜索主推荐', cover: '/play/signed-cover.webp?signature=a%2Bb' },
{ id: 42, title: '无 slug 作品' },
] } })); return;
}
if (path === '/api/app/auth/getInfo') {
response.end(JSON.stringify({ code: 200, data: { token: 'session', userId: 12345, nickName: '用户', type: 1 } })); return;
}
if (path === '/api/app/coin/buy') {
response.end(JSON.stringify(scenario === 'expired'
? { code: 6001, msg: 'expired', tip: '', data: { internal: true } }
: { code: 200, msg: 'ok', tip: '', data: { payUrl: 'https://pay.example/order' } })); return;
}
if (path.startsWith('/api/app/coin/trans/export')) {
response.end(JSON.stringify({ code: 6001, msg: 'expired', data: null })); return;
}
if (path === '/api/app/comment/send') {
response.end(JSON.stringify({ code: 1001, msg: '评论暂不可用', tip: '', data: null })); return;
}
response.end(JSON.stringify({ code: 200, msg: 'backend message', tip: '', data: { ok: true } }));
});
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
const address = server.address(); if (!address || typeof address === 'string') throw new Error('No address');
process.env.UPSTREAM_API_URL = `http://127.0.0.1:${address.port}`;
});
after(async () => {
await new Promise<void>(resolve => server.close(() => resolve()));
if (originalURL === undefined) delete process.env.UPSTREAM_API_URL; else process.env.UPSTREAM_API_URL = originalURL;
});
const ctx = (pathname: string, body?: unknown) => createContext(new Request('http://local.example' + pathname, {
method: body === undefined ? 'GET' : 'POST',
headers: { Cookie: 'token=session; userType=1; isRegister=1', ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
}));
async function api(path: string, body?: unknown) {
const response = await handleAPI(ctx(path, body)); assert.ok(response); return response;
}
test('surviving BFF routes retain their registered request method and removed account routes are unregistered', () => {
const posts = ['auth/guest', 'upload/image', 'feedback/add', 'playlet/chapter/watch/report'];
const gets = ['auth/session', 'playlet/guess-like/list', 'web/search/suggest'];
for (const path of posts) assert.equal(apiRoutes['/api/' + path], 'POST', path);
for (const path of gets) assert.equal(apiRoutes['/api/' + path], 'GET', path);
assert.deepEqual(Object.keys(apiRoutes).sort(), [...posts, ...gets].map(path => '/api/' + path).sort());
const removed = ['comment/list', 'auth/login', 'auth/register', 'auth/logout', 'auth/resetPassword', 'auth/profile', 'recharge/coin/buy', 'coin/buy', 'playlet/history/clear', 'playlet/history/delete', 'video/history/clear', 'video/history/delete', 'comment/send', 'comment/like/set', 'playlet/chapter/like/set', 'playlet/chapter/collect/set', 'playlet/chapter/unlock', 'playlet/like/set', 'playlet/collect/set', 'video/like/set', 'video/collect/set', 'recharge/transactions', 'coin/trans/list', 'coin/trans/export', 'recharge/transactions/export'];
for (const path of removed) assert.equal(apiRoutes['/api/' + path], undefined, path);
});
test('removed account BFF routes fall through without reaching the upstream', async () => {
scenario = '';
for (const [path, body] of [['auth/login', { account: 'a', password: 'b' }], ['comment/send', { objectId: 2, text: '评论' }], ['coin/buy', { packageId: 9 }], ['coin/trans/export?type=1', undefined]] as const) {
received.length = 0;
assert.equal(await handleAPI(ctx('/api/' + path, body)), null, path);
assert.equal(received.length, 0, path);
}
});
test('watch report payload preserves IDs and omitted optional watch identifiers', async () => {
scenario = '';
received.length = 0;
assert.equal((await api('/api/playlet/chapter/watch/report', { chapterId: 5, ignored: 'discard' })).status, 200);
assert.equal(received[0].path, '/api/app/playlet/chapter/watch/report');
assert.deepEqual(JSON.parse(received[0].body), { chapterId: 5 });
});
test('Go DTO type errors are HTTP 400 before forwarding any mutation', async () => {
scenario = '';
const cases: [string, unknown][] = [
['playlet/chapter/watch/report', { chapterId: 1.5 }],
['feedback/add', { type: 1, content: '反馈', screenshots: [123] }],
];
for (const [path, body] of cases) {
received.length = 0;
assert.equal((await api('/api/' + path, body)).status, 400, path);
assert.equal(received.length, 0, path);
}
});
test('Set-Cookie replacement respects Path and Domain scopes', () => {
const context = ctx('/');
context.responseHeaders.append('Set-Cookie', 'sid=sub; Path=/sub; HttpOnly');
context.responseHeaders.append('Set-Cookie', 'sid=domain; Path=/; Domain=example.com');
context.setCookie('sid', 'root'); context.setCookie('sid', 'updated');
const cookies = context.responseHeaders.getSetCookie();
assert.equal(cookies.length, 3);
assert.ok(cookies.includes('sid=sub; Path=/sub; HttpOnly'));
assert.ok(cookies.includes('sid=domain; Path=/; Domain=example.com'));
assert.equal(cookies.filter(value => value.startsWith('sid=updated;')).length, 1);
});
test('first attribution keeps the source seven-day cookie lifetime', () => {
const context = ctx('/list/?dc=campaign');
assert.equal(resolveChannel(context), 'campaign');
assert.match(context.responseHeaders.getSetCookie().find(value => value.startsWith('channel=')) || '', /Max-Age=604800(?:;|$)/);
});
test('tool and API trailing-slash redirects retain the original query and POST method', async () => {
for (const path of ['/enkey/', '/newenkey/', '/api/web/search/suggest/', '/robots.txt/', '/sitemap.xml/']) {
const response = await handleRequest(new Request('http://local.example' + path + '?sig=abc%2B123'));
assert.equal(response.status, 301, path);
assert.equal(response.headers.get('location'), path.slice(0, -1) + '?sig=abc%2B123');
}
const response = await handleRequest(new Request('http://local.example/api/feedback/add/?x=1', { method: 'POST', body: '{}' }));
assert.equal(response.status, 307);
assert.equal(response.headers.get('location'), '/api/feedback/add?x=1');
});
test('navigation-only deployment exposes navigation and denies application API and media routes', async () => {
const previous = process.env.NAVIGATION_ONLY;
process.env.NAVIGATION_ONLY = 'true';
try {
for (const path of ['/api/web/search/suggest?keyword=a', '/enkey', '/list/']) {
const response = await handleRequest(new Request('http://local.example' + path));
assert.equal(response.status, 404, path);
assert.match(response.headers.get('content-type') || '', /text\/html/);
}
const robots = await handleRequest(new Request('https://public.drama.test/robots.txt'));
assert.equal(robots.status, 404);
assert.match(robots.headers.get('content-type') || '', /text\/html/);
assert.deepEqual(robots.headers.getSetCookie(), []);
const nav = await handleRequest(new Request('http://local.example/nav'));
assert.equal(nav.status, 301); assert.equal(nav.headers.get('location'), '/navigation/');
} finally {
if (previous === undefined) delete process.env.NAVIGATION_ONLY; else process.env.NAVIGATION_ONLY = previous;
}
});
test('search suggestions expose semantic page URLs while retaining encoded slugs, ID fallback and media addresses', async () => {
scenario = '';
received.length = 0;
const response = await api('/api/web/search/suggest?keyword=' + encodeURIComponent('短剧'));
const data = await response.json();
assert.equal(response.status, 200);
assert.equal(data.feature.url, '/drama/%E6%81%8B%2F%E7%88%B1%252F%3F%E7%89%87%23%E6%AE%B5/');
assert.equal(data.feature.cover, '/play/signed-cover.webp?signature=a%2Bb');
assert.equal(data.items[0].url, '/drama/42/');
assert.equal(data.items[1].url, '/search/' + encodeURIComponent('短剧搜索') + '/');
assert.equal(new URL(received[0].path, 'http://local.example').pathname, '/api/app/playlet/search/suggest');
});
+180
View File
@@ -0,0 +1,180 @@
import test, { after, before } from 'node:test';
import assert from 'node:assert/strict';
import { createServer, type Server } from 'node:http';
import { createContext, responseHeaders } from '../src/server/context';
import { buildUpstreamHeaders, upstreamGet } from '../src/server/upstream';
import { handleRequest } from '../src/server/handler';
type Received = { path: string; headers: Record<string, string | string[] | undefined>; body: string };
let server: Server; let upstream = ''; let scenario = '';
const received: Received[] = [];
const originalURL = process.env.UPSTREAM_API_URL;
before(async () => {
server = createServer(async (request, response) => {
let body = ''; for await (const chunk of request) body += chunk;
const path = request.url || '';
received.push({ path, headers: request.headers, body });
if (scenario === 'offline') { response.writeHead(503).end('unavailable'); return; }
if (path === '/api/app/redirect') { response.writeHead(302, { Location: '/api/app/config/info' }).end(); return; }
if (scenario === 'media-redirects' && (path.startsWith('/h5/m3u8/redirect.m3u8?') || path.startsWith('/enkey/?'))) {
const query = path.slice(path.indexOf('?'));
response.writeHead(path.startsWith('/enkey/') ? 301 : 302, { Location: (path.startsWith('/enkey/') ? '/media-key-target' : '/h5/m3u8/playlist.m3u8') + query }).end(); return;
}
if (path.startsWith('/media-key-target?')) { response.writeHead(200, { 'Content-Type': 'application/octet-stream' }).end('1234567890123456'); return; }
if (scenario === 'media-business-error' && path.startsWith('/h5/m3u8/')) { response.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ code: 6020, msg: '' })); return; }
if (path.startsWith('/h5/m3u8/')) { response.writeHead(200, { 'Content-Type': 'application/vnd.apple.mpegurl' }).end('#EXTM3U\nsegment.ts\n'); return; }
if (path.startsWith('/enkey/')) { response.writeHead(200, { 'Content-Type': 'application/octet-stream' }).end(Buffer.from(scenario === 'binary-brace' ? '{234567890123456' : '1234567890123456')); return; }
if (path.startsWith('/api/app/coin/trans/export')) { response.writeHead(200, { 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename="records.csv"' }).end('id,amount\n1,2\n'); return; }
if (path === '/api/app/upload/image') { response.setHeader('Content-Type', 'application/json'); response.end(JSON.stringify({ code: 200, data: { id: 'asset-1', sourceNameFs: 'https://cdn.example/image.png' } })); return; }
if (scenario === 'cookies') response.setHeader('Set-Cookie', ['upstream_a=a; Path=/; HttpOnly', 'upstream_b=b; Path=/; SameSite=Lax']);
let data: unknown = { ok: true }; let code = 200;
if (path === '/api/app/auth/guest' || path === '/api/app/auth/login') data = { token: 'guest-fresh', userId: 12345, nickName: '测试 用户', accountNo: 'guest', type: 0, recharge: 1020 };
if (scenario === 'auth-rejected' && path === '/api/app/auth/login') { code = 1001; data = 'upstream-error'; }
if (path.startsWith('/api/app/playlet/search/suggest')) {
if (scenario === 'expired' && request.headers.authorization === 'stale') code = 6001;
else data = { keyword: '真实', list: [{ id: 1, slug: 'real', playName: '真实响应' }] };
}
response.setHeader('Content-Type', 'application/json'); response.end(JSON.stringify({ code, data, msg: 'ok', tip: '' }));
});
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
const address = server.address(); if (!address || typeof address === 'string') throw new Error('No upstream address');
upstream = `http://127.0.0.1:${address.port}`; process.env.UPSTREAM_API_URL = upstream;
});
after(async () => { await new Promise<void>(resolve => server.close(() => resolve())); if (originalURL === undefined) delete process.env.UPSTREAM_API_URL; else process.env.UPSTREAM_API_URL = originalURL; });
const request = (path: string, init: RequestInit = {}) => new Request('http://local.example' + path, init);
const post = (path: string, body: unknown, headers: HeadersInit = {}) => request(path, { method: 'POST', headers: { 'Content-Type': 'application/json', ...Object.fromEntries(new Headers(headers)) }, body: JSON.stringify(body) });
test('SSR/upstream preserve client cookies, cookie-token priority, IP, custom UA and SDK headers', async () => {
scenario = ''; received.length = 0;
const ctx = createContext(request('/list/', { headers: { Cookie: 'token=cookie-token; extra=keep%20me', Authorization: 'header-token', 'User-Agent': 'browser-agent', 'X-User-Agent': 'client-UA', 'X-Real-IP': '203.0.113.12', 'X-SDK-Common': 'sdk-snapshot' } }));
await upstreamGet(ctx, '/api/app/config/info');
const headers = received[0].headers;
assert.equal(headers.authorization, 'cookie-token'); assert.equal(headers.cookie, 'token=cookie-token; extra=keep%20me');
assert.equal(headers['user-agent'], 'browser-agent'); assert.equal(headers['x-user-agent'], 'client-UA'); assert.equal(headers['x-real-ip'], '203.0.113.12'); assert.equal(headers['x-sdk-common'], 'sdk-snapshot');
});
test('public data requests isolate both stale Authorization and auth cookies', async () => {
scenario = ''; received.length = 0;
await upstreamGet(createContext(request('/', { headers: { Cookie: 'token=stale', Authorization: 'stale' } })), '/api/app/config/info', { public: true });
assert.equal(received[0].headers.authorization, undefined); assert.equal(received[0].headers.cookie, undefined);
});
test('JSON APIs retain the original follow-redirect behavior', async () => {
scenario = ''; received.length = 0;
const result = await upstreamGet<{ code: number }>(createContext(request('/')), '/api/app/redirect');
assert.equal(result.code, 200); assert.deepEqual(received.map(item => item.path), ['/api/app/redirect', '/api/app/config/info']);
});
test('hop-by-hop, Connection-nominated and internal headers never cross the BFF', () => {
const ctx = createContext(request('/', { headers: { Connection: 'keep-alive, x-internal', 'X-Internal': 'no', 'X-Origin-Head': '1', 'X-Tracker-Request-Id': 'forged', 'X-DC-Request-Context': 'forged' } }));
const headers = buildUpstreamHeaders(ctx);
for (const name of ['connection', 'x-internal', 'x-origin-head', 'x-tracker-request-id', 'x-dc-request-context']) assert.equal(headers.has(name), false);
});
test('canonical attribution snapshot forwards only on the original allowed action', () => {
const id = 'a'.repeat(32);
const envelope = { v: 1, request_id: id, event_name: 'drama_like', common: { app_id: 'JHA-4001', uid: '12345', channel: 'campaign', sid: 'session', client_ts: 1791550000, device: 'PC', device_id: 'device', device_brand: '', device_model: '', device_fingerprint: '', fp_version: '', user_agent: 'browser', sdk_version: '1.2.0', app_version: '1.0.0' }, drama: { media_id: '', drama_id: '123', episode: 1, video_id: '', recommend_trace_id: '' } };
const headers = { 'X-Tracker-Request-Id': id, 'X-Tracker-Event-Name': 'drama_like', 'X-DC-Request-Context': Buffer.from(JSON.stringify(envelope)).toString('base64url') };
const allowed = buildUpstreamHeaders(createContext(post('/api/playlet/like/set', { id: 123, isLike: true }, headers)));
assert.deepEqual(JSON.parse(Buffer.from(allowed.get('X-DC-Request-Context')!, 'base64url').toString()), envelope);
assert.equal(buildUpstreamHeaders(createContext(post('/api/video/like/set', {}, headers))).has('X-DC-Request-Context'), false);
});
test('Set-Cookie values remain separate and updated same-name cookies are written once', async () => {
scenario = 'cookies'; received.length = 0;
const ctx = createContext(request('/api/auth/login'));
await upstreamGet(ctx, '/api/app/config/info');
ctx.setCookie('nickName', '中文 名称', { maxAge: 100 }); ctx.setCookie('nickName', '新名称', { maxAge: 100 });
const headers = responseHeaders(ctx, responseHeaders(ctx));
assert.equal(headers.getSetCookie().length, 3);
assert.equal(headers.getSetCookie().filter(value => value.startsWith('nickName=')).length, 1);
assert.ok(headers.getSetCookie().some(value => value.startsWith('nickName=' + encodeURIComponent('新名称'))));
scenario = '';
});
test('guest authentication establishes SSR-compatible cookies with its success tip', async () => {
scenario = '';
const guest = await handleRequest(post('/api/auth/guest', { devID: 'device-1' }));
assert.equal(guest.status, 200);
const result = await guest.json(); assert.equal(result.code, 200); assert.equal(result.tip, '游客登录成功');
assert.ok(guest.headers.getSetCookie().some(value => value.startsWith('token=guest-fresh;')));
assert.ok(guest.headers.getSetCookie().some(value => value.startsWith('nickName=' + encodeURIComponent('测试 用户'))));
});
test('removed account APIs are unregistered and never reach the upstream', async () => {
scenario = ''; received.length = 0;
for (const [path, init] of [
['/api/auth/login', { method: 'POST', body: JSON.stringify({ account: 'account', password: 'secret' }) }],
['/api/auth/logout', { method: 'POST', body: '{}' }],
['/api/playlet/history/clear', { method: 'POST' }],
['/api/playlet/chapter/like/set', { method: 'POST', body: JSON.stringify({ chapterId: 1, isLike: true }) }],
['/api/coin/trans/export?type=1', {}],
] as [string, RequestInit][]) {
const response = await handleRequest(request(path, { ...init, headers: { Cookie: 'token=session', 'Content-Type': 'application/json' } }));
assert.equal(response.status, 404, path);
assert.equal(response.headers.get('Content-Disposition'), null, path);
assert.equal(response.headers.getSetCookie().some(value => value.startsWith('token=')), false, path);
}
assert.equal(received.some(item => /\/api\/app\/(?:auth\/(?:login|logout)|playlet\/history|playlet\/chapter\/like|coin\/trans)/.test(item.path)), false);
});
test('watch reports require a session before reaching the upstream', async () => {
scenario = ''; received.length = 0;
const unauthorized = await handleRequest(post('/api/playlet/chapter/watch/report', { chapterId: 1 }));
assert.equal(unauthorized.status, 200); assert.equal((await unauthorized.json()).code, 401);
assert.equal(received.some(item => item.path === '/api/app/playlet/chapter/watch/report'), false);
});
test('expired content sessions recover once and the same request uses the newly issued token', async () => {
scenario = 'expired'; received.length = 0;
const response = await handleRequest(request('/api/web/search/suggest?keyword=' + encodeURIComponent('真实'), { headers: { Cookie: 'token=stale' } }));
assert.match(JSON.stringify(await response.json()), /真实响应/);
assert.equal(received.filter(item => item.path === '/api/app/auth/guest').length, 1);
assert.deepEqual(received.filter(item => item.path.startsWith('/api/app/playlet/search/suggest')).map(item => item.headers.authorization), ['stale', 'guest-fresh']);
scenario = '';
});
test('invalid action payloads never reach the upstream; network failures remain JSON responses', async () => {
scenario = ''; received.length = 0;
const invalid = await handleRequest(post('/api/playlet/chapter/watch/report', { chapterId: 'x' }, { Cookie: 'token=session' }));
assert.equal((await invalid.json()).code, 400); assert.equal(received.length, 0);
scenario = 'offline';
const response = await handleRequest(post('/api/playlet/chapter/watch/report', { chapterId: 1 }, { Cookie: 'token=session' }));
assert.equal(response.status, 200); assert.equal((await response.json()).code, 500); assert.equal(response.headers.get('x-robots-tag'), 'noindex, nofollow');
scenario = '';
});
test('multipart uploads retain generated boundary, file contents and the original frontend URL field', async () => {
scenario = ''; received.length = 0;
const form = new FormData(); form.set('file', new File(['PNG-data'], 'test.png', { type: 'image/png' }));
const response = await handleRequest(request('/api/upload/image', { method: 'POST', body: form, headers: { Cookie: 'token=session' } }));
assert.equal((await response.json()).data.url, 'https://cdn.example/image.png');
assert.match(String(received[0].headers['content-type']), /^multipart\/form-data; boundary=/);
assert.match(received[0].body, /name="watermark"\r\n\r\n0/); assert.match(received[0].body, /PNG-data/);
});
test('signed HLS and encryption key proxy preserve query parameters without session authentication', async () => {
scenario = ''; received.length = 0;
const response = await handleRequest(request('/h5/m3u8/playlist.m3u8?token=signed&quality=1080', { headers: { Cookie: 'token=session', Authorization: 'session', 'X-User-Agent': 'player-UA' } }));
assert.equal(response.status, 200); assert.equal(await response.text(), '#EXTM3U\nsegment.ts\n');
assert.equal(received[0].path, '/h5/m3u8/playlist.m3u8?token=signed&quality=1080'); assert.equal(received[0].headers.authorization, undefined); assert.equal(received[0].headers.cookie, undefined);
assert.equal(received[0].headers['x-user-agent'], 'player-UA');
const key = await handleRequest(request('/enkey?token=signed'));
assert.equal((await key.arrayBuffer()).byteLength, 16); assert.equal(key.headers.get('Access-Control-Allow-Origin'), '*'); assert.equal(received.at(-1)!.path, '/enkey/?token=signed');
scenario = 'binary-brace';
const binary = await handleRequest(request('/enkey?token=signed'));
assert.equal(await binary.text(), '{234567890123456');
scenario = '';
});
test('signed media and key redirects retain the original follow behavior and signature bytes', async () => {
scenario = 'media-redirects';
const query = '?ticket=a%2Bb%3D&path=%2Ffoo&keep=1';
try {
for (const [path, target, body] of [
['/h5/m3u8/redirect.m3u8', '/h5/m3u8/playlist.m3u8', '#EXTM3U\nsegment.ts\n'],
['/enkey', '/media-key-target', '1234567890123456'],
]) {
received.length = 0;
const response = await handleRequest(request(path + query, { headers: { Cookie: 'token=session', Authorization: 'session' } }));
assert.equal(response.status, 200); assert.equal(await response.text(), body);
assert.equal(received.at(-1)?.path, target + query);
for (const call of received) { assert.equal(call.headers.authorization, undefined); assert.equal(call.headers.cookie, undefined); }
}
} finally { scenario = ''; }
});
test('upstream media business errors become 502 and preserve the original error envelope', async () => {
scenario = 'media-business-error';
try {
const response = await handleRequest(request('/h5/m3u8/error.m3u8?ticket=original'));
assert.equal(response.status, 502); assert.deepEqual(await response.json(), { code: 6020, msg: '' });
assert.equal(response.headers.get('Content-Type'), 'application/json; charset=utf-8');
} finally { scenario = ''; }
});
+211
View File
@@ -0,0 +1,211 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { SiteProvider } from "../src/components/providers/SiteProvider";
import { SiteHeader } from "../src/components/layout/SiteHeader";
import { MobileTabbar } from "../src/components/layout/MobileTabbar";
import { HomePage } from "../src/features/catalog/HomePage";
import { HomeRoadPage } from "../src/features/content/HomeRoadPage";
import { SiteNavigationPage } from "../src/features/content/SiteNavigationPage";
import { brandAssets } from "../src/lib/brand-assets";
import { toPublicPath } from "../src/lib/routes";
import { allListURL, chapterPlayURL, categoryURL, listThemeSegment, listUpdateStatus, moreListURL, moreSeriesListURL, navHasMegaMenu, navHref, navIsActive, navIsDownload, pageURL, videoPlayURL } from "../src/lib/view-helpers";
const data = {
requestPath: "/dramas/", isLoggedIn: true, isRegistered: true, userID: 7,
userName: "观众", recharge: 100, appConfig: {}, config: {},
categoryList: [
{ name: "首页", urlValue: "/", categoryType: 1 },
{ name: "短剧", urlValue: "/list", categoryType: 1 },
{ name: "专题", urlValue: "/topic", categoryType: 1 },
{ name: "排行榜", urlValue: "/rank", categoryType: 1 },
{ name: "回家的路", urlValue: "/home-road/", categoryType: 1 },
],
themeList: [{ id: 1, slug: "romance", name: "爱情" }],
};
const render = (children: ReactNode) => renderToStaticMarkup(<SiteProvider data={data} dialogs={false}>{children}</SiteProvider>);
const hrefs = (html: string) => [...html.matchAll(/<a\b[^>]*\bhref="([^"]*)"/g)].map((match) => match[1].replaceAll("&amp;", "&"));
function assertPublicLinks(html: string): string[] {
const links = hrefs(html);
for (const href of links.filter((value) => value.startsWith("/") && !value.startsWith("//")))
assert.equal(href, toPublicPath(href), "internal link should reach its page directly: " + href);
return links;
}
test("shared view helpers produce business URLs and keep nested menu activation boundaries", () => {
assert.equal(chapterPlayURL("story/a", 7, 101), "/drama/story%2Fa/101/");
assert.equal(pageURL("/play/story%2fa?x=a+b&x=%2b#ep%202"), "/drama/story%2fa/?x=a+b&x=%2b#ep%202");
assert.equal(videoPlayURL("movie", 9), "/series/watch/movie/");
assert.equal(categoryURL("romance", 1), "/collection/romance/");
assert.equal(allListURL("romance", 1, 2, 1, 3), "/dramas/romance/2/1/page/3/");
assert.equal(moreListURL("picks", 1, 0, "", 0, 1), "/curated/dramas/picks/");
assert.equal(moreSeriesListURL("movies", 2, 0, "", 0, 1), "/curated/series/movies/");
assert.equal(navHref("/playlet"), "/dramas/");
assert.equal(navHref("/topic/romance/"), "/collection/romance/");
assert.equal(navHref("/download-app/"), "/app/download/");
assert.equal(navHref("/home-road/"), "/return-home/");
assert.equal(navIsDownload("/app/download/", "客户端下载"), true);
assert.equal(navHasMegaMenu("/dramas/"), true);
assert.equal(listThemeSegment("/dramas/romance/2/1/"), "romance");
assert.equal(listUpdateStatus("/dramas/romance/2/1/"), 2);
assert.equal(navIsActive("/series/", "/series/watch/movie/"), false);
assert.equal(navIsActive("/dramas/", "/dramas/romance/"), true);
});
test("navigation configuration resolves both legacy and normalized collection-detail aliases without decoding slugs", () => {
for (const root of ["/topic", "/collections"]) {
assert.equal(navHref(root + "/热门?q=a"), "/collection/%E7%83%AD%E9%97%A8/?q=a");
assert.equal(navHref(root + "/story%2fa%252F1?x=a+b&x=%2b#ep%202"), "/collection/story%2fa%252F1/?x=a+b&x=%2b#ep%202");
assert.equal(navHref(root + "/page/2/"), "/collections/page/2/");
assert.equal(navHref(root + "/sort/2/"), "/collections/sort/2/");
}
});
test("SSR header exposes direct canonical URLs and no account entries", () => {
const html = render(<SiteHeader data={data} />);
const links = assertPublicLinks(html);
for (const expected of ["/dramas/", "/collections/", "/rankings/", "/discover/search/", "/return-home/"])
assert.ok(links.includes(expected), expected);
assert.ok(!links.some((link) => link.startsWith("/account")), "account links removed");
assert.doesNotMatch(html, /data-login-open|hk-user-menu/);
});
test("configured header submenus expose canonical links, external link attributes and download actions", () => {
const html = render(<SiteHeader data={{
...data,
appConfig: { appAddr: "https://downloads.test/app" },
categoryList: [
{
name: "友情链接", urlValue: "#", categoryType: 2,
translationList: [
{ name: "黄果视频", urlValue: "https://huangguo.com", isFriendAds: false },
{ name: "外部站点", urlValue: "//partner.test/path", isFriendAds: true },
{ name: "爱情专题", urlValue: "/topic/romance/" },
{ name: "下载 App", urlValue: "/download-app/" },
],
},
{ name: "更多入口", urlValue: "#", translationList: [{ name: "排行榜", urlValue: "/rank/" }] },
],
}} />);
const links = assertPublicLinks(html);
assert.ok(links.includes("/collection/romance/"));
assert.ok(links.includes("/rankings/"));
for (const href of ["https://huangguo.com", "//partner.test/path"]) {
const anchor = [...html.matchAll(/<a\b[^>]*>/g)].map(([tag]) => tag).find((tag) => tag.includes(`href="${href}"`));
assert.ok(anchor, href);
assert.match(anchor, /target="_blank"/);
assert.match(anchor, /rel="sponsored nofollow noopener"/);
}
assert.match(html, /<a\b[^>]*href="https:\/\/downloads\.test\/app"[^>]*data-download-open=""/);
const toggles = [...html.matchAll(/<button\b[^>]*>/g)].map(([tag]) => tag).filter((tag) => tag.includes("hk-site-navigation__submenu-toggle"));
assert.equal(toggles.length, 2);
const controls = toggles.map((tag) => {
assert.match(tag, /aria-expanded="false"/);
const id = /aria-controls="([^"]+)"/.exec(tag)?.[1];
assert.ok(id);
assert.ok(html.includes(`id="${id}"`), "submenu control points to its rendered panel");
return id;
});
assert.equal(new Set(controls).size, controls.length);
});
test("configured lists take precedence over the short-drama mega menu while missing or empty lists retain normal navigation", () => {
const configured = render(<SiteHeader data={{
...data,
categoryList: [{ name: "短剧", urlValue: "/list", translationList: [{ name: "爱情专题", urlValue: "/topic/romance/" }] }],
}} />);
assert.match(configured, /data-navigation-submenu="desktop"/);
assert.doesNotMatch(configured, /aria-label="短剧快捷筛选"/);
for (const translationList of [undefined, [], null, {}, "invalid"]) {
const html = render(<SiteHeader data={{
...data,
categoryList: [
{ name: "短剧", urlValue: "/list", translationList },
{ name: "专题", urlValue: "/topic", translationList },
],
}} />);
assert.doesNotMatch(html, /data-navigation-submenu="desktop"/);
assert.match(html, /aria-label="短剧快捷筛选"/);
const links = assertPublicLinks(html);
assert.ok(links.includes("/dramas/"));
assert.ok(links.includes("/collections/"));
}
});
test("mobile TabBar shows only content tabs and stays hidden on nested series pages", () => {
const links = assertPublicLinks(renderToStaticMarkup(<MobileTabbar data={{ requestPath: "/" }} />));
assert.deepEqual(links, ["/", "/dramas/", "/series/"]);
for (const requestPath of ["/account/", "/series/watch/movie/"])
assert.equal(renderToStaticMarkup(<MobileTabbar data={{ requestPath }} />), "", requestPath);
assert.match(renderToStaticMarkup(<MobileTabbar data={{ requestPath: "/dramas/romance/" }} />), /aria-current="page"[^>]*data-navigation-key="mobile_short"/);
});
test("home Hero, cards and curated sections all navigate directly to new page addresses", () => {
const work = { id: 7, slug: "story", playName: "作品", totalEpisode: 8, lastChapterId: 101, lastEpisode: 2, cover: "/static/brand/header.svg", banner: "/static/brand/header.svg" };
const html = render(<HomePage data={{
...data, homeBannerData: [work],
homeConfigData: [
{ id: 1, slug: "picks", name: "精选", type: 1, list: [work] },
{ id: 2, slug: "movies", name: "剧集", type: 2, videoList: [{ id: 9, slug: "movie", title: "剧集", coverImg: "/static/brand/header.svg" }] },
{ id: 3, name: "专题", type: 3, themeList: [{ id: 4, slug: "romance", name: "爱情", covers: [work.cover] }] },
],
}} />);
const links = assertPublicLinks(html);
for (const expected of ["/drama/story/", "/dramas/", "/curated/dramas/picks/", "/curated/series/movies/", "/series/watch/movie/", "/collection/romance/", "/collections/", "/app/download/"])
assert.ok(links.includes(expected), expected);
});
test("home road renders current branding, configured access routes and official contact links", () => {
const html = render(<HomeRoadPage data={{
requestPath: "/return-home/", siteURL: "https://huakui.example",
appConfig: {
newURLAddr: "https://new.huakui.example/",
foreverURLAddr: "https://huakui.example/home-road/",
newURLAddr1: "/topic/",
navPageList: [{ url: "/list/" }, { url: "https://backup.example/path?x=a+b&x=%2b" }],
gmailAddr: "help@huakui.example", tgGroup: "https://t.me/huakui_official",
telegram: "https://t.me/huakui_support", twitterAddr: "https://x.com/huakui",
},
faqItems: [{ Question: "如何保存入口?", Answer: "收藏本页并保存备用地址。" }],
}} />);
const links = assertPublicLinks(html);
for (const expected of ["https://new.huakui.example/", "/dramas/", "https://backup.example/path?x=a+b&x=%2b", "mailto:help@huakui.example", "https://t.me/huakui_official", "https://t.me/huakui_support", "https://x.com/huakui"])
assert.ok(links.includes(expected), expected);
assert.ok(html.includes(`src="${brandAssets.navigationLogo}"`));
assert.match(html, /alt="花魁短剧"/);
assert.match(html, /花魁短剧 · 官方最新地址发布页/);
assert.match(html, /huakui\.example\/return-home/);
assert.match(html, /如何保存入口?/);
assert.doesNotMatch(html, /黄果短剧|huangguoaiai@gmail\.com|href="\/home-road\//);
});
test("home road keeps an accessible fallback when configured routes or contacts are absent or unsafe", () => {
for (const appConfig of [{}, {
newURLAddr: "javascript:alert(1)", foreverURLAddr: "//unknown.example/",
newURLAddr1: "https://unsafe.example/\\path", newURLAddr2: "data:text/html,unsafe",
navPageList: [{ url: "javascript:alert(2)" }],
gmailAddr: "invalid", tgGroup: "javascript:alert(3)",
}]) {
const html = render(<HomeRoadPage data={{ appConfig }} />);
assert.ok(hrefs(html).every((href) => href === "/"));
assert.match(html, /永久入口正在更新/);
assert.match(html, /官方联系方式正在更新/);
assert.match(html, /回家的路页面有什么作用?/);
assert.doesNotMatch(html, /javascript:|data:text|huangguoaiai@gmail\.com/);
}
});
test("site navigation uses the supplied home road destination and hides an unavailable entry", () => {
for (const homeRoadURL of ["/return-home/", "https://main.huakui.test/return-home/", "https://files.huakui.test/access.pdf", ""]) {
const html = render(<SiteNavigationPage data={{ appConfig: {}, homeRoadURL }} />);
const homeRoadLink = [...html.matchAll(/<a\b[^>]*>[\s\S]*?<\/a>/g)]
.map(([anchor]) => anchor).find((anchor) => anchor.includes(">回家的路</span>"));
if (!homeRoadURL) assert.equal(homeRoadLink, undefined);
else {
assert.ok(homeRoadLink);
assert.ok(homeRoadLink.includes(`href="${homeRoadURL}"`));
if (homeRoadURL.startsWith("https://")) assert.match(homeRoadLink, /target="_blank"/);
}
}
});
+227
View File
@@ -0,0 +1,227 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { runInNewContext } from "node:vm";
import { createElement, Fragment } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { NextRequest } from "next/server";
import { createMetadataElements } from "next/dist/lib/metadata/metadata-elements";
import {
unstable_doesMiddlewareMatch as doesProxyMatch,
} from "next/experimental/testing/server";
import nextConfig from "../next.config";
import manifest from "../src/app/manifest";
import { pwaConfig, pwaViewport as viewport } from "../src/lib/pwa/config";
import { config as proxyConfig, proxy } from "../src/proxy";
import { createContext } from "../src/server/context";
import { enrichSEO, toNextMetadata } from "../src/server/seo";
function pngDimensions(source: string): [number, number] {
assert.match(source, /^\/static\/pwa\/[\w-]+\.png$/);
const bytes = readFileSync(new URL("../public" + source, import.meta.url));
assert.deepEqual(bytes.subarray(0, 8), Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
assert.equal(bytes.toString("ascii", 12, 16), "IHDR");
return [bytes.readUInt32BE(16), bytes.readUInt32BE(20)];
}
test("native manifest installs the Chinese brand at the root with fullscreen and standalone support", () => {
const data = manifest();
assert.equal(data.name, "花魁短剧");
assert.equal(data.short_name, "花魁短剧");
assert.equal(data.lang, "zh-CN");
assert.equal(data.id, "/");
assert.equal(data.start_url, "/");
assert.equal(data.scope, "/");
assert.equal(data.display, "fullscreen");
assert.deepEqual(data.display_override, ["fullscreen", "standalone"]);
assert.equal(data.orientation, "any");
assert.equal(data.theme_color, pwaConfig.themeColor);
assert.equal(data.background_color, pwaConfig.themeColor);
assert.equal(pwaConfig.manifest, "/manifest.webmanifest");
assert.equal(pwaConfig.worker, "/sw.js");
});
test("installation icons are real 192/512 PNGs with an independent maskable asset and 180 Apple icon", () => {
const icons = manifest().icons || [];
for (const size of [192, 512]) {
const icon = icons.find((item) => item.sizes === `${size}x${size}` && item.purpose === "any");
assert.ok(icon, `${size}px icon`);
assert.equal(icon.type, "image/png");
assert.deepEqual(pngDimensions(icon.src), [size, size]);
}
const maskable = icons.find((item) => item.purpose === "maskable");
const regular = icons.find((item) => item.sizes === "512x512" && item.purpose === "any");
assert.ok(maskable && regular);
assert.equal(maskable.sizes, "512x512");
assert.equal(maskable.type, "image/png");
assert.deepEqual(pngDimensions(maskable.src), [512, 512]);
assert.notEqual(maskable.src, regular.src);
assert.notDeepEqual(
readFileSync(new URL("../public" + maskable.src, import.meta.url)),
readFileSync(new URL("../public" + regular.src, import.meta.url)),
);
assert.deepEqual(pngDimensions(pwaConfig.icons.apple), [180, 180]);
});
test("global viewport and Apple metadata cover the installed screen on public, private and error pages", () => {
assert.equal(viewport.width, "device-width");
assert.equal(viewport.initialScale, 1);
assert.equal(viewport.viewportFit, "cover");
assert.equal(viewport.themeColor, pwaConfig.themeColor);
const site = "https://canonical.drama.test";
const appConfig = { foreverUrlAddr: site, seoInfo: { webSiteName: "花魁短剧" } };
for (const [route, status] of [["/", 200], ["/drama/story/", 200], ["/account/settings/", 200], ["/missing/", 404]] as const) {
const data = enrichSEO(createContext(new Request(site + route)), { appConfig }, { status });
const metadata = toNextMetadata(data);
assert.equal(metadata.applicationName, "花魁短剧", route);
assert.equal(metadata.manifest, "/manifest.webmanifest", route);
assert.deepEqual(metadata.appleWebApp, {
capable: true,
title: "花魁短剧",
statusBarStyle: "black-translucent",
}, route);
assert.equal(metadata.other?.["apple-mobile-web-app-capable"], "yes", route);
// Exercise Next's actual output: appleWebApp emits the generic capability,
// while Safari still requires the separate Apple-prefixed capability.
const elements = createMetadataElements({
appleWebApp: metadata.appleWebApp,
other: metadata.other,
} as Parameters<typeof createMetadataElements>[0]);
const head = renderToStaticMarkup(createElement(Fragment, null, ...elements));
for (const name of ["mobile-web-app-capable", "apple-mobile-web-app-capable"]) {
assert.equal((head.match(new RegExp(`name="${name}"`, "g")) || []).length, 1, route + " " + name);
assert.match(head, new RegExp(`<meta name="${name}" content="yes"`));
}
assert.deepEqual((metadata.icons as { apple: unknown }).apple, {
url: pwaConfig.icons.apple,
sizes: "180x180",
type: "image/png",
}, route);
}
// Apple capability is generated once through Metadata, never duplicated by a player-only tag.
const layout = readFileSync(new URL("../src/app/layout.tsx", import.meta.url), "utf8");
assert.doesNotMatch(layout, /name\s*=\s*["']apple-mobile-web-app-capable["']/);
});
test("PWA metadata preserves canonical, indexing policy, discovery markup and configured verification tags", () => {
const site = "https://canonical.drama.test";
const appConfig = { foreverUrlAddr: site, seoInfo: { title: "花魁短剧", webSiteName: "花魁短剧" } };
const page = enrichSEO(createContext(new Request("http://internal:3000/dramas/all/0/1/page/2/?dc=discard")), {
appConfig,
pagination: { Page: 2, Visible: true, HasPrev: true, PrevURL: "/list/", HasNext: true, NextURL: "/list/all/0/1/page/3/" },
list: [{ id: 41, slug: "story", title: "故事" }, { id: 42, slug: "another", title: "另一部故事" }],
});
const metadata = toNextMetadata(page);
assert.equal(metadata.alternates?.canonical, site + "/dramas/all/0/1/page/2/");
assert.deepEqual(metadata.pagination, { previous: site + "/dramas/", next: site + "/dramas/all/0/1/page/3/" });
assert.equal(metadata.robots, "index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1");
assert.equal(metadata.other?.googlebot, page.robots);
assert.equal(metadata.verification?.google, "Y_ldHPiw-yBVgVw_jZ4UG5WJw-xjO31mBMfhflqMGw8");
assert.deepEqual(metadata.verification?.yandex, ["56acf08532be3060", "acaf652efa6cd920"]);
assert.equal(metadata.verification?.other?.["msvalidate.01"], "57A5999E9B5167EE9039265EBCA2AE3A");
assert.ok(JSON.parse(page.jsonLD)["@graph"].some((node: { "@type": string }) => node["@type"] === "ItemList"));
for (const [route, extra, status, robots] of [
["/feedback/", {}, 200, "noindex,nofollow"],
["/navigation/", { isNavPage: true }, 200, "noindex,follow"],
["/missing/", {}, 404, "noindex,follow"],
["/dramas/", {}, 503, "noindex,follow"],
] as const) {
const ctx = createContext(new Request(site + route));
const data = enrichSEO(ctx, { appConfig, ...extra }, { status });
const result = toNextMetadata(data);
assert.equal(result.robots, robots, route);
assert.equal(result.alternates, undefined, route);
assert.equal(data.jsonLD, undefined, route);
assert.equal(ctx.responseHeaders.get("x-robots-tag"), robots.replace(/,/g, ", "), route);
if (route === "/navigation/") {
assert.equal(result.verification?.google, "Y_ldHPiw-yBVgVw_jZ4UG5WJw-xjO31mBMfhflqMGw8");
assert.deepEqual(result.verification?.yandex, ["f5f965a1c5d31a69", "acaf652efa6cd920"]);
}
}
});
test("Next's actual matcher excludes installation assets while retaining normal pages, APIs and signed media", () => {
for (const url of ["/manifest.webmanifest", "/manifest.webmanifest?v=1", "/sw.js", "/sw.js?v=1", "/static/pwa/icon-192.png", "/_next/static/chunk.js"])
assert.equal(doesProxyMatch({ config: proxyConfig, nextConfig, url }), false, url);
for (const url of ["/", "/dramas/", "/account/login/", "/feedback/", "/api/auth/guest", "/h5/m3u8/story.m3u8?ticket=a%2Bb%3D", "/enkey?ticket=1", "/manifest.webmanifest/child", "/sw.js/child"])
assert.equal(doesProxyMatch({ config: proxyConfig, nextConfig, url }), true, url);
});
test("manifest and worker runtime bypass never create guest sessions, cookies or SSR handoffs, including navigation-only mode", async () => {
const originalFetch = globalThis.fetch;
const originalNavigation = process.env.NAVIGATION_ONLY;
let requests = 0;
globalThis.fetch = async () => {
requests++;
throw new Error("Installation assets must not contact the upstream");
};
try {
for (const navigation of ["false", "true"]) {
process.env.NAVIGATION_ONLY = navigation;
for (const path of ["/manifest.webmanifest?dc=pwa-test", "/sw.js?v=pwa-test"])
for (const method of ["GET", "HEAD"]) {
const request = new NextRequest("https://site.test" + path, { method });
const response = await proxy(request);
assert.equal(response.status, 200, navigation + path + method);
assert.equal(response.headers.get("x-middleware-next"), "1");
assert.deepEqual(response.headers.getSetCookie(), []);
assert.equal(response.headers.has("location"), false);
assert.equal(response.headers.has("x-middleware-rewrite"), false);
assert.equal(response.headers.has("x-middleware-override-headers"), false);
for (const name of response.headers.keys())
assert.equal(name.includes("x-hk-"), false, "Unexpected handoff: " + name);
assert.equal(request.headers.get("cookie"), null);
}
}
assert.equal(requests, 0);
} finally {
globalThis.fetch = originalFetch;
if (originalNavigation === undefined) delete process.env.NAVIGATION_ONLY;
else process.env.NAVIGATION_ONLY = originalNavigation;
}
});
test("the generated worker activates without fetch interception, network calls or Cache Storage access", async () => {
type Event = { waitUntil: (promise: Promise<unknown>) => void };
const listeners = new Map<string, (event: Event) => void>();
let waiting = 0;
let claimed = 0;
const worker = {
addEventListener(name: string, listener: (event: Event) => void) {
assert.equal(listeners.has(name), false, "Duplicate worker listener");
listeners.set(name, listener);
},
skipWaiting: async () => { waiting++; },
clients: { claim: async () => { claimed++; } },
};
const forbidden = (name: string) => () => {
assert.fail("The installation worker must not access " + name);
};
const sandbox = { self: worker };
for (const target of [worker, sandbox]) {
Object.defineProperty(target, "caches", { get: forbidden("Cache Storage") });
Object.defineProperty(target, "fetch", { get: forbidden("fetch") });
}
runInNewContext(readFileSync(new URL("../public/sw.js", import.meta.url), "utf8"), sandbox, { timeout: 1000 });
assert.deepEqual([...listeners.keys()].sort(), ["activate", "install"]);
for (const name of ["install", "activate"]) {
const promises: Promise<unknown>[] = [];
listeners.get(name)!({ waitUntil: (promise) => { promises.push(Promise.resolve(promise)); } });
assert.equal(promises.length, 1, name + " must wait for its lifecycle operation");
await Promise.all(promises);
}
assert.equal(waiting, 1);
assert.equal(claimed, 1);
});
test("the worker is served as JavaScript without persistent HTTP caching and with root scope", async () => {
const rules = await nextConfig.headers!();
const worker = rules.find((rule) => rule.source === "/sw.js");
assert.ok(worker);
const headers = new Headers(worker.headers.map(({ key, value }): [string, string] => [key, value]));
assert.match(headers.get("content-type") || "", /^(?:application|text)\/javascript\b/i);
assert.match(headers.get("cache-control") || "", /\bno-store\b/);
assert.match(headers.get("cache-control") || "", /\bno-cache\b/);
assert.equal(headers.get("service-worker-allowed"), "/");
});
+242
View File
@@ -0,0 +1,242 @@
import { strict as assert } from "node:assert";
import { readFileSync, readdirSync, existsSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { createRequire } from "node:module";
import { test } from "node:test";
import { createElement, type ComponentType } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { toPublicPath } from "../src/lib/routes";
import {
AppRouterContext,
type AppRouterInstance,
} from "next/dist/shared/lib/app-router-context.shared-runtime";
import { SiteProvider } from "../src/components/providers/SiteProvider";
import { catalogPages } from "../src/features/catalog";
import { ListPage, TagsPage } from "../src/features/catalog/BrowsePages";
import { DramaCard } from "../src/components/catalog/Cards";
import { AdBanners } from "../src/components/catalog/Promotions";
import {
adSlice,
allListURL,
cardTags,
externalProps,
moreListURL,
navHref,
seriesListURL,
} from "../src/features/catalog/model";
import type { Data } from "../src/server/services/model";
const fixture = (name: string): string =>
readFileSync(
new URL("./fixtures/templates/" + name, import.meta.url),
"utf8",
);
const router = {
back() {},
forward() {},
refresh() {},
push() {},
replace() {},
prefetch: async () => {},
} as unknown as AppRouterInstance;
const renderPage = (Component: ComponentType<{ data: Data }>, data: Data) =>
renderToStaticMarkup(
createElement(AppRouterContext.Provider, {
value: router,
children: createElement(SiteProvider, {
data,
dialogs: false,
children: createElement(Component, { data }),
}),
}),
);
const main = (html: string): string =>
html.match(/<main\b[^>]*>([\s\S]*?)<\/main>/)?.[1] || "";
const decode = (text: string): string =>
text.replace(
/&#(?:34|x27|39);|&quot;|&amp;|&lt;|&gt;/g,
(entity) =>
({
"&#34;": '"',
"&#x27;": "'",
"&#39;": "'",
"&quot;": '"',
"&amp;": "&",
"&lt;": "<",
"&gt;": ">",
})[entity] || entity,
);
const anchors = (html: string) =>
[...html.matchAll(/<a\b[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/g)].map(
(match) => ({
href: toPublicPath(decode(match[1])),
text: decode(
match[2]
.replace(/<[^>]*>/g, "")
.replace(/\s+/g, " ")
.trim(),
),
}),
);
// These fixtures came from native Go html/template. Compare the user-visible
// catalog/link contracts while Next now owns the document head and hydration.
test("React tags and list pages preserve the original Go catalog content and navigation", () => {
for (const [name, Component] of [
["tags", TagsPage],
["list", ListPage],
] as const) {
const data = JSON.parse(fixture(name + "-data.json")) as Data;
const actual = main(renderPage(Component, data)),
expected = main(fixture(name + "-go.html"));
assert.ok(actual, name);
// The episode label moved from the cover link into the meta-row chip.
const coverEpisode = /^(?:全 \d+ 集|更新至 \d+ 集|连载中|已完结)$/;
assert.deepEqual(
anchors(actual),
anchors(expected).map((link) =>
link.href.startsWith("/drama/") && coverEpisode.test(link.text)
? { ...link, text: "" }
: link,
),
name + " links and labels",
);
assert.equal(
(actual.match(/class="hk-drama-card hk-drama-card--list"/g) || []).length,
data.list.length,
name,
);
assert.equal(
(actual.match(/data-track-ad="external"/g) || []).length,
(expected.match(/data-track-ad="external"/g) || []).length,
name + " ad placement",
);
assert.match(actual, /花魁 &amp; 第一部/);
assert.match(actual, /<span class="hk-drama-card__meta-ep" data-status="done">完结 · 4 集<\/span>/);
}
});
test("all catalog page types render actual React markup without legacy business scripts", () => {
assert.equal(Object.keys(catalogPages).length, 13);
for (const [name, Component] of Object.entries(catalogPages)) {
const html = renderPage(Component, {});
assert.ok(html.length > 100, name);
assert.doesNotMatch(html, /\{\{|<script\b|data-hgdj-page/, name);
}
});
test("React text escaping and shared URL guards reject injected markup and ad protocols", () => {
const html = renderToStaticMarkup(
createElement(DramaCard, {
item: { id: 1, playName: '<script>alert("x")</script>', totalEpisode: 4 },
}),
);
assert.match(html, /&lt;script&gt;alert\(&quot;x&quot;\)&lt;\/script&gt;/);
assert.doesNotMatch(html, /<script\b/);
const ad = renderToStaticMarkup(
createElement(AdBanners, {
items: [
{
id: 1,
link: "javascript:alert(1)",
images: "/static/brand/header.svg",
title: "<unsafe>",
},
],
}),
);
assert.match(ad, /href="#"/);
assert.match(ad, /alt="&lt;unsafe&gt;"/);
assert.doesNotMatch(ad, /href="javascript:/);
});
test("shared React route, tag and advertising helpers retain the original contracts", () => {
assert.equal(allListURL("", 0, 0, 1, 1), "/dramas/");
assert.equal(
allListURL("都市", 1, 2, 3, 2),
"/dramas/%E9%83%BD%E5%B8%82/2/3/page/2/",
);
assert.equal(
moreListURL("热门", 1, 0, "", 0, 2),
"/curated/dramas/%E7%83%AD%E9%97%A8/page/2/",
);
assert.equal(seriesListURL(2, 3, 2, 2), "/series/anime/3/2/page/2/");
assert.equal(
navHref("/collections/热门?q=a"),
"/collection/%E7%83%AD%E9%97%A8/?q=a",
);
assert.deepEqual(
externalProps("https://example.com:8080/a", "http://example.com"),
{},
);
assert.deepEqual(externalProps("//external.example/a"), {
target: "_blank",
rel: "sponsored nofollow noopener",
});
assert.deepEqual(
cardTags({
tagNames: ["都市", "热播"],
tagIds: [1, 2],
themeNames: ["都市", "古装"],
themeIds: [3, 4],
}),
[
{ Name: "都市", URL: "/tags/1/" },
{ Name: "热播", URL: "/tags/2/" },
{ Name: "古装", URL: "/dramas/4/" },
],
);
assert.deepEqual(
adSlice([{ images: "" }, { images: "a" }, { images: "b" }], 1, 3),
[{ images: "b" }, { images: "a" }, { images: "b" }],
);
});
test("every static asset referenced by React components, CSS and media modules exists", () => {
function files(directory: string): string[] {
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) =>
entry.isDirectory()
? files(join(directory, entry.name))
: [join(directory, entry.name)],
);
}
const paths = [
...files("src"),
...files("public/static"),
].filter((path) => /\.(?:tsx?|css|js)$/.test(path));
const references = new Set<string>();
const stylesheets = new Set<string>();
const require = createRequire(import.meta.url);
for (const path of paths) {
const source = readFileSync(path, "utf8");
for (const match of source.matchAll(/\bimport\s+["']([^"']+\.css)["']/g)) {
const stylesheet = match[1].startsWith("@/")
? resolve("src", match[1].slice(2))
: match[1].startsWith(".")
? resolve(dirname(path), match[1])
: require.resolve(match[1], { paths: [resolve(dirname(path))] });
assert.ok(existsSync(stylesheet), `${path}: ${match[1]}`);
stylesheets.add(stylesheet);
}
for (const match of source.matchAll(
/\/static\/[A-Za-z0-9_./-]+\.(?:css|js|svg|png|jpg|jpeg|gif|webp|woff2?)/g,
))
references.add(match[0]);
for (const match of source.matchAll(
/<Icon\b[^>]*\bname="([A-Za-z0-9_./-]+)"/g,
))
references.add(
match[1].startsWith("/")
? match[1]
: "/static/images/" +
match[1] +
(/\.(?:svg|png|webp)$/.test(match[1]) ? "" : ".svg"),
);
}
assert.ok(references.size > 30, "static references are discovered");
assert.ok(stylesheets.size >= 36, "Application source imports the migrated stylesheets");
for (const stylesheet of files("src/styles").filter((path) => path.endsWith(".css")))
assert.ok(stylesheets.has(resolve(stylesheet)), `Unimported stylesheet: ${stylesheet}`);
for (const reference of references)
assert.ok(existsSync("public" + reference), reference);
});
+286
View File
@@ -0,0 +1,286 @@
import test from "node:test";
import assert from "node:assert/strict";
import { canonicalizePageURLs, ROUTE_MIGRATIONS, toLegacyPath, toPublicPath, toPublicURL } from "../src/lib/routes";
import { createContext } from "../src/server/context";
import { canonicalRedirect } from "../src/server/redirects";
import { matchPageRoute } from "../src/server/pages";
import { pageRoutes, publicPageRoutes } from "../src/server/pages/routes";
import { preparePage } from "../src/server/react/page-data";
import { trackerPage } from "../src/server/services/shared";
test("Huakui route prefixes preserve encoded slugs, raw queries and fragments", () => {
const suffix = "/%E7%9F%AD%E5%89%A7%2F%E9%9B%86%252F1/?x=a+b&x=%2b&token=a%2FB#episode%202";
assert.equal(toPublicPath("/play" + suffix), "/drama" + suffix);
assert.equal(toLegacyPath("/drama" + suffix), "/play" + suffix);
assert.equal(toLegacyPath("/series/watch/video/recommendations/page/2/"), "/series-play/video/recommendations/page/2/");
for (const path of ["/account/settings/", "/settings/", "/profile/", "/account/login/"]) {
assert.equal(toPublicPath(path), path, "removed account path is not migrated " + path);
assert.equal(toLegacyPath(path), path, "removed account path is not migrated " + path);
}
assert.equal(toPublicPath("/dh/"), "/navigation/");
assert.equal(toPublicPath("/home-road/"), "/return-home/");
assert.equal(toLegacyPath("/return-home/"), "/home-road/");
for (const path of ["/", "/series/anime/all/1/", "/search/word/", "/listings/", "/api/playlet/list", "/h5/m3u8/signed", "/static/images/play/foo.svg", "//external.example/play/a/"]) {
assert.equal(toPublicPath(path), path, path);
}
for (const { legacy, public: current } of ROUTE_MIGRATIONS) {
assert.equal(toPublicPath(legacy + "/"), current + "/");
assert.equal(toPublicPath(current + "/"), current + "/", "idempotent " + current);
}
});
test("all registered HTML routes resolve under new public URLs with identical parameters", () => {
const values: Record<string, string> = { q: "短剧/a", theme: "romance", slug: "剧名/a", configId: "recommend", media: "anime", topic: "12", updateStatus: "2", sortType: "3", sortOrder: "2", rankType: "3", timeRange: "1", page: "2", guessPage: "2", ep: "100", filter: "chapter" };
assert.equal(pageRoutes.length, 59);
assert.equal(publicPageRoutes.length, pageRoutes.length);
for (const [pattern, handler] of pageRoutes) {
const params: Record<string, string> = {};
const legacy = pattern.replace(/:(\w+)/g, (_, key: string) => {
params[key] = values[key];
return encodeURIComponent(values[key]);
});
const current = toPublicPath(legacy);
assert.deepEqual(matchPageRoute(current), { handler, params }, pattern);
assert.deepEqual(matchPageRoute(legacy), { handler, params }, "legacy " + pattern);
for (const method of ["GET", "HEAD"]) {
assert.equal(canonicalRedirect(createContext(new Request("https://huakui.example" + current, { method }))), null, "canonical " + current);
const response = canonicalRedirect(createContext(new Request("https://huakui.example" + legacy, { method })));
if (current === legacy) assert.equal(response, null, "unchanged " + legacy);
else {
assert.equal(response?.status, 301, "legacy " + legacy);
assert.equal(response?.headers.get("Location"), current, "single-hop " + legacy);
}
}
}
for (const path of ["/dramas//", "/dramas/theme/extra/", "/series/watch/", "/account/unknown/", "/account/", "/account/settings/", "/account/favorites/chapter/", "/profile/", "/login/", "/collection/a/page/2/extra/", "/curated/dramas/", "/discover/search/extra/", "/return-home//", "/return-home/extra/", "/home-road/extra/"]) {
assert.equal(matchPageRoute(path), null, path);
}
});
test("legacy pages redirect once with 301 to new canonical path and keep request suffix bytes", () => {
const saved = process.env.NAVIGATION_ONLY;
process.env.NAVIGATION_ONLY = "false";
try {
const suffix = "?x=a+b&x=%2b&return=%2Fplay%2Fa%2F#part%202";
for (const [legacy, current] of [["/play/a%2Fb", "/drama/a%2Fb/"], ["/list/romance/2/1/page/2/", "/dramas/romance/2/1/page/2/"], ["/dh", "/navigation/"], ["/home-road", "/return-home/"], ["/home-road/", "/return-home/"]]) {
for (const method of ["GET", "HEAD"]) {
const response = canonicalRedirect(createContext(new Request("https://huakui.example" + legacy + suffix, { method })));
assert.equal(response?.status, 301, legacy);
assert.equal(response?.headers.get("Location"), current + suffix, legacy);
assert.equal(canonicalRedirect(createContext(new Request("https://huakui.example" + current + suffix, { method }))), null, "single hop " + current);
}
}
assert.equal(canonicalRedirect(createContext(new Request("https://huakui.example/dramas")))?.headers.get("Location"), "/dramas/");
// Removed account surfaces permanently return home without carrying the query or fragment.
for (const path of ["/account", "/account/", "/account/login/", "/account/register/", "/account/history/page/2/", "/account/favorites/chapter/page/3/", "/account/purchases/", "/account/recharge/", "/account/settings/", "/login", "/login/", "/register/", "/profile", "/profile/", "/history/page/2/", "/favorites/chapter/", "/purchased/", "/setting/", "/settings", "/settings/", "/recharge/"]) {
for (const method of ["GET", "HEAD"]) {
const response = canonicalRedirect(createContext(new Request("https://huakui.example" + path + suffix, { method })));
assert.equal(response?.status, 301, method + " " + path);
assert.equal(response?.headers.get("Location"), "/", method + " " + path);
}
assert.equal(canonicalRedirect(createContext(new Request("https://huakui.example" + path, { method: "POST" }))), null, "POST " + path);
}
for (const path of ["/accounts/", "/account-settings/", "/loginx/", "/history-of-drama/", "/settingsx"]) {
assert.notEqual(canonicalRedirect(createContext(new Request("https://huakui.example" + path)))?.headers.get("Location"), "/", "segment boundary " + path);
}
for (const path of ["/list//", "/list/theme/extra/", "/static/css/list.css", "/h5/m3u8/signed?sign=%2B", "/api/feedback/add", "/api/playlet/collect/set/"]) {
assert.equal(canonicalRedirect(createContext(new Request("https://huakui.example" + path))), null, path);
}
for (const path of ["/play/a", "/drama/a", "/profile", "/account", "/home-road/", "/return-home/"]) {
assert.equal(canonicalRedirect(createContext(new Request("https://huakui.example" + path, { method: "POST" }))), null, path);
}
const post = canonicalRedirect(createContext(new Request("https://huakui.example/api/feedback/add/?sign=%2B", { method: "POST" })));
assert.equal(post?.status, 307);
assert.equal(post?.headers.get("Location"), "/api/feedback/add?sign=%2B");
assert.equal(canonicalRedirect(createContext(new Request("https://huakui.example/api/playlet/collect/set/?sign=%2B", { method: "POST" }))), null);
} finally {
if (saved === undefined) delete process.env.NAVIGATION_ONLY;
else process.env.NAVIGATION_ONLY = saved;
}
});
test("DTO link canonicalization leaves external, media, SDK, payment and SEO inputs intact", () => {
const external = "https://cdn.example/play/signed.m3u8?sign=%2B";
const data = {
requestPath: "/play/a/1/", canonicalPath: "/play/a/",
works: [{ URL: "/play/a%2Fb/", url: "https://huakui.example/topic-detail/a/?q=%2b" }],
pagination: { PrevURL: "/list/", nextURL: "/list/romance/1/1/page/2/" },
categoryList: [{ urlValue: "/rank/" }, { urlValue: "https://external.example/list/" }],
chapter: { url: "/play/signed-media.m3u8?sign=%2B" },
video: { video: { url: "/play/signed-video.m3u8?sign=%2B" } },
advitise: { url: "https://huakui.example/play/ad/" },
config: { url: "/play/config/" },
mainURL: external, previewURL: "/play/signed-preview.m3u8?sign=%2B",
payment: { url: "/play/payment/" },
canonicalURL: "https://huakui.example/play/a/",
seoInfo: { canonicalUrl: "https://huakui.example/play/a/" },
jsonLD: '{"url":"https://huakui.example/play/a/"}',
};
assert.equal(canonicalizePageURLs(data, "https://huakui.example"), data);
assert.equal(data.requestPath, "/drama/a/1/");
assert.equal(data.canonicalPath, "/drama/a/");
assert.equal(data.works[0].URL, "/drama/a%2Fb/");
assert.equal(data.works[0].url, "https://huakui.example/collection/a/?q=%2b");
assert.equal(data.pagination.PrevURL, "/dramas/");
assert.equal(data.pagination.nextURL, "/dramas/romance/1/1/page/2/");
assert.equal(data.categoryList[0].urlValue, "/rankings/");
assert.equal(data.categoryList[1].urlValue, "https://external.example/list/");
assert.equal(data.chapter.url, "/play/signed-media.m3u8?sign=%2B");
assert.equal(data.video.video.url, "/play/signed-video.m3u8?sign=%2B");
assert.equal(data.advitise.url, "https://huakui.example/play/ad/");
assert.equal(data.config.url, "/play/config/");
assert.equal(data.mainURL, external);
assert.equal(data.previewURL, "/play/signed-preview.m3u8?sign=%2B");
assert.equal(data.payment.url, "/play/payment/");
assert.equal(data.canonicalURL, "https://huakui.example/play/a/");
assert.equal(data.seoInfo.canonicalUrl, "https://huakui.example/play/a/");
assert.equal(data.jsonLD, '{"url":"https://huakui.example/play/a/"}');
assert.equal(toPublicURL("https://huakui.example/play/a%2Fb/?sign=a+b#e", "https://huakui.example/"), "https://huakui.example/drama/a%2Fb/?sign=a+b#e");
assert.equal(toPublicURL(external, "https://huakui.example"), external);
});
test("navigation-only mode recognizes the new navigation path without enabling other page redirects", () => {
const saved = process.env.NAVIGATION_ONLY;
process.env.NAVIGATION_ONLY = "true";
try {
assert.equal(canonicalRedirect(createContext(new Request("https://huakui.example/nav?channel=%2B")))?.headers.get("Location"), "/navigation/?channel=%2B");
assert.equal(canonicalRedirect(createContext(new Request("https://huakui.example/navigation/"))), null);
assert.equal(canonicalRedirect(createContext(new Request("https://huakui.example/list/"))), null);
assert.equal(canonicalRedirect(createContext(new Request("https://huakui.example/home-road/"))), null);
assert.equal(canonicalRedirect(createContext(new Request("https://huakui.example/account/settings/"))), null);
} finally {
if (saved === undefined) delete process.env.NAVIGATION_ONLY;
else process.env.NAVIGATION_ONLY = saved;
}
});
test("new content paths reach unchanged upstream loaders and preserve navigation-only isolation", async () => {
const savedFetch = globalThis.fetch;
const savedUpstream = process.env.UPSTREAM_API_URL;
const savedNavigation = process.env.NAVIGATION_ONLY;
process.env.UPSTREAM_API_URL = "http://huakui-route-loader-test";
process.env.NAVIGATION_ONLY = "false";
const calls: URL[] = [];
globalThis.fetch = async (input) => {
const url = new URL(String(input));
calls.push(url);
let data: Record<string, unknown> = {};
if (url.pathname === "/api/app/config/info") data = { config: { foreverUrlAddr: "https://huakui.example" } };
if (url.pathname === "/api/app/theme/list") data = { list: [{ id: 12, name: "爱情", slug: "romance", status: true }] };
if (url.pathname === "/api/app/playlet/all/list") data = { list: [{ id: 10, playName: "作品", slug: "story" }], totalCount: 100, pageNum: Number(url.searchParams.get("pageNum")), pageSize: 24 };
return Response.json({ code: 200, data });
};
const context = (path: string) => {
const ctx = createContext(new Request("https://huakui.example" + path, { headers: { cookie: "token=route-test; isRegister=1" } }));
ctx.user = { userId: 7, nickName: "读者", type: 1, isRegister: true };
ctx.state.set("auth:fresh", true);
return ctx;
};
try {
const list = await preparePage(context("/dramas/romance/1/1/page/2/"));
assert.equal(list.status || 200, 200);
assert.equal(list.view, "list");
assert.equal(list.data.themeId, 12);
assert.equal(list.data.updateStatus, 1);
assert.equal(list.data.requestPath, "/dramas/romance/1/1/page/2/");
assert.equal(list.data.canonicalPath, "/dramas/romance/1/1/page/2/");
assert.equal(list.data.pagination.NextURL || list.data.pagination.nextURL, "/dramas/romance/1/1/page/3/");
assert.equal(list.data.canonicalURL, "https://huakui.example/dramas/romance/1/1/page/2/");
assert.match(list.data.robots, /^index,follow/);
assert.equal(list.data.paginationNextURL, "https://huakui.example/dramas/romance/1/1/page/3/");
const sorted = await preparePage(context("/dramas/romance/1/2/"));
assert.equal(sorted.data.robots, "noindex,follow");
assert.equal(sorted.data.canonicalURL, undefined);
const upstream = calls.find((url) => url.pathname === "/api/app/playlet/all/list");
assert.equal(upstream?.searchParams.get("themeSlug"), "romance");
assert.equal(upstream?.searchParams.get("pageNum"), "2");
for (const path of ["/account/", "/account/settings/", "/account/favorites/chapter/", "/setting/"])
assert.equal((await preparePage(context(path))).status, 404, path);
const homeRoad = await preparePage(context("/return-home/"));
assert.equal(homeRoad.status || 200, 200);
assert.equal(homeRoad.view, "homeRoad");
assert.equal(homeRoad.data.requestPath, "/return-home/");
assert.equal(homeRoad.data.pageKey, "home_road");
assert.match(homeRoad.data.metaTitle, /回家的路.*花魁短剧/);
assert.equal(homeRoad.data.robots, "noindex,follow");
assert.equal(homeRoad.data.canonicalURL, undefined);
assert.equal(homeRoad.data.jsonLD, undefined);
assert.ok(homeRoad.data.faqItems.length > 0);
assert.equal((await preparePage(context("/navigation/"))).data.homeRoadURL, "/return-home/");
process.env.NAVIGATION_ONLY = "true";
const navigation = await preparePage(context("/navigation/"));
assert.equal(navigation.view, "nav");
assert.equal(navigation.data.isNavPage, true);
assert.equal(navigation.data.homeRoadURL, "https://huakui.example/return-home/");
assert.equal((await preparePage(context("/dramas/"))).status, 404);
assert.equal((await preparePage(context("/return-home/"))).status, 404);
} finally {
globalThis.fetch = savedFetch;
if (savedUpstream === undefined) delete process.env.UPSTREAM_API_URL;
else process.env.UPSTREAM_API_URL = savedUpstream;
if (savedNavigation === undefined) delete process.env.NAVIGATION_ONLY;
else process.env.NAVIGATION_ONLY = savedNavigation;
}
});
test("navigation-only home road links use a configured main-site origin, then the saved PDF fallback", async () => {
const savedFetch = globalThis.fetch;
const savedUpstream = process.env.UPSTREAM_API_URL;
const savedNavigation = process.env.NAVIGATION_ONLY;
process.env.NAVIGATION_ONLY = "true";
let config: Record<string, string> = {};
globalThis.fetch = async (input) => {
const url = new URL(String(input));
return Response.json({ code: 200, data: url.pathname === "/api/app/config/info" ? { config } : {} });
};
const cases: [Record<string, string>, string][] = [
[{ foreverUrlAddr: "https://main.huakui.test/old/path?ch=main", newUrlAddr: "https://latest.huakui.test/" }, "https://main.huakui.test/return-home/"],
[{ foreverUrlAddr: "https://example.com/", newUrlAddr: "https://latest.huakui.test/old/path" }, "https://latest.huakui.test/return-home/"],
[{ foreverUrlAddr: "javascript:invalid", newUrlAddr: "/", pdfAddr: "https://files.huakui.test/access.pdf" }, "https://files.huakui.test/access.pdf"],
[{}, ""],
];
try {
for (const [index, [configured, expected]] of cases.entries()) {
// Isolate public-config caches while testing each configured deployment.
process.env.UPSTREAM_API_URL = `http://home-road-navigation-test-${index}`;
config = configured;
const context = createContext(new Request("https://navigation.huakui.test/navigation/", { headers: { cookie: "token=nav-test; isRegister=1" } }));
context.user = { userId: 7, type: 1, isRegister: true };
context.state.set("auth:fresh", true);
const page = await preparePage(context);
assert.equal(page.status || 200, 200);
assert.equal(page.view, "nav");
assert.equal(page.data.homeRoadURL, expected);
}
} finally {
globalThis.fetch = savedFetch;
if (savedUpstream === undefined) delete process.env.UPSTREAM_API_URL;
else process.env.UPSTREAM_API_URL = savedUpstream;
if (savedNavigation === undefined) delete process.env.NAVIGATION_ONLY;
else process.env.NAVIGATION_ONLY = savedNavigation;
}
});
test("semantic page paths retain their original analytics classification", () => {
const paths: [string, string, string][] = [
["/list/romance/", "/dramas/romance/", "list"],
["/play/a%2Fb/123/", "/drama/a%2Fb/123/", "play"],
["/series-play/film/", "/series/watch/film/", "series_play"],
["/series/all/all/1/", "/series/all/all/1/", "series"],
["/topic/", "/collections/", "topic"],
["/topic-detail/story/", "/collection/story/", "topic_detail"],
["/rank/2/", "/rankings/2/", "rank"],
["/popular-recommend/", "/featured/", "popular_recommend"],
["/recently-added/", "/latest/", "recently_added"],
["/more-list/recommend/", "/curated/dramas/recommend/", "more_list"],
["/more-series-list/recommend/", "/curated/series/recommend/", "more_series_list"],
["/search-mobile/", "/discover/search/", "search"],
["/home-road/", "/return-home/", "home_road"],
];
for (const [legacy, current, key] of paths) {
assert.deepEqual(trackerPage(current), trackerPage(legacy), current);
assert.equal(trackerPage(current)[0], key, current);
}
assert.equal(trackerPage("/unknown/")[0], "internal_other");
});
+81
View File
@@ -0,0 +1,81 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { runInNewContext } from "node:vm";
const sdkSource = readFileSync(new URL("../public/static/common/web-sdk-v1.2.0.js", import.meta.url), "utf8");
interface SDKContext {
navigator?: { userAgent: string; platform: string; maxTouchPoints: number };
WebSDK?: { getCommonFields(fields?: string[]): Promise<Record<string, unknown>> };
}
function loadSDK(userAgent?: string, platform = "", maxTouchPoints = 0) {
const context: SDKContext = {};
if (userAgent !== undefined) context.navigator = { userAgent, platform, maxTouchPoints };
runInNewContext(sdkSource, context, { filename: "web-sdk-v1.2.0.js" });
assert.ok(context.WebSDK);
return context.WebSDK;
}
const userAgents = [
{
name: "iPhone",
ua: "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Version/18.0 Mobile/15E148 Safari/604.1",
platform: "iPhone", touch: 5, system: "iOS", version: "18.0",
},
{
name: "iPad mobile mode",
ua: "Mozilla/5.0 (iPad; CPU OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 Version/17.6 Mobile/15E148 Safari/604.1",
platform: "iPad", touch: 5, system: "iOS", version: "17.6.1",
},
{
name: "Android",
ua: "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Chrome/129.0.0.0 Mobile Safari/537.36",
platform: "Linux armv8l", touch: 5, system: "Android", version: "14",
},
{
name: "Windows NT",
ua: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/129.0.0.0 Safari/537.36",
platform: "Win32", touch: 0, system: "Windows", version: "10.0",
},
{
name: "macOS",
ua: "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6_1) AppleWebKit/605.1.15 Version/17.6 Safari/605.1.15",
platform: "MacIntel", touch: 0, system: "macOS", version: "14.6.1",
},
{
name: "iPad desktop mode without an exposed iOS version",
ua: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/18.0 Safari/605.1.15",
platform: "MacIntel", touch: 5, system: "iOS", version: "",
},
{
name: "Linux without an exposed kernel version",
ua: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/129.0.0.0 Safari/537.36",
platform: "Linux x86_64", touch: 0, system: "Linux", version: "",
},
{
name: "unknown operating system",
ua: "ExampleBrowser/1.0", platform: "", touch: 0, system: "Other", version: "",
},
];
for (const device of userAgents) {
test(`bundled SDK common fields report the operating system for ${device.name}`, async () => {
const sdk = loadSDK(device.ua, device.platform, device.touch);
const common = await sdk.getCommonFields();
assert.equal(common.system_name, device.system);
assert.equal(common.system_version, device.version);
assert.equal(common.user_agent, device.ua);
const selected = await sdk.getCommonFields(["system_name", "system_version"]);
assert.deepEqual(JSON.parse(JSON.stringify(selected)), {
system_name: device.system, system_version: device.version,
});
});
}
test("bundled SDK OS fields tolerate a missing navigator without inventing a version", async () => {
const common = await loadSDK().getCommonFields();
assert.equal(common.system_name, "Other");
assert.equal(common.system_version, "");
});
+229
View File
@@ -0,0 +1,229 @@
import 'next/dist/server/node-environment';
import assert from 'node:assert/strict';
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { NextRequest } from 'next/server';
import { unstable_doesMiddlewareMatch as doesProxyMatch } from 'next/experimental/testing/server';
import nextConfig from '../next.config';
import { config as proxyConfig, proxy } from '../src/proxy';
import { createContext } from '../src/server/context';
import { handleDiscoveryFileRequest } from '../src/server/seo/discovery-handler';
import { finalizeResponse } from '../src/server/handler';
import { enrichSEO, pageRepresentsMissingResource, toNextMetadata } from '../src/server/seo';
import { firstExistingFile, handleSEORequest, isRegisteredSEOPath, isValidSitemapShardName, sitemapCandidates } from '../src/server/seo/routes';
import { canonicalRedirect } from '../src/server/redirects';
import { receivePage } from '../src/server/react/handoff';
const origin = 'https://public.drama.test';
const request = (pathname: string, method = 'GET', extra: HeadersInit = {}) => new Request(origin + pathname, { method, headers: { Cookie: 'token=must-stay-private', Authorization: 'private-session', ...Object.fromEntries(new Headers(extra)) } });
let sequence = 0;
async function isolated(work: (directory: string) => Promise<void>) {
const keys = ['SITEMAP_DIR', 'NAVIGATION_ONLY', 'APP_ENV', 'UPSTREAM_API_URL'] as const;
const before = Object.fromEntries(keys.map(key => [key, process.env[key]]));
const directory = await mkdtemp(path.join(os.tmpdir(), 'huakui-discovery-'));
const originalFetch = globalThis.fetch;
process.env.SITEMAP_DIR = directory;
process.env.UPSTREAM_API_URL = 'https://discovery-fixture-' + (++sequence) + '.test';
process.env.APP_ENV = 'test';
delete process.env.NAVIGATION_ONLY;
try { await work(directory); }
finally {
globalThis.fetch = originalFetch;
for (const key of keys) {
if (before[key] === undefined) delete process.env[key]; else process.env[key] = before[key];
}
await rm(directory, { recursive: true, force: true });
}
}
const mockConfig = { config: { foreverUrlAddr: 'https://canonical.drama.test/' }, seoInfo: { webSiteName: '花魁短剧', description: '后端配置的介绍' } };
function mockUpstream() {
const calls: string[] = [];
globalThis.fetch = async (url, init) => {
const route = new URL(String(url)).pathname;
calls.push(route);
if (route === '/api/app/config/info') {
const headers = new Headers(init?.headers);
assert.equal(headers.has('cookie'), false);
assert.equal(headers.has('authorization'), false);
}
return Response.json({ code: 200, data: route === '/api/app/config/info' ? mockConfig : {} }, { headers: { 'Set-Cookie': 'public_upstream=must-not-escape; Path=/' } });
};
return calls;
}
test('robots and llms load cached public config and return original text headers without guest/session work', async () => {
await isolated(async () => {
const calls = mockUpstream();
for (const pathname of ['/robots.txt', '/llms.txt']) {
const ctx = createContext(request(pathname));
const response = await handleSEORequest(ctx);
assert.ok(response);
assert.equal(response.status, 200);
assert.equal(response.headers.get('content-type'), 'text/plain; charset=utf-8');
assert.equal(response.headers.get('cache-control'), null);
assert.equal(response.headers.get('etag'), null);
assert.deepEqual(ctx.responseHeaders.getSetCookie(), []);
const body = await response.text();
assert.ok(body.includes('https://canonical.drama.test/sitemap.xml'));
if (pathname === '/llms.txt') assert.ok(body.includes('后端配置的介绍'));
}
assert.deepEqual(calls, ['/api/app/config/info']);
const response = await handleDiscoveryFileRequest(request('/robots.txt', 'HEAD'));
assert.equal(response.status, 200);
assert.equal(await response.text(), '');
assert.ok(Number(response.headers.get('content-length')) > 0);
assert.deepEqual(response.headers.getSetCookie(), []);
});
});
test('first config failure still serves robots from request origin, never invokes guest auth', async () => {
await isolated(async () => {
const calls: string[] = [];
globalThis.fetch = async url => { calls.push(String(url)); throw new Error('fixture offline'); };
const response = await handleDiscoveryFileRequest(request('/robots.txt'));
assert.equal(response.status, 200);
assert.match(await response.text(), /Sitemap: https:\/\/public\.drama\.test\/sitemap\.xml/);
assert.deepEqual(response.headers.getSetCookie(), []);
assert.equal(calls.length, 1);
assert.ok(calls[0]?.endsWith('/api/app/config/info'));
});
});
test('sitemap keeps Go candidate order and all stat failures or directories fall through', async () => {
await isolated(async directory => {
const root = path.join(directory, 'runtime');
assert.deepEqual(sitemapCandidates('/sitemap.xml', root, ''), [path.join(root, 'template/pages/sitemaps/sitemap.xml'), path.join(root, 'template/pages/sitemap.xml')]);
assert.deepEqual(sitemapCandidates('/sitemap.xml', root, '/mounted/xml'), ['/mounted/xml/sitemap.xml', path.join(root, 'template/pages/sitemap.xml')]);
assert.deepEqual(sitemapCandidates('/sitemaps/sitemap-drama-1.xml', root, ''), [path.join(root, 'template/pages/sitemaps/sitemap-drama-1.xml')]);
await mkdir(path.join(directory, 'directory.xml'));
await writeFile(path.join(directory, 'empty.xml'), '');
await writeFile(path.join(directory, 'later.xml'), 'later');
await symlink('recursive.xml', path.join(directory, 'recursive.xml'));
assert.equal(await firstExistingFile([path.join(directory, 'missing'), path.join(directory, 'directory.xml'), path.join(directory, 'recursive.xml'), path.join(directory, 'empty.xml'), path.join(directory, 'later.xml')]), path.join(directory, 'empty.xml'));
assert.equal(await firstExistingFile([path.join(directory, 'missing'), path.join(directory, 'directory.xml')]), null);
});
});
test('sitemap returns original bytes, including old URLs, signed assets, empty and non-XML files', async () => {
await isolated(async directory => {
globalThis.fetch = async () => { throw new Error('File service must not call an API'); };
const xml = '\ufeff<?xml version="1.0"?>\r\n<urlset><url><loc>https://old.test/play/story/?sig=a%2Bb%3D&amp;x=2</loc></url></urlset>\r\n';
for (const bytes of [Buffer.from(xml), Buffer.from([0, 255, 128, 10]), Buffer.alloc(0)]) {
await writeFile(path.join(directory, 'sitemap.xml'), bytes);
assert.equal((await proxy(new NextRequest(request('/sitemap.xml')))).headers.get('x-middleware-next'), '1');
const response = await handleDiscoveryFileRequest(request('/sitemap.xml'));
assert.equal(response.status, 200);
assert.deepEqual(Buffer.from(await response.arrayBuffer()), bytes);
assert.equal(response.headers.get('content-type'), 'text/xml; charset=utf-8');
assert.equal(response.headers.get('content-length'), String(bytes.length));
assert.equal(response.headers.get('accept-ranges'), 'bytes');
assert.ok(response.headers.get('last-modified'));
assert.equal(response.headers.get('cache-control'), null);
assert.equal(response.headers.get('etag'), null);
assert.deepEqual(response.headers.getSetCookie(), []);
}
});
});
test('missing index never synthesizes static XML or discovers shards; missing and invalid shards use page 404', async () => {
await isolated(async directory => {
await writeFile(path.join(directory, 'sitemap-drama-1.xml'), '<urlset/>');
const calls = mockUpstream();
assert.equal(await handleSEORequest(createContext(request('/sitemap.xml'))), null);
const shard = await handleSEORequest(createContext(request('/sitemaps/sitemap-drama-1.xml')));
assert.equal(await shard?.text(), '<urlset/>');
assert.equal(calls.length, 0);
for (const pathname of ['/sitemap.xml', '/sitemaps', '/sitemaps/sitemap-missing.xml', '/sitemaps/secret.xml', '/sitemaps/sitemap-a.XML', '/sitemaps/sitemap-a.xml/secret']) {
const response = await proxy(new NextRequest(request(pathname)));
assert.equal(response.status, 404, pathname);
assert.match(response.headers.get('x-robots-tag') || '', /noindex/);
const forwarded = new Headers();
for (const [key, value] of response.headers) if (key.startsWith('x-middleware-request-')) forwarded.set(key.slice('x-middleware-request-'.length), value);
const page = await receivePage(forwarded);
assert.equal(page?.view, '404');
assert.equal(page?.data.canonicalURL, undefined);
assert.equal(page?.data.jsonLD, undefined);
assert.ok(page?.data.appConfig);
}
assert.ok(calls.includes('/api/app/auth/getInfo'), 'full 404 retains normal session initialization');
});
});
test('Gin single-segment matching, shard validation and trailing slash redirects preserve query', async () => {
await isolated(async directory => {
await writeFile(path.join(directory, 'sitemap-a.xml'), '<urlset/>');
for (const name of ['sitemap-a.xml', 'sitemap-0.xml', 'sitemap--.xml']) assert.equal(isValidSitemapShardName(name), true);
for (const name of ['sitemap-.xml', 'sitemap-A.xml', 'sitemap-a.XML', 'sitemap-a_1.xml', 'sitemap-a.xml/child']) assert.equal(isValidSitemapShardName(name), false);
assert.equal(isRegisteredSEOPath('/sitemaps/invalid.xml'), true);
assert.equal(isRegisteredSEOPath('/sitemaps/sitemap-a.xml/child'), false);
const encoded = await handleSEORequest(createContext(request('/sitemaps/sitemap-a%2Exml')));
assert.equal(await encoded?.text(), '<urlset/>');
assert.equal(await handleSEORequest(createContext(request('/sitemaps/sitemap-a%2F.xml'))), null);
assert.equal(await handleSEORequest(createContext(request('/sitemaps/sitemap-a%252Exml'))), null, 'Gin decodes exactly once');
for (const pathname of ['/robots.txt/', '/llms.txt/', '/sitemap.xml/', '/sitemaps/invalid.xml/', '/sitemap.xml%2F', '/site%6Daps/invalid.xml%2F']) {
for (const method of ['GET', 'HEAD']) {
const redirect = canonicalRedirect(createContext(request(pathname + '?signature=a%2Bb&x=1', method)));
assert.equal(redirect?.status, 301);
assert.equal(redirect?.headers.get('location'), decodeURIComponent(pathname).slice(0, -1) + '?signature=a%2Bb&x=1');
}
}
assert.equal(canonicalRedirect(createContext(request('/sitemap.xml/', 'POST'))), null);
});
});
test('Next matcher executes original discovery dispatch, but navigation-only exposes none of the Go SEO routes', async () => {
await isolated(async directory => {
const calls = mockUpstream();
await writeFile(path.join(directory, 'sitemap.xml'), '<urlset/>');
for (const pathname of ['/robots.txt', '/llms.txt', '/sitemap.xml', '/sitemaps/sitemap-a.xml']) assert.equal(doesProxyMatch({ config: proxyConfig, nextConfig, url: pathname }), true);
process.env.NAVIGATION_ONLY = 'true';
for (const pathname of ['/robots.txt', '/llms.txt', '/sitemap.xml', '/sitemaps/sitemap-a.xml']) {
assert.equal(await handleSEORequest(createContext(request(pathname))), null);
const response = await proxy(new NextRequest(request(pathname)));
assert.equal(response.status, 404);
assert.match(response.headers.get('x-robots-tag') || '', /noindex/);
}
assert.ok(calls.length > 0);
for (const pathname of ['/manifest.webmanifest', '/sw.js']) assert.equal(doesProxyMatch({ config: proxyConfig, nextConfig, url: pathname }), false);
});
});
test('HEAD 304 keeps the original absent length while text and file responses retain Go HSTS', async () => {
const ctx = createContext(request('/sitemap.xml', 'HEAD'));
const response = await finalizeResponse(ctx, new Response(null, { status: 304 }));
assert.equal(response.headers.get('content-length'), null);
assert.equal(response.headers.get('strict-transport-security'), 'max-age=31536000');
assert.equal(await response.text(), '');
});
test('new public page URLs preserve SEO policy, canonical pagination, structured document links and genuine media', () => {
const site = 'https://canonical.drama.test';
const config = { foreverUrlAddr: site, seoInfo: { title: '花魁短剧', webSiteName: '花魁短剧' } };
const data = enrichSEO(createContext(new Request(origin + '/dramas/all/0/1/page/2/')), {
appConfig: config,
pagination: { Visible: true, HasPrev: true, PrevURL: '/dramas/', HasNext: true, NextURL: '/dramas/all/0/1/page/3/' },
list: [{ id: 1, slug: 'story' }, { id: 2, slug: 'other' }],
});
assert.equal(data.canonicalURL, site + '/dramas/all/0/1/page/2/');
assert.equal(data.paginationPrevURL, site + '/dramas/');
assert.equal(data.paginationNextURL, site + '/dramas/all/0/1/page/3/');
assert.equal(toNextMetadata(data).alternates?.canonical, data.canonicalURL);
const graph = JSON.parse(data.jsonLD)['@graph'];
assert.equal(graph.find((node: any) => node['@type'] === 'ItemList').itemListElement[0].url, site + '/drama/story/');
const media = site + '/play/signed.m3u8?token=a%2Bb%3D';
const cover = site + '/play/signed.webp?token=a%2Bb%3D';
const playback = enrichSEO(createContext(new Request(origin + '/drama/story/')), { appConfig: config, playlet: { id: 1, slug: 'story', title: '故事', cover, onlineAt: '2026-10-12 12:00:00' }, chapter: { currentEpisode: 1, videoUrl: media } });
assert.equal(playback.canonicalURL, site + '/drama/story/');
const video = JSON.parse(playback.jsonLD)['@graph'].find((node: any) => node['@type'] === 'VideoObject');
assert.equal(video.contentUrl, media);
assert.deepEqual(video.thumbnailUrl, [cover]);
for (const pathname of ['/drama/story/2/', '/account/', '/account/settings/', '/navigation/']) {
const page = enrichSEO(createContext(new Request(origin + pathname)), { appConfig: config });
assert.match(page.robots, /noindex/);
assert.equal(page.canonicalURL, undefined);
}
assert.equal(pageRepresentsMissingResource(createContext(new Request(origin + '/drama/story/recommendations/page/1/')), {}), false);
});
+209
View File
@@ -0,0 +1,209 @@
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&amp;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));
});
+88
View File
@@ -0,0 +1,88 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createContext } from '../src/server/context';
import { enrichSEO, imageURL, toNextMetadata } from '../src/server/seo';
const site = 'https://canonical.drama.test';
const cdn = 'https://primary.images.test/assets/';
const bases = [cdn, 'https://backup.images.test/media/'];
const cover = 'v3/image/story.webp?ticket=a%2Bb%3D&path=%2Fcover';
const config = { foreverUrlAddr: site, seoInfo: { title: '花魁短剧', webSiteName: '花魁短剧' } };
const playlet = { id: 41, slug: 'story', title: '故事', cover, onlineAt: '2026-10-12 12:34:56', totalEpisode: 2 };
const chapter = { id: 401, currentEpisode: 1, videoUrl: 'https://video.drama.test/story.m3u8?sig=preserved' };
const context = (route: string) => createContext(new Request('http://internal:3000' + route));
const graph = (data: Record<string, any>) => JSON.parse(data.jsonLD)['@graph'] as Record<string, any>[];
const node = (data: Record<string, any>, type: string) => graph(data).find(item => item['@type'] === type);
test('detail, episode and series structured images resolve relative object keys on the configured CDN', () => {
const detail = enrichSEO(context('/play/story/'), { appConfig: config, imageCDNs: bases, detail: playlet });
assert.deepEqual(node(detail, 'TVSeries')?.image, [cdn + cover]);
assert.deepEqual(node(detail, 'TVSeries')?.thumbnailUrl, [cdn + cover]);
const episode = enrichSEO(context('/play/story/'), { appConfig: config, imageCDNs: bases, playlet, chapter });
for (const type of ['TVSeries', 'VideoObject', 'TVEpisode']) {
assert.deepEqual(node(episode, type)?.image, [cdn + cover], type);
}
assert.deepEqual(node(episode, 'VideoObject')?.thumbnailUrl, [cdn + cover]);
assert.equal(node(episode, 'VideoObject')?.contentUrl, chapter.videoUrl);
assert.equal(episode.canonicalURL, site + '/drama/story/');
assert.equal(episode.robots, 'index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1');
for (const video of [
{ id: 42, slug: 'film', title: '剧集', coverImg: cover, addedTime: '2026-10-12 10:00:00', video: { url: '/film.m3u8' } },
{ id: 42, slug: 'film', title: '剧集', addedTime: '2026-10-12 10:00:00', video: { cover, url: '/film.m3u8' } },
]) {
const series = enrichSEO(context('/series-play/film/'), { appConfig: config, imageCDNs: bases, video, canWatch: true });
assert.deepEqual(node(series, 'VideoObject')?.image, [cdn + cover]);
assert.deepEqual(node(series, 'VideoObject')?.thumbnailUrl, [cdn + cover]);
assert.equal(series.canonicalURL, site + '/series/watch/film/');
}
});
test('absolute signed media image URLs remain byte-for-byte unchanged in JSON-LD and metadata', () => {
const signed = cdn + 'v3/image/signed.webp?Signature=a%2Bb%3D&Policy=c%2Fd&Key-Pair-Id=fixture#preserved';
const data = enrichSEO(context('/play/story/'), { appConfig: config, imageCDNs: bases, playlet: { ...playlet, cover: signed }, chapter });
assert.deepEqual(node(data, 'TVSeries')?.image, [signed]);
assert.deepEqual(node(data, 'VideoObject')?.thumbnailUrl, [signed]);
const home = enrichSEO(context('/'), { appConfig: config, imageCDNs: bases, cover: signed });
assert.equal(home.ogImage, signed);
assert.deepEqual(toNextMetadata(home).openGraph?.images, [signed]);
assert.deepEqual(toNextMetadata(home).twitter?.images, [signed]);
});
test('non-static OG images share the CDN resolver while local/default playback OG images stay on the site', () => {
const home = enrichSEO(context('/'), { appConfig: config, imageCDNs: bases, cover });
assert.equal(home.ogImage, cdn + cover);
for (const data of [
enrichSEO(context('/'), { appConfig: config, imageCDNs: bases }),
enrichSEO(context('/play/story/'), { appConfig: config, imageCDNs: bases, playlet, chapter }),
enrichSEO(context('/series-play/film/'), { appConfig: config, imageCDNs: bases, cover, canWatch: true }),
]) {
const url = site + '/static/og-default.png?v=huakui-20261013';
assert.equal(data.ogImage, url);
const metadata = toNextMetadata(data);
const images = [{ url, width: 1200, height: 630, type: 'image/png', alt: '花魁短剧' }];
assert.deepEqual(metadata.openGraph?.images, images);
assert.deepEqual(metadata.twitter?.images, images);
assert.equal(metadata.twitter && 'card' in metadata.twitter ? metadata.twitter.card : undefined, 'summary_large_image');
}
assert.equal(imageURL(site, '/static/custom-og.png?v=1', bases), site + '/static/custom-og.png?v=1');
});
test('missing CDN configuration preserves site-root image paths independently of the page URL', () => {
const data = enrichSEO(context('/play/story/'), { appConfig: config, playlet, chapter });
assert.deepEqual(node(data, 'TVSeries')?.image, [site + '/' + cover]);
assert.deepEqual(node(data, 'VideoObject')?.thumbnailUrl, [site + '/' + cover]);
assert.equal(imageURL(site, '/' + cover), site + '/' + cover);
assert.equal(imageURL(site, cover, { imageCDNs: bases }), cdn + cover);
});
test('empty or unsafe image values never become a homepage or fabricated structured image', () => {
for (const value of ['', ' ', undefined, null, 'javascript:alert(1)', 'data:image/png;base64,AA==', 'blob:https://images.test/transient', '//unconfigured.images.test/cover.webp', 'https://user:password@images.test/cover.webp']) {
assert.equal(imageURL(site, value, bases), '', String(value));
const data = enrichSEO(context('/play/story/'), { appConfig: config, imageCDNs: bases, playlet: { ...playlet, cover: value }, chapter });
assert.equal(node(data, 'TVSeries')?.image, undefined, String(value));
assert.equal(node(data, 'TVSeries')?.thumbnailUrl, undefined, String(value));
assert.equal(node(data, 'VideoObject'), undefined, String(value));
}
});
+226
View File
@@ -0,0 +1,226 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import { createContext } from '../src/server/context';
import {
buildLLMSTXT, buildRobotsTXT, chineseNumber, DEFAULT_INDEXNOW_KEY,
enrichSEO, handleSEORequest, isIndexablePath, isPureAttributionQuery,
isValidSitemapShardName, isoDateTime, normalizeSEO, pageRepresentsMissingResource,
resolvePlayPageSEO, resolveSiteURL, sanitizeVerifyFile, serializeJSONLD,
videoContentURL,
} from '../src/server/seo';
const config = { foreverUrlAddr: 'https://canonical.drama.test/path?ignore=1', seoInfo: { title: '花魁首页', description: '首页描述', keywords: ['首页词'], webSiteName: '花魁短剧' } };
const ctx = (route: string) => createContext(new Request(`http://internal:3000${route}`, { headers: { 'x-forwarded-proto': 'https, http', 'x-forwarded-host': 'public.drama.test, edge.internal' } }));
test('download redirect falls back to newAddressInfo when config appAddr is empty', async () => {
const response = await handleSEORequest(ctx('/download-app/'), {
appAddr: '',
config: { appAddr: ' ', officialAppAddr: 'https://official.drama.test/' },
newAddressInfo: { appAddr: ' https://download.drama.test/install?channel=web ' },
});
assert.ok(response);
assert.equal(response.status, 302);
assert.equal(response.headers.get('location'), 'https://download.drama.test/install?channel=web');
assert.equal(response.headers.get('x-robots-tag'), 'noindex, nofollow');
assert.equal(response.headers.get('set-cookie'), null);
});
test('download redirect uses the first nonempty official address when all app addresses are empty', async () => {
const response = await handleSEORequest(ctx('/download-app/'), {
appAddr: '',
officialAppAddr: ' ',
config: { appAddr: '', officialAppAddr: '' },
newAddressInfo: { appAddr: ' ', officialAppAddr: ' https://official.drama.test/download ' },
});
assert.ok(response);
assert.equal(response.status, 302);
assert.equal(response.headers.get('location'), 'https://official.drama.test/download');
assert.equal(response.headers.get('x-robots-tag'), 'noindex, nofollow');
});
test('download redirect preserves the home fallback when no download address is configured', async () => {
const response = await handleSEORequest(ctx('/download-app/'), {
appAddr: '',
officialAppAddr: '',
config: { appAddr: ' ', officialAppAddr: ' ' },
newAddressInfo: { appAddr: '', officialAppAddr: '' },
});
assert.ok(response);
assert.equal(response.status, 302);
assert.equal(response.headers.get('location'), '/');
assert.equal(response.headers.get('x-robots-tag'), 'noindex, nofollow');
});
test('the exact public route whitelist keeps pagination, filters, episodes, private pages and tools distinct', () => {
const publicPaths = ['/', '/about/', '/help/', '/terms/', '/privacy/', '/2257/', '/dmca/', '/list/', '/list/theme/', '/list/all/1/1/', '/list/all/0/1/page/2/', '/list/theme/0/1/page/2/', '/series/', '/series/film/all/1/', '/series/all/1/1/', '/series/all/all/1/page/2/', '/topic/', '/topic/page/2/', '/topic-detail/hello/', '/topic-detail/hello/page/3/', '/tags/tag/', '/tags/tag/page/2/', '/rank/', '/rank/2/', '/rank/3/page/2/', '/popular-recommend/page/2/', '/more-list/editorial/', '/more-list/editorial/page/2/', '/more-series-list/1/', '/recently-added/page/2/', '/play/hello/', '/series-play/hello/'];
const excluded = ['/nav/', '/dh/', '/home-road/', '/return-home/', '/application/', '/search/a/', '/login/', '/profile/', '/feedback/', '/api/app/config/info', '/list/all/', '/list/all/0/1/', '/list/theme/1/2/', '/list/theme/3/1/', '/list/theme/0/1/page/1/', '/list/page/2/', '/series/all/all/1/', '/series/film/all/2/', '/series/unknown/all/1/', '/rank/1/', '/rank/page/1/', '/topic/sort/2/', '/topic-detail/x/sort/2/', '/tags/x/sort/2/', '/more-list/x/1/all/', '/play/hello/1/', '/play/hello/abc/', '/series-play/hello/1/', '/unknown/'];
for (const route of publicPaths) assert.equal(isIndexablePath(route), true, route);
for (const route of excluded) assert.equal(isIndexablePath(route), false, route);
});
test('home road metadata uses the current brand and removes canonical and structured markup', () => {
for (const route of ['/home-road/', '/return-home/']) {
const request = ctx(route);
const result = enrichSEO(request, { appConfig: config, title: '回家的路 - 花魁短剧官方最新地址发布页', canonicalURL: 'old', jsonLD: 'old' });
assert.match(result.metaTitle, /回家的路.*花魁短剧/, route);
assert.match(result.metaDescription, /最新地址|永久入口/, route);
assert.equal(result.robots, 'noindex,follow', route);
assert.equal(result.noindex, true, route);
assert.equal(result.canonicalURL, undefined, route);
assert.equal(result.jsonLD, undefined, route);
assert.equal(request.responseHeaders.get('x-robots-tag'), 'noindex, follow', route);
}
});
test('canonical resolves the dynamic permanent origin, slug, trailing slash and query-free path', () => {
const request = ctx('/play/123/?ch=affiliate#ignored');
const result = enrichSEO(request, { appConfig: config, seoInfo: { title: '作品', description: '剧情介绍' }, playlet: { id: 123, slug: '中文作品', title: '作品' }, chapter: { currentEpisode: 1 } });
assert.equal(result.siteURL, 'https://canonical.drama.test');
assert.equal(result.canonicalURL, 'https://canonical.drama.test/drama/%E4%B8%AD%E6%96%87%E4%BD%9C%E5%93%81/');
assert.equal(result.metaTitle, '作品 | 花魁短剧');
assert.equal(result.metaDescription, '剧情介绍 | 花魁短剧');
assert.equal(result.ogImage, 'https://canonical.drama.test/static/og-default.png?v=huakui-20261013');
assert.equal(resolveSiteURL(request, { foreverUrlAddr: 'https://example.com' }), 'https://public.drama.test');
assert.equal(resolveSiteURL(request, { foreverUrlAddr: 'canonical.drama.test/some/path' }), 'https://canonical.drama.test');
});
test('navigation SEO wins its actual route, field aliases decode, and generic home TDK is differentiated', () => {
const result = enrichSEO(ctx('/list/theme/0/1/page/2/'), {
appConfig: config,
seoInfo: JSON.stringify({ title: '花魁首页', description: '首页描述', keywords: '首页词' }),
categoryList: [{ name: '短剧', urlValue: '/playlet', categoryType: 1, seoInfo: { title: '分类专属标题', description: '分类介绍', keyworld: ['分类词'] } }],
seoSelections: [{ Dimension: '题材', Value: '都市' }, { dimension: '题材', value: '都市' }],
pagination: { Page: 2, Visible: true, HasPrev: true, PrevURL: '/list/theme/', HasNext: true, NextURL: '/list/theme/0/1/page/3/' },
list: [{ id: 1, slug: 'one' }, { id: 2, slug: 'two' }], totalCount: 100, pageNum: 2, pageSize: 24,
});
assert.equal(result.metaTitle, '分类专属标题 - 都市 - 第2页');
assert.equal(result.metaDescription, '分类介绍 - 都市 - 第2页');
assert.equal(result.metaKeywords, '分类词,都市,第2页');
assert.equal(result.canonicalURL, 'https://canonical.drama.test/dramas/theme/0/1/page/2/');
assert.equal(result.paginationPrevURL, 'https://canonical.drama.test/dramas/theme/');
assert.equal(result.paginationNextURL, 'https://canonical.drama.test/dramas/theme/0/1/page/3/');
const generic = enrichSEO(ctx('/rank/'), { appConfig: config, seoInfo: config.seoInfo });
assert.equal(generic.metaTitle, '短剧排行榜 - 花魁短剧');
assert.notEqual(generic.metaDescription, config.seoInfo.description);
assert.deepEqual(normalizeSEO({ webTitle: ' 标题 ', webDescription: ' 描述 ', webKeyworld: '词一,词二;词一', canonical: 'https://canonical.drama.test' }).keywords, ['词一', '词二']);
});
test('episodes, inaccessible series, nav and error pages remove canonical and structured markup', () => {
for (const [route, extra, status] of [
['/play/hello/2/', {}, 200], ['/series-play/hello/', { canWatch: false }, 200], ['/nav/', { isNavPage: true }, 200], ['/list/', {}, 404], ['/list/theme/', { seoEntityValid: false }, 200],
] as const) {
const request = ctx(route); const result = enrichSEO(request, { appConfig: config, ...extra, canonicalURL: 'old', jsonLD: 'old' }, { status });
assert.equal(result.robots, 'noindex,follow', route);
assert.equal(result.noindex, true, route);
assert.equal(result.canonicalURL, undefined, route);
assert.equal(result.jsonLD, undefined, route);
assert.equal(request.responseHeaders.get('x-robots-tag'), 'noindex, follow');
}
const account = enrichSEO(ctx('/profile/'), { appConfig: config });
assert.equal(account.robots, 'noindex,nofollow');
});
test('entity and pagination rules detect real 404 conditions independently of noindex', () => {
assert.equal(isIndexablePath('/list/all/0/1/page/%2B2/'), true);
assert.equal(pageRepresentsMissingResource(ctx('/list/all/0/1/page/%2B2/'), { pageSize: 24, totalCount: 100 }), false);
assert.equal(pageRepresentsMissingResource(ctx('/list/all/0/1/page/2e0/'), { pageSize: 24, totalCount: 100 }), true);
assert.equal(pageRepresentsMissingResource(ctx('/list/all/0/1/page/2.0/'), { pageSize: 24, totalCount: 100 }), true);
assert.equal(pageRepresentsMissingResource(ctx('/list/all/0/1/page/1/'), {}), true);
assert.equal(pageRepresentsMissingResource(ctx('/topic/page/nope/'), {}), true);
assert.equal(pageRepresentsMissingResource(ctx('/topic/page/3/'), { totalCount: 25, pageSize: 12 }), false);
assert.equal(pageRepresentsMissingResource(ctx('/topic/page/4/'), { totalCount: 25, pageSize: 12 }), true);
assert.equal(pageRepresentsMissingResource(ctx('/topic/'), { pageNum: 1, totalCount: 0, pageSize: 12 }), false);
assert.equal(pageRepresentsMissingResource(ctx('/play/missing/'), { seoEntityValid: false }), true);
assert.equal(pageRepresentsMissingResource(ctx('/play/story/recommendations/page/1/'), {}), false);
});
test('VideoObject uses genuine metadata, safe media URLs, UTC+8 dates and original series relationships', () => {
const playlet = { id: 1, slug: 'story', title: '故事', desc: '故事剧情', cover: '/covers/story.jpg', onlineAt: '2026-10-10 12:34:56', tagNames: ['都市'], totalEpisode: 10 };
const chapter = { currentEpisode: 1, mediaTime: 120, videoUrl: 'hash-is-not-media', countBrowse: 123, width: 1920, height: 1080, mediaSize: 50000 };
const result = enrichSEO(ctx('/play/story/'), { appConfig: config, playlet, chapter, seoInfo: resolvePlayPageSEO(chapter, playlet) });
const graph = JSON.parse(result.jsonLD)['@graph'];
const video = graph.find((node: any) => node['@type'] === 'VideoObject');
assert.equal(video.uploadDate, '2026-10-10T12:34:56+08:00');
assert.equal(video.contentUrl, undefined);
assert.equal(video.thumbnailUrl[0], 'https://canonical.drama.test/covers/story.jpg');
assert.equal(video.duration, 'PT120S');
assert.equal(video.contentSize, '50000 bytes');
assert.equal(video.interactionStatistic.userInteractionCount, 123);
assert.equal(video.isFamilyFriendly, false);
assert.equal(graph.find((node: any) => node['@type'] === 'TVEpisode').partOfSeries['@id'], result.canonicalURL + '#tvseries');
const missingDate = enrichSEO(ctx('/play/story/'), { appConfig: config, playlet: { ...playlet, onlineAt: '' }, chapter });
assert.equal(JSON.parse(missingDate.jsonLD)['@graph'].some((node: any) => node['@type'] === 'VideoObject'), false);
assert.equal(videoContentURL('https://canonical.drama.test', 'state-token'), '');
assert.equal(isoDateTime('1970-01-01 00:00:00'), '');
assert.equal(isoDateTime('2026-09-21T08:57:07.11Z'), '2026-09-21T08:57:07Z');
assert.equal(isoDateTime('2026-09-21T08:57:07.11+00:00'), '2026-09-21T08:57:07Z');
assert.equal(chineseNumber(102), '一百零二');
assert.equal(chineseNumber(12), '十二');
});
test('list structured data describes SSR links and real pagination positions; FAQ matches visible data', () => {
const result = enrichSEO(ctx('/rank/page/2/'), { appConfig: config, list: [{ id: 1, slug: 'one' }, { id: 1, slug: 'one' }, { id: 2, slug: 'two' }], rankOffset: 40, faqItems: [{ Question: '如何播放?', Answer: '选择作品播放。' }] });
const graph = JSON.parse(result.jsonLD)['@graph'];
const items = graph.find((node: any) => node['@type'] === 'ItemList');
assert.equal(items.numberOfItems, 2);
assert.equal(items.itemListElement[0].position, 41);
assert.equal(items.itemListElement[0].url, 'https://canonical.drama.test/drama/one/');
const faq = graph.find((node: any) => node['@type'] === 'FAQPage');
assert.equal(faq.mainEntity[0].acceptedAnswer.text, '选择作品播放。');
const raw = serializeJSONLD({ text: '</script><script>alert(1)</script>&\u2028' });
assert.equal(raw.includes('</script>'), false);
assert.equal(JSON.parse(raw).text, '</script><script>alert(1)</script>&\u2028');
});
test('robots and llms retain discovery links and preserve noindex page crawlability', () => {
const body = buildRobotsTXT('https://canonical.drama.test');
assert.match(body, /Sitemap: https:\/\/canonical\.drama\.test\/sitemap\.xml/);
assert.match(body, /Host: canonical\.drama\.test/);
assert.match(body, /Disallow: \/api\//);
assert.doesNotMatch(body, /Disallow: \/(?:search|login|profile)/);
assert.match(buildLLMSTXT('https://canonical.drama.test', config), /\[今日更新\]\(https:\/\/canonical\.drama\.test\/latest\/\)/);
assert.equal(sanitizeVerifyFile('../baidu_verify_x.html'), 'baidu_verify_x.html');
assert.equal(sanitizeVerifyFile('unsafe..html'), '');
assert.equal(sanitizeVerifyFile('unsafe.js'), '');
});
test('attribution redirects accept only pure unique valid attribution codes and never mutate cookies', async () => {
for (const query of ['dc=a', 'ch=a&pc=b', 'pc=0', 'dc=abc_.-', 'dc=abc%2E']) assert.equal(isPureAttributionQuery(query), true, query);
for (const query of ['', 'dc=a&dc=b', 'dc=', 'dc=a&q=test', 'dc=a%zz', 'dc=a;b', 'pc=' + 'x'.repeat(51), 'dc=a%2Fb']) assert.equal(isPureAttributionQuery(query), false, query);
const request = ctx('/?ch=channel');
const response = await handleSEORequest(request);
assert.equal(response?.status, 302);
assert.equal(response?.headers.get('location'), '/');
assert.equal(response?.headers.get('cache-control'), 'no-store');
assert.equal(request.responseHeaders.getSetCookie().length, 0);
assert.equal(await handleSEORequest(ctx('/?ch=a&q=foo')), null);
});
test('backend sitemap files and IndexNow stay available; missing shards continue to the full HTML 404', async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), 'huakui-seo-'));
const oldDir = process.env.SITEMAP_DIR;
process.env.SITEMAP_DIR = dir;
const xml = '<?xml version="1.0"?><sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><sitemap><loc>https://canonical.drama.test/sitemaps/sitemap-playletdetail-1.xml</loc></sitemap></sitemapindex>';
try {
await writeFile(path.join(dir, 'sitemap.xml'), xml);
await writeFile(path.join(dir, 'sitemap-playletdetail-1.xml'), '<urlset/>');
const index = await handleSEORequest(ctx('/sitemap.xml'));
assert.equal(index?.status, 200); assert.equal(await index?.text(), xml);
const shard = await handleSEORequest(ctx('/sitemaps/sitemap-playletdetail-1.xml'));
assert.equal(await shard?.text(), '<urlset/>');
const missing = await handleSEORequest(ctx('/sitemaps/sitemap-missing.xml'));
assert.equal(missing, null);
assert.equal(isValidSitemapShardName('../../secret.xml'), false);
assert.equal(isValidSitemapShardName('sitemap-foo.XML'), false);
assert.equal(isValidSitemapShardName('sitemap-playletdetail-1.xml'), true);
const key = await handleSEORequest(ctx('/' + DEFAULT_INDEXNOW_KEY + '.txt'));
assert.equal(await key?.text(), DEFAULT_INDEXNOW_KEY);
assert.equal(await handleSEORequest(ctx('/not-configured-indexnow.txt')), null);
} finally {
await rm(dir, { recursive: true, force: true });
if (oldDir === undefined) delete process.env.SITEMAP_DIR; else process.env.SITEMAP_DIR = oldDir;
}
});
+253
View File
@@ -0,0 +1,253 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, readFileSync } from 'node:fs';
import { lstat, mkdir, mkdtemp, readFile, readlink, rm, stat, symlink, writeFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { tmpdir } from 'node:os';
import { spawnSync } from 'node:child_process';
import { parse } from 'dotenv';
const script = readFileSync(new URL('../deploy/up_next_h5.sh', import.meta.url), 'utf8');
const program = script.match(/# BEGIN preserve backend sitemaps[\s\S]*?<<'NODE'\n([\s\S]*?)\nNODE\n\)/)?.[1];
assert.ok(program, 'deploy script must expose the actual pre-switch sitemap preservation step');
async function fixture() {
const root = await mkdtemp(join(tmpdir(), 'huakui-sitemap-deploy-'));
const current = join(root, 'next'), staging = join(root, 'staging'), original = join(root, 'go');
for (const directory of [current, original, join(staging, 'config'), join(staging, 'node_modules')]) await mkdir(directory, { recursive: true });
await symlink(resolve('node_modules/dotenv'), join(staging, 'node_modules/dotenv'), 'dir');
await writeFile(join(staging, 'config/test.env'), 'SITEMAP_DIR=./template/pages/sitemaps\n');
const put = async (file: string, contents: string | Buffer) => { await mkdir(resolve(file, '..'), { recursive: true }); await writeFile(file, contents); };
const run = (configured?: string) => {
const env = { ...process.env }; delete env.SITEMAP_DIR;
if (configured !== undefined) env.SITEMAP_DIR = configured;
const result = spawnSync(process.execPath, ['--input-type=module', '-', current, staging, original], { input: program, cwd: staging, env, encoding: 'utf8' });
assert.equal(result.status, 0, result.stderr);
};
return { root, current, staging, original, put, run, close: () => rm(root, { recursive: true, force: true }) };
}
test('development uses a local snapshot while deployed Next reads the backend parent directory', () => {
for (const environment of ['development', 'test', 'production']) {
const config = parse(readFileSync(new URL(`../config/${environment}.env`, import.meta.url)));
assert.equal(config.SITEMAP_DIR, environment === 'development' ? './template/pages/sitemaps' : '../template/pages/sitemaps', environment);
assert.equal(Object.hasOwn(config, 'PUBLIC_SITE_URL'), false, environment);
assert.equal(Object.hasOwn(config, 'SITEMAP_UPSTREAM_URL'), false, environment);
}
});
test('deployment preserves original XML bytes and fallback index over packaged files', async () => {
const f = await fixture();
try {
const xml = Buffer.from('\ufeff<?xml version="1.0"?>\r\n<urlset><url><loc>https://old.test/play/a/</loc><priority>0.80</priority></url></urlset>\r\n');
await f.put(join(f.current, 'template/pages/sitemaps/sitemap.xml'), xml);
await f.put(join(f.current, 'template/pages/sitemaps/sitemap-playlet-1.xml'), xml);
await f.put(join(f.current, 'template/pages/sitemaps/sitemap-empty.xml'), Buffer.alloc(0));
await f.put(join(f.current, 'template/pages/sitemap.xml'), 'original second index');
await f.put(join(f.current, 'template/pages/robots.txt'), 'User-agent: Custom\r\nDisallow: /private/\r\n');
await f.put(join(f.staging, 'template/pages/sitemaps/sitemap.xml'), 'stale build copy');
f.run();
assert.deepEqual(await readFile(join(f.staging, 'template/pages/sitemaps/sitemap.xml')), xml);
assert.deepEqual(await readFile(join(f.staging, 'template/pages/sitemaps/sitemap-playlet-1.xml')), xml);
assert.equal((await readFile(join(f.staging, 'template/pages/sitemaps/sitemap-empty.xml'))).length, 0);
assert.equal(await readFile(join(f.staging, 'template/pages/sitemap.xml'), 'utf8'), 'original second index');
assert.equal(await readFile(join(f.staging, 'template/pages/robots.txt'), 'utf8'), 'User-agent: Custom\r\nDisallow: /private/\r\n');
assert.deepEqual(await readFile(join(f.current, 'template/pages/sitemaps/sitemap.xml')), xml);
} finally { await f.close(); }
});
test('deployment migrates the previous data directory and can copy the original Go output on first release', async () => {
for (const previous of ['legacy', 'go'] as const) {
const f = await fixture();
try {
const source = previous === 'legacy' ? join(f.current, 'data/sitemaps') : join(f.original, 'template/pages/sitemaps');
await f.put(join(source, 'sitemap.xml'), previous);
await f.put(join(source, 'sitemap-series-1.xml'), previous + '-shard');
f.run();
assert.equal(await readFile(join(f.staging, 'template/pages/sitemaps/sitemap.xml'), 'utf8'), previous);
assert.equal(await readFile(join(f.staging, 'template/pages/sitemaps/sitemap-series-1.xml'), 'utf8'), previous + '-shard');
assert.equal(await readFile(join(source, 'sitemap.xml'), 'utf8'), previous);
} finally { await f.close(); }
}
});
test('an existing empty Go directory remains empty instead of reviving stale legacy or packaged XML', async () => {
const f = await fixture();
try {
await mkdir(join(f.current, 'template/pages/sitemaps'), { recursive: true });
await f.put(join(f.current, 'data/sitemaps/sitemap.xml'), 'old removed index');
await f.put(join(f.staging, 'template/pages/sitemaps/sitemap.xml'), 'old packaged index');
f.run();
assert.equal(existsSync(join(f.staging, 'template/pages/sitemaps/sitemap.xml')), false);
assert.equal(await readFile(join(f.current, 'data/sitemaps/sitemap.xml'), 'utf8'), 'old removed index');
} finally { await f.close(); }
});
test('deployment preserves custom relative and in-release absolute directory mappings', async () => {
for (const absolute of [false, true]) {
const f = await fixture();
try {
const source = join(f.current, 'runtime/xml');
await f.put(join(source, 'sitemap.xml'), 'custom source');
await f.put(join(source, 'application.js'), 'do not copy unrelated files');
const configured = absolute ? source : './runtime/xml';
await writeFile(join(f.staging, 'config/test.local.env'), 'SITEMAP_DIR=' + configured + '\n');
f.run();
assert.equal(await readFile(join(f.staging, 'runtime/xml/sitemap.xml'), 'utf8'), 'custom source');
assert.equal(existsSync(join(f.staging, 'runtime/xml/application.js')), false);
} finally { await f.close(); }
}
});
test('deployment leaves external backend directories untouched and preserves default mount symlinks', async () => {
const f = await fixture();
try {
const external = join(f.root, 'backend-owned');
await f.put(join(external, 'sitemap.xml'), 'backend continues writing here');
await f.put(join(f.current, 'template/pages/sitemaps/sitemap.xml'), 'unused local snapshot');
f.run(external);
assert.equal(existsSync(join(f.staging, 'template/pages/sitemaps')), false);
assert.equal(await readFile(join(external, 'sitemap.xml'), 'utf8'), 'backend continues writing here');
await rm(join(f.current, 'template/pages/sitemaps'), { recursive: true });
await symlink(external, join(f.current, 'template/pages/sitemaps'), 'dir');
f.run();
assert.equal((await lstat(join(f.staging, 'template/pages/sitemaps'))).isSymbolicLink(), true);
assert.equal(await readlink(join(f.staging, 'template/pages/sitemaps')), external);
assert.equal(await readFile(join(external, 'sitemap.xml'), 'utf8'), 'backend continues writing here');
} finally { await f.close(); }
});
test('the deployed parent-relative default keeps the live backend directory external to each frontend release', async () => {
const f = await fixture();
try {
const backend = join(f.root, 'template/pages/sitemaps');
const xml = Buffer.from('\ufeff<?xml version="1.0"?>\r\n<urlset><url><loc>https://original.test/play/backend/</loc></url></urlset>\r\n');
await f.put(join(backend, 'sitemap.xml'), xml);
await f.put(join(backend, 'sitemap-pages.xml'), xml);
await f.put(join(f.current, 'template/pages/sitemaps/sitemap.xml'), 'unused release-local copy');
await writeFile(join(f.staging, 'config/test.env'), readFileSync(new URL('../config/test.env', import.meta.url)));
const config = parse(await readFile(join(f.staging, 'config/test.env')));
assert.equal(config.SITEMAP_DIR, '../template/pages/sitemaps');
assert.equal(resolve(f.current, config.SITEMAP_DIR!), backend);
assert.equal(resolve(f.staging, config.SITEMAP_DIR!), backend);
const before = await stat(join(backend, 'sitemap.xml'));
f.run();
assert.deepEqual(await readFile(join(backend, 'sitemap.xml')), xml);
assert.deepEqual(await readFile(join(backend, 'sitemap-pages.xml')), xml);
assert.equal((await stat(join(backend, 'sitemap.xml'))).mtimeMs, before.mtimeMs);
assert.equal(existsSync(join(f.staging, 'template/pages/sitemaps')), false);
assert.equal(existsSync(join(f.staging, 'data/sitemaps')), false);
assert.equal(await readFile(join(f.current, 'template/pages/sitemaps/sitemap.xml'), 'utf8'), 'unused release-local copy');
} finally { await f.close(); }
});
test('release-local symlink targets are preserved so the mounted sitemap does not become dangling', async () => {
const f = await fixture();
try {
await f.put(join(f.current, 'data/sitemaps/sitemap.xml'), 'linked index');
await mkdir(join(f.current, 'template/pages'), { recursive: true });
await symlink('../../data/sitemaps', join(f.current, 'template/pages/sitemaps'), 'dir');
f.run();
assert.equal(await readlink(join(f.staging, 'template/pages/sitemaps')), '../../data/sitemaps');
assert.equal(await readFile(join(f.staging, 'template/pages/sitemaps/sitemap.xml'), 'utf8'), 'linked index');
assert.equal(await readFile(join(f.current, 'data/sitemaps/sitemap.xml'), 'utf8'), 'linked index');
} finally { await f.close(); }
});
test('standalone packaging refuses to remove a configured backend sitemap inside replaceable output', async () => {
const root = await mkdtemp(join(tmpdir(), 'huakui-sitemap-package-'));
try {
for (const directory of ['scripts', 'config', 'dist/test/backend']) await mkdir(join(root, directory), { recursive: true });
await symlink(resolve('node_modules'), join(root, 'node_modules'), 'dir');
await writeFile(join(root, 'package.json'), '{"type":"module"}');
await writeFile(join(root, 'scripts/package-standalone.ts'), readFileSync(new URL('../scripts/package-standalone.ts', import.meta.url)));
await writeFile(join(root, 'config/test.env'), 'SITEMAP_DIR=./template/pages/sitemaps\n');
const backend = join(root, 'dist/test/backend');
await writeFile(join(backend, 'sitemap.xml'), 'must survive packaging');
const result = spawnSync(resolve('node_modules/.bin/tsx'), [join(root, 'scripts/package-standalone.ts'), 'test'], { cwd: root, env: { ...process.env, SITEMAP_DIR: backend }, encoding: 'utf8' });
assert.notEqual(result.status, 0);
assert.match(result.stderr, /SITEMAP_DIR must live outside the replaceable standalone package directory/);
assert.equal(await readFile(join(backend, 'sitemap.xml'), 'utf8'), 'must survive packaging');
} finally { await rm(root, { recursive: true, force: true }); }
});
async function packageFixture() {
const root = await mkdtemp(join(tmpdir(), 'huakui-sitemap-bundle-'));
const standalone = join(root, '.next-test/standalone'), output = join(root, 'dist/test'), backend = join(root, 'backend');
const put = async (file: string, contents: string | Buffer) => {
await mkdir(resolve(file, '..'), { recursive: true });
await writeFile(file, contents);
};
for (const directory of ['scripts', 'config', 'public', '.next-test/static', '.next-test/standalone', 'backend']) await mkdir(join(root, directory), { recursive: true });
await symlink(resolve('node_modules'), join(root, 'node_modules'), 'dir');
await put(join(root, 'package.json'), '{"type":"module"}');
await put(join(root, 'scripts/package-standalone.ts'), readFileSync(new URL('../scripts/package-standalone.ts', import.meta.url)));
await put(join(root, 'config/test.env'), 'APP_ENV=test\nSITEMAP_DIR=../template/pages/sitemaps\nUPSTREAM_API_URL=http://127.0.0.1:1\n');
await put(join(root, '.next-test/BUILD_ID'), 'fixture-build');
await put(join(root, '.next-test/static/app.js'), 'fixture-static');
await put(join(standalone, 'server.js'), '// fixture server, never executed');
const run = () => spawnSync(resolve('node_modules/.bin/tsx'), [join(root, 'scripts/package-standalone.ts'), 'test'], {
cwd: root, env: { ...process.env, SITEMAP_DIR: backend }, encoding: 'utf8',
});
return { root, standalone, output, backend, put, run, close: () => rm(root, { recursive: true, force: true }) };
}
test('standalone packaging removes Proxy-traced XML copies while retaining backend bytes, robots, media keys and unknown files', async () => {
const f = await packageFixture();
try {
const xml = Buffer.from('\ufeff<?xml version="1.0"?>\r\n<urlset><url><loc>https://original.test/play/untouched/</loc></url></urlset>\r\n');
await f.put(join(f.backend, 'sitemap.xml'), xml);
for (const path of ['template/pages/sitemaps/sitemap.xml', 'template/pages/sitemaps/sitemap-pages.xml', 'data/sitemaps/sitemap.xml', 'data/sitemaps/sitemap-seriesplay.xml', 'template/pages/sitemap.xml']) await f.put(join(f.standalone, path), xml);
await symlink(join(f.backend, 'sitemap.xml'), join(f.standalone, 'data/sitemaps/sitemap-linked.xml'));
await f.put(join(f.standalone, 'template/pages/robots.txt'), 'User-agent: Preserved\r\n');
await f.put(join(f.standalone, 'config/secrets/m3u8.key'), 'fixture-media-key');
await f.put(join(f.standalone, 'template/pages/sitemaps/README.md'), 'preserve documentation');
await f.put(join(f.standalone, 'data/sitemaps/custom.xml'), 'unknown XML is not generated sitemap');
await f.put(join(f.standalone, 'data/sitemaps/application.js'), 'preserve unknown application data');
const before = await stat(join(f.backend, 'sitemap.xml'));
const result = f.run();
assert.equal(result.status, 0, result.stderr);
for (const path of ['template/pages/sitemaps/sitemap.xml', 'template/pages/sitemaps/sitemap-pages.xml', 'data/sitemaps/sitemap.xml', 'data/sitemaps/sitemap-seriesplay.xml', 'data/sitemaps/sitemap-linked.xml', 'template/pages/sitemap.xml']) assert.equal(existsSync(join(f.output, path)), false, path);
assert.deepEqual(await readFile(join(f.backend, 'sitemap.xml')), xml);
assert.equal((await stat(join(f.backend, 'sitemap.xml'))).mtimeMs, before.mtimeMs);
assert.deepEqual(await readFile(join(f.standalone, 'template/pages/sitemaps/sitemap.xml')), xml);
assert.equal((await lstat(join(f.standalone, 'data/sitemaps/sitemap-linked.xml'))).isSymbolicLink(), true);
assert.equal(await readFile(join(f.output, 'template/pages/robots.txt'), 'utf8'), 'User-agent: Preserved\r\n');
assert.equal(await readFile(join(f.output, 'config/secrets/m3u8.key'), 'utf8'), 'fixture-media-key');
assert.equal(await readFile(join(f.output, 'template/pages/sitemaps/README.md'), 'utf8'), 'preserve documentation');
assert.equal(await readFile(join(f.output, 'data/sitemaps/custom.xml'), 'utf8'), 'unknown XML is not generated sitemap');
assert.equal(await readFile(join(f.output, 'data/sitemaps/application.js'), 'utf8'), 'preserve unknown application data');
assert.ok(existsSync(join(f.output, '.next-test/static/app.js')));
assert.ok(existsSync(join(f.output, 'start.mjs')));
} finally { await f.close(); }
});
test('standalone XML cleanup refuses every symlinked parent instead of following a backend mount', async () => {
for (const suffix of ['template', 'template/pages', 'template/pages/sitemaps', 'data', 'data/sitemaps']) {
const f = await packageFixture();
try {
const bytes = Buffer.from('backend XML remains untouched: ' + suffix);
await f.put(join(f.backend, 'sitemap.xml'), bytes);
await mkdir(resolve(join(f.standalone, suffix), '..'), { recursive: true });
await symlink(f.backend, join(f.standalone, suffix), 'dir');
const result = f.run();
assert.notEqual(result.status, 0, suffix);
assert.match(result.stderr, /Sitemap cleanup requires ordinary directories inside the standalone package/);
assert.deepEqual(await readFile(join(f.backend, 'sitemap.xml')), bytes);
assert.equal((await lstat(join(f.standalone, suffix))).isSymbolicLink(), true);
} finally { await f.close(); }
}
});
test('standalone XML cleanup refuses sitemap-named directories before deleting any copied file', async () => {
const f = await packageFixture();
try {
await f.put(join(f.standalone, 'template/pages/sitemaps/sitemap.xml'), 'copied index');
await mkdir(join(f.standalone, 'data/sitemaps/sitemap-pages.xml'), { recursive: true });
const result = f.run();
assert.notEqual(result.status, 0);
assert.match(result.stderr, /Packaged sitemap XML must be a file/);
assert.equal(await readFile(join(f.output, 'template/pages/sitemaps/sitemap.xml'), 'utf8'), 'copied index');
assert.equal((await stat(join(f.output, 'data/sitemaps/sitemap-pages.xml'))).isDirectory(), true);
} finally { await f.close(); }
});
+174
View File
@@ -0,0 +1,174 @@
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&amp;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/**/*"));
});