From d96b0e6065e3ab81041b35ecb94f501cf451c1ca Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 16 Jul 2026 11:15:57 +0000 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20restore=20e71f624=20core=20and=20clo?= =?UTF-8?q?se=20P0=E2=80=93P2=20gaps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore dataset/extract/cron/push/admin/collect from 5acb9fc, add Galaxy Book6 Pro, require session ownership for watches, sanitize popular queries, stop fallback fake winners, add CI/rate limits, and align docs (CACHE v9, partial noindex, seed production guard). Co-authored-by: Min0504 --- .claude/launch.json | 4 +- .env.example | 8 +- .github/workflows/ci.yml | 26 + CLAUDE.md | 4 +- DEV_NOTES.md | 8 +- PROMPT.md | 6 +- README.md | 6 +- app/api/admin/extract/route.ts | 113 ++ app/api/cron/price-check/route.ts | 123 ++ app/api/cron/price-snapshot/route.ts | 95 ++ app/api/price/route.ts | 11 + app/api/push/subscribe/route.ts | 76 ++ app/api/share/guest/route.ts | 24 +- app/api/track/route.ts | 11 + app/api/watches/route.ts | 78 +- app/page.tsx | 31 +- components/vs-input.tsx | 13 +- components/watch-list.tsx | 19 +- docs/handoff.md | 23 +- docs/issues.md | 37 +- docs/progress.md | 50 +- lib/decision-engine-fallback.ts | 36 +- lib/i18n/en.ts | 16 +- lib/i18n/ja.ts | 16 +- lib/i18n/ko.ts | 18 +- lib/popular-queries.ts | 43 + lib/pricing/index.ts | 10 +- lib/specs/dataset/earphones.ts | 349 ++++++ lib/specs/dataset/index.ts | 299 +++++ lib/specs/dataset/kr/earphones.ts | 201 ++++ lib/specs/dataset/kr/laptops.ts | 261 +++++ lib/specs/dataset/kr/smartphones.ts | 455 ++++++++ lib/specs/dataset/laptops-apple.ts | 8 - lib/specs/dataset/laptops-lg.ts | 77 -- lib/specs/dataset/laptops.ts | 556 ++++++++- lib/specs/dataset/smartphones.ts | 1027 +++++++++++++++++ lib/specs/dataset/tablets.ts | 457 ++++++++ lib/specs/extract/index.ts | 228 ++++ lib/specs/extract/rules.ts | 210 ++++ next-env.d.ts | 2 +- package-lock.json | 153 --- scripts/collect-specs/models/smartphones.json | 131 +++ scripts/collect-specs/sources/danawa.ts | 338 ++++++ scripts/collect-specs/sources/gsmarena.ts | 225 ++++ tests/fallback.test.ts | 9 + tests/popular-queries.test.ts | 32 + 46 files changed, 5503 insertions(+), 420 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 app/api/admin/extract/route.ts create mode 100644 app/api/cron/price-check/route.ts create mode 100644 app/api/cron/price-snapshot/route.ts create mode 100644 app/api/push/subscribe/route.ts create mode 100644 lib/popular-queries.ts create mode 100644 lib/specs/dataset/earphones.ts create mode 100644 lib/specs/dataset/index.ts create mode 100644 lib/specs/dataset/kr/earphones.ts create mode 100644 lib/specs/dataset/kr/laptops.ts create mode 100644 lib/specs/dataset/kr/smartphones.ts delete mode 100644 lib/specs/dataset/laptops-apple.ts delete mode 100644 lib/specs/dataset/laptops-lg.ts create mode 100644 lib/specs/dataset/smartphones.ts create mode 100644 lib/specs/dataset/tablets.ts create mode 100644 lib/specs/extract/index.ts create mode 100644 lib/specs/extract/rules.ts create mode 100644 scripts/collect-specs/models/smartphones.json create mode 100644 scripts/collect-specs/sources/danawa.ts create mode 100644 scripts/collect-specs/sources/gsmarena.ts create mode 100644 tests/popular-queries.test.ts diff --git a/.claude/launch.json b/.claude/launch.json index bce63dc..7410b67 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -9,7 +9,9 @@ "autoPort": true, "env": { "AXIS_PRICE_SOURCE": "seed" - } + }, + "_comment": "seed는 로컬 데모 전용. 프로덕션/Vercel에는 넣지 말 것." + } ] } diff --git a/.env.example b/.env.example index 51d91dc..71fe879 100644 --- a/.env.example +++ b/.env.example @@ -37,8 +37,12 @@ GOOGLE_SEARCH_CX= # 로컬 검토 시에만 1로 켜고, 프로덕션에서는 보호된 환경변수로만 설정하세요. AXIS_ADMIN= -# 가격 provider. seed = 데모/개발용 fixture, 실데이터 provider 연결 전에는 비워두면 가격 UI가 숨겨집니다. -AXIS_PRICE_SOURCE=seed +# 가격 provider. +# - 로컬 데모: seed +# - 프로덕션: naver (또는 coupang). seed 금지 — 가짜 가격이 노출됩니다. +# - 미설정: 가격 UI 숨김 +AXIS_PRICE_SOURCE= + # 검색엔진 사이트 인증 (Google Search Console / Naver Search Advisor 등록 후 발급) # NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b3045f8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: ".nvmrc" + cache: npm + - run: npm ci + - run: npm run lint + - run: npx tsc --noEmit + - run: npm test + - run: npm run build + env: + # Build must not require real secrets; seed only for compile-time pages. + AXIS_PRICE_SOURCE: seed diff --git a/CLAUDE.md b/CLAUDE.md index 110c2e6..2fce4ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,11 +23,11 @@ Axis 코드베이스에서 작업할 때 따라야 할 규칙. 프로젝트 개 - 타입 체크: `npx tsc --noEmit`. - 데이터셋 변경 시 `dataset.test.ts` 무결성 검사 통과 확인. - 스키마·결과 포맷을 바꾸면 `lib/comparison-cache.ts`의 `CACHE_VERSION`을 올려 - 구버전 캐시를 무효화한다 (현재 v8). + 구버전 캐시를 무효화한다 (현재 **v9**). ## 데이터셋 작업 -- 수동 검증 데이터: `lib/specs/dataset/{smartphones,earphones,laptops,tablets}.ts` +- 수동 검증 데이터: `lib/specs/dataset/{index,smartphones,earphones,laptops,tablets}.ts` (+ `kr/`) - 제품명은 로케일 정규화됨 — `canonicalName`(한국어) + `nameEn` + `nameJa`. EN/JA 로케일에서 어필리에이트 검색어·표시명이 이 필드로 결정된다. - 새 제품 추가 시: `id`는 lowercase-kebab, spec 키는 카테고리 스키마에 존재해야 함. diff --git a/DEV_NOTES.md b/DEV_NOTES.md index c84576d..81c5391 100644 --- a/DEV_NOTES.md +++ b/DEV_NOTES.md @@ -1,7 +1,7 @@ # Axis — 개발 노트 -> 마지막 업데이트: 2026-06-11 -> 테스트: `npm test` 통과 기준 유지 · 캐시 버전: **v8** +> 마지막 업데이트: 2026-07-16 +> 테스트: `npm test` 통과 기준 유지 · 캐시 버전: **v9** > 프로덕션: https://axis-app-beta.vercel.app > > 이 문서는 개발 단일 참조점이다. 제품 방향·진행 현황·아키텍처·남은 작업을 모두 담는다. @@ -46,7 +46,7 @@ ``` 사용자 쿼리 ("에어팟 프로 vs 버즈") ↓ -[1] 캐시 확인 (Supabase comparison_cache, v8|query|locale|country) +[1] 캐시 확인 (Supabase comparison_cache, v9|query|locale|country) ↓ 미스 [2] expandComparisonOptions() — 브랜드명 → 최신 모델 확장 [3] detectCategory() — 카테고리 분류 @@ -88,7 +88,7 @@ |---------|------|------| | 스마트폰 | `smartphones.ts` | 55 | | 이어폰 | `earphones.ts` | 18 | -| 노트북 | `laptops.ts` | 26 | +| 노트북 | `laptops.ts` | 28 (+ Book6 Pro 14/16) | | 태블릿 | `tablets.ts` | 23 | | **합계** | | **122** | diff --git a/PROMPT.md b/PROMPT.md index 0ced458..0c01117 100644 --- a/PROMPT.md +++ b/PROMPT.md @@ -34,7 +34,7 @@ - 이메일 가격 알림 (Resend) + 웹 푸시 알림 (VAPID, PWA) - 검증 데이터셋 122개 (스마트폰 55 · 이어폰 18 · 노트북 26 · 태블릿 23) - 다국어 KR/US/JP (제품명 로케일 정규화: canonicalName / nameEn / nameJa) -- SEO 정적 비교 페이지 (`/compare/[slug]`), 비교 결과 캐시 (v8), 클릭 트래킹 +- SEO 정적 비교 페이지 (`/compare/[slug]`), 비교 결과 캐시 (v9), 클릭 트래킹 ### 운영 환경 (설정 완료) - `CRON_SECRET` 교체 완료, VAPID 3종 설정, `AXIS_PRICE_SOURCE=naver` 활성 @@ -87,7 +87,7 @@ - **결과/추천 로직은 요청 없이 건드리지 않음.** `selectedOption`, `reasons`, `oneLineConclusion`, per-option 분석은 사용자가 프롬프트로 직접 작업. - **스펙은 DB에서 꺼내지 않음.** 공식 페이지 AI 검증 또는 `lib/specs/dataset/` 수동 데이터에서만. Supabase는 계정·히스토리·가격추적·알림 전용. - **검증 게이트 준수.** primary 스펙이 공식 소스(tier 1~2)로 뒷받침될 때만 `verified`. 뻥스펙·하드코딩 fallback 추천 금지. 없는 제품은 "찾을 수 없음"으로 떨어뜨림. -- 스키마·결과 포맷 변경 시 `lib/comparison-cache.ts`의 `CACHE_VERSION`을 올려 구버전 캐시 무효화 (현재 v8). +- 스키마·결과 포맷 변경 시 `lib/comparison-cache.ts`의 `CACHE_VERSION`을 올려 구버전 캐시 무효화 (현재 v9). - `CRON_SECRET` 등 시크릿을 코드·문서에 하드코딩하지 않음. - 변경 후 반드시 `npm test` (특히 레지스트리·데이터셋·파이프라인 변경 시) + `npx tsc --noEmit`. @@ -115,7 +115,7 @@ lib/ ai/ AI 프로바이더 추상화 + 프롬프트 specs/dataset/ 수동 검증 스펙 122개 pricing/ 가격 프로바이더 (naver · coupang · seed) - comparison-cache.ts 캐시 레이어 (v8) + comparison-cache.ts 캐시 레이어 (v9) affiliate.ts 제휴 링크 생성 (Amazon/Coupang/Naver) scripts/collect-specs/ 반자동 스펙 수집 (danawa/gsmarena/kakaku) ``` diff --git a/README.md b/README.md index 9a1c3bc..a715820 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ **프로덕션:** https://axis-app-beta.vercel.app · **상태:** 베타, 사업성 검증 단계 (한국 · 노트북 · 제휴) +> SEO: `verified`만 색인 (`partial`/`unverified`는 noindex). 캐시 버전: **v9**. + --- ## 주요 기능 @@ -45,7 +47,7 @@ | 가격 이력 적재 (일별 크론) | ✅ 완료 | | 이메일 가격 알림 (Resend) | ✅ 완료 | | 웹 푸시 알림 (VAPID) | ✅ 완료 | -| 검증 데이터셋 | ✅ 122개 제품 (스마트폰·이어폰·노트북·태블릿) | +| 검증 데이터셋 | ✅ 수동 122+ (+KR 자동수집 병합, 북6 프로 포함) | | 다국어 KR/US/JP | ✅ 완료 | | 쿠팡 파트너스 연동 | ⏳ 누적 매출 15만원 후 발급 | | Groq 폴백 체인 | ⏳ 트래픽 증가 후 | @@ -205,7 +207,7 @@ tier 2: 검증된 리뷰/언론 (GSMArena, Notebookcheck …) tier 3: AI 추정값 verified = tier 1~2로 primary 스펙 확인됨 → 색인 허용 -partial = 일부 스펙만 검증됨 → 색인 허용 (배지 표시) +partial = 일부 스펙만 검증됨 → noindex (배지 표시) unverified = AI 추정값만 → noindex ``` diff --git a/app/api/admin/extract/route.ts b/app/api/admin/extract/route.ts new file mode 100644 index 0000000..8787e6a --- /dev/null +++ b/app/api/admin/extract/route.ts @@ -0,0 +1,113 @@ +import { NextResponse } from "next/server"; +import { isAiConfigured } from "@/lib/ai/decide"; +import { getProductById, allVerifiedProducts } from "@/lib/specs/dataset"; +import { discoverOfficialUrl } from "@/lib/specs/extract/discover"; +import { extractProductSpecs } from "@/lib/specs/extract/pipeline"; +import { configuredSearchProvider } from "@/lib/specs/extract/web-search"; +import { detectCategory } from "@/lib/category"; +import { isCountry, type Country } from "@/lib/i18n"; +import { resolveOfficialProduct, resolveProductSource } from "@/lib/specs/product-registry"; +import type { ProductSourceCandidate } from "@/lib/specs/types"; + +/** + * Dev-only extraction trigger. Runs the AI extractor against a catalog + * product's OFFICIAL page and returns the extracted specs for review before + * they're committed to the verified store. + * + * Safety: + * - Gated behind AXIS_ADMIN=1 (off in production by default). + * - SSRF-safe: only fetches the `source` URL already stored in our catalog; + * the caller supplies an `id`, never a URL. + * - Returns { result: null } when no AI key is configured (honest no-op). + * + * Usage: GET /api/admin/extract?id=macbook-air-13-m3 + * GET /api/admin/extract → lists available ids + */ +export async function GET(req: Request) { + if (process.env.AXIS_ADMIN !== "1") { + return NextResponse.json({ error: "not found" }, { status: 404 }); + } + + const searchParams = new URL(req.url).searchParams; + const id = searchParams.get("id")?.trim(); + const productName = searchParams.get("product")?.trim(); + const rawCountry = searchParams.get("country")?.trim().toUpperCase(); + + if (!id && !productName) { + return NextResponse.json({ + status: adminExtractionStatus(), + ids: allVerifiedProducts().map((p) => ({ id: p.id, name: p.canonicalName, source: p.source })) + }); + } + + if (productName) { + const country: Country = isCountry(rawCountry) ? rawCountry : "KR"; + const category = detectCategory(productName); + const entry = resolveOfficialProduct(productName); + const source: ProductSourceCandidate | null = entry + ? resolveProductSource(entry, country) + : await discoverOfficialUrl(productName, category, { country }).then((url) => + url ? { url, tier: 2, kind: "manufacturer" } : null + ); + if (!source) { + const status = adminExtractionStatus(); + const hint = status.searchProvider + ? "공식 도메인 후보를 찾지 못했거나 AI가 제품 일치 공식 페이지로 승인하지 않았습니다." + : "registry 밖 제품을 찾으려면 BRAVE_SEARCH_API_KEY 또는 GOOGLE_SEARCH_API_KEY + GOOGLE_SEARCH_CX가 필요합니다."; + return NextResponse.json( + { error: `no source for ${productName} in ${country}`, hint, status }, + { status: 404 } + ); + } + + const result = await extractProductSpecs({ + productName, + category, + sourceUrl: source.url + }); + + return NextResponse.json({ + status: adminExtractionStatus(), + result, + source + }); + } + + if (!id) { + return NextResponse.json({ error: "missing product id" }, { status: 400 }); + } + + const product = getProductById(id); + if (!product) { + return NextResponse.json({ error: `unknown product id: ${id}` }, { status: 404 }); + } + + const result = await extractProductSpecs({ + productName: product.canonicalName, + category: product.category, + sourceUrl: product.source // from our catalog, not user input + }); + + if (!result) { + return NextResponse.json({ + result: null, + status: adminExtractionStatus(), + hint: "extraction returned nothing — set an LLM API key (OPENAI/GEMINI/ANTHROPIC) and ensure the official page is fetchable." + }); + } + + // Compare against the hand-seeded specs so you can spot-check the AI extraction. + return NextResponse.json({ + status: adminExtractionStatus(), + result, + seeded: product.specs, + note: "review `result.specs` against `seeded` before committing to the dataset." + }); +} + +function adminExtractionStatus() { + return { + aiConfigured: isAiConfigured(), + searchProvider: configuredSearchProvider() + }; +} diff --git a/app/api/cron/price-check/route.ts b/app/api/cron/price-check/route.ts new file mode 100644 index 0000000..067c4bf --- /dev/null +++ b/app/api/cron/price-check/route.ts @@ -0,0 +1,123 @@ +import { NextResponse } from "next/server"; +import { listAllWatches, updateLastNotified } from "@/lib/watch/db"; +import { listAllPushWatches, updatePushLastNotified, deletePushWatchById } from "@/lib/push/db"; +import { evaluateAlert } from "@/lib/watch/alerts"; +import { getPriceProvider } from "@/lib/pricing"; +import { getProductById, resolveVerifiedAny } from "@/lib/specs/dataset"; +import { sendPriceAlert } from "@/lib/email/send"; +import { sendPricePush } from "@/lib/push/send"; +import type { Watch } from "@/lib/watch/types"; + +/** + * GET /api/cron/price-check + * Secured with Authorization: Bearer . + * Vercel Cron calls this with GET (see vercel.json). + */ +async function runPriceCheck(req: Request) { + const cronSecret = process.env.CRON_SECRET; + const auth = req.headers.get("Authorization"); + if (!cronSecret || auth !== `Bearer ${cronSecret}`) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + + const [emailRows, pushRows] = await Promise.all([listAllWatches(), listAllPushWatches()]); + let fired = 0; + + async function checkAndAlert( + row: { id: string; product_id: string; product_name: string; region: "US" | "KR" | "JP"; target_price: number | null; added_at: string; last_notified_price: number | null }, + send: (buyUrl: string, price: number, currency: import("@/lib/pricing/types").Currency, reason: import("@/lib/watch/types").AlertReason) => Promise + ) { + const product = getProductById(row.product_id) ?? resolveVerifiedAny(row.product_name); + if (!product) return; + + const provider = getPriceProvider(row.region); + if (!provider) return; + + const priceable = { id: product.id, name: product.canonicalName, category: product.category }; + const [history, quote] = await Promise.all([ + provider.getHistory(priceable, row.region).catch(() => null), + provider.getQuote(priceable, row.region).catch(() => null), + ]); + if (!history) return; + + const watch: Watch = { + productId: row.product_id, + name: row.product_name, + region: row.region, + targetPrice: row.target_price ?? undefined, + addedAt: row.added_at, + }; + + const decision = evaluateAlert(watch, history, row.last_notified_price ?? undefined); + if (!decision.fire || !decision.reason) return; + + const ok = await send( + quote?.url ?? "https://axis.so", + decision.price, + history.currency, + decision.reason + ); + if (ok) fired++; + } + + // ── Email watches ────────────────────────────────────────────────────────── + await Promise.all( + emailRows.map((row) => + checkAndAlert(row, async (buyUrl, price, currency, reason) => { + const err = await sendPriceAlert({ + to: row.email, + productName: row.product_name, + price, + currency, + reason, + targetPrice: row.target_price ?? undefined, + buyUrl, + }).then(() => null).catch((e: unknown) => String(e)); + if (!err) { + await updateLastNotified(row.id, price); + return true; + } + return false; + }) + ) + ); + + // ── Push watches ─────────────────────────────────────────────────────────── + await Promise.all( + pushRows.map((row) => + checkAndAlert(row, async (buyUrl, price, currency, reason) => { + const result = await sendPricePush({ + subscription: row.subscription, + productName: row.product_name, + price, + currency, + reason, + buyUrl, + }); + if (result === "gone") { + await deletePushWatchById(row.id); + return false; + } + if (result === "sent") { + await updatePushLastNotified(row.id, price); + return true; + } + return false; + }) + ) + ); + + return NextResponse.json({ + emailChecked: emailRows.length, + pushChecked: pushRows.length, + fired, + }); +} + +export async function GET(req: Request) { + return runPriceCheck(req); +} + +export async function POST(req: Request) { + return runPriceCheck(req); +} diff --git a/app/api/cron/price-snapshot/route.ts b/app/api/cron/price-snapshot/route.ts new file mode 100644 index 0000000..49a1d46 --- /dev/null +++ b/app/api/cron/price-snapshot/route.ts @@ -0,0 +1,95 @@ +import { NextResponse } from "next/server"; +import { createServiceClientSafe } from "@/lib/supabase-server"; +import { getPriceProvider } from "@/lib/pricing"; +import { laptops } from "@/lib/specs/dataset/laptops"; + +/** + * GET /api/cron/price-snapshot + * + * Records today's KR price for every laptop SKU into `price_history`. + * Uses whatever provider is active via AXIS_PRICE_SOURCE env var. + * Idempotent — upserts by (product_id, region, recorded_date). + * + * Secured with Authorization: Bearer . + * Vercel Cron schedule: 0 1 * * * (01:00 UTC = 10:00 KST). + */ +async function runPriceSnapshot(req: Request) { + const cronSecret = process.env.CRON_SECRET; + const auth = req.headers.get("Authorization"); + if (!cronSecret || auth !== `Bearer ${cronSecret}`) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + + const provider = getPriceProvider("KR"); + if (!provider) { + return NextResponse.json( + { error: "no_provider", hint: "Set AXIS_PRICE_SOURCE=naver or coupang" }, + { status: 503 } + ); + } + + const db = createServiceClientSafe(); + if (!db) { + return NextResponse.json({ error: "db_unavailable" }, { status: 503 }); + } + + const today = new Date().toISOString().slice(0, 10); + let stored = 0; + let failed = 0; + + for (const laptop of laptops) { + const priceable = { + id: laptop.id, + name: laptop.canonicalName, + category: laptop.category, + }; + + try { + const quote = await provider.getQuote(priceable, "KR"); + if (!quote) { + failed++; + continue; + } + + const { error } = await db.from("price_history").upsert( + { + product_id: laptop.id, + region: "KR", + currency: "KRW", + price: quote.price, + source: provider.source, + affiliate_url: quote.url, + recorded_date: today, + }, + { onConflict: "product_id,region,recorded_date" } + ); + + if (error) { + failed++; + } else { + stored++; + } + } catch { + failed++; + } + + // Throttle to respect API rate limits (Naver: 10 req/s, Coupang: 10 req/s) + await new Promise((r) => setTimeout(r, 150)); + } + + return NextResponse.json({ + date: today, + provider: provider.source, + stored, + failed, + total: laptops.length, + }); +} + +export async function GET(req: Request) { + return runPriceSnapshot(req); +} + +export async function POST(req: Request) { + return runPriceSnapshot(req); +} diff --git a/app/api/price/route.ts b/app/api/price/route.ts index fd7e7aa..5e8d24d 100644 --- a/app/api/price/route.ts +++ b/app/api/price/route.ts @@ -8,6 +8,7 @@ import { type Region } from "@/lib/pricing"; import { isLocale, type Locale } from "@/lib/i18n"; +import { getClientIp, rateLimit } from "@/lib/rate-limit"; export type PriceApiResult = { productId: string; @@ -32,6 +33,16 @@ export type PriceApiResult = { * configured. Never invents prices — same honesty rule as the spec gate. */ export async function GET(req: Request) { + const ip = getClientIp(req); + const limit = rateLimit(`price:${ip}`, 60, 60_000); + if (!limit.allowed) { + const retryAfter = Math.ceil((limit.resetAt - Date.now()) / 1000); + return NextResponse.json( + { error: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요." }, + { status: 429, headers: { "Retry-After": String(retryAfter) } } + ); + } + const { searchParams } = new URL(req.url); const id = searchParams.get("id")?.trim(); const name = searchParams.get("name")?.trim(); diff --git a/app/api/push/subscribe/route.ts b/app/api/push/subscribe/route.ts new file mode 100644 index 0000000..fb944a6 --- /dev/null +++ b/app/api/push/subscribe/route.ts @@ -0,0 +1,76 @@ +import { NextResponse } from "next/server"; +import { upsertPushWatch, deletePushWatch } from "@/lib/push/db"; +import type { Region } from "@/lib/pricing/types"; +import type webpush from "web-push"; + +function isValidRegion(r: unknown): r is Region { + return r === "US" || r === "KR" || r === "JP"; +} + +function isValidSubscription(s: unknown): s is webpush.PushSubscription { + return ( + typeof s === "object" && + s !== null && + typeof (s as Record).endpoint === "string" + ); +} + +/** + * POST /api/push/subscribe + * { subscription, productId, name, region, targetPrice?, addedAt? } + */ +export async function POST(req: Request) { + let body: Record; + try { + body = (await req.json()) as Record; + } catch { + return NextResponse.json({ error: "invalid json" }, { status: 400 }); + } + + const { subscription, productId, name, region, targetPrice, addedAt } = body; + + if ( + !isValidSubscription(subscription) || + typeof productId !== "string" || !productId || + typeof name !== "string" || !name || + !isValidRegion(region) + ) { + return NextResponse.json({ error: "missing or invalid fields" }, { status: 400 }); + } + + await upsertPushWatch(subscription, { + productId, + name, + region, + targetPrice: typeof targetPrice === "number" ? targetPrice : undefined, + addedAt: typeof addedAt === "string" ? addedAt : new Date().toISOString(), + }); + + return NextResponse.json({ ok: true }); +} + +/** + * DELETE /api/push/subscribe + * { endpoint, productId, region } + */ +export async function DELETE(req: Request) { + let body: Record; + try { + body = (await req.json()) as Record; + } catch { + return NextResponse.json({ error: "invalid json" }, { status: 400 }); + } + + const { endpoint, productId, region } = body; + + if ( + typeof endpoint !== "string" || !endpoint || + typeof productId !== "string" || !productId || + !isValidRegion(region) + ) { + return NextResponse.json({ error: "missing or invalid fields" }, { status: 400 }); + } + + await deletePushWatch(endpoint, productId, region); + return NextResponse.json({ ok: true }); +} diff --git a/app/api/share/guest/route.ts b/app/api/share/guest/route.ts index 293f3d5..9e789a7 100644 --- a/app/api/share/guest/route.ts +++ b/app/api/share/guest/route.ts @@ -1,10 +1,12 @@ +import { randomBytes } from "node:crypto"; import { NextResponse } from "next/server"; import { createSupabaseRouteClient } from "@/lib/supabase-route"; +import { getClientIp, rateLimit } from "@/lib/rate-limit"; import type { ComparisonResult } from "@/lib/types"; +/** 22-char URL-safe token (~131 bits) — harder to enumerate than 10-char charset. */ function generateToken() { - const chars = "abcdefghijkmnpqrstuvwxyz23456789"; - return Array.from({ length: 10 }, () => chars[Math.floor(Math.random() * chars.length)]).join(""); + return randomBytes(16).toString("base64url").slice(0, 22); } /** @@ -13,6 +15,16 @@ function generateToken() { * The stored row has no user_id. */ export async function POST(req: Request) { + const ip = getClientIp(req); + const limit = rateLimit(`share-guest:${ip}`, 10, 60_000); + if (!limit.allowed) { + const retryAfter = Math.ceil((limit.resetAt - Date.now()) / 1000); + return NextResponse.json( + { error: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요." }, + { status: 429, headers: { "Retry-After": String(retryAfter) } } + ); + } + let body: { query?: string; result?: ComparisonResult }; try { body = (await req.json()) as typeof body; @@ -24,6 +36,12 @@ export async function POST(req: Request) { return NextResponse.json({ error: "결과 데이터가 없습니다." }, { status: 400 }); } + // Cap stored query length to reduce spam / PII dumps. + const query = String(body.query).trim().slice(0, 120); + if (query.length < 3) { + return NextResponse.json({ error: "결과 데이터가 없습니다." }, { status: 400 }); + } + const supabase = await createSupabaseRouteClient(req); if (!supabase) { return NextResponse.json({ error: "Supabase not configured" }, { status: 503 }); @@ -32,7 +50,7 @@ export async function POST(req: Request) { const token = generateToken(); const { error } = await supabase.from("comparisons").insert({ user_id: null, - query: body.query, + query, category: body.result.category ?? "general", selected_option: body.result.selectedOption, analysis_result: body.result, diff --git a/app/api/track/route.ts b/app/api/track/route.ts index fad755a..23fe910 100644 --- a/app/api/track/route.ts +++ b/app/api/track/route.ts @@ -1,9 +1,20 @@ import { NextResponse } from "next/server"; import { createServiceClientSafe } from "@/lib/supabase-server"; +import { getClientIp, rateLimit } from "@/lib/rate-limit"; export const runtime = "nodejs"; export async function POST(req: Request) { + const ip = getClientIp(req); + const limit = rateLimit(`track:${ip}`, 60, 60_000); + if (!limit.allowed) { + const retryAfter = Math.ceil((limit.resetAt - Date.now()) / 1000); + return NextResponse.json( + { error: "rate_limited" }, + { status: 429, headers: { "Retry-After": String(retryAfter) } } + ); + } + try { const body = await req.json(); const { event_type, product_id, slug, region, retailer, session_id } = body; diff --git a/app/api/watches/route.ts b/app/api/watches/route.ts index faed8a7..adf36fa 100644 --- a/app/api/watches/route.ts +++ b/app/api/watches/route.ts @@ -1,5 +1,7 @@ import { NextResponse } from "next/server"; import { upsertWatch, deleteWatch, listWatchesByEmail } from "@/lib/watch/db"; +import { createSupabaseRouteClient } from "@/lib/supabase-route"; +import { getClientIp, rateLimit } from "@/lib/rate-limit"; import type { Region } from "@/lib/pricing/types"; function isValidEmail(email: string): boolean { @@ -11,24 +13,66 @@ function isValidRegion(r: unknown): r is Region { } /** - * GET /api/watches?email=… - * Returns the watch list for that email (email acts as an unguessable owner token). + * Resolve the authenticated user's email. Client-supplied emails are never + * trusted for ownership — only the session (cookie or Bearer) is. */ -export async function GET(req: Request) { - const { searchParams } = new URL(req.url); - const email = searchParams.get("email")?.trim().toLowerCase(); +async function requireSessionEmail(req: Request): Promise< + { email: string } | { error: NextResponse } +> { + const supabase = await createSupabaseRouteClient(req); + if (!supabase) { + return { error: NextResponse.json({ error: "auth unavailable" }, { status: 503 }) }; + } + const { + data: { user }, + } = await supabase.auth.getUser(); + const email = user?.email?.trim().toLowerCase(); if (!email || !isValidEmail(email)) { - return NextResponse.json({ error: "invalid email" }, { status: 400 }); + return { error: NextResponse.json({ error: "unauthorized" }, { status: 401 }) }; + } + return { email }; +} + +function rateLimitOrReject(req: Request, action: string): NextResponse | null { + const ip = getClientIp(req); + const limit = rateLimit(`watches:${action}:${ip}`, 30, 60_000); + if (!limit.allowed) { + const retryAfter = Math.ceil((limit.resetAt - Date.now()) / 1000); + return NextResponse.json( + { error: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요." }, + { status: 429, headers: { "Retry-After": String(retryAfter) } } + ); } - const watches = await listWatchesByEmail(email); + return null; +} + +/** + * GET /api/watches + * Returns the watch list for the authenticated user only. + */ +export async function GET(req: Request) { + const limited = rateLimitOrReject(req, "get"); + if (limited) return limited; + + const auth = await requireSessionEmail(req); + if ("error" in auth) return auth.error; + + const watches = await listWatchesByEmail(auth.email); return NextResponse.json({ watches }); } /** * POST /api/watches - * Body: { email, productId, name, region, targetPrice?, addedAt? } + * Body: { productId, name, region, targetPrice?, addedAt? } + * Optional `email` in body is ignored (session email wins). */ export async function POST(req: Request) { + const limited = rateLimitOrReject(req, "post"); + if (limited) return limited; + + const auth = await requireSessionEmail(req); + if ("error" in auth) return auth.error; + let body: Record; try { body = (await req.json()) as Record; @@ -36,10 +80,9 @@ export async function POST(req: Request) { return NextResponse.json({ error: "invalid json" }, { status: 400 }); } - const { email, productId, name, region, targetPrice, addedAt } = body; + const { productId, name, region, targetPrice, addedAt } = body; if ( - typeof email !== "string" || !isValidEmail(email) || typeof productId !== "string" || !productId || typeof name !== "string" || !name || !isValidRegion(region) @@ -47,7 +90,7 @@ export async function POST(req: Request) { return NextResponse.json({ error: "missing or invalid fields" }, { status: 400 }); } - await upsertWatch(email.toLowerCase(), { + await upsertWatch(auth.email, { productId, name, region, @@ -60,9 +103,15 @@ export async function POST(req: Request) { /** * DELETE /api/watches - * Body: { email, productId, region } + * Body: { productId, region } */ export async function DELETE(req: Request) { + const limited = rateLimitOrReject(req, "delete"); + if (limited) return limited; + + const auth = await requireSessionEmail(req); + if ("error" in auth) return auth.error; + let body: Record; try { body = (await req.json()) as Record; @@ -70,16 +119,15 @@ export async function DELETE(req: Request) { return NextResponse.json({ error: "invalid json" }, { status: 400 }); } - const { email, productId, region } = body; + const { productId, region } = body; if ( - typeof email !== "string" || !isValidEmail(email) || typeof productId !== "string" || !productId || !isValidRegion(region) ) { return NextResponse.json({ error: "missing or invalid fields" }, { status: 400 }); } - await deleteWatch(email.toLowerCase(), productId, region); + await deleteWatch(auth.email, productId, region); return NextResponse.json({ ok: true }); } diff --git a/app/page.tsx b/app/page.tsx index 434cf6e..3c27298 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -9,35 +9,28 @@ import PopularRankList from "@/components/popular-rank-list"; import { getLocale } from "@/lib/i18n/server"; import { getDictionary } from "@/lib/i18n"; import { COMPARISONS } from "@/lib/compare-pages/comparisons"; -import { createServiceClient } from "@/lib/supabase-server"; +import { createServiceClientSafe } from "@/lib/supabase-server"; +import { aggregatePopularQueries } from "@/lib/popular-queries"; type PopularQuery = { query: string; count: number }; -/** 실제 사용자 비교 쿼리 기반 인기 순위. 데이터 없으면 빈 배열. */ +/** KR·노트북 검증 우선: 홈 정적 랭킹은 노트북 비교만. */ +const HOME_COMPARISONS = COMPARISONS.filter((c) => c.category === "laptop"); + +/** 실제 사용자 비교 쿼리 기반 인기 순위. 민감·비비교 문구는 제외. */ async function getPopularQueries(limit = 8): Promise { try { - const db = createServiceClient(); + const db = createServiceClientSafe(); + if (!db) return []; const { data } = await db .from("comparisons") .select("query") .not("query", "is", null) .order("created_at", { ascending: false }) - .limit(500); // 최근 500건에서 집계 + .limit(500); if (!data?.length) return []; - - // 클라이언트 측 집계 (쿼리 정규화 후 카운트) - const counts = new Map(); - for (const row of data) { - const q = (row.query as string).trim().toLowerCase(); - if (q.length < 3) continue; - counts.set(q, (counts.get(q) ?? 0) + 1); - } - - return Array.from(counts.entries()) - .sort((a, b) => b[1] - a[1]) - .slice(0, limit) - .map(([query, count]) => ({ query, count })); + return aggregatePopularQueries(data, limit); } catch { return []; } @@ -128,7 +121,7 @@ export default async function Home() { ) : ( // Static curated comparisons → direct result page (no loading needed)
    - {COMPARISONS.slice(0, 10).map((c, i) => ( + {HOME_COMPARISONS.slice(0, 10).map((c, i) => (
  • {i + 1} @@ -143,7 +136,7 @@ export default async function Home() {
    - {t.home.compareViewAll(COMPARISONS.length)} + {t.home.compareViewAll(HOME_COMPARISONS.length)}
    diff --git a/components/vs-input.tsx b/components/vs-input.tsx index 3c362c4..a505012 100644 --- a/components/vs-input.tsx +++ b/components/vs-input.tsx @@ -67,11 +67,14 @@ export default function VsInput({ maxOptions = 2, locale = "ko" }: { maxOptions? const incoming = detail?.options?.map((o) => o.trim()).filter(Boolean) ?? []; if (incoming.length < 2) return; const next = incoming.slice(0, Math.max(2, maxOptions)); - setOptions(next.length < 2 ? [...next, ""] : next); - setError(""); - requestAnimationFrame(() => { - formRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }); - formRef.current?.querySelector("input")?.focus({ preventScroll: true }); + // Defer setState out of the effect body (event handler / microtask). + queueMicrotask(() => { + setOptions(next.length < 2 ? [...next, ""] : next); + setError(""); + requestAnimationFrame(() => { + formRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }); + formRef.current?.querySelector("input")?.focus({ preventScroll: true }); + }); }); } window.addEventListener(PREFILL_EVENT, onPrefill); diff --git a/components/watch-list.tsx b/components/watch-list.tsx index 1a85dac..a1fbb96 100644 --- a/components/watch-list.tsx +++ b/components/watch-list.tsx @@ -8,16 +8,11 @@ import { formatPrice } from "@/lib/pricing/types"; import { getDictionary, type Locale } from "@/lib/i18n"; const ENDPOINT_KEY = "axis:push:endpoint"; -const EMAIL_KEY = "axis:watch:email"; function getStoredEndpoint() { if (typeof window === "undefined") return ""; return localStorage.getItem(ENDPOINT_KEY) ?? ""; } -function getStoredEmail() { - if (typeof window === "undefined") return ""; - return localStorage.getItem(EMAIL_KEY) ?? ""; -} const EMPTY_WATCHES: Watch[] = []; @@ -34,14 +29,12 @@ async function syncRemove(productId: string, region: Watch["region"]) { body: JSON.stringify({ endpoint, productId, region }), }).catch(() => null); } - const email = getStoredEmail(); - if (email) { - fetch("/api/watches", { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email, productId, region }), - }).catch(() => null); - } + // Server sync requires an authenticated session; email query-param ownership was removed. + fetch("/api/watches", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ productId, region }), + }).catch(() => null); } export default function WatchList({ locale = "ko" }: { locale?: Locale }) { diff --git a/docs/handoff.md b/docs/handoff.md index daecc82..9f14ca7 100644 --- a/docs/handoff.md +++ b/docs/handoff.md @@ -1,20 +1,27 @@ # handoff.md — Axis -마지막 갱신: 2026-06-18 (conductor 종합) +마지막 갱신: 2026-07-16 (restore + fixes) ## 현재 상태 -베타 배포 완료. 공유 카드 UI + 갤럭시 북6 데이터셋 완료. lint 블로커(`vs-input.tsx:63`)로 현재 배포 불가. 보안 이슈 2건(watches 소유권, 집계 익명화) 미수정. +`e71f624` 코어 파손 복구 + P0~P2 수정 진행 브랜치: `cursor/restore-core-and-fixes-646a` + +- dataset/extract/cron/push/admin/collect 복구 +- Galaxy Book6 Pro 14/16 데이터셋 추가 +- watches 세션 소유권, 인기쿼리 익명화, fallback 가짜 승자 제거 +- CI · rate limit · guest share 토큰 강화 · 홈 노트북 집중 ## 주요 제약 -- 결과/추천 로직 수정 금지 (프롬프트로만) +- 결과/추천 로직 수정 금지 (프롬프트로만) — fallback은 “결론 보류”만 허용 - 스펙은 `lib/specs/dataset/`에서만 -- `CACHE_VERSION` 변경 시 `lib/comparison-cache.ts` 버전 올리기 +- `CACHE_VERSION` = **v9** - 시크릿 하드코딩 절대 금지 +- 프로덕션 `AXIS_PRICE_SOURCE=seed` 금지 -## 역할별 다음 과제 +## 역할별 다음 -- frontend (Task #26): `vs-input.tsx:63` lint 수정 (배포 블로커); 쿠팡 파트너스 제휴 링크 버튼 실연동 -- backend (Task #27): `/api/watches` 소유권 검증 (signed token); 인기 집계 익명화; Groq 폴백 체인 -- security (Task #28): frontend/backend 완료 후 재검수 → 배포 가능/불가 최종 판정 +- FE: 쿠팡 제휴 CTA 실연동 확인 +- BE: npm audit 취약점 (lockfile은 PM 승인) +- SEC: 배포 전 watches/cron/share 재검수 +- LEAD: merge 판정 diff --git a/docs/issues.md b/docs/issues.md index 43ed3d2..ca16895 100644 --- a/docs/issues.md +++ b/docs/issues.md @@ -1,30 +1,21 @@ # issues.md — Axis -마지막 갱신: 2026-06-19 (conductor 종합) +마지막 갱신: 2026-07-16 (restore + fixes) -## 🔴 배포 블로커 +## ✅ 이번 브랜치에서 해결 -### [audit] npm audit 9 취약점 — critical 1, high 2, moderate 6 -- `undici`(high), `esbuild`/`vite`/`vitest`, `postcss`/`next`, `js-yaml` 관련 -- **배정: conductor** — 의존성 업데이트 범위 결정 후 backend 실행. lockfile 변경은 conductor 승인 필수. +- [파손] e71f624 dataset/extract/cron/push/admin/collect 복구 +- [보안] `/api/watches` 세션 이메일 소유권 +- [보안] 인기 비교 집계 익명화 (`lib/popular-queries.ts`) +- [동작] fallback 가짜 승자 제거 +- [lint] vs-input setState-in-effect +- [운영] CI, rate limit, guest share 토큰, seed 프로덕션 가드 -### [lint] components/vs-input.tsx:63 — react-hooks/set-state-in-effect -- 기존 오류, 이번 스프린트에서 미수정 -- 해결 전 배포 불가 (security 판정) -- **배정: frontend (Task #26)** → 수정 후 security 재검수 +## 🟠 남은 것 -## 🟠 보안 이슈 (배포 전 해결 권장) +### [audit] npm audit 취약점 +- lockfile 변경은 PM 승인 필수 +- **배정: BE** -### [보안-중] /api/watches 소유권 미검증 -- 이메일만 알면 타인의 watch 목록 조회 가능 — 개인정보성 데이터 노출 위험 -- **배정: backend (Task #27)** — magic token / Supabase auth / signed token 중 하나로 전환 - -### [보안-검토] 인기 비교 집계 개인정보 노출 가능성 -- 홈 화면 집계가 service_role로 `comparisons.query` 읽어 공개 노출 -- 쿼리에 개인/민감 정보 포함 가능성 -- **배정: backend (Task #27)** — normalized query만 저장/집계 또는 민감어 필터 - -## ✅ 해결됨 - -- tsc fetch mock 튜플 타입 오류 (backend 06-18 수정) -- CRON_SECRET 미설정 위험 (security 06-18 확인, 설정됨) +### [기능] 쿠팡 파트너스 최종승인 대기 +- 누적 매출 15만원 후 env 전환 diff --git a/docs/progress.md b/docs/progress.md index c7dcdb1..812e79c 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -1,37 +1,35 @@ # progress.md — Axis -마지막 갱신: 2026-06-18 (conductor 종합) +마지막 갱신: 2026-07-16 (restore + fixes) -## 이번 스프린트 완료 (06-18) +## 이번 작업 (`cursor/restore-core-and-fixes-646a`) -### frontend (Codex) -- `components/share-actions.tsx`: 공유 카드 프리뷰, 로케일별 공유/구매 보조 문구, 제휴 링크 안내 구조 개선 -- `app/globals.css`: 공유 카드·구매 CTA·공유 버튼·제휴 안내 모바일 레이아웃 + 다크모드 스타일 +### P0 복구 +- `5acb9fc`에서 dataset/extract/cron/push/admin/collect 복구 +- 깨진 laptops 분리 파일 제거 → 단일 `laptops.ts` 복원 +- Galaxy Book6 Pro 14/16 추가 (Samsung US 공식 스펙) -### backend (Codex) -- `lib/specs/dataset/laptops*.ts`: 갤럭시 북6 Pro 14/16 추가, 파일 분리(제조사별) -- `tests/complete.test.ts`, `tests/web-search.test.ts`: fetch mock strict tuple 타입 수정 → tsc 통과 -- 갤럭시 북6 alias/검증 등급 회귀 테스트 추가 +### P0 보안 +- `/api/watches` 세션 소유권 (클라이언트 email 무시) +- 인기 비교 `aggregatePopularQueries` 필터 -### security (Codex) -- CRON_SECRET: `.env.local` 설정됨(len=64), 크론 라우트 2개 Bearer 검증 확인 -- AI 프로바이더 키: `NEXT_PUBLIC_` 없음, 서버 전용 확인 -- Supabase RLS: 주요 사용자 테이블 활성화, 공개 읽기 정책 의도된 데이터 제한 확인 -- **배포 판정: 불가** (lint 실패) +### P1 +- fallback 가짜 승자 제거 → 결론 보류 +- vs-input lint 수정 +- README partial 색인 / CACHE v9 / seed 문서 정합 +- `.env.example` seed 기본값 제거, 프로덕션 seed 가드 + +### P2 +- GitHub Actions CI +- track/price/watches/share rate limit +- guest share 토큰 강화 +- 홈 예시·랭킹 노트북 집중 ## 명령 결과 | 명령 | 결과 | |---|---| -| `npm test` | ✅ 170 tests 통과 | -| `npx tsc --noEmit` | ✅ 통과 (backend 06-18 수정 후) | -| `npm run build` | ✅ 통과 | -| `npm run lint` | ❌ 실패 — `components/vs-input.tsx:63` react-hooks/set-state-in-effect | - -## 남은 작업 - -- lint 블로커 수정 → security 재검수 -- /api/watches 소유권 검증 -- 인기 비교 집계 익명화 -- Groq 폴백 체인 -- 쿠팡 파트너스 제휴 링크 실연동 +| `npm test` | ✅ 177 tests | +| `npx tsc --noEmit` | ✅ | +| `npm run lint` | ✅ | +| `npm run build` | ✅ | diff --git a/lib/decision-engine-fallback.ts b/lib/decision-engine-fallback.ts index 191a8c6..420de20 100644 --- a/lib/decision-engine-fallback.ts +++ b/lib/decision-engine-fallback.ts @@ -1,42 +1,36 @@ import type { Category, ComparisonResult } from "@/lib/types"; +/** + * Temporary unavailable result when AI cannot run. + * Must NOT invent a winner — verification-gate rules forbid hardcoded recommendations. + */ export function buildFallbackDecision( options: string[], category: Category, reason: "no-key" | "ai-failed" = "no-key" ): ComparisonResult { - // Deterministic placeholder pick (longest name) used when AI is unavailable. - // The returned copy must make the temporary status clear to users. - const selectedOption = options.reduce((a, b) => (a.length >= b.length ? a : b), options[0] ?? ""); - const reasonLine = reason === "ai-failed" - ? "실시간 분석이 지연되어 기본 비교 기준으로 임시 결론을 생성했습니다." - : "현재 기본 비교 기준으로 임시 결론을 생성했습니다."; + ? "실시간 분석이 지연되어 지금은 추천 결론을 내릴 수 없습니다." + : "AI 키가 없어 지금은 추천 결론을 내릴 수 없습니다."; const detail = reason === "ai-failed" - ? "잠시 후 다시 시도하면 더 정확한 상황별 분석을 받을 수 있습니다." - : "공식 스펙과 상황별 기준을 연결하면 더 정교한 구매 판단을 제공할 수 있습니다."; + ? "잠시 후 다시 시도하면 상황별 분석을 받을 수 있습니다." + : "서버에 AI 프로바이더를 설정한 뒤 다시 비교해 주세요."; return { - selectedOption, + selectedOption: "일시적으로 결론을 낼 수 없습니다", category, options, - oneLineConclusion: `이번에는 ${selectedOption}을(를) 선택하는 것이 더 적합합니다.`, - reasons: [ - `${selectedOption}이(가) ${category} 용도에서 더 실용적인 선택입니다.`, - `다른 선택지 대비 선택 피로가 더 낮습니다.`, - reasonLine - ], + status: "verification_pending", + oneLineConclusion: "지금은 추천을 확정하지 않습니다. 잠시 후 다시 시도해 주세요.", + reasons: [reasonLine, "가짜 승자·하드코딩 추천은 제공하지 않습니다.", detail], comparison: [], - analyses: options.map((opt) => - opt === selectedOption - ? `${opt}은(는) 이번 비교에서 가장 균형 잡힌 선택입니다.` - : `${opt}도 좋은 선택지이지만 이번에는 우선순위가 낮습니다.` - ), + analyses: options.map((opt) => `${opt}: 분석 대기 중`), detail, - specCollectionNote: reason === "ai-failed" ? "실시간 분석 지연 · 임시 결론" : "기본 비교 기준 · 임시 결론", + specCollectionNote: + reason === "ai-failed" ? "실시간 분석 지연 · 결론 보류" : "AI 미설정 · 결론 보류", verification: "unverified" }; } diff --git a/lib/i18n/en.ts b/lib/i18n/en.ts index f240229..7f3632d 100644 --- a/lib/i18n/en.ts +++ b/lib/i18n/en.ts @@ -18,19 +18,19 @@ export const en = { tryThis: "Compare →", examples: [ { - category: "Phones", - query: "iPhone 16 vs Galaxy S25", - note: "Camera, battery, weight, resale value" + category: "Laptops", + query: "MacBook Air M4 vs Galaxy Book5 Pro", + note: "Portability, performance, school/work fit" }, { category: "Laptops", - query: "MacBook Air vs Galaxy Book", - note: "Portability, performance, school/work fit" + query: "LG gram 16 vs MacBook Air 15", + note: "Weight, battery, Windows/macOS fit" }, { - category: "Earbuds", - query: "AirPods Pro vs Galaxy Buds", - note: "ANC, calls, ecosystem fit" + category: "Laptops", + query: "Galaxy Book6 Pro vs MacBook Pro M4", + note: "Display, performance, price band" } ], methodTitle: "How Axis decides", diff --git a/lib/i18n/ja.ts b/lib/i18n/ja.ts index 3390c4d..8987328 100644 --- a/lib/i18n/ja.ts +++ b/lib/i18n/ja.ts @@ -18,19 +18,19 @@ export const ja = { tryThis: "比較する →", examples: [ { - category: "スマートフォン", - query: "iPhone 16 vs Galaxy S25", - note: "カメラ、バッテリー、重さ、リセール" + category: "ノートPC", + query: "MacBook Air M4 vs Galaxy Book5 Pro", + note: "携帯性、性能、学業・仕事への適性" }, { category: "ノートPC", - query: "MacBook Air vs Galaxy Book", - note: "携帯性、性能、学業・仕事への適性" + query: "LG gram 16 vs MacBook Air 15", + note: "重量、バッテリー、OS適性" }, { - category: "イヤホン", - query: "AirPods Pro vs Galaxy Buds", - note: "ノイズキャンセル、通話、エコシステム" + category: "ノートPC", + query: "Galaxy Book6 Pro vs MacBook Pro M4", + note: "ディスプレイ、性能、価格帯" } ], methodTitle: "Axisの判断方法", diff --git a/lib/i18n/ko.ts b/lib/i18n/ko.ts index 630555c..4a9f029 100644 --- a/lib/i18n/ko.ts +++ b/lib/i18n/ko.ts @@ -20,19 +20,19 @@ export const ko = { tryThis: "비교하기 →", examples: [ { - category: "스마트폰", - query: "아이폰 16 vs 갤럭시 S25", - note: "카메라, 배터리, 무게, 가격 방어까지" + category: "노트북", + query: "맥북 에어 M4 vs 갤럭시 북5 프로", + note: "휴대성, 성능, 과제/업무 용도 기준" }, { category: "노트북", - query: "맥북 에어 vs 갤럭시북", - note: "휴대성, 성능, 과제/업무 용도 기준" + query: "LG 그램 16 vs 맥북 에어 15", + note: "무게, 배터리, Windows/macOS 적합도" }, { - category: "이어폰", - query: "에어팟 프로 vs 버즈", - note: "노이즈캔슬링, 통화, 생태계 적합도" + category: "노트북", + query: "갤럭시 북6 프로 vs 맥북 프로 M4", + note: "디스플레이, 성능, 가격대 비교" } ], methodTitle: "Axis가 판단하는 방식", @@ -76,7 +76,7 @@ export const ko = { // Input input: { ordinals: ["첫 번째", "두 번째", "세 번째", "네 번째", "다섯 번째"], - placeholders: ["예: 아이폰 16", "예: 갤럭시 S25", "예: 픽셀 9", "예: 샤오미 15", "예: 원플러스 13"], + placeholders: ["예: 맥북 에어 M4", "예: 갤럭시 북5 프로", "예: LG 그램 16", "예: 맥북 프로 14", "예: 씽크패드 X1"], addOption: "+ 선택지 추가", submit: "Axis에게 맡기기 →", submitting: "분석 중...", diff --git a/lib/popular-queries.ts b/lib/popular-queries.ts new file mode 100644 index 0000000..218e427 --- /dev/null +++ b/lib/popular-queries.ts @@ -0,0 +1,43 @@ +/** + * Sanitize user comparison queries before exposing them on the public homepage. + * Rejects emails, phones, overly long free text, and non-comparison phrases. + */ + +const MAX_QUERY_LEN = 80; +const MIN_QUERY_LEN = 5; + +const UNSAFE = + /@|\b\d{2,4}[-.\s]?\d{3,4}[-.\s]?\d{4}\b|비밀번호|주민|계좌|주소|내\s*이메일|신용카드/i; + +const COMPARISON_SEP = /\s*(?:\bvs\b|대비|대)\s*/i; + +export function normalizePopularQuery(raw: string): string { + return raw.trim().replace(/\s+/g, " ").toLowerCase(); +} + +export function isSafePublicQuery(raw: string): boolean { + const q = normalizePopularQuery(raw); + if (q.length < MIN_QUERY_LEN || q.length > MAX_QUERY_LEN) return false; + if (UNSAFE.test(q)) return false; + const parts = q.split(COMPARISON_SEP).map((p) => p.trim()).filter(Boolean); + if (parts.length < 2) return false; + // Each side should look like a short product name, not a paragraph. + if (parts.some((p) => p.length > 40 || p.split(/\s+/).length > 6)) return false; + return true; +} + +export function aggregatePopularQueries( + rows: Array<{ query: string | null }>, + limit: number +): Array<{ query: string; count: number }> { + const counts = new Map(); + for (const row of rows) { + if (!row.query || !isSafePublicQuery(row.query)) continue; + const q = normalizePopularQuery(row.query); + counts.set(q, (counts.get(q) ?? 0) + 1); + } + return Array.from(counts.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, limit) + .map(([query, count]) => ({ query, count })); +} diff --git a/lib/pricing/index.ts b/lib/pricing/index.ts index 3bf2774..cbd85f2 100644 --- a/lib/pricing/index.ts +++ b/lib/pricing/index.ts @@ -19,7 +19,15 @@ export function getPriceProvider(region: Region): PriceProvider | null { const source = process.env.AXIS_PRICE_SOURCE; if (source === "naver") return region === "KR" ? naverProvider : null; if (source === "coupang") return region === "KR" ? coupangProvider : null; - if (source === "seed") return seedPriceProvider; + if (source === "seed") { + // Never serve fixture prices on Vercel production (VERCEL_ENV=production). + // NODE_ENV=production alone is not enough — local/`next build` also sets it. + if (process.env.VERCEL_ENV === "production") { + console.error("[pricing] AXIS_PRICE_SOURCE=seed is forbidden in production"); + return null; + } + return seedPriceProvider; + } return null; } diff --git a/lib/specs/dataset/earphones.ts b/lib/specs/dataset/earphones.ts new file mode 100644 index 0000000..077d9bc --- /dev/null +++ b/lib/specs/dataset/earphones.ts @@ -0,0 +1,349 @@ +import type { VerifiedProduct } from "./types"; + +/** + * 이어폰·헤드폰 검증 데이터셋 — 완전 하드코딩 (2020년 이후 주요 모델). + * + * 출처: 각 제조사 공식 페이지 (Apple KR, Samsung SEC, Sony KR, Bose KR). + * canonicalName=한국어, nameEn=영어(검색·표시). + * + * 필드: battery_hr=본체 단독(ANC 켬), battery_total_hr=케이스 포함, weight_g=한쪽. + */ +export const earphones: VerifiedProduct[] = [ + + // ══════════════════════════════════════════════════════════════════════════ + // Apple AirPods 시리즈 + // ══════════════════════════════════════════════════════════════════════════ + { + id: "airpods-pro-2", + canonicalName: "에어팟 프로 2세대", + nameEn: "AirPods Pro 2nd Gen", + aliases: [ + "에어팟 프로 2세대", "에어팟프로 2세대", "에어팟 프로2세대", "에어팟프로2세대", + "에어팟 프로 2", "에어팟프로2", "에어팟 프로2", "에어팟 프로", "에어팟프로", + "airpods pro 2", "airpods pro 2세대", "airpods pro 2nd", "airpods pro 2nd generation", "airpods pro" + ], + category: "earphones", + country: "KR", + source: "https://www.apple.com/kr/airpods-pro/specs/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "AirPods Pro (2세대)", driver: "맞춤형 Apple 고역치 드라이버", + anc: "액티브 노이즈 캔슬링, 적응형 투명도 모드", battery_hr: "6", battery_total_hr: "30", + charging_type: "USB-C, MagSafe, Qi2 무선 충전", water_resist: "IP54", weight_g: "5.3", + launch_price_krw: "359,000원", release_date: "2022년 9월", form: "커널형", codec: "AAC, SBC" + } + }, + { + id: "airpods-4-anc", + canonicalName: "에어팟 4 ANC", + nameEn: "AirPods 4 (ANC)", + aliases: [ + "에어팟 4 anc", "에어팟4 anc", "에어팟 4 (anc)", "에어팟 4(anc)", "에어팟4anc", "에어팟4(anc)", + "에어팟 4", "에어팟4", "에어팟4세대", "airpods 4 anc", "airpods 4 (anc)", "airpods 4", "airpods4 anc" + ], + category: "earphones", + country: "KR", + source: "https://www.apple.com/kr/airpods-4/specs/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "에어팟 4 (ANC)", driver: "맞춤형 Apple 드라이버", + anc: "액티브 노이즈 캔슬링, 투명 모드", battery_hr: "5", battery_total_hr: "30", + charging_type: "USB-C, MagSafe, Qi2 무선 충전", water_resist: "IP54", weight_g: "4.4", + launch_price_krw: "229,000원", release_date: "2024년 9월", form: "오픈형", codec: "AAC, SBC" + } + }, + { + id: "airpods-4", + canonicalName: "에어팟 4 (표준)", + nameEn: "AirPods 4", + aliases: ["에어팟 4 표준", "에어팟4 표준", "airpods 4 standard"], + category: "earphones", + country: "KR", + source: "https://www.apple.com/kr/airpods-4/specs/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "에어팟 4", driver: "맞춤형 Apple 드라이버", anc: "없음", + battery_hr: "5", battery_total_hr: "30", charging_type: "USB-C, MagSafe, Qi2 무선 충전", + water_resist: "IPX4", weight_g: "4.3", launch_price_krw: "179,000원", + release_date: "2024년 9월", form: "오픈형", codec: "AAC, SBC" + } + }, + { + id: "airpods-3", + canonicalName: "에어팟 3세대", + nameEn: "AirPods 3rd Gen", + aliases: ["에어팟 3세대", "에어팟3세대", "에어팟 3", "에어팟3", "airpods 3", "airpods 3rd", "airpods 3세대"], + category: "earphones", + country: "KR", + source: "https://support.apple.com/ko-kr/111851", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "에어팟 3세대", driver: "맞춤형 고진폭 Apple 드라이버", anc: "없음 (적응형 EQ)", + battery_hr: "6", battery_total_hr: "30", charging_type: "Lightning, MagSafe, Qi 무선 충전", + water_resist: "IPX4", weight_g: "4.3", launch_price_krw: "249,000원", + release_date: "2021년 10월", form: "오픈형", codec: "AAC, SBC" + } + }, + { + id: "airpods-max", + canonicalName: "에어팟 맥스", + nameEn: "AirPods Max", + aliases: ["에어팟 맥스", "에어팟맥스", "airpods max", "airpodsmax"], + category: "earphones", + country: "KR", + source: "https://www.apple.com/kr/airpods-max/specs/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "AirPods Max", driver: "40mm 맞춤형 Apple 드라이버", + anc: "액티브 노이즈 캔슬링, 투명 모드", battery_hr: "20", battery_total_hr: "20", + charging_type: "USB-C 충전", water_resist: "IPX4", weight_g: "385", + launch_price_krw: "769,000원", release_date: "2020년 12월", form: "오버이어", codec: "AAC, SBC" + } + }, + + // ══════════════════════════════════════════════════════════════════════════ + // Samsung Galaxy Buds 시리즈 + // ══════════════════════════════════════════════════════════════════════════ + { + id: "galaxy-buds4-pro", + canonicalName: "갤럭시 버즈4 프로", + nameEn: "Galaxy Buds4 Pro", + aliases: ["갤럭시 버즈4 프로", "갤럭시버즈4 프로", "갤럭시버즈4프로", "버즈4 프로", "버즈4프로", "galaxy buds4 pro", "galaxy buds 4 pro", "buds4 pro", "갤럭시 버즈 4 프로", "버즈 4 프로"], + category: "earphones", + country: "KR", + source: "https://www.samsung.com/sec/mobile-accessories/galaxy-buds4-pro/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy Buds4 Pro", driver: "10mm 우퍼 + 6.1mm 트위터 (이중 드라이버)", + anc: "Intelligent ANC, 360 Audio", battery_hr: "6", battery_total_hr: "26", + charging_type: "USB-C, Qi 무선 충전", water_resist: "IP57", weight_g: "5.5", + launch_price_krw: "249,000원", release_date: "2025년 7월", form: "커널형", codec: "SBC, AAC, Samsung Seamless Codec (SSC)" + } + }, + { + id: "galaxy-buds4", + canonicalName: "갤럭시 버즈4", + nameEn: "Galaxy Buds4", + aliases: ["갤럭시 버즈4", "갤럭시버즈4", "버즈4", "galaxy buds4", "galaxy buds 4", "buds4", "갤럭시 버즈 4", "버즈 4"], + category: "earphones", + country: "KR", + source: "https://www.samsung.com/sec/mobile-accessories/galaxy-buds4/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy Buds4", driver: "10mm 동적 드라이버", anc: "ANC 지원, 주변음 허용 모드", + battery_hr: "6", battery_total_hr: "27", charging_type: "USB-C, Qi 무선 충전", + water_resist: "IP54", weight_g: "5.7", launch_price_krw: "149,000원", + release_date: "2025년 7월", form: "오픈형", codec: "SBC, AAC, Samsung Seamless Codec (SSC)" + } + }, + { + id: "galaxy-buds3-pro", + canonicalName: "갤럭시 버즈3 프로", + nameEn: "Galaxy Buds3 Pro", + aliases: ["갤럭시 버즈3 프로", "갤럭시버즈3 프로", "갤럭시버즈3프로", "버즈3 프로", "버즈3프로", "galaxy buds3 pro", "galaxy buds 3 pro", "buds3 pro", "갤럭시 버즈 3 프로", "버즈 3 프로"], + category: "earphones", + country: "KR", + source: "https://www.samsung.com/sec/audio-sound/galaxy-buds3-pro/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy Buds3 Pro", driver: "10.5mm 우퍼 + 6mm 트위터 (이중 드라이버)", + anc: "Intelligent ANC, 주변음 허용 모드", battery_hr: "6", battery_total_hr: "26", + charging_type: "USB-C, Qi 무선 충전", water_resist: "IPX7", weight_g: "6.2", + launch_price_krw: "299,000원", release_date: "2024년 7월", form: "커널형", codec: "SBC, AAC, Samsung Seamless Codec (SSC HiFi)" + } + }, + { + id: "galaxy-buds2-pro", + canonicalName: "갤럭시 버즈2 프로", + nameEn: "Galaxy Buds2 Pro", + aliases: ["갤럭시 버즈2 프로", "갤럭시버즈2 프로", "갤럭시버즈2프로", "버즈2 프로", "버즈2프로", "galaxy buds2 pro", "galaxy buds 2 pro", "buds2 pro"], + category: "earphones", + country: "KR", + source: "https://www.samsung.com/sec/audio-sound/galaxy-buds2-pro/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy Buds2 Pro", driver: "10mm 우퍼 + 5.3mm 트위터 (이중 드라이버)", + anc: "지능형 ANC, 주변음 허용 모드", battery_hr: "5", battery_total_hr: "18", + charging_type: "USB-C, Qi 무선 충전", water_resist: "IPX7", weight_g: "5.5", + launch_price_krw: "279,000원", release_date: "2022년 8월", form: "커널형", codec: "SBC, AAC, Samsung Seamless Codec (SSC HiFi)" + } + }, + + // ══════════════════════════════════════════════════════════════════════════ + // Sony 시리즈 + // ══════════════════════════════════════════════════════════════════════════ + { + id: "sony-wf-1000xm5", + canonicalName: "소니 WF-1000XM5", + nameEn: "Sony WF-1000XM5", + aliases: ["소니 wf-1000xm5", "소니wf1000xm5", "wf-1000xm5", "wf1000xm5", "sony wf-1000xm5", "sony wf1000xm5", "소니 1000xm5"], + category: "earphones", + country: "KR", + source: "https://store.sony.co.kr/product-view/102303600", + fetchedAt: "2026-06", + tier: 2, + specs: { + model_name: "WF-1000XM5", driver: "8.4mm 드라이버 유닛", anc: "Integrated Processor V2 탑재 고성능 ANC", + battery_hr: "8", battery_total_hr: "24", charging_type: "USB-C, Qi 무선 충전", + water_resist: "IPX4", weight_g: "5.9", launch_price_krw: "359,000원", + release_date: "2023년 6월", form: "커널형", codec: "SBC, AAC, LDAC, LC3" + } + }, + { + id: "sony-wf-1000xm4", + canonicalName: "소니 WF-1000XM4", + nameEn: "Sony WF-1000XM4", + aliases: ["소니 wf-1000xm4", "소니wf1000xm4", "wf-1000xm4", "wf1000xm4", "sony wf-1000xm4", "sony wf1000xm4"], + category: "earphones", + country: "KR", + source: "https://store.sony.co.kr/product-view/101908550", + fetchedAt: "2026-06", + tier: 2, + specs: { + model_name: "WF-1000XM4", driver: "6mm 드라이버 유닛", anc: "Integrated Processor V1 탑재 ANC", + battery_hr: "8", battery_total_hr: "24", charging_type: "USB-C, Qi 무선 충전", + water_resist: "IPX4", weight_g: "7.3", launch_price_krw: "319,000원", + release_date: "2021년 6월", form: "커널형", codec: "SBC, AAC, LDAC" + } + }, + { + id: "sony-wh-1000xm5", + canonicalName: "소니 WH-1000XM5", + nameEn: "Sony WH-1000XM5", + aliases: ["소니 wh-1000xm5", "소니wh1000xm5", "wh-1000xm5", "wh1000xm5", "sony wh-1000xm5", "sony wh1000xm5", "소니 헤드폰"], + category: "earphones", + country: "KR", + source: "https://store.sony.co.kr/product-view/102291940", + fetchedAt: "2026-06", + tier: 2, + specs: { + model_name: "WH-1000XM5", driver: "30mm 드라이버 유닛", anc: "고성능 ANC, 주변음 모드", + battery_hr: "30", battery_total_hr: "30", charging_type: "USB-C 충전", + water_resist: "없음", weight_g: "250", launch_price_krw: "449,000원", + release_date: "2022년 5월", form: "오버이어", codec: "SBC, AAC, LDAC" + } + }, + + // ══════════════════════════════════════════════════════════════════════════ + // Bose + // ══════════════════════════════════════════════════════════════════════════ + { + id: "bose-qc-ultra-earbuds", + canonicalName: "보스 QC 울트라 이어버드", + nameEn: "Bose QuietComfort Ultra Earbuds", + aliases: ["보스 qc 울트라", "보스qc울트라", "보스 qc울트라이어버드", "bose qc ultra earbuds", "bose qc ultra", "보스 콰이어트컴포트 울트라"], + category: "earphones", + country: "KR", + source: "https://www.bose.com/ko_kr/products/headphones/earbuds/quietcomfort-ultra-earbuds.html", + fetchedAt: "2026-06", + tier: 2, + specs: { + model_name: "Bose QuietComfort Ultra Earbuds", driver: "커스텀 드라이버", + anc: "QuietComfort 기술 ANC, Aware Mode", battery_hr: "6", battery_total_hr: "24", + charging_type: "USB-C, Qi 무선 충전", water_resist: "IPX4", weight_g: "6.2", + launch_price_krw: "379,000원", release_date: "2023년 10월", form: "커널형", codec: "SBC, AAC, aptX Adaptive" + } + }, + + // ══════════════════════════════════════════════════════════════════════════ + // Samsung Galaxy Buds 추가 (2020–2024) + // ══════════════════════════════════════════════════════════════════════════ + { + id: "galaxy-buds-live", + canonicalName: "갤럭시 버즈 라이브", + nameEn: "Galaxy Buds Live", + aliases: ["갤럭시 버즈 라이브", "갤럭시버즈라이브", "galaxy buds live", "buds live", "버즈라이브", "버즈 라이브"], + category: "earphones", + country: "KR", + source: "https://www.samsung.com/sec/audio-sound/galaxy-buds-live/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy Buds Live", driver: "12mm 우퍼 + 5.6mm 트위터", + anc: "능동형 노이즈 캔슬링 (ANC)", battery_hr: "6", battery_total_hr: "21", + charging_type: "USB-C, 무선 충전(Qi)", water_resist: "IPX2", weight_g: "5.6", + launch_price_krw: "199,000원", release_date: "2020년 8월", form: "오픈형 (빈 형태)", codec: "AAC, SBC" + } + }, + { + id: "galaxy-buds-pro", + canonicalName: "갤럭시 버즈 프로", + nameEn: "Galaxy Buds Pro", + aliases: ["갤럭시 버즈 프로", "갤럭시버즈프로", "galaxy buds pro", "buds pro", "버즈프로", "버즈 프로"], + category: "earphones", + country: "KR", + source: "https://www.samsung.com/sec/audio-sound/galaxy-buds-pro/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy Buds Pro", driver: "11mm 우퍼 + 6.5mm 트위터", + anc: "인텔리전트 ANC (최대 11dB 노이즈 캔슬링)", battery_hr: "5", battery_total_hr: "28", + charging_type: "USB-C, 무선 충전(Qi)", water_resist: "IPX7", weight_g: "6.3", + launch_price_krw: "249,000원", release_date: "2021년 1월", form: "커널형", codec: "AAC, SBC" + } + }, + { + id: "galaxy-buds2", + canonicalName: "갤럭시 버즈2", + nameEn: "Galaxy Buds2", + aliases: ["갤럭시 버즈2", "갤럭시버즈2", "galaxy buds2", "galaxy buds 2", "buds2", "버즈2", "버즈 2"], + category: "earphones", + country: "KR", + source: "https://www.samsung.com/sec/audio-sound/galaxy-buds2/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy Buds2", driver: "11mm 우퍼 + 6.5mm 트위터", + anc: "액티브 노이즈 캔슬링 (ANC)", battery_hr: "5", battery_total_hr: "20", + charging_type: "USB-C, 무선 충전(Qi)", water_resist: "IPX2", weight_g: "5.0", + launch_price_krw: "149,000원", release_date: "2021년 8월", form: "커널형", codec: "AAC, SBC" + } + }, + { + id: "galaxy-buds3", + canonicalName: "갤럭시 버즈3", + nameEn: "Galaxy Buds3", + aliases: ["갤럭시 버즈3", "갤럭시버즈3", "galaxy buds3", "galaxy buds 3", "buds3", "버즈3", "버즈 3"], + category: "earphones", + country: "KR", + source: "https://www.samsung.com/sec/audio-sound/galaxy-buds3/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy Buds3", driver: "11mm 우퍼 + 6.5mm 트위터", + anc: "액티브 노이즈 캔슬링 (ANC)", battery_hr: "7", battery_total_hr: "30", + charging_type: "USB-C, 무선 충전(Qi)", water_resist: "IPX5", weight_g: "5.5", + launch_price_krw: "199,000원", release_date: "2024년 7월", form: "오픈형 (블레이드)", codec: "AAC, SBC" + } + }, + + // ══════════════════════════════════════════════════════════════════════════ + // Sony WH-1000XM4 헤드폰 (2020) + // ══════════════════════════════════════════════════════════════════════════ + { + id: "sony-wh1000xm4", + canonicalName: "소니 WH-1000XM4", + nameEn: "Sony WH-1000XM4", + aliases: ["소니 wh-1000xm4", "소니wh1000xm4", "sony wh-1000xm4", "sony wh1000xm4", "wh1000xm4", "wh-1000xm4", "소니 xm4 헤드폰"], + category: "earphones", + country: "KR", + source: "https://www.sony.co.kr/handler/Product-Start?pid=WH1000XM4", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "WH-1000XM4", driver: "40mm 돔형", + anc: "HD 노이즈 캔슬링 프로세서 QN1, 듀얼 노이즈 센서", battery_hr: "30", battery_total_hr: "30", + charging_type: "USB-C (10분 충전 5시간 재생)", water_resist: "없음", weight_g: "254", + launch_price_krw: "379,000원", release_date: "2020년 8월", form: "오버이어 헤드폰", codec: "LDAC, AAC, SBC, aptX" + } + }, +]; diff --git a/lib/specs/dataset/index.ts b/lib/specs/dataset/index.ts new file mode 100644 index 0000000..4d582a3 --- /dev/null +++ b/lib/specs/dataset/index.ts @@ -0,0 +1,299 @@ +import type { Category, ComparisonRow } from "@/lib/types"; +import type { Locale } from "@/lib/i18n"; +import { getCategorySchema, getField } from "@/lib/specs/schema"; +// ── 수동 검증 데이터셋 (tier-1, 공식 소스) ───────────────────────────────── +import { laptops } from "./laptops"; +import { smartphones } from "./smartphones"; +import { earphones } from "./earphones"; +import { tablets } from "./tablets"; +// ── 다나와 자동 수집 데이터셋 (KR 시장, 2026-06) ─────────────────────────── +import { laptops as krLaptops } from "./kr/laptops"; +import { smartphones as krSmartphones } from "./kr/smartphones"; +import { earphones as krEarphones } from "./kr/earphones"; +import type { VerifiedProduct, DatasetCountry } from "./types"; + +export type { VerifiedProduct } from "./types"; + +/** + * 데이터셋 병합 — 앞쪽 pool이 우선권을 가짐 (ID 중복 시 첫 번째 유지). + * 수동 검증 데이터셋이 항상 다나와 자동 수집 데이터보다 앞에 위치해야 함. + */ +function mergeDatasets(...pools: VerifiedProduct[][]): VerifiedProduct[] { + const seenIds = new Set(); + const result: VerifiedProduct[] = []; + for (const pool of pools) { + for (const p of pool) { + if (!seenIds.has(p.id)) { + seenIds.add(p.id); + result.push(p); + } + } + } + return result; +} + +/** All verified products — hardcoded (official) first, KR batch second. */ +const ALL: VerifiedProduct[] = mergeDatasets( + [...laptops, ...smartphones, ...earphones, ...tablets], // 수동 검증 데이터 (우선) + [...krSmartphones, ...krLaptops, ...krEarphones] // 다나와 자동 수집 (신모델 추가) +); + +/** + * 검색어 정규화: + * - 소문자 + 공백 압축 + * - 영어 tech 접미어 → 한국어 변환 (사용자 혼용 입력 커버) + * "pro"→"프로", "plus"→"플러스", "max"→"맥스", "ultra"→"울트라", "mini"→"미니" + */ +function norm(s: string): string { + return s + .trim() + .toLowerCase() + .replace(/\s+/g, " ") + // 영문 tech 접미어를 한국어로 통일 (단어 경계 \b 사용) + .replace(/\bpro\b/g, "프로") + .replace(/\bplus\b/g, "플러스") + .replace(/\bmax\b/g, "맥스") + .replace(/\bultra\b/g, "울트라") + .replace(/\bmini\b/g, "미니") + .replace(/\s+/g, " ") // 변환 후 다시 공백 정리 + .trim(); +} + +/** + * 포함(containment) 매칭에서 가장 구체적인 항목을 선택. + * "아이폰 16 프로" 검색 시 "아이폰 16"(짧음)이 아닌 "아이폰 16 프로"(긺)를 우선. + * 매칭 길이가 긴 항목이 더 구체적인 제품명. + */ +function bestContainsMatch(pool: VerifiedProduct[], key: string): VerifiedProduct | null { + let best: VerifiedProduct | null = null; + let bestScore = 0; + + for (const p of pool) { + // canonicalName + 로케일 이름(nameEn/nameJa) + aliases를 모두 후보로 + const names = [p.canonicalName, p.nameEn, p.nameJa, ...p.aliases].filter( + (n): n is string => Boolean(n) + ); + for (const name of names) { + const n = norm(name); + if (key.includes(n) || n.includes(key)) { + if (n.length > bestScore) { + bestScore = n.length; + best = p; + } + } + } + } + + return best; +} + +/** Look up a verified product by its stable id (the key for prices/watches/URLs). */ +export function getProductById(id: string): VerifiedProduct | null { + return ALL.find((p) => p.id === id) ?? null; +} + +/** Every verified product, read-only. */ +export function allVerifiedProducts(): readonly VerifiedProduct[] { + return ALL; +} + +/** + * Resolve a free-text product name to a verified entry. Exact alias/canonical + * matches win; otherwise falls back to a containment match so "맥북 에어 M3 13형" + * still resolves. Returns null when nothing is confidently matched. + * + * Country priority: prefer country-specific entry over GLOBAL. + * If country is provided, KR/US/JP entries for that market are preferred; + * GLOBAL entries serve as fallback when no country-specific entry exists. + */ +function matchIn( + pool: VerifiedProduct[], + name: string, + country?: DatasetCountry +): VerifiedProduct | null { + const key = norm(name); + if (!key) return null; + + const isExact = (p: VerifiedProduct) => + norm(p.canonicalName) === key || + (p.nameEn != null && norm(p.nameEn) === key) || + (p.nameJa != null && norm(p.nameJa) === key) || + p.aliases.some((a) => norm(a) === key); + + // Country priority: country-specific → GLOBAL → any other market. + // Hardware specs are identical worldwide, so a KR-sourced entry is still the + // correct spec sheet for a US/JP user — only market-specific price fields + // differ (the consumer strips those via stripForeignMarketFields). + if (country && country !== "GLOBAL") { + const countryPool = pool.filter((p) => p.country === country); + const globalPool = pool.filter((p) => p.country === "GLOBAL"); + const otherPool = pool.filter((p) => p.country !== country && p.country !== "GLOBAL"); + + const exact = + countryPool.find(isExact) ?? globalPool.find(isExact) ?? otherPool.find(isExact); + if (exact) return exact; + // containment: 가장 구체적인(긴 canonical) 매칭 우선 + return ( + bestContainsMatch(countryPool, key) ?? + bestContainsMatch(globalPool, key) ?? + bestContainsMatch(otherPool, key) ?? + null + ); + } + + // No country filter — match across all + const exact = pool.find(isExact); + if (exact) return exact; + return bestContainsMatch(pool, key) ?? null; +} + +/** + * Locale-aware display/search name for a verified product. + * - ko → canonicalName (Korean) + * - en → nameEn ?? canonicalName + * - ja → nameJa ?? nameEn ?? canonicalName + * + * This is what powers "type 아이폰 16 in English locale → see iPhone 16 and + * search 'iPhone 16' on Amazon US". + */ +export function localizedProductName(entry: VerifiedProduct, locale: Locale): string { + if (locale === "en") return entry.nameEn ?? entry.canonicalName; + if (locale === "ja") return entry.nameJa ?? entry.nameEn ?? entry.canonicalName; + return entry.canonicalName; +} + +/** + * Best-effort transliteration of common Korean tech product names to English. + * Used as a fallback when a product isn't in the dataset — ensures that even + * unregistered products like "아이폰 17" display as "iPhone 17" in EN/JA locale, + * and the AI receives a consistent language input. + */ +function koToEnTechName(name: string): string { + return name + .replace(/아이패드 프로/g, "iPad Pro") + .replace(/아이패드 에어/g, "iPad Air") + .replace(/아이패드 미니/g, "iPad mini") + .replace(/아이패드/g, "iPad") + .replace(/에어팟 프로/g, "AirPods Pro") + .replace(/에어팟 맥스/g, "AirPods Max") + .replace(/에어팟/g, "AirPods") + .replace(/맥북 에어/g, "MacBook Air") + .replace(/맥북 프로/g, "MacBook Pro") + .replace(/맥북/g, "MacBook") + .replace(/아이폰/g, "iPhone") + .replace(/갤럭시 버즈/g, "Galaxy Buds") + .replace(/갤럭시 탭/g, "Galaxy Tab") + .replace(/갤럭시 북/g, "Galaxy Book") + .replace(/갤럭시/g, "Galaxy") + .replace(/\s*프로\s*맥스/g, " Pro Max") + .replace(/\s*프로/g, " Pro") + .replace(/\s*맥스/g, " Max") + .replace(/\s*울트라/g, " Ultra") + .replace(/\s*플러스/g, " Plus") + .replace(/\s*미니/g, " mini") + .trim() + .replace(/\s+/g, " "); +} + +/** + * Resolve a free-text product name and return its locale-appropriate display + * name. Falls back to koToEnTechName for EN/JA when the product isn't in the + * catalog, so "아이폰 17" → "iPhone 17" even before it's added to the dataset. + * This ensures the AI always receives a language-consistent comparison prompt. + */ +export function localizeDisplayName( + name: string, + category: Category, + country: DatasetCountry, + locale: Locale +): string { + const entry = resolveVerifiedProduct(category, name, country); + if (entry) return localizedProductName(entry, locale); + if (locale === "en" || locale === "ja") return koToEnTechName(name); + return name; +} + +/** Spec keys that only make sense in their origin market (currency-bound). */ +const MARKET_SPECIFIC_KEYS = ["launch_price_krw", "price_krw"]; + +/** + * When a dataset entry from another market is served cross-country (e.g. a + * KR-sourced iPhone entry shown to a US user), strip currency-bound fields so + * the AI fills the local price from its own knowledge instead of echoing ₩. + * Hardware fields pass through untouched — they're identical worldwide. + */ +export function stripForeignMarketFields( + entry: VerifiedProduct, + country?: DatasetCountry +): Record { + if (!country || country === "GLOBAL" || entry.country === country || entry.country === "GLOBAL") { + return entry.specs; + } + const filtered: Record = {}; + for (const [key, value] of Object.entries(entry.specs)) { + if (!MARKET_SPECIFIC_KEYS.includes(key)) filtered[key] = value; + } + return filtered; +} + +export function resolveVerifiedProduct( + category: Category, + name: string, + country?: DatasetCountry +): VerifiedProduct | null { + return matchIn(ALL.filter((p) => p.category === category), name, country); +} + +/** Category-agnostic name match — used where the category isn't known (e.g. price API). */ +export function resolveVerifiedAny(name: string, country?: DatasetCountry): VerifiedProduct | null { + return matchIn(ALL, name, country); +} + +/** + * Build a schema-ordered comparison table from verified products. Each value + * carries the product's official source URL, so the verification grader treats + * filled primary fields as tier-1 (official). Rows with no data are dropped. + */ +export function buildVerifiedComparison( + category: Category, + products: (VerifiedProduct | null)[] +): ComparisonRow[] { + const schema = getCategorySchema(category); + if (!schema) return []; + + const rows: ComparisonRow[] = []; + for (const field of schema.fields) { + const values = products.map((p) => p?.specs[field.key] ?? "—"); + if (!values.some((v) => v && v !== "—")) continue; + const sources = products.map((p) => (p?.specs[field.key] ? p.source : undefined)); + rows.push({ key: field.label, values, sources }); + } + return rows; +} + +/** + * Integrity guard: every dataset spec key must exist in its category schema. + * Surfaces typos like `weigth_g` at test time instead of silently dropping data. + */ +export function validateDataset(): string[] { + const problems: string[] = []; + const seenIds = new Set(); + for (const p of ALL) { + if (!p.id || !/^[a-z0-9-]+$/.test(p.id)) { + problems.push(`${p.canonicalName}: invalid id "${p.id}" (use lowercase-kebab)`); + } + if (seenIds.has(p.id)) problems.push(`duplicate id "${p.id}"`); + seenIds.add(p.id); + + if (!getCategorySchema(p.category)) { + problems.push(`${p.canonicalName}: no schema for category "${p.category}"`); + continue; + } + for (const key of Object.keys(p.specs)) { + if (!getField(p.category, key)) { + problems.push(`${p.canonicalName}: unknown spec key "${key}"`); + } + } + } + return problems; +} diff --git a/lib/specs/dataset/kr/earphones.ts b/lib/specs/dataset/kr/earphones.ts new file mode 100644 index 0000000..774434e --- /dev/null +++ b/lib/specs/dataset/kr/earphones.ts @@ -0,0 +1,201 @@ +/** + * 한국 시장 earphone 스펙 데이터셋. + * + * 자동 수집: 다나와 (danawa.com) + * 수집일: 2026-06-09 + * + * ⚠️ 자동 생성 파일입니다. 수동 편집 시 재수집 시 덮어씌워집니다. + * 수집 명령: npx tsx scripts/collect-specs/index.ts earphone KR + */ + +import type { VerifiedProduct } from "@/lib/specs/dataset/types"; + +export const earphones: VerifiedProduct[] = [ + { + id: "airpods-4-anc", + canonicalName: "에어팟 4 ANC", + aliases: ["에어팟 4 anc", "에어팟4 anc"], + category: "earphones", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=65919884", + fetchedAt: "2026-06", + tier: 1, + specs: { + battery_total_hr: "20", + launch_price_krw: "27만원", + release_date: "2024년 10월" + } + }, + { + id: "airpods-4", + canonicalName: "에어팟 4", + aliases: ["에어팟 4", "에어팟4"], + category: "earphones", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=65919884", + fetchedAt: "2026-06", + tier: 1, + specs: { + battery_total_hr: "20", + launch_price_krw: "27만원", + release_date: "2024년 10월" + } + }, + { + id: "airpods-pro-2", + canonicalName: "에어팟 프로 2세대", + aliases: ["에어팟 프로 2세대", "에어팟 프로2", "airpods pro 2", "에어팟 프로"], + category: "earphones", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=28208783", + fetchedAt: "2026-06", + tier: 1, + specs: { + battery_total_hr: "30", + launch_price_krw: "35만원", + release_date: "2023년 10월" + } + }, + { + id: "airpods-max", + canonicalName: "에어팟 맥스", + aliases: ["에어팟 맥스", "airpods max"], + category: "earphones", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=65920895", + fetchedAt: "2026-06", + tier: 1, + specs: { + battery_hr: "20", + weight_g: "386.2", + launch_price_krw: "77만원", + release_date: "2024년 9월" + } + }, + { + id: "galaxy-buds4-pro", + canonicalName: "갤럭시 버즈4 프로", + aliases: ["갤럭시 버즈4 프로", "갤럭시버즈4 프로", "버즈4 프로", "galaxy buds4 pro"], + category: "earphones", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=106702589", + fetchedAt: "2026-06", + tier: 1, + specs: { + driver: "다이나믹, 평판형", + battery_total_hr: "26", + launch_price_krw: "36만원", + release_date: "2026년 2월" + } + }, + { + id: "galaxy-buds4", + canonicalName: "갤럭시 버즈4", + aliases: ["갤럭시 버즈4", "galaxy buds4"], + category: "earphones", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=106702589", + fetchedAt: "2026-06", + tier: 1, + specs: { + driver: "다이나믹, 평판형", + battery_total_hr: "26", + launch_price_krw: "36만원", + release_date: "2026년 2월" + } + }, + { + id: "galaxy-buds3-pro", + canonicalName: "갤럭시 버즈3 프로", + aliases: ["갤럭시 버즈3 프로", "galaxy buds3 pro"], + category: "earphones", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=59537216", + fetchedAt: "2026-06", + tier: 1, + specs: { + driver: "평판형, 다이나믹", + battery_total_hr: "26", + launch_price_krw: "32만원", + release_date: "2024년 7월" + } + }, + { + id: "sony-wf-1000xm5", + canonicalName: "소니 WF-1000XM5", + aliases: ["소니 wf-1000xm5", "wf-1000xm5"], + category: "earphones", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=27250154", + fetchedAt: "2026-06", + tier: 1, + specs: { + driver: "다이나믹", + battery_total_hr: "24", + launch_price_krw: "36만원", + release_date: "2023년 7월" + } + }, + { + id: "sony-wf-1000xm4", + canonicalName: "소니 WF-1000XM4", + aliases: ["소니 wf-1000xm4", "wf-1000xm4"], + category: "earphones", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=14570231", + fetchedAt: "2026-06", + tier: 1, + specs: { + battery_total_hr: "24", + launch_price_krw: "30만원", + release_date: "2021년 6월" + } + }, + { + id: "sony-wh-1000xm5", + canonicalName: "소니 WH-1000XM5", + aliases: ["소니 wh-1000xm5", "wh-1000xm5"], + category: "earphones", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=17229284", + fetchedAt: "2026-06", + tier: 1, + specs: { + battery_hr: "30", + weight_g: "250", + launch_price_krw: "52만원", + release_date: "2022년 5월" + } + }, + { + id: "bose-qc-ultra", + canonicalName: "보스 QC 울트라 이어버드", + aliases: ["보스 qc 울트라", "bose qc ultra earbuds"], + category: "earphones", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=94884290", + fetchedAt: "2026-06", + tier: 1, + specs: { + battery_total_hr: "24", + launch_price_krw: "36만원", + release_date: "2025년 7월" + } + }, + { + id: "jabra-elite-10", + canonicalName: "자브라 Elite 10", + aliases: ["자브라 elite 10", "jabra elite 10"], + category: "earphones", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=28805954", + fetchedAt: "2026-06", + tier: 1, + specs: { + driver: "다이나믹", + battery_total_hr: "27", + launch_price_krw: "33만원", + release_date: "2023년 10월" + } + } +]; diff --git a/lib/specs/dataset/kr/laptops.ts b/lib/specs/dataset/kr/laptops.ts new file mode 100644 index 0000000..40d4704 --- /dev/null +++ b/lib/specs/dataset/kr/laptops.ts @@ -0,0 +1,261 @@ +/** + * 한국 시장 laptop 스펙 데이터셋. + * + * 자동 수집: 다나와 (danawa.com) + * 수집일: 2026-06-09 + * + * ⚠️ 자동 생성 파일입니다. 수동 편집 시 재수집 시 덮어씌워집니다. + * 수집 명령: npx tsx scripts/collect-specs/index.ts laptop KR + */ + +import type { VerifiedProduct } from "@/lib/specs/dataset/types"; + +export const laptops: VerifiedProduct[] = [ + { + id: "macbook-air-13-m4", + canonicalName: "맥북 에어 13 M4", + aliases: ["맥북 에어 m4", "macbook air m4", "맥북 에어 13 m4"], + category: "laptop", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=77378204", + fetchedAt: "2026-06", + tier: 1, + specs: { + weight_g: "1240", + chipset: "M4", + ram_gb: "16", + storage_gb: "512", + launch_price_krw: "189만원", + release_date: "2025년 3월" + } + }, + { + id: "macbook-air-15-m4", + canonicalName: "맥북 에어 15 M4", + aliases: ["맥북 에어 15 m4", "macbook air 15 m4"], + category: "laptop", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=77378312", + fetchedAt: "2026-06", + tier: 1, + specs: { + weight_g: "1510", + chipset: "M4", + ram_gb: "16", + storage_gb: "256", + launch_price_krw: "189만원", + release_date: "2025년 3월" + } + }, + { + id: "macbook-air-13-m3", + canonicalName: "맥북 에어 13 M3", + aliases: ["맥북 에어 m3", "macbook air m3", "맥북 에어 13 m3", "맥북 에어"], + category: "laptop", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=39249308", + fetchedAt: "2026-06", + tier: 1, + specs: { + weight_g: "1240", + chipset: "M3", + ram_gb: "8", + storage_gb: "512", + launch_price_krw: "186만원", + release_date: "2024년 3월" + } + }, + { + id: "macbook-air-15-m3", + canonicalName: "맥북 에어 15 M3", + aliases: ["맥북 에어 15", "macbook air 15", "맥북 에어 15 m3"], + category: "laptop", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=39249983", + fetchedAt: "2026-06", + tier: 1, + specs: { + weight_g: "1510", + chipset: "M3", + ram_gb: "8", + storage_gb: "256", + launch_price_krw: "189만원", + release_date: "2024년 3월" + } + }, + { + id: "macbook-pro-14-m4", + canonicalName: "맥북 프로 14 M4", + aliases: ["맥북 프로 14 m4", "macbook pro 14 m4"], + category: "laptop", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=70250684", + fetchedAt: "2026-06", + tier: 1, + specs: { + weight_g: "1600", + chipset: "M4 Pro", + ram_gb: "24", + storage_gb: "1024", + launch_price_krw: "359만원", + release_date: "2024년 11월" + } + }, + { + id: "macbook-pro-16-m4", + canonicalName: "맥북 프로 16 M4", + aliases: ["맥북 프로 16 m4", "macbook pro 16 m4"], + category: "laptop", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=70251404", + fetchedAt: "2026-06", + tier: 1, + specs: { + weight_g: "2140", + chipset: "M4 Pro", + ram_gb: "24", + storage_gb: "512", + launch_price_krw: "369만원", + release_date: "2024년 11월" + } + }, + { + id: "macbook-pro-14-m3", + canonicalName: "맥북 프로 14 M3", + aliases: ["맥북 프로 14 m3", "맥북프로 14", "macbook pro 14 m3", "맥북 프로 14"], + category: "laptop", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=29622560", + fetchedAt: "2026-06", + tier: 1, + specs: { + weight_g: "1610", + chipset: "M3 Pro", + ram_gb: "18", + storage_gb: "512", + launch_price_krw: "299만원", + release_date: "2023년 11월" + } + }, + { + id: "lg-gram-16", + canonicalName: "LG 그램 16", + aliases: ["lg 그램 16", "그램 16", "lg gram 16"], + category: "laptop", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=103451483", + fetchedAt: "2026-06", + tier: 1, + specs: { + weight_g: "1199", + ram_gb: "16", + storage_gb: "512", + launch_price_krw: "269만원", + release_date: "2026년 1월" + } + }, + { + id: "lg-gram-14", + canonicalName: "LG 그램 14", + aliases: ["lg 그램 14", "그램 14"], + category: "laptop", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=103451186", + fetchedAt: "2026-06", + tier: 1, + specs: { + weight_g: "1120", + ram_gb: "16", + storage_gb: "512", + launch_price_krw: "214만원", + release_date: "2026년 1월" + } + }, + { + id: "galaxy-book4-pro-14", + canonicalName: "갤럭시 북4 프로 14", + aliases: ["갤럭시북4 프로", "갤럭시 북4 프로", "galaxy book4 pro"], + category: "laptop", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=31347431", + fetchedAt: "2026-06", + tier: 1, + specs: { + weight_g: "1230", + chipset: "코어 울트라5", + ram_gb: "16", + storage_gb: "256", + launch_price_krw: "188만원", + release_date: "2024년 1월" + } + }, + { + id: "galaxy-book5-pro-14", + canonicalName: "갤럭시 북5 프로 14", + aliases: ["갤럭시북5 프로", "갤럭시 북5 프로", "galaxy book5 pro"], + category: "laptop", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=73614713", + fetchedAt: "2026-06", + tier: 1, + specs: { + weight_g: "1230", + chipset: "코어 울트라5(S2)", + ram_gb: "16", + storage_gb: "256", + launch_price_krw: "180만원", + release_date: "2025년 1월" + } + }, + { + id: "dell-xps-13", + canonicalName: "델 XPS 13", + aliases: ["델 xps 13", "dell xps 13"], + category: "laptop", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=20661845", + fetchedAt: "2026-06", + tier: 1, + specs: { + weight_g: "1230", + chipset: "코어i5-10세대", + ram_gb: "16", + storage_gb: "500", + release_date: "2023년 6월" + } + }, + { + id: "lenovo-thinkpad-x1-carbon", + canonicalName: "레노버 씽크패드 X1 카본", + aliases: ["씽크패드 x1", "thinkpad x1 carbon"], + category: "laptop", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=101072093", + fetchedAt: "2026-06", + tier: 1, + specs: { + chipset: "코어 울트라7(S2)", + ram_gb: "32", + storage_gb: "1024", + launch_price_krw: "416만원", + release_date: "2025년 11월" + } + }, + { + id: "asus-zenbook-14", + canonicalName: "에이수스 젠북 14", + aliases: ["젠북 14", "zenbook 14", "asus zenbook 14"], + category: "laptop", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=75537797", + fetchedAt: "2026-06", + tier: 1, + specs: { + chipset: "스냅드래곤 X", + ram_gb: "16", + storage_gb: "512", + launch_price_krw: "140만원", + release_date: "2025년 2월" + } + } +]; diff --git a/lib/specs/dataset/kr/smartphones.ts b/lib/specs/dataset/kr/smartphones.ts new file mode 100644 index 0000000..8709eb1 --- /dev/null +++ b/lib/specs/dataset/kr/smartphones.ts @@ -0,0 +1,455 @@ +/** + * 한국 시장 smartphone 스펙 데이터셋. + * + * 자동 수집: 다나와 (danawa.com) + * 수집일: 2026-06-09 + * + * ⚠️ 자동 생성 파일입니다. 수동 편집 시 재수집 시 덮어씌워집니다. + * 수집 명령: npx tsx scripts/collect-specs/index.ts smartphone KR + */ + +import type { VerifiedProduct } from "@/lib/specs/dataset/types"; + +export const smartphones: VerifiedProduct[] = [ + { + id: "iphone-16", + canonicalName: "아이폰 16", + aliases: ["아이폰 16", "아이폰16", "iphone 16"], + category: "smartphone", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=92149433", + fetchedAt: "2026-06", + tier: 1, + specs: { + display_inch: "6.1", + ram_gb: "8", + chipset: "A18", + battery: "3,561mAh", + charging: "30W", + water_resist: "IP68", + weight_g: "170", + launch_price_krw: "125만원", + release_date: "2024년 9월" + } + }, + { + id: "iphone-16-plus", + canonicalName: "아이폰 16 플러스", + aliases: ["아이폰 16 플러스", "아이폰16 플러스", "iphone 16 plus"], + category: "smartphone", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=65915945", + fetchedAt: "2026-06", + tier: 1, + specs: { + display_inch: "6.7", + ram_gb: "8", + chipset: "A18", + battery: "4,674mAh", + charging: "30W", + water_resist: "IP68", + weight_g: "199", + launch_price_krw: "150만원", + release_date: "2024년 9월" + } + }, + { + id: "iphone-16-pro", + canonicalName: "아이폰 16 프로", + aliases: ["아이폰 16 프로", "아이폰16 프로", "iphone 16 pro"], + category: "smartphone", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=65222552", + fetchedAt: "2026-06", + tier: 1, + specs: { + display_inch: "6.3", + ram_gb: "8", + chipset: "A18 Pro", + battery: "3,582mAh", + charging: "30W", + water_resist: "IP68", + weight_g: "199", + launch_price_krw: "170만원", + release_date: "2024년 9월" + } + }, + { + id: "iphone-16-pro-max", + canonicalName: "아이폰 16 프로 맥스", + aliases: ["아이폰 16 프로 맥스", "아이폰16 프로맥스", "iphone 16 pro max"], + category: "smartphone", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=65915921", + fetchedAt: "2026-06", + tier: 1, + specs: { + display_inch: "6.9", + ram_gb: "8", + chipset: "A18 Pro", + battery: "4,685mAh", + charging: "30W", + water_resist: "IP68", + weight_g: "227", + launch_price_krw: "220만원", + release_date: "2024년 9월" + } + }, + { + id: "iphone-15", + canonicalName: "아이폰 15", + aliases: ["아이폰 15", "아이폰15", "iphone 15"], + category: "smartphone", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=63139238", + fetchedAt: "2026-06", + tier: 1, + specs: { + display_inch: "6.1", + ram_gb: "6", + chipset: "A16 Bionic", + battery: "3,349mAh", + charging: "20W", + water_resist: "IP68", + weight_g: "171", + launch_price_krw: "125만원", + release_date: "2023년 9월" + } + }, + { + id: "iphone-15-plus", + canonicalName: "아이폰 15 플러스", + aliases: ["아이폰 15 플러스", "iphone 15 plus"], + category: "smartphone", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=48649022", + fetchedAt: "2026-06", + tier: 1, + specs: { + display_inch: "6.7", + ram_gb: "6", + chipset: "A16 Bionic", + battery: "4,383mAh", + charging: "20W", + water_resist: "IP68", + weight_g: "201", + launch_price_krw: "150만원", + release_date: "2023년 9월" + } + }, + { + id: "iphone-15-pro", + canonicalName: "아이폰 15 프로", + aliases: ["아이폰 15 프로", "iphone 15 pro"], + category: "smartphone", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=28209767", + fetchedAt: "2026-06", + tier: 1, + specs: { + display_inch: "6.1", + ram_gb: "8", + chipset: "A17 Pro", + battery: "3,274mAh", + charging: "23W", + water_resist: "IP68", + weight_g: "187", + launch_price_krw: "170만원", + release_date: "2023년 9월" + } + }, + { + id: "iphone-15-pro-max", + canonicalName: "아이폰 15 프로 맥스", + aliases: ["아이폰 15 프로 맥스", "iphone 15 pro max"], + category: "smartphone", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=28189334", + fetchedAt: "2026-06", + tier: 1, + specs: { + display_inch: "6.7", + ram_gb: "8", + chipset: "A17 Pro", + battery: "4,422mAh", + charging: "23W", + water_resist: "IP68", + weight_g: "221", + launch_price_krw: "190만원", + release_date: "2023년 9월" + } + }, + { + id: "iphone-14", + canonicalName: "아이폰 14", + aliases: ["아이폰 14", "iphone 14"], + category: "smartphone", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=96773444", + fetchedAt: "2026-06", + tier: 1, + specs: { + display_inch: "6.1", + ram_gb: "6", + chipset: "A15 Bionic", + battery: "3,279mAh", + charging: "20W", + water_resist: "IP68", + weight_g: "172", + launch_price_krw: "125만원", + release_date: "2022년 9월" + } + }, + { + id: "iphone-14-pro", + canonicalName: "아이폰 14 프로", + aliases: ["아이폰 14 프로", "iphone 14 pro"], + category: "smartphone", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=96774518", + fetchedAt: "2026-06", + tier: 1, + specs: { + display_inch: "6.1", + ram_gb: "6", + chipset: "A16 Bionic", + battery: "3,200mAh", + charging: "23W", + water_resist: "IP68", + weight_g: "206", + launch_price_krw: "170만원", + release_date: "2022년 9월" + } + }, + { + id: "iphone-14-pro-max", + canonicalName: "아이폰 14 프로 맥스", + aliases: ["아이폰 14 프로 맥스", "iphone 14 pro max"], + category: "smartphone", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=96310055", + fetchedAt: "2026-06", + tier: 1, + specs: { + display_inch: "6.7", + ram_gb: "6", + chipset: "A16 Bionic", + battery: "4,323mAh", + charging: "23W", + water_resist: "IP68", + weight_g: "240", + launch_price_krw: "190만원", + release_date: "2022년 9월" + } + }, + { + id: "galaxy-s25", + canonicalName: "갤럭시 S25", + aliases: ["갤럭시 s25", "갤럭시s25", "galaxy s25"], + category: "smartphone", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=75001853", + fetchedAt: "2026-06", + tier: 1, + specs: { + display_inch: "6.2", + ram_gb: "12", + chipset: "스냅드래곤8 엘리트", + battery: "4,000mAh", + charging: "25W", + water_resist: "IP68", + weight_g: "162", + launch_price_krw: "116만원", + release_date: "2025년 1월" + } + }, + { + id: "galaxy-s25-plus", + canonicalName: "갤럭시 S25+", + aliases: ["갤럭시 s25+", "갤럭시 s25 플러스", "galaxy s25+", "galaxy s25 plus"], + category: "smartphone", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=102125705", + fetchedAt: "2026-06", + tier: 1, + specs: { + display_inch: "6.7", + ram_gb: "12", + chipset: "스냅드래곤8 엘리트", + battery: "4,900mAh", + charging: "45W", + water_resist: "IP68", + weight_g: "190", + launch_price_krw: "135만원", + release_date: "2025년 1월" + } + }, + { + id: "galaxy-s25-ultra", + canonicalName: "갤럭시 S25 울트라", + aliases: ["갤럭시 s25 울트라", "galaxy s25 ultra"], + category: "smartphone", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=102126383", + fetchedAt: "2026-06", + tier: 1, + specs: { + display_inch: "6.9", + ram_gb: "12", + chipset: "스냅드래곤8 엘리트", + battery: "5,000mAh", + charging: "45W", + water_resist: "IP68", + weight_g: "218", + launch_price_krw: "170만원", + release_date: "2025년 1월" + } + }, + { + id: "galaxy-s24", + canonicalName: "갤럭시 S24", + aliases: ["갤럭시 s24", "galaxy s24"], + category: "smartphone", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=63138947", + fetchedAt: "2026-06", + tier: 1, + specs: { + display_inch: "6.2", + ram_gb: "8", + chipset: "엑시노스2400", + battery: "4,000mAh", + charging: "25W", + water_resist: "IP68", + weight_g: "168", + launch_price_krw: "116만원", + release_date: "2024년 1월" + } + }, + { + id: "galaxy-s24-plus", + canonicalName: "갤럭시 S24+", + aliases: ["갤럭시 s24+", "갤럭시 s24 플러스", "galaxy s24+", "galaxy s24 plus"], + category: "smartphone", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=63138968", + fetchedAt: "2026-06", + tier: 1, + specs: { + display_inch: "6.7", + ram_gb: "12", + chipset: "엑시노스2400", + battery: "4,900mAh", + charging: "45W", + water_resist: "IP68", + weight_g: "197", + launch_price_krw: "135만원", + release_date: "2024년 1월" + } + }, + { + id: "galaxy-s24-ultra", + canonicalName: "갤럭시 S24 울트라", + aliases: ["갤럭시 s24 울트라", "galaxy s24 ultra"], + category: "smartphone", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=63138974", + fetchedAt: "2026-06", + tier: 1, + specs: { + display_inch: "6.8", + ram_gb: "12", + chipset: "스냅드래곤8 Gen3", + battery: "5,000mAh", + charging: "45W", + water_resist: "IP68", + weight_g: "233", + launch_price_krw: "170만원", + release_date: "2024년 1월" + } + }, + { + id: "galaxy-s23", + canonicalName: "갤럭시 S23", + aliases: ["갤럭시 s23", "galaxy s23"], + category: "smartphone", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=96259997", + fetchedAt: "2026-06", + tier: 1, + specs: { + display_inch: "6.1", + ram_gb: "8", + chipset: "스냅드래곤8 Gen2", + battery: "3,900mAh", + charging: "25W", + water_resist: "IP68", + weight_g: "167", + launch_price_krw: "116만원", + release_date: "2023년 2월" + } + }, + { + id: "galaxy-s23-ultra", + canonicalName: "갤럭시 S23 울트라", + aliases: ["갤럭시 s23 울트라", "galaxy s23 ultra"], + category: "smartphone", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=20624228", + fetchedAt: "2026-06", + tier: 1, + specs: { + display_inch: "6.8", + ram_gb: "12", + chipset: "스냅드래곤8 Gen2", + battery: "5,000mAh", + charging: "45W", + water_resist: "IP68", + weight_g: "233", + launch_price_krw: "160만원", + release_date: "2023년 2월" + } + }, + { + id: "galaxy-z-fold6", + canonicalName: "갤럭시 Z 폴드6", + aliases: ["갤럭시 z 폴드6", "갤럭시 폴드6", "galaxy z fold 6"], + category: "smartphone", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=58009616", + fetchedAt: "2026-06", + tier: 1, + specs: { + display_inch: "6.3", + chipset: "스냅드래곤8 Gen3", + ram_gb: "12", + battery: "4,400mAh", + charging: "25W", + water_resist: "IP48", + weight_g: "239", + launch_price_krw: "223만원", + release_date: "2024년 7월" + } + }, + { + id: "galaxy-z-flip6", + canonicalName: "갤럭시 Z 플립6", + aliases: ["갤럭시 z 플립6", "갤럭시 플립6", "galaxy z flip 6"], + category: "smartphone", + country: "KR", + source: "https://prod.danawa.com/info/?pcode=70097453", + fetchedAt: "2026-06", + tier: 1, + specs: { + display_inch: "3.4", + ram_gb: "12", + chipset: "스냅드래곤8 Gen3", + battery: "4,000mAh", + charging: "25W", + water_resist: "IP48", + weight_g: "187", + launch_price_krw: "149만원", + release_date: "2024년 7월" + } + } +]; diff --git a/lib/specs/dataset/laptops-apple.ts b/lib/specs/dataset/laptops-apple.ts deleted file mode 100644 index aaa9a7b..0000000 --- a/lib/specs/dataset/laptops-apple.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { appleAirLaptops } from "./laptops-apple-air"; -import { appleProLaptops } from "./laptops-apple-pro"; -import type { VerifiedProduct } from "./types"; - -export const appleLaptops: VerifiedProduct[] = [ - ...appleAirLaptops, - ...appleProLaptops -]; diff --git a/lib/specs/dataset/laptops-lg.ts b/lib/specs/dataset/laptops-lg.ts deleted file mode 100644 index 0834332..0000000 --- a/lib/specs/dataset/laptops-lg.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { VerifiedProduct } from "./types"; - -export const lgLaptops: VerifiedProduct[] = [ - // ── LG 그램 ────────────────────────────────────────────────────────────── - { - id: "lg-gram-16", - canonicalName: "LG 그램 16", - nameEn: "LG gram 16", - aliases: ["lg 그램 16", "그램 16", "그램16", "lg그램16", "lg 그램16", "lg gram 16", "gram 16", "lg 그램", "그램", "lg gram"], - category: "laptop", - country: "KR", - source: "https://www.lge.co.kr/notebooks", - fetchedAt: "2026-06", - tier: 1, - specs: { - model_name: "LG gram 16", os: "Windows 11", cpu: "Intel Core Ultra 7 155H", gpu: "Intel Arc Graphics", - ram_gb: "16", storage_gb: "512", display_inch: "16", brightness_nits: "350", panel: "IPS", - resolution: "2560×1600", refresh_hz: "60", weight_g: "1199", battery_wh: "80", - ports: "Thunderbolt 4 ×2, USB-A ×2, HDMI, USB-C", launch_price_krw: "169만원부터", release_date: "2024년 1월" - } - }, - { - id: "lg-gram-14", - canonicalName: "LG 그램 14", - nameEn: "LG gram 14", - aliases: ["lg 그램 14", "그램 14", "그램14", "lg그램14", "lg 그램14", "lg gram 14", "gram 14"], - category: "laptop", - country: "KR", - source: "https://www.lge.co.kr/notebooks", - fetchedAt: "2026-06", - tier: 1, - specs: { - model_name: "LG gram 14", os: "Windows 11", cpu: "Intel Core Ultra 7 155H", gpu: "Intel Arc Graphics", - ram_gb: "16", storage_gb: "512", display_inch: "14", brightness_nits: "350", panel: "IPS", - resolution: "1920×1200", refresh_hz: "60", weight_g: "980", battery_wh: "72", - ports: "Thunderbolt 4 ×2, USB-A ×2, HDMI", launch_price_krw: "149만원부터", release_date: "2024년 1월" - } - }, - - // ══════════════════════════════════════════════════════════════════════════ - // LG 그램 Pro (2024) - // ══════════════════════════════════════════════════════════════════════════ - { - id: "lg-gram-pro-16", - canonicalName: "LG 그램 Pro 16", - nameEn: "LG gram Pro 16", - aliases: ["lg 그램 프로 16", "lg그램프로16", "lg gram pro 16", "그램 프로 16", "그램프로16", "gram pro 16"], - category: "laptop", - country: "KR", - source: "https://www.lg.com/kr/laptops/lg-gram/", - fetchedAt: "2026-06", - tier: 1, - specs: { - model_name: "LG gram Pro 16 (2024)", os: "Windows 11", cpu: "Intel Core Ultra 7 155H", gpu: "Intel Arc Graphics", - ram_gb: "16", storage_gb: "512", display_inch: "16.0", brightness_nits: "350", panel: "IPS (Anti-Glare)", - resolution: "2560×1600", refresh_hz: "60", weight_g: "1199", battery_wh: "80", - ports: "Thunderbolt 4 ×2, USB-C, USB-A ×2, HDMI, MicroSD", launch_price_krw: "189만원부터", release_date: "2024년 1월" - } - }, - { - id: "lg-gram-pro-14", - canonicalName: "LG 그램 Pro 14", - nameEn: "LG gram Pro 14", - aliases: ["lg 그램 프로 14", "lg그램프로14", "lg gram pro 14", "그램 프로 14", "그램프로14", "gram pro 14"], - category: "laptop", - country: "KR", - source: "https://www.lg.com/kr/laptops/lg-gram/", - fetchedAt: "2026-06", - tier: 1, - specs: { - model_name: "LG gram Pro 14 (2024)", os: "Windows 11", cpu: "Intel Core Ultra 7 155H", gpu: "Intel Arc Graphics", - ram_gb: "16", storage_gb: "512", display_inch: "14.0", brightness_nits: "350", panel: "IPS (Anti-Glare)", - resolution: "2560×1600", refresh_hz: "60", weight_g: "980", battery_wh: "72", - ports: "Thunderbolt 4 ×2, USB-C, USB-A ×2, HDMI, MicroSD", launch_price_krw: "169만원부터", release_date: "2024년 1월" - } - } -]; diff --git a/lib/specs/dataset/laptops.ts b/lib/specs/dataset/laptops.ts index 9e6c84d..a50e938 100644 --- a/lib/specs/dataset/laptops.ts +++ b/lib/specs/dataset/laptops.ts @@ -1,15 +1,555 @@ -import { appleLaptops } from "./laptops-apple"; -import { lgLaptops } from "./laptops-lg"; -import { samsungLaptops } from "./laptops-samsung"; import type { VerifiedProduct } from "./types"; /** - * 노트북 검증 데이터셋 — 공식/검증 소스 기반 수동 데이터. + * 노트북 검증 데이터셋 — 완전 하드코딩 (2023년 이후 주요 모델). * - * 세부 제조사 데이터는 파일 크기와 충돌을 줄이기 위해 분리한다. + * 출처: 각 제조사 공식 페이지 (Apple KR, Samsung SEC, LG). 기본 구성 기준. + * canonicalName=한국어, nameEn=영어(검색·표시). */ export const laptops: VerifiedProduct[] = [ - ...appleLaptops, - ...lgLaptops, - ...samsungLaptops + + // ── MacBook Air M4 (2025) ──────────────────────────────────────────────── + { + id: "macbook-air-13-m4", + canonicalName: "맥북 에어 13 M4", + nameEn: "MacBook Air 13 M4", + aliases: [ + "맥북 에어 m4", "맥북에어 m4", "맥북에어m4", "맥북 에어 13 m4", "맥북에어 13 m4", + "맥북 에어 13", "맥북에어13", "macbook air m4", "macbook air 13 m4", "macbook air 13", + "맥북 에어", "맥북에어" + ], + category: "laptop", + country: "KR", + source: "https://www.apple.com/kr/macbook-air/specs/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "MacBook Air 13-inch M4", os: "macOS", cpu: "Apple M4 (10코어 CPU)", gpu: "Apple M4 (10코어 GPU)", + ram_gb: "16", storage_gb: "256", display_inch: "13.6", brightness_nits: "500", panel: "IPS (Liquid Retina)", + resolution: "2560×1664", refresh_hz: "60", weight_g: "1240", battery_wh: "52.6", + ports: "Thunderbolt 4 ×2, MagSafe 3, 3.5mm 헤드폰", launch_price_krw: "149만 9천원부터", release_date: "2025년 3월" + } + }, + { + id: "macbook-air-15-m4", + canonicalName: "맥북 에어 15 M4", + nameEn: "MacBook Air 15 M4", + aliases: ["맥북 에어 15 m4", "맥북에어 15 m4", "맥북에어15m4", "맥북 에어 15", "맥북에어15", "macbook air 15 m4", "macbook air 15"], + category: "laptop", + country: "KR", + source: "https://www.apple.com/kr/macbook-air/specs/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "MacBook Air 15-inch M4", os: "macOS", cpu: "Apple M4 (10코어 CPU)", gpu: "Apple M4 (10코어 GPU)", + ram_gb: "16", storage_gb: "256", display_inch: "15.3", brightness_nits: "500", panel: "IPS (Liquid Retina)", + resolution: "2880×1864", refresh_hz: "60", weight_g: "1510", battery_wh: "66.5", + ports: "Thunderbolt 4 ×2, MagSafe 3, 3.5mm 헤드폰", launch_price_krw: "179만 9천원부터", release_date: "2025년 3월" + } + }, + + // ── MacBook Air M3 (2024) ──────────────────────────────────────────────── + { + id: "macbook-air-13-m3", + canonicalName: "맥북 에어 13 M3", + nameEn: "MacBook Air 13 M3", + aliases: ["맥북 에어 m3", "맥북에어 m3", "맥북에어m3", "맥북 에어 13 m3", "맥북에어 13 m3", "macbook air m3", "macbook air 13 m3"], + category: "laptop", + country: "KR", + source: "https://www.apple.com/kr/macbook-air/specs/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "MacBook Air 13-inch M3", os: "macOS", cpu: "Apple M3 (8코어 CPU)", gpu: "Apple M3 (10코어 GPU)", + ram_gb: "8", storage_gb: "256", display_inch: "13.6", brightness_nits: "500", panel: "IPS (Liquid Retina)", + resolution: "2560×1664", refresh_hz: "60", weight_g: "1240", battery_wh: "52.6", + ports: "Thunderbolt 3 ×2, MagSafe 3, 3.5mm 헤드폰", launch_price_krw: "149만 9천원부터", release_date: "2024년 3월" + } + }, + { + id: "macbook-air-15-m3", + canonicalName: "맥북 에어 15 M3", + nameEn: "MacBook Air 15 M3", + aliases: ["맥북 에어 15 m3", "맥북에어 15 m3", "맥북에어15m3", "macbook air 15 m3"], + category: "laptop", + country: "KR", + source: "https://www.apple.com/kr/macbook-air/specs/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "MacBook Air 15-inch M3", os: "macOS", cpu: "Apple M3 (8코어 CPU)", gpu: "Apple M3 (10코어 GPU)", + ram_gb: "8", storage_gb: "256", display_inch: "15.3", brightness_nits: "500", panel: "IPS (Liquid Retina)", + resolution: "2880×1864", refresh_hz: "60", weight_g: "1510", battery_wh: "66.5", + ports: "Thunderbolt 3 ×2, MagSafe 3, 3.5mm 헤드폰", launch_price_krw: "169만 9천원부터", release_date: "2024년 3월" + } + }, + + // ── MacBook Pro M4 (2024) ──────────────────────────────────────────────── + { + id: "macbook-pro-14-m4", + canonicalName: "맥북 프로 14 M4", + nameEn: "MacBook Pro 14 M4", + aliases: [ + "맥북 프로 14 m4", "맥북프로 14 m4", "맥북프로14m4", "맥북 프로 m4", "맥북프로 m4", "맥북프로m4", + "맥북 프로 14", "맥북프로14", "macbook pro 14 m4", "macbook pro m4", "macbook pro 14", + "맥북 프로", "맥북프로" + ], + category: "laptop", + country: "KR", + source: "https://www.apple.com/kr/macbook-pro/specs/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "MacBook Pro 14-inch M4", os: "macOS", cpu: "Apple M4 (10코어 CPU)", gpu: "Apple M4 (10코어 GPU)", + ram_gb: "16", storage_gb: "512", display_inch: "14.2", brightness_nits: "1000", panel: "OLED (Liquid Retina XDR)", + resolution: "3024×1964", refresh_hz: "120", weight_g: "1610", battery_wh: "72.4", + ports: "Thunderbolt 4 ×3, HDMI, SD카드, MagSafe 3, 3.5mm 헤드폰", launch_price_krw: "199만 9천원부터", release_date: "2024년 11월" + } + }, + { + id: "macbook-pro-16-m4", + canonicalName: "맥북 프로 16 M4", + nameEn: "MacBook Pro 16 M4", + aliases: ["맥북 프로 16 m4", "맥북프로 16 m4", "맥북프로16m4", "맥북 프로 16", "맥북프로16", "macbook pro 16 m4", "macbook pro 16"], + category: "laptop", + country: "KR", + source: "https://www.apple.com/kr/macbook-pro/specs/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "MacBook Pro 16-inch M4", os: "macOS", cpu: "Apple M4 (10코어 CPU)", gpu: "Apple M4 (10코어 GPU)", + ram_gb: "24", storage_gb: "512", display_inch: "16.2", brightness_nits: "1000", panel: "OLED (Liquid Retina XDR)", + resolution: "3456×2234", refresh_hz: "120", weight_g: "2140", battery_wh: "99.6", + ports: "Thunderbolt 4 ×3, HDMI, SD카드, MagSafe 3, 3.5mm 헤드폰", launch_price_krw: "299만 9천원부터", release_date: "2024년 11월" + } + }, + + // ── MacBook Pro M3 (2023) ──────────────────────────────────────────────── + { + id: "macbook-pro-14-m3", + canonicalName: "맥북 프로 14 M3", + nameEn: "MacBook Pro 14 M3", + aliases: ["맥북 프로 14 m3", "맥북프로 14 m3", "맥북프로14m3", "macbook pro 14 m3"], + category: "laptop", + country: "KR", + source: "https://www.apple.com/kr/macbook-pro/specs/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "MacBook Pro 14-inch M3", os: "macOS", cpu: "Apple M3 (8코어 CPU)", gpu: "Apple M3 (10코어 GPU)", + ram_gb: "8", storage_gb: "512", display_inch: "14.2", brightness_nits: "1000", panel: "OLED (Liquid Retina XDR)", + resolution: "3024×1964", refresh_hz: "120", weight_g: "1610", battery_wh: "72.4", + ports: "Thunderbolt 3 ×3, HDMI, SD카드, MagSafe 3, 3.5mm 헤드폰", launch_price_krw: "199만 9천원부터", release_date: "2023년 11월" + } + }, + { + id: "macbook-pro-16-m3", + canonicalName: "맥북 프로 16 M3", + nameEn: "MacBook Pro 16 M3", + aliases: ["맥북 프로 16 m3", "맥북프로 16 m3", "맥북프로16m3", "macbook pro 16 m3"], + category: "laptop", + country: "KR", + source: "https://www.apple.com/kr/macbook-pro/specs/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "MacBook Pro 16-inch M3 Pro", os: "macOS", cpu: "Apple M3 Pro (12코어 CPU)", gpu: "Apple M3 Pro (18코어 GPU)", + ram_gb: "18", storage_gb: "512", display_inch: "16.2", brightness_nits: "1000", panel: "OLED (Liquid Retina XDR)", + resolution: "3456×2234", refresh_hz: "120", weight_g: "2140", battery_wh: "99.6", + ports: "Thunderbolt 3 ×3, HDMI, SD카드, MagSafe 3, 3.5mm 헤드폰", launch_price_krw: "319만 9천원부터", release_date: "2023년 11월" + } + }, + + // ── LG 그램 ────────────────────────────────────────────────────────────── + { + id: "lg-gram-16", + canonicalName: "LG 그램 16", + nameEn: "LG gram 16", + aliases: ["lg 그램 16", "그램 16", "그램16", "lg그램16", "lg 그램16", "lg gram 16", "gram 16", "lg 그램", "그램", "lg gram"], + category: "laptop", + country: "KR", + source: "https://www.lge.co.kr/notebooks", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "LG gram 16", os: "Windows 11", cpu: "Intel Core Ultra 7 155H", gpu: "Intel Arc Graphics", + ram_gb: "16", storage_gb: "512", display_inch: "16", brightness_nits: "350", panel: "IPS", + resolution: "2560×1600", refresh_hz: "60", weight_g: "1199", battery_wh: "80", + ports: "Thunderbolt 4 ×2, USB-A ×2, HDMI, USB-C", launch_price_krw: "169만원부터", release_date: "2024년 1월" + } + }, + { + id: "lg-gram-14", + canonicalName: "LG 그램 14", + nameEn: "LG gram 14", + aliases: ["lg 그램 14", "그램 14", "그램14", "lg그램14", "lg 그램14", "lg gram 14", "gram 14"], + category: "laptop", + country: "KR", + source: "https://www.lge.co.kr/notebooks", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "LG gram 14", os: "Windows 11", cpu: "Intel Core Ultra 7 155H", gpu: "Intel Arc Graphics", + ram_gb: "16", storage_gb: "512", display_inch: "14", brightness_nits: "350", panel: "IPS", + resolution: "1920×1200", refresh_hz: "60", weight_g: "980", battery_wh: "72", + ports: "Thunderbolt 4 ×2, USB-A ×2, HDMI", launch_price_krw: "149만원부터", release_date: "2024년 1월" + } + }, + + // ── Samsung Galaxy Book ────────────────────────────────────────────────── + { + id: "galaxy-book4-pro-14", + canonicalName: "갤럭시 북4 프로 14", + nameEn: "Galaxy Book4 Pro 14", + aliases: ["갤럭시북4 프로", "갤럭시 북4 프로", "갤럭시북4프로", "갤럭시북 프로", "galaxy book4 pro", "galaxy book4 pro 14", "갤럭시북4", "갤럭시 북4", "갤럭시북", "갤럭시 북"], + category: "laptop", + country: "KR", + source: "https://www.samsung.com/sec/notebooks/galaxy-book4-pro/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy Book4 Pro 14", os: "Windows 11", cpu: "Intel Core Ultra 7 155H", gpu: "Intel Arc Graphics", + ram_gb: "16", storage_gb: "512", display_inch: "14", brightness_nits: "400", panel: "OLED (Dynamic AMOLED 2X)", + resolution: "2880×1800", refresh_hz: "120", weight_g: "1170", battery_wh: "63", + ports: "Thunderbolt 4 ×2, USB-A, HDMI, microSD", launch_price_krw: "189만 8천원부터", release_date: "2024년 1월" + } + }, + { + id: "galaxy-book5-pro-14", + canonicalName: "갤럭시 북5 프로 14", + nameEn: "Galaxy Book5 Pro 14", + aliases: ["갤럭시북5 프로", "갤럭시 북5 프로", "갤럭시북5프로", "galaxy book5 pro", "galaxy book5 pro 14", "갤럭시북5", "갤럭시 북5"], + category: "laptop", + country: "KR", + source: "https://www.samsung.com/sec/notebooks/galaxy-book5-pro/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy Book5 Pro 14", os: "Windows 11", cpu: "Intel Core Ultra 7 256V", gpu: "Intel Arc Graphics 140V", + ram_gb: "16", storage_gb: "512", display_inch: "14", brightness_nits: "400", panel: "OLED (Dynamic AMOLED 2X)", + resolution: "2880×1800", refresh_hz: "120", weight_g: "1230", battery_wh: "61.8", + ports: "Thunderbolt 4 ×2, USB-A, HDMI 2.1, microSD", launch_price_krw: "219만 9천원부터", release_date: "2025년 1월" + } + }, + { + id: "galaxy-book6-pro-14", + canonicalName: "갤럭시 북6 프로 14", + nameEn: "Galaxy Book6 Pro 14", + aliases: [ + "갤럭시북6 프로", "갤럭시 북6 프로", "갤럭시북6프로", "갤럭시 북6 프로 14", "갤럭시북6 프로 14", + "galaxy book6 pro", "galaxy book6 pro 14", "갤럭시북6", "갤럭시 북6" + ], + category: "laptop", + country: "KR", + source: "https://www.samsung.com/us/computers/galaxy-book/galaxy-book6-pro/", + fetchedAt: "2026-07", + tier: 1, + specs: { + model_name: "Galaxy Book6 Pro 14", os: "Windows 11 Home", cpu: "Intel Core Ultra 7 356H", gpu: "Intel Arc Graphics", + ram_gb: "16", storage_gb: "512", display_inch: "14", brightness_nits: "400", panel: "OLED (Dynamic AMOLED 2X)", + resolution: "2880×1800", refresh_hz: "120", weight_g: "1240", battery_wh: "67.18", + ports: "Thunderbolt 4 ×2, USB-A, HDMI 2.1, headphone", release_date: "2026년" + } + }, + { + id: "galaxy-book6-pro-16", + canonicalName: "갤럭시 북6 프로 16", + nameEn: "Galaxy Book6 Pro 16", + aliases: [ + "갤럭시북6 프로 16", "갤럭시 북6 프로 16", "갤럭시북6프로16", "galaxy book6 pro 16", "북6 프로 16" + ], + category: "laptop", + country: "KR", + source: "https://www.samsung.com/us/computers/galaxy-book/galaxy-book6-pro/", + fetchedAt: "2026-07", + tier: 1, + specs: { + model_name: "Galaxy Book6 Pro 16", os: "Windows 11 Home", cpu: "Intel Core Ultra X7 358H", gpu: "Intel Arc Graphics", + ram_gb: "32", storage_gb: "1024", display_inch: "16", brightness_nits: "400", panel: "OLED (Dynamic AMOLED 2X)", + resolution: "2880×1800", refresh_hz: "120", weight_g: "1590", battery_wh: "78.07", + ports: "Thunderbolt 4 ×2, USB-A, HDMI 2.1, headphone", release_date: "2026년" + } + }, + + // ══════════════════════════════════════════════════════════════════════════ + // MacBook Air M1 / M2 (2020–2023) + // ══════════════════════════════════════════════════════════════════════════ + { + id: "macbook-air-13-m1", + canonicalName: "맥북 에어 13 M1", + nameEn: "MacBook Air 13 M1", + aliases: [ + "맥북 에어 m1", "맥북에어 m1", "맥북에어m1", "맥북 에어 13 m1", "맥북에어 13 m1", + "macbook air m1", "macbook air 13 m1", "맥북 에어 2020", "맥북에어 2020" + ], + category: "laptop", + country: "KR", + source: "https://support.apple.com/ko-kr/111893", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "MacBook Air 13-inch M1", os: "macOS", cpu: "Apple M1 (8코어 CPU)", gpu: "Apple M1 (7코어 GPU)", + ram_gb: "8", storage_gb: "256", display_inch: "13.3", brightness_nits: "400", panel: "IPS (Retina)", + resolution: "2560×1600", refresh_hz: "60", weight_g: "1290", battery_wh: "49.9", + ports: "Thunderbolt / USB 4 ×2, 3.5mm 헤드폰", launch_price_krw: "149만원부터", release_date: "2020년 11월" + } + }, + { + id: "macbook-air-13-m2", + canonicalName: "맥북 에어 13 M2", + nameEn: "MacBook Air 13 M2", + aliases: [ + "맥북 에어 m2", "맥북에어 m2", "맥북에어m2", "맥북 에어 13 m2", "맥북에어 13 m2", + "macbook air m2", "macbook air 13 m2", "맥북 에어 2022", "맥북에어 2022" + ], + category: "laptop", + country: "KR", + source: "https://support.apple.com/ko-kr/111893", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "MacBook Air 13-inch M2", os: "macOS", cpu: "Apple M2 (8코어 CPU)", gpu: "Apple M2 (8코어 GPU)", + ram_gb: "8", storage_gb: "256", display_inch: "13.6", brightness_nits: "500", panel: "IPS (Liquid Retina)", + resolution: "2560×1664", refresh_hz: "60", weight_g: "1240", battery_wh: "52.6", + ports: "Thunderbolt 4 ×2, MagSafe 3, 3.5mm 헤드폰", launch_price_krw: "149만원부터", release_date: "2022년 6월" + } + }, + { + id: "macbook-air-15-m2", + canonicalName: "맥북 에어 15 M2", + nameEn: "MacBook Air 15 M2", + aliases: ["맥북 에어 15 m2", "맥북에어 15 m2", "맥북에어15m2", "macbook air 15 m2", "맥북 에어 15 2023", "맥북에어15 2023"], + category: "laptop", + country: "KR", + source: "https://support.apple.com/ko-kr/111893", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "MacBook Air 15-inch M2", os: "macOS", cpu: "Apple M2 (8코어 CPU)", gpu: "Apple M2 (10코어 GPU)", + ram_gb: "8", storage_gb: "256", display_inch: "15.3", brightness_nits: "500", panel: "IPS (Liquid Retina)", + resolution: "2880×1864", refresh_hz: "60", weight_g: "1510", battery_wh: "66.5", + ports: "Thunderbolt 4 ×2, MagSafe 3, 3.5mm 헤드폰", launch_price_krw: "169만원부터", release_date: "2023년 6월" + } + }, + + // ══════════════════════════════════════════════════════════════════════════ + // MacBook Pro M1 (2020–2021) + // ══════════════════════════════════════════════════════════════════════════ + { + id: "macbook-pro-13-m1", + canonicalName: "맥북 프로 13 M1", + nameEn: "MacBook Pro 13 M1", + aliases: [ + "맥북 프로 m1", "맥북프로 m1", "맥북프로m1", "맥북 프로 13 m1", "맥북프로 13 m1", + "macbook pro m1", "macbook pro 13 m1", "맥북 프로 2020", "맥북프로 2020" + ], + category: "laptop", + country: "KR", + source: "https://support.apple.com/ko-kr/111901", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "MacBook Pro 13-inch M1", os: "macOS", cpu: "Apple M1 (8코어 CPU)", gpu: "Apple M1 (8코어 GPU)", + ram_gb: "8", storage_gb: "256", display_inch: "13.3", brightness_nits: "500", panel: "IPS (Retina)", + resolution: "2560×1600", refresh_hz: "60", weight_g: "1400", battery_wh: "58.2", + ports: "Thunderbolt / USB 4 ×2, 3.5mm 헤드폰", launch_price_krw: "179만원부터", release_date: "2020년 11월" + } + }, + { + id: "macbook-pro-14-m1", + canonicalName: "맥북 프로 14 M1", + nameEn: "MacBook Pro 14 M1", + aliases: [ + "맥북 프로 14 m1", "맥북프로 14 m1", "맥북프로14m1", "macbook pro 14 m1", + "맥북 프로 14 2021", "맥북프로 14 2021", "맥북 프로 14인치 m1" + ], + category: "laptop", + country: "KR", + source: "https://support.apple.com/ko-kr/111901", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "MacBook Pro 14-inch M1 Pro", os: "macOS", cpu: "Apple M1 Pro (10코어 CPU)", gpu: "Apple M1 Pro (16코어 GPU)", + ram_gb: "16", storage_gb: "512", display_inch: "14.2", brightness_nits: "1600", panel: "미니LED (Liquid Retina XDR)", + resolution: "3024×1964", refresh_hz: "120", weight_g: "1600", battery_wh: "69.6", + ports: "Thunderbolt 4 ×3, MagSafe 3, HDMI, SD, 3.5mm 헤드폰", launch_price_krw: "279만원부터", release_date: "2021년 10월" + } + }, + { + id: "macbook-pro-16-m1", + canonicalName: "맥북 프로 16 M1", + nameEn: "MacBook Pro 16 M1", + aliases: [ + "맥북 프로 16 m1", "맥북프로 16 m1", "맥북프로16m1", "macbook pro 16 m1", + "맥북 프로 16 2021", "맥북프로 16 2021", "맥북 프로 16인치 m1" + ], + category: "laptop", + country: "KR", + source: "https://support.apple.com/ko-kr/111901", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "MacBook Pro 16-inch M1 Pro", os: "macOS", cpu: "Apple M1 Pro (10코어 CPU)", gpu: "Apple M1 Pro (16코어 GPU)", + ram_gb: "16", storage_gb: "512", display_inch: "16.2", brightness_nits: "1600", panel: "미니LED (Liquid Retina XDR)", + resolution: "3456×2234", refresh_hz: "120", weight_g: "2100", battery_wh: "99.6", + ports: "Thunderbolt 4 ×3, MagSafe 3, HDMI, SD, 3.5mm 헤드폰", launch_price_krw: "329만원부터", release_date: "2021년 10월" + } + }, + + // ══════════════════════════════════════════════════════════════════════════ + // MacBook Pro M2 (2022–2023) + // ══════════════════════════════════════════════════════════════════════════ + { + id: "macbook-pro-13-m2", + canonicalName: "맥북 프로 13 M2", + nameEn: "MacBook Pro 13 M2", + aliases: [ + "맥북 프로 m2", "맥북프로 m2", "맥북프로m2", "맥북 프로 13 m2", "맥북프로 13 m2", + "macbook pro m2", "macbook pro 13 m2", "맥북 프로 2022", "맥북프로 2022" + ], + category: "laptop", + country: "KR", + source: "https://support.apple.com/ko-kr/111901", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "MacBook Pro 13-inch M2", os: "macOS", cpu: "Apple M2 (8코어 CPU)", gpu: "Apple M2 (10코어 GPU)", + ram_gb: "8", storage_gb: "256", display_inch: "13.3", brightness_nits: "500", panel: "IPS (Retina)", + resolution: "2560×1600", refresh_hz: "60", weight_g: "1400", battery_wh: "58.2", + ports: "Thunderbolt 4 ×2, 3.5mm 헤드폰", launch_price_krw: "169만원부터", release_date: "2022년 6월" + } + }, + { + id: "macbook-pro-14-m2", + canonicalName: "맥북 프로 14 M2", + nameEn: "MacBook Pro 14 M2", + aliases: [ + "맥북 프로 14 m2", "맥북프로 14 m2", "맥북프로14m2", "macbook pro 14 m2", + "맥북 프로 14 2023", "맥북프로 14 2023" + ], + category: "laptop", + country: "KR", + source: "https://support.apple.com/ko-kr/111901", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "MacBook Pro 14-inch M2 Pro", os: "macOS", cpu: "Apple M2 Pro (12코어 CPU)", gpu: "Apple M2 Pro (19코어 GPU)", + ram_gb: "16", storage_gb: "512", display_inch: "14.2", brightness_nits: "1600", panel: "미니LED (Liquid Retina XDR)", + resolution: "3024×1964", refresh_hz: "120", weight_g: "1600", battery_wh: "69.6", + ports: "Thunderbolt 4 ×3, MagSafe 3, HDMI, SD, 3.5mm 헤드폰", launch_price_krw: "279만원부터", release_date: "2023년 1월" + } + }, + { + id: "macbook-pro-16-m2", + canonicalName: "맥북 프로 16 M2", + nameEn: "MacBook Pro 16 M2", + aliases: [ + "맥북 프로 16 m2", "맥북프로 16 m2", "맥북프로16m2", "macbook pro 16 m2", + "맥북 프로 16 2023", "맥북프로 16 2023" + ], + category: "laptop", + country: "KR", + source: "https://support.apple.com/ko-kr/111901", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "MacBook Pro 16-inch M2 Pro", os: "macOS", cpu: "Apple M2 Pro (12코어 CPU)", gpu: "Apple M2 Pro (19코어 GPU)", + ram_gb: "16", storage_gb: "512", display_inch: "16.2", brightness_nits: "1600", panel: "미니LED (Liquid Retina XDR)", + resolution: "3456×2234", refresh_hz: "120", weight_g: "2150", battery_wh: "99.6", + ports: "Thunderbolt 4 ×3, MagSafe 3, HDMI, SD, 3.5mm 헤드폰", launch_price_krw: "329만원부터", release_date: "2023년 1월" + } + }, + + // ══════════════════════════════════════════════════════════════════════════ + // Samsung Galaxy Book3 시리즈 (2023) + // ══════════════════════════════════════════════════════════════════════════ + { + id: "galaxy-book3-pro-14", + canonicalName: "갤럭시 북3 프로 14", + nameEn: "Galaxy Book3 Pro 14", + aliases: ["갤럭시 북3 프로 14", "갤럭시북3프로14", "galaxy book3 pro 14", "galaxy book 3 pro 14", "북3 프로 14", "book3 pro 14"], + category: "laptop", + country: "KR", + source: "https://www.samsung.com/sec/laptops/galaxy-book3-pro-14/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy Book3 Pro 14", os: "Windows 11", cpu: "Intel Core i7-1360P", gpu: "Intel Iris Xe", + ram_gb: "16", storage_gb: "512", display_inch: "14.0", brightness_nits: "400", panel: "AMOLED (Dynamic AMOLED 2X)", + resolution: "2880×1800", refresh_hz: "120", weight_g: "1170", battery_wh: "63", + ports: "Thunderbolt 4 ×2, USB-C, USB-A, HDMI, MicroSD", launch_price_krw: "239만원부터", release_date: "2023년 2월" + } + }, + { + id: "galaxy-book3-pro-16", + canonicalName: "갤럭시 북3 프로 16", + nameEn: "Galaxy Book3 Pro 16", + aliases: ["갤럭시 북3 프로 16", "갤럭시북3프로16", "galaxy book3 pro 16", "galaxy book 3 pro 16", "북3 프로 16", "book3 pro 16"], + category: "laptop", + country: "KR", + source: "https://www.samsung.com/sec/laptops/galaxy-book3-pro-16/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy Book3 Pro 16", os: "Windows 11", cpu: "Intel Core i7-1360P", gpu: "Intel Iris Xe", + ram_gb: "16", storage_gb: "512", display_inch: "16.0", brightness_nits: "400", panel: "AMOLED (Dynamic AMOLED 2X)", + resolution: "2880×1800", refresh_hz: "120", weight_g: "1560", battery_wh: "76", + ports: "Thunderbolt 4 ×2, USB-C, USB-A ×2, HDMI, MicroSD", launch_price_krw: "259만원부터", release_date: "2023년 2월" + } + }, + { + id: "galaxy-book3-ultra", + canonicalName: "갤럭시 북3 울트라", + nameEn: "Galaxy Book3 Ultra", + aliases: ["갤럭시 북3 울트라", "갤럭시북3울트라", "galaxy book3 ultra", "galaxy book 3 ultra", "북3 울트라", "book3 ultra"], + category: "laptop", + country: "KR", + source: "https://www.samsung.com/sec/laptops/galaxy-book3-ultra/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy Book3 Ultra", os: "Windows 11", cpu: "Intel Core i9-13900H", gpu: "NVIDIA GeForce RTX 4050", + ram_gb: "16", storage_gb: "512", display_inch: "16.0", brightness_nits: "400", panel: "AMOLED (Dynamic AMOLED 2X)", + resolution: "2880×1800", refresh_hz: "120", weight_g: "1790", battery_wh: "76", + ports: "Thunderbolt 4 ×2, USB-C, USB-A ×2, HDMI, MicroSD", launch_price_krw: "299만원부터", release_date: "2023년 2월" + } + }, + + // ══════════════════════════════════════════════════════════════════════════ + // LG 그램 Pro (2024) + // ══════════════════════════════════════════════════════════════════════════ + { + id: "lg-gram-pro-16", + canonicalName: "LG 그램 Pro 16", + nameEn: "LG gram Pro 16", + aliases: ["lg 그램 프로 16", "lg그램프로16", "lg gram pro 16", "그램 프로 16", "그램프로16", "gram pro 16"], + category: "laptop", + country: "KR", + source: "https://www.lg.com/kr/laptops/lg-gram/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "LG gram Pro 16 (2024)", os: "Windows 11", cpu: "Intel Core Ultra 7 155H", gpu: "Intel Arc Graphics", + ram_gb: "16", storage_gb: "512", display_inch: "16.0", brightness_nits: "350", panel: "IPS (Anti-Glare)", + resolution: "2560×1600", refresh_hz: "60", weight_g: "1199", battery_wh: "80", + ports: "Thunderbolt 4 ×2, USB-C, USB-A ×2, HDMI, MicroSD", launch_price_krw: "189만원부터", release_date: "2024년 1월" + } + }, + { + id: "lg-gram-pro-14", + canonicalName: "LG 그램 Pro 14", + nameEn: "LG gram Pro 14", + aliases: ["lg 그램 프로 14", "lg그램프로14", "lg gram pro 14", "그램 프로 14", "그램프로14", "gram pro 14"], + category: "laptop", + country: "KR", + source: "https://www.lg.com/kr/laptops/lg-gram/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "LG gram Pro 14 (2024)", os: "Windows 11", cpu: "Intel Core Ultra 7 155H", gpu: "Intel Arc Graphics", + ram_gb: "16", storage_gb: "512", display_inch: "14.0", brightness_nits: "350", panel: "IPS (Anti-Glare)", + resolution: "2560×1600", refresh_hz: "60", weight_g: "980", battery_wh: "72", + ports: "Thunderbolt 4 ×2, USB-C, USB-A ×2, HDMI, MicroSD", launch_price_krw: "169만원부터", release_date: "2024년 1월" + } + }, ]; diff --git a/lib/specs/dataset/smartphones.ts b/lib/specs/dataset/smartphones.ts new file mode 100644 index 0000000..1ce840d --- /dev/null +++ b/lib/specs/dataset/smartphones.ts @@ -0,0 +1,1027 @@ +import type { VerifiedProduct } from "./types"; + +/** + * 스마트폰 검증 데이터셋 — 완전 하드코딩 (2020년 이후 주요 모델). + * + * 출처: 각 제조사 공식 페이지 (Apple KR, Samsung SEC). + * canonicalName=한국어, nameEn=영어(검색·표시), 별칭은 한/영 혼용 입력 전부 커버. + * + * camera_mp = 메인(광각) 센서 화소 기준 (스키마 hint). + */ +export const smartphones: VerifiedProduct[] = [ + + // ══════════════════════════════════════════════════════════════════════════ + // iPhone 16 시리즈 (2024) + // ══════════════════════════════════════════════════════════════════════════ + { + id: "iphone-16", + canonicalName: "아이폰 16", + nameEn: "iPhone 16", + aliases: ["아이폰 16", "아이폰16", "iphone 16", "iphone16", "아이폰 16 기본"], + category: "smartphone", + country: "KR", + source: "https://www.apple.com/kr/iphone-16/specs/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "iPhone 16", release_date: "2024년 9월", launch_price_krw: "125만원부터", + os: "iOS 18", chipset: "A18", display_inch: "6.1", brightness_nits: "2000", + ram_gb: "8", storage_gb: "128", camera_mp: "48", battery: "동영상 재생 최대 22시간", + charging: "25W 유선, 25W MagSafe 무선", water_resist: "IP68", weight_g: "170", refresh_hz: "60" + } + }, + { + id: "iphone-16-plus", + canonicalName: "아이폰 16 플러스", + nameEn: "iPhone 16 Plus", + aliases: ["아이폰 16 플러스", "아이폰16 플러스", "아이폰16플러스", "iphone 16 plus", "iphone16 plus", "16 플러스", "16플러스"], + category: "smartphone", + country: "KR", + source: "https://www.apple.com/kr/iphone-16/specs/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "iPhone 16 Plus", release_date: "2024년 9월", launch_price_krw: "135만원부터", + os: "iOS 18", chipset: "A18", display_inch: "6.7", brightness_nits: "2000", + ram_gb: "8", storage_gb: "128", camera_mp: "48", battery: "동영상 재생 최대 27시간", + charging: "25W 유선, 25W MagSafe 무선", water_resist: "IP68", weight_g: "203", refresh_hz: "60" + } + }, + { + id: "iphone-16-pro", + canonicalName: "아이폰 16 프로", + nameEn: "iPhone 16 Pro", + aliases: ["아이폰 16 프로", "아이폰16 프로", "아이폰16프로", "아이폰 16pro", "아이폰16pro", "iphone 16 pro", "iphone16 pro", "iphone 16pro", "16 프로", "16프로"], + category: "smartphone", + country: "KR", + source: "https://www.apple.com/kr/iphone-16-pro/specs/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "iPhone 16 Pro", release_date: "2024년 9월", launch_price_krw: "155만원부터", + os: "iOS 18", chipset: "A18 Pro", display_inch: "6.3", brightness_nits: "2000", + ram_gb: "8", storage_gb: "128", camera_mp: "48", battery: "동영상 재생 최대 27시간", + charging: "25W 유선, 25W MagSafe 무선", water_resist: "IP68", weight_g: "199", refresh_hz: "120" + } + }, + { + id: "iphone-16-pro-max", + canonicalName: "아이폰 16 프로 맥스", + nameEn: "iPhone 16 Pro Max", + aliases: ["아이폰 16 프로 맥스", "아이폰16 프로 맥스", "아이폰16프로맥스", "아이폰 16 프로맥스", "iphone 16 pro max", "iphone16 pro max", "iphone 16 promax", "16 프로 맥스", "16프로맥스"], + category: "smartphone", + country: "KR", + source: "https://www.apple.com/kr/iphone-16-pro/specs/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "iPhone 16 Pro Max", release_date: "2024년 9월", launch_price_krw: "189만원부터", + os: "iOS 18", chipset: "A18 Pro", display_inch: "6.9", brightness_nits: "2000", + ram_gb: "8", storage_gb: "256", camera_mp: "48", battery: "동영상 재생 최대 33시간", + charging: "25W 유선, 25W MagSafe 무선", water_resist: "IP68", weight_g: "227", refresh_hz: "120" + } + }, + + // ══════════════════════════════════════════════════════════════════════════ + // iPhone 15 시리즈 (2023) + // ══════════════════════════════════════════════════════════════════════════ + { + id: "iphone-15", + canonicalName: "아이폰 15", + nameEn: "iPhone 15", + aliases: ["아이폰 15", "아이폰15", "iphone 15", "iphone15", "아이폰 15 기본"], + category: "smartphone", + country: "KR", + source: "https://support.apple.com/ko-kr/111831", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "iPhone 15", release_date: "2023년 9월", launch_price_krw: "125만원부터", + os: "iOS 17", chipset: "A16 Bionic", display_inch: "6.1", brightness_nits: "2000", + ram_gb: "6", storage_gb: "128", camera_mp: "48", battery: "동영상 재생 최대 20시간", + charging: "20W 유선, 15W MagSafe 무선", water_resist: "IP68", weight_g: "171", refresh_hz: "60" + } + }, + { + id: "iphone-15-plus", + canonicalName: "아이폰 15 플러스", + nameEn: "iPhone 15 Plus", + aliases: ["아이폰 15 플러스", "아이폰15 플러스", "아이폰15플러스", "iphone 15 plus", "15 플러스", "15플러스"], + category: "smartphone", + country: "KR", + source: "https://support.apple.com/ko-kr/111830", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "iPhone 15 Plus", release_date: "2023년 9월", launch_price_krw: "135만원부터", + os: "iOS 17", chipset: "A16 Bionic", display_inch: "6.7", brightness_nits: "2000", + ram_gb: "6", storage_gb: "128", camera_mp: "48", battery: "동영상 재생 최대 26시간", + charging: "20W 유선, 15W MagSafe 무선", water_resist: "IP68", weight_g: "201", refresh_hz: "60" + } + }, + { + id: "iphone-15-pro", + canonicalName: "아이폰 15 프로", + nameEn: "iPhone 15 Pro", + aliases: ["아이폰 15 프로", "아이폰15 프로", "아이폰15프로", "아이폰 15pro", "아이폰15pro", "iphone 15 pro", "15 프로", "15프로"], + category: "smartphone", + country: "KR", + source: "https://support.apple.com/ko-kr/111900", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "iPhone 15 Pro", release_date: "2023년 9월", launch_price_krw: "155만원부터", + os: "iOS 17", chipset: "A17 Pro", display_inch: "6.1", brightness_nits: "2000", + ram_gb: "8", storage_gb: "128", camera_mp: "48", battery: "동영상 재생 최대 23시간", + charging: "20W 유선, 15W MagSafe 무선", water_resist: "IP68", weight_g: "187", refresh_hz: "120" + } + }, + { + id: "iphone-15-pro-max", + canonicalName: "아이폰 15 프로 맥스", + nameEn: "iPhone 15 Pro Max", + aliases: ["아이폰 15 프로 맥스", "아이폰15 프로 맥스", "아이폰15프로맥스", "아이폰 15 프로맥스", "iphone 15 pro max", "15 프로 맥스", "15프로맥스"], + category: "smartphone", + country: "KR", + source: "https://support.apple.com/ko-kr/111901", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "iPhone 15 Pro Max", release_date: "2023년 9월", launch_price_krw: "189만원부터", + os: "iOS 17", chipset: "A17 Pro", display_inch: "6.7", brightness_nits: "2000", + ram_gb: "8", storage_gb: "256", camera_mp: "48", battery: "동영상 재생 최대 29시간", + charging: "20W 유선, 15W MagSafe 무선", water_resist: "IP68", weight_g: "221", refresh_hz: "120" + } + }, + + // ══════════════════════════════════════════════════════════════════════════ + // iPhone 14 시리즈 (2022) + // ══════════════════════════════════════════════════════════════════════════ + { + id: "iphone-14", + canonicalName: "아이폰 14", + nameEn: "iPhone 14", + aliases: ["아이폰 14", "아이폰14", "iphone 14", "iphone14", "아이폰 14 기본"], + category: "smartphone", + country: "KR", + source: "https://support.apple.com/ko-kr/111850", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "iPhone 14", release_date: "2022년 9월", launch_price_krw: "125만원부터", + os: "iOS 16", chipset: "A15 Bionic", display_inch: "6.1", brightness_nits: "1200", + ram_gb: "6", storage_gb: "128", camera_mp: "12", battery: "동영상 재생 최대 20시간", + charging: "20W 유선, 15W MagSafe 무선", water_resist: "IP68", weight_g: "172", refresh_hz: "60" + } + }, + { + id: "iphone-14-plus", + canonicalName: "아이폰 14 플러스", + nameEn: "iPhone 14 Plus", + aliases: ["아이폰 14 플러스", "아이폰14 플러스", "아이폰14플러스", "iphone 14 plus", "14 플러스", "14플러스"], + category: "smartphone", + country: "KR", + source: "https://support.apple.com/ko-kr/111854", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "iPhone 14 Plus", release_date: "2022년 10월", launch_price_krw: "135만원부터", + os: "iOS 16", chipset: "A15 Bionic", display_inch: "6.7", brightness_nits: "1200", + ram_gb: "6", storage_gb: "128", camera_mp: "12", battery: "동영상 재생 최대 26시간", + charging: "20W 유선, 15W MagSafe 무선", water_resist: "IP68", weight_g: "203", refresh_hz: "60" + } + }, + { + id: "iphone-14-pro", + canonicalName: "아이폰 14 프로", + nameEn: "iPhone 14 Pro", + aliases: ["아이폰 14 프로", "아이폰14 프로", "아이폰14프로", "아이폰 14pro", "아이폰14pro", "iphone 14 pro", "14 프로", "14프로"], + category: "smartphone", + country: "KR", + source: "https://support.apple.com/ko-kr/111849", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "iPhone 14 Pro", release_date: "2022년 9월", launch_price_krw: "155만원부터", + os: "iOS 16", chipset: "A16 Bionic", display_inch: "6.1", brightness_nits: "2000", + ram_gb: "6", storage_gb: "128", camera_mp: "48", battery: "동영상 재생 최대 23시간", + charging: "20W 유선, 15W MagSafe 무선", water_resist: "IP68", weight_g: "206", refresh_hz: "120" + } + }, + { + id: "iphone-14-pro-max", + canonicalName: "아이폰 14 프로 맥스", + nameEn: "iPhone 14 Pro Max", + aliases: ["아이폰 14 프로 맥스", "아이폰14 프로 맥스", "아이폰14프로맥스", "아이폰 14 프로맥스", "iphone 14 pro max", "14 프로 맥스", "14프로맥스"], + category: "smartphone", + country: "KR", + source: "https://support.apple.com/ko-kr/111846", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "iPhone 14 Pro Max", release_date: "2022년 9월", launch_price_krw: "189만원부터", + os: "iOS 16", chipset: "A16 Bionic", display_inch: "6.7", brightness_nits: "2000", + ram_gb: "6", storage_gb: "256", camera_mp: "48", battery: "동영상 재생 최대 29시간", + charging: "20W 유선, 15W MagSafe 무선", water_resist: "IP68", weight_g: "240", refresh_hz: "120" + } + }, + + // ══════════════════════════════════════════════════════════════════════════ + // iPhone 13 시리즈 (2021) + // ══════════════════════════════════════════════════════════════════════════ + { + id: "iphone-13", + canonicalName: "아이폰 13", + nameEn: "iPhone 13", + aliases: ["아이폰 13", "아이폰13", "iphone 13", "iphone13", "아이폰 13 기본"], + category: "smartphone", + country: "KR", + source: "https://support.apple.com/ko-kr/111872", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "iPhone 13", release_date: "2021년 9월", launch_price_krw: "109만원부터", + os: "iOS 15", chipset: "A15 Bionic", display_inch: "6.1", brightness_nits: "800", + ram_gb: "4", storage_gb: "128", camera_mp: "12", battery: "동영상 재생 최대 19시간", + charging: "20W 유선, 15W MagSafe 무선", water_resist: "IP68", weight_g: "173", refresh_hz: "60" + } + }, + { + id: "iphone-13-mini", + canonicalName: "아이폰 13 미니", + nameEn: "iPhone 13 mini", + aliases: ["아이폰 13 미니", "아이폰13 미니", "아이폰13미니", "iphone 13 mini", "13 미니", "13미니"], + category: "smartphone", + country: "KR", + source: "https://support.apple.com/ko-kr/111869", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "iPhone 13 mini", release_date: "2021년 9월", launch_price_krw: "95만원부터", + os: "iOS 15", chipset: "A15 Bionic", display_inch: "5.4", brightness_nits: "800", + ram_gb: "4", storage_gb: "128", camera_mp: "12", battery: "동영상 재생 최대 17시간", + charging: "20W 유선, 15W MagSafe 무선", water_resist: "IP68", weight_g: "140", refresh_hz: "60" + } + }, + { + id: "iphone-13-pro", + canonicalName: "아이폰 13 프로", + nameEn: "iPhone 13 Pro", + aliases: ["아이폰 13 프로", "아이폰13 프로", "아이폰13프로", "아이폰 13pro", "아이폰13pro", "iphone 13 pro", "13 프로", "13프로"], + category: "smartphone", + country: "KR", + source: "https://support.apple.com/ko-kr/111877", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "iPhone 13 Pro", release_date: "2021년 9월", launch_price_krw: "135만원부터", + os: "iOS 15", chipset: "A15 Bionic", display_inch: "6.1", brightness_nits: "1000", + ram_gb: "6", storage_gb: "128", camera_mp: "12", battery: "동영상 재생 최대 22시간", + charging: "20W 유선, 15W MagSafe 무선", water_resist: "IP68", weight_g: "203", refresh_hz: "120" + } + }, + { + id: "iphone-13-pro-max", + canonicalName: "아이폰 13 프로 맥스", + nameEn: "iPhone 13 Pro Max", + aliases: ["아이폰 13 프로 맥스", "아이폰13 프로 맥스", "아이폰13프로맥스", "아이폰 13 프로맥스", "iphone 13 pro max", "13 프로 맥스", "13프로맥스"], + category: "smartphone", + country: "KR", + source: "https://support.apple.com/ko-kr/111880", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "iPhone 13 Pro Max", release_date: "2021년 9월", launch_price_krw: "149만원부터", + os: "iOS 15", chipset: "A15 Bionic", display_inch: "6.7", brightness_nits: "1000", + ram_gb: "6", storage_gb: "128", camera_mp: "12", battery: "동영상 재생 최대 28시간", + charging: "20W 유선, 15W MagSafe 무선", water_resist: "IP68", weight_g: "240", refresh_hz: "120" + } + }, + + // ══════════════════════════════════════════════════════════════════════════ + // iPhone 12 시리즈 (2020) + // ══════════════════════════════════════════════════════════════════════════ + { + id: "iphone-12", + canonicalName: "아이폰 12", + nameEn: "iPhone 12", + aliases: ["아이폰 12", "아이폰12", "iphone 12", "iphone12", "아이폰 12 기본"], + category: "smartphone", + country: "KR", + source: "https://support.apple.com/ko-kr/111886", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "iPhone 12", release_date: "2020년 10월", launch_price_krw: "109만원부터", + os: "iOS 14", chipset: "A14 Bionic", display_inch: "6.1", brightness_nits: "625", + ram_gb: "4", storage_gb: "64", camera_mp: "12", battery: "동영상 재생 최대 17시간", + charging: "20W 유선, 15W MagSafe 무선", water_resist: "IP68", weight_g: "162", refresh_hz: "60" + } + }, + { + id: "iphone-12-mini", + canonicalName: "아이폰 12 미니", + nameEn: "iPhone 12 mini", + aliases: ["아이폰 12 미니", "아이폰12 미니", "아이폰12미니", "iphone 12 mini", "12 미니", "12미니"], + category: "smartphone", + country: "KR", + source: "https://support.apple.com/ko-kr/111883", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "iPhone 12 mini", release_date: "2020년 11월", launch_price_krw: "95만원부터", + os: "iOS 14", chipset: "A14 Bionic", display_inch: "5.4", brightness_nits: "625", + ram_gb: "4", storage_gb: "64", camera_mp: "12", battery: "동영상 재생 최대 15시간", + charging: "20W 유선, 15W MagSafe 무선", water_resist: "IP68", weight_g: "135", refresh_hz: "60" + } + }, + { + id: "iphone-12-pro", + canonicalName: "아이폰 12 프로", + nameEn: "iPhone 12 Pro", + aliases: ["아이폰 12 프로", "아이폰12 프로", "아이폰12프로", "아이폰 12pro", "아이폰12pro", "iphone 12 pro", "12 프로", "12프로"], + category: "smartphone", + country: "KR", + source: "https://support.apple.com/ko-kr/111889", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "iPhone 12 Pro", release_date: "2020년 10월", launch_price_krw: "135만원부터", + os: "iOS 14", chipset: "A14 Bionic", display_inch: "6.1", brightness_nits: "800", + ram_gb: "6", storage_gb: "128", camera_mp: "12", battery: "동영상 재생 최대 17시간", + charging: "20W 유선, 15W MagSafe 무선", water_resist: "IP68", weight_g: "187", refresh_hz: "60" + } + }, + { + id: "iphone-12-pro-max", + canonicalName: "아이폰 12 프로 맥스", + nameEn: "iPhone 12 Pro Max", + aliases: ["아이폰 12 프로 맥스", "아이폰12 프로 맥스", "아이폰12프로맥스", "아이폰 12 프로맥스", "iphone 12 pro max", "12 프로 맥스", "12프로맥스"], + category: "smartphone", + country: "KR", + source: "https://support.apple.com/ko-kr/111892", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "iPhone 12 Pro Max", release_date: "2020년 11월", launch_price_krw: "149만원부터", + os: "iOS 14", chipset: "A14 Bionic", display_inch: "6.7", brightness_nits: "800", + ram_gb: "6", storage_gb: "128", camera_mp: "12", battery: "동영상 재생 최대 20시간", + charging: "20W 유선, 15W MagSafe 무선", water_resist: "IP68", weight_g: "226", refresh_hz: "60" + } + }, + + // ══════════════════════════════════════════════════════════════════════════ + // Galaxy S25 시리즈 (2025) + // ══════════════════════════════════════════════════════════════════════════ + { + id: "galaxy-s25", + canonicalName: "갤럭시 S25", + nameEn: "Galaxy S25", + aliases: ["갤럭시 s25", "갤럭시s25", "galaxy s25", "갤럭시 S25 기본", "s25"], + category: "smartphone", + country: "KR", + source: "https://www.samsung.com/sec/smartphones/galaxy-s25/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy S25", release_date: "2025년 2월", launch_price_krw: "115만 5천원부터", + os: "Android 15 (One UI 7)", chipset: "Snapdragon 8 Elite for Galaxy", display_inch: "6.2", brightness_nits: "2600", + ram_gb: "12", storage_gb: "256", camera_mp: "50", battery: "4000mAh", + charging: "25W 유선, 15W 무선", water_resist: "IP68", weight_g: "162", refresh_hz: "120" + } + }, + { + id: "galaxy-s25-plus", + canonicalName: "갤럭시 S25+", + nameEn: "Galaxy S25+", + aliases: ["갤럭시 s25+", "갤럭시s25+", "갤럭시 s25 플러스", "갤럭시s25플러스", "galaxy s25+", "galaxy s25 plus", "s25+", "s25 플러스", "s25플러스"], + category: "smartphone", + country: "KR", + source: "https://www.samsung.com/sec/smartphones/galaxy-s25plus/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy S25+", release_date: "2025년 2월", launch_price_krw: "135만 5천원부터", + os: "Android 15 (One UI 7)", chipset: "Snapdragon 8 Elite for Galaxy", display_inch: "6.7", brightness_nits: "2600", + ram_gb: "12", storage_gb: "256", camera_mp: "50", battery: "4900mAh", + charging: "45W 유선, 15W 무선", water_resist: "IP68", weight_g: "190", refresh_hz: "120" + } + }, + { + id: "galaxy-s25-ultra", + canonicalName: "갤럭시 S25 울트라", + nameEn: "Galaxy S25 Ultra", + aliases: ["갤럭시 s25 울트라", "갤럭시s25울트라", "갤럭시s25 울트라", "galaxy s25 ultra", "s25 울트라", "s25울트라"], + category: "smartphone", + country: "KR", + source: "https://www.samsung.com/sec/smartphones/galaxy-s25-ultra/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy S25 Ultra", release_date: "2025년 2월", launch_price_krw: "175만 5천원부터", + os: "Android 15 (One UI 7)", chipset: "Snapdragon 8 Elite for Galaxy", display_inch: "6.9", brightness_nits: "2600", + ram_gb: "12", storage_gb: "256", camera_mp: "200", battery: "5000mAh", + charging: "45W 유선, 15W 무선", water_resist: "IP68", weight_g: "218", refresh_hz: "120" + } + }, + + // ══════════════════════════════════════════════════════════════════════════ + // Galaxy S24 시리즈 (2024) + // ══════════════════════════════════════════════════════════════════════════ + { + id: "galaxy-s24", + canonicalName: "갤럭시 S24", + nameEn: "Galaxy S24", + aliases: ["갤럭시 s24", "갤럭시s24", "galaxy s24", "갤럭시 S24 기본", "s24"], + category: "smartphone", + country: "KR", + source: "https://www.samsung.com/sec/smartphones/galaxy-s24/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy S24", release_date: "2024년 1월", launch_price_krw: "115만 5천원부터", + os: "Android 14 (One UI 6.1)", chipset: "Exynos 2400", display_inch: "6.2", brightness_nits: "2600", + ram_gb: "8", storage_gb: "256", camera_mp: "50", battery: "4000mAh", + charging: "25W 유선, 15W 무선", water_resist: "IP68", weight_g: "167", refresh_hz: "120" + } + }, + { + id: "galaxy-s24-plus", + canonicalName: "갤럭시 S24+", + nameEn: "Galaxy S24+", + aliases: ["갤럭시 s24+", "갤럭시s24+", "갤럭시 s24 플러스", "갤럭시s24플러스", "galaxy s24+", "galaxy s24 plus", "s24+", "s24 플러스", "s24플러스"], + category: "smartphone", + country: "KR", + source: "https://www.samsung.com/sec/smartphones/galaxy-s24plus/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy S24+", release_date: "2024년 1월", launch_price_krw: "135만 5천원부터", + os: "Android 14 (One UI 6.1)", chipset: "Exynos 2400", display_inch: "6.7", brightness_nits: "2600", + ram_gb: "12", storage_gb: "256", camera_mp: "50", battery: "4900mAh", + charging: "45W 유선, 15W 무선", water_resist: "IP68", weight_g: "196", refresh_hz: "120" + } + }, + { + id: "galaxy-s24-ultra", + canonicalName: "갤럭시 S24 울트라", + nameEn: "Galaxy S24 Ultra", + aliases: ["갤럭시 s24 울트라", "갤럭시s24울트라", "갤럭시s24 울트라", "galaxy s24 ultra", "s24 울트라", "s24울트라"], + category: "smartphone", + country: "KR", + source: "https://www.samsung.com/sec/smartphones/galaxy-s24-ultra/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy S24 Ultra", release_date: "2024년 1월", launch_price_krw: "169만 8천원부터", + os: "Android 14 (One UI 6.1)", chipset: "Snapdragon 8 Gen 3 for Galaxy", display_inch: "6.8", brightness_nits: "2600", + ram_gb: "12", storage_gb: "256", camera_mp: "200", battery: "5000mAh", + charging: "45W 유선, 15W 무선", water_resist: "IP68", weight_g: "232", refresh_hz: "120" + } + }, + + // ══════════════════════════════════════════════════════════════════════════ + // Galaxy S23 시리즈 (2023) + // ══════════════════════════════════════════════════════════════════════════ + { + id: "galaxy-s23", + canonicalName: "갤럭시 S23", + nameEn: "Galaxy S23", + aliases: ["갤럭시 s23", "갤럭시s23", "galaxy s23", "s23"], + category: "smartphone", + country: "KR", + source: "https://www.samsung.com/sec/smartphones/galaxy-s23/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy S23", release_date: "2023년 2월", launch_price_krw: "115만원부터", + os: "Android 13 (One UI 5.1)", chipset: "Snapdragon 8 Gen 2 for Galaxy", display_inch: "6.1", brightness_nits: "1750", + ram_gb: "8", storage_gb: "256", camera_mp: "50", battery: "3900mAh", + charging: "25W 유선, 15W 무선", water_resist: "IP68", weight_g: "168", refresh_hz: "120" + } + }, + { + id: "galaxy-s23-ultra", + canonicalName: "갤럭시 S23 울트라", + nameEn: "Galaxy S23 Ultra", + aliases: ["갤럭시 s23 울트라", "갤럭시s23울트라", "galaxy s23 ultra", "s23 울트라", "s23울트라"], + category: "smartphone", + country: "KR", + source: "https://www.samsung.com/sec/smartphones/galaxy-s23-ultra/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy S23 Ultra", release_date: "2023년 2월", launch_price_krw: "159만 9천원부터", + os: "Android 13 (One UI 5.1)", chipset: "Snapdragon 8 Gen 2 for Galaxy", display_inch: "6.8", brightness_nits: "1750", + ram_gb: "12", storage_gb: "256", camera_mp: "200", battery: "5000mAh", + charging: "45W 유선, 15W 무선", water_resist: "IP68", weight_g: "234", refresh_hz: "120" + } + }, + + // ══════════════════════════════════════════════════════════════════════════ + // Galaxy S22 시리즈 (2022) + // ══════════════════════════════════════════════════════════════════════════ + { + id: "galaxy-s22", + canonicalName: "갤럭시 S22", + nameEn: "Galaxy S22", + aliases: ["갤럭시 s22", "갤럭시s22", "galaxy s22", "s22"], + category: "smartphone", + country: "KR", + source: "https://www.samsung.com/sec/smartphones/galaxy-s22/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy S22", release_date: "2022년 2월", launch_price_krw: "99만 9천원부터", + os: "Android 12 (One UI 4.1)", chipset: "Snapdragon 8 Gen 1", display_inch: "6.1", brightness_nits: "1300", + ram_gb: "8", storage_gb: "256", camera_mp: "50", battery: "3700mAh", + charging: "25W 유선, 15W 무선", water_resist: "IP68", weight_g: "167", refresh_hz: "120" + } + }, + { + id: "galaxy-s22-ultra", + canonicalName: "갤럭시 S22 울트라", + nameEn: "Galaxy S22 Ultra", + aliases: ["갤럭시 s22 울트라", "갤럭시s22울트라", "galaxy s22 ultra", "s22 울트라", "s22울트라"], + category: "smartphone", + country: "KR", + source: "https://www.samsung.com/sec/smartphones/galaxy-s22-ultra/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy S22 Ultra", release_date: "2022년 2월", launch_price_krw: "145만 2천원부터", + os: "Android 12 (One UI 4.1)", chipset: "Snapdragon 8 Gen 1", display_inch: "6.8", brightness_nits: "1750", + ram_gb: "12", storage_gb: "256", camera_mp: "108", battery: "5000mAh", + charging: "45W 유선, 15W 무선", water_resist: "IP68", weight_g: "228", refresh_hz: "120" + } + }, + + // ══════════════════════════════════════════════════════════════════════════ + // Galaxy S21 시리즈 (2021) + // ══════════════════════════════════════════════════════════════════════════ + { + id: "galaxy-s21", + canonicalName: "갤럭시 S21", + nameEn: "Galaxy S21", + aliases: ["갤럭시 s21", "갤럭시s21", "galaxy s21", "s21"], + category: "smartphone", + country: "KR", + source: "https://www.samsung.com/sec/smartphones/galaxy-s21-5g/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy S21 5G", release_date: "2021년 1월", launch_price_krw: "99만 9천원부터", + os: "Android 11 (One UI 3.1)", chipset: "Exynos 2100", display_inch: "6.2", brightness_nits: "1300", + ram_gb: "8", storage_gb: "256", camera_mp: "12", battery: "4000mAh", + charging: "25W 유선, 15W 무선", water_resist: "IP68", weight_g: "169", refresh_hz: "120" + } + }, + { + id: "galaxy-s21-ultra", + canonicalName: "갤럭시 S21 울트라", + nameEn: "Galaxy S21 Ultra", + aliases: ["갤럭시 s21 울트라", "갤럭시s21울트라", "galaxy s21 ultra", "s21 울트라", "s21울트라"], + category: "smartphone", + country: "KR", + source: "https://www.samsung.com/sec/smartphones/galaxy-s21-ultra-5g/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy S21 Ultra 5G", release_date: "2021년 1월", launch_price_krw: "145만 2천원부터", + os: "Android 11 (One UI 3.1)", chipset: "Exynos 2100", display_inch: "6.8", brightness_nits: "1500", + ram_gb: "12", storage_gb: "256", camera_mp: "108", battery: "5000mAh", + charging: "25W 유선, 15W 무선", water_resist: "IP68", weight_g: "227", refresh_hz: "120" + } + }, + + // ══════════════════════════════════════════════════════════════════════════ + // Galaxy Z 시리즈 (폴더블) + // ══════════════════════════════════════════════════════════════════════════ + { + id: "galaxy-z-fold6", + canonicalName: "갤럭시 Z 폴드6", + nameEn: "Galaxy Z Fold6", + aliases: ["갤럭시 z 폴드6", "갤럭시 z 폴드 6", "갤럭시z폴드6", "갤럭시 폴드6", "갤럭시폴드6", "galaxy z fold6", "galaxy z fold 6", "galaxy fold 6", "폴드6", "fold6", "z폴드6"], + category: "smartphone", + country: "KR", + source: "https://www.samsung.com/sec/smartphones/galaxy-z-fold6/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy Z Fold6", release_date: "2024년 7월", launch_price_krw: "222만 5천원부터", + os: "Android 14 (One UI 6.1.1)", chipset: "Snapdragon 8 Gen 3 for Galaxy", display_inch: "7.6", brightness_nits: "2600", + ram_gb: "12", storage_gb: "256", camera_mp: "50", battery: "4400mAh", + charging: "25W 유선, 15W 무선", water_resist: "IP48", weight_g: "239", refresh_hz: "120" + } + }, + { + id: "galaxy-z-flip6", + canonicalName: "갤럭시 Z 플립6", + nameEn: "Galaxy Z Flip6", + aliases: ["갤럭시 z 플립6", "갤럭시 z 플립 6", "갤럭시z플립6", "갤럭시 플립6", "갤럭시플립6", "galaxy z flip6", "galaxy z flip 6", "galaxy flip 6", "플립6", "flip6", "z플립6"], + category: "smartphone", + country: "KR", + source: "https://www.samsung.com/sec/smartphones/galaxy-z-flip6/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy Z Flip6", release_date: "2024년 7월", launch_price_krw: "148만 5천원부터", + os: "Android 14 (One UI 6.1.1)", chipset: "Snapdragon 8 Gen 3 for Galaxy", display_inch: "6.7", brightness_nits: "2600", + ram_gb: "12", storage_gb: "256", camera_mp: "50", battery: "4000mAh", + charging: "25W 유선, 15W 무선", water_resist: "IP48", weight_g: "187", refresh_hz: "120" + } + }, + { + id: "galaxy-z-fold5", + canonicalName: "갤럭시 Z 폴드5", + nameEn: "Galaxy Z Fold5", + aliases: ["갤럭시 z 폴드5", "갤럭시 z 폴드 5", "갤럭시z폴드5", "갤럭시 폴드5", "galaxy z fold5", "galaxy z fold 5", "폴드5", "fold5", "z폴드5"], + category: "smartphone", + country: "KR", + source: "https://www.samsung.com/sec/smartphones/galaxy-z-fold5/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy Z Fold5", release_date: "2023년 8월", launch_price_krw: "209만 8천원부터", + os: "Android 13 (One UI 5.1.1)", chipset: "Snapdragon 8 Gen 2 for Galaxy", display_inch: "7.6", brightness_nits: "1750", + ram_gb: "12", storage_gb: "256", camera_mp: "50", battery: "4400mAh", + charging: "25W 유선, 15W 무선", water_resist: "IPX8", weight_g: "253", refresh_hz: "120" + } + }, + { + id: "galaxy-z-flip5", + canonicalName: "갤럭시 Z 플립5", + nameEn: "Galaxy Z Flip5", + aliases: ["갤럭시 z 플립5", "갤럭시 z 플립 5", "갤럭시z플립5", "갤럭시 플립5", "galaxy z flip5", "galaxy z flip 5", "플립5", "flip5", "z플립5"], + category: "smartphone", + country: "KR", + source: "https://www.samsung.com/sec/smartphones/galaxy-z-flip5/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy Z Flip5", release_date: "2023년 8월", launch_price_krw: "139만 7천원부터", + os: "Android 13 (One UI 5.1.1)", chipset: "Snapdragon 8 Gen 2 for Galaxy", display_inch: "6.7", brightness_nits: "1750", + ram_gb: "8", storage_gb: "256", camera_mp: "50", battery: "3700mAh", + charging: "25W 유선, 15W 무선", water_resist: "IPX8", weight_g: "187", refresh_hz: "120" + } + }, + + // ══════════════════════════════════════════════════════════════════════════ + // Galaxy S20 시리즈 (2020) + // ══════════════════════════════════════════════════════════════════════════ + { + id: "galaxy-s20", + canonicalName: "갤럭시 S20", + nameEn: "Galaxy S20", + aliases: ["갤럭시 s20", "갤럭시s20", "galaxy s20", "s20"], + category: "smartphone", + country: "KR", + source: "https://www.samsung.com/sec/smartphones/galaxy-s20-5g/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy S20 5G", release_date: "2020년 2월", launch_price_krw: "124만 3천원부터", + os: "Android 10 (One UI 2.1)", chipset: "Exynos 990", display_inch: "6.2", brightness_nits: "1200", + ram_gb: "12", storage_gb: "128", camera_mp: "12", battery: "4000mAh", + charging: "25W 유선, 15W 무선", water_resist: "IP68", weight_g: "163", refresh_hz: "120" + } + }, + { + id: "galaxy-s20-ultra", + canonicalName: "갤럭시 S20 울트라", + nameEn: "Galaxy S20 Ultra", + aliases: ["갤럭시 s20 울트라", "갤럭시s20울트라", "galaxy s20 ultra", "s20 울트라", "s20울트라"], + category: "smartphone", + country: "KR", + source: "https://www.samsung.com/sec/smartphones/galaxy-s20-ultra-5g/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy S20 Ultra 5G", release_date: "2020년 2월", launch_price_krw: "159만 5천원부터", + os: "Android 10 (One UI 2.1)", chipset: "Exynos 990", display_inch: "6.9", brightness_nits: "1400", + ram_gb: "12", storage_gb: "256", camera_mp: "108", battery: "5000mAh", + charging: "45W 유선, 15W 무선", water_resist: "IP68", weight_g: "222", refresh_hz: "120" + } + }, + + // ══════════════════════════════════════════════════════════════════════════ + // iPhone SE 3세대 / 16e + // ══════════════════════════════════════════════════════════════════════════ + { + id: "iphone-se-3", + canonicalName: "아이폰 SE 3세대", + nameEn: "iPhone SE (3rd generation)", + aliases: ["아이폰 se 3세대", "아이폰se3세대", "아이폰 se3", "아이폰se3", "iphone se 3", "iphone se3", "iphone se 3세대", "se 3세대", "se3세대", "아이폰 se"], + category: "smartphone", + country: "KR", + source: "https://www.apple.com/kr/iphone-se/specs/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "iPhone SE (3세대)", release_date: "2022년 3월", launch_price_krw: "65만원부터", + os: "iOS 15", chipset: "A15 Bionic", display_inch: "4.7", brightness_nits: "625", + ram_gb: "4", storage_gb: "64", camera_mp: "12", battery: "동영상 재생 최대 15시간", + charging: "20W 유선, 15W MagSafe 무선", water_resist: "IP67", weight_g: "144", refresh_hz: "60" + } + }, + { + id: "iphone-16e", + canonicalName: "아이폰 16e", + nameEn: "iPhone 16e", + aliases: ["아이폰 16e", "아이폰16e", "iphone 16e", "iphone16e", "아이폰 se 4세대", "아이폰 se4"], + category: "smartphone", + country: "KR", + source: "https://www.apple.com/kr/iphone-16e/specs/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "iPhone 16e", release_date: "2025년 2월", launch_price_krw: "79만원부터", + os: "iOS 18", chipset: "A16 Bionic", display_inch: "6.1", brightness_nits: "1200", + ram_gb: "8", storage_gb: "128", camera_mp: "48", battery: "동영상 재생 최대 18시간", + charging: "20W 유선, 25W MagSafe 무선", water_resist: "IP68", weight_g: "167", refresh_hz: "60" + } + }, + + // ══════════════════════════════════════════════════════════════════════════ + // Galaxy S20 시리즈 추가 (2020) + // ══════════════════════════════════════════════════════════════════════════ + { + id: "galaxy-s20-plus", + canonicalName: "갤럭시 S20+", + nameEn: "Galaxy S20+", + aliases: ["갤럭시 s20+", "갤럭시s20+", "galaxy s20+", "galaxy s20 plus", "s20+", "s20플러스", "갤럭시 s20 플러스"], + category: "smartphone", + country: "KR", + source: "https://www.samsung.com/sec/smartphones/galaxy-s20-plus-5g/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy S20+ 5G", release_date: "2020년 2월", launch_price_krw: "135만 3천원부터", + os: "Android 10 (One UI 2.1)", chipset: "Exynos 990", display_inch: "6.7", brightness_nits: "1300", + ram_gb: "12", storage_gb: "128", camera_mp: "64", battery: "4500mAh", + charging: "25W 유선, 15W 무선", water_resist: "IP68", weight_g: "186", refresh_hz: "120" + } + }, + { + id: "galaxy-s20-fe", + canonicalName: "갤럭시 S20 FE", + nameEn: "Galaxy S20 FE", + aliases: ["갤럭시 s20 fe", "갤럭시s20fe", "galaxy s20 fe", "s20 fe", "s20fe", "갤럭시 s20 팬에디션"], + category: "smartphone", + country: "KR", + source: "https://www.samsung.com/sec/smartphones/galaxy-s20-fe-5g/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy S20 FE 5G", release_date: "2020년 10월", launch_price_krw: "89만 9천원부터", + os: "Android 10 (One UI 2.5)", chipset: "Exynos 990", display_inch: "6.5", brightness_nits: "1200", + ram_gb: "6", storage_gb: "128", camera_mp: "12", battery: "4500mAh", + charging: "25W 유선, 15W 무선", water_resist: "IP68", weight_g: "190", refresh_hz: "120" + } + }, + + // ══════════════════════════════════════════════════════════════════════════ + // Galaxy S21 시리즈 추가 (2021) + // ══════════════════════════════════════════════════════════════════════════ + { + id: "galaxy-s21-plus", + canonicalName: "갤럭시 S21+", + nameEn: "Galaxy S21+", + aliases: ["갤럭시 s21+", "갤럭시s21+", "galaxy s21+", "galaxy s21 plus", "s21+", "s21플러스", "갤럭시 s21 플러스"], + category: "smartphone", + country: "KR", + source: "https://www.samsung.com/sec/smartphones/galaxy-s21-plus-5g/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy S21+ 5G", release_date: "2021년 1월", launch_price_krw: "135만 3천원부터", + os: "Android 11 (One UI 3.1)", chipset: "Exynos 2100", display_inch: "6.7", brightness_nits: "1300", + ram_gb: "8", storage_gb: "128", camera_mp: "12", battery: "4800mAh", + charging: "25W 유선, 15W 무선", water_resist: "IP68", weight_g: "200", refresh_hz: "120" + } + }, + { + id: "galaxy-s21-fe", + canonicalName: "갤럭시 S21 FE", + nameEn: "Galaxy S21 FE", + aliases: ["갤럭시 s21 fe", "갤럭시s21fe", "galaxy s21 fe", "s21 fe", "s21fe", "갤럭시 s21 팬에디션"], + category: "smartphone", + country: "KR", + source: "https://www.samsung.com/sec/smartphones/galaxy-s21-fe-5g/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy S21 FE 5G", release_date: "2022년 1월", launch_price_krw: "89만 9천원부터", + os: "Android 12 (One UI 4.0)", chipset: "Snapdragon 888", display_inch: "6.4", brightness_nits: "1200", + ram_gb: "6", storage_gb: "128", camera_mp: "12", battery: "4500mAh", + charging: "25W 유선, 15W 무선", water_resist: "IP68", weight_g: "177", refresh_hz: "120" + } + }, + + // ══════════════════════════════════════════════════════════════════════════ + // Galaxy S22 시리즈 추가 (2022) + // ══════════════════════════════════════════════════════════════════════════ + { + id: "galaxy-s22-plus", + canonicalName: "갤럭시 S22+", + nameEn: "Galaxy S22+", + aliases: ["갤럭시 s22+", "갤럭시s22+", "galaxy s22+", "galaxy s22 plus", "s22+", "s22플러스", "갤럭시 s22 플러스"], + category: "smartphone", + country: "KR", + source: "https://www.samsung.com/sec/smartphones/galaxy-s22-plus-5g/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy S22+ 5G", release_date: "2022년 2월", launch_price_krw: "135만 3천원부터", + os: "Android 12 (One UI 4.1)", chipset: "Exynos 2200", display_inch: "6.6", brightness_nits: "1750", + ram_gb: "8", storage_gb: "128", camera_mp: "50", battery: "4500mAh", + charging: "45W 유선, 15W 무선", water_resist: "IP68", weight_g: "196", refresh_hz: "120" + } + }, + + // ══════════════════════════════════════════════════════════════════════════ + // Galaxy S23 시리즈 추가 (2023) + // ══════════════════════════════════════════════════════════════════════════ + { + id: "galaxy-s23-plus", + canonicalName: "갤럭시 S23+", + nameEn: "Galaxy S23+", + aliases: ["갤럭시 s23+", "갤럭시s23+", "galaxy s23+", "galaxy s23 plus", "s23+", "s23플러스", "갤럭시 s23 플러스"], + category: "smartphone", + country: "KR", + source: "https://www.samsung.com/sec/smartphones/galaxy-s23-plus-5g/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy S23+ 5G", release_date: "2023년 2월", launch_price_krw: "135만 3천원부터", + os: "Android 13 (One UI 5.1)", chipset: "Snapdragon 8 Gen 2", display_inch: "6.6", brightness_nits: "1750", + ram_gb: "8", storage_gb: "256", camera_mp: "50", battery: "4700mAh", + charging: "45W 유선, 15W 무선", water_resist: "IP68", weight_g: "195", refresh_hz: "120" + } + }, + { + id: "galaxy-s23-fe", + canonicalName: "갤럭시 S23 FE", + nameEn: "Galaxy S23 FE", + aliases: ["갤럭시 s23 fe", "갤럭시s23fe", "galaxy s23 fe", "s23 fe", "s23fe", "갤럭시 s23 팬에디션"], + category: "smartphone", + country: "KR", + source: "https://www.samsung.com/sec/smartphones/galaxy-s23-fe-5g/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy S23 FE 5G", release_date: "2023년 10월", launch_price_krw: "79만 9천원부터", + os: "Android 14 (One UI 6.0)", chipset: "Exynos 2200", display_inch: "6.4", brightness_nits: "1200", + ram_gb: "8", storage_gb: "128", camera_mp: "50", battery: "4500mAh", + charging: "25W 유선, 15W 무선", water_resist: "IP68", weight_g: "209", refresh_hz: "120" + } + }, + + // ══════════════════════════════════════════════════════════════════════════ + // Galaxy S24 FE / S25 Edge (2024–2025) + // ══════════════════════════════════════════════════════════════════════════ + { + id: "galaxy-s24-fe", + canonicalName: "갤럭시 S24 FE", + nameEn: "Galaxy S24 FE", + aliases: ["갤럭시 s24 fe", "갤럭시s24fe", "galaxy s24 fe", "s24 fe", "s24fe", "갤럭시 s24 팬에디션"], + category: "smartphone", + country: "KR", + source: "https://www.samsung.com/sec/smartphones/galaxy-s24-fe-5g/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy S24 FE 5G", release_date: "2024년 10월", launch_price_krw: "89만 9천원부터", + os: "Android 14 (One UI 7.0)", chipset: "Exynos 2500", display_inch: "6.7", brightness_nits: "1900", + ram_gb: "8", storage_gb: "128", camera_mp: "50", battery: "4700mAh", + charging: "25W 유선, 15W 무선", water_resist: "IP68", weight_g: "213", refresh_hz: "120" + } + }, + { + id: "galaxy-s25-edge", + canonicalName: "갤럭시 S25 엣지", + nameEn: "Galaxy S25 Edge", + aliases: ["갤럭시 s25 엣지", "갤럭시s25엣지", "galaxy s25 edge", "s25 엣지", "s25edge", "s25 edge"], + category: "smartphone", + country: "KR", + source: "https://www.samsung.com/sec/smartphones/galaxy-s25-edge/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy S25 Edge", release_date: "2025년 5월", launch_price_krw: "169만 4천원부터", + os: "Android 15 (One UI 7.0)", chipset: "Snapdragon 8 Elite", display_inch: "6.7", brightness_nits: "2600", + ram_gb: "12", storage_gb: "256", camera_mp: "200", battery: "3900mAh", + charging: "25W 유선, 15W 무선", water_resist: "IP68", weight_g: "163", refresh_hz: "120" + } + }, + + // ══════════════════════════════════════════════════════════════════════════ + // Galaxy Z Fold 시리즈 (2020–2022) + // ══════════════════════════════════════════════════════════════════════════ + { + id: "galaxy-z-fold2", + canonicalName: "갤럭시 Z 폴드2", + nameEn: "Galaxy Z Fold2", + aliases: ["갤럭시 z 폴드2", "갤럭시z폴드2", "galaxy z fold2", "galaxy z fold 2", "z폴드2", "z 폴드2"], + category: "smartphone", + country: "KR", + source: "https://www.samsung.com/sec/smartphones/galaxy-z-fold2-5g/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy Z Fold2 5G", release_date: "2020년 9월", launch_price_krw: "239만 8천원부터", + os: "Android 10 (One UI 2.5)", chipset: "Snapdragon 865+", display_inch: "7.6 (메인) / 6.23 (커버)", + brightness_nits: "1200", ram_gb: "12", storage_gb: "256", camera_mp: "12", + battery: "4500mAh", charging: "25W 유선, 11W 무선", water_resist: "IPX8", weight_g: "282", refresh_hz: "120" + } + }, + { + id: "galaxy-z-fold3", + canonicalName: "갤럭시 Z 폴드3", + nameEn: "Galaxy Z Fold3", + aliases: ["갤럭시 z 폴드3", "갤럭시z폴드3", "galaxy z fold3", "galaxy z fold 3", "z폴드3", "z 폴드3"], + category: "smartphone", + country: "KR", + source: "https://www.samsung.com/sec/smartphones/galaxy-z-fold3-5g/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy Z Fold3 5G", release_date: "2021년 8월", launch_price_krw: "199만 8천원부터", + os: "Android 11 (One UI 3.1.1)", chipset: "Snapdragon 888", display_inch: "7.6 (메인) / 6.2 (커버)", + brightness_nits: "1200", ram_gb: "12", storage_gb: "256", camera_mp: "12", + battery: "4400mAh", charging: "25W 유선, 10W 무선", water_resist: "IPX8", weight_g: "271", refresh_hz: "120" + } + }, + { + id: "galaxy-z-fold4", + canonicalName: "갤럭시 Z 폴드4", + nameEn: "Galaxy Z Fold4", + aliases: ["갤럭시 z 폴드4", "갤럭시z폴드4", "galaxy z fold4", "galaxy z fold 4", "z폴드4", "z 폴드4"], + category: "smartphone", + country: "KR", + source: "https://www.samsung.com/sec/smartphones/galaxy-z-fold4-5g/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy Z Fold4 5G", release_date: "2022년 8월", launch_price_krw: "199만 4천원부터", + os: "Android 12 (One UI 4.1.1)", chipset: "Snapdragon 8+ Gen 1", display_inch: "7.6 (메인) / 6.2 (커버)", + brightness_nits: "1200", ram_gb: "12", storage_gb: "256", camera_mp: "50", + battery: "4400mAh", charging: "25W 유선, 15W 무선", water_resist: "IPX8", weight_g: "263", refresh_hz: "120" + } + }, + + // ══════════════════════════════════════════════════════════════════════════ + // Galaxy Z Flip 시리즈 (2020–2022) + // ══════════════════════════════════════════════════════════════════════════ + { + id: "galaxy-z-flip", + canonicalName: "갤럭시 Z 플립", + nameEn: "Galaxy Z Flip", + aliases: ["갤럭시 z 플립", "갤럭시z플립", "galaxy z flip", "z플립", "z 플립", "갤럭시 z 플립 2020"], + category: "smartphone", + country: "KR", + source: "https://www.samsung.com/sec/smartphones/galaxy-z-flip/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy Z Flip", release_date: "2020년 2월", launch_price_krw: "165만원부터", + os: "Android 10 (One UI 2.0)", chipset: "Snapdragon 855+", display_inch: "6.7 (메인) / 1.1 (커버)", + brightness_nits: "1100", ram_gb: "8", storage_gb: "256", camera_mp: "12", + battery: "3300mAh", charging: "15W 유선", water_resist: "없음", weight_g: "183", refresh_hz: "60" + } + }, + { + id: "galaxy-z-flip3", + canonicalName: "갤럭시 Z 플립3", + nameEn: "Galaxy Z Flip3", + aliases: ["갤럭시 z 플립3", "갤럭시z플립3", "galaxy z flip3", "galaxy z flip 3", "z플립3", "z 플립3"], + category: "smartphone", + country: "KR", + source: "https://www.samsung.com/sec/smartphones/galaxy-z-flip3-5g/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy Z Flip3 5G", release_date: "2021년 8월", launch_price_krw: "135만 3천원부터", + os: "Android 11 (One UI 3.1.1)", chipset: "Snapdragon 888", display_inch: "6.7 (메인) / 1.9 (커버)", + brightness_nits: "1200", ram_gb: "8", storage_gb: "128", camera_mp: "12", + battery: "3300mAh", charging: "15W 유선, 10W 무선", water_resist: "IPX8", weight_g: "183", refresh_hz: "120" + } + }, + { + id: "galaxy-z-flip4", + canonicalName: "갤럭시 Z 플립4", + nameEn: "Galaxy Z Flip4", + aliases: ["갤럭시 z 플립4", "갤럭시z플립4", "galaxy z flip4", "galaxy z flip 4", "z플립4", "z 플립4"], + category: "smartphone", + country: "KR", + source: "https://www.samsung.com/sec/smartphones/galaxy-z-flip4-5g/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy Z Flip4 5G", release_date: "2022년 8월", launch_price_krw: "135만 3천원부터", + os: "Android 12 (One UI 4.1.1)", chipset: "Snapdragon 8+ Gen 1", display_inch: "6.7 (메인) / 1.9 (커버)", + brightness_nits: "1200", ram_gb: "8", storage_gb: "128", camera_mp: "12", + battery: "3700mAh", charging: "25W 유선, 15W 무선", water_resist: "IPX8", weight_g: "187", refresh_hz: "120" + } + }, +]; diff --git a/lib/specs/dataset/tablets.ts b/lib/specs/dataset/tablets.ts new file mode 100644 index 0000000..68ba662 --- /dev/null +++ b/lib/specs/dataset/tablets.ts @@ -0,0 +1,457 @@ +import type { VerifiedProduct } from "./types"; + +/** + * 태블릿 검증 데이터셋 — 완전 하드코딩 (2020년 이후 주요 모델). + * + * 출처: 각 제조사 공식 페이지 (Apple KR, Samsung SEC). 기본 구성(Wi-Fi) 기준. + * canonicalName=한국어, nameEn=영어(검색·표시). + * + * 필드: battery_wh=공식 표기(텍스트), stylus=펜 지원, cellular=연결 옵션. + */ +export const tablets: VerifiedProduct[] = [ + + // ══════════════════════════════════════════════════════════════════════════ + // Apple iPad Pro (M4, 2024) + // ══════════════════════════════════════════════════════════════════════════ + { + id: "ipad-pro-13-m4", + canonicalName: "아이패드 프로 13 M4", + nameEn: "iPad Pro 13 M4", + aliases: [ + "아이패드 프로 13 m4", "아이패드프로 13 m4", "아이패드 프로 13", "아이패드프로13", + "ipad pro 13 m4", "ipad pro 13", "아이패드 프로 m4", "아이패드 프로 12.9", "ipad pro 12.9" + ], + category: "tablet", + country: "KR", + source: "https://www.apple.com/kr/ipad-pro/specs/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "iPad Pro 13형 (M4)", os: "iPadOS", chipset: "Apple M4", display_inch: "13", + resolution: "2752×2064", refresh_hz: "120", storage_gb: "256", ram_gb: "8", + battery_wh: "동영상 재생 최대 10시간 (38.99Wh)", stylus: "Apple Pencil Pro 지원", weight_g: "579", + cellular: "Wi-Fi (셀룰러 옵션)", launch_price_krw: "189만 9천원부터" + } + }, + { + id: "ipad-pro-11-m4", + canonicalName: "아이패드 프로 11 M4", + nameEn: "iPad Pro 11 M4", + aliases: ["아이패드 프로 11 m4", "아이패드프로 11 m4", "아이패드 프로 11", "아이패드프로11", "ipad pro 11 m4", "ipad pro 11"], + category: "tablet", + country: "KR", + source: "https://www.apple.com/kr/ipad-pro/specs/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "iPad Pro 11형 (M4)", os: "iPadOS", chipset: "Apple M4", display_inch: "11", + resolution: "2420×1668", refresh_hz: "120", storage_gb: "256", ram_gb: "8", + battery_wh: "동영상 재생 최대 10시간 (31.29Wh)", stylus: "Apple Pencil Pro 지원", weight_g: "444", + cellular: "Wi-Fi (셀룰러 옵션)", launch_price_krw: "149만 9천원부터" + } + }, + + // ══════════════════════════════════════════════════════════════════════════ + // Apple iPad Air (M2, 2024) + // ══════════════════════════════════════════════════════════════════════════ + { + id: "ipad-air-13-m2", + canonicalName: "아이패드 에어 13 M2", + nameEn: "iPad Air 13 M2", + aliases: ["아이패드 에어 13 m2", "아이패드에어 13 m2", "아이패드 에어 13", "ipad air 13 m2", "ipad air 13"], + category: "tablet", + country: "KR", + source: "https://www.apple.com/kr/ipad-air/specs/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "iPad Air 13형 (M2)", os: "iPadOS", chipset: "Apple M2", display_inch: "13", + resolution: "2732×2048", refresh_hz: "60", storage_gb: "128", ram_gb: "8", + battery_wh: "동영상 재생 최대 10시간 (36.59Wh)", stylus: "Apple Pencil Pro 지원", weight_g: "617", + cellular: "Wi-Fi (셀룰러 옵션)", launch_price_krw: "114만 9천원부터" + } + }, + { + id: "ipad-air-11-m2", + canonicalName: "아이패드 에어 11 M2", + nameEn: "iPad Air 11 M2", + aliases: [ + "아이패드 에어 11 m2", "아이패드에어 11 m2", "아이패드 에어 11", "ipad air 11 m2", "ipad air 11", + "아이패드 에어 m2", "아이패드 에어", "아이패드에어", "ipad air m2", "ipad air" + ], + category: "tablet", + country: "KR", + source: "https://www.apple.com/kr/ipad-air/specs/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "iPad Air 11형 (M2)", os: "iPadOS", chipset: "Apple M2", display_inch: "11", + resolution: "2360×1640", refresh_hz: "60", storage_gb: "128", ram_gb: "8", + battery_wh: "동영상 재생 최대 10시간 (28.93Wh)", stylus: "Apple Pencil Pro 지원", weight_g: "462", + cellular: "Wi-Fi (셀룰러 옵션)", launch_price_krw: "92만 9천원부터" + } + }, + + // ══════════════════════════════════════════════════════════════════════════ + // Apple iPad / iPad mini + // ══════════════════════════════════════════════════════════════════════════ + { + id: "ipad-10", + canonicalName: "아이패드 10세대", + nameEn: "iPad (10th gen)", + aliases: ["아이패드 10세대", "아이패드10세대", "아이패드 10", "아이패드10", "ipad 10", "ipad 10th", "아이패드 기본", "아이패드"], + category: "tablet", + country: "KR", + source: "https://www.apple.com/kr/ipad-10.9/specs/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "iPad (10세대)", os: "iPadOS", chipset: "A14 Bionic", display_inch: "10.9", + resolution: "2360×1640", refresh_hz: "60", storage_gb: "64", ram_gb: "4", + battery_wh: "동영상 재생 최대 10시간 (28.6Wh)", stylus: "Apple Pencil (USB-C), 1세대 지원", weight_g: "477", + cellular: "Wi-Fi (셀룰러 옵션)", launch_price_krw: "67만 9천원부터" + } + }, + { + id: "ipad-mini-7", + canonicalName: "아이패드 미니 7세대", + nameEn: "iPad mini 7", + aliases: ["아이패드 미니 7", "아이패드미니7", "아이패드 미니 7세대", "ipad mini 7", "아이패드 미니", "아이패드미니", "ipad mini"], + category: "tablet", + country: "KR", + source: "https://www.apple.com/kr/ipad-mini/specs/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "iPad mini (A17 Pro)", os: "iPadOS", chipset: "A17 Pro", display_inch: "8.3", + resolution: "2266×1488", refresh_hz: "60", storage_gb: "128", ram_gb: "8", + battery_wh: "동영상 재생 최대 10시간 (19.3Wh)", stylus: "Apple Pencil Pro 지원", weight_g: "293", + cellular: "Wi-Fi (셀룰러 옵션)", launch_price_krw: "74만 9천원부터" + } + }, + { + id: "ipad-mini-6", + canonicalName: "아이패드 미니 6세대", + nameEn: "iPad mini 6", + aliases: ["아이패드 미니 6", "아이패드미니6", "아이패드 미니 6세대", "ipad mini 6"], + category: "tablet", + country: "KR", + source: "https://support.apple.com/ko-kr/111864", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "iPad mini (6세대)", os: "iPadOS", chipset: "A15 Bionic", display_inch: "8.3", + resolution: "2266×1488", refresh_hz: "60", storage_gb: "64", ram_gb: "4", + battery_wh: "동영상 재생 최대 10시간 (19.3Wh)", stylus: "Apple Pencil 2세대 지원", weight_g: "293", + cellular: "Wi-Fi (셀룰러 옵션)", launch_price_krw: "71만 9천원부터" + } + }, + + // ══════════════════════════════════════════════════════════════════════════ + // Samsung Galaxy Tab S10 (2024) + // ══════════════════════════════════════════════════════════════════════════ + { + id: "galaxy-tab-s10-ultra", + canonicalName: "갤럭시 탭 S10 울트라", + nameEn: "Galaxy Tab S10 Ultra", + aliases: ["갤럭시 탭 s10 울트라", "갤럭시탭 s10 울트라", "갤럭시탭s10울트라", "galaxy tab s10 ultra", "탭 s10 울트라", "tab s10 ultra"], + category: "tablet", + country: "KR", + source: "https://www.samsung.com/sec/tablets/galaxy-tab-s10/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy Tab S10 Ultra", os: "Android 14 (One UI 6.1)", chipset: "MediaTek Dimensity 9300+", display_inch: "14.6", + resolution: "2960×1848", refresh_hz: "120", storage_gb: "256", ram_gb: "12", + battery_wh: "11200mAh", stylus: "S펜 포함", weight_g: "718", + cellular: "Wi-Fi (5G 옵션)", launch_price_krw: "149만 9천원부터" + } + }, + { + id: "galaxy-tab-s10-plus", + canonicalName: "갤럭시 탭 S10+", + nameEn: "Galaxy Tab S10+", + aliases: ["갤럭시 탭 s10+", "갤럭시탭 s10+", "갤럭시탭s10플러스", "갤럭시 탭 s10 플러스", "galaxy tab s10+", "galaxy tab s10 plus", "탭 s10 플러스"], + category: "tablet", + country: "KR", + source: "https://www.samsung.com/sec/tablets/galaxy-tab-s10/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy Tab S10+", os: "Android 14 (One UI 6.1)", chipset: "MediaTek Dimensity 9300+", display_inch: "12.4", + resolution: "2800×1752", refresh_hz: "120", storage_gb: "256", ram_gb: "12", + battery_wh: "10090mAh", stylus: "S펜 포함", weight_g: "571", + cellular: "Wi-Fi (5G 옵션)", launch_price_krw: "118만 9천원부터" + } + }, + + // ══════════════════════════════════════════════════════════════════════════ + // Samsung Galaxy Tab S9 (2023) + // ══════════════════════════════════════════════════════════════════════════ + { + id: "galaxy-tab-s9-ultra", + canonicalName: "갤럭시 탭 S9 울트라", + nameEn: "Galaxy Tab S9 Ultra", + aliases: ["갤럭시 탭 s9 울트라", "갤럭시탭 s9 울트라", "갤럭시탭s9울트라", "galaxy tab s9 ultra", "탭 s9 울트라", "tab s9 ultra"], + category: "tablet", + country: "KR", + source: "https://www.samsung.com/sec/tablets/galaxy-tab-s9/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy Tab S9 Ultra", os: "Android 13 (One UI 5.1.1)", chipset: "Snapdragon 8 Gen 2 for Galaxy", display_inch: "14.6", + resolution: "2960×1848", refresh_hz: "120", storage_gb: "256", ram_gb: "12", + battery_wh: "11200mAh", stylus: "S펜 포함", weight_g: "732", + cellular: "Wi-Fi (5G 옵션)", launch_price_krw: "149만 9천원부터" + } + }, + { + id: "galaxy-tab-s9-plus", + canonicalName: "갤럭시 탭 S9+", + nameEn: "Galaxy Tab S9+", + aliases: ["갤럭시 탭 s9+", "갤럭시탭 s9+", "갤럭시탭s9플러스", "갤럭시 탭 s9 플러스", "galaxy tab s9+", "galaxy tab s9 plus"], + category: "tablet", + country: "KR", + source: "https://www.samsung.com/sec/tablets/galaxy-tab-s9/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy Tab S9+", os: "Android 13 (One UI 5.1.1)", chipset: "Snapdragon 8 Gen 2 for Galaxy", display_inch: "12.4", + resolution: "2800×1752", refresh_hz: "120", storage_gb: "256", ram_gb: "12", + battery_wh: "10090mAh", stylus: "S펜 포함", weight_g: "581", + cellular: "Wi-Fi (5G 옵션)", launch_price_krw: "124만 9천원부터" + } + }, + { + id: "galaxy-tab-s9", + canonicalName: "갤럭시 탭 S9", + nameEn: "Galaxy Tab S9", + aliases: ["갤럭시 탭 s9", "갤럭시탭 s9", "갤럭시탭s9", "galaxy tab s9", "탭 s9", "tab s9", "갤럭시 탭", "갤럭시탭", "galaxy tab"], + category: "tablet", + country: "KR", + source: "https://www.samsung.com/sec/tablets/galaxy-tab-s9/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy Tab S9", os: "Android 13 (One UI 5.1.1)", chipset: "Snapdragon 8 Gen 2 for Galaxy", display_inch: "11", + resolution: "2560×1600", refresh_hz: "120", storage_gb: "128", ram_gb: "8", + battery_wh: "8400mAh", stylus: "S펜 포함", weight_g: "498", + cellular: "Wi-Fi (5G 옵션)", launch_price_krw: "99만 9천원부터" + } + }, + + // ══════════════════════════════════════════════════════════════════════════ + // Apple iPad Pro M1 / M2 (2021–2022) + // ══════════════════════════════════════════════════════════════════════════ + { + id: "ipad-pro-11-m1", + canonicalName: "아이패드 프로 11 M1", + nameEn: "iPad Pro 11 M1", + aliases: ["아이패드 프로 11 m1", "아이패드프로 11 m1", "아이패드 프로 11 2021", "ipad pro 11 m1", "ipad pro 11 2021"], + category: "tablet", + country: "KR", + source: "https://support.apple.com/ko-kr/111901", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "iPad Pro 11형 (3세대, M1)", os: "iPadOS", chipset: "Apple M1", display_inch: "11", + resolution: "2388×1668", refresh_hz: "120", storage_gb: "128", ram_gb: "8", + battery_wh: "동영상 재생 최대 10시간 (28.65Wh)", stylus: "Apple Pencil 2세대 지원", weight_g: "466", + cellular: "Wi-Fi (셀룰러 옵션)", launch_price_krw: "119만 9천원부터" + } + }, + { + id: "ipad-pro-13-m1", + canonicalName: "아이패드 프로 12.9 M1", + nameEn: "iPad Pro 12.9 M1", + aliases: [ + "아이패드 프로 12.9 m1", "아이패드프로 12.9 m1", "아이패드 프로 12.9 2021", "ipad pro 12.9 m1", "ipad pro 12.9 2021", + "아이패드 프로 13 m1", "아이패드프로13m1" + ], + category: "tablet", + country: "KR", + source: "https://support.apple.com/ko-kr/111901", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "iPad Pro 12.9형 (5세대, M1)", os: "iPadOS", chipset: "Apple M1", display_inch: "12.9", + resolution: "2732×2048", refresh_hz: "120", storage_gb: "128", ram_gb: "8", + battery_wh: "동영상 재생 최대 10시간 (40.88Wh)", stylus: "Apple Pencil 2세대 지원", weight_g: "682", + cellular: "Wi-Fi (셀룰러 옵션)", launch_price_krw: "149만 9천원부터" + } + }, + { + id: "ipad-pro-11-m2", + canonicalName: "아이패드 프로 11 M2", + nameEn: "iPad Pro 11 M2", + aliases: ["아이패드 프로 11 m2", "아이패드프로 11 m2", "아이패드 프로 11 2022", "ipad pro 11 m2", "ipad pro 11 2022"], + category: "tablet", + country: "KR", + source: "https://support.apple.com/ko-kr/111901", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "iPad Pro 11형 (4세대, M2)", os: "iPadOS", chipset: "Apple M2", display_inch: "11", + resolution: "2388×1668", refresh_hz: "120", storage_gb: "128", ram_gb: "8", + battery_wh: "동영상 재생 최대 10시간 (28.65Wh)", stylus: "Apple Pencil 2세대, 호버 지원", weight_g: "466", + cellular: "Wi-Fi (셀룰러 옵션)", launch_price_krw: "129만 9천원부터" + } + }, + { + id: "ipad-pro-13-m2", + canonicalName: "아이패드 프로 12.9 M2", + nameEn: "iPad Pro 12.9 M2", + aliases: [ + "아이패드 프로 12.9 m2", "아이패드프로 12.9 m2", "아이패드 프로 12.9 2022", "ipad pro 12.9 m2", "ipad pro 12.9 2022", + "아이패드 프로 13 m2", "아이패드프로13m2" + ], + category: "tablet", + country: "KR", + source: "https://support.apple.com/ko-kr/111901", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "iPad Pro 12.9형 (6세대, M2)", os: "iPadOS", chipset: "Apple M2", display_inch: "12.9", + resolution: "2732×2048", refresh_hz: "120", storage_gb: "128", ram_gb: "8", + battery_wh: "동영상 재생 최대 10시간 (40.88Wh)", stylus: "Apple Pencil 2세대, 호버 지원", weight_g: "682", + cellular: "Wi-Fi (셀룰러 옵션)", launch_price_krw: "159만 9천원부터" + } + }, + + // ══════════════════════════════════════════════════════════════════════════ + // Apple iPad Air / iPad (일반) 추가 + // ══════════════════════════════════════════════════════════════════════════ + { + id: "ipad-air-5-m1", + canonicalName: "아이패드 에어 5세대 M1", + nameEn: "iPad Air 5th Gen M1", + aliases: [ + "아이패드 에어 5세대", "아이패드에어5세대", "아이패드 에어 m1", "아이패드에어m1", + "ipad air 5", "ipad air 5th gen", "ipad air m1", "아이패드 에어 2022" + ], + category: "tablet", + country: "KR", + source: "https://support.apple.com/ko-kr/111901", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "iPad Air 5세대 (M1)", os: "iPadOS", chipset: "Apple M1", display_inch: "10.9", + resolution: "2360×1640", refresh_hz: "60", storage_gb: "64", ram_gb: "8", + battery_wh: "동영상 재생 최대 10시간 (28.65Wh)", stylus: "Apple Pencil 2세대 지원", weight_g: "461", + cellular: "Wi-Fi (셀룰러 옵션)", launch_price_krw: "99만원부터" + } + }, + { + id: "ipad-9", + canonicalName: "아이패드 9세대", + nameEn: "iPad (9th generation)", + aliases: [ + "아이패드 9세대", "아이패드9세대", "아이패드 9", "아이패드9", "ipad 9", "ipad 9th gen", + "ipad 9세대", "아이패드 2021", "아이패드2021" + ], + category: "tablet", + country: "KR", + source: "https://support.apple.com/ko-kr/111901", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "iPad 9세대", os: "iPadOS", chipset: "Apple A13 Bionic", display_inch: "10.2", + resolution: "2160×1620", refresh_hz: "60", storage_gb: "64", ram_gb: "3", + battery_wh: "동영상 재생 최대 10시간 (32.4Wh)", stylus: "Apple Pencil 1세대 지원", weight_g: "487", + cellular: "Wi-Fi (셀룰러 옵션)", launch_price_krw: "49만 9천원부터" + } + }, + + // ══════════════════════════════════════════════════════════════════════════ + // Samsung Galaxy Tab S7 시리즈 (2020) + // ══════════════════════════════════════════════════════════════════════════ + { + id: "galaxy-tab-s7", + canonicalName: "갤럭시 탭 S7", + nameEn: "Galaxy Tab S7", + aliases: ["갤럭시 탭 s7", "갤럭시탭s7", "galaxy tab s7", "탭 s7", "탭s7"], + category: "tablet", + country: "KR", + source: "https://www.samsung.com/sec/tablets/galaxy-tab-s7/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy Tab S7", os: "Android 10 (One UI 2.5)", chipset: "Snapdragon 865+", display_inch: "11", + resolution: "2560×1600", refresh_hz: "120", storage_gb: "128", ram_gb: "6", + battery_wh: "8000mAh", stylus: "S펜 포함", weight_g: "498", + cellular: "Wi-Fi (5G 옵션)", launch_price_krw: "89만 9천원부터" + } + }, + { + id: "galaxy-tab-s7-plus", + canonicalName: "갤럭시 탭 S7+", + nameEn: "Galaxy Tab S7+", + aliases: ["갤럭시 탭 s7+", "갤럭시탭s7+", "galaxy tab s7+", "galaxy tab s7 plus", "탭 s7+", "탭s7+"], + category: "tablet", + country: "KR", + source: "https://www.samsung.com/sec/tablets/galaxy-tab-s7-plus/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy Tab S7+", os: "Android 10 (One UI 2.5)", chipset: "Snapdragon 865+", display_inch: "12.4", + resolution: "2800×1752", refresh_hz: "120", storage_gb: "128", ram_gb: "6", + battery_wh: "10090mAh", stylus: "S펜 포함", weight_g: "575", + cellular: "Wi-Fi (5G 옵션)", launch_price_krw: "119만 9천원부터" + } + }, + + // ══════════════════════════════════════════════════════════════════════════ + // Samsung Galaxy Tab S8 시리즈 (2022) + // ══════════════════════════════════════════════════════════════════════════ + { + id: "galaxy-tab-s8", + canonicalName: "갤럭시 탭 S8", + nameEn: "Galaxy Tab S8", + aliases: ["갤럭시 탭 s8", "갤럭시탭s8", "galaxy tab s8", "탭 s8", "탭s8"], + category: "tablet", + country: "KR", + source: "https://www.samsung.com/sec/tablets/galaxy-tab-s8/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy Tab S8", os: "Android 12 (One UI 4.1)", chipset: "Snapdragon 8 Gen 1", display_inch: "11", + resolution: "2560×1600", refresh_hz: "120", storage_gb: "128", ram_gb: "8", + battery_wh: "8000mAh", stylus: "S펜 포함", weight_g: "503", + cellular: "Wi-Fi (5G 옵션)", launch_price_krw: "94만 9천원부터" + } + }, + { + id: "galaxy-tab-s8-plus", + canonicalName: "갤럭시 탭 S8+", + nameEn: "Galaxy Tab S8+", + aliases: ["갤럭시 탭 s8+", "갤럭시탭s8+", "galaxy tab s8+", "galaxy tab s8 plus", "탭 s8+", "탭s8+"], + category: "tablet", + country: "KR", + source: "https://www.samsung.com/sec/tablets/galaxy-tab-s8-plus/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy Tab S8+", os: "Android 12 (One UI 4.1)", chipset: "Snapdragon 8 Gen 1", display_inch: "12.4", + resolution: "2800×1752", refresh_hz: "120", storage_gb: "128", ram_gb: "8", + battery_wh: "10090mAh", stylus: "S펜 포함", weight_g: "572", + cellular: "Wi-Fi (5G 옵션)", launch_price_krw: "119만 9천원부터" + } + }, + { + id: "galaxy-tab-s8-ultra", + canonicalName: "갤럭시 탭 S8 울트라", + nameEn: "Galaxy Tab S8 Ultra", + aliases: ["갤럭시 탭 s8 울트라", "갤럭시탭s8울트라", "galaxy tab s8 ultra", "탭 s8 울트라", "탭s8울트라"], + category: "tablet", + country: "KR", + source: "https://www.samsung.com/sec/tablets/galaxy-tab-s8-ultra/", + fetchedAt: "2026-06", + tier: 1, + specs: { + model_name: "Galaxy Tab S8 Ultra", os: "Android 12 (One UI 4.1)", chipset: "Snapdragon 8 Gen 1", display_inch: "14.6", + resolution: "2960×1848", refresh_hz: "120", storage_gb: "128", ram_gb: "8", + battery_wh: "11200mAh", stylus: "S펜 포함", weight_g: "726", + cellular: "Wi-Fi (5G 옵션)", launch_price_krw: "159만 9천원부터" + } + }, +]; diff --git a/lib/specs/extract/index.ts b/lib/specs/extract/index.ts new file mode 100644 index 0000000..98922f3 --- /dev/null +++ b/lib/specs/extract/index.ts @@ -0,0 +1,228 @@ +import { getCategorySchema, getField } from "@/lib/specs/schema"; +import { isMeaningful, type SpecSourceTier } from "@/lib/specs/source"; +import { completeJson, type CompleteFn } from "@/lib/ai/complete"; +import { extractRuleBasedSpecs } from "@/lib/specs/extract/rules"; +import type { Category } from "@/lib/types"; + +/** + * AI spec extractor — the engine that realizes "AI reads the official page and + * pulls the specs out". Given a product's official page HTML, an LLM extracts + * ONLY the values literally present, mapped into the category schema. Output is + * tier-2 (verified-from-official, AI-extracted) — strong enough to pass the gate + * and index, while staying honest that a human didn't transcribe it. + * + * Design: extract-once-and-cache, NOT live-per-request. The orchestrator is + * pure given an injected `complete`, so the prompt + parsing are fully testable + * without a live model. Real fetch + LLM plug in via `complete` / the fetch caller. + */ + +/** Tier assigned to AI-extracted-from-official specs. */ +export const EXTRACTED_TIER: SpecSourceTier = 2; + +export type ExtractedSpecs = { + productName: string; + category: Category; + /** Official page the values were extracted from. */ + source: string; + /** YYYY-MM-DD the page was extracted. */ + fetchedAt: string; + tier: SpecSourceTier; + /** schema fieldKey → value. */ + specs: Record; +}; + +const MAX_TEXT = 12_000; +const MAX_FOCUSED_TEXT = 5_000; +const SNIPPET_RADIUS = 360; + +/** Strip an official page down to readable text for the model. */ +export function htmlToText(html: string): string { + return html + .replace(//gi, " ") + .replace(//gi, " ") + .replace(//g, " ") + .replace(/<[^>]+>/g, " ") + .replace(/ /gi, " ") + .replace(/&/gi, "&") + .replace(/</gi, "<") + .replace(/>/gi, ">") + .replace(/&#x([0-9a-f]+);/gi, (_, code: string) => String.fromCodePoint(parseInt(code, 16))) + .replace(/&#\d+;/g, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, MAX_TEXT); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function searchTermsForCategory(category: Category): string[] { + const schema = getCategorySchema(category); + if (!schema) return []; + return schema.fields + .flatMap((field) => [ + field.key, + field.label, + field.labelEn, + ...(field.searchTerms ?? []) + ]) + .map((term) => term.trim()) + .filter((term, index, terms) => term.length >= 2 && terms.indexOf(term) === index); +} + +export function buildFocusedPageText(category: Category, pageText: string): string { + const text = pageText.trim(); + if (text.length <= MAX_FOCUSED_TEXT) return text; + + const ranges = searchTermsForCategory(category) + .flatMap((term) => { + const pattern = new RegExp(escapeRegExp(term), "gi"); + return Array.from(text.matchAll(pattern)).slice(0, 2).map((match) => { + const index = match.index ?? 0; + return { + start: Math.max(0, index - SNIPPET_RADIUS), + end: Math.min(text.length, index + term.length + SNIPPET_RADIUS) + }; + }); + }) + .sort((a, b) => a.start - b.start); + + if (!ranges.length) return text.slice(0, MAX_FOCUSED_TEXT); + + const merged: Array<{ start: number; end: number }> = []; + for (const range of ranges) { + const last = merged[merged.length - 1]; + if (!last || range.start > last.end + 80) { + merged.push(range); + } else { + last.end = Math.max(last.end, range.end); + } + } + + return merged + .map((range) => text.slice(range.start, range.end).trim()) + .join("\n…\n") + .slice(0, MAX_FOCUSED_TEXT); +} + +export function buildExtractionPrompt(category: Category, productName: string, pageText: string) { + const schema = getCategorySchema(category); + const fields = schema?.fields ?? []; + const focusedPageText = buildFocusedPageText(category, pageText); + + const fieldLines = fields + .map((f) => { + const unit = f.unit ? ` (단위: ${f.unit})` : ""; + const hint = f.hint ? ` — ${f.hint}` : ""; + return `- ${f.key}: ${f.label}${unit}${hint}`; + }) + .join("\n"); + + const system = + "너는 제조사 공식 제품 페이지에서 스펙을 추출하는 도구다. " + + "규칙: (1) 페이지 텍스트에 실제로 적힌 값만 사용한다. (2) 없으면 null. " + + "(3) 절대 추측하거나 일반 지식으로 채우지 않는다. (4) 숫자는 단위 없이 숫자만(예: 무게 1240, 배터리 52.6). " + + "(5) 여러 용량/구성이 있으면 가장 낮은 저장공간의 기본 구성을 우선한다. " + + "(6) 모델명·칩셋만 채우고 멈추지 말고, 표/목록 전체에서 주요 스펙 필드를 끝까지 찾는다. " + + "(7) 반드시 JSON 객체만 출력한다."; + + const user = `제품: ${productName} +카테고리: ${category} + +추출할 필드 (JSON 키 = 필드 id): +${fieldLines} + +아래는 공식 페이지의 텍스트다. 위 필드를 JSON으로 추출하라. +각 키의 값은 페이지에 있으면 문자열, 없으면 null. + +JSON 스키마: +{ ${fields.map((f) => `"${f.key}": "값 또는 null"`).join(", ")} } + +--- 공식 페이지 텍스트 --- +${focusedPageText}`; + + return { system, user }; +} + +function specsWithProductIdentity( + category: Category, + productName: string, + specs: Record +): Record { + if (!getField(category, "model_name") || specs.model_name) return specs; + return { model_name: productName, ...specs }; +} + +/** Parse the model's JSON, keeping ONLY valid schema fields with meaningful values. */ +export function parseExtraction(raw: string, category: Category): Record { + const cleaned = raw.replace(/```json|```/g, "").trim(); + const start = cleaned.indexOf("{"); + const end = cleaned.lastIndexOf("}"); + if (start === -1 || end === -1 || end <= start) return {}; + + let obj: Record; + try { + obj = JSON.parse(cleaned.slice(start, end + 1)) as Record; + } catch { + return {}; + } + + const out: Record = {}; + for (const [key, value] of Object.entries(obj)) { + if (!getField(category, key)) continue; // drop keys not in the schema + if (value === null || value === undefined) continue; + const str = String(value).trim(); + if (!isMeaningful(str)) continue; + out[key] = str; + } + return out; +} + +export async function extractSpecsFromPage(params: { + productName: string; + category: Category; + sourceUrl: string; + html: string; + complete?: CompleteFn; +}): Promise { + if (!getCategorySchema(params.category)) return null; + + const pageText = htmlToText(params.html); + if (pageText.length < 40) return null; // nothing usable on the page + + const ruleBasedSpecs = extractRuleBasedSpecs(params.category, params.productName, pageText); + if (Object.keys(ruleBasedSpecs).length >= 4) { + return { + productName: params.productName, + category: params.category, + source: params.sourceUrl, + fetchedAt: new Date().toISOString().slice(0, 10), + tier: EXTRACTED_TIER, + specs: specsWithProductIdentity(params.category, params.productName, ruleBasedSpecs) + }; + } + + const { system, user } = buildExtractionPrompt(params.category, params.productName, pageText); + const complete = params.complete ?? completeJson; + + const raw = await complete(system, user); + if (!raw) return null; + + const parsedSpecs = parseExtraction(raw, params.category); + if (Object.keys(parsedSpecs).length === 0 && Object.keys(ruleBasedSpecs).length < 4) return null; + const specs = specsWithProductIdentity(params.category, params.productName, { + ...ruleBasedSpecs, + ...parsedSpecs + }); + if (Object.keys(specs).length === 0) return null; + + return { + productName: params.productName, + category: params.category, + source: params.sourceUrl, + fetchedAt: new Date().toISOString().slice(0, 10), + tier: EXTRACTED_TIER, + specs + }; +} diff --git a/lib/specs/extract/rules.ts b/lib/specs/extract/rules.ts new file mode 100644 index 0000000..3c47587 --- /dev/null +++ b/lib/specs/extract/rules.ts @@ -0,0 +1,210 @@ +import type { Category } from "@/lib/types"; + +function firstMatch(text: string, patterns: RegExp[]): string | null { + for (const pattern of patterns) { + const match = text.match(pattern); + if (match?.[1]) return match[1].trim(); + } + return null; +} + +function nthMatch(text: string, pattern: RegExp, index: number): string | null { + const matches = Array.from(text.matchAll(pattern)); + return matches[index]?.[1]?.trim() ?? null; +} + +function maxNumericMatch(text: string, pattern: RegExp): string | null { + const values = Array.from(text.matchAll(pattern)) + .map((match) => Number(match[1])) + .filter((value) => Number.isFinite(value)); + if (!values.length) return null; + return String(Math.max(...values)); +} + +function normalizedProductName(productName: string): string { + return productName.toLowerCase().replace(/\s+/g, " ").trim(); +} + +function isIphone(productName: string): boolean { + return /iphone|아이폰/i.test(productName); +} + +function toGrams(value: string): string { + return String(Math.round(Number(value) * 1000)); +} + +function extractIphoneModel(productName: string): string | null { + return firstMatch(productName, [/(?:iphone|아이폰)\s*(\d{2}(?:\s*pro\s*max|\s*pro|\s*plus)?)/i]); +} + +function extractAppleSmartphoneSpecs(productName: string, text: string): Record { + const specs: Record = {}; + const model = extractIphoneModel(productName); + const escapedModel = model?.replace(/\s+/g, "\\s*"); + const modelPattern = escapedModel ? `(?:iPhone|아이폰)\\s*${escapedModel}` : "iPhone\\s*\\d+"; + + const storage = firstMatch(text, [new RegExp(`${modelPattern}\\s+(\\d+)GB`, "i"), /저장 용량\s+\d?\s*(\d+)GB/i]); + const supportStorage = firstMatch(text, [/(?:Capacity|저장 용량)[\s\S]{0,80}?(\d+)\s*GB/i]); + const weight = firstMatch(text, [ + new RegExp(`${modelPattern}\\s+무게:\\s*(\\d+)g`, "i"), + new RegExp(`${modelPattern}\\s+Weight:\\s*(?:[\\d.]+ ounces \\()?(\\d+) grams`, "i"), + /무게:\s*(\d+)g/i, + /Weight:\s*(?:[\d.]+ ounces \()?(\d+) grams/i + ]); + const battery = firstMatch(text, [ + new RegExp(`${modelPattern}\\s+동영상 재생 최대\\s*([\\d]+시간)`, "i"), + /동영상 재생:\s*최대\s*([\d]+시간)/i, + new RegExp(`${modelPattern}[\\s\\S]{0,120}?video playback\\s*up to\\s*([\\d]+ hours)`, "i"), + /Video playback:?\s*Up to\s*([\d]+ hours)/i + ]); + const display = firstMatch(text, [ + new RegExp(`${modelPattern}\\s+Super Retina[\\s\\S]{0,80}?(\\d+(?:\\.\\d+)?cm)`, "i"), + /디스플레이\s+Super Retina[\s\S]{0,80}?(\d+(?:\.\d+)?cm)/i, + new RegExp(`${modelPattern}[\\s\\S]{0,120}?(\\d+(?:\\.\\d+)?)[‑-]?inch`, "i"), + /Display[\s\S]{0,80}?(\d+(?:\.\d+)?)[‑-]?inch/i + ]); + const refresh = firstMatch(text, [ + /(?:up to|최대)\s*(\d+)\s*Hz/i, + /(\d+)\s*Hz\s*(?:adaptive|가변|주사율)/i + ]); + const brightness = maxNumericMatch(text, /(\d+)\s*nits?/gi); + const camera = firstMatch(text, [ + /(\d+)MP\s+Fusion\s+메인/i, + /(\d+)MP\s+Fusion\s+Main/i, + /(\d+)MP\s+메인/i, + /(\d+)MP\s+Main/i + ]); + const chipset = firstMatch(text, [ + /\b(A\d+(?:\s+Bionic|\s+Pro)?)\s+칩/i, + /\b(A\d+(?:\s+Bionic|\s+Pro)?)\s+chip/i + ]); + const os = firstMatch(text, [/(?:Operating System|운영체제)\s+(iOS)/i]); + + if (os) specs.os = os; + if (chipset) specs.chipset = chipset; + if (display) specs.display_inch = display; + if (battery) specs.battery = battery; + if (weight) specs.weight_g = weight; + if (camera) specs.camera_mp = camera; + if (storage ?? supportStorage) specs.storage_gb = storage ?? supportStorage ?? ""; + if (refresh) specs.refresh_hz = refresh; + if (brightness) specs.brightness_nits = brightness; + + return specs; +} + +function extractSamsungSmartphoneSpecs(productName: string, text: string): Record { + const specs: Record = {}; + const normalized = normalizedProductName(productName); + const variantIndex = /\+|plus|플러스/.test(normalized) ? 1 : 0; + const display = firstMatch(text, [ + /크기 \(Main Display\)\s*([\d.]+mm)/i, + /Size \(Main[ _]Display\)\s*([\d.]+mm)/i + ]); + const battery = firstMatch(text, [ + /배터리 용량\(mAh, Typical\)\s*(\d+)/i, + /Battery Capacity\(mAh, Typical\)\s*(\d+)/i + ]); + const weight = firstMatch(text, [/무게\(g\)\s*(\d+)/i, /Weight\(g\)\s*(\d+)/i]); + const camera = firstMatch(text, [ + /후면 카메라 - 화소 \(Multiple\)\s*([\d.]+)\s*MP/i, + /Rear Camera - Resolution \(Multiple\)\s*([\d.]+)\s*MP/i + ]); + const storage = firstMatch(text, [/ROM Size \(GB\)\s*(\d+)/i, /스토리지\(GB\)\s*(\d+)/i]); + const refresh = firstMatch(text, [ + /최대 주사율 \(Main Display\)\s*(\d+)\s*Hz/i, + /Max Refresh Rate \(Main Display\)\s*(\d+)\s*Hz/i + ]); + const featureDisplay = variantIndex === 1 + ? firstMatch(text, [/Galaxy S25\+ has a ([\d.]+-inch) display/i]) + : firstMatch(text, [/Galaxy S25 has a ([\d.]+-inch) display/i]); + const featureBattery = nthMatch(text, /(\d{4})mAh\s+Up to\s+\d+\s+hrs? of video playback/gi, variantIndex); + const featureCamera = firstMatch(text, [/(\d+)MP Wide-angle Camera/i]); + const featureStorage = nthMatch(text, /((?:\d+GB\s*\|\s*)*\d+GB)\s+storage\s+\d+GB memory/gi, variantIndex); + const featureChipset = firstMatch(text, [/(Snapdragon®?\s*8\s*Elite\s*for\s*Galaxy)/i]); + + if (featureChipset) specs.chipset = featureChipset; + if (display ?? featureDisplay) specs.display_inch = display ?? featureDisplay ?? ""; + if (battery ?? featureBattery) specs.battery = `${battery ?? featureBattery}mAh`; + if (weight) specs.weight_g = weight; + if (camera ?? featureCamera) specs.camera_mp = camera ?? featureCamera ?? ""; + if (storage ?? featureStorage) specs.storage_gb = storage ?? featureStorage ?? ""; + if (refresh) specs.refresh_hz = refresh; + + return specs; +} + +function extractAppleLaptopSpecs(text: string): Record { + const specs: Record = {}; + const cpu = firstMatch(text, [/(Apple M\d+)\s*칩/i, /(Apple M\d+)\s*chip/i]); + const gpu = firstMatch(text, [/(\d+코어 GPU)/i, /(\d+-core GPU)/i]); + const ram = firstMatch(text, [/메모리\s+(\d+)GB\s+\d+GB 통합 메모리/i, /Memory\s+(\d+)GB/i]); + const storage = firstMatch(text, [/저장 장치\s+\d?\s*(\d+)GB\s+\d+GB SSD/i, /Storage\s+(\d+)GB\s+\d+GB SSD/i]); + const display = firstMatch(text, [/Liquid Retina 디스플레이\s+([\d.]+cm)/i, /Liquid Retina display\s+([\d.]+-inch)/i]); + const resolution = firstMatch(text, [/([\d]{4}\s*x\s*[\d]{4}) 기본 해상도/i, /([\d]{4}-by-[\d]{4}) native resolution/i]); + const brightness = firstMatch(text, [/(\d+)\s*니트\s*밝기/i, /(\d+)\s*nits?\s*brightness/i]); + const battery = firstMatch(text, [/([\d.]+)와트시 리튬/i, /Built-in\s+([\d.]+)[‑-]?watt[‑-]?hour/i]); + const weightKg = firstMatch(text, [/무게:\s*([\d.]+)kg/i, /Weight:\s*[\d.]+ pounds \(([\d.]+) kg\)/i]); + const ports = firstMatch(text, [/(Thunderbolt 4\(USB-C\)) 포트 2개/i, /(Two Thunderbolt 4 \(USB-C\) ports)/i]); + + if (cpu) specs.cpu = cpu; + if (cpu && gpu) specs.gpu = gpu.includes("core") ? `${cpu} ${gpu}` : gpu; + if (ram) specs.ram_gb = ram; + if (storage) specs.storage_gb = storage; + if (display) specs.display_inch = display; + if (display) specs.panel = "IPS (Liquid Retina)"; + if (resolution) specs.resolution = resolution.replace("-by-", " x "); + if (brightness) specs.brightness_nits = brightness; + if (battery) specs.battery_wh = battery; + if (weightKg) specs.weight_g = toGrams(weightKg); + if (ports) specs.ports = ports.includes("Two") ? "Thunderbolt 4(USB-C) ×2" : `${ports} ×2`; + if (/macOS/i.test(text)) specs.os = "macOS"; + + return specs; +} + +function extractSamsungLaptopSpecs(text: string): Record { + const specs: Record = {}; + const cpu = firstMatch(text, [/CPU\s+(.+?Processor\s+\d+[A-Z]?)(?:\s*\(|\s+그래픽카드)/i]); + const gpu = firstMatch(text, [/그래픽카드\s+(.+?)\s+(?:그래픽카드 타입|디스플레이 크기)/i]); + const display = firstMatch(text, [/디스플레이 크기\s+[\d.]+\s*cm\s+\(([\d.]+)\s*inch\)/i]); + const resolution = firstMatch(text, [/해상도\s+([\d]{4}\s*x\s*[\d]{4})/i]); + const panel = firstMatch(text, [/종류\s+(.+?)\s+(?:터치스크린|메모리\/저장장치|메모리\s+\d+\s*GB)/i]); + const ram = firstMatch(text, [/메모리\s+(\d+)\s*GB\s+메모리 타입/i]); + const storage = firstMatch(text, [/저장장치 용량\s+(\d+)\s*GB/i]); + const battery = firstMatch(text, [/배터리 용량 \(Typical\)\(Wh\)\s*([\d.]+)/i]); + const weightKg = firstMatch(text, [/무게 \(kg\)\s*([\d.]+)/i]); + const os = firstMatch(text, [/운영체제\s+(Windows \d+ [A-Za-z]+)/i]); + + if (cpu) specs.cpu = cpu; + if (gpu) specs.gpu = gpu; + if (ram) specs.ram_gb = ram; + if (storage) specs.storage_gb = storage; + if (display) specs.display_inch = display; + if (panel) specs.panel = panel.replace(/,\s*Anti-Reflective/i, ""); + if (resolution) specs.resolution = resolution; + if (battery) specs.battery_wh = battery; + if (weightKg) specs.weight_g = toGrams(weightKg); + if (os) specs.os = os; + + return specs; +} + +export function extractRuleBasedSpecs( + category: Category, + productName: string, + text: string +): Record { + const normalized = normalizedProductName(productName); + if (category === "smartphone") { + if (isIphone(normalized)) return extractAppleSmartphoneSpecs(productName, text); + if (/galaxy|갤럭시/i.test(normalized)) return extractSamsungSmartphoneSpecs(productName, text); + } + + if (category === "laptop") { + if (/macbook|맥북/i.test(normalized)) return extractAppleLaptopSpecs(text); + if (/galaxy\s*book|갤럭시\s*북|갤럭시북/i.test(normalized)) return extractSamsungLaptopSpecs(text); + } + + return {}; +} diff --git a/next-env.d.ts b/next-env.d.ts index c4b7818..9edff1c 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/package-lock.json b/package-lock.json index 23bf124..7710c99 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1057,9 +1057,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1076,9 +1073,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1095,9 +1089,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1114,9 +1105,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1133,9 +1121,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1152,9 +1137,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1171,9 +1153,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1190,9 +1169,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1209,9 +1185,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1234,9 +1207,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1259,9 +1229,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1284,9 +1251,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1309,9 +1273,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1334,9 +1295,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1359,9 +1317,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1384,9 +1339,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1602,9 +1554,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1621,9 +1570,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1640,9 +1586,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1659,9 +1602,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1843,9 +1783,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1860,9 +1797,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1877,9 +1811,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1894,9 +1825,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1911,9 +1839,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1928,9 +1853,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1945,9 +1867,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1962,9 +1881,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1979,9 +1895,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1996,9 +1909,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2013,9 +1923,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2030,9 +1937,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2047,9 +1951,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2391,9 +2292,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2411,9 +2309,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2431,9 +2326,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2451,9 +2343,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3030,9 +2919,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3047,9 +2933,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3064,9 +2947,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3081,9 +2961,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3098,9 +2975,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3115,9 +2989,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3132,9 +3003,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3149,9 +3017,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3166,9 +3031,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3183,9 +3045,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6379,9 +6238,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6403,9 +6259,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6427,9 +6280,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6451,9 +6301,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/scripts/collect-specs/models/smartphones.json b/scripts/collect-specs/models/smartphones.json new file mode 100644 index 0000000..4c80114 --- /dev/null +++ b/scripts/collect-specs/models/smartphones.json @@ -0,0 +1,131 @@ +{ + "description": "Smartphones to collect specs for. Each entry defines the product id, canonical name, aliases, and search queries per market.", + "models": [ + { + "id": "iphone-16", + "canonicalName": { "KR": "아이폰 16", "US": "iPhone 16", "JP": "iPhone 16" }, + "aliases": { "KR": ["아이폰 16", "아이폰16", "iphone 16"], "US": ["iphone 16"], "JP": ["アイフォン16", "iPhone 16"] }, + "searchQuery": { "KR": "아이폰 16", "US": "Apple iPhone 16", "JP": "iPhone 16" } + }, + { + "id": "iphone-16-plus", + "canonicalName": { "KR": "아이폰 16 플러스", "US": "iPhone 16 Plus", "JP": "iPhone 16 Plus" }, + "aliases": { "KR": ["아이폰 16 플러스", "아이폰16 플러스", "iphone 16 plus"], "US": ["iphone 16 plus"], "JP": ["iPhone 16 Plus"] }, + "searchQuery": { "KR": "아이폰 16 플러스", "US": "Apple iPhone 16 Plus", "JP": "iPhone 16 Plus" } + }, + { + "id": "iphone-16-pro", + "canonicalName": { "KR": "아이폰 16 프로", "US": "iPhone 16 Pro", "JP": "iPhone 16 Pro" }, + "aliases": { "KR": ["아이폰 16 프로", "아이폰16 프로", "iphone 16 pro"], "US": ["iphone 16 pro"], "JP": ["iPhone 16 Pro"] }, + "searchQuery": { "KR": "아이폰 16 프로", "US": "Apple iPhone 16 Pro", "JP": "iPhone 16 Pro" } + }, + { + "id": "iphone-16-pro-max", + "canonicalName": { "KR": "아이폰 16 프로 맥스", "US": "iPhone 16 Pro Max", "JP": "iPhone 16 Pro Max" }, + "aliases": { "KR": ["아이폰 16 프로 맥스", "아이폰16 프로맥스", "iphone 16 pro max"], "US": ["iphone 16 pro max"], "JP": ["iPhone 16 Pro Max"] }, + "searchQuery": { "KR": "아이폰 16 프로 맥스", "US": "Apple iPhone 16 Pro Max", "JP": "iPhone 16 Pro Max" } + }, + { + "id": "iphone-15", + "canonicalName": { "KR": "아이폰 15", "US": "iPhone 15", "JP": "iPhone 15" }, + "aliases": { "KR": ["아이폰 15", "아이폰15", "iphone 15"], "US": ["iphone 15"], "JP": ["iPhone 15"] }, + "searchQuery": { "KR": "아이폰 15", "US": "Apple iPhone 15", "JP": "iPhone 15" } + }, + { + "id": "iphone-15-plus", + "canonicalName": { "KR": "아이폰 15 플러스", "US": "iPhone 15 Plus", "JP": "iPhone 15 Plus" }, + "aliases": { "KR": ["아이폰 15 플러스", "iphone 15 plus"], "US": ["iphone 15 plus"], "JP": ["iPhone 15 Plus"] }, + "searchQuery": { "KR": "아이폰 15 플러스", "US": "Apple iPhone 15 Plus", "JP": "iPhone 15 Plus" } + }, + { + "id": "iphone-15-pro", + "canonicalName": { "KR": "아이폰 15 프로", "US": "iPhone 15 Pro", "JP": "iPhone 15 Pro" }, + "aliases": { "KR": ["아이폰 15 프로", "iphone 15 pro"], "US": ["iphone 15 pro"], "JP": ["iPhone 15 Pro"] }, + "searchQuery": { "KR": "아이폰 15 프로", "US": "Apple iPhone 15 Pro", "JP": "iPhone 15 Pro" } + }, + { + "id": "iphone-15-pro-max", + "canonicalName": { "KR": "아이폰 15 프로 맥스", "US": "iPhone 15 Pro Max", "JP": "iPhone 15 Pro Max" }, + "aliases": { "KR": ["아이폰 15 프로 맥스", "iphone 15 pro max"], "US": ["iphone 15 pro max"], "JP": ["iPhone 15 Pro Max"] }, + "searchQuery": { "KR": "아이폰 15 프로 맥스", "US": "Apple iPhone 15 Pro Max", "JP": "iPhone 15 Pro Max" } + }, + { + "id": "iphone-14", + "canonicalName": { "KR": "아이폰 14", "US": "iPhone 14", "JP": "iPhone 14" }, + "aliases": { "KR": ["아이폰 14", "iphone 14"], "US": ["iphone 14"], "JP": ["iPhone 14"] }, + "searchQuery": { "KR": "아이폰 14", "US": "Apple iPhone 14", "JP": "iPhone 14" } + }, + { + "id": "iphone-14-pro", + "canonicalName": { "KR": "아이폰 14 프로", "US": "iPhone 14 Pro", "JP": "iPhone 14 Pro" }, + "aliases": { "KR": ["아이폰 14 프로", "iphone 14 pro"], "US": ["iphone 14 pro"], "JP": ["iPhone 14 Pro"] }, + "searchQuery": { "KR": "아이폰 14 프로", "US": "Apple iPhone 14 Pro", "JP": "iPhone 14 Pro" } + }, + { + "id": "iphone-14-pro-max", + "canonicalName": { "KR": "아이폰 14 프로 맥스", "US": "iPhone 14 Pro Max", "JP": "iPhone 14 Pro Max" }, + "aliases": { "KR": ["아이폰 14 프로 맥스", "iphone 14 pro max"], "US": ["iphone 14 pro max"], "JP": ["iPhone 14 Pro Max"] }, + "searchQuery": { "KR": "아이폰 14 프로 맥스", "US": "Apple iPhone 14 Pro Max", "JP": "iPhone 14 Pro Max" } + }, + { + "id": "galaxy-s25", + "canonicalName": { "KR": "갤럭시 S25", "US": "Galaxy S25", "JP": "Galaxy S25" }, + "aliases": { "KR": ["갤럭시 s25", "갤럭시s25", "galaxy s25"], "US": ["galaxy s25"], "JP": ["Galaxy S25", "ギャラクシーS25"] }, + "searchQuery": { "KR": "삼성 갤럭시 S25 SM-S931", "US": "Samsung Galaxy S25", "JP": "Galaxy S25" } + }, + { + "id": "galaxy-s25-plus", + "canonicalName": { "KR": "갤럭시 S25+", "US": "Galaxy S25+", "JP": "Galaxy S25+" }, + "aliases": { "KR": ["갤럭시 s25+", "갤럭시 s25 플러스", "galaxy s25+", "galaxy s25 plus"], "US": ["galaxy s25+", "galaxy s25 plus"], "JP": ["Galaxy S25+"] }, + "searchQuery": { "KR": "갤럭시 S25 플러스", "US": "Samsung Galaxy S25 Plus", "JP": "Galaxy S25+" } + }, + { + "id": "galaxy-s25-ultra", + "canonicalName": { "KR": "갤럭시 S25 울트라", "US": "Galaxy S25 Ultra", "JP": "Galaxy S25 Ultra" }, + "aliases": { "KR": ["갤럭시 s25 울트라", "galaxy s25 ultra"], "US": ["galaxy s25 ultra"], "JP": ["Galaxy S25 Ultra"] }, + "searchQuery": { "KR": "갤럭시 S25 울트라", "US": "Samsung Galaxy S25 Ultra", "JP": "Galaxy S25 Ultra" } + }, + { + "id": "galaxy-s24", + "canonicalName": { "KR": "갤럭시 S24", "US": "Galaxy S24", "JP": "Galaxy S24" }, + "aliases": { "KR": ["갤럭시 s24", "galaxy s24"], "US": ["galaxy s24"], "JP": ["Galaxy S24"] }, + "searchQuery": { "KR": "삼성 갤럭시 S24 SM-S921", "US": "Samsung Galaxy S24", "JP": "Galaxy S24" } + }, + { + "id": "galaxy-s24-plus", + "canonicalName": { "KR": "갤럭시 S24+", "US": "Galaxy S24+", "JP": "Galaxy S24+" }, + "aliases": { "KR": ["갤럭시 s24+", "갤럭시 s24 플러스", "galaxy s24+", "galaxy s24 plus"], "US": ["galaxy s24+", "galaxy s24 plus"], "JP": ["Galaxy S24+"] }, + "searchQuery": { "KR": "삼성 갤럭시 S24 플러스 SM-S926", "US": "Samsung Galaxy S24 Plus", "JP": "Galaxy S24+" } + }, + { + "id": "galaxy-s24-ultra", + "canonicalName": { "KR": "갤럭시 S24 울트라", "US": "Galaxy S24 Ultra", "JP": "Galaxy S24 Ultra" }, + "aliases": { "KR": ["갤럭시 s24 울트라", "galaxy s24 ultra"], "US": ["galaxy s24 ultra"], "JP": ["Galaxy S24 Ultra"] }, + "searchQuery": { "KR": "갤럭시 S24 울트라", "US": "Samsung Galaxy S24 Ultra", "JP": "Galaxy S24 Ultra" } + }, + { + "id": "galaxy-s23", + "canonicalName": { "KR": "갤럭시 S23", "US": "Galaxy S23", "JP": "Galaxy S23" }, + "aliases": { "KR": ["갤럭시 s23", "galaxy s23"], "US": ["galaxy s23"], "JP": ["Galaxy S23"] }, + "searchQuery": { "KR": "갤럭시 S23", "US": "Samsung Galaxy S23", "JP": "Galaxy S23" } + }, + { + "id": "galaxy-s23-ultra", + "canonicalName": { "KR": "갤럭시 S23 울트라", "US": "Galaxy S23 Ultra", "JP": "Galaxy S23 Ultra" }, + "aliases": { "KR": ["갤럭시 s23 울트라", "galaxy s23 ultra"], "US": ["galaxy s23 ultra"], "JP": ["Galaxy S23 Ultra"] }, + "searchQuery": { "KR": "갤럭시 S23 울트라", "US": "Samsung Galaxy S23 Ultra", "JP": "Galaxy S23 Ultra" } + }, + { + "id": "galaxy-z-fold6", + "canonicalName": { "KR": "갤럭시 Z 폴드6", "US": "Galaxy Z Fold6", "JP": "Galaxy Z Fold6" }, + "aliases": { "KR": ["갤럭시 z 폴드6", "갤럭시 폴드6", "galaxy z fold 6"], "US": ["galaxy z fold6", "galaxy fold 6"], "JP": ["Galaxy Z Fold6"] }, + "searchQuery": { "KR": "갤럭시 Z 폴드6", "US": "Samsung Galaxy Z Fold6", "JP": "Galaxy Z Fold6" } + }, + { + "id": "galaxy-z-flip6", + "canonicalName": { "KR": "갤럭시 Z 플립6", "US": "Galaxy Z Flip6", "JP": "Galaxy Z Flip6" }, + "aliases": { "KR": ["갤럭시 z 플립6", "갤럭시 플립6", "galaxy z flip 6"], "US": ["galaxy z flip6", "galaxy flip 6"], "JP": ["Galaxy Z Flip6"] }, + "searchQuery": { "KR": "갤럭시 Z 플립6", "US": "Samsung Galaxy Z Flip6", "JP": "Galaxy Z Flip6" } + } + ] +} diff --git a/scripts/collect-specs/sources/danawa.ts b/scripts/collect-specs/sources/danawa.ts new file mode 100644 index 0000000..456ca06 --- /dev/null +++ b/scripts/collect-specs/sources/danawa.ts @@ -0,0 +1,338 @@ +/** + * 다나와 (danawa.com) spec scraper — Korean market. + * + * ── 실제 HTML 구조 (2024-06 검증) ───────────────────────────────────────── + * 검색 결과 페이지에 각 상품의 스펙이 인라인 텍스트로 포함됨: + * + *
    + *
    + * 화면:15.5cm(6.1인치)/ + * : 8GB/ + * ... + * 무게: 170g / + * 출시가: 1,250,000원 + *
    + *
    + * + * 스펙 테이블(spec 탭)은 JS 렌더링이라 th/td 방식으로 파싱 불가. + * 검색 결과 리스팅에서 spec_list 텍스트를 직접 파싱하는 방식이 가장 안정적. + * + * Flow: + * 1. 검색 결과 페이지 fetch (단 1회 요청) + * 2. 첫 번째 비광고 상품의 spec_list 텍스트 추출 + * 3. "key:value / key:value" 형식 파싱 → schema fieldKey 매핑 + * 4. 출시일은 등록일 텍스트(예: "24.09. 등록")에서 추출 + */ + +const HEADERS = { + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Accept-Language": "ko-KR,ko;q=0.9", + Accept: "text/html,application/xhtml+xml", + Referer: "https://www.danawa.com/" +}; + +const FETCH_TIMEOUT_MS = 15_000; + +async function fetchHtml(url: string): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + try { + const res = await fetch(url, { headers: HEADERS, signal: controller.signal }); + if (!res.ok) return null; + return await res.text(); + } catch { + return null; + } finally { + clearTimeout(timer); + } +} + +// ─── 파싱 유틸 ────────────────────────────────────────────────────────────── + +/** spec_list 안의 HTML을 plain text로 변환 */ +function specListHtmlToText(html: string): string { + return html + // 레이블 → 레이블 + .replace(/]*class="view_dic"[^>]*>([\s\S]*?)<\/a>/gi, (_, inner) => + inner.replace(/<[^>]+>/g, "").trim() + ) + // / → / + .replace(/\s*\/\s*<\/em>/gi, " / ") + // 텍스트 → 텍스트 + .replace(/([\s\S]*?)<\/u>/gi, "$1") + // 텍스트 → 텍스트 + .replace(/]*>([\s\S]*?)<\/span>/gi, "$1") + // 나머지 태그 제거 + .replace(/<[^>]+>/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +/** "화면:15.5cm(6.1인치)" → "6.1" (인치 값만 추출) */ +function extractInch(raw: string): string { + const m = raw.match(/\(([0-9.]+)인치\)/); + if (m) return m[1]; + // fallback: cm to inch (15.5cm / 2.54 ≈ 6.1) + const cm = raw.match(/([0-9.]+)cm/i); + if (cm) return String(Math.round((parseFloat(cm[1]) / 2.54) * 10) / 10); + return raw.replace(/[^0-9.]/g, "").split(".").slice(0, 2).join("."); +} + +/** "170g" / "5.5g" → "170" / "5.5", "2.14kg" → "2140" */ +function extractGrams(raw: string): string { + // kg 단위 (노트북 등): "2.14kg" → "2140" + const kg = raw.match(/([0-9]+(?:\.[0-9]+)?)\s*kg/i); + if (kg) return String(Math.round(parseFloat(kg[1]) * 1000)); + const m = raw.match(/([0-9,]+(?:\.[0-9]+)?)\s*g/i); + return m ? m[1].replace(/,/g, "") : raw; +} + +/** "128GB" → "128", "불가능" → "" (invalid → skip) */ +function extractGB(raw: string): string { + const m = raw.match(/([0-9,]+)\s*GB/i); + if (m) return m[1].replace(/,/g, ""); + // TB 단위: "1TB" → "1024" + const tb = raw.match(/([0-9.]+)\s*TB/i); + if (tb) return String(Math.round(parseFloat(tb[1]) * 1024)); + // 숫자 없으면 빈 값 반환 → 상위에서 필터링 + return ""; +} + +/** "약30W" or "65W" → "30W" / "65W" */ +function extractWatt(raw: string): string { + return raw.replace(/^약\s*/, "").trim(); +} + +// ─── 핵심 파서 ────────────────────────────────────────────────────────────── + +/** + * spec_list 텍스트를 schema fieldKey 맵으로 파싱. + * + * 입력 형식 예: + * "스마트폰(바형) / 화면:15.5cm(6.1인치) / 60Hz / 램: 8GB / 용량: 128GB / + * 시스템 / A18 / 무게: 170g / 출시가: 1,250,000원" + */ +export function parseDanawaSpecText(specText: string): Record { + const specs: Record = {}; + if (!specText) return specs; + + // "/" 기준으로 분리, 공백 정리 + const segments = specText.split("/").map((s) => s.trim()).filter(Boolean); + + let prevLabel = ""; + + for (const seg of segments) { + // "key: value" 또는 "key:value" 형식 + const colonIdx = seg.indexOf(":"); + if (colonIdx > 0) { + const label = seg.slice(0, colonIdx).trim(); + const value = seg.slice(colonIdx + 1).trim(); + if (!value) { prevLabel = label; continue; } + + const fieldKey = mapDanawaSpecLabel(label); + if (fieldKey) { + const cleaned = cleanDanawaValue(fieldKey, value); + if (cleaned) specs[fieldKey] = cleaned; // 빈 값(이어폰 케이스 무게 등) 제외 + } + prevLabel = label; + } else { + // 레이블 없이 값만 있는 세그먼트 (예: "A18", "3,561mAh") + // 직전 레이블로 문맥 추정 + const value = seg.trim(); + if (!value || value.length > 40) continue; + + // 칩셋 패턴: 영문(A17/A18/Snapdragon/Exynos/Intel Core) + 한국어(스냅드래곤/엑시노스/코어 울트라) + if (!specs.chipset && /^(A\d{2}|Snapdragon|Exynos|Dimensity|Kirin|M\d|Intel|Core|스냅드래곤|엑시노스|디멘시티|기린|코어\s*울트라|코어\s*i\d|M\d)/i.test(value)) { + specs.chipset = value; + } + // 배터리 용량 패턴: 숫자mAh + if (!specs.battery && /[\d,]+\s*mAh/i.test(value)) { + specs.battery = value.replace(/\s/g, ""); + } + // IP 방수 등급: IP54 / IP68 / IPX4 (독립 세그먼트, 정확히 IP+숫자 형식) + if (!specs.water_resist && /^IP[X\d]\d\b/i.test(value) && value.length <= 8) { + specs.water_resist = value.trim(); + } + // 노트북 무게: "2.14kg" 독립 세그먼트 + if (!specs.weight_g && /^[0-9]+(?:\.[0-9]+)?\s*kg$/i.test(value)) { + specs.weight_g = extractGrams(value); + } + } + } + + return specs; +} + +/** 다나와 spec 레이블 → schema fieldKey */ +function mapDanawaSpecLabel(label: string): string | null { + const l = label.trim().replace(/\s+/g, " "); + const MAP: Record = { + // 스마트폰 + "화면": "display_inch", + "램": "ram_gb", + "용량": "storage_gb", + "무게": "weight_g", + "출시가": "launch_price_krw", + "방수": "water_resist", + "최대충전": "charging", + "맥세이프": "charging", // 중복이면 기존값 유지 + "시스템": "chipset", + "AP": "chipset", + "카메라 후면": "camera_mp", + "후면": "camera_mp", + "OS": "os", + "운영체제": "os", + // 이어폰 + "드라이버": "driver", + "노이즈캔슬링": "anc", + "재생시간": "battery_hr", + "연속재생": "battery_hr", + "최대재생": "battery_total_hr", // "최대재생: 30시간" (케이스 포함) + "최대 재생": "battery_total_hr", + "총 재생시간": "battery_total_hr", + "배터리 용량": "battery", + "충전방식": "charging_type", + "충전": "charging_type", + "방수등급": "water_resist", + "IP등급": "water_resist", + // 노트북 + "CPU": "cpu", + "그래픽": "gpu", + "메모리": "ram_gb", + "SSD": "storage_gb", + "저장공간": "storage_gb", + "해상도": "resolution", + }; + + if (MAP[l]) return MAP[l]; + // 부분 매칭 + for (const [key, field] of Object.entries(MAP)) { + if (l.includes(key) || key.includes(l)) return field; + } + return null; +} + +/** fieldKey에 맞게 값 정제 */ +function cleanDanawaValue(fieldKey: string, raw: string): string { + switch (fieldKey) { + case "display_inch": return extractInch(raw); + case "weight_g": { + // 이어폰 케이스 포함 무게(40g+)는 제외, 이어버드 단독 무게(≤30g)만 허용 + const grams = extractGrams(raw); + if (grams && parseFloat(grams) > 30 && /g/i.test(raw) && !/kg/i.test(raw)) { + // 케이스 포함 무게일 가능성 높음 — 스마트폰은 100g+ 이므로 이어폰(≤30g 기준)에만 적용 + // 실제로 30g 이하 기기가 이어폰 외에 없으므로 30g 초과 시 이어폰 맥락으로 보고 skip + // 하지만 스마트폰(100g+)은 그대로 통과 → 30 < x < 100 범위만 문제 + // 40g 미만이면 이어폰 단독 무게, 40g 이상 100g 미만이면 케이스 무게로 간주 + if (parseFloat(grams) < 100) return ""; // 케이스 무게 skip + } + return grams; + } + case "ram_gb": return extractGB(raw); + case "storage_gb": return extractGB(raw); + case "charging": return extractWatt(raw); + case "battery_hr": + case "battery_total_hr": { + // "30시간" / "26시간(ANC ON기준)" / "6시간(ANC ON)" → 숫자만 + const h = raw.match(/^([0-9]+(?:\.[0-9]+)?)\s*시간/); + if (h) return h[1]; + return raw; + } + case "camera_mp": { + // "4,800만화소+1,200만화소" → "48" (메인 화소 단위 변환) + const m = raw.match(/([\d,]+)만화소/); + if (m) return String(Math.round(parseInt(m[1].replace(/,/g, "")) / 100)); + return raw; + } + case "launch_price_krw": { + // "1,250,000원" → "125만원" 형식으로 정규화 + const m = raw.match(/([\d,]+)원/); + if (m) { + const won = parseInt(m[1].replace(/,/g, "")); + if (won >= 10000) return `${Math.round(won / 10000)}만원`; + return `${won.toLocaleString()}원`; + } + return raw; + } + default: return raw; + } +} + +// ─── 검색 & 수집 ───────────────────────────────────────────────────────────── + +interface DanawaSearchResult { + pcode: string; + specText: string; + registeredAt?: string; // "24.09." → 출시일 근사값 +} + +/** + * 다나와 검색 결과에서 첫 번째 상품의 pcode + spec 텍스트 추출. + * + * 광고(first item with 광고 badge)는 건너뛰고 두 번째 실제 상품 기준으로 가져옴. + */ +export async function searchDanawa(query: string): Promise { + const url = `https://search.danawa.com/dsearch.php?query=${encodeURIComponent(query)}&tab=goods&orderMethod=point&limit=10`; + const html = await fetchHtml(url); + if (!html) return null; + + // pcode 추출 (여러 개, 첫 번째 사용) + const pcodePattern = /pcode[=\/](\d{7,10})/g; + const pcodeMatches = [...html.matchAll(pcodePattern)]; + if (!pcodeMatches.length) return null; + const pcode = pcodeMatches[0][1]; + + // spec_list 텍스트 추출 + const specBoxMatch = html.match(/]*class="spec[_-]list"[^>]*>([\s\S]*?)<\/div>/i); + let specText = ""; + if (specBoxMatch) { + specText = specListHtmlToText(specBoxMatch[1]); + } + + // 등록일 추출: "24.09. 등록" 패턴 + const regMatch = html.match(/(\d{2}\.\d{2}\.)\s*등록/); + const registeredAt = regMatch ? regMatch[1] : undefined; + + return { pcode, specText, registeredAt }; +} + +/** 등록일 "24.09." → "2024년 9월" */ +function registeredAtToReleaseDate(reg: string): string { + const m = reg.match(/^(\d{2})\.(\d{2})\./); + if (!m) return reg; + const year = 2000 + parseInt(m[1]); + const month = parseInt(m[2]); + return `${year}년 ${month}월`; +} + +/** 다나와에서 제품 스펙 전체 수집 (검색 결과 리스팅 기반) */ +export async function fetchDanawaSpecs( + productName: string +): Promise<{ source: string; specs: Record } | null> { + const result = await searchDanawa(productName); + if (!result) { + console.warn(`[danawa] "${productName}" 검색 결과 없음`); + return null; + } + + const { pcode, specText, registeredAt } = result; + const sourceUrl = `https://prod.danawa.com/info/?pcode=${pcode}`; + + // spec 텍스트에서 fieldKey 맵 파싱 + const specs: Record = parseDanawaSpecText(specText); + + // 출시일 추가 (등록일 기반) + if (registeredAt && !specs.release_date) { + specs.release_date = registeredAtToReleaseDate(registeredAt); + } + + if (Object.keys(specs).length === 0) { + console.warn(`[danawa] "${productName}" 스펙 파싱 실패 (pcode=${pcode})`); + console.warn(` spec 텍스트: "${specText.slice(0, 100)}"`); + return null; + } + + console.log(`[danawa] "${productName}" → pcode=${pcode}, 추출 필드: ${Object.keys(specs).join(", ")}`); + return { source: sourceUrl, specs }; +} diff --git a/scripts/collect-specs/sources/gsmarena.ts b/scripts/collect-specs/sources/gsmarena.ts new file mode 100644 index 0000000..2e51fc0 --- /dev/null +++ b/scripts/collect-specs/sources/gsmarena.ts @@ -0,0 +1,225 @@ +/** + * GSMArena spec scraper — global/US market. + * + * Flow: + * 1. Search: product name → GSMArena search → best match slug + * 2. Spec page: https://www.gsmarena.com/{slug}.php + * 3. Parse: spec table → key-value map + * + * GSMArena is the de-facto global database for smartphone and earphone hardware + * specs. Values are hardware-level (not market-specific), so they serve as GLOBAL + * entries in the dataset. US launch prices come from brand sites directly. + */ + +const HEADERS = { + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Accept-Language": "en-US,en;q=0.9", + Accept: "text/html,application/xhtml+xml", + Referer: "https://www.gsmarena.com/" +}; + +const FETCH_TIMEOUT_MS = 15_000; + +async function fetchHtml(url: string): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + try { + const res = await fetch(url, { headers: HEADERS, signal: controller.signal }); + if (!res.ok) return null; + return await res.text(); + } catch { + return null; + } finally { + clearTimeout(timer); + } +} + +/** + * GSMArena search → best match slug (e.g. "apple_iphone_16-12568") + * Returns full spec page URL. + */ +export async function searchGsmarena(query: string): Promise { + const url = `https://www.gsmarena.com/search.php3?sQuickSearch=${encodeURIComponent(query)}`; + const html = await fetchHtml(url); + if (!html) return null; + + // GSMArena search result links: href="/apple_iphone_16-12568.php" + const slugPattern = /href="\/([a-z0-9_]+-\d+)\.php"/gi; + const matches = [...html.matchAll(slugPattern)]; + if (!matches.length) return null; + + // First result = highest relevance + return `https://www.gsmarena.com/${matches[0][1]}.php`; +} + +/** Parse GSMArena spec page HTML → raw key-value map */ +export function parseGsmarenaSpecs(html: string): Record { + const specs: Record = {}; + + // GSMArena spec table structure: + // LabelValue + // or: Section header + const rowPattern = /]*class="ttl"[^>]*>\s*]*>([\s\S]*?)<\/a>\s*<\/td>\s*]*class="nfo"[^>]*>([\s\S]*?)<\/td>/gi; + let match: RegExpExecArray | null; + + while ((match = rowPattern.exec(html)) !== null) { + const rawLabel = match[1].replace(/<[^>]+>/g, "").trim(); + const rawValue = match[2] + .replace(//gi, " | ") + .replace(/<[^>]+>/g, "") + .replace(/ /g, " ") + .trim(); + + if (!rawLabel || !rawValue || rawValue === "-") continue; + + const label = rawLabel.replace(/\s+/g, " "); + const value = rawValue.replace(/\s+/g, " ").replace(/&/g, "&"); + + specs[label] = value; + } + + return specs; +} + +/** + * GSMArena label → schema fieldKey mapping. + * GSMArena uses English labels; values are hardware-level global specs. + */ +export const GSMARENA_LABEL_MAP: Record = { + // ── Smartphone ──────────────────────────────────────────────────────────── + "Model": "model_name", + "Announced": "release_date", + "Status": "release_date", + "Size": "display_inch", + "Type": "panel", + "Resolution": "resolution", + "OS": "os", + "Chipset": "chipset", + "CPU": "chipset", + "RAM": "ram_gb", + "Internal": "storage_gb", + "Main Camera": "camera_mp", + "Triple": "camera_mp", + "Dual": "camera_mp", + "Single": "camera_mp", + "Capacity": "battery", + "Charging": "charging", + "Weight": "weight_g", + "Refresh rate": "refresh_hz", + "Brightness": "brightness_nits", + "Price": "launch_price_usd", + + // ── Earphones ───────────────────────────────────────────────────────────── + "Driver": "driver", + "Active noise cancellation": "anc", + "ANC": "anc", + "Playback": "battery_hr", + "Battery life": "battery_hr", + "Codec": "codec", + "Water resistance": "water_resist", + "Charging time": "charging_type", +}; + +/** GSMArena label → schema fieldKey (with partial matching) */ +export function mapGsmarenaLabel(label: string): string | null { + const trimmed = label.trim(); + if (GSMARENA_LABEL_MAP[trimmed]) return GSMARENA_LABEL_MAP[trimmed]; + + for (const [key, fieldKey] of Object.entries(GSMARENA_LABEL_MAP)) { + if (trimmed.toLowerCase().includes(key.toLowerCase()) || + key.toLowerCase().includes(trimmed.toLowerCase())) { + return fieldKey; + } + } + return null; +} + +/** Post-process raw GSMArena values for specific fields */ +function cleanGsmarenaValue(fieldKey: string, raw: string): string { + switch (fieldKey) { + case "display_inch": { + // "6.1 inches, 89.0 cm2" → "6.1" + const m = raw.match(/^([\d.]+)\s*inches?/i); + return m ? m[1] : raw; + } + case "weight_g": { + // "174 g (6.14 oz)" → "174" + const m = raw.match(/([\d.]+)\s*g/i); + return m ? m[1] : raw; + } + case "battery": { + // "3279 mAh" → "3279mAh" + return raw.replace(/\s*(mAh)\s*/i, "mAh"); + } + case "ram_gb": { + // "6 GB RAM, 128 GB" → "6" + const m = raw.match(/^([\d]+)\s*GB\s*RAM/i); + return m ? m[1] : raw.split(",")[0].replace(/GB/i, "").trim(); + } + case "storage_gb": { + // "128GB" or "128 GB" → "128" + const m = raw.match(/([\d]+)\s*GB/i); + return m ? m[1] : raw; + } + case "camera_mp": { + // "48 MP, f/1.6, 26mm..." → "48" + const m = raw.match(/^([\d]+)\s*MP/i); + return m ? m[1] : raw; + } + case "refresh_hz": { + // "60Hz" or "1-120Hz" → "120" + const m = raw.match(/([\d]+)\s*Hz/i); + return m ? m[1] : raw; + } + case "brightness_nits": { + // "2000 nits" → "2000" + const m = raw.match(/([\d]+)\s*nits?/i); + return m ? m[1] : raw; + } + default: + return raw; + } +} + +/** Full GSMArena fetch pipeline — returns {source, specs} or null */ +export async function fetchGsmarenaSpecs( + productName: string +): Promise<{ source: string; specs: Record } | null> { + // 1. Search + const specUrl = await searchGsmarena(productName); + if (!specUrl) { + console.warn(`[gsmarena] "${productName}" 검색 결과 없음`); + return null; + } + + // 2. Fetch spec page + const html = await fetchHtml(specUrl); + if (!html) { + console.warn(`[gsmarena] "${productName}" 스펙 페이지 로드 실패 (${specUrl})`); + return null; + } + + // 3. Parse raw specs + const rawSpecs = parseGsmarenaSpecs(html); + if (Object.keys(rawSpecs).length === 0) { + console.warn(`[gsmarena] "${productName}" 스펙 파싱 실패`); + return null; + } + + // 4. Map + clean + const mappedSpecs: Record = {}; + for (const [label, value] of Object.entries(rawSpecs)) { + const fieldKey = mapGsmarenaLabel(label); + if (fieldKey && !mappedSpecs[fieldKey]) { + // Keep first match per fieldKey (most specific) + mappedSpecs[fieldKey] = cleanGsmarenaValue(fieldKey, value); + } + } + + console.log( + `[gsmarena] "${productName}" → ${specUrl}, 추출 필드: ${Object.keys(mappedSpecs).join(", ")}` + ); + + return { source: specUrl, specs: mappedSpecs }; +} diff --git a/tests/fallback.test.ts b/tests/fallback.test.ts index 54330a4..867324c 100644 --- a/tests/fallback.test.ts +++ b/tests/fallback.test.ts @@ -8,4 +8,13 @@ describe("buildFallbackDecision", () => { expect(result.comparison).toEqual([]); expect(JSON.stringify(result)).not.toContain("관점"); }); + + it("does not invent a winner from option name length", () => { + const result = buildFallbackDecision(["짧은", "아주아주긴이름"], "laptop", "no-key"); + + expect(result.selectedOption).toBe("일시적으로 결론을 낼 수 없습니다"); + expect(result.status).toBe("verification_pending"); + expect(result.verification).toBe("unverified"); + expect(result.selectedOption).not.toBe("아주아주긴이름"); + }); }); diff --git a/tests/popular-queries.test.ts b/tests/popular-queries.test.ts new file mode 100644 index 0000000..898e0ce --- /dev/null +++ b/tests/popular-queries.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { + aggregatePopularQueries, + isSafePublicQuery, + normalizePopularQuery +} from "@/lib/popular-queries"; + +describe("popular query sanitization", () => { + it("accepts short product comparisons", () => { + expect(isSafePublicQuery("맥북 에어 vs 갤럭시 북5")).toBe(true); + expect(isSafePublicQuery("MacBook Air vs LG gram")).toBe(true); + }); + + it("rejects emails, phones, and non-comparisons", () => { + expect(isSafePublicQuery("user@example.com vs test")).toBe(false); + expect(isSafePublicQuery("010-1234-5678 vs 상품")).toBe(false); + expect(isSafePublicQuery("맥북 에어")).toBe(false); + expect(isSafePublicQuery("내 이메일 비밀번호 vs 테스트")).toBe(false); + }); + + it("aggregates only safe queries", () => { + const rows = [ + { query: "맥북 에어 vs LG 그램" }, + { query: "맥북 에어 vs LG 그램" }, + { query: "secret@mail.com vs leak" }, + { query: "그냥 잡담" } + ]; + expect(aggregatePopularQueries(rows, 5)).toEqual([ + { query: normalizePopularQuery("맥북 에어 vs LG 그램"), count: 2 } + ]); + }); +}); From b65d61fe0d28e8799ff9060cce83f85e6683ea8a Mon Sep 17 00:00:00 2001 From: Minseok Chae <154256470+Min0504@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:35:24 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=EC=8A=A4=ED=8E=99=20=EB=B9=84?= =?UTF-8?q?=EA=B5=90=20=EB=A7=89=EB=8C=80=20=EA=B7=B8=EB=9E=98=ED=94=84=20?= =?UTF-8?q?+=20site-url=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 숫자 스펙만 단일 선택 막대 비교로 보여주고, 비교 가능 연도 안내와 site URL/noindex/share region 수정을 함께 반영한다. --- DESIGN.md | 68 +++++++++++ DEV_NOTES.md | 32 ++--- PROMPT.md | 27 +++-- README.md | 25 ++-- app/api/cron/price-check/route.ts | 3 +- app/compare/[slug]/page.tsx | 9 +- app/globals.css | 193 ++++++++++++++++++++++++++++++ app/layout.tsx | 3 +- app/page.tsx | 2 + app/robots.ts | 3 +- app/sitemap.ts | 3 +- components/results-view.tsx | 67 +++++++++++ components/share-actions.tsx | 3 +- components/spec-graphs.tsx | 101 ++++++++++++++++ lib/i18n/en.ts | 5 + lib/i18n/ja.ts | 5 + lib/i18n/ko.ts | 5 + lib/site-url.ts | 10 ++ lib/specs/coverage.ts | 5 + 19 files changed, 523 insertions(+), 46 deletions(-) create mode 100644 DESIGN.md create mode 100644 components/spec-graphs.tsx create mode 100644 lib/site-url.ts create mode 100644 lib/specs/coverage.ts diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..6f82df4 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,68 @@ +# Axis Design System + +## 1. Atmosphere & Identity + +Axis feels like a quiet decision desk for high-stakes electronics. Surfaces stay calm and readable; accent color appears only on winners, actions, and focus. The signature is comparison clarity: short verdicts, clear winners, and numeric gaps shown as selectable line graphs rather than noise. + +## 2. Color + +### Palette + +| Role | Token | Light | Dark | Usage | +|------|-------|-------|------|-------| +| Surface/primary | --bg | #ffffff | #0d0f14 | Page background | +| Surface/secondary | --bg-subtle | #f8f9fc | #111318 | Soft panels | +| Text/primary | --text | #0f172a | #e2e8f4 | Headlines, body | +| Text/muted | --muted | #64748b | #8891a6 | Notes, secondary | +| Accent/primary | --accent | #3454e8 | #5b78f5 | CTAs, winners, focus | +| Accent/secondary | --accent-2 | #7c5cff | #9474ff | Winner gradients | + +### Rules +- Accent is for interactive or winning states only. +- Coverage notes and helper text use muted, never decorative color. +- Spec line graphs reuse accent/accent-2 for the path and best points. + +## 3. Typography + +- Body and cards: system UI stack already set in globals. +- Section titles in detail cards stay compact and high-contrast. +- Coverage notes: ~0.82–0.88rem, weight 500–600, muted. + +## 4. Spacing + +- Detail cards: existing padding (~1.5rem). +- Spec graph cards: 0.7–1.05rem vertical rhythm. +- Line chart viewBox ~640×220 with soft gridlines. + +## 5. Components + +### CoverageNote +- Short muted sentence near hero input and results graph section. +- Copy pattern: `{year}년 이후 출시 제품부터 비교 가능합니다` (localized). + +### SpecBarGraphs +- One compare bar chart at a time for numeric comparable fields. +- Products are shown side-by-side as vertical bars. +- Bar height follows raw numeric magnitude (larger number = taller bar). No better/worse inversion. +- Chip selector shows exactly one spec chart to keep the page short. +- Product labels + numeric values stay visible; no recommendation highlight in the graph. +- Keep existing cmp-table below as the exact-value source. + +### Spec table +- Existing `.cmp-table` remains source of exact values. + +## 6. Motion + +- Verdict bar width transition remains ~0.7s; bar height transition matches verdict bars (~0.7s). + +## 7. Accessibility + +- Graphs must remain readable without color alone: product labels + numeric values stay visible. +- Spec chips use `role="radio"` with `aria-checked` for single-select. +- Focus rings use accent outline. +- Do not rely on percentage labels for the verdict bars; numeric values stay on graph rows. + +## 8. Accepted Debt + +- No DESIGN.md existed; this file extracts the current Axis look rather than redesigning. +- React-grab / react-scan / react-doctor deferred pending PM package approval. diff --git a/DEV_NOTES.md b/DEV_NOTES.md index 81c5391..c4db1f6 100644 --- a/DEV_NOTES.md +++ b/DEV_NOTES.md @@ -1,6 +1,6 @@ # Axis — 개발 노트 -> 마지막 업데이트: 2026-07-16 +> 마지막 업데이트: 2026-07-21 > 테스트: `npm test` 통과 기준 유지 · 캐시 버전: **v9** > 프로덕션: https://axis-app-beta.vercel.app > @@ -88,9 +88,9 @@ |---------|------|------| | 스마트폰 | `smartphones.ts` | 55 | | 이어폰 | `earphones.ts` | 18 | -| 노트북 | `laptops.ts` | 28 (+ Book6 Pro 14/16) | +| 노트북 | `laptops.ts` | 28 | | 태블릿 | `tablets.ts` | 23 | -| **합계** | | **122** | +| **합계** | | **124** | 추가로 다나와 자동 수집 데이터(`dataset/kr/`)가 수동 데이터 뒤에 병합됨 (ID 중복 시 수동 우선). @@ -139,7 +139,7 @@ npm run collect:jp # 価格.com (JP, JPY 가격) - 스펙 정확도 수정: 아이폰16 프로 주사율(60→120Hz), `enrichWithDatasetFallback` merge 방식 - 한/미/일 전체 점검: US/JP 데이터셋 fallback, ja 라벨 중앙화(JA_FIELD_LABELS), danawa URL 공식소스 유출 차단 -- 제품명 로케일 정규화 + 데이터셋 122개 확장, 태블릿 카테고리 신설 +- 제품명 로케일 정규화 + 데이터셋 124개 확장(수동), 태블릿 카테고리 신설 - UI: 결과 카드 화이트 리디자인, fit score 바, 어필리에이트 "공식" 제거 + 다이렉트 링크 --- @@ -151,23 +151,22 @@ npm run collect:jp # 価格.com (JP, JPY 가격) | 항목 | 내용 | |------|------| | `CRON_SECRET` 교체 | 현재 기본값 → `openssl rand -base64 32` → Vercel Production | -| VAPID 키 생성·등록 | `npx web-push generate-vapid-keys` → 3개 키 → WatchButton 노출 확인 | +| VAPID 키 생성·등록 | 로컬 3종 SET. WatchButton은 PriceComparison remount로 노출 경로 복구됨 | | Supabase 마이그레이션 | 0014_price_history.sql 로컬 적용 완료. 프로덕션은 Vercel 배포 후 `npx supabase db push` | | 네이버 쇼핑 API 키 설정 | [https://developers.naver.com/apps/](https://developers.naver.com/apps/) → 앱 등록 → 쇼핑 체크 → `NAVER_CLIENT_ID` / `NAVER_CLIENT_SECRET` → `AXIS_PRICE_SOURCE=naver` | | Coupang API (대기) | 누적 15만원 매출 달성 후 파트너스 포털 최종승인 → `COUPANG_ACCESS_KEY` / `COUPANG_SECRET_KEY` → `AXIS_PRICE_SOURCE=coupang` 으로 전환 | ### 기능 비활성화 (API 키 없음) -- `BRAVE_SEARCH_API_KEY` 미설정 → 미등록 제품 웹 검색 폴백 안 됨 -- `RESEND_API_KEY` 미설정 → 이메일 가격 알림 안 됨 -- `VAPID_*` 미설정 → 푸시 알림 안 됨 +- `BRAVE_SEARCH_API_KEY` 의도적 미설정 → 웹 검색 폴백은 검증 보류 +- `RESEND_API_KEY` 로컬 SET. 프로덕션 패리티·실발송 QA는 별도 확인 +- `VAPID_*` 로컬 SET. 푸시 실동작 QA는 별도 확인 ### 미확인 - Sony 한국 URL(`sony.co.kr`) 실제 동작 여부 - LG gram 14/16/17형 containment match 오매핑 가능성 -- WatchButton UI 미노출 (VAPID_SUBJECT 관련 가능성) -- WatchButton UI 미노출 (VAPID_SUBJECT 관련 가능성) +- ~~WatchButton UI 미노출~~ → 원인: ResultsView에서 PriceComparison orphan. remount 완료. VAPID 가설 폐기 - Sony 한국 URL(`sony.co.kr`) 실제 동작 여부 - LG gram 14/16/17형 containment match 오매핑 가능성 @@ -176,7 +175,7 @@ npm run collect:jp # 価格.com (JP, JPY 가격) 추가 기능 빌드 전에 핵심 가정부터 싸게 검증한다. 통과 시에만 빌드 재개. **핵심 가정 (먼저 검증):** -- A 실가격·이력을 싸게 확보 가능 — 쿠팡 현재가 + 노트북 26 SKU 자체 일별 적재 PoC +- A 실가격·이력을 싸게 확보 가능 — 쿠팡 현재가 + 노트북 28 SKU 자체 일별 적재 PoC → **[준비 완료]** `naver-provider.ts` + `coupang-provider.ts` + `price-snapshot` 크론 구현 완료 (2026-06-11). ① 네이버 쇼핑 API 즉시 발급 후 `AXIS_PRICE_SOURCE=naver` 로 활성화 (가격=전국최저가, 구매링크=쿠팡 제휴). ② Coupang 누적 15만원 매출 달성 → 최종승인 → `AXIS_PRICE_SOURCE=coupang` 으로 전환 (env 변경만으로 완료). @@ -225,13 +224,14 @@ openssl rand -base64 32 # CRON_SECRET 생성 | `SUPABASE_SERVICE_ROLE_KEY` | 설정됨 | Supabase 서비스 키 | | `GROQ_API_KEY` | 설정됨 | AI 결정 엔진 | | `CRON_SECRET` | ✅ 교체 완료 | 2026-06-11 새 키로 교체 | -| `NEXT_PUBLIC_VAPID_PUBLIC_KEY` | ✅ 설정됨 | 푸시 알림 | -| `VAPID_PRIVATE_KEY` | ✅ 설정됨 | 푸시 알림 | -| `VAPID_SUBJECT` | ✅ 설정됨 | 푸시 알림 | +| `NEXT_PUBLIC_VAPID_PUBLIC_KEY` | ✅ 로컬 SET | 푸시 알림 | +| `VAPID_PRIVATE_KEY` | ✅ 로컬 SET | 푸시 알림 | +| `VAPID_SUBJECT` | ✅ 로컬 SET | 푸시 알림 | | `AXIS_PRICE_SOURCE` | ✅ naver | 네이버 쇼핑 최저가 활성화 | | `NAVER_CLIENT_ID` | ✅ 설정됨 | 네이버 쇼핑 API | | `NAVER_CLIENT_SECRET` | ✅ 설정됨 | 네이버 쇼핑 API | -| `BRAVE_SEARCH_API_KEY` | ❌ 미설정 | 웹 검색 폴백 | -| `RESEND_API_KEY` | ❌ 미설정 | 이메일 알림 (D단계) | +| `BRAVE_SEARCH_API_KEY` | ❌ 미설정(보류) | 웹 검색 폴백 | +| `RESEND_API_KEY` | ✅ 로컬 SET / 프로덕션 패리티 미확인 | 이메일 알림 (D단계) | +| `NEXT_PUBLIC_SITE_URL` | ⚠️ 미설정 시 fallback | public=`https://axis-app-beta.vercel.app` · layout local=`http://localhost:3000` | | `COUPANG_ACCESS_KEY` | ❌ 대기 중 | 쿠팡 파트너스 API (최종승인 후 발급 — 15만원 매출 필요) | | `COUPANG_SECRET_KEY` | ❌ 대기 중 | 쿠팡 파트너스 API | diff --git a/PROMPT.md b/PROMPT.md index 0c01117..7d69ff8 100644 --- a/PROMPT.md +++ b/PROMPT.md @@ -1,6 +1,7 @@ # Axis — AI 작업 프롬프트 > 배포 목표: 2026-08-15 (베타 → 정식) | 현재: 프로덕션 베타 배포 완료, 사업성 검증 단계 +> 문서 기준일: 2026-07-21 · 로컬 브랜치 `local-fix` · 프로덕션 HEAD 재배포 필요 --- @@ -8,7 +9,7 @@ 너는 **Axis** (전자제품 구매 결정 도구)를 베타에서 정식 배포로 끌어올리는 시니어 엔지니어다. -**프로젝트 위치:** `/Users/minseokchae/Documents/Personal_Project/Axis/` +**프로젝트 위치:** `/Users/minseokchae/Dev/deployed/Axis/` **프로덕션 URL:** https://axis-app-beta.vercel.app **상태:** 베타 · 사업성 검증 단계 (한국 · 노트북 · 제휴 집중) @@ -25,24 +26,28 @@ --- -## 현재 완료 상태 (2026-06-17 기준) +## 현재 완료 상태 (2026-07-21 기준) ### 기능 (완료) - AI 구매 결정 엔진 + 검증 게이트 (verified / partial / unverified) - 맞춤 재분석 (userContext — 용도·예산·상황 가중치로 결론 재계산, 캐시 우회) - 네이버 쇼핑 실시간 최저가 + 자체 일별 가격 이력 적재 (크론) -- 이메일 가격 알림 (Resend) + 웹 푸시 알림 (VAPID, PWA) -- 검증 데이터셋 122개 (스마트폰 55 · 이어폰 18 · 노트북 26 · 태블릿 23) +- 이메일 가격 알림 (Resend) + 웹 푸시 알림 (VAPID, PWA) — **코드 완료** +- 검증 데이터셋 수동 124개 (스마트폰 55 · 이어폰 18 · 노트북 28 · 태블릿 23) - 다국어 KR/US/JP (제품명 로케일 정규화: canonicalName / nameEn / nameJa) - SEO 정적 비교 페이지 (`/compare/[slug]`), 비교 결과 캐시 (v9), 클릭 트래킹 +- ResultsView `PriceComparison`/`WatchButton` 재마운트 (2026-07-21, `hidePrices`일 때만 숨김) +- `lib/site-url.ts` 공용 사이트 URL 헬퍼 (공개 fallback = beta 호스트) -### 운영 환경 (설정 완료) +### 운영 환경 (로컬 기준 설정 완료) - `CRON_SECRET` 교체 완료, VAPID 3종 설정, `AXIS_PRICE_SOURCE=naver` 활성 - `NAVER_CLIENT_ID` / `NAVER_CLIENT_SECRET` 설정 완료 +- `RESEND_API_KEY` 로컬 SET (프로덕션 패리티는 PM 확인 필요) -### 대기 중 +### 대기 중 / 프로덕션 갭 - 쿠팡 파트너스 API: 누적 매출 15만원 달성 후 발급 (`AXIS_PRICE_SOURCE=coupang` env 전환만으로 완료) -- `BRAVE_SEARCH_API_KEY`(웹 검색 폴백), `RESEND_API_KEY`(이메일 알림) 미설정 +- `BRAVE_SEARCH_API_KEY`(웹 검색 폴백) 미설정 — **보류** (30일 검증 범위 밖) +- 프로덕션 배포가 seed-only SHA `3b54e0a`에 고정 → **로컬 HEAD 재배포 필요 (PM)** --- @@ -54,7 +59,7 @@ - 노트북 추천/비교 콘텐츠 15~20편 색인 (SEO/커뮤니티 유입 검증) - `/compare/[slug]` 정적 페이지가 verified 등급으로 색인되는지 확인 -- 데이터셋: 노트북 26 SKU 우선 정확도 점검 (LG gram 14/16/17형 containment match 오매핑 가능성 확인) +- 데이터셋: 노트북 28 SKU 우선 정확도 점검 (LG gram 14/16/17형 containment match 오매핑 가능성 확인) ### Phase 2: 제휴 전환 추적 (검증 가정 C) @@ -64,9 +69,9 @@ ### Phase 3: 알림 재방문 검증 (검증 가정 D) -- `RESEND_API_KEY` 설정 후 실알림 활성화 +- 로컬 `RESEND_API_KEY` SET 확인됨 → 프로덕션 패리티 확인 후 실알림 활성화 - M2 재방문 >30% 측정 -- WatchButton UI 미노출 이슈 확인 (VAPID_SUBJECT 관련 가능성) +- WatchButton: 과거 "VAPID_SUBJECT" 가설 폐기. 실제 원인은 ResultsView에서 `PriceComparison` 미마운트였고, 2026-07-21 재마운트로 로컬 수정 완료. 프로덕션 반영은 HEAD 재배포 후 확인. ### Phase 4: 검증 배지 A/B (검증 가정 E) @@ -113,7 +118,7 @@ components/ lib/ decision-engine.ts 비교 파이프라인 오케스트레이터 ai/ AI 프로바이더 추상화 + 프롬프트 - specs/dataset/ 수동 검증 스펙 122개 + specs/dataset/ 수동 검증 스펙 124개 pricing/ 가격 프로바이더 (naver · coupang · seed) comparison-cache.ts 캐시 레이어 (v9) affiliate.ts 제휴 링크 생성 (Amazon/Coupang/Naver) diff --git a/README.md b/README.md index a715820..00927a5 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ "아이폰 16 vs 갤럭시 S25" 같은 자연어 쿼리를 입력하면 공식 스펙을 검증해 AI가 비교 테이블과 결론을 만들고, 가격을 추적해 최적 구매 타이밍을 알려줍니다. -한국(한국어) · 미국(English) · 일본(日本語) 3개 시장을 동시 지원합니다. +한국(한국어) 우선 운영. 미국·일본 코드 자산은 유지하되 사업성 검증 중에는 KR 집중입니다. **프로덕션:** https://axis-app-beta.vercel.app · **상태:** 베타, 사업성 검증 단계 (한국 · 노트북 · 제휴) @@ -25,7 +25,7 @@ | **구매 타이밍** | 가격 이력 기반 "지금 살까 / 기다릴까" 판정 + 다음 모델 출시 주기 힌트 | | **가격 알림** | 관심 상품 등록 → 목표가·역대최저·급락 시 이메일/푸시 알림 (일일 크론) | | **클릭 트래킹** | 제휴 클릭·페이지뷰 이벤트 적재 (`click_events`) | -| **다국어** | KR/US/JP 동시 운영, 제품명 로케일 정규화 (`nameEn` / `nameJa`) | +| **다국어** | KR/US/JP 코드 지원 · 운영은 KR 우선, 제품명 로케일 정규화 (`nameEn` / `nameJa`) | ## 핵심 차별점 @@ -36,21 +36,22 @@ --- -## 현재 상태 (2026-06-17) +## 현재 상태 (2026-07-21) | 항목 | 상태 | |------|------| -| 프로덕션 배포 | ✅ 베타 완료 | +| 프로덕션 배포 | ⏳ 베타 라이브 · seed-only SHA `3b54e0a` — HEAD 재배포 필요 (PM) | | AI 구매 결정 엔진 | ✅ 검증 게이트 포함 | | 맞춤 재분석 (userContext) | ✅ 완료 | | 네이버 쇼핑 실시간 최저가 | ✅ 완료 | | 가격 이력 적재 (일별 크론) | ✅ 완료 | -| 이메일 가격 알림 (Resend) | ✅ 완료 | -| 웹 푸시 알림 (VAPID) | ✅ 완료 | -| 검증 데이터셋 | ✅ 수동 122+ (+KR 자동수집 병합, 북6 프로 포함) | -| 다국어 KR/US/JP | ✅ 완료 | +| 이메일 가격 알림 (Resend) | 🟡 코드 완료 · 로컬 env SET · 프로덕션 패리티 대기 | +| 웹 푸시 알림 (VAPID) | 🟡 코드 완료 · 로컬 VAPID SET · Watch UI는 PriceComparison remount로 복구 | +| 검증 데이터셋 | ✅ 수동 124 (스마트폰 55 · 이어폰 18 · 노트북 28 · 태블릿 23) | +| 다국어 KR/US/JP | ✅ 코드 완료 · 운영은 KR 우선 | +| 사이트 URL 헬퍼 | ✅ `lib/site-url.ts` — env 우선, public fallback=`axis-app-beta.vercel.app` | | 쿠팡 파트너스 연동 | ⏳ 누적 매출 15만원 후 발급 | -| Groq 폴백 체인 | ⏳ 트래픽 증가 후 | +| Groq 폴백 체인 | ⏳ 트래픽 증가 후 · 보류 | | 커뮤니티 홍보 | ⏳ 예정 | --- @@ -144,9 +145,9 @@ npm test | `AXIS_PRICE_SOURCE` | `naver` / `coupang` / `seed` | **프로덕션에서 `seed` 금지** | | `NAVER_CLIENT_ID` / `NAVER_CLIENT_SECRET` | 네이버 쇼핑 실시간 최저가 | 무료·즉시 발급 | | `COUPANG_ACCESS_KEY` / `COUPANG_SECRET_KEY` | 쿠팡 파트너스 API | 누적 매출 15만원 후 발급 | -| `RESEND_API_KEY` | 이메일 가격 알림 | | +| `RESEND_API_KEY` | 이메일 가격 알림 | 로컬 SET · 프로덕션 패리티 확인 필요 | | `RESEND_FROM_EMAIL` | 발신 주소 | 미인증 시 `onboarding@resend.dev` | -| `BRAVE_SEARCH_API_KEY` | 미등록 제품 웹 검색 폴백 | | +| `BRAVE_SEARCH_API_KEY` | 미등록 제품 웹 검색 폴백 | 검증 범위 밖 — 보류 | | `NEXT_PUBLIC_VAPID_PUBLIC_KEY` | 푸시 알림 (클라이언트) | | | `VAPID_PRIVATE_KEY` / `VAPID_SUBJECT` | 푸시 알림 (서버) | | | `CRON_SECRET` | 가격 점검·스냅샷 크론 보호 | | @@ -189,7 +190,7 @@ components/ lib/ ├── decision-engine.ts 비교 파이프라인 오케스트레이터 ├── ai/ AI 프로바이더 추상화 + 프롬프트 -├── specs/dataset/ 수동 검증 스펙 122개 +├── specs/dataset/ 수동 검증 스펙 124개 ├── pricing/ 가격 프로바이더 (naver · coupang · seed) ├── comparison-cache.ts 캐시 레이어 └── affiliate.ts 제휴 링크 생성 (Amazon/Coupang/Naver) diff --git a/app/api/cron/price-check/route.ts b/app/api/cron/price-check/route.ts index 067c4bf..8830420 100644 --- a/app/api/cron/price-check/route.ts +++ b/app/api/cron/price-check/route.ts @@ -7,6 +7,7 @@ import { getProductById, resolveVerifiedAny } from "@/lib/specs/dataset"; import { sendPriceAlert } from "@/lib/email/send"; import { sendPricePush } from "@/lib/push/send"; import type { Watch } from "@/lib/watch/types"; +import { getSiteUrl } from "@/lib/site-url"; /** * GET /api/cron/price-check @@ -52,7 +53,7 @@ async function runPriceCheck(req: Request) { if (!decision.fire || !decision.reason) return; const ok = await send( - quote?.url ?? "https://axis.so", + quote?.url ?? getSiteUrl("public"), decision.price, history.currency, decision.reason diff --git a/app/compare/[slug]/page.tsx b/app/compare/[slug]/page.tsx index 0867fd0..aa0ec49 100644 --- a/app/compare/[slug]/page.tsx +++ b/app/compare/[slug]/page.tsx @@ -7,6 +7,8 @@ import { createServiceClient } from "@/lib/supabase-server"; import ResultsView from "@/components/results-view"; import PageViewTracker from "@/components/page-view-tracker"; import type { ComparisonResult } from "@/lib/types"; +import { getSiteUrl } from "@/lib/site-url"; +import { isIndexable } from "@/lib/specs/source"; // Re-generate at most once per day. export const revalidate = 86400; @@ -28,11 +30,14 @@ export async function generateMetadata({ params }: Props): Promise { if (!def) return { title: "비교" }; const title = `${def.title} — Axis의 선택은?`; - const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "https://axis.so"; + const siteUrl = getSiteUrl(); + const result = await getOrGenerate(slug, def.options); + const indexable = result ? isIndexable(result.verification ?? "unverified") : false; return { title, description: def.description, + robots: indexable ? undefined : { index: false, follow: true }, openGraph: { title, description: def.description, @@ -89,7 +94,7 @@ export default async function ComparePage({ params }: Props) { (c) => c.category === def.category && c.slug !== slug ).slice(0, 4); - const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "https://axis.so"; + const siteUrl = getSiteUrl(); // JSON-LD structured data const jsonLd = { diff --git a/app/globals.css b/app/globals.css index c841ac6..6d6a907 100644 --- a/app/globals.css +++ b/app/globals.css @@ -4559,3 +4559,196 @@ a { margin: 0.4rem 0 0; line-height: 1.4; } + + +/* ---- coverage note ---- */ +.coverage-note { + margin: 0.55rem 0 0; + color: color-mix(in srgb, var(--text) 62%, var(--muted)); + font-size: 0.84rem; + font-weight: 700; + line-height: 1.5; + letter-spacing: 0; +} + +.coverage-note-inline { + margin: 0.7rem 0 0.35rem; +} + +.coverage-note-home { + margin: 0.75rem auto 0; + max-width: 36rem; + text-align: center; + padding: 0.55rem 0.85rem; + border: 1px solid var(--border-light); + border-radius: 10px; + background: color-mix(in srgb, var(--bg-subtle) 88%, transparent); +} + +/* ---- spec graphs (compare bars) ---- */ +.spec-graph-section { + display: grid; + gap: 1rem; +} + +.spec-graph-list { + display: grid; + gap: 1.05rem; +} + +.spec-graph-selector { + display: grid; + gap: 0.55rem; +} + +.spec-graph-selector-label { + font-size: 0.8rem; + font-weight: 700; + color: color-mix(in srgb, var(--text) 62%, var(--muted)); +} + +.spec-graph-chips { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; +} + +.spec-graph-chip { + border: 1px solid var(--border-light); + background: color-mix(in srgb, var(--bg) 88%, var(--bg-subtle)); + color: color-mix(in srgb, var(--text) 72%, var(--muted)); + border-radius: 9999px; + padding: 0.38rem 0.72rem; + font-size: 0.78rem; + font-weight: 700; + cursor: pointer; + transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease; +} + +.spec-graph-chip:hover { + border-color: color-mix(in srgb, var(--accent) 35%, var(--border-light)); + color: var(--text); +} + +.spec-graph-chip.is-on { + background: color-mix(in srgb, var(--accent) 14%, var(--bg)); + border-color: color-mix(in srgb, var(--accent) 45%, var(--border-light)); + color: var(--accent); +} + +.spec-graph-chip:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.spec-bar-card { + padding: 0.95rem 1rem 0.85rem; + border: 1px solid var(--border-light); + border-radius: 12px; + background: var(--bg-subtle); +} + +.spec-graph-label-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.85rem; +} + +.spec-graph-label { + margin: 0; + font-size: 0.95rem; + font-weight: 800; + color: var(--text); +} + +.spec-bar-compare { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(4.5rem, 1fr)); + gap: 0.85rem; + align-items: end; + min-height: 11.5rem; +} + +.spec-bar-col { + display: grid; + grid-template-rows: auto 1fr auto; + gap: 0.4rem; + justify-items: center; + min-width: 0; +} + +.spec-bar-value { + font-size: 0.8rem; + font-weight: 800; + color: color-mix(in srgb, var(--text) 62%, var(--muted)); + font-variant-numeric: tabular-nums; + text-align: center; + line-height: 1.2; +} + +.spec-bar-track { + width: 100%; + max-width: 3.2rem; + height: 8.5rem; + border-radius: 9999px; + background: color-mix(in srgb, var(--border-light) 88%, var(--border)); + display: flex; + align-items: flex-end; + overflow: hidden; + padding: 0.22rem; +} + +.spec-bar-fill { + width: 100%; + border-radius: 9999px; + background: color-mix(in srgb, var(--text) 28%, var(--border)); + transition: height 0.7s cubic-bezier(0.22, 1, 0.36, 1); + min-height: 0.55rem; +} + +.spec-bar-name { + font-size: 0.78rem; + font-weight: 700; + color: var(--text); + text-align: center; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + + + + + +@media (max-width: 640px) { + .spec-bar-card { + padding: 0.8rem 0.75rem 0.7rem; + } + + .spec-graph-label-row { + flex-direction: column; + align-items: flex-start; + gap: 0.25rem; + } + + .spec-bar-compare { + gap: 0.55rem; + min-height: 10.5rem; + } + + .spec-bar-track { + height: 7.6rem; + max-width: 2.7rem; + } + + .spec-bar-value { + font-size: 0.74rem; + } + + .spec-bar-name { + font-size: 0.72rem; + } +} diff --git a/app/layout.tsx b/app/layout.tsx index 883994f..afc318d 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -4,8 +4,9 @@ import { ThemeProvider } from "@/components/theme-provider"; import { getLocale } from "@/lib/i18n/server"; import { getDictionary } from "@/lib/i18n"; import ServiceWorkerRegistrar from "@/components/service-worker-registrar"; +import { getSiteUrl } from "@/lib/site-url"; -const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000"; +const siteUrl = getSiteUrl("local"); export async function generateMetadata(): Promise { const locale = await getLocale(); diff --git a/app/page.tsx b/app/page.tsx index 3c27298..7e28be7 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -11,6 +11,7 @@ import { getDictionary } from "@/lib/i18n"; import { COMPARISONS } from "@/lib/compare-pages/comparisons"; import { createServiceClientSafe } from "@/lib/supabase-server"; import { aggregatePopularQueries } from "@/lib/popular-queries"; +import { coverageNoteYear } from "@/lib/specs/coverage"; type PopularQuery = { query: string; count: number }; @@ -75,6 +76,7 @@ export default async function Home() { +

    {t.home.coverageNote(coverageNoteYear())}

    diff --git a/app/robots.ts b/app/robots.ts index dbf792e..8b4c7b7 100644 --- a/app/robots.ts +++ b/app/robots.ts @@ -1,6 +1,7 @@ import type { MetadataRoute } from "next"; +import { getSiteUrl } from "@/lib/site-url"; -const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "https://axis.so"; +const siteUrl = getSiteUrl("public"); export default function robots(): MetadataRoute.Robots { return { diff --git a/app/sitemap.ts b/app/sitemap.ts index 7a5a149..cb5000a 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -1,7 +1,8 @@ import type { MetadataRoute } from "next"; import { COMPARISONS } from "@/lib/compare-pages/comparisons"; +import { getSiteUrl } from "@/lib/site-url"; -const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "https://axis.so"; +const siteUrl = getSiteUrl("public"); export default function sitemap(): MetadataRoute.Sitemap { const now = new Date(); diff --git a/components/results-view.tsx b/components/results-view.tsx index cd66371..3a4e929 100644 --- a/components/results-view.tsx +++ b/components/results-view.tsx @@ -5,9 +5,12 @@ import SettingsBar from "@/components/settings-bar"; import UserNav from "@/components/user-nav"; import ContextCard from "@/components/context-card"; import TimingSection from "@/components/timing-section"; +import PriceComparison from "@/components/price-comparison"; import { getDictionary, type Locale } from "@/lib/i18n"; import { verificationLabel } from "@/lib/specs/source"; import { resolveFieldByLabel } from "@/lib/specs/schema"; +import { coverageNoteYear } from "@/lib/specs/coverage"; +import SpecGraphs, { type SpecGraph } from "@/components/spec-graphs"; import type { Category } from "@/lib/types"; type Props = { @@ -75,6 +78,43 @@ function computeFitScores( return scores.map((s) => Math.round((s / total) * 100)); } + + + +function parseNumericValue(value: string): number | null { + const m = (value ?? "").replace(/,/g, "").match(/-?\d+(?:\.\d+)?/); + return m ? Number(m[0]) : null; +} + +function buildSpecGraphs( + options: string[], + comparison: NormalizedRow[], + category: Category +): SpecGraph[] { + const graphs: SpecGraph[] = []; + + for (const row of comparison) { + const field = resolveFieldByLabel(category, row.key); + if (!field || field.better === "none") continue; + + const nums = row.values.map((v) => parseNumericValue(v ?? "")); + if (nums.some((v) => v === null)) continue; + const values = nums as number[]; + if (values.length < 2) continue; + + graphs.push({ + key: row.key, + values: values.map((num, i) => ({ + label: options[i] ?? `Option ${i + 1}`, + raw: row.values[i] ?? "—", + num, + })), + }); + } + + return graphs; +} + function sourceLabel(source: OfficialSourceMeta | undefined, t: ReturnType["results"]) { if (!source) return t.officialShort; return source.kind === "authorized_importer" ? t.sourceImporter : t.sourceManufacturer; @@ -86,6 +126,7 @@ export default function ResultsView({ comparisonId, shareToken, locale = "ko", + hidePrices = false, slug, region, }: Props) { @@ -96,6 +137,10 @@ export default function ResultsView({ const isBlockedResult = result.status === "not_found" || result.status === "verification_pending"; const fitScores = computeFitScores(options, rows, result.category); + const specGraphs = isBlockedResult + ? [] + : buildSpecGraphs(options, rows, result.category); + const coverageYear = coverageNoteYear(); const showVerifyBadge = result.verification != null && @@ -169,6 +214,23 @@ export default function ResultsView({
    )} + {!isBlockedResult && !hidePrices && ( + + )} + + + {specGraphs.length > 0 && ( + g.key).join("|")} + graphs={specGraphs} + labels={{ + title: t.specGraphs, + select: t.specGraphSelect, + numericOnly: t.specGraphNumericOnly, + }} + /> + )} + {/* ── 3. Spec table ── */}
    @@ -180,6 +242,7 @@ export default function ResultsView({ )}
    +

    {t.coverageNote(coverageYear)}

    {rows.length > 0 ? (
    @@ -236,6 +299,10 @@ export default function ResultsView({ )}
    + + {/* ── 3b. Spec graphs ── */} + + {/* ── 4. Per-option analysis ── */} {analyses.some(Boolean) && (
    diff --git a/components/share-actions.tsx b/components/share-actions.tsx index bd96801..daf5ab8 100644 --- a/components/share-actions.tsx +++ b/components/share-actions.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { primaryBuyLink } from "@/lib/affiliate"; import type { Category, ComparisonResult } from "@/lib/types"; import { getDictionary, type Locale } from "@/lib/i18n"; +import { localeToRegion } from "@/lib/pricing/types"; type Props = { selectedOption: string; @@ -139,7 +140,7 @@ export default function ShareActions({ event_type: "affiliate", product_id: selectedOption, slug: slug ?? null, - region: region ?? locale.toUpperCase(), + region: region ?? localeToRegion(locale), retailer: buyLink.label, }), }).catch(() => null); diff --git a/components/spec-graphs.tsx b/components/spec-graphs.tsx new file mode 100644 index 0000000..55a4b1a --- /dev/null +++ b/components/spec-graphs.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { useMemo, useState } from "react"; + +export type SpecGraph = { + key: string; + values: { label: string; raw: string; num: number }[]; +}; + +type Labels = { + title: string; + select: string; + numericOnly: string; +}; + +type Props = { + graphs: SpecGraph[]; + labels: Labels; +}; + +function barPct(values: number[], num: number): number { + const maxAbs = Math.max(...values.map((n) => Math.abs(n)), 1); + return Math.max(8, Math.round((Math.abs(num) / maxAbs) * 100)); +} + +function SpecBarChart({ graph }: { graph: SpecGraph }) { + const nums = graph.values.map((v) => v.num); + return ( +
    +
    +

    {graph.key}

    +
    +
    `${v.label} ${v.raw}`).join(", ")}`} + > + {graph.values.map((v, i) => { + const pct = barPct(nums, v.num); + return ( +
    +
    {v.raw}
    +
    +
    +
    +
    + {v.label.length > 18 ? `${v.label.slice(0, 17)}…` : v.label} +
    +
    + ); + })} +
    +
    + ); +} + +export default function SpecGraphs({ graphs, labels }: Props) { + const initialKey = useMemo(() => graphs[0]?.key ?? "", [graphs]); + const [activeKey, setActiveKey] = useState(initialKey); + + if (graphs.length === 0) return null; + + const active = graphs.find((g) => g.key === activeKey) ?? graphs[0]; + const showSelector = graphs.length > 1; + + return ( +
    +
    +

    {labels.title}

    +
    +

    {labels.numericOnly}

    + + {showSelector && ( +
    + {labels.select} +
    + {graphs.map((g) => { + const on = g.key === active.key; + return ( + + ); + })} +
    +
    + )} + +
    + +
    +
    + ); +} diff --git a/lib/i18n/en.ts b/lib/i18n/en.ts index 7f3632d..9eeba83 100644 --- a/lib/i18n/en.ts +++ b/lib/i18n/en.ts @@ -62,6 +62,7 @@ export const en = { compareSub: "Pre-analyzed comparisons based on official specs.", compareRealData: "The most searched comparisons by users.", compareViewAll: (n: number) => `View all comparisons (${n}) →`, + coverageNote: (year: number) => `Comparisons are available for products released from ${year} onward`, categoryLabels: { laptop: "Laptops", smartphone: "Smartphones", @@ -98,6 +99,10 @@ export const en = { ], fitScoreLabel: "Spec advantage ratio", fitScoreNote: "Based on numerically comparable official spec fields", + specGraphs: "Spec graphs", + specGraphSelect: "Choose a spec", + specGraphNumericOnly: "Only numeric comparable specs appear as bar graphs", + coverageNote: (year: number) => `Comparisons are available for products released from ${year} onward`, whyChosen: "Why this pick?", specComparison: "Official spec comparison", specComparisonPending: "Spec comparison", diff --git a/lib/i18n/ja.ts b/lib/i18n/ja.ts index 8987328..91a4505 100644 --- a/lib/i18n/ja.ts +++ b/lib/i18n/ja.ts @@ -62,6 +62,7 @@ export const ja = { compareSub: "公式スペックをもとに事前に分析した人気比較です。", compareRealData: "ユーザーが最も多く検索した比較です。", compareViewAll: (n: number) => `比較をすべて見る (${n}件) →`, + coverageNote: (year: number) => `${year}年以降に発売された製品から比較できます`, categoryLabels: { laptop: "ノートPC", smartphone: "スマートフォン", @@ -98,6 +99,10 @@ export const ja = { ], fitScoreLabel: "スペック優位比率", fitScoreNote: "数値比較可能な公式スペック項目を基準", + specGraphs: "スペックグラフ", + specGraphSelect: "スペックを選択", + specGraphNumericOnly: "数値で比較できるスペックだけを棒グラフで表示します", + coverageNote: (year: number) => `${year}年以降に発売された製品から比較できます`, whyChosen: "おすすめの理由", specComparison: "公式スペック比較", specComparisonPending: "スペック比較", diff --git a/lib/i18n/ko.ts b/lib/i18n/ko.ts index 4a9f029..878bbbf 100644 --- a/lib/i18n/ko.ts +++ b/lib/i18n/ko.ts @@ -65,6 +65,7 @@ export const ko = { compareSub: "공식 스펙 기반으로 미리 분석해둔 인기 비교입니다.", compareRealData: "사용자들이 가장 많이 찾은 비교입니다.", compareViewAll: (n: number) => `비교 전체 보기 (${n}개) →`, + coverageNote: (year: number) => `${year}년 이후 출시 제품부터 비교 가능합니다`, categoryLabels: { laptop: "노트북", smartphone: "스마트폰", @@ -103,6 +104,10 @@ export const ko = { ], fitScoreLabel: "스펙 우위 비율", fitScoreNote: "공식 스펙 중 수치 비교 가능 항목 기준", + specGraphs: "스펙 그래프", + specGraphSelect: "스펙 선택", + specGraphNumericOnly: "숫자로 비교 가능한 스펙만 막대그래프로 표시합니다", + coverageNote: (year: number) => `${year}년 이후 출시 제품부터 비교 가능합니다`, whyChosen: "추천 이유", specComparison: "공식 스펙 비교", specComparisonPending: "스펙 비교", diff --git a/lib/site-url.ts b/lib/site-url.ts new file mode 100644 index 0000000..1dd1261 --- /dev/null +++ b/lib/site-url.ts @@ -0,0 +1,10 @@ +const LOCAL_FALLBACK = "http://localhost:3000" as const; +const PUBLIC_FALLBACK = "https://axis-app-beta.vercel.app" as const; + +type SiteUrlMode = "public" | "local"; + +export function getSiteUrl(mode: SiteUrlMode = "public"): string { + const fromEnv = process.env.NEXT_PUBLIC_SITE_URL?.trim(); + if (fromEnv) return fromEnv.replace(/\/$/, ""); + return mode === "local" ? LOCAL_FALLBACK : PUBLIC_FALLBACK; +} diff --git a/lib/specs/coverage.ts b/lib/specs/coverage.ts new file mode 100644 index 0000000..85a43b9 --- /dev/null +++ b/lib/specs/coverage.ts @@ -0,0 +1,5 @@ +export const DATASET_START_YEAR = 2020; + +export function coverageNoteYear(year: number = DATASET_START_YEAR): number { + return year; +} From 40fd2538e740876cd1fdc7eb7b7b72e49c1d2d6a Mon Sep 17 00:00:00 2001 From: Minseok Chae <154256470+Min0504@users.noreply.github.com> Date: Tue, 21 Jul 2026 19:04:54 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20en/ja=20UI=20=ED=95=9C=EA=B5=AD?= =?UTF-8?q?=EC=96=B4=20=ED=95=98=EB=93=9C=EC=BD=94=EB=94=A9=20=EB=88=84?= =?UTF-8?q?=EC=88=98=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 타이밍·상황카드·결과 빈화면·검증뱃지 문구를 locale 사전으로 연결한다. --- components/context-card.tsx | 71 +++++++++++++------------ components/results-view.tsx | 7 ++- components/session-results.tsx | 8 ++- components/timing-section.tsx | 97 ++++++++++++++++++---------------- components/vs-input.tsx | 2 +- components/watch-button.tsx | 15 +++--- lib/i18n/en.ts | 65 +++++++++++++++++++++++ lib/i18n/ja.ts | 65 +++++++++++++++++++++++ lib/i18n/ko.ts | 65 +++++++++++++++++++++++ 9 files changed, 299 insertions(+), 96 deletions(-) diff --git a/components/context-card.tsx b/components/context-card.tsx index 0e0c7f0..b20005c 100644 --- a/components/context-card.tsx +++ b/components/context-card.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { SESSION_RESULT_KEY } from "@/components/session-results"; import type { ComparisonResult } from "@/lib/types"; -import type { Locale } from "@/lib/i18n"; +import { getDictionary, type Locale } from "@/lib/i18n"; type Props = { originalQuery: string; @@ -16,23 +16,22 @@ type CompareResponse = { comparisonId?: string; }; -const USE_CASES = [ - { value: "daily", label: "일상 사용" }, - { value: "work", label: "업무 · 생산성" }, - { value: "creator", label: "영상 · 편집" }, - { value: "game", label: "게임" }, - { value: "student", label: "학교 · 공부" }, -]; - -const BUDGETS = [ - { value: "under50", label: "50만 미만" }, - { value: "50to100", label: "50–100만" }, - { value: "100to200", label: "100–200만" }, - { value: "over200", label: "200만 이상" }, -]; - export default function ContextCard({ originalQuery, locale = "ko" }: Props) { const router = useRouter(); + const t = getDictionary(locale).context; + const useCases = [ + { value: "daily", label: t.useCaseDaily }, + { value: "work", label: t.useCaseWork }, + { value: "creator", label: t.useCaseCreator }, + { value: "game", label: t.useCaseGame }, + { value: "student", label: t.useCaseStudent }, + ] as const; + const budgets = [ + { value: "under50", label: t.budgetUnder50 }, + { value: "50to100", label: t.budget50to100 }, + { value: "100to200", label: t.budget100to200 }, + { value: "over200", label: t.budgetOver200 }, + ] as const; const [open, setOpen] = useState(false); const [useCase, setUseCase] = useState(""); const [budget, setBudget] = useState(""); @@ -42,8 +41,12 @@ export default function ContextCard({ originalQuery, locale = "ko" }: Props) { function buildContext(): string { const parts: string[] = []; - if (useCase) parts.push(`용도: ${USE_CASES.find((u) => u.value === useCase)?.label}`); - if (budget) parts.push(`예산: ${BUDGETS.find((b) => b.value === budget)?.label}`); + if (useCase) { + parts.push(`${t.useCasePrefix}: ${useCases.find((u) => u.value === useCase)?.label}`); + } + if (budget) { + parts.push(`${t.budgetPrefix}: ${budgets.find((b) => b.value === budget)?.label}`); + } if (extra.trim()) parts.push(extra.trim()); return parts.join(", "); } @@ -54,7 +57,7 @@ export default function ContextCard({ originalQuery, locale = "ko" }: Props) { const context = buildContext(); if (!context) { - setError("용도·예산 중 하나 이상 선택하거나 메모를 입력해주세요."); + setError(t.errorNeedInput); return; } @@ -70,7 +73,7 @@ export default function ContextCard({ originalQuery, locale = "ko" }: Props) { if (!res.ok) { const body = (await res.json().catch(() => ({}))) as { error?: string }; - setError(body.error ?? "재분석 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요."); + setError(body.error ?? t.errorRetry); setLoading(false); return; } @@ -93,7 +96,7 @@ export default function ContextCard({ originalQuery, locale = "ko" }: Props) { } router.push("/results"); } catch { - setError("네트워크 오류가 발생했습니다. 잠시 후 다시 시도해주세요."); + setError(t.errorNetwork); setLoading(false); } } @@ -102,8 +105,8 @@ export default function ContextCard({ originalQuery, locale = "ko" }: Props) { return (
    diff --git a/components/session-results.tsx b/components/session-results.tsx index a9f4118..00dc363 100644 --- a/components/session-results.tsx +++ b/components/session-results.tsx @@ -112,12 +112,10 @@ export default function SessionResults({ locale: localeProp }: { locale?: Locale
    -

    비교를 시작해볼까요?

    -

    - 두 제품을 입력하면 Axis가 공식 스펙을 분석해 최적의 선택을 골라드립니다. -

    +

    {t.emptyTitle}

    +

    {t.emptySub}

    - 비교 시작하기 → + {t.emptyCta}
    diff --git a/components/timing-section.tsx b/components/timing-section.tsx index c7a4fdc..fda2a05 100644 --- a/components/timing-section.tsx +++ b/components/timing-section.tsx @@ -2,22 +2,25 @@ import { useEffect, useState } from "react"; import type { PriceApiResult } from "@/app/api/price/route"; +import { getDictionary, isLocale, type Locale } from "@/lib/i18n"; type TimingVerdict = "buy_now" | "wait_short" | "wait_model" | "collecting"; -type NextModelHint = { label: string; month: string } | null; +type NextModelHint = { readonly label: string; readonly month: string } | null; -function getNextModelHint(productName: string): NextModelHint { +function getNextModelHint(productName: string, timing: ReturnType["timing"]): NextModelHint { const n = productName.toLowerCase(); - if (n.includes("iphone")) return { label: "아이폰 신모델", month: "매년 9월" }; - if (n.includes("galaxy s") && !n.includes("fold") && !n.includes("flip")) - return { label: "갤럭시 S 신모델", month: "매년 1월" }; - if (n.includes("galaxy z fold") || n.includes("galaxy z flip")) - return { label: "갤럭시 Z 신모델", month: "매년 7월" }; - if (n.includes("macbook air")) return { label: "맥북 에어 신모델", month: "봄 (3~4월)" }; - if (n.includes("macbook pro")) return { label: "맥북 프로 신모델", month: "가을 (10~11월)" }; - if (n.includes("galaxy book")) return { label: "갤럭시 북 신모델", month: "봄 (3~5월)" }; - if (n.includes("lg gram")) return { label: "LG 그램 신모델", month: "봄 (1~3월)" }; + if (n.includes("iphone")) return { label: timing.iphoneLabel, month: timing.iphoneMonth }; + if (n.includes("galaxy s") && !n.includes("fold") && !n.includes("flip")) { + return { label: timing.galaxySLabel, month: timing.galaxySMonth }; + } + if (n.includes("galaxy z fold") || n.includes("galaxy z flip")) { + return { label: timing.galaxyZLabel, month: timing.galaxyZMonth }; + } + if (n.includes("macbook air")) return { label: timing.macbookAirLabel, month: timing.macbookAirMonth }; + if (n.includes("macbook pro")) return { label: timing.macbookProLabel, month: timing.macbookProMonth }; + if (n.includes("galaxy book")) return { label: timing.galaxyBookLabel, month: timing.galaxyBookMonth }; + if (n.includes("lg gram")) return { label: timing.lgGramLabel, month: timing.lgGramMonth }; return null; } @@ -27,53 +30,53 @@ function getVerdict(price: PriceApiResult): TimingVerdict { return "wait_model"; } -const VERDICT_CONFIG: Record = { - buy_now: { - signal: "ts-green", - text: "지금 사기 좋습니다", - sub: "최근 최저가에 가깝습니다. 더 기다려도 크게 내려가기 어렵습니다.", - }, - wait_short: { - signal: "ts-amber", - text: "지금 사도 크게 손해 없습니다", - sub: "평균 가격대입니다. 할인 시즌(블프·11번가 등)을 노린다면 조금 더 기다릴 수 있습니다.", - }, - wait_model: { - signal: "ts-red", - text: "잠깐, 기다려보세요", - sub: "현재 가격이 최저가보다 많이 높습니다. 할인이나 신모델 출시 전 재고 정리를 노려보세요.", - }, - collecting: { - signal: "ts-gray", - text: "가격 이력 수집 중입니다", - sub: "매일 가격을 수집하고 있습니다. 며칠 후 정확한 타이밍 판정을 드릴게요.", - }, -}; +function verdictConfig(timing: ReturnType["timing"]) { + return { + buy_now: { + signal: "ts-green", + text: timing.buyNowText, + sub: timing.buyNowSub, + }, + wait_short: { + signal: "ts-amber", + text: timing.waitShortText, + sub: timing.waitShortSub, + }, + wait_model: { + signal: "ts-red", + text: timing.waitModelText, + sub: timing.waitModelSub, + }, + collecting: { + signal: "ts-gray", + text: timing.collectingText, + sub: timing.collectingSub, + }, + } as const; +} type Props = { productName: string; - locale?: string; + locale?: Locale | string; }; export default function TimingSection({ productName, locale = "ko" }: Props) { + const resolvedLocale: Locale = isLocale(locale) ? locale : "ko"; + const timing = getDictionary(resolvedLocale).timing; const [price, setPrice] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { - fetch(`/api/price?name=${encodeURIComponent(productName)}&locale=${locale}`) + fetch(`/api/price?name=${encodeURIComponent(productName)}&locale=${resolvedLocale}`) .then((r) => r.json()) .then((data: { result: PriceApiResult | null }) => setPrice(data.result ?? null)) .catch(() => setPrice(null)) .finally(() => setLoading(false)); - }, [productName, locale]); + }, [productName, resolvedLocale]); const verdict: TimingVerdict | "collecting" = price ? getVerdict(price) : "collecting"; - const cfg = VERDICT_CONFIG[verdict]; - const hint = getNextModelHint(productName); + const cfg = verdictConfig(timing)[verdict]; + const hint = getNextModelHint(productName, timing); if (loading) return null; @@ -94,7 +97,7 @@ export default function TimingSection({ productName, locale = "ko" }: Props) { return (
    - 구매 타이밍 + {timing.label}
    @@ -111,9 +114,9 @@ export default function TimingSection({ productName, locale = "ko" }: Props) {
    - 최저 {price.lowest.toLocaleString()} - 현재 {price.current.toLocaleString()} - 평균 {price.average.toLocaleString()} + {timing.lowest} {price.lowest.toLocaleString()} + {timing.current} {price.current.toLocaleString()} + {timing.average} {price.average.toLocaleString()}
    )} @@ -125,7 +128,7 @@ export default function TimingSection({ productName, locale = "ko" }: Props) { - {hint.label} 출시 예정: {hint.month} + {timing.releaseHint(hint.label, hint.month)} )}
    diff --git a/components/vs-input.tsx b/components/vs-input.tsx index a505012..4065583 100644 --- a/components/vs-input.tsx +++ b/components/vs-input.tsx @@ -196,7 +196,7 @@ export default function VsInput({ maxOptions = 2, locale = "ko" }: { maxOptions? const overlay = isLoading ? createPortal( -
    +
    axis
    diff --git a/components/watch-button.tsx b/components/watch-button.tsx index 710fe34..225e09c 100644 --- a/components/watch-button.tsx +++ b/components/watch-button.tsx @@ -86,7 +86,8 @@ export default function WatchButton({ labelOn: string; locale?: Locale; }) { - const tw = getDictionary(locale).watch; + const dict = getDictionary(locale); + const tw = dict.watch; const watched = useIsWatched(productId); const [prompt, setPrompt] = useState("idle"); const [pushSupported] = useState(supportsPush); @@ -145,12 +146,12 @@ export default function WatchButton({ {prompt === "asking" && (
    -

    {tw.pushPrompt}

    diff --git a/lib/i18n/en.ts b/lib/i18n/en.ts index 9eeba83..1931525 100644 --- a/lib/i18n/en.ts +++ b/lib/i18n/en.ts @@ -125,6 +125,71 @@ export const en = { verificationPendingConclusion: "The products were found, but Axis could not collect enough official fields to build a verified spec table yet.", verificationPendingReason: "Axis stopped before recommending anything based on uncertain specs.", verificationPendingDetail: "Once official page mapping or extraction rules are improved, this comparison will be shown as a verified table.", + verifyVerified: "Official specs verified", + verifyPartial: "Some official specs verified", + verifyUnverified: "AI summary (unverified)", + emptyTitle: "Ready to compare?", + emptySub: "Enter two products and Axis will analyze official specs to pick the better fit.", + emptyCta: "Start comparing →", + }, + + timing: { + label: "Buy timing", + lowest: "Low", + current: "Now", + average: "Avg", + releaseHint: (label: string, month: string) => `${label} expected: ${month}`, + buyNowText: "Good time to buy", + buyNowSub: "Close to the recent low. Waiting longer is unlikely to save much.", + waitShortText: "Buying now is fine", + waitShortSub: "Around the average price. You can wait for a sale if you are not in a hurry.", + waitModelText: "Consider waiting", + waitModelSub: "Current price is well above the low. Watch for discounts or a new model cycle.", + collectingText: "Collecting price history", + collectingSub: "We gather prices daily. A clearer timing call will be ready in a few days.", + iphoneLabel: "Next iPhone", + iphoneMonth: "Every September", + galaxySLabel: "Next Galaxy S", + galaxySMonth: "Every January", + galaxyZLabel: "Next Galaxy Z", + galaxyZMonth: "Every July", + macbookAirLabel: "Next MacBook Air", + macbookAirMonth: "Spring (Mar–Apr)", + macbookProLabel: "Next MacBook Pro", + macbookProMonth: "Fall (Oct–Nov)", + galaxyBookLabel: "Next Galaxy Book", + galaxyBookMonth: "Spring (Mar–May)", + lgGramLabel: "Next LG gram", + lgGramMonth: "Spring (Jan–Mar)", + }, + + context: { + triggerTitle: "Reanalyze for my situation", + triggerHint: "Tell us your use case and budget for a sharper pick", + headTitle: "Reanalyze for my situation", + headSub: "We recalculate the recommendation with your selected conditions", + closeAria: "Close", + useCaseLabel: "Main use case", + budgetLabel: "Budget", + memoLabel: "Extra notes", + memoOptional: "(optional)", + memoPlaceholder: "e.g. Battery matters most, switching from iPhone", + submit: "Reanalyze with this context", + submitting: "Reanalyzing…", + errorNeedInput: "Choose a use case or budget, or add a note.", + errorRetry: "Reanalysis failed. Please try again shortly.", + errorNetwork: "Network error. Please try again shortly.", + useCasePrefix: "Use case", + budgetPrefix: "Budget", + useCaseDaily: "Everyday use", + useCaseWork: "Work · productivity", + useCaseCreator: "Video · editing", + useCaseGame: "Gaming", + useCaseStudent: "School · study", + budgetUnder50: "Under ₩500k", + budget50to100: "₩500k–1M", + budget100to200: "₩1–2M", + budgetOver200: "₩2M+", }, // Price diff --git a/lib/i18n/ja.ts b/lib/i18n/ja.ts index 91a4505..bb7ad2c 100644 --- a/lib/i18n/ja.ts +++ b/lib/i18n/ja.ts @@ -125,6 +125,71 @@ export const ja = { verificationPendingConclusion: "公式製品は確認できましたが、検証済みの表を作るのに十分な公式項目をまだ収集できませんでした。", verificationPendingReason: "不確かなスペックで推薦しないため、結果生成を停止しました。", verificationPendingDetail: "公式ページの対応または抽出ルールを追加すると、検証済みの表として表示できます。", + verifyVerified: "公式スペック検証済み", + verifyPartial: "一部の公式スペックを検証", + verifyUnverified: "AI整理(未検証)", + emptyTitle: "比較を始めますか?", + emptySub: "2つの製品を入力すると、Axisが公式スペックを分析して最適な選択を選びます。", + emptyCta: "比較を始める →", + }, + + timing: { + label: "購入タイミング", + lowest: "最安", + current: "現在", + average: "平均", + releaseHint: (label: string, month: string) => `${label} 発売予定: ${month}`, + buyNowText: "今買うのがおすすめです", + buyNowSub: "最近の最安値に近いです。これ以上待っても大きく下がる可能性は低いです。", + waitShortText: "今買っても大きく損はありません", + waitShortSub: "平均価格帯です。急がなくてよいならセールを待ってもよいです。", + waitModelText: "少し待つことを検討してください", + waitModelSub: "現在価格は最安値よりかなり高いです。値下げや新モデル前の在庫整理を狙いましょう。", + collectingText: "価格履歴を収集中です", + collectingSub: "毎日価格を集めています。数日後により正確なタイミング判定ができます。", + iphoneLabel: "次のiPhone", + iphoneMonth: "毎年9月", + galaxySLabel: "次のGalaxy S", + galaxySMonth: "毎年1月", + galaxyZLabel: "次のGalaxy Z", + galaxyZMonth: "毎年7月", + macbookAirLabel: "次のMacBook Air", + macbookAirMonth: "春(3〜4月)", + macbookProLabel: "次のMacBook Pro", + macbookProMonth: "秋(10〜11月)", + galaxyBookLabel: "次のGalaxy Book", + galaxyBookMonth: "春(3〜5月)", + lgGramLabel: "次のLG gram", + lgGramMonth: "春(1〜3月)", + }, + + context: { + triggerTitle: "自分の状況に合わせて再分析", + triggerHint: "用途・予算を伝えると、より正確なおすすめになります", + headTitle: "自分の状況に合わせて再分析", + headSub: "選んだ条件を反映しておすすめを再計算します", + closeAria: "閉じる", + useCaseLabel: "主な用途", + budgetLabel: "予算", + memoLabel: "追加メモ", + memoOptional: "(任意)", + memoPlaceholder: "例: バッテリーが一番大事、iPhoneから乗り換えたい", + submit: "この条件で再分析", + submitting: "再分析中…", + errorNeedInput: "用途か予算を選ぶか、メモを入力してください。", + errorRetry: "再分析中にエラーが発生しました。しばらくしてから再試行してください。", + errorNetwork: "ネットワークエラーが発生しました。しばらくしてから再試行してください。", + useCasePrefix: "用途", + budgetPrefix: "予算", + useCaseDaily: "日常使い", + useCaseWork: "仕事・生産性", + useCaseCreator: "映像・編集", + useCaseGame: "ゲーム", + useCaseStudent: "学校・勉強", + budgetUnder50: "50万未満", + budget50to100: "50–100万", + budget100to200: "100–200万", + budgetOver200: "200万以上", }, // Price diff --git a/lib/i18n/ko.ts b/lib/i18n/ko.ts index 878bbbf..7dd9e3c 100644 --- a/lib/i18n/ko.ts +++ b/lib/i18n/ko.ts @@ -130,6 +130,71 @@ export const ko = { verificationPendingConclusion: "공식 제품은 확인됐지만 스펙 표를 만들 만큼 충분한 공식 항목을 아직 수집하지 못했습니다.", verificationPendingReason: "확실하지 않은 스펙으로 추천하지 않기 위해 결과 생성을 중단했습니다.", verificationPendingDetail: "공식 페이지 연결 또는 추출 규칙을 보강한 뒤 다시 비교하면 검증된 표로 표시됩니다.", + verifyVerified: "공식 스펙 검증됨", + verifyPartial: "일부 공식 스펙 검증", + verifyUnverified: "AI 정리 (검증 전)", + emptyTitle: "비교를 시작해볼까요?", + emptySub: "두 제품을 입력하면 Axis가 공식 스펙을 분석해 최적의 선택을 골라드립니다.", + emptyCta: "비교 시작하기 →", + }, + + timing: { + label: "구매 타이밍", + lowest: "최저", + current: "현재", + average: "평균", + releaseHint: (label: string, month: string) => `${label} 출시 예정: ${month}`, + buyNowText: "지금 사기 좋습니다", + buyNowSub: "최근 최저가에 가깝습니다. 더 기다려도 크게 내려가기 어렵습니다.", + waitShortText: "지금 사도 크게 손해 없습니다", + waitShortSub: "평균 가격대입니다. 할인 시즌(블프·11번가 등)을 노린다면 조금 더 기다릴 수 있습니다.", + waitModelText: "잠깐, 기다려보세요", + waitModelSub: "현재 가격이 최저가보다 많이 높습니다. 할인이나 신모델 출시 전 재고 정리를 노려보세요.", + collectingText: "가격 이력 수집 중입니다", + collectingSub: "매일 가격을 수집하고 있습니다. 며칠 후 정확한 타이밍 판정을 드릴게요.", + iphoneLabel: "아이폰 신모델", + iphoneMonth: "매년 9월", + galaxySLabel: "갤럭시 S 신모델", + galaxySMonth: "매년 1월", + galaxyZLabel: "갤럭시 Z 신모델", + galaxyZMonth: "매년 7월", + macbookAirLabel: "맥북 에어 신모델", + macbookAirMonth: "봄 (3~4월)", + macbookProLabel: "맥북 프로 신모델", + macbookProMonth: "가을 (10~11월)", + galaxyBookLabel: "갤럭시 북 신모델", + galaxyBookMonth: "봄 (3~5월)", + lgGramLabel: "LG 그램 신모델", + lgGramMonth: "봄 (1~3월)", + }, + + context: { + triggerTitle: "내 상황에 맞게 다시 분석받기", + triggerHint: "용도 · 예산을 알려주면 더 정확한 추천을 드려요", + headTitle: "내 상황에 맞게 재분석", + headSub: "선택한 조건을 반영해 추천을 다시 계산합니다", + closeAria: "닫기", + useCaseLabel: "주요 용도", + budgetLabel: "예산", + memoLabel: "추가 메모", + memoOptional: "(선택)", + memoPlaceholder: "예: 배터리가 제일 중요해, 아이폰에서 갈아타려고", + submit: "이 상황으로 다시 분석", + submitting: "다시 분석하는 중…", + errorNeedInput: "용도·예산 중 하나 이상 선택하거나 메모를 입력해주세요.", + errorRetry: "재분석 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.", + errorNetwork: "네트워크 오류가 발생했습니다. 잠시 후 다시 시도해주세요.", + useCasePrefix: "용도", + budgetPrefix: "예산", + useCaseDaily: "일상 사용", + useCaseWork: "업무 · 생산성", + useCaseCreator: "영상 · 편집", + useCaseGame: "게임", + useCaseStudent: "학교 · 공부", + budgetUnder50: "50만 미만", + budget50to100: "50–100만", + budget100to200: "100–200만", + budgetOver200: "200만 이상", }, // Price