diff --git a/README.md b/README.md index b011130..e239297 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ $ npx https://git.fogtype.com/nebel/gadl/archive/main.tar.gz --help ## Supported Sites - DMM ブックス (漫画) +- FANZA 同人 - Google Play ブックス (漫画) ## License diff --git a/browser.ts b/browser.ts index 2500317..883b1ac 100644 --- a/browser.ts +++ b/browser.ts @@ -3,11 +3,78 @@ import { chromium, devices } from "playwright"; import type { Database } from "./database"; import type { TPlatform } from "./platform"; +export type ImageFile = { + url: string; + blocks?: Array>; + width?: number; + height?: number; +}; + +async function drawImage(imageFile: ImageFile): Promise { + const canvas = Object.assign(document.createElement("canvas"), { + width: imageFile.width, + height: imageFile.height, + }); + + const image = (await new Promise((resolve) => { + Object.assign(new Image(), { + crossOrigin: "use-credentials", + src: imageFile.url, + onload() { + resolve(this); + }, + }); + })) as HTMLImageElement; + + const ctx = canvas.getContext("2d")!; + + for (const q of imageFile.blocks!) { + ctx.drawImage( + image, + q.destX, + q.destY, + q.width, + q.height, + q.srcX, + q.srcY, + q.width, + q.height, + ); + } + + const dataUrl = canvas.toDataURL(); + return dataUrl; +} + +async function fetchImage(imageFile: ImageFile): Promise { + const res = await fetch(imageFile.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 type Browser = { loadBrowserContext(platform: TPlatform): Promise; saveBrowserContext(platform: TPlatform, ctx: BrowserContext): Promise; - newContext: () => Promise; - close: () => Promise; + newContext(): Promise; + close(): Promise; + drawImage( + pageOrFrame: Playwright.Page | Playwright.Frame, + imageFile: ImageFile, + ): Promise; }; export type BrowserContext = Playwright.BrowserContext; @@ -38,6 +105,7 @@ export async function createBrowser({ const ctx = await browser.newContext({ storageState, userAgent }); return ctx; }, + async saveBrowserContext( platform: TPlatform, ctx: BrowserContext, @@ -49,7 +117,29 @@ export async function createBrowser({ platform, ); }, + newContext: () => browser.newContext(), close: () => browser.close(), + + async drawImage( + pageOrFrame: Playwright.Page | Playwright.Frame, + imageFile: ImageFile, + ): Promise { + if (Array.isArray(imageFile.blocks) && imageFile.blocks.length > 0) { + return await pageOrFrame.evaluate(drawImage, imageFile); + } + + if (imageFile.url.startsWith("blob:")) { + return await pageOrFrame.evaluate(fetchImage, imageFile); + } + + const page = "page" in pageOrFrame ? pageOrFrame.page() : pageOrFrame; + const res = await page.context().request.get(imageFile.url); + const buffer = await res.body(); + const type = res.headers()["content-type"]; + const base64 = buffer.toString("base64"); + + return `data:${type};base64,${base64}`; + }, }; } diff --git a/library.ts b/library.ts index d56acda..b4d2aab 100644 --- a/library.ts +++ b/library.ts @@ -20,7 +20,14 @@ export function createLibrary(db: Database) { const platform = site(readerUrlOrBook); await db.run( - `insert into books(platform_id, reader_url) values((select id from platforms where name = ?), ?)`, + `\ +insert into books( + platform_id, + reader_url) +values((select id from platforms where name = ?), ?) +on conflict(reader_url) + do nothing +`, platform, readerUrlOrBook, ); @@ -48,10 +55,11 @@ on conflict(reader_url) async delete(id: number) { await db.run(`delete from books where id = ?`, id); }, - async get(id: number): Promise { + async get(readerUrlOrBookId: string | number): Promise { const row = await db.get( - `select books.id, platforms.name as platform, books.reader_url as readerUrl, books.title, books.authors from books left join platforms on books.platform_id = platforms.id where books.id = ?`, - id, + `select books.id, platforms.name as platform, books.reader_url as readerUrl, books.title, books.authors from books left join platforms on books.platform_id = platforms.id where books.reader_url = ? or books.id = ?`, + readerUrlOrBookId, + Number(readerUrlOrBookId), ); const book: Book | undefined = row && { @@ -87,9 +95,17 @@ on conflict(reader_url) throw new Error(`Not found: ${path}`); } - const title = `${book.authors - .slice(0, opts.outAuthorsLimit) - .join("、")}「${book.title}」`.replace(/[/]/g, "%2F"); + if (!book.title) { + book.title = String(book.id); + } + + if (book.authors.length > 0) { + book.title = `「${book.title}」`; + } + + const title = `${book.authors.slice(0, opts.outAuthorsLimit).join("、")}${ + book.title + }`.replace(/[/]/g, "%2F"); const out = createWriteStream(`${opts.outDir}/${title}.cbz`); diff --git a/main.ts b/main.ts index 9a4ce60..a053dd5 100644 --- a/main.ts +++ b/main.ts @@ -128,7 +128,7 @@ const options = { await library.add(args.values.download!); } - const book = await library.get(Number(args.values.download!)); + const book = await library.get(args.values.download!); if (!book) { process.exit(1); diff --git a/migrations/3_add_fanza_doujin.sql b/migrations/3_add_fanza_doujin.sql new file mode 100644 index 0000000..890162b --- /dev/null +++ b/migrations/3_add_fanza_doujin.sql @@ -0,0 +1,2 @@ +insert into platforms(name) values + ('fanza-doujin'); diff --git a/package.json b/package.json index 4a23e96..611fbb7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fogtype/gadl", - "version": "1.0.0", + "version": "1.1.0", "license": "AGPL-3.0", "type": "module", "bin": "bin/run.js", diff --git a/platform.ts b/platform.ts index d708c92..898335e 100644 --- a/platform.ts +++ b/platform.ts @@ -4,20 +4,20 @@ import type { Book } from "./library"; import type { Browser } from "./browser"; import type { Database } from "./database"; import { DmmBooks } from "./platforms/dmm-books"; +import { FanzaDoujin } from "./platforms/fanza-doujin"; import { GooglePlayBooks } from "./platforms/google-play-books"; const platforms = { "dmm-books": DmmBooks, + "fanza-doujin": FanzaDoujin, "google-play-books": GooglePlayBooks, }; export type TPlatform = keyof typeof platforms; export function site(url: string): TPlatform { - const { origin } = new URL(url); - - for (const [platform, { site }] of Object.entries(platforms)) { - if (site.includes(origin)) return platform as TPlatform; + for (const [platform, { siteUrl }] of Object.entries(platforms)) { + if (siteUrl(new URL(url))) return platform as TPlatform; } throw new Error(`Unsupported URL: ${url}`); @@ -29,7 +29,11 @@ export function createPlatform(opts: { browser: Browser; }) { if (!(opts.platform in platforms)) { - throw new Error(`Available platform: ${Object.keys(platforms).join(", ")}`); + throw new Error( + `The value must be a platform type: ${[...Object.keys(platforms)].join( + ", ", + )}.`, + ); } const platform = platforms[opts.platform](opts.browser); diff --git a/platforms/dmm-books.ts b/platforms/dmm-books.ts index 0a39b2b..07b261f 100644 --- a/platforms/dmm-books.ts +++ b/platforms/dmm-books.ts @@ -1,16 +1,9 @@ import type { Book } from "../library"; -import type { Browser, BrowserContext } from "../browser"; - -type ImageFile = { - url: string; - blocks: Array>; - width: number; - height: number; -}; +import type { Browser, BrowserContext, ImageFile } from "../browser"; var NFBR: any; -async function getImageFiles(): Promise> { +export async function getImageFiles(): Promise> { const params = new URLSearchParams(location.search); const model = new NFBR.a6G.Model({ settings: new NFBR.Settings("NFBR.SettingData"), @@ -79,42 +72,6 @@ async function getImageFiles(): Promise> { return imageFiles; } -async function drawImage(imageFile: ImageFile) { - const canvas = Object.assign(document.createElement("canvas"), { - width: imageFile.width, - height: imageFile.height, - }); - - const image = (await new Promise((resolve) => { - Object.assign(new Image(), { - crossOrigin: "use-credentials", - src: imageFile.url, - onload() { - resolve(this); - }, - }); - })) as HTMLImageElement; - - const ctx = canvas.getContext("2d")!; - - for (const q of imageFile.blocks) { - ctx.drawImage( - image, - q.destX, - q.destY, - q.width, - q.height, - q.srcX, - q.srcY, - q.width, - q.height, - ); - } - - const dataUrl = canvas.toDataURL(); - return dataUrl; -} - export function DmmBooks(browser: Browser) { async function* getSeriesBooks( ctx: BrowserContext, @@ -246,7 +203,7 @@ export function DmmBooks(browser: Browser) { const imageFiles = await page.evaluate(getImageFiles); return imageFiles.map((imageFile) => async () => { - const dataUrl = await page.evaluate(drawImage, imageFile); + const dataUrl = await browser.drawImage(page, imageFile); process.stderr.write("."); @@ -256,4 +213,5 @@ export function DmmBooks(browser: Browser) { }; } -DmmBooks.site = ["https://book.dmm.com", "https://book.dmm.co.jp"]; +DmmBooks.siteUrl = (url: URL) => + ["https://book.dmm.com", "https://book.dmm.co.jp"].includes(url.origin); diff --git a/platforms/fanza-doujin.ts b/platforms/fanza-doujin.ts new file mode 100644 index 0000000..cc37f48 --- /dev/null +++ b/platforms/fanza-doujin.ts @@ -0,0 +1,97 @@ +import type { Book } from "../library"; +import type { Browser, BrowserContext, ImageFile } from "../browser"; +import { getImageFiles } from "./dmm-books"; + +export function FanzaDoujin(browser: Browser) { + async function* getAllBooks(ctx: BrowserContext): AsyncGenerator { + const endpoint = + "https://www.dmm.co.jp/dc/doujin/api/mylibraries/?limit=20"; + const pager = { + page: 1, + perPage: 20, + totalCount: Infinity, + }; + + while (pager.page * pager.perPage <= pager.totalCount) { + const res = await ctx.request.get(`${endpoint}&page=${pager.page}`); + + if (!res.ok()) { + throw new Error(`${res.status()} ${res.statusText()}`); + } + + const body: { + data: { + items: Record< + string, + Array<{ + productId: string; + title: string; + makerName: string; + }> + >; + total: number; + }; + } = await res.json(); + + for (const item of Object.values(body.data.items).flat()) { + yield { + id: NaN, + platform: "fanza-doujin", + readerUrl: `https://www.dmm.co.jp/dc/-/mylibrary/detail/=/product_id=${item.productId}/`, + title: item.title || "", + authors: [item.makerName], + }; + + process.stderr.write("."); + } + + pager.page += 1; + pager.totalCount = body.data.total; + } + } + + return { + async login() { + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + await page.goto("https://accounts.dmm.co.jp/service/login/password"); + await page.waitForURL("https://www.dmm.co.jp/top/", { timeout: 0 }); + await browser.saveBrowserContext("fanza-doujin", ctx); + }, + + async *pull(): AsyncGenerator { + const ctx = await browser.loadBrowserContext("fanza-doujin"); + + yield* getAllBooks(ctx); + + process.stderr.write(`\n`); + }, + + async getFiles(book: Book): Promise Promise>> { + const ctx = await browser.loadBrowserContext("fanza-doujin"); + const page = await ctx.newPage(); + + await page.goto(book.readerUrl); + await page.waitForSelector(`li[class^="fileTreeItem"]`); + await page.click(`li[class^="fileTreeItem"]>a`); + await page.waitForURL((url) => + url.href.startsWith("https://www.dmm.co.jp/dc/-/viewer/=/product_id="), + ); + + const imageFiles: Array = await page.evaluate(getImageFiles); + + return imageFiles.map((imageFile) => async () => { + const dataUrl = await browser.drawImage(page, imageFile); + + process.stderr.write("."); + + return dataUrl; + }); + }, + }; +} + +FanzaDoujin.siteUrl = (url: URL) => + url.href.startsWith( + "https://www.dmm.co.jp/dc/-/mylibrary/detail/=/product_id=", + ); diff --git a/platforms/google-play-books.ts b/platforms/google-play-books.ts index 873d498..323312f 100644 --- a/platforms/google-play-books.ts +++ b/platforms/google-play-books.ts @@ -1,9 +1,5 @@ import type { Book } from "../library"; -import type { Browser } from "../browser"; - -type ImageFile = { - url: string; -}; +import type { Browser, ImageFile } from "../browser"; async function getImageFiles(): Promise> { const pages: NodeListOf = await new Promise(async function ( @@ -41,26 +37,6 @@ async function getImageFiles(): Promise> { return [...images].map((image) => ({ url: image.href.baseVal })); } -async function fetchImage(imageFile: ImageFile): Promise { - const res = await fetch(imageFile.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() { @@ -150,7 +126,7 @@ export function GooglePlayBooks(browser: Browser) { for (const imageFile of imageFiles) { if (fileMap.has(imageFile.url)) continue; - const dataUrl = await frame.evaluate(fetchImage, imageFile); + const dataUrl = await browser.drawImage(frame, imageFile); process.stderr.write("."); @@ -163,4 +139,5 @@ export function GooglePlayBooks(browser: Browser) { }; } -GooglePlayBooks.site = ["https://play.google.com"]; +GooglePlayBooks.siteUrl = (url: URL) => + url.origin === "https://play.google.com";