feat: root path for API
improve: the GET video snapshots API
This commit is contained in:
parent
1322cc4671
commit
a2b55d0900
@ -3,7 +3,8 @@
|
||||
"workspace": ["./packages/crawler", "./packages/frontend", "./packages/backend", "./packages/core"],
|
||||
"nodeModulesDir": "auto",
|
||||
"tasks": {
|
||||
"crawler": "deno task --filter 'crawler' all"
|
||||
"crawler": "deno task --filter 'crawler' all",
|
||||
"backend": "deno task --filter 'backend' start"
|
||||
},
|
||||
"fmt": {
|
||||
"useTabs": true,
|
||||
|
20
packages/backend/database.ts
Normal file
20
packages/backend/database.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -1,13 +1,17 @@
|
||||
{
|
||||
"name": "@cvsa/backend",
|
||||
"imports": {
|
||||
"hono": "jsr:@hono/hono@^4.7.5"
|
||||
"hono": "jsr:@hono/hono@^4.7.5",
|
||||
"zod": "npm:zod",
|
||||
"yup": "npm:yup"
|
||||
},
|
||||
"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": {
|
||||
"jsx": "precompile",
|
||||
"jsxImportSource": "hono/jsx"
|
||||
}
|
||||
},
|
||||
"exports": "./main.ts"
|
||||
}
|
||||
|
@ -1,9 +1,20 @@
|
||||
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) => {
|
||||
return c.text("Hello Hono!");
|
||||
});
|
||||
app.use('/video/*', dbMiddleware);
|
||||
|
||||
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
31
packages/backend/root.ts
Normal 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
103
packages/backend/singers.ts
Normal 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);
|
||||
};
|
89
packages/backend/snapshots.ts
Normal file
89
packages/backend/snapshots.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
5
packages/backend/utils.ts
Normal file
5
packages/backend/utils.ts
Normal 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
55
packages/core/db/schema.d.ts
vendored
Normal 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;
|
||||
}
|
33
packages/core/db/videoSnapshot.ts
Normal file
33
packages/core/db/videoSnapshot.ts
Normal 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;
|
||||
}
|
Loading…
Reference in New Issue
Block a user