diff --git a/eh-view-enhance.meta.js b/eh-view-enhance.meta.js index c9f3bae..e364e99 100644 --- a/eh-view-enhance.meta.js +++ b/eh-view-enhance.meta.js @@ -48,6 +48,7 @@ // @match https://*.copymanga.tv/* // @match https://e621.net/* // @match https://arca.live/* +// @match https://*.artstation.com/* // @require https://cdn.jsdelivr.net/npm/@zip.js/zip.js@2.7.44/dist/zip-full.min.js // @require https://cdn.jsdelivr.net/npm/file-saver@2.0.5/dist/FileSaver.min.js // @require https://cdn.jsdelivr.net/npm/pica@9.0.1/dist/pica.min.js @@ -82,6 +83,7 @@ // @connect mangafuna.xyz // @connect e621.net // @connect namu.la +// @connect artstation.com // @connect * // @grant GM_getValue // @grant GM_setValue diff --git a/eh-view-enhance.user.js b/eh-view-enhance.user.js index b187baf..1d15c1f 100644 --- a/eh-view-enhance.user.js +++ b/eh-view-enhance.user.js @@ -48,6 +48,7 @@ // @match https://*.copymanga.tv/* // @match https://e621.net/* // @match https://arca.live/* +// @match https://*.artstation.com/* // @require https://cdn.jsdelivr.net/npm/@zip.js/zip.js@2.7.44/dist/zip-full.min.js // @require https://cdn.jsdelivr.net/npm/file-saver@2.0.5/dist/FileSaver.min.js // @require https://cdn.jsdelivr.net/npm/pica@9.0.1/dist/pica.min.js @@ -82,6 +83,7 @@ // @connect mangafuna.xyz // @connect e621.net // @connect namu.la +// @connect artstation.com // @connect * // @grant GM_getValue // @grant GM_setValue @@ -1257,6 +1259,31 @@ Report issues here: window.fetch(url).then((resp) => { + if (resp.ok) { + switch (respType) { + case "text": + return resp.text(); + case "json": + return resp.json(); + case "arraybuffer": + return resp.arrayBuffer(); + } + } + throw new Error(`failed to fetch ${url}: ${resp.status} ${resp.statusText}`); + }).then((raw) => results[index + i] = raw) + ); + await Promise.all(batchPromises); + i += concurrency; + } + return results; + } var FetchState = /* @__PURE__ */ ((FetchState2) => { FetchState2[FetchState2["FAILED"] = 0] = "FAILED"; @@ -1352,11 +1379,9 @@ Report issues here: `https://www.artstation.com/projects/${p.hash_id}.json`); + const assets = await batchFetch(projectURLs, 10, "json"); + let ret = []; + for (let asset of assets) { + this.info.projects++; + this.tags[asset.slug] = asset.tags; + for (let i = 0; i < asset.assets.length; i++) { + const a = asset.assets[i]; + if (a.asset_type === "cover") + continue; + const thumb = a.image_url.replace("/large/", "/small/"); + const ext = a.image_url.match(/\.(\w+)\?\d+$/)?.[1] ?? "jpg"; + const title = `${asset.slug}-${i + 1}.${ext}`; + let originSrc = a.image_url; + if (a.has_embedded_player && a.player_embedded) { + if (a.player_embedded.includes("youtube")) + continue; + originSrc = a.player_embedded; + } + this.info.assets++; + ret.push(new ImageNode(thumb, asset.permalink, title, void 0, originSrc)); + } + } + return ret; + } + async fetchOriginMeta(node) { + if (node.originSrc?.startsWith(" res.text()).then((text) => new DOMParser().parseFromString(text, "text/html")); + const source = doc.querySelector("video > source"); + if (!source) + throw new Error("cannot find video element"); + return { url: source.src }; + } + return { url: node.originSrc }; + } + async processData(data, contentType) { + if (contentType.startsWith("binary") || contentType.startsWith("text")) { + return [data, "video/mp4"]; + } + return [data, contentType]; + } + workURL() { + return /artstation.com\/[-\w]+(\/albums\/\d+)?$/; + } + async fetchArtistInfo() { + const user = window.location.pathname.slice(1).split("/").shift(); + if (!user) + throw new Error("cannot match artist's username"); + const info = await window.fetch(`https://www.artstation.com/users/${user}/quick.json`).then((res) => res.json()); + return info; + } + async fetchProjects(user, id, page) { + const url = `https://www.artstation.com/users/${user}/projects.json?user_id=${id}&page=${page}`; + const project = await window.fetch(url).then((res) => res.json()); + return project.data; + } + } + class DanbooruMatcher extends BaseMatcher { tags = {}; blacklistTags = []; @@ -5423,10 +5538,10 @@ before contentType: ${contentType}, after contentType: ${blob.type} return list; const pidList = JSON.parse(source); this.fetchTagsByPids(pidList); - const pageListData = await fetchUrls(pidList.map((p) => `https://www.pixiv.net/ajax/illust/${p}/pages?lang=en`), 5); + const pageListData = await batchFetch(pidList.map((p) => `https://www.pixiv.net/ajax/illust/${p}/pages?lang=en`), 5, "json"); for (let i = 0; i < pidList.length; i++) { const pid = pidList[i]; - const data = JSON.parse(pageListData[i]); + const data = pageListData[i]; if (data.error) { throw new Error(`Fetch page list error: ${data.message}`); } @@ -5479,24 +5594,6 @@ before contentType: ${contentType}, after contentType: ${blob.type} } } } - async function fetchUrls(urls, concurrency) { - const results = new Array(urls.length); - let i = 0; - while (i < urls.length) { - const batch = urls.slice(i, i + concurrency); - const batchPromises = batch.map( - (url, index) => window.fetch(url).then((resp) => { - if (resp.ok) { - return resp.text(); - } - throw new Error(`Failed to fetch ${url}: ${resp.status} ${resp.statusText}`); - }).then((raw) => results[index + i] = raw) - ); - await Promise.all(batchPromises); - i += concurrency; - } - return results; - } class RokuHentaiMatcher extends BaseMatcher { name() { @@ -5909,7 +6006,8 @@ before contentType: ${contentType}, after contentType: ${blob.type} new MHGMatcher(), new MangaCopyMatcher(), new E621Matcher(), - new ArcaMatcher() + new ArcaMatcher(), + new ArtStationMatcher() ]; } function adaptMatcher(url) { diff --git a/src/img-fetcher.ts b/src/img-fetcher.ts index f175cf9..4e17961 100644 --- a/src/img-fetcher.ts +++ b/src/img-fetcher.ts @@ -119,11 +119,11 @@ export class IMGFetcher implements VisualNode { [this.data, this.contentType] = ret; [this.data, this.contentType] = await this.matcher.processData(this.data, this.contentType, this.node.originSrc!); if (this.contentType.startsWith("text")) { - if (this.data.byteLength < 100000) { // less then 100kb - const str = new TextDecoder().decode(this.data); - evLog("error", "unexpect content:\n", str); - throw new Error(`expect image data, fetched wrong type: ${this.contentType}, the content is showing up in console(F12 open it).`); - } + // if (this.data.byteLength < 100000) { // less then 100kb + const str = new TextDecoder().decode(this.data); + evLog("error", "unexpect content:\n", str); + throw new Error(`expect image data, fetched wrong type: ${this.contentType}, the content is showing up in console(F12 open it).`); + // } } this.node.blobSrc = transient.imgSrcCSP ? this.node.originSrc : URL.createObjectURL(new Blob([this.data], { type: this.contentType })); this.node.mimeType = this.contentType; diff --git a/src/platform/adapt.ts b/src/platform/adapt.ts index 8dfd75f..d63c57c 100644 --- a/src/platform/adapt.ts +++ b/src/platform/adapt.ts @@ -1,6 +1,7 @@ import { conf } from "../config"; import { Comic18Matcher } from "./18comic"; import { ArcaMatcher } from "./arca"; +import { ArtStationMatcher } from "./artstation"; import { DanbooruDonmaiMatcher, E621Matcher, GelBooruMatcher, KonachanMatcher, Rule34Matcher, YandereMatcher } from "./danbooru"; import { EHMatcher } from "./ehentai"; import { HentaiNexusMatcher } from "./hentainexus"; @@ -41,6 +42,7 @@ export function getMatchers(): Matcher[] { new MangaCopyMatcher(), new E621Matcher(), new ArcaMatcher(), + new ArtStationMatcher(), ]; } diff --git a/src/platform/artstation.ts b/src/platform/artstation.ts new file mode 100644 index 0000000..fc5dbd0 --- /dev/null +++ b/src/platform/artstation.ts @@ -0,0 +1,133 @@ +import { GalleryMeta } from "../download/gallery-meta"; +import ImageNode from "../img-node"; +import { PagesSource } from "../page-fetcher"; +import { batchFetch } from "../utils/query"; +import { BaseMatcher, OriginMeta } from "./platform"; + +export class ArtStationMatcher extends BaseMatcher { + pageData: Map = new Map(); + info: { username: string, projects: number, assets: number } = { username: "", projects: 0, assets: 0 }; + tags: Record = {}; + name(): string { + return "Art Station"; + } + galleryMeta(): GalleryMeta { + const meta = new GalleryMeta(window.location.href, `artstaion-${this.info.username}-w${this.info.projects}-p${this.info.assets}`); + meta.tags = this.tags; + return meta; + } + async *fetchPagesSource(): AsyncGenerator { + // find artist id; + const { id, username } = await this.fetchArtistInfo(); + this.info.username = username; + let page = 0; + while (true) { + page++; + const projects = await this.fetchProjects(username, id.toString(), page); + if (!projects || projects.length === 0) break; + this.pageData.set(page.toString(), projects); + yield page.toString(); + } + } + async parseImgNodes(pageNo: PagesSource): Promise { + const projects = this.pageData.get(pageNo as string); + if (!projects) throw new Error("cannot get projects form page data"); + const projectURLs = projects.map(p => `https://www.artstation.com/projects/${p.hash_id}.json`) + const assets = await batchFetch(projectURLs, 10, "json"); + let ret: ImageNode[] = []; + for (let asset of assets) { + this.info.projects++; + this.tags[asset.slug] = asset.tags; + for (let i = 0; i < asset.assets.length; i++) { + const a = asset.assets[i]; + if (a.asset_type === "cover") continue; + const thumb = a.image_url.replace("/large/", "/small/"); + const ext = a.image_url.match(/\.(\w+)\?\d+$/)?.[1] ?? "jpg"; + const title = `${asset.slug}-${i + 1}.${ext}`; + let originSrc = a.image_url; + if (a.has_embedded_player && a.player_embedded) { + if (a.player_embedded.includes("youtube")) continue; // skip youtube embedded + originSrc = a.player_embedded; + } + this.info.assets++; + ret.push(new ImageNode(thumb, asset.permalink, title, undefined, originSrc)); + } + } + return ret; + } + async fetchOriginMeta(node: ImageNode): Promise { + if (node.originSrc?.startsWith(" res.text()).then(text => new DOMParser().parseFromString(text, "text/html")); + const source = doc.querySelector("video > source"); + if (!source) throw new Error("cannot find video element"); + return { url: source.src }; + } + return { url: node.originSrc! }; + } + async processData(data: Uint8Array, contentType: string): Promise<[Uint8Array, string]> { + if (contentType.startsWith("binary") || contentType.startsWith("text")) { + return [data, "video/mp4"]; + } + return [data, contentType]; + } + workURL(): RegExp { + return /artstation.com\/[-\w]+(\/albums\/\d+)?$/; + } + async fetchArtistInfo(): Promise { + const user = window.location.pathname.slice(1).split("/").shift(); + if (!user) throw new Error("cannot match artist's username"); + const info = await window.fetch(`https://www.artstation.com/users/${user}/quick.json`).then(res => res.json()) as ArtStationArtistInfo; + return info; + } + async fetchProjects(user: string, id: string, page: number): Promise { + const url = `https://www.artstation.com/users/${user}/projects.json?user_id=${id}&page=${page}`; + const project = await window.fetch(url).then(res => res.json()) as { data: ArtStationProject[], total_count: number }; + return project.data; + } + +} + +type ArtStationArtistInfo = { + id: number, + full_name: string, + username: string, + permalink: string, +} + +type ArtStationProject = { + id: number, + assets_count: number, + title: string, + description: string, + slug: string, // title + hash_id: string, + permalink: string, // href + cover: { + id: number, + small_square_url: string, + micro_square_image_url: string, + thumb_url: string + }, +} + +type ArtStationAsset = { + tags: string[], + assets: { + has_image: boolean, + has_embedded_player: boolean, + // "player_embedded": "", + player_embedded?: string, + image_url: string, + width: number, + height: number, + position: number, // 0 + asset_type: "image" | "cover" | "video_clip", + + }[], + id: 19031208, + cover_url: string, + permalink: string, + slug: string, +} diff --git a/src/platform/pixiv.ts b/src/platform/pixiv.ts index 26b93e3..dd9f783 100644 --- a/src/platform/pixiv.ts +++ b/src/platform/pixiv.ts @@ -6,6 +6,7 @@ import ImageNode from "../img-node"; import { conf } from "../config"; import { PagesSource } from "../page-fetcher"; import * as zip_js from "@zip.js/zip.js"; +import { batchFetch } from "../utils/query"; type Page = { urls: { @@ -161,11 +162,11 @@ before contentType: ${contentType}, after contentType: ${blob.type} const pidList = JSON.parse(source as string) as string[]; // async function but no await, it will fetch tags in background this.fetchTagsByPids(pidList); - - const pageListData = await fetchUrls(pidList.map(p => `https://www.pixiv.net/ajax/illust/${p}/pages?lang=en`), 5); + type PageData = { error: boolean, message: string, body: Page[] }; + const pageListData = await batchFetch(pidList.map(p => `https://www.pixiv.net/ajax/illust/${p}/pages?lang=en`), 5, "json"); for (let i = 0; i < pidList.length; i++) { const pid = pidList[i]; - const data = JSON.parse(pageListData[i]) as { error: boolean, message: string, body: Page[] }; + const data = pageListData[i]; if (data.error) { throw new Error(`Fetch page list error: ${data.message}`); } @@ -223,27 +224,7 @@ before contentType: ${contentType}, after contentType: ${blob.type} const pids = pidList.splice(0, 20); yield JSON.stringify(pids); } - } } -async function fetchUrls(urls: string[], concurrency: number): Promise { - const results = new Array(urls.length); - let i = 0; - while (i < urls.length) { - const batch = urls.slice(i, i + concurrency); - const batchPromises = batch.map((url, index) => - window.fetch(url).then((resp) => { - if (resp.ok) { - return resp.text(); - } - throw new Error(`Failed to fetch ${url}: ${resp.status} ${resp.statusText}`); - }).then(raw => results[index + i] = raw) - ); - - await Promise.all(batchPromises); - i += concurrency; - } - return results; -} diff --git a/src/utils/query.ts b/src/utils/query.ts index f91f296..3e977b3 100644 --- a/src/utils/query.ts +++ b/src/utils/query.ts @@ -49,3 +49,28 @@ export function fetchImage(url: string): Promise { }, {}, 10 * 1000); }); } +export async function batchFetch(urls: string[], concurrency: number, respType: "text" | "json" | "arraybuffer" = "text"): Promise { + const results = new Array(urls.length); + let i = 0; + while (i < urls.length) { + const batch = urls.slice(i, i + concurrency); + const batchPromises = batch.map((url, index) => + window.fetch(url).then((resp) => { + if (resp.ok) { + switch (respType) { + case "text": + return resp.text(); + case "json": + return resp.json(); + case "arraybuffer": + return resp.arrayBuffer(); + } + } + throw new Error(`failed to fetch ${url}: ${resp.status} ${resp.statusText}`); + }).then(raw => results[index + i] = raw) + ); + await Promise.all(batchPromises); + i += concurrency; + } + return results; +} diff --git a/vite.config.ts b/vite.config.ts index 36e36b4..ba8ad82 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -66,6 +66,7 @@ export default defineConfig(({ command }) => { 'https://*.copymanga.tv/*', 'https://e621.net/*', 'https://arca.live/*', + 'https://*.artstation.com/*', // '*://*/*', ], name: { @@ -118,6 +119,7 @@ export default defineConfig(({ command }) => { 'mangafuna.xyz', 'e621.net', 'namu.la', + 'artstation.com', '*', ], grant: [