Files
web_huakui_web/tests/seo-discovery.test.ts
谢宇宁andClaude Opus 5.5 d2ddb1c321 [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>
2026-10-16 11:33:31 +07:00

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&amp;x=2</loc></url></urlset>\r\n';
for (const bytes of [Buffer.from(xml), Buffer.from([0, 255, 128, 10]), Buffer.alloc(0)]) {
await writeFile(path.join(directory, 'sitemap.xml'), bytes);
assert.equal((await proxy(new NextRequest(request('/sitemap.xml')))).headers.get('x-middleware-next'), '1');
const response = await handleDiscoveryFileRequest(request('/sitemap.xml'));
assert.equal(response.status, 200);
assert.deepEqual(Buffer.from(await response.arrayBuffer()), bytes);
assert.equal(response.headers.get('content-type'), 'text/xml; charset=utf-8');
assert.equal(response.headers.get('content-length'), String(bytes.length));
assert.equal(response.headers.get('accept-ranges'), 'bytes');
assert.ok(response.headers.get('last-modified'));
assert.equal(response.headers.get('cache-control'), null);
assert.equal(response.headers.get('etag'), null);
assert.deepEqual(response.headers.getSetCookie(), []);
}
});
});
test('missing index never synthesizes static XML or discovers shards; missing and invalid shards use page 404', async () => {
await isolated(async directory => {
await writeFile(path.join(directory, 'sitemap-drama-1.xml'), '<urlset/>');
const calls = mockUpstream();
assert.equal(await handleSEORequest(createContext(request('/sitemap.xml'))), null);
const shard = await handleSEORequest(createContext(request('/sitemaps/sitemap-drama-1.xml')));
assert.equal(await shard?.text(), '<urlset/>');
assert.equal(calls.length, 0);
for (const pathname of ['/sitemap.xml', '/sitemaps', '/sitemaps/sitemap-missing.xml', '/sitemaps/secret.xml', '/sitemaps/sitemap-a.XML', '/sitemaps/sitemap-a.xml/secret']) {
const response = await proxy(new NextRequest(request(pathname)));
assert.equal(response.status, 404, pathname);
assert.match(response.headers.get('x-robots-tag') || '', /noindex/);
const forwarded = new Headers();
for (const [key, value] of response.headers) if (key.startsWith('x-middleware-request-')) forwarded.set(key.slice('x-middleware-request-'.length), value);
const page = await receivePage(forwarded);
assert.equal(page?.view, '404');
assert.equal(page?.data.canonicalURL, undefined);
assert.equal(page?.data.jsonLD, undefined);
assert.ok(page?.data.appConfig);
}
assert.ok(calls.includes('/api/app/auth/getInfo'), 'full 404 retains normal session initialization');
});
});
test('Gin single-segment matching, shard validation and trailing slash redirects preserve query', async () => {
await isolated(async directory => {
await writeFile(path.join(directory, 'sitemap-a.xml'), '<urlset/>');
for (const name of ['sitemap-a.xml', 'sitemap-0.xml', 'sitemap--.xml']) assert.equal(isValidSitemapShardName(name), true);
for (const name of ['sitemap-.xml', 'sitemap-A.xml', 'sitemap-a.XML', 'sitemap-a_1.xml', 'sitemap-a.xml/child']) assert.equal(isValidSitemapShardName(name), false);
assert.equal(isRegisteredSEOPath('/sitemaps/invalid.xml'), true);
assert.equal(isRegisteredSEOPath('/sitemaps/sitemap-a.xml/child'), false);
const encoded = await handleSEORequest(createContext(request('/sitemaps/sitemap-a%2Exml')));
assert.equal(await encoded?.text(), '<urlset/>');
assert.equal(await handleSEORequest(createContext(request('/sitemaps/sitemap-a%2F.xml'))), null);
assert.equal(await handleSEORequest(createContext(request('/sitemaps/sitemap-a%252Exml'))), null, 'Gin decodes exactly once');
for (const pathname of ['/robots.txt/', '/llms.txt/', '/sitemap.xml/', '/sitemaps/invalid.xml/', '/sitemap.xml%2F', '/site%6Daps/invalid.xml%2F']) {
for (const method of ['GET', 'HEAD']) {
const redirect = canonicalRedirect(createContext(request(pathname + '?signature=a%2Bb&x=1', method)));
assert.equal(redirect?.status, 301);
assert.equal(redirect?.headers.get('location'), decodeURIComponent(pathname).slice(0, -1) + '?signature=a%2Bb&x=1');
}
}
assert.equal(canonicalRedirect(createContext(request('/sitemap.xml/', 'POST'))), null);
});
});
test('Next matcher executes original discovery dispatch, but navigation-only exposes none of the Go SEO routes', async () => {
await isolated(async directory => {
const calls = mockUpstream();
await writeFile(path.join(directory, 'sitemap.xml'), '<urlset/>');
for (const pathname of ['/robots.txt', '/llms.txt', '/sitemap.xml', '/sitemaps/sitemap-a.xml']) assert.equal(doesProxyMatch({ config: proxyConfig, nextConfig, url: pathname }), true);
process.env.NAVIGATION_ONLY = 'true';
for (const pathname of ['/robots.txt', '/llms.txt', '/sitemap.xml', '/sitemaps/sitemap-a.xml']) {
assert.equal(await handleSEORequest(createContext(request(pathname))), null);
const response = await proxy(new NextRequest(request(pathname)));
assert.equal(response.status, 404);
assert.match(response.headers.get('x-robots-tag') || '', /noindex/);
}
assert.ok(calls.length > 0);
for (const pathname of ['/manifest.webmanifest', '/sw.js']) assert.equal(doesProxyMatch({ config: proxyConfig, nextConfig, url: pathname }), false);
});
});
test('HEAD 304 keeps the original absent length while text and file responses retain Go HSTS', async () => {
const ctx = createContext(request('/sitemap.xml', 'HEAD'));
const response = await finalizeResponse(ctx, new Response(null, { status: 304 }));
assert.equal(response.headers.get('content-length'), null);
assert.equal(response.headers.get('strict-transport-security'), 'max-age=31536000');
assert.equal(await response.text(), '');
});
test('new public page URLs preserve SEO policy, canonical pagination, structured document links and genuine media', () => {
const site = 'https://canonical.drama.test';
const config = { foreverUrlAddr: site, seoInfo: { title: '花魁短剧', webSiteName: '花魁短剧' } };
const data = enrichSEO(createContext(new Request(origin + '/dramas/all/0/1/page/2/')), {
appConfig: config,
pagination: { Visible: true, HasPrev: true, PrevURL: '/dramas/', HasNext: true, NextURL: '/dramas/all/0/1/page/3/' },
list: [{ id: 1, slug: 'story' }, { id: 2, slug: 'other' }],
});
assert.equal(data.canonicalURL, site + '/dramas/all/0/1/page/2/');
assert.equal(data.paginationPrevURL, site + '/dramas/');
assert.equal(data.paginationNextURL, site + '/dramas/all/0/1/page/3/');
assert.equal(toNextMetadata(data).alternates?.canonical, data.canonicalURL);
const graph = JSON.parse(data.jsonLD)['@graph'];
assert.equal(graph.find((node: any) => node['@type'] === 'ItemList').itemListElement[0].url, site + '/drama/story/');
const media = site + '/play/signed.m3u8?token=a%2Bb%3D';
const cover = site + '/play/signed.webp?token=a%2Bb%3D';
const playback = enrichSEO(createContext(new Request(origin + '/drama/story/')), { appConfig: config, playlet: { id: 1, slug: 'story', title: '故事', cover, onlineAt: '2026-10-12 12:00:00' }, chapter: { currentEpisode: 1, videoUrl: media } });
assert.equal(playback.canonicalURL, site + '/drama/story/');
const video = JSON.parse(playback.jsonLD)['@graph'].find((node: any) => node['@type'] === 'VideoObject');
assert.equal(video.contentUrl, media);
assert.deepEqual(video.thumbnailUrl, [cover]);
for (const pathname of ['/drama/story/2/', '/account/', '/account/settings/', '/navigation/']) {
const page = enrichSEO(createContext(new Request(origin + pathname)), { appConfig: config });
assert.match(page.robots, /noindex/);
assert.equal(page.canonicalURL, undefined);
}
assert.equal(pageRepresentsMissingResource(createContext(new Request(origin + '/drama/story/recommendations/page/1/')), {}), false);
});