Compare commits

...
Sign in to create a new pull request.

8 commits
go ... main

8 changed files with 896 additions and 503 deletions

View file

@ -8,10 +8,10 @@ $ npx https://git.fogtype.com/nebel/gadl/archive/main.tar.gz --help
## Supported Sites ## Supported Sites
- DLsite 同人
- DMM ブックス (漫画)
- FANZA 同人
- Google Play ブックス (漫画) - Google Play ブックス (漫画)
- DMM ブックス (漫画)
- DLsite 同人/がるまに/成年コミック
- FANZA 同人
## License ## License

View file

@ -1,8 +1,10 @@
import * as Playwright from "playwright"; import type * as Playwright from "playwright";
import { chromium, devices } from "playwright"; 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 PageOrFrame = Playwright.Page | Playwright.Frame;
export type ImageFile = { export type ImageFile = {
url: string; url: string;
blocks?: Array<Record<string, number>>; blocks?: Array<Record<string, number>>;
@ -10,6 +12,16 @@ export type ImageFile = {
height?: number; height?: number;
}; };
export type Browser = {
loadBrowserContext(platform: TPlatform): Promise<Playwright.BrowserContext>;
saveBrowserContext(platform: TPlatform, ctx: BrowserContext): Promise<void>;
newContext(): Promise<Playwright.BrowserContext>;
close(): Promise<void>;
drawImage(pageOrFrame: PageOrFrame, imageFile: ImageFile): Promise<Blob>;
};
export type BrowserContext = Playwright.BrowserContext;
async function drawImage(imageFile: ImageFile): Promise<string> { async function drawImage(imageFile: ImageFile): Promise<string> {
const canvas = Object.assign(document.createElement("canvas"), { const canvas = Object.assign(document.createElement("canvas"), {
width: imageFile.width, width: imageFile.width,
@ -71,25 +83,12 @@ async function dataUrlToBlob(dataUrl: string): Promise<Blob> {
return await res.blob(); return await res.blob();
} }
export type Browser = {
loadBrowserContext(platform: TPlatform): Promise<Playwright.BrowserContext>;
saveBrowserContext(platform: TPlatform, ctx: BrowserContext): Promise<void>;
newContext(): Promise<Playwright.BrowserContext>;
close(): Promise<void>;
drawImage(
pageOrFrame: Playwright.Page | Playwright.Frame,
imageFile: ImageFile,
): Promise<Blob>;
};
export type BrowserContext = Playwright.BrowserContext;
export async function createBrowser({ export async function createBrowser({
db, db,
headless = true, headless,
}: { }: {
db: Database; db: Database;
headless?: boolean; headless: boolean;
}): Promise<Browser> { }): Promise<Browser> {
const { userAgent } = devices["Desktop Chrome"]; const { userAgent } = devices["Desktop Chrome"];
const browser = await chromium.launch({ const browser = await chromium.launch({
@ -127,7 +126,7 @@ export async function createBrowser({
close: () => browser.close(), close: () => browser.close(),
async drawImage( async drawImage(
pageOrFrame: Playwright.Page | Playwright.Frame, pageOrFrame: PageOrFrame,
imageFile: ImageFile, imageFile: ImageFile,
): Promise<Blob> { ): Promise<Blob> {
if (Array.isArray(imageFile.blocks) && imageFile.blocks.length > 0) { if (Array.isArray(imageFile.blocks) && imageFile.blocks.length > 0) {

View file

@ -100,12 +100,14 @@ on conflict(reader_url)
} }
if (book.authors.length > 0) { if (book.authors.length > 0) {
book.title = `${book.title}`; book.title = `[${book.authors
.slice(0, opts.outAuthorsLimit)
.join(", ")}] ${book.title}`.replace(/[/]/g, "%2F");
} }
const title = `${book.authors.slice(0, opts.outAuthorsLimit).join("、")}${ await fs.mkdir(opts.outDir, {
book.title recursive: true,
}`.replace(/[/]/g, "%2F"); });
const files = await fs.readdir(path); const files = await fs.readdir(path);
@ -117,18 +119,19 @@ on conflict(reader_url)
} }
for (const [n, f] of Object.entries(files)) { for (const [n, f] of Object.entries(files)) {
await fs.rename( await fs.copyFile(
`${path}/${f}`, `${path}/${f}`,
`${opts.outDir}/${title}${ `${opts.outDir}/${book.title}${
files.length > 1 ? ` - ${pad(n)}` : "" files.length > 1 ? ` - ${pad(n)}` : ""
}.${f.split(".").at(-1)}`, }.${f.split(".").at(-1)}`,
); );
} }
await fs.rmdir(path);
await fs.rm(path, { recursive: true });
return; return;
} }
const out = createWriteStream(`${opts.outDir}/${title}.cbz`); const out = createWriteStream(`${opts.outDir}/${book.title}.cbz`);
const zip = new Zip(function cb(err, data, final) { const zip = new Zip(function cb(err, data, final) {
if (err) { if (err) {

27
main.ts
View file

@ -4,9 +4,9 @@ import path from "node:path";
import util from "node:util"; import util from "node:util";
import { createBrowser } from "./browser"; import { createBrowser } from "./browser";
import { createDatabase } from "./database"; import { createDatabase } from "./database";
import { type Book, createLibrary } from "./library"; import { createLibrary, type Book } from "./library";
import { type TPlatform, createPlatform, platforms } from "./platform";
import * as pkg from "./package.json"; import * as pkg from "./package.json";
import { createPlatform, platforms, type TPlatform } from "./platform";
const options = { const options = {
db: { db: {
@ -30,6 +30,10 @@ const options = {
return `<output_authors_limit> (default: ${this.default})`; return `<output_authors_limit> (default: ${this.default})`;
}, },
}, },
"no-headless": {
type: "boolean",
default: false,
},
login: { login: {
type: "string", type: "string",
toString() { toString() {
@ -53,8 +57,9 @@ const options = {
return [...Object.keys(platforms)].join("|"); return [...Object.keys(platforms)].join("|");
}, },
async run() { async run() {
const db = await createDatabase(args.values.db!); const db = await createDatabase(args.values.db!),
const browser = await createBrowser({ db }); headless = !args.values["no-headless"];
const browser = await createBrowser({ db, headless });
const platform = createPlatform({ const platform = createPlatform({
platform: args.values.logout as TPlatform, platform: args.values.logout as TPlatform,
db, db,
@ -128,9 +133,10 @@ const options = {
return [...Object.keys(platforms)].join("|"); return [...Object.keys(platforms)].join("|");
}, },
async run() { async run() {
const db = await createDatabase(args.values.db!); const db = await createDatabase(args.values.db!),
const library = createLibrary(db); library = createLibrary(db),
const browser = await createBrowser({ db }); headless = !args.values["no-headless"];
const browser = await createBrowser({ db, headless });
const platform = createPlatform({ const platform = createPlatform({
platform: args.values.pull as TPlatform, platform: args.values.pull as TPlatform,
db, db,
@ -150,8 +156,9 @@ const options = {
return `all|<reader_url_or_id>`; return `all|<reader_url_or_id>`;
}, },
async run() { async run() {
const db = await createDatabase(args.values.db!); const db = await createDatabase(args.values.db!),
const library = createLibrary(db); library = createLibrary(db),
headless = !args.values["no-headless"];
const books: Array<Book> = []; const books: Array<Book> = [];
if (args.values.download === "all") { if (args.values.download === "all") {
@ -171,7 +178,7 @@ const options = {
} }
for (const book of books) { for (const book of books) {
const browser = await createBrowser({ db }); const browser = await createBrowser({ db, headless });
const platform = createPlatform({ const platform = createPlatform({
platform: book.platform, platform: book.platform,
db, db,

1183
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,17 +1,20 @@
{ {
"name": "@fogtype/gadl", "name": "@fogtype/gadl",
"version": "1.4.0", "version": "1.7.0",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"type": "module", "type": "module",
"bin": "bin/run.js", "bin": "bin/run.js",
"dependencies": { "dependencies": {
"fflate": "^0.8.1", "fflate": "^0.8.2",
"playwright": "1.40.1", "playwright": "1.48.1",
"sqlite": "^5.1.1", "sqlite": "^5.1.1",
"sqlite3": "^5.1.6", "sqlite3": "^5.1.7",
"tsx": "^4.6.1" "tsx": "^4.19.1"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.10.2" "@types/node": "^22.8.0"
},
"engines": {
"node": ">=22.8.0"
} }
} }

View file

@ -1,18 +1,17 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path";
import type { Book } from "./library"; 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 { DlsiteManiax } from "./platforms/dlsite-maniax";
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";
import { DmmBooks } from "./platforms/dmm-books";
import { DlsiteManiax } from "./platforms/dlsite-maniax";
import { FanzaDoujin } from "./platforms/fanza-doujin";
export const platforms = { export const platforms = {
"dlsite-maniax": DlsiteManiax,
"dmm-books": DmmBooks,
"fanza-doujin": FanzaDoujin,
"google-play-books": GooglePlayBooks, "google-play-books": GooglePlayBooks,
"dmm-books": DmmBooks,
"dlsite-maniax": DlsiteManiax,
"fanza-doujin": FanzaDoujin,
}; };
export type TPlatform = keyof typeof platforms; export type TPlatform = keyof typeof platforms;
@ -44,7 +43,7 @@ export function createPlatform(opts: {
...platform, ...platform,
async download(dir: string, book: Book): Promise<void> { async download(dir: string, book: Book): Promise<void> {
await fs.mkdir(dir); await fs.mkdir(dir, { recursive: true });
const files: Array<() => Promise<Blob>> = await platform.getFiles(book); const files: Array<() => Promise<Blob>> = await platform.getFiles(book);
const digits = String(files.length).length; const digits = String(files.length).length;

View file

@ -1,5 +1,39 @@
import type {
Browser,
BrowserContext,
ImageFile,
PageOrFrame,
} from "../browser";
import type { Book } from "../library"; import type { Book } from "../library";
import type { Browser, BrowserContext, ImageFile } from "../browser";
// リーダーのページ要素
const workTreeItemsSelector = `[class^=_worktree_] li[class^=_item_]`;
function Reader(page: PageOrFrame, readerUrl: string) {
const workId = /^https:[/][/]play[.]dlsite[.]com[/]#[/]work[/]([^/]+)/.exec(
readerUrl,
)?.[1];
if (!workId) {
throw new Error(`workId is not included: ${readerUrl}`);
}
return {
async load() {
await page.goto(readerUrl);
},
async downloadUrl(): Promise<null | string> {
const url = `https://www.dlsite.com/home/download/=/product_id/${workId}.html`;
if (!workId.startsWith("B")) return url;
const items = await page.waitForSelector(workTreeItemsSelector);
const text = await items.textContent();
return text?.match(/画像/) ? null : url;
},
};
}
export function DlsiteManiax(browser: Browser) { export function DlsiteManiax(browser: Browser) {
async function* getAllBooks(ctx: BrowserContext): AsyncGenerator<Book> { async function* getAllBooks(ctx: BrowserContext): AsyncGenerator<Book> {
@ -37,6 +71,7 @@ export function DlsiteManiax(browser: Browser) {
ja_JP: string; ja_JP: string;
}; };
}; };
author_name: string | null;
}>; }>;
} = await res.json(); } = await res.json();
@ -46,7 +81,7 @@ export function DlsiteManiax(browser: Browser) {
platform: "dlsite-maniax", platform: "dlsite-maniax",
readerUrl: `https://play.dlsite.com/#/work/${work.workno}`, readerUrl: `https://play.dlsite.com/#/work/${work.workno}`,
title: work.name.ja_JP || "", title: work.name.ja_JP || "",
authors: [work.maker.name.ja_JP || ""], authors: [work.author_name || work.maker.name.ja_JP || ""],
}; };
process.stderr.write("."); process.stderr.write(".");
@ -69,30 +104,60 @@ export function DlsiteManiax(browser: Browser) {
async getFiles(book: Book): Promise<Array<() => Promise<Blob>>> { async getFiles(book: Book): Promise<Array<() => Promise<Blob>>> {
const ctx = await browser.loadBrowserContext("dlsite-maniax"); const ctx = await browser.loadBrowserContext("dlsite-maniax");
const page = await ctx.newPage(); const page = await ctx.newPage();
const reader = Reader(page, book.readerUrl);
await page.goto(book.readerUrl); await reader.load();
const downloadUrl = await reader.downloadUrl();
const [, workId] = if (downloadUrl) {
/^https:[/][/]play[.]dlsite[.]com[/]#[/]work[/]([^/]+)/.exec( const imageFile: ImageFile = { url: downloadUrl };
book.readerUrl,
) ?? [];
if (!workId) { return [
throw new Error(`workId is not included: ${book.readerUrl}`); async () => {
const blob = await browser.drawImage(page, imageFile);
process.stderr.write(".");
return blob;
},
];
} }
const url = `https://www.dlsite.com/home/download/=/product_id/${workId}.html`; // ページ数 … 画面に表示されている要素を辿る
const imageFile = { url }; await page.waitForSelector(workTreeItemsSelector);
const workTreeItems = await page.locator(workTreeItemsSelector).count();
await page.click(workTreeItemsSelector);
return [ // 見開き表示の無効化 … 初回: 右下見開きボタンをクリックして無効化
async () => { const spreadButton = page.getByRole("button", { name: "見開き" });
const blob = await browser.drawImage(page, imageFile); await spreadButton.click();
await Promise.all([
spreadButton.waitFor({ state: "detached" }),
page.mouse.click(0, 720 / 2),
]);
await page.keyboard.press("ArrowRight");
process.stderr.write("."); // ページ数だけ画面送りを繰り返し行い、canvasをそのままキャプチャしていく
const files: Array<() => Promise<Blob>> = [];
while (files.length < workTreeItems) {
await page.waitForTimeout(1000);
const n = Math.min(2, Math.max(0, workTreeItems - 1 - files.length));
const canvas = page.locator("canvas").nth(n);
await canvas.waitFor({ state: "visible" });
const [width, height] = await Promise.all(
["width", "height"].map((d) => canvas.getAttribute(d).then(Number)),
);
await page.setViewportSize({ width, height });
await page.waitForTimeout(1000);
const buff = await canvas.screenshot();
files.push(async () => new Blob([buff], { type: "image/png" }));
return blob; process.stderr.write(".");
},
]; await page.keyboard.press("ArrowLeft");
}
return files;
}, },
loginEndpoints: ["https://www.dlsite.com/home/login"], loginEndpoints: ["https://www.dlsite.com/home/login"],
loginSuccessUrl: (url: URL) => url.origin === "https://www.dlsite.com", loginSuccessUrl: (url: URL) => url.origin === "https://www.dlsite.com",