mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-21 14:12:25 -05:00
feat: handle download limit error gracefully
This commit is contained in:
1 parent
9e61819453
commit
fd7c26a760
10 files changed
+171
-43
No files matched your search
@@ -12,6 +12,8 @@ import {
|
||||
parseMakerworldModelUrl,
|
||||
selectMakerworldProfiles,
|
||||
type MakerworldProfileScope,
|
||||
MAKERWORLD_DOWNLOAD_LIMIT_CODE,
|
||||
MAKERWORLD_DOWNLOAD_LIMIT_MESSAGE,
|
||||
} from "./makerworldCloudApi";
|
||||
import { fetchMakerworldCollectionTitle, parseMakerworldCollectionUrl } from "./makerworldCollections";
|
||||
import {
|
||||
@@ -40,18 +42,24 @@ type PrintablesCollectionImportJobBody = ImportRequestBody & { model_ids: string
|
||||
type MakerworldProfilesImportJobBody = ImportRequestBody & { scope: Exclude<MakerworldProfileScope, "url"> };
|
||||
|
||||
// Why a design failed, so a batch reads as one clear cause instead of "N failed". Once a CAPTCHA
|
||||
// ("rateLimited") or auth failure hits, every remaining item fails the same way.
|
||||
type ImportFailureReason = "unavailable" | "rateLimited" | "auth" | "other";
|
||||
// ("rateLimited"), the daily download limit or an auth failure hits, every remaining item fails
|
||||
// the same way.
|
||||
type ImportFailureReason = "unavailable" | "rateLimited" | "downloadLimit" | "auth" | "other";
|
||||
type StopReason = "rateLimited" | "downloadLimit" | "auth";
|
||||
|
||||
function classifyImportFailure(err: unknown): ImportFailureReason {
|
||||
if (err instanceof HttpError) {
|
||||
if (err.status === 403 || err.status === 404) return "unavailable";
|
||||
if (err.status === 429) return "rateLimited";
|
||||
if (err.status === 429) return err.code === MAKERWORLD_DOWNLOAD_LIMIT_CODE ? "downloadLimit" : "rateLimited";
|
||||
if (err.status === 401) return "auth";
|
||||
}
|
||||
return "other";
|
||||
}
|
||||
|
||||
function asStopReason(reason: ImportFailureReason): StopReason | null {
|
||||
return reason === "rateLimited" || reason === "downloadLimit" || reason === "auth" ? reason : null;
|
||||
}
|
||||
|
||||
async function markJobFailed(jobId: string, err: unknown): Promise<void> {
|
||||
const message = err instanceof Error ? err.message : "Import failed";
|
||||
console.error(`Import job ${jobId} failed:`, err);
|
||||
@@ -64,10 +72,10 @@ type ProfileImportTotals = {
|
||||
alreadyInLibrary: number;
|
||||
failed: number;
|
||||
printId: string | null;
|
||||
stopReason: "rateLimited" | "auth" | null;
|
||||
stopReason: StopReason | null;
|
||||
};
|
||||
|
||||
/** Imports each profile URL onto one print, in order. Once a CAPTCHA ("rateLimited") or auth
|
||||
/** Imports each profile URL onto one print, in order. Once a CAPTCHA, download-limit or auth
|
||||
* failure hits, every remaining profile fails the same way without another MakerWorld call. */
|
||||
async function importProfileUrlsSequentially(
|
||||
userId: string,
|
||||
@@ -98,8 +106,7 @@ async function importProfileUrlsSequentially(
|
||||
else totals.imported++;
|
||||
} catch (err) {
|
||||
totals.failed++;
|
||||
const reason = classifyImportFailure(err);
|
||||
if (reason === "rateLimited" || reason === "auth") totals.stopReason = reason;
|
||||
totals.stopReason = asStopReason(classifyImportFailure(err));
|
||||
}
|
||||
}
|
||||
totals.processed++;
|
||||
@@ -165,6 +172,10 @@ export async function runMakerworldProfilesImportJob(
|
||||
bodyParts.push(
|
||||
`the rest blocked by a MakerWorld CAPTCHA challenge — this usually clears in 1-4 hours, then import the model again to add the missing profiles`,
|
||||
);
|
||||
} else if (stopReason === "downloadLimit") {
|
||||
bodyParts.push(
|
||||
`the rest not downloaded — MakerWorld's daily download limit was reached, import the model again tomorrow to add the missing profiles`,
|
||||
);
|
||||
} else if (stopReason === "auth") {
|
||||
bodyParts.push(
|
||||
`the rest failed because your MakerWorld session expired — update the cookie in Settings and import again`,
|
||||
@@ -195,6 +206,7 @@ export async function runCollectionImportJob(
|
||||
let processed = 0;
|
||||
let unavailable = 0;
|
||||
let rateLimited = 0;
|
||||
let downloadLimited = 0;
|
||||
let authFailed = 0;
|
||||
const failed: string[] = [];
|
||||
const successPrintIds: string[] = [];
|
||||
@@ -210,6 +222,10 @@ export async function runCollectionImportJob(
|
||||
makerworldPaceMs: IMPORT_MAKERWORLD_CALL_DELAY_MS,
|
||||
};
|
||||
try {
|
||||
// Every later download would hit the same limit, so don't spend more requests on it.
|
||||
if (downloadLimited) {
|
||||
throw new HttpError(429, MAKERWORLD_DOWNLOAD_LIMIT_MESSAGE, MAKERWORLD_DOWNLOAD_LIMIT_CODE);
|
||||
}
|
||||
const { print, alreadyImported } = await importPrintFromUrl(userId, modelUrl, itemBody);
|
||||
successPrintIds.push(print.id);
|
||||
if (alreadyImported) alreadyInLibrary++;
|
||||
@@ -219,6 +235,7 @@ export async function runCollectionImportJob(
|
||||
const reason = classifyImportFailure(err);
|
||||
if (reason === "unavailable") unavailable++;
|
||||
else if (reason === "rateLimited") rateLimited++;
|
||||
else if (reason === "downloadLimit") downloadLimited++;
|
||||
else if (reason === "auth") authFailed++;
|
||||
} finally {
|
||||
processed++;
|
||||
@@ -275,13 +292,18 @@ export async function runCollectionImportJob(
|
||||
const label = collectionTitle ? `"${collectionTitle}"` : "a MakerWorld collection";
|
||||
const bodyParts: string[] = [];
|
||||
if (alreadyInLibrary) bodyParts.push(`${alreadyInLibrary} already in your library`);
|
||||
const otherFailed = failed.length - unavailable - rateLimited - authFailed;
|
||||
const otherFailed = failed.length - unavailable - rateLimited - downloadLimited - authFailed;
|
||||
if (unavailable) bodyParts.push(`${unavailable} unavailable (private, deleted, or hidden)`);
|
||||
if (rateLimited) {
|
||||
bodyParts.push(
|
||||
`${rateLimited} blocked by a MakerWorld CAPTCHA challenge (too many requests at once) — this usually clears in 1-4 hours, then retry the same collection`,
|
||||
);
|
||||
}
|
||||
if (downloadLimited) {
|
||||
bodyParts.push(
|
||||
`${downloadLimited} not downloaded — MakerWorld's daily download limit was reached, retry the same collection tomorrow`,
|
||||
);
|
||||
}
|
||||
if (authFailed)
|
||||
bodyParts.push(
|
||||
`${authFailed} failed because your MakerWorld session expired — update the cookie in Settings and retry`,
|
||||
@@ -698,7 +720,7 @@ async function runLinks(jobId: string, userId: string, body: LinksImportJobBody)
|
||||
let profilesImported = 0;
|
||||
let profilesProcessed = 0;
|
||||
let printId: string | null = null;
|
||||
let stopReason: "rateLimited" | "auth" | null = null;
|
||||
let stopReason: StopReason | null = null;
|
||||
let processed = (await countJobItems(jobId, "DONE")) + (await countJobItems(jobId, "FAILED"));
|
||||
|
||||
for (const item of items) {
|
||||
@@ -716,10 +738,11 @@ async function runLinks(jobId: string, userId: string, body: LinksImportJobBody)
|
||||
while (!settled) {
|
||||
attempts++;
|
||||
if (stopReason) {
|
||||
itemError =
|
||||
stopReason === "rateLimited"
|
||||
? "blocked by a MakerWorld CAPTCHA challenge — retry once it clears (usually 1-4 hours)"
|
||||
: "the MakerWorld session expired — update the cookie in Settings, then retry";
|
||||
itemError = {
|
||||
rateLimited: "blocked by a MakerWorld CAPTCHA challenge — retry once it clears (usually 1-4 hours)",
|
||||
downloadLimit: "MakerWorld's daily download limit was reached — retry tomorrow",
|
||||
auth: "the MakerWorld session expired — update the cookie in Settings, then retry",
|
||||
}[stopReason];
|
||||
settled = true;
|
||||
break;
|
||||
}
|
||||
@@ -742,10 +765,11 @@ async function runLinks(jobId: string, userId: string, body: LinksImportJobBody)
|
||||
itemError = `${result.failed} of ${result.processed} print profiles failed`;
|
||||
if (result.stopReason) {
|
||||
stopReason = result.stopReason;
|
||||
itemError +=
|
||||
result.stopReason === "rateLimited"
|
||||
? " (MakerWorld CAPTCHA challenge — retry once it clears)"
|
||||
: " (the MakerWorld session expired — update the cookie in Settings, then retry)";
|
||||
itemError += {
|
||||
rateLimited: " (MakerWorld CAPTCHA challenge — retry once it clears)",
|
||||
downloadLimit: " (MakerWorld's daily download limit — retry tomorrow)",
|
||||
auth: " (the MakerWorld session expired — update the cookie in Settings, then retry)",
|
||||
}[result.stopReason];
|
||||
}
|
||||
settled = true;
|
||||
} else {
|
||||
@@ -754,8 +778,7 @@ async function runLinks(jobId: string, userId: string, body: LinksImportJobBody)
|
||||
}
|
||||
} catch (err) {
|
||||
itemError = failureMessage(err);
|
||||
const reason = classifyImportFailure(err);
|
||||
if (reason === "rateLimited" || reason === "auth") stopReason = reason;
|
||||
stopReason = asStopReason(classifyImportFailure(err)) ?? stopReason;
|
||||
if (!isTransientImportFailure(err) || attempts >= LINKS_MAX_ATTEMPTS) {
|
||||
settled = true;
|
||||
} else {
|
||||
@@ -809,6 +832,10 @@ async function runLinks(jobId: string, userId: string, body: LinksImportJobBody)
|
||||
bodyParts.push(
|
||||
`the rest blocked by a MakerWorld CAPTCHA challenge — this usually clears in 1-4 hours, then retry the failed links`,
|
||||
);
|
||||
} else if (stopReason === "downloadLimit") {
|
||||
bodyParts.push(
|
||||
`the rest not downloaded — MakerWorld's daily download limit was reached, retry the failed links tomorrow`,
|
||||
);
|
||||
} else if (stopReason === "auth") {
|
||||
bodyParts.push(
|
||||
`the rest failed because your MakerWorld session expired — update the cookie in Settings, then retry`,
|
||||
|
||||
@@ -10,7 +10,12 @@ import {
|
||||
IMPORT_USER_AGENT,
|
||||
} from "../config";
|
||||
import { isJsonContentType } from "../utils/fileUtils";
|
||||
import { isCaptchaChallenge, MakerworldCaptchaError } from "./makerworldCaptcha";
|
||||
import {
|
||||
isCaptchaChallenge,
|
||||
isDownloadLimitReply,
|
||||
MakerworldCaptchaError,
|
||||
MakerworldDownloadLimitError,
|
||||
} from "./makerworldCaptcha";
|
||||
import {
|
||||
extractJsonFromBrowserBody,
|
||||
fetchViaFlaresolverr,
|
||||
@@ -592,11 +597,12 @@ function makerworldModelIdFromUrl(url: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** A CAPTCHA reply (HTTP 418) is thrown, not treated as "no URL": the fallbacks after it would only
|
||||
* hit the same block and end in a misleading "No downloadable model file found". */
|
||||
/** A CAPTCHA reply (HTTP 418) or the daily download limit is thrown, not treated as "no URL": the
|
||||
* fallbacks after it would only hit the same block and end in a misleading error. */
|
||||
function makerworldDownloadUrlOrCaptcha(data: unknown, apiUrl: string): string | null {
|
||||
const url = data ? extractDownloadUrlFromResponse(data, apiUrl) : null;
|
||||
if (!url && isCaptchaChallenge(data)) throw new MakerworldCaptchaError();
|
||||
if (!url && isDownloadLimitReply(data)) throw new MakerworldDownloadLimitError();
|
||||
return url;
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@ import {
|
||||
MakerworldAuthError,
|
||||
MakerworldCaptchaError,
|
||||
makerworldCaptchaCooloffActive,
|
||||
MAKERWORLD_DOWNLOAD_LIMIT_CODE,
|
||||
MakerworldDownloadLimitError,
|
||||
parseMakerworldModelUrl,
|
||||
resolveMakerworldViaCloudApi,
|
||||
completeMakerworldAuthor,
|
||||
@@ -286,6 +288,9 @@ async function tryMakerworldCloudApi(url: string, body: ImportRequestBody): Prom
|
||||
return resolved;
|
||||
} catch (err) {
|
||||
if (err instanceof MakerworldCaptchaError) throw new HttpError(429, err.message);
|
||||
if (err instanceof MakerworldDownloadLimitError) {
|
||||
throw new HttpError(429, err.message, MAKERWORLD_DOWNLOAD_LIMIT_CODE);
|
||||
}
|
||||
// 400, not 401: the frontend treats any 401 as an expired Thingport session and logs out.
|
||||
if (err instanceof MakerworldAuthError) throw new HttpError(400, err.message);
|
||||
throw err;
|
||||
@@ -382,6 +387,9 @@ export async function openImportResponse(
|
||||
resolved = await resolveMakerworldDownloadUrl(html, finalUrl, makerworldCookie, requestedInstanceId);
|
||||
} catch (err) {
|
||||
if (err instanceof MakerworldCaptchaError) throw new HttpError(429, err.message);
|
||||
if (err instanceof MakerworldDownloadLimitError) {
|
||||
throw new HttpError(429, err.message, MAKERWORLD_DOWNLOAD_LIMIT_CODE);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (resolved) {
|
||||
|
||||
@@ -41,6 +41,28 @@ export class MakerworldCaptchaError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// The per-account daily download cap: HTTP 400 with {"code":-1,"error":"You've reached your daily
|
||||
// download limit."}. Every later download fails the same way until it resets.
|
||||
export function isDownloadLimitReply(data: unknown): boolean {
|
||||
if (!isRecord(data)) return false;
|
||||
return ["error", "message", "msg"].some(
|
||||
(key) => typeof data[key] === "string" && /download limit/i.test(data[key] as string),
|
||||
);
|
||||
}
|
||||
|
||||
export const MAKERWORLD_DOWNLOAD_LIMIT_MESSAGE =
|
||||
"MakerWorld daily download limit reached for this account. Try again tomorrow.";
|
||||
|
||||
// The HttpError code, so batch runners can tell it from a CAPTCHA (both are 429).
|
||||
export const MAKERWORLD_DOWNLOAD_LIMIT_CODE = "MAKERWORLD_DOWNLOAD_LIMIT";
|
||||
|
||||
export class MakerworldDownloadLimitError extends Error {
|
||||
constructor() {
|
||||
super(MAKERWORLD_DOWNLOAD_LIMIT_MESSAGE);
|
||||
this.name = "MakerworldDownloadLimitError";
|
||||
}
|
||||
}
|
||||
|
||||
export class MakerworldAuthError extends Error {
|
||||
constructor() {
|
||||
super("Your MakerWorld session has expired or was rejected. Update the cookie in Settings and try again.");
|
||||
|
||||
@@ -15,9 +15,11 @@ import { maybeSleep, sleep } from "../utils/concurrency";
|
||||
import { HttpError } from "../utils/fileUtils";
|
||||
import {
|
||||
isCaptchaChallenge,
|
||||
isDownloadLimitReply,
|
||||
makerworldCaptchaCooloffActive,
|
||||
MakerworldAuthError,
|
||||
MakerworldCaptchaError,
|
||||
MakerworldDownloadLimitError,
|
||||
noteCaptchaChallenge,
|
||||
} from "./makerworldCaptcha";
|
||||
|
||||
@@ -26,6 +28,9 @@ export {
|
||||
makerworldCaptchaCooloffActive,
|
||||
MakerworldAuthError,
|
||||
MakerworldCaptchaError,
|
||||
MAKERWORLD_DOWNLOAD_LIMIT_CODE,
|
||||
MAKERWORLD_DOWNLOAD_LIMIT_MESSAGE,
|
||||
MakerworldDownloadLimitError,
|
||||
} from "./makerworldCaptcha";
|
||||
|
||||
// makerworld.com puts Cloudflare and a Geetest CAPTCHA in front of download resolution;
|
||||
@@ -364,6 +369,7 @@ export async function resolveMakerworldViaCloudApi(
|
||||
noteCaptchaChallenge();
|
||||
throw new MakerworldCaptchaError();
|
||||
}
|
||||
if (isDownloadLimitReply(downloadResult.data)) throw new MakerworldDownloadLimitError();
|
||||
if (downloadResult.status === 401 || downloadResult.status === 403) throw new MakerworldAuthError();
|
||||
if (downloadResult.status !== 200 || !isRecord(downloadResult.data)) return null;
|
||||
const body = downloadResult.data;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
MakerworldCaptchaError,
|
||||
makerworldCaptchaCooloffActive,
|
||||
MakerworldDownloadLimitError,
|
||||
resolveMakerworldViaCloudApi,
|
||||
} from "../src/services/makerworldCloudApi";
|
||||
|
||||
@@ -47,6 +48,22 @@ describe("resolveMakerworldViaCloudApi", () => {
|
||||
expect(makerworldCaptchaCooloffActive()).toBe(false);
|
||||
});
|
||||
|
||||
it("throws MakerworldDownloadLimitError on the daily download limit reply", async () => {
|
||||
global.fetch = vi.fn<(input: RequestInfo | URL) => Promise<Response>>(async (input) => {
|
||||
const url = String(input);
|
||||
if (url.includes("/design-service/design/")) return jsonResponse(200, VALID_DESIGN);
|
||||
if (url.includes("/iot-service/api/user/profile/")) {
|
||||
return jsonResponse(400, { code: -1, error: "You've reached your daily download limit." });
|
||||
}
|
||||
throw new Error(`Unexpected fetch to ${url}`);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
await expect(resolveMakerworldViaCloudApi("321", null, "test-token")).rejects.toBeInstanceOf(
|
||||
MakerworldDownloadLimitError,
|
||||
);
|
||||
expect(makerworldCaptchaCooloffActive()).toBe(false);
|
||||
});
|
||||
|
||||
it("throws MakerworldCaptchaError and starts the cooloff on a real CAPTCHA challenge, then short-circuits further calls without hitting the network again", async () => {
|
||||
const fetchMock = vi.fn<(input: RequestInfo | URL) => Promise<Response>>(async (input) => {
|
||||
const url = String(input);
|
||||
|
||||
@@ -128,8 +128,9 @@ function claimStep(tabId: number): Promise<MakerworldJob | null> {
|
||||
return claimed;
|
||||
}
|
||||
|
||||
/** A CAPTCHA pauses the run on this model for the user to solve; any other failure stops it, since
|
||||
* it almost always means MakerWorld rejected the request, and more requests would make it worse. */
|
||||
/** A CAPTCHA pauses the run on this model for the user to solve; any other failure, the daily
|
||||
* download limit included, stops it, since it almost always means MakerWorld rejected the
|
||||
* request, and more requests would make it worse. */
|
||||
export async function advanceJob(tabId: number): Promise<void> {
|
||||
const job = await claimStep(tabId);
|
||||
if (!job) return;
|
||||
@@ -142,7 +143,8 @@ export async function advanceJob(tabId: number): Promise<void> {
|
||||
let resolveError: string | null = null;
|
||||
try {
|
||||
const reply = await withTimeout(sendToTab(tabId, "RESOLVE_MAKERWORLD_DOWNLOAD_URL"), DOWNLOAD_RESOLVE_TIMEOUT_MS);
|
||||
// The content script only replies with an error for a CAPTCHA; the backend would hit it too.
|
||||
// The content script only replies with an error for a CAPTCHA or the daily download limit;
|
||||
// the backend would hit it too.
|
||||
if (reply && reply.ok) resolved = reply.data;
|
||||
else if (reply) resolveError = reply.error;
|
||||
} catch {}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { listen, send, type ContentMessages } from "../shared/messages";
|
||||
import { CONFIG_CHANGE_KEYS } from "../shared/storage";
|
||||
import { classifyUrl } from "../shared/urls";
|
||||
import { setContext, type LibraryState } from "./context";
|
||||
import { MakerworldCaptchaError, resolveMakerworldDownloadUrl } from "./makerworld/downloadResolver";
|
||||
import { isMakerworldBlockingError, resolveMakerworldDownloadUrl } from "./makerworld/downloadResolver";
|
||||
import { offerNormalizedDownload, unmountNormalizedDownload } from "./makerworld/normalizedDownload";
|
||||
import { mountJobOverlay, mountPausedJobOverlay } from "./overlays";
|
||||
import { loadPanel } from "./panels";
|
||||
@@ -155,10 +155,11 @@ if (!window.thingportGrabInjected) {
|
||||
});
|
||||
|
||||
listen<ContentMessages>({
|
||||
// A CAPTCHA comes back as an error reply so the guided import can say so.
|
||||
// A CAPTCHA or the daily download limit comes back as an error reply so the guided import can
|
||||
// say so.
|
||||
RESOLVE_MAKERWORLD_DOWNLOAD_URL: () =>
|
||||
resolveMakerworldDownloadUrl(location.href).catch((err) => {
|
||||
if (err instanceof MakerworldCaptchaError) throw err;
|
||||
if (isMakerworldBlockingError(err)) throw err;
|
||||
return null;
|
||||
}),
|
||||
MAKERWORLD_JOB_UPDATED: () => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// Resolves the MakerWorld file URL in the page instead of the backend, whose resolution calls are
|
||||
// what trip MakerWorld's account-wide CAPTCHA. Same-origin fetches carry the real session and look
|
||||
// like the page's own traffic. Returns null on any failure so the backend can resolve it instead,
|
||||
// except a CAPTCHA, which is thrown: the backend would only hit the same wall.
|
||||
// except a CAPTCHA or the daily download limit, which are thrown: the backend would only hit the
|
||||
// same wall.
|
||||
|
||||
import type { ResolvedDownload } from "../../shared/messages";
|
||||
import { send } from "../../shared/messages";
|
||||
@@ -56,6 +57,29 @@ export class MakerworldCaptchaError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export const MAKERWORLD_DOWNLOAD_LIMIT_MESSAGE =
|
||||
"MakerWorld daily download limit reached for this account. Try again tomorrow.";
|
||||
|
||||
/** The per-account daily cap: HTTP 400 with {"code":-1,"error":"You've reached your daily download
|
||||
* limit."}. Every later download fails the same way until it resets. */
|
||||
export class MakerworldDownloadLimitError extends Error {
|
||||
constructor() {
|
||||
super(MAKERWORLD_DOWNLOAD_LIMIT_MESSAGE);
|
||||
this.name = "MakerworldDownloadLimitError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Errors that end an import outright: retrying or falling back to the backend can't get past them. */
|
||||
export function isMakerworldBlockingError(err: unknown): boolean {
|
||||
return err instanceof MakerworldCaptchaError || err instanceof MakerworldDownloadLimitError;
|
||||
}
|
||||
|
||||
function looksLikeDownloadLimit(data: unknown): boolean {
|
||||
if (!data || typeof data !== "object") return false;
|
||||
const { error, message, msg } = data as Record<string, unknown>;
|
||||
return [error, message, msg].some((value) => typeof value === "string" && /download limit/i.test(value));
|
||||
}
|
||||
|
||||
/** Only asked of a body without a download URL, whose filename could otherwise say "robot". */
|
||||
function looksLikeCaptcha(data: unknown): boolean {
|
||||
if (!data || typeof data !== "object") return false;
|
||||
@@ -74,9 +98,10 @@ async function fetchMakerworldApiJson(url: string, nonce: string | null): Promis
|
||||
const data: unknown = await res.json();
|
||||
if (extractDownloadUrl(data)) return data;
|
||||
if (looksLikeCaptcha(data)) throw new MakerworldCaptchaError();
|
||||
if (looksLikeDownloadLimit(data)) throw new MakerworldDownloadLimitError();
|
||||
return res.ok ? data : null;
|
||||
} catch (err) {
|
||||
if (err instanceof MakerworldCaptchaError) throw err;
|
||||
if (isMakerworldBlockingError(err)) throw err;
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
@@ -269,7 +294,7 @@ export async function resolveMakerworldDownloadUrl(
|
||||
: { downloadUrl: viaClick.url, instanceId };
|
||||
}
|
||||
return resolveFromPageApi(pageUrl).catch((err) => {
|
||||
if (err instanceof MakerworldCaptchaError) throw err;
|
||||
if (isMakerworldBlockingError(err)) throw err;
|
||||
return null;
|
||||
});
|
||||
}
|
||||
@@ -281,6 +306,9 @@ export async function resolveMakerworldProfileDownload(
|
||||
): Promise<ResolvedDownload | null> {
|
||||
const page = await loadMakerworldDesignForPage(pageUrl);
|
||||
if (!page) return null;
|
||||
const downloadUrl = await fetchInstanceDownloadUrl(page.design, instanceId).catch(() => null);
|
||||
const downloadUrl = await fetchInstanceDownloadUrl(page.design, instanceId).catch((err) => {
|
||||
if (err instanceof MakerworldDownloadLimitError) throw err;
|
||||
return null;
|
||||
});
|
||||
return downloadUrl ? { downloadUrl, instanceId, design: makerworldDesignForImport(page.design) } : null;
|
||||
}
|
||||
@@ -4,7 +4,11 @@
|
||||
import type { InspectResult, ZipEntriesResult } from "../../shared/api";
|
||||
import { request } from "../../shared/messages";
|
||||
import { ctx } from "../context";
|
||||
import { resolveMakerworldDownloadUrl, resolveMakerworldProfileDownload } from "../makerworld/downloadResolver";
|
||||
import {
|
||||
isMakerworldBlockingError,
|
||||
resolveMakerworldDownloadUrl,
|
||||
resolveMakerworldProfileDownload,
|
||||
} from "../makerworld/downloadResolver";
|
||||
import {
|
||||
currentMakerworldProfileTitle,
|
||||
loadMakerworldDesignForPage,
|
||||
@@ -215,11 +219,11 @@ async function runDirectImport(opts?: { entries?: string[] }): Promise<void> {
|
||||
const { url, instanceUrl, classification, title } = ctx();
|
||||
renderFunStatus(importingPhrases(classification.provider));
|
||||
const collectionId = await collectionIdFor(choice);
|
||||
const resolved =
|
||||
classification.provider === "makerworld" && classification.type === "model"
|
||||
? await resolveMakerworldDownloadUrl(url).catch(() => null)
|
||||
: null;
|
||||
try {
|
||||
const resolved =
|
||||
classification.provider === "makerworld" && classification.type === "model"
|
||||
? await resolveMakerworldDownloadUrl(url).catch(nullUnlessBlocking)
|
||||
: null;
|
||||
// One message so import and collection filing finish even if the page is gone.
|
||||
const print = await request("IMPORT_SINGLE", { url, entries: opts?.entries, collectionId, resolved, title });
|
||||
const link = print ? `${instanceUrl}/models/${print.id}` : `${instanceUrl}/models`;
|
||||
@@ -241,8 +245,14 @@ async function runDirectImport(opts?: { entries?: string[] }): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Null lets the backend resolve it; a CAPTCHA or the download limit would stop it too. */
|
||||
function nullUnlessBlocking(err: unknown): null {
|
||||
if (isMakerworldBlockingError(err)) throw err;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The first profile creates (or finds) the model; later ones are added as files. Stops on a
|
||||
* CAPTCHA, which would fail every later one. */
|
||||
* CAPTCHA or the daily download limit, which would fail every later one. */
|
||||
async function runProfilesImport(scope: MakerworldProfileScope, collectionId: string | null): Promise<void> {
|
||||
const { url, instanceUrl, title } = ctx();
|
||||
const page = await loadMakerworldDesignForPage(url);
|
||||
@@ -261,11 +271,11 @@ async function runProfilesImport(scope: MakerworldProfileScope, collectionId: st
|
||||
renderPanel(statusHtml(`Importing print profile ${index + 1} of ${ids.length}…`));
|
||||
if (index > 0) await new Promise((resolve) => setTimeout(resolve, PROFILE_GAP_MS));
|
||||
// The page's Download button gives exactly the link's profile.
|
||||
const resolved =
|
||||
index === 0
|
||||
? await resolveMakerworldDownloadUrl(url).catch(() => null)
|
||||
: await resolveMakerworldProfileDownload(url, instanceId).catch(() => null);
|
||||
try {
|
||||
const resolved =
|
||||
index === 0
|
||||
? await resolveMakerworldDownloadUrl(url).catch(nullUnlessBlocking)
|
||||
: await resolveMakerworldProfileDownload(url, instanceId).catch(nullUnlessBlocking);
|
||||
const print = await request("IMPORT_SINGLE", {
|
||||
url: index === 0 ? url : profileUrl(url, instanceId),
|
||||
collectionId,
|
||||
@@ -278,7 +288,8 @@ async function runProfilesImport(scope: MakerworldProfileScope, collectionId: st
|
||||
} catch (err) {
|
||||
failed++;
|
||||
lastError = err;
|
||||
if (err instanceof Error && /captcha/i.test(err.message)) {
|
||||
// The backend's errors arrive as plain text.
|
||||
if (err instanceof Error && /captcha|download limit/i.test(err.message)) {
|
||||
failed += ids.length - index - 1;
|
||||
break;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user