// ==UserScript==
// @name MAL List Exporter
// @namespace http://tampermonkey.net/
// @version 2026-04-23
// @description Export any MyAnimeList.net list you have access to as XML.
// @author Nutzboie Funnimanne
// @license AGPL-3.0-only
// @match https://myanimelist.net/animelist/*
// @match https://myanimelist.net/mangalist/*
// @homepage https://github.com/nutzboi/MAL-Exporter
// @downloadURL https://raw.githubusercontent.com/nutzboi/MAL-Exporter/master/userscript/MAL-Exporter.user.js
// @updateURL https://raw.githubusercontent.com/nutzboi/MAL-Exporter/master/userscript/MAL-Exporter.user.js
// @icon https://raw.githubusercontent.com/nutzboi/MAL-Exporter/master/favicon.png
// @grant none
// ==/UserScript==
(function() {
'use strict';
// Assumes this script runs in the browser on a MyAnimeList anime/manga list page.
// It will try to fetch "load.json" from the same origin; if not present, it will
// try to reuse an already-loaded `loadjson` variable on the page (if one exists).
// Uses the previously converted jsonToXml function (import/define it above or include inline).
let userid = 0;
let username = window.location.pathname.split("/").pop();
let update_on_import = false
if(username.indexOf("?") != -1){
username = username.split("?")[0];
}
if(username.indexOf("#") != -1){
username = username.split("#")[0];
}
let lstype = 0;
const path = window.location.pathname.toLowerCase();
if (path.includes('.net/mangalist/')) lstype = 1;
const btnsvg = "";
const btntitle = "\nExport";
(async function() {
function buildMyInfo(loadjson, lstype, lstypestr) {
// lstype: 0 = anime, 1 = manga (matching earlier code)
// uses global userid, username
const st = {
1: lstype ? "reading" : "watching",
2: "completed",
3: "onhold",
4: "dropped",
6: lstype ? "plantoread" : "plantowatch"
};
// stats indices 0..6 (keep 7 elements like original)
const stats = new Array(7).fill(0);
for (const entry of loadjson) {
const statKey = dic[lstype].j.stat;
const idx = Number(entry[statKey]) || 0;
if (idx >= 0 && idx < stats.length) stats[idx] += 1;
}
const info = {
user_id: userid,
user_name: username,
user_export_type: lstype + 1,
["user_total_" + lstypestr]: loadjson.length
};
for (const iStr of Object.keys(st)) {
const i = Number(iStr);
info["user_total_" + st[i]] = stats[i] || 0;
}
return info;
}
// getData returns a Promise resolving to loadjson array, or rejects/returns null on failure.
// listType defaults to "anime".
async function getData(user, listType = "anime") {
// global userid will be set if extraction succeeds
try {
// Fetch profile page to extract user id
const profileResp = await fetch(`https://myanimelist.net/profile/${encodeURIComponent(user)}`, { credentials: "omit" });
if (!profileResp.ok) {
console.error(`${profileResp.status} User does not exist.`);
return null;
}
const profileText = await profileResp.text();
// Attempt to locate the id fragment used in the Python version
const needle = "https://myanimelist.net/modules.php?go=report&type=profile&id=";
let uid = null;
const startPos = profileText.indexOf(needle);
if (startPos !== -1) {
const idStart = startPos + needle.length;
let idEnd = profileText.indexOf('"', idStart);
if (idEnd === -1) idEnd = profileText.length;
const idStr = profileText.slice(idStart, idEnd).replace(/[^0-9]/g, "");
if (idStr) {
uid = parseInt(idStr, 10);
userid = uid; // set global
} else {
console.warn("Warning: Failed to extract user id.");
}
} else {
console.warn("Warning: Failed to extract user id.");
}
// Fetch initial load.json
const baseUrl = `https://myanimelist.net/${listType}list/${encodeURIComponent(user)}/load.json`;
let resp = await fetch(baseUrl+`?status=7`, { credentials: "include" });
if (!resp.ok) {
console.error(`User's ${listType} list is not public.`);
return null;
}
let loadjson = await resp.json();
// If empty or not array, treat as failure
if (!Array.isArray(loadjson)) loadjson = Array.isArray(loadjson.list) ? loadjson.list : [];
// Fetch additional pages (offsets of 300) until no more results
let offset = 300;
while (true) {
const pagedUrl = `${baseUrl}?status=7&offset=${offset}`;
const r = await fetch(pagedUrl, { credentials: "include" });
if (!r.ok) break;
const nextJson = await r.json();
if (!nextJson || (Array.isArray(nextJson) && nextJson.length === 0)) break;
// If server returns object with .list, prefer that
const chunk = Array.isArray(nextJson) ? nextJson : (Array.isArray(nextJson.list) ? nextJson.list : []);
if (chunk.length === 0) break;
loadjson = loadjson.concat(chunk);
offset += 300;
}
return loadjson;
} catch (err) {
console.error("getData error:", err);
return null;
}
}
/* // Try fetch from relative path "load.json"
async function fetchLoadJson() {
try {
const resp = await fetch(window.location.href+'/load.json', { credentials: 'same-origin' });
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const data = await resp.json();
if (Array.isArray(data)) return data;
// if the JSON wraps the array in a property, try common keys
if (data && Array.isArray(data.list)) return data.list;
return null;
} catch (e) {
return null;
}
} */
// jsonToXml function (concise inline version). If you already have a module, remove this block.
const dic = [
{ x: { id:"series_animedb_id", title:"series_title", type:"series_type", seps:"series_episodes",
weps:"my_watched_episodes", sdate:"my_start_date", fdate:"my_finish_date", score:"my_score",
store:"my_storage", storev:"my_storage_value", stat:"my_status", notes:"my_comments",
pri:"my_priority", tags:"my_tags", rew:"my_rewatching", updimp: "update_on_import" },
j: { id:"anime_id", title:"anime_title", type:"anime_media_type_string", seps:"anime_num_episodes",
weps:"num_watched_episodes", sdate:"start_date_string", fdate:"finish_date_string",
score:"score", store:"storage_string", stat:"status", notes:"editable_notes", pri:"priority_string",
tags:"tags", rew:"is_rewatching" } },
{ x: { id:"manga_mangadb_id", title:"manga_title", mvol:"manga_volumes", mch:"manga_chapters",
rvol:"my_read_volumes", rch:"my_read_chapters", sdate:"my_start_date", fdate:"my_finish_date",
score:"my_score", ret:"my_retail_volumes", stat:"my_status", notes:"my_comments",
tags:"my_tags", pri:"my_priority", rew:"my_rereading", updimp: "update_on_import" },
j: { id:"id", title:"manga_title", mvol:"manga_num_volumes", mch:"manga_num_chapters",
rvol:"num_read_volumes", rch:"num_read_chapters", sdate:"start_date_string",
fdate:"finish_date_string", score:"score", ret:"retail_string", stat:"status",
notes:"editable_notes", tags:"tags", pri:"priority_string", rew:"is_rereading" } }
];
function CD(s) {
if (s === null || s === undefined) s = "";
s = String(s);
return "/g, "]]]]>") + "]]>";
}
function formatDateToYMD(dateStr) {
if (!dateStr) return "0000-00-00";
const d = new Date(dateStr);
if (isNaN(d.getTime())) return "0000-00-00";
const yyyy = d.getFullYear().toString().padStart(4,"0");
const mm = (d.getMonth()+1).toString().padStart(2,"0");
const dd = d.getDate().toString().padStart(2,"0");
return `${yyyy}-${mm}-${dd}`;
}
function parseStore(s) {
let store = {"store": "", "storev": "0.00"};
const storedic = {
"RDVD": "Retail DVD",
"EHD": "External HD",
"VHS": "VHS",
"": "",
"HD":"Hard Drive",
"NAS": "NAS",
"DVD": "DVD / CD"
};
if (s) {
s = s.split(" ");
store["store"] = storedic[s[0]];
store["storev"] = s[1]+"0";
}
return store;
}
function jsonToXml(loadjsonArr = []) {
let lstype = 0, lstypestr = "anime";
if (loadjsonArr.length > 0 && Object.prototype.hasOwnProperty.call(loadjsonArr[0], "manga_id")) {
lstype = 1; lstypestr = "manga";
}
const st = {
0: "0",
1: (lstype?"Reading":"Watching"),
2: "Completed",
3: "On-Hold",
4: "Dropped",
6: (lstype?"Plan to Read":"Plan to Watch")
};
const newlist = [];
for (const jsonentry of loadjsonArr) {
newlist.push({[lstypestr]: {}});
for (const prop in dic[lstype].j) {
let obj = {};
const jsonKey = dic[lstype].j[prop];
let propval = jsonentry[jsonKey];
if (prop === "title" || prop === "notes" || prop === "tags")
obj = {[prop]: CD(propval)};
else if (prop === "pri")
obj = {[prop]: String(propval).toUpperCase()};
else if (prop.includes("date"))
obj = {[prop]: formatDateToYMD(propval)};
else if (prop == "store")
obj = parseStore(propval);
else if (prop == "stat")
obj = {[prop]: [st[[propval]]]};
else
obj = {[prop]: propval}
Object.assign(newlist[newlist.length - 1][lstypestr], obj);
}
newlist[newlist.length - 1][[lstypestr]].updimp = String(Number(update_on_import));
}
let xml = '\n\t\t', {
alt: 'Export',
css: { width: '16px', height: '16px', verticalAlign: 'middle' }
});
// Define the export button element
function createExportButton() {
return $('', {
class: 'icon-menu export',
title: 'Export list',
href: '#',
text: "Export"
}).html(btnsvg+btntitle);
}
// The function the button should run
function runExport(e) {
e.preventDefault();
// Replace this with the real export action
console.log('Export button clicked');
run(); // UNCOMMENT LINE UNCOMMENT LINE UNCOMMENT LINE UNCOMMENT LINE UNCOMMENT LINE UNCOMMENT LINE UNCOMMENT LINE
// Example: call jsonToXml/loadjson handling or open a modal
/*if (typeof window.runExportAction === 'function') {
window.runExportAction();
}*/
return;
}
$(function(){
var pathname = window.location.pathname || '';
var $menu = $('.list-menu-float').first();
var $existing = $menu.length ? $menu.find('.export').first() : $();
if(!$menu.length){
// No .list-menu-float: create one.
$menu = $('