import {test} from 'node:test'; import assert from 'node:assert/strict'; import {seo,clientSeo,headDocument} from './helpers/seo.mjs'; const config={seoInfo:{title:'全站标题',description:'全站描述',keywords:['全站词'],webSiteName:'91剧场'},config:{foreverUrlAddr:'https://drama.example.net/entry/?channel=1'}}; const environment=seo.seoEnvironment(config); const page=(path,content={},search='')=>seo.buildPageHead(path,search,environment,content); test('path-based search metadata retains keywords and page numbers without becoming indexable',()=>{ const head=page('/search/'+encodeURIComponent('都市 / 爱情')+'/page/2/',{totalCount:60,pageSize:25}); assert.ok(head.title.includes('都市 / 爱情 · 搜索结果')); assert.ok(head.title.includes('第2页')); assert.equal(head.robots,'noindex,follow'); assert.equal(head.canonical,undefined);assert.equal(head.jsonLd,undefined); }); test('normalizes legacy/string SEO values and uses the permanent site origin',()=>{ assert.deepEqual(seo.normalizeSeo(JSON.stringify({webTitle:' 标题 ',webDescription:' 简介 ',keyworld:'都市,甜宠;都市',webSiteName:'91'})),{title:'标题',description:'简介',keywords:['都市','甜宠'],siteName:'91'}); assert.equal(environment.origin,'https://drama.example.net'); for(const value of ['',null,'""','{bad',[],12])assert.equal(seo.normalizeSeo(value).title,''); for(const value of ['javascript:alert(1)','https://user:secret@host.test','localhost:3000','https://example.com/path','https://www.xx.xxx/'])assert.equal(seo.siteOrigin(value),'',value); assert.equal(seo.seoEnvironment({config:{foreverUrlAddr:'https://example.com'},newAddressInfo:{foreverUrlAddr:'drama.example.org/entry'}}).origin,'https://drama.example.org'); assert.equal(seo.seoEnvironment({}).origin,''); }); test('home uses global SEO, independent route fields survive, and copied homepage fields fall back to page-specific content',()=>{ const home=page('/');assert.equal(home.title,'全站标题 - 91剧场');assert.equal(home.description,'全站描述');assert.equal(home.siteName,'91剧场'); const copied=page('/shorts/',{seoInfo:config.seoInfo});assert.equal(copied.title,'短剧 - 91剧场');assert.notEqual(copied.description,'全站描述'); const own=page('/topics/7/',{title:'专题名称',seoInfo:{title:'独立专题TDK',description:'独立描述',keywords:'专题词'}}); assert.equal(own.title,'独立专题TDK - 91剧场');assert.equal(own.description,'独立描述');assert.deepEqual(own.keywords,['专题词']); const detail=page('/detail/9/',seo.dramaSeoContent({id:9,title:'都市故事',desc:'故事简介',tagNames:['都市'],seoInfo:{title:'剧集独立TDK'}})); assert.equal(detail.title,'剧集独立TDK - 91剧场');assert.equal(detail.description,'故事简介');assert.ok(detail.keywords.includes('都市')); const episode=page('/play/9/90/',seo.dramaSeoContent({id:9,title:'都市故事',desc:'故事简介'},{currentEpisode:3})); assert.equal(episode.title,'都市故事 - 第3集 | 91剧场');assert.ok(episode.description.includes('第3集'));assert.equal(episode.type,'video.episode'); }); test('navigation TDK has priority on category routes and does not replace drama content',()=>{ const nav=seo.seoEnvironment(config,[{urlValue:'/playlet',seoInfo:{title:'短剧分类TDK',keyworld:['分类词'],description:'分类描述'}},{urlValue:'/topics/7/',seoInfo:{title:'独立专题分类TDK'}}]); assert.equal(seo.buildPageHead('/shorts/page/2/','',nav,{seoInfo:{title:'列表TDK'},totalCount:72}).title,'短剧分类TDK - 第2页'); const detail=seo.buildPageHead('/detail/9/','',nav,{title:'剧名',seoInfo:{title:'剧集TDK',description:'剧集描述'}}); assert.equal(detail.title,'剧集TDK - 91剧场');assert.equal(detail.description,'剧集描述'); assert.equal(seo.buildPageHead('/topics/7/','',nav).title,'独立专题分类TDK'); const withoutOwnTDK=seo.buildPageHead('/detail/9/','',nav,seo.dramaSeoContent({id:9,title:'具体剧名',desc:'具体简介'})); assert.equal(withoutOwnTDK.title,'具体剧名 - 91剧场');assert.equal(withoutOwnTDK.description,'具体简介'); const ranking=seo.seoEnvironment(config,[{urlValue:'/rank/',seoInfo:{title:'排行榜配置标题'}}]); assert.equal(seo.buildPageHead('/rank/new/','',ranking).title,'排行榜配置标题 - 新剧榜'); assert.equal(seo.buildPageHead('/rank/follow/','',ranking).title,'排行榜配置标题 - 追剧榜'); assert.equal(page('/rank/').canonical,page('/rank/hot/').canonical); const home=seo.buildPageHead('/','',seo.seoEnvironment(config,[{urlValue:'https://external.test/',seoInfo:{title:'外站'}}]));assert.equal(home.title,'全站标题 - 91剧场'); }); test('configuration failures and missing permanent origins retain the last valid SEO environment',()=>{ const previous=seo.resolveSeoEnvironment(config,[{urlValue:'/shorts/',seoInfo:{title:'短剧分类'}}]); for(const broken of [{},{seoInfo:{webSiteName:'错误品牌'}},{config:{foreverUrlAddr:'javascript:bad'}},{config:{foreverUrlAddr:'http://localhost:3000'}}]){ assert.equal(seo.resolveSeoEnvironment(broken,undefined,previous),previous); const head=seo.buildPageHead('/shorts/page/2/','',seo.resolveSeoEnvironment(broken,undefined,previous),{totalCount:72}); assert.equal(head.canonical,'https://drama.example.net/shorts/page/2/');assert.equal(head.siteName,'91剧场'); assert.equal(head.jsonLd['@graph'].find(item=>item['@type']==='Organization').name,'91剧场'); } const partial=seo.resolveSeoEnvironment({config:config.config},undefined,previous); assert.equal(partial.site.siteName,'91剧场');assert.equal(partial.site.title,'全站标题');assert.deepEqual(partial.categories,previous.categories); const updated=seo.resolveSeoEnvironment({config:{foreverUrlAddr:'https://new.drama.test/path/'},seoInfo:{webSiteName:'新站名'}},[],previous); assert.equal(updated.origin,'https://new.drama.test');assert.equal(updated.site.siteName,'新站名');assert.deepEqual(updated.categories,[]); }); test('cold starts never guess a canonical and the browser preserves the server identity',()=>{ const cold=seo.resolveSeoEnvironment({},[]); assert.equal(cold.origin,'');assert.equal(cold.site.siteName,'91短剧'); const head=seo.buildPageHead('/shorts/','',cold,{totalCount:72}); assert.equal(head.canonical,undefined);assert.equal(head.jsonLd,undefined);assert.equal(head.next,undefined); assert.equal(seo.clientSeoEnvironment(environment,{config:{foreverUrlAddr:'https://other.drama.test'},seoInfo:{webSiteName:'其他品牌'}}),environment); assert.equal(seo.clientSeoEnvironment(environment,{}),environment); const recovered=seo.clientSeoEnvironment(cold,config); assert.equal(recovered.origin,environment.origin);assert.equal(recovered.site.siteName,environment.site.siteName); const entity=page('/detail/9/',{title:'作品',seoInfo:{webSiteName:'作品接口的其他品牌'}}); assert.equal(entity.siteName,'91剧场');assert.ok(entity.title.endsWith(' - 91剧场')); assert.equal(entity.jsonLd['@graph'].find(item=>item['@type']==='Organization').name,entity.siteName); }); test('canonical is query-free, every pagination page identifies itself, and first/middle/last page links are bounded',()=>{ const first=page('/shorts',{totalCount:72,pageSize:24},'?channel=test#fragment'); assert.equal(first.canonical,'https://drama.example.net/shorts/');assert.equal(first.previous,undefined);assert.equal(first.next,'https://drama.example.net/shorts/page/2/?channel=test'); const middle=page('/shorts/page/2/',{totalCount:72,pageSize:24}); assert.equal(middle.canonical,'https://drama.example.net/shorts/page/2/');assert.equal(middle.previous,'https://drama.example.net/shorts/');assert.equal(middle.next,'https://drama.example.net/shorts/page/3/'); assert.match(middle.title,/第2页/);assert.match(middle.description,/第2页/);assert.ok(middle.keywords.includes('第2页')); const last=page('/shorts/page/3/',{totalCount:72,pageSize:24});assert.equal(last.next,undefined);assert.equal(last.previous,'https://drama.example.net/shorts/page/2/'); assert.equal(page('/shorts/page/1/',{totalCount:72}).canonical,'https://drama.example.net/shorts/'); const overflow=page('/shorts/page/4/',{totalCount:72,pageSize:24});assert.equal(overflow.canonical,undefined);assert.match(overflow.robots,/noindex/);assert.equal(overflow.previous,undefined); for(const totalCount of [0,1,24]){const head=page('/shorts/',{totalCount});assert.equal(head.previous,undefined);assert.equal(head.next,undefined);} const center=page('/member/center/',{totalCount:72});assert.equal(center.previous,undefined);assert.equal(center.next,undefined); for(const totalCount of [-1,NaN,'72']){const head=page('/shorts/',{totalCount});assert.equal(head.next,undefined);} }); test('private/tools/search/error pages do not inherit public canonical or indexability',()=>{ for(const path of ['/search/爱情/','/navigation/','/home-road/','/apps/','/unknown-page/']){const head=page(path);assert.match(head.robots,/noindex,follow/);assert.equal(head.canonical,undefined,path);} for(const path of ['/member/center/','/member/likes/','/member/likes/page/2/','/member/favorites/page/2/','/info/deposit/','/info/feedback/'])assert.equal(page(path).robots,'noindex,nofollow',path); assert.match(page('/shorts/',{},'?sort=2').robots,/noindex/);assert.equal(page('/shorts/',{},'?sort=2').canonical,undefined); for(const content of [{failed:true},{missing:true},{unavailable:true}])assert.equal(page('/detail/9/',content).canonical,undefined); for(const path of ['/info/about-us/','/info/privacy-policy/','/rank/hot/','/rank/new/','/rank/follow/'])assert.ok(page(path).canonical,path); }); test('random shorts metadata matches the selected feed and remains excluded from indexing',()=>{ const head=page('/shorts/page/2/',{totalCount:72},'?sort=4'); assert.ok(head.title.includes('随机推荐'));assert.ok(head.description.includes('随机推荐'));assert.ok(head.title.includes('第2页')); assert.equal(head.robots,'noindex,follow');assert.equal(head.canonical,undefined); }); test('client navigation updates SSR head once and clears old canonical/OG URL/pagination after private or failed routes',()=>{ const doc=headDocument(); const first=page('/shorts/page/2/',{totalCount:72});clientSeo.applyPageHead(first,doc); const duplicate=doc.createElement('link');duplicate.setAttribute('rel','canonical');doc.head.appendChild(duplicate); const last=page('/rank/new/page/3/',{totalCount:72});clientSeo.applyPageHead(last,doc); assert.equal(doc.head.querySelectorAll('link[rel="canonical"]').length,1); assert.equal(doc.title,last.title);assert.equal(doc.value('meta[property="og:site_name"]'),'91剧场'); assert.equal(doc.value('link[rel="canonical"]'),last.canonical);assert.equal(doc.value('meta[property="og:url"]'),last.canonical); assert.equal(doc.value('link[rel="next"]'),undefined);assert.equal(doc.value('link[rel="prev"]'),last.previous); const privatePage=page('/info/deposit/');clientSeo.applyPageHead(privatePage,doc); assert.equal(doc.value('link[rel="canonical"]'),undefined);assert.equal(doc.value('meta[property="og:url"]'),undefined);assert.equal(doc.value('link[rel="prev"]'),undefined); assert.equal(doc.value('meta[name="robots"]'),'noindex,nofollow');assert.equal(doc.value('meta[name="googlebot"]'),'noindex,nofollow'); assert.equal(doc.value('meta[name="twitter:title"]'),privatePage.title); }); test('episode metadata uses chapter SEO before drama SEO and keeps the real episode number',()=>{ const data=seo.dramaSeoContent({id:9,title:'都市故事',seoInfo:{title:'剧集标题',description:'剧集描述'}},{id:90,currentEpisode:3,seoInfo:{title:'章节标题',description:'章节描述',keyworld:'章节词'}}); const head=page('/play/9/90/',data); assert.ok(head.title.includes('章节标题'));assert.equal(head.description,'章节描述 - 第3集 | 91剧场');assert.ok(head.keywords.includes('章节词'));assert.equal(data.episode,3); }); test('topic aliases and numeric ID variants share a primary canonical without changing catalog pagination',()=>{ for(const [path,expected] of [ ['/theme/007/','/topics/7/'],['/theme/007/page/2/','/topics/7/page/2/'], ['/topics/007/page/1/','/topics/7/'],['/tag/009/','/tag/9/'],['/detail/009/','/detail/9/'], ]){ const head=page(path,{totalCount:72},'?utm_source=test'); assert.equal(head.canonical,environment.origin+expected,path);assert.ok(head.robots.startsWith('index,'),path); } const alias=page('/theme/007/page/2/',{totalCount:72}); assert.equal(alias.previous,environment.origin+'/topics/7/');assert.equal(alias.next,environment.origin+'/topics/7/page/3/'); assert.equal(page('/shorts/page/2/',{totalCount:72}).canonical,environment.origin+'/shorts/page/2/'); assert.equal(page('/search/007/').canonical,undefined); }); test('empty public lists and comment pagination are noindex while preserving successful and error statuses',()=>{ for(const path of ['/shorts/','/topics/','/topics/7/','/theme/7/','/tag/9/','/rank/hot/']){ const content={totalCount:0},head=page(path,content); assert.equal(head.robots,'noindex,follow',path);assert.equal(head.canonical,undefined);assert.equal(head.jsonLd,undefined); assert.equal(seo.pageResponseStatus(path,content),200);assert.equal(seo.pageResponseStatus(path+'page/2/',content),404); assert.equal(seo.pageResponseStatus(path,{...content,failed:true}),500); assert.equal(seo.pageResponseStatus(path,{...content,missing:true}),404); } const drama=seo.dramaSeoContent({id:9,title:'作品名',desc:'作品简介'}); const playable=seo.dramaSeoContent({id:9,title:'作品名'},{id:90,currentEpisode:3,videoUrl:'https://media.example.net/3.mp4'}); for(const [path,data] of [['/detail/9/',drama],['/play/9/90/',playable]]){ assert.ok(page(path,{...data,totalCount:0,pageSize:10}).robots.startsWith('index,'),path); const content={...data,totalCount:35,pageSize:10},paged=page(path+'page/2/',content); assert.equal(seo.pageResponseStatus(path+'page/2/',content),200); assert.equal(paged.robots,'noindex,follow'); for(const key of ['canonical','previous','next','jsonLd'])assert.equal(paged[key],undefined,key); } }); test('more snapshots need visible collection information and actual items before indexing',()=>{ const route={group:'page',key:'config',value:'31'}; const data={name:'都市精选',desc:'都市题材作品精选合集',list:[{id:9}],totalCount:1}; const complete=seo.listSeoContent(data,route); assert.ok(page('/config/0031/',complete).robots.startsWith('index,')); assert.equal(page('/config/0031/',complete).canonical,environment.origin+'/config/31/'); for(const patch of [{name:''},{desc:''},{list:[]},{list:[{id:-1}]},{desc:''}]){ const content=seo.listSeoContent({...data,...patch},route),head=page('/config/31/',content); assert.equal(head.robots,'noindex,follow');assert.equal(head.jsonLd,undefined);assert.equal(seo.pageResponseStatus('/config/31/',content),200); } const topics=seo.listSeoContent({name:'主题精选',desc:'主题合集介绍',type:3,themeList:[{id:7}],totalCount:1},route); assert.deepEqual(topics.items,[{path:'/topics/7/'}]);assert.ok(page('/config/31/',topics).canonical); }); test('episode indexing requires an actual authorized source and episode identity, not metadata or a shared preview',()=>{ const data={id:9,title:'作品名',desc:'作品简介',preview:'https://media.example.net/shared-preview.mp4'}; const chapter={id:90,currentEpisode:3,videoUrl:'https://media.example.net/3.mp4'}; for(const patch of [{videoUrl:''},{videoUrl:'javascript:bad'},{currentEpisode:0},{currentEpisode:undefined},{canWatch:false,needUnlock:true},{canWatch:false,needVip:true}]){ const content=seo.dramaSeoContent(data,{...chapter,...patch,seoInfo:{title:'单集标题',description:'单集描述'}}); const head=page('/play/9/90/',content); assert.equal(head.robots,'noindex,follow');assert.equal(head.jsonLd,undefined);assert.equal(seo.pageResponseStatus('/play/9/90/',content),200); if(patch.needUnlock||patch.needVip)assert.equal(content.entity.video.source,undefined); } for(const patch of [{},{payType:1,canWatch:true},{payType:2,canWatch:true}])assert.ok(page('/play/9/90/',seo.dramaSeoContent(data,{...chapter,...patch})).canonical); const doc=headDocument();clientSeo.applyPageHead(page('/play/9/90/',seo.dramaSeoContent(data,chapter)),doc); clientSeo.applyPageHead(page('/play/9/90/',seo.dramaSeoContent(data,{...chapter,needUnlock:true})),doc); assert.equal(doc.value('meta[name="robots"]'),'noindex,follow');assert.equal(doc.value('link[rel="canonical"]'),undefined); });