🦄 refactor: Refactoring using hono

This commit is contained in:
imsyy
2024-04-08 16:35:58 +08:00
parent 7459858767
commit 34ab73a3f1
95 changed files with 3555 additions and 6345 deletions

54
src/app.tsx Normal file
View File

@@ -0,0 +1,54 @@
import { Hono } from "hono";
import { cors } from "hono/cors";
import { config } from "./config.js";
import { serveStatic } from "@hono/node-server/serve-static";
import { compress } from "hono/compress";
import logger from "./utils/logger.js";
import registry from "./registry.js";
import robotstxt from "./robots.txt.js";
import NotFound from "./views/NotFound.js";
import Home from "./views/Home.js";
import Error from "./views/Error.js";
const app = new Hono();
// 压缩响应
app.use(compress());
// CORS
app.use(
"*",
cors({
// 可写为数组
origin: config.ALLOWED_DOMAIN,
allowMethods: ["POST", "GET", "OPTIONS"],
allowHeaders: ["X-Custom-Header", "Upgrade-Insecure-Requests"],
credentials: true,
}),
);
// 静态资源
app.use(
"/*",
serveStatic({
root: "./public",
rewriteRequestPath: (path) => (path === "/favicon.ico" ? "/favicon.png" : path),
}),
);
// 主路由
app.route("/", registry);
// robots
app.get("/robots.txt", robotstxt);
// 首页
app.get("/", (c) => c.html(<Home />));
// 404
app.notFound((c) => c.html(<NotFound />, 404));
// error
app.onError((err, c) => {
logger.error(`出现致命错误:${err}`);
return c.html(<Error error={err?.message} />, 500);
});
export default app;

50
src/config.ts Normal file
View File

@@ -0,0 +1,50 @@
import dotenv from "dotenv";
// 环境变量
dotenv.config();
export type Config = {
PORT: number;
DISALLOW_ROBOT: boolean;
CACHE_TTL: number;
REQUEST_TIMEOUT: number;
ALLOWED_DOMAIN: string;
USE_LOG_FILE: boolean;
RSS_MODE: boolean;
};
// 验证并提取环境变量
const getEnvVariable = (key: string): string | undefined => {
const value = process.env[key];
if (value === undefined) {
return null;
}
return value;
};
// 将环境变量转换为数值
const getNumericEnvVariable = (key: string, defaultValue: number): number => {
const value = getEnvVariable(key) ?? String(defaultValue);
const parsedValue = parseInt(value, 10);
if (isNaN(parsedValue)) {
return defaultValue
}
return parsedValue;
};
// 将环境变量转换为布尔值
const getBooleanEnvVariable = (key: string, defaultValue: boolean): boolean => {
const value = getEnvVariable(key) ?? String(defaultValue);
return value.toLowerCase() === "true";
};
// 创建配置对象
export const config: Config = {
PORT: getNumericEnvVariable("PORT", 6688),
DISALLOW_ROBOT: getBooleanEnvVariable("DISALLOW_ROBOT", true),
CACHE_TTL: getNumericEnvVariable("CACHE_TTL", 3600),
REQUEST_TIMEOUT: getNumericEnvVariable("CACHE_TTL", 6000),
ALLOWED_DOMAIN: getEnvVariable("ALLOWED_DOMAIN") || "*",
USE_LOG_FILE: getBooleanEnvVariable("USE_LOG_FILE", true),
RSS_MODE: getBooleanEnvVariable("RSS_MODE", false),
};

15
src/index.ts Normal file
View File

@@ -0,0 +1,15 @@
import { serve } from "@hono/node-server";
import { config } from "./config.js";
import logger from "./utils/logger.js";
import app from "./app.js";
logger.info(`🔥 DailyHot API 成功在端口 ${config.PORT} 上运行`);
logger.info(`🔗 Local: 👉 http://localhost:${config.PORT}`);
// 启动服务器
const server = serve({
fetch: app.fetch,
port: config.PORT,
});
export default server;

105
src/registry.ts Normal file
View File

@@ -0,0 +1,105 @@
import { fileURLToPath } from "url";
import { config } from "./config.js";
import { Hono } from "hono";
import getRSS from "./utils/getRSS.js";
import path from "path";
import fs from "fs";
const app = new Hono();
// 模拟 __dirname
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// 路由数据
let allRoutePath: Array<string> = [];
const routersDirName: string = "routes";
// 建立完整目录路径
const routersDirPath = path.join(__dirname, routersDirName);
// 递归查找函数
const findTsFiles = (dirPath: string, allFiles: string[] = [], basePath: string = ""): string[] => {
// 读取目录下的所有文件和文件夹
const items: Array<string> = fs.readdirSync(dirPath);
// 遍历每个文件或文件夹
items.forEach((item) => {
const fullPath: string = path.join(dirPath, item);
const relativePath: string = basePath ? path.posix.join(basePath, item) : item;
const stat: fs.Stats = fs.statSync(fullPath);
if (stat.isDirectory()) {
// 如果是文件夹,递归查找
findTsFiles(fullPath, allFiles, relativePath);
} else if (stat.isFile() && (item.endsWith(".ts") || item.endsWith(".js"))) {
// 符合条件
allFiles.push(relativePath.replace(/\.(ts|js)$/, ""));
}
});
return allFiles;
};
// 获取全部路由
if (fs.existsSync(routersDirPath) && fs.statSync(routersDirPath).isDirectory()) {
allRoutePath = findTsFiles(routersDirPath);
} else {
console.error(`目录 ${routersDirPath} 不存在或不是目录`);
}
// 注册全部路由
for (let index = 0; index < allRoutePath.length; index++) {
const router = allRoutePath[index];
const listApp = app.basePath(`/${router}`);
// 返回榜单
listApp.get("/", async (c) => {
// 是否采用缓存
const noCache = c.req.query("cache") === "false";
// 限制显示条目
const limit = c.req.query("limit");
// 是否输出 RSS
const rssEnabled = c.req.query("rss") === "true";
// 获取路由路径
const { handleRoute } = await import(`./routes/${router}.js`);
const listData = await handleRoute(c, noCache);
// 是否限制条目
if (limit && listData?.data?.length > parseInt(limit)) {
listData.total = parseInt(limit);
listData.data = listData.data.slice(0, parseInt(limit));
}
// 是否输出 RSS
if (rssEnabled || config.RSS_MODE) {
const rss = getRSS(listData);
if (typeof rss === "string") {
c.header("Content-Type", "application/xml; charset=utf-8");
return c.body(rss);
} else {
return c.json(
{
code: 500,
message: "RSS 生成失败",
},
500,
);
}
}
return c.json({
code: 200,
...listData,
});
});
}
// 获取全部路由
app.get("/all", (c) =>
c.json(
{
code: 200,
count: allRoutePath.length,
routes: allRoutePath.map((path) => ({
name: path,
path: `/${path}`,
})),
},
200,
),
);
export default app;

13
src/robots.txt.ts Normal file
View File

@@ -0,0 +1,13 @@
import type { Handler } from "hono";
import { config } from "./config.js";
const handler: Handler = (c) => {
if (config.DISALLOW_ROBOT) {
return c.text("User-agent: *\nDisallow: /");
} else {
c.status(404);
return c.text("");
}
};
export default handler;

188
src/router.types.ts Normal file
View File

@@ -0,0 +1,188 @@
export type RouterType = {
"36kr": {
itemId: number;
templateMaterial: {
widgetTitle: string;
authorName: string;
statCollect: number;
widgetImage: string;
};
};
"qq-news": {
id: string;
title: string;
abstract: string;
source: string;
hotEvent: {
hotScore: number;
};
miniProShareImage: string;
};
"netease-news": {
title: string;
imgsrc: string;
source: string;
docid: string;
};
"zhihu-daily": {
id: number;
images: [string];
title: string;
hint: string;
url: string;
type: number;
};
bilibili: {
bvid: string;
title: string;
desc: string;
pic: string;
owner: {
name: string;
};
stat: {
view: number;
};
short_link_v2?: string;
};
juejin: {
content: {
content_id: string;
title: string;
name: string;
};
author: {
name: string;
};
content_counter: {
hot_rank: string;
};
};
weibo: {
mid: string;
word: string;
word_scheme: string;
note: string;
category: string;
raw_hot: number;
};
zhihu: {
target: {
id: number;
title: string;
excerpt: string;
};
children: [
{
thumbnail: string;
},
];
detail_text: string;
};
douyin: {
sentence_id: string;
word: string;
hot_value: number;
};
baidu: {
index: number;
word: string;
desc: string;
img: string;
hotScore: string;
show: string;
rawUrl: string;
query: string;
};
miyoushe: {
post: {
post_id: string;
subject: string;
content: string;
cover: string;
};
stat: {
view_num: number;
};
user: {
nickname: string;
};
image_list: [
{
url: string;
},
];
};
weread: {
readingCount: number;
bookInfo: {
bookId: string;
title: string;
intro: string;
cover: string;
author: string;
};
};
toutiao: {
ClusterIdStr: string;
Title: string;
HotValue: string;
Image: {
url: string;
};
};
thepaper: {
contId: string;
name: string;
pic: string;
praiseTimes: string;
};
sspai: {
id: number;
title: string;
summary: string;
banner: string;
like_count: number;
author: {
nickname: string;
};
};
lol: {
sAuthor: string;
sIMG: string;
sTitle: string;
iTotalPlay: string;
iDocID: string;
};
ngabbs: {
tid: number;
subject: string;
author: string;
tpcurl: string;
replies: number;
};
tieba: {
topic_id: number;
topic_name: string;
topic_desc: string;
topic_pic: string;
topic_url: string;
discuss_num: number;
};
acfun: {
dougaId: string;
contentTitle: string;
userName: string;
contentDesc: string;
likeCount: number;
coverUrl: string;
};
hellogithub: {
item_id: string;
title: string;
author: string;
description: string;
summary: string;
clicks_total: number;
};
};

74
src/routes/36kr.ts Normal file
View File

@@ -0,0 +1,74 @@
import type { RouterData, ListContext, Options } from "../types.js";
import type { RouterType } from "../router.types.js";
import { post } from "../utils/getData.js";
export const handleRoute = async (c: ListContext, noCache: boolean) => {
const type = c.req.query("type") || "hot";
const { fromCache, data, updateTime } = await getList({ type }, noCache);
const routeData: RouterData = {
name: "36kr",
title: "36氪",
type: "热榜",
parameData: {
type: {
name: "热榜分类",
type: {
hot: "人气榜",
video: "视频榜",
comment: "热议榜",
collect: "收藏榜",
},
},
},
link: "https://m.36kr.com/hot-list-m",
total: data?.length || 0,
updateTime,
fromCache,
data,
};
return routeData;
};
const getList = async (options: Options, noCache: boolean) => {
const { type } = options;
const url = `https://gateway.36kr.com/api/mis/nav/home/nav/rank/${type}`;
const result = await post({
url,
noCache,
headers: {
"Content-Type": "application/json; charset=utf-8",
},
body: {
partner_id: "wap",
param: {
siteId: 1,
platformId: 2,
},
timestamp: new Date().getTime(),
},
});
const listType = {
hot: "hotRankList",
video: "videoList",
comment: "remarkList",
collect: "collectList",
};
const list =
result.data.data[(listType as Record<string, keyof typeof result.data.data>)[type || "hot"]];
return {
fromCache: result.fromCache,
updateTime: result.updateTime,
data: list.map((v: RouterType["36kr"]) => {
const item = v.templateMaterial;
return {
id: v.itemId,
title: item.widgetTitle,
cover: item.widgetImage,
author: item.authorName,
hot: item.statCollect,
url: `https://www.36kr.com/p/${v.itemId}`,
mobileUrl: `https://m.36kr.com/p/${v.itemId}`,
};
}),
};
};

75
src/routes/acfun.ts Normal file
View File

@@ -0,0 +1,75 @@
import type { RouterData, ListContext, Options } from "../types.js";
import type { RouterType } from "../router.types.js";
import { get } from "../utils/getData.js";
export const handleRoute = async (c: ListContext, noCache: boolean) => {
const type = c.req.query("type") || "-1";
const range = c.req.query("range") || "DAY";
const { fromCache, data, updateTime } = await getList({ type, range }, noCache);
const routeData: RouterData = {
name: "acfun",
title: "AcFun",
type: "排行榜",
description: "AcFun是一家弹幕视频网站致力于为每一个人带来欢乐。",
parameData: {
type: {
name: "频道",
type: {
"-1": "全站综合",
"155": "番剧",
"1": "动画",
"60": "娱乐",
"201": "生活",
"58": "音乐",
"123": "舞蹈·偶像",
"59": "游戏",
"70": "科技",
"68": "影视",
"69": "体育",
"125": "鱼塘",
},
},
range: {
name: "时间",
type: {
DAY: "今日",
THREE_DAYS: "三日",
WEEK: "本周",
},
},
},
link: "https://www.acfun.cn/rank/list/",
total: data?.length || 0,
updateTime,
fromCache,
data,
};
return routeData;
};
const getList = async (options: Options, noCache: boolean) => {
const { type, range } = options;
const url = `https://www.acfun.cn/rest/pc-direct/rank/channel?channelId=${type === "-1" ? "" : type}&rankLimit=30&rankPeriod=${range}`;
const result = await get({
url,
headers: {
Referer: `https://www.acfun.cn/rank/list/?cid=-1&pcid=${type}&range=${range}`,
},
noCache,
});
const list = result.data.rankList;
return {
fromCache: result.fromCache,
updateTime: result.updateTime,
data: list.map((v: RouterType["acfun"]) => ({
id: v.dougaId,
title: v.contentTitle,
desc: v.contentDesc,
cover: v.coverUrl,
author: v.userName,
hot: v.likeCount,
url: `https://www.acfun.cn/v/ac${v.dougaId}`,
mobileUrl: `https://m.acfun.cn/v/?ac=${v.dougaId}`,
})),
};
};

63
src/routes/baidu.ts Normal file
View File

@@ -0,0 +1,63 @@
import type { RouterData, ListContext, Options } from "../types.js";
import type { RouterType } from "../router.types.js";
import { get } from "../utils/getData.js";
export const handleRoute = async (c: ListContext, noCache: boolean) => {
const type = c.req.query("type") || "realtime";
const { fromCache, data, updateTime } = await getList({ type }, noCache);
const routeData: RouterData = {
name: "baidu",
title: "百度",
type: "热搜榜",
parameData: {
type: {
name: "热搜类别",
type: {
realtime: "热搜",
novel: "小说",
movie: "电影",
teleplay: "电视剧",
car: "汽车",
game: "游戏",
},
},
},
link: "https://top.baidu.com/board",
total: data?.length || 0,
updateTime,
fromCache,
data,
};
return routeData;
};
const getList = async (options: Options, noCache: boolean) => {
const { type } = options;
const url = `https://top.baidu.com/board?tab=${type}`;
const result = await get({
url,
noCache,
headers: {
"User-Agent":
"Mozilla/5.0 (iPhone; CPU iPhone OS 14_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/1.0 Mobile/12F69 Safari/605.1.15",
},
});
// 正则查找
const pattern = /<!--s-data:(.*?)-->/s;
const matchResult = result.data.match(pattern);
const jsonObject = JSON.parse(matchResult[1]).cards[0].content;
return {
fromCache: result.fromCache,
updateTime: result.updateTime,
data: jsonObject.map((v: RouterType["baidu"]) => ({
id: v.index,
title: v.word,
desc: v.desc,
cover: v.img,
author: v.show?.length ? v.show : "",
hot: Number(v.hotScore),
url: `https://www.baidu.com/s?wd=${encodeURIComponent(v.query)}`,
mobileUrl: v.rawUrl,
})),
};
};

67
src/routes/bilibili.ts Normal file
View File

@@ -0,0 +1,67 @@
import type { RouterData, ListContext, Options } from "../types.js";
import type { RouterType } from "../router.types.js";
import { get } from "../utils/getData.js";
export const handleRoute = async (c: ListContext, noCache: boolean) => {
const type = c.req.query("type") || "0";
const { fromCache, data, updateTime } = await getList({ type }, noCache);
const routeData: RouterData = {
name: "bilibili",
title: "哔哩哔哩",
type: "热门榜",
description: "你所热爱的,就是你的生活",
parameData: {
type: {
name: "排行榜分区",
type: {
0: "全站",
1: "动画",
3: "音乐",
4: "游戏",
5: "娱乐",
36: "科技",
119: "鬼畜",
129: "舞蹈",
155: "时尚",
160: "生活",
168: "国创相关",
188: "数码",
181: "影视",
},
},
},
link: "https://www.bilibili.com/v/popular/rank/all",
total: data?.length || 0,
updateTime,
fromCache,
data,
};
return routeData;
};
const getList = async (options: Options, noCache: boolean) => {
const { type } = options;
const url = `https://api.bilibili.com/x/web-interface/ranking/v2?rid=${type}`;
const result = await get({
url,
headers: {
Referer: `https://www.bilibili.com/ranking/all`,
},
noCache,
});
const list = result.data.data.list;
return {
fromCache: result.fromCache,
updateTime: result.updateTime,
data: list.map((v: RouterType["bilibili"]) => ({
id: v.bvid,
title: v.title,
desc: v.desc,
cover: v.pic.replace(/http:/, "https:"),
author: v.owner.name,
hot: v.stat.view,
url: v.short_link_v2 || `https://www.bilibili.com/video/${v.bvid}`,
mobileUrl: `https://m.bilibili.com/video/${v.bvid}`,
})),
};
};

View File

@@ -0,0 +1,54 @@
import type { RouterData } from "../types.js";
import { load } from "cheerio";
import { get } from "../utils/getData.js";
export const handleRoute = async (_: undefined, noCache: boolean) => {
const { fromCache, data, updateTime } = await getList(noCache);
const routeData: RouterData = {
name: "douban-group",
title: "豆瓣讨论",
type: "讨论精选",
link: "https://www.douban.com/group/explore",
total: data?.length || 0,
updateTime,
fromCache,
data,
};
return routeData;
};
// 数据处理
const getNumbers = (text: string | undefined) => {
if (!text) return 100000000;
const regex = /\d+/;
const match = text.match(regex);
if (match) {
return Number(match[0]);
} else {
return 100000000;
}
};
const getList = async (noCache: boolean) => {
const url = `https://www.douban.com/group/explore`;
const result = await get({ url, noCache });
const $ = load(result.data);
const listDom = $(".article .channel-item");
const listData = listDom.toArray().map((item) => {
const dom = $(item);
const url = dom.find("h3 a").attr("href") || undefined;
return {
id: getNumbers(url),
title: dom.find("h3 a").text().trim(),
cover: dom.find(".pic-wrap img").attr("src"),
desc: dom.find(".block p").text().trim(),
url,
mobileUrl: `https://m.douban.com/group/topic/${getNumbers(url)}/`,
};
});
return {
fromCache: result.fromCache,
updateTime: result.updateTime,
data: listData,
};
};

View File

@@ -0,0 +1,63 @@
import type { RouterData } from "../types.js";
import { load } from "cheerio";
import { get } from "../utils/getData.js";
export const handleRoute = async (_: undefined, noCache: boolean) => {
const { fromCache, data, updateTime } = await getList(noCache);
const routeData: RouterData = {
name: "douban-movie",
title: "豆瓣电影",
type: "新片排行榜",
link: "https://movie.douban.com/chart",
total: data?.length || 0,
updateTime,
fromCache,
data,
};
return routeData;
};
// 数据处理
const getNumbers = (text: string | undefined) => {
if (!text) return 10000000;
const regex = /\d+/;
const match = text.match(regex);
if (match) {
return Number(match[0]);
} else {
return 10000000;
}
};
const getList = async (noCache: boolean) => {
const url = `https://movie.douban.com/chart/`;
const result = await get({
url,
noCache,
headers: {
"User-Agent":
"Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Mobile/15E148 Safari/604.1",
},
});
const $ = load(result.data);
const listDom = $(".article tr.item");
const listData = listDom.toArray().map((item) => {
const dom = $(item);
const url = dom.find("a").attr("href") || undefined;
const score = dom.find(".rating_nums").text() ?? "0.0";
return {
id: getNumbers(url),
title: `${score}${dom.find("a").attr("title")}`,
cover: dom.find("img").attr("src"),
desc: dom.find("p.pl").text(),
hot: getNumbers(dom.find("span.pl").text()),
url,
mobileUrl:`https://m.douban.com/movie/subject/${getNumbers(url)}/`,
};
});
return {
fromCache: result.fromCache,
updateTime: result.updateTime,
data: listData,
};
};

59
src/routes/douyin.ts Normal file
View File

@@ -0,0 +1,59 @@
import type { RouterData } from "../types.js";
import type { RouterType } from "../router.types.js";
import { get } from "../utils/getData.js";
export const handleRoute = async (_: undefined, noCache: boolean) => {
const { fromCache, data, updateTime } = await getList(noCache);
const routeData: RouterData = {
name: "douyin",
title: "抖音",
type: "热榜",
description: "实时上升热点",
link: "https://www.douyin.com",
total: data?.length || 0,
updateTime,
fromCache,
data,
};
return routeData;
};
// 获取抖音临时 Cookis
const getDyCookies = async () => {
try {
const cookisUrl = "https://www.douyin.com/passport/general/login_guiding_strategy/?aid=6383";
const { data } = await get({ url: cookisUrl, originaInfo: true });
const pattern = /passport_csrf_token=(.*); Path/s;
const matchResult = data.headers["set-cookie"][0].match(pattern);
const cookieData = matchResult[1];
return cookieData;
} catch (error) {
console.error("获取抖音 Cookie 出错" + error);
return null;
}
};
const getList = async (noCache: boolean) => {
const url =
"https://www.douyin.com/aweme/v1/web/hot/search/list/?device_platform=webapp&aid=6383&channel=channel_pc_web&detail_list=1";
const cookie = await getDyCookies();
const result = await get({
url,
noCache,
headers: {
Cookie: `passport_csrf_token=${cookie}`,
},
});
const list = result.data.data.word_list;
return {
fromCache: result.fromCache,
updateTime: result.updateTime,
data: list.map((v: RouterType["douyin"]) => ({
id: v.sentence_id,
title: v.word,
hot: v.hot_value,
url: `https://www.douyin.com/hot/${encodeURIComponent(v.sentence_id)}`,
mobileUrl: `https://www.douyin.com/hot/${encodeURIComponent(v.sentence_id)}`,
})),
};
};

53
src/routes/genshin.ts Normal file
View File

@@ -0,0 +1,53 @@
import type { RouterData, ListContext, Options } from "../types.js";
import type { RouterType } from "../router.types.js";
import { get } from "../utils/getData.js";
export const handleRoute = async (c: ListContext, noCache: boolean) => {
const type = c.req.query("type") || "1";
const { fromCache, data, updateTime } = await getList({ type }, noCache);
const routeData: RouterData = {
name: "genshin",
title: "原神",
type: "最新动态",
parameData: {
type: {
name: "榜单分类",
type: {
1: "公告",
2: "活动",
3: "资讯",
},
},
},
link: "https://www.miyoushe.com/ys/home/28",
total: data?.length || 0,
updateTime,
fromCache,
data,
};
return routeData;
};
const getList = async (options: Options, noCache: boolean) => {
const { type } = options;
const url = `https://bbs-api.miyoushe.com/post/wapi/getNewsList?gids=2&page_size=20&type=${type}`;
const result = await get({ url, noCache });
const list = result.data.data.list;
return {
fromCache: result.fromCache,
updateTime: result.updateTime,
data: list.map((v: RouterType["miyoushe"]) => {
const data = v.post;
return {
id: data.post_id,
title: data.subject,
desc: data.content,
cover: data.cover,
author: v.user.nickname,
hot: v.stat.view_num,
url: `https://www.miyoushe.com/ys/article/${data.post_id}`,
mobileUrl: `https://m.miyoushe.com/ys/#/article/${data.post_id}`,
};
}),
};
};

49
src/routes/hellogithub.ts Normal file
View File

@@ -0,0 +1,49 @@
import type { RouterData, ListContext, Options } from "../types.js";
import type { RouterType } from "../router.types.js";
import { get } from "../utils/getData.js";
export const handleRoute = async (c: ListContext, noCache: boolean) => {
const sort = c.req.query("sort") || "hot";
const { fromCache, data, updateTime } = await getList({ sort }, noCache);
const routeData: RouterData = {
name: "hellogithub",
title: "HelloGitHub",
type: "热门仓库",
description: "分享 GitHub 上有趣、入门级的开源项目",
parameData: {
sort: {
name: "排行榜分区",
type: {
hot: "热门",
last: "最新",
},
},
},
link: "https://hellogithub.com/",
total: data?.length || 0,
updateTime,
fromCache,
data,
};
return routeData;
};
const getList = async (options: Options, noCache: boolean) => {
const { sort } = options;
const url = `https://abroad.hellogithub.com/v1/?sort_by=${sort}&tid=&page=1`;
const result = await get({ url, noCache });
const list = result.data.data;
return {
fromCache: result.fromCache,
updateTime: result.updateTime,
data: list.map((v: RouterType["hellogithub"]) => ({
id: v.item_id,
title: v.title,
desc: v.summary,
author: v.author,
hot: v.clicks_total,
url: `https://hellogithub.com/repository/${v.item_id}`,
mobileUrl: `https://hellogithub.com/repository/${v.item_id}`,
})),
};
};

53
src/routes/honkai.ts Normal file
View File

@@ -0,0 +1,53 @@
import type { RouterData, ListContext, Options } from "../types.js";
import type { RouterType } from "../router.types.js";
import { get } from "../utils/getData.js";
export const handleRoute = async (c: ListContext, noCache: boolean) => {
const type = c.req.query("type") || "1";
const { fromCache, data, updateTime } = await getList({ type }, noCache);
const routeData: RouterData = {
name: "honkai",
title: "崩坏3",
type: "最新动态",
parameData: {
type: {
name: "榜单分类",
type: {
1: "公告",
2: "活动",
3: "资讯",
},
},
},
link: "https://www.miyoushe.com/bh3/home/6",
total: data?.length || 0,
updateTime,
fromCache,
data,
};
return routeData;
};
const getList = async (options: Options, noCache: boolean) => {
const { type } = options;
const url = `https://bbs-api.miyoushe.com/post/wapi/getNewsList?gids=1&page_size=20&type=${type}`;
const result = await get({ url, noCache });
const list = result.data.data.list;
return {
fromCache: result.fromCache,
updateTime: result.updateTime,
data: list.map((v: RouterType["miyoushe"]) => {
const data = v.post;
return {
id: data.post_id,
title: data.subject,
desc: data.content,
cover: data.cover || v.image_list[0].url,
author: v.user.nickname,
hot: v.stat.view_num,
url: `https://www.miyoushe.com/bh3/article/${data.post_id}`,
mobileUrl: `https://m.miyoushe.com/bh3/#/article/${data.post_id}`,
};
}),
};
};

56
src/routes/ithome.ts Normal file
View File

@@ -0,0 +1,56 @@
import type { RouterData } from "../types.js";
import { load } from "cheerio";
import { get } from "../utils/getData.js";
export const handleRoute = async (_: undefined, noCache: boolean) => {
const { fromCache, data, updateTime } = await getList(noCache);
const routeData: RouterData = {
name: "ithome",
title: "IT之家",
type: "热榜",
description: "爱科技,爱这里 - 前沿科技新闻网站",
link: "https://m.ithome.com/rankm/",
total: data?.length || 0,
updateTime,
fromCache,
data,
};
return routeData;
};
// 链接处理
const replaceLink = (url: string, getId: boolean = false) => {
const match = url.match(/[html|live]\/(\d+)\.htm/);
// 是否匹配成功
if (match && match[1]) {
return getId
? match[1]
: `https://www.ithome.com/0/${match[1].slice(0, 3)}/${match[1].slice(3)}.htm`;
}
// 返回原始 URL
return url;
};
const getList = async (noCache: boolean) => {
const url = `https://m.ithome.com/rankm/`;
const result = await get({ url, noCache });
const $ = load(result.data);
const listDom = $(".rank-box .placeholder");
const listData = listDom.toArray().map((item) => {
const dom = $(item);
const href = dom.find("a").attr("href");
return {
id: href ? Number(replaceLink(href, true)) : 100000,
title: dom.find(".plc-title").text().trim(),
cover: dom.find("img").attr("data-original"),
hot: Number(dom.find(".review-num").text().replace(/\D/g, "")),
url: href ? replaceLink(href) : undefined,
mobileUrl: href || undefined,
};
});
return {
fromCache: result.fromCache,
updateTime: result.updateTime,
data: listData,
};
};

57
src/routes/jianshu.ts Normal file
View File

@@ -0,0 +1,57 @@
import type { RouterData } from "../types.js";
import { load } from "cheerio";
import { get } from "../utils/getData.js";
export const handleRoute = async (_: undefined, noCache: boolean) => {
const { fromCache, data, updateTime } = await getList(noCache);
const routeData: RouterData = {
name: "jianshu",
title: "简书",
type: "热门推荐",
description: "一个优质的创作社区",
link: "https://www.jianshu.com/",
total: data?.length || 0,
updateTime,
fromCache,
data,
};
return routeData;
};
// 获取 ID
const getID = (url: string) => {
if (!url) return "undefined";
const match = url.match(/([^/]+)$/);
return match ? match[1] : "undefined";
};
const getList = async (noCache: boolean) => {
const url = `https://www.jianshu.com/`;
const result = await get({
url,
noCache,
headers: {
Referer: "https://www.jianshu.com",
},
});
const $ = load(result.data);
const listDom = $("ul.note-list li");
const listData = listDom.toArray().map((item) => {
const dom = $(item);
const href = dom.find("a").attr("href") || "";
return {
id: getID(href),
title: dom.find("a.title").text()?.trim(),
cover: dom.find("img").attr("src"),
desc: dom.find("p.abstract").text()?.trim(),
author: dom.find("a.nickname").text()?.trim(),
url: `https://www.jianshu.com${href}`,
mobileUrl: `https://www.jianshu.com${href}`,
};
});
return {
fromCache: result.fromCache,
updateTime: result.updateTime,
data: listData,
};
};

36
src/routes/juejin.ts Normal file
View File

@@ -0,0 +1,36 @@
import type { RouterData } from "../types.js";
import type { RouterType } from "../router.types.js";
import { get } from "../utils/getData.js";
export const handleRoute = async (_: undefined, noCache: boolean) => {
const { fromCache, data, updateTime } = await getList(noCache);
const routeData: RouterData = {
name: "juejin",
title: "稀土掘金",
type: "文章榜",
link: "https://juejin.cn/hot/articles",
total: data?.length || 0,
updateTime,
fromCache,
data,
};
return routeData;
};
const getList = async (noCache: boolean) => {
const url = `https://api.juejin.cn/content_api/v1/content/article_rank?category_id=1&type=hot`;
const result = await get({ url, noCache });
const list = result.data.data;
return {
fromCache: result.fromCache,
updateTime: result.updateTime,
data: list.map((v: RouterType["juejin"]) => ({
id: v.content.content_id,
title: v.content.title,
author: v.author.name,
hot: v.content_counter.hot_rank,
url: `https://juejin.cn/post/${v.content.content_id}`,
mobileUrl: `https://juejin.cn/post/${v.content.content_id}`,
})),
};
};

38
src/routes/lol.ts Normal file
View File

@@ -0,0 +1,38 @@
import type { RouterData } from "../types.js";
import type { RouterType } from "../router.types.js";
import { get } from "../utils/getData.js";
export const handleRoute = async (_: undefined, noCache: boolean) => {
const { fromCache, data, updateTime } = await getList(noCache);
const routeData: RouterData = {
name: "lol",
title: "英雄联盟",
type: "更新公告",
link: "https://lol.qq.com/gicp/news/423/2/1334/1.html",
total: data?.length || 0,
updateTime,
fromCache,
data,
};
return routeData;
};
const getList = async (noCache: boolean) => {
const url =
"https://apps.game.qq.com/cmc/zmMcnTargetContentList?r0=json&page=1&num=30&target=24&source=web_pc";
const result = await get({ url, noCache });
const list = result.data.data.result;
return {
fromCache: result.fromCache,
updateTime: result.updateTime,
data: list.map((v: RouterType["lol"]) => ({
id: v.iDocID,
title: v.sTitle,
cover: `https:${v.sIMG}`,
author: v.sAuthor,
hot: Number(v.iTotalPlay),
url: `https://lol.qq.com/news/detail.shtml?docid=${encodeURIComponent(v.iDocID)}`,
mobileUrl: `https://lol.qq.com/news/detail.shtml?docid=${encodeURIComponent(v.iDocID)}`,
})),
};
};

View File

@@ -0,0 +1,36 @@
import type { RouterData } from "../types.js";
import type { RouterType } from "../router.types.js";
import { get } from "../utils/getData.js";
export const handleRoute = async (_: undefined, noCache: boolean) => {
const { fromCache, data, updateTime } = await getList(noCache);
const routeData: RouterData = {
name: "netease-news",
title: "网易新闻",
type: "热点榜",
link: "https://m.163.com/hot",
total: data?.length || 0,
updateTime,
fromCache,
data,
};
return routeData;
};
const getList = async (noCache: boolean) => {
const url = `https://m.163.com/fe/api/hot/news/flow`;
const result = await get({ url, noCache });
const list = result.data.data.list;
return {
fromCache: result.fromCache,
updateTime: result.updateTime,
data: list.map((v: RouterType["netease-news"]) => ({
id: v.docid,
title: v.title,
cover: v.imgsrc,
author: v.source,
url: `https://www.163.com/dy/article/${v.docid}.html`,
mobileUrl: `https://m.163.com/dy/article/${v.docid}.html`,
})),
};
};

55
src/routes/ngabbs.ts Normal file
View File

@@ -0,0 +1,55 @@
import type { RouterData } from "../types.js";
import type { RouterType } from "../router.types.js";
import { post } from "../utils/getData.js";
export const handleRoute = async (_: undefined, noCache: boolean) => {
const { fromCache, data, updateTime } = await getList(noCache);
const routeData: RouterData = {
name: "ngabbs",
title: "NGA",
type: "论坛热帖",
description: "精英玩家俱乐部",
link: "https://ngabbs.com/",
total: data?.length || 0,
updateTime,
fromCache,
data,
};
return routeData;
};
const getList = async (noCache: boolean) => {
const url = `https://ngabbs.com/nuke.php?__lib=load_topic&__act=load_topic_reply_ladder2&opt=1&all=1`;
const result = await post({
url,
noCache,
headers: {
Accept: "*/*",
Host: "ngabbs.com",
Referer: "https://ngabbs.com/",
Connection: "keep-alive",
"Content-Length": "11",
"Accept-Encoding": "gzip, deflate, br",
"Accept-Language": "zh-Hans-CN;q=1",
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "Apifox/1.0.0 (https://apifox.com)",
"X-User-Agent": "NGA_skull/7.3.1(iPhone13,2;iOS 17.2.1)",
},
body: {
__output: "14",
},
});
const list = result.data.result[0];
return {
fromCache: result.fromCache,
updateTime: result.updateTime,
data: list.map((v: RouterType["ngabbs"]) => ({
id: v.tid,
title: v.subject,
author: v.author,
hot: v.replies,
url: `https://bbs.nga.cn${v.tpcurl}`,
mobileUrl: `https://bbs.nga.cn${v.tpcurl}`,
})),
};
};

37
src/routes/qq-news.ts Normal file
View File

@@ -0,0 +1,37 @@
import type { RouterData } from "../types.js";
import type { RouterType } from "../router.types.js";
import { get } from "../utils/getData.js";
export const handleRoute = async (_: undefined, noCache: boolean) => {
const { fromCache, data, updateTime } = await getList(noCache);
const routeData: RouterData = {
name: "qq-news",
title: "腾讯新闻",
type: "热点榜",
link: "https://news.qq.com/",
total: data?.length || 0,
updateTime,
fromCache,
data,
};
return routeData;
};
const getList = async (noCache: boolean) => {
const url = `https://r.inews.qq.com/gw/event/hot_ranking_list?page_size=50`;
const result = await get({ url, noCache });const list = result.data.idlist[0].newslist.slice(1);
return {
fromCache: result.fromCache,
updateTime: result.updateTime,
data: list.map((v: RouterType["qq-news"]) => ({
id: v.id,
title: v.title,
desc: v.abstract,
cover: v.miniProShareImage,
author: v.source,
hot: v.hotEvent.hotScore,
url: `https://new.qq.com/rain/a/${v.id}`,
mobileUrl: `https://view.inews.qq.com/k/${v.id}`,
})),
};
};

46
src/routes/sspai.ts Normal file
View File

@@ -0,0 +1,46 @@
import type { RouterData, ListContext, Options } from "../types.js";
import type { RouterType } from "../router.types.js";
import { get } from "../utils/getData.js";
export const handleRoute = async (c: ListContext, noCache: boolean) => {
const type = c.req.query("type") || "热门文章";
const { fromCache, data, updateTime } = await getList({ type }, noCache);
const routeData: RouterData = {
name: "sspai",
title: "少数派",
type: "热榜",
parameData: {
type: {
name: "分类",
type: ["热门文章", "应用推荐", "生活方式", "效率技巧", "少数派播客"],
},
},
link: "https://sspai.com/",
total: data?.length || 0,
updateTime,
fromCache,
data,
};
return routeData;
};
const getList = async (options: Options, noCache: boolean) => {
const { type } = options;
const url = `https://sspai.com/api/v1/article/tag/page/get?limit=40&tag=${type}`;
const result = await get({ url, noCache });
const list = result.data.data;
return {
fromCache: result.fromCache,
updateTime: result.updateTime,
data: list.map((v: RouterType["sspai"]) => ({
id: v.id,
title: v.title,
desc: v.summary,
cover: v.banner,
author: v.author.nickname,
hot: v.like_count,
url: `https://sspai.com/post/${v.id}`,
mobileUrl: `https://sspai.com/post/${v.id}`,
})),
};
};

53
src/routes/starrail.ts Normal file
View File

@@ -0,0 +1,53 @@
import type { RouterData, ListContext, Options } from "../types.js";
import type { RouterType } from "../router.types.js";
import { get } from "../utils/getData.js";
export const handleRoute = async (c: ListContext, noCache: boolean) => {
const type = c.req.query("type") || "1";
const { fromCache, data, updateTime } = await getList({ type }, noCache);
const routeData: RouterData = {
name: "starrail",
title: "崩坏:星穹铁道",
type: "最新动态",
parameData: {
type: {
name: "榜单分类",
type: {
1: "公告",
2: "活动",
3: "资讯",
},
},
},
link: "https://www.miyoushe.com/sr/home/53",
total: data?.length || 0,
updateTime,
fromCache,
data,
};
return routeData;
};
const getList = async (options: Options, noCache: boolean) => {
const { type } = options;
const url = `https://bbs-api.miyoushe.com/post/wapi/getNewsList?gids=6&page_size=20&type=${type}`;
const result = await get({ url, noCache });
const list = result.data.data.list;
return {
fromCache: result.fromCache,
updateTime: result.updateTime,
data: list.map((v: RouterType["miyoushe"]) => {
const data = v.post;
return {
id: data.post_id,
title: data.subject,
desc: data.content,
cover: data.cover,
author: v.user.nickname,
hot: v.stat.view_num,
url: `https://www.miyoushe.com/sr/article/${data.post_id}`,
mobileUrl: `https://m.miyoushe.com/sr/#/article/${data.post_id}`,
};
}),
};
};

36
src/routes/thepaper.ts Normal file
View File

@@ -0,0 +1,36 @@
import type { RouterData } from "../types.js";
import type { RouterType } from "../router.types.js";
import { get } from "../utils/getData.js";
export const handleRoute = async (_: undefined, noCache: boolean) => {
const { fromCache, data, updateTime } = await getList(noCache);
const routeData: RouterData = {
name: "thepaper",
title: "澎湃新闻",
type: "热榜",
link: "https://www.thepaper.cn/",
total: data?.length || 0,
updateTime,
fromCache,
data,
};
return routeData;
};
const getList = async (noCache: boolean) => {
const url = `https://cache.thepaper.cn/contentapi/wwwIndex/rightSidebar`;
const result = await get({ url, noCache });
const list = result.data.data.hotNews;
return {
fromCache: result.fromCache,
updateTime: result.updateTime,
data: list.map((v: RouterType["thepaper"]) => ({
id: v.contId,
title: v.name,
cover: v.pic,
hot: Number(v.praiseTimes),
url: `https://www.thepaper.cn/newsDetail_forward_${v.contId}`,
mobileUrl: `https://m.thepaper.cn/newsDetail_forward_${v.contId}`,
})),
};
};

38
src/routes/tieba.ts Normal file
View File

@@ -0,0 +1,38 @@
import type { RouterData } from "../types.js";
import type { RouterType } from "../router.types.js";
import { get } from "../utils/getData.js";
export const handleRoute = async (_: undefined, noCache: boolean) => {
const { fromCache, data, updateTime } = await getList(noCache);
const routeData: RouterData = {
name: "tieba",
title: "百度贴吧",
type: "热议榜",
description: "全球领先的中文社区",
link: "https://tieba.baidu.com/hottopic/browse/topicList",
total: data?.length || 0,
updateTime,
fromCache,
data,
};
return routeData;
};
const getList = async (noCache: boolean) => {
const url = `https://tieba.baidu.com/hottopic/browse/topicList`;
const result = await get({ url, noCache });
const list = result.data.data.bang_topic.topic_list;
return {
fromCache: result.fromCache,
updateTime: result.updateTime,
data: list.map((v: RouterType["tieba"]) => ({
id: v.topic_id,
title: v.topic_name,
desc: v.topic_desc,
cover: v.topic_pic,
hot: v.discuss_num,
url: v.topic_url,
mobileUrl: v.topic_url,
})),
};
};

36
src/routes/toutiao.ts Normal file
View File

@@ -0,0 +1,36 @@
import type { RouterData } from "../types.js";
import type { RouterType } from "../router.types.js";
import { get } from "../utils/getData.js";
export const handleRoute = async (_: undefined, noCache: boolean) => {
const { fromCache, data, updateTime } = await getList(noCache);
const routeData: RouterData = {
name: "toutiao",
title: "今日头条",
type: "热榜",
link: "https://www.toutiao.com/",
total: data?.length || 0,
updateTime,
fromCache,
data,
};
return routeData;
};
const getList = async (noCache: boolean) => {
const url = `https://www.toutiao.com/hot-event/hot-board/?origin=toutiao_pc`;
const result = await get({ url, noCache });
const list = result.data.data;
return {
fromCache: result.fromCache,
updateTime: result.updateTime,
data: list.map((v: RouterType["toutiao"]) => ({
id: v.ClusterIdStr,
title: v.Title,
cover: v.Image.url,
hot: Number(v.HotValue),
url: `https://www.toutiao.com/trending/${v.ClusterIdStr}/`,
mobileUrl: `https://api.toutiaoapi.com/feoffline/amos_land/new/html/main/index.html?topic_id=${v.ClusterIdStr}`,
})),
};
};

43
src/routes/weibo.ts Normal file
View File

@@ -0,0 +1,43 @@
import type { RouterData } from "../types.js";
import type { RouterType } from "../router.types.js";
import { get } from "../utils/getData.js";
export const handleRoute = async (_: undefined, noCache: boolean) => {
const { fromCache, data, updateTime } = await getList(noCache);
const routeData: RouterData = {
name: "weibo",
title: "微博",
type: "热搜榜",
description: "实时热点,每分钟更新一次",
link: "https://s.weibo.com/top/summary/",
total: data?.length || 0,
updateTime,
fromCache,
data,
};
return routeData;
};
const getList = async (noCache: boolean) => {
const url = `https://weibo.com/ajax/side/hotSearch`;
const result = await get({ url, noCache, ttl: 60 });
const list = result.data.data.realtime;
return {
fromCache: result.fromCache,
updateTime: result.updateTime,
data: list.map((v: RouterType["weibo"]) => {
const key = v.word_scheme ? v.word_scheme : `#${v.word}`;
return {
id: v.mid,
title: v.word,
desc: v.note || key,
author: v.category,
hot: v.raw_hot,
url: `https://s.weibo.com/weibo?q=${encodeURIComponent(key)}&t=31&band_rank=1&Refer=top`,
mobileUrl: `https://s.weibo.com/weibo?q=${encodeURIComponent(
key,
)}&t=31&band_rank=1&Refer=top`,
};
}),
};
};

49
src/routes/weread.ts Normal file
View File

@@ -0,0 +1,49 @@
import type { RouterData } from "../types.js";
import type { RouterType } from "../router.types.js";
import { get } from "../utils/getData.js";
import getWereadID from "../utils/getWereadID.js";
export const handleRoute = async (_: undefined, noCache: boolean) => {
const { fromCache, data, updateTime } = await getList(noCache);
const routeData: RouterData = {
name: "weread",
title: "微信读书",
type: "飙升榜",
link: "https://weread.qq.com/",
total: data?.length || 0,
updateTime,
fromCache,
data,
};
return routeData;
};
const getList = async (noCache: boolean) => {
const url = `https://weread.qq.com/web/bookListInCategory/rising?rank=1`;
const result = await get({
url,
noCache,
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36 Edg/114.0.1823.67",
},
});
const list = result.data.books;
return {
fromCache: result.fromCache,
updateTime: result.updateTime,
data: list.map((v: RouterType["weread"]) => {
const data = v.bookInfo;
return {
id: data.bookId,
title: data.title,
author: data.author,
desc: data.intro,
cover: data.cover.replace("s_", "t9_"),
hot: v.readingCount,
url: `https://weread.qq.com/web/bookDetail/${getWereadID(data.bookId)}`,
mobileUrl: `https://weread.qq.com/web/bookDetail/${getWereadID(data.bookId)}`,
};
}),
};
};

44
src/routes/zhihu-daily.ts Normal file
View File

@@ -0,0 +1,44 @@
import type { RouterData } from "../types.js";
import type { RouterType } from "../router.types.js";
import { get } from "../utils/getData.js";
export const handleRoute = async (_: undefined, noCache: boolean) => {
const { fromCache, data, updateTime } = await getList(noCache);
const routeData: RouterData = {
name: "zhihu-daily",
title: "知乎日报",
type: "推荐榜",
description: "每天三次,每次七分钟",
link: "https://daily.zhihu.com/",
total: data?.length || 0,
updateTime,
fromCache,
data,
};
return routeData;
};
const getList = async (noCache: boolean) => {
const url = `https://news-at.zhihu.com/api/4/news/latest`;
const result = await get({
url,
noCache,
headers: {
Referer: "https://news-at.zhihu.com/api/4/news/latest",
Host: "news-at.zhihu.com",
},
});
const list = result.data.stories.filter((el: RouterType["zhihu-daily"]) => el.type === 0);
return {
fromCache: result.fromCache,
updateTime: result.updateTime,
data: list.map((v: RouterType["zhihu-daily"]) => ({
id: v.id,
title: v.title,
cover: v.images[0],
author: v.hint,
url: v.url,
mobileUrl: v.url,
})),
};
};

40
src/routes/zhihu.ts Normal file
View File

@@ -0,0 +1,40 @@
import type { RouterData } from "../types.js";
import type { RouterType } from "../router.types.js";
import { get } from "../utils/getData.js";
export const handleRoute = async (_: undefined, noCache: boolean) => {
const { fromCache, data, updateTime } = await getList(noCache);
const routeData: RouterData = {
name: "zhihu",
title: "知乎",
type: "热榜",
link: "https://www.zhihu.com/hot",
total: data?.length || 0,
updateTime,
fromCache,
data,
};
return routeData;
};
const getList = async (noCache: boolean) => {
const url = `https://www.zhihu.com/api/v3/feed/topstory/hot-lists/total?limit=50&desktop=true`;
const result = await get({ url, noCache });
const list = result.data.data;
return {
fromCache: result.fromCache,
updateTime: result.updateTime,
data: list.map((v: RouterType["zhihu"]) => {
const data = v.target;
return {
id: data.id,
title: data.title,
desc: data.excerpt,
cover: v.children[0].thumbnail,
hot: parseInt(v.detail_text.replace(/[^\d]/g, "")) * 10000,
url: `https://www.zhihu.com/question/${data.id}`,
mobileUrl: `https://www.zhihu.com/question/${data.id}`,
};
}),
};
};

56
src/types.ts Normal file
View File

@@ -0,0 +1,56 @@
import type { Context } from "hono";
// Context
export type ListContext = Context;
// 榜单数据
export type ListItem = {
id: number | string;
title: string;
cover?: string;
author?: string;
desc?: string;
hot?: number;
url: string | undefined;
mobileUrl: string | undefined;
};
// 路由数据
export type RouterData = {
name: string;
title: string;
type: string;
description?: string;
parameData?: Record<string, string | object>;
total: number;
link?: string;
updateTime: string;
fromCache: boolean;
data: ListItem[];
};
// 请求类型
export type Get = {
url: string;
headers?: Record<string, string | string[]>;
params?: Record<string, string | number>;
timeout?: number;
noCache?: boolean;
ttl?: number;
originaInfo?: boolean;
};
export type Post = {
url: string;
headers?: Record<string, string | string[]>;
body?: string | object | Buffer | undefined;
timeout?: number;
noCache?: boolean;
ttl?: number;
originaInfo?: boolean;
};
// 参数类型
export type Options = {
[key: string]: string | undefined;
};

37
src/utils/cache.ts Normal file
View File

@@ -0,0 +1,37 @@
import { config } from "../config.js";
import NodeCache from "node-cache";
import logger from "./logger.js";
// init
const cache = new NodeCache({
// 缓存过期时间( 秒
stdTTL: config.CACHE_TTL,
// 定期检查过期缓存( 秒
checkperiod: 600,
// 克隆变量
useClones: false,
// 最大键值对
maxKeys: 100,
});
interface GetCache<T> {
updateTime: string;
data: T;
}
// 从缓存中获取数据
export const getCache = <T>(key: string): GetCache<T> | undefined => {
return cache.get(key);
};
// 将数据写入缓存
export const setCache = <T>(key: string, value: T, ttl: number = config.CACHE_TTL) => {
const success = cache.set(key, value, ttl);
if (logger) logger.info("数据缓存成功", { url: key });
return success;
};
// 从缓存中删除数据
export const delCache = (key: string) => {
return cache.del(key);
};

100
src/utils/getData.ts Normal file
View File

@@ -0,0 +1,100 @@
import type { Get, Post } from "../types.ts";
import { config } from "../config.js";
import { getCache, setCache, delCache } from "./cache.js";
import logger from "./logger.js";
import axios from "axios";
// 基础配置
const request = axios.create({
// 请求超时设置
timeout: config.REQUEST_TIMEOUT,
withCredentials: true,
});
// 请求拦截
request.interceptors.request.use(
(request) => {
if (!request.params) request.params = {};
// 发送请求
return request;
},
(error) => {
logger.error("请求失败,请稍后重试");
return Promise.reject(error);
},
);
// 响应拦截
request.interceptors.response.use(
(response) => {
return response;
},
(error) => {
// 继续传递错误
return Promise.reject(error);
},
);
// GET
export const get = async (options: Get) => {
const { url, headers, noCache, ttl = config.CACHE_TTL, originaInfo = false } = options;
logger.info("发起 GET 请求", options);
try {
// 检查缓存
if (noCache) delCache(url);
else {
const cachedData = getCache(url);
if (cachedData) {
logger.info("采用缓存", { url });
return { fromCache: true, data: cachedData.data, updateTime: cachedData.updateTime };
}
}
// 缓存不存在时请求接口
logger.info("请求接口", { url });
const response = await request.get(url, { headers });
const responseData = response?.data || response;
// 存储新获取的数据到缓存
const updateTime = new Date().toISOString();
const data = originaInfo ? response : responseData;
setCache(url, { data, updateTime }, ttl);
// 返回数据
logger.info("接口调用成功", { status: response?.statusText });
return { fromCache: false, data, updateTime };
} catch (error) {
logger.error("GET 请求出错", error);
throw error;
}
};
// POST
export const post = async (options: Post) => {
const { url, headers, body, noCache, ttl = config.CACHE_TTL, originaInfo = false } = options;
logger.info("发起 POST 请求", options);
try {
// 检查缓存
if (noCache) delCache(url);
else {
const cachedData = getCache(url);
if (cachedData) {
logger.info("采用缓存", { url });
return { fromCache: true, data: cachedData.data, updateTime: cachedData.updateTime };
}
}
// 缓存不存在时请求接口
logger.info("请求接口", { url });
const response = await request.post(url, body, { headers });
const responseData = response?.data || response;
// 存储新获取的数据到缓存
const updateTime = new Date().toISOString();
const data = originaInfo ? response : responseData;
if (!noCache) {
setCache(url, { data, updateTime }, ttl);
}
// 返回数据
logger.info("接口调用成功", { status: response?.statusText });
return { fromCache: false, data, updateTime };
} catch (error) {
logger.error("POST 请求出错", error);
throw error;
}
};

42
src/utils/getRSS.ts Normal file
View File

@@ -0,0 +1,42 @@
import type { RouterData, ListItem } from "../types.ts";
import { Feed } from "feed";
import logger from "./logger.js";
// 生成 RSS
const getRSS = (data: RouterData) => {
try {
// 基本信息
const feed = new Feed({
title: data.title,
description: data.title + data.type + (data?.description ? " - " + data?.description : ""),
id: data.name,
link: data.link,
language: "zh",
generator: "DailyHotApi",
copyright: "Copyright © 2020-present imsyy",
updated: new Date(data.updateTime),
});
// 获取数据
const listData = data.data;
listData.forEach((item: ListItem) => {
feed.addItem({
id: item.id?.toString(),
title: item.title,
date: new Date(data.updateTime),
link: item.url || "获取失败",
description: item?.desc,
author: [
{
name: item.author,
},
],
});
});
const rssData = feed.rss2();
return rssData;
} catch (error) {
logger.error("RSS 生成失败:", error);
}
};
export default getRSS;

67
src/utils/getWereadID.ts Normal file
View File

@@ -0,0 +1,67 @@
import crypto from "crypto";
/**
* 获取微信读书的书籍 ID
* 感谢 @MCBBC 及 ChatGPT
*/
const getWereadID = (bookId: string) => {
try {
// 使用 MD5 哈希算法创建哈希对象
const hash = crypto.createHash("md5");
hash.update(bookId);
const str = hash.digest("hex");
// 取哈希结果的前三个字符作为初始值
let strSub = str.substring(0, 3);
// 判断书籍 ID 的类型并进行转换
let fa;
if (/^\d*$/.test(bookId)) {
// 如果书籍 ID 只包含数字,则将其拆分成长度为 9 的子字符串,并转换为十六进制表示
const chunks = [];
for (let i = 0; i < bookId.length; i += 9) {
const chunk = bookId.substring(i, i + 9);
chunks.push(parseInt(chunk).toString(16));
}
fa = ["3", chunks];
} else {
// 如果书籍 ID 包含其他字符,则将每个字符的 Unicode 编码转换为十六进制表示
let hexStr = "";
for (let i = 0; i < bookId.length; i++) {
hexStr += bookId.charCodeAt(i).toString(16);
}
fa = ["4", [hexStr]];
}
// 将类型添加到初始值中
strSub += fa[0];
// 将数字 2 和哈希结果的后两个字符添加到初始值中
strSub += "2" + str.substring(str.length - 2);
// 处理转换后的子字符串数组
for (let i = 0; i < fa[1].length; i++) {
const sub = fa[1][i];
const subLength = sub.length.toString(16);
// 如果长度只有一位数,则在前面添加 0
const subLengthPadded = subLength.length === 1 ? "0" + subLength : subLength;
// 将长度和子字符串添加到初始值中
strSub += subLengthPadded + sub;
// 如果不是最后一个子字符串,则添加分隔符 'g'
if (i < fa[1].length - 1) {
strSub += "g";
}
}
// 如果初始值长度不足 20从哈希结果中取足够的字符补齐
if (strSub.length < 20) {
strSub += str.substring(0, 20 - strSub.length);
}
// 使用 MD5 哈希算法创建新的哈希对象
const finalHash = crypto.createHash("md5");
finalHash.update(strSub);
const finalStr = finalHash.digest("hex");
// 取最终哈希结果的前三个字符并添加到初始值的末尾
strSub += finalStr.substring(0, 3);
return strSub;
} catch (error) {
console.error("处理微信读书 ID 时出现错误:" + error);
return null;
}
};
export default getWereadID;

49
src/utils/logger.ts Normal file
View File

@@ -0,0 +1,49 @@
import { config } from "../config.js";
import { createLogger, format, transports } from "winston";
import path from "path";
let pathOption: (typeof transports.File)[] = [];
// 日志输出目录
if (config.USE_LOG_FILE) {
pathOption = [
new transports.File({
filename: path.resolve("logs/error.log"),
level: "error",
maxsize: 1024 * 1024,
maxFiles: 1,
}),
new transports.File({
filename: path.resolve("logs/logger.log"),
maxsize: 1024 * 1024,
maxFiles: 1,
}),
];
}
// logger
const logger = createLogger({
// 最低的日志级别
level: "info",
// 定义日志的格式
format: format.combine(
format.timestamp({
format: "YYYY-MM-DD HH:mm:ss",
}),
format.errors({ stack: true }),
format.splat(),
format.json(),
),
transports: pathOption,
});
// 控制台输出
if (process.env.NODE_ENV !== "production") {
logger.add(
new transports.Console({
format: format.combine(format.colorize(), format.simple()),
}),
);
}
export default logger;

60
src/views/Error.tsx Normal file
View File

@@ -0,0 +1,60 @@
import type { FC } from "hono/jsx";
import { html } from "hono/html";
import Layout from "./Layout.js";
const Error: FC = (props) => {
return (
<Layout title="Error | DailyHot API">
<main className="error">
<div className="img">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 36 36">
<path
fill="currentColor"
d="M30 13.5a7.49 7.49 0 0 1-6.78-4.3H4V7h18.57a7.52 7.52 0 0 1-.07-1a7.52 7.52 0 0 1 .07-1H4a2 2 0 0 0-2 2v22a2 2 0 0 0 2 2h28a2 2 0 0 0 2-2V12.34a7.46 7.46 0 0 1-4 1.16m-13.2 6.33l-10 4.59v-2.64l6.51-3l-6.51-3v-2.61l10 4.59Zm6.6 5.57H17V23h6.4Z"
class="clr-i-solid--badged clr-i-solid-path-1--badged"
/>
<circle
cx="30"
cy="6"
r="5"
fill="currentColor"
class="clr-i-solid--badged clr-i-solid-path-2--badged clr-i-badge"
/>
<path fill="none" d="M0 0h36v36H0z" />
</svg>
</div>
<div className="title">
<h1 className="title-text">Looks like something went wrong</h1>
<span className="title-tip"></span>
{props?.error ? <p className="content">{props.error}</p> : null}
</div>
<div class="control">
<button id="reload-button">
<svg
className="btn-icon"
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M17.65 6.35a7.95 7.95 0 0 0-6.48-2.31c-3.67.37-6.69 3.35-7.1 7.02C3.52 15.91 7.27 20 12 20a7.98 7.98 0 0 0 7.21-4.56c.32-.67-.16-1.44-.9-1.44c-.37 0-.72.2-.88.53a5.994 5.994 0 0 1-6.8 3.31c-2.22-.49-4.01-2.3-4.48-4.52A6.002 6.002 0 0 1 12 6c1.66 0 3.14.69 4.22 1.78l-1.51 1.51c-.63.63-.19 1.71.7 1.71H19c.55 0 1-.45 1-1V6.41c0-.89-1.08-1.34-1.71-.71z"
/>
</svg>
<span className="btn-text"></span>
</button>
</div>
</main>
{html`
<script>
document.getElementById("reload-button").addEventListener("click", () => {
window.location.reload();
});
</script>
`}
</Layout>
);
};
export default Error;

60
src/views/Home.tsx Normal file
View File

@@ -0,0 +1,60 @@
import type { FC } from "hono/jsx";
import { html } from "hono/html";
import Layout from "./Layout.js";
const Home: FC = () => {
return (
<Layout title="DailyHot API">
<main className="home">
<div className="img">
<img src="/ico/favicon.png" alt="logo" />
</div>
<div className="title">
<h1 className="title-text">DailyHot API</h1>
<span className="title-tip"></span>
</div>
<div class="control">
<button id="test-button">
<svg
className="btn-icon"
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M7.71 6.71a.996.996 0 0 0-1.41 0L1.71 11.3a.996.996 0 0 0 0 1.41L6.3 17.3a.996.996 0 1 0 1.41-1.41L3.83 12l3.88-3.88c.38-.39.38-1.03 0-1.41m8.58 0a.996.996 0 0 0 0 1.41L20.17 12l-3.88 3.88a.996.996 0 1 0 1.41 1.41l4.59-4.59a.996.996 0 0 0 0-1.41L17.7 6.7c-.38-.38-1.02-.38-1.41.01M8 13c.55 0 1-.45 1-1s-.45-1-1-1s-1 .45-1 1s.45 1 1 1m4 0c.55 0 1-.45 1-1s-.45-1-1-1s-1 .45-1 1s.45 1 1 1m4-2c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1"
/>
</svg>
<span className="btn-text"></span>
</button>
<button id="docs-button">
<svg
className="btn-icon"
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M3 6c-.55 0-1 .45-1 1v13c0 1.1.9 2 2 2h13c.55 0 1-.45 1-1s-.45-1-1-1H5c-.55 0-1-.45-1-1V7c0-.55-.45-1-1-1m17-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2m-2 9h-8c-.55 0-1-.45-1-1s.45-1 1-1h8c.55 0 1 .45 1 1s-.45 1-1 1m-4 4h-4c-.55 0-1-.45-1-1s.45-1 1-1h4c.55 0 1 .45 1 1s-.45 1-1 1m4-8h-8c-.55 0-1-.45-1-1s.45-1 1-1h8c.55 0 1 .45 1 1s-.45 1-1 1"
/>
</svg>
<span className="btn-text"></span>
</button>
</div>
</main>
{html`
<script>
document.getElementById("test-button").addEventListener("click", () => {
window.location.href = "/all";
});
</script>
`}
</Layout>
);
};
export default Home;

243
src/views/Layout.tsx Normal file
View File

@@ -0,0 +1,243 @@
import type { FC } from "hono/jsx";
import { css, Style } from "hono/css";
type LayoutProps = {
title: string;
children: JSX.Element | JSX.Element[];
};
const Layout: FC<LayoutProps> = (props) => {
const globalClass = css`
:-hono-global {
* {
margin: 0;
padding: 0;
user-select: none;
box-sizing: border-box;
-webkit-user-drag: none;
}
:root {
--text-color: #000;
--text-color-gray: #cbcbcb;
--text-color-hover: #fff;
--icon-color: #444;
}
@media (prefers-color-scheme: dark) {
:root {
--text-color: #fff;
--text-color-gray: #cbcbcb;
--text-color-hover: #3c3c3c;
--icon-color: #cbcbcb;
}
}
a {
text-decoration: none;
color: var(--text-color);
}
body {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
color: var(--text-color);
background-color: var(--text-color-hover);
font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei";
transition:
color 0.3s,
background-color 0.3s;
}
main {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
margin: 20px;
height: 100%;
}
.img {
width: 120px;
height: 120px;
margin-bottom: 20px;
}
.img img,
.img svg {
width: 100%;
height: 100%;
}
.title {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 40px;
}
.title .title-text {
font-size: 28px;
font-weight: bold;
margin-bottom: 12px;
}
.title .title-tip {
font-size: 20px;
opacity: 0.8;
}
.title .content {
margin-top: 30px;
display: flex;
padding: 20px;
border-radius: 12px;
border: 1px dashed var(--text-color);
}
.control {
display: flex;
flex-direction: row;
align-items: center;
}
.control button {
display: flex;
flex-direction: row;
align-items: center;
color: var(--text-color);
border: var(--text-color) solid;
background-color: var(--text-color-hover);
border-radius: 8px;
padding: 8px 12px;
margin: 0 8px;
transition:
color 0.3s,
background-color 0.3s;
cursor: pointer;
}
.control button .btn-icon {
width: 22px;
height: 22px;
margin-right: 8px;
}
.control button .btn-text {
font-size: 14px;
}
.control button:hover {
border: var(--text-color) solid;
background: var(--text-color);
color: var(--text-color-hover);
}
.control button i {
margin-right: 6px;
}
footer {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
line-height: 30px;
padding: 20px;
}
.social {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 8px;
}
.social .link {
display: flex;
flex-direction: row;
align-items: center;
margin: 0 4px;
}
.social .link::after {
content: "";
width: 4px;
height: 4px;
border-radius: 50%;
background-color: var(--text-color);
opacity: 0.4;
margin-left: 8px;
}
.social .link:last-child::after {
display: none;
}
.social .link svg {
width: 22px;
height: 22px;
}
footer .power,
footer .icp {
font-size: 14px;
}
footer a {
color: var(--text-color-gray);
transition: color 0.3s;
}
footer a:hover {
color: var(--text-color);
}
}
`;
return (
<html lang="zh-CN">
<head>
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta charset="utf-8" />
<title>{props.title}</title>
<link rel="icon" href="/favicon.ico" />
<meta name="description" content="今日热榜 API一个聚合热门数据的 API 接口" />
<Style>{globalClass}</Style>
</head>
<body>
{props.children}
<footer>
<div class="social">
<a href="https://github.com/imsyy/DailyHotApi" className="link" target="_blank">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M12 2A10 10 0 0 0 2 12c0 4.42 2.87 8.17 6.84 9.5c.5.08.66-.23.66-.5v-1.69c-2.77.6-3.36-1.34-3.36-1.34c-.46-1.16-1.11-1.47-1.11-1.47c-.91-.62.07-.6.07-.6c1 .07 1.53 1.03 1.53 1.03c.87 1.52 2.34 1.07 2.91.83c.09-.65.35-1.09.63-1.34c-2.22-.25-4.55-1.11-4.55-4.92c0-1.11.38-2 1.03-2.71c-.1-.25-.45-1.29.1-2.64c0 0 .84-.27 2.75 1.02c.79-.22 1.65-.33 2.5-.33c.85 0 1.71.11 2.5.33c1.91-1.29 2.75-1.02 2.75-1.02c.55 1.35.2 2.39.1 2.64c.65.71 1.03 1.6 1.03 2.71c0 3.82-2.34 4.66-4.57 4.91c.36.31.69.92.69 1.85V21c0 .27.16.59.67.5C19.14 20.16 22 16.42 22 12A10 10 0 0 0 12 2"
/>
</svg>
</a>
<a href="https://www.imsyy.top" className="link" target="_blank">
<svg
className="btn-icon"
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M10 19v-5h4v5c0 .55.45 1 1 1h3c.55 0 1-.45 1-1v-7h1.7c.46 0 .68-.57.33-.87L12.67 3.6c-.38-.34-.96-.34-1.34 0l-8.36 7.53c-.34.3-.13.87.33.87H5v7c0 .55.45 1 1 1h3c.55 0 1-.45 1-1"
/>
</svg>
</a>
<a href="mailto:one@imsyy.top" className="link">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24">
<path
fill="currentColor"
d="m20 8l-8 5l-8-5V6l8 5l8-5m0-2H4c-1.11 0-2 .89-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2"
/>
</svg>
</a>
</div>
<div class="power">
Copyright&nbsp;©&nbsp;
<a href="https://www.imsyy.top/" target="_blank">
</a>
&nbsp;|&nbsp;Power by&nbsp;
<a href="https://github.com/honojs/hono/" target="_blank">
Hono
</a>
</div>
<div class="icp">
<a href="https://beian.miit.gov.cn/" target="_blank">
ICP备2022018134号-1
</a>
</div>
</footer>
</body>
</html>
);
};
export default Layout;

44
src/views/NotFound.tsx Normal file
View File

@@ -0,0 +1,44 @@
import type { FC } from "hono/jsx";
import { html } from "hono/html";
import Layout from "./Layout.js";
const NotFound: FC = () => {
return (
<Layout title="404 Not Found | DailyHot API">
<main className="not-found">
<div className="img">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M12 17q.425 0 .713-.288Q13 16.425 13 16t-.287-.713Q12.425 15 12 15t-.712.287Q11 15.575 11 16t.288.712Q11.575 17 12 17Zm0 5q-2.075 0-3.9-.788q-1.825-.787-3.175-2.137q-1.35-1.35-2.137-3.175Q2 14.075 2 12t.788-3.9q.787-1.825 2.137-3.175q1.35-1.35 3.175-2.138Q9.925 2 12 2t3.9.787q1.825.788 3.175 2.138q1.35 1.35 2.137 3.175Q22 9.925 22 12t-.788 3.9q-.787 1.825-2.137 3.175q-1.35 1.35-3.175 2.137Q14.075 22 12 22Zm0-9q.425 0 .713-.288Q13 12.425 13 12V8q0-.425-.287-.713Q12.425 7 12 7t-.712.287Q11 7.575 11 8v4q0 .425.288.712q.287.288.712.288Z"
/>
</svg>
</div>
<div className="title">
<h1 className="title-text">404 Not Found</h1>
<span className="title-tip"></span>
</div>
<div class="control">
<button id="home-button">
<svg className="btn-icon" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M10 19v-5h4v5c0 .55.45 1 1 1h3c.55 0 1-.45 1-1v-7h1.7c.46 0 .68-.57.33-.87L12.67 3.6c-.38-.34-.96-.34-1.34 0l-8.36 7.53c-.34.3-.13.87.33.87H5v7c0 .55.45 1 1 1h3c.55 0 1-.45 1-1"
/>
</svg>
<span className="btn-text"></span>
</button>
</div>
</main>
{html`
<script>
document.getElementById("home-button").addEventListener("click", () => {
window.location.href = "/";
});
</script>
`}
</Layout>
);
};
export default NotFound;