Skip to content

Instantly share code, notes, and snippets.

@mdashlw
Created April 1, 2025 12:38
Show Gist options
  • Select an option

  • Save mdashlw/34d416c262094d57ae1effb827c3601f to your computer and use it in GitHub Desktop.

Select an option

Save mdashlw/34d416c262094d57ae1effb827c3601f to your computer and use it in GitHub Desktop.
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