Что делает
- Добавляет кнопку "Экспорт в Shikimori" на
https://animego.me/profile/*иhttps://animego.me/user/*. - При нажатии на кнопку страница начнёт прокручиваться вниз для загрузки всех названий.
- После этого автоматически скачается файл.
Как использовать
- Заходим на страницу AnimeGo, где у вас отмечен список.
К сожелению, они убрали список где сразу всех аниме, так что каждый список придется экспортировать отдельно (если они добавят такой раздел, тегните меня). - Нажимаем на кнопку экспорта.
- Ждём до того момента, пока не скачается файл.
- Дальше на Shikimori:
Профиль → Настройки → Список аниме и манги →
Импортировать список → Выбираем файл → Импортировать.
Код
// ==UserScript==
// @name AnimeGo → Shikimori экспорт
// @namespace https://animego.me/
// @version 2.0
// @description ---
// @author Graf_NEET
// @match https://animego.me/profile/*
// @match https://animego.me/user/*
// @grant none
// ==/UserScript==
(function () {
'use strict';
/*********************
* CONFIG
*********************/
const DEBUG = true;
const TYPE_MAP = {
1: 'watching',
2: 'completed',
3: 'on_hold',
4: 'dropped',
5: 'planned',
6: 'rewatching'
};
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;
/*********************
* DEBUG
*********************/
const log = (...a) => DEBUG && console.log('[AG-EXPORT]', ...a);
const warn = (...a) => DEBUG && console.warn('[AG-EXPORT]', ...a);
const sleep = ms => new Promise(r => setTimeout(r, ms));
/*********************
* UTIL (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];
}
/*********************
* TYPE / COUNT
*********************/
function getCurrentType() {
return parseInt(new URLSearchParams(location.search).get('type') || '1', 10);
}
function getExpectedCount(type) {
const b = document.querySelector(`a[href$="?type=${type}"] span.badge`);
if (!b) return null;
const n = parseInt(b.textContent.trim(), 10);
return isNaN(n) ? null : n;
}
/*********************
* STATS UI
*********************/
function createStatsPanel() {
const el = document.createElement('div');
el.style.marginTop = '6px';
el.style.fontSize = '12px';
el.style.opacity = '0.85';
el.innerHTML = `
<div>Статус: <b id="ag-stat-status">idle</b></div>
<div>Ожидалось: <b id="ag-stat-expected">?</b></div>
<div>Загружено: <b id="ag-stat-loaded">0</b></div>
<div>Экспортировано: <b id="ag-stat-exported">0</b></div>
`;
return el;
}
function setStat(id, value) {
const el = document.getElementById(id);
if (el) el.textContent = value;
}
/*********************
* WAIT LIST
*********************/
async function waitForListReady(timeout = 12000) {
const start = Date.now();
while (Date.now() - start < timeout) {
if (document.querySelector('.user-mylist__item')) return;
await sleep(300);
}
throw new Error('List not loaded');
}
/*********************
* SCROLL
*********************/
async function scrollUntilLoaded(expected) {
let last = 0, stable = 0;
setStat('ag-stat-expected', expected ?? '?');
setStat('ag-stat-status', 'scrolling');
while (true) {
window.scrollTo({ top: document.body.scrollHeight });
await sleep(700);
const c = document.querySelectorAll('.user-mylist__item').length;
setStat('ag-stat-loaded', c);
log('Loaded:', c);
if (c === last) stable++;
else { stable = 0; last = c; }
if ((expected && c >= expected) || stable >= 4) break;
}
}
/*********************
* PARSER
*********************/
function parseCard(card, status) {
const a = card.querySelector('.user-mylist__title a');
return {
russian: a?.textContent.trim() || null,
original: card.querySelector('.fw-lighter.small')?.textContent.trim() || null,
score: parseInt(card.querySelector('[data-rating-value]')?.textContent || '0', 10) || 0,
status,
href: a?.getAttribute('href') || null
};
}
/*********************
* SHIKIMORI MATCHING
*********************/
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';
}
/*********************
* EXPORT
*********************/
async function exportCurrentList() {
const type = getCurrentType();
const status = TYPE_MAP[type];
if (!status) return alert('Неизвестный тип');
setStat('ag-stat-status', 'init');
try {
await waitForListReady();
await scrollUntilLoaded(getExpectedCount(type));
setStat('ag-stat-status', 'parsing');
const items = [];
document.querySelectorAll('.user-mylist__item').forEach(c => {
items.push(parseCard(c, status));
setStat('ag-stat-exported', items.length);
});
// Загрузка базы Shikimori
setStat('ag-stat-status', 'loading shiki base');
let shikiArray = null;
try {
const resp = await fetch(SHIKIMORI_JSON_URL, {credentials: 'same-origin'});
shikiArray = await resp.json();
} catch(e){
console.error(e);
alert('Не удалось загрузить базу Shikimori. Проверь SHIKIMORI_JSON_URL.');
return;
}
const idx = buildIndex(shikiArray);
setStat('ag-stat-status', 'mapping');
const out = [];
const debug = [];
for (const it of items) {
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});
}
finalizeExport(out);
setStat('ag-stat-status', 'done');
setStat('ag-stat-exported', out.length);
if(debug.length > 0) console.info('Unmatched (first 50):', debug.slice(0,50));
} catch (e) {
console.error(e);
setStat('ag-stat-status', 'error');
alert('Ошибка экспорта (см. консоль)');
}
}
function finalizeExport(items) {
const blob = new Blob([JSON.stringify(items, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `animego_to_shikimori_export.json`;
a.click();
}
/*********************
* UI + OBSERVER
*********************/
function tryInsertUI() {
if (document.getElementById('ag-shiki-export')) return;
const anchor =
document.querySelector('[data-user-stats]')?.parentElement ||
document.querySelector('.profile-head') ||
document.querySelector('.profile-header');
if (!anchor) return;
log('UI mounted');
const btn = document.createElement('button');
btn.id = 'ag-shiki-export';
btn.className = 'btn btn-primary ms-2';
btn.textContent = 'Экспорт в Shikimori';
btn.onclick = exportCurrentList;
const wrap = document.createElement('div');
wrap.style.display = 'inline-block';
wrap.append(btn, createStatsPanel());
anchor.appendChild(wrap);
}
function observeUI() {
const obs = new MutationObserver(() => tryInsertUI());
obs.observe(document.body, { childList: true, subtree: true });
tryInsertUI();
}
/*********************
* INIT
*********************/
let observerStarted = false;
function initObserverOnce() {
if (observerStarted) return;
observerStarted = true;
log('Starting UI observer');
observeUI();
}
initObserverOnce();
window.addEventListener('load', async () => {
await sleep(1200);
initObserverOnce();
});
})();Установка:
- Установите Tampermonkey в браузер.
- Создайте новый скрипт и вставьте код выше.
- Сохраните и обновите страницу списка аниме на AnimeGo.
Скриншоты/Пример работы:
Выглядит чуть коряво, но и фиг с ним.

@Graf_NEET@Egor Zakin, :один эмодзи красное сердце:@Graf_NEET@Niplusch, чуть позже поправлю@Niplusch@Graf_NEET, было бы здорово, а то перекидывать 400-500 строк вручную, целый день потратить нужно.@Graf_NEET@Niplusch, Я обновил топик и код, к сожалению каждый раздел отдельно придется тебе экспортировать.@Niplusch,@Niplusch@Graf_NEET, это не проблема), главное не руками, спасибо большое)@Graf_NEET, Статус:Ошибка. Неверный формат списка. В списке отсутствуют поля:
target_type,target_idЧто-то там пошло не так
@Graf_NEET@Niplusch, ИИ чуть словила галлюцинацию и при фиксе бага вырезала часть кода, я поправил и вернул код на свое место.@Niplusch@Graf_NEET, круть, Заработало, все выгрузил, большое спасибо.@Graf_NEET, Мля чел, спасибо большое, ты просто очишуенен