// ==UserScript==
// @name Bunpro Exporter
// @namespace https://github.com/zyaga/bunpro-exporter
// @version 1.0
// @description Tampermonkey / Greasemonkey userscript that adds a floating export button to Bunpro.jp pages which exports all of a users in progress and learned vocabulary to a CSV file.
// @author Zyaga
// @license MIT
// @match https://bunpro.jp/*
// @icon https://bunpro.jp/favicon.ico
// @run-at document-end
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_notification
// ==/UserScript==
// @run-at document-end
// ==/UserScript==
// --- Efficient dashboard detector ---
(function setupDashboardDetector() {
let active = false;
let lastPath = location.pathname;
function checkRoute() {
const isDashboard = location.pathname.startsWith("/dashboard");
if (isDashboard && !active) {
active = true;
main();
} else if (!isDashboard && active) {
active = false;
document.querySelector(".bp-fab-wrap")?.remove();
window.__BUNPRO_EXPORTER_UI__ = false;
}
lastPath = location.pathname;
}
// Hook the history API (covers most SPA transitions)
const _ps = history.pushState;
const _rs = history.replaceState;
history.pushState = function () {
_ps.apply(this, arguments);
window.dispatchEvent(new Event("locationchange"));
};
history.replaceState = function () {
_rs.apply(this, arguments);
window.dispatchEvent(new Event("locationchange"));
};
window.addEventListener("popstate", () =>
window.dispatchEvent(new Event("locationchange")),
);
window.addEventListener("locationchange", checkRoute);
// Observe the main React mount point (catches internal rerenders)
const root = document.querySelector("main") || document.body;
const observer = new MutationObserver(() => {
if (location.pathname !== lastPath) {
checkRoute();
}
});
observer.observe(root, { childList: true, subtree: true });
// Initial run
checkRoute();
})();
// --- end detector ---
function main() {
(() => {
if (window.__BUNPRO_EXPORTER_UI__) return;
window.__BUNPRO_EXPORTER_UI__ = true;
if (!location.pathname.startsWith("/dashboard")) return;
/************** 1) Inject page-scope token hook (no auto-run) **************/
const hookCode = `
(function(){
function normalize(raw){ if(!raw) return null; return raw.includes("Token token=")? raw.split("Token token=")[1] : raw.trim(); }
function postToken(t){ if(!t) return; window.__BUNPRO_TOKEN__ = t; window.postMessage({type:"bunpro_token", token:t},"*"); }
function storeToken(raw){ const t = normalize(raw); if(!t) return; if(window.__BUNPRO_TOKEN__!==t){ console.log("🔐 [Page] Captured Bunpro token:",t); } postToken(t); }
const origFetch = window.fetch;
window.fetch = function(input, init){
try{
const headers = (init && init.headers) || (input && input.headers);
if (headers){
if (headers.get){
const h = headers.get("Authorization") || headers.get("authorization");
if (h) storeToken(h);
} else if (Array.isArray(headers)){
for (const [k,v] of headers) if ((k||"").toLowerCase()==="authorization"){ storeToken(v); break; }
} else if (typeof headers === "object"){
for (const k in headers) if ((k||"").toLowerCase()==="authorization"){ storeToken(headers[k]); break; }
}
}
}catch(_){}
return origFetch.apply(this, arguments);
};
const origSet = XMLHttpRequest.prototype.setRequestHeader;
XMLHttpRequest.prototype.setRequestHeader = function(name, value){
try{ if((name||"").toLowerCase()==="authorization" && value) storeToken(value); }catch(_){}
return origSet.apply(this, arguments);
};
// If already present (e.g., SPA navigation), post it once
if (window.__BUNPRO_TOKEN__) postToken(window.__BUNPRO_TOKEN__);
console.log("🟢 [Page] Bunpro token hook injected.");
})();
`;
const sc = document.createElement("script");
sc.textContent = hookCode;
document.documentElement.appendChild(sc);
sc.remove();
// Keep latest token in GM storage
let latestToken = null;
window.addEventListener("message", async (ev) => {
if (!ev?.data || ev.data.type !== "bunpro_token") return;
latestToken = ev.data.token;
try {
await GM_setValue("bunpro_token", latestToken);
} catch {}
});
/************** 2) Floating button UI **************/
const styles = `
.bp-fab-wrap {
position: fixed; right: 16px; bottom: 16px; z-index: 2147483647;
display: flex; flex-direction: column; gap: 8px; align-items: flex-end;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
}
.bp-fab {
appearance: none; border: 0; outline: 0; cursor: pointer;
background: #2563eb; color: white; font-weight: 600; font-size: 14px;
padding: 10px 14px; border-radius: 9999px; box-shadow: 0 8px 20px rgba(0,0,0,.2);
display: inline-flex; align-items: center; gap: 10px;
transition: transform .08s ease, opacity .2s ease, background .2s ease;
}
.bp-fab:hover { background: #1d4ed8; }
.bp-fab.bp-disabled { opacity: .7; cursor: default; pointer-events: none; }
.bp-spinner {
width: 16px; height: 16px; border-radius: 9999px;
border: 2px solid rgba(255,255,255,.35); border-top-color: #fff;
animation: bp-spin .9s linear infinite;
}
@keyframes bp-spin { to { transform: rotate(360deg); } }
.bp-toast {
background: #0f172a; color: #e2e8f0; padding: 8px 10px; border-radius: 8px; font-size: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,.25); max-width: 280px;
}
.bp-success { background: #16a34a; color: #fff; }
.bp-error { background: #dc2626; color: #fff; }
`;
const st = document.createElement("style");
st.textContent = styles;
document.head.appendChild(st);
const wrap = document.createElement("div");
wrap.className = "bp-fab-wrap";
const toast = document.createElement("div");
toast.className = "bp-toast";
toast.style.display = "none";
const btn = document.createElement("button");
btn.className = "bp-fab";
btn.innerHTML = `📝 Export Bunpro CSV`;
wrap.appendChild(toast);
wrap.appendChild(btn);
document.body.appendChild(wrap);
function showToast(text, variant = "") {
toast.textContent = text;
toast.className = `bp-toast ${variant}`;
toast.style.display = "block";
clearTimeout(showToast._t);
showToast._t = setTimeout(() => {
toast.style.display = "none";
}, 4000);
}
function setLoading(on) {
if (on) {
btn.classList.add("bp-disabled");
btn.innerHTML = `<span class="bp-spinner"></span> Exporting…`;
} else {
btn.classList.remove("bp-disabled");
btn.innerHTML = `📝 Export Bunpro CSV`;
}
}
/************** 3) Click handler -> run exporter **************/
btn.addEventListener("click", async () => {
setLoading(true);
// Wait for token (most of the time we already have it)
let token = latestToken || (await GM_getValue("bunpro_token"));
const t0 = Date.now();
while (!token && Date.now() - t0 < 30000) {
// wait up to 30s
await new Promise((r) => setTimeout(r, 300));
token = latestToken || (await GM_getValue("bunpro_token"));
}
if (!token) {
setLoading(false);
showToast(
"Couldn’t get token. Trigger any Bunpro request (e.g. ‘See More’) and click again.",
"bp-error",
);
return;
}
try {
const total = await runExporter(token, (msg) => showToast(msg));
btn.innerHTML = `✅ Done (${total} items)`;
showToast(`Exported ${total} items. CSV downloaded.`, "bp-success");
try {
GM_notification({
title: "Bunpro Export Complete",
text: `${total} items exported`,
timeout: 4000,
});
} catch {}
} catch (e) {
console.error(e);
showToast(`Export failed: ${e?.message || e}`, "bp-error");
setLoading(false);
return;
}
// reset after a bit
setTimeout(() => setLoading(false), 1800);
});
/************** 4) Exporter (same logic, just callable) **************/
async function runExporter(token, notify) {
const LEVELS = [
{ api: "beginner", label: "Beginner" },
{ api: "adept", label: "Adept" },
{ api: "seasoned", label: "Seasoned" },
{ api: "expert", label: "Expert" },
{ api: "master", label: "Master" },
];
const TYPE = "Vocab";
const BASE = `https://api.bunpro.jp/api/frontend/user_stats/srs_level_details?reviewable_type=${TYPE}&level=`;
const all = new Map();
async function fetchLevel(api, label) {
let page = 1;
notify?.(`Fetching ${label}…`);
while (true) {
const url = `${BASE}${api}&page=${page}&_=${Date.now()}`;
const res = await fetch(url, {
method: "GET",
credentials: "include",
cache: "no-store",
headers: {
Authorization: `Token token=${token}`,
Accept: "application/json",
"X-Requested-With": "XMLHttpRequest",
"Cache-Control": "no-cache",
Pragma: "no-cache",
},
});
if (!res.ok) {
if (res.status === 500) break; // end
throw new Error(`${label} HTTP ${res.status} on page ${page}`);
}
const j = await res.json();
const d = j.reviews?.data || [];
const inc = j.reviews?.included || [];
if (!d.length && !inc.length) break;
const vocabMap = new Map(
inc.map((v) => [
String(v.id),
{
title: (v.attributes?.title ?? "").trim(),
meaning: (v.attributes?.meaning ?? "").trim(),
},
]),
);
for (const r of d) {
const id = String(r.attributes?.reviewable_id ?? "");
const v = vocabMap.get(id);
if (v && v.title && !all.has(v.title))
all.set(v.title, [v.title, v.meaning, label]);
}
page++;
await new Promise((r) => setTimeout(r, 150)); // be polite
}
}
for (const lvl of LEVELS) await fetchLevel(lvl.api, lvl.label);
// Build and download CSV
let csv = '"word","description","progress"\n';
for (const [, [w, d, p]] of all)
csv += `"${w.replace(/"/g, '""')}","${d.replace(/"/g, '""')}","${p}"\n`;
const blob = new Blob([csv], { type: "text/csv" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `bunpro_vocab_all_levels_${Date.now()}.csv`;
a.click();
return all.size;
}
// Optional initial hint
console.log(
"🟢 Bunpro Exporter ready — use the floating button when you want to export.",
);
})();
}
main();