Skip to content

Instantly share code, notes, and snippets.

@ag4ve
Created July 13, 2026 17:34
Show Gist options
  • Select an option

  • Save ag4ve/12e2f6531b5c163c44b36648b2b56ff5 to your computer and use it in GitHub Desktop.

Select an option

Save ag4ve/12e2f6531b5c163c44b36648b2b56ff5 to your computer and use it in GitHub Desktop.
iOS scriptable script to batch download humble bundle assets you purchased
// Humble ebooks Scriptable helper
// ============================================================
// GOAL
// -----
// * Log into humblebundle.com in a WebView.
// * Fetch /api/v1/user/order + each order's /api/v1/order/<gamekey>?all_tpkds=true.
// * Extract obvious ebook downloads (.epub/.mobi/.pdf etc.).
// * Let the user choose a download root folder via the iOS folder picker.
// * Download only NEW ebooks (don’t re-download existing files).
// * Keep state (what’s downloaded & where) in an iCloud JSON file.
// * Prepare “Books batch” folders that can be imported into Apple Books on macOS/iOS.
// * Always return to a main menu loop until the user chooses Exit.
//
// STATE FILE
// ----------
// Location: iCloud Scriptable documents:
// FileManager.iCloud().documentsDirectory() + "/humble_ebooks_state.json"
//
// Shape (evolves over time; compatible upgrades):
// {
// version: string, // script version that last wrote the file
// schemaVersion: number, // to help migrations
// cacheTtlDays: number,
// library: [...], // raw Humble order JSON objects
// libraryUpdatedAt: ISO string, // when `library` was fetched
// entries: [ // derived ebook entries
// {
// id: string, // stable key: gamekey/subproduct/fileName
// gamekey: string,
// bundleName: string, // eg "Humble Book Bundle: X"
// subTitle: string, // ebook “product” title
// format: string, // epub/mobi/pdf/…
// humbleUrl: string, // direct download URL (web)
// fileName: string, // suggested filename
// relativePath: string, // path under download root
// localPath: string|null, // full path where it lives (if downloaded)
// localExists: boolean, // fs check result
// downloadedAt: string|null,
// importedToBooks: boolean // if user has already batched/imported
// },
// ...
// ],
// downloadRootBookmark: string|null, // bookmark for chosen folder
// downloadRootDisplayName: string|null,// human friendly path/name
// lastRun: ISO string|null
// }
//
// IMPORTANT BEHAVIOUR
// -------------------
// * Main menu appears FIRST and after each completed action.
// * “Refresh Humble order JSON” will:
//
// - Open Humble library in WebView, let you log in,
// - Then use the same WebView (with your cookies) to fetch JSON
// via /api/v1/user/order and each /api/v1/order/<gamekey>?all_tpkds=true.
//
// * “Download new ebooks”:
//
// - Requires a download root (it will prompt with the folder picker).
// - Builds ebook entries from the Humble JSON each run.
// - Uses entry.relativePath to decide where to place a file.
// - If a file already exists at that location, it is NOT re-downloaded.
//
// * “Prepare Books batch”:
//
// - Creates one batch folder under <downloadRoot>/BooksBatches/.
// - Copies (not symlinks; Scriptable has no symlink API) up to BATCH_SIZE
// ebooks that are downloaded but not yet importedToBooks.
// - Marks those entries as pending/importedToBooks=true.
//
// * Logging:
//
// - logTS() prints timestamped log lines (for Scriptable’s log pane).
// - notifyStatus() sends a small notification after long-running operations.
//
// BUG/TODO NOTES
// --------------
// * If Humble changes their HTML “<pre>JSON</pre>” wrapper, the JSON scraping
// in humbleFetchJSON() may need adjustment.
// * No direct, fully-automatic Apple Books import is possible from Scriptable.
// The “Books batch” folders are meant for macOS Finder / Files.app drag-drop.
// ============================================================
const SCRIPT_VERSION = '2025-12-11-01';
const STATE_SCHEMA_VERSION = 2;
const DEFAULT_CACHE_TTL_DAYS = 2; // refresh Humble JSON every ~2 days
const BATCH_SIZE = 40; // ebooks per “Books batch” folder
const EBOOK_EXTS = ['epub', 'mobi', 'pdf', 'azw3', 'cbz', 'cbr'];
const fm = FileManager.iCloud();
const STATE_FILE = fm.joinPath(fm.documentsDirectory(), 'humble_ebooks_state.json');
// ---------- small utilities ----------
function nowIso() { return new Date().toISOString(); }
function logTS(msg) {
const ts = nowIso();
console.log(`${ts}: ${msg}`);
}
async function notifyStatus(title, body) {
try {
const n = new Notification();
n.title = title;
n.body = body;
n.sound = null;
await n.schedule();
} catch (err) {
logTS(`notifyStatus failed: ${String(err)}`);
}
}
function minutesBetween(olderIso, newerIso) {
const older = new Date(olderIso).getTime();
const newer = new Date(newerIso).getTime();
return (newer - older) / 60000;
}
function sanitizeName(name) {
if (!name) return 'unnamed';
return String(name)
.replace(/[\\/:*?"<>|]/g, '_')
.replace(/\s+/g, ' ')
.trim();
}
function sanitizeFileName(name) {
const cleaned = sanitizeName(name);
return cleaned === '' ? 'file' : cleaned;
}
// ---------- state load/save & migrations ----------
function defaultState() {
return {
version: SCRIPT_VERSION,
schemaVersion: STATE_SCHEMA_VERSION,
cacheTtlDays: DEFAULT_CACHE_TTL_DAYS,
library: null,
libraryUpdatedAt: null,
entries: [],
downloadRootBookmark: null,
downloadRootDisplayName: null,
lastRun: null
};
}
async function loadState() {
if (!fm.fileExists(STATE_FILE)) {
logTS(`State file does not exist at ${STATE_FILE}`);
return defaultState();
}
try {
const raw = fm.readString(STATE_FILE);
const obj = JSON.parse(raw);
logTS(`Loaded state file from ${STATE_FILE}, keys=${Object.keys(obj).join(',')}`);
if (typeof obj.schemaVersion !== 'number') {
obj.schemaVersion = 1;
}
if (obj.cacheTtlDays == null) obj.cacheTtlDays = DEFAULT_CACHE_TTL_DAYS;
obj.version = SCRIPT_VERSION;
// migrate schema v1 -> v2 (add entries array if missing)
if (obj.schemaVersion < STATE_SCHEMA_VERSION) {
if (!Array.isArray(obj.entries)) obj.entries = [];
obj.schemaVersion = STATE_SCHEMA_VERSION;
logTS(`Migrated state schema to v${STATE_SCHEMA_VERSION}`);
saveState(obj);
}
return obj;
} catch (err) {
logTS(`Failed to parse state; starting fresh. Error=${String(err)}`);
return defaultState();
}
}
function saveState(state) {
try {
state.version = SCRIPT_VERSION;
const text = JSON.stringify(state, null, 2);
fm.writeString(STATE_FILE, text);
logTS(`Saved state to ${STATE_FILE}, length=${text.length}`);
} catch (err) {
logTS(`ERROR: Failed to save state: ${String(err)}`);
}
}
// ---------- Humble JSON via WebView ----------
async function humbleLoginAndCollectLibrary(state) {
logTS('collectLibraryViaWebView: Creating WebView and loading Humble library…');
const wv = new WebView();
await wv.loadURL('https://www.humblebundle.com/home/library?sort=recent');
await wv.present(true); // user logs in, closes when ready
logTS('collectLibraryViaWebView: Login WebView closed; starting JSON collection via WebView.getHTML().');
// orders list
const ordersList = await humbleFetchJSON(
wv,
'https://www.humblebundle.com/api/v1/user/order'
);
if (!Array.isArray(ordersList)) {
throw new Error('Unexpected /api/v1/user/order response; expected array.');
}
logTS(`collectLibraryViaWebView: got ${ordersList.length} order stubs`);
const orders = [];
for (let i = 0; i < ordersList.length; i++) {
const stub = ordersList[i];
const gamekey = stub.gamekey || stub.game_key || stub.gameKey;
if (!gamekey) {
logTS(`collectLibraryViaWebView: order stub ${i} missing gamekey; skipping`);
continue;
}
const url = `https://www.humblebundle.com/api/v1/order/${encodeURIComponent(
gamekey
)}?all_tpkds=true`;
logTS(`collectLibraryViaWebView: fetching order ${i + 1}/${
ordersList.length
} -> ${url}`);
const order = await humbleFetchJSON(wv, url);
orders.push(order);
}
state.library = orders;
state.libraryUpdatedAt = nowIso();
saveState(state);
logTS(
`collectLibraryViaWebView: stored ${orders.length} orders; libraryUpdatedAt=${state.libraryUpdatedAt}`
);
await notifyStatus(
'Humble ebooks',
`Fetched ${orders.length} Humble orders.`
);
}
// Extract JSON from the HTML <pre> wrapper Humble uses for API responses.
async function humbleFetchJSON(webView, url) {
logTS(`[WV_FETCH] loadURL -> ${url}`);
await webView.loadURL(url);
const text = await webView.getHTML();
logTS(
`[WV_FETCH] got text length=${text.length}, preview=${text.slice(
0,
120
).replace(/\s+/g, ' ')}`
);
// Try extracting either an array or an object from within the HTML.
function sliceJsonCandidate(t, openChar, closeChar) {
const start = t.indexOf(openChar);
const end = t.lastIndexOf(closeChar);
if (start === -1 || end === -1 || end <= start) return null;
return t.slice(start, end + 1);
}
const candidates = [];
const objCandidate = sliceJsonCandidate(text, '{', '}');
const arrCandidate = sliceJsonCandidate(text, '[', ']');
if (objCandidate) candidates.push(objCandidate);
if (arrCandidate) candidates.push(arrCandidate);
let lastError = null;
for (const c of candidates) {
try {
const json = JSON.parse(c);
logTS(`[WV_FETCH] parsed JSON successfully for ${url}`);
return json;
} catch (err) {
lastError = err;
// continue
}
}
throw new Error(
`Failed to parse JSON from ${url}: ${String(lastError || 'no JSON candidate')}`
);
}
function isLibraryFresh(state) {
if (!state.library || !state.libraryUpdatedAt) return false;
const ageMin = minutesBetween(state.libraryUpdatedAt, nowIso());
const ttlMin = (state.cacheTtlDays || DEFAULT_CACHE_TTL_DAYS) * 1440;
logTS(
`isLibraryFresh: ageMinutes=${ageMin.toFixed(
1
)}, ttlMinutes=${ttlMin.toFixed(1)}`
);
return ageMin <= ttlMin && state.library.length > 0;
}
async function ensureHumbleLibrary(state) {
logTS('ensureHumbleLibrary: starting.');
if (isLibraryFresh(state)) {
logTS(
`ensureHumbleLibrary: cache is fresh; library entries=${state.library.length}`
);
return;
}
logTS(
'ensureHumbleLibrary: cache is stale or library empty; refreshing via WebView…'
);
await humbleLoginAndCollectLibrary(state);
}
// ---------- ebook entry extraction & filesystem sync ----------
function entryIdFromPieces(gamekey, subTitle, fileName) {
return [
String(gamekey || '').trim(),
String(subTitle || '').trim(),
String(fileName || '').trim()
].join('::');
}
// Rebuild entries from the raw Humble library JSON, preserving per-entry
// flags (localPath/importedToBooks) where possible by ID.
function rebuildEntriesFromLibrary(state) {
if (!Array.isArray(state.library)) {
state.entries = [];
return;
}
const prevById = {};
if (Array.isArray(state.entries)) {
for (const e of state.entries) {
if (e && e.id) prevById[e.id] = e;
}
}
const entries = [];
for (const order of state.library) {
if (!order) continue;
const gamekey = order.gamekey || order.game_key || order.gameKey;
const product = order.product || {};
const bundleName =
product.human_name ||
product.humanName ||
product.machine_name ||
product.machineName ||
'Humble Bundle';
const subproducts = order.subproducts || [];
for (const sub of subproducts) {
const subTitle =
sub.human_name || sub.humanName || sub.machine_name || sub.machineName;
const downloads = sub.downloads || [];
for (const dl of downloads) {
const dStructs = dl.download_struct || dl.downloads || [];
for (const ds of dStructs) {
const urlObj = ds.url || {};
const url = urlObj.web || urlObj.bittorrent || urlObj.us || urlObj.uk;
if (!url) continue;
const fileNameSource =
ds.file_name || ds.filename || ds.name || subTitle || 'ebook';
const fileName = sanitizeFileName(fileNameSource);
const lowerName = fileName.toLowerCase();
const lowerLabel = String(ds.name || '').toLowerCase();
let ext = '';
const dotIdx = lowerName.lastIndexOf('.');
if (dotIdx !== -1) ext = lowerName.slice(dotIdx + 1);
const looksLikeEbook =
EBOOK_EXTS.includes(ext) ||
/epub|mobi|pdf|ebook|book/i.test(lowerLabel);
if (!looksLikeEbook) continue;
const id = entryIdFromPieces(gamekey, subTitle, fileName);
const prev = prevById[id] || {};
const bundleDir = sanitizeName(bundleName);
const relativePath = `${bundleDir}/${sanitizeFileName(
subTitle
)} - ${fileName}`;
const entry = {
id,
gamekey,
bundleName,
subTitle: subTitle || fileName,
format: ext || 'ebook',
humbleUrl: url,
fileName,
relativePath,
localPath: prev.localPath || null,
localExists: false,
downloadedAt: prev.downloadedAt || null,
importedToBooks: !!prev.importedToBooks
};
entries.push(entry);
}
}
}
}
logTS(
`rebuildEntriesFromLibrary: extracted ${entries.length} ebook candidates from ${state.library.length} orders`
);
state.entries = entries;
}
// Use downloadRootBookmark to check which entries already exist on disk.
function syncEntriesWithFilesystem(state) {
if (!Array.isArray(state.entries)) state.entries = [];
if (!state.downloadRootBookmark) {
for (const e of state.entries) {
e.localExists = false;
e.localPath = null;
}
logTS(
'syncEntriesWithFilesystem: no download root bookmark; all entries treated as not downloaded.'
);
return;
}
let rootPath;
try {
rootPath = fm.bookmarkedPath(state.downloadRootBookmark);
} catch (err) {
logTS(
`syncEntriesWithFilesystem: failed to resolve bookmark; clearing. Error=${String(
err
)}`
);
state.downloadRootBookmark = null;
state.downloadRootDisplayName = null;
for (const e of state.entries) {
e.localExists = false;
e.localPath = null;
}
saveState(state);
return;
}
if (!fm.isDirectory(rootPath)) {
logTS(
`syncEntriesWithFilesystem: resolved rootPath is not a directory: ${rootPath}`
);
}
for (const e of state.entries) {
const fullPath = fm.joinPath(rootPath, e.relativePath);
const exists = fm.fileExists(fullPath);
e.localExists = exists;
e.localPath = exists ? fullPath : null;
}
const counts = summarizeEntryStats(state.entries);
logTS(
`syncEntriesWithFilesystem: total=${counts.total}, localExists=${counts.localExists}, pendingDownload=${counts.pendingDownload}, pendingBooks=${counts.pendingBooks}`
);
}
function summarizeEntryStats(entries) {
let total = 0;
let localExists = 0;
let pendingDownload = 0;
let pendingBooks = 0;
for (const e of entries) {
if (!e) continue;
total++;
if (e.localExists) localExists++;
else pendingDownload++;
if (e.localExists && !e.importedToBooks) pendingBooks++;
}
return { total, localExists, pendingDownload, pendingBooks };
}
// ---------- download root selection ----------
async function ensureDownloadRoot(state) {
if (state.downloadRootBookmark) {
return;
}
await promptForDownloadRoot(state);
}
async function promptForDownloadRoot(state) {
const folderPath = await DocumentPicker.openFolder();
if (!folderPath) {
throw new Error('No folder selected.');
}
const bookmark = fm.bookmarkForFile(folderPath);
state.downloadRootBookmark = bookmark;
state.downloadRootDisplayName = folderPath;
saveState(state);
logTS(`Selected download root folder: ${folderPath}`);
}
// ---------- downloading ebooks ----------
async function downloadNewEbooks(state) {
await ensureHumbleLibrary(state);
rebuildEntriesFromLibrary(state);
await ensureDownloadRoot(state);
syncEntriesWithFilesystem(state);
const entries = state.entries || [];
const countsBefore = summarizeEntryStats(entries);
const candidates = entries.filter((e) => !e.localExists);
logTS(
`downloadNewEbooks: candidates=${candidates.length}, beforeStats=${JSON.stringify(
countsBefore
)}`
);
if (candidates.length === 0) {
const a = new Alert();
a.title = 'Download Ebooks';
a.message =
'No new ebooks need downloading.\n\n' +
`Downloaded files: ${countsBefore.localExists}\n` +
`Pending for Books: ${countsBefore.pendingBooks}`;
a.addAction('OK');
await a.present();
return;
}
let rootPath = fm.bookmarkedPath(state.downloadRootBookmark);
const total = candidates.length;
let doneCount = 0;
let skippedCount = 0;
let errorCount = 0;
for (const e of candidates) {
doneCount++;
const fullPath = fm.joinPath(rootPath, e.relativePath);
const dir = fullPath.slice(0, fullPath.lastIndexOf('/'));
if (!fm.isDirectory(dir)) {
fm.createDirectory(dir, true);
}
if (fm.fileExists(fullPath)) {
logTS(
`downloadNewEbooks: already exists on disk; marking downloaded. ${fullPath}`
);
e.localExists = true;
e.localPath = fullPath;
skippedCount++;
continue;
}
try {
logTS(`Downloading ${doneCount}/${total}: ${e.humbleUrl}`);
const req = new Request(e.humbleUrl);
const data = await req.load();
fm.write(fullPath, data);
e.localExists = true;
e.localPath = fullPath;
e.downloadedAt = nowIso();
logTS(
`Saved ebook to ${fullPath}, size=${data.byteLength || data.length}`
);
} catch (err) {
logTS(
`ERROR downloading ${e.humbleUrl} -> ${fullPath}: ${String(err)}`
);
errorCount++;
}
if (doneCount % 5 === 0) {
saveState(state); // checkpoint
}
}
saveState(state);
syncEntriesWithFilesystem(state);
const countsAfter = summarizeEntryStats(entries);
const msgLines = [
`Downloaded: ${doneCount - skippedCount - errorCount}`,
`Already on disk: ${skippedCount}`,
`Errors: ${errorCount}`,
'',
`Total ebooks: ${countsAfter.total}`,
`Downloaded files: ${countsAfter.localExists}`,
`Pending for Books: ${countsAfter.pendingBooks}`
];
const a = new Alert();
a.title = 'Download Ebooks';
a.message = msgLines.join('\n');
a.addAction('OK');
await a.present();
await notifyStatus(
'Humble ebooks',
`Download complete. New=${doneCount - skippedCount - errorCount}, errors=${errorCount}`
);
}
// ---------- prepare Books batch folders ----------
async function prepareBooksBatch(state) {
await ensureHumbleLibrary(state);
rebuildEntriesFromLibrary(state);
await ensureDownloadRoot(state);
syncEntriesWithFilesystem(state);
const entries = state.entries || [];
const eligible = entries.filter((e) => e.localExists && !e.importedToBooks);
if (eligible.length === 0) {
const counts = summarizeEntryStats(entries);
const a = new Alert();
a.title = 'Prepare Books Batch';
a.message =
'No ebooks are ready to batch for Books.\n\n' +
`Downloaded files: ${counts.localExists}\n` +
`Already marked for/imported to Books: ${
counts.localExists - counts.pendingBooks
}`;
a.addAction('OK');
await a.present();
return;
}
const batchSize = Math.min(BATCH_SIZE, eligible.length);
const batchItems = eligible.slice(0, batchSize);
const rootPath = fm.bookmarkedPath(state.downloadRootBookmark);
const batchesRoot = fm.joinPath(rootPath, 'BooksBatches');
if (!fm.isDirectory(batchesRoot)) {
fm.createDirectory(batchesRoot, true);
}
const stamp = new Date()
.toISOString()
.replace(/[:-]/g, '')
.replace(/\..+$/, '');
const batchFolder = fm.joinPath(batchesRoot, `Batch-${stamp}`);
fm.createDirectory(batchFolder, true);
let copied = 0;
for (const e of batchItems) {
if (!e.localPath || !fm.fileExists(e.localPath)) continue;
const dest = fm.joinPath(batchFolder, e.fileName);
if (!fm.fileExists(dest)) {
fm.copy(e.localPath, dest);
copied++;
}
e.importedToBooks = true; // mark as “batched”
}
saveState(state);
const msgLines = [
`Batch folder:`,
batchFolder,
'',
`Files in batch: ${copied}`,
`Remaining pending for Books: ${
summarizeEntryStats(entries).pendingBooks
}`
];
const a = new Alert();
a.title = 'Prepare Books Batch';
a.message = msgLines.join('\n');
a.addAction('OK');
await a.present();
await notifyStatus(
'Humble ebooks',
`Prepared Books batch: ${copied} files.`
);
}
// ---------- menu & summary ----------
async function showSummaryDialog(state) {
const entries = state.entries || [];
const counts = summarizeEntryStats(entries);
const updatedAt = state.libraryUpdatedAt || 'never';
const a = new Alert();
a.title = 'Humble ebooks';
a.message =
`Script version: ${SCRIPT_VERSION}\n` +
`Library orders: ${Array.isArray(state.library) ? state.library.length : 0}\n` +
`Library updated at:\n${updatedAt}\n\n` +
`Ebook entries: ${counts.total}\n` +
`Downloaded files: ${counts.localExists}\n` +
`Pending download: ${counts.pendingDownload}\n` +
`Pending for Books: ${counts.pendingBooks}\n\n` +
`Download root:\n${
state.downloadRootDisplayName || '(not chosen yet)'
}`;
a.addAction('OK');
await a.present();
}
async function mainMenuLoop(state) {
while (true) {
rebuildEntriesFromLibrary(state); // no-op if library missing
if (state.downloadRootBookmark) {
syncEntriesWithFilesystem(state);
}
const entries = state.entries || [];
const counts = summarizeEntryStats(entries);
const menu = new Alert();
menu.title = 'Humble ebooks';
menu.message =
`Choose an action.\n\n` +
`Orders: ${
Array.isArray(state.library) ? state.library.length : 0
} | Ebooks: ${counts.total}\n` +
`Downloaded: ${counts.localExists}, Pending DL: ${
counts.pendingDownload
}, Pending Books: ${counts.pendingBooks}\n` +
`Download root: ${
state.downloadRootDisplayName || '(not set)'
}`;
menu.addAction('Refresh Humble JSON');
menu.addAction('Download new ebooks');
menu.addAction('Prepare Books batch');
menu.addAction('Change download folder');
menu.addAction('Show summary');
menu.addCancelAction('Exit');
const idx = await menu.presentSheet();
if (idx === -1) {
// Exit
logTS('User chose Exit from main menu.');
return;
}
try {
if (idx === 0) {
await ensureHumbleLibrary(state);
} else if (idx === 1) {
await downloadNewEbooks(state);
} else if (idx === 2) {
await prepareBooksBatch(state);
} else if (idx === 3) {
await promptForDownloadRoot(state);
} else if (idx === 4) {
await showSummaryDialog(state);
}
} catch (err) {
const msg = `Top-level error in menu action: ${String(err)}`;
logTS(msg);
const a = new Alert();
a.title = 'Script crashed';
a.message = msg;
a.addAction('OK');
await a.present();
}
}
}
// ---------- entry point ----------
async function main() {
try {
logTS(`Starting Humble ebooks script (version=${SCRIPT_VERSION})`);
const state = await loadState();
state.lastRun = nowIso();
saveState(state);
await mainMenuLoop(state);
} catch (outerErr) {
const msg = 'Top-level error: ' + String(outerErr);
logTS(msg);
const a = new Alert();
a.title = 'Script crashed';
a.message = msg;
a.addAction('OK');
await a.present();
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment