RSSHub/lib/routes/theinitium/utils.ts

310 lines
11 KiB
TypeScript

import { load } from 'cheerio';
import type { Context } from 'hono';
import { config } from '@/config';
import InvalidParameterError from '@/errors/types/invalid-parameter';
import cache from '@/utils/cache';
import logger from '@/utils/logger';
import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';
// Strip '-zh-hans' suffix from display names for cleanliness
const stripLangSuffix = (name: string) => name.replace(/-zh-hans$/i, '');
const GHOST_API_BASE = 'https://production-initium-media.ghost.io/ghost/api/content';
const GHOST_CONTENT_KEY = 'a44a0409c222328d39e2c75293';
// Old channel slugs → Ghost tag slugs mapping
export const CHANNEL_TAG_MAP: Record<string, string> = {
latest: '', // no filter = latest
whatsnew: 'whatsnew',
'news-brief': 'whatsnew',
opinion: 'opinion',
international: 'international',
mainland: 'mainland',
hongkong: 'hong-kong',
taiwan: 'taiwan',
technology: 'technology',
feature: 'report',
report: 'report',
'daily-brief': 'daily-brief',
weekly: 'weekly',
};
// Ghost uses a language-based tagging system:
// - zh-hant (Traditional Chinese): uses base tag slug, e.g. "whatsnew", with internal tag #zh-hant
// - zh-hans (Simplified Chinese): uses suffixed tag slug, e.g. "whatsnew-zh-hans", with internal tag #zh-hans
// When no language is specified, we return all posts (both zh-hans and zh-hant mixed).
export function applyLanguageToTagSlug(tagSlug: string, language: string): string {
if (language === 'zh-hans') {
return `${tagSlug}-zh-hans`;
}
// zh-hant uses the base slug
return tagSlug;
}
interface GhostPost {
id: string;
uuid: string;
slug: string;
title: string;
html: string;
feature_image?: string;
feature_image_caption?: string;
custom_excerpt?: string;
published_at: string;
updated_at: string;
url: string;
excerpt?: string;
access: boolean;
visibility?: string;
authors?: Array<{ name: string; slug: string }>;
tags?: Array<{ name: string; slug: string; visibility: string }>;
primary_author?: { name: string; slug: string };
primary_tag?: { name: string; slug: string };
}
interface GhostResponse {
posts: GhostPost[];
meta: {
pagination: {
page: number;
limit: number;
pages: number;
total: number;
};
};
}
export async function ghostFetch(endpoint: string, params: Record<string, string> = {}): Promise<any> {
const url = new URL(`${GHOST_API_BASE}/${endpoint}/`);
url.searchParams.set('key', GHOST_CONTENT_KEY);
for (const [k, v] of Object.entries(params)) {
url.searchParams.set(k, v);
}
return await ofetch(url.href);
}
async function scrapeFullArticle(url: string, cookie: string): Promise<string | null> {
try {
const response = await ofetch(url, {
headers: {
Cookie: cookie,
},
parseResponse: (txt) => txt,
});
const $ = load(response);
const article = $('article');
if (article.length === 0) {
return null;
}
// If paywall CTA present, cookie didn't work — fall back to Ghost preview
if (article.find('.gh-post-upgrade-cta').length > 0) {
return null;
}
return article.html();
} catch (error) {
logger.warn(`Failed to scrape Initium article: ${url}`, error);
return null;
}
}
/**
* Clean Ghost Koenig editor card HTML for RSS consumption.
* Strips kg-* wrapper divs, converts bookmark cards to simple links,
* removes callout markup, etc.
*/
function cleanGhostHtml(html: string): string {
const $ = load(html, null, false);
// Convert kg-bookmark-card to a simple link
$('a.kg-bookmark-container, a.kg-bookmark-card').each((_, el) => {
const $el = $(el);
const href = $el.attr('href') || '';
const title = $el.find('.kg-bookmark-title').text().trim();
const desc = $el.find('.kg-bookmark-description').text().trim();
const replacement = title ? `<p><a href="${href}">${title}</a>${desc ? `${desc}` : ''}</p>` : `<p><a href="${href}">${href}</a></p>`;
$el.replaceWith(replacement);
});
// Convert kg-callout-card: keep text, strip wrapper
$('.kg-callout-card').each((_, el) => {
const $el = $(el);
const text = $el.find('.kg-callout-text').html() || $el.html() || '';
$el.replaceWith(`<blockquote>${text}</blockquote>`);
});
// Convert kg-toggle-card: heading + content
$('.kg-toggle-card').each((_, el) => {
const $el = $(el);
const heading = $el.find('.kg-toggle-heading-text').text().trim();
const content = $el.find('.kg-toggle-content').html() || '';
$el.replaceWith(`${heading ? `<p><strong>${heading}</strong></p>` : ''}${content}`);
});
// Unwrap remaining kg-card divs (keep inner content)
$('.kg-card').each((_, el) => {
const $el = $(el);
$el.replaceWith($el.html() || '');
});
// Remove figure wrapping around bookmark cards that we already replaced
$('figure.kg-bookmark-card').each((_, el) => {
const $el = $(el);
$el.replaceWith($el.html() || '');
});
// Strip members-only paywall markers
$('p:contains("<!--members-only-->")').remove();
return $.html();
}
export async function postsToItems(posts: GhostPost[]) {
const memberCookie = config.initium?.memberCookie;
const items = await Promise.all(
posts.map(async (post) => {
const authors = post.authors?.map((a) => stripLangSuffix(a.name)) ?? [];
const categories = post.tags?.filter((t) => t.visibility === 'public').map((t) => stripLangSuffix(t.name)) ?? [];
let description = post.html ? cleanGhostHtml(post.html) : post.html;
// For paid articles with truncated content, scrape full text if cookie available
if (!post.access && memberCookie) {
const fullHtml = (await cache.tryGet(`theinitium:full:${post.slug}`, () => scrapeFullArticle(post.url, memberCookie), config.cache.contentExpire)) as string | null;
if (fullHtml) {
description = cleanGhostHtml(fullHtml);
}
}
return {
title: post.title,
author: authors.join(', ') || post.primary_author?.name || '',
category: categories,
description,
link: post.url,
pubDate: parseDate(post.published_at),
updated: parseDate(post.updated_at),
guid: post.uuid,
banner: post.feature_image ?? undefined,
};
})
);
return items;
}
export const processFeed = async (model: string, ctx: Context) => {
const type = ctx.req.param('type') ?? 'latest';
const language = ctx.req.param('language') ?? '';
let filter = '';
let listLink = '';
let feedName = '';
switch (model) {
case 'channel': {
const baseTag = CHANNEL_TAG_MAP[type] ?? type;
if (baseTag === '') {
// "latest" = no tag filter, but we can still filter by language via internal tag
if (language === 'zh-hans' || language === 'zh-hant') {
filter = `tag:hash-${language}`;
}
} else {
const tagSlug = language ? applyLanguageToTagSlug(baseTag, language) : baseTag;
filter = `tag:${tagSlug}`;
}
listLink = type === 'latest' ? 'https://theinitium.com/latest/' : `https://theinitium.com/tag/${baseTag}/`;
feedName = type;
break;
}
case 'tags': {
const tagSlug = language ? applyLanguageToTagSlug(type, language) : type;
filter = `tag:${tagSlug}`;
listLink = `https://theinitium.com/tag/${type}/`;
feedName = type;
break;
}
case 'author': {
// Author slugs also have -zh-hans suffixed versions for simplified Chinese
const authorSlug = language === 'zh-hans' ? `${type}-zh-hans` : type;
filter = `author:${authorSlug}`;
listLink = `https://theinitium.com/author/${type}/`;
feedName = type;
break;
}
default:
throw new InvalidParameterError(`Unsupported model: ${model}`);
}
const cacheKey = `theinitium:ghost:${model}:${type}:${language}`;
// Use routeExpire (5 min default) and refresh=false so cache actually expires
const data = (await cache.tryGet(
cacheKey,
async () => {
const params: Record<string, string> = {
include: 'tags,authors',
limit: '20',
};
if (filter) {
params.filter = filter;
}
return await ghostFetch('posts', params);
},
config.cache.routeExpire,
false
)) as GhostResponse;
const items = await postsToItems(data.posts);
// Try to get a nice display name from the first post's relevant tag/author
let displayName = feedName;
if (data.posts.length > 0) {
switch (model) {
case 'channel': {
const baseTag = CHANNEL_TAG_MAP[type] ?? type;
if (baseTag) {
const langTag = language === 'zh-hans' ? `${baseTag}-zh-hans` : baseTag;
const matchedTag = data.posts[0].tags?.find((t) => t.slug === langTag || t.slug === baseTag);
if (matchedTag) {
displayName = stripLangSuffix(matchedTag.name);
}
} else {
displayName = '最新';
}
break;
}
case 'tags': {
const langTag = language === 'zh-hans' ? `${type}-zh-hans` : type;
const matchedTag = data.posts[0].tags?.find((t) => t.slug === langTag || t.slug === type);
if (matchedTag) {
displayName = stripLangSuffix(matchedTag.name);
}
break;
}
case 'author': {
const authorSlug = language === 'zh-hans' ? `${type}-zh-hans` : type;
const matchedAuthor = data.posts[0].authors?.find((a) => a.slug === authorSlug || a.slug === type);
if (matchedAuthor) {
displayName = stripLangSuffix(matchedAuthor.name);
}
break;
}
default:
// Do nothing
}
}
return {
title: `端傳媒 - ${displayName}`,
link: listLink,
icon: 'https://theinitium.com/favicon.ico',
language: language === 'zh-hans' ? 'zh-CN' : 'zh-TW',
item: items,
};
};