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