diff --git a/lib/middleware/template.test.ts b/lib/middleware/template.test.ts index da61f0ffec..b3ac3c60df 100644 --- a/lib/middleware/template.test.ts +++ b/lib/middleware/template.test.ts @@ -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, + }); + }); }); diff --git a/lib/middleware/template.tsx b/lib/middleware/template.tsx index 6bcf3fd639..a42a815fdd 100644 --- a/lib/middleware/template.tsx +++ b/lib/middleware/template.tsx @@ -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'; diff --git a/lib/registry.ts b/lib/registry.ts index 9e713aac37..ce693ae9ae 100644 --- a/lib/registry.ts +++ b/lib/registry.ts @@ -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); diff --git a/lib/routes/test/api-index.ts b/lib/routes/test/api-index.ts new file mode 100644 index 0000000000..ee0b894797 --- /dev/null +++ b/lib/routes/test/api-index.ts @@ -0,0 +1,13 @@ +import { APIRoute } from '@/types'; + +export const apiRoute: APIRoute = { + path: '/', + maintainers: ['DIYgod'], + handler, +}; + +function handler() { + return { + code: 0, + }; +} diff --git a/lib/routes/xiaohongshu/check-cookie.ts b/lib/routes/xiaohongshu/check-cookie.ts new file mode 100644 index 0000000000..86eecffedb --- /dev/null +++ b/lib/routes/xiaohongshu/check-cookie.ts @@ -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, + }; +} diff --git a/lib/routes/xiaohongshu/user.ts b/lib/routes/xiaohongshu/user.ts index 9a5cb140dc..7e95fc6275 100644 --- a/lib/routes/xiaohongshu/user.ts +++ b/lib/routes/xiaohongshu/user.ts @@ -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`, diff --git a/lib/routes/xiaohongshu/util.ts b/lib/routes/xiaohongshu/util.ts index 68a0a760c0..864a28b9b0 100644 --- a/lib/routes/xiaohongshu/util.ts +++ b/lib/routes/xiaohongshu/util.ts @@ -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 }; diff --git a/lib/types.ts b/lib/types.ts index 5fb66bb4ac..e4a45d3ba7 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -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; +} diff --git a/scripts/workflow/build-routes.ts b/scripts/workflow/build-routes.ts index 9807cfc4c4..ca4d11b6c1 100644 --- a/scripts/workflow/build-routes.ts +++ b/scripts/workflow/build-routes.ts @@ -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));