From 5a571f592e2f9f8bebd17ffc942c158f70fa1831 Mon Sep 17 00:00:00 2001 From: kim Date: Wed, 23 Sep 2026 11:43:03 +0900 Subject: [PATCH] =?UTF-8?q?feat:=203D=20=EB=AA=A8=EB=8D=B8=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=EC=86=8C=20=EC=97=B0=EB=8F=99=20=E2=80=94=20model=5Fs?= =?UTF-8?q?earch/model=5Fdownload=20(Printables=C2=B7MakerWorld=C2=B7Thing?= =?UTF-8?q?iverse)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - model_search: 3사이트 통합 검색(Thingiverse는 토큰 필요, Printables/MakerWorld는 익명 검색 실측 확인) - model_download: 슬라이싱 가능한 STL/3MF 중 최대 파일 1개 워크스페이스 저장(Printables getDownloadLink 익명 동작, Thingiverse /v2/files download, MakerWorld는 Bambu 토큰 필요) - print3d_model에 stl_path 추가 — model_download로 받아둔 파일 바로 슬라이싱 - 토큰은 vault secret(print3d.*_token/cookie), Thingiverse App Token 등록 완료 - 게이트: ws_ 세션 항상 + 메인 채팅은 3D 키워드 Co-Authored-By: Claude Code --- src/config/config.ts | 4 + src/gateway/chat/build-tools.ts | 36 ++- src/gateway/chat/tool-scope.ts | 4 + src/tools/model-search.ts | 488 ++++++++++++++++++++++++++++++++ src/tools/print3d.ts | 76 +++-- src/tools/registry.ts | 4 + tests/model-search.test.ts | 117 ++++++++ 7 files changed, 703 insertions(+), 26 deletions(-) create mode 100644 src/tools/model-search.ts create mode 100644 tests/model-search.test.ts diff --git a/src/config/config.ts b/src/config/config.ts index eafde11..459388c 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -336,6 +336,10 @@ const SECRET_FIELD_MAP: Array<[string[], string]> = [ [['legal', 'courtlistener_api_key'], 'legal.courtlistener_api_key'], [['writer', 'providers', 'google_books', 'apiKey'], 'writer.providers.google_books.apiKey'], [['writer', 'providers', 'aladin', 'apiKey'], 'writer.providers.aladin.apiKey'], + // 3D 모델 저장소 (model-search.ts, 2026-09-23) + [['print3d', 'printables_cookie'], 'print3d.printables_cookie'], + [['print3d', 'makerworld_token'], 'print3d.makerworld_token'], + [['print3d', 'thingiverse_token'], 'print3d.thingiverse_token'], ]; function deepGet(obj: any, keys: string[]): string | undefined { diff --git a/src/gateway/chat/build-tools.ts b/src/gateway/chat/build-tools.ts index 541e8b6..73534fc 100644 --- a/src/gateway/chat/build-tools.ts +++ b/src/gateway/chat/build-tools.ts @@ -1106,12 +1106,13 @@ export function createBuildTools(isOrchestrationSkillEnabled: () => boolean) { type: 'function', function: { name: 'print3d_model', - description: 'STL 3D모델 URL을 받아 K2 콤보 프린터용으로 슬라이싱하고 프린터(Moonraker)에 업로드한다(action="prepare"), 이후 사용자가 확인하면 실제 출력을 시작한다(action="start", confirm=true 필수). STL 자체를 찾는 건 web_search로 먼저 하고, 확보한 직접 다운로드 URL을 여기 넘길 것. prepare는 예상 출력시간/필라멘트 사용량을 돌려주니 사용자에게 보여주고 "출력 시작할까요?" 확인부터 받은 뒤에만 start를 호출할 것 — 재료/시간이 드는 물리적 동작이라 사용자 승인 없이 절대 start 호출 금지.', + description: 'STL 3D모델 URL(stl_url) 또는 워크스페이스 파일(stl_path)을 받아 K2 콤보 프린터용으로 슬라이싱하고 프린터(Moonraker)에 업로드한다(action="prepare"), 이후 사용자가 확인하면 실제 출력을 시작한다(action="start", confirm=true 필수). STL 자체는 model_search(작업실 세션) 또는 web_search로 먼저 찾고, 확보한 직접 다운로드 URL을 stl_url로 넘기거나 model_download로 받아둔 파일을 stl_path로 넘길 것. prepare는 예상 출력시간/필라멘트 사용량을 돌려주니 사용자에게 보여주고 "출력 시작할까요?" 확인부터 받은 뒤에만 start를 호출할 것 — 재료/시간이 드는 물리적 동작이라 사용자 승인 없이 절대 start 호출 금지.', parameters: { type: 'object', required: ['action'], properties: { action: { type: 'string', enum: ['prepare', 'start'], description: 'prepare: STL 다운로드+슬라이싱+프린터 업로드(출력은 시작 안 함). start: 실제 출력 시작(반드시 confirm=true, 사용자가 명시적으로 승인한 뒤에만 호출)' }, - stl_url: { type: 'string', description: '(prepare 필수) STL 파일 직접 다운로드 URL(http/https). Thingiverse는 인증 없이 다운로드가 막혀있어 안 됨 — Printables, GitHub raw 등 직접 링크를 쓸 것.' }, + stl_url: { type: 'string', description: '(prepare, stl_path 대신) STL 또는 3MF 파일 직접 다운로드 URL(http/https). URL이 .3mf로 끝나면 3MF로 처리(PrusaSlicer가 네이티브 지원). Thingiverse는 인증 없이 다운로드가 막혀있어 안 됨 — model_search로 찾아 model_download로 받거나, Printables/GitHub raw 등 직접 링크를 쓸 것.' }, + stl_path: { type: 'string', description: '(prepare, stl_url 대신) 워크스페이스 기준 상대경로의 STL/3MF 파일(예: "print3d/model-search/printables_1138497_mecanum.stl"). model_download로 받아둔 파일을 슬라이싱할 때 이걸 쓴다. stl_url과 동시에 주면 stl_path 우선.' }, layer_height: { type: 'number', description: '(prepare, 선택) 레이어 높이 mm. 기본 0.2' }, infill_percent: { type: 'number', description: '(prepare, 선택) 인필 밀도 %. 기본 15' }, wall_count: { type: 'number', description: '(prepare, 선택) 벽 겹수. 기본 2' }, @@ -1122,6 +1123,37 @@ export function createBuildTools(isOrchestrationSkillEnabled: () => boolean) { }, }, }, + { + type: 'function', + function: { + name: 'model_search', + description: '3D 모델 저장소(Thingiverse/Printables/MakerWorld)를 통합 검색한다. "608ZZ 베어링 마운트 STL 찾아줘", "메카넘휠 모델 검색" 같은 요청에 사용. 영문 검색어가 가장 잘 맞는다(한글 제목은 거의 없음). 결과에서 마음에 드는 모델의 id를 model_download(site + model_id)에 넣어 워크스페이스로 받는다. Thingiverse는 토큰(print3d.thingiverse_token)이 없으면 스킵되고, MakerWorld 다운로드는 별도 토큰이 필요하지만 검색은 토큰 없이도 된다.', + parameters: { + 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' }, + }, + }, + }, + }, + { + type: 'function', + function: { + 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는 토큰 없이도 대부분 받을 수 있다.', + parameters: { + 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: '(선택) 작업실 프로젝트명(일부만 일치해도 됨) — 주면 print3d/<프로젝트명>/에 저장' }, + filename: { type: 'string', description: '(선택) 저장 파일명. 기본은 __<모델명>.' }, + }, + }, + }, + }, { type: 'function', function: { diff --git a/src/gateway/chat/tool-scope.ts b/src/gateway/chat/tool-scope.ts index e66900c..03aaca2 100644 --- a/src/gateway/chat/tool-scope.ts +++ b/src/gateway/chat/tool-scope.ts @@ -388,6 +388,10 @@ export function buildSkillToolFilter(input: ToolScopeInput): (toolName: string) if ((name === 'eonet_events' || name === 'satellite_snapshot') && !hasSatelliteKeyword) return false; if (name === 'nvr_snapshot' && !hasCctvKeyword) return false; if (name === 'print3d_model' && !hasPrint3dKeyword) return false; + // model_search/model_download — 09-23: 3D 모델 저장소(Printables/MakerWorld/Thingiverse) + // 검색·다운로드. 작업실 세션(ws_)에선 항상 노출, 메인 채팅에선 print3d_model과 같은 + // 3D 키워드에서만 열어둔다(stl 클러스터의 "작업실 전용"과 print3d_model의 키워드 게이트 중간). + if ((name === 'model_search' || name === 'model_download') && !isWorkshopAppSession && !hasPrint3dKeyword) return false; // 09-22: 사용자 요청 "작업실 전용으로 하자" — 이 셋 다 워크숍 세션(ws_)에서만 노출. // 원래 stl_cad는 키워드만 맞으면 메인 채팅/다른 앱에서도 쓰였는데(k2-app.html 웹 UI // 패널과는 별개 경로라 안 건드려도 됨 — routes-cad.ts 직접 호출, 이 게이트 무관), diff --git a/src/tools/model-search.ts b/src/tools/model-search.ts new file mode 100644 index 0000000..b5e1659 --- /dev/null +++ b/src/tools/model-search.ts @@ -0,0 +1,488 @@ +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, sanitizeFolderName } from './print3d.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(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 { + 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 { + 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 { + 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; 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 { + 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 { + 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 { + 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 : []); + const profileId = hits[0]?.id || hits[0]?.profileId || 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, '')}`); + return { url, headers: {}, ext: '.3mf', name: `makerworld_${id}.3mf`, totalFiles: hits.length }; +} + +// ─── 인증 다운로드 (print3d.ts downloadStl의 스니프/캡 로직 재사용 형태) ───── + +async function authedDownload(url: string, headers: Record, 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(' => { + 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: '(선택) 작업실 프로젝트명(일부만 일치해도 됨) — 주면 print3d/<프로젝트명>/에 저장' }, + filename: { type: 'string', description: '(선택) 저장 파일명. 기본은 __<모델명>.' }, + }, + additionalProperties: false, + }, + execute: async (args: any): Promise => { + 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); + const baseDir = path.join(workspacePath, 'print3d'); + const projectNameArg = String(args?.project_name || '').trim(); + let outDir = path.join(baseDir, 'model-search'); + if (projectNameArg) { + const found = findWorkshopProjectName(workspacePath, projectNameArg); + if (!found.ok) return { success: false, error: found.error }; + outDir = path.join(baseDir, sanitizeFolderName(found.name)); + if (!isPathInsideDir(baseDir, outDir)) return { success: false, error: '프로젝트 이름으로 만든 경로가 print3d 디렉토리 밖입니다.' }; + } + 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}")을 호출하세요.`, + }; + }, +}; \ No newline at end of file diff --git a/src/tools/print3d.ts b/src/tools/print3d.ts index 5ec9de2..ef57b93 100644 --- a/src/tools/print3d.ts +++ b/src/tools/print3d.ts @@ -12,7 +12,8 @@ import { // "작업실"(workshop_project) 프로젝트에 묶어서 받은 STL이면 print3d/<프로젝트명>/ 하위폴더에 // 저장한다(2026-09-20) — project_name 없이 부르면 예전처럼 print3d/ 바로 아래(공용). 프로젝트 // 이름 매칭은 workshop-project.ts의 findProjectIndex와 동일한 부분일치 규칙을 그대로 따른다. -function findWorkshopProjectName(workspacePath: string, query: string): { ok: true; name: string } | { ok: false; error: string } { +// 09-23: model-search.ts(model_download)에서도 재사용하므로 export. +export function findWorkshopProjectName(workspacePath: string, query: string): { ok: true; name: string } | { ok: false; error: string } { const data = loadWorkshop(workspacePath); const q = query.trim().toLowerCase(); const matches = data.projects.filter((pr) => pr.name.toLowerCase().includes(q)); @@ -27,7 +28,8 @@ function findWorkshopProjectName(workspacePath: string, query: string): { ok: tr } // 프로젝트 이름을 폴더 세그먼트로 — 슬래시/역슬래시/NUL만 막고 나머지(한글 포함)는 그대로 둔다. -function sanitizeFolderName(name: string): string { +// 09-23: model-search.ts에서도 재사용하므로 export. +export function sanitizeFolderName(name: string): string { const cleaned = name.replace(/[\/\\\0]/g, '_').trim().replace(/^\.+/, ''); return cleaned || 'project'; } @@ -65,14 +67,15 @@ async function downloadStl(url: string, outPath: string): Promise<{ ok: true } | export const print3dModelTool = { name: 'print3d_model', - description: 'STL 3D모델 URL을 받아 K2 콤보 프린터용으로 슬라이싱하고 프린터(Moonraker)에 업로드한다(action="prepare"), 이후 사용자가 확인하면 실제 출력을 시작한다(action="start", confirm=true 필수). STL 자체를 찾는 건 web_search로 먼저 하고, 확보한 직접 다운로드 URL을 여기 넘길 것. prepare는 예상 출력시간/필라멘트 사용량을 돌려주니 사용자에게 보여주고 "출력 시작할까요?" 확인부터 받은 뒤에만 start를 호출할 것 — 재료/시간이 드는 물리적 동작이라 사용자 승인 없이 절대 start 호출 금지. 이 부품이 "작업실" 대시보드의 특정 프로젝트(로봇, 3D프린팅 등) 것이면 project_name을 같이 넘겨서 그 프로젝트 하위폴더에 정리할 것.', - schema: { action: 'string', stl_url: 'string' }, + description: 'STL 3D모델 URL(stl_url) 또는 워크스페이스 파일(stl_path)을 받아 K2 콤보 프린터용으로 슬라이싱하고 프린터(Moonraker)에 업로드한다(action="prepare"), 이후 사용자가 확인하면 실제 출력을 시작한다(action="start", confirm=true 필수). STL 자체는 model_search(작업실 세션) 또는 web_search로 먼저 찾고, 확보한 직접 다운로드 URL을 stl_url로 넘기거나 model_download로 받아둔 파일을 stl_path로 넘길 것. prepare는 예상 출력시간/필라멘트 사용량을 돌려주니 사용자에게 보여주고 "출력 시작할까요?" 확인부터 받은 뒤에만 start를 호출할 것 — 재료/시간이 드는 물리적 동작이라 사용자 승인 없이 절대 start 호출 금지. 이 부품이 "작업실" 대시보드의 특정 프로젝트(로봇, 3D프린팅 등) 것이면 project_name을 같이 넘겨서 그 프로젝트 하위폴더에 정리할 것.', + schema: { action: 'string', stl_url: 'string', stl_path: 'string' }, jsonSchema: { type: 'object', required: ['action'], properties: { action: { type: 'string', enum: ['prepare', 'start'], description: 'prepare: STL 다운로드+슬라이싱+프린터 업로드(출력은 시작 안 함). start: 실제 출력 시작(반드시 confirm=true, 사용자가 명시적으로 승인한 뒤에만 호출)' }, - stl_url: { type: 'string', description: '(prepare 필수) STL 또는 3MF 파일 직접 다운로드 URL(http/https). URL이 .3mf로 끝나면 3MF로 처리(PrusaSlicer가 네이티브 지원). Thingiverse는 인증 없이 다운로드가 막혀있어 안 됨 — Printables, GitHub raw 등 직접 링크를 쓸 것.' }, + stl_url: { type: 'string', description: '(prepare, stl_path 대신) STL 또는 3MF 파일 직접 다운로드 URL(http/https). URL이 .3mf로 끝나면 3MF로 처리(PrusaSlicer가 네이티브 지원). Thingiverse는 인증 없이 다운로드가 막혀있어 안 됨 — model_search로 찾아 model_download로 받거나, Printables/GitHub raw 등 직접 링크를 쓸 것.' }, + stl_path: { type: 'string', description: '(prepare, stl_url 대신) 워크스페이스 기준 상대경로의 STL/3MF 파일(예: "print3d/model-search/printables_1138497_mecanum.stl"). model_download로 받아둔 파일을 슬라이싱할 때 이걸 쓴다. stl_url과 동시에 주면 stl_path 우선.' }, project_name: { type: 'string', description: '(prepare, 선택) 이 STL이 "작업실" 대시보드의 어느 프로젝트 부품인지(일부만 일치해도 됨, 예: "로봇"). 주면 print3d/<프로젝트명>/ 하위폴더에 저장해 그 프로젝트 작업물끼리 묶는다. 생략하면 기존처럼 공용 print3d/ 폴더(프로젝트와 무관한 일회성 출력)에 저장 — 작업실 프로젝트와 상관없는 요청이면 비워둘 것.' }, layer_height: { type: 'number', description: '(prepare, 선택) 레이어 높이 mm. 기본 0.2' }, infill_percent: { type: 'number', description: '(prepare, 선택) 인필 밀도 %. 기본 15' }, @@ -117,7 +120,10 @@ export const print3dModelTool = { } const stlUrl = String(args?.stl_url || '').trim(); - if (!stlUrl) return { success: false, error: 'stl_url이 필요합니다.' }; + // 09-23: model_download로 워크스페이스에 받아둔 파일을 바로 슬라이싱하는 경로 — + // stl_path가 있으면 stl_url 없이 진행(다운로드 단계를 건너뛰고 그 파일을 그대로 사용). + const stlPathArg = String(args?.stl_path || '').trim(); + if (!stlUrl && !stlPathArg) return { success: false, error: 'stl_url 또는 stl_path 중 하나는 필요합니다.' }; if (!fs.existsSync(PRUSA_SLICER_BIN)) { return { success: false, error: `PrusaSlicer가 설치돼있지 않습니다(${PRUSA_SLICER_BIN}). "sudo apt-get install -y prusa-slicer" 필요.` }; @@ -133,26 +139,48 @@ export const print3dModelTool = { const workspacePath = getWorkspacePath(args); const baseDir = path.join(workspacePath, 'print3d'); - const projectNameArg = String(args?.project_name || '').trim(); - let outDir = baseDir; - let projectFolderName = ''; - if (projectNameArg) { - const found = findWorkshopProjectName(workspacePath, projectNameArg); - if (!found.ok) return { success: false, error: found.error }; - projectFolderName = found.name; - outDir = path.join(baseDir, sanitizeFolderName(projectFolderName)); - if (!isPathInsideDir(baseDir, outDir)) return { success: false, error: '프로젝트 이름으로 만든 경로가 print3d 디렉토리 밖입니다.' }; - } - if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true }); const stamp = Date.now(); - // 2026-09-21: 3MF 지원 — URL 확장자가 .3mf면 그대로 3mf로 저장(PrusaSlicer CLI가 네이티브 - // 지원, 확장자로 자동판별). 그 외엔 기존처럼 .stl로 저장(URL에 확장자 없어도 안전한 기본값). - const urlExt = (() => { try { return path.extname(new URL(stlUrl).pathname).toLowerCase(); } catch { return ''; } })(); - const modelExt = urlExt === '.3mf' ? '.3mf' : '.stl'; - const stlPath = path.join(outDir, `model_${stamp}${modelExt}`); + const projectNameArg = String(args?.project_name || '').trim(); + let projectFolderName = ''; + let outDir = baseDir; + let stlPath: string; + if (stlPathArg) { + const resolved = path.resolve(workspacePath, stlPathArg); + if (!isPathInsideDir(workspacePath, resolved)) return { success: false, error: 'stl_path가 워크스페이스 밖을 가리킵니다.' }; + if (!fs.existsSync(resolved)) return { success: false, error: `stl_path 파일이 없습니다: ${stlPathArg}` }; + const ext = path.extname(resolved).toLowerCase(); + if (ext !== '.stl' && ext !== '.3mf') return { success: false, error: `stl_path는 .stl 또는 .3mf 파일이어야 합니다(지금: ${ext}).` }; + stlPath = resolved; + // gcode는 모델 파일 옆에 두는 게 자연스럽다 — stl_path 상위폴더에 저장. + // (project_name을 명시 줬다면 그쪽이 우선 — 모델 파일이 프로젝트 폴더 밖에 있을 수 있음) + if (projectNameArg) { + const found = findWorkshopProjectName(workspacePath, projectNameArg); + if (!found.ok) return { success: false, error: found.error }; + projectFolderName = found.name; + outDir = path.join(baseDir, sanitizeFolderName(projectFolderName)); + if (!isPathInsideDir(baseDir, outDir)) return { success: false, error: '프로젝트 이름으로 만든 경로가 print3d 디렉토리 밖입니다.' }; + } else { + outDir = path.dirname(resolved); + } + if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true }); + } else { + if (projectNameArg) { + const found = findWorkshopProjectName(workspacePath, projectNameArg); + if (!found.ok) return { success: false, error: found.error }; + projectFolderName = found.name; + outDir = path.join(baseDir, sanitizeFolderName(projectFolderName)); + if (!isPathInsideDir(baseDir, outDir)) return { success: false, error: '프로젝트 이름으로 만든 경로가 print3d 디렉토리 밖입니다.' }; + } + if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true }); + // 2026-09-21: 3MF 지원 — URL 확장자가 .3mf면 그대로 3mf로 저장(PrusaSlicer CLI가 네이티브 + // 지원, 확장자로 자동판별). 그 외엔 기존처럼 .stl로 저장(URL에 확장자 없어도 안전한 기본값). + const urlExt = (() => { try { return path.extname(new URL(stlUrl).pathname).toLowerCase(); } catch { return ''; } })(); + const modelExt = urlExt === '.3mf' ? '.3mf' : '.stl'; + stlPath = path.join(outDir, `model_${stamp}${modelExt}`); - const dl = await downloadStl(stlUrl, stlPath); - if (!dl.ok) return { success: false, error: dl.detail }; + const dl = await downloadStl(stlUrl, stlPath); + if (!dl.ok) return { success: false, error: dl.detail }; + } const gcodeFilename = `print3d_${stamp}.gcode`; const gcodePath = path.join(outDir, gcodeFilename); diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 00cc153..de0e21b 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -32,6 +32,7 @@ import { cmsHospitalCompareTool } from './cms.js'; import { drugDurCheckTool } from './mfds.js'; import { nvrSnapshotTool } from './nvr.js'; import { print3dModelTool } from './print3d.js'; +import { modelSearchTool, modelDownloadTool } from './model-search.js'; import { workshopProjectTool } from './workshop-project.js'; import { stlCadTool, scadToStlTool, writeScadTool, scadAnimateTool } from './stl-cad.js'; @@ -240,6 +241,9 @@ class ToolRegistry { // 집 CCTV 한 프레임 캡처 → 비전 모델이 직접 확인 (감시카메라/마당/현관/택배 확인 요청) this.registerSafe(nvrSnapshotTool); this.registerSafe(print3dModelTool); + // 3D 모델 저장소 검색+다운로드 (Printables/MakerWorld/Thingiverse, 2026-09-23) + this.registerSafe(modelSearchTool); + this.registerSafe(modelDownloadTool); this.registerSafe(workshopProjectTool); this.registerSafe(stlCadTool); this.registerSafe(scadToStlTool); diff --git a/tests/model-search.test.ts b/tests/model-search.test.ts new file mode 100644 index 0000000..b46de73 --- /dev/null +++ b/tests/model-search.test.ts @@ -0,0 +1,117 @@ +// model-search.ts 순수함수 단위 테스트 (2026-09-23) +// 네트워크는 건드리지 않는다 — 사이트 클라이언트의 응답 매핑/파싱만 검증. +// 픽스처는 2026-09-23 실측 curl 응답(축약)을 그대로 쓴다. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + parseModelRef, resolveSitePlan, mapThingiverseResults, mapPrintablesResults, + mapMakerWorldResults, formatModelResults, downloadErrorDetail, pickSlicableFile, +} from '../src/tools/model-search.js'; + +test('parseModelRef: 숫자 id 그대로 통과', () => { + assert.deepEqual(parseModelRef('printables', '1138497'), { id: '1138497' }); + assert.deepEqual(parseModelRef('thingiverse', '2473'), { id: '2473' }); +}); + +test('parseModelRef: 모델 페이지 URL 해석 (3 사이트)', () => { + assert.deepEqual(parseModelRef('printables', 'https://www.printables.com/model/1138497-mecanum-wheel'), { id: '1138497' }); + assert.deepEqual(parseModelRef('printables', 'printables.com/print/952992'), { id: '952992' }); + assert.deepEqual(parseModelRef('makerworld', 'https://makerworld.com/en/models/716411'), { id: '716411' }); + assert.deepEqual(parseModelRef('thingiverse', 'https://www.thingiverse.com/thing:2473'), { id: '2473' }); + assert.deepEqual(parseModelRef('thingiverse', 'thingiverse.com/things/2473'), { id: '2473' }); +}); + +test('parseModelRef: 가비지는 에러', () => { + assert.ok('error' in parseModelRef('printables', '')); + assert.ok('error' in parseModelRef('printables', 'https://example.com/thing/123')); + assert.ok('error' in parseModelRef('makerworld', 'not-a-url')); +}); + +test('resolveSitePlan: Thingiverse는 토큰 없으면 스킵, 나머지는 익명 가능', () => { + const plan = resolveSitePlan({}, {}); + assert.deepEqual(plan.attempt, ['printables', 'makerworld']); + assert.equal(plan.skipped.length, 1); + assert.equal(plan.skipped[0].site, 'thingiverse'); + assert.ok(plan.skipped[0].reason.includes('thingiverse_token')); +}); + +test('resolveSitePlan: 토큰 있으면 3개 전부 시도', () => { + const plan = resolveSitePlan({}, { thingiverse: 'tok' }); + assert.deepEqual(plan.attempt, ['thingiverse', 'printables', 'makerworld']); + assert.equal(plan.skipped.length, 0); +}); + +test('resolveSitePlan: site 지정이 그 사이트만', () => { + const plan = resolveSitePlan({ site: 'printables' }, {}); + assert.deepEqual(plan.attempt, ['printables']); + // 지정 사이트에 토큰이 필요한데 없으면 전부 스킵 + const plan2 = resolveSitePlan({ site: 'thingiverse' }, {}); + assert.deepEqual(plan2.attempt, []); + assert.equal(plan2.skipped[0].site, 'thingiverse'); +}); + +test('mapThingiverseResults: 실측 응답(축약) 매핑', () => { + const mapped = mapThingiverseResults({ + total: 221, + hits: [{ + id: 2473, name: 'Mecanum Wheel MK2', like_count: 1323, + public_url: 'https://www.thingiverse.com/thing:2473', + creator: { name: 'Zaggo' }, + }], + }); + assert.equal(mapped.length, 1); + assert.equal(mapped[0].site, 'thingiverse'); + assert.equal(mapped[0].id, '2473'); + assert.equal(mapped[0].likes, 1323); + assert.equal(mapped[0].author, 'Zaggo'); + assert.equal(mapped[0].url, 'https://www.thingiverse.com/thing:2473'); +}); + +test('mapPrintablesResults: 실측 응답 매핑 + 빈 필드 허용', () => { + const mapped = mapPrintablesResults({ + items: [ + { id: '1138497', name: 'Mecanum Wheel', slug: 'mecanum-wheel', likesCount: 104, downloadCount: 366, filesCount: 6, user: { publicUsername: 'Evolv3D' } }, + { id: '952992', name: 'x', user: {} }, + ], + }); + assert.equal(mapped.length, 2); + assert.equal(mapped[0].fileCount, 6); + assert.equal(mapped[1].likes, 0); + assert.equal(mapped[1].author, ''); +}); + +test('mapMakerWorldResults: 실측 응답 매핑', () => { + const mapped = mapMakerWorldResults({ + hits: [{ id: 716411, title: 'Omnidirectional Ball Caster Wheel', likeCount: 1591, downloadCount: 2521, designCreator: { name: 'Claymuh' } }], + }); + assert.equal(mapped[0].id, '716411'); + assert.equal(mapped[0].site, 'makerworld'); + assert.equal(mapped[0].likes, 1591); +}); + +test('formatModelResults: 스킵 진단 라인 포함', () => { + const out = formatModelResults([], [{ site: 'thingiverse', reason: '토큰 미설정' }], 'mecanum wheel'); + assert.ok(out.includes('스킵됨: thingiverse')); + assert.ok(out.includes('영문')); +}); + +test('downloadErrorDetail: 사이트별 토큰 만료 안내', () => { + assert.ok(downloadErrorDetail('thingiverse', 401, '').includes('thingiverse_token')); + assert.ok(downloadErrorDetail('makerworld', 401, '').includes('90일')); + assert.ok(downloadErrorDetail('makerworld', 418, '').includes('캡차')); + assert.ok(downloadErrorDetail('printables', 403, '').includes('printables_cookie')); + assert.ok(downloadErrorDetail('printables', 500, 'oops').includes('HTTP 500')); +}); + +test('pickSlicableFile: stl/3mf만, 큰 것 우선, .stp 배제', () => { + // 09-23 실측: Printables stls 리스트에 .stp 파일이 섞여 온다 + const pick = pickSlicableFile([ + { name: 'mecanum-wheel-Part1.stp', size: 189000 }, + { name: 'mecanum-wheel-Part2.stp', size: 10699 }, + { name: 'small.stl', size: 5000 }, + { name: 'big.3mf', size: 9000 }, + { name: 'biggest.stl', size: 12000 }, + ]); + assert.equal(pick?.name, 'biggest.stl'); + assert.equal(pickSlicableFile([{ name: 'a.stp', size: 100 }, { name: 'b.jpg', size: 50 }]), null); +}); \ No newline at end of file