mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 06:06:18 -08:00
First comparison page that names other software. It answers the question every "IPTV on a computer" guide raises — VLC opens the playlist, so why a dedicated player — with three tables (what each can connect to, what a playlist file cannot carry, and the practical differences) and eight FAQ entries, then ends where the honest answer is: IPTVnator hands VLC the stream, with the playlist's headers and the resume position, and reads the position back. Naming another project brings rules, now recorded in the registry doc comment and a new README section: a dated ThirdPartyNote stating the other project is independent and endorses nothing, only stable publicly documented facts, no claim of a missing feature that was not checked, and no logos, brand styling or links to the other project. The compare test enforces the note and the date for every page in its NAMES_THIRD_PARTY_SOFTWARE set. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
93 lines
4.1 KiB
JavaScript
93 lines
4.1 KiB
JavaScript
import { readFile } from 'node:fs/promises';
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
/**
|
|
* Structural checks for the comparison pages: canonical URLs, the
|
|
* WebPage + FAQPage + BreadcrumbList structured data, a short verdict before
|
|
* the detail, at least one comparison table, cross-links and sitemap entries.
|
|
*/
|
|
|
|
const distRoot = new URL('../../dist/apps/website/', import.meta.url);
|
|
const SITE = 'https://4gray.github.io/iptvnator';
|
|
const COMPARISONS = [
|
|
'm3u-vs-xtream-vs-stalker',
|
|
'playback-engines',
|
|
'desktop-vs-browser',
|
|
'iptvnator-vs-vlc',
|
|
];
|
|
/** Pages that name other software must say they are not affiliated with it, and date their claims. */
|
|
const NAMES_THIRD_PARTY_SOFTWARE = new Set(['iptvnator-vs-vlc']);
|
|
|
|
const readDist = (relativePath) => readFile(new URL(relativePath, distRoot), 'utf8');
|
|
|
|
function extractJsonLd(html) {
|
|
const blocks = [...html.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)];
|
|
assert.ok(blocks.length > 0, 'Expected at least one JSON-LD script block.');
|
|
return blocks.flatMap((match) => JSON.parse(match[1]));
|
|
}
|
|
|
|
for (const slug of COMPARISONS) {
|
|
test(`comparison page ${slug}: canonical, schema and verdict`, async () => {
|
|
const html = await readDist(`compare/${slug}/index.html`);
|
|
assert.match(html, new RegExp(`<link rel="canonical" href="${SITE}/compare/${slug}/"`));
|
|
assert.match(html, /<meta name="robots" content="index, follow/);
|
|
assert.match(html, /Short answer/, 'Every comparison must state its verdict up front.');
|
|
assert.match(html, /<table/, 'Every comparison must carry a comparison table.');
|
|
|
|
const schema = extractJsonLd(html);
|
|
const page = schema.find((entry) => entry['@type'] === 'WebPage');
|
|
assert.ok(page, 'Expected a WebPage entry.');
|
|
assert.equal(page.url, `${SITE}/compare/${slug}/`);
|
|
const faq = schema.find((entry) => entry['@type'] === 'FAQPage');
|
|
assert.ok(faq && faq.mainEntity.length >= 5, 'Expected a FAQPage with at least five questions.');
|
|
const crumbs = schema.find((entry) => entry['@type'] === 'BreadcrumbList');
|
|
assert.equal(crumbs.itemListElement.at(-1).item, `${SITE}/compare/${slug}/`);
|
|
assert.ok(
|
|
!schema.some((entry) => entry['@type'] === 'SoftwareApplication'),
|
|
'A comparison page is guidance, not a product listing.'
|
|
);
|
|
|
|
if (NAMES_THIRD_PARTY_SOFTWARE.has(slug)) {
|
|
assert.match(html, /Not affiliated\./, 'A page naming other software must carry the disclaimer.');
|
|
assert.match(
|
|
html,
|
|
/Claims on this page were checked in \w+ \d{4}/,
|
|
'A page naming other software must date its claims.'
|
|
);
|
|
}
|
|
});
|
|
|
|
test(`comparison page ${slug}: links to the hub and the other comparisons`, async () => {
|
|
const html = await readDist(`compare/${slug}/index.html`);
|
|
assert.match(html, /href="\/iptvnator\/compare\/"/);
|
|
for (const other of COMPARISONS.filter((candidate) => candidate !== slug)) {
|
|
assert.match(html, new RegExp(`href="/iptvnator/compare/${other}/"`));
|
|
}
|
|
assert.match(html, /href="\/iptvnator\/blog\/[a-z0-9-]+\/"/, 'Expected a link into the guides.');
|
|
});
|
|
}
|
|
|
|
test('compare hub links to every comparison and to the feature pages', async () => {
|
|
const html = await readDist('compare/index.html');
|
|
assert.match(html, new RegExp(`<link rel="canonical" href="${SITE}/compare/"`));
|
|
for (const slug of COMPARISONS) {
|
|
assert.match(html, new RegExp(`href="/iptvnator/compare/${slug}/"`));
|
|
}
|
|
assert.match(html, /href="\/iptvnator\/features\/[a-z0-9-]+\/"/);
|
|
});
|
|
|
|
test('the header and the features hub link into the comparisons', async () => {
|
|
const home = await readDist('index.html');
|
|
assert.match(home, /href="\/iptvnator\/compare\/"/);
|
|
const features = await readDist('features/index.html');
|
|
assert.match(features, /href="\/iptvnator\/compare\/"/);
|
|
});
|
|
|
|
test('sitemap lists the comparison pages', async () => {
|
|
const sitemap = await readDist('sitemap-0.xml');
|
|
for (const path of ['compare/', ...COMPARISONS.map((slug) => `compare/${slug}/`)]) {
|
|
assert.match(sitemap, new RegExp(`<loc>${SITE}/${path}</loc>`));
|
|
}
|
|
});
|