feat: root path for API

improve: the GET video snapshots API
This commit is contained in:
alikia2x (寒寒) 2025-03-31 05:07:22 +08:00
parent 1322cc4671
commit a2b55d0900
Signed by: alikia2x
GPG Key ID: 56209E0CCD8420C6
10 changed files with 361 additions and 9 deletions

View File

@ -3,7 +3,8 @@
"workspace": ["./packages/crawler", "./packages/frontend", "./packages/backend", "./packages/core"], "workspace": ["./packages/crawler", "./packages/frontend", "./packages/backend", "./packages/core"],
"nodeModulesDir": "auto", "nodeModulesDir": "auto",
"tasks": { "tasks": {
"crawler": "deno task --filter 'crawler' all" "crawler": "deno task --filter 'crawler' all",
"backend": "deno task --filter 'backend' start"
}, },
"fmt": { "fmt": {
"useTabs": true, "useTabs": true,

View File

@ -0,0 +1,20 @@
import { type Client, Pool } from "https://deno.land/x/postgres@v0.19.3/mod.ts";
import { postgresConfig } from "@core/db/pgConfig.ts";
import { createMiddleware } from "hono/factory";
const pool = new Pool(postgresConfig, 4);
export const db = pool;
export const dbMiddleware = createMiddleware(async (c, next) => {
const connection = await pool.connect();
c.set("db", connection);
await next();
connection.release();
});
declare module "hono" {
interface ContextVariableMap {
db: Client;
}
}

View File

@ -1,13 +1,17 @@
{ {
"name": "@cvsa/backend", "name": "@cvsa/backend",
"imports": { "imports": {
"hono": "jsr:@hono/hono@^4.7.5" "hono": "jsr:@hono/hono@^4.7.5",
"zod": "npm:zod",
"yup": "npm:yup"
}, },
"tasks": { "tasks": {
"start": "deno run --allow-net main.ts" "dev": "deno serve --env-file=.env --allow-env --allow-net --watch main.ts",
"start": "deno serve --env-file=.env --allow-env --allow-net --host 127.0.0.1 main.ts"
}, },
"compilerOptions": { "compilerOptions": {
"jsx": "precompile", "jsx": "precompile",
"jsxImportSource": "hono/jsx" "jsxImportSource": "hono/jsx"
} },
"exports": "./main.ts"
} }

View File

@ -1,9 +1,20 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { dbMiddleware } from "./database.ts";
import { rootHandler } from "./root.ts";
import { getSnapshotsHanlder } from "./snapshots.ts";
const app = new Hono(); export const app = new Hono();
app.get("/", (c) => { app.use('/video/*', dbMiddleware);
return c.text("Hello Hono!");
});
Deno.serve(app.fetch); app.get("/", ...rootHandler);
app.get('/video/:id/snapshots', ...getSnapshotsHanlder);
const fetch = app.fetch;
export default {
fetch,
} satisfies Deno.ServeDefaultExport;
export const VERSION = "0.2.0";

31
packages/backend/root.ts Normal file
View File

@ -0,0 +1,31 @@
import { getSingerForBirthday, pickSinger, pickSpecialSinger, type Singer } from "./singers.ts";
import { VERSION } from "./main.ts";
import { createHandlers } from "./utils.ts";
export const rootHandler = createHandlers((c) => {
let singer: Singer | Singer[] | null = null;
const shouldShowSpecialSinger = Math.random() < 0.016;
if (getSingerForBirthday().length !== 0){
singer = getSingerForBirthday();
for (const s of singer) {
delete s.birthday;
s.message = `${s.name}生日快乐~`
}
}
else if (shouldShowSpecialSinger) {
singer = pickSpecialSinger();
}
else {
singer = pickSinger();
}
return c.json({
"project": {
"name": "中V档案馆",
"motto": "一起唱吧,心中的歌!"
},
"status": 200,
"version": VERSION,
"time": Date.now(),
"singer": singer
})
})

103
packages/backend/singers.ts Normal file
View File

@ -0,0 +1,103 @@
export const singers = [
{
"name": "洛天依",
"color": "#66CCFF",
"birthday": "0712",
},
{
"name": "言和",
"color": "#00FFCC",
"birthday": "0711",
},
{
"name": "乐正绫",
"color": "#EE0000",
"birthday": "0412",
},
{
"name": "乐正龙牙",
"color": "#006666",
"birthday": "1002",
},
{
"name": "徵羽摩柯",
"color": "#0080FF",
"birthday": "1210",
},
{
"name": "墨清弦",
"color": "#FFFF00",
"birthday": "0520",
},
{
"name": "星尘",
"color": "#9999FF",
"birthday": "0812",
},
{
"name": "心华",
"color": "#EE82EE",
"birthday": "0210",
},
{
"name": "海伊",
"color": "#3399FF",
"birthday": "0722",
},
{
"name": "苍穹",
"color": "#8BC0B5",
"birthday": "0520",
},
{
"name": "赤羽",
"color": "#FF4004",
"birthday": "1126",
},
{
"name": "诗岸",
"color": "#F6BE72",
"birthday": "0119",
},
{
"name": "牧心",
"color": "#2A2859",
"birthday": "0807",
},
];
export interface Singer {
name: string;
color?: string;
birthday?: string;
message?: string;
}
export const specialSingers = [
{
"name": "雅音宫羽",
"message": "你是我最真模样,从来不曾遗忘。",
},
{
"name": "初音未来",
"message": "初始之音,响彻未来!",
},
];
export const pickSinger = () => {
const index = Math.floor(Math.random() * singers.length);
return singers[index];
};
export const pickSpecialSinger = () => {
const index = Math.floor(Math.random() * specialSingers.length);
return specialSingers[index];
};
export const getSingerForBirthday = (): Singer[] => {
const today = new Date();
const month = String(today.getMonth() + 1).padStart(2, "0");
const day = String(today.getDate()).padStart(2, "0");
const datestring = `${month}${day}`;
return singers.filter((singer) => singer.birthday === datestring);
};

View File

@ -0,0 +1,89 @@
import type { Context } from "hono";
import { createHandlers } from "./utils.ts";
import type { BlankEnv, BlankInput } from "hono/types";
import { getVideoSnapshots, getVideoSnapshotsByBV } from "@core/db/videoSnapshot.ts";
import type { VideoSnapshotType } from "@core/db/schema.d.ts";
import { boolean, mixed, number, object, ValidationError } from "yup";
const SnapshotQueryParamsSchema = object({
ps: number().optional().positive(),
pn: number().optional().positive(),
offset: number().optional().positive(),
reverse: boolean().optional(),
});
const idSchema = mixed().test(
"is-valid-id",
'id must be a string starting with "av" followed by digits, or "BV" followed by 10 alphanumeric characters, or a positive integer',
(value) => {
if (typeof value === "number") {
return Number.isInteger(value) && value > 0;
}
if (typeof value === "string") {
if (value.startsWith("av")) {
const digitsOnly = value.substring(2);
return /^\d+$/.test(digitsOnly) && digitsOnly.length > 0;
}
if (value.startsWith("BV")) {
const remainingChars = value.substring(2);
return /^[a-zA-Z0-9]{10}$/.test(remainingChars);
}
}
return false;
},
);
type ContextType = Context<BlankEnv, "/video/:id/snapshots", BlankInput>;
export const getSnapshotsHanlder = createHandlers(async (c: ContextType) => {
const client = c.get("db");
try {
const idParam = await idSchema.validate(c.req.param("id"));
let videoId: number | string = idParam as string | number;
if (typeof videoId === "string" && videoId.startsWith("av")) {
videoId = videoId.slice(2);
}
const queryParams = await SnapshotQueryParamsSchema.validate(c.req.query());
const { ps, pn, offset, reverse = false } = queryParams;
let limit = 1000;
if (ps && ps > 1) {
limit = ps;
}
let pageOrOffset = 1;
let mode: "page" | "offset" = "page";
if (pn && pn > 1) {
pageOrOffset = pn;
mode = "page";
} else if (offset && offset > 1) {
pageOrOffset = offset;
mode = "offset";
}
let result: VideoSnapshotType[];
if (typeof videoId === "number") {
result = await getVideoSnapshots(client, videoId, limit, pageOrOffset, reverse, mode);
} else {
result = await getVideoSnapshotsByBV(client, videoId, limit, pageOrOffset, reverse, mode);
}
const rows = result.map((row) => ({
...row,
aid: Number(row.aid),
}));
return c.json(rows);
} catch (e) {
if (e instanceof ValidationError) {
return c.json({ message: "Invalid query parameters", errors: e.errors }, 400);
} else {
return c.json({ message: "Unhandled error", error: e }, 500);
}
}
});

View File

@ -0,0 +1,5 @@
import { createFactory } from 'hono/factory'
const factory = createFactory();
export const createHandlers = factory.createHandlers;

55
packages/core/db/schema.d.ts vendored Normal file
View File

@ -0,0 +1,55 @@
export interface AllDataType {
id: number;
aid: bigint;
bvid: string | null;
description: string | null;
uid: number | null;
tags: string | null;
title: string | null;
published_at: string | null;
duration: number;
created_at: string | null;
}
export interface BiliUserType {
id: number;
uid: number;
username: string;
desc: string;
fans: number;
}
export interface VideoSnapshotType {
id: number;
created_at: string;
views: number;
coins: number;
likes: number;
favorites: number;
shares: number;
danmakus: number;
aid: bigint;
replies: number;
}
export interface LatestSnapshotType {
aid: bigint;
time: number;
views: number;
danmakus: number;
replies: number;
likes: number;
coins: number;
shares: number;
favorites: number;
}
export interface SnapshotScheduleType {
id: number;
aid: bigint;
type?: string;
created_at: string;
started_at?: string;
finished_at?: string;
status: string;
}

View File

@ -0,0 +1,33 @@
import { Client } from "https://deno.land/x/postgres@v0.19.3/mod.ts";
import { VideoSnapshotType } from "@core/db/schema.d.ts";
export async function getVideoSnapshots(client: Client, aid: number, limit: number, pageOrOffset: number, reverse: boolean, mode: 'page' | 'offset' = 'page') {
const offset = mode === 'page' ? (pageOrOffset - 1) * limit : pageOrOffset;
const order = reverse ? 'ASC' : 'DESC';
const query = `
SELECT *
FROM video_snapshot
WHERE aid = $1
ORDER BY created_at ${order}
LIMIT $2
OFFSET $3
`;
const queryResult = await client.queryObject<VideoSnapshotType>(query, [aid, limit, offset]);
return queryResult.rows;
}
export async function getVideoSnapshotsByBV(client: Client, bv: string, limit: number, pageOrOffset: number, reverse: boolean, mode: 'page' | 'offset' = 'page') {
const offset = mode === 'page' ? (pageOrOffset - 1) * limit : pageOrOffset;
const order = reverse ? 'ASC' : 'DESC';
const query = `
SELECT vs.*
FROM video_snapshot vs
JOIN bilibili_metadata bm ON vs.aid = bm.aid
WHERE bm.bvid = $1
ORDER BY vs.created_at ${order}
LIMIT $2
OFFSET $3
`
const queryResult = await client.queryObject<VideoSnapshotType>(query, [bv, limit, offset]);
return queryResult.rows;
}