feat(route): add 钛媒体专栏 (#18532)

This commit is contained in:
Ethan Shen 2025-03-07 10:44:33 +08:00 committed by GitHub
parent 64f6d388a4
commit 7ee74e4e87
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 509 additions and 199 deletions

View File

@ -0,0 +1,298 @@
import { type Data, type Route, ViewType } from '@/types';
import { type Context } from 'hono';
import { baseUrl, apiBaseUrl, processItems } from './util';
export const handler = async (ctx: Context): Promise<Data> => {
const { id } = ctx.req.param();
const limit: number = Number.parseInt(ctx.req.query('limit') ?? '30', 10);
const targetUrl: string = new URL(`column/${id}`, baseUrl).href;
const listApiUrl: string = new URL('v1/categories/multi_content/list', apiBaseUrl).href;
const query = {
subtype: 'post',
category_guid: id,
};
return await processItems(limit, query, listApiUrl, targetUrl);
};
export const route: Route = {
path: '/column/:id',
name: '最新',
url: 'www.tmtpost.com',
maintainers: ['nczitzk'],
handler,
example: '/tmtpost/column/6916385',
parameters: {
id: {
description: '专栏 id可在对应专栏页 URL 中找到',
options: [
{
label: 'AGI',
value: '6916385',
},
{
label: '出海',
value: '6998081',
},
{
label: '创新场景',
value: '3882035',
},
{
label: '钛度号',
value: '6100587',
},
{
label: '深度',
value: '3189960',
},
{
label: '焦点',
value: '6043895',
},
{
label: '创投',
value: '5994956',
},
{
label: '汽车',
value: '2573550',
},
{
label: '3C',
value: '3615534',
},
{
label: '消费',
value: '3882530',
},
{
label: '大健康',
value: '3882507',
},
{
label: '金融',
value: '3882486',
},
{
label: '钛智宏观',
value: '4277188',
},
{
label: '产业研究',
value: '5506730',
},
{
label: '地产',
value: '3882499',
},
{
label: '大公司',
value: '2446153',
},
{
label: 'IPO',
value: '6043750',
},
{
label: '钛度图闻',
value: '5750087',
},
{
label: '城视',
value: '6998636',
},
{
label: '创业家',
value: '4273329',
},
{
label: '人文',
value: '6252390',
},
{
label: '新职业研究所',
value: '5750104',
},
{
label: '科普',
value: '5714992',
},
{
label: '文娱',
value: '2446157',
},
],
},
},
description: `:::tip
[AGI](https://www.tmtpost.com/column/6916385),网址为 \`https://www.tmtpost.com/column/6916385\`,请截取 \`https://www.tmtpost.com/column\` 到末尾的部分 \`6916385\` 作为 \`id\` 参数填入,此时目标路由为 [\`/tmtpost/column/6916385\`](https://rsshub.app/tmtpost/column/6916385)。
:::
<details>
<summary></summary>
| [AGI](https://www.tmtpost.com/column/6916385) | [出海](https://www.tmtpost.com/column/6998081) | [创新场景](https://www.tmtpost.com/column/3882035) | [钛度号](https://www.tmtpost.com/column/6100587) | [深度](https://www.tmtpost.com/column/3189960) |
| ---------------------------------------------------- | ---------------------------------------------------- | ---------------------------------------------------- | ---------------------------------------------------- | ---------------------------------------------------- |
| [6916385](https://rsshub.app/tmtpost/column/6916385) | [6998081](https://rsshub.app/tmtpost/column/6998081) | [3882035](https://rsshub.app/tmtpost/column/3882035) | [6100587](https://rsshub.app/tmtpost/column/6100587) | [3189960](https://rsshub.app/tmtpost/column/3189960) |
| [](https://www.tmtpost.com/column/6043895) | [创投](https://www.tmtpost.com/column/5994956) | [汽车](https://www.tmtpost.com/column/2573550) | [3C](https://www.tmtpost.com/column/3615534) | [消费](https://www.tmtpost.com/column/3882530) |
| ---------------------------------------------------- | ---------------------------------------------------- | ---------------------------------------------------- | ---------------------------------------------------- | ---------------------------------------------------- |
| [6043895](https://rsshub.app/tmtpost/column/6043895) | [5994956](https://rsshub.app/tmtpost/column/5994956) | [2573550](https://rsshub.app/tmtpost/column/2573550) | [3615534](https://rsshub.app/tmtpost/column/3615534) | [3882530](https://rsshub.app/tmtpost/column/3882530) |
| [](https://www.tmtpost.com/column/3882507) | [金融](https://www.tmtpost.com/column/3882486) | [钛智宏观](https://www.tmtpost.com/column/4277188) | [产业研究](https://www.tmtpost.com/column/5506730) | [地产](https://www.tmtpost.com/column/3882499) |
| ---------------------------------------------------- | ---------------------------------------------------- | ---------------------------------------------------- | ---------------------------------------------------- | ---------------------------------------------------- |
| [3882507](https://rsshub.app/tmtpost/column/3882507) | [3882486](https://rsshub.app/tmtpost/column/3882486) | [4277188](https://rsshub.app/tmtpost/column/4277188) | [5506730](https://rsshub.app/tmtpost/column/5506730) | [3882499](https://rsshub.app/tmtpost/column/3882499) |
| [](https://www.tmtpost.com/column/2446153) | [IPO](https://www.tmtpost.com/column/6043750) | [钛度图闻](https://www.tmtpost.com/column/5750087) | [城视](https://www.tmtpost.com/column/6998636) | [创业家](https://www.tmtpost.com/column/4273329) |
| ---------------------------------------------------- | ---------------------------------------------------- | ---------------------------------------------------- | ---------------------------------------------------- | ---------------------------------------------------- |
| [2446153](https://rsshub.app/tmtpost/column/2446153) | [6043750](https://rsshub.app/tmtpost/column/6043750) | [5750087](https://rsshub.app/tmtpost/column/5750087) | [6998636](https://rsshub.app/tmtpost/column/6998636) | [4273329](https://rsshub.app/tmtpost/column/4273329) |
| [](https://www.tmtpost.com/column/6252390) | [新职业研究所](https://www.tmtpost.com/column/5750104) | [科普](https://www.tmtpost.com/column/5714992) | [文娱](https://www.tmtpost.com/column/2446157) |
| ---------------------------------------------------- | ------------------------------------------------------ | ---------------------------------------------------- | ---------------------------------------------------- |
| [6252390](https://rsshub.app/tmtpost/column/6252390) | [5750104](https://rsshub.app/tmtpost/column/5750104) | [5714992](https://rsshub.app/tmtpost/column/5714992) | [2446157](https://rsshub.app/tmtpost/column/2446157) |
</details>
`,
categories: ['new-media'],
features: {
requireConfig: false,
requirePuppeteer: false,
antiCrawler: false,
supportRadar: true,
supportBT: false,
supportPodcast: false,
supportScihub: false,
},
radar: [
{
source: ['www.tmtpost.com/column/:id'],
target: '/column/:id',
},
{
title: 'AGI',
source: ['www.tmtpost.com/column/6916385'],
target: '/column/6916385',
},
{
title: '出海',
source: ['www.tmtpost.com/column/6998081'],
target: '/column/6998081',
},
{
title: '创新场景',
source: ['www.tmtpost.com/column/3882035'],
target: '/column/3882035',
},
{
title: '钛度号',
source: ['www.tmtpost.com/column/6100587'],
target: '/column/6100587',
},
{
title: '深度',
source: ['www.tmtpost.com/column/3189960'],
target: '/column/3189960',
},
{
title: '焦点',
source: ['www.tmtpost.com/column/6043895'],
target: '/column/6043895',
},
{
title: '创投',
source: ['www.tmtpost.com/column/5994956'],
target: '/column/5994956',
},
{
title: '汽车',
source: ['www.tmtpost.com/column/2573550'],
target: '/column/2573550',
},
{
title: '3C',
source: ['www.tmtpost.com/column/3615534'],
target: '/column/3615534',
},
{
title: '消费',
source: ['www.tmtpost.com/column/3882530'],
target: '/column/3882530',
},
{
title: '大健康',
source: ['www.tmtpost.com/column/3882507'],
target: '/column/3882507',
},
{
title: '金融',
source: ['www.tmtpost.com/column/3882486'],
target: '/column/3882486',
},
{
title: '钛智宏观',
source: ['www.tmtpost.com/column/4277188'],
target: '/column/4277188',
},
{
title: '产业研究',
source: ['www.tmtpost.com/column/5506730'],
target: '/column/5506730',
},
{
title: '地产',
source: ['www.tmtpost.com/column/3882499'],
target: '/column/3882499',
},
{
title: '大公司',
source: ['www.tmtpost.com/column/2446153'],
target: '/column/2446153',
},
{
title: 'IPO',
source: ['www.tmtpost.com/column/6043750'],
target: '/column/6043750',
},
{
title: '钛度图闻',
source: ['www.tmtpost.com/column/5750087'],
target: '/column/5750087',
},
{
title: '城视',
source: ['www.tmtpost.com/column/6998636'],
target: '/column/6998636',
},
{
title: '创业家',
source: ['www.tmtpost.com/column/4273329'],
target: '/column/4273329',
},
{
title: '人文',
source: ['www.tmtpost.com/column/6252390'],
target: '/column/6252390',
},
{
title: '新职业研究所',
source: ['www.tmtpost.com/column/5750104'],
target: '/column/5750104',
},
{
title: '科普',
source: ['www.tmtpost.com/column/5714992'],
target: '/column/5714992',
},
{
title: '文娱',
source: ['www.tmtpost.com/column/2446157'],
target: '/column/2446157',
},
],
view: ViewType.Articles,
};

View File

@ -1,211 +1,16 @@
import { type Data, type DataItem, type Route, ViewType } from '@/types';
import { type Data, type Route, ViewType } from '@/types';
import { art } from '@/utils/render';
import cache from '@/utils/cache';
import { getCurrentPath } from '@/utils/helpers';
import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';
import { type CheerioAPI, load } from 'cheerio';
import { type Context } from 'hono';
import path from 'node:path';
const __dirname = getCurrentPath(import.meta.url);
import { baseUrl, apiBaseUrl, processItems } from './util';
export const handler = async (ctx: Context): Promise<Data> => {
const limit: number = Number.parseInt(ctx.req.query('limit') ?? '1', 10);
const limit: number = Number.parseInt(ctx.req.query('limit') ?? '30', 10);
const baseUrl: string = 'https://www.tmtpost.com';
const apiBaseUrl: string = 'https://api.tmtpost.com';
const targetUrl: string = new URL('new', baseUrl).href;
const listApiUrl: string = new URL('v1/lists/new', apiBaseUrl).href;
const postApiUrl: string = new URL('v1/posts/', apiBaseUrl).href;
const headers = {
'app-version': 'web1.0',
};
const response = await ofetch(listApiUrl, {
query: {
limit,
},
headers,
});
const targetResponse = await ofetch(targetUrl);
const $: CheerioAPI = load(targetResponse);
const language = $('html').attr('lang') ?? 'zh-CN';
let items: DataItem[] = [];
items = response.data.slice(0, limit).map((item): DataItem => {
const title: string = item.title;
const description: string = art(path.join(__dirname, 'templates/description.art'), {
intro: item.summary,
});
const pubDate: number | string = item.time_published;
const linkUrl: string | undefined = item.share_link;
const guid: string = item.guid;
const image: string | undefined = item.thumb_image?.original?.url;
const updated: number | string = item.updated ?? pubDate;
const processedItem: DataItem = {
title,
description,
pubDate: pubDate ? parseDate(pubDate, 'X') : undefined,
link: linkUrl,
guid,
id: guid,
content: {
html: description,
text: item.content ?? description,
},
image,
banner: image,
updated: updated ? parseDate(updated, 'X') : undefined,
language,
};
return processedItem;
});
items = (
await Promise.all(
items.map((item) => {
if (!item.link) {
return item;
}
return cache.tryGet(item.link, async (): Promise<DataItem> => {
const apiUrl: string = new URL(item.guid as string, postApiUrl).href;
const detailResponse = await ofetch(apiUrl, {
query: {
fields: ['authors', 'tags', 'featured_image', 'categories', 'stock_list', 'big_plate', 'concept_plate', 'plate', 'plate_list', 'share_link'].join(';'),
},
headers,
});
const data = detailResponse.data;
if (!data) {
return item;
}
const title: string = data.title;
const description: string = art(path.join(__dirname, 'templates/description.art'), {
intro: data.summary,
description: data.main,
});
const pubDate: number | string = data.time_published;
const linkUrl: string | undefined = data.share_link;
const categories: string[] = [
...new Set(
(
[...(data.categories ?? []), ...(data.stock_list ?? []), ...(data.big_plate ?? []), ...(data.concept_plate ?? []), ...(data.plate ?? []), ...(data.plate_list ?? []), ...(data.tags ?? [])].map(
(c) => c.title ?? c.name ?? c.tag
) as string[]
).filter(Boolean)
),
];
const authors: DataItem['author'] = data.authors?.map((author) => ({
name: author.username,
url: new URL(`user/${author.guid}`, baseUrl).href,
avatar: author.avatar,
}));
const guid: string = `tmtpost-${data.post_guid}`;
const image: string | undefined = data.images?.[0]?.url;
const updated: number | string = data.time_updated;
let processedItem: DataItem = {
title,
description,
pubDate: pubDate ? parseDate(pubDate, 'X') : undefined,
link: linkUrl ?? item.link,
category: categories,
author: authors,
guid,
id: guid,
content: {
html: description,
text: description,
},
image,
banner: image,
updated: updated ? parseDate(updated, 'X') : undefined,
language,
};
const enclosureUrl: string | undefined = data.audio;
if (enclosureUrl) {
const enclosureType: string = `audio/${enclosureUrl.split(/\./).pop()}`;
const itunesDuration: string | number | undefined = data.duration;
processedItem = {
...processedItem,
enclosure_url: enclosureUrl,
enclosure_type: enclosureType,
enclosure_title: title,
enclosure_length: undefined,
itunes_duration: itunesDuration,
itunes_item_image: image,
};
}
const medias: Record<string, Record<string, string>> = {};
if (data.full_size_images ?? data.images) {
const images = data.full_size_images ?? data.images;
for (const media of images) {
const url: string | undefined = media.url ?? media;
if (!url) {
continue;
}
const medium: string = 'image';
const count: number = Object.values(medias).filter((m) => m.medium === medium).length + 1;
const key: string = `${medium}${count}`;
medias[key] = {
url,
medium,
title,
thumbnail: media.thumbnail ?? url,
};
}
}
processedItem = {
...processedItem,
media: medias,
};
return {
...item,
...processedItem,
};
});
})
)
).filter((_): _ is DataItem => true);
const title: string = $('title').text();
const author: string | undefined = title.split(/-/).pop();
return {
title,
description: $('meta[property="og:description"]').attr('content'),
link: targetUrl,
item: items,
allowEmpty: true,
image: $('meta[property="og:image"]').attr('content'),
author: title.split(/-/).pop(),
language,
itunes_author: author,
itunes_category: 'Technology',
id: targetUrl,
};
return await processItems(limit, {}, listApiUrl, targetUrl);
};
export const route: Route = {

207
lib/routes/tmtpost/util.ts Normal file
View File

@ -0,0 +1,207 @@
import { type Data, type DataItem } from '@/types';
import { art } from '@/utils/render';
import cache from '@/utils/cache';
import { getCurrentPath } from '@/utils/helpers';
import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';
import { type CheerioAPI, load } from 'cheerio';
import path from 'node:path';
const __dirname = getCurrentPath(import.meta.url);
const baseUrl: string = 'https://www.tmtpost.com';
const apiBaseUrl: string = 'https://api.tmtpost.com';
const postApiUrl: string = new URL('v1/posts/', apiBaseUrl).href;
const headers = {
'app-version': 'web1.0',
};
const processItems = async (limit: number, query: Record<string, any>, apiUrl: string, targetUrl: string): Promise<Data> => {
const response = await ofetch(apiUrl, {
query: {
limit,
...query,
},
headers,
});
const targetResponse = await ofetch(targetUrl);
const $: CheerioAPI = load(targetResponse);
const language = $('html').attr('lang') ?? 'zh-CN';
let items: DataItem[] = [];
items = response.data.slice(0, limit).map((item): DataItem => {
const title: string = item.title;
const description: string = art(path.join(__dirname, 'templates/description.art'), {
intro: item.summary,
});
const pubDate: number | string = item.time_published;
const linkUrl: string | undefined = item.share_link;
const guid: string = item.guid;
const image: string | undefined = item.thumb_image?.original?.url;
const updated: number | string = item.updated ?? pubDate;
const processedItem: DataItem = {
title,
description,
pubDate: pubDate ? parseDate(pubDate, 'X') : undefined,
link: linkUrl,
guid,
id: guid,
content: {
html: description,
text: item.content ?? description,
},
image,
banner: image,
updated: updated ? parseDate(updated, 'X') : undefined,
language,
};
return processedItem;
});
items = (
await Promise.all(
items.map((item) => {
if (!item.link) {
return item;
}
return cache.tryGet(item.link, async (): Promise<DataItem> => {
const detailApiUrl: string = new URL(item.guid as string, postApiUrl).href;
const detailResponse = await ofetch(detailApiUrl, {
query: {
fields: ['authors', 'tags', 'featured_image', 'categories', 'stock_list', 'big_plate', 'concept_plate', 'plate', 'plate_list', 'share_link'].join(';'),
},
headers,
});
const data = detailResponse.data;
if (!data) {
return item;
}
const title: string = data.title;
const description: string = art(path.join(__dirname, 'templates/description.art'), {
intro: data.summary,
description: data.main,
});
const pubDate: number | string = data.time_published;
const linkUrl: string | undefined = data.share_link;
const categories: string[] = [
...new Set(
(
[...(data.categories ?? []), ...(data.stock_list ?? []), ...(data.big_plate ?? []), ...(data.concept_plate ?? []), ...(data.plate ?? []), ...(data.plate_list ?? []), ...(data.tags ?? [])].map(
(c) => c.title ?? c.name ?? c.tag
) as string[]
).filter(Boolean)
),
];
const authors: DataItem['author'] = data.authors?.map((author) => ({
name: author.username,
url: new URL(`user/${author.guid}`, baseUrl).href,
avatar: author.avatar,
}));
const guid: string = `tmtpost-${data.post_guid}`;
const image: string | undefined = data.images?.[0]?.url;
const updated: number | string = data.time_updated;
let processedItem: DataItem = {
title,
description,
pubDate: pubDate ? parseDate(pubDate, 'X') : undefined,
link: linkUrl ?? item.link,
category: categories,
author: authors,
guid,
id: guid,
content: {
html: description,
text: description,
},
image,
banner: image,
updated: updated ? parseDate(updated, 'X') : undefined,
language,
};
const enclosureUrl: string | undefined = data.audio;
if (enclosureUrl) {
const enclosureType: string = `audio/${enclosureUrl.split(/\./).pop()}`;
const itunesDuration: string | number | undefined = data.duration;
processedItem = {
...processedItem,
enclosure_url: enclosureUrl,
enclosure_type: enclosureType,
enclosure_title: title,
enclosure_length: undefined,
itunes_duration: itunesDuration,
itunes_item_image: image,
};
}
const medias: Record<string, Record<string, string>> = {};
if (data.full_size_images ?? data.images) {
const images = data.full_size_images ?? data.images;
for (const media of images) {
const url: string | undefined = media.url ?? media;
if (!url) {
continue;
}
const medium: string = 'image';
const count: number = Object.values(medias).filter((m) => m.medium === medium).length + 1;
const key: string = `${medium}${count}`;
medias[key] = {
url,
medium,
title,
thumbnail: media.thumbnail ?? url,
};
}
}
processedItem = {
...processedItem,
media: medias,
};
return {
...item,
...processedItem,
};
});
})
)
).filter((_): _ is DataItem => true);
const title: string = $('title').text();
const author: string | undefined = title.split(/-/).pop();
return {
title,
description: $('meta[property="og:description"]').attr('content'),
link: targetUrl,
item: items,
allowEmpty: true,
image: $('meta[property="og:image"]').attr('content'),
author: title.split(/-/).pop(),
language,
itunes_author: author,
itunes_category: 'Technology',
id: targetUrl,
};
};
export { baseUrl, apiBaseUrl, processItems };