- 原生 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>
230 lines
14 KiB
TypeScript
230 lines
14 KiB
TypeScript
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);
|
|
});
|