Created
April 1, 2025 12:38
-
-
Save mdashlw/34d416c262094d57ae1effb827c3601f to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import crypto from "node:crypto"; | |
| import process from "node:process"; | |
| import { setTimeout } from "node:timers/promises"; | |
| import { parseArgs } from "node:util"; | |
| /** | |
| * Prerequisites: Node.js | |
| * Usage: node --experimental-strip-types batch-batch-tagger.ts (args...) | |
| * See below for args syntax and example. | |
| * | |
| * Extract cookies and csrf using the browser DevTools. Cookie string looks like | |
| * "_ses=d..............; user_remember_me=...; _philomena_key=XCP..." | |
| * | |
| * Tip: _ses cookie is the fingerprint. It may be a good idea to use a fake one | |
| * to easily find your tag changes in case you mess up. | |
| */ | |
| const USAGE_ARGS = | |
| "--booru <host> --cookie <string> --csrf <token> --query <search> --filter <id> --tags <string> --batch <size> --delay <ms>"; | |
| const EXAMPLE_USAGE_ARGS = | |
| '--booru https://derpibooru.org --cookie "..." --csrf "..." --query "(filly || colt), -foal" --filter 56027 --tags "foal" --batch 1000 --delay 20000'; | |
| const USER_AGENT = "batch-batch-tagger/1.0 (by mdashlw)"; | |
| const { values: args } = parseArgs({ | |
| options: { | |
| booru: { | |
| type: "string", | |
| }, | |
| cookie: { | |
| type: "string", | |
| }, | |
| csrf: { | |
| type: "string", | |
| }, | |
| query: { | |
| type: "string", | |
| }, | |
| filter: { | |
| type: "string", | |
| }, | |
| tags: { | |
| type: "string", | |
| }, | |
| batch: { | |
| type: "string", | |
| }, | |
| delay: { | |
| type: "string", | |
| }, | |
| }, | |
| }); | |
| if ( | |
| !args.booru || | |
| !args.cookie || | |
| !args.csrf || | |
| !args.query || | |
| !args.filter || | |
| !args.tags || | |
| !args.batch || | |
| !args.delay | |
| ) { | |
| console.error("Args:", USAGE_ARGS); | |
| console.error("Example:", EXAMPLE_USAGE_ARGS); | |
| process.exit(1); | |
| } | |
| if (!URL.canParse(args.booru)) { | |
| console.error("Invalid --booru host"); | |
| process.exit(1); | |
| } | |
| const baseUrl = new URL(args.booru); | |
| const batchSize = Number(args.batch); | |
| const delayMs = Number(args.delay); | |
| if (baseUrl.pathname !== "/") { | |
| console.error("Invalid --booru host"); | |
| process.exit(1); | |
| } | |
| if ( | |
| Number.isNaN(batchSize) || | |
| !Number.isSafeInteger(batchSize) || | |
| batchSize <= 0 | |
| ) { | |
| console.error("Invalid --batch size"); | |
| process.exit(1); | |
| } | |
| if (Number.isNaN(delayMs) || !Number.isSafeInteger(delayMs) || delayMs <= 100) { | |
| console.error("Invalid --delay ms"); | |
| process.exit(1); | |
| } | |
| async function handleResponseError(resp: Response) { | |
| if (!resp.ok) { | |
| const jsonOrNothing = await resp.json().catch(() => null); | |
| throw new Error( | |
| `Response: ${resp.status} ${resp.statusText} (${JSON.stringify(jsonOrNothing)})`, | |
| ); | |
| } | |
| } | |
| const runId = crypto.randomUUID(); | |
| const userAgent = `${USER_AGENT} B/${runId}`; | |
| type Image = { | |
| id: number; | |
| }; | |
| let total = 0; | |
| async function* images(chunkSize: number) { | |
| let chunk: Image[] = []; | |
| for (let page = 1; ; ++page) { | |
| const url = new URL("/api/v1/json/search/images", baseUrl); | |
| url.searchParams.set("filter_id", args.filter!); | |
| url.searchParams.set("per_page", `${50}`); | |
| url.searchParams.set("sf", "id"); | |
| url.searchParams.set("sd", "asc"); | |
| url.searchParams.set("q", args.query!); | |
| url.searchParams.set("page", `${page}`); | |
| const resp = await fetch(url, { | |
| headers: { | |
| "user-agent": userAgent, | |
| }, | |
| }); | |
| await handleResponseError(resp); | |
| const json = (await resp.json()) as { total: number; images: Image[] }; | |
| if (!json.images.length) { | |
| break; | |
| } | |
| if (!total) { | |
| total = json.total; | |
| } | |
| chunk.push(...json.images); | |
| if (chunk.length >= chunkSize) { | |
| yield chunk; | |
| chunk = []; | |
| page = 0; | |
| } | |
| await setTimeout(500); | |
| } | |
| if (chunk.length) { | |
| yield chunk; | |
| } | |
| } | |
| async function batchTag(imageIds: number[], tags: string) { | |
| const resp = await fetch(new URL("/admin/batch/tags", baseUrl), { | |
| method: "PUT", | |
| body: JSON.stringify({ | |
| tags, | |
| image_ids: imageIds.map((id) => id.toString()), | |
| _method: "PUT", | |
| }), | |
| headers: { | |
| cookie: args.cookie!, | |
| "x-csrf-token": args.csrf!, | |
| "x-requested-with": "xmlhttprequest", | |
| "user-agent": userAgent, | |
| "content-type": "application/json", | |
| }, | |
| }); | |
| await handleResponseError(resp); | |
| const json = (await resp.json()) as { failed: number[]; succeeded: number[] }; | |
| return json; | |
| } | |
| let chunkNumber = 0; | |
| for await (const chunk of images(batchSize)) { | |
| console.log( | |
| `Batch-tagging chunk #${++chunkNumber}... Progress so far: ${(chunkNumber - 1) * batchSize}/${total}`, | |
| ); | |
| const { failed, succeeded } = await batchTag( | |
| chunk.map((i) => i.id), | |
| args.tags, | |
| ); | |
| console.log(`Succeeded: ${succeeded.length}, failed: ${failed.length}`); | |
| console.log(`Sleeping for ${(delayMs / 1_000).toFixed(1)}s...`); | |
| await setTimeout(delayMs); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment