From b88692e7c1f307851bed3016df126c5acfd74e03 Mon Sep 17 00:00:00 2001 From: alikia2x Date: Wed, 31 Dec 2025 23:12:01 +0800 Subject: [PATCH] add: annual archive script, recover the FC proxy improve the logger, add some error handling in getVideoInfo --- .idea/data_source_mapping.xml | 1 + bun.lock | 2 +- packages/cf-worker/package.json | 2 +- packages/cf-worker/src/index.ts | 49 +-- packages/core/log/index.ts | 133 +++++--- packages/core/net/delegate.ts | 44 +-- packages/core/net/getVideoDetails.ts | 8 +- packages/crawler/mq/exec/getVideoInfo.ts | 144 +++++---- packages/crawler/net/bulkGetVideoStats.ts | 9 +- src/annualArchive.ts | 95 ++++++ src/extractAidsFromEvocalRank.ts | 373 ++++++++++++++++++++++ 11 files changed, 694 insertions(+), 166 deletions(-) create mode 100644 src/annualArchive.ts create mode 100644 src/extractAidsFromEvocalRank.ts diff --git a/.idea/data_source_mapping.xml b/.idea/data_source_mapping.xml index fe1e0ad..e7530ed 100644 --- a/.idea/data_source_mapping.xml +++ b/.idea/data_source_mapping.xml @@ -9,6 +9,7 @@ + diff --git a/bun.lock b/bun.lock index 9a8bcfc..8def0ae 100644 --- a/bun.lock +++ b/bun.lock @@ -45,7 +45,7 @@ "name": "cf-worker", "version": "0.0.0", "dependencies": { - "@alikia/http-parser": "^1.0.2", + "@alikia/http-parser": "1.0.2", }, "devDependencies": { "@cloudflare/vitest-pool-workers": "^0.8.19", diff --git a/packages/cf-worker/package.json b/packages/cf-worker/package.json index 052631a..3d9a698 100644 --- a/packages/cf-worker/package.json +++ b/packages/cf-worker/package.json @@ -16,6 +16,6 @@ "wrangler": "^4.56.0" }, "dependencies": { - "@alikia/http-parser": "^1.0.2" + "@alikia/http-parser": "1.0.2" } } diff --git a/packages/cf-worker/src/index.ts b/packages/cf-worker/src/index.ts index b166e4f..5336f53 100644 --- a/packages/cf-worker/src/index.ts +++ b/packages/cf-worker/src/index.ts @@ -23,19 +23,11 @@ interface ProxyRequest { headers?: Record; } -interface ParsedHeaders { - status: number; - statusText: string; - headers: Headers; - headerEnd: number; -} - const CONFIG: ProxyConfig = { TIMEOUT_MS: 5000, }; const encoder = new TextEncoder(); -const decoder = new TextDecoder(); function concatUint8Arrays(...arrays: Uint8Array[]): Uint8Array { const total = arrays.reduce((sum, arr) => sum + arr.length, 0); @@ -48,31 +40,6 @@ function concatUint8Arrays(...arrays: Uint8Array[]): Uint8Array { return result; } -function parseHttpHeaders(buff: Uint8Array): ParsedHeaders | null { - const text = decoder.decode(buff); - const headerEnd = text.indexOf("\r\n\r\n"); - if (headerEnd === -1) return null; - - const lines = text.slice(0, headerEnd).split("\r\n"); - const statusMatch = lines[0].match(/HTTP\/1\.[01] (\d+) (.*)/); - if (!statusMatch) throw new Error("Invalid status line"); - - const headers = new Headers(); - for (let i = 1; i < lines.length; i++) { - const idx = lines[i].indexOf(": "); - if (idx !== -1) { - headers.append(lines[i].slice(0, idx), lines[i].slice(idx + 2)); - } - } - - return { - headerEnd, - headers, - status: Number(statusMatch[1]), - statusText: statusMatch[2], - }; -} - function withTimeout(promise: Promise, ms: number): Promise { return Promise.race([ promise, @@ -198,6 +165,16 @@ function createErrorResponse(message: string, status: number, requestId: string) ); } +function isJSON(str: string) { + if (typeof str !== "string") return false; + try { + const result = JSON.parse(str); + return typeof result === "object" && result !== null; + } catch (e) { + return false; + } +} + export default { async fetch(request: Request, _env: Env, _ctx: ExecutionContext): Promise { const requestId = crypto.randomUUID().slice(0, 8); // Track this specific request @@ -223,12 +200,14 @@ export default { } try { - console.log(`[${requestId}] Attempting handleSocket...`); const data = await withTimeout( handleSocket(targetUrl, customHeaders, requestTime), CONFIG.TIMEOUT_MS ); - console.log(`[${requestId}] Success via handleSocket (${Date.now() - requestTime}ms)`); + const json = isJSON(data.data); + console.log( + `[${requestId}] Success via handleSocket (${Date.now() - requestTime}ms) ${data.data.length} bytes, isJSON: ${json}, rawData: ${data.data}` + ); return createJsonResponse(data, requestId); } catch (socketErr: any) { console.warn( diff --git a/packages/core/log/index.ts b/packages/core/log/index.ts index 6f7ae59..577671e 100644 --- a/packages/core/log/index.ts +++ b/packages/core/log/index.ts @@ -2,46 +2,86 @@ import chalk from "chalk"; import type { TransformableInfo } from "logform"; import winston, { format, transports } from "winston"; -const customFormat = format.printf((info: TransformableInfo) => { - const { timestamp, level, message, service, codePath, error } = info; +/* ------------------------------------------------- + * Bun-style console formatter + * ------------------------------------------------- */ +function formatLikeConsole(input: unknown, colors: boolean): string { + const inspect = (value: unknown) => + Bun.inspect(value, { + colors, + compact: false, + depth: 6, + }); + + // console.log(...args) + if (Array.isArray(input)) { + return input.map(inspect).join(" "); + } + + if (input instanceof Error) { + const stack = input.stack ?? input.message; + return colors ? chalk.red(stack) : stack; + } + + if (typeof input === "string") { + return input; + } + + return inspect(input); +} + +/* ------------------------------------------------- + * Console format (colored, human-readable) + * ------------------------------------------------- */ +const customConsoleFormat = format.printf((info: TransformableInfo) => { + const { timestamp, level, message, service, codePath } = info; + const coloredService = service ? chalk.magenta(service) : ""; const coloredCodePath = codePath ? chalk.grey(`@${codePath}`) : ""; const colon = service || codePath ? ": " : ""; - const err = error as Error | undefined; - if (err) { - return `${timestamp} [${level}] ${coloredService}${colon}${message}\n${chalk.red(err.stack) ?? ""}`; - } - return coloredCodePath - ? `${timestamp} [${level}] ${coloredService}${coloredCodePath}${colon}${message}` - : `${timestamp} [${level}] ${coloredService}${colon}${message}`; + const renderedMessage = formatLikeConsole(message, true); + + return `${timestamp} [${level}] ${coloredService}${coloredCodePath}${colon}${renderedMessage}`; }); -const timestampFormat = format.timestamp({ format: "YYYY-MM-DD HH:mm:ss.SSSZZ" }); +/* ------------------------------------------------- + * Timestamp + * ------------------------------------------------- */ +const timestampFormat = format.timestamp({ + format: "YYYY-MM-DD HH:mm:ss.SSSZZ", +}); +/* ------------------------------------------------- + * File transport factory (no colors) + * ------------------------------------------------- */ const createTransport = (level: string, filename: string) => { - const MB = 1000000; - let maxsize; - let maxFiles; - let tailable; + const MB = 1_000_000; + let maxsize: number | undefined; + let maxFiles: number | undefined; + const tailable = false; + if (level === "silly") { maxsize = 500 * MB; - maxFiles = undefined; - tailable = false; } else if (level === "warn") { maxsize = 10 * MB; maxFiles = 5; - tailable = false; } - function replacer(key: unknown, value: unknown) { + + function replacer(_: unknown, value: unknown) { if (typeof value === "bigint") { return value.toString(); } - if (key === "error") { - return undefined; + if (value instanceof Error) { + return { + message: value.message, + name: value.name, + stack: value.stack, + }; } return value; } + return new transports.File({ filename, format: format.combine(timestampFormat, format.json({ replacer })), @@ -52,20 +92,21 @@ const createTransport = (level: string, filename: string) => { }); }; +/* ------------------------------------------------- + * Paths + * ------------------------------------------------- */ const sillyLogPath = process.env["LOG_VERBOSE"] ?? "logs/verbose.log"; const warnLogPath = process.env["LOG_WARN"] ?? "logs/warn.log"; const errorLogPath = process.env["LOG_ERROR"] ?? "logs/error.log"; +/* ------------------------------------------------- + * Winston logger + * ------------------------------------------------- */ const winstonLogger = winston.createLogger({ levels: winston.config.npm.levels, transports: [ new transports.Console({ - format: format.combine( - format.timestamp({ format: "YYYY-MM-DD HH:mm:ss.SSSZZ" }), - format.colorize(), - format.errors({ stack: true }), - customFormat - ), + format: format.combine(timestampFormat, format.colorize(), customConsoleFormat), level: "debug", }), createTransport("silly", sillyLogPath), @@ -74,28 +115,36 @@ const winstonLogger = winston.createLogger({ ], }); +/* ------------------------------------------------- + * Public logger API + * ------------------------------------------------- */ const logger = { - debug: (message: string, service?: string, codePath?: string) => { - winstonLogger.debug(message, { codePath, service }); + debug: (message: unknown, service?: string, codePath?: string) => { + winstonLogger.debug(message as string, { codePath, service }); }, - error: (error: string | Error, service?: string, codePath?: string) => { - if (error instanceof Error) { - winstonLogger.error(error.message, { codePath, error: error, service }); - } else { - winstonLogger.error(error, { codePath, service }); - } + + error: (message: unknown, service?: string, codePath?: string) => { + winstonLogger.error(message as string, { codePath, service }); }, - log: (message: string, service?: string, codePath?: string) => { - winstonLogger.info(message, { codePath, service }); + + info: (message: unknown, service?: string, codePath?: string) => { + winstonLogger.info(message as string, { codePath, service }); }, - silly: (message: string, service?: string, codePath?: string) => { - winstonLogger.silly(message, { codePath, service }); + + log: (message: unknown, service?: string, codePath?: string) => { + winstonLogger.info(message as string, { codePath, service }); }, - verbose: (message: string, service?: string, codePath?: string) => { - winstonLogger.verbose(message, { codePath, service }); + + silly: (message: unknown, service?: string, codePath?: string) => { + winstonLogger.silly(message as string, { codePath, service }); }, - warn: (message: string, service?: string, codePath?: string) => { - winstonLogger.warn(message, { codePath, service }); + + verbose: (message: unknown, service?: string, codePath?: string) => { + winstonLogger.verbose(message as string, { codePath, service }); + }, + + warn: (message: unknown, service?: string, codePath?: string) => { + winstonLogger.warn(message as string, { codePath, service }); }, }; diff --git a/packages/core/net/delegate.ts b/packages/core/net/delegate.ts index 6a7a441..98a9715 100644 --- a/packages/core/net/delegate.ts +++ b/packages/core/net/delegate.ts @@ -23,7 +23,7 @@ import { ReplyError } from "ioredis"; type ProxyType = "native" | "alicloud-fc" | "ip-proxy" | "cf-worker"; -const aliRegions = ["hangzhou"] as const; +const aliRegions = ["hangzhou", "beijing", "shanghai", "chengdu"] as const; type AliRegion = (typeof aliRegions)[number]; function createAliProxiesObject(regions: T) { @@ -42,8 +42,8 @@ function createAliProxiesObject(regions: T) { ); } -const _aliProxiesObject = createAliProxiesObject(aliRegions); -const _aliProxies = aliRegions.map((region) => `alicloud_${region}` as `alicloud_${AliRegion}`); +const aliProxiesObject = createAliProxiesObject(aliRegions); +const aliProxies = aliRegions.map((region) => `alicloud_${region}` as `alicloud_${AliRegion}`); const proxies = { "cf-worker": { @@ -56,6 +56,8 @@ const proxies = { data: {}, type: "native" as const, }, + + ...aliProxiesObject, } satisfies Record; interface FCResponse { @@ -152,14 +154,9 @@ bili_normal[0].max = 5; bili_normal[1].max = 40; bili_normal[2].max = 200; -const bili_strict = structuredClone(biliLimiterConfig); -bili_strict[0].max = 1; -bili_strict[1].max = 6; -bili_strict[2].max = 100; - type MyProxyKeys = keyof typeof proxies; -const _fcProxies = aliRegions.map((region) => `alicloud_${region}`) as MyProxyKeys[]; +const fcProxies = aliRegions.map((region) => `alicloud_${region}`) as MyProxyKeys[]; function createNetworkConfig( config: NetworkConfigInternal @@ -169,36 +166,41 @@ function createNetworkConfig { const url = `https://api.bilibili.com/x/web-interface/view/detail?aid=${aid}`; const { data } = await networkDelegate.request(url, "getVideoInfo"); const errMessage = `Error fetching metadata for ${aid}:`; if (data.code !== 0) { - logger.error(errMessage + data.code + "-" + data.message, "net", "fn:getVideoInfo"); + logger.error(`${errMessage + data.code}-${data.message}`, "net", "fn:getVideoInfo"); return null; } return data.data; diff --git a/packages/crawler/mq/exec/getVideoInfo.ts b/packages/crawler/mq/exec/getVideoInfo.ts index 1edcd89..f832a0e 100644 --- a/packages/crawler/mq/exec/getVideoInfo.ts +++ b/packages/crawler/mq/exec/getVideoInfo.ts @@ -1,5 +1,7 @@ import { bilibiliUser, db, videoSnapshot } from "@core/drizzle"; +import { SECOND } from "@core/lib"; import logger from "@core/log"; +import { NetSchedulerError } from "@core/net/delegate"; import { getVideoDetails } from "@core/net/getVideoDetails"; import type { Job } from "bullmq"; import { @@ -9,7 +11,7 @@ import { } from "db/bilibili_metadata"; import { eq } from "drizzle-orm"; import { snapshotCounter } from "metrics"; -import { ClassifyVideoQueue, latestVideosEventsProducer } from "mq/index"; +import { ClassifyVideoQueue, LatestVideosQueue, latestVideosEventsProducer } from "mq/index"; import type { GetVideoInfoJobData } from "mq/schema"; import { insertIntoSongs } from "mq/task/collectSongs"; @@ -46,73 +48,91 @@ export const getVideoInfoWorker = async (job: Job): Promise await publishAddsongEvent(songs[0].id, job.data.uid); return; } - const data = await getVideoDetails(aid); - if (data === null) { - return null; - } + try { + const data = await getVideoDetails(aid); + if (data === null) { + return null; + } - const uid = data.View.owner.mid; + const uid = data.View.owner.mid; - await insertIntoMetadata({ - aid, - bvid: data.View.bvid, - coverUrl: data.View.pic, - description: data.View.desc, - duration: data.View.duration, - publishedAt: new Date(data.View.pubdate * 1000).toISOString(), - tags: data.Tags.filter((tag) => !["old_channel", "topic"].indexOf(tag.tag_type)) - .map((tag) => tag.tag_name) - .join(","), - title: data.View.title, - uid: uid, - }); - - const userExists = await userExistsInBiliUsers(aid); - if (!userExists) { - await db.insert(bilibiliUser).values({ - avatar: data.View.owner.face, - desc: data.Card.card.sign, - fans: data.Card.follower, - uid, - username: data.View.owner.name, + await insertIntoMetadata({ + aid, + bvid: data.View.bvid, + coverUrl: data.View.pic, + description: data.View.desc, + duration: data.View.duration, + publishedAt: new Date(data.View.pubdate * 1000).toISOString(), + tags: data.Tags.filter((tag) => !["old_channel", "topic"].indexOf(tag.tag_type)) + .map((tag) => tag.tag_name) + .join(","), + title: data.View.title, + uid: uid, }); - } else { - await db - .update(bilibiliUser) - .set({ + + const userExists = await userExistsInBiliUsers(aid); + if (!userExists) { + await db.insert(bilibiliUser).values({ avatar: data.View.owner.face, desc: data.Card.card.sign, fans: data.Card.follower, + uid, username: data.View.owner.name, - }) - .where(eq(bilibiliUser.uid, uid)); + }); + } else { + await db + .update(bilibiliUser) + .set({ + avatar: data.View.owner.face, + desc: data.Card.card.sign, + fans: data.Card.follower, + username: data.View.owner.name, + }) + .where(eq(bilibiliUser.uid, uid)); + } + + const stat = data.View.stat; + + await db.insert(videoSnapshot).values({ + aid, + coins: stat.coin, + danmakus: stat.danmaku, + favorites: stat.favorite, + likes: stat.like, + replies: stat.reply, + shares: stat.share, + views: stat.view, + }); + + snapshotCounter.add(1); + + logger.log(`Inserted video metadata for aid: ${aid}`, "mq"); + + if (!insertSongs) { + await ClassifyVideoQueue.add("classifyVideo", { aid }); + return; + } + const songs = await insertIntoSongs(aid); + if (songs.length === 0) { + logger.warn(`Failed to insert song for aid: ${aid}`, "mq", "fn:getVideoInfoWorker"); + return; + } + await publishAddsongEvent(songs[0].id, job.data.uid); + } catch (e) { + if (e instanceof NetSchedulerError) { + await LatestVideosQueue.add( + "getVideoInfo", + { aid }, + { + attempts: 10, + backoff: { + delay: 30 * SECOND, + jitter: 1, + type: "fixed", + }, + } + ); + } + logger.error(e, "mq", "fn:getVideoInfoWorker"); } - - const stat = data.View.stat; - - await db.insert(videoSnapshot).values({ - aid, - coins: stat.coin, - danmakus: stat.danmaku, - favorites: stat.favorite, - likes: stat.like, - replies: stat.reply, - shares: stat.share, - views: stat.view, - }); - - snapshotCounter.add(1); - - logger.log(`Inserted video metadata for aid: ${aid}`, "mq"); - - if (!insertSongs) { - await ClassifyVideoQueue.add("classifyVideo", { aid }); - return; - } - const songs = await insertIntoSongs(aid); - if (songs.length === 0) { - logger.warn(`Failed to insert song for aid: ${aid}`, "mq", "fn:getVideoInfoWorker"); - return; - } - await publishAddsongEvent(songs[0].id, job.data.uid); }; diff --git a/packages/crawler/net/bulkGetVideoStats.ts b/packages/crawler/net/bulkGetVideoStats.ts index f4f7bb8..29101bc 100644 --- a/packages/crawler/net/bulkGetVideoStats.ts +++ b/packages/crawler/net/bulkGetVideoStats.ts @@ -1,6 +1,6 @@ import logger from "@core/log"; import type { MediaListInfoData, MediaListInfoResponse } from "@core/net/bilibili.d"; -import networkDelegate from "@core/net/delegate"; +import networkDelegate, { type RequestTasks } from "@core/net/delegate"; /* * Bulk fetch video metadata from bilibili API @@ -11,7 +11,10 @@ import networkDelegate from "@core/net/delegate"; * - The native `fetch` function threw an error: with error code `FETCH_ERROR` * - The alicloud-fc threw an error: with error code `ALICLOUD_FC_ERROR` */ -export async function bulkGetVideoStats(aids: number[]): Promise< +export async function bulkGetVideoStats( + aids: number[], + task?: RequestTasks +): Promise< | { data: MediaListInfoData; time: number; @@ -25,7 +28,7 @@ export async function bulkGetVideoStats(aids: number[]): Promise< } const { data, time } = await networkDelegate.request( url, - "bulkSnapshot" + task ?? "bulkSnapshot" ); const errMessage = `Error fetching metadata for aid list: ${aids.join(",")}:`; if (data.code !== 0) { diff --git a/src/annualArchive.ts b/src/annualArchive.ts new file mode 100644 index 0000000..3dcd11e --- /dev/null +++ b/src/annualArchive.ts @@ -0,0 +1,95 @@ +import { bilibiliMetadata, db, eta, type VideoSnapshotType } from "@core/drizzle"; +import { SECOND } from "@core/lib"; +import logger from "@core/log"; +import { NetSchedulerError } from "@core/net/delegate"; +import { bulkGetVideoStats } from "@crawler/net/bulkGetVideoStats"; +import { desc, eq } from "drizzle-orm"; + +const store = Bun.file(`temp/annualSnapshots.json`); + +const snapshots: Omit[] = []; + +if (await store.exists()) { + // load +} + +const aids = await db + .select({ + aid: bilibiliMetadata.aid, + }) + .from(bilibiliMetadata) + .leftJoin(eta, eq(bilibiliMetadata.aid, eta.aid)) + .orderBy(desc(eta.speed)) + .then((rows) => { + const mapped = rows.map((row) => row.aid); + return mapped.filter((item): item is number => item !== null); + }); + +const totalAids = aids.length; + +logger.log(`Total aids: ${totalAids}`); + +const bulkSize = 50; + +const groupedAids = []; +for (let i = 0; i < aids.length; i += bulkSize) { + groupedAids.push(aids.slice(i, i + bulkSize)); +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const serialize = async () => { + const json = JSON.stringify(snapshots, null, 4); + await store.write(json); +}; + +let aidsProcessed = 0; + +const requestForSnapshot = async (aids: number[], depth: number = 0) => { + if (depth > 10) { + logger.error(`Cannot fetch metadata for aids: ${aids.join(",")}, depth: ${depth}`); + return; + } + try { + const rawData = await bulkGetVideoStats(aids, "annualArchive"); + logger.log(`Fetched metadata for ${aids.length} aids, depth: ${depth}`); + if (typeof rawData === "number") { + await sleep(2 * SECOND); + return requestForSnapshot(aids, depth + 1); + } + for (const item of rawData.data) { + snapshots.push({ + aid: item.id, + coins: item.cnt_info.coin, + createdAt: new Date(rawData.time).toISOString(), + danmakus: item.cnt_info.danmaku, + favorites: item.cnt_info.collect, + likes: item.cnt_info.thumb_up, + replies: item.cnt_info.reply, + shares: item.cnt_info.share, + views: item.cnt_info.play, + }); + aidsProcessed += 1; + } + } catch (e) { + if (e instanceof NetSchedulerError) { + requestForSnapshot(aids, 1); + } + } +}; + +const taskFactories = groupedAids.map((group) => () => requestForSnapshot(group)); + +const concurrency = 100; + +for (let i = 0; i < taskFactories.length; i += concurrency) { + const batch = taskFactories.slice(i, i + concurrency); + + await Promise.all(batch.map((factory) => factory())); + + logger.log(`Processed ${aidsProcessed} of ${totalAids}`); + await sleep(1.7 * SECOND); + serialize(); +} +await serialize(); +process.exit(0); diff --git a/src/extractAidsFromEvocalRank.ts b/src/extractAidsFromEvocalRank.ts new file mode 100644 index 0000000..96c462f --- /dev/null +++ b/src/extractAidsFromEvocalRank.ts @@ -0,0 +1,373 @@ +export interface Root { + version: number; + ranknum: number; + url: string; + coverurl: string; + pubdate: string; + generate_time: string; + generate_timestamp: number; + collect_start_time: string; + collect_end_time: string; + collect_start_time_timestamp: number; + collect_end_time_timestamp: number; + main_rank: MainRank[]; + second_rank: SecondRank[]; + super_hit: SuperHit[]; + pick_up: PickUp[]; + oth_pickup: any[]; + Vocaloid_pick_up: VocaloidPickUp[]; + "history-1-year": History1Year[]; + "history-10-year": History10Year[]; + ed: Ed[]; + op: Op[]; + statistic: Statistic; + thanks_list: any[]; +} + +export interface MainRank { + url: string; + avid: string; + coverurl: string; + title: string; + pubdate: any; + point: number; + play: number; + coin: number; + comment: number; + danmaku: number; + favorite: number; + like: number; + share: number; + referSource: ReferSource; + rank: number; + ext_rank: ExtRank; +} + +export interface ReferSource { + point: number; + play: number; + coin: number; + comment: number; + danmaku: number; + favorite: number; + like: number; + share: number; +} + +export interface ExtRank { + vocaloid?: number; +} + +export interface SecondRank { + url: string; + avid: string; + coverurl: string; + title: string; + pubdate: string; + point: number; + play: number; + coin: number; + comment: number; + danmaku: number; + favorite: number; + like: number; + share: number; + referSource: ReferSource2; + rank: number; + ext_rank: ExtRank2; +} + +export interface ReferSource2 { + point: number; + play: number; + coin: number; + comment: number; + danmaku: number; + favorite: number; + like: number; + share: number; +} + +export interface ExtRank2 { + vocaloid?: number; +} + +export interface SuperHit { + url: string; + avid: string; + coverurl: string; + title: string; + pubdate: string; + point: number; + play: number; + coin: number; + comment: number; + danmaku: number; + favorite: number; + like: number; + share: number; + referSource: ReferSource3; + superHit_times: number; + rank: string; +} + +export interface ReferSource3 { + point: number; + play: number; + coin: number; + comment: number; + danmaku: number; + favorite: number; + like: number; + share: number; +} + +export interface PickUp { + url: string; + avid: string; + coverurl: string; + title: string; + pubdate: string; + point: number; + play: number; + coin: number; + comment: number; + danmaku: number; + favorite: number; + like: number; + share: number; + referSource: ReferSource4; + rank: number; + ext_rank: ExtRank4; +} + +export interface ReferSource4 { + point: number; + play: number; + coin: number; + comment: number; + danmaku: number; + favorite: number; + like: number; + share: number; +} + +export interface ExtRank4 { + vocaloid?: number; +} + +export interface VocaloidPickUp { + url: string; + avid: string; + coverurl: string; + title: string; + pubdate: string; + point: number; + play: number; + coin: number; + comment: number; + danmaku: number; + favorite: number; + like: number; + share: number; + referSource: ReferSource5; + rank: number; + ext_rank: ExtRank5; +} + +export interface ReferSource5 { + point: number; + play: number; + coin: number; + comment: number; + danmaku: number; + favorite: number; + like: number; + share: number; +} + +export interface ExtRank5 { + vocaloid: number; +} + +export interface History1Year { + url: string; + avid: string; + coverurl: string; + title: string; + pubdate: string; + point: number; + play: number; + coin: number; + comment: number; + danmaku: number; + favorite: number; + like: number; + share: number; + referSource: ReferSource6; + rank: number; + ext_rank: ExtRank6; +} + +export interface ReferSource6 { + point: number; + play: number; + coin: number; + comment: number; + danmaku: number; + favorite: number; + like: number; + share: number; +} + +export interface ExtRank6 { + vocaloid?: number; +} + +export interface History10Year { + url: string; + avid: string; + coverurl: string; + title: string; + pubdate: string; + point: number; + play: number; + coin: number; + comment: number; + danmaku: number; + favorite: number; + like: number; + share: number; + referSource: ReferSource7; + rank: number; +} + +export interface ReferSource7 { + point: number; + play: number; + coin: number; + comment: number; + danmaku: number; + favorite: number; + like: number; + share: number; +} + +export interface Ed { + url: string; + avid: string; + coverurl: string; + title: string; + pubdate: string; +} + +export interface Op { + url: string; + avid: string; + coverurl: string; + title: string; + pubdate: string; +} + +export interface Statistic { + diff: Diff; + total_collect_count: number; + new_video_count: number; + new_in_rank_count: number; + new_in_mainrank_count: number; + pick_up_count: number; + oth_pick_up_count: number; + new_vc_in_rank_count: number; + new_vc_in_mainrank_count: number; + vc_in_rank_count: number; + vc_in_mainrank_count: number; + new_sv_in_rank_count: number; + new_sv_in_mainrank_count: number; + sv_in_rank_count: number; + sv_in_mainrank_count: number; + new_ace_in_rank_count: number; + new_ace_in_mainrank_count: number; + ace_in_rank_count: number; + ace_in_mainrank_count: number; +} + +export interface Diff { + total_play: number; + new_video_count: number; + new_in_rank_count: number; + new_in_mainrank_count: number; + new_vc_in_rank_count: number; + new_vc_in_mainrank_count: number; + vc_in_rank_count: number; + vc_in_mainrank_count: number; + new_sv_in_rank_count: number; + new_sv_in_mainrank_count: number; + sv_in_rank_count: number; + sv_in_mainrank_count: number; + new_ace_in_rank_count: number; + new_ace_in_mainrank_count: number; + ace_in_rank_count: number; + ace_in_mainrank_count: number; +} + +const aids = new Set(); +const f = Bun.file("evocalrank.json"); + +for (let i = 699; i >= 520; i--) { + const url = `https://www.evocalrank.com/data/rank_data/${i}.json`; + const response = await fetch(url); + const data = await response.json() as Partial; + if (data.main_rank) { + for (const item of data.main_rank) { + if (item.avid) { + aids.add(item.avid); + } + } + } + if (data.second_rank) { + for (const item of data.second_rank) { + if (item.avid) { + aids.add(item.avid); + } + } + } + if (data.pick_up) { + for (const item of data.pick_up) { + if (item.avid) { + aids.add(item.avid); + } + } + } + if (data.super_hit) { + for (const item of data.super_hit) { + if (item.avid) { + aids.add(item.avid); + } + } + } + if (data.Vocaloid_pick_up) { + for (const item of data.Vocaloid_pick_up) { + if (item.avid) { + aids.add(item.avid); + } + } + } + if (data.ed) { + for (const item of data.ed) { + if (item.avid) { + aids.add(item.avid); + } + } + } + if (data.op) { + for (const item of data.op) { + if (item.avid) { + aids.add(item.avid); + } + } + } + + const serialized = JSON.stringify([...aids], null, 4); + await f.write(serialized); + console.log(`${i} ${aids.size}`); +}