From e334550d61a94e439021f5b1f6ab84561ba39eb3 Mon Sep 17 00:00:00 2001 From: LinxHex <92703641+LinxHex@users.noreply.github.com> Date: Sat, 4 Apr 2026 14:36:25 +0800 Subject: [PATCH] feat(route): add Peter Wunder achievements badges feed (#21580) * feat(route): add Peter Wunder achievements badges feed * fix(route): address review feedback for Peter Wunder achievements --- lib/routes/peterwunder/achievements.ts | 138 +++++++++++++++++++++++++ lib/routes/peterwunder/namespace.ts | 9 ++ 2 files changed, 147 insertions(+) create mode 100644 lib/routes/peterwunder/achievements.ts create mode 100644 lib/routes/peterwunder/namespace.ts diff --git a/lib/routes/peterwunder/achievements.ts b/lib/routes/peterwunder/achievements.ts new file mode 100644 index 000000000..3ba06d5d6 --- /dev/null +++ b/lib/routes/peterwunder/achievements.ts @@ -0,0 +1,138 @@ +import type { CheerioAPI } from 'cheerio'; +import { load } from 'cheerio'; + +import type { DataItem, Route } from '@/types'; +import { ViewType } from '@/types'; +import cache from '@/utils/cache'; +import got from '@/utils/got'; +import { parseDate } from '@/utils/parse-date'; + +const author = 'Peter Wunder'; +const rootUrl = 'https://projects.peterwunder.de'; +const currentUrl = new URL('/achievements/', rootUrl).href; +const icon = new URL('/achievements/images/touchicon.png', rootUrl).href; +const defaultLimit = 20; + +type BadgeItem = DataItem & { + link: string; + title: string; +}; + +function absolutizeImageSource($: CheerioAPI, itemUrl: string) { + $('article') + .first() + .find('[src]') + .each((_, element) => { + const value = $(element).attr('src'); + + if (value) { + $(element).attr('src', new URL(value, itemUrl).href); + } + }); +} + +function extractBadgeDescription($: CheerioAPI) { + const article = $('article').first(); + + if (!article.length) { + return; + } + + article.find('h1, script, style, noscript').remove(); + + return article.html() ?? undefined; +} + +function extractListItems($: CheerioAPI, limit: number): BadgeItem[] { + return $('section.badges a.badge') + .slice(0, limit) + .toArray() + .map((element) => { + const badge = $(element); + const href = badge.attr('href'); + const title = badge.find('.title').text().trim(); + + if (!href || !title) { + return null; + } + + const image = badge.find('img').attr('src'); + const visibleStart = badge.attr('data-vis-start'); + + return { + title, + link: new URL(href, rootUrl).href, + pubDate: visibleStart ? parseDate(visibleStart) : undefined, + image: image ? new URL(image, rootUrl).href : undefined, + }; + }) + .filter(Boolean) as BadgeItem[]; +} + +function fetchBadge(item: BadgeItem) { + return cache.tryGet(item.link, async () => { + const { data: response } = await got(item.link); + const $: CheerioAPI = load(response); + + const title = $('article h1').first().text().trim(); + const visibleStart = $('ul.metadata li').first().find('time.date').first().attr('datetime'); + const image = $('meta[property="og:image"]').attr('content'); + absolutizeImageSource($, item.link); + + return { + ...item, + title: title || item.title, + description: extractBadgeDescription($), + pubDate: visibleStart ? parseDate(visibleStart) : item.pubDate, + author, + image: image ? new URL(image, rootUrl).href : item.image, + }; + }); +} + +const handler: Route['handler'] = async (ctx) => { + const limit = Math.max(Number.parseInt(ctx.req.query('limit') ?? '', 10) || defaultLimit, 1); + + const { data: response } = await got(currentUrl); + const $: CheerioAPI = load(response); + + const items = await Promise.all(extractListItems($, limit).map((item) => fetchBadge(item))); + + return { + title: 'All Activity Challenges - New Badges', + description: "Latest badge pages from Peter Wunder's All Activity Challenges catalog. The website's own Atom feed was discontinued on August 20, 2024, so this route follows the latest entries directly from the site.", + link: currentUrl, + item: items, + language: 'en', + author, + icon, + logo: icon, + image: icon, + }; +}; + +export const route: Route = { + path: '/achievements', + categories: ['other'], + view: ViewType.Pictures, + example: '/peterwunder/achievements', + parameters: {}, + features: { + requireConfig: false, + requirePuppeteer: false, + antiCrawler: false, + supportBT: false, + supportPodcast: false, + supportScihub: false, + }, + radar: [ + { + source: ['projects.peterwunder.de/achievements'], + }, + ], + name: 'New Badges', + maintainers: ['LinxHex'], + description: "Latest badge pages from Peter Wunder's All Activity Challenges catalog. `pubDate` uses the first 'Visible in the app' date because the site does not expose a publication timestamp.", + handler, + url: 'projects.peterwunder.de/achievements', +}; diff --git a/lib/routes/peterwunder/namespace.ts b/lib/routes/peterwunder/namespace.ts new file mode 100644 index 000000000..52e07a75e --- /dev/null +++ b/lib/routes/peterwunder/namespace.ts @@ -0,0 +1,9 @@ +import type { Namespace } from '@/types'; + +export const namespace: Namespace = { + name: 'Peter Wunder', + url: 'projects.peterwunder.de', + categories: ['other'], + description: 'Projects and catalogs maintained by Peter Wunder.', + lang: 'en', +};