규칙(workshop-layout.ts 한 곳에 정의, 도구·스크립트가 공유): workshop/<id>/CAD/(.scad+STL 같은 폴더) · CAD/참고/(비인쇄 참고 모델) · CAD/다운로드/ · 출력/ · 사진/ · 영상/ · 조립설명서/ print3d/K2웹슬라이서/ · 모델검색/ · 일회성/ · profiles/ 인쇄용 STL은 .scad 옆에 두되 참고용은 CAD/참고/로 분리 유지(09-22 "참고용을 인쇄 대상으로 착각" 방지) - 이동 스크립트 scripts/migrate-workshop-layout.ts: 기본 dry-run, --apply(이동만·덮어쓰기 없음·충돌은 건너뛰고 보고), --undo <manifest>. .scad 경로 참조(조립 명세)와 문서 문구(CAD/print→CAD 등) 갱신, 빈 폴더만 정리. 멱등 - 도구가 같은 규칙으로 저장: print3d_model(모델→CAD/다운로드, G-code→출력, 프로젝트 없으면 print3d/일회성), model_download(CAD/다운로드 또는 print3d/모델검색), K2 웹 슬라이서(print3d/K2웹슬라이서). 옛 print3d/<프로젝트이름>/ 제거 - 채팅 프롬프트: STL은 .scad와 같은 폴더(옛 09-22 CAD/print 하위폴더 규칙 폐지), 프로젝트 폴더 규칙 안내 - 파일 탭: 권장 폴더를 비어 있어도 업로드 대상으로 표시하고 규칙 순서로 정렬 - 테스트 34건(규칙 표, 멱등, 충돌 미덮어쓰기, apply→undo 왕복 무손실, saveDirs, print3d 계획) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
491 lines
28 KiB
TypeScript
491 lines
28 KiB
TypeScript
import path from 'path';
|
|
import fs from 'fs';
|
|
import { ToolResult } from '../types.js';
|
|
import { getConfig } from '../config/config.js';
|
|
import { getVault } from '../security/vault.js';
|
|
import { getWorkspacePath } from '../config/paths.js';
|
|
import { isPathInsideDir } from './image.js';
|
|
import { findWorkshopProjectName } from './print3d.js';
|
|
import { saveDirs } from '../gateway/routes/workshop-layout.js';
|
|
|
|
// 3D 모델 저장소 검색+다운로드 (2026-09-23) — Printables / MakerWorld / Thingiverse.
|
|
// 세 곳 모두 공식 API가 없거나(Printables/MakerWorld) 응용토큰이 필요한(Thingiverse) 사이라
|
|
// 이 파일 하나에 클라이언트를 모은다. 실측(2026-09-23 curl):
|
|
// - Printables: api.printables.com/graphql/ 익명 검색·상세·getDownloadLink 모두 동작(로그인 불필요)
|
|
// - MakerWorld: api.bambulab.com/v1/search-service/select/design2 익명 검색 동작.
|
|
// 다운로드는 Bambu Cloud 토큰(makerworld_token, ~90일 만료) 필요 — 미설정 시 검색만 가능
|
|
// - Thingiverse: api.thingiverse.com 공식 REST + Bearer 토큰(앱 등록 시 발급).
|
|
// 검색·파일목록·다운로드 전부 토큰 필요
|
|
//
|
|
// 키가 없어도 되는 범위가 많으므로 "키 미설정"은 에러가 아니라 스킵+진단으로 처리한다
|
|
// (web.ts runSearchProviders의 프로바이더 캐스케이드와 같은 우아한 저하 방식).
|
|
|
|
export type ModelSite = 'printables' | 'makerworld' | 'thingiverse';
|
|
const ALL_SITES: ModelSite[] = ['thingiverse', 'printables', 'makerworld'];
|
|
|
|
export interface ModelResult {
|
|
site: ModelSite;
|
|
id: string;
|
|
name: string;
|
|
author: string;
|
|
likes: number;
|
|
downloads: number;
|
|
url: string;
|
|
fileCount: number;
|
|
}
|
|
|
|
// ─── 키/토큰 해석 (news.ts getApiKey 패턴: resolveSecret → vault 폴백) ───────
|
|
|
|
function getConfigKey(site: ModelSite): string {
|
|
if (site === 'printables') return 'print3d.printables_cookie';
|
|
if (site === 'makerworld') return 'print3d.makerworld_token';
|
|
return 'print3d.thingiverse_token';
|
|
}
|
|
|
|
function getSiteKey(site: ModelSite): string | undefined {
|
|
try {
|
|
const cm = getConfig();
|
|
const cfg = cm.getConfig() as any;
|
|
const dotted = getConfigKey(site);
|
|
const [a, b] = dotted.split('.');
|
|
const resolved = cm.resolveSecret(cfg?.[a]?.[b]);
|
|
if (resolved) return resolved;
|
|
const vault = getVault(cm.getConfigDir());
|
|
return vault.get(dotted, 'model-search:key')?.expose();
|
|
} catch {}
|
|
return undefined;
|
|
}
|
|
|
|
// ─── 순수 함수 (단위 테스트 대상 — news.ts resolveNewsParams 추출 패턴) ──────
|
|
|
|
export function parseModelRef(site: ModelSite, ref: string): { id: string } | { error: string } {
|
|
const s = String(ref || '').trim();
|
|
if (!s) return { error: 'model_id가 필요합니다.' };
|
|
if (/^\d+$/.test(s)) return { id: s };
|
|
// URL 붙여넣기 허용: printables.com/model/123-slug, makerworld.com/en/models/123, thingiverse.com/thing:123
|
|
try {
|
|
const u = new URL(s.startsWith('http') ? s : `https://${s}`);
|
|
const host = u.hostname.replace(/^www\./, '');
|
|
const parts = u.pathname.split('/').filter(Boolean);
|
|
if (host === 'printables.com') {
|
|
const idx = parts.indexOf('model') !== -1 ? parts.indexOf('model') : parts.indexOf('print');
|
|
if (idx !== -1 && /^\d+/.test(parts[idx + 1] || '')) return { id: (parts[idx + 1].match(/^\d+/) as any)[0] };
|
|
} else if (host === 'makerworld.com') {
|
|
const idx = parts.indexOf('models');
|
|
if (idx !== -1 && /^\d+/.test(parts[idx + 1] || '')) return { id: (parts[idx + 1].match(/^\d+/) as any)[0] };
|
|
} else if (host === 'thingiverse.com') {
|
|
const thing = parts.find((p) => p.startsWith('thing:'));
|
|
if (thing) return { id: thing.split(':')[1] };
|
|
const idx = parts.indexOf('things');
|
|
if (idx !== -1 && /^\d+$/.test(parts[idx + 1] || '')) return { id: parts[idx + 1] };
|
|
}
|
|
return { error: `${host} URL에서 모델 id를 찾지 못했습니다. model_search 결과의 id나 모델 페이지 URL을 넣어주세요.` };
|
|
} catch {
|
|
return { error: `model_id를 해석하지 못했습니다: ${s}` };
|
|
}
|
|
}
|
|
|
|
// 검색 대상 결정 — site 미지정이면 전부 시도. 키 유무는 검색(익명 가능)엔 무관하고
|
|
// Thingiverse 검색만 토큰 필요라 keys에서 판단한다.
|
|
export function resolveSitePlan(
|
|
args: any,
|
|
keys: { printables?: string; makerworld?: string; thingiverse?: string }
|
|
): { attempt: ModelSite[]; skipped: Array<{ site: ModelSite; reason: string }> } {
|
|
const skipped: Array<{ site: ModelSite; reason: string }> = [];
|
|
const explicit = String(args?.site || 'all').trim().toLowerCase();
|
|
if (explicit !== 'all' && ALL_SITES.includes(explicit as ModelSite)) {
|
|
const site = explicit as ModelSite;
|
|
if (site === 'thingiverse' && !keys.thingiverse) {
|
|
return { attempt: [], skipped: [{ site, reason: 'Thingiverse는 토큰이 필요합니다 (print3d.thingiverse_token 미설정)' }] };
|
|
}
|
|
return { attempt: [site], skipped };
|
|
}
|
|
const attempt: ModelSite[] = [];
|
|
for (const site of ALL_SITES) {
|
|
if (site === 'thingiverse' && !keys.thingiverse) {
|
|
skipped.push({ site, reason: '토큰 미설정 (print3d.thingiverse_token) — Thingiverse는 익명 검색 불가' });
|
|
continue;
|
|
}
|
|
attempt.push(site);
|
|
}
|
|
return { attempt, skipped };
|
|
}
|
|
|
|
export function mapThingiverseResults(data: any): ModelResult[] {
|
|
const hits = Array.isArray(data?.hits) ? data.hits : [];
|
|
return hits.map((h: any) => ({
|
|
site: 'thingiverse' as const,
|
|
id: String(h?.id ?? ''),
|
|
name: String(h?.name || '(제목 없음)'),
|
|
author: String(h?.creator?.name || ''),
|
|
likes: Number(h?.like_count) || 0,
|
|
downloads: Number(h?.download_count) || 0,
|
|
url: String(h?.public_url || `https://www.thingiverse.com/thing:${h?.id ?? ''}`),
|
|
fileCount: 0,
|
|
})).filter((r: ModelResult) => r.id && r.id !== 'undefined');
|
|
}
|
|
|
|
export function mapPrintablesResults(data: any): ModelResult[] {
|
|
const items = Array.isArray(data?.items) ? data.items : [];
|
|
return items.map((p: any) => ({
|
|
site: 'printables' as const,
|
|
id: String(p?.id ?? ''),
|
|
name: String(p?.name || '(제목 없음)'),
|
|
author: String(p?.user?.publicUsername || ''),
|
|
likes: Number(p?.likesCount) || 0,
|
|
downloads: Number(p?.downloadCount) || 0,
|
|
url: `https://www.printables.com/model/${p?.id ?? ''}`,
|
|
fileCount: Number(p?.filesCount) || 0,
|
|
})).filter((r: ModelResult) => r.id && r.id !== 'undefined');
|
|
}
|
|
|
|
export function mapMakerWorldResults(data: any): ModelResult[] {
|
|
const hits = Array.isArray(data?.hits) ? data.hits : [];
|
|
return hits.map((h: any) => ({
|
|
site: 'makerworld' as const,
|
|
id: String(h?.id ?? ''),
|
|
name: String(h?.title || '(제목 없음)'),
|
|
author: String(h?.designCreator?.name || ''),
|
|
likes: Number(h?.likeCount) || 0,
|
|
downloads: Number(h?.downloadCount) || 0,
|
|
url: `https://makerworld.com/en/models/${h?.id ?? ''}`,
|
|
fileCount: 0,
|
|
})).filter((r: ModelResult) => r.id && r.id !== 'undefined');
|
|
}
|
|
|
|
export function formatModelResults(
|
|
results: ModelResult[],
|
|
skipped: Array<{ site: ModelSite; reason: string }>,
|
|
query: string
|
|
): string {
|
|
if (results.length === 0) {
|
|
const skipNote = skipped.length ? `\n스킵됨: ${skipped.map((s) => `${s.site}(${s.reason})`).join(', ')}` : '';
|
|
return `"${query}" 검색 결과가 없습니다.${skipNote}\n영문 검색어를 시도해 보세요(세 사이트 모두 영문 제목 매칭이 가장 잘 됩니다).`;
|
|
}
|
|
const lines: string[] = [`"${query}" 검색 결과 ${results.length}건:`];
|
|
for (let i = 0; i < results.length; i++) {
|
|
const r = results[i];
|
|
const stats: string[] = [];
|
|
if (r.likes) stats.push(`좋아요 ${r.likes.toLocaleString()}`);
|
|
if (r.downloads) stats.push(`다운로드 ${r.downloads.toLocaleString()}`);
|
|
if (r.fileCount) stats.push(`파일 ${r.fileCount}개`);
|
|
lines.push(`${i + 1}. [${r.site}] ${r.name} — ${r.author || '작성자 미상'}${stats.length ? ` (${stats.join(', ')})` : ''}\n id: ${r.id} · ${r.url}`);
|
|
}
|
|
if (skipped.length) {
|
|
lines.push(`\n스킵됨: ${skipped.map((s) => `${s.site}(${s.reason})`).join(', ')}`);
|
|
}
|
|
lines.push('\n다운로드하려면 model_download(site + model_id)를 호출하세요. 슬라이싱·출력까지 하려면 다운로드 후 print3d_model(prepare, stl_path=...)로 이어가면 됩니다.');
|
|
return lines.join('\n');
|
|
}
|
|
|
|
// 사이트별 다운로드 실패 진단 — 401/403은 토큰 만료/미설정 안내로 변환.
|
|
export function downloadErrorDetail(site: ModelSite, status: number, body: string): string {
|
|
const base = `${site} 다운로드 실패: HTTP ${status}`;
|
|
if (site === 'thingiverse' && (status === 401 || status === 403)) {
|
|
return `${base} — Thingiverse 토큰이 만료됐거나 유효하지 않습니다. print3d.thingiverse_token을 갱신하세요(발급: thingiverse.com/apps/create → App Token).`;
|
|
}
|
|
if (site === 'makerworld' && (status === 401 || status === 403 || status === 418)) {
|
|
return `${base} — Bambu Cloud 토큰이 만료됐거나 미설정입니다. MakerWorld 토큰(AAB_ 시작, 약 90일 유효)을 재발급해 print3d.makerworld_token에 넣으세요. 418은 캡차 챌린지입니다(시간이 지나 재시도).`;
|
|
}
|
|
if (site === 'printables' && (status === 401 || status === 403)) {
|
|
return `${base} — 로그인이 필요한 모델일 수 있습니다. Printables 세션 쿠키(sessionid=...)를 print3d.printables_cookie에 넣고 재시도하세요.`;
|
|
}
|
|
return `${base} ${body.slice(0, 200)}`;
|
|
}
|
|
|
|
// 파일 목록에서 슬라이싱 가능한(.stl/.3mf) 최적 파일 1개를 고른다 — 큰 것 우선.
|
|
export function pickSlicableFile<T extends { name: string; size: number; id?: any }>(files: T[]): T | null {
|
|
const ok = files.filter((f) => /\.(stl|3mf)$/i.test(String(f?.name || '')));
|
|
if (!ok.length) return null;
|
|
return ok.sort((a, b) => (Number(b.size) || 0) - (Number(a.size) || 0))[0];
|
|
}
|
|
|
|
// ─── 네트워크 ────────────────────────────────────────────────────────────────
|
|
|
|
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36';
|
|
|
|
async function jsonFetch(url: string, init: RequestInit = {}, timeoutMs = 20_000): Promise<{ status: number; json: any; text: string }> {
|
|
const res = await fetch(url, {
|
|
...init,
|
|
headers: { 'User-Agent': UA, ...(init.headers || {}) },
|
|
signal: AbortSignal.timeout(timeoutMs),
|
|
});
|
|
const text = await res.text();
|
|
let json: any = null;
|
|
try { json = JSON.parse(text); } catch {}
|
|
return { status: res.status, json, text };
|
|
}
|
|
|
|
// Thingiverse 검색 — 공식 REST. GET /search/{term}/ (Bearer 토큰 필수, 실측 확인)
|
|
async function searchThingiverse(token: string, query: string, limit: number): Promise<ModelResult[]> {
|
|
const { status, json } = await jsonFetch(
|
|
`https://api.thingiverse.com/search/${encodeURIComponent(query)}/?per_page=${limit}`,
|
|
{ headers: { Authorization: `Bearer ${token}` } }
|
|
);
|
|
if (status === 401 || status === 403) throw new Error(downloadErrorDetail('thingiverse', status, ''));
|
|
if (!status || status >= 400) throw new Error(downloadErrorDetail('thingiverse', status, ''));
|
|
return mapThingiverseResults(json);
|
|
}
|
|
|
|
// Printables 검색 — 내부 GraphQL(searchPrints2), 익명 동작(실측 확인).
|
|
async function searchPrintables(query: string, limit: number): Promise<ModelResult[]> {
|
|
const body = {
|
|
operationName: 'SearchModels',
|
|
query: `query SearchModels($query: String!, $limit: Int) { result: searchPrints2(query: $query, printType: print, limit: $limit, ordering: best_match) { items { id name slug likesCount downloadCount filesCount user { publicUsername } } } }`,
|
|
variables: { query, limit },
|
|
};
|
|
const { status, json, text } = await jsonFetch('https://api.printables.com/graphql/', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
if (status >= 400 || !json?.data?.result) throw new Error(`Printables 검색 실패: HTTP ${status} ${text.slice(0, 200)}`);
|
|
return mapPrintablesResults(json.data.result);
|
|
}
|
|
|
|
// MakerWorld 검색 — api.bambulab.com search-service, 익명 동작(실측 확인).
|
|
// makerworld.com 직접 접근은 Cloudflare에 막히므로 절대 직접 긁지 말 것.
|
|
async function searchMakerWorld(query: string, limit: number): Promise<ModelResult[]> {
|
|
const url = `https://api.bambulab.com/v1/search-service/select/design2?keyword=${encodeURIComponent(query)}&pageIndex=1&pageSize=${limit}&sort=DEFAULT`;
|
|
const { status, json } = await jsonFetch(url);
|
|
if (status >= 400 || !json?.hits) throw new Error(`MakerWorld 검색 실패: HTTP ${status}`);
|
|
return mapMakerWorldResults(json);
|
|
}
|
|
|
|
// ─── 다운로드 URL 해석 ───────────────────────────────────────────────────────
|
|
|
|
interface ResolvedDownload { url: string; headers: Record<string, string>; ext: string; name: string; totalFiles: number }
|
|
|
|
// Thingiverse: GET /things/{id}/files → download_url(/v2/files/{id}/download, Bearer 필요)
|
|
async function resolveThingiverseDownload(id: string, token: string): Promise<ResolvedDownload> {
|
|
const { status, json, text } = await jsonFetch(
|
|
`https://api.thingiverse.com/things/${id}/files`,
|
|
{ headers: { Authorization: `Bearer ${token}` } }
|
|
);
|
|
if (status >= 400) throw new Error(downloadErrorDetail('thingiverse', status, text));
|
|
const files = Array.isArray(json) ? json : [];
|
|
const pick = pickSlicableFile(files.map((f: any) => ({ id: f.id, name: String(f?.name || ''), size: Number(f?.size) || 0, download_url: String(f?.download_url || '') })));
|
|
if (!pick || !(pick as any).download_url) {
|
|
const exts = files.map((f: any) => path.extname(String(f?.name || '')).toLowerCase()).filter(Boolean).join(', ');
|
|
throw new Error(`Thingiverse thing ${id}에 슬라이싱 가능한 STL/3MF 파일이 없습니다${exts ? ` (있는 파일 형식: ${exts})` : ''} — 웹에서 직접 받아야 합니다.`);
|
|
}
|
|
const url = (pick as any).download_url.startsWith('http') ? (pick as any).download_url : `https://api.thingiverse.com${(pick as any).download_url}`;
|
|
return {
|
|
url,
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
ext: path.extname((pick as any).name).toLowerCase(),
|
|
name: String((pick as any).name),
|
|
totalFiles: files.length,
|
|
};
|
|
}
|
|
|
|
// Printables: GraphQL print(id) 파일목록 → getDownloadLink mutation(익명 동작, 실측 확인).
|
|
// 주의: stls 리스트에 .stp(Step) 파일도 섞여 있음 — 슬라이싱 가능한 stl/3mf만 고름.
|
|
async function resolvePrintablesDownload(id: string, cookie?: string): Promise<ResolvedDownload> {
|
|
const detailBody = {
|
|
operationName: 'PrintDetail',
|
|
query: `query PrintDetail($id: ID!) { print(id: $id) { id name stls { id name fileSize } gcodes { id name fileSize } } }`,
|
|
variables: { id },
|
|
};
|
|
const d = await jsonFetch('https://api.printables.com/graphql/', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(detailBody),
|
|
});
|
|
if (d.status >= 400 || !d.json?.data?.print) throw new Error(`Printables 모델 조회 실패: HTTP ${d.status} ${d.text.slice(0, 200)}`);
|
|
const files: any[] = [
|
|
...(d.json.data.print.stls || []),
|
|
...(d.json.data.print.gcodes || []),
|
|
].map((f: any) => ({ id: String(f.id), name: String(f.name || ''), size: Number(f.fileSize) || 0 }));
|
|
const pick = pickSlicableFile(files);
|
|
if (!pick) {
|
|
const exts = [...new Set(files.map((f: any) => path.extname(f.name).toLowerCase()).filter(Boolean))].join(', ');
|
|
throw new Error(`Printables 모델 ${id}에 슬라이싱 가능한 STL/3MF 파일이 없습니다${exts ? ` (있는 파일 형식: ${exts})` : ''} — 웹에서 직접 받아야 합니다.`);
|
|
}
|
|
const fileType = /\.3mf$/i.test(pick.name) ? '3mf' : 'stl';
|
|
const dlBody = {
|
|
operationName: 'GetDownloadLink',
|
|
query: `mutation GetDownloadLink($id: ID!, $modelId: ID!, $fileType: DownloadFileTypeEnum!, $source: DownloadSourceEnum!) { getDownloadLink(id: $id, printId: $modelId, fileType: $fileType, source: $source) { ok errors { field } output { link } } }`,
|
|
variables: { id: pick.id, modelId: id, fileType, source: 'model_detail' },
|
|
};
|
|
const dl = await jsonFetch('https://api.printables.com/graphql/', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(dlBody),
|
|
});
|
|
const link = dl.json?.data?.getDownloadLink?.output?.link;
|
|
if (!link) throw new Error(downloadErrorDetail('printables', dl.status, dl.text.slice(0, 200)));
|
|
return { url: link, headers: cookie ? { Cookie: cookie } : {}, ext: path.extname(pick.name).toLowerCase(), name: pick.name, totalFiles: files.length };
|
|
}
|
|
|
|
// MakerWorld: 디자인 상세(익명) → 인스턴스 목록 → Bambu Cloud 토큰으로 presigned URL 발급.
|
|
// (YASTL #51 / Bambuddy 문서의 역공학 플로우 — 토큰 없으면 1단계까지만 가능)
|
|
async function resolveMakerWorldDownload(id: string, token: string): Promise<ResolvedDownload> {
|
|
const detail = await jsonFetch(`https://api.bambulab.com/v1/design-service/design/${id}`);
|
|
if (detail.status >= 400) throw new Error(`MakerWorld 모델 조회 실패: HTTP ${detail.status}`);
|
|
const modelId = detail.json?.design?.modelId || detail.json?.modelId || detail.json?.data?.modelId;
|
|
const inst = await jsonFetch(`https://api.bambulab.com/v1/design-service/design/${id}/instances`);
|
|
const hits = Array.isArray(inst.json?.hits) ? inst.json.hits : (Array.isArray(inst.json?.data?.hits) ? inst.json.data.hits : []);
|
|
// 주의: hits[].id는 "인스턴스" id(647181식)라 iot-service에 안 통한다 — profileId(141248202식)가 정답(2026-09-23 실측)
|
|
const profileId = hits[0]?.profileId || hits[0]?.id || hits[0]?._id;
|
|
if (!modelId || !profileId) throw new Error(`MakerWorld 모델 ${id}의 파일 인스턴스를 찾지 못했습니다 (API 응답 구조가 변경된 것 같습니다) — 웹에서 직접 받아야 합니다.`);
|
|
const prof = await jsonFetch(
|
|
`https://api.bambulab.com/v1/iot-service/api/user/profile/${profileId}?model_id=${encodeURIComponent(String(modelId))}`,
|
|
{ headers: { Authorization: `Bearer ${token}` } }
|
|
);
|
|
if (prof.status >= 400) throw new Error(downloadErrorDetail('makerworld', prof.status, prof.text.slice(0, 200)));
|
|
// presigned URL 필드명이 버전마다 달라서(url/download_url/file_url/s3_url) 유연하게 찾는다.
|
|
const findUrl = (o: any, depth = 0): string | null => {
|
|
if (!o || typeof o !== 'object' || depth > 3) return null;
|
|
for (const k of ['url', 'download_url', 'downloadUrl', 'file_url', 'fileUrl', 's3_url']) {
|
|
if (typeof o[k] === 'string' && o[k].startsWith('http')) return o[k];
|
|
}
|
|
for (const v of Object.values(o)) {
|
|
const found = findUrl(v, depth + 1);
|
|
if (found) return found;
|
|
}
|
|
return null;
|
|
};
|
|
const url = findUrl(prof.json);
|
|
if (!url) throw new Error(`MakerWorld presigned URL을 응답에서 찾지 못했습니다 — 토큰이 만료됐거나 권한이 없을 수 있습니다. ${downloadErrorDetail('makerworld', prof.status, '')}`);
|
|
const title = String(detail.json?.design?.title || detail.json?.title || '').trim();
|
|
return { url, headers: {}, ext: '.3mf', name: title || `makerworld_${id}`, totalFiles: hits.length };
|
|
}
|
|
|
|
// ─── 인증 다운로드 (print3d.ts downloadStl의 스니프/캡 로직 재사용 형태) ─────
|
|
|
|
async function authedDownload(url: string, headers: Record<string, string>, outPath: string): Promise<{ ok: true; size: number } | { ok: false; detail: string }> {
|
|
let res: Response;
|
|
try {
|
|
res = await fetch(url, { headers, redirect: 'follow', signal: AbortSignal.timeout(60_000) });
|
|
} catch (e: any) {
|
|
return { ok: false, detail: `다운로드 실패: ${String(e?.message || e)}` };
|
|
}
|
|
if (!res.ok || !res.body) return { ok: false, detail: `다운로드 실패: HTTP ${res.status}` };
|
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
if (buf.length < 100) return { ok: false, detail: '파일이 너무 작습니다 — 모델 파일이 아닌 것 같습니다.' };
|
|
if (buf.length > 100 * 1024 * 1024) return { ok: false, detail: '파일이 100MB를 넘어 너무 큽니다.' };
|
|
const head = buf.subarray(0, 200).toString('utf8').toLowerCase();
|
|
if (head.includes('<html') || head.includes('<!doctype')) {
|
|
return { ok: false, detail: '내려받은 내용이 모델 파일이 아니라 HTML입니다 (로그인/인증이 필요한 다운로드일 수 있습니다).' };
|
|
}
|
|
fs.writeFileSync(outPath, buf);
|
|
return { ok: true, size: buf.length };
|
|
}
|
|
|
|
// ─── 도구 ────────────────────────────────────────────────────────────────────
|
|
|
|
export const modelSearchTool = {
|
|
name: 'model_search',
|
|
description: '3D 모델 저장소(Thingiverse/Printables/MakerWorld)를 통합 검색한다. "608ZZ 베어링 마운트 STL 찾아줘", "메카넘휠 모델 검색" 같은 요청에 사용. 영문 검색어가 가장 잘 맞는다(한글 제목은 거의 없음). 결과에서 마음에 드는 모델의 id를 model_download(site + model_id)에 넣어 워크스페이스로 받는다. Thingiverse는 토큰(print3d.thingiverse_token)이 없으면 스킵되고, MakerWorld 다운로드는 별도 토큰이 필요하지만 검색은 토큰 없이도 된다.',
|
|
schema: { query: 'string', site: 'string', limit: 'number' },
|
|
jsonSchema: {
|
|
type: 'object',
|
|
required: ['query'],
|
|
properties: {
|
|
query: { type: 'string', description: '(필수) 검색어. 영문 권장 — 세 사이트 모두 영문 제목 매칭이 가장 잘 됨(예: "mecanum wheel", "608 bearing mount")' },
|
|
site: { type: 'string', enum: ['all', 'printables', 'makerworld', 'thingiverse'], description: '(선택) 검색할 사이트. 기본 all — 설정된 사이트 전부에서 검색해 합친다. 특정 사이트를 지목당하거나 다른 사이트에서 적당한 게 없을 때만 좁힐 것.' },
|
|
limit: { type: 'number', description: '(선택) 사이트당 최대 결과 수. 기본 5' },
|
|
},
|
|
additionalProperties: false,
|
|
},
|
|
execute: async (args: any): Promise<ToolResult> => {
|
|
const query = String(args?.query || '').trim();
|
|
if (!query) return { success: false, error: 'query가 필요합니다.' };
|
|
const limit = Math.min(10, Math.max(1, Number(args?.limit) || 5));
|
|
const keys = {
|
|
printables: getSiteKey('printables'),
|
|
makerworld: getSiteKey('makerworld'),
|
|
thingiverse: getSiteKey('thingiverse'),
|
|
};
|
|
const plan = resolveSitePlan(args, keys);
|
|
if (plan.attempt.length === 0) {
|
|
return { success: false, error: `검색 가능한 사이트가 없습니다. ${plan.skipped.map((s) => `${s.site}: ${s.reason}`).join('; ')}` };
|
|
}
|
|
const results: ModelResult[] = [];
|
|
for (const site of plan.attempt) {
|
|
try {
|
|
const r = site === 'thingiverse'
|
|
? await searchThingiverse(keys.thingiverse!, query, limit)
|
|
: site === 'printables'
|
|
? await searchPrintables(query, limit)
|
|
: await searchMakerWorld(query, limit);
|
|
results.push(...r);
|
|
if (site === 'makerworld') console.log(`[model-search] ${site}: ${r.length}건 (키: 검색은 무관)`);
|
|
} catch (e: any) {
|
|
console.error(`[model-search] ${site} 실패:`, e?.message || e);
|
|
plan.skipped.push({ site, reason: String(e?.message || e).slice(0, 120) });
|
|
}
|
|
}
|
|
return { success: results.length > 0 || plan.skipped.length < plan.attempt.length, stdout: formatModelResults(results, plan.skipped, query) };
|
|
},
|
|
};
|
|
|
|
export const modelDownloadTool = {
|
|
name: 'model_download',
|
|
description: 'model_search 결과에서 골라낸 3D 모델(Printables/MakerWorld/Thingiverse)을 워크스페이스로 내려받는다. 슬라이싱 가능한 STL/3MF 중 가장 큰 파일 1개를 저장하고 나머지 파일은 웹에서 직접 받으라고 안내한다. 받은 파일은 print3d_model(action="prepare", stl_path=...)로 바로 슬라이싱할 수 있다. Thingiverse/MakerWorld는 각각 토큰(print3d.thingiverse_token / print3d.makerworld_token)이 필요하고, Printables는 토큰 없이도 대부분 받을 수 있다.',
|
|
schema: { site: 'string', model_id: 'string', project_name: 'string', filename: 'string' },
|
|
jsonSchema: {
|
|
type: 'object',
|
|
required: ['site', 'model_id'],
|
|
properties: {
|
|
site: { type: 'string', enum: ['printables', 'makerworld', 'thingiverse'], description: '(필수) 어느 사이트의 모델인지 — model_search 결과의 [site] 태그 값' },
|
|
model_id: { type: 'string', description: '(필수) model_search 결과의 id. 모델 페이지 URL을 그대로 붙여넣어도 해석됨(예: printables.com/model/1138497, thingiverse.com/thing:2473)' },
|
|
project_name: { type: 'string', description: '(선택) 작업실 프로젝트명(일부만 일치해도 됨) — 주면 workshop/<프로젝트id>/CAD/다운로드/에 저장(생략하면 print3d/모델검색/)' },
|
|
filename: { type: 'string', description: '(선택) 저장 파일명. 기본은 <site>_<id>_<모델명>.<stl|3mf>' },
|
|
},
|
|
additionalProperties: false,
|
|
},
|
|
execute: async (args: any): Promise<ToolResult> => {
|
|
const site = String(args?.site || '').trim().toLowerCase() as ModelSite;
|
|
if (!ALL_SITES.includes(site)) return { success: false, error: `site는 printables / makerworld / thingiverse 중 하나여야 합니다.` };
|
|
const ref = parseModelRef(site, String(args?.model_id || ''));
|
|
if ('error' in ref) return { success: false, error: ref.error };
|
|
const id = ref.id;
|
|
|
|
const key = getSiteKey(site);
|
|
if (site === 'thingiverse' && !key) {
|
|
return { success: false, error: 'Thingiverse 다운로드에는 토큰이 필요합니다(print3d.thingiverse_token). 발급: thingiverse.com/apps/create → App Token.' };
|
|
}
|
|
if (site === 'makerworld' && !key) {
|
|
return { success: false, error: 'MakerWorld 다운로드에는 Bambu Cloud 토큰이 필요합니다(print3d.makerworld_token, AAB_ 시작, 약 90일 유효). 검색만은 model_search로 토큰 없이 가능합니다.' };
|
|
}
|
|
|
|
const workspacePath = getWorkspacePath(args);
|
|
// 저장 위치(workshop-layout.ts): 프로젝트가 있으면 workshop/<id>/CAD/다운로드/, 없으면 print3d/모델검색/
|
|
const projectNameArg = String(args?.project_name || '').trim();
|
|
let outDir = saveDirs(workspacePath).downloadDir;
|
|
if (projectNameArg) {
|
|
const found = findWorkshopProjectName(workspacePath, projectNameArg);
|
|
if (!found.ok) return { success: false, error: found.error };
|
|
outDir = saveDirs(workspacePath, found.id).downloadDir;
|
|
if (!isPathInsideDir(workspacePath, outDir)) return { success: false, error: '프로젝트 폴더 경로가 워크스페이스 밖입니다.' };
|
|
}
|
|
fs.mkdirSync(outDir, { recursive: true });
|
|
|
|
let dl: ResolvedDownload;
|
|
try {
|
|
dl = site === 'thingiverse' ? await resolveThingiverseDownload(id, key!)
|
|
: site === 'makerworld' ? await resolveMakerWorldDownload(id, key!)
|
|
: await resolvePrintablesDownload(id, key);
|
|
} catch (e: any) {
|
|
return { success: false, error: String(e?.message || e) };
|
|
}
|
|
|
|
const customName = String(args?.filename || '').trim().replace(/[\/\\\0]/g, '_').trim();
|
|
const baseName = customName || `${site}_${id}_${dl.name.replace(/\.[^.]+$/, '').replace(/[^\w가-힣.-]+/g, '_').slice(0, 80)}`;
|
|
const outPath = path.join(outDir, `${baseName}${dl.ext || '.stl'}`);
|
|
if (!isPathInsideDir(workspacePath, outPath)) return { success: false, error: '저장 경로가 워크스페이스 밖입니다.' };
|
|
|
|
const result = await authedDownload(dl.url, dl.headers, outPath);
|
|
if (!result.ok) return { success: false, error: result.detail };
|
|
console.log(`[model-download] ${site}:${id} → ${outPath} (${result.size} bytes)`);
|
|
|
|
const relPath = path.relative(workspacePath, outPath);
|
|
const multiNote = dl.totalFiles > 1 ? `\n- 이 모델은 파일 ${dl.totalFiles}개 중 슬라이싱 가능한 가장 큰 STL/3MF 1개만 받았습니다. 나머지 부품이 필요하면 모델 페이지(${site === 'printables' ? `https://www.printables.com/model/${id}` : site === 'makerworld' ? `https://makerworld.com/en/models/${id}` : `https://www.thingiverse.com/thing:${id}`})에서 직접 받아 워크스페이스에 넣어주세요.` : '';
|
|
return {
|
|
success: true,
|
|
stdout: `다운로드 완료: **${relPath}** (${(result.size / 1024 / 1024).toFixed(1)}MB, ${dl.name})${multiNote}\n\n바로 슬라이싱하려면 print3d_model(action="prepare", stl_path="${relPath}")을 호출하세요.`,
|
|
};
|
|
},
|
|
}; |