feat: work with hono and typescript

This commit is contained in:
DIYgod
2024-01-21 11:55:18 +08:00
parent cd48da470e
commit 847724cad1
53 changed files with 2744 additions and 1141 deletions
-4
View File
@@ -1,4 +0,0 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx lint-staged
+349 -47
View File
@@ -1,16 +1,321 @@
require('dotenv').config();
const randUserAgent = require('./utils/rand-user-agent');
const got = require('got');
import 'dotenv/config'
import randUserAgent from '@/utils/rand-user-agent';
import got from 'got';
let envs = process.env;
let value;
type Config = {
disallowRobot: boolean;
enableCluster?: string;
isPackage: boolean;
nodeName?: string;
puppeteerWSEndpoint?: string;
chromiumExecutablePath?: string;
connect: {
port: number;
socket: string | null;
};
listenInaddrAny: boolean;
requestRetry: number;
requestTimeout: number;
ua: string;
trueUA: string;
allowOrigin?: string;
cache: {
type: string;
requestTimeout: number;
routeExpire: number;
contentExpire: number;
};
memory: {
max: number;
};
redis: {
url: string;
};
proxyUri?: string;
proxy: {
protocol?: string;
host?: string;
port?: string;
auth?: string;
url_regex: string;
};
proxyStrategy: string;
reverseProxyUrl?: string;
pacUri?: string;
pacScript?: string;
authentication: {
name: string;
pass: string;
};
denylist?: string[];
allowlist?: string[];
allowLocalhost: boolean;
accessKey?: string;
debugInfo: string;
loggerLevel: string;
noLogfiles?: boolean;
showLoggerTimestamp?: boolean;
sentry: {
dsn?: string;
routeTimeout: number;
};
hotlink: {
template?: string;
includePaths?: string[];
excludePaths?: string[];
};
feature: {
allow_user_hotlink_template: boolean;
filter_regex_engine: string;
allow_user_supply_unsafe_domain: boolean;
};
suffix?: string;
titleLengthLimit: number;
openai: {
apiKey?: string;
model?: string;
temperature?: number;
maxTokens?: number;
endpoint?: string;
prompt?: string;
};
bilibili: {
cookies: Record<string, string | undefined>;
dmImgList?: string;
};
bitbucket: {
username?: string;
password?: string;
};
btbyr: {
host?: string;
cookies?: string;
};
bupt: {
portal_cookie?: string;
};
civitai: {
cookie?: string;
};
dida365: {
username?: string;
password?: string;
};
discord: {
authorization?: string;
};
discourse: {
config: Record<string, string | undefined>;
};
discuz: {
cookies: Record<string, string | undefined>;
};
disqus: {
api_key?: string;
};
douban: {
cookie?: string;
};
ehentai: {
ipb_member_id?: string;
ipb_pass_hash?: string;
sk?: string;
igneous?: string;
star?: string;
img_proxy?: string;
};
email: {
config: Record<string, string | undefined>;
};
fanbox: {
session?: string;
};
fanfou: {
consumer_key?: string;
consumer_secret?: string;
username?: string;
password?: string;
};
fantia: {
cookies?: string;
};
game4399: {
cookie?: string;
};
github: {
access_token?: string;
};
gitee: {
access_token?: string;
};
google: {
fontsApiKey?: string;
};
hefeng: {
key?: string;
};
infzm: {
cookie?: string;
};
initium: {
username?: string;
password?: string;
bearertoken?: string;
iap_receipt?: string;
};
instagram: {
username?: string;
password?: string;
proxy?: string;
cookie?: string;
};
iwara: {
username?: string;
password?: string;
};
lastfm: {
api_key?: string;
};
lightnovel: {
cookie?: string;
};
manhuagui: {
cookie?: string;
};
mastodon: {
apiHost?: string;
accessToken?: string;
acctDomain?: string;
};
medium: {
cookies: Record<string, string | undefined>;
articleCookie?: string;
};
miniflux: {
instance?: string;
token?: string;
};
ncm: {
cookies?: string;
};
newrank: {
cookie?: string;
};
nga: {
uid?: string;
cid?: string;
};
nhentai: {
username?: string;
password?: string;
};
notion: {
key?: string;
};
pianyuan: {
cookie?: string;
};
pixabay: {
key?: string;
};
pixiv: {
refreshToken?: string;
bypassCdn?: boolean;
bypassCdnHostname?: string;
bypassCdnDoh?: string;
imgProxy?: string;
};
pkubbs: {
cookie?: string;
};
saraba1st: {
cookie?: string;
};
sehuatang: {
cookie?: string;
};
scboy: {
token?: string;
};
scihub: {
host?: string;
};
spotify: {
clientId?: string;
clientSecret?: string;
refreshToken?: string;
};
telegram: {
token?: string;
};
tophub: {
cookie?: string;
};
twitter: {
oauthTokens?: string[];
oauthTokenSecrets?: string[];
};
weibo: {
app_key?: string;
app_secret?: string;
cookies?: string;
redirect_url?: string;
};
wenku8: {
cookie?: string;
};
wordpress: {
cdnUrl?: string;
};
xiaoyuzhou: {
device_id?: string;
refresh_token?: string;
};
ximalaya: {
token?: string;
};
youtube: {
key?: string;
clientId?: string;
clientSecret?: string;
refreshToken?: string;
};
zhihu: {
cookies?: string;
};
zodgame: {
cookie?: string;
};
};
let _value: Config | undefined = undefined;
const TRUE_UA = 'RSSHub/1.0 (+http://github.com/DIYgod/RSSHub; like FeedFetcher-Google)';
const toBoolean = (value: string | undefined, defaultValue: boolean) => {
if (value !== undefined) {
return (value === '' || value === '0' || value === 'false') ? false : !!value;
} else {
return defaultValue;
}
}
const toInt = (value: string | undefined, defaultValue: number) => {
if (value !== undefined) {
return Number.parseInt(value);
} else {
return defaultValue;
}
}
const calculateValue = () => {
const bilibili_cookies = {};
const email_config = {};
const discuz_cookies = {};
const medium_cookies = {};
const discourse_config = {};
const bilibili_cookies: Record<string, string | undefined> = {};
const email_config: Record<string, string | undefined> = {};
const discuz_cookies: Record<string, string | undefined> = {};
const medium_cookies: Record<string, string | undefined> = {};
const discourse_config: Record<string, string | undefined> = {};
for (const name in envs) {
if (name.startsWith('BILIBILI_COOKIE_')) {
@@ -27,39 +332,39 @@ const calculateValue = () => {
medium_cookies[username] = envs[name];
} else if (name.startsWith('DISCOURSE_CONFIG_')) {
const id = name.slice('DISCOURSE_CONFIG_'.length);
discourse_config[id] = JSON.parse(envs[name]);
discourse_config[id] = JSON.parse(envs[name] || '{}');
}
}
value = {
_value = {
// app config
disallowRobot: envs.DISALLOW_ROBOT !== '0' && envs.DISALLOW_ROBOT !== 'false',
enableCluster: envs.ENABLE_CLUSTER,
isPackage: envs.IS_PACKAGE,
isPackage: !!envs.IS_PACKAGE,
nodeName: envs.NODE_NAME,
puppeteerWSEndpoint: envs.PUPPETEER_WS_ENDPOINT,
chromiumExecutablePath: envs.CHROMIUM_EXECUTABLE_PATH,
// network
connect: {
port: envs.PORT || 1200, // 监听端口
port: toInt(envs.PORT, 1200), // 监听端口
socket: envs.SOCKET || null, // 监听 Unix Socket, null 为禁用
},
listenInaddrAny: envs.LISTEN_INADDR_ANY || 1, // 是否允许公网连接,取值 0 1
requestRetry: Number.parseInt(envs.REQUEST_RETRY) || 2, // 请求失败重试次数
requestTimeout: Number.parseInt(envs.REQUEST_TIMEOUT) || 30000, // Milliseconds to wait for the server to end the response before aborting the request
ua: envs.UA ?? (envs.NO_RANDOM_UA === 'true' || envs.NO_RANDOM_UA === '1' ? TRUE_UA : randUserAgent({ browser: 'chrome', os: 'mac os', device: 'desktop' })),
listenInaddrAny: toBoolean(envs.LISTEN_INADDR_ANY, true), // 是否允许公网连接,取值 0 1
requestRetry: toInt(envs.REQUEST_RETRY, 2), // 请求失败重试次数
requestTimeout: toInt(envs.REQUEST_TIMEOUT, 30000), // Milliseconds to wait for the server to end the response before aborting the request
ua: envs.UA ?? (toBoolean(envs.NO_RANDOM_UA, false) ? TRUE_UA : randUserAgent({ browser: 'chrome', os: 'mac os', device: 'desktop' })),
trueUA: TRUE_UA,
// cors request
allowOrigin: envs.ALLOW_ORIGIN,
// cache
cache: {
type: envs.CACHE_TYPE === undefined ? 'memory' : envs.CACHE_TYPE, // 缓存类型,支持 'memory' 和 'redis',设为空可以禁止缓存
requestTimeout: Number.parseInt(envs.CACHE_REQUEST_TIMEOUT) || 60,
routeExpire: Number.parseInt(envs.CACHE_EXPIRE) || 5 * 60, // 路由缓存时间,单位为秒
contentExpire: Number.parseInt(envs.CACHE_CONTENT_EXPIRE) || 1 * 60 * 60, // 不变内容缓存时间,单位为秒
type: envs.CACHE_TYPE || 'memory', // 缓存类型,支持 'memory' 和 'redis',设为空可以禁止缓存
requestTimeout: toInt(envs.CACHE_REQUEST_TIMEOUT, 60),
routeExpire: toInt(envs.CACHE_EXPIRE, 5 * 60), // 路由缓存时间,单位为秒
contentExpire: toInt(envs.CACHE_CONTENT_EXPIRE, 1 * 60 * 60), // 不变内容缓存时间,单位为秒
},
memory: {
max: Number.parseInt(envs.MEMORY_MAX) || Math.pow(2, 8), // The maximum number of items that remain in the cache. This must be a positive finite intger.
max: toInt(envs.MEMORY_MAX, Math.pow(2, 8)), // The maximum number of items that remain in the cache. This must be a positive finite intger.
// https://github.com/isaacs/node-lru-cache#options
},
redis: {
@@ -84,38 +389,38 @@ const calculateValue = () => {
pass: envs.HTTP_BASIC_AUTH_PASS || 'passw0rd',
},
// access control
denylist: envs.DENYLIST && envs.DENYLIST.split(','),
allowlist: envs.ALLOWLIST && envs.ALLOWLIST.split(','),
allowLocalhost: envs.ALLOW_LOCALHOST,
denylist: envs.DENYLIST ? envs.DENYLIST.split(',') : undefined,
allowlist: envs.ALLOWLIST ? envs.ALLOWLIST.split(',') : undefined,
allowLocalhost: toBoolean(envs.ALLOW_LOCALHOST, false),
accessKey: envs.ACCESS_KEY,
// logging
// 是否显示 Debug 信息,取值 'true' 'false' 'some_string' ,取值为 'true' 时永久显示,取值为 'false' 时永远隐藏,取值为 'some_string' 时请求带上 '?debug=some_string' 显示
debugInfo: envs.DEBUG_INFO || 'true',
loggerLevel: envs.LOGGER_LEVEL || 'info',
noLogfiles: envs.NO_LOGFILES,
showLoggerTimestamp: envs.SHOW_LOGGER_TIMESTAMP,
noLogfiles: toBoolean(envs.NO_LOGFILES, false),
showLoggerTimestamp: toBoolean(envs.SHOW_LOGGER_TIMESTAMP, false),
sentry: {
dsn: envs.SENTRY,
routeTimeout: Number.parseInt(envs.SENTRY_ROUTE_TIMEOUT) || 30000,
routeTimeout: toInt(envs.SENTRY_ROUTE_TIMEOUT, 30000),
},
// feed config
hotlink: {
template: envs.HOTLINK_TEMPLATE,
includePaths: envs.HOTLINK_INCLUDE_PATHS && envs.HOTLINK_INCLUDE_PATHS.split(','),
excludePaths: envs.HOTLINK_EXCLUDE_PATHS && envs.HOTLINK_EXCLUDE_PATHS.split(','),
includePaths: envs.HOTLINK_INCLUDE_PATHS ? envs.HOTLINK_INCLUDE_PATHS.split(',') : undefined,
excludePaths: envs.HOTLINK_EXCLUDE_PATHS ? envs.HOTLINK_EXCLUDE_PATHS.split(',') : undefined,
},
feature: {
allow_user_hotlink_template: envs.ALLOW_USER_HOTLINK_TEMPLATE === 'true',
allow_user_hotlink_template: toBoolean(envs.ALLOW_USER_HOTLINK_TEMPLATE, false),
filter_regex_engine: envs.FILTER_REGEX_ENGINE || 're2',
allow_user_supply_unsafe_domain: envs.ALLOW_USER_SUPPLY_UNSAFE_DOMAIN === 'true',
allow_user_supply_unsafe_domain: toBoolean(envs.ALLOW_USER_SUPPLY_UNSAFE_DOMAIN, false),
},
suffix: envs.SUFFIX,
titleLengthLimit: Number.parseInt(envs.TITLE_LENGTH_LIMIT) || 150,
titleLengthLimit: toInt(envs.TITLE_LENGTH_LIMIT, 150),
openai: {
apiKey: envs.OPENAI_API_KEY,
model: envs.OPENAI_MODEL || 'gpt-3.5-turbo-16k',
temperature: envs.OPENAI_TEMPERATURE || 0.2,
maxTokens: envs.OPENAI_MAX_TOKENS || null,
temperature: toInt(envs.OPENAI_TEMPERATURE, 0.2),
maxTokens: toInt(envs.OPENAI_MAX_TOKENS, 0) || undefined,
endpoint: envs.OPENAI_API_ENDPOINT || 'https://api.openai.com/v1',
prompt: envs.OPENAI_PROMPT || 'Please summarize the following article and reply with markdown format.',
},
@@ -263,7 +568,7 @@ const calculateValue = () => {
},
pixiv: {
refreshToken: envs.PIXIV_REFRESHTOKEN,
bypassCdn: envs.PIXIV_BYPASS_CDN && envs.PIXIV_BYPASS_CDN !== '0' && envs.PIXIV_BYPASS_CDN !== 'false',
bypassCdn: toBoolean(envs.PIXIV_BYPASS_CDN, false),
bypassCdnHostname: envs.PIXIV_BYPASS_HOSTNAME || 'public-api.secure.pixiv.net',
bypassCdnDoh: envs.PIXIV_BYPASS_DOH || 'https://1.1.1.1/dns-query',
imgProxy: envs.PIXIV_IMG_PROXY || 'https://i.pixiv.re',
@@ -340,20 +645,17 @@ if (envs.REMOTE_CONFIG) {
if (data) {
envs = Object.assign(envs, data);
calculateValue();
require('@/utils/logger').info('Remote config loaded.');
require('@/utils/logger').default.info('Remote config loaded.');
}
})
.catch((error) => {
require('@/utils/logger').error('Remote config load failed.', error);
require('@/utils/logger').default.error('Remote config load failed.', error);
});
}
module.exports = {
set: (env) => {
envs = Object.assign(process.env, env);
calculateValue();
},
get value() {
return value;
},
};
export const config: Config = _value!;
export const setConfig = (env: Partial<Config>) => {
envs = Object.assign(process.env, env);
calculateValue();
}
@@ -1,3 +1,3 @@
class RequestInProgressError extends Error {}
module.exports = RequestInProgressError;
export default RequestInProgressError;
-3
View File
@@ -1,3 +0,0 @@
module.exports = {
RequestInProgressError: require('./RequestInProgress'),
};
+3
View File
@@ -0,0 +1,3 @@
import _RequestInProgressError from './RequestInProgress';
export const RequestInProgressError = _RequestInProgressError;
+51
View File
@@ -0,0 +1,51 @@
import { serve } from '@hono/node-server'
import { Handler, Hono } from 'hono'
import cache from '@/middleware/cache'
import template from '@/middleware/template'
import onerror from '@/middleware/onerror'
import accessControl from '@/middleware/access-control'
import debug from '@/middleware/debug'
import header from '@/middleware/header'
import antiHotlink from '@/middleware/anti-hotlink'
import parameter from '@/middleware/parameter'
import routes from '@/routes'
import { config } from '@/config'
const app = new Hono()
app.use('*', onerror);
app.use('*', accessControl);
app.use('*', debug);
app.use('*', header);
app.use('*', template);
app.use('*', antiHotlink);
app.use('*', parameter);
app.use('*', cache);
for (const name in routes) {
const subApp = app.basePath(`/${name}`)
routes[name]({
get: (path, handler) => {
const wrapedHandler: Handler = async (ctx, ...args) => {
if (!ctx.get('data')) {
await handler(ctx, ...args)
}
}
subApp.get(path, wrapedHandler)
},
})
}
app.get('/', (c) => {
return c.text('Hello Hono!')
})
const port = config.connect.port
console.log(`Server is running on port ${port}`)
serve({
fetch: app.fetch,
port
})
@@ -1,9 +1,11 @@
const config = require('@/config').value;
const md5 = require('@/utils/md5');
const isLocalhost = require('is-localhost-ip');
import type { MiddlewareHandler, Context } from 'hono';
import { config } from '@/config';
import md5 from '@/utils/md5';
import isLocalhost from 'is-localhost-ip';
import { getIp } from '@/utils/helpers';
const reject = (ctx) => {
ctx.response.status = 403;
const reject = (ctx: Context) => {
ctx.status(403);
throw new Error('Authentication failed. Access denied.');
};
@@ -11,7 +13,7 @@ const reject = (ctx) => {
const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}$/;
const cidrPattern = /((?:\d{1,3}\.){3}\d{1,3})\/(\d{1,2})/;
const ipInCidr = (cidr, ip) => {
const ipInCidr = (cidr: string, ip: string) => {
const cidrMatch = cidr.match(cidrPattern);
const ipMatch = ip.match(ipv4Pattern);
if (!cidrMatch || !ipMatch) {
@@ -23,25 +25,25 @@ const ipInCidr = (cidr, ip) => {
return cidrIpBits === ipBits;
};
const ipv4ToBitsring = (ip) =>
const ipv4ToBitsring = (ip: string) =>
ip
.split('.')
.map((part) => ('00000000' + Number.parseInt(part).toString(2)).slice(-8))
.join('');
module.exports = async (ctx, next) => {
const ip = ctx.ips[0] || ctx.ip;
const requestPath = ctx.request.path;
const requestUA = ctx.request.header['user-agent'];
const accessKey = ctx.query.key;
const accessCode = ctx.query.code;
const middleware: MiddlewareHandler = async (ctx, next) => {
const ip = getIp(ctx);
const requestPath = ctx.req.path;
const requestUA = ctx.req.header('user-agent');
const accessKey = ctx.req.query('key');
const accessCode = ctx.req.query('code');
const isControlled = config.accessKey || config.allowlist || config.denylist;
const allowLocalhost = config.allowLocalhost && (await isLocalhost(ip));
const allowLocalhost = config.allowLocalhost && ip && (await isLocalhost(ip));
const grant = async () => {
if (ctx.response.status !== 403) {
if (ctx.res.status !== 403) {
await next();
}
};
@@ -57,14 +59,16 @@ module.exports = async (ctx, next) => {
return grant();
}
if (config.allowlist && config.allowlist.some((item) => ip.includes(item) || ipInCidr(item, ip) || requestPath.includes(item) || requestUA.includes(item))) {
if (config.allowlist && config.allowlist.some((item) => ip?.includes(item) || (ip && ipInCidr(item, ip)) || requestPath.includes(item) || requestUA?.includes(item))) {
return grant();
}
if (config.denylist && !config.denylist.some((item) => ip.includes(item) || ipInCidr(item, ip) || requestPath.includes(item) || requestUA.includes(item))) {
if (config.denylist && !config.denylist.some((item) => ip?.includes(item) || (ip && ipInCidr(item, ip)) || requestPath.includes(item) || requestUA?.includes(item))) {
return grant();
}
reject(ctx);
}
};
export default middleware;
@@ -1,15 +1,16 @@
const config = require('@/config').value;
const cheerio = require('cheerio');
const logger = require('@/utils/logger');
const path = require('path');
const { art } = require('@/utils/render');
import { config } from '@/config';
import { load, type CheerioAPI } from 'cheerio';
import logger from '@/utils/logger';
import * as path from 'node:path';
import render from '@/utils/render';
import { type MiddlewareHandler } from 'hono';
const templateRegex = /\${([^{}]+)}/g;
const allowedUrlProperties = new Set(['hash', 'host', 'hostname', 'href', 'origin', 'password', 'pathname', 'port', 'protocol', 'search', 'searchParams', 'username']);
const IframeWrapperTemplate = path.join(__dirname, 'templates/iframe.art');
// match path or sub-path
const matchPath = (path, paths) => {
const matchPath = (path: string, paths: string[]) => {
for (const p of paths) {
if (path.startsWith(p) && (path.length === p.length || path[p.length] === '/')) {
return true;
@@ -19,13 +20,13 @@ const matchPath = (path, paths) => {
};
// return ture if the path needs to be processed
const filterPath = (path) => {
const filterPath = (path: string) => {
const include = config.hotlink.includePaths;
const exclude = config.hotlink.excludePaths;
return !(include && !matchPath(path, include)) && !(exclude && matchPath(path, exclude));
};
const interpolate = (str, obj) =>
const interpolate = (str: string, obj: Record<string, any>) =>
str.replaceAll(templateRegex, (_, prop) => {
let needEncode = false;
if (prop.endsWith('_ue')) {
@@ -35,7 +36,7 @@ const interpolate = (str, obj) =>
}
return needEncode ? encodeURIComponent(obj[prop]) : obj[prop];
});
const parseUrl = (str) => {
const parseUrl = (str: string) => {
let url;
try {
url = new URL(str);
@@ -45,7 +46,7 @@ const parseUrl = (str) => {
return url;
};
const replaceUrls = ($, selector, template, attribute = 'src') => {
const replaceUrls = ($: CheerioAPI, selector: string, template: string, attribute = 'src') => {
$(selector).each(function () {
const old_src = $(this).attr(attribute);
if (old_src) {
@@ -58,15 +59,15 @@ const replaceUrls = ($, selector, template, attribute = 'src') => {
});
};
const wrapWithIframe = ($, selector) => {
const wrapWithIframe = ($: CheerioAPI, selector: string) => {
$(selector).each((_, elem) => {
elem = $(elem);
elem.replaceWith(art(IframeWrapperTemplate, { content: elem.toString() }));
const $elem = $(elem);
$elem.replaceWith(render.art(IframeWrapperTemplate, { content: elem.toString() }));
});
};
const process = (html, image_hotlink_template, multimedia_hotlink_template, wrap_multimedia_in_iframe) => {
const $ = cheerio.load(html, undefined, false);
const process = (html: string, image_hotlink_template?: string, multimedia_hotlink_template?: string, wrap_multimedia_in_iframe?: boolean) => {
const $ = load(html, undefined, false);
if (image_hotlink_template) {
replaceUrls($, 'img, picture > source', image_hotlink_template);
replaceUrls($, 'video[poster]', image_hotlink_template, 'poster');
@@ -83,7 +84,7 @@ const process = (html, image_hotlink_template, multimedia_hotlink_template, wrap
return $.html();
};
const validateTemplate = (template) => {
const validateTemplate = (template?: string) => {
if (!template) {
return;
}
@@ -95,12 +96,12 @@ const validateTemplate = (template) => {
}
};
module.exports = async (ctx, next) => {
const middleware: MiddlewareHandler = async (ctx, next) => {
await next();
let image_hotlink_template;
let multimedia_hotlink_template;
const shouldWrapInIframe = ctx.query.wrap_multimedia_in_iframe === '1';
const shouldWrapInIframe = ctx.req.query('wrap_multimedia_in_iframe') === '1';
// Read params if enabled
if (config.feature.allow_user_hotlink_template) {
@@ -109,13 +110,13 @@ module.exports = async (ctx, next) => {
// A risk is that the media URLs will be replaced by user-supplied templates,
// so a user could literally take the control of "where are the media from",
// but only in their personal-use feed URL.
multimedia_hotlink_template = ctx.query.multimedia_hotlink_template;
image_hotlink_template = ctx.query.image_hotlink_template;
multimedia_hotlink_template = ctx.req.query('multimedia_hotlink_template');
image_hotlink_template = ctx.req.query('image_hotlink_template');
}
// Force config hotlink template on conflict
if (config.hotlink.template) {
image_hotlink_template = filterPath(ctx.request.path) ? config.hotlink.template : undefined;
image_hotlink_template = filterPath(ctx.req.path) ? config.hotlink.template : undefined;
}
if (!image_hotlink_template && !multimedia_hotlink_template && !shouldWrapInIframe) {
@@ -129,17 +130,22 @@ module.exports = async (ctx, next) => {
// and here we will only check them in description.
// Use Cheerio to load the description as html and filter all
// image link
if (ctx.state.data) {
if (ctx.state.data.description) {
ctx.state.data.description = process(ctx.state.data.description, image_hotlink_template, multimedia_hotlink_template, shouldWrapInIframe);
const data = ctx.get('data');
if (data) {
if (data.description) {
data.description = process(data.description, image_hotlink_template, multimedia_hotlink_template, shouldWrapInIframe);
}
if (ctx.state.data.item) {
for (const item of ctx.state.data.item) {
if (data.item) {
for (const item of data.item) {
if (item.description) {
item.description = process(item.description, image_hotlink_template, multimedia_hotlink_template, shouldWrapInIframe);
}
}
}
ctx.set('data', data);
}
};
export default middleware;
+68
View File
@@ -0,0 +1,68 @@
import xxhash from 'xxhash-wasm';
import type { MiddlewareHandler } from 'hono';
import { config } from '@/config';
import { RequestInProgressError } from '@/errors';
import cacheModule from '@/utils/cache/index'
// only give cache string, as the `!` condition tricky
// md5 is used to shrink key size
// plz, write these tips in comments!
const middleware: MiddlewareHandler = async (ctx, next) => {
const { h64ToString } = await xxhash();
const key = 'rsshub:koa-redis-cache:' + h64ToString(ctx.req.path);
const controlKey = 'rsshub:path-requested:' + h64ToString(ctx.req.path);
if (!cacheModule.status.available) {
await next();
return;
}
const isRequesting = await cacheModule.globalCache.get(controlKey);
if (isRequesting === '1') {
throw new RequestInProgressError('This path is currently fetching, please come back later!');
}
try {
const value = await cacheModule.globalCache.get(key);
if (value) {
ctx.status(200)
if (config.cache.type === 'redis') {
ctx.header('X-Koa-Redis-Cache', 'true')
} else if (config.cache.type === 'memory') {
ctx.header('X-Koa-Memory-Cache', 'true')
}
ctx.set('data', JSON.parse(value))
await next();
return;
}
} catch (error) {
throw error;
}
// Doesn't hit the cache? We need to let others know!
await cacheModule.globalCache.set(controlKey, '1', config.cache.requestTimeout);
try {
await next();
} catch (error) {
await cacheModule.globalCache.set(controlKey, '0', config.cache.requestTimeout);
throw error;
}
const data = ctx.get('data')
if (ctx.res.headers.get('Cache-Control') !== 'no-cache' && data) {
data.lastBuildDate = new Date().toUTCString();
ctx.set('data', data)
const body = JSON.stringify(data);
await cacheModule.globalCache.set(key, body, config.cache.routeExpire);
}
// We need to let it go, even no cache set.
// Wait to set cache so the next request could be handled correctly
await cacheModule.globalCache.set(controlKey, '0', config.cache.requestTimeout);
};
export default middleware;
-139
View File
@@ -1,139 +0,0 @@
const xxhash = require('xxhash-wasm');
const config = require('@/config').value;
const logger = require('@/utils/logger');
const { RequestInProgressError } = require('@/errors');
const globalCache = {
get: () => null,
set: () => null,
};
let cacheModule = {
get: () => null,
set: () => null,
status: { available: false },
clients: {},
};
if (config.cache.type === 'redis') {
cacheModule = require('./redis');
const { redisClient } = cacheModule.clients;
globalCache.get = async (key) => {
if (key && cacheModule.status.available) {
const value = await redisClient.get(key);
return value;
}
};
globalCache.set = cacheModule.set;
} else if (config.cache.type === 'memory') {
cacheModule = require('./memory');
const { memoryCache } = cacheModule.clients;
globalCache.get = (key) => {
if (key && cacheModule.status.available) {
return memoryCache.get(key, { updateAgeOnGet: false });
}
};
globalCache.set = (key, value, maxAge) => {
if (!value || value === 'undefined') {
value = '';
}
if (typeof value === 'object') {
value = JSON.stringify(value);
}
if (key) {
return memoryCache.set(key, value, { ttl: maxAge * 1000 });
}
};
} else {
logger.error('Cache not available, concurrent requests are not limited. This could lead to bad behavior.');
}
// only give cache string, as the `!` condition tricky
// md5 is used to shrink key size
// plz, write these tips in comments!
module.exports = function (app) {
const { get, set, status } = cacheModule;
app.context.cache = {
...cacheModule,
tryGet: async (key, getValueFunc, maxAge = config.cache.contentExpire, refresh = true) => {
if (typeof key !== 'string') {
throw new TypeError('Cache key must be a string');
}
let v = await get(key, refresh);
if (v) {
let parsed;
try {
parsed = JSON.parse(v);
} catch {
parsed = null;
}
if (parsed) {
v = parsed;
}
} else {
v = await getValueFunc();
set(key, v, maxAge);
}
return v;
},
globalCache,
};
return async (ctx, next) => {
const { h64ToString } = await xxhash();
const key = 'rsshub:koa-redis-cache:' + h64ToString(ctx.request.path);
const controlKey = 'rsshub:path-requested:' + h64ToString(ctx.request.path);
if (!status.available) {
return next();
}
const isRequesting = await globalCache.get(controlKey);
if (isRequesting === '1') {
throw new RequestInProgressError('This path is currently fetching, please come back later!');
}
try {
const value = await globalCache.get(key);
if (value) {
ctx.response.status = 200;
if (config.cache.type === 'redis') {
ctx.response.set({
'X-Koa-Redis-Cache': 'true',
});
} else if (config.cache.type === 'memory') {
ctx.response.set({
'X-Koa-Memory-Cache': 'true',
});
}
ctx.state.data = JSON.parse(value);
return;
}
} catch {
//
}
// Doesn't hit the cache? We need to let others know!
await globalCache.set(controlKey, '1', config.cache.requestTimeout);
try {
await next();
} catch (error) {
await globalCache.set(controlKey, '0', config.cache.requestTimeout);
throw error;
}
if (ctx.response.get('Cache-Control') !== 'no-cache' && ctx.state && ctx.state.data) {
ctx.state.data.lastBuildDate = new Date().toUTCString();
const body = JSON.stringify(ctx.state.data);
await globalCache.set(key, body, config.cache.routeExpire);
}
// We need to let it go, even no cache set.
// Wait to set cache so the next request could be handled correctly
await globalCache.set(controlKey, '0', config.cache.requestTimeout);
};
};
-25
View File
@@ -1,25 +0,0 @@
module.exports = async (ctx, next) => {
if (!ctx.debug.paths[ctx.request.path]) {
ctx.debug.paths[ctx.request.path] = 0;
}
ctx.debug.paths[ctx.request.path]++;
ctx.debug.request++;
await next();
if (!ctx.debug.routes[ctx._matchedRoute]) {
ctx._matchedRoute && (ctx.debug.routes[ctx._matchedRoute] = 0);
}
ctx._matchedRoute && ctx.debug.routes[ctx._matchedRoute]++;
if (ctx.response.get('X-Koa-Redis-Cache') || ctx.response.get('X-Koa-Memory-Cache')) {
ctx.debug.hitCache++;
}
ctx.state.debuged = true;
if (ctx.status === 304) {
ctx.debug.etag++;
}
};
+43
View File
@@ -0,0 +1,43 @@
import { MiddlewareHandler } from "hono";
import { getRouteNameFromPath } from '@/utils/helpers';
const middleware: MiddlewareHandler = async (ctx, next) => {
const debug = Object.assign({
hitCache: 0,
request: 0,
etag: 0,
paths: [],
routes: [],
errorPaths: [],
errorRoutes: [],
}, ctx.get('debug'));
if (!debug.paths[ctx.req.path]) {
debug.paths[ctx.req.path] = 0;
}
debug.paths[ctx.req.path]++;
debug.request++;
await next();
const routeName = getRouteNameFromPath(ctx.req.path);
if (routeName) {
if (!debug.routes[routeName]) {
debug.routes[routeName] = 0;
}
debug.routes[routeName]++;
}
if (ctx.res.headers.get('X-Koa-Redis-Cache') || ctx.res.headers.get('X-Koa-Memory-Cache')) {
debug.hitCache++;
}
ctx.set('debuged', true);
if (ctx.res.status === 304) {
debug.etag++;
}
};
export default middleware;
-45
View File
@@ -1,45 +0,0 @@
const etagCalculate = require('etag');
const logger = require('@/utils/logger');
const config = require('@/config').value;
const headers = {
'Access-Control-Allow-Methods': 'GET',
'Content-Type': 'application/xml; charset=utf-8',
'Cache-Control': `public, max-age=${config.cache.routeExpire}`,
'X-Content-Type-Options': 'nosniff',
};
if (config.nodeName) {
headers['RSSHub-Node'] = config.nodeName;
}
module.exports = async (ctx, next) => {
logger.info(`${ctx.url}, user IP: ${ctx.ips[0] || ctx.ip}`);
ctx.set(headers);
ctx.set({
'Access-Control-Allow-Origin': config.allowOrigin || ctx.host,
});
await next();
if (!ctx.body || typeof ctx.body !== 'string' || ctx.response.get('ETag')) {
return;
}
const status = Math.trunc(ctx.status / 100);
if (2 !== status) {
return;
}
ctx.set('ETag', etagCalculate(ctx.body.replace(/<lastBuildDate>(.*)<\/lastBuildDate>/, '').replace(/<atom:link(.*)\/>/, '')));
if (ctx.fresh) {
ctx.status = 304;
ctx.body = null;
} else {
const match = ctx.body.match(/<lastBuildDate>(.*)<\/lastBuildDate>/);
if (match) {
ctx.set({
'Last-Modified': match[1],
});
}
}
};
+59
View File
@@ -0,0 +1,59 @@
import { getIp } from "@/utils/helpers";
import { MiddlewareHandler } from "hono";
import etagCalculate from "etag";
import logger from "@/utils/logger";
import { config } from "@/config";
const headers: Record<string, string> = {
'Access-Control-Allow-Methods': 'GET',
'Content-Type': 'application/xml; charset=utf-8',
'Cache-Control': `public, max-age=${config.cache.routeExpire}`,
'X-Content-Type-Options': 'nosniff',
};
if (config.nodeName) {
headers['RSSHub-Node'] = config.nodeName;
}
function etagMatches(etag: string, ifNoneMatch: string | null) {
return ifNoneMatch != null && ifNoneMatch.split(/,\s*/).indexOf(etag) > -1
}
const middleware: MiddlewareHandler = async (ctx, next) => {
const ip = getIp(ctx);
logger.info(`${ctx.req.url}, user IP: ${ip}`);
for (const key in headers) {
ctx.header(key, headers[key])
}
ctx.header('Access-Control-Allow-Origin', config.allowOrigin || new URL(ctx.req.url).host);
await next();
if (!ctx.res.body || ctx.res.headers.get('ETag')) {
return;
}
const status = Math.trunc(ctx.res.status / 100);
if (2 !== status) {
return;
}
const res = ctx.res as Response;
const body = await res.clone().text();
const etag = etagCalculate(body.replace(/<lastBuildDate>(.*)<\/lastBuildDate>/, '').replace(/<atom:link(.*)\/>/, ''));
ctx.set('ETag', etag);
const ifNoneMatch = ctx.req.header('If-None-Match') ?? null;
if (etagMatches(etag, ifNoneMatch)) {
ctx.status(304);
ctx.body(null);
} else {
const match = body.match(/<lastBuildDate>(.*)<\/lastBuildDate>/);
if (match) {
ctx.header('Last-Modified', match[1]);
}
}
};
export default middleware;
-119
View File
@@ -1,119 +0,0 @@
const logger = require('@/utils/logger');
const config = require('@/config').value;
const art = require('art-template');
const path = require('path');
const { RequestInProgressError } = require('@/errors');
let Sentry;
let gitHash;
if (config.sentry.dsn) {
Sentry = Sentry || require('@sentry/node');
Sentry.init({
dsn: config.sentry.dsn,
});
Sentry.getCurrentScope().setTag('node_name', config.nodeName);
logger.info('Sentry inited.');
}
try {
gitHash = require('git-rev-sync').short();
} catch {
gitHash = (process.env.HEROKU_SLUG_COMMIT && process.env.HEROKU_SLUG_COMMIT.slice(0, 7)) || (process.env.VERCEL_GIT_COMMIT_SHA && process.env.VERCEL_GIT_COMMIT_SHA.slice(0, 7)) || 'unknown';
}
module.exports = async (ctx, next) => {
try {
const time = Date.now();
await next();
if (config.sentry.dsn && Date.now() - time >= config.sentry.routeTimeout) {
Sentry.withScope((scope) => {
scope.setTag('route', ctx._matchedRoute);
scope.setTag('name', ctx.request.path.split('/')[1]);
scope.addEventProcessor((event) => Sentry.Handlers.parseRequest(event, ctx.request));
Sentry.captureException(new Error('Route Timeout'));
});
}
} catch (error) {
if (error instanceof Error && !error.stack.split('\n')[1].includes('lib/middleware/parameter.js')) {
// Append v2 route path if a route throws an error
// since koa-mount will remove the mount path from ctx.request.path
// https://github.com/koajs/mount/issues/62
ctx.request.path = (ctx.mountPath ?? '') + ctx.request.path;
ctx._matchedRoute = ctx._matchedRoute ? (ctx.mountPath ?? '') + ctx._matchedRoute : ctx._matchedRoute;
}
let message = error;
if (error.name && (error.name === 'HTTPError' || error.name === 'RequestError')) {
message = `${error.message}: target website might be blocking our access, you can <a href="https://docs.rsshub.app/install/">host your own RSSHub instance</a> for a better usability.`;
} else if (error instanceof Error) {
message = process.env.NODE_ENV === 'production' ? error.message : error.stack;
}
logger.error(`Error in ${ctx.request.path}: ${message}`);
if (config.isPackage) {
ctx.body = {
error: {
message: error.message ?? error,
},
};
} else {
ctx.set({
'Content-Type': 'text/html; charset=UTF-8',
});
if (error instanceof RequestInProgressError) {
ctx.status = 503;
message = error.message;
ctx.set('Cache-Control', `public, max-age=${config.cache.requestTimeout}`);
} else if (ctx.status === 403) {
message = error.message;
} else {
ctx.status = 404;
}
const requestPath = ctx.request.path;
ctx.body = art(path.resolve(__dirname, '../views/error.art'), {
requestPath,
message,
errorPath: ctx.path,
nodeVersion: process.version,
gitHash,
});
}
if (!ctx.debug.errorPaths[ctx.request.path]) {
ctx.debug.errorPaths[ctx.request.path] = 0;
}
ctx.debug.errorPaths[ctx.request.path]++;
if (!ctx.debug.errorRoutes[ctx._matchedRoute]) {
ctx._matchedRoute && (ctx.debug.errorRoutes[ctx._matchedRoute] = 0);
}
ctx._matchedRoute && ctx.debug.errorRoutes[ctx._matchedRoute]++;
if (!ctx.state.debuged) {
if (!ctx.debug.routes[ctx._matchedRoute]) {
ctx._matchedRoute && (ctx.debug.routes[ctx._matchedRoute] = 0);
}
ctx._matchedRoute && ctx.debug.routes[ctx._matchedRoute]++;
if (ctx.response.get('X-Koa-Redis-Cache') || ctx.response.get('X-Koa-Memory-Cache')) {
ctx.debug.hitCache++;
}
}
if (config.sentry.dsn) {
Sentry.withScope((scope) => {
scope.setTag('route', ctx._matchedRoute);
scope.setTag('name', ctx.request.path.split('/')[1]);
scope.addEventProcessor((event) => Sentry.Handlers.parseRequest(event, ctx.request));
Sentry.captureException(error);
});
}
}
};
+92
View File
@@ -0,0 +1,92 @@
import { MiddlewareHandler } from "hono";
import logger from "@/utils/logger";
import { config } from "@/config";
import art from 'art-template';
import * as path from 'node:path';
import { RequestInProgressError } from '@/errors';
import Sentry from '@sentry/node';
import { getRouteNameFromPath } from "@/utils/helpers";
import gitRevSync from 'git-rev-sync';
let gitHash;
if (config.sentry.dsn) {
Sentry.init({
dsn: config.sentry.dsn,
});
Sentry.getCurrentScope().setTag('node_name', config.nodeName);
logger.info('Sentry inited.');
}
try {
gitHash = gitRevSync.short();
} catch {
gitHash = (process.env.HEROKU_SLUG_COMMIT && process.env.HEROKU_SLUG_COMMIT.slice(0, 7)) || (process.env.VERCEL_GIT_COMMIT_SHA && process.env.VERCEL_GIT_COMMIT_SHA.slice(0, 7)) || 'unknown';
}
const middleware: MiddlewareHandler = async (ctx, next) => {
try {
const time = Date.now();
await next();
if (config.sentry.dsn && Date.now() - time >= config.sentry.routeTimeout) {
Sentry.withScope((scope) => {
scope.setTag('name', getRouteNameFromPath(ctx.req.path));
Sentry.captureException(new Error('Route Timeout'));
});
}
} catch (error: any) {
let message = error;
if (error.name && (error.name === 'HTTPError' || error.name === 'RequestError')) {
message = `${error.message}: target website might be blocking our access, you can <a href="https://docs.rsshub.app/install/">host your own RSSHub instance</a> for a better usability.`;
} else if (error instanceof Error) {
message = process.env.NODE_ENV === 'production' ? error.message : error.stack;
}
logger.error(`Error in ${ctx.req.path}: ${message}`);
if (config.isPackage) {
ctx.json({
error: {
message: error.message ?? error,
},
});
} else {
ctx.header('Content-Type', 'text/html; charset=UTF-8');
if (error instanceof RequestInProgressError) {
ctx.status(503);
message = error.message;
ctx.set('Cache-Control', `public, max-age=${config.cache.requestTimeout}`);
} else if (ctx.res.status === 403) {
message = error.message;
} else {
ctx.status(404);
}
const requestPath = ctx.req.path;
ctx.body = art(path.resolve(__dirname, '../views/error.art'), {
requestPath,
message,
errorPath: ctx.req.path,
nodeVersion: process.version,
gitHash,
});
}
const debug = ctx.get('debug');
if (ctx.res.headers.get('X-Koa-Redis-Cache') || ctx.res.headers.get('X-Koa-Memory-Cache')) {
debug.hitCache++;
}
if (config.sentry.dsn) {
Sentry.withScope((scope) => {
scope.setTag('name', ctx.req.path.split('/')[1]);
Sentry.captureException(error);
});
}
}
};
export default middleware;
-370
View File
@@ -1,370 +0,0 @@
const entities = require('entities');
const cheerio = require('cheerio');
const { simplecc } = require('simplecc-wasm');
const got = require('@/utils/got');
const config = require('@/config').value;
const { RE2JS } = require('re2js');
const md = require('markdown-it')({
html: true,
});
const htmlToText = require('html-to-text');
let mercury_parser;
const resolveRelativeLink = ($, elem, attr, baseUrl) => {
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 summarizeArticle = async (articleText) => {
const apiUrl = `${config.openai.endpoint}/chat/completions`;
const response = await got.post(apiUrl, {
json: {
model: config.openai.model,
max_tokens: config.openai.maxTokens,
messages: [
{ role: 'system', content: config.openai.prompt },
{ role: 'user', content: articleText },
],
temperature: config.openai.temperature,
},
headers: {
Authorization: `Bearer ${config.openai.apiKey}`,
},
});
return response.data.choices[0].message.content;
};
module.exports = async (ctx, next) => {
await next();
if (!ctx.state.data && !ctx._matchedRoute) {
throw new Error('wrong path');
}
if (ctx.state.data) {
if ((!ctx.state.data.item || ctx.state.data.item.length === 0) && !ctx.state.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
ctx.state.data.item = ctx.state.data.item || [];
// decode HTML entities
ctx.state.data.title && (ctx.state.data.title = entities.decodeXML(ctx.state.data.title + ''));
ctx.state.data.description && (ctx.state.data.description = entities.decodeXML(ctx.state.data.description + ''));
// sort items
if (ctx.query.sorted !== 'false') {
ctx.state.data.item = ctx.state.data.item.sort((a, b) => +new Date(b.pubDate || 0) - +new Date(a.pubDate || 0));
}
const handleItem = (item) => {
item.title && (item.title = entities.decodeXML(item.title + ''));
// handle pubDate
if (item.pubDate) {
item.pubDate = new Date(item.pubDate).toUTCString();
}
// handle link
if (item.link) {
let baseUrl = ctx.state.data.link;
if (baseUrl && !/^https?:\/\//.test(baseUrl)) {
baseUrl = /^\/\//.test(baseUrl) ? 'http:' + baseUrl : 'http://' + baseUrl;
}
item.link = new URL(item.link, baseUrl).href;
}
// handle description
if (item.description) {
const $ = cheerio.load(item.description);
let baseUrl = item.link || ctx.state.data.link;
if (baseUrl && !/^https?:\/\//.test(baseUrl)) {
baseUrl = /^\/\//.test(baseUrl) ? 'http:' + baseUrl : 'http://' + baseUrl;
}
$('script').remove();
$('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) => {
$(elem).attr('referrerpolicy', 'no-referrer');
});
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) => typeof e === 'string');
}
return item;
};
ctx.state.data.item = await Promise.all(ctx.state.data.item.map((itm) => handleItem(itm)));
if (ctx.query) {
// filter
const engine = config.feature.filter_regex_engine;
const makeRegex = (string) => {
if (!string) {
return null;
}
// default: case_senstivie = true
const insensitive = ctx.query.filter_case_sensitive === 'false';
switch (engine) {
case 'regexp':
return new RegExp(string, insensitive ? 'i' : '');
case 're2':
return RE2JS.compile(string, insensitive ? RE2JS.CASE_INSENSITIVE : 0);
default:
throw new Error(`Invalid Engine Value: ${engine}, please check your config.`);
}
};
if (ctx.query.filter) {
const regex = makeRegex(ctx.query.filter);
ctx.state.data.item = ctx.state.data.item.filter((item) => {
const title = item.title || '';
const description = item.description || title;
const author = item.author || '';
const category = item.category || [];
const isFilter =
engine === 're2'
? 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.query.filter && (ctx.query.filter_title || ctx.query.filter_description || ctx.query.filter_author || ctx.query.filter_category)) {
ctx.state.data.item = ctx.state.data.item.filter((item) => {
const title = item.title || '';
const description = item.description || title;
const author = item.author || '';
const category = item.category || [];
let isFilter = true;
const titleRegex = makeRegex(ctx.query.filter_title);
const descriptionRegex = makeRegex(ctx.query.filter_description);
const authorRegex = makeRegex(ctx.query.filter_author);
const categoryRegex = makeRegex(ctx.query.filter_category);
ctx.query.filter_title && (isFilter = engine === 're2' ? titleRegex.matcher(title).find() : title.match(titleRegex));
ctx.query.filter_description && (isFilter = isFilter && (engine === 're2' ? descriptionRegex.matcher(description).find() : description.match(descriptionRegex)));
ctx.query.filter_author && (isFilter = isFilter && (engine === 're2' ? authorRegex.matcher(author).find() : author.match(authorRegex)));
ctx.query.filter_category && (isFilter = isFilter && category.some((c) => (engine === 're2' ? categoryRegex.matcher(c).find() : c.match(categoryRegex))));
return isFilter;
});
}
if (ctx.query.filterout || ctx.query.filterout_title || ctx.query.filterout_description || ctx.query.filterout_author || ctx.query.filterout_category) {
if (ctx.query.filterout) {
ctx.query.filterout_title = ctx.query.filterout;
ctx.query.filterout_description = ctx.query.filterout;
}
ctx.state.data.item = ctx.state.data.item.filter((item) => {
const title = item.title;
const description = item.description || title;
const author = item.author || '';
const category = item.category || [];
let isFilter = true;
const titleRegex = makeRegex(ctx.query.filterout_title);
const descriptionRegex = makeRegex(ctx.query.filterout_description);
const authorRegex = makeRegex(ctx.query.filterout_author);
const categoryRegex = makeRegex(ctx.query.filterout_category);
ctx.query.filterout_title && (isFilter = engine === 're2' ? !titleRegex.matcher(title).find() : !title.match(titleRegex));
ctx.query.filterout_description && (isFilter = isFilter && (engine === 're2' ? !descriptionRegex.matcher(description).find() : !description.match(descriptionRegex)));
ctx.query.filterout_author && (isFilter = isFilter && (engine === 're2' ? !authorRegex.matcher(author).find() : !author.match(authorRegex)));
ctx.query.filterout_category && (isFilter = isFilter && !category.some((c) => (engine === 're2' ? categoryRegex.matcher(c).find() : c.match(categoryRegex))));
return isFilter;
});
}
if (ctx.query.filter_time) {
const now = Date.now();
ctx.state.data.item = ctx.state.data.item.filter(({ pubDate }) => {
let isFilter = true;
try {
isFilter = !pubDate || now - new Date(pubDate).getTime() <= Number.parseInt(ctx.query.filter_time) * 1000;
} catch {
// no-empty
}
return isFilter;
});
}
// limit
if (ctx.query.limit) {
ctx.state.data.item = ctx.state.data.item.slice(0, Number.parseInt(ctx.query.limit));
}
// telegram instant view
if (ctx.query.tgiv) {
ctx.state.data.item.map((item) => {
const encodedlink = encodeURIComponent(item.link);
item.link = `https://t.me/iv?url=${encodedlink}&rhash=${ctx.query.tgiv}`;
return item;
});
}
// fulltext
if (ctx.query.mode && ctx.query.mode.toLowerCase() === 'fulltext') {
const tasks = ctx.state.data.item.map(async (item) => {
const { link, author, description } = item;
const parsed_result = await ctx.cache.tryGet(`mercury-cache-${link}`, async () => {
// if parser failed, return default description and not report error
try {
mercury_parser = mercury_parser || require('@postlight/parser');
const { data: res } = await got(link);
const $ = cheerio.load(res);
const result = await mercury_parser.parse(link, {
html: $.html(),
});
return result;
} catch {
// no-empty
}
});
item.author = author || (parsed_result ? parsed_result.author : '');
item.description = parsed_result && parsed_result.content.length > 40 ? entities.decodeXML(parsed_result.content) : description;
});
await Promise.all(tasks);
}
// openai
if (ctx.query.chatgpt && config.openai.apiKey) {
ctx.state.data.item = await Promise.all(
ctx.state.data.item.map(async (item) => {
if (item.description) {
try {
const summary = await ctx.cache.tryGet(`openai:${item.link}`, async () => {
const text = htmlToText.htmlToText(item.description);
if (text.length < 300) {
return '';
}
const summary_md = await summarizeArticle(text);
return md.render(summary_md);
});
// 将总结结果添加到文章数据中
if (summary !== '') {
item.description = summary + '<hr/><br/>' + item.description;
}
} catch {
// when openai failed, return default description and not write cache
}
}
return item;
})
);
}
// scihub
if (ctx.query.scihub) {
ctx.state.data.item.map((item) => {
item.link = item.doi ? `${config.scihub.host}${item.doi}` : `${config.scihub.host}${item.link}`;
return item;
});
}
// opencc
if (ctx.query.opencc) {
for (const item of ctx.state.data.item) {
item.title = simplecc(item.title ?? item.link, ctx.query.opencc);
item.description = simplecc(item.description ?? item.title ?? item.link, ctx.query.opencc);
}
}
// brief
if (ctx.query.brief) {
const num = /[1-9]\d{2,}/;
if (num.test(ctx.query.brief)) {
ctx.query.brief = Number.parseInt(ctx.query.brief);
for (const item of ctx.state.data.item) {
let text;
if (item.description) {
text = item.description.replaceAll(/<\/?[^>]+(>|$)/g, '');
}
if (text?.length) {
item.description = text.length > ctx.query.brief ? `<p>${text.substring(0, ctx.query.brief)}…</p>` : `<p>${text}</p>`;
}
}
} else {
throw new Error(`Invalid parameter brief. Please check the doc https://docs.rsshub.app/parameter#shu-chu-jian-xun`);
}
}
// some parameters are processed in `anti-hotlink.js`
}
}
};
+380
View File
@@ -0,0 +1,380 @@
import * as entities from 'entities';
import { load, type CheerioAPI, type Element } from 'cheerio';
import { simplecc } from 'simplecc-wasm';
import got from '@/utils/got';
import { config } from '@/config';
import { RE2JS } from 're2js';
import markdownit from 'markdown-it'
import htmlToText from 'html-to-text'
import { MiddlewareHandler } from 'hono';
import cache from '@/utils/cache';
import Parser from '@postlight/parser';
import { Data, DataItem } from '@/types';
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 summarizeArticle = async (articleText: string) => {
const apiUrl = `${config.openai.endpoint}/chat/completions`;
const response = await got.post(apiUrl, {
json: {
model: config.openai.model,
max_tokens: config.openai.maxTokens,
messages: [
{ role: 'system', content: config.openai.prompt },
{ role: 'user', content: articleText },
],
temperature: config.openai.temperature,
},
headers: {
Authorization: `Bearer ${config.openai.apiKey}`,
},
});
return response.data.choices[0].message.content;
};
const middleware: MiddlewareHandler = async (ctx, next) => {
await next();
const data = ctx.get('data') as Data;
if (!data) {
throw new Error('wrong path');
} else {
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 = data.item || [];
// decode HTML entities
data.title && (data.title = entities.decodeXML(data.title + ''));
data.description && (data.description = entities.decodeXML(data.description + ''));
// sort items
if (ctx.req.query('sorted') !== 'false') {
data.item = data.item.sort((a: DataItem, b: DataItem) => +new Date(b.pubDate || 0) - +new Date(a.pubDate || 0));
}
const handleItem = (item: DataItem) => {
item.title && (item.title = entities.decodeXML(item.title + ''));
// 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 = /^\/\//.test(baseUrl) ? '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 = /^\/\//.test(baseUrl) ? 'http:' + baseUrl : 'http://' + baseUrl;
}
$('script').remove();
$('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) => {
$(elem).attr('referrerpolicy', 'no-referrer');
});
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) => typeof e === 'string');
}
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 = item.author || '';
const 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 = item.author || '';
const 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() : !!title.match(titleRegex);
}
if (ctx.req.query('filter_description')) {
const descriptionRegex = makeRegex(ctx.req.query('filter_description')!);
isFilter = isFilter && (descriptionRegex instanceof RE2JS ? descriptionRegex.matcher(description).find() : !!description.match(descriptionRegex));
}
if (ctx.req.query('filter_author')) {
const authorRegex = makeRegex(ctx.req.query('filter_author')!);
isFilter = isFilter && (authorRegex instanceof RE2JS ? authorRegex.matcher(author).find() : !!author.match(authorRegex));
}
if (ctx.req.query('filter_category')) {
const categoryRegex = makeRegex(ctx.req.query('filter_category')!);
isFilter = 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 = item.author || '';
const category = item.category || [];
let isFilter = true;
if (ctx.req.query('filterout')) {
const titleRegex = makeRegex(ctx.req.query('filterout_title') || ctx.req.query('filterout')!);
const descriptionRegex = makeRegex(ctx.req.query('filterout_description') || ctx.req.query('filterout')!);
isFilter = titleRegex instanceof RE2JS ? !titleRegex.matcher(title).find() : !title.match(titleRegex);
isFilter = isFilter && (descriptionRegex instanceof RE2JS ? !descriptionRegex.matcher(description).find() : !description.match(descriptionRegex));
}
if (ctx.req.query('filterout_author')) {
const authorRegex = makeRegex(ctx.req.query('filterout_author')!);
isFilter = isFilter && (authorRegex instanceof RE2JS ? !authorRegex.matcher(author).find() : !author.match(authorRegex));
}
if (ctx.req.query('filterout_category')) {
const categoryRegex = makeRegex(ctx.req.query('filterout_category')!);
isFilter = isFilter && !category.some((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
if (ctx.req.query('limit')) {
data.item = data.item.slice(0, Number.parseInt(ctx.req.query('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;
}
});
}
// 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 { data: res } = await got(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 ? entities.decodeXML(parsed_result.content) : description;
});
await Promise.all(tasks);
}
// openai
if (ctx.req.query('chatgpt') && config.openai.apiKey) {
data.item = await Promise.all(
data.item.map(async (item) => {
if (item.description) {
try {
const summary = await cache.tryGet(`openai:${item.link}`, async () => {
const text = htmlToText.htmlToText(item.description!);
if (text.length < 300) {
return '';
}
const summary_md = await summarizeArticle(text);
return md.render(summary_md);
});
// 将总结结果添加到文章数据中
if (summary !== '') {
item.description = summary + '<hr/><br/>' + item.description;
}
} catch {
// when openai failed, return default description 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 (ctx.req.query('opencc')) {
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.parseInt(ctx.req.query('brief')!);
for (const item of data.item) {
let text;
if (item.description) {
text = item.description.replaceAll(/<\/?[^>]+(>|$)/g, '');
item.description = text.length > brief ? `<p>${text.substring(0, brief)}…</p>` : `<p>${text}</p>`;
}
}
} else {
throw new Error(`Invalid parameter brief. Please check the doc https://docs.rsshub.app/parameter#shu-chu-jian-xun`);
}
}
// some parameters are processed in `anti-hotlink.js`
ctx.set('data', data)
}
};
export default middleware;
@@ -1,59 +1,51 @@
const { art, json, rss3Ums } = require('@/utils/render');
const path = require('path');
const config = require('@/config').value;
import render from '@/utils/render';
import * as path from 'node:path';
import { config } from '@/config';
const typeRegex = /\.(atom|rss|ums|debug\.json|json|\d+\.debug\.html)$/;
const { collapseWhitespace, convertDateToISO8601 } = require('@/utils/common-utils');
import utils from '@/utils/common-utils';
import type { MiddlewareHandler } from 'hono';
module.exports = async (ctx, next) => {
if (ctx.headers['user-agent'] && ctx.headers['user-agent'].includes('Reeder')) {
ctx.request.path = ctx.request.path.replace(/.com$/, '');
const middleware: MiddlewareHandler = async (ctx, next) => {
if (ctx.req.header('user-agent')?.includes('Reeder')) {
ctx.req.path = ctx.req.path.replace(/.com$/, '');
}
ctx.state.type = ctx.request.path.match(typeRegex) || ['', ''];
ctx.request.path = ctx.request.path.replace(typeRegex, '');
ctx.set('type', ctx.req.path.match(typeRegex) || ['', '']);
ctx.req.path = ctx.req.path.replace(typeRegex, '');
await next();
const outputType = ctx.state.type[1] || 'rss';
const outputType = ctx.get('type')[1] || 'rss';
// only enable when debugInfo=true
if (config.debugInfo) {
if (outputType === 'debug.json') {
ctx.set({
'Content-Type': 'application/json; charset=UTF-8',
});
ctx.body = ctx.state.json ? JSON.stringify(ctx.state.json, null, 4) : JSON.stringify({ message: 'plugin does not set debug json' });
ctx.header('Content-Type', 'application/json; charset=UTF-8');
return ctx.body(ctx.get('json') ? JSON.stringify(ctx.get('json'), null, 4) : JSON.stringify({ message: 'plugin does not set debug json' }));
}
if (outputType.endsWith('.debug.html')) {
ctx.set({
'Content-Type': 'text/html; charset=UTF-8',
});
ctx.header('Content-Type', 'text/html; charset=UTF-8');
const index = Number.parseInt(outputType.match(/(\d+)\.debug\.html$/)[1]);
ctx.body = ctx.state.data && ctx.state.data.item && ctx.state.data.item[index] ? ctx.state.data.item[index].description : `ctx.state.data.item[${index}] not found`;
return ctx.body(ctx.get('data')?.item?.[index]?.description || `ctx.get('data')?.item?.[${index}]?.description not found`);
}
}
if (!ctx.body) {
const templateName = outputType === 'atom' ? 'atom.art' : 'rss.art';
const template = path.resolve(__dirname, `../views/${templateName}`);
if (ctx.state.data) {
const collapseWhitespaceForProperties = (properties, obj) => {
for (const prop of properties) {
if (obj[prop]) {
obj[prop] = collapseWhitespace(obj[prop]);
}
if (ctx.get('data')) {
for (const prop of ['title', 'subtitle', 'author']) {
if (ctx.get('data')[prop]) {
ctx.get('data')[prop] = utils.collapseWhitespace(ctx.get('data')[prop]);
}
};
}
collapseWhitespaceForProperties(['title', 'subtitle', 'author'], ctx.state.data);
if (ctx.state.data.item) {
for (const item of ctx.state.data.item) {
if (ctx.get('data').item) {
for (const item of ctx.get('data').item) {
if (item.title) {
item.title = collapseWhitespace(item.title);
item.title = utils.collapseWhitespace(item.title);
// trim title length
for (let length = 0, i = 0; i < item.title.length; i++) {
length += Buffer.from(item.title[i]).length === 1 ? 1 : 2;
@@ -65,13 +57,15 @@ module.exports = async (ctx, next) => {
}
if (typeof item.author === 'string') {
item.author = collapseWhitespace(item.author);
item.author = utils.collapseWhitespace(item.author);
} else if (typeof item.author === 'object' && item.author !== null) {
for (const a of item.author) {
a.name = collapseWhitespace(a.name);
a.name = utils.collapseWhitespace(a.name);
}
if (outputType !== 'json') {
item.author = item.author.map((a) => a.name).join(', ');
item.author = item.author.map((a: {
name: string;
}) => a.name).join(', ');
}
}
@@ -82,8 +76,8 @@ module.exports = async (ctx, next) => {
}
if (outputType !== 'rss') {
item.pubDate = convertDateToISO8601(item.pubDate);
item.updated = convertDateToISO8601(item.updated);
item.pubDate = utils.convertDateToISO8601(item.pubDate);
item.updated = utils.convertDateToISO8601(item.updated);
}
}
}
@@ -94,23 +88,23 @@ module.exports = async (ctx, next) => {
lastBuildDate: currentDate.toUTCString(),
updated: currentDate.toISOString(),
ttl: Math.trunc(config.cache.routeExpire / 60),
atomlink: ctx.request.href,
...ctx.state.data,
atomlink: ctx.req.url,
...ctx.get('data'),
};
if (config.isPackage) {
ctx.body = data;
return;
return ctx.body(data);
}
if (outputType === 'ums') {
ctx.set({ 'Content-Type': 'application/json; charset=UTF-8' });
ctx.body = rss3Ums(data);
ctx.header('Content-Type', 'application/json; charset=UTF-8');
return ctx.body(render.rss3Ums(data));
} else if (outputType === 'json') {
ctx.set({ 'Content-Type': 'application/feed+json; charset=UTF-8' });
ctx.body = json(data);
ctx.header('Content-Type', 'application/feed+json; charset=UTF-8');
return ctx.body(render.json(data));
} else {
ctx.body = art(template, data);
return ctx.body(render.art(template, data));
}
}
};
export default middleware;
+21
View File
@@ -0,0 +1,21 @@
import { directoryImport } from '@/utils/directory-import';
import type { Context, Handler, Hono } from 'hono'
type Root = {
get: (path: string, handler: Handler) => void;
}
const imports = directoryImport({
callerDirectoryPath: __dirname,
targetDirectoryPath: './v3',
importPattern: /router\.js$/,
});
const routes: Record<string, (root: Root) => void> = {};
for (const path in imports) {
const name = path.split('/').filter(Boolean)[0];
routes[name] = imports[path] as (root: Root) => void;
}
export default routes;
+24
View File
@@ -0,0 +1,24 @@
export type DataItem = {
title: string;
description?: string;
pubDate: number | string;
link?: string;
category?: string[];
author?: string;
doi?: string;
_extra?: Record<string, any> & {
links?: {
url: string;
type: string;
content_html?: string;
}[];
};
}
export type Data = {
title: string;
description: string;
link?: string;
item: DataItem[];
allowEmpty?: boolean;
}
+16
View File
@@ -0,0 +1,16 @@
import type Redis from 'ioredis';
import type { LRUCache } from 'lru-cache';
type CacheModule = {
get: (key: string, refresh?: boolean) => Promise<string | null> | string | null;
set: (key: string, value?: string | Record<string, any>, maxAge?: number) => any;
status: {
available: boolean;
};
clients: {
redisClient?: Redis;
memoryCache?: LRUCache<{}, {}>;
}
};
export default CacheModule;
+77
View File
@@ -0,0 +1,77 @@
import { config } from '@/config';
import redis from './redis';
import memory from './memory';
import type CacheModule from './base';
const globalCache: {
get: (key: string) => Promise<string | null | undefined> | string | null | undefined;
set: (key: string, value?: string | Record<string, any>, maxAge?: number) => any;
} = {
get: () => null,
set: () => null,
};
let cacheModule: CacheModule
if (config.cache.type === 'redis') {
cacheModule = redis;
const { redisClient } = cacheModule.clients;
globalCache.get = async (key) => {
if (key && cacheModule.status.available && redisClient) {
const value = await redisClient.get(key);
return value;
}
};
globalCache.set = cacheModule.set;
} else {
cacheModule = memory;
const { memoryCache } = cacheModule.clients;
globalCache.get = (key) => {
if (key && cacheModule.status.available && memoryCache) {
return memoryCache.get(key, { updateAgeOnGet: false }) as string | undefined;
}
};
globalCache.set = (key, value, maxAge = config.cache.routeExpire) => {
if (!value || value === 'undefined') {
value = '';
}
if (typeof value === 'object') {
value = JSON.stringify(value);
}
if (key && memoryCache) {
return memoryCache.set(key, value, { ttl: maxAge * 1000 });
}
};
}
// only give cache string, as the `!` condition tricky
// md5 is used to shrink key size
// plz, write these tips in comments!
export default {
...cacheModule,
tryGet: async (key: string, getValueFunc: () => Promise<string | Record<string, any>>, maxAge = config.cache.contentExpire, refresh = true) => {
if (typeof key !== 'string') {
throw new TypeError('Cache key must be a string');
}
let v = await cacheModule.get(key, refresh);
if (v) {
let parsed;
try {
parsed = JSON.parse(v);
} catch {
parsed = null;
}
if (parsed) {
v = parsed;
}
return v;
} else {
const value = await getValueFunc();
cacheModule.set(key, value, maxAge);
return value;
}
},
globalCache,
};
@@ -1,5 +1,6 @@
const { LRUCache } = require('lru-cache');
const config = require('@/config').value;
import { LRUCache } from 'lru-cache';
import { config } from '@/config';
import type CacheModule from './base';
const status = { available: false };
@@ -10,10 +11,10 @@ const memoryCache = new LRUCache({
status.available = true;
module.exports = {
get: (key, refresh = true) => {
export default {
get: (key: string, refresh = true) => {
if (key && status.available) {
let value = memoryCache.get(key, { updateAgeOnGet: refresh });
let value = memoryCache.get(key, { updateAgeOnGet: refresh }) as string | undefined;
if (value) {
value = value + '';
}
@@ -33,4 +34,4 @@ module.exports = {
},
clients: { memoryCache },
status,
};
} as CacheModule;
+10 -9
View File
@@ -1,6 +1,7 @@
const config = require('@/config').value;
const Redis = require('ioredis');
const logger = require('@/utils/logger');
import { config } from '@/config';
import Redis from 'ioredis';
import logger from '@/utils/logger';
import type CacheModule from './base';
const redisClient = new Redis(config.redis.url);
@@ -18,15 +19,15 @@ redisClient.on('connect', () => {
logger.info('Redis connected.');
});
const getCacheTtlKey = (key) => {
const getCacheTtlKey = (key: string) => {
if (key.startsWith('rsshub:cacheTtl:')) {
throw new Error('"rsshub:cacheTtl:" prefix is reserved for the internal usage, please change your cache key'); // blocking any attempt to get/set the cacheTtl
}
return `rsshub:cacheTtl:${key}`;
};
module.exports = {
get: async (key, refresh = true) => {
export default {
get: async (key: string, refresh = true) => {
if (key && status.available) {
const cacheTtlKey = getCacheTtlKey(key);
let [value, cacheTtl] = await redisClient.mget(key, cacheTtlKey);
@@ -35,7 +36,7 @@ module.exports = {
redisClient.expire(cacheTtlKey, cacheTtl);
} else {
// if cacheTtl is not set, that means the cache expire time is contentExpire
cacheTtl = config.cache.contentExpire;
cacheTtl = config.cache.contentExpire + '';
// dont save cacheTtl to Redis, as it is the default value
// redisClient.set(cacheTtlKey, cacheTtl, 'EX', cacheTtl);
}
@@ -45,7 +46,7 @@ module.exports = {
return value;
}
},
set: (key, value, maxAge = config.cache.contentExpire) => {
set: (key: string, value?: string | Record<string, any>, maxAge = config.cache.contentExpire) => {
if (!status.available) {
return;
}
@@ -65,4 +66,4 @@ module.exports = {
},
clients: { redisClient },
status,
};
} as CacheModule;
@@ -1,32 +1,32 @@
const { parseDate } = require('@/utils/parse-date');
import parseDate from '@/utils/parse-date';
const title = require('title');
// convert a string into title case
const toTitleCase = (str) => title(str);
const toTitleCase = (str: string) => title(str);
const rWhiteSpace = /\s+/;
const rAllWhiteSpace = /\s+/g;
// collapse all whitespaces into a single space (like "white-space: normal;" would do), and trim
const collapseWhitespace = (str) => {
const collapseWhitespace = (str: string) => {
if (str && rWhiteSpace.test(str)) {
return str.replaceAll(rAllWhiteSpace, ' ').trim();
}
return str;
};
const convertDateToISO8601 = (date) => {
const convertDateToISO8601 = (date: string | Date) => {
if (!date) {
return date;
}
if (typeof date !== 'object') {
// some routes may call `.toUTCString()` before passing the date to ctx...
date = parseDate(date);
date = parseDate.parse(date);
}
return date.toISOString();
};
module.exports = {
export default {
toTitleCase,
collapseWhitespace,
convertDateToISO8601,
@@ -0,0 +1,41 @@
import { promises as fs } from 'node:fs';
import { ImportedModulesPrivateOptions } from './types.d';
/**
* Async version of the directory reader.
*
* I use *for loop* instead of forEach because it is faster.
* I define the *counter* variable outside the loop because it is faster. Because it is not redefined every iteration.
* @param {ImportedModulesPrivateOptions} options - The private options generated by the preparePrivateOptions function.
* @param {string} targetDirectoryPath - The path to the directory to import modules from. Required for recursive calls.
* @returns {Promise<string[]>} An object containing all imported modules.
*/
export default async function readDirectoryAsync(
options: ImportedModulesPrivateOptions,
targetDirectoryPath: string,
): Promise<string[]> {
const receivedItemsPaths = await fs.readdir(targetDirectoryPath);
const result = await Promise.all(
receivedItemsPaths.map(async (itemPath) => {
const receivedFilesPaths = [];
const relativeItemPath = `${targetDirectoryPath}/${itemPath}`;
const stat = await fs.stat(relativeItemPath);
if (stat.isDirectory() && options.includeSubdirectories) {
const files = await readDirectoryAsync(options, relativeItemPath);
receivedFilesPaths.push(...files);
} else if (stat.isFile()) {
receivedFilesPaths.push('/' + relativeItemPath);
}
return receivedFilesPaths;
}),
);
return result.flat();
}
@@ -0,0 +1,46 @@
import * as fs from 'node:fs';
import { ImportedModulesPrivateOptions } from './types.d';
/**
* Sync version of the directory reader.
*
* !important this function will block the event loop until it finishes.
* I use *for loop* instead of forEach because it is faster.
* I define the *counter* variable outside the loop because it is faster. Because it is not redefined every iteration.
* @param {ImportedModulesPrivateOptions} options - The private options generated by the preparePrivateOptions function.
* @param {string} targetDirectoryPath - The path to the directory to import modules from. Required for recursive calls.
* @returns {string[]} An array of file paths.
*/
export default function readDirectorySync(
options: ImportedModulesPrivateOptions,
targetDirectoryPath: string,
): string[] {
const receivedItemsPaths = fs.readdirSync(targetDirectoryPath);
const receivedDirectoriesPaths = [];
const receivedFilesPaths = [];
// Get the files and directories paths from the target directory.
let itemsCounter = 0;
for (itemsCounter; itemsCounter < receivedItemsPaths.length; itemsCounter += 1) {
const itemPath = `${targetDirectoryPath}/${receivedItemsPaths[itemsCounter]}`;
const stat = fs.statSync(itemPath);
if (stat.isDirectory() && options.includeSubdirectories) {
receivedDirectoriesPaths.push(itemPath);
} else if (stat.isFile()) {
receivedFilesPaths.push('/' + itemPath);
}
}
// Recursively get the files paths from the subdirectories.
let directoriesCounter = 0;
for (directoriesCounter; directoriesCounter < receivedDirectoriesPaths.length; directoriesCounter += 1) {
const files = readDirectorySync(options, receivedDirectoriesPaths[directoriesCounter] as string);
receivedFilesPaths.push(...files);
}
// Return the files paths.
return receivedFilesPaths;
}
@@ -0,0 +1,105 @@
import * as path from 'node:path';
import readDirectoryAsync from './directory-reader-async';
import readDirectorySync from './directory-reader-sync';
import { ImportedModules, ImportedModulesPrivateOptions } from './types.d';
const VALID_IMPORT_EXTENSIONS = new Set(['.js', '.mjs', '.ts', '.json']);
const handlers = { async: asyncHandler, sync: syncHandler };
/**
* Synchronously import modules from the specified directory.
* @param {ImportedModulesPrivateOptions} options - The private options generated by the preparePrivateOptions function.
* @returns {ImportedModules} An object containing all imported modules.
*/
function syncHandler(options: ImportedModulesPrivateOptions): ImportedModules {
const modules = {};
const filesPaths = readDirectorySync(options, options.targetDirectoryPath);
let index = 0;
for (const filePath of filesPaths) {
const isModuleImported = importModule(filePath, index, options, modules);
if (isModuleImported) index += 1;
if (index === options.limit) break;
}
return modules;
}
/**
* Asynchronously import modules from the specified directory.
* @param {ImportedModulesPrivateOptions} options - The private options generated by the preparePrivateOptions function.
* @returns {Promise<ImportedModules>} An object containing all imported modules.
*/
async function asyncHandler(options: ImportedModulesPrivateOptions): Promise<ImportedModules> {
const modules = {};
const filesPaths = await readDirectoryAsync(options, options.targetDirectoryPath);
let index = 0;
for (const filePath of filesPaths) {
const isModuleImported = importModule(filePath, index, options, modules);
if (isModuleImported) index += 1;
if (index === options.limit) break;
}
return modules;
}
/**
* Import a module and add it to the modules object.
* @param {string} filePath - The path to the file to import.
* @param {number} index - The index of the module.
* @param {ImportedModulesPrivateOptions} options - The private options generated by the preparePrivateOptions function.
* @param {ImportedModules} modules - The object containing all imported modules.
* @returns {boolean} Whether the module was imported or not.
*/
function importModule(
filePath: string,
index: number,
options: ImportedModulesPrivateOptions,
modules: ImportedModules,
) {
const { name: fileName, ext: fileExtension } = path.parse(filePath);
const isValidModuleExtension = VALID_IMPORT_EXTENSIONS.has(fileExtension);
const isDeclarationFile = filePath.endsWith('.d.ts');
const isValidFilePath = options.importPattern ? options.importPattern.test(filePath) : true;
if (!isValidModuleExtension) return false;
if (!isValidFilePath) return false;
if (isDeclarationFile) return false;
const relativeModulePath = filePath.slice(options.targetDirectoryPath.length + 1);
// eslint-disable-next-line security/detect-non-literal-require, @typescript-eslint/no-var-requires, unicorn/prefer-module
const importedModule = require(filePath) as unknown;
modules[relativeModulePath] = importedModule;
if (options.callback) {
options.callback(fileName, relativeModulePath, importedModule, index);
}
return true;
}
/**
* Import all modules from the specified directory synchronously or asynchronously.
* @param {ImportedModulesPrivateOptions} options - The private options generated by the preparePrivateOptions function.
* @returns {ImportedModules | Promise<ImportedModules>} An object containing all imported modules.
*/
export default function importModules(
options: ImportedModulesPrivateOptions,
): ImportedModules | Promise<ImportedModules> {
if (!handlers[options.importMode]) {
throw new Error(`Expected sync or async import method, but got: ${options.importMode}`);
}
return handlers[options.importMode](options);
}
+92
View File
@@ -0,0 +1,92 @@
import importModules from './import-modules';
import preparePrivateOptions from './prepare-private-options';
import {
ImportedModules,
ImportedModulesPublicOptions,
ImportModulesCallback,
ImportModulesInputArguments,
ImportModulesMode,
} from './types.d';
/**
* Import modules from the current directory synchronously
* @returns {ImportedModules} An object containing all imported modules.
*/
function directoryImport(): ImportedModules;
/**
* Import modules from the current directory synchronously and call the provided callback for each imported module.
* @param {ImportModulesCallback} callback - The callback function to call for each imported module.
* @returns {ImportedModules} An object containing all imported modules.
*/
function directoryImport(callback: ImportModulesCallback): ImportedModules;
/**
* Import modules from the specified directory synchronously.
* @param {string} targetDirectoryPath - The path to the directory to import modules from.
* @returns {ImportedModules} An object containing all imported modules.
*/
function directoryImport(targetDirectoryPath: string): ImportedModules;
/**
* Import all modules from the specified directory synchronously and call the provided callback for each imported module.
* @param {string} targetDirectoryPath - The path to the directory to import modules from.
* @param {ImportModulesCallback} callback - The callback function to call for each imported module.
* @returns {ImportedModules} An object containing all imported modules.
*/
function directoryImport(targetDirectoryPath: string, callback: ImportModulesCallback): ImportedModules;
/**
* Import all modules from the specified directory synchronously or asynchronously.
* @param {string} targetDirectoryPath - The path to the directory to import modules from.
* @param {ImportModulesMode} mode - The import mode. Can be 'sync' or 'async'.
* @returns {ImportedModules} An object containing all imported modules.
*/
function directoryImport(targetDirectoryPath: string, mode: ImportModulesMode): ImportedModules;
/**
* Import all modules from the specified directory synchronously or asynchronously and call the provided callback for each imported module.
* @param {string} targetDirectoryPath - The path to the directory to import modules from.
* @param {ImportModulesMode} importMode - The import mode. Can be 'sync' or 'async'.
* @param {ImportModulesCallback} callback - The callback function to call for each imported module.
* @returns {ImportedModules} An object containing all imported modules.
*/
function directoryImport(
targetDirectoryPath: string,
importMode: ImportModulesMode,
callback: ImportModulesCallback,
): ImportedModules;
/**
* Import all modules from the specified directory
* @param {ImportedModulesPublicOptions} options - The options object.
* @returns {ImportedModules} An object containing all imported modules.
*/
function directoryImport(options: ImportedModulesPublicOptions): ImportedModules;
/**
* Import all modules from the specified directory and call the provided callback for each imported module.
* @param {ImportedModulesPublicOptions} options - The options object.
* @param {ImportModulesCallback} callback - The callback function to call for each imported module.
* @returns {ImportedModules} An object containing all imported modules.
*/
function directoryImport(options: ImportedModulesPublicOptions, callback: ImportModulesCallback): ImportedModules;
/**
* Import all modules from the specified directory with the given options and call the provided callback for each imported module.
* @param {ImportModulesInputArguments} arguments_ - The arguments.
* @returns {ImportedModules|Promise<ImportedModules>} An object containing all imported modules.
*/
function directoryImport(...arguments_: ImportModulesInputArguments): ImportedModules | Promise<ImportedModules> {
const options = preparePrivateOptions(...arguments_);
try {
return importModules(options);
} catch (error) {
Object.assign(error as Error, options);
throw error;
}
}
export { directoryImport };
@@ -0,0 +1,121 @@
import * as path from 'node:path';
import {
ImportedModulesPrivateOptions,
ImportedModulesPublicOptions,
ImportModulesInputArguments,
ImportModulesMode,
} from './types.d';
const getDefaultOptions = (): ImportedModulesPrivateOptions => {
const options = {
includeSubdirectories: true,
importMode: 'sync' as ImportModulesMode,
importPattern: /.*/,
limit: Number.POSITIVE_INFINITY,
callerFilePath: '/',
callerDirectoryPath: '/',
targetDirectoryPath: '/',
};
options.callerFilePath = (new Error('functional-error').stack as string)
.split('\n')[4]
?.match(/at file:\/\/(.+):\d+|at .+ \((.+):\d+:\d+\)/)?.slice(1).find(Boolean) as string;
options.callerDirectoryPath = options.callerFilePath?.split('/').slice(0, -1).join('/');
options.targetDirectoryPath = options.callerDirectoryPath;
return options;
};
/**
* Prepare the options object from the provided arguments.
* @param {...any} arguments_ - The arguments.
* @returns {ImportedModulesPrivateOptions} The options object.
*/
export default function preparePrivateOptions(
...arguments_: ImportModulesInputArguments
): ImportedModulesPrivateOptions {
const options = { ...getDefaultOptions() };
// * Check first argument
// ** If user provided nothing as first argument,
// ** it means that he wants to use the default options
// ** return the default options
if (arguments_[0] === undefined) {
return options;
}
// ** If user provided an object as first argument,
// ** it means that he wants to set custom options
// ** use it to oweverwrite the default options
else if (typeof arguments_[0] === 'object') {
const result = {
...getDefaultOptions(),
...(arguments_[0] as ImportedModulesPublicOptions),
};
result.targetDirectoryPath = path.resolve(result.callerDirectoryPath, result.targetDirectoryPath);
result.callback = typeof arguments_[1] === 'function' ? arguments_[1] : undefined;
return result;
}
// ** If user provided a string
// ** it means that he wants to set the target directory path as first argument
// ** use it to oweverwrite the default options
else if (typeof arguments_[0] === 'string') {
options.targetDirectoryPath = path.resolve(options.callerDirectoryPath, arguments_[0]);
}
// ** If user provided a function
// ** it means that he wants to set the callback as first argument
// ** use it to oweverwrite the default options
else if (typeof arguments_[0] === 'function') {
options.callback = arguments_[0];
}
// ** If user provided something else
// ** it means that he made a mistake
// ** throw an error
else {
throw new TypeError(
`Expected undefined, object, string or function as first argument, but got: ${typeof arguments_[0]}`,
);
}
// * Check second argument
// ** If user provided string as second argument
// ** it means that he wants to set the import mode (sync or async) as second argument
// ** use it to oweverwrite the default options
if (typeof arguments_[1] === 'string') {
// ** But if the import mode is not sync or async
// ** throw an error
if (arguments_[1] !== 'sync' && arguments_[1] !== 'async') {
throw new TypeError(`Expected sync or async as second argument, but got: ${arguments_[1]}`);
}
options.importMode = arguments_[1];
}
// ** If user provided a function as second argument
// ** it means that he wants to set the callback as second argument
// ** use it to oweverwrite the default options
else if (typeof arguments_[1] === 'function') {
options.callback = arguments_[1];
}
// * Check third argument
// ** If user provided a function as third argument
// ** it means that he wants to set the callback as third argument
// ** use it to oweverwrite the default options
if (typeof arguments_[2] === 'function') {
options.callback = arguments_[2];
}
// * Return the options object
return options;
}
+33
View File
@@ -0,0 +1,33 @@
export type ModuleName = string;
export type ModulePath = string;
export type ModuleData = unknown;
export type ModuleIndex = number;
export type ImportModulesMode = 'sync' | 'async';
export type ImportModulesCallback = (name: ModuleName, path: ModulePath, data: ModuleData, index: ModuleIndex) => void;
export type ImportModulesInputArguments = [
targetDirectoryPathOrOptionsOrCallback?: string | ImportedModulesPublicOptions | ImportModulesCallback,
modeOrCallback?: ImportModulesMode | ImportModulesCallback,
callback?: ImportModulesCallback,
];
export interface ImportedModules {
[key: ModulePath]: ModuleData;
}
export interface ImportedModulesPublicOptions {
includeSubdirectories?: boolean;
callerDirectoryPath?: string;
targetDirectoryPath?: string;
importPattern?: RegExp;
importMode?: ImportModulesMode;
limit?: number;
}
export interface ImportedModulesPrivateOptions extends Required<ImportedModulesPublicOptions> {
callback?: ImportModulesCallback;
callerFilePath: string;
callerDirectoryPath: string;
}
+19 -10
View File
@@ -1,15 +1,19 @@
const logger = require('./logger');
const config = require('@/config').value;
const got = require('got');
import logger from "@/utils/logger";
import { config } from "@/config";
import got, { type Response, type NormalizedOptions, type Options } from "got";
const custom = got.extend({
const custom: typeof got & {
all?: <T>(list: Array<Promise<T>>) => Promise<Array<T>>;
} = got.extend({
retry: {
limit: config.requestRetry,
statusCodes: [400, 401, 402, 403, 404, 405, 406, 407, 408, 409, 410, 411, 412, 413, 414, 415, 416, 417, 421, 422, 423, 424, 426, 428, 429, 431, 451, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511, 521, 522, 524],
},
hooks: {
beforeRetry: [
(options, err, count) => {
(options: NormalizedOptions & {
retryCount?: number;
}, err, count) => {
logger.error(`Request ${options.url} fail, retry attempt #${count}: ${err}`);
options.retryCount = count;
},
@@ -20,18 +24,23 @@ const custom = got.extend({
},
],
afterResponse: [
(response) => {
(response: Response & {
data?: Record<string, any> | string;
status?: number;
}) => {
try {
response.data = JSON.parse(response.body);
response.data = JSON.parse(response.body as string);
} catch {
response.data = response.body;
response.data = response.body as string;
}
response.status = response.statusCode;
return response;
},
],
init: [
(options) => {
(options: Options & {
data?: string;
}) => {
// compatible with axios api
if (options && options.data) {
options.body = options.body || options.data;
@@ -46,4 +55,4 @@ const custom = got.extend({
});
custom.all = (list) => Promise.all(list);
module.exports = custom;
export default custom;
+30
View File
@@ -0,0 +1,30 @@
import { Context } from "hono";
import { ReadableStream } from "node:stream/web";
export const getRouteNameFromPath = (path: string) => {
const p = path.split('/').filter(Boolean);
if (p.length > 0) {
return p[0];
}
return null;
}
export const getIp = (ctx: Context) => {
return ctx.req.header('X-Real-IP') || ctx.req.header('X-Forwarded-For')
}
export const streamToString = async (stream: ReadableStream) => {
const reader = stream.getReader();
const chunks: Uint8Array[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
chunks.push(value);
}
const result = new TextDecoder('utf-8').decode(
new Uint8Array(chunks.reduce((a, c) => a + c.length, 0))
);
return result;
}
+5 -5
View File
@@ -1,8 +1,8 @@
const path = require('path');
const winston = require('winston');
const config = require('@/config').value;
import * as path from 'node:path';
import winston from 'winston';
import { config } from '@/config';
let transports = [];
let transports: (typeof winston.transports.File)[] = [];
if (!config.noLogfiles) {
transports = [
new winston.transports.File({
@@ -43,4 +43,4 @@ if (!config.isPackage) {
);
}
module.exports = logger;
export default logger;
-5
View File
@@ -1,5 +0,0 @@
const crypto = require('crypto');
module.exports = function md5(date) {
return crypto.createHash('md5').update(date).digest('hex');
};
+5
View File
@@ -0,0 +1,5 @@
import crypto from 'crypto';
export default function md5(date: string) {
return crypto.createHash('md5').update(date).digest('hex');
};
@@ -1,8 +1,13 @@
const dayjs = require('dayjs');
dayjs.extend(require('dayjs/plugin/customParseFormat'));
dayjs.extend(require('dayjs/plugin/duration'));
dayjs.extend(require('dayjs/plugin/isSameOrBefore'));
dayjs.extend(require('dayjs/plugin/weekday'));
import dayjs from 'dayjs';
import customParseFormat from 'dayjs/plugin/customParseFormat';
import duration from 'dayjs/plugin/duration';
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
import weekday from 'dayjs/plugin/weekday';
dayjs.extend(customParseFormat);
dayjs.extend(duration);
dayjs.extend(isSameOrBefore);
dayjs.extend(weekday);
const words = [
{
@@ -92,7 +97,7 @@ const patternSize = Object.keys(patterns).length;
* 预处理日期字符串
* @param {String} date 原始日期字符串
*/
const toDate = (date) =>
const toDate = (date: string) =>
date
.toLowerCase()
.replaceAll(/(^an?\s)|(\san?\s)/g, '1') // 替换 `a` 和 `an` 为 `1`
@@ -104,8 +109,8 @@ const toDate = (date) =>
* 用于描述时间长度
* @param {Array.<String>} matches 所有匹配结果
*/
const toDurations = (matches) => {
const durations = {};
const toDurations = (matches: string[]) => {
const durations: Record<string, string> = {};
let p = 0;
for (const m of matches) {
@@ -120,9 +125,9 @@ const toDurations = (matches) => {
return durations;
};
module.exports = {
parseDate: (date, ...options) => dayjs(date, ...options).toDate(),
parseRelativeDate: (date) => {
export default {
parse: (date: string, ...options: any) => dayjs(date, ...options).toDate(),
parseRelativeDate: (date: string) => {
// 预处理日期字符串 date
const theDate = toDate(date);
@@ -136,49 +141,53 @@ module.exports = {
const lastMatch = matches.pop();
// 若最后的时间单元含有 `前`、`以前`、`之前` 等标识字段,减去相应的时间长度
// 如 `1分10秒前`
if (lastMatch) {
// 若最后的时间单元含有 `前`、`以前`、`之前` 等标识字段,减去相应的时间长度
// 如 `1分10秒前`
const beforeMatches = /(.*)(?:[之以]?前|ago)$/.exec(lastMatch);
if (beforeMatches) {
matches.push(beforeMatches[1]);
return dayjs()
.subtract(dayjs.duration(toDurations(matches)))
.toDate();
}
const beforeMatches = /(.*)(?:[之以]?前|ago)$/.exec(lastMatch);
if (beforeMatches) {
matches.push(beforeMatches[1]);
return dayjs()
.subtract(dayjs.duration(toDurations(matches)))
.toDate();
}
// 若最后的时间单元含有 `后`、`以后`、`之后` 等标识字段,加上相应的时间长度
// 如 `1分10秒后`
// 若最后的时间单元含有 `后`、`以后`、`之后` 等标识字段,加上相应的时间长度
// 如 `1分10秒后`
const afterMatches = /(?:^in(.*)|(.*)[之以]?[后後])$/.exec(lastMatch);
if (afterMatches) {
matches.push(afterMatches[1] ?? afterMatches[2]);
return dayjs()
.add(dayjs.duration(toDurations(matches)))
.toDate();
}
// 以下处理日期字符串 date 含有特殊词的情形
// 如 `今天1点10分`
matches.push(lastMatch);
const firstMatch = matches.shift();
for (const w of words) {
const wordMatches = w.regExp.exec(firstMatch);
if (wordMatches) {
matches.unshift(wordMatches[1]);
// 取特殊词对应日零时为起点,加上相应的时间长度
return w.startAt
.set('hour', 0)
.set('minute', 0)
.set('second', 0)
.set('millisecond', 0)
const afterMatches = /(?:^in(.*)|(.*)[之以]?[后後])$/.exec(lastMatch);
if (afterMatches) {
matches.push(afterMatches[1] ?? afterMatches[2]);
return dayjs()
.add(dayjs.duration(toDurations(matches)))
.toDate();
}
// 以下处理日期字符串 date 含有特殊词的情形
// 如 `今天1点10分`
matches.push(lastMatch);
}
const firstMatch = matches.shift();
if (firstMatch) {
for (const w of words) {
const wordMatches = w.regExp.exec(firstMatch);
if (wordMatches) {
matches.unshift(wordMatches[1]);
// 取特殊词对应日零时为起点,加上相应的时间长度
return w.startAt
.set('hour', 0)
.set('minute', 0)
.set('second', 0)
.set('millisecond', 0)
.add(dayjs.duration(toDurations(matches)))
.toDate();
}
}
}
} else {
// 若日期字符串 date 不匹配 patterns 中所有模式,则默认为 `特殊词 + 标准时间格式` 的情形,此时直接将特殊词替换为对应日期
@@ -1,4 +1,4 @@
const { randUserAgent } = require('@tonyrl/rand-user-agent');
import { randUserAgent } from '@tonyrl/rand-user-agent';
require.resolve('@tonyrl/rand-user-agent/data/user-agents.json'); // workaround for @vercel/nft removing datastore
/**
@@ -10,7 +10,7 @@ require.resolve('@tonyrl/rand-user-agent/data/user-agents.json'); // workaround
* @param {string} randUserAgent.device Name of a device, case-insensitive. `desktop`, `mobile` or `tablet`.
* @returns A random useragent for the given specifications.
*/
module.exports = ({ browser = 'chrome', os = 'mac os', device = 'desktop' }) => {
export default ({ browser = 'chrome', os = 'mac os', device = 'desktop' }) => {
device = device.toLowerCase();
browser = browser.toLowerCase();
os = os.toLowerCase();
-11
View File
@@ -1,11 +0,0 @@
const art = require('art-template');
const json = require('@/views/json');
const rss3Ums = require('@/views/rss3-ums');
// We may add more control over it later
module.exports = {
art,
json, // This should be used by RSSHub middleware only.
rss3Ums,
};
+11
View File
@@ -0,0 +1,11 @@
import art from 'art-template';
import json from '@/views/json';
import rss3Ums from '@/views/rss3-ums';
// We may add more control over it later
export default {
art,
json, // This should be used by RSSHub middleware only.
rss3Ums,
};
+5
View File
@@ -0,0 +1,5 @@
module.exports = {
'/topic/text/:id': ['HenryQW'],
'/topic/:id/:showUid?': ['DIYgod', 'prnake'],
'/user/:id': ['DIYgod', 'prnake'],
};
+54
View File
@@ -0,0 +1,54 @@
module.exports = {
'okjike.com': {
_name: '即刻',
m: [
{
title: '用户动态',
docs: 'https://docs.rsshub.app/routes/social-media#ji-ke',
source: '/users/:uid',
target: '/jike/user/:uid',
},
{
title: '用户动态',
docs: 'https://docs.rsshub.app/routes/social-media#ji-ke',
source: '/reposts/:repostId',
target: (params, url, document) => {
const uid = document.querySelector('.avatar').getAttribute('href').replace('/users/', '');
return uid ? `/jike/user/${uid}` : '';
},
},
{
title: '圈子',
docs: 'https://docs.rsshub.app/routes/social-media#ji-ke',
source: '/topics/:id',
target: '/jike/topic/:id',
},
{
title: '圈子 - 纯文字',
docs: 'https://docs.rsshub.app/routes/social-media#ji-ke',
source: '/topics/:id',
target: '/jike/topic/text/:id',
},
],
web: [
{
title: '用户动态',
docs: 'https://docs.rsshub.app/routes/social-media#ji-ke',
source: '/u/:uid',
target: '/jike/user/:uid',
},
{
title: '圈子',
docs: 'https://docs.rsshub.app/routes/social-media#ji-ke',
source: '/topic/:id',
target: '/jike/topic/:id',
},
{
title: '圈子 - 纯文字',
docs: 'https://docs.rsshub.app/routes/social-media#ji-ke',
source: '/topic/:id',
target: '/jike/topic/text/:id',
},
],
},
};
+5
View File
@@ -0,0 +1,5 @@
module.exports = (router) => {
router.get('/topic/text/:id', require('./topic-text'));
router.get('/topic/:id/:showUid?', require('./topic'));
router.get('/user/:id', require('./user'));
};
+21
View File
@@ -0,0 +1,21 @@
const dayjs = require('dayjs');
const { constructTopicEntry } = require('./utils');
module.exports = async (ctx) => {
const id = ctx.params.id;
const topicUrl = `https://m.okjike.com/topics/${id}`;
const data = await constructTopicEntry(ctx, topicUrl);
if (data) {
ctx.state.data.item = data.posts.map((item) => {
const date = dayjs(item.createdAt);
return {
title: `${data.topic.content} ${date.format('MM月DD日')}`,
description: item.content.replaceAll('\n', '<br>'),
pubDate: date.toDate(),
link: `https://m.okjike.com/originalPosts/${item.id}`,
};
});
}
};
+48
View File
@@ -0,0 +1,48 @@
const got = require('@/utils/got');
const { topicDataHanding } = require('./utils');
const cheerio = require('cheerio');
const dayjs = require('dayjs');
const { constructTopicEntry } = require('@/v3/jike/utils');
module.exports = async (ctx) => {
const id = ctx.req.param('id');
const topicUrl = `https://m.okjike.com/topics/${id}`;
const data = await constructTopicEntry(ctx, topicUrl);
if (data) {
let item = topicDataHanding(data, ctx);
if (id === '553870e8e4b0cafb0a1bef68' || id === '55963702e4b0d84d2c30ce6f') {
item = await Promise.all(
item.map(async (one) => {
const item = { ...one };
const regResult = /https:\/\/www\.okjike\.com\/medium\/[\dA-Za-z]*/.exec(item.description);
if (regResult) {
const newsUrl = regResult[0];
item.description = await ctx.cache.tryGet(newsUrl, async () => {
const { data } = await got(newsUrl);
const $ = cheerio.load(data);
const upper = $('ul.main > li.item');
const links = upper.find('a').map((_, ele) => $(ele).attr('href'));
const texts = upper.find('span.text').map((_, ele) => $(ele).text());
let description = '';
for (const [i, link] of links.entries()) {
description += `${i + 1}、<a href="${link}">${texts[i]}</a><br>`;
}
description = description.replace(/<br>$/, '');
return description;
});
}
item.title = `${data.topic.content} ${dayjs(one.pubDate).format('MM月DD日')}`;
return item;
})
);
}
ctx.set('data', {
...ctx.get('data'),
item,
});
}
};
+158
View File
@@ -0,0 +1,158 @@
const got = require('@/utils/got');
const cheerio = require('cheerio');
const { parseDate } = require('@/utils/parse-date');
module.exports = async (ctx) => {
const id = ctx.params.id;
const response = await got({
method: 'get',
url: `https://m.okjike.com/users/${id}`,
headers: {
Referer: `https://m.okjike.com/users/${id}`,
},
});
const html = response.data;
const $ = cheerio.load(html);
const raw = $('[type = "application/json"]').html();
const data = JSON.parse(raw).props.pageProps;
const getLink = (id, type) => {
switch (type) {
case 'REPOST':
return `https://m.okjike.com/reposts/${id}`;
case 'MEDIUM':
return `https://www.okjike.com/medium/${id}`;
default:
return `https://m.okjike.com/originalPosts/${id}`;
}
};
const items = await Promise.all(
data.posts.map(async (item) => {
const typeMap = {
ORIGINAL_POST: '发布',
REPOST: '转发',
ANSWER: '回答',
QUESTION: '提问',
PERSONAL_UPDATE: '创建新主题',
};
let linkTemplate = '';
if (item.linkInfo && item.linkInfo.linkUrl) {
linkTemplate = `<a href="${item.linkInfo.linkUrl}">${item.linkInfo.title}</a><br>`;
}
let imgTemplate = '';
if (item.pictures) {
for (const picture of item.pictures) {
imgTemplate += `<br><img src="${picture.picUrl}">`;
}
}
let content = item.content || (item.linkInfo && item.linkInfo.title) || (item.question && item.question.title) || item.title || '';
content = content.replaceAll(/\r\n|\n|\r/g, '<br>');
let shortenTitle = '一条动态';
if (content) {
shortenTitle = content.replaceAll(/(<br>)+/g, ' ');
content = `${content}<br><br>`;
}
let repostContent;
if (item.type === 'REPOST') {
const screenNameTemplate = item.target.user ? `<a href="https://m.okjike.com/users/${item.target.user.username}" target="_blank">@${item.target.user.screenName}</a>` : '';
let repostImgTemplate = '';
if (item.target.pictures) {
for (const picture of item.target.pictures) {
repostImgTemplate += `<br><img src="${picture.thumbnailUrl}">`;
}
}
repostContent = `<div class="rsshub-quote">转发 ${screenNameTemplate}: ${item.target.content}${repostImgTemplate}</div>`.replaceAll(/\r\n|\n|\r/g, '<br>');
content = `${content}${repostContent}`;
}
// 部分功能未知
/* else if (item.type === 'ANSWER') {
let answerTextTemplate = '';
let answerImgTemplate = '';
let answerImgKeys = [];
item.richtextContent.blocks &&
item.richtextContent.blocks.forEach((item) => {
if (item.entityRanges.length && item.text === '[图片]') {
answerImgKeys = [...answerImgKeys, ...Object.keys(item.entityRanges)];
} else {
answerTextTemplate += item.text;
}
});
if (answerImgKeys.length) {
answerImgKeys.forEach((key) => {
const entity = item.richtextContent.entityMap[key];
if (entity.type.toUpperCase() === 'IMAGE') {
answerImgTemplate += `<br><img src="${entity.data.pictureUrl.middlePicUrl}">`;
}
});
}
const answerContent = `回答: ${answerTextTemplate}${answerImgTemplate}`;
content = `${content}${answerContent}`.replace(/\n|\r/g, '<br>');
} else if (item.type === 'QUESTION') {
content = `在主题 <a href="https://web.okjike.com/topic/${item.topic.id}/official" target="_blank">${item.topic.content}</a> 提出了一个问题:<br><br>${content}`;
} else if (item.type === 'PERSONAL_UPDATE') {
shortenTitle = item.topic.content;
content = `<img src="${item.topic.squarePicture.picUrl}"> 主题简介:<br>${item.topic.briefIntro.replace(/(?:\r\n|\r|\n)/g, '<br>')}`;
}*/
const single = {
title: `${typeMap[item.type]}了: ${shortenTitle}`,
description: `${content}${linkTemplate}${imgTemplate}`.replace(/(<br>|\s)+$/, ''),
pubDate: parseDate(item.createdAt),
link: getLink(item.id, item.type),
_extra: repostContent && {
links: [
{
url: getLink(item.target.id, item.target.type),
type: 'quote',
},
],
},
};
if (id === 'wenhao1996' && item.topic.id === '553870e8e4b0cafb0a1bef68') {
single.link = item.urlsInText[0].url;
const { data } = await got({
method: 'get',
url: single.link,
headers: {
Referer: `https://m.okjike.com/users/${id}`,
},
});
const $$ = cheerio.load(data);
$$('span.num,span.arrow').remove();
single.title = `一觉醒来世界发生了什么 ${$$('title').text()}`;
single.description = '';
$$('div.container')
.find('li.item')
// eslint-disable-next-line array-callback-return
.map((i, j) => {
single.description += `<a href="${$$(j).find('a').attr('href')}">${$$(j).find('a').text()}</a><br>`;
});
}
return single;
})
);
ctx.state.data = {
title: `${data.user.screenName}的即刻动态`,
description: data.user.bio,
link: `https://m.okjike.com/users/${id}`,
image: data.user.avatarImage.picUrl,
item: items,
};
};
+198
View File
@@ -0,0 +1,198 @@
const cheerio = require('cheerio');
import parseDate from '@/utils/parse-date';
import got from '@/utils/got';
import { config } from '@/config';
import cache from '@/utils/cache';
const videoAPI = 'https://api.ruguoapp.com/1.0/mediaMeta/play?type=ORIGINAL_POST';
module.exports = {
topicDataHanding: (data, ctx) =>
data.posts.map((item) => {
let audioName, videoName, linkName;
// 获取纯文字内容 和 即刻原文链接
let content, link;
switch (item.type) {
case 'ORIGINAL_POST':
content = item.content;
link = `https://m.okjike.com/originalPosts/${item.id}`;
break;
/* case 'QUESTION':
content = item.title;
link = `https://m.okjike.com/questions/${item.id}`;
break;
case 'OFFICIAL_MESSAGE':
content = item.content;
link = `https://web.okjike.com/message-detail/${item.id}/officialMessage`;
break;*/
default:
content = '未知类型,请前往GitHub提交issue';
link = 'https://github.com/DIYgod/RSSHub/issues';
}
// rss内容
let description = '';
// 作者昵称
let author = '';
// 添加内容作者信息
if (item.user) {
author = item.user.screenName;
if (ctx.req.param('showUid')) {
description += `<span>用户昵称:${author} <br> Username:${item.user.username}</span><br>`;
}
}
if (item.linkInfo) {
const linkUrl = item.linkInfo.originalLinkUrl || item.linkInfo.linkUrl;
// 对于即刻抓取的微信公众号文章 特殊处理
// 此时 Rss原文链接 变为 微信公众号链接
if (new URL(linkUrl).host === 'mp.weixin.qq.com') {
link = linkUrl;
}
// 1. 音频
const audioObject = item.linkInfo.audio || item.audio;
if (audioObject) {
const audioImage = audioObject.image.picUrl || audioObject.image.thumbnailUrl;
const audioLink = linkUrl;
const audioTitle = audioObject.title;
const audioAuthor = audioObject.author;
audioName = `${audioTitle} - ${audioAuthor}`;
description += `
<img src="${audioImage}">
<a href="${audioLink}">${audioName}</a>
`;
}
// 2. 视频
const videoObject = item.linkInfo.video || item.video;
if (videoObject) {
const videoImage = videoObject.image.picUrl || videoObject.image.thumbnailUrl;
const videoLink = linkUrl;
const videoDuration = Math.floor(videoObject.duration / 60000);
videoName = item.linkInfo.title;
description += `
<img src="${videoImage}">
<a href="${videoLink}">${videoName || '观看视频'} - 约${videoDuration}分钟</a>
`;
}
// 3. 链接
if (!audioObject && !videoObject && linkUrl) {
// 部分链接有标题
linkName = item.linkInfo.title;
const linkTitle = linkName || '访问原文';
// 部分链接有缩略图
const linkImage = item.linkInfo.pictureUrl;
const imageTag = `<img src="${linkImage}">`;
description += `
${linkImage ? imageTag : ''}
<a href="${linkUrl}">${linkTitle}</a>
`;
}
}
// 4. 文字内容
description += description ? `<br>${content}` : content;
// 5. 图片
if (item.pictures) {
for (const pic of item.pictures) {
if (pic.format === 'gif') {
description += `<img src="${pic.picUrl.split('?imageMogr2/')[0]}">`;
} else {
// jpeg, bmp, png, gif, webp
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types
// let type = 'jpeg';
// switch (pic.format) {
// case 'bmp':
// type = 'bmp';
// break;
// case 'png':
// type = 'png';
// break;
// default:
// break;
// }
const imgUrl = /\.[\da-z]+?\?imageMogr2/.test(pic.picUrl) ? pic.picUrl.split('?imageMogr2/')[0] : pic.picUrl.replace(/thumbnail\/.+/, '');
description += `<br><img src="${imgUrl}">`;
// description += `<br><picture><source srcset="${
// pic.picUrl.split('/thumbnail/')[0]
// }/strip/format/webp" type="image/webp"><source srcset="${imgUrl}" type="image/${type}"><img src="${imgUrl}"></picture>`;
}
}
}
// 6. 视频
if (item.video) {
description += `<br><video src="${item.video.url}" controls></video>`;
}
// rss标题
// 优先将音频和视频名作为标题
// 其次将正文内容作为标题
// 若都没有 则是推送型消息,将连接标题作为主题
// “无题” fallback
const title = audioName || videoName || content || linkName || '无题';
return {
title,
description: description.trim().replaceAll('\n', '<br>'),
pubDate: parseDate.parse(item.createdAt),
author,
link,
};
}),
constructTopicEntry: async (ctx, url) => {
const data = await cache.tryGet(
url,
async () => {
const resp = await got(url);
const html = resp.data;
const $ = cheerio.load(html);
const raw = $('[type = "application/json"]').html();
const data = JSON.parse(raw).props.pageProps;
data.posts = await Promise.all(
data.posts.map(async (item) => {
if (!item.video) {
return item;
}
const videoUrl = `${videoAPI}&id=${item.id}`;
const videoRes = await got(videoUrl);
item.video = videoRes.data;
return item;
})
);
return data;
},
false,
config.cache.routeExpire
);
if (data.length === 0) {
ctx.set('data', {
title: '主题 ID 不存在,或该主题暂无内容',
});
return null;
}
const topic = data.topic;
ctx.set('data', {
title: `${topic.content} - 即刻圈子`,
link: url,
description: topic.briefIntro,
image: topic.squarePicture.picUrl || topic.squarePicture.middlePicUrl || topic.squarePicture.thumbnailUrl,
// item: topicDataHanding(data),
});
return data;
},
};
+1 -1
View File
@@ -43,4 +43,4 @@ const json = (data) => {
return JSON.stringify(jsonFeed, null, 4);
};
module.exports = json;
export default json;
@@ -1,4 +1,4 @@
const dayjs = require('dayjs');
import dayjs from 'dayjs';
/**
* This function should be used by RSSHub middleware only.
@@ -59,4 +59,4 @@ function getOwnershipFieldFromURL(item) {
}
}
module.exports = rss3Ums;
export default rss3Ums;
+5 -11
View File
@@ -23,7 +23,7 @@
"build:all": "npm run build:radar && npm run build:maintainer",
"build:maintainer": "node scripts/workflow/build-maintainer.js",
"build:radar": "node scripts/workflow/build-radar.js",
"dev": "cross-env NODE_ENV=dev nodemon --inspect lib/index.js",
"dev": "cross-env NODE_ENV=dev tsx watch lib/index.ts",
"format": "eslint --cache --fix \"**/*.{js,yml}\" && node website/docs/.format/format.mjs && prettier \"**/*.{js,json}\" --write",
"format:check": "eslint --cache \"**/*.{js,yml}\" && prettier \"**/*.{js,json}\" --check",
"format:staged": "lint-staged",
@@ -44,14 +44,6 @@
"*.md?(x)": "node website/docs/.format/format.mjs --staged",
"*.yml": "eslint --cache --fix"
},
"nodemonConfig": {
"ext": "art,js,mjs,json",
"watch": [
".env",
"lib",
"test"
]
},
"jest": {
"bail": true,
"collectCoverageFrom": [
@@ -83,11 +75,13 @@
"outputDirectory": "coverage"
},
"dependencies": {
"@hono/node-server": "1.4.0",
"@koa/router": "12.0.1",
"@notionhq/client": "2.2.14",
"@postlight/parser": "2.2.3",
"@sentry/node": "7.94.1",
"@tonyrl/rand-user-agent": "2.0.46",
"@types/node": "20.11.5",
"aes-js": "3.1.2",
"art-template": "4.13.2",
"bbcodejs": "0.0.4",
@@ -104,6 +98,7 @@
"git-rev-sync": "3.0.2",
"googleapis": "131.0.0",
"got": "11.8.6",
"hono": "3.12.6",
"html-to-text": "9.0.5",
"https-proxy-agent": "7.0.2",
"iconv-lite": "0.6.3",
@@ -176,7 +171,6 @@
"@types/mailparser": "3.4.4",
"@types/markdown-it": "13.0.7",
"@types/module-alias": "2.0.4",
"@types/nodemon": "1.19.6",
"@types/plist": "3.0.5",
"@types/request-promise-native": "1.0.21",
"@types/require-all": "3.0.6",
@@ -199,7 +193,6 @@
"lint-staged": "15.2.0",
"mockdate": "3.0.5",
"nock": "13.5.0",
"nodemon": "3.0.3",
"prettier": "3.2.4",
"remark": "14.0.3",
"remark-custom-heading-id": "1.0.1",
@@ -216,6 +209,7 @@
"supertest": "6.3.4",
"to-vfile": "8.0.0",
"tosource": "2.0.0-alpha.3",
"tsx": "4.7.0",
"unified": "9.2.2",
"unist-util-visit": "4.1.2",
"unist-util-visit-parents": "5.1.3"
+368 -183
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"lib": ["ES2022"],
"strict": true,
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"paths": {
"@/*": ["./lib/*"]
},
"esModuleInterop": true
}
}