diff --git a/lib/routes/gcores/articles.ts b/lib/routes/gcores/articles.ts new file mode 100644 index 00000000000000..321c3193d2d21b --- /dev/null +++ b/lib/routes/gcores/articles.ts @@ -0,0 +1,49 @@ +import { type Data, type Route, ViewType } from '@/types'; + +import { getCurrentPath } from '@/utils/helpers'; +import { type Context } from 'hono'; + +import { baseUrl, processItems } from './util'; + +export const __dirname = getCurrentPath(import.meta.url); + +export const handler = async (ctx: Context): Promise => { + const limit: number = Number.parseInt(ctx.req.query('limit') ?? '30', 10); + + const targetUrl: string = new URL('articles', baseUrl).href; + const apiUrl: string = new URL(`gapi/v1/articles`, baseUrl).href; + + const query = { + 'filter[is-news]': 0, + }; + + return await processItems(limit, query, apiUrl, targetUrl); +}; + +export const route: Route = { + path: '/articles', + name: '文章', + url: 'www.gcores.com', + maintainers: ['nczitzk'], + handler, + example: '/gcores/articles', + parameters: undefined, + description: undefined, + categories: ['game'], + features: { + requireConfig: false, + requirePuppeteer: false, + antiCrawler: false, + supportRadar: true, + supportBT: false, + supportPodcast: false, + supportScihub: false, + }, + radar: [ + { + source: ['www.gcores.com/articles'], + target: '/gcores/articles', + }, + ], + view: ViewType.Articles, +}; diff --git a/lib/routes/gcores/news.ts b/lib/routes/gcores/news.ts index ec506d126207db..2fbf8a54e48e9f 100644 --- a/lib/routes/gcores/news.ts +++ b/lib/routes/gcores/news.ts @@ -1,146 +1,23 @@ -import { type Data, type DataItem, type Route, ViewType } from '@/types'; +import { type Data, type Route, ViewType } from '@/types'; -import { art } from '@/utils/render'; 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'; -import { parseContent } from './util'; +import { baseUrl, processItems } from './util'; export const __dirname = getCurrentPath(import.meta.url); export const handler = async (ctx: Context): Promise => { const limit: number = Number.parseInt(ctx.req.query('limit') ?? '30', 10); - const baseUrl: string = 'https://www.gcores.com'; - const imageBaseUrl: string = 'https://image.gcores.com'; - const audioBaseUrl: string = 'https://alioss.gcores.com'; const targetUrl: string = new URL('news', baseUrl).href; const apiUrl: string = new URL('gapi/v1/articles', baseUrl).href; - const response = await ofetch(apiUrl, { - query: { - 'page[limit]': limit, - sort: '-published-at', - include: 'category,user,media', - 'filter[is-news]': 1, - }, - }); - - const included = response.included; - - 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 attributes = item.attributes; - - const title: string = attributes.title; - const pubDate: number | string = attributes['published-at']; - const linkUrl: string | undefined = `${item.type}/${item.id}`; - - const categoryObj = item.relationships?.category?.data; - const categories: string[] = categoryObj ? [included.find((i) => i.type === categoryObj.type && i.id === categoryObj.id)?.attributes?.name].filter(Boolean) : []; - - const authorObj = item.relationships?.user?.data; - const authorIncluded = included.find((i) => i.type === authorObj.type && i.id === authorObj.id); - const authors: DataItem['author'] = authorIncluded - ? [ - { - name: authorIncluded.attributes?.nickname, - url: authorIncluded.id ? new URL(`${authorObj.type}/${authorIncluded.id}`, baseUrl).href : undefined, - avatar: authorIncluded.thumb ? new URL(authorIncluded.thumb, imageBaseUrl).href : undefined, - }, - ] - : undefined; - - const guid: string = `gcores-${item.id}`; - const image: string | undefined = (attributes.cover ?? attributes.thumb) ? new URL(attributes.cover ?? attributes.thumb, imageBaseUrl).href : undefined; - const updated: number | string = pubDate; - - let processedItem: DataItem = { - title, - pubDate: pubDate ? parseDate(pubDate) : undefined, - link: linkUrl, - category: categories, - author: authors, - guid, - id: guid, - image, - banner: image, - updated: updated ? parseDate(updated) : undefined, - language, - }; - - const enclosureUrl: string | undefined = attributes['speech-path'] ? new URL(`uploads/audio/${attributes['speech-path']}`, audioBaseUrl).href : undefined; - - if (enclosureUrl) { - const enclosureType: string = `audio/${enclosureUrl.split(/\./).pop()}`; - const enclosureLength: number = attributes.duration ? Number(attributes.duration) : 0; - - processedItem = { - ...processedItem, - enclosure_url: enclosureUrl, - enclosure_type: enclosureType, - enclosure_title: title, - enclosure_length: enclosureLength, - itunes_duration: enclosureLength, - itunes_item_image: image, - }; - } - - const description: string = art(path.join(__dirname, 'templates/description.art'), { - images: attributes.cover - ? [ - { - src: new URL(attributes.cover, imageBaseUrl).href, - alt: title, - }, - ] - : undefined, - audios: enclosureUrl - ? [ - { - src: enclosureUrl, - type: `audio/${enclosureUrl.split(/\./).pop()}`, - }, - ] - : undefined, - intro: attributes.desc || attributes.excerpt, - description: attributes.content ? parseContent(JSON.parse(attributes.content)) : undefined, - }); - - processedItem = { - ...processedItem, - description, - content: { - html: description, - text: description, - }, - }; - - return processedItem; - }); - - const title: string = $('title').text(); - - return { - title, - description: $('meta[name="description"]').attr('content'), - link: targetUrl, - item: items, - allowEmpty: true, - author: title.split(/\|/).pop()?.trim(), - language, - id: $('meta[property="og: url"]').attr('content'), + const query = { + 'filter[is-news]': 1, }; + + return await processItems(limit, query, apiUrl, targetUrl); }; export const route: Route = { diff --git a/lib/routes/gcores/parser.ts b/lib/routes/gcores/parser.ts new file mode 100644 index 00000000000000..c307154621cccf --- /dev/null +++ b/lib/routes/gcores/parser.ts @@ -0,0 +1,241 @@ +import { art } from '@/utils/render'; +import { getCurrentPath } from '@/utils/helpers'; + +import path from 'node:path'; + +export const __dirname = getCurrentPath(import.meta.url); + +interface Style { + [key: string]: string; +} + +interface BlockType { + element: string; + parentElement?: string; + aliasedElements?: string[]; +} + +interface InlineStyleRange { + offset: number; + length: number; + style: string; +} + +interface EntityRange { + offset: number; + length: number; + key: number; +} + +interface Entity { + type: string; + mutability: string; + data: any; +} + +interface Block { + key: string; + text: string; + type: string; + depth: number; + inlineStyleRanges: InlineStyleRange[]; + entityRanges: EntityRange[]; + data: any; +} + +interface Content { + blocks: Block[]; + entityMap: { [key: string]: Entity }; +} + +const imageBaseUrl: string = 'https://image.gcores.com'; + +const STYLES: Readonly> = { + BOLD: { fontWeight: 'bold' }, + CODE: { fontFamily: 'monospace', wordWrap: 'break-word' }, + ITALIC: { fontStyle: 'italic' }, + STRIKETHROUGH: { textDecoration: 'line-through' }, + UNDERLINE: { textDecoration: 'underline' }, +}; + +const BLOCK_TYPES: Readonly> = { + 'header-one': { element: 'h1' }, + 'header-two': { element: 'h2' }, + 'header-three': { element: 'h3' }, + 'header-four': { element: 'h4' }, + 'header-five': { element: 'h5' }, + 'header-six': { element: 'h6' }, + 'unordered-list-item': { element: 'li', parentElement: 'ul' }, + 'ordered-list-item': { element: 'li', parentElement: 'ol' }, + blockquote: { element: 'blockquote' }, + atomic: { element: 'figure' }, + 'code-block': { element: 'pre' }, + unstyled: { element: 'div', aliasedElements: ['p'] }, +}; + +/** + * Creates a styled HTML fragment for a given text and style object. + * @param text The text content of the fragment. + * @param style An object containing CSS styles (key-value pairs). + * @returns An HTML string representing the styled fragment. + */ +const createStyledFragment = (text: string, style: Record): string => + `${text}`; + +/** + * Applies inline styles to a text string. + * @param text The text to style. + * @param inlineStyleRanges An array of inline style ranges. + * @returns The styled text. + */ +const applyStyles = (text: string, inlineStyleRanges: readonly InlineStyleRange[]): string => { + if (!inlineStyleRanges || inlineStyleRanges.length === 0) { + return text; + } + + const sortedRanges = [...inlineStyleRanges].sort((a, b) => a.offset - b.offset); + + let lastOffset = 0; + const styledFragments = sortedRanges.map((range) => { + const style = STYLES[range.style]; + if (!style) { + return text.substring(lastOffset, range.offset); + } + + const styledText = createStyledFragment(text.substring(range.offset, range.offset + range.length), style); + const preText = text.substring(lastOffset, range.offset); + lastOffset = range.offset + range.length; + return preText + styledText; + }); + let result = styledFragments.join(''); + result += text.substring(lastOffset); + return result; +}; + +/** + * Creates an HTML element for a given entity. + * @param entity The entity to create an element for. + * @param block The current block the entity belongs to, for debugging purposes. + * @returns The HTML element string. + */ +const createEntityElement = (entity: Entity, block: Block): string => { + switch (entity.type) { + case 'EMBED': + return entity.data.content.startsWith('http') ? `${entity.data.content}` : entity.data.content; + case 'IMAGE': + return art(path.join(__dirname, 'templates/description.art'), { + images: entity.data.path + ? [ + { + src: new URL(entity.data.path, imageBaseUrl).href, + alt: entity.data.caption, + width: entity.data.width, + height: entity.data.height, + }, + ] + : undefined, + }); + case 'GALLERY': + if (!entity.data.images || !Array.isArray(entity.data.images)) { + return ''; + } + return art(path.join(__dirname, 'templates/description.art'), { + images: entity.data.images.map((image: any) => ({ + src: new URL(image.path, imageBaseUrl).href, + alt: image.caption, + width: image.width, + height: image.height, + })), + }); + case 'LINK': + return `${block.text}`; + case 'WIDGET': + return `${entity.data.title}`; + default: + return ''; + } +}; + +/** + * Parses a single content block into an HTML string. + * @param block The block to parse. + * @param entityMap The entity map. + * @returns The parsed HTML string. + */ +const parseBlock = (block: Block, entityMap: { [key: string]: Entity }): string => { + const blockType = BLOCK_TYPES[block.type]; + if (!blockType) { + return ''; + } + + const usedElement = blockType.aliasedElements?.[0] ?? blockType.element; + + let content = applyStyles(block.text, block.inlineStyleRanges); + + if (block.entityRanges && block.entityRanges.length > 0) { + const entityElements = block.entityRanges + .map((range) => entityMap[range.key]) + .filter(Boolean) + .map((entity) => createEntityElement(entity!, block)); + + content = entityElements.join(''); + } + + return `<${usedElement}>${content}`; +}; + +/** + * Parses a Content object into an HTML string using a for loop. + * @param content The Content object to parse. + * @returns The parsed HTML string. + */ +const parseContent = (content: Content): string => { + const { blocks, entityMap } = content; + + if (!blocks || blocks.length === 0) { + return ''; + } + + let html = ''; + let currentParent: string | undefined = undefined; + let parentContent = ''; + + for (const block of blocks) { + const blockType = BLOCK_TYPES[block.type]; + if (!blockType) { + continue; + } + + const parentElement = blockType.parentElement; + const parsedBlock = parseBlock(block, entityMap); + + if (parentElement) { + if (currentParent === parentElement) { + parentContent += parsedBlock; + } else { + if (currentParent) { + html += `<${currentParent}>${parentContent}`; + } + currentParent = parentElement; + parentContent = parsedBlock; + } + } else { + if (currentParent) { + html += `<${currentParent}>${parentContent}`; + currentParent = undefined; + parentContent = ''; + } + html += parsedBlock; + } + } + + if (currentParent) { + html += `<${currentParent}>${parentContent}`; + } + + return html; +}; + +export { parseContent }; diff --git a/lib/routes/gcores/templates/description.art b/lib/routes/gcores/templates/description.art index 7ac6d47081bb94..bc3dbd13bdfa76 100644 --- a/lib/routes/gcores/templates/description.art +++ b/lib/routes/gcores/templates/description.art @@ -27,6 +27,22 @@ {{ /each }} {{ /if }} +{{ if video }} + {{ each videos video }} + {{ if video?.src }} + + {{ /if }} + {{ /each }} +{{ /if }} + {{ if intro }}
{{ intro }}
{{ /if }} diff --git a/lib/routes/gcores/util.ts b/lib/routes/gcores/util.ts index c307154621cccf..5550c953c45d48 100644 --- a/lib/routes/gcores/util.ts +++ b/lib/routes/gcores/util.ts @@ -1,241 +1,172 @@ +import { type Data, type DataItem } from '@/types'; + import { art } from '@/utils/render'; 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'; -export const __dirname = getCurrentPath(import.meta.url); - -interface Style { - [key: string]: string; -} - -interface BlockType { - element: string; - parentElement?: string; - aliasedElements?: string[]; -} - -interface InlineStyleRange { - offset: number; - length: number; - style: string; -} - -interface EntityRange { - offset: number; - length: number; - key: number; -} - -interface Entity { - type: string; - mutability: string; - data: any; -} - -interface Block { - key: string; - text: string; - type: string; - depth: number; - inlineStyleRanges: InlineStyleRange[]; - entityRanges: EntityRange[]; - data: any; -} - -interface Content { - blocks: Block[]; - entityMap: { [key: string]: Entity }; -} +const __dirname = getCurrentPath(import.meta.url); + +import { parseContent } from './parser'; +const baseUrl: string = 'https://www.gcores.com'; const imageBaseUrl: string = 'https://image.gcores.com'; +const audioBaseUrl: string = 'https://alioss.gcores.com'; -const STYLES: Readonly> = { - BOLD: { fontWeight: 'bold' }, - CODE: { fontFamily: 'monospace', wordWrap: 'break-word' }, - ITALIC: { fontStyle: 'italic' }, - STRIKETHROUGH: { textDecoration: 'line-through' }, - UNDERLINE: { textDecoration: 'underline' }, +const baseQuery = { + sort: '-published-at', + include: 'category,user,media', + 'filter[list-all]': 1, + 'filter[is-news]': 1, }; -const BLOCK_TYPES: Readonly> = { - 'header-one': { element: 'h1' }, - 'header-two': { element: 'h2' }, - 'header-three': { element: 'h3' }, - 'header-four': { element: 'h4' }, - 'header-five': { element: 'h5' }, - 'header-six': { element: 'h6' }, - 'unordered-list-item': { element: 'li', parentElement: 'ul' }, - 'ordered-list-item': { element: 'li', parentElement: 'ol' }, - blockquote: { element: 'blockquote' }, - atomic: { element: 'figure' }, - 'code-block': { element: 'pre' }, - unstyled: { element: 'div', aliasedElements: ['p'] }, -}; +const processItems = async (limit: number, query: any, apiUrl: string, targetUrl: string): Promise => { + const response = await ofetch(apiUrl, { + query: { + ...baseQuery, + query, + }, + }); -/** - * Creates a styled HTML fragment for a given text and style object. - * @param text The text content of the fragment. - * @param style An object containing CSS styles (key-value pairs). - * @returns An HTML string representing the styled fragment. - */ -const createStyledFragment = (text: string, style: Record): string => - `${text}`; - -/** - * Applies inline styles to a text string. - * @param text The text to style. - * @param inlineStyleRanges An array of inline style ranges. - * @returns The styled text. - */ -const applyStyles = (text: string, inlineStyleRanges: readonly InlineStyleRange[]): string => { - if (!inlineStyleRanges || inlineStyleRanges.length === 0) { - return text; - } - - const sortedRanges = [...inlineStyleRanges].sort((a, b) => a.offset - b.offset); - - let lastOffset = 0; - const styledFragments = sortedRanges.map((range) => { - const style = STYLES[range.style]; - if (!style) { - return text.substring(lastOffset, range.offset); + const included = response.included; + + 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 attributes = item.attributes; + const relationships = item.relationships; + + const title: string = attributes.title; + const pubDate: number | string = attributes['published-at']; + const linkUrl: string | undefined = `${item.type}/${item.id}`; + + const categoryObj = relationships?.category?.data; + const categories: string[] = categoryObj ? [included.find((i) => i.type === categoryObj.type && i.id === categoryObj.id)?.attributes?.name].filter(Boolean) : []; + + const authorObj = relationships?.user?.data; + const authorIncluded = included.find((i) => i.type === authorObj.type && i.id === authorObj.id); + const authors: DataItem['author'] = authorIncluded + ? [ + { + name: authorIncluded.attributes?.nickname, + url: authorIncluded.id ? new URL(`${authorObj.type}/${authorIncluded.id}`, baseUrl).href : undefined, + avatar: authorIncluded.thumb ? new URL(authorIncluded.thumb, imageBaseUrl).href : undefined, + }, + ] + : undefined; + + const guid: string = `gcores-${item.id}`; + const image: string | undefined = (attributes.cover ?? attributes.thumb) ? new URL(attributes.cover ?? attributes.thumb, imageBaseUrl).href : undefined; + const updated: number | string = pubDate; + + let processedItem: DataItem = { + title, + pubDate: pubDate ? parseDate(pubDate) : undefined, + link: linkUrl, + category: categories, + author: authors, + guid, + id: guid, + image, + banner: image, + updated: updated ? parseDate(updated) : undefined, + language, + }; + + let enclosureUrl: string | undefined; + let enclosureType: string | undefined; + + const mediaAttrs = included.find((i) => i.id === relationships.media?.data?.id)?.attributes; + + if (attributes['speech-path']) { + enclosureUrl = new URL(`uploads/audio/${attributes['speech-path']}`, audioBaseUrl).href; + enclosureType = `audio/${enclosureUrl?.split(/\./).pop()}`; + } else if (mediaAttrs) { + if (mediaAttrs.audio) { + enclosureUrl = mediaAttrs.audio; + enclosureType = `audio/${enclosureUrl?.split(/\./).pop()}`; + } else if (mediaAttrs['original-src']) { + enclosureUrl = mediaAttrs['original-src']; + enclosureType = 'video/mpeg'; + } } - const styledText = createStyledFragment(text.substring(range.offset, range.offset + range.length), style); - const preText = text.substring(lastOffset, range.offset); - lastOffset = range.offset + range.length; - return preText + styledText; - }); - let result = styledFragments.join(''); - result += text.substring(lastOffset); - return result; -}; + if (enclosureUrl) { + const enclosureLength: number = attributes.duration ? Number(attributes.duration) : 0; + + processedItem = { + ...processedItem, + enclosure_url: enclosureUrl, + enclosure_type: enclosureType, + enclosure_title: title, + enclosure_length: enclosureLength, + itunes_duration: enclosureLength, + itunes_item_image: image, + }; + } -/** - * Creates an HTML element for a given entity. - * @param entity The entity to create an element for. - * @param block The current block the entity belongs to, for debugging purposes. - * @returns The HTML element string. - */ -const createEntityElement = (entity: Entity, block: Block): string => { - switch (entity.type) { - case 'EMBED': - return entity.data.content.startsWith('http') ? `${entity.data.content}` : entity.data.content; - case 'IMAGE': - return art(path.join(__dirname, 'templates/description.art'), { - images: entity.data.path + const description: string = art(path.join(__dirname, 'templates/description.art'), { + images: attributes.cover + ? [ + { + src: new URL(attributes.cover, imageBaseUrl).href, + alt: title, + }, + ] + : undefined, + audios: + enclosureType?.startsWith('audio') && enclosureUrl ? [ { - src: new URL(entity.data.path, imageBaseUrl).href, - alt: entity.data.caption, - width: entity.data.width, - height: entity.data.height, + src: enclosureUrl, + type: enclosureType, }, ] : undefined, - }); - case 'GALLERY': - if (!entity.data.images || !Array.isArray(entity.data.images)) { - return ''; - } - return art(path.join(__dirname, 'templates/description.art'), { - images: entity.data.images.map((image: any) => ({ - src: new URL(image.path, imageBaseUrl).href, - alt: image.caption, - width: image.width, - height: image.height, - })), - }); - case 'LINK': - return `${block.text}`; - case 'WIDGET': - return `${entity.data.title}`; - default: - return ''; - } -}; - -/** - * Parses a single content block into an HTML string. - * @param block The block to parse. - * @param entityMap The entity map. - * @returns The parsed HTML string. - */ -const parseBlock = (block: Block, entityMap: { [key: string]: Entity }): string => { - const blockType = BLOCK_TYPES[block.type]; - if (!blockType) { - return ''; - } - - const usedElement = blockType.aliasedElements?.[0] ?? blockType.element; - - let content = applyStyles(block.text, block.inlineStyleRanges); - - if (block.entityRanges && block.entityRanges.length > 0) { - const entityElements = block.entityRanges - .map((range) => entityMap[range.key]) - .filter(Boolean) - .map((entity) => createEntityElement(entity!, block)); - - content = entityElements.join(''); - } - - return `<${usedElement}>${content}`; -}; - -/** - * Parses a Content object into an HTML string using a for loop. - * @param content The Content object to parse. - * @returns The parsed HTML string. - */ -const parseContent = (content: Content): string => { - const { blocks, entityMap } = content; - - if (!blocks || blocks.length === 0) { - return ''; - } - - let html = ''; - let currentParent: string | undefined = undefined; - let parentContent = ''; - - for (const block of blocks) { - const blockType = BLOCK_TYPES[block.type]; - if (!blockType) { - continue; - } - - const parentElement = blockType.parentElement; - const parsedBlock = parseBlock(block, entityMap); - - if (parentElement) { - if (currentParent === parentElement) { - parentContent += parsedBlock; - } else { - if (currentParent) { - html += `<${currentParent}>${parentContent}`; - } - currentParent = parentElement; - parentContent = parsedBlock; - } - } else { - if (currentParent) { - html += `<${currentParent}>${parentContent}`; - currentParent = undefined; - parentContent = ''; - } - html += parsedBlock; - } - } - - if (currentParent) { - html += `<${currentParent}>${parentContent}`; - } + videos: + enclosureType?.startsWith('video') && enclosureUrl + ? [ + { + src: enclosureUrl, + type: enclosureType, + }, + ] + : undefined, + intro: attributes.desc || attributes.excerpt, + description: attributes.content ? parseContent(JSON.parse(attributes.content)) : undefined, + }); + + processedItem = { + ...processedItem, + description, + content: { + html: description, + text: description, + }, + }; + + return processedItem; + }); - return html; + const title: string = $('title').text(); + + return { + title, + description: $('meta[name="description"]').attr('content'), + link: targetUrl, + item: items, + allowEmpty: true, + author: title.split(/\|/).pop()?.trim(), + language, + id: $('meta[property="og:url"]').attr('content'), + }; }; -export { parseContent }; +export { baseUrl, imageBaseUrl, audioBaseUrl, processItems };