import fs from "node:fs/promises"; import type { Book } from "../library"; import type { Browser } from "../browser"; async function getFiles(): Promise> { const pages: NodeListOf = await new Promise(async function ( resolve, reject, ) { const timeout = setTimeout(() => { reject(new Error("Page loading timeout.")); }, 60_000); let pages: NodeListOf; while (true) { pages = document.querySelectorAll("reader-page"); const loaded = pages.length > 0 && [...pages].every((page) => page.classList.contains("-gb-loaded")); if (loaded) { break; } else { await new Promise((resolve) => setTimeout(resolve, 100)); } } resolve(pages); clearTimeout(timeout); }); const images: Array = [...pages].map( (el) => el.querySelector("svg image")!, ); const files = [...images].map((image) => ({ url: image.href.baseVal })); return files; } async function drawImage(file: { url: string }): Promise { const res = await fetch(file.url); const blob = await res.blob(); const dataUrl: string = await new Promise((resolve, reject) => { const fileReader = Object.assign(new FileReader(), { onload(): void { resolve(this.result); }, onerror(e: ErrorEvent): void { const error = new Error(`${e.type}: ${e.message}`); reject(error); }, }); fileReader.readAsDataURL(blob); }); return dataUrl; } export function GooglePlayBooks(browser: Browser) { return { async login() { const ctx = await browser.newContext(); const page = await ctx.newPage(); await page.goto("https://accounts.google.com"); await page.waitForURL( (url) => url.origin === "https://myaccount.google.com", { timeout: 0 }, ); await browser.saveBrowserContext("google-play-books", ctx); }, async *pull(): AsyncGenerator { const ctx = await browser.loadBrowserContext("google-play-books"); const page = await ctx.newPage(); await page.goto( "https://play.google.com/books?type=comics&source=purchases", ); await page.waitForSelector("gpb-library-card"); for (const metadata of await page.$$("gpb-library-card .metadata")) { const readerUrl = await metadata.$eval("a", (a) => a.href); const [title, author] = (await metadata.innerText()).split("\n"); yield { id: NaN, platform: "google-play-books", readerUrl, title, authors: [author], }; process.stderr.write("."); } process.stderr.write(`\n`); }, async download(dir: string, book: Book) { const ctx = await browser.loadBrowserContext("google-play-books"); const page = await ctx.newPage(); await page.goto(book.readerUrl); await page.waitForSelector(".display"); const frame = page.frames().at(-1); if (!frame) { throw new Error("Frame not found."); } await frame.evaluate(function scrollToTop() { const viewport = document.querySelector("cdk-virtual-scroll-viewport"); viewport?.scroll({ top: 0 }); }); async function next(): Promise { return await frame!.evaluate(function scroll() { const viewport = document.querySelector( "cdk-virtual-scroll-viewport", ); if (!viewport) throw new Error("Viewport not found."); const hasNext = 1 <= Math.abs( viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop, ); if (hasNext) { viewport.scrollBy({ top: viewport.clientHeight }); } return hasNext; }); } const fileMap: Map = new Map(); while (await next()) { const files = await frame.evaluate(getFiles); for (const file of files) { if (fileMap.has(file.url)) continue; const dataUrl = await frame.evaluate(drawImage, file); fileMap.set(file.url, { ...file, dataUrl }); process.stderr.write("."); } } const files = [...fileMap.values()]; const digits = String(files.length).length; function pad(n: string) { return n.padStart(digits, "0"); } for (const [n, file] of Object.entries(files)) { const [prefix, base64] = file.dataUrl.split(",", 2); if (!prefix.startsWith("data:image/jpeg;")) { throw new Error("Only image/jpeg is supported."); } if (!prefix.endsWith(";base64")) { throw new Error("Only base64 is supported."); } const buffer = Buffer.from(base64, "base64"); await fs.writeFile(`${dir}/${pad(n)}.jpeg`, buffer); } process.stderr.write(`\n`); }, }; } GooglePlayBooks.site = ["https://play.google.com"];