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