feat: api route (#19209)

* feat: api route

* test: api route
This commit is contained in:
DIYgod
2025-05-27 13:21:27 +08:00
committed by GitHub
parent 964a386b72
commit 8520db3e13
9 changed files with 175 additions and 8 deletions
+8
View File
@@ -114,4 +114,12 @@ describe('template', () => {
expect(response.status).toBe(301);
expect(response.headers.get('location')).toBe('/test/1');
});
it(`api`, async () => {
const response = await app.request('/api/test');
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
code: 0,
});
});
});
+5
View File
@@ -14,6 +14,11 @@ const middleware: MiddlewareHandler = async (ctx, next) => {
const ttl = (cacheModule.status.available && Math.trunc(config.cache.routeExpire / 60)) || 1;
await next();
const apiData = ctx.get('apiData');
if (apiData) {
return ctx.json(apiData);
}
const data: Data = ctx.get('data');
const outputType = ctx.req.query('format') || 'rss';
+68 -1
View File
@@ -1,4 +1,4 @@
import type { Namespace, Route } from '@/types';
import type { APIRoute, Namespace, Route } from '@/types';
import { directoryImport } from 'directory-import';
import { Hono, type Handler } from 'hono';
import path from 'node:path';
@@ -22,6 +22,12 @@ let namespaces: Record<
location: string;
}
>;
apiRoutes: Record<
string,
APIRoute & {
location: string;
}
>;
}
> = {};
@@ -48,6 +54,9 @@ if (Object.keys(modules).length) {
}
| {
namespace: Namespace;
}
| {
apiRoute: APIRoute;
};
const namespace = module.split(/[/\\]/)[1];
if ('namespace' in content) {
@@ -63,6 +72,7 @@ if (Object.keys(modules).length) {
namespaces[namespace] = {
name: namespace,
routes: {},
apiRoutes: {},
};
}
if (Array.isArray(content.route.path)) {
@@ -78,6 +88,27 @@ if (Object.keys(modules).length) {
location: module.split(/[/\\]/).slice(2).join('/'),
};
}
} else if ('apiRoute' in content) {
if (!namespaces[namespace]) {
namespaces[namespace] = {
name: namespace,
routes: {},
apiRoutes: {},
};
}
if (Array.isArray(content.apiRoute.path)) {
for (const path of content.apiRoute.path) {
namespaces[namespace].apiRoutes[path] = {
...content.apiRoute,
location: module.split(/[/\\]/).slice(2).join('/'),
};
}
} else {
namespaces[namespace].apiRoutes[content.apiRoute.path] = {
...content.apiRoute,
location: module.split(/[/\\]/).slice(2).join('/'),
};
}
}
}
}
@@ -143,6 +174,42 @@ for (const namespace in namespaces) {
}
}
for (const namespace in namespaces) {
const subApp = app.basePath(`/api/${namespace}`);
const namespaceData = namespaces[namespace];
if (!namespaceData || !namespaceData.apiRoutes) {
continue;
}
const sortedRoutes = Object.entries(namespaceData.apiRoutes) as [
string,
APIRoute & {
location: string;
module?: () => Promise<{ apiRoute: APIRoute }>;
},
][];
for (const [path, routeData] of sortedRoutes) {
const wrappedHandler: Handler = async (ctx) => {
if (!ctx.get('apiData')) {
if (typeof routeData.handler !== 'function') {
if (process.env.NODE_ENV === 'test') {
const { apiRoute } = await import(`./routes/${namespace}/${routeData.location}`);
routeData.handler = apiRoute.handler;
} else if (routeData.module) {
const { apiRoute } = await routeData.module();
routeData.handler = apiRoute.handler;
}
}
const data = await routeData.handler(ctx);
ctx.set('apiData', data);
}
};
subApp.get(path, wrappedHandler);
}
}
app.get('/', index);
app.get('/healthz', healthz);
app.get('/robots.txt', robotstxt);
+13
View File
@@ -0,0 +1,13 @@
import { APIRoute } from '@/types';
export const apiRoute: APIRoute = {
path: '/',
maintainers: ['DIYgod'],
handler,
};
function handler() {
return {
code: 0,
};
}
+16
View File
@@ -0,0 +1,16 @@
import { APIRoute } from '@/types';
import { checkCookie } from './util';
export const apiRoute: APIRoute = {
path: '/check-cookie',
description: '检查小红书 cookie 是否有效',
maintainers: ['DIYgod'],
handler,
};
async function handler() {
const valid = await checkCookie();
return {
code: valid ? 0 : -1,
};
}
+1 -1
View File
@@ -64,7 +64,7 @@ async function handler(ctx) {
if (cookie && category === 'notes') {
try {
const urlNotePrefix = 'https://www.xiaohongshu.com/explore';
const user = await getUserWithCookie(url, cookie);
const user = await getUserWithCookie(url);
const notes = await renderNotesFulltext(user.notes, urlNotePrefix, displayLivePhoto);
return {
title: `${user.userPageData.basicInfo.nickname} - 笔记 • 小红书 / RED`,
+12 -3
View File
@@ -2,7 +2,7 @@ import { config } from '@/config';
import logger from '@/utils/logger';
import { parseDate } from '@/utils/parse-date';
import puppeteer from '@/utils/puppeteer';
import { ofetch } from 'ofetch';
import ofetch from '@/utils/ofetch';
import { load } from 'cheerio';
import cache from '@/utils/cache';
@@ -235,7 +235,8 @@ async function getFullNote(link, displayLivePhoto) {
return data;
}
async function getUserWithCookie(url: string, cookie: string) {
async function getUserWithCookie(url: string) {
const cookie = config.xiaohongshu.cookie;
const res = await ofetch(url, {
headers: getHeaders(cookie),
});
@@ -267,4 +268,12 @@ function extractInitialState($) {
return script;
}
export { getUser, getBoard, formatText, formatNote, renderNotesFulltext, getFullNote, getUserWithCookie };
async function checkCookie() {
const cookie = config.xiaohongshu.cookie;
const res = await ofetch('https://edith.xiaohongshu.com/api/sns/web/v2/user/me', {
headers: getHeaders(cookie),
});
return res.code === 0 && !!res.data.user_id;
}
export { getUser, getBoard, formatText, formatNote, renderNotesFulltext, getFullNote, getUserWithCookie, checkCookie };
+48 -3
View File
@@ -359,14 +359,12 @@ interface RouteItem {
view?: ViewType;
}
interface Route extends RouteItem {
export interface Route extends RouteItem {
ja?: RouteItem;
zh?: RouteItem;
'zh-TW'?: RouteItem;
}
export type { Route };
// radar
export type RadarItem = {
/**
@@ -412,3 +410,50 @@ export type RadarDomain = {
} & {
[subdomain: string]: RadarItem[];
};
export interface APIRoute {
/**
* The route path, using [Hono routing](https://hono.dev/api/routing) syntax
*/
path: string;
/**
* The GitHub handle of the people responsible for maintaining this route
*/
maintainers: string[];
/**
* The handler function of the route
*/
handler: (ctx: Context) =>
| Promise<{
code: number;
message?: string;
data?: any;
}>
| {
code: number;
message?: string;
data?: any;
};
/**
* The description of the route parameters
*/
parameters?: Record<
string,
{
description: string;
default?: string;
options?: {
value: string;
label: string;
}[];
}
>;
/**
* Hints and additional explanations for users using this route, it will be appended after the route component, supports markdown
*/
description?: string;
}
+4
View File
@@ -66,6 +66,10 @@ for (const namespace in namespaces) {
}
data.module = `() => import('@/routes/${namespace}/${data.location}')`;
}
for (const path in namespaces[namespace].apiRoutes) {
const data = namespaces[namespace].apiRoutes[path];
data.module = `() => import('@/routes/${namespace}/${data.location}')`;
}
}
fs.writeFileSync(path.join(__dirname, '../../assets/build/radar-rules.json'), JSON.stringify(radar, null, 2));