Skip to content

Commit b55d3a2

Browse files
IvanWng97claude
andcommitted
feat(route): add KQED
KQED's current site publishes no feed, and the legacy feed marks every image `loading="lazy"` with `sizes="auto"`, which stops many readers from ever loading them. This route reads the WordPress REST API behind each section, strips those attributes, and links to the current site. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 069ef65 commit b55d3a2

2 files changed

Lines changed: 143 additions & 0 deletions

File tree

lib/routes/kqed/index.tsx

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { load } from 'cheerio';
2+
import { decodeHTML } from 'entities';
3+
import { raw } from 'hono/html';
4+
import { renderToString } from 'hono/jsx/dom/server';
5+
6+
import InvalidParameterError from '@/errors/types/invalid-parameter';
7+
import type { DataItem, Route } from '@/types';
8+
import { ViewType } from '@/types';
9+
import ofetch from '@/utils/ofetch';
10+
import { parseDate } from '@/utils/parse-date';
11+
12+
// Each section is its own site in a WordPress multisite; the current front end lives on www
13+
const apiBase = 'https://ww2.kqed.org';
14+
const siteBase = 'https://www.kqed.org';
15+
16+
const sections = {
17+
arts: 'Arts',
18+
news: 'News',
19+
science: 'Science',
20+
forum: 'Forum',
21+
mindshift: 'MindShift',
22+
perspectives: 'Perspectives',
23+
education: 'Education',
24+
};
25+
26+
// The `author` taxonomy only repeats the byline as slugs
27+
const skipTaxonomies = new Set(['author']);
28+
29+
// Post bodies ship with lazy-loading attributes that keep many feed readers from ever loading the embedded
30+
// media, and `sizes="auto"` makes renderers that do not support it pick a zero-width candidate
31+
const cleanContent = (html: string): string => {
32+
const $ = load(html, null, false);
33+
$('img, iframe').each((_, el) => {
34+
$(el).removeAttr('loading').removeAttr('decoding').removeAttr('srcset').removeAttr('sizes');
35+
});
36+
return $.html();
37+
};
38+
39+
export const route: Route = {
40+
path: '/:section?',
41+
categories: ['new-media'],
42+
view: ViewType.Articles,
43+
example: '/kqed/arts',
44+
parameters: { section: 'Section, see the table below, `arts` by default' },
45+
features: {
46+
requireConfig: false,
47+
requirePuppeteer: false,
48+
antiCrawler: false,
49+
supportBT: false,
50+
supportPodcast: false,
51+
supportScihub: false,
52+
},
53+
radar: [
54+
{
55+
// a bare `:section` would also match unrelated top-level pages such as /about or /support
56+
source: ['www.kqed.org/:section(arts|news|science|forum|mindshift|perspectives|education)', 'www.kqed.org/:section(arts|news|science|forum|mindshift|perspectives|education)/:id/:slug'],
57+
},
58+
],
59+
name: 'Section',
60+
maintainers: ['IvanWng97'],
61+
handler,
62+
description: `KQED publishes no feed for the current site, and the legacy one marks every image \`loading="lazy"\`, which stops many readers from loading them. This route returns the full post with working images and links to the current site.
63+
64+
| Section | Slug |
65+
| ------------ | -------------- |
66+
| Arts | \`arts\` |
67+
| News | \`news\` |
68+
| Science | \`science\` |
69+
| Forum | \`forum\` |
70+
| MindShift | \`mindshift\` |
71+
| Perspectives | \`perspectives\` |
72+
| Education | \`education\` |`,
73+
};
74+
75+
async function handler(ctx) {
76+
const section = ctx.req.param('section') ?? 'arts';
77+
const sectionName = sections[section as keyof typeof sections];
78+
if (!sectionName) {
79+
throw new InvalidParameterError(`Unknown section "${section}", expected one of ${Object.keys(sections).join(', ')}`);
80+
}
81+
const limit = Number(ctx.req.query('limit')) || 20;
82+
83+
const posts = await ofetch(`${apiBase}/${section}/wp-json/wp/v2/posts`, {
84+
query: {
85+
per_page: limit,
86+
orderby: 'date',
87+
order: 'desc',
88+
_embed: 'wp:featuredmedia,wp:term,author',
89+
},
90+
// the default browser-like Accept header can make WordPress serve the HTML page instead of JSON
91+
headers: { accept: 'application/json' },
92+
});
93+
if (!Array.isArray(posts)) {
94+
throw new TypeError(`Unexpected response from the posts API: ${JSON.stringify(posts).slice(0, 200)}`);
95+
}
96+
97+
const items: DataItem[] = posts.map((post) => {
98+
const featured = post._embedded?.['wp:featuredmedia']?.find((media) => media.id === post.featured_media);
99+
const image = featured?.source_url;
100+
101+
return {
102+
title: decodeHTML(post.title.rendered),
103+
// the API still returns legacy ww2 permalinks, so build the current site's URL instead
104+
link: `${siteBase}/${section}/${post.id}/${post.slug}`,
105+
// WordPress returns *_gmt without a timezone designator
106+
pubDate: parseDate(`${post.date_gmt}Z`),
107+
updated: parseDate(`${post.modified_gmt}Z`),
108+
author: post._embedded?.author?.[0]?.name,
109+
category: (post._embedded?.['wp:term'] ?? [])
110+
.filter((group) => Array.isArray(group))
111+
.flat()
112+
.filter((term) => !skipTaxonomies.has(term.taxonomy))
113+
.map((term) => decodeHTML(term.name)),
114+
description: renderToString(
115+
<>
116+
{image ? (
117+
<figure>
118+
<img src={image} alt={featured.alt_text || undefined} />
119+
{featured.caption?.rendered ? <figcaption>{raw(featured.caption.rendered)}</figcaption> : null}
120+
</figure>
121+
) : null}
122+
{raw(cleanContent(post.content.rendered))}
123+
</>
124+
),
125+
};
126+
});
127+
128+
return {
129+
title: `KQED - ${sectionName}`,
130+
link: `${siteBase}/${section}`,
131+
description: 'Public media for Northern California: Bay Area news, arts and culture, science and education.',
132+
item: items,
133+
};
134+
}

lib/routes/kqed/namespace.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type { Namespace } from '@/types';
2+
3+
export const namespace: Namespace = {
4+
name: 'KQED',
5+
url: 'kqed.org',
6+
categories: ['new-media'],
7+
description: 'Public media for Northern California: Bay Area news, arts and culture, science and education.',
8+
lang: 'en',
9+
};

0 commit comments

Comments
 (0)