feat(route/gcores): add user content routes with filtering (#21258)

* feat(route/gcores): add user content routes with filtering

- Add user-talks route for user social posts, excluding podcasts and videos
- Add user-radios route for user podcasts with exclude_albums parameter to filter out specific albums
- Update cache middleware to include exclude_albums in cache key for proper cache separation

* chore(gcores): appease codefactor by simplifying radios handler

* Update lib/routes/gcores/user-radios.ts

Co-authored-by: Tony <TonyRL@users.noreply.github.com>

* Update lib/routes/gcores/user-talks.ts

Co-authored-by: Tony <TonyRL@users.noreply.github.com>

* fix: address PR review - add maintainer ID and remove inferrable types

* refactor(route/gcores): replace exclude_albums with album category for common param filtering

---------
This commit is contained in:
Dzming Li 2026-03-03 11:30:48 -08:00 committed by GitHub
parent 8043a5a819
commit 0ac1e409d8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 327 additions and 0 deletions

View File

@ -0,0 +1,185 @@
import type { CheerioAPI } from 'cheerio';
import { load } from 'cheerio';
import type { Context } from 'hono';
import type { Data, DataItem, Route } from '@/types';
import { ViewType } from '@/types';
import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';
import { parseContent } from './parser';
import { renderDescription } from './templates/description';
import { baseUrl, imageBaseUrl } from './util';
const buildAuthors = (relationships: any, included: any[]): DataItem['author'] => {
const authorObj = relationships?.user?.data;
const authorIncluded = authorObj ? included.find((i) => i.type === authorObj.type && i.id === authorObj.id) : undefined;
return authorIncluded
? [
{
name: authorIncluded.attributes?.nickname,
url: authorIncluded.id ? new URL(`users/${authorIncluded.id}`, baseUrl).href : undefined,
avatar: authorIncluded.thumb ? new URL(authorIncluded.thumb, imageBaseUrl).href : undefined,
},
]
: undefined;
};
const buildEnclosure = (attributes: any, relationships: any, included: any[], image: string | undefined, title: string | undefined) => {
const mediaAttrs = included.find((i) => i.id === relationships.media?.data?.id)?.attributes;
let enclosureUrl: string | undefined;
let enclosureType: string | undefined;
if (attributes['speech-path']) {
enclosureUrl = new URL(`uploads/audio/${attributes['speech-path']}`, 'https://alioss.gcores.com').href;
enclosureType = `audio/${enclosureUrl?.split(/\./).pop()}`;
} else if (mediaAttrs && mediaAttrs.audio) {
enclosureUrl = mediaAttrs.audio;
enclosureType = `audio/${enclosureUrl?.split(/\./).pop()}`;
}
if (!enclosureUrl) {
return {};
}
const enclosureLength = attributes.duration ? Number(attributes.duration) : 0;
return {
enclosure_url: enclosureUrl,
enclosure_type: enclosureType,
enclosure_title: title,
enclosure_length: enclosureLength,
itunes_duration: enclosureLength,
itunes_item_image: image,
};
};
const buildDescription = (attributes: any, title: string | undefined, enclosureUrl?: string, enclosureType?: string) =>
renderDescription({
images: attributes.cover
? [
{
src: new URL(attributes.cover, imageBaseUrl).href,
alt: title,
},
]
: undefined,
audios:
enclosureType?.startsWith('audio') && enclosureUrl
? [
{
src: enclosureUrl,
type: enclosureType,
},
]
: undefined,
intro: attributes.desc || attributes.excerpt,
description: attributes.content ? parseContent(JSON.parse(attributes.content)) : undefined,
});
export const handler = async (ctx: Context): Promise<Data> => {
const { id } = ctx.req.param();
const limit = Number.parseInt(ctx.req.query('limit') ?? '30', 10);
const targetUrl = new URL(`users/${id}/content?tab=radios`, baseUrl).href;
const apiUrl = new URL(`gapi/v1/users/${id}/radios`, baseUrl).href;
const query = {
'page[limit]': limit,
sort: '-published-at',
include: 'user,media,albums',
};
const response = await ofetch(apiUrl, { query });
const data = response.data;
const targetResponse = await ofetch(targetUrl);
const $: CheerioAPI = load(targetResponse);
const language = $('html').attr('lang') ?? 'zh-CN';
const included = response.included || [];
// 处理每个播客项目
const items: DataItem[] = data.map((item): DataItem => {
const attributes = item.attributes;
const relationships = item.relationships;
const title = attributes.title;
const pubDate: number | string = attributes['published-at'];
const linkUrl = new URL(`radios/${item.id}`, baseUrl).href;
const image: string | undefined = (attributes.cover ?? attributes.thumb) ? new URL(attributes.cover ?? attributes.thumb, imageBaseUrl).href : undefined;
const authors = buildAuthors(relationships, included);
const enclosure = buildEnclosure(attributes, relationships, included, image, title);
const description = buildDescription(attributes, title, enclosure.enclosure_url, enclosure.enclosure_type);
const albumNames = (relationships?.albums?.data || []).map((album) => included.find((i) => i.type === album.type && i.id === album.id)?.attributes?.title).filter(Boolean);
return {
title: title ?? $(description).text(),
pubDate: pubDate ? parseDate(pubDate) : undefined,
link: linkUrl,
author: authors,
category: albumNames.length > 0 ? albumNames : undefined,
guid: `gcores-${item.id}`,
id: `gcores-${item.id}`,
image,
banner: image,
updated: pubDate ? parseDate(pubDate) : undefined,
language,
description,
content: {
html: description,
text: description,
},
...enclosure,
};
});
return {
title: $('title').text(),
description: $('meta[name="description"]').attr('content'),
link: targetUrl,
item: items,
allowEmpty: true,
author: $('title').text().split(/\|/).pop()?.trim(),
language,
id: $('meta[property="og:url"]').attr('content'),
};
};
export const route: Route = {
path: '/users/:id/radios',
name: '用户播客',
url: 'www.gcores.com',
maintainers: ['DzmingLi'],
handler,
example: '/gcores/users/31418/radios',
parameters: {
id: {
description: '用户 ID可在用户主页 URL 中找到',
},
},
description: `::: tip
[](https://www.gcores.com/users/31418) 发布的播客,网址为 \`https://www.gcores.com/users/31418\`,请截取 \`https://www.gcores.com/users/\` 之后的部分 \`31418\` 作为 \`id\` 参数填入,此时目标路由为 [\`/gcores/users/31418/radios\`](https://rsshub.app/gcores/users/31418/radios)。
:::
`,
categories: ['game'],
features: {
requireConfig: false,
requirePuppeteer: false,
antiCrawler: false,
supportRadar: true,
supportBT: false,
supportPodcast: true,
supportScihub: false,
},
radar: [
{
source: ['www.gcores.com/users/:id/content', 'www.gcores.com/users/:id'],
target: '/users/:id/radios',
},
],
view: ViewType.Audios,
};

View File

@ -0,0 +1,142 @@
import type { CheerioAPI } from 'cheerio';
import { load } from 'cheerio';
import type { Context } from 'hono';
import type { Data, DataItem, Route } from '@/types';
import { ViewType } from '@/types';
import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';
import { parseContent } from './parser';
import { renderDescription } from './templates/description';
import { baseUrl, imageBaseUrl } from './util';
export const handler = async (ctx: Context): Promise<Data> => {
const { id } = ctx.req.param();
const limit = Number.parseInt(ctx.req.query('limit') ?? '30', 10);
const targetUrl = new URL(`users/${id}/talks`, baseUrl).href;
const apiUrl = new URL(`gapi/v1/users/${id}/talks`, baseUrl).href;
// 获取更多数据以确保过滤后仍有足够的项目
const fetchLimit = Math.min(limit * 2, 100);
const query = {
'page[limit]': fetchLimit,
sort: '-created-at',
include: 'user',
};
const response = await ofetch(apiUrl, { query });
const targetResponse = await ofetch(targetUrl);
const $: CheerioAPI = load(targetResponse);
const language = $('html').attr('lang') ?? 'zh-CN';
const included = response.included || [];
// 过滤掉播客和视频类型的动态
let data = response.data.filter((item) => item.type !== 'radios' && item.type !== 'videos');
// 限制数量
data = data.slice(0, limit);
const items: DataItem[] = data.map((item): DataItem => {
const attributes = item.attributes;
const relationships = item.relationships;
const title: string = attributes.title;
const pubDate: number | string = attributes['created-at'] || attributes['published-at'];
const authorObj = relationships?.user?.data;
const authorIncluded = authorObj ? included.find((i) => i.type === authorObj.type && i.id === authorObj.id) : undefined;
const authors: DataItem['author'] = authorIncluded
? [
{
name: authorIncluded.attributes?.nickname,
url: authorIncluded.id ? new URL(`users/${authorIncluded.id}`, baseUrl).href : undefined,
avatar: authorIncluded.thumb ? new URL(authorIncluded.thumb, imageBaseUrl).href : undefined,
},
]
: undefined;
const guid = `gcores-${item.type}-${item.id}`;
const image: string | undefined = (attributes.cover ?? attributes.thumb) ? new URL(attributes.cover ?? attributes.thumb, imageBaseUrl).href : undefined;
const description = renderDescription({
images: attributes.cover
? [
{
src: new URL(attributes.cover, imageBaseUrl).href,
alt: title,
},
]
: undefined,
intro: attributes.desc || attributes.excerpt,
description: attributes.content ? parseContent(JSON.parse(attributes.content)) : undefined,
});
return {
title: title ?? $(description).text(),
pubDate: pubDate ? parseDate(pubDate) : undefined,
link: new URL(`${item.type}/${item.id}`, baseUrl).href,
author: authors,
guid,
id: guid,
image,
banner: image,
updated: pubDate ? parseDate(pubDate) : undefined,
language,
description,
content: {
html: description,
text: description,
},
};
});
return {
title: $('title').text(),
description: $('meta[name="description"]').attr('content'),
link: targetUrl,
item: items,
allowEmpty: true,
author: $('title').text().split(/\|/).pop()?.trim(),
language,
id: $('meta[property="og:url"]').attr('content'),
};
};
export const route: Route = {
path: '/users/:id/talks',
name: '用户动态',
url: 'www.gcores.com',
maintainers: ['DzmingLi'],
handler,
example: '/gcores/users/31418/talks',
parameters: {
id: {
description: '用户 ID可在用户主页 URL 中找到',
},
},
description: `::: tip
[](https://www.gcores.com/users/31418/talks) 的动态,网址为 \`https://www.gcores.com/users/31418/talks\`,请截取 \`https://www.gcores.com/users/\` 到 \`/talks\` 之间的部分 \`31418\` 作为 \`id\` 参数填入,此时目标路由为 [\`/gcores/users/31418/talks\`](https://rsshub.app/gcores/users/31418/talks)。
:::
`,
categories: ['game'],
features: {
requireConfig: false,
requirePuppeteer: false,
antiCrawler: false,
supportRadar: true,
supportBT: false,
supportPodcast: false,
supportScihub: false,
},
radar: [
{
source: ['www.gcores.com/users/:id/talks'],
target: '/users/:id/talks',
},
],
view: ViewType.SocialMedia,
};