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: '&\u2028' }); assert.equal(raw.includes(''), false); assert.equal(JSON.parse(raw).text, '&\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 = 'https://canonical.drama.test/sitemaps/sitemap-playletdetail-1.xml'; try { await writeFile(path.join(dir, 'sitemap.xml'), xml); await writeFile(path.join(dir, 'sitemap-playletdetail-1.xml'), ''); 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(), ''); 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; } });