Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Validate AKB specific feed requirements #25

Open
wants to merge 19 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 13 additions & 9 deletions .github/workflows/validate-feed.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,18 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 20
cache: npm
- uses: denolib/setup-deno@master
with:
deno-version: v1.35.3
- name: Validate AKB specific feed requirements
run: npm run lint:feed public/feed.rss
- name: Validate feed against W3C RSS Feed Validator
run: npm run lint:feed-w3c https://validator.w3.org/feed/check.cgi?url=https://raw.githubusercontent.com/akronymisierbar/akronymisier.bar/${{ github.sha }}/public/feed.rss
- uses: actions/github-script@v6
with:
script: |
Expand All @@ -22,13 +34,5 @@ jobs:
repo: context.repo.repo,
body: `Looks like you made a change to the RSS feed. Aside from the automatic checks, please also check [podbase's feed validator](${podbaseURL}), thanks!`
})
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 20
cache: npm
- uses: denolib/setup-deno@master
with:
deno-version: v1.35.3
- run: npm run lint:feed-w3c https://validator.w3.org/feed/check.cgi?url=https://raw.githubusercontent.com/akronymisierbar/akronymisier.bar/${{ github.sha }}/public/feed.rss


6 changes: 2 additions & 4 deletions item-template.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,9 @@
<title>{title}</title>
<description>{description}</description>
<pubDate>{pubDate}</pubDate>
<author>[email protected] (Akronymisierbar)</author>
<author>[email protected] (Akronymisierbar)</author>
<link>https://akronymisier.bar/{id}</link>
<content:encoded><![CDATA[
{content}
]]></content:encoded>
<content:encoded><![CDATA[{content}]]></content:encoded>
<enclosure length="{length}" type="audio/mpeg" url="https://kkw.lol/k/akb/{id}.mp3"/>
<itunes:title>{title}</itunes:title>
<itunes:author>Akronymisierbar</itunes:author>
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"lint:feed": "deno run --allow-read --allow-net scripts/validate-feed.ts",
"lint:feed-w3c": "deno run --allow-net scripts/validate-feed-w3c.ts",
"preview": "vite preview"
},
Expand Down
386 changes: 94 additions & 292 deletions public/feed.rss

Large diffs are not rendered by default.

69 changes: 69 additions & 0 deletions scripts/deadlinks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
#! /usr/bin/env deno run --allow-read --allow-net

import { parse } from "https://deno.land/x/[email protected]/mod.ts";
import { Item, num } from "./types.ts";

function getURLs(item: Item): string[] {
const urlRegExp = /(\bhttps?:\/\/\S+)"/gi;
let match;
const urls = [];

while ((match = urlRegExp.exec(item["content:encoded"])) !== null) {
urls.push(match[1]);
}
return urls;
}

function timeout(ms: number, promise: Promise<any>) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error('Request timed out'));
}, ms);

promise.then(value => {
clearTimeout(timer);
resolve(value);
}).catch(reason => {
clearTimeout(timer);
reject(reason);
})
});
}

if (Deno.args.length < 1) {
console.error("Usage: ./deadlinks.ts /path/to/feed.rss");
Deno.exit(1);
}

const data = await Deno.readTextFile(Deno.args[0]);
const feed = parse(data);
let items;

if (Deno.args[1]) {
items = feed?.rss?.channel?.item.filter(i => num(i) === Deno.args[1]);
} else {
items = feed?.rss?.channel?.item;
}

for (const item of items) {
console.log(`${num(item)}:`)

const fetchPromises = getURLs(item).map(url => {
return timeout(5000, fetch(url, { method: "HEAD", redirect: "manual" }))
// return fetch(url, { method: "HEAD", redirect: "manual" })
.then(res => {
if (!res.ok) {
if (res.status.toString().startsWith("3")) {
// console.error(` - ${res.status} ${url} -> ${res.headers.get("location")}`);
} else {
console.error(` - ${res.status} on ${url}`);
}
}
})
.catch(error => console.error(` - ${url} ${error}`));
});

await Promise.all(fetchPromises);

console.log("");
}
52 changes: 52 additions & 0 deletions scripts/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
export interface Item {
guid: Guid;
title: string;
description: string;
pubDate: string;
author: string;
link: string;
"content:encoded": string;
enclosure: Enclosure;
"itunes:title": string;
"itunes:author": string;
"itunes:image"?: iTunesImage;
"itunes:duration": string;
"itunes:summary": string;
"itunes:subtitle": string;
"itunes:explicit": boolean,
"itunes:episodeType": string;
"itunes:episode": number;
"podcast:chapters"?: Chapters;
"podcast:socialInteract"?: SocialInteract;
}

export function num(item: Item) {
return item["itunes:episode"].toString().padStart(3, "0");
}

interface Guid {
"@isPermaLink": boolean;
"#text": string;
}

interface Enclosure {
"@length": string;
"@type": string;
"@url": string;
}

interface iTunesImage {
"@href": string;
}

interface Chapters {
"@url": string;
"@type": string;
}

interface SocialInteract {
"@uri": string;
"@protocol": string;
"@accountId": string;
"@accountUrl": string;
}
188 changes: 188 additions & 0 deletions scripts/validate-feed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
#! /usr/bin/env deno run --allow-read --allow-net

import { parse } from "https://deno.land/x/[email protected]/mod.ts";
import { Item, num } from "./types.ts";

let foundIssue = false;
function err(text: string) {
foundIssue = true;
console.error(" - " + text);
}

async function assertEnclosure(item: Item) {
if (!item.enclosure["@url"].startsWith("https://kkw.lol")) {
err(`not hosted on kkw.lol.`);
}
const res = await fetch(item.enclosure["@url"], {
method: "HEAD",
});
const enclosureLength = item.enclosure["@length"].toString();
const contentLength = res.headers.get("content-length");
if (enclosureLength !== contentLength) {
err(
`has an enclosure length of ${enclosureLength}, but server reports ${contentLength}.`
);
}
}

function assertDescription(item: Item) {
if (!item.description) {
err(`has no description.`);
}
if (item.description?.length > 250) {
err(`description is longer than 250 chars.`);
}
if (item.description?.trim() === item["content:encoded"]?.trim()) {
err(`has the same value for its description and content:encoded.`);
}
if (item.description !== item["itunes:summary"]) {
err(`has mismatching description and itunes:summary.`);
}
}

function assertLink(item: Item) {
const correctEpisodeLink = `https://akronymisier.bar/${item["itunes:episode"]
.toString()
.padStart(3, "0")}`;
if (item.link !== correctEpisodeLink) {
err(`has an invalid link '${item.link}'.`);
}
}

function assertTitle(item: Item) {
const titleRegex = /\d{3} - [a-zA-Z\d¯\\_(ツ)/ ]+/;
if (!item.title.match(titleRegex)) {
err(`has invalid format.`);
}
if (item.title !== item["itunes:title"]) {
err(
`should match itunes:title ${item["itunes:title"]}`
);
}
if (item.title.slice(0, 3) !== num(item)) {
err(`title number and episode number don't match: ${item.title.slice(0, 3)} and ${num(item)}`);
}
}

function assertPubDate(item: Item) {
const pubDate = new Date(item.pubDate);
if (pubDate > new Date()) {
err(`has a future pubDate.`);
}
}

function assertiTunesTags(item: Item) {
if (!item["itunes:title"]) {
err(`has no itunes:title.`);
}
if (!item["itunes:author"]) {
err(`has no itunes:author.`);
}
if (!item["itunes:duration"]) {
err(`has no itunes:duration.`);
}
if (!item["itunes:summary"]) {
err(`has no itunes:summary.`);
}
if (!item["itunes:subtitle"]) {
err(`has no itunes:subtitle.`);
}
if (item["itunes:explicit"] !== false) {
err(`has no itunes:explicit or it's set to true (lol?).`);
}
if (!item["itunes:episodeType"]) {
err(`has no itunes:episodeType.`);
}
if (!item["itunes:episode"]) {
err(`has no itunes:episode.`);
}

if (item["itunes:summary"]?.length > 250) {
err(`itunes:summary is longer than 250 chars.`);
}

if (item["itunes:summary"] !== item["itunes:subtitle"]) {
console.log(
`itunes:summary and itunes:subtitle don't match.`
);
}
}

function assertEncodedContent(item: Item) {
if (!item["content:encoded"]) {
err(`has no content:encoded.`);
}
if (item["content:encoded"].includes("<h1>")) {
err(`should not contain any <h1>s in its content.`);
}
if (item["content:encoded"].includes("<h2>")) {
err(`should not contain any <h2>s in its content.`);
}
if (item["content:encoded"].includes("Shownotes")) {
err(`should not contain a Shownotes header in its content.`);
}
}

async function assertChaptermarks(item: Item) {
const resTXT = await fetch(
`https://kkw.lol/k/akb/${num(item)}.chapters.txt`,
{ method: "HEAD" }
);
if (resTXT.ok && !item["podcast:chapters"]) {
err(`has chapter marks, but no podcast:chapters tag.`);
}

if (item["podcast:chapters"]) {
const resJSON = await fetch(item["podcast:chapters"]["@url"], {
method: "HEAD",
});
if (!resJSON.ok) {
err(`lists json chapter marks, but they're not available.`);
}
}
}

async function assertCoverart(item: Item) {
if (!item["itunes:image"]) {
return;
}
const res = await fetch(item["itunes:image"]["@href"], { method: "HEAD" });
if (!res.ok) {
err(`specifies custom cover art, but the image isn't available.`);
}
}

async function validateItem(item: Item) {
assertDescription(item);
assertLink(item);
assertTitle(item);
assertPubDate(item);
assertiTunesTags(item);
assertEncodedContent(item);
await assertEnclosure(item);
await assertChaptermarks(item);
await assertCoverart(item);
}

if (Deno.args.length !== 1) {
console.error("Usage: ./validate-feed.ts /path/to/feed.rss");
Deno.exit(1);
}

const data = await Deno.readTextFile(Deno.args[0]);
const feed = parse(data);

try {
const items = feed?.rss?.channel?.item;
for (const item of items) {
console.log(`${num(item)}`)
await validateItem(item);
}
} catch (error) {
console.error(error);
Deno.exit(1);
}

if (foundIssue) {
Deno.exit(1);
}