|
| 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 | +} |
0 commit comments