134 lines
11 KiB
JavaScript
134 lines
11 KiB
JavaScript
import {linkShared} from './helpers/shared-modules.mjs';
|
|
import {test} from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {readFile} from 'node:fs/promises';
|
|
import ts from 'typescript';
|
|
import {seo,structuredData,clientSeo,headDocument} from './helpers/seo.mjs';
|
|
|
|
const origin='https://drama.example.net';
|
|
const environment=seo.seoEnvironment({seoInfo:{webSiteName:'91剧场',description:'本站介绍'},config:{foreverUrlAddr:origin,tgGroup:'https://t.me/official',twitterAddr:'https://x.com/official',telegram:'javascript:bad'}});
|
|
const head=(path,content={},search='')=>seo.buildPageHead(path,search,environment,content);
|
|
const nodes=(page,type)=>page.jsonLd?.['@graph'].filter(node=>node['@type']===type)||[];
|
|
const drama={id:9,title:'剧集名称',desc:'剧集简介',cover:'https://images.example.net/poster.webp',onlineAt:'2025-10-18 10:30:00',mediaUpdatedAt:'2025-10-19T08:00:00Z',totalEpisode:12,tagNames:['都市'],anchor:'主演姓名'};
|
|
const chapter={id:90,playletId:9,currentEpisode:3,canWatch:true,videoUrl:'https://media.example.net/episode.m3u8',mediaTime:125,width:1080,height:1920,mediaSize:30000,countBrowse:25};
|
|
|
|
test('home schema uses this site, actual assets, configured official profiles and the visible unique internal item URLs',()=>{
|
|
const page=head('/',seo.homeSeoContent({homeBannerData:[drama,{...drama,id:10}],homeConfigData:[
|
|
{id:1,status:true,type:1,list:[drama,{...drama,id:11}]},
|
|
{id:2,status:false,type:1,list:[{...drama,id:99}]},
|
|
{id:3,status:true,type:2,videoList:[{...drama,id:12}]},
|
|
{id:4,status:true,type:3,themeList:[{id:8,name:'题材'}]},
|
|
]}));
|
|
const organization=nodes(page,'Organization')[0],website=nodes(page,'WebSite')[0];
|
|
assert.equal(organization.name,'91剧场');assert.equal(organization.url,origin+'/');
|
|
assert.equal(organization.logo.url,origin+'/apple-touch-icon.png');assert.equal(organization.logo.width,180);
|
|
assert.deepEqual(organization.sameAs,['https://x.com/official','https://t.me/official']);
|
|
assert.equal(website.publisher['@id'],organization['@id']);assert.equal(website.description,'本站介绍');
|
|
assert.equal(nodes(page,'BreadcrumbList').length,0);
|
|
const items=nodes(page,'ItemList')[0];assert.equal(items.numberOfItems,4);
|
|
assert.deepEqual(items.itemListElement.map(item=>item.url),['/detail/9/','/detail/10/','/detail/11/','/topics/8/'].map(path=>origin+path));
|
|
});
|
|
|
|
test('lists, topics and ranks use native URLs and the current page order, not hidden homepage recommendations',()=>{
|
|
const data={list:[{id:7},{id:8},{id:7},null,{id:-1},{id:'invalid'}],totalCount:72,name:'接口分类名'};
|
|
const page=head('/shorts/page/2/',seo.listSeoContent(data,{group:'page',key:'shorts',page:2}));
|
|
assert.equal(nodes(page,'CollectionPage')[0].url,page.canonical);
|
|
assert.deepEqual(nodes(page,'ItemList')[0].itemListElement,[{'@type':'ListItem',position:25,url:origin+'/detail/7/'},{'@type':'ListItem',position:26,url:origin+'/detail/8/'}]);
|
|
const crumb=nodes(page,'BreadcrumbList')[0].itemListElement;assert.equal(crumb.at(-1).item,page.canonical);assert.equal(crumb.at(-1).name,'接口分类名');
|
|
const topics=head('/topics/',seo.listSeoContent({list:[{id:7},{id:8}]},{group:'page',key:'topics'}));
|
|
assert.equal(nodes(topics,'ItemList')[0].itemListElement[0].url,origin+'/topics/7/');
|
|
const rank=head('/rank/new/',seo.listSeoContent(data,{group:'page',key:'rank',value:'new'}));
|
|
assert.equal(nodes(rank,'BreadcrumbList')[0].itemListElement.at(-1).name,'新剧榜');
|
|
});
|
|
|
|
test('current homepage modules use the same theme paths as the visible cards',()=>{
|
|
const content=seo.homeSeoContent({modules:[{id:1,title:'短剧',moduleType:1,list:[drama]},{id:2,title:'题材',moduleType:2,topicList:[{id:7,slug:'urban',name:'都市'}]}]});
|
|
assert.deepEqual(content.items,[{path:'/detail/9/'},{path:'/theme/urban/'}]);
|
|
});
|
|
|
|
test('homepage schema follows API limits independently of category styles',()=>{
|
|
const limits=[3,9,8,7,5,2,0,undefined];
|
|
const modules=['A','B','C','D','E','F','A','B'].map((style,index)=>({id:index+1,title:'分类',moduleType:style==='E'?2:1,style,limit:limits[index],
|
|
list:Array.from({length:12},(_,item)=>({id:100*(index+1)+item,slug:style.toLowerCase()+'-'+item})),
|
|
topicList:Array.from({length:12},(_,item)=>({id:100*(index+1)+item,slug:'topic-'+item})),
|
|
}));
|
|
const content=seo.homeSeoContent({modules});
|
|
const expected=modules.flatMap((module,index)=>Array.from({length:limits[index]??12},(_,item)=>({path:module.style==='E'?`/theme/topic-${item}/`:`/detail/${module.style.toLowerCase()}-${item}/`})));
|
|
assert.deepEqual(content.items,expected);
|
|
});
|
|
|
|
test('detail describes the series; episode watch pages connect TVEpisode and VideoObject to stable series/media IDs',()=>{
|
|
const detail=head('/detail/9/',seo.dramaSeoContent(drama));
|
|
assert.equal(nodes(detail,'VideoObject').length,0);assert.equal(nodes(detail,'TVEpisode').length,0);
|
|
const series=nodes(detail,'TVSeries')[0];assert.equal(series.name,drama.title);assert.equal(series.numberOfEpisodes,12);assert.deepEqual(series.genre,['都市']);assert.equal(series.actor.name,'主演姓名');
|
|
const page=head('/play/9/90/',seo.dramaSeoContent(drama,chapter));
|
|
const episode=nodes(page,'TVEpisode')[0],video=nodes(page,'VideoObject')[0];
|
|
assert.equal(episode.episodeNumber,3);assert.equal(episode.partOfSeries['@id'],series['@id']);assert.equal(episode.associatedMedia['@id'],video['@id']);
|
|
assert.equal(video.name,'剧集名称 - 第3集');assert.equal(video.uploadDate,'2025-10-18T10:30:00+08:00');assert.equal(video.duration,'PT125S');assert.equal(video.contentUrl,chapter.videoUrl);
|
|
assert.equal(video.width,1080);assert.equal(video.height,1920);assert.equal(video.interactionStatistic.userInteractionCount,25);
|
|
const paged=head('/play/9/90/page/2/',{...seo.dramaSeoContent(drama,chapter),totalCount:35,pageSize:10});
|
|
assert.equal(paged.robots,'noindex,follow');assert.equal(paged.jsonLd,undefined);
|
|
});
|
|
|
|
test('missing media data and invalid dates do not produce fabricated VideoObject fields or leak locked playback URLs',()=>{
|
|
for(const patch of [{onlineAt:undefined},{onlineAt:'0001-01-01'},{onlineAt:'not-a-date'},{cover:''},{cover:'javascript:bad'}]){
|
|
const page=head('/play/9/90/',seo.dramaSeoContent({...drama,...patch},chapter));
|
|
assert.equal(nodes(page,'VideoObject').length,0);assert.equal(nodes(page,'TVEpisode').length,1);
|
|
}
|
|
const locked=head('/play/9/90/',seo.dramaSeoContent(drama,{...chapter,canWatch:false,needUnlock:true}));
|
|
assert.equal(locked.robots,'noindex,follow');assert.equal(locked.jsonLd,undefined);
|
|
const earlier=head('/play/9/90/',seo.dramaSeoContent({...drama,mediaUpdatedAt:'2025-10-17 01:00:00'},chapter));
|
|
assert.equal(nodes(earlier,'VideoObject')[0].dateModified,undefined);
|
|
const missingName=head('/play/9/90/',seo.dramaSeoContent({...drama,title:''},chapter));assert.equal(nodes(missingName,'VideoObject').length,0);
|
|
const fallbackDate=head('/play/9/90/',seo.dramaSeoContent({...drama,onlineAt:'0001-01-01',createdAt:'2025-10-01'},chapter));
|
|
assert.equal(nodes(fallbackDate,'VideoObject')[0].uploadDate,'2025-10-01T00:00:00+08:00');
|
|
for(const value of ['2025-02-29','2025-13-01','2025-01-01 25:00:00','1970-01-01','0001-01-01'])assert.equal(structuredData.schemaDate(value),'',value);
|
|
assert.equal(structuredData.schemaDate('2024/02/29'),'2024-02-29T00:00:00+08:00');
|
|
});
|
|
|
|
test('paywalled playable chapters identify the restricted region and normalize canonical media IDs',()=>{
|
|
const paid=head('/play/009/090/',seo.dramaSeoContent(drama,{...chapter,payType:1}));
|
|
const page=nodes(paid,'WebPage')[0],video=nodes(paid,'VideoObject')[0],episode=nodes(paid,'TVEpisode')[0];
|
|
assert.equal(paid.canonical,origin+'/play/9/90/');
|
|
assert.equal(page.isAccessibleForFree,false);
|
|
assert.deepEqual(page.hasPart,{'@type':'WebPageElement',isAccessibleForFree:false,cssSelector:'.play-paywalled-content'});
|
|
assert.equal(video.isAccessibleForFree,false);assert.equal(episode.isAccessibleForFree,false);
|
|
assert.equal(video['@id'],origin+'/play/9/90/#video');assert.equal(episode.url,paid.canonical);
|
|
const hashed=head('/play/9/90/',seo.dramaSeoContent(drama,{...chapter,videoUrl:'signed/media.m3u8?token=a%2Fb'}));
|
|
assert.equal(nodes(hashed,'VideoObject')[0].contentUrl,origin+'/h5/m3u8/signed/media.m3u8?token=a%2Fb');
|
|
const free=head('/play/9/90/',seo.dramaSeoContent(drama,chapter));
|
|
assert.equal(nodes(free,'WebPage')[0].hasPart,undefined);
|
|
});
|
|
|
|
test('removed standalone video routes have no canonical or structured data',()=>{
|
|
const page=head('/video/9/',seo.dramaSeoContent({...drama,video:{url:'https://media.example.net/movie.mp4',videoTime:360}}));
|
|
assert.equal(page.canonical,undefined);assert.equal(page.jsonLd,undefined);assert.match(page.robots,/noindex/);
|
|
});
|
|
|
|
test('about and FAQ use visible local content, while private/search/tools/failed pages output no graph',async()=>{
|
|
const code=ts.transpileModule(await readFile(new URL('../src/lib/info-pages.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022}}).outputText;
|
|
const info=await import('data:text/javascript;base64,'+Buffer.from(linkShared(code)).toString('base64'));
|
|
const about=head('/info/about-us/',seo.infoSeoContent(info.getInfoPage('about-us')));assert.equal(nodes(about,'AboutPage').length,1);
|
|
const faqPage=info.getInfoPage('help-center'),content=seo.infoSeoContent(faqPage),page=head('/info/help-center/',content);
|
|
assert.equal(nodes(page,'FAQPage')[0].mainEntity.length,5);assert.ok(!faqPage.html.includes('反馈'));
|
|
for(const question of nodes(page,'FAQPage')[0].mainEntity){assert.ok(faqPage.html.includes(question.name));assert.ok(question.acceptedAnswer.text);}
|
|
for(const path of ['/search/爱情/','/navigation/','/home-road/','/apps/','/member/favorites/','/info/deposit/','/unknown/'])assert.equal(head(path,content).jsonLd,undefined,path);
|
|
assert.equal(head('/shorts/',content,'?sort=2').jsonLd,undefined);assert.equal(head('/detail/9/',{...content,failed:true}).jsonLd,undefined);
|
|
});
|
|
|
|
test('native script serialization stays valid JSON and cannot close its script through API content',()=>{
|
|
const graph={'@context':'https://schema.org','@graph':[{'@type':'WebPage',name:'</script><script>alert(1)</script>\u2028\u2029'}]};
|
|
const serialized=structuredData.serializeStructuredData(graph);assert.ok(!serialized.includes('<'));assert.ok(!serialized.includes('\u2028'));assert.deepEqual(JSON.parse(serialized),graph);
|
|
});
|
|
|
|
test('client route changes keep a single script, replace graph contents and remove stale public data on noindex pages',()=>{
|
|
const doc=headDocument(),selector='script[data-site-json-ld]';
|
|
clientSeo.applyPageHead(head('/',seo.homeSeoContent({homeBannerData:[drama]})),doc);
|
|
const duplicate=doc.createElement('script');duplicate.setAttribute('data-site-json-ld','');doc.head.appendChild(duplicate);
|
|
clientSeo.applyPageHead(head('/detail/9/',seo.dramaSeoContent(drama)),doc);
|
|
assert.equal(doc.head.querySelectorAll(selector).length,1);
|
|
const graph=JSON.parse(doc.head.querySelectorAll(selector)[0].textContent);assert.ok(graph['@graph'].some(node=>node['@type']==='TVSeries'));assert.ok(!graph['@graph'].some(node=>node['@type']==='ItemList'));
|
|
clientSeo.applyPageHead(head('/member/center/'),doc);assert.equal(doc.head.querySelectorAll(selector).length,0);
|
|
clientSeo.applyPageHead(head('/shorts/',{items:[{path:'/detail/7/'}]}),doc);assert.equal(doc.head.querySelectorAll(selector).length,1);
|
|
});
|