Ctrl K
ComunidadGuías…

lib/middleware/parameter.ts

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import type { CheerioAPI } from 'cheerio';
import { load } from 'cheerio';
import type { Element } from 'domhandler';
import { decodeHTMLStrict } from 'entities';
import type { MiddlewareHandler } from 'hono';
import { convert } from 'html-to-text';
import markdownit from 'markdown-it';
import { RE2JS } from 're2js';
import sanitizeHtml from 'sanitize-html';

import { config } from '@/config';
import type { Data, DataItem } from '@/types';
import { extractAttachments, getImageMimeType } from '@/utils/attachments';
import cache from '@/utils/cache';
import { isWorker } from '@/utils/is-worker';
import ofetch from '@/utils/ofetch';

const md = markdownit({
    html: true,
});

const resolveRelativeLink = ($: CheerioAPI, elem: Element, attr: string, baseUrl?: string) => {
    const $elem = $(elem);

    if (baseUrl) {
        try {
            const oldAttr = $elem.attr(attr);
            if (oldAttr) {
                // e.g. <video><source src="https://example.com"></video> should leave <video> unchanged
                $elem.attr(attr, new URL(oldAttr, baseUrl).href);
            }
        } catch {
            // no-empty
        }
    }
};

const getAiCompletion = async (prompt: string, text: string) => {
    const apiUrl = `${config.openai.endpoint}/chat/completions`;
    const response = await ofetch(apiUrl, {
        method: 'POST',
        body: {
            model: config.openai.model,
            max_tokens: config.openai.maxTokens,
            messages: [
                { role: 'system', content: prompt },
                { role: 'user', content: text },
            ],
            temperature: config.openai.temperature,
        },
        headers: {
            Authorization: `Bearer ${config.openai.apiKey}`,
        },
    });

    return response.choices[0].message.content;
};

const getAuthorString = (item: DataItem) => {
    let author = '';
    if (item.author) {
        author = Array.isArray(item.author) ? item.author.map((i) => i.name).join(' ') : item.author;
    }
    return author;
};

const middleware: MiddlewareHandler = async (ctx, next) => {
    await next();

    const data: Data = ctx.get('data');
    if (data) {
        if ((!data.item || data.item.length === 0) && !data.allowEmpty) {
            throw new Error('this route is empty, please check the original site or <a href="https://github.com/DIYgod/RSSHub/issues/new/choose">create an issue</a>');
        }

        // fix allowEmpty
        data.item ||= [];

        // decode HTML entities
        // oxlint-disable-next-line @typescript-eslint/no-unnecessary-type-conversion -- routes may return non-string values at runtime
        data.title &&= decodeHTMLStrict(data.title + '');
        // oxlint-disable-next-line @typescript-eslint/no-unnecessary-type-conversion -- routes may return non-string values at runtime
        data.description &&= decodeHTMLStrict(data.description + '');

        // sort items
        const sorted = ctx.req.query('sorted');
        if (sorted === undefined ? data.sortItems !== false : sorted !== 'false') {
            data.item = data.item.toSorted((a: DataItem, b: DataItem) => +new Date(b.pubDate || 0) - +new Date(a.pubDate || 0));
        }

        const hideImages = ctx.req.query('show_image') === 'false';
        const extractEnclosures = ctx.req.query('enclosure') === 'true';
        if (hideImages) {
            delete data.image;
        }

        const handleItem = (item: DataItem) => {
            // oxlint-disable-next-line @typescript-eslint/no-unnecessary-type-conversion -- routes may return non-string values at runtime
            item.title &&= decodeHTMLStrict(item.title + '');
            item.description ||= item.content?.html;

            // handle pubDate
            if (item.pubDate) {
                item.pubDate = new Date(item.pubDate).toUTCString();
            }

            // handle link
            if (item.link) {
                let baseUrl = data.link;
                if (baseUrl && !/^https?:\/\//.test(baseUrl)) {
                    baseUrl = baseUrl.startsWith('//') ? 'http:' + baseUrl : 'http://' + baseUrl;
                }

                item.link = new URL(item.link, baseUrl).href;
            }

            // handle description
            if (item.description) {
                const $ = load(item.description);
                let baseUrl = item.link || data.link;

                if (baseUrl && !/^https?:\/\//.test(baseUrl)) {
                    baseUrl = baseUrl.startsWith('//') ? 'http:' + baseUrl : 'http://' + baseUrl;
                }

                $('script').remove();
                if (hideImages) {
                    $('img, picture').remove();
                    $('video[poster]').removeAttr('poster');
                }

                $('img').each((_, ele) => {
                    const $ele = $(ele);

                    // fix lazyload
                    if (!$ele.attr('src')) {
                        const lazySrc = $ele.attr('data-src') || $ele.attr('data-original');
                        if (lazySrc) {
                            $ele.attr('src', lazySrc);
                        } else {
                            for (const key in ele.attribs) {
                                const value = ele.attribs[key].trim();
                                if (['.gif', '.png', '.jpg', '.webp'].some((suffix) => value.includes(suffix))) {
                                    $ele.attr('src', value);
                                    break;
                                }
                            }
                        }
                    }

                    // redundant attributes
                    for (const e of ['onclick', 'onerror', 'onload']) {
                        $ele.removeAttr(e);
                    }
                });

                // resolve relative link & fix referrer policy
                // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy
                // https://www.w3schools.com/tags/att_href.asp
                $('a, area').each((_, elem) => {
                    resolveRelativeLink($, elem, 'href', baseUrl);
                    // $(elem).attr('rel', 'noreferrer');  // currently no such a need
                });
                // https://www.w3schools.com/tags/att_src.asp
                $('img, video, audio, source, iframe, embed, track').each((_, elem) => {
                    resolveRelativeLink($, elem, 'src', baseUrl);
                });
                $('video[poster]').each((_, elem) => {
                    resolveRelativeLink($, elem, 'poster', baseUrl);
                });
                $('img, iframe').each((_, elem) => {
                    if (!$(elem).attr('referrerpolicy')) {
                        $(elem).attr('referrerpolicy', 'no-referrer');
                    }
                });

                if (extractEnclosures) {
                    item.attachments = extractAttachments($, item, hideImages);
                }

                item.description = $('body').html() + '' + (config.suffix || '');

                if (item._extra?.links && $('.rsshub-quote').length) {
                    item._extra?.links?.map((e) => {
                        e.content_html = $.html($('.rsshub-quote'));
                        return e;
                    });
                }
            }

            // handle category
            if (item.category) {
                // convert single string to array, and filter only string type category
                Array.isArray(item.category) || (item.category = [item.category]);
                item.category = item.category.filter((e: unknown): e is string => typeof e === 'string');
            }

            // handle enclosure and image
            if (hideImages) {
                delete item.image;
                delete item.banner;
                delete item.itunes_item_image;
                if (item.enclosure_type?.startsWith('image/')) {
                    delete item.enclosure_url;
                    delete item.enclosure_type;
                    delete item.enclosure_length;
                }
                if (item.media) {
                    delete item.media.thumbnail;
                    if (item.media.content?.type?.startsWith('image/')) {
                        delete item.media.content;
                    }
                }
                item.attachments = item.attachments?.filter((attachment) => !attachment.mime_type?.startsWith('image/'));
            }
            if (item.image && !item.enclosure_url && !item.attachments?.length) {
                item.enclosure_type = getImageMimeType(item.image);
            }
            return item;
        };

        data.item = await Promise.all(data.item.map((itm) => handleItem(itm)));

        // filter
        const engine = config.feature.filter_regex_engine;
        const makeRegex = (str: string) => {
            // default: case_senstivie = true
            const insensitive = ctx.req.query('filter_case_sensitive') === 'false';
            switch (engine) {
                case 'regexp':
                    return new RegExp(str, insensitive ? 'i' : '');
                case 're2':
                    return RE2JS.compile(str, insensitive ? RE2JS.CASE_INSENSITIVE : 0);
                default:
                    throw new Error(`Invalid Engine Value: ${engine}, please check your config.`);
            }
        };

        if (ctx.req.query('filter')) {
            const regex = makeRegex(ctx.req.query('filter')!);

            data.item = data.item.filter((item) => {
                const title = item.title || '';
                const description = item.description || title;
                const author = getAuthorString(item);
                const category = Array.isArray(item.category) ? item.category : [];
                const isFilter =
                    regex instanceof RE2JS
                        ? regex.matcher(title).find() || regex.matcher(description).find() || regex.matcher(author).find() || category.some((c) => regex.matcher(c).find())
                        : title.match(regex) || description.match(regex) || author.match(regex) || category.some((c) => c.match(regex));

                return isFilter;
            });
        }

        // 启用filter参数时,无效filter_title/description/author/category
        if (!ctx.req.query('filter') && (ctx.req.query('filter_title') || ctx.req.query('filter_description') || ctx.req.query('filter_author') || ctx.req.query('filter_category'))) {
            data.item = data.item.filter((item) => {
                const title = item.title || '';
                const description = item.description || title;
                const author = getAuthorString(item);
                const category = Array.isArray(item.category) ? item.category : [];
                let isFilter = true;

                if (ctx.req.query('filter_title')) {
                    const titleRegex = makeRegex(ctx.req.query('filter_title')!);
                    isFilter = titleRegex instanceof RE2JS ? titleRegex.matcher(title).find() : titleRegex.test(title);
                }
                if (ctx.req.query('filter_description')) {
                    const descriptionRegex = makeRegex(ctx.req.query('filter_description')!);
                    isFilter &&= descriptionRegex instanceof RE2JS ? descriptionRegex.matcher(description).find() : descriptionRegex.test(description);
                }
                if (ctx.req.query('filter_author')) {
                    const authorRegex = makeRegex(ctx.req.query('filter_author')!);
                    isFilter &&= authorRegex instanceof RE2JS ? authorRegex.matcher(author).find() : authorRegex.test(author);
                }
                if (ctx.req.query('filter_category')) {
                    const categoryRegex = makeRegex(ctx.req.query('filter_category')!);
                    isFilter &&= category.some((c) => (categoryRegex instanceof RE2JS ? categoryRegex.matcher(c).find() : c.match(categoryRegex)));
                }

                return isFilter;
            });
        }

        if (ctx.req.query('filterout') || ctx.req.query('filterout_title') || ctx.req.query('filterout_description') || ctx.req.query('filterout_author') || ctx.req.query('filterout_category')) {
            data.item = data.item.filter((item) => {
                const title = item.title;
                const description = item.description || title;
                const author = getAuthorString(item);
                const category = Array.isArray(item.category) ? item.category : [];
                let isFilter = true;

                if (ctx.req.query('filterout') || ctx.req.query('filterout_title')) {
                    const titleRegex = makeRegex(ctx.req.query('filterout_title') || ctx.req.query('filterout')!);
                    isFilter = titleRegex instanceof RE2JS ? !titleRegex.matcher(title).find() : !titleRegex.test(title);
                }
                if (ctx.req.query('filterout') || ctx.req.query('filterout_description')) {
                    const descriptionRegex = makeRegex(ctx.req.query('filterout_description') || ctx.req.query('filterout')!);
                    isFilter &&= descriptionRegex instanceof RE2JS ? !descriptionRegex.matcher(description).find() : !descriptionRegex.test(description);
                }
                if (ctx.req.query('filterout_author')) {
                    const authorRegex = makeRegex(ctx.req.query('filterout_author')!);
                    isFilter &&= authorRegex instanceof RE2JS ? !authorRegex.matcher(author).find() : !authorRegex.test(author);
                }
                if (ctx.req.query('filterout_category')) {
                    const categoryRegex = makeRegex(ctx.req.query('filterout_category')!);
                    isFilter &&= category.every((c) => !(categoryRegex instanceof RE2JS ? categoryRegex.matcher(c).find() : c.match(categoryRegex)));
                }

                return isFilter;
            });
        }

        if (ctx.req.query('filter_time')) {
            const now = Date.now();
            data.item = data.item.filter(({ pubDate }) => {
                let isFilter = true;
                try {
                    isFilter = !pubDate || now - new Date(pubDate).getTime() <= Number.parseInt(ctx.req.query('filter_time')!) * 1000;
                } catch {
                    // no-empty
                }
                return isFilter;
            });
        }

        // limit
        const limit = Number.parseInt(ctx.req.query('limit') ?? '');
        if (limit >= 0) {
            data.item = data.item.slice(0, limit);
        }

        // telegram instant view
        if (ctx.req.query('tgiv')) {
            data.item.map((item) => {
                if (item.link) {
                    const encodedlink = encodeURIComponent(item.link);
                    item.link = `https://t.me/iv?url=${encodedlink}&rhash=${ctx.req.query('tgiv')}`;
                    return item;
                }
                return item;
            });
        }

        // fulltext
        if (ctx.req.query('mode')?.toLowerCase() === 'fulltext') {
            const tasks = data.item.map(async (item) => {
                const { link, author, description } = item;
                const parsed_result: any = await cache.tryGet(`mercury-cache-${link}`, async () => {
                    if (link) {
                        // if parser failed, return default description and not report error
                        try {
                            const { default: Parser } = await import('@jocmp/mercury-parser');
                            const res = await ofetch(link);
                            const $ = load(res);
                            const result = await Parser.parse(link, {
                                html: $.html(),
                            });
                            return result;
                        } catch {
                            // no-empty
                        }
                    }
                });

                item.author = author || parsed_result?.author;
                item.description = parsed_result && parsed_result.content.length > 40 ? decodeHTMLStrict(parsed_result.content) : description;
                if (extractEnclosures && parsed_result?.content.length > 40 && item.description) {
                    item.attachments = extractAttachments(load(item.description), item, hideImages);
                }
            });
            await Promise.all(tasks);
        }

        // openai
        if (ctx.req.query('chatgpt') && config.openai.apiKey) {
            data.item = await Promise.all(
                data.item.map(async (item) => {
                    try {
                        // handle description
                        if (config.openai.inputOption === 'description' && item.description) {
                            const description = await cache.tryGet(`openai:description:${item.link}`, async () => {
                                const description = convert(item.description!);
                                const descriptionMd = await getAiCompletion(config.openai.promptDescription, description);
                                return md.render(descriptionMd);
                            });
                            // add it to the description
                            if (description !== '') {
                                item.description = description + '<hr/><br/>' + item.description;
                            }
                        }
                        // handle title
                        else if (config.openai.inputOption === 'title' && item.title) {
                            const title = await cache.tryGet(`openai:title:${item.link}`, async () => {
                                const title = convert(item.title!);
                                return await getAiCompletion(config.openai.promptTitle, title);
                            });
                            // replace the title
                            if (title !== '') {
                                item.title = title + '';
                            }
                        }
                        // handle both
                        else if (config.openai.inputOption === 'both' && item.title && item.description) {
                            const title = await cache.tryGet(`openai:title:${item.link}`, async () => {
                                const title = convert(item.title!);
                                return await getAiCompletion(config.openai.promptTitle, title);
                            });
                            // replace the title
                            if (title !== '') {
                                item.title = title + '';
                            }

                            const description = await cache.tryGet(`openai:description:${item.link}`, async () => {
                                const description = convert(item.description!);
                                const descriptionMd = await getAiCompletion(config.openai.promptDescription, description);
                                return md.render(descriptionMd);
                            });
                            // add it to the description
                            if (description !== '') {
                                item.description = description + '<hr/><br/>' + item.description;
                            }
                        }
                    } catch {
                        // when openai failed, return default content and not write cache
                    }
                    return item;
                })
            );
        }

        // scihub
        if (ctx.req.query('scihub')) {
            data.item.map((item) => {
                item.link = item.doi ? `${config.scihub.host}${item.doi}` : `${config.scihub.host}${item.link}`;
                return item;
            });
        }

        // opencc
        if (!isWorker && ctx.req.query('opencc')) {
            const { simplecc } = await import('simplecc-wasm');
            for (const item of data.item) {
                item.title = simplecc(item.title ?? item.link, ctx.req.query('opencc')!);
                item.description = simplecc(item.description ?? item.title ?? item.link, ctx.req.query('opencc')!);
            }
        }

        // brief
        if (ctx.req.query('brief')) {
            const num = /[1-9]\d{2,}/;
            if (num.test(ctx.req.query('brief')!)) {
                const brief: number = Number.parseInt(ctx.req.query('brief')!);
                for (const item of data.item) {
                    if (!item.description) {
                        continue;
                    }

                    const text = sanitizeHtml(item.description, { allowedTags: [], allowedAttributes: {} });
                    item.description = text.length > brief ? `<p>${text.slice(0, brief)}…</p>` : `<p>${text}</p>`;
                }
            } else {
                throw new Error('Invalid parameter brief. Please check the doc https://docs.rsshub.app/guide/parameters#shu-chu-jian-xun');
            }
        }
        // some parameters are processed in `anti-hotlink.js`

        ctx.set('data', data);
    } else {
        // throw new Error('wrong path');
    }
};

export default middleware;