fix(route/makerworld): bypass Cloudflare challenge and adapt to new API shape (#22411)

* fix(route/makerworld): bypass Cloudflare challenge and adapt to new API shape

makerworld.com now sits behind a Cloudflare bot-management challenge that
fingerprints the TLS/HTTP client rather than just headers, so plain fetch
requests get a 403 even with a browser-like User-Agent. Fall back to a
stealth Playwright page load when that happens.

The trending page's data shape also changed: designs moved from
`pageProps.popularDesignsData` to `pageProps.v2Props.foryouData.hits[].design`,
mixed in with non-design promo/community entries, and `tags`/`startTime` were
replaced by `createTime` (no tags exposed on this endpoint anymore).

* fix(route/makerworld): always fetch via browser instead of trying ofetch first

Cloudflare's challenge fingerprints the client itself, so the plain fetch
attempt was guaranteed to always fail with a 403 first. Go straight to the
Playwright fallback instead of paying for a request known to fail.
This commit is contained in:
Arslan Ablikim
2026-07-11 00:44:03 +08:00
committed by GitHub
parent 0ab18ff80e
commit 478df5e488
4 changed files with 42 additions and 42 deletions
+2 -8
View File
@@ -1,9 +1,7 @@
import { config } from '@/config';
import type { Route } from '@/types';
import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';
import { baseUrl, getNextBuildId } from './utils';
import { baseUrl, fetchJson, getNextBuildId } from './utils';
export const route: Route = {
path: '/contests',
@@ -21,11 +19,7 @@ export const route: Route = {
async function handler() {
const nextBuildId = await getNextBuildId();
const response = await ofetch(`${baseUrl}/_next/data/${nextBuildId}/en/contests.json`, {
headers: {
'User-Agent': config.trueUA,
},
});
const response = await fetchJson(`${baseUrl}/_next/data/${nextBuildId}/en/contests.json`);
const { listConst, previewList } = response.pageProps;
const items = [
+11 -16
View File
@@ -1,9 +1,7 @@
import { config } from '@/config';
import type { Route } from '@/types';
import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';
import { baseUrl, getNextBuildId } from './utils';
import { baseUrl, fetchJson, getNextBuildId } from './utils';
export const route: Route = {
path: '/trending',
@@ -21,20 +19,17 @@ export const route: Route = {
async function handler() {
const nextBuildId = await getNextBuildId();
const response = await ofetch(`${baseUrl}/_next/data/${nextBuildId}/en.json`, {
headers: {
'User-Agent': config.trueUA,
},
});
const response = await fetchJson(`${baseUrl}/_next/data/${nextBuildId}/en.json`);
const items = response.pageProps.popularDesignsData.map((d) => ({
title: d.title,
link: `${baseUrl}/en/models/${d.id}-${d.slug}`,
author: d.designCreator.name,
category: d.tags,
pubDate: parseDate(d.startTime),
description: d.designExtension.design_pictures.map((i) => `<figure><img src="${i.url}" alt="${d.name}"><figcaption>${i.name}</figcaption></figure>`).join(''),
}));
const items = response.pageProps.v2Props.foryouData.hits
.filter((hit) => hit.design?.title)
.map(({ design: d }) => ({
title: d.title,
link: `${baseUrl}/en/models/${d.id}-${d.slug}`,
author: d.designCreator.name,
pubDate: parseDate(d.createTime),
description: d.designExtension.design_pictures.map((i) => `<figure><img src="${i.url}" alt="${d.title}"><figcaption>${i.name}</figcaption></figure>`).join(''),
}));
return {
title: 'Trending Models - MakerWorld',
+2 -11
View File
@@ -1,9 +1,7 @@
import { config } from '@/config';
import type { Route } from '@/types';
import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';
import { baseUrl, getNextBuildId } from './utils';
import { baseUrl, fetchJson, getNextBuildId } from './utils';
export const route: Route = {
path: '/user/:handle/upload',
@@ -26,14 +24,7 @@ async function handler(ctx) {
const { handle } = ctx.req.param();
const nextBuildId = await getNextBuildId();
const response = await ofetch(`${baseUrl}/_next/data/${nextBuildId}/en/${handle}/upload.json`, {
headers: {
'User-Agent': config.trueUA,
},
query: {
handle,
},
});
const response = await fetchJson(`${baseUrl}/_next/data/${nextBuildId}/en/${handle}/upload.json`, { handle });
const { userInfo, designs } = response.pageProps;
const items = designs.map((d) => ({
+27 -7
View File
@@ -1,18 +1,38 @@
import { load } from 'cheerio';
import { config } from '@/config';
import cache from '@/utils/cache';
import ofetch from '@/utils/ofetch';
import { getPlaywrightPage } from '@/utils/playwright';
export const baseUrl = 'https://makerworld.com';
// makerworld.com sits behind a Cloudflare bot-management challenge that fingerprints the
// TLS/HTTP client itself rather than just headers, so a plain fetch always gets a 403 no
// matter what headers are sent. A real (stealth-patched) browser is required to pass it.
const fetchViaBrowser = async (url: string, type: 'html' | 'json' = 'html') => {
const { page, destroy } = await getPlaywrightPage(url, {
onBeforeLoad: async (page) => {
const expectResourceTypes = new Set(['document', 'script', 'xhr', 'fetch']);
await page.route('**/*', (route) => {
const request = route.request();
expectResourceTypes.has(request.resourceType()) ? route.continue() : route.abort();
});
},
});
try {
return (await page.evaluate(type === 'html' ? () => document.documentElement.innerHTML : () => document.documentElement.textContent)) ?? '';
} finally {
await destroy();
}
};
export const fetchJson = async (url: string, query?: Record<string, string>) => {
const finalUrl = query ? `${url}?${new URLSearchParams(query).toString()}` : url;
return JSON.parse(await fetchViaBrowser(finalUrl, 'json'));
};
export const getNextBuildId = () =>
cache.tryGet('makerworld:nextBuildId', async () => {
const response = await ofetch(`${baseUrl}/en`, {
headers: {
'User-Agent': config.trueUA,
},
});
const response = await fetchViaBrowser(`${baseUrl}/en`);
const $ = load(response);
const nextData = JSON.parse($('script#__NEXT_DATA__').text());
return nextData.buildId;