Book page capture tool

Saves each page of an online book as its own HTML file, numbered _page_001, _page_002 … so they upload into Book Reading in the right order.

Static capture v3 excludes scripts, audio/video and unused styles/fonts while keeping page text, layout and links. Each page reports what is actually taking up space.

  1. Download the file below into your page-capture folder, replacing the old singlefile.mjs.
  2. Put the page links in links.txt, one per line, in page order.
  3. First run node singlefile.mjs "links.txt" "Book title" --limit 2, sign in when Chrome opens, then press ENTER. Check the two saved pages and their sizes before removing --limit 2 to capture the whole book.
/**
 * Capture book pages as SingleFile HTML, in order, with predictable names.
 *
 * Why: SingleFile names files after the page title, so a whole book ends up
 * with unusable or duplicated names and the pages lose their order. This saves
 * them as <Book>_page_001.html, 002, 003 ... so the Book Reading "HTML pages"
 * uploader keeps them in page order.
 *
 * How to get this file onto your PC: open the app's Capture tool page
 * (/capture-tool) and use "Download singlefile.mjs" or "Copy text", then paste
 * it over your local singlefile.mjs. This asset is the single source of that tool.
 *
 * Setup (once, in the folder you run it from — the SingleFile package needs Node 24+):
 *   npm i puppeteer single-file-cli
 *   npx puppeteer browsers install chrome
 *
 * Run:
 *   node singlefile.mjs links.txt "My Book"             one URL per line, in page order
 *   node singlefile.mjs links.txt                      asks for the book title
 *   node singlefile.mjs links.txt "My Book" --jobs 3    capture 3 pages at once (default 1, max 6)
 *   node singlefile.mjs links.txt "My Book" --limit 2   test only the first 2 pages
 *   node singlefile.mjs links.txt "My Book" --width 2800  keep larger page images
 *   node singlefile.mjs "https://..." "My Book"         single page
 *
 * Static capture v3: explicitly exclude scripts, audio/video, hidden content,
 * unused styles and unused/alternative fonts. Calling getPageData({}) does NOT
 * apply the CLI's cleanup defaults. Keep the page's text, layout and links.
 * Log the actual saved payload by resource type instead of assuming images
 * account for the file size. --limit 2 tests a short sample before a full run.
 * Images are still capped at 2000 px JPEG (quality 0.82); --width and --quality
 * can retain more detail. File size depends on the page's remaining resources.
 *
 * Sign-in: the first link is opened on its own BEFORE anything is saved, so the
 * login screen (or the home page it lands on) can never be saved as page 1.
 * Capture starts when you press ENTER. The session is kept in the same Chrome
 * profile your screenshot script uses.
 *
 * Pages are captured one at a time by default: the reader site only tolerates one
 * open page per session, so extra tabs come back blank ("detached Frame"). A page
 * that fails is retried in a fresh tab.
 *
 * Files are written to ./<Book title>/ — upload that folder in Book Reading.
 */

import fs from 'node:fs';
import path from 'node:path';
import readline from 'node:readline';
import puppeteer from 'puppeteer';
import {
  script as SINGLEFILE_SCRIPT,
  hookScript as SINGLEFILE_HOOK,
} from 'single-file-cli/lib/single-file-bundle.js';

const sleep = ms => new Promise(r => setTimeout(r, ms));

function ask(question) {
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
  return new Promise(resolve => rl.question(question, answer => { rl.close(); resolve(answer); }));
}

// The reader counts as ready once its page container (or at least the page
// image) is there — the same markers the Book Reading uploader looks for.
const readerReady = () =>
  !!document.querySelector('[data-print-page], [id^="page"][id$="-div"]') ||
  !!document.querySelector('img, canvas, svg');

const MAX_IMAGE_WIDTH = 2000;
const IMAGE_QUALITY = 0.82;
const SHRINK_ABOVE_BYTES = 40 * 1024;
const IMAGE_URL_PATTERN = 'data:image\\/(?:png|jpe?g|webp|bmp|tiff?);base64,[A-Za-z0-9+/=]+';

const megabytes = bytes => (bytes / 1024 / 1024).toFixed(1);
const CAPTURE_VERSION = 'static-v3';

// getPageData is the core API, not the CLI: supply its cleanup options explicitly.
// Do not block fonts or styles outright; used fonts and layout must stay intact.
const CAPTURE_OPTIONS = {
  blockScripts: true, blockAudios: true, blockVideos: true,
  removeFrames: true, removeHiddenElements: true,
  removeUnusedStyles: true, removeUnusedFonts: true,
  removeAlternativeFonts: true, removeAlternativeImages: true,
  removeAlternativeMedias: true, removeNoScriptTags: true,
  compressHTML: true, groupDuplicateImages: true,
};

function payloadBreakdown(html) {
  const groups = new Map();
  for (const match of html.matchAll(/data:([^,\s"'<>]*),[^\s"'<>)]*/gi)) {
    const type = match[1].split(';')[0].toLowerCase() || 'text/plain';
    groups.set(type, (groups.get(type) || 0) + Buffer.byteLength(match[0], 'utf8'));
  }
  const embeddedBytes = [...groups.values()].reduce((sum, bytes) => sum + bytes, 0);
  const totalBytes = Buffer.byteLength(html, 'utf8');
  const resources = [...groups.entries()].sort((a, b) => b[1] - a[1]);
  const scripts = [...html.matchAll(/<script\b[^>]*>[\s\S]*?<\/script\s*>/gi)]
    .reduce((sum, match) => sum + Buffer.byteLength(match[0], 'utf8'), 0);
  return [...resources.map(([type, bytes]) => `${type}: ${megabytes(bytes)} MB`),
    `other HTML: ${megabytes(Math.max(0, totalBytes - embeddedBytes))} MB`,
    `script blocks (included above): ${megabytes(scripts)} MB`].join(' | ');
}

// Defense in depth: remove non-reading payloads even if a SingleFile version
// leaves any behind. Parse inertly in the helper; never alter the live source.
async function cleanStaticPage(tab, html) {
  return tab.evaluate(source => {
    const doc = new DOMParser().parseFromString(source, 'text/html');
    const stats = { scripts: 0, media: 0, frames: 0, metadata: 0 };
    for (const [group, selector] of Object.entries({
      scripts: 'script, noscript', media: 'audio, video',
      frames: 'iframe, frame, frameset, object, embed',
      metadata: 'link[rel~="icon"], meta[http-equiv]',
    })) {
      for (const el of doc.querySelectorAll(selector)) { stats[group]++; el.remove(); }
    }
    for (const el of doc.querySelectorAll('*')) {
      for (const attr of [...el.attributes]) {
        if (/^on/i.test(attr.name)) el.removeAttribute(attr.name);
      }
    }
    return { html: '<!doctype html>\n' + doc.documentElement.outerHTML, stats };
  }, html);
}

/** Re-encode large raster images in a policy-free helper, retaining layout. */
async function shrinkImages(tab, html, { maxWidth, quality }) {
  return tab.evaluate(async (source, opts) => {
    const replacements = new Map();
    const stats = { found: 0, large: 0, resized: 0, small: 0, unreadable: 0, noGain: 0, bytes: 0 };
    const pattern = new RegExp(opts.pattern, 'gi');

    let match;
    while ((match = pattern.exec(source)) !== null) {
      const url = match[0];
      stats.found++;
      if (replacements.has(url)) continue;
      replacements.set(url, null);

      const bytes = Math.round(url.length * 0.75);
      if (bytes < opts.minBytes) {
        stats.small++;
        continue;
      }
      stats.bytes += bytes;
      stats.large++;

      try {
        const img = new Image();
        img.src = url;
        await img.decode();
        const width = img.naturalWidth || img.width;
        const height = img.naturalHeight || img.height;
        if (!width || !height) {
          stats.unreadable++;
          continue;
        }
        const scale = Math.min(1, opts.maxWidth / width);
        const canvas = document.createElement('canvas');
        canvas.width = Math.max(1, Math.round(width * scale));
        canvas.height = Math.max(1, Math.round(height * scale));
        const ctx = canvas.getContext('2d');
        // Page scans have no transparency; the white base keeps an image with
        // an alpha edge from turning black once it is JPEG.
        ctx.fillStyle = '#fff';
        ctx.fillRect(0, 0, canvas.width, canvas.height);
        ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
        const smaller = canvas.toDataURL('image/jpeg', opts.quality);
        if (smaller.length < url.length * 0.9) {
          replacements.set(url, smaller);
          stats.resized++;
        } else {
          stats.noGain++;
        }
      } catch {
        stats.unreadable++;
      }
    }

    let out = source;
    for (const [before, after] of replacements) {
      if (!after) continue;
      out = out.split(before).join(after);
    }
    return { html: out, stats };
  }, html, { maxWidth, quality, minBytes: SHRINK_ABOVE_BYTES, pattern: IMAGE_URL_PATTERN });
}

// One empty tab, reused for every page: no page policy, no reader scripts.
let helperTab = null;
async function shrinkTabFor(browser) {
  if (helperTab && !helperTab.isClosed()) return helperTab;
  helperTab = await browser.newPage();
  await helperTab.setContent('<!doctype html><meta charset="utf-8"><title>image helper</title>');
  return helperTab;
}

async function capturePage({ page, browser, url, index, total, outDir, safeBook, maxWidth, quality }) {
  const label = `(${index + 1}/${total})`;
  const pageNum = String(index + 1).padStart(3, '0');
  const file = path.join(outDir, `${safeBook}_page_${pageNum}.html`);

  console.log(label, 'open', url);

  await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 120000 });

  const needsLogin = await page.evaluate(
    () => !!document.querySelector('input[type="password"]')
  );
  if (needsLogin) {
    throw new Error('the site asked you to sign in again — run the command again and sign in at the start');
  }

  await page.waitForFunction(readerReady, { timeout: 20000 });
  await page
    .waitForFunction(() => Array.from(document.images).every(img => img.complete), { timeout: 6000 })
    .catch(() => {});
  await sleep(400);

  const engineReady = await page.evaluate(() => typeof window.singlefile?.getPageData === 'function');
  if (!engineReady) throw new Error('SingleFile engine did not load on this page');

  const pageData = await page.evaluate(options => window.singlefile.getPageData(options), CAPTURE_OPTIONS);
  const html = pageData?.content instanceof Uint8Array
    ? new TextDecoder().decode(pageData.content)
    : pageData?.content;

  if (typeof html !== 'string' || html.length === 0) throw new Error('the page could not be captured');

  console.log('        captured payload:', payloadBreakdown(html));
  const tab = await shrinkTabFor(browser);
  const cleaned = await cleanStaticPage(tab, html);
  const { html: shrunk, stats } = await shrinkImages(tab, cleaned.html, { maxWidth, quality });
  const finalBytes = Buffer.byteLength(shrunk, 'utf8');

  fs.writeFileSync(file, shrunk, 'utf8');
  console.log(
    label,
    'saved',
    path.basename(file),
    `(${megabytes(finalBytes)} MB, capture ${megabytes(Buffer.byteLength(html, 'utf8'))} MB)`
  );
  console.log('        removed:', JSON.stringify(cleaned.stats));
  console.log('        saved payload:', payloadBreakdown(shrunk));
  if (finalBytes > 5 * 1024 * 1024) {
    console.warn('        LARGE PAGE: still over 5 MB; inspect the payload above before capturing the whole book.');
  }
  console.log(
    '        images:',
    `${stats.found} found, ${stats.large} large (${megabytes(stats.bytes)} MB),`,
    `${stats.resized} resized, ${stats.unreadable} unreadable, ${stats.noGain} no gain`
  );
}

async function main() {
  const argv = process.argv.slice(2);
  const positional = [];
  let jobs = 1;
  let limit = Infinity;
  let maxWidth = MAX_IMAGE_WIDTH;
  let quality = IMAGE_QUALITY;

  for (let i = 0; i < argv.length; i++) {
    if (argv[i] === '--jobs') {
      jobs = Math.max(1, Math.min(6, parseInt(argv[i + 1], 10) || 1));
      i++;
    } else if (argv[i] === '--limit') {
      limit = Math.max(1, parseInt(argv[i + 1], 10) || 2);
      i++;
    } else if (argv[i] === '--width') {
      maxWidth = Math.max(600, parseInt(argv[i + 1], 10) || MAX_IMAGE_WIDTH);
      i++;
    } else if (argv[i] === '--quality') {
      quality = Math.min(1, Math.max(0.4, parseFloat(argv[i + 1]) || IMAGE_QUALITY));
      i++;
    } else {
      positional.push(argv[i]);
    }
  }

  const input = (positional[0] || '').trim();
  if (!input) {
    console.log('Usage: node singlefile.mjs <url OR links.txt> ["Book title"] [--jobs N] [--limit N]');
    process.exit(1);
  }

  const urls = (fs.existsSync(input)
    ? fs.readFileSync(input, 'utf8')
        .split(/\r?\n/)
        .map(line => line.trim())
        .filter(line => line && !line.startsWith('#'))
    : [input]).slice(0, limit);

  if (urls.length === 0) {
    console.log('No links found in', input);
    process.exit(1);
  }

  const bookTitle = (positional[1] || '').trim() || (await ask('Book title (used for filenames): ')).trim() || 'Book';
  const safeBook = bookTitle.replace(/[^\w-]+/g, '_');
  const outDir = path.join(process.cwd(), safeBook);
  fs.mkdirSync(outDir, { recursive: true });

  const workers = Math.min(jobs, urls.length);
  console.log('Capture version:', CAPTURE_VERSION, '| Static text, layout and links; no scripts/audio/video.');
  if (Number.isFinite(limit)) console.log('Trial run: first', urls.length, 'page(s) only.');
  console.log('Pages:', urls.length, '| Book:', safeBook, '| At once:', workers);
  console.log('Saving to:', outDir);

  const browser = await puppeteer.launch({
    headless: false,
    userDataDir: path.join(
      process.env.LOCALAPPDATA || path.join(process.cwd(), '.chrome-profile'),
      'hmh-shot-chrome-profile'
    ),
    defaultViewport: null,
  });

  const newCaptureTab = async () => {
    const page = await browser.newPage();
    await page.setViewport({ width: 1400, height: 900, deviceScaleFactor: 2 });
    await page.setDefaultTimeout(120000);
    // Injected before every navigation: the same SingleFile engine the
    // extension and the single-file CLI use, so pages come out identical.
    await page.evaluateOnNewDocument(SINGLEFILE_HOOK);
    await page.evaluateOnNewDocument(
      `${SINGLEFILE_SCRIPT}\nif (typeof singlefile !== 'undefined') { window.singlefile = singlefile; }`
    );
    return page;
  };

  // Open the first link before any page is saved, so signing in there can never
  // replace page 1. The capture loop starts from that same link afterwards.
  console.log('Opening the first link so you can sign in.');
  const loginTab = await newCaptureTab();
  await loginTab.goto(urls[0], { waitUntil: 'domcontentloaded', timeout: 120000 }).catch(() => {});
  await ask('Sign in if the site asks, then press ENTER to start capturing: ');

  const startedAt = Date.now();
  const failed = [];
  let next = 0;

  // Each worker owns one tab, opened only when that worker has a page to
  // capture. A page that fails is retried in a brand-new tab.
  const worker = async initialPage => {
    let page = initialPage;

    while (next < urls.length) {
      const index = next++;
      const label = `(${index + 1}/${urls.length})`;
      let lastError = null;

      for (let attempt = 1; attempt <= 3; attempt++) {
        try {
          if (!page || page.isClosed()) page = await newCaptureTab();
          await capturePage({ page, browser, url: urls[index], index, total: urls.length, outDir, safeBook, maxWidth, quality });
          lastError = null;
          break;
        } catch (error) {
          lastError = String(error?.message || error);
          if (page) { try { await page.close(); } catch {} page = null; }
          if (lastError.includes('sign in again')) break;
          if (attempt < 3) {
            console.log(label, 'retrying in a fresh tab —', lastError);
            await sleep(700);
          }
        }
      }

      if (lastError) {
        failed.push({ index, url: urls[index], message: lastError });
        console.log(`${label} FAILED —`, lastError);
      }
    }

    if (page) { try { await page.close(); } catch {} }
  };

  await Promise.all(
    Array.from({ length: workers }, (_, w) => worker(w === 0 ? loginTab : null))
  );

  await browser.close();

  const minutes = ((Date.now() - startedAt) / 60000).toFixed(1);
  console.log('Done in', minutes, 'min.', urls.length - failed.length, 'of', urls.length, 'pages saved in', outDir);

  if (failed.length > 0) {
    console.log('These pages failed — run the same command again to retry them:');
    failed
      .sort((a, b) => a.index - b.index)
      .forEach(item => console.log(`  page ${item.index + 1}: ${item.url} — ${item.message}`));
  }
}

main();