Что делает
- Добавляет кнопку "Экспорт в Shikimori (JSON)" на
https://animego.me/user/*/mylist/anime*. - При нажатии на кнопку страница начнёт прокручиваться вниз для загрузки всех названий.
- После этого автоматически скачается файл.
Как использовать
- Заходим на страницу AnimeGo, где у вас отмечен список.
- Нажимаем на кнопку экспорта.
- Ждём до того момента, пока не скачается файл.
- Дальше на Shikimori:
Профиль → Настройки → Список аниме и манги →
Импортировать список → Выбираем файл → Импортировать.
Код
// ==UserScript==
// @name AnimeGo → Shikimori экспорт
// @namespace https://animego.me/
// @version 1.0
// @description ---
// @author Graf_NEET
// @match https://animego.me/user/*/mylist/anime*
// @grant none
// ==/UserScript==
(function () {
'use strict';
// Настройки
const SHIKIMORI_JSON_URL = "https://raw.githubusercontent.com/GRaf-NEET/franchises_counter/refs/heads/main/anime_franchises.json";
const JACCARD_THRESHOLD = 0.55;
const LEVENSHTEIN_REL_THRESHOLD = 0.35;
const MAX_PAGES_FETCH = 200; // страховка
const SCROLL_TRY_TIMEOUT = 12000; // ms — сколько ждать новых строк после скрола (увеличь если медленный интернет)
const FETCH_DELAY = 400; // ms задержка между fetch страницами
// Утилиты (normalize/tokenize/metrics)
function normalize(s){ if(!s) return ""; return s.toLowerCase().replace(/ё/g,'е').replace(/[:'"“”«»(){}\[\].,!?\/\\—–\-]/g,' ').replace(/\s+/g,' ').trim(); }
const STOP_WORDS = new Set(["аниме","фильм","тв","сериал","ova","ona","special","season","сезон","часть","фильм","серии"]);
function tokenize(s){ const n = normalize(s); if(!n) return []; return n.split(/\s+/).map(t=>t.replace(/\d+$/,'')).filter(Boolean).filter(t=>!STOP_WORDS.has(t)); }
function jaccard(a,b){ const A=new Set(a), B=new Set(b); const inter=[...A].filter(x=>B.has(x)).length; const uni=new Set([...A,...B]).size; return uni===0?0:inter/uni; }
function levenshtein(a,b){ a=a||""; b=b||""; if(a===b) return 0; const n=a.length,m=b.length; if(n===0) return m; if(m===0) return n; let prev=new Array(m+1), cur=new Array(m+1); for(let j=0;j<=m;j++) prev[j]=j; for(let i=1;i<=n;i++){ cur[0]=i; for(let j=1;j<=m;j++){ const cost = a[i-1]===b[j-1]?0:1; cur[j]=Math.min(prev[j]+1, cur[j-1]+1, prev[j-1]+cost); } [prev,cur]=[cur,prev]; } return prev[m]; }
// Сбор всех строк: динамическая подгрузка + fetch
function getTotalCountFromHeader(){
const el = document.querySelector('.card-header .pt-2 a.text-link-gray .text-gray-dark-6, .card-header .pt-2 a.card-link .text-gray-dark-6');
// лучше поискать более специфично:
const nodes = document.querySelectorAll('.card-header .pt-2 a .text-gray-dark-6');
for(const n of nodes){
const v = parseInt(n.textContent.trim().replace(/\D+/g,''),10);
if(!isNaN(v) && v>0) return v;
}
// fallback: иногда есть span с числом в заголовке "Все X"
const allLink = Array.from(document.querySelectorAll('.card-header .pt-2 a')).find(a=>/Все/i.test(a.textContent));
if(allLink){
const sp = allLink.querySelector('.text-gray-dark-6');
if(sp){ const v = parseInt(sp.textContent.trim().replace(/\D+/g,''),10); if(!isNaN(v)) return v; }
}
return null;
}
function sleep(ms){ return new Promise(r=>setTimeout(r, ms)); }
async function tryAutoScrollLoadAll(targetCount){
// постараемся прокрутить вниз, дожидаясь появления новых строк
const tableBody = document.querySelector('table.table tbody');
if(!tableBody) return false;
let prevCount = tableBody.querySelectorAll('tr').length;
let lastIncreaseAt = Date.now();
const deadline = Date.now() + SCROLL_TRY_TIMEOUT;
// делаем циклический скролл; если появились новые строки - обновляем prevCount и продолжаем, пока не достигнем targetCount или пока не выйдет таймаут
window.scrollTo({top: document.body.scrollHeight, behavior: 'smooth'});
while(Date.now() < deadline){
await sleep(600);
window.scrollTo({top: document.body.scrollHeight, behavior: 'smooth'});
const curCount = tableBody.querySelectorAll('tr').length;
if(curCount > prevCount){
prevCount = curCount;
lastIncreaseAt = Date.now();
// если достигли цель — возвращаем true
if(targetCount && prevCount >= targetCount) return true;
// продлим deadline
// (позволяем ещё ждать следующее появление)
} else {
// если с последнего увеличения прошло достаточно — выходим
if(Date.now() - lastIncreaseAt > 2000) break;
}
}
// короткая пауза и финальная проверка
await sleep(300);
return (targetCount ? document.querySelectorAll('table.table tbody tr').length >= targetCount : true);
}
async function fetchPageRows(baseUrl, page, paramsObj){
// Попробуем GET с параметром page, затем POST (fallback)
const urlGet = baseUrl + (baseUrl.includes('?') ? '&' : '?') + 'page=' + page;
try {
const resp = await fetch(urlGet, { credentials: 'same-origin' });
if(resp.ok){
const text = await resp.text();
// вернём распарсенные tr элементы
return parseRowsFromHTML(text);
}
} catch(e){
console.warn('fetch GET page error', e);
}
// fallback: постим JSON-params + page
try {
const body = Object.assign({}, paramsObj || {}, {page});
const resp2 = await fetch(baseUrl, {
method: 'POST',
credentials: 'same-origin',
headers: {'Content-Type':'application/json'},
body: JSON.stringify(body)
});
if(resp2.ok){
const text = await resp2.text();
return parseRowsFromHTML(text);
}
} catch(e){
console.warn('fetch POST page error', e);
}
return [];
}
function parseRowsFromHTML(htmlText){
// пытаемся найти <tr> в полученном фрагменте
const parser = new DOMParser();
const doc = parser.parseFromString(htmlText, 'text/html');
const rows = Array.from(doc.querySelectorAll('table.table tbody tr'));
// если ничего не найдено — может вернуться просто набор tr
if(rows.length === 0){
// пробуем найти только 'tr' в корне
const rawTr = Array.from(doc.querySelectorAll('tr'));
return rawTr;
}
return rows;
}
async function collectAllRows(){
const tbody = document.querySelector('table.table tbody');
if(!tbody) throw new Error('Таблица не найдена на странице');
const totalExpected = getTotalCountFromHeader();
// сначала попробуем автоскролл (наиболее "человечный" способ)
const scrolled = await tryAutoScrollLoadAll(totalExpected);
if(scrolled){
// собрали всё в DOM
return Array.from(document.querySelectorAll('table.table tbody tr'));
}
// если автоскролл не помог — используем fetch к data-loaded-scroll-url
const dataUrl = tbody.getAttribute('data-loaded-scroll-url');
const dataParamsRaw = tbody.getAttribute('data-loaded-scroll-params');
let paramsObj = null;
try { paramsObj = dataParamsRaw ? JSON.parse(dataParamsRaw) : null; } catch(e){ paramsObj = null; }
// начнём с уже существующих строк
const collected = Array.from(tbody.querySelectorAll('tr'));
let page = parseInt(tbody.getAttribute('data-loaded-scroll-page')||'1',10);
if(isNaN(page) || page < 1) page = 1;
// подгружаем последующие страницы
for(let p = page+1; p <= MAX_PAGES_FETCH; p++){
await sleep(FETCH_DELAY);
const rows = await fetchPageRows(dataUrl, p, paramsObj);
if(!rows || rows.length === 0) break;
// вставляем распарсенные строки в массив (не модифицируем DOM основной таблицы)
collected.push(...rows);
if(totalExpected && collected.length >= totalExpected) break;
// защита от бесконечного цикла
if(p - page > 100) break;
}
return collected;
}
// Парсинг одной строки (принимает <tr> либо DOMNode из собранных)
function parseRowNode(row){
try {
// если node из внешнего doc, querySelectors те же
const titleLink = row.querySelector('td.table-100 a');
const russian = titleLink ? titleLink.textContent.trim() : null;
const originalDiv = row.querySelector('td.table-100 .text-gray-dark-6');
const original = originalDiv ? originalDiv.textContent.trim() : null;
const statusTextEl = row.querySelector('td[data-label="В списке"] .indicator, td[data-label="В списке"] .d-lg-none');
let status = null;
if (statusTextEl) {
status = statusTextEl.textContent.trim() || statusTextEl.getAttribute('data-title') || null;
}
const score = (() => {
const s = row.querySelector('td[data-label="Оценка"]');
if (!s) return null;
const t = s.textContent.trim();
if (!t || t === '–' || t === '—') return null;
const num = parseInt(t,10);
return isNaN(num) ? null : num;
})();
const episodes = (() => {
const e = row.querySelector('td[data-label="Эпизоды"]');
if (!e) return null;
const t = e.textContent.trim();
if (!t || t === '–') return null;
const m = t.match(/(\d+)\s*\/\s*(\d+)/);
if (m) return { watched: parseInt(m[1],10), total: parseInt(m[2],10) };
const single = t.match(/(\d+)/);
return single ? { watched: parseInt(single[1],10), total: null } : null;
})();
const type = (() => {
const t = row.querySelector('td[data-label="Тип"]');
return t ? t.textContent.trim() : null;
})();
const hrefEl = row.querySelector('td a.d-inline-block');
const href = hrefEl ? hrefEl.getAttribute('href') : null;
return { russian, original, status, score, episodes, type, href };
} catch(e){
console.error('parseRowNode error', e);
return null;
}
}
// Индексация и сопоставление
function buildIndex(shikiArray){ const byRussian=new Map(), byFranchise=new Map(); for(const rec of shikiArray){ if(rec.russian){ const key = normalize(rec.russian); if(!byRussian.has(key)) byRussian.set(key,[]); byRussian.get(key).push(rec); } if(rec.franchise){ const k = rec.franchise; if(!byFranchise.has(k)) byFranchise.set(k,[]); byFranchise.get(k).push(rec); } } return {byRussian, byFranchise}; }
function matchOne(item, shikiArray, idx){
const candidates = [];
const russianNorm = normalize(item.russian || '');
const originalNorm = normalize(item.original || '');
if (russianNorm && idx.byRussian.has(russianNorm)) return { id: idx.byRussian.get(russianNorm)[0].id, method: 'exact_russian' };
if (originalNorm && idx.byRussian.has(originalNorm)) return { id: idx.byRussian.get(originalNorm)[0].id, method: 'exact_original_as_russian' };
for (const rec of shikiArray) {
if (rec.franchise) {
const f = rec.franchise.toLowerCase().replace(/_/g,' ');
if ((originalNorm && originalNorm.includes(f)) || (russianNorm && russianNorm.includes(f))) return { id: rec.id, method: 'franchise_substring' };
}
}
const t1 = tokenize(item.russian || item.original || '');
if (t1.length > 0) {
let bestScore = 0, bestRec = null;
for (const rec of shikiArray) {
const rtoks = tokenize(rec.russian || '');
const score = jaccard(t1, rtoks);
if (score > bestScore) { bestScore = score; bestRec = rec; }
}
if (bestScore >= JACCARD_THRESHOLD) return { id: bestRec.id, method: 'jaccard', score: bestScore };
else if (bestRec) candidates.push({ id: bestRec.id, method: 'jaccard', score: bestScore });
}
const tokensA = new Set(tokenize(item.russian || item.original || ''));
const scored = [];
for (const rec of shikiArray) {
const tokensB = new Set(tokenize(rec.russian || ''));
const common = [...tokensA].filter(t => tokensB.has(t)).length;
if (common > 0) scored.push({ rec, common });
}
scored.sort((a,b)=>b.common-a.common);
const candList = scored.slice(0,8).map(x=>x.rec);
if (candList.length === 0) candList.push(...shikiArray.slice(0,50));
let bestLev = { rec: null, dist: Infinity };
const sourceForLev = normalize(item.russian || item.original || '');
for (const rec of candList) {
const target = normalize(rec.russian || '');
const d = levenshtein(sourceForLev, target);
const rel = target.length === 0 ? Infinity : d / Math.max(target.length, sourceForLev.length);
if (rel < bestLev.dist) bestLev = { rec, dist: rel, raw: d };
}
if (bestLev.rec && bestLev.dist <= LEVENSHTEIN_REL_THRESHOLD) return { id: bestLev.rec.id, method: 'levenshtein', lev_rel: bestLev.dist };
return { id: null, method: 'not_found', candidates: candidates.concat(candList.slice(0,5).map(r=>({id:r.id, russian:r.russian}))) };
}
function mapStatus(src){
if (!src) return 'planned';
const s = src.toLowerCase();
if (s.includes('смотрю') || s.includes('watch')) return 'watching';
if (s.includes('просмотрено') || s.includes('completed')) return 'completed';
if (s.includes('отложено') || s.includes('onhold')) return 'on_hold';
if (s.includes('брошено') || s.includes('dropped')) return 'dropped';
if (s.includes('запланировано') || s.includes('planned')) return 'planned';
return 'planned';
}
// UI + основной поток
function createUI(){
const header = document.querySelector('.card-header .pt-2');
if(!header) return;
const btn = document.createElement('button');
btn.textContent = 'Экспорт в Shikimori (JSON)';
btn.style.marginLeft = '10px';
btn.className = 'btn btn-primary';
header.appendChild(btn);
const info = document.createElement('span'); info.style.marginLeft='10px'; header.appendChild(info);
btn.addEventListener('click', async ()=>{
try{
info.textContent = 'Собираю все строки...';
const rawRows = await collectAllRows();
info.textContent = `Найдено строк: ${rawRows.length}. Парсю...`;
const items = rawRows.map(r => parseRowNode(r)).filter(Boolean);
// загружаем базу shikimori
info.textContent = 'Загружаю базу Shikimori...';
let shikiArray = null;
try {
const resp = await fetch(SHIKIMORI_JSON_URL, {credentials: 'same-origin'});
shikiArray = await resp.json();
} catch(e){
alert('Не удалось загрузить базу по URL. Скопируй локально файл и обнови константу SHIKIMORI_JSON_URL или загрузи файл вручную (пока не реализовано в этой версии).');
console.error(e);
return;
}
info.textContent = 'Индексирую базу и сопоставляю...';
const idx = buildIndex(shikiArray);
const out = []; const debug = [];
for(let i=0;i<items.length;i++){
const it = items[i];
const match = matchOne(it, shikiArray, idx);
out.push({
target_title: it.original || it.russian,
target_title_ru: it.russian,
target_id: match.id,
target_type: "Anime",
score: it.score || 0,
status: mapStatus(it.status),
rewatches: 0,
episodes: 0,
text: null
});
if(!match.id) debug.push({item: it, match});
}
const blob = new Blob([JSON.stringify(out,null,2)], {type:'application/json;charset=utf-8'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href=url; a.download='animego_to_shikimori_export.json'; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
info.textContent = `Готово. Всего: ${out.length}. Не распознано: ${debug.length}. Файл скачан. (см. консоль для деталей)`;
if(debug.length>0) console.info('Unmatched (first 50):', debug.slice(0,50));
window.scrollTo({top: 0, behavior: 'smooth'});
} catch(e){
console.error(e);
alert('Ошибка: ' + e.message);
}
});
}
// старт
try { createUI(); } catch(e){ console.error(e); }
})();Установка:
- Установите Tampermonkey в браузер.
- Создайте новый скрипт и вставьте код выше.
- Сохраните и обновите страницу списка аниме на AnimeGo.
Скриншоты/Пример работы:

Нет комментариев