Compare commits
8 commits
Author | SHA1 | Date | |
---|---|---|---|
2145b26f7c | |||
4710cbccbd | |||
0d280b26fd | |||
3dfe483973 | |||
27c4784793 | |||
9479741b75 | |||
bf83e1cf0b | |||
2130778667 |
8 changed files with 896 additions and 503 deletions
|
@ -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
|
||||||
|
|
||||||
|
|
33
browser.ts
33
browser.ts
|
@ -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) {
|
||||||
|
|
19
library.ts
19
library.ts
|
@ -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
27
main.ts
|
@ -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,
|
||||||
|
|
1181
package-lock.json
generated
1181
package-lock.json
generated
File diff suppressed because it is too large
Load diff
15
package.json
15
package.json
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
15
platform.ts
15
platform.ts
|
@ -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;
|
||||||
|
|
|
@ -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,20 +104,13 @@ 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) {
|
|
||||||
throw new Error(`workId is not included: ${book.readerUrl}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = `https://www.dlsite.com/home/download/=/product_id/${workId}.html`;
|
|
||||||
const imageFile = { url };
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
async () => {
|
async () => {
|
||||||
|
@ -93,6 +121,43 @@ export function DlsiteManiax(browser: Browser) {
|
||||||
return blob;
|
return blob;
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ページ数 … 画面に表示されている要素を辿る
|
||||||
|
await page.waitForSelector(workTreeItemsSelector);
|
||||||
|
const workTreeItems = await page.locator(workTreeItemsSelector).count();
|
||||||
|
await page.click(workTreeItemsSelector);
|
||||||
|
|
||||||
|
// 見開き表示の無効化 … 初回: 右下見開きボタンをクリックして無効化
|
||||||
|
const spreadButton = page.getByRole("button", { name: "見開き" });
|
||||||
|
await spreadButton.click();
|
||||||
|
await Promise.all([
|
||||||
|
spreadButton.waitFor({ state: "detached" }),
|
||||||
|
page.mouse.click(0, 720 / 2),
|
||||||
|
]);
|
||||||
|
await page.keyboard.press("ArrowRight");
|
||||||
|
|
||||||
|
// ページ数だけ画面送りを繰り返し行い、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" }));
|
||||||
|
|
||||||
|
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",
|
||||||
|
|
Loading…
Add table
Reference in a new issue