Skip to content

Instantly share code, notes, and snippets.

@prr21
Last active August 23, 2026 22:22
Show Gist options
  • Select an option

  • Save prr21/97c01dd94902f2b7d859b48632db9da8 to your computer and use it in GitHub Desktop.

Select an option

Save prr21/97c01dd94902f2b7d859b48632db9da8 to your computer and use it in GitHub Desktop.
Сортировка фотоальбома ВКонтакте по цвету: блок сложных картинок + монотонный проход по тону
// ==UserScript==
// @name VK Photo Color Sort
// @namespace local.vkcs
// @version 1.0
// @description Сортировка фотоальбома ВКонтакте по цвету: блок сложных картинок + монотонный проход по тону
// @match https://vk.com/*
// @match https://vk.ru/*
// @grant none
// @run-at document-idle
// ==/UserScript==
/* ============================================================================
* Два способа запуска:
* A) Tampermonkey — установить как userscript, на странице альбома появится
* панель с кнопками. Переживает перезагрузку страницы.
* B) Консоль DevTools — вставить весь файл целиком (метаблок выше станет
* обычным комментарием) и работать через VKCS.* или ту же панель.
*
* Порядок:
* await VKCS.collect() прокрутить альбом, собрать превью, посчитать цвета
* VKCS.stats() распределение purity и выбранный порог
* VKCS.preview() посмотреть будущий порядок
* VKCS.watch() включить перехват; затем перетащить ОДНУ фотографию
* VKCS.learn() распознать запрос reorder
* await VKCS.apply({ dry: true }) прогон без отправки
* await VKCS.apply() применить
*
* Кэш цветов версии v2 несовместим с v1 (в v1 не было purity), поэтому первый
* collect() перекачает превью заново. Для 500 фото это пара минут.
* ========================================================================== */
;(() => {
"use strict"
if (window.VKCS) {
console.warn("[VKCS] уже загружен, перезагрузи страницу")
return
}
const CFG = {
/* анализ */
thumb: 32, // до какого размера ужимать превью
concurrency: 5,
chromaRef: 0.075, // «полностью насыщенная» средняя цветность в OKLab
minPixelChroma: 0.01, // ниже этого пиксель не участвует в оценке тона
/* раскладка */
layout: "bands", // 'bands' сложные → чистые → непрочитанные
// 'ramp' тёмные сложные → чистые → светлые сложные
// 'pure-first' чистые → сложные
threshold: "auto", // 'auto' = метод Оцу, либо число 0..1
hueStart: 20, // с какого тона начинается круг (20° ≈ красный)
hueBuckets: 0, // 0 = подобрать от количества
lightBuckets: 0,
failedAt: "end", // 'end' | 'start'
/* применение */
reorderDelay: 900,
token: "", // если заполнить — пойдёт через photos.reorderPhotos
/* прокрутка */
scrollStep: 900,
scrollPause: 350,
idleRounds: 6,
keyFeats: "vkcs.feats.v2",
keyProgress: "vkcs.progress.v2",
}
const S = {
items: new Map(), // key -> { key, owner, id, src }
feats: new Map(), // key -> { L, hue, conc, chroma, purity, rgb }
order: [],
bands: [], // [{ name, keys }] — для превью и отладки
cut: 0, // фактический порог purity
net: [],
tpl: null,
}
const sleep = ms => new Promise(r => setTimeout(r, ms))
const log = (...a) => console.log("%c[VKCS]", "color:#4c8", ...a)
const warn = (...a) => console.warn("[VKCS]", ...a)
const TAU = Math.PI * 2
/* ============================================================== цвет ==== */
const s2l = c => {
c /= 255
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
}
const l2s = v =>
255 * (v <= 0.0031308 ? 12.92 * v : 1.055 * Math.pow(v, 1 / 2.4) - 0.055)
// OKLab: перцептивно равномерное пространство, расстояние в нём соответствует
// видимой разнице цветов.
function rgbToOklab(r, g, b) {
const R = s2l(r),
G = s2l(g),
B = s2l(b)
const l = Math.cbrt(0.4122214708 * R + 0.5363325363 * G + 0.0514459929 * B)
const m = Math.cbrt(0.2119034982 * R + 0.6806995451 * G + 0.1073969566 * B)
const s = Math.cbrt(0.0883024619 * R + 0.2817188376 * G + 0.6299787005 * B)
return [
0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
]
}
function oklabToRgb(L, A, B) {
const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3
const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3
const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3
const r = +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s
const g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s
const b = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s
return [r, g, b].map(v =>
Math.max(0, Math.min(255, Math.round(l2s(Math.max(0, v))))),
)
}
/*
* Ключевая метрика — насколько в кадре преобладает ОДИН тон.
* Складываем векторы C·e^{ih} по всем пикселям и делим длину суммы на сумму C:
* 1.0 — все цветные пиксели одного тона (закат, зелёная листва)
* 0.2 — красное рядом с зелёным, или просто визуальная каша
* Отдельно средняя цветность: она отсекает серые кадры, у которых концентрация
* может быть высокой случайно, просто потому что цвета почти нет.
* purity = концентрация × нормированная цветность.
*/
function analyzePixels(d) {
let sumL = 0,
n = 0
let vx = 0,
vy = 0,
sumC = 0
let lr = 0,
lg = 0,
lb = 0
for (let i = 0; i < d.length; i += 4) {
if (d[i + 3] < 128) continue
const r = d[i],
g = d[i + 1],
b = d[i + 2]
const [L, A, B] = rgbToOklab(r, g, b)
const C = Math.hypot(A, B)
sumL += L
n++
lr += s2l(r)
lg += s2l(g)
lb += s2l(b)
if (C >= CFG.minPixelChroma) {
const h = Math.atan2(B, A)
vx += C * Math.cos(h)
vy += C * Math.sin(h)
sumC += C
}
}
if (!n) throw new Error("пустое изображение")
const chroma = sumC / n
const conc = sumC > 1e-6 ? Math.hypot(vx, vy) / sumC : 0
const hue = Math.atan2(vy, vx)
const purity = conc * Math.min(1, chroma / CFG.chromaRef)
const L = sumL / n
// цвет для плашки в превью: тон и цветность кадра при его же светлоте
const rgb = oklabToRgb(
L,
Math.cos(hue) * chroma * 2.2,
Math.sin(hue) * chroma * 2.2,
)
const avg = [l2s(lr / n), l2s(lg / n), l2s(lb / n)].map(Math.round)
return { L, hue, conc, chroma, purity, rgb, avg }
}
/* ============================================================ загрузка == */
function loadImage(src) {
return new Promise((res, rej) => {
const img = new Image()
img.crossOrigin = "anonymous" // строго до присвоения src
img.referrerPolicy = "no-referrer"
img.onload = () => res(img)
img.onerror = () => rej(new Error("не загрузилось"))
img.src = src
})
}
async function featureOf(src) {
const img = await loadImage(src)
const n = CFG.thumb
const c = document.createElement("canvas")
c.width = c.height = n
const ctx = c.getContext("2d", { willReadFrequently: true })
ctx.drawImage(img, 0, 0, n, n)
return analyzePixels(ctx.getImageData(0, 0, n, n).data)
}
function scanDom() {
document.querySelectorAll('a[href*="/photo"]').forEach(a => {
const m = (a.getAttribute("href") || "").match(/\/photo(-?\d+)_(\d+)/)
if (!m) return
const key = m[1] + "_" + m[2]
if (S.items.has(key)) return
const img = a.querySelector("img")
let src = img && (img.currentSrc || img.src)
if (!src) {
const b = (getComputedStyle(a).backgroundImage || "").match(
/url\("?(.+?)"?\)/,
)
src = b && b[1]
}
if (!src || src.startsWith("data:")) return
S.items.set(key, { key, owner: +m[1], id: +m[2], src })
})
}
async function collect() {
restoreFeats()
status("прокручиваю альбом…")
window.scrollTo(0, 0)
await sleep(700)
let idle = 0,
last = 0
while (idle < CFG.idleRounds) {
scanDom() // сетка виртуализирована: ссылки
if (S.items.size === last)
idle++ // собираем по ходу прокрутки,
else {
idle = 0
last = S.items.size
} // анализируем потом, уже по URL
if (
window.scrollY + window.innerHeight >=
document.documentElement.scrollHeight - 8
)
idle++
window.scrollBy(0, CFG.scrollStep)
status(`прокрутка… найдено ${S.items.size}`)
await sleep(CFG.scrollPause)
}
scanDom()
log(`фотографий найдено: ${S.items.size}`)
if (!S.items.size) {
status("ничего не найдено")
warn("открыта ли страница альбома?")
return
}
try {
await featureOf([...S.items.values()][0].src)
} catch (e) {
if (e.name === "SecurityError" || String(e).includes("SecurityError")) {
warn("canvas заблокирован: CDN не отдаёт Access-Control-Allow-Origin.")
warn(
"Тогда считать цвета придётся вне браузера и подложить через VKCS.importFeats(obj)",
)
status("CORS блокирует чтение пикселей")
return
}
}
await analyzeAll()
buildOrder()
stats()
status(`готово: ${S.order.length} шт., порог ${S.cut.toFixed(3)}`)
}
async function analyzeAll() {
const todo = [...S.items.values()].filter(it => !S.feats.has(it.key))
log(`анализирую ${todo.length} (в кэше ${S.feats.size})`)
let done = 0,
failed = 0
const q = todo.slice()
await Promise.all(
Array.from({ length: CFG.concurrency }, async () => {
while (q.length) {
const it = q.shift()
try {
S.feats.set(it.key, await featureOf(it.src))
} catch (e) {
failed++
}
if (++done % 25 === 0) {
status(`анализ ${done}/${todo.length}`)
saveFeats()
}
}
}),
)
saveFeats()
if (failed) warn(`не прочиталось: ${failed} — уйдут отдельным блоком`)
}
function saveFeats() {
try {
localStorage.setItem(CFG.keyFeats, JSON.stringify([...S.feats]))
} catch (e) {}
}
function restoreFeats() {
try {
const raw = localStorage.getItem(CFG.keyFeats)
if (raw) S.feats = new Map(JSON.parse(raw))
} catch (e) {}
}
function importFeats(obj) {
S.feats = new Map(Object.entries(obj))
saveFeats()
return buildOrder()
}
/* ============================================================== порог === */
// Метод Оцу: ищем порог, максимально разделяющий распределение purity на две
// группы. Работает по фактическим данным альбома, а не по угаданной константе.
function otsu(values) {
const B = 64
const min = Math.min(...values),
max = Math.max(...values)
if (max - min < 1e-6) return max
const hist = new Array(B).fill(0)
for (const v of values)
hist[Math.min(B - 1, Math.floor(((v - min) / (max - min)) * B))]++
const total = values.length
let sum = 0
for (let i = 0; i < B; i++) sum += i * hist[i]
let wB = 0,
sumB = 0,
best = -1,
bestIdx = 0
for (let i = 0; i < B; i++) {
wB += hist[i]
if (!wB) continue
const wF = total - wB
if (!wF) break
sumB += i * hist[i]
const between = wB * wF * (sumB / wB - (sum - sumB) / wF) ** 2
if (between > best) {
best = between
bestIdx = i
}
}
return min + ((bestIdx + 1) / B) * (max - min)
}
function stats() {
const vals = [...S.feats.values()].map(f => f.purity).sort((a, b) => a - b)
if (!vals.length) return
const q = p => vals[Math.min(vals.length - 1, Math.floor(p * vals.length))]
console.table({
"кол-во": vals.length,
мин: +vals[0].toFixed(3),
"25%": +q(0.25).toFixed(3),
медиана: +q(0.5).toFixed(3),
"75%": +q(0.75).toFixed(3),
макс: +vals[vals.length - 1].toFixed(3),
порог: +S.cut.toFixed(3),
чистых: vals.filter(v => v >= S.cut).length,
сложных: vals.filter(v => v < S.cut).length,
})
log(
"порог правится так: VKCS.CFG.threshold = 0.25; VKCS.buildOrder(); VKCS.preview()",
)
}
/* ============================================================= порядок == */
/*
* Проход по тону строго по кругу — именно это убирает возврат к красному.
* Секторы режем по квантилям, а не по равным углам: в фотографиях синего и
* оранжевого всегда больше, чем зелёного, и равные углы дали бы один
* гигантский сектор и несколько пустых.
* Внутри сектора — по светлоте, направление чередуется (серпантин), чтобы
* светлый конец одного тона стыковался со светлым концом следующего.
*/
function sweepByHue(list) {
if (list.length < 2) return list.slice()
const start = (CFG.hueStart * Math.PI) / 180
const rot = h => (((h - start) % TAU) + TAU) % TAU
const arr = list.slice().sort((a, b) => rot(a.f.hue) - rot(b.f.hue))
const K =
CFG.hueBuckets || Math.max(6, Math.round(Math.sqrt(arr.length) * 1.2))
const size = arr.length / K
const out = []
for (let i = 0; i < K; i++) {
const chunk = arr.slice(Math.round(i * size), Math.round((i + 1) * size))
chunk.sort((a, b) => a.f.L - b.f.L)
if (i % 2) chunk.reverse()
out.push(...chunk)
}
return out
}
// Для сложных кадров тон недостоверен, ведущая ось — светлота.
// Внутри полосы светлоты раскладываем по остаточному оттенку, тоже серпантином.
function sweepByLight(list) {
if (list.length < 2) return list.slice()
const arr = list.slice().sort((a, b) => a.f.L - b.f.L)
const K =
CFG.lightBuckets || Math.max(3, Math.round(Math.sqrt(arr.length) / 1.4))
const size = arr.length / K
const out = []
for (let i = 0; i < K; i++) {
const chunk = arr.slice(Math.round(i * size), Math.round((i + 1) * size))
chunk.sort((a, b) => a.f.hue - b.f.hue)
if (i % 2) chunk.reverse()
out.push(...chunk)
}
return out
}
function buildOrder() {
const all = [...S.items.values()]
const ok = all
.filter(it => S.feats.has(it.key))
.map(it => ({ key: it.key, f: S.feats.get(it.key) }))
const failed = all.filter(it => !S.feats.has(it.key)).map(it => it.key)
if (!ok.length) {
warn("нет данных о цветах")
return
}
S.cut =
CFG.threshold === "auto" ? otsu(ok.map(p => p.f.purity)) : +CFG.threshold
const pure = sweepByHue(ok.filter(p => p.f.purity >= S.cut)).map(p => p.key)
const cplx = sweepByLight(ok.filter(p => p.f.purity < S.cut)).map(
p => p.key,
)
const bands = []
if (CFG.layout === "pure-first") {
bands.push({ name: "один преобладающий цвет", keys: pure })
bands.push({ name: "сложные", keys: cplx })
} else if (CFG.layout === "ramp") {
const half = Math.ceil(cplx.length / 2)
bands.push({ name: "сложные, тёмные", keys: cplx.slice(0, half) })
bands.push({ name: "один преобладающий цвет", keys: pure })
bands.push({ name: "сложные, светлые", keys: cplx.slice(half) })
} else {
bands.push({ name: "сложные", keys: cplx })
bands.push({ name: "один преобладающий цвет", keys: pure })
}
if (failed.length) {
const b = { name: "не прочитались", keys: failed }
CFG.failedAt === "start" ? bands.unshift(b) : bands.push(b)
}
S.bands = bands.filter(b => b.keys.length)
S.order = S.bands.flatMap(b => b.keys)
return S.order
}
/* ============================================================== превью == */
function preview(opts = {}) {
document.getElementById("vkcs-preview")?.remove()
if (!S.order.length) {
warn("порядок не построен")
return
}
const box = document.createElement("div")
box.id = "vkcs-preview"
box.style.cssText =
"position:fixed;left:0;right:0;bottom:0;z-index:2147483646;background:#0d0d0f;" +
"color:#ddd;font:12px/1.4 ui-monospace,Menlo,Consolas,monospace;padding:8px;" +
"max-height:52vh;overflow:auto;box-shadow:0 -4px 24px rgba(0,0,0,.6)"
const head = document.createElement("div")
head.style.cssText =
"position:sticky;top:0;background:#0d0d0f;padding-bottom:6px;display:flex;gap:12px;align-items:center"
head.innerHTML = `<span>${S.order.length} шт. · порог ${S.cut.toFixed(3)} · раскладка ${CFG.layout}</span>`
const close = document.createElement("button")
close.textContent = "закрыть"
close.style.cssText = "margin-left:auto;padding:3px 10px;cursor:pointer"
close.onclick = () => box.remove()
head.append(close)
box.append(head)
for (const band of S.bands) {
const title = document.createElement("div")
title.textContent = `${band.name} — ${band.keys.length}`
title.style.cssText =
"margin:10px 0 4px;padding-left:2px;color:#7a8;border-left:3px solid #7a8"
box.append(title)
const row = document.createElement("div")
row.style.cssText = "display:flex;flex-wrap:wrap;gap:2px"
for (const key of band.keys) {
const it = S.items.get(key),
f = S.feats.get(key)
const cell = document.createElement("a")
cell.href = `/photo${key}`
cell.target = "_blank"
cell.title = f
? `${key}\npurity ${f.purity.toFixed(3)} · conc ${f.conc.toFixed(2)} · C ${f.chroma.toFixed(3)} · L ${f.L.toFixed(2)}`
: `${key}\nне прочиталось`
cell.style.cssText = "width:24px;display:block;text-decoration:none"
const sw = document.createElement("div")
sw.style.cssText = f
? `height:24px;background:rgb(${f.rgb.join(",")})`
: "height:24px;background:repeating-linear-gradient(45deg,#333 0 4px,#555 4px 8px)"
cell.append(sw)
if (opts.thumbs && it) {
const im = document.createElement("img")
im.src = it.src
im.style.cssText =
"width:24px;height:24px;object-fit:cover;display:block"
cell.prepend(im)
}
if (f) {
// тонкая полоска = purity
const bar = document.createElement("div")
bar.style.cssText = `height:3px;background:#eee;opacity:${Math.min(1, f.purity / 0.6).toFixed(2)}`
cell.append(bar)
}
row.append(cell)
}
box.append(row)
}
document.body.append(box)
}
/* ================================================== перехват и отправка = */
function bodyToString(b) {
if (!b) return ""
if (typeof b === "string") return b
if (b instanceof URLSearchParams) return b.toString()
if (typeof FormData !== "undefined" && b instanceof FormData) {
return [...b.entries()]
.map(([k, v]) => encodeURIComponent(k) + "=" + encodeURIComponent(v))
.join("&")
}
try {
return JSON.stringify(b)
} catch (e) {
return ""
}
}
function watch() {
S.net.length = 0
if (window.__vkcsWatch) {
status("перехват уже включён — тащи фото")
return
}
window.__vkcsWatch = true
const rec = (url, method, body, headers) => {
S.net.push({
url: String(url),
method: (method || "GET").toUpperCase(),
body: bodyToString(body),
headers: headers || {},
t: Date.now(),
})
if (S.net.length > 300) S.net.shift()
}
const oFetch = window.fetch
window.fetch = function (input, init) {
try {
const url = typeof input === "string" ? input : input && input.url
const i = init || {}
rec(url, i.method || (input && input.method), i.body, i.headers)
} catch (e) {}
return oFetch.apply(this, arguments)
}
const oOpen = XMLHttpRequest.prototype.open
const oSend = XMLHttpRequest.prototype.send
const oHead = XMLHttpRequest.prototype.setRequestHeader
XMLHttpRequest.prototype.open = function (m, u) {
this.__vkcs = { m, u, h: {} }
return oOpen.apply(this, arguments)
}
XMLHttpRequest.prototype.setRequestHeader = function (k, v) {
if (this.__vkcs) this.__vkcs.h[k] = v
return oHead.apply(this, arguments)
}
XMLHttpRequest.prototype.send = function (b) {
try {
if (this.__vkcs) rec(this.__vkcs.u, this.__vkcs.m, b, this.__vkcs.h)
} catch (e) {}
return oSend.apply(this, arguments)
}
status("перехват включён — перетащи одну фотографию, потом «распознать»")
}
function parseSlots(req) {
const slots = []
try {
new URL(req.url, location.origin).searchParams.forEach((v, k) =>
slots.push({ where: "url", key: k, value: v }),
)
} catch (e) {}
try {
new URLSearchParams(req.body).forEach((v, k) =>
slots.push({ where: "body", key: k, value: v }),
)
} catch (e) {}
return slots
}
function learn(map) {
const ids = new Set([...S.items.values()].map(i => String(i.id)))
const hits = S.net.filter(r => {
const s = r.url + " " + r.body
if (/reorder|move|swap|order/i.test(s)) return true
return (s.match(/\d{5,}/g) || []).filter(x => ids.has(x)).length >= 2
})
if (!hits.length) {
warn("запрос не найден. Перехват был включён ДО перетаскивания?")
warn("Все запросы в VKCS.net — можно найти нужный и задать вручную:")
warn(
'VKCS.setTemplate(VKCS.net[i], { moved: "photo_id", after: "after" })',
)
status("запрос reorder не найден")
return
}
const req = hits[hits.length - 1]
const slots = parseSlots(req).filter(s => ids.has(s.value))
const pick = re => slots.find(s => re.test(s.key))
const m = map || {
moved: (
pick(/^(photo_?id|pid|photo|id)$/i) ||
pick(/photo/i) ||
slots[0] ||
{}
).key,
after: (pick(/after|next/i) || {}).key,
before: (pick(/before|prev/i) || {}).key,
}
S.tpl = { req, slots, map: m }
if (!m.moved || (!m.after && !m.before)) {
warn("параметры не распознались, слоты с id:", slots)
warn('задай вручную: VKCS.learn({ moved: "pid", after: "after" })')
status("параметры не распознались, см. консоль")
return S.tpl
}
log("шаблон распознан:", { url: req.url, method: req.method, map: m })
status("шаблон распознан — можно применять")
return S.tpl
}
function setTemplate(req, map) {
S.tpl = { req, slots: parseSlots(req), map }
return S.tpl
}
function buildRequest(movedId, neighborId, side) {
const { req, map } = S.tpl
const u = new URL(req.url, location.origin)
const body = new URLSearchParams(req.body)
const put = (key, val) => {
if (!key) return
if (u.searchParams.has(key)) u.searchParams.set(key, val)
else if (body.has(key)) body.set(key, val)
}
put(map.moved, movedId)
if (map.after) put(map.after, side === "after" ? neighborId : "")
if (map.before) put(map.before, side === "before" ? neighborId : "")
return {
url: u.toString(),
method: req.method,
body: body.toString(),
headers: req.headers,
}
}
async function sendCaptured(r) {
const headers = Object.assign({}, r.headers)
const hasCT = Object.keys(headers).some(
k => k.toLowerCase() === "content-type",
)
if (r.body && r.method !== "GET" && !hasCT)
headers["Content-Type"] = "application/x-www-form-urlencoded"
const res = await fetch(r.url, {
method: r.method,
body: r.method === "GET" ? undefined : r.body,
headers,
credentials: "include",
})
if (!res.ok) throw new Error("HTTP " + res.status)
return res.text()
}
async function sendApi(ownerId, movedId, neighborId, side) {
const body = new URLSearchParams({
owner_id: ownerId,
photo_id: movedId,
[side]: neighborId,
access_token: CFG.token,
v: "5.199",
})
const res = await fetch("https://api.vk.com/method/photos.reorderPhotos", {
method: "POST",
body,
})
const json = await res.json()
if (json.error)
throw new Error(`${json.error.error_code}: ${json.error.error_msg}`)
return json.response
}
/* =========================================================== применение = */
async function apply(opts = {}) {
const dry = !!opts.dry
if (!S.order.length) {
warn("порядок не построен")
return
}
const viaApi = !S.tpl && !!CFG.token
if (!S.tpl && !viaApi) {
warn("нет ни шаблона, ни токена")
status("нечем отправлять")
return
}
const side = viaApi ? "after" : S.tpl.map.after ? "after" : "before"
const list = S.order.map(k => S.items.get(k))
// Ставим каждую следующую сразу за предыдущей: после прохода блок покрывает
// весь альбом, поэтому первая фотография оказывается первой сама.
const steps = []
if (side === "after")
for (let i = 1; i < list.length; i++) steps.push([list[i], list[i - 1]])
else
for (let i = list.length - 2; i >= 0; i--)
steps.push([list[i], list[i + 1]])
let start = 0
if (opts.resume !== false && !dry) {
const p = +(localStorage.getItem(CFG.keyProgress) || 0)
if (p > 0 && p < steps.length) {
start = p
log(`продолжаю с шага ${p}`)
}
}
for (let i = start; i < steps.length; i++) {
const [moved, near] = steps[i]
if (dry) {
if (i < 3)
console.log(
viaApi
? {
method: "photos.reorderPhotos",
photo_id: moved.id,
[side]: near.id,
}
: buildRequest(moved.id, near.id, side),
)
continue
}
try {
if (viaApi) await sendApi(moved.owner, moved.id, near.id, side)
else await sendCaptured(buildRequest(moved.id, near.id, side))
} catch (e) {
warn(`шаг ${i} упал: ${e.message} — прогресс сохранён, повтори apply()`)
localStorage.setItem(CFG.keyProgress, String(i))
status(`ошибка на шаге ${i}: ${e.message}`)
return
}
localStorage.setItem(CFG.keyProgress, String(i + 1))
if ((i + 1) % 10 === 0) status(`применение ${i + 1}/${steps.length}`)
await sleep(CFG.reorderDelay)
}
if (dry) {
log(`сухой прогон: ${steps.length} запросов, выше первые три`)
status(`сухой прогон: ${steps.length} запросов`)
return
}
localStorage.removeItem(CFG.keyProgress)
status("готово — обнови страницу")
}
function reset() {
localStorage.removeItem(CFG.keyFeats)
localStorage.removeItem(CFG.keyProgress)
S.items.clear()
S.feats.clear()
S.order = []
S.bands = []
S.net = []
S.tpl = null
status("кэш очищен")
}
/* ================================================================ панель = */
let statusEl = null
function status(text) {
log(text)
if (statusEl) statusEl.textContent = text
}
function panel() {
document.getElementById("vkcs-panel")?.remove()
const p = document.createElement("div")
p.id = "vkcs-panel"
p.style.cssText =
"position:fixed;right:12px;bottom:12px;z-index:2147483647;background:#0d0d0f;" +
"color:#ddd;font:12px/1.5 ui-monospace,Menlo,Consolas,monospace;border:1px solid #2a2a30;" +
"padding:10px;width:230px;box-shadow:0 6px 28px rgba(0,0,0,.55)"
const title = document.createElement("div")
title.textContent = "сортировка по цвету"
title.style.cssText = "color:#7a8;margin-bottom:8px;letter-spacing:.04em"
const grid = document.createElement("div")
grid.style.cssText = "display:grid;grid-template-columns:1fr 1fr;gap:4px"
const btn = (label, fn) => {
const b = document.createElement("button")
b.textContent = label
b.style.cssText =
"padding:5px 4px;cursor:pointer;background:#1a1a1f;color:#ddd;border:1px solid #2a2a30;font:inherit"
b.onclick = () =>
Promise.resolve(fn()).catch(e => {
warn(e)
status("ошибка: " + e.message)
})
return b
}
grid.append(
btn("собрать", collect),
btn("превью", () => {
buildOrder()
preview()
}),
btn("перехват", watch),
btn("распознать", () => learn()),
btn("прогон", () => apply({ dry: true })),
btn("применить", () => apply()),
)
statusEl = document.createElement("div")
statusEl.textContent = "начни с «собрать»"
statusEl.style.cssText =
"margin-top:8px;color:#8a8a92;word-break:break-word"
p.append(title, grid, statusEl)
document.body.append(p)
return p
}
/* ================================================================ старт = */
window.VKCS = {
CFG,
S,
collect,
buildOrder,
preview,
stats,
watch,
learn,
setTemplate,
apply,
importFeats,
reset,
panel,
get net() {
return S.net
},
get order() {
return S.order
},
}
restoreFeats()
if (/\/album|\/photos/.test(location.pathname) || !window.GM_info) panel()
log("готов. Кнопки в панели справа внизу либо VKCS.collect()")
})()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment