feat(route/newslaundary): add newslaundary (#19576)

* init newslaundary

* correct path

* add youtube support

* skip first entry
This commit is contained in:
Nishant Singh 2025-07-14 07:25:22 +05:30 committed by GitHub
parent c6f96ef5a9
commit 37bbf928f2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 385 additions and 0 deletions

View File

@ -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<Data> {
return await fetchCollection('explainer', `${rootUrl}/explainer`);
}

View File

@ -0,0 +1,8 @@
import type { Namespace } from '@/types';
export const namespace: Namespace = {
name: 'Newslaundry',
url: 'newslaundry.com',
categories: ['new-media'],
lang: 'en',
};

View File

@ -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<Data> {
return await fetchCollection('nl-cheatsheet', `${rootUrl}/collection/nl-cheatsheet`);
}

View File

@ -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<Data> {
return await fetchCollection('nl-collaborations', `${rootUrl}/nl-collaborations`);
}

View File

@ -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<Data> {
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);
}

View File

@ -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<Data> {
return await fetchCollection('reports', undefined, true);
}

View File

@ -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<Data> {
return await fetchCollection('shot');
}

View File

@ -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<Data> {
return await fetchCollection('subscriber-only');
}

View File

@ -0,0 +1,28 @@
{{if subheadline}}
<p><strong>{{subheadline}}</strong></p>
{{/if}}
{{if heroImage}}
<figure>
<img src="{{heroImage}}" alt="{{heroAlt}}">
<figcaption>{{heroCaption}}{{if heroAttribution}} ({{heroAttribution}}){{/if}}</figcaption>
</figure>
{{/if}}
{{each elements}}
{{if $value.type === 'text'}}
{{@ $value.text}}
{{else if $value.type === 'image'}}
<figure>
<img src="{{$value.url}}" alt="{{$value.alt}}">
<figcaption>{{$value.title}}</figcaption>
</figure>
{{else if $value.type === 'jsembed'}}
{{@ $value.content}}
{{else if $value.type === 'youtube-video'}}
<figure>
<iframe width="560" height="315" src="{{$value.embedUrl}}" frameborder="0" allowfullscreen></iframe>
<figcaption><a href="{{$value.url}}" target="_blank" rel="noopener noreferrer">Watch on YouTube</a></figcaption>
</figure>
{{/if}}
{{/each}}

View File

@ -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;
}