Last active
August 23, 2026 22:22
-
-
Save prr21/97c01dd94902f2b7d859b48632db9da8 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
| // ==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