Compare commits
19 Commits
291a21d82a
...
9af7e52464
Author | SHA1 | Date | |
---|---|---|---|
9af7e52464 | |||
b2edaf8fc4 | |||
da8b2d3b4d | |||
64a7f13da7 | |||
39acac09e7 | |||
7aa988f0fe | |||
6fd34a88e7 | |||
cded4cd825 | |||
1346700c35 | |||
91566fce83 | |||
db5ea97fae | |||
cc202fb3c6 | |||
8b17f8177c | |||
4fe266ce82 | |||
f585b49ee4 | |||
19d7276280 | |||
f7806c6a39 | |||
106049bfc6 | |||
e0776a452e |
@ -2,4 +2,5 @@ data
|
||||
*.json
|
||||
*.svg
|
||||
*.txt
|
||||
*.md
|
||||
*.md
|
||||
*config*
|
19
deno.json
@ -2,7 +2,20 @@
|
||||
"lock": false,
|
||||
"workspace": ["./packages/crawler", "./packages/frontend", "./packages/backend", "./packages/core"],
|
||||
"nodeModulesDir": "auto",
|
||||
"tasks": {
|
||||
"crawler": "deno task --filter 'crawler' all"
|
||||
}
|
||||
"tasks": {
|
||||
"crawler": "deno task --filter 'crawler' all"
|
||||
},
|
||||
"fmt": {
|
||||
"useTabs": true,
|
||||
"lineWidth": 120,
|
||||
"indentWidth": 4,
|
||||
"semiColons": true,
|
||||
"proseWrap": "always"
|
||||
},
|
||||
"imports": {
|
||||
"@astrojs/node": "npm:@astrojs/node@^9.1.3",
|
||||
"@astrojs/svelte": "npm:@astrojs/svelte@^7.0.8",
|
||||
"@core/db/": "./packages/core/db/",
|
||||
"date-fns": "npm:date-fns@^4.1.0"
|
||||
}
|
||||
}
|
||||
|
@ -8,6 +8,7 @@ if (unsetVars.length > 0) {
|
||||
|
||||
const databaseHost = Deno.env.get("DB_HOST")!;
|
||||
const databaseName = Deno.env.get("DB_NAME")!;
|
||||
const databaseNameCred = Deno.env.get("DB_NAME_CRED")!;
|
||||
const databaseUser = Deno.env.get("DB_USER")!;
|
||||
const databasePassword = Deno.env.get("DB_PASSWORD")!;
|
||||
const databasePort = Deno.env.get("DB_PORT")!;
|
||||
@ -19,3 +20,11 @@ export const postgresConfig = {
|
||||
user: databaseUser,
|
||||
password: databasePassword,
|
||||
};
|
||||
|
||||
export const postgresConfigCred = {
|
||||
hostname: databaseHost,
|
||||
port: parseInt(databasePort),
|
||||
database: databaseNameCred,
|
||||
user: databaseUser,
|
||||
password: databasePassword,
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
import { Pool } from "https://deno.land/x/postgres@v0.19.3/mod.ts";
|
||||
import { postgresConfig } from "db/pgConfig.ts";
|
||||
import { postgresConfig } from "@core/db/pgConfig.ts";
|
||||
|
||||
const pool = new Pool(postgresConfig, 12);
|
||||
|
||||
|
@ -28,7 +28,7 @@ export async function refreshSnapshotWindowCounts(client: Client, redisClient: R
|
||||
WHERE started_at >= NOW() AND status = 'pending' AND started_at <= NOW() + INTERVAL '10 days'
|
||||
GROUP BY 1
|
||||
ORDER BY window_start
|
||||
`
|
||||
`;
|
||||
|
||||
await redisClient.del(REDIS_KEY);
|
||||
|
||||
@ -36,7 +36,7 @@ export async function refreshSnapshotWindowCounts(client: Client, redisClient: R
|
||||
|
||||
for (const row of result.rows) {
|
||||
const targetOffset = Math.floor((row.window_start.getTime() - startTime) / (5 * MINUTE));
|
||||
const offset = (currentWindow + targetOffset);
|
||||
const offset = currentWindow + targetOffset;
|
||||
if (offset >= 0) {
|
||||
await redisClient.hset(REDIS_KEY, offset.toString(), Number(row.count));
|
||||
}
|
||||
@ -186,7 +186,13 @@ export async function getSnapshotScheduleCountWithinRange(client: Client, start:
|
||||
* @param aid The aid of the video.
|
||||
* @param targetTime Scheduled time for snapshot. (Timestamp in milliseconds)
|
||||
*/
|
||||
export async function scheduleSnapshot(client: Client, aid: number, type: string, targetTime: number, force: boolean = false) {
|
||||
export async function scheduleSnapshot(
|
||||
client: Client,
|
||||
aid: number,
|
||||
type: string,
|
||||
targetTime: number,
|
||||
force: boolean = false,
|
||||
) {
|
||||
if (await videoHasActiveSchedule(client, aid) && !force) return;
|
||||
let adjustedTime = new Date(targetTime);
|
||||
if (type !== "milestone" && type !== "new") {
|
||||
@ -199,7 +205,13 @@ export async function scheduleSnapshot(client: Client, aid: number, type: string
|
||||
);
|
||||
}
|
||||
|
||||
export async function bulkScheduleSnapshot(client: Client, aids: number[], type: string, targetTime: number, force: boolean = false) {
|
||||
export async function bulkScheduleSnapshot(
|
||||
client: Client,
|
||||
aids: number[],
|
||||
type: string,
|
||||
targetTime: number,
|
||||
force: boolean = false,
|
||||
) {
|
||||
for (const aid of aids) {
|
||||
await scheduleSnapshot(client, aid, type, targetTime, force);
|
||||
}
|
||||
@ -237,12 +249,12 @@ export async function adjustSnapshotTime(
|
||||
|
||||
if (delayedDate.getTime() < now.getTime()) {
|
||||
const elapsed = performance.now() - t;
|
||||
timePerIteration = elapsed / (i+1);
|
||||
timePerIteration = elapsed / (i + 1);
|
||||
logger.log(`${timePerIteration.toFixed(3)}ms * ${iters} iterations`, "perf", "fn:adjustSnapshotTime");
|
||||
return now;
|
||||
}
|
||||
const elapsed = performance.now() - t;
|
||||
timePerIteration = elapsed / (i+1);
|
||||
timePerIteration = elapsed / (i + 1);
|
||||
logger.log(`${timePerIteration.toFixed(3)}ms * ${iters} iterations`, "perf", "fn:adjustSnapshotTime");
|
||||
return delayedDate;
|
||||
}
|
||||
@ -253,7 +265,6 @@ export async function adjustSnapshotTime(
|
||||
return expectedStartTime;
|
||||
}
|
||||
|
||||
|
||||
export async function getSnapshotsInNextSecond(client: Client) {
|
||||
const query = `
|
||||
SELECT *
|
||||
@ -272,7 +283,7 @@ export async function getSnapshotsInNextSecond(client: Client) {
|
||||
}
|
||||
|
||||
export async function getBulkSnapshotsInNextSecond(client: Client) {
|
||||
const query = `
|
||||
const query = `
|
||||
SELECT *
|
||||
FROM snapshot_schedule
|
||||
WHERE started_at <= NOW() + INTERVAL '15 seconds' AND status = 'pending' AND type = 'normal'
|
||||
|
@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@cvsa/crawler",
|
||||
"name": "@cvsa/crawler",
|
||||
"tasks": {
|
||||
"crawl-raw-bili": "deno --allow-env --allow-ffi --allow-read --allow-net --allow-write --allow-run src/db/raw/insertAidsToDB.ts",
|
||||
"crawl-bili-aids": "deno --allow-env --allow-ffi --allow-read --allow-net --allow-write --allow-run src/db/raw/fetchAids.ts",
|
||||
@ -26,11 +26,11 @@
|
||||
"@huggingface/transformers": "npm:@huggingface/transformers@3.0.0",
|
||||
"bullmq": "npm:bullmq",
|
||||
"mq/": "./mq/",
|
||||
"db/": "./db/",
|
||||
"log/": "./log/",
|
||||
"net/": "./net/",
|
||||
"ml/": "./ml/",
|
||||
"utils/": "./utils/",
|
||||
"db/": "./db/",
|
||||
"log/": "./log/",
|
||||
"net/": "./net/",
|
||||
"ml/": "./ml/",
|
||||
"utils/": "./utils/",
|
||||
"ioredis": "npm:ioredis",
|
||||
"@bull-board/api": "npm:@bull-board/api",
|
||||
"@bull-board/express": "npm:@bull-board/express",
|
||||
@ -39,12 +39,5 @@
|
||||
"onnxruntime": "npm:onnxruntime-node@1.19.2",
|
||||
"chalk": "npm:chalk"
|
||||
},
|
||||
"fmt": {
|
||||
"useTabs": true,
|
||||
"lineWidth": 120,
|
||||
"indentWidth": 4,
|
||||
"semiColons": true,
|
||||
"proseWrap": "always"
|
||||
},
|
||||
"exports": "./main.ts"
|
||||
}
|
||||
"exports": "./main.ts"
|
||||
}
|
||||
|
@ -4,4 +4,4 @@
|
||||
// SO HERE'S A PLACHOLDER EXPORT FOR DENO:
|
||||
export const DENO = "FUCK YOU DENO";
|
||||
// Oh, maybe export the version is a good idea
|
||||
export const VERSION = "1.0.13";
|
||||
export const VERSION = "1.0.17";
|
||||
|
@ -7,6 +7,7 @@ import {
|
||||
bulkSetSnapshotStatus,
|
||||
findClosestSnapshot,
|
||||
findSnapshotBefore,
|
||||
getBulkSnapshotsInNextSecond,
|
||||
getLatestSnapshot,
|
||||
getSnapshotsInNextSecond,
|
||||
getVideosWithoutActiveSnapshotSchedule,
|
||||
@ -15,7 +16,6 @@ import {
|
||||
setSnapshotStatus,
|
||||
snapshotScheduleExists,
|
||||
videoHasProcessingSchedule,
|
||||
getBulkSnapshotsInNextSecond
|
||||
} from "db/snapshotSchedule.ts";
|
||||
import { Client } from "https://deno.land/x/postgres@v0.19.3/mod.ts";
|
||||
import { HOUR, MINUTE, SECOND, WEEK } from "$std/datetime/constants.ts";
|
||||
@ -109,7 +109,7 @@ const log = (value: number, base: number = 10) => Math.log(value) / Math.log(bas
|
||||
* @param aid - aid of the video
|
||||
* @returns ETA in hours
|
||||
*/
|
||||
const getAdjustedShortTermETA = async (client: Client, aid: number) => {
|
||||
export const getAdjustedShortTermETA = async (client: Client, aid: number) => {
|
||||
const latestSnapshot = await getLatestSnapshot(client, aid);
|
||||
// Immediately dispatch a snapshot if there is no snapshot yet
|
||||
if (!latestSnapshot) return 0;
|
||||
@ -117,7 +117,7 @@ const getAdjustedShortTermETA = async (client: Client, aid: number) => {
|
||||
if (!snapshotsEnough) return 0;
|
||||
|
||||
const currentTimestamp = new Date().getTime();
|
||||
const timeIntervals = [3 * MINUTE, 20 * MINUTE, 1 * HOUR, 3 * HOUR, 6 * HOUR];
|
||||
const timeIntervals = [3 * MINUTE, 20 * MINUTE, 1 * HOUR, 3 * HOUR, 6 * HOUR, 72 * HOUR];
|
||||
const DELTA = 0.00001;
|
||||
let minETAHours = Infinity;
|
||||
|
||||
@ -282,8 +282,7 @@ export const takeBulkSnapshotForVideosWorker = async (job: Job) => {
|
||||
}
|
||||
logger.error(e as Error, "mq", "fn:takeBulkSnapshotForVideosWorker");
|
||||
await bulkSetSnapshotStatus(client, ids, "failed");
|
||||
}
|
||||
finally {
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
@ -372,8 +371,8 @@ export const scheduleCleanupWorker = async (_job: Job) => {
|
||||
const query = `
|
||||
SELECT id, aid, type
|
||||
FROM snapshot_schedule
|
||||
WHERE status IN ('pending', 'processing')
|
||||
AND started_at < NOW() - INTERVAL '5 minutes'
|
||||
WHERE status IN ('pending', 'processing')
|
||||
AND started_at < NOW() - INTERVAL '30 minutes'
|
||||
`;
|
||||
const { rows } = await client.queryObject<{ id: bigint; aid: bigint; type: string }>(query);
|
||||
if (rows.length === 0) return;
|
||||
|
@ -24,10 +24,11 @@ export async function insertVideoInfo(client: Client, aid: number) {
|
||||
const title = data.View.title;
|
||||
const published_at = formatTimestampToPsql(data.View.pubdate * SECOND + 8 * HOUR);
|
||||
const duration = data.View.duration;
|
||||
const cover = data.View.pic;
|
||||
await client.queryObject(
|
||||
`INSERT INTO bilibili_metadata (aid, bvid, description, uid, tags, title, published_at, duration)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
[aid, bvid, desc, uid, tags, title, published_at, duration],
|
||||
`INSERT INTO bilibili_metadata (aid, bvid, description, uid, tags, title, published_at, duration, cover_url)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
[aid, bvid, desc, uid, tags, title, published_at, duration, cover],
|
||||
);
|
||||
const userExists = await userExistsInBiliUsers(client, aid);
|
||||
if (!userExists) {
|
||||
|
5
packages/crawler/net/bilibili.d.ts
vendored
@ -13,10 +13,9 @@ export type MediaListInfoResponse = BaseResponse<MediaListInfoData>;
|
||||
|
||||
export type MediaListInfoData = MediaListInfoItem[];
|
||||
|
||||
|
||||
export interface MediaListInfoItem {
|
||||
attr: number;
|
||||
bvid: string;
|
||||
bvid: string;
|
||||
id: number;
|
||||
cnt_info: {
|
||||
coin: number;
|
||||
@ -26,7 +25,7 @@ export interface MediaListInfoItem {
|
||||
reply: number;
|
||||
share: number;
|
||||
thumb_up: number;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
interface VideoInfoData {
|
||||
|
@ -13,7 +13,7 @@ import logger from "log/logger.ts";
|
||||
*/
|
||||
export async function bulkGetVideoStats(aids: number[]): Promise<MediaListInfoData | number> {
|
||||
const baseURL = `https://api.bilibili.com/medialist/gateway/base/resource/infos?resources=`;
|
||||
let url = baseURL;
|
||||
let url = baseURL;
|
||||
for (const aid of aids) {
|
||||
url += `${aid}:2,`;
|
||||
}
|
||||
|
@ -6,13 +6,13 @@ import { lockManager } from "mq/lockManager.ts";
|
||||
import { WorkerError } from "mq/schema.ts";
|
||||
import { getVideoInfoWorker } from "mq/exec/getLatestVideos.ts";
|
||||
import {
|
||||
bulkSnapshotTickWorker,
|
||||
collectMilestoneSnapshotsWorker,
|
||||
regularSnapshotsWorker,
|
||||
snapshotTickWorker,
|
||||
takeSnapshotForVideoWorker,
|
||||
scheduleCleanupWorker,
|
||||
snapshotTickWorker,
|
||||
takeBulkSnapshotForVideosWorker,
|
||||
bulkSnapshotTickWorker
|
||||
takeSnapshotForVideoWorker,
|
||||
} from "mq/exec/snapshotTick.ts";
|
||||
|
||||
Deno.addSignalListener("SIGINT", async () => {
|
||||
|
@ -1,48 +0,0 @@
|
||||
# Astro Starter Kit: Basics
|
||||
|
||||
```sh
|
||||
deno create astro@latest -- --template basics
|
||||
```
|
||||
|
||||
[](https://stackblitz.com/github/withastro/astro/tree/latest/examples/basics)
|
||||
[](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/basics)
|
||||
[](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/basics/devcontainer.json)
|
||||
|
||||
> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
|
||||
|
||||

|
||||
|
||||
## 🚀 Project Structure
|
||||
|
||||
Inside of your Astro project, you'll see the following folders and files:
|
||||
|
||||
```text
|
||||
/
|
||||
├── public/
|
||||
│ └── favicon.svg
|
||||
├── src/
|
||||
│ ├── layouts/
|
||||
│ │ └── Layout.astro
|
||||
│ └── pages/
|
||||
│ └── index.astro
|
||||
└── package.json
|
||||
```
|
||||
|
||||
To learn more about the folder structure of an Astro project, refer to [our guide on project structure](https://docs.astro.build/en/basics/project-structure/).
|
||||
|
||||
## 🧞 Commands
|
||||
|
||||
All commands are run from the root of the project, from a terminal:
|
||||
|
||||
| Command | Action |
|
||||
| :------------------------ | :----------------------------------------------- |
|
||||
| `deno install` | Installs dependencies |
|
||||
| `deno dev` | Starts local dev server at `localhost:4321` |
|
||||
| `deno build` | Build your production site to `./dist/` |
|
||||
| `deno preview` | Preview your build locally, before deploying |
|
||||
| `deno astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||
| `deno astro -- --help` | Get help using the Astro CLI |
|
||||
|
||||
## 👀 Want to learn more?
|
||||
|
||||
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
|
@ -1,5 +1,24 @@
|
||||
// @ts-check
|
||||
import { defineConfig } from 'astro/config';
|
||||
import { defineConfig } from "astro/config";
|
||||
import tailwind from "@astrojs/tailwind";
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({});
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
import node from "@astrojs/node";
|
||||
import svelte from "@astrojs/svelte";
|
||||
|
||||
export default defineConfig({
|
||||
output: "server",
|
||||
adapter: node({
|
||||
mode: "standalone",
|
||||
}),
|
||||
integrations: [tailwind(), svelte()],
|
||||
vite: {
|
||||
server: {
|
||||
fs: {
|
||||
allow: [".", "../../"],
|
||||
},
|
||||
},
|
||||
plugins: [tsconfigPaths()],
|
||||
},
|
||||
});
|
||||
|
8
packages/frontend/deno.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "@cvsa/frontend",
|
||||
"tasks": {
|
||||
"preview": "bun run astro preview",
|
||||
"build": "bun run astro build"
|
||||
},
|
||||
"exports": "./main.ts"
|
||||
}
|
1
packages/frontend/main.ts
Normal file
@ -0,0 +1 @@
|
||||
export const VERSION = "1.2.6";
|
@ -1,14 +1,23 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"astro": "^5.5.5"
|
||||
}
|
||||
}
|
||||
"name": "frontend",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/tailwind": "^6.0.2",
|
||||
"astro": "^5.5.5",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"pg": "^8.11.11",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^3.0.24",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/pg": "^8.11.11"
|
||||
}
|
||||
}
|
||||
|
7
packages/frontend/src/assets/TitleBar Mobile Dark.svg
Normal file
After Width: | Height: | Size: 6.1 KiB |
7
packages/frontend/src/assets/TitleBar Mobile Light.svg
Normal file
After Width: | Height: | Size: 6.1 KiB |
15
packages/frontend/src/assets/TitleBar-Dark.svg
Normal file
After Width: | Height: | Size: 22 KiB |
15
packages/frontend/src/assets/TitleBar-Light.svg
Normal file
After Width: | Height: | Size: 22 KiB |
@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" width="115" height="48"><path fill="#17191E" d="M7.77 36.35C6.4 35.11 6 32.51 6.57 30.62c.99 1.2 2.35 1.57 3.75 1.78 2.18.33 4.31.2 6.33-.78.23-.12.44-.27.7-.42.18.55.23 1.1.17 1.67a4.56 4.56 0 0 1-1.94 3.23c-.43.32-.9.61-1.34.91-1.38.94-1.76 2.03-1.24 3.62l.05.17a3.63 3.63 0 0 1-1.6-1.38 3.87 3.87 0 0 1-.63-2.1c0-.37 0-.74-.05-1.1-.13-.9-.55-1.3-1.33-1.32a1.56 1.56 0 0 0-1.63 1.26c0 .06-.03.12-.05.2Z"/><path fill="url(#a)" d="M7.77 36.35C6.4 35.11 6 32.51 6.57 30.62c.99 1.2 2.35 1.57 3.75 1.78 2.18.33 4.31.2 6.33-.78.23-.12.44-.27.7-.42.18.55.23 1.1.17 1.67a4.56 4.56 0 0 1-1.94 3.23c-.43.32-.9.61-1.34.91-1.38.94-1.76 2.03-1.24 3.62l.05.17a3.63 3.63 0 0 1-1.6-1.38 3.87 3.87 0 0 1-.63-2.1c0-.37 0-.74-.05-1.1-.13-.9-.55-1.3-1.33-1.32a1.56 1.56 0 0 0-1.63 1.26c0 .06-.03.12-.05.2Z"/><path fill="#17191E" d="M.02 30.31s4.02-1.95 8.05-1.95l3.04-9.4c.11-.45.44-.76.82-.76.37 0 .7.31.82.76l3.04 9.4c4.77 0 8.05 1.95 8.05 1.95L17 11.71c-.2-.56-.53-.91-.98-.91H7.83c-.44 0-.76.35-.97.9L.02 30.31Zm42.37-5.97c0 1.64-2.05 2.62-4.88 2.62-1.85 0-2.5-.45-2.5-1.41 0-1 .8-1.49 2.65-1.49 1.67 0 3.09.03 4.73.23v.05Zm.03-2.04a21.37 21.37 0 0 0-4.37-.36c-5.32 0-7.82 1.25-7.82 4.18 0 3.04 1.71 4.2 5.68 4.2 3.35 0 5.63-.84 6.46-2.92h.14c-.03.5-.05 1-.05 1.4 0 1.07.18 1.16 1.06 1.16h4.15a16.9 16.9 0 0 1-.36-4c0-1.67.06-2.93.06-4.62 0-3.45-2.07-5.64-8.56-5.64-2.8 0-5.9.48-8.26 1.19.22.93.54 2.83.7 4.06 2.04-.96 4.95-1.37 7.2-1.37 3.11 0 3.97.71 3.97 2.15v.57Zm11.37 3c-.56.07-1.33.07-2.12.07-.83 0-1.6-.03-2.12-.1l-.02.58c0 2.85 1.87 4.52 8.45 4.52 6.2 0 8.2-1.64 8.2-4.55 0-2.74-1.33-4.09-7.2-4.39-4.58-.2-4.99-.7-4.99-1.28 0-.66.59-1 3.65-1 3.18 0 4.03.43 4.03 1.35v.2a46.13 46.13 0 0 1 4.24.03l.02-.55c0-3.36-2.8-4.46-8.2-4.46-6.08 0-8.13 1.49-8.13 4.39 0 2.6 1.64 4.23 7.48 4.48 4.3.14 4.77.62 4.77 1.28 0 .7-.7 1.03-3.71 1.03-3.47 0-4.35-.48-4.35-1.47v-.13Zm19.82-12.05a17.5 17.5 0 0 1-6.24 3.48c.03.84.03 2.4.03 3.24l1.5.02c-.02 1.63-.04 3.6-.04 4.9 0 3.04 1.6 5.32 6.58 5.32 2.1 0 3.5-.23 5.23-.6a43.77 43.77 0 0 1-.46-4.13c-1.03.34-2.34.53-3.78.53-2 0-2.82-.55-2.82-2.13 0-1.37 0-2.65.03-3.84 2.57.02 5.13.07 6.64.11-.02-1.18.03-2.9.1-4.04-2.2.04-4.65.07-6.68.07l.07-2.93h-.16Zm13.46 6.04a767.33 767.33 0 0 1 .07-3.18H82.6c.07 1.96.07 3.98.07 6.92 0 2.95-.03 4.99-.07 6.93h5.18c-.09-1.37-.11-3.68-.11-5.65 0-3.1 1.26-4 4.12-4 1.33 0 2.28.16 3.1.46.03-1.16.26-3.43.4-4.43-.86-.25-1.81-.41-2.96-.41-2.46-.03-4.26.98-5.1 3.38l-.17-.02Zm22.55 3.65c0 2.5-1.8 3.66-4.64 3.66-2.81 0-4.61-1.1-4.61-3.66s1.82-3.52 4.61-3.52c2.82 0 4.64 1.03 4.64 3.52Zm4.71-.11c0-4.96-3.87-7.18-9.35-7.18-5.5 0-9.23 2.22-9.23 7.18 0 4.94 3.49 7.59 9.21 7.59 5.77 0 9.37-2.65 9.37-7.6Z"/><defs><linearGradient id="a" x1="6.33" x2="19.43" y1="40.8" y2="34.6" gradientUnits="userSpaceOnUse"><stop stop-color="#D83333"/><stop offset="1" stop-color="#F041FF"/></linearGradient></defs></svg>
|
Before Width: | Height: | Size: 2.8 KiB |
@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="1024" fill="none"><path fill="url(#a)" fill-rule="evenodd" d="M-217.58 475.75c91.82-72.02 225.52-29.38 341.2-44.74C240 415.56 372.33 315.14 466.77 384.9c102.9 76.02 44.74 246.76 90.31 366.31 29.83 78.24 90.48 136.14 129.48 210.23 57.92 109.99 169.67 208.23 155.9 331.77-13.52 121.26-103.42 264.33-224.23 281.37-141.96 20.03-232.72-220.96-374.06-196.99-151.7 25.73-172.68 330.24-325.85 315.72-128.6-12.2-110.9-230.73-128.15-358.76-12.16-90.14 65.87-176.25 44.1-264.57-26.42-107.2-167.12-163.46-176.72-273.45-10.15-116.29 33.01-248.75 124.87-320.79Z" clip-rule="evenodd" style="opacity:.154"/><path fill="url(#b)" fill-rule="evenodd" d="M1103.43 115.43c146.42-19.45 275.33-155.84 413.5-103.59 188.09 71.13 409 212.64 407.06 413.88-1.94 201.25-259.28 278.6-414.96 405.96-130 106.35-240.24 294.39-405.6 265.3-163.7-28.8-161.93-274.12-284.34-386.66-134.95-124.06-436-101.46-445.82-284.6-9.68-180.38 247.41-246.3 413.54-316.9 101.01-42.93 207.83 21.06 316.62 6.61Z" clip-rule="evenodd" style="opacity:.154"/><defs><linearGradient id="b" x1="373" x2="1995.44" y1="1100" y2="118.03" gradientUnits="userSpaceOnUse"><stop stop-color="#D83333"/><stop offset="1" stop-color="#F041FF"/></linearGradient><linearGradient id="a" x1="107.37" x2="1130.66" y1="1993.35" y2="1026.31" gradientUnits="userSpaceOnUse"><stop stop-color="#3245FF"/><stop offset="1" stop-color="#BC52EE"/></linearGradient></defs></svg>
|
Before Width: | Height: | Size: 1.4 KiB |
15
packages/frontend/src/assets/header-logo-dark.svg
Normal file
After Width: | Height: | Size: 22 KiB |
15
packages/frontend/src/assets/header-logo-light.svg
Normal file
After Width: | Height: | Size: 22 KiB |
15
packages/frontend/src/assets/标题-浅色.svg
Normal file
After Width: | Height: | Size: 22 KiB |
15
packages/frontend/src/assets/标题-深色.svg
Normal file
After Width: | Height: | Size: 22 KiB |
12
packages/frontend/src/components/CloseIcon.svelte
Normal file
@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
export let className = "";
|
||||
</script>
|
||||
|
||||
<div class={className}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="26" height="26" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M6.4 19L5 17.6l5.6-5.6L5 6.4L6.4 5l5.6 5.6L17.6 5L19 6.4L13.4 12l5.6 5.6l-1.4 1.4l-5.6-5.6z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
41
packages/frontend/src/components/DarkModeImage.svelte
Normal file
@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { fade } from "svelte/transition";
|
||||
|
||||
export let lightSrc: string;
|
||||
export let darkSrc: string;
|
||||
export let alt: string = "";
|
||||
export let className: string = "";
|
||||
|
||||
let isDarkMode = false;
|
||||
let currentSrc: string;
|
||||
let opacity = 0;
|
||||
|
||||
onMount(() => {
|
||||
const handleDarkModeChange = (event: MediaQueryListEvent) => {
|
||||
isDarkMode = event.matches;
|
||||
currentSrc = isDarkMode ? darkSrc : lightSrc;
|
||||
opacity = 1;
|
||||
};
|
||||
|
||||
const darkModeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
isDarkMode = darkModeMediaQuery.matches;
|
||||
currentSrc = isDarkMode ? darkSrc : lightSrc;
|
||||
opacity = 1;
|
||||
|
||||
darkModeMediaQuery.addEventListener("change", handleDarkModeChange);
|
||||
|
||||
return () => {
|
||||
darkModeMediaQuery.removeEventListener("change", handleDarkModeChange);
|
||||
};
|
||||
});
|
||||
|
||||
$: currentSrc = isDarkMode ? darkSrc : lightSrc;
|
||||
</script>
|
||||
|
||||
<img
|
||||
src={currentSrc}
|
||||
{alt}
|
||||
class={className}
|
||||
style={`opacity: ${opacity}`}
|
||||
/>
|
20
packages/frontend/src/components/MenuIcon.svelte
Normal file
@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
export let className = '';
|
||||
</script>
|
||||
|
||||
<div class={className}>
|
||||
<svg width="28.000000" height="28.000000" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<desc>
|
||||
Created with Pixso.
|
||||
</desc>
|
||||
<defs>
|
||||
<clipPath id="clip97_210">
|
||||
<rect id="菜单按钮" width="28.000000" height="28.000000" fill="white" fill-opacity="0"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g clip-path="url(#clip97_210)">
|
||||
<path id="path" d="M4.66 21C4.33 21 4.05 20.88 3.83 20.66C3.61 20.44 3.5 20.16 3.5 19.83C3.49 19.5 3.61 19.22 3.83 19C4.06 18.77 4.33 18.66 4.66 18.66L23.33 18.66C23.66 18.66 23.94 18.77 24.16 19C24.38 19.22 24.5 19.5 24.5 19.83C24.49 20.16 24.38 20.44 24.16 20.66C23.94 20.88 23.66 21 23.33 21L4.66 21ZM4.66 15.16C4.33 15.16 4.05 15.05 3.83 14.83C3.61 14.6 3.5 14.32 3.5 14C3.49 13.67 3.61 13.39 3.83 13.16C4.06 12.94 4.33 12.83 4.66 12.83L23.33 12.83C23.66 12.83 23.94 12.94 24.16 13.16C24.38 13.39 24.5 13.67 24.5 14C24.49 14.32 24.38 14.6 24.16 14.83C23.94 15.05 23.66 15.16 23.33 15.16L4.66 15.16ZM4.66 9.33C4.33 9.33 4.05 9.22 3.83 8.99C3.61 8.77 3.5 8.49 3.5 8.16C3.49 7.83 3.61 7.56 3.83 7.33C4.06 7.11 4.33 7 4.66 7L23.33 7C23.66 7 23.94 7.11 24.16 7.33C24.38 7.56 24.5 7.83 24.5 8.16C24.49 8.49 24.38 8.77 24.16 8.99C23.94 9.22 23.66 9.33 23.33 9.33L4.66 9.33Z" fill="currentColor" fill-opacity="1.000000" fill-rule="nonzero"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
</div>
|
44
packages/frontend/src/components/SearchBox.svelte
Normal file
@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
let inputBox: HTMLInputElement | null = null;
|
||||
|
||||
export function changeFocusState(target: boolean) {
|
||||
if (!inputBox) return;
|
||||
if (target) {
|
||||
inputBox.focus();
|
||||
} else {
|
||||
inputBox.blur();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
const input = event.target as HTMLInputElement;
|
||||
const value: string = input.value.trim();
|
||||
if (!value) return;
|
||||
window.location.href = `/song/${value}/info`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<div
|
||||
class="absolute left-0 md:left-96 ml-4 w-[calc(100%-5rem)] md:w-[calc(100%-40rem)] 2xl:max-w-[50rem] 2xl:left-1/2 2xl:-translate-x-1/2 inline-flex items-center h-full"
|
||||
>
|
||||
<input
|
||||
bind:this={inputBox}
|
||||
type="search"
|
||||
placeholder="搜索"
|
||||
class="top-0 w-full h-10 px-4 rounded-lg bg-white/80 dark:bg-zinc-800/70
|
||||
backdrop-blur-lg border border-zinc-300 dark:border-zinc-600 focus:border-zinc-400 duration-200 transition-colors focus:outline-none"
|
||||
on:keydown={handleKeydown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
[type="search"]::-webkit-search-cancel-button,
|
||||
[type="search"]::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
</style>
|
12
packages/frontend/src/components/SearchIcon.svelte
Normal file
@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
export let className = '';
|
||||
</script>
|
||||
|
||||
<div class={className}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="26" height="26" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="m19.6 21l-6.3-6.3q-.75.6-1.725.95T9.5 16q-2.725 0-4.612-1.888T3 9.5t1.888-4.612T9.5 3t4.613 1.888T16 9.5q0 1.1-.35 2.075T14.7 13.3l6.3 6.3zM9.5 14q1.875 0 3.188-1.312T14 9.5t-1.312-3.187T9.5 5T6.313 6.313T5 9.5t1.313 3.188T9.5 14"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
30
packages/frontend/src/components/TitleBar.astro
Normal file
@ -0,0 +1,30 @@
|
||||
---
|
||||
import astroLogoLight from "@assets/标题-浅色.svg";
|
||||
import astroLogoDark from "@assets/标题-深色.svg";
|
||||
import DarkModeImage from "@components/DarkModeImage.svelte";
|
||||
import SearchBox from "@components/SearchBox.svelte";
|
||||
import TitleBarMobile from "@components/TitleBarMobile.svelte";
|
||||
---
|
||||
|
||||
<div class="hidden md:block fixed top-0 left-0 w-full h-28 bg-white/80 dark:bg-zinc-900/70 backdrop-blur-lg z-50">
|
||||
<div class="w-[305px] ml-8 inline-flex h-full items-center">
|
||||
<a href="/">
|
||||
<DarkModeImage
|
||||
lightSrc={astroLogoLight.src}
|
||||
darkSrc={astroLogoDark.src}
|
||||
alt="Logo"
|
||||
className="w-[305px] h-24 inline-block"
|
||||
client:load
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<SearchBox client:load />
|
||||
|
||||
<div
|
||||
class="inline-flex right-12 absolute gap-4 h-full text-xl dark:text-[#C6DCF2] font-medium items-center w-48 justify-end"
|
||||
>
|
||||
<a href="/about" class="hover:dark:text-[#B1C5DA]">关于</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TitleBarMobile client:load />
|
47
packages/frontend/src/components/TitleBarMobile.svelte
Normal file
@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import logoMobileDark from "@assets/TitleBar Mobile Dark.svg";
|
||||
import logoMobileLight from "@assets/TitleBar Mobile Light.svg";
|
||||
import SearchBox from "./SearchBox.svelte";
|
||||
import SearchIcon from "./SearchIcon.svelte";
|
||||
import MenuIcon from "./MenuIcon.svelte";
|
||||
import DarkModeImage from "./DarkModeImage.svelte";
|
||||
import CloseIcon from "./CloseIcon.svelte";
|
||||
|
||||
let searchBox: SearchBox | null = null;
|
||||
let showSearchBox = false;
|
||||
|
||||
$: if (showSearchBox && searchBox) {
|
||||
searchBox.changeFocusState(true);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="md:hidden fixed top-0 left-0 w-full h-16 bg-white/80 dark:bg-zinc-800/70 backdrop-blur-lg z-50">
|
||||
{#if !showSearchBox}
|
||||
<button class="inline-block ml-4 mt-4 dark:text-white">
|
||||
<MenuIcon />
|
||||
</button>
|
||||
<div class="ml-8 inline-flex h-full items-center">
|
||||
<a href="/">
|
||||
<DarkModeImage
|
||||
lightSrc={logoMobileLight.src}
|
||||
darkSrc={logoMobileDark.src}
|
||||
alt="Logo"
|
||||
className="w-24 h-8 translate-y-[2px]"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
{#if showSearchBox}
|
||||
<SearchBox bind:this={searchBox} />
|
||||
{/if}
|
||||
<button
|
||||
class="inline-flex absolute right-0 h-full items-center mr-4"
|
||||
onclick={() => (showSearchBox = !showSearchBox)}
|
||||
>
|
||||
{#if showSearchBox}
|
||||
<CloseIcon />
|
||||
{:else}
|
||||
<SearchIcon />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
@ -1,210 +1,10 @@
|
||||
---
|
||||
import astroLogo from '../assets/astro.svg';
|
||||
import background from '../assets/background.svg';
|
||||
import TitleBar from "@components/TitleBar.astro";
|
||||
---
|
||||
|
||||
<div id="container">
|
||||
<img id="background" src={background.src} alt="" fetchpriority="high" />
|
||||
<main>
|
||||
<section id="hero">
|
||||
<a href="https://astro.build"
|
||||
><img src={astroLogo.src} width="115" height="48" alt="Astro Homepage" /></a
|
||||
>
|
||||
<h1>
|
||||
To get started, open the <code><pre>src/pages</pre></code> directory in your project.
|
||||
</h1>
|
||||
<section id="links">
|
||||
<a class="button" href="https://docs.astro.build">Read our docs</a>
|
||||
<a href="https://astro.build/chat"
|
||||
>Join our Discord <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 127.14 96.36"
|
||||
><path
|
||||
fill="currentColor"
|
||||
d="M107.7 8.07A105.15 105.15 0 0 0 81.47 0a72.06 72.06 0 0 0-3.36 6.83 97.68 97.68 0 0 0-29.11 0A72.37 72.37 0 0 0 45.64 0a105.89 105.89 0 0 0-26.25 8.09C2.79 32.65-1.71 56.6.54 80.21a105.73 105.73 0 0 0 32.17 16.15 77.7 77.7 0 0 0 6.89-11.11 68.42 68.42 0 0 1-10.85-5.18c.91-.66 1.8-1.34 2.66-2a75.57 75.57 0 0 0 64.32 0c.87.71 1.76 1.39 2.66 2a68.68 68.68 0 0 1-10.87 5.19 77 77 0 0 0 6.89 11.1 105.25 105.25 0 0 0 32.19-16.14c2.64-27.38-4.51-51.11-18.9-72.15ZM42.45 65.69C36.18 65.69 31 60 31 53s5-12.74 11.43-12.74S54 46 53.89 53s-5.05 12.69-11.44 12.69Zm42.24 0C78.41 65.69 73.25 60 73.25 53s5-12.74 11.44-12.74S96.23 46 96.12 53s-5.04 12.69-11.43 12.69Z"
|
||||
></path></svg
|
||||
>
|
||||
</a>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
<TitleBar/>
|
||||
|
||||
<a href="https://astro.build/blog/astro-5/" id="news" class="box">
|
||||
<svg width="32" height="32" fill="none" xmlns="http://www.w3.org/2000/svg"
|
||||
><path
|
||||
d="M24.667 12c1.333 1.414 2 3.192 2 5.334 0 4.62-4.934 5.7-7.334 12C18.444 28.567 18 27.456 18 26c0-4.642 6.667-7.053 6.667-14Zm-5.334-5.333c1.6 1.65 2.4 3.43 2.4 5.333 0 6.602-8.06 7.59-6.4 17.334C13.111 27.787 12 25.564 12 22.666c0-4.434 7.333-8 7.333-16Zm-6-5.333C15.111 3.555 16 5.556 16 7.333c0 8.333-11.333 10.962-5.333 22-3.488-.774-6-4-6-8 0-8.667 8.666-10 8.666-20Z"
|
||||
fill="#111827"></path></svg
|
||||
>
|
||||
<h2>What's New in Astro 5.0?</h2>
|
||||
<p>
|
||||
From content layers to server islands, click to learn more about the new features and
|
||||
improvements in Astro 5.0
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#background {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: -1;
|
||||
filter: blur(100px);
|
||||
}
|
||||
|
||||
#container {
|
||||
font-family: Inter, Roboto, 'Helvetica Neue', 'Arial Nova', 'Nimbus Sans', Arial, sans-serif;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
main {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#hero {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 22px;
|
||||
margin-top: 0.25em;
|
||||
}
|
||||
|
||||
#links {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
#links a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
color: #111827;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
#links a:hover {
|
||||
color: rgb(78, 80, 86);
|
||||
}
|
||||
|
||||
#links a svg {
|
||||
height: 1em;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
#links a.button {
|
||||
color: white;
|
||||
background: linear-gradient(83.21deg, #3245ff 0%, #bc52ee 100%);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.12),
|
||||
inset 0 -2px 0 rgba(0, 0, 0, 0.24);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
#links a.button:hover {
|
||||
color: rgb(230, 230, 230);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
pre {
|
||||
font-family:
|
||||
ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono',
|
||||
monospace;
|
||||
font-weight: normal;
|
||||
background: linear-gradient(14deg, #d83333 0%, #f041ff 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 1em;
|
||||
font-weight: normal;
|
||||
color: #111827;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #4b5563;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
letter-spacing: -0.006em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code {
|
||||
display: inline-block;
|
||||
background:
|
||||
linear-gradient(66.77deg, #f3cddd 0%, #f5cee7 100%) padding-box,
|
||||
linear-gradient(155deg, #d83333 0%, #f041ff 18%, #f5cee7 45%) border-box;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.box {
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 1);
|
||||
border-radius: 16px;
|
||||
border: 1px solid white;
|
||||
}
|
||||
|
||||
#news {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
max-width: 300px;
|
||||
text-decoration: none;
|
||||
transition: background 0.2s;
|
||||
backdrop-filter: blur(50px);
|
||||
}
|
||||
|
||||
#news:hover {
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
@media screen and (max-height: 368px) {
|
||||
#news {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
#container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#hero {
|
||||
display: block;
|
||||
padding-top: 10%;
|
||||
}
|
||||
|
||||
#links {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
#links a.button {
|
||||
padding: 14px 18px;
|
||||
}
|
||||
|
||||
#news {
|
||||
right: 16px;
|
||||
left: 16px;
|
||||
bottom: 2.5rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
h1 {
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<main class="flex flex-col items-center justify-center min-h-screen gap-8">
|
||||
<h1 class="text-4xl font-bold text-center">正在施工中……</h1>
|
||||
<p>在搜索栏输入BV号或AV号,可以查询目前数据库收集到的信息~</p>
|
||||
</main>
|
||||
|
60
packages/frontend/src/content/about.md
Normal file
@ -0,0 +1,60 @@
|
||||
# 关于「中V档案馆」
|
||||
|
||||
「中V档案馆」是一个旨在收录与展示「中文歌声合成作品」及有关信息的网站。
|
||||
|
||||
## 创建背景与关联工作
|
||||
|
||||
纵观整个互联网,对于「中文歌声合成」或「中文虚拟歌手」(常简称为中V或VC)相关信息进行较为系统、全面地整理收集的主要有以下几个网站:
|
||||
|
||||
- [萌娘百科](https://zh.moegirl.org.cn/):
|
||||
收录了大量中V歌曲及歌姬的信息,呈现形式为传统维基(基于[MediaWiki](https://www.mediawiki.org/))。
|
||||
- [VCPedia](https://vcpedia.cn/):
|
||||
由原萌娘百科中文歌声合成编辑团队的部分成员搭建,专属于中文歌声合成相关内容的信息集成站点[^1],呈现形式为传统维基(基于[MediaWiki](https://www.mediawiki.org/))。
|
||||
- [VocaDB](https://vocadb.net/): 一个围绕 Vocaloid、UTAU 和其他歌声合成器的协作数据库,其中包含艺术家、唱片、PV
|
||||
等[^2],其中包含大量中文歌声合成作品。
|
||||
- [天钿Daily](https://tdd.bunnyxt.com/):一个VC相关数据交流与分享的网站。致力于VC相关数据交流,定期抓取VC相关数据,选取有意义的纬度展示。[^3]
|
||||
|
||||
上述网站中,或多或少存在一些不足,例如:
|
||||
|
||||
- 萌娘百科、VCPedia受限于传统维基,绝大多数内容依赖人工编辑。
|
||||
- VocaDB基于结构化数据库构建,由此可以依赖程序生成一些信息,但**条目收录**仍然完全依赖人工完成。
|
||||
- VocaDB主要专注于元数据展示,少有关于歌曲、作者等的描述性的文字,也缺乏描述性的背景信息。
|
||||
- 天钿Daily只展示歌曲的统计数据及历史趋势,没有关于歌曲其它信息的收集。
|
||||
|
||||
因此,**中V档案馆**吸取前人经验,克服上述网站的不足,希望做到:
|
||||
|
||||
- 歌曲收录(指发现歌曲并创建条目)的完全自动化
|
||||
- 歌曲元信息提取的高度自动化
|
||||
- 歌曲统计数据收集的完全自动化
|
||||
- 在程序辅助的同时欢迎并鼓励贡献者参与编辑(主要为描述性内容)或纠错
|
||||
- 在适当的许可声明下,引用来自上述源的数据,使内容更加全面、丰富。
|
||||
|
||||
## 技术架构
|
||||
|
||||
参见[CVSA文档](https://cvsa.gitbook.io/)。
|
||||
|
||||
## 开放许可
|
||||
|
||||
受本文以[CC BY-NC-SA 4.0协议](https://creativecommons.org/licenses/by-nc-sa/4.0/)提供。
|
||||
|
||||
### 数据库
|
||||
|
||||
中V档案馆使用[PostgreSQL](https://postgresql.org)作为数据库,我们承诺定期导出数据库转储 (dump)
|
||||
文件并公开,其内容遵从以下协议或条款:
|
||||
|
||||
- 数据库中的事实性数据,根据适用法律,不构成受版权保护的内容。中V档案馆放弃一切可能的权利([CC0 1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/))。
|
||||
- 对于数据库中有原创性的内容(如贡献者编辑的描述性内容),如无例外,以[CC BY 4.0协议](https://creativecommons.org/licenses/by/4.0/)提供。
|
||||
- 对于引用、摘编或改编自萌娘百科、VCPedia的内容,以与原始协议(CC BY-NC-SA 3.0
|
||||
CN)兼容的协议[CC BY-NC-SA 4.0协议](https://creativecommons.org/licenses/by-nc-sa/4.0/)提供,并注明原始协议 。
|
||||
> 根据原始协议第四条第2项内容,CC BY-NC-SA 4.0协议为与原始协议具有相同授权要素的后续版本(“可适用的协议”)。
|
||||
- 中V档案馆文档使用[CC BY 4.0协议](https://creativecommons.org/licenses/by/4.0/)。
|
||||
|
||||
### 软件代码
|
||||
|
||||
用于构建中V档案馆的软件代码在[AGPL 3.0](https://www.gnu.org/licenses/agpl-3.0.html)许可证下公开,参见[LICENSE](./LICENSE)
|
||||
|
||||
[^1]: 引用自[VCPedia](https://vcpedia.cn/%E9%A6%96%E9%A1%B5),于[知识共享 署名-非商业性使用-相同方式共享 3.0中国大陆 (CC BY-NC-SA 3.0 CN) 许可协议](https://creativecommons.org/licenses/by-nc-sa/3.0/cn/)下提供。
|
||||
|
||||
[^2]: 翻译自[VocaDB](https://vocadb.net/),于[CC BY 4.0协议](https://creativecommons.org/licenses/by/4.0/)下提供。
|
||||
|
||||
[^3]: 引用自[关于 - 天钿Daily](https://tdd.bunnyxt.com/about)
|
@ -1,22 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<title>Astro Basics</title>
|
||||
</head>
|
||||
<body>
|
||||
<slot />
|
||||
</body>
|
||||
</html>
|
||||
---
|
||||
import "../styles/global.css";
|
||||
---
|
||||
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CVSA 前端</title>
|
||||
</head>
|
||||
<body class="dark:bg-zinc-900 dark:text-zinc-100">
|
||||
<slot />
|
||||
</body>
|
||||
</html>
|
||||
|
10
packages/frontend/src/pages/404.astro
Normal file
@ -0,0 +1,10 @@
|
||||
---
|
||||
import Layout from '@layouts/Layout.astro';
|
||||
---
|
||||
|
||||
<Layout>
|
||||
<main class="flex flex-col items-center justify-center min-h-screen gap-8">
|
||||
<h1 class="text-9xl font-thin">404</h1>
|
||||
<p class="text-xl font-medium">咦……页面去哪里了(゚Д゚≡゚д゚)!?</p>
|
||||
</main>
|
||||
</Layout>
|
15
packages/frontend/src/pages/about.astro
Normal file
@ -0,0 +1,15 @@
|
||||
---
|
||||
import TitleBar from "@components/TitleBar.astro";
|
||||
import Layout from '@layouts/Layout.astro';
|
||||
import {Content as AboutContent} from '../content/about.md';
|
||||
import "../styles/content.css";
|
||||
---
|
||||
|
||||
<Layout>
|
||||
<TitleBar/>
|
||||
<main class="flex flex-col items-center min-h-screen gap-8 mt-36 relative z-0">
|
||||
<div class="lg:w-1/2 content">
|
||||
<AboutContent/>
|
||||
</div>
|
||||
</main>
|
||||
</Layout>
|
@ -1,9 +1,6 @@
|
||||
---
|
||||
import Welcome from '../components/Welcome.astro';
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
|
||||
// Welcome to Astro! Wondering what to do next? Check out the Astro documentation at https://docs.astro.build
|
||||
// Don't want to use any of this? Delete everything in this file, the `assets`, `components`, and `layouts` directories, and start fresh.
|
||||
import Welcome from '@components/Welcome.astro';
|
||||
import Layout from '@layouts/Layout.astro';
|
||||
---
|
||||
|
||||
<Layout>
|
||||
|
200
packages/frontend/src/pages/song/[id]/info.astro
Normal file
@ -0,0 +1,200 @@
|
||||
---
|
||||
import Layout from "@layouts/Layout.astro";
|
||||
import TitleBar from "@components/TitleBar.astro";
|
||||
import pg from "pg";
|
||||
import { format } from 'date-fns';
|
||||
import { zhCN } from 'date-fns/locale';
|
||||
|
||||
const databaseHost = process.env.DB_HOST
|
||||
const databaseName = process.env.DB_NAME
|
||||
const databaseUser = process.env.DB_USER
|
||||
const databasePassword = process.env.DB_PASSWORD
|
||||
const databasePort = process.env.DB_PORT
|
||||
|
||||
const postgresConfig = {
|
||||
hostname: databaseHost,
|
||||
port: parseInt(databasePort!),
|
||||
database: databaseName,
|
||||
user: databaseUser,
|
||||
password: databasePassword,
|
||||
};
|
||||
|
||||
// 路由参数
|
||||
const { id } = Astro.params;
|
||||
const { Client } = pg;
|
||||
const client = new Client(postgresConfig);
|
||||
await client.connect();
|
||||
|
||||
// 数据库查询函数
|
||||
async function getVideoMetadata(aid: number) {
|
||||
const res = await client.query("SELECT * FROM bilibili_metadata WHERE aid = $1", [aid]);
|
||||
if (res.rows.length <= 0) {
|
||||
return null;
|
||||
}
|
||||
const row = res.rows[0];
|
||||
if (row) {
|
||||
return row;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
async function getVideoSnapshots(aid: number) {
|
||||
const res = await client.query("SELECT * FROM video_snapshot WHERE aid = $1 ORDER BY created_at DESC", [
|
||||
aid,
|
||||
]);
|
||||
if (res.rows.length <= 0) {
|
||||
return null;
|
||||
}
|
||||
return res.rows;
|
||||
}
|
||||
|
||||
async function getAidFromBV(bv: string) {
|
||||
const res = await client.query("SELECT aid FROM bilibili_metadata WHERE bvid = $1", [bv]);
|
||||
if (res.rows.length <= 0) {
|
||||
return null;
|
||||
}
|
||||
const row = res.rows[0];
|
||||
if (row && row.aid) {
|
||||
return Number(row.aid);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getVideoAid(id: string) {
|
||||
if (id.startsWith("av")) {
|
||||
return parseInt(id.slice(2));
|
||||
} else if (id.startsWith("BV")) {
|
||||
return getAidFromBV(id);
|
||||
}
|
||||
return parseInt(id);
|
||||
}
|
||||
|
||||
// 获取数据
|
||||
if (!id) {
|
||||
Astro.response.status = 404;
|
||||
client.end();
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
const aid = await getVideoAid(id);
|
||||
if (!aid || isNaN(aid)) {
|
||||
Astro.response.status = 404;
|
||||
client.end();
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
const videoInfo = await getVideoMetadata(aid);
|
||||
const snapshots = await getVideoSnapshots(aid);
|
||||
client.end();
|
||||
|
||||
interface Snapshot {
|
||||
created_at: Date;
|
||||
views: number;
|
||||
danmakus: number;
|
||||
replies: number;
|
||||
coins: number;
|
||||
likes: number;
|
||||
favorites: number;
|
||||
shares: number;
|
||||
id: number;
|
||||
}
|
||||
---
|
||||
|
||||
<Layout>
|
||||
<TitleBar />
|
||||
<main class="flex flex-col items-center min-h-screen gap-8 mt-36 relative z-0">
|
||||
<div class="max-w-4xl mx-auto rounded-lg p-6">
|
||||
<h1 class="text-2xl font-bold mb-4">视频信息: <a href={`https://www.bilibili.com/video/av${aid}`} class="underline">av{aid}</a></h1>
|
||||
|
||||
<div class="mb-6 p-4 rounded-lg">
|
||||
<h2 class="text-xl font-semibold mb-8">基本信息</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table-auto w-full">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="border dark:border-zinc-500 px-4 py-2 font-bold">ID</td>
|
||||
<td class="border dark:border-zinc-500 px-4 py-2">{videoInfo?.id}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="border dark:border-zinc-500 px-4 py-2 font-bold">AID</td>
|
||||
<td class="border dark:border-zinc-500 px-4 py-2">{videoInfo?.aid}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="border dark:border-zinc-500 px-4 py-2 font-bold">BVID</td>
|
||||
<td class="border dark:border-zinc-500 px-4 py-2">{videoInfo?.bvid}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="border dark:border-zinc-500 px-4 py-2 font-bold">标题</td>
|
||||
<td class="border dark:border-zinc-500 px-4 py-2">{videoInfo?.title}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="border dark:border-zinc-500 px-4 py-2 font-bold">描述</td>
|
||||
<td class="border dark:border-zinc-500 px-4 py-2">{videoInfo?.description}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="border dark:border-zinc-500 px-4 py-2 font-bold">UID</td>
|
||||
<td class="border dark:border-zinc-500 px-4 py-2">{videoInfo?.uid}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="border dark:border-zinc-500 px-4 py-2 font-bold">标签</td>
|
||||
<td class="border dark:border-zinc-500 px-4 py-2">{videoInfo?.tags}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="border dark:border-zinc-500 px-4 py-2 font-bold">发布时间</td>
|
||||
<td class="border dark:border-zinc-500 px-4 py-2">{videoInfo?.published_at ? format(new Date(videoInfo.published_at), 'yyyy-MM-dd HH:mm:ss', { locale: zhCN }) : '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="border dark:border-zinc-500 px-4 py-2 font-bold">时长 (秒)</td>
|
||||
<td class="border dark:border-zinc-500 px-4 py-2">{videoInfo?.duration}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="border dark:border-zinc-500 px-4 py-2 font-bold">创建时间</td>
|
||||
<td class="border dark:border-zinc-500 px-4 py-2">{videoInfo?.created_at ? format(new Date(videoInfo.created_at), 'yyyy-MM-dd HH:mm:ss', { locale: zhCN }) : '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="border dark:border-zinc-500 px-4 py-2 font-bold">封面</td>
|
||||
<td class="border dark:border-zinc-500 px-4 py-2">{videoInfo?.cover_url ? videoInfo.cover_url : '-'}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 rounded-lg">
|
||||
<h2 class="text-xl font-semibold mb-4">播放量历史数据</h2>
|
||||
{snapshots && snapshots.length > 0 ? (
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table-auto w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="border dark:border-zinc-500 px-4 py-2">创建时间</th>
|
||||
<th class="border dark:border-zinc-500 px-4 py-2">观看</th>
|
||||
<th class="border dark:border-zinc-500 px-4 py-2">硬币</th>
|
||||
<th class="border dark:border-zinc-500 px-4 py-2">点赞</th>
|
||||
<th class="border dark:border-zinc-500 px-4 py-2">收藏</th>
|
||||
<th class="border dark:border-zinc-500 px-4 py-2">分享</th>
|
||||
<th class="border dark:border-zinc-500 px-4 py-2">弹幕</th>
|
||||
<th class="border dark:border-zinc-500 px-4 py-2">评论</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{snapshots.map((snapshot: Snapshot) => (
|
||||
<tr>
|
||||
<td class="border dark:border-zinc-500 px-4 py-2">{format(new Date(snapshot.created_at), 'yyyy-MM-dd HH:mm:ss', { locale: zhCN })}</td>
|
||||
<td class="border dark:border-zinc-500 px-4 py-2">{snapshot.views}</td>
|
||||
<td class="border dark:border-zinc-500 px-4 py-2">{snapshot.coins}</td>
|
||||
<td class="border dark:border-zinc-500 px-4 py-2">{snapshot.likes}</td>
|
||||
<td class="border dark:border-zinc-500 px-4 py-2">{snapshot.favorites}</td>
|
||||
<td class="border dark:border-zinc-500 px-4 py-2">{snapshot.shares}</td>
|
||||
<td class="border dark:border-zinc-500 px-4 py-2">{snapshot.danmakus}</td>
|
||||
<td class="border dark:border-zinc-500 px-4 py-2">{snapshot.replies}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p>暂无历史数据。</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</Layout>
|
76
packages/frontend/src/styles/content.css
Normal file
@ -0,0 +1,76 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
.content {
|
||||
@apply text-gray-800 dark:text-zinc-100;
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
@apply font-bold text-gray-900 dark:text-white my-4;
|
||||
}
|
||||
|
||||
h1 {
|
||||
@apply text-3xl;
|
||||
}
|
||||
|
||||
h2 {
|
||||
@apply text-2xl;
|
||||
}
|
||||
|
||||
h3 {
|
||||
@apply text-xl;
|
||||
}
|
||||
|
||||
h4 {
|
||||
@apply text-lg;
|
||||
}
|
||||
|
||||
p {
|
||||
@apply my-4;
|
||||
}
|
||||
|
||||
a {
|
||||
@apply text-blue-500 hover:text-blue-700 dark:hover:text-blue-400 underline;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
@apply list-disc list-inside my-4;
|
||||
}
|
||||
|
||||
li {
|
||||
@apply my-2;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
@apply border-l-4 border-gray-300 pl-4 italic my-4;
|
||||
}
|
||||
|
||||
code {
|
||||
@apply bg-gray-100 text-gray-800 rounded px-1 duration-300;
|
||||
}
|
||||
|
||||
pre {
|
||||
@apply bg-gray-100 p-4 rounded overflow-x-auto my-4 duration-300 h-0;
|
||||
}
|
||||
|
||||
table {
|
||||
@apply w-full border-collapse my-4;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
@apply border border-gray-300 p-2;
|
||||
}
|
||||
|
||||
th {
|
||||
@apply bg-gray-200 font-bold;
|
||||
}
|
||||
ul li p,
|
||||
ol li p {
|
||||
@apply inline;
|
||||
}
|
||||
}
|
3
packages/frontend/src/styles/global.css
Normal file
@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
8
packages/frontend/tailwind.config.js
Normal file
@ -0,0 +1,8 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ["./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}"],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
@ -1,5 +1,16 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist"]
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist"],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@components/*": ["src/components/*"],
|
||||
"@layouts/*": ["src/layouts/*"],
|
||||
"@utils/*": ["src/utils/*"],
|
||||
"@assets/*": ["src/assets/*"],
|
||||
"@styles": ["src/styles/*"],
|
||||
"@core/*": ["../core/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -14,14 +14,20 @@ const db = new Database(DATABASE_PATH, { int64: true });
|
||||
// 设置日志
|
||||
async function setupLogging() {
|
||||
await ensureDir(LOG_DIR);
|
||||
const logStream = await Deno.open(LOG_FILE, { write: true, create: true, append: true });
|
||||
const logStream = await Deno.open(LOG_FILE, {
|
||||
write: true,
|
||||
create: true,
|
||||
append: true,
|
||||
});
|
||||
|
||||
const redirectConsole =
|
||||
// deno-lint-ignore no-explicit-any
|
||||
(originalConsole: (...args: any[]) => void) =>
|
||||
// deno-lint-ignore no-explicit-any
|
||||
(...args: any[]) => {
|
||||
const message = args.map((arg) => (typeof arg === "object" ? JSON.stringify(arg) : arg)).join(" ");
|
||||
const message = args.map((
|
||||
arg,
|
||||
) => (typeof arg === "object" ? JSON.stringify(arg) : arg)).join(" ");
|
||||
originalConsole(message);
|
||||
logStream.write(new TextEncoder().encode(message + "\n"));
|
||||
};
|
||||
@ -38,14 +44,17 @@ interface Metadata {
|
||||
|
||||
// 获取最后一次更新的时间
|
||||
function getLastUpdate(): Date {
|
||||
const result = db.prepare("SELECT value FROM metadata WHERE key = 'fetchAid-lastUpdate'").get() as Metadata;
|
||||
const result = db.prepare(
|
||||
"SELECT value FROM metadata WHERE key = 'fetchAid-lastUpdate'",
|
||||
).get() as Metadata;
|
||||
return result ? new Date(result.value as string) : new Date(0);
|
||||
}
|
||||
|
||||
// 更新最后更新时间
|
||||
function updateLastUpdate() {
|
||||
const now = new Date().toISOString();
|
||||
db.prepare("UPDATE metadata SET value = ? WHERE key = 'fetchAid-lastUpdate'").run(now);
|
||||
db.prepare("UPDATE metadata SET value = ? WHERE key = 'fetchAid-lastUpdate'")
|
||||
.run(now);
|
||||
}
|
||||
|
||||
// 辅助函数:获取数据
|
||||
@ -66,7 +75,9 @@ async function fetchData(pn: number, retries = MAX_RETRIES): Promise<any> {
|
||||
|
||||
// 插入 aid 到数据库
|
||||
function insertAid(aid: number) {
|
||||
db.prepare("INSERT OR IGNORE INTO bili_info_crawl (aid, status) VALUES (?, 'pending')").run(aid);
|
||||
db.prepare(
|
||||
"INSERT OR IGNORE INTO bili_info_crawl (aid, status) VALUES (?, 'pending')",
|
||||
).run(aid);
|
||||
}
|
||||
|
||||
// 主函数
|
||||
|
@ -5,7 +5,16 @@ import { ensureDir } from "https://deno.land/std@0.113.0/fs/mod.ts";
|
||||
|
||||
const aidPath = "./data/2025010104_c30_aids.txt";
|
||||
const db = new Database("./data/main.db", { int64: true });
|
||||
const regions = ["shanghai", "hangzhou", "qingdao", "beijing", "zhangjiakou", "chengdu", "shenzhen", "hohhot"];
|
||||
const regions = [
|
||||
"shanghai",
|
||||
"hangzhou",
|
||||
"qingdao",
|
||||
"beijing",
|
||||
"zhangjiakou",
|
||||
"chengdu",
|
||||
"shenzhen",
|
||||
"hohhot",
|
||||
];
|
||||
const logDir = "./logs/bili-info-crawl";
|
||||
const logFile = path.join(logDir, `run-${Date.now() / 1000}.log`);
|
||||
const shouldReadTextFile = false;
|
||||
@ -26,14 +35,20 @@ const requestQueue: number[] = [];
|
||||
|
||||
async function setupLogging() {
|
||||
await ensureDir(logDir);
|
||||
const logStream = await Deno.open(logFile, { write: true, create: true, append: true });
|
||||
const logStream = await Deno.open(logFile, {
|
||||
write: true,
|
||||
create: true,
|
||||
append: true,
|
||||
});
|
||||
|
||||
const redirectConsole =
|
||||
// deno-lint-ignore no-explicit-any
|
||||
(originalConsole: (...args: any[]) => void) =>
|
||||
// deno-lint-ignore no-explicit-any
|
||||
(...args: any[]) => {
|
||||
const message = args.map((arg) => (typeof arg === "object" ? JSON.stringify(arg) : arg)).join(" ");
|
||||
const message = args.map((
|
||||
arg,
|
||||
) => (typeof arg === "object" ? JSON.stringify(arg) : arg)).join(" ");
|
||||
originalConsole(message);
|
||||
logStream.write(new TextEncoder().encode(message + "\n"));
|
||||
};
|
||||
@ -78,7 +93,9 @@ async function readFromText() {
|
||||
const newAids = aids.filter((aid) => !existingAidsSet.has(aid));
|
||||
|
||||
// 插入这些新条目
|
||||
const insertStmt = db.prepare("INSERT OR IGNORE INTO bili_info_crawl (aid, status) VALUES (?, 'pending')");
|
||||
const insertStmt = db.prepare(
|
||||
"INSERT OR IGNORE INTO bili_info_crawl (aid, status) VALUES (?, 'pending')",
|
||||
);
|
||||
newAids.forEach((aid) => insertStmt.run(aid));
|
||||
}
|
||||
|
||||
@ -88,7 +105,9 @@ async function insertAidsToDB() {
|
||||
}
|
||||
|
||||
const aidsInDB = db
|
||||
.prepare("SELECT aid FROM bili_info_crawl WHERE status = 'pending' OR status = 'failed'")
|
||||
.prepare(
|
||||
"SELECT aid FROM bili_info_crawl WHERE status = 'pending' OR status = 'failed'",
|
||||
)
|
||||
.all()
|
||||
.map((row) => row.aid) as number[];
|
||||
|
||||
@ -98,13 +117,21 @@ async function insertAidsToDB() {
|
||||
|
||||
const processAid = async (aid: number) => {
|
||||
try {
|
||||
const res = await getBiliBiliVideoInfo(aid, regions[processedAids % regions.length]);
|
||||
const res = await getBiliBiliVideoInfo(
|
||||
aid,
|
||||
regions[processedAids % regions.length],
|
||||
);
|
||||
if (res === null) {
|
||||
updateAidStatus(aid, "failed");
|
||||
} else {
|
||||
const rawData = JSON.parse(res);
|
||||
if (rawData.code === 0) {
|
||||
updateAidStatus(aid, "success", rawData.data.View.bvid, JSON.stringify(rawData.data));
|
||||
updateAidStatus(
|
||||
aid,
|
||||
"success",
|
||||
rawData.data.View.bvid,
|
||||
JSON.stringify(rawData.data),
|
||||
);
|
||||
} else {
|
||||
updateAidStatus(aid, "error", undefined, res);
|
||||
}
|
||||
@ -136,7 +163,12 @@ async function insertAidsToDB() {
|
||||
console.log("Starting to process aids...");
|
||||
}
|
||||
|
||||
function updateAidStatus(aid: number, status: string, bvid?: string, data?: string) {
|
||||
function updateAidStatus(
|
||||
aid: number,
|
||||
status: string,
|
||||
bvid?: string,
|
||||
data?: string,
|
||||
) {
|
||||
const stmt = db.prepare(`
|
||||
UPDATE bili_info_crawl
|
||||
SET status = ?,
|
||||
@ -145,11 +177,22 @@ function updateAidStatus(aid: number, status: string, bvid?: string, data?: stri
|
||||
timestamp = ?
|
||||
WHERE aid = ?
|
||||
`);
|
||||
const params = [status, ...(bvid ? [bvid] : []), ...(data ? [data] : []), Date.now() / 1000, aid];
|
||||
const params = [
|
||||
status,
|
||||
...(bvid ? [bvid] : []),
|
||||
...(data ? [data] : []),
|
||||
Date.now() / 1000,
|
||||
aid,
|
||||
];
|
||||
stmt.run(...params);
|
||||
}
|
||||
|
||||
function logProgress(aid: number, processedAids: number, totalAids: number, startTime: number) {
|
||||
function logProgress(
|
||||
aid: number,
|
||||
processedAids: number,
|
||||
totalAids: number,
|
||||
startTime: number,
|
||||
) {
|
||||
const elapsedTime = Date.now() - startTime;
|
||||
const elapsedSeconds = Math.floor(elapsedTime / 1000);
|
||||
const elapsedMinutes = Math.floor(elapsedSeconds / 60);
|
||||
|
@ -1,4 +1,7 @@
|
||||
export async function getBiliBiliVideoInfo(bvidORaid?: string | number, region: string = "hangzhou") {
|
||||
export async function getBiliBiliVideoInfo(
|
||||
bvidORaid?: string | number,
|
||||
region: string = "hangzhou",
|
||||
) {
|
||||
const bvid = typeof bvidORaid === "string" ? bvidORaid : undefined;
|
||||
const aid = typeof bvidORaid === "number" ? bvidORaid : undefined;
|
||||
|
||||
@ -18,7 +21,10 @@ export async function getBiliBiliVideoInfo(bvidORaid?: string | number, region:
|
||||
}
|
||||
}
|
||||
|
||||
async function proxyRequestWithRegion(url: string, region: string): Promise<any | null> {
|
||||
async function proxyRequestWithRegion(
|
||||
url: string,
|
||||
region: string,
|
||||
): Promise<any | null> {
|
||||
const td = new TextDecoder();
|
||||
// aliyun configure set --access-key-id $ALIYUN_AK --access-key-secret $ALIYUN_SK --region cn-shenzhen --profile CVSA-shenzhen --mode AK
|
||||
const p = await new Deno.Command("aliyun", {
|
||||
@ -40,7 +46,9 @@ async function proxyRequestWithRegion(url: string, region: string): Promise<any
|
||||
const out = td.decode(p.stdout);
|
||||
const rawData = JSON.parse(out);
|
||||
if (rawData.statusCode !== 200) {
|
||||
console.error(`Error proxying request ${url} to ${region} , statusCode: ${rawData.statusCode}`);
|
||||
console.error(
|
||||
`Error proxying request ${url} to ${region} , statusCode: ${rawData.statusCode}`,
|
||||
);
|
||||
return null;
|
||||
} else {
|
||||
return JSON.parse(rawData.body);
|
||||
|