Files
web_hgdjc_h5/tests/structured-data.test.mjs
2026-11-06 00:13:27 +07:00

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);
});