尚未偵測到更新。GitHub 發布版本後會顯示在這裡。
個人 GitHub 帳號下公開儲存庫的擁有者可以登入並驗證所有權,自行管理介紹,無需管理員核准。
登入並產生驗證檔案,在 30 分鐘內將其提交至此儲存庫的預設分支。其他分支或分叉儲存庫中的檔案無效。然後在下方檢查檔案,即可無需管理員核准直接管理專案。請保留檔案以維持管理權限。
如需登入,將前往登入頁面。登入後請回到此處,再次點擊所選的驗證按鈕。
在專案資料夾中執行 /nomorevibe verify,即可驗證所有權並自行管理介紹。
一分鐘完成驗證正在載入完整 README。 以下為已儲存的摘錄。
쇼핑 중 발견한 옷의 상품 페이지 URL을 붙여 넣으면 "이 옷, 나한테 맞을까?"에 답해 주는 서비스입니다. 소재, 사이즈, 취향, 관리 난이도를 내 기준(체형, 선호 핏, 쇼핑 성향)으로 판단해 하나의 결과로 보여 줍니다. 로그인 없이 바로 쓸 수 있고, 프로필, 취향, 분석 기록은 브라우저(localStorage)에만 저장됩니다. http://localhost:3000을 엽니다. API 키 없이도 실행되며, 이때는 AI 없이 동작합니다(아래 "동작 모드" 참고). 상품 페이지는 형식이 제각각이라, 페이지 텍스트를 구조화된 상품 정보로 바꾸는 단계에만 Gemini를 씁니다. 추천 점수는 Gemini가 만들지 않습니다. 점수, 소재 평가, 취향 매칭, 사이즈 추천, 관리법은 기존 규칙 기반 코드가 계산합니다. 1. Google AI Studio에서 API 키를 발급합니다. 2. .env.example을 .env.local로 복사하고 값을 채웁니다. 3. 개발 서버를 다시 시작합니다. 키를 넣는 것 외에 코드 수정은 필요 없습니다.檢視完整 README ↗
쇼핑 중 발견한 옷의 상품 페이지 URL을 붙여 넣으면 "이 옷, 나한테 맞을까?"에 답해 주는 서비스입니다. 소재, 사이즈, 취향, 관리 난이도를 내 기준(체형, 선호 핏, 쇼핑 성향)으로 판단해 하나의 결과로 보여 줍니다.
로그인 없이 바로 쓸 수 있습니다. Supabase가 연결되어 있으면 첫 방문자는 익명 사용자로 생성되고, 프로필, 취향, 분석 기록은 Supabase DB에 저장됩니다. localStorage는 캐시와 네트워크 실패 fallback으로 계속 유지됩니다.
npm install
npm run dev
http://localhost:3000을 엽니다. API 키 없이도 실행되며, 이때는 AI 없이 동작합니다(아래 "동작 모드" 참고).
상품 페이지는 형식이 제각각이라, 페이지 텍스트를 구조화된 상품 정보로 바꾸는 단계에만 Gemini를 씁니다. 추천 점수는 Gemini가 만들지 않습니다. 점수, 소재 평가, 취향 매칭, 사이즈 추천, 관리법은 기존 규칙 기반 코드가 계산합니다.
Google AI Studio에서 API 키를 발급합니다.
.env.example을 .env.local로 복사하고 값을 채웁니다.
cp .env.example .env.local
| 변수 | 필수 | 설명 |
|---|---|---|
GEMINI_API_KEY | 예 (AI를 쓰려면) | 서버에서만 읽습니다. 브라우저 번들, 로그, 에러 응답에 나가지 않습니다. |
GEMINI_MODEL | 아니오 | 첫 번째로 시도할 모델. 기본값 gemini-3.5-flash-lite. 비워 두는 것을 권장합니다. 아래 fallback 순서는 이 값과 상관없이 뒤에 이어집니다. |
NEXT_PUBLIC_SUPABASE_URL | 예 (동기화를 쓰려면) | Supabase Project URL. 브라우저에서 publishable key와 함께 사용합니다. |
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY | 예 (동기화를 쓰려면) | Supabase publishable key. service-role key는 절대 넣지 않습니다. |
개발 서버를 다시 시작합니다. 키를 넣는 것 외에 코드 수정은 필요 없습니다.
SDK는 공식 @google/genai를 쓰고, 모델에는 JSON Schema 기반 구조화 출력(responseJsonSchema)을 요청합니다.
모델 목록과 상태는 공식 모델 문서를 확인하세요. 모델 ID는 바뀔 수 있어서 코드가 아니라 환경변수로 둡니다.
| 상태 | 동작 |
|---|---|
GEMINI_API_KEY 있음 | 페이지에서 읽은 텍스트를 Gemini로 구조화하고, 결과를 페이지 원문과 대조해 검증합니다. |
| 키 없음 | 구조화 데이터(JSON-LD), 메타 태그, 본문 정규식으로 추출합니다. 못 찾은 항목은 경고로 보여 주고, 설명 직접 입력으로 보완할 수 있습니다. |
| Gemini 오류, 한도 초과, 시간 초과 | 앱은 계속 동작합니다. 위 "키 없음"과 같은 결과에 "AI 분석을 쓰지 못했다"는 안내와 직접 입력 버튼이 붙습니다. 내부 오류 문구나 키는 노출하지 않습니다. |
| "샘플로 보기" | 네트워크 없이 샘플 상품(가상 데이터)으로 전체 흐름을 보여 줍니다. |
상품 URL
→ POST /api/analyze-url (서버)
→ safeFetch: URL 검증, 연결 시점 IP 검증, 리다이렉트 재검증, 크기·시간 제한
→ htmlExtraction: JSON-LD / 메타 / 본문 텍스트 (결정론적 추출)
→ GeminiProductExtractionProvider (키가 있을 때만)
구조화 출력(JSON Schema) → zod 검증 → 페이지 원문 대조
→ ProductFacts (각 값에 출처 표시)
→ 소재 평가 · 취향 매칭 · 사이즈 추천 · 관리 안내 · 점수 (브라우저, 규칙 기반)
→ Result
주요 위치:
| 경로 | 역할 |
|---|---|
components/ShoppingApp.tsx | 화면 전체 (Home, Analyzing, Result, Fit, Preferences, History) |
app/api/analyze-url/route.ts | 분석 API (입력 검증, 스로틀, 중복 요청 합치기) |
services/extraction/safeFetch.ts | SSRF 방어 fetch |
services/extraction/geminiProvider.ts, geminiSchema.ts | Gemini 호출, 스키마, 검증, 매핑 |
services/extraction/urlExtraction.ts | 추출 파이프라인과 병합 규칙 |
domain/ | 소재, 취향, 사이즈, 점수 규칙 (Gemini와 무관) |
repository/localShoppingRepository.ts | localStorage cache/fallback 저장소 |
repository/supabaseShoppingRepository.ts | Supabase Auth/DB 저장소, 익명 세션, Kakao identity linking, migration |
lib/supabase/ | Supabase browser/server/proxy client |
lib/urlSafety.ts, lib/env.ts, lib/requestGuard.ts | URL 안전 규칙, 환경변수 검증, 남용 방어 |
취향은 13개이고, 각 취향은 자기만의 metric 하나로만 평가합니다. 서로 다른 취향이 같은 값을 쓰지 않아서, 두 개를 골라도 같은 사실이 두 번 계산되지 않습니다(data/preferences.ts, 테스트로 고정).
| 분류 | 취향 | metric |
|---|---|---|
| 착용감 | 부드러운 촉감 | softness |
| 가벼운 옷 | lightweight | |
| 기능 | 보온성 | warmth |
| 통기성 | breathability (공기가 통하는 정도) | |
| 땀 배출·속건 | moistureWicking (땀을 이동시키고 말리는 정도) | |
| 신축성 | stretch | |
| 관리 | 세탁이 편함 | washEase |
| 건조기 사용 가능 | dryerSafe | |
| 보풀 적음 | pillingResistance | |
| 구김 적음 | wrinkleResistance | |
| 구매 성향 | 오래 입기 | durability |
| 천연 소재 선호 | naturalFiberRatio (천연 섬유 비중) | |
| 가성비 | valueForMoney (가격 대비 구성, 아래 참고) |
이전 버전에서 합치거나 뺀 항목:
softness로 계산되던 중복이었습니다.관리 항목은 제조사 안내가 있으면 그것을 가장 먼저 따르고(basis: product_page, 신뢰도 높음), 없을 때만 소재 특성으로 예상합니다(material_inference, 결과 화면에 구분해서 표시).
가격은 ProductFacts.pricing에 currentPrice(실제 결제하는 가격), originalPrice, discountRate, currency, 출처, 신뢰도로 들어갑니다. 할인율은 두 가격에서 직접 계산하고, 마케팅 문구는 믿지 않습니다.
추출 우선순위: JSON-LD Product/Offer → 메타 태그(product:price:*, og:price:*) → 마이크로데이터 → 본문의 라벨이 붙은 가격(정가, 판매가, 할인가 …) → Gemini. 본문에서는 통화 표시와 라벨이 모두 있는 숫자만 읽습니다(사이즈, 상품번호, 배송비를 가격으로 읽지 않기 위해서). Gemini가 돌려준 가격도 페이지에 그 숫자와 통화가 실제로 있어야만 인정하고, 없으면 null로 둡니다. 가격을 못 찾으면 비워 두고 경고만 남깁니다.
가성비(valueForMoney)는 내구성과 별개로 계산합니다. "현재 판매가에 비해 소재 구성과 기능이 어느 정도인가"를 보는 값입니다.
data/priceReference.ts)에 소재 원가 계수(폴리에스터 1 … 캐시미어 6)를 곱해 "비슷한 구성의 참고 가격대"를 만듭니다.medium이고, 화면에는 "브랜드, 봉제 완성도, 원단 등급은 반영하지 못했다"는 안내가 항상 붙습니다.기준 가격표는 의도적으로 거칠고 고치기 쉬운 상수입니다. KRW와 USD만 지원하며, 다른 통화는 판단하지 않습니다.
available: false입니다. 가짜 중간 점수를 넣지 않습니다.null이고, 전체 궁합 점수는 남은 구성요소로만 계산합니다. 소재와 관리도 같은 방식입니다.low가 되고 취향 점수에서 0.6배로 반영됩니다. 소재 특성으로 예상한 값은 신뢰도가 medium을 넘지 않습니다.저장소는 스키마 버전(shopping-assistant:schema, 현재 2)을 갖습니다. 이전 버전에서 저장된 취향은 처음 읽을 때 한 번만 변환됩니다.
각 소재와 사이즈 값에는 출처(source)가 붙고 결과 화면에 표시됩니다.
structured-data, meta, pagegemini-extracteduser-input, 샘플: demoGemini가 상상한 값이 점수에 들어가지 않도록 두 겹으로 막습니다.
null, [], unknown으로 돌려주도록 지시하고, 페이지 내용은 신뢰할 수 없는 데이터로 취급합니다(프롬프트 인젝션 대비).지그재그 앱의 "공유하기"로 복사한 https://s.zigzag.kr/... 링크를 그대로 붙여 넣어도 분석됩니다. 일반 상품 URL은 아무 변화 없이 기존 경로를 탑니다.
입력 URL → 검증 → 공유 링크 resolver → 상품 페이지 URL → 기존 fetch → 추출 → Gemini
s.zigzag.kr만 지그재그 공유 링크로 봅니다. 알려진 단축/딥링크 호스트(abr.ge, bit.ly, kko.to 등)는 일반 리다이렉트 resolver가 따라갑니다. 그 외 호스트는 요청 없이 그대로 통과합니다.s.zigzag.kr → abr.ge(추적 리다이렉트) → zigzag://... 구조이고, 목적지(fallback_desktop, 앱 링크의 url, catalog_product_id)가 추적 URL의 쿼리에 들어 있습니다. 첫 Location만 읽지 않고 쿼리, 앱 스킴, intent://, meta refresh, script 이동, 페이지 안의 fallback 필드를 모두 찾습니다. 이동 주소 안에 목적지가 이미 있으면 그 주소는 요청하지도 않습니다.zigzag://, intent://는 요청하지 않고 읽기만 합니다. javascript:, data:, file:, ftp: 등은 무시합니다. 링크에 웹 주소 없이 상품 ID만 있으면, 같은 링크의 fallback_desktop에서 확인한 형태(https://zigzag.kr/p/<id>)로만 만들고 derivedFromId로 표시합니다.__NEXT_DATA__ JSON에 담고 있어 화면 텍스트에는 보이지 않습니다. 지그재그 adapter가 이 JSON을 먼저 읽고(브라우저 렌더링 없음), 혼용률과 사이즈는 상세 설명 텍스트에서 읽습니다. 쿠폰가는 안내만 하고 판매가로 쓰지 않습니다. 페이지에 없는 값은 만들지 않습니다.services/extraction/resolution/에 ShortUrlResolver를 구현하고 index.ts의 목록에 넣으면 됩니다. 페이지 데이터를 읽는 adapter는 services/extraction/adapters/에 추가합니다.소재·사이즈·관리법이 본문에 없다고 바로 "정보 없음"으로 끝내지 않고, 클릭 없이 읽을 수 있는 곳을 순서대로 확인합니다. 브라우저 자동화(Playwright로 "더보기" 클릭)는 쓰지 않습니다. 앞 단계에서 얻은 값은 뒤 단계가 덮어쓰지 않고, 비어 있는 항목만 채웁니다.
| 단계 | source 기록 | 읽는 곳 |
|---|---|---|
| 1 | zigzag-next-data | 페이지에 이미 들어 있는 __NEXT_DATA__ JSON. 접힌 "상품정보 제공고시"(essentials, product_info_map)와 판매자 상세 설명이 여기에 있습니다. "상세정보 참고" 같은 항목은 사실이 아니므로 버립니다. |
| 2 | zigzag-detail-api | 사이즈 탭은 클릭해야 불러오는 데이터라서, 사이트가 쓰는 공개 GraphQL(api.zigzag.kr의 GetSizeInfo)을 같은 방식으로 한 번 호출합니다. 페이지 데이터에 사이즈가 없을 때만 호출합니다. |
| 3 | (텍스트 추출) | 확보한 상세 설명·고시 항목에서 소재, 사이즈, 세탁법을 다시 읽고, Gemini 텍스트 추출에도 근거로 넘깁니다. |
| 4 | product-detail-image-vision | 소재나 사이즈가 아직 비어 있을 때만, 상세 이미지를 후보 선별 후 Gemini 멀티모달로 읽습니다(아래). |
| 5 | 직접 입력 | 그래도 없을 때만 사용자에게 설명 붙여넣기를 안내합니다. |
extractionMetadata.detailSources에 단계별로 어떤 항목을 얻었는지 순서대로 남고, 이미지로 읽은 값은 개별 값에도 source: "image-vision"이 붙습니다.
이미지 읽기는 소재나 사이즈가 비어 있을 때만 시작합니다. 둘 다 있으면 관리법이 비어 있어도 시작하지 않고(이미지를 읽는 김에 관리법도 찾을 뿐, 관리법 하나만으로는 시작하지 않습니다), 비어 있는 항목만 묻고 채웁니다. 이미 확보한 값은 덮어쓰지 않습니다.
상세 이미지 URL 목록(뒤쪽 → 앞쪽 순으로 최대 24장 다운로드)
→ 긴 이미지는 겹치는 세로 타일로 자르고, 넓은 이미지는 폭 1000px로 줄여 JPEG로 압축 (sharp)
→ 타일이 6개 이하면 바로 읽기
→ 많으면: 흰 바탕 글자 이미지(표·안내문)처럼 보이는 타일 최대 24개를 번호가 그려진 contact sheet 2장으로 묶어
Gemini가 "소재표 / 사이즈표 / 세탁정보가 있을 것 같은 번호"만 고름 (글자는 읽지 않음)
→ 정밀 추출은 두 번: ① 가장 표처럼 보이는 타일 4장(후보 선택과 동시에 시작) ② 후보 선택이 고른 나머지 타일 4장.
부족한 항목만 지정해 원본 해상도로 읽음
17(17번 이미지) 또는 17-2(17번 이미지의 두 번째 타일)입니다. 폰트가 없는 서버에서도 읽히도록 번호는 글꼴 대신 도형(7-segment)으로 그립니다.MEDIA_RESOLUTION_MEDIUM)으로 보냅니다.mode: "heuristic").source: "image-vision"이, 메타데이터에는 어느 이미지(imageIndex)의 몇 번째 타일(tileIndex)에서 읽었는지(vision.evidence)와 단계별 소요 시간(vision.durationsMs, timingsMs)이 남습니다. 화면에는 "상세 이미지에서 AI가 읽은 값 (참고용)"으로 표시되고, 이 관리법으로 만든 건조기·세탁 평가도 "참고" 수준입니다.사이즈표의 열이 어떤 치수인지는 옷 종류마다 다릅니다. 상품명으로 종류를 정하고(domain/garment.ts: 원피스 > 스커트 > 아우터 > 바지 > 니트 > 상의 > 셔츠 순으로 옷 이름이 소재·스타일 단어를 이깁니다), 카테고리를 모르면 표의 열(밑위·허벅지면 하의, 가슴이면 상의)로 정합니다.
| 종류 | 치수 |
|---|---|
| 상의 · 아우터 | 어깨, 가슴, 소매, 총장 |
| 하의 | 허리, 엉덩이, 허벅지, 밑위, 밑단, 총장 |
| 원피스 | 어깨, 가슴, 허리, 엉덩이, 소매, 총장 |
| 스커트 | 허리, 엉덩이, 총장, 밑단 |
"단면"(펼쳐 잰 폭)은 무조건 2배 하지 않고, 값이 단면으로 가능한 범위일 때만 둘레로 환산합니다. 표시가 "단면"인데 값이 너무 커서 단면일 수 없으면 판매자가 둘레를 잘못 적은 것으로 보고 그대로 쓰며 신뢰도를 낮춥니다. 표시가 없으면 둘레일 수 없는 작은 값만 환산하고, 둘 다 가능한 구간은 적힌 그대로 둡니다(domain/sizeMeasurements.ts). 하의와 스커트는 허리둘레로 사이즈를 고르고, 허리둘레를 입력하지 않았다면 키·몸무게로 추정하지 않고 입력을 안내합니다.
의존성을 더하지 않고 CSS와 작은 훅으로 만들었습니다(app/motion.css, components/motion/, lib/motion.ts). 효과의 수가 아니라 일관성을 기준으로 삼았습니다.
fast 160ms(누름, hover), standard 280ms(토글, 아코디언), slow 480ms(섹션·화면 전환), cinematic 1100ms(Home 인트로 한정) 네 가지, 곡선도 이름이 붙은 네 가지만 씁니다.transform과 opacity뿐입니다. 포인터 값은 rAF로 한 프레임에 한 번만 CSS 변수로 내보냅니다.(hover: hover) and (pointer: fine)에서만 동작해 터치 기기에서 고착되지 않고, prefers-reduced-motion에서는 움직임이 빠지되 상태 변화는 그대로 보입니다.lib/env.ts를 import하지 않습니다..env.local은 .gitignore에 포함되어 있습니다. .env.example에는 빈 값만 있습니다.http/https만 허용, 기본 포트(80/443)만 허용, 계정 정보(user:pw@)와 2048자 초과 URL 차단localhost, *.local, *.internal, 단일 라벨 호스트, 사설/루프백/링크로컬/CGNAT/메타데이터(169.254.169.254)/예약 대역 차단 (IPv4, IPv6, IPv4-mapped, NAT64, 6to4 포함)no-store입니다.로그인이 없는 공개 API라 Gemini 호출 비용이 남용될 수 있습니다. 현재는 다음만 적용돼 있습니다.
마지막 항목은 보안 장치가 아닙니다. 서버리스 인스턴스마다 메모리가 따로이고 인스턴스가 자주 바뀌므로, 한 인스턴스의 짧은 폭주만 완화합니다. 의도적인 우회는 막지 못합니다. 트래픽이 늘면 영구 저장소 기반 제한이 필요합니다(Vercel WAF의 Rate Limiting, Upstash Redis 등). 그 전까지는 Google AI Studio에서 키의 일일 한도와 예산 알림을 설정해 두세요.
main 브랜치)GEMINI_API_KEY, NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY를 추가합니다. GEMINI_MODEL은 선택입니다. Production(필요하면 Preview도)에 적용합니다.키를 추가하기 전에도 배포는 정상 동작하고(AI 없이), 추가 후 재배포하면 Gemini 분석이 켜집니다.
Supabase Auth에서 Anonymous Sign-ins, Kakao provider, Allow users without an email, Manual identity linking을 켭니다.
DB schema와 RLS는 supabase/migrations/20261010120000_user_storage.sql에 있습니다.
npx supabase link --project-ref <project-ref>
npx supabase db push
테이블:
| 테이블 | 용도 | RLS 기준 |
|---|---|---|
profiles | 사용자 신체/핏 프로필 1개 | user_id = auth.uid() |
preferences | 쇼핑 성향 배열 1개 | user_id = auth.uid() |
analysis_history | 분석 결과 기록, (user_id, id) 복합 PK | user_id = auth.uid() |
흐름:
signInAnonymously()로 익명 세션을 만듭니다.localStorage profile/preferences/history는 첫 Supabase 연결 때 DB로 migration합니다. 원격 데이터가 이미 있으면 덮어쓰지 않고, history만 id 기준으로 합칩니다.localStorage에 cache합니다.localStorage fallback으로 계속 동작합니다.supabase.auth.linkIdentity({ provider: "kakao" })를 사용해 현재 anonymous user에 Kakao identity를 붙입니다.npm test # 단위, 통합 테스트
npm run lint
npm run typecheck
npm run build
테스트는 규칙 기반 도메인(소재, 취향, 사이즈, 점수), URL 안전 규칙, 로컬 HTTP 서버를 쓰는 fetch 동작(리다이렉트, 압축, EUC-KR, 크기·시간 제한), Gemini 스키마·검증·오류 처리(SDK는 모킹), 추출 파이프라인의 대체 동작, API 라우트의 입력 제한을 다룹니다. 실제 Gemini 호출은 자동 테스트에 포함되지 않습니다(키와 요금이 필요). 키를 넣은 뒤 개발 서버에서 상품 URL을 분석해 확인하세요.
GEMINI_MODEL로 첫 모델을 바꿀 수 있고, 전체 순서는 lib/env.ts의 GEMINI_FALLBACK_MODELS에 있습니다.gemini-3.5-flash-lite → gemini-3.1-flash-lite → gemini-3.5-flash → gemini-3.8-flash → gemini-3-flash-preview → gemini-2.5-flash-lite → gemini-2.5-flash
gemini-3.1-flash는 텍스트 모델로 존재하지 않고, gemini-3-flash는 gemini-3-flash-preview로만 제공됩니다. 2.5 계열은 기존 사용자에게만 열려 있어 마지막에 둡니다.extractionMetadata.aiModel에 기록되며 화면에는 표시하지 않습니다.localStorage에만 저장됩니다.