Select multiple wallpapers for batch download and batch bookmark on wallhaven.cc
// ==UserScript==
// @name Wallhaven Batch Tools
// @namespace https://wallhaven.cc/
// @version 1.0.1
// @description Select multiple wallpapers for batch download and batch bookmark on wallhaven.cc
// @author saltyegg
// @license MIT
// @match https://wallhaven.cc/*
// @grant GM_getValue
// @grant GM_setValue
// @run-at document-end
// @icon https://wallhaven.cc/favicon.ico
// ==/UserScript==
(function () {
"use strict";
// Only activate on listing pages (search, latest, toplist, collections, etc.)
if (!document.querySelector(".thumb-listing-page")) return;
// ─── State ────────────────────────────────────────────────────────────────
const state = {
selectMode: false,
selected: new Set(), // Set of wallpaper ID strings
apiKey: GM_getValue("wbt_apikey", ""),
username: GM_getValue("wbt_username", ""),
collections: [],
chosenColId: "",
};
// ─── Wallpaper helpers ────────────────────────────────────────────────────
function getWpId(li) {
const fig = li.querySelector("figure[data-wallpaper-id]");
return fig ? fig.dataset.wallpaperId : null;
}
function getWpUrl(id, li) {
const pre = id.substring(0, 2);
const ext = li.querySelector("span.png") ? "png" : "jpg";
return `https://w.wallhaven.cc/full/${pre}/wallhaven-${id}.${ext}`;
}
function allLiItems() {
return Array.from(document.querySelectorAll(".thumb-listing-page li"));
}
// ─── API helpers ──────────────────────────────────────────────────────────
async function apiFetch(path, options = {}) {
const sep = path.includes("?") ? "&" : "?";
const res = await fetch(
`https://wallhaven.cc${path}${sep}apikey=${state.apiKey}`,
options,
);
if (!res.ok) {
const text = await res.text();
throw new Error(`HTTP ${res.status}: ${text.slice(0, 200)}`);
}
return res.json();
}
// Extract username from the page header (no API call needed)
function getUsernameFromPage() {
const links = document.querySelectorAll("#header a, nav a, .topbar a");
for (const link of links) {
const m = (link.href || "").match(/wallhaven\.cc\/user\/([^/?#\s]+)/);
if (m) return m[1];
}
return "";
}
async function fetchUsernameFromApi() {
const data = await apiFetch("/api/v1/user/settings");
return data.data?.username || "";
}
async function fetchCollections() {
const data = await apiFetch("/api/v1/collections");
state.collections = data.data || [];
return state.collections;
}
// ─── Web-interface bookmark (session-based, no API write endpoint exists) ──
// URL templates cached after the first successful probe, reused for the whole batch.
// __WP__ = wallpaper ID placeholder, __COL__ = collection ID placeholder.
const bk = {
addFavTemplate: null, // for already-favorited wallpapers: POST to assign collection
favTemplate: null, // for not-yet-favorited wallpapers: POST to add to favorites
moveUrl: null, // drag-drop endpoint (.collections-list[data-target])
};
function csrf() {
return document.querySelector('meta[name="csrf-token"]')?.content || "";
}
async function webPost(url, body) {
const headers = {
"X-CSRF-TOKEN": csrf(),
"X-Requested-With": "XMLHttpRequest",
Accept: "application/json",
};
if (body) headers["Content-Type"] = "application/x-www-form-urlencoded";
const res = await fetch(url, {
method: "POST",
headers,
body: body ? new URLSearchParams(body).toString() : undefined,
credentials: "include",
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json().catch(() => ({ status: false }));
}
// Fetch a wallpaper page while logged in and extract URL templates from its HTML.
// .add-fav links are server-rendered per-collection assignment links.
// .collections-list[data-target] is JS-rendered so often absent in the static fetch.
async function probeWallpaperPage(wallpaperId) {
const res = await fetch(`/w/${wallpaperId}`, { credentials: "include" });
const html = await res.text();
const doc = new DOMParser().parseFromString(html, "text/html");
const addFavLinks = [...doc.querySelectorAll(".add-fav[href]")];
const favBtnHref = doc
.querySelector("#fav-button[href]")
?.getAttribute("href");
const colListTarget = doc.querySelector(".collections-list[data-target]")
?.dataset.target;
console.log(
"[WBT] probe",
wallpaperId,
"| status:",
res.status,
"| .add-button:",
!!doc.querySelector(".add-button"),
"| .add-fav count:",
addFavLinks.length,
"| .add-fav hrefs:",
addFavLinks.map((l) => l.getAttribute("href")),
"| #fav-button[href]:",
favBtnHref,
"| .collections-list[data-target]:",
colListTarget,
);
// Extract addFavTemplate from .add-fav links.
// Each link's href encodes both the wallpaper ID and a collection ID,
// e.g. /favorites/gw2ewl/add/672724 → template: /favorites/__WP__/add/__COL__
if (!bk.addFavTemplate && addFavLinks.length > 0) {
for (const link of addFavLinks) {
const href = link.getAttribute("href");
// Try to match a known collection ID in the href
for (const col of state.collections) {
if (href.includes(String(col.id))) {
bk.addFavTemplate = href
.replace(wallpaperId, "__WP__")
.replace(String(col.id), "__COL__");
break;
}
}
// Fallback: replace wallpaper ID only; collection slot is whatever remains
if (!bk.addFavTemplate) {
bk.addFavTemplate = href.replace(wallpaperId, "__WP__");
// We'll substitute __COL__ at a trailing numeric segment when using it
}
if (bk.addFavTemplate) break;
}
}
// favTemplate: #fav-button is a plain anchor when the wallpaper is NOT in favorites
if (!bk.favTemplate && favBtnHref) {
bk.favTemplate = favBtnHref.replace(wallpaperId, "__WP__");
}
// moveUrl: drag-drop collection assignment (only present in JS-rendered pages)
if (!bk.moveUrl && colListTarget) {
bk.moveUrl = colListTarget;
}
}
async function addOneToCollection(wallpaperId, collectionId) {
const colIdStr = String(collectionId);
// ── Fast path: use cached templates ────────────────────────────────────
if (bk.addFavTemplate) {
const url = bk.addFavTemplate
.replace("__WP__", wallpaperId)
.replace("__COL__", colIdStr);
const d = await webPost(url);
if (d.status) return;
}
if (bk.moveUrl) {
const d = await webPost(bk.moveUrl, {
_token: csrf(),
wallpaper_id: wallpaperId,
collection_id: collectionId,
});
if (d.status) return;
}
// ── Probe to populate templates ─────────────────────────────────────────
await probeWallpaperPage(wallpaperId);
if (bk.addFavTemplate) {
const url = bk.addFavTemplate
.replace("__WP__", wallpaperId)
.replace("__COL__", colIdStr);
const d = await webPost(url);
if (d.status) return;
}
if (bk.moveUrl) {
const d = await webPost(bk.moveUrl, {
_token: csrf(),
wallpaper_id: wallpaperId,
collection_id: collectionId,
});
if (d.status) return;
}
// ── Not yet in favorites — add first, then assign ───────────────────────
if (!bk.favTemplate) {
throw new Error(
`${wallpaperId}: no endpoint detected — see [WBT] probe in console`,
);
}
const favUrl = bk.favTemplate.replace("__WP__", wallpaperId);
const favData = await webPost(favUrl);
if (!favData.status)
throw new Error(`${wallpaperId}: failed to add to favorites`);
// After favoriting, the server returns {status, view} where view is the updated
// button HTML — it may now contain .add-fav links we can use
if (favData.view) {
const vd = new DOMParser().parseFromString(favData.view, "text/html");
const newLinks = [...vd.querySelectorAll(".add-fav[href]")];
// Find direct match or derive template
const match = newLinks.find((l) =>
l.getAttribute("href").includes(colIdStr),
);
if (match) {
const d = await webPost(match.getAttribute("href"));
if (d.status) return;
}
if (!bk.addFavTemplate && newLinks.length > 0) {
const href = newLinks[0].getAttribute("href");
for (const col of state.collections) {
if (href.includes(String(col.id))) {
bk.addFavTemplate = href
.replace(wallpaperId, "__WP__")
.replace(String(col.id), "__COL__");
break;
}
}
}
}
if (bk.addFavTemplate) {
const url = bk.addFavTemplate
.replace("__WP__", wallpaperId)
.replace("__COL__", colIdStr);
const d = await webPost(url);
if (d.status) return;
}
throw new Error(`${wallpaperId}: all bookmark strategies failed`);
}
async function batchBookmark(collectionId, ids) {
let ok = 0,
fail = 0;
for (let i = 0; i < ids.length; i++) {
setStatus(`Bookmarking ${i + 1} / ${ids.length}…`);
try {
await addOneToCollection(ids[i], collectionId);
ok++;
} catch (e) {
fail++;
console.warn("[WBT] bookmark failed for", ids[i], e.message);
}
if (i < ids.length - 1) await sleep(200);
}
return { ok, fail };
}
// ─── Download ─────────────────────────────────────────────────────────────
function downloadImage(url, filename) {
return new Promise((resolve) => {
const xhr = new XMLHttpRequest();
xhr.open("GET", url, true);
xhr.responseType = "blob";
xhr.onload = () => {
const blobUrl = URL.createObjectURL(xhr.response);
const a = document.createElement("a");
a.href = blobUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(blobUrl), 15000);
resolve();
};
xhr.onerror = () => resolve(); // skip on error, don't stall queue
xhr.send();
});
}
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
async function batchDownload() {
const ids = [...state.selected];
if (!ids.length) return;
// Build id→li map for URL resolution
const liMap = {};
allLiItems().forEach((li) => {
const id = getWpId(li);
if (id) liMap[id] = li;
});
dlBtn.disabled = true;
for (let i = 0; i < ids.length; i++) {
const id = ids[i];
const li = liMap[id];
if (!li) continue;
setStatus(`Downloading ${i + 1} / ${ids.length}…`);
await downloadImage(getWpUrl(id, li), `wallhaven-${id}`);
// Small gap between downloads to avoid browser tab overload
if (i < ids.length - 1) await sleep(350);
}
setStatus(`Downloaded ${ids.length} wallpaper(s) ✓`);
dlBtn.disabled = false;
}
// ─── Selection UI ─────────────────────────────────────────────────────────
function ensureCheckbox(li) {
if (li.querySelector(".wbt-check")) return;
const check = document.createElement("span");
check.className = "wbt-check";
check.textContent = "✓";
li.appendChild(check);
// Capture phase so we intercept before wallhaven's own click handlers
li.addEventListener(
"click",
(e) => {
if (!state.selectMode) return;
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
const id = getWpId(li);
if (id) toggleSelect(id, li);
},
true,
);
}
function toggleSelect(id, li) {
if (state.selected.has(id)) {
state.selected.delete(id);
li.classList.remove("wbt-sel");
} else {
state.selected.add(id);
li.classList.add("wbt-sel");
}
syncToolbar();
}
function selectAll() {
allLiItems().forEach((li) => {
const id = getWpId(li);
if (id) {
state.selected.add(id);
li.classList.add("wbt-sel");
}
});
syncToolbar();
}
function deselectAll() {
state.selected.clear();
document
.querySelectorAll(".wbt-sel")
.forEach((el) => el.classList.remove("wbt-sel"));
syncToolbar();
}
function initOverlays() {
allLiItems().forEach((li) => ensureCheckbox(li));
}
// ─── Toolbar ──────────────────────────────────────────────────────────────
let statusEl, modeBtn, dlBtn, bkBtn, colSelect;
function buildToolbar() {
const bar = document.createElement("div");
bar.id = "wbt-bar";
bar.innerHTML = `
<span id="wbt-status">Batch Tools</span>
<button id="wbt-mode-btn">☐ Select</button>
<button id="wbt-all-btn">All</button>
<button id="wbt-none-btn">None</button>
<button id="wbt-dl-btn" disabled>⬇ Download (0)</button>
<select id="wbt-col-sel"><option value="">— Collection —</option></select>
<button id="wbt-bk-btn" disabled>🔖 Bookmark (0)</button>
<button id="wbt-cfg-btn" title="Settings">⚙</button>
`;
document.body.appendChild(bar);
statusEl = document.getElementById("wbt-status");
modeBtn = document.getElementById("wbt-mode-btn");
dlBtn = document.getElementById("wbt-dl-btn");
bkBtn = document.getElementById("wbt-bk-btn");
colSelect = document.getElementById("wbt-col-sel");
modeBtn.addEventListener("click", toggleMode);
document.getElementById("wbt-all-btn").addEventListener("click", selectAll);
document
.getElementById("wbt-none-btn")
.addEventListener("click", deselectAll);
dlBtn.addEventListener("click", batchDownload);
bkBtn.addEventListener("click", handleBookmark);
document
.getElementById("wbt-cfg-btn")
.addEventListener("click", openSettings);
colSelect.addEventListener("change", () => {
state.chosenColId = colSelect.value;
syncToolbar();
});
}
function toggleMode() {
state.selectMode = !state.selectMode;
modeBtn.textContent = state.selectMode ? "☑ Select ON" : "☐ Select";
modeBtn.classList.toggle("wbt-on", state.selectMode);
document.body.classList.toggle("wbt-selecting", state.selectMode);
if (!state.selectMode) deselectAll();
initOverlays();
}
function syncToolbar() {
const n = state.selected.size;
dlBtn.textContent = `⬇ Download (${n})`;
dlBtn.disabled = n === 0;
bkBtn.textContent = `🔖 Bookmark (${n})`;
bkBtn.disabled = n === 0 || !state.chosenColId;
}
function setStatus(msg) {
statusEl.textContent = msg;
}
function fillCollectionSelect(cols) {
colSelect.innerHTML = '<option value="">— Collection —</option>';
cols.forEach((col) => {
const opt = document.createElement("option");
opt.value = col.id;
opt.textContent = `${col.label} (${col.count || 0})`;
colSelect.appendChild(opt);
});
}
// ─── Bookmark ─────────────────────────────────────────────────────────────
async function handleBookmark() {
if (!state.chosenColId) {
setStatus("Select a collection first");
return;
}
const ids = [...state.selected];
setStatus(`Bookmarking ${ids.length}…`);
bkBtn.disabled = true;
try {
const { ok, fail } = await batchBookmark(state.chosenColId, ids);
setStatus(
fail > 0
? `Done: ${ok} bookmarked, ${fail} failed (see console)`
: `Bookmarked ${ok} wallpaper(s) ✓`,
);
} catch (e) {
setStatus(`Bookmark error: ${e.message}`);
console.error("[WBT] bookmark error", e);
}
bkBtn.disabled = false;
syncToolbar();
}
// ─── Settings modal ───────────────────────────────────────────────────────
function openSettings() {
const existing = document.getElementById("wbt-modal");
if (existing) {
existing.remove();
return;
}
const modal = document.createElement("div");
modal.id = "wbt-modal";
modal.innerHTML = `
<div id="wbt-mbox">
<button id="wbt-mclose">✕</button>
<h3>Wallhaven Batch Tools — Settings</h3>
<label>
API Key
<input id="wbt-api-input" type="text"
value="${state.apiKey}"
placeholder="Your wallhaven.cc API key"
autocomplete="off" spellcheck="false" />
</label>
<p class="wbt-hint">
Get your key at
<a href="https://wallhaven.cc/settings/account" target="_blank">
Settings → Account
</a>
(scroll to "API Key")
</p>
<div id="wbt-mbtn-row">
<button id="wbt-api-save">Save & Load Collections</button>
</div>
<div id="wbt-minfo"></div>
</div>
`;
document.body.appendChild(modal);
modal.addEventListener("click", (e) => {
if (e.target === modal) modal.remove();
});
document
.getElementById("wbt-mclose")
.addEventListener("click", () => modal.remove());
document
.getElementById("wbt-api-save")
.addEventListener("click", saveAndLoadCollections);
}
async function saveAndLoadCollections() {
const key = document.getElementById("wbt-api-input").value.trim();
if (!key) return;
state.apiKey = key;
GM_setValue("wbt_apikey", key);
const infoEl = document.getElementById("wbt-minfo");
infoEl.textContent = "Loading…";
try {
// Prefer pulling username from the live page DOM (no extra API call)
let username = getUsernameFromPage();
if (!username) username = await fetchUsernameFromApi();
state.username = username;
GM_setValue("wbt_username", username);
const cols = await fetchCollections();
fillCollectionSelect(cols);
infoEl.innerHTML = `Logged in as <strong>${username}</strong> — ${cols.length} collection(s) loaded`;
setStatus(`Ready — ${cols.length} collections`);
} catch (e) {
infoEl.textContent = `Error: ${e.message}`;
console.error("[WBT] settings load error", e);
}
}
// ─── Watch for new cards (infinite scroll / pagination) ───────────────────
function watchForNewCards() {
const ul = document.querySelector(".thumb-listing-page ul");
if (!ul) return;
new MutationObserver(() => {
if (state.selectMode) initOverlays();
}).observe(ul, { childList: true });
}
// ─── CSS ──────────────────────────────────────────────────────────────────
function injectCSS() {
const style = document.createElement("style");
style.textContent = `
/* ── Floating toolbar ── */
#wbt-bar {
position: fixed; bottom: 18px; left: 50%;
transform: translateX(-50%); z-index: 99999;
display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
background: rgba(14,14,14,0.94);
padding: 8px 14px; border-radius: 10px;
box-shadow: 0 4px 28px rgba(0,0,0,0.65);
font: 13px/1 'Segoe UI', Arial, sans-serif;
color: #ddd; user-select: none;
backdrop-filter: blur(8px);
max-width: 92vw;
}
#wbt-bar button {
background: #272727; color: #ddd; border: 1px solid #3e3e3e;
border-radius: 5px; padding: 5px 10px; cursor: pointer;
font-size: 12px; white-space: nowrap;
transition: background .15s;
}
#wbt-bar button:hover:not(:disabled) { background: #353535; }
#wbt-bar button:disabled { opacity: .35; cursor: default; }
#wbt-bar button.wbt-on { background: #1a6ee8; border-color: #1a6ee8; color: #fff; }
#wbt-bar select {
background: #272727; color: #ddd; border: 1px solid #3e3e3e;
border-radius: 5px; padding: 5px 7px; font-size: 12px;
cursor: pointer; max-width: 170px;
}
#wbt-status { font-size: 11px; color: #888; min-width: 110px; }
/* ── Per-thumbnail checkbox ── */
.thumb-listing-page li { position: relative; }
.wbt-check {
position: absolute; top: 6px; left: 6px;
width: 20px; height: 20px; line-height: 18px; text-align: center;
background: rgba(0,0,0,0.52); border: 2px solid rgba(255,255,255,0.45);
border-radius: 4px; font-size: 13px; font-weight: bold;
color: transparent; z-index: 30; display: none; pointer-events: none;
transition: background .1s, color .1s;
}
.wbt-selecting .wbt-check { display: block; }
.wbt-sel .wbt-check { background: #1a6ee8; border-color: #1a6ee8; color: #fff; }
/* Highlight border on selected card */
.wbt-sel figure { outline: 3px solid #1a6ee8; outline-offset: -3px; border-radius: 3px; }
/* Pointer cursor while in select mode */
.wbt-selecting .thumb-listing-page li { cursor: pointer; }
/* ── Settings modal ── */
#wbt-modal {
position: fixed; inset: 0; z-index: 100000;
background: rgba(0,0,0,0.68);
display: flex; align-items: center; justify-content: center;
}
#wbt-mbox {
background: #181818; color: #ddd;
border-radius: 10px; padding: 26px 30px; width: 420px; max-width: 94vw;
box-shadow: 0 8px 44px rgba(0,0,0,0.85);
font: 13px/1.65 'Segoe UI', Arial, sans-serif; position: relative;
}
#wbt-mbox h3 { margin: 0 0 18px; font-size: 15px; color: #fff; }
#wbt-mbox label { display: block; font-size: 12px; color: #999; }
#wbt-api-input {
display: block; width: 100%; margin-top: 5px; padding: 8px 10px;
background: #242424; border: 1px solid #383838; border-radius: 5px;
color: #eee; font-size: 13px; box-sizing: border-box;
font-family: monospace;
}
#wbt-api-input:focus { outline: 1px solid #1a6ee8; border-color: #1a6ee8; }
.wbt-hint { font-size: 11px; color: #585858; margin: 7px 0 0; }
.wbt-hint a { color: #5a9fd4; text-decoration: none; }
.wbt-hint a:hover { text-decoration: underline; }
#wbt-mbtn-row { margin-top: 16px; }
#wbt-api-save {
padding: 8px 18px; background: #1a6ee8; color: #fff;
border: none; border-radius: 5px; cursor: pointer; font-size: 13px;
transition: background .15s;
}
#wbt-api-save:hover { background: #1558c8; }
#wbt-minfo { margin-top: 14px; font-size: 12px; color: #777; min-height: 18px; }
#wbt-minfo strong { color: #ccc; }
#wbt-mclose {
position: absolute; top: 12px; right: 14px;
background: transparent; border: none; color: #555;
font-size: 16px; cursor: pointer; padding: 3px 7px; border-radius: 4px;
line-height: 1;
}
#wbt-mclose:hover { background: #2a2a2a; color: #bbb; }
`;
document.head.appendChild(style);
}
// ─── Init ─────────────────────────────────────────────────────────────────
async function init() {
injectCSS();
buildToolbar();
watchForNewCards();
if (state.apiKey) {
setStatus("Loading collections…");
try {
// Resolve username: prefer DOM, fall back to API
if (!state.username) {
state.username =
getUsernameFromPage() || (await fetchUsernameFromApi());
GM_setValue("wbt_username", state.username);
}
const cols = await fetchCollections();
fillCollectionSelect(cols);
setStatus(`Ready — ${cols.length} collections`);
} catch (e) {
setStatus("API error — check ⚙ settings");
console.error("[WBT] init error", e);
}
} else {
setStatus("Set API key in ⚙");
}
}
init();
})();