fanza-doujin

This commit is contained in:
Nebel 2023-12-03 05:54:45 +09:00
parent 89e892f870
commit 5135d5121f
Signed by: nebel
GPG key ID: 79807D08C6EF6460
10 changed files with 235 additions and 90 deletions

View file

@ -10,6 +10,7 @@ $ npx https://git.fogtype.com/nebel/gadl/archive/main.tar.gz --help
## Supported Sites ## Supported Sites
- DMM ブックス (漫画) - DMM ブックス (漫画)
- FANZA 同人
- Google Play ブックス (漫画) - Google Play ブックス (漫画)
## License ## License

View file

@ -3,11 +3,78 @@ import { chromium, devices } from "playwright";
import type { Database } from "./database"; import type { Database } from "./database";
import type { TPlatform } from "./platform"; import type { TPlatform } from "./platform";
export type ImageFile = {
url: string;
blocks?: Array<Record<string, number>>;
width?: number;
height?: number;
};
async function drawImage(imageFile: ImageFile): Promise<string> {
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<string> {
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 = { export type Browser = {
loadBrowserContext(platform: TPlatform): Promise<Playwright.BrowserContext>; loadBrowserContext(platform: TPlatform): Promise<Playwright.BrowserContext>;
saveBrowserContext(platform: TPlatform, ctx: BrowserContext): Promise<void>; saveBrowserContext(platform: TPlatform, ctx: BrowserContext): Promise<void>;
newContext: () => Promise<Playwright.BrowserContext>; newContext(): Promise<Playwright.BrowserContext>;
close: () => Promise<void>; close(): Promise<void>;
drawImage(
pageOrFrame: Playwright.Page | Playwright.Frame,
imageFile: ImageFile,
): Promise<string>;
}; };
export type BrowserContext = Playwright.BrowserContext; export type BrowserContext = Playwright.BrowserContext;
@ -38,6 +105,7 @@ export async function createBrowser({
const ctx = await browser.newContext({ storageState, userAgent }); const ctx = await browser.newContext({ storageState, userAgent });
return ctx; return ctx;
}, },
async saveBrowserContext( async saveBrowserContext(
platform: TPlatform, platform: TPlatform,
ctx: BrowserContext, ctx: BrowserContext,
@ -49,7 +117,29 @@ export async function createBrowser({
platform, platform,
); );
}, },
newContext: () => browser.newContext(), newContext: () => browser.newContext(),
close: () => browser.close(), close: () => browser.close(),
async drawImage(
pageOrFrame: Playwright.Page | Playwright.Frame,
imageFile: ImageFile,
): Promise<string> {
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}`;
},
}; };
} }

View file

@ -20,7 +20,14 @@ export function createLibrary(db: Database) {
const platform = site(readerUrlOrBook); const platform = site(readerUrlOrBook);
await db.run( 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, platform,
readerUrlOrBook, readerUrlOrBook,
); );
@ -48,10 +55,11 @@ on conflict(reader_url)
async delete(id: number) { async delete(id: number) {
await db.run(`delete from books where id = ?`, id); await db.run(`delete from books where id = ?`, id);
}, },
async get(id: number): Promise<Book | undefined> { async get(readerUrlOrBookId: string | number): Promise<Book | undefined> {
const row = await db.get( 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 = ?`, `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 = ?`,
id, readerUrlOrBookId,
Number(readerUrlOrBookId),
); );
const book: Book | undefined = row && { const book: Book | undefined = row && {
@ -87,9 +95,17 @@ on conflict(reader_url)
throw new Error(`Not found: ${path}`); throw new Error(`Not found: ${path}`);
} }
const title = `${book.authors if (!book.title) {
.slice(0, opts.outAuthorsLimit) book.title = String(book.id);
.join("、")}${book.title}`.replace(/[/]/g, "%2F"); }
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`); const out = createWriteStream(`${opts.outDir}/${title}.cbz`);

View file

@ -128,7 +128,7 @@ const options = {
await library.add(args.values.download!); 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) { if (!book) {
process.exit(1); process.exit(1);

View file

@ -0,0 +1,2 @@
insert into platforms(name) values
('fanza-doujin');

View file

@ -1,6 +1,6 @@
{ {
"name": "@fogtype/gadl", "name": "@fogtype/gadl",
"version": "1.0.0", "version": "1.1.0",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"type": "module", "type": "module",
"bin": "bin/run.js", "bin": "bin/run.js",

View file

@ -4,20 +4,20 @@ import type { Book } from "./library";
import type { Browser } from "./browser"; import type { Browser } from "./browser";
import type { Database } from "./database"; import type { Database } from "./database";
import { DmmBooks } from "./platforms/dmm-books"; import { DmmBooks } from "./platforms/dmm-books";
import { FanzaDoujin } from "./platforms/fanza-doujin";
import { GooglePlayBooks } from "./platforms/google-play-books"; import { GooglePlayBooks } from "./platforms/google-play-books";
const platforms = { const platforms = {
"dmm-books": DmmBooks, "dmm-books": DmmBooks,
"fanza-doujin": FanzaDoujin,
"google-play-books": GooglePlayBooks, "google-play-books": GooglePlayBooks,
}; };
export type TPlatform = keyof typeof platforms; export type TPlatform = keyof typeof platforms;
export function site(url: string): TPlatform { export function site(url: string): TPlatform {
const { origin } = new URL(url); for (const [platform, { siteUrl }] of Object.entries(platforms)) {
if (siteUrl(new URL(url))) return platform as TPlatform;
for (const [platform, { site }] of Object.entries(platforms)) {
if (site.includes(origin)) return platform as TPlatform;
} }
throw new Error(`Unsupported URL: ${url}`); throw new Error(`Unsupported URL: ${url}`);
@ -29,7 +29,11 @@ export function createPlatform(opts: {
browser: Browser; browser: Browser;
}) { }) {
if (!(opts.platform in platforms)) { 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); const platform = platforms[opts.platform](opts.browser);

View file

@ -1,16 +1,9 @@
import type { Book } from "../library"; import type { Book } from "../library";
import type { Browser, BrowserContext } from "../browser"; import type { Browser, BrowserContext, ImageFile } from "../browser";
type ImageFile = {
url: string;
blocks: Array<Record<string, number>>;
width: number;
height: number;
};
var NFBR: any; var NFBR: any;
async function getImageFiles(): Promise<Array<ImageFile>> { export async function getImageFiles(): Promise<Array<ImageFile>> {
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
const model = new NFBR.a6G.Model({ const model = new NFBR.a6G.Model({
settings: new NFBR.Settings("NFBR.SettingData"), settings: new NFBR.Settings("NFBR.SettingData"),
@ -79,42 +72,6 @@ async function getImageFiles(): Promise<Array<ImageFile>> {
return imageFiles; 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) { export function DmmBooks(browser: Browser) {
async function* getSeriesBooks( async function* getSeriesBooks(
ctx: BrowserContext, ctx: BrowserContext,
@ -246,7 +203,7 @@ export function DmmBooks(browser: Browser) {
const imageFiles = await page.evaluate(getImageFiles); const imageFiles = await page.evaluate(getImageFiles);
return imageFiles.map((imageFile) => async () => { return imageFiles.map((imageFile) => async () => {
const dataUrl = await page.evaluate(drawImage, imageFile); const dataUrl = await browser.drawImage(page, imageFile);
process.stderr.write("."); 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);

97
platforms/fanza-doujin.ts Normal file
View file

@ -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<Book> {
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<Book> {
const ctx = await browser.loadBrowserContext("fanza-doujin");
yield* getAllBooks(ctx);
process.stderr.write(`\n`);
},
async getFiles(book: Book): Promise<Array<() => Promise<string>>> {
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<ImageFile> = 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=",
);

View file

@ -1,9 +1,5 @@
import type { Book } from "../library"; import type { Book } from "../library";
import type { Browser } from "../browser"; import type { Browser, ImageFile } from "../browser";
type ImageFile = {
url: string;
};
async function getImageFiles(): Promise<Array<ImageFile>> { async function getImageFiles(): Promise<Array<ImageFile>> {
const pages: NodeListOf<HTMLElement> = await new Promise(async function ( const pages: NodeListOf<HTMLElement> = await new Promise(async function (
@ -41,26 +37,6 @@ async function getImageFiles(): Promise<Array<ImageFile>> {
return [...images].map((image) => ({ url: image.href.baseVal })); return [...images].map((image) => ({ url: image.href.baseVal }));
} }
async function fetchImage(imageFile: ImageFile): Promise<string> {
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) { export function GooglePlayBooks(browser: Browser) {
return { return {
async login() { async login() {
@ -150,7 +126,7 @@ export function GooglePlayBooks(browser: Browser) {
for (const imageFile of imageFiles) { for (const imageFile of imageFiles) {
if (fileMap.has(imageFile.url)) continue; if (fileMap.has(imageFile.url)) continue;
const dataUrl = await frame.evaluate(fetchImage, imageFile); const dataUrl = await browser.drawImage(frame, imageFile);
process.stderr.write("."); 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";