[Ai] feat: 花魁短剧 Next.js 站点初始提交
- 原生 Next.js App Router + 严格 TypeScript 实现花魁短剧 PC/H5 页面,按 src/features/{catalog,content,playback} 组织,保留完整 SEO(metadata、JSON-LD、canonical、robots、llms、sitemap、IndexNow)。
- 主域名通过 SITE_URL=https://huakuidj.com 强制覆盖后端 foreverUrlAddr(config/*.env、src/server/config.ts、src/server/seo/shared.ts、src/server/services/shared.ts),支持邮箱改为 support@huakuidj.com。
- 移除登录与账户体系:删除 /account 全部页面、登录/注册/充值弹窗、收藏点赞、评论、金币解锁及对应 BFF 接口;旧账户地址 GET/HEAD 统一 301 到首页(src/lib/routes.ts REMOVED_ACCOUNT_PATHS),保留游客会话、反馈、图片上传、观看上报与搜索联想。
- 播放页:评论位改为同类推荐 + 热播榜单,锁定集改为 App 观看遮罩与底部弹层,新增简介标签、分集图例与独立视觉皮肤(src/features/playback、src/styles/pages/playback.css)。
- 卡片:短剧/视频卡状态与时长移至信息行胶囊标签,封面角标移至左上角(src/components/catalog/Cards.tsx、src/styles/foundations/base.css)。
- 同步更新单元测试、浏览器冒烟脚本、路由清单与 README。
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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/");
|
||||
}
|
||||
});
|
||||
@@ -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(), []);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;}
|
||||
});
|
||||
@@ -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, /暂无剧集/);
|
||||
});
|
||||
@@ -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
@@ -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"
|
||||
]
|
||||
]
|
||||
+1
File diff suppressed because one or more lines are too long
+1116
File diff suppressed because it is too large
Load Diff
+1
File diff suppressed because one or more lines are too long
+1343
File diff suppressed because it is too large
Load Diff
+1
File diff suppressed because one or more lines are too long
+1290
File diff suppressed because it is too large
Load Diff
+1
File diff suppressed because one or more lines are too long
+1349
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
+1190
File diff suppressed because it is too large
Load Diff
+1
@@ -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
@@ -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>花魁 & "标题" <test></title>
|
||||
|
||||
<meta name="description" content="短剧'说明 & 安全">
|
||||
|
||||
<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="花魁 & "标题" <test>">
|
||||
<meta property="og:description" content="短剧'说明 & 安全">
|
||||
<meta property="og:url" content="https://drama.example/tags/">
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="花魁 & "标题" <test>">
|
||||
<meta name="twitter:description" content="短剧'说明 & 安全">
|
||||
|
||||
<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&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/">花魁 & 第一部</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 <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 岁以上成年人 </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>
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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;}
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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 = ''; }
|
||||
});
|
||||
@@ -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("&", "&"));
|
||||
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"/);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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"), "/");
|
||||
});
|
||||
@@ -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);|"|&|<|>/g,
|
||||
(entity) =>
|
||||
({
|
||||
""": '"',
|
||||
"'": "'",
|
||||
"'": "'",
|
||||
""": '"',
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
})[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, /花魁 & 第一部/);
|
||||
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, /<script>alert\("x"\)<\/script>/);
|
||||
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="<unsafe>"/);
|
||||
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);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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, "");
|
||||
});
|
||||
@@ -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&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);
|
||||
});
|
||||
@@ -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&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));
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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&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/**/*"));
|
||||
});
|
||||
Reference in New Issue
Block a user