From 37bbf928f28efec797018d418067d02b31f25cdb Mon Sep 17 00:00:00 2001 From: Nishant Singh <57475999+Rjnishant530@users.noreply.github.com> Date: Mon, 14 Jul 2025 07:25:22 +0530 Subject: [PATCH] feat(route/newslaundary): add newslaundary (#19576) * init newslaundary * correct path * add youtube support * skip first entry --- lib/routes/newslaundry/explainer.ts | 30 +++++ lib/routes/newslaundry/namespace.ts | 8 ++ lib/routes/newslaundry/nl-cheatsheet.ts | 30 +++++ lib/routes/newslaundry/nl-collaborations.ts | 30 +++++ lib/routes/newslaundry/podcast.ts | 61 ++++++++++ lib/routes/newslaundry/reports.ts | 30 +++++ lib/routes/newslaundry/shot.ts | 30 +++++ lib/routes/newslaundry/subscriber-only.ts | 30 +++++ .../newslaundry/templates/description.art | 28 +++++ lib/routes/newslaundry/utils.ts | 108 ++++++++++++++++++ 10 files changed, 385 insertions(+) create mode 100644 lib/routes/newslaundry/explainer.ts create mode 100644 lib/routes/newslaundry/namespace.ts create mode 100644 lib/routes/newslaundry/nl-cheatsheet.ts create mode 100644 lib/routes/newslaundry/nl-collaborations.ts create mode 100644 lib/routes/newslaundry/podcast.ts create mode 100644 lib/routes/newslaundry/reports.ts create mode 100644 lib/routes/newslaundry/shot.ts create mode 100644 lib/routes/newslaundry/subscriber-only.ts create mode 100644 lib/routes/newslaundry/templates/description.art create mode 100644 lib/routes/newslaundry/utils.ts diff --git a/lib/routes/newslaundry/explainer.ts b/lib/routes/newslaundry/explainer.ts new file mode 100644 index 000000000..dfa4f3bdd --- /dev/null +++ b/lib/routes/newslaundry/explainer.ts @@ -0,0 +1,30 @@ +import { Data, Route, ViewType } from '@/types'; +import { fetchCollection, rootUrl } from './utils'; + +export const route: Route = { + path: '/explainer', + view: ViewType.Articles, + categories: ['new-media'], + example: '/newslaundry/explainer', + features: { + requireConfig: false, + requirePuppeteer: false, + antiCrawler: false, + supportBT: false, + supportPodcast: false, + supportScihub: false, + }, + radar: [ + { + source: ['newslaundry.com/explainer'], + target: '/explainer', + }, + ], + name: 'Explainer', + maintainers: ['Rjnishant530'], + handler, +}; + +async function handler(): Promise { + return await fetchCollection('explainer', `${rootUrl}/explainer`); +} diff --git a/lib/routes/newslaundry/namespace.ts b/lib/routes/newslaundry/namespace.ts new file mode 100644 index 000000000..e0077dc66 --- /dev/null +++ b/lib/routes/newslaundry/namespace.ts @@ -0,0 +1,8 @@ +import type { Namespace } from '@/types'; + +export const namespace: Namespace = { + name: 'Newslaundry', + url: 'newslaundry.com', + categories: ['new-media'], + lang: 'en', +}; diff --git a/lib/routes/newslaundry/nl-cheatsheet.ts b/lib/routes/newslaundry/nl-cheatsheet.ts new file mode 100644 index 000000000..416dfb72d --- /dev/null +++ b/lib/routes/newslaundry/nl-cheatsheet.ts @@ -0,0 +1,30 @@ +import { Data, Route, ViewType } from '@/types'; +import { fetchCollection, rootUrl } from './utils'; + +export const route: Route = { + path: '/nl-cheatsheet', + view: ViewType.Articles, + categories: ['new-media'], + example: '/newslaundry/nl-cheatsheet', + features: { + requireConfig: false, + requirePuppeteer: false, + antiCrawler: false, + supportBT: false, + supportPodcast: false, + supportScihub: false, + }, + radar: [ + { + source: ['newslaundry.com/collection/nl-cheatsheet'], + target: '/nl-cheatsheet', + }, + ], + name: 'Explains', + maintainers: ['Rjnishant530'], + handler, +}; + +async function handler(): Promise { + return await fetchCollection('nl-cheatsheet', `${rootUrl}/collection/nl-cheatsheet`); +} diff --git a/lib/routes/newslaundry/nl-collaborations.ts b/lib/routes/newslaundry/nl-collaborations.ts new file mode 100644 index 000000000..7c661b0b8 --- /dev/null +++ b/lib/routes/newslaundry/nl-collaborations.ts @@ -0,0 +1,30 @@ +import { Data, Route, ViewType } from '@/types'; +import { fetchCollection, rootUrl } from './utils'; + +export const route: Route = { + path: '/nl-collaborations', + view: ViewType.Articles, + categories: ['new-media'], + example: '/newslaundry/nl-collaborations', + features: { + requireConfig: false, + requirePuppeteer: false, + antiCrawler: false, + supportBT: false, + supportPodcast: false, + supportScihub: false, + }, + radar: [ + { + source: ['newslaundry.com/nl-collaborations'], + target: '/nl-collaborations', + }, + ], + name: 'NL Collaboration', + maintainers: ['Rjnishant530'], + handler, +}; + +async function handler(): Promise { + return await fetchCollection('nl-collaborations', `${rootUrl}/nl-collaborations`); +} diff --git a/lib/routes/newslaundry/podcast.ts b/lib/routes/newslaundry/podcast.ts new file mode 100644 index 000000000..fa6422de5 --- /dev/null +++ b/lib/routes/newslaundry/podcast.ts @@ -0,0 +1,61 @@ +import { Data, Route, ViewType } from '@/types'; +import { fetchCollection, rootUrl } from './utils'; + +export const route: Route = { + path: '/podcast/:category?', + view: ViewType.Articles, + categories: ['new-media'], + example: '/newslaundry/podcast', + parameters: { category: 'Podcast category, see below for details' }, + features: { + requireConfig: false, + requirePuppeteer: false, + antiCrawler: false, + supportBT: false, + supportPodcast: true, + supportScihub: false, + }, + radar: [ + { + source: ['newslaundry.com/podcast'], + target: '/podcast', + }, + { + source: ['newslaundry.com/collection/nl-hafta-podcast'], + target: '/podcast/nl-hafta', + }, + { + source: ['newslaundry.com/podcast/whats-your-ism'], + target: '/podcast/whats-your-ism', + }, + ], + name: 'Podcast', + description: `| Category | URL | +| -------- | --- | +| All Podcasts | [/podcast](https://rsshub.app/newslaundry/podcast) | +| NL Hafta | [/podcast/nl-hafta](https://rsshub.app/newslaundry/podcast/nl-hafta) | +| What's Your Ism? | [/podcast/whats-your-ism](https://rsshub.app/newslaundry/podcast/whats-your-ism) |`, + maintainers: ['Rjnishant530'], + handler, +}; + +async function handler(ctx): Promise { + const category = ctx.req.param('category'); + + // Map category to collection slug and URL + const categoryMap = { + 'nl-hafta': { + slug: 'nl-hafta-podcast', + url: `${rootUrl}/collection/nl-hafta-podcast`, + }, + 'whats-your-ism': { + slug: 'whats-your-ism-podcast-newslaundry-hindi', + url: `${rootUrl}/podcast/whats-your-ism`, + }, + }; + + // For main podcast route, skip the first item + const skipFirstItem = !category; + + return category && categoryMap[category] ? await fetchCollection(categoryMap[category].slug, categoryMap[category].url) : await fetchCollection('podcast', undefined, skipFirstItem); +} diff --git a/lib/routes/newslaundry/reports.ts b/lib/routes/newslaundry/reports.ts new file mode 100644 index 000000000..f0d9e59bf --- /dev/null +++ b/lib/routes/newslaundry/reports.ts @@ -0,0 +1,30 @@ +import { Data, Route, ViewType } from '@/types'; +import { fetchCollection } from './utils'; + +export const route: Route = { + path: '/reports', + view: ViewType.Articles, + categories: ['new-media'], + example: '/newslaundry/reports', + features: { + requireConfig: false, + requirePuppeteer: false, + antiCrawler: false, + supportBT: false, + supportPodcast: false, + supportScihub: false, + }, + radar: [ + { + source: ['newslaundry.com/reports'], + target: '/reports', + }, + ], + name: 'Reports', + maintainers: ['Rjnishant530'], + handler, +}; + +async function handler(): Promise { + return await fetchCollection('reports', undefined, true); +} diff --git a/lib/routes/newslaundry/shot.ts b/lib/routes/newslaundry/shot.ts new file mode 100644 index 000000000..b25943a55 --- /dev/null +++ b/lib/routes/newslaundry/shot.ts @@ -0,0 +1,30 @@ +import { Data, Route, ViewType } from '@/types'; +import { fetchCollection } from './utils'; + +export const route: Route = { + path: '/shot', + view: ViewType.Articles, + categories: ['new-media'], + example: '/newslaundry/shot', + features: { + requireConfig: false, + requirePuppeteer: false, + antiCrawler: false, + supportBT: false, + supportPodcast: false, + supportScihub: false, + }, + radar: [ + { + source: ['newslaundry.com/shot'], + target: '/shot', + }, + ], + name: 'Shot', + maintainers: ['Rjnishant530'], + handler, +}; + +async function handler(): Promise { + return await fetchCollection('shot'); +} diff --git a/lib/routes/newslaundry/subscriber-only.ts b/lib/routes/newslaundry/subscriber-only.ts new file mode 100644 index 000000000..3218cffa4 --- /dev/null +++ b/lib/routes/newslaundry/subscriber-only.ts @@ -0,0 +1,30 @@ +import { Data, Route, ViewType } from '@/types'; +import { fetchCollection } from './utils'; + +export const route: Route = { + path: '/subscriber-only', + view: ViewType.Articles, + categories: ['new-media'], + example: '/newslaundry/subscriber-only', + features: { + requireConfig: false, + requirePuppeteer: false, + antiCrawler: false, + supportBT: false, + supportPodcast: false, + supportScihub: false, + }, + radar: [ + { + source: ['newslaundry.com/subscriber-only'], + target: '/subscriber-only', + }, + ], + name: 'Subscriber Only', + maintainers: ['Rjnishant530'], + handler, +}; + +async function handler(): Promise { + return await fetchCollection('subscriber-only'); +} diff --git a/lib/routes/newslaundry/templates/description.art b/lib/routes/newslaundry/templates/description.art new file mode 100644 index 000000000..a5126e29b --- /dev/null +++ b/lib/routes/newslaundry/templates/description.art @@ -0,0 +1,28 @@ +{{if subheadline}} +

{{subheadline}}

+{{/if}} + +{{if heroImage}} +
+ {{heroAlt}} +
{{heroCaption}}{{if heroAttribution}} ({{heroAttribution}}){{/if}}
+
+{{/if}} + +{{each elements}} + {{if $value.type === 'text'}} + {{@ $value.text}} + {{else if $value.type === 'image'}} +
+ {{$value.alt}} +
{{$value.title}}
+
+ {{else if $value.type === 'jsembed'}} + {{@ $value.content}} + {{else if $value.type === 'youtube-video'}} +
+ +
Watch on YouTube
+
+ {{/if}} +{{/each}} diff --git a/lib/routes/newslaundry/utils.ts b/lib/routes/newslaundry/utils.ts new file mode 100644 index 000000000..479e7db31 --- /dev/null +++ b/lib/routes/newslaundry/utils.ts @@ -0,0 +1,108 @@ +import { Data, DataItem } from '@/types'; +import ofetch from '@/utils/ofetch'; +import { parseDate } from '@/utils/parse-date'; +import { art } from '@/utils/render'; +import path from 'node:path'; + +export const rootUrl = 'https://www.newslaundry.com'; + +export async function fetchCollection(collectionSlug: string, customUrl?: string, skipFirstItem: boolean = false) { + const apiUrl = `${rootUrl}/api/v1/collections/${collectionSlug}`; + const currentUrl = customUrl || `${rootUrl}/${collectionSlug}`; + + const response = await ofetch(apiUrl); + + if (!response.items || !response.items.length) { + throw new Error('No articles found'); + } + + // Skip first item if requested + const itemsToProcess = skipFirstItem ? response.items.slice(1) : response.items; + const items = itemsToProcess.map((item) => processStory(item.story)); + + return { + title: `${response.name} - Newslaundry`, + description: response.summary || `${response.name} articles from Newslaundry`, + link: currentUrl, + item: items, + language: 'en', + logo: `${rootUrl}/favicon.ico`, + icon: `${rootUrl}/favicon.ico`, + } as Data; +} + +function processStory(story: any): DataItem { + const articleUrl = story.url; + const pubDate = story['published-at'] ? parseDate(story['published-at']) : null; + + // Prepare data for template + const heroImage = story['hero-image-s3-key'] ? `https://media.assettype.com/${story['hero-image-s3-key']}?auto=format%2Ccompress&fit=max&dpr=1.0&format=webp` : null; + + // Extract elements from cards + const elements = + story.cards?.flatMap( + (card) => + card?.['story-elements'] + ?.map((element) => { + if (element.type === 'text' && element.text) { + return { + type: 'text', + text: element.text, + }; + } else if (element.type === 'image' && element['image-s3-key']) { + return { + type: 'image', + url: `https://media.assettype.com/${element['image-s3-key']}?auto=format%2Ccompress&format=webp`, + alt: element['alt-text'] || '', + title: element.title || '', + }; + } else if (element.type === 'jsembed' && element['embed-js']) { + try { + return { + type: 'jsembed', + content: Buffer.from(element['embed-js'], 'base64').toString(), + }; + } catch { + // Skip if unable to decode + return null; + } + } else if (element.type === 'youtube-video' && element.url) { + return { + type: 'youtube-video', + url: element.url, + embedUrl: element['embed-url'] || '', + }; + } + return null; + }) + .filter(Boolean) || [] + ) || []; + + // Render content using template + const content = art(path.join(__dirname, 'templates/description.art'), { + heroImage, + heroAlt: story['hero-image-alt-text'] || '', + heroCaption: story['hero-image-caption'] || '', + heroAttribution: story['hero-image-attribution'], + elements, + subheadline: story.subheadline, + }); + + // Extract author information + const authors = + story.authors?.map((author) => ({ + name: author.name, + url: author.slug ? `${rootUrl}/author/${author.slug}` : undefined, + })) || []; + + return { + title: story.headline, + link: articleUrl, + image: heroImage, + description: content || story.subheadline, + pubDate, + updated: story['last-correction-published-at'] ? parseDate(story['last-correction-published-at']) : undefined, + author: authors.length > 0 ? authors : story['author-name'], + category: story.tags?.map((tag) => tag.name) || [], + } as DataItem; +}