From e22d76c65a7620f366639fac164bde763f8f5419 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 17 Jul 2026 06:51:39 +0000 Subject: [PATCH 1/2] =?UTF-8?q?refactor:=20strip=20to=20laptop=20essential?= =?UTF-8?q?s=20=E2=80=94=20no=20AI=20chat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove AI verdicts, push/email/share/admin/history/watches APIs, non-laptop datasets/SEO, collect-specs, and Conductor. Decide from verified spec tables only; Track is localStorage; Coupang CTA kept. Bump CACHE_VERSION to v10; add CSP; drop unused deps. Co-authored-by: Min0504 --- .conductor/settings.toml | 13 - .env.example | 80 +- AGENTS.md | 114 +- CLAUDE.md | 37 +- README.md | 4 +- app/account/page.tsx | 54 - app/admin/sources/page.tsx | 28 - app/api/admin/extract/route.ts | 113 -- app/api/compare/route.ts | 48 +- app/api/cron/price-check/route.ts | 123 -- app/api/history/[id]/route.ts | 66 -- app/api/history/route.ts | 31 - app/api/push/subscribe/route.ts | 76 -- app/api/share/[id]/route.ts | 53 - app/api/share/guest/route.ts | 67 -- app/api/watches/route.ts | 133 --- app/compare/[slug]/page.tsx | 49 +- app/layout.tsx | 4 - app/page.tsx | 90 +- app/share/[token]/page.tsx | 95 -- components/admin-product-source-reviewer.tsx | 203 ---- components/buy-actions.tsx | 53 + components/context-card.tsx | 202 ---- components/example-chips.tsx | 2 +- components/history-list.tsx | 138 --- components/popular-rank-list.tsx | 117 -- components/results-view.tsx | 83 +- components/service-worker-registrar.tsx | 15 - components/share-actions.tsx | 189 --- components/watch-button.tsx | 166 +-- components/watch-list.tsx | 36 +- docs/handoff.md | 27 +- docs/progress.md | 40 +- lib/affiliate.ts | 114 +- lib/ai/axis-prompt.ts | 107 -- lib/ai/complete.ts | 163 --- lib/ai/decide.ts | 231 ---- lib/ai/types.ts | 40 - lib/compare-pages/comparisons.ts | 189 --- lib/comparison-cache.ts | 2 +- lib/decision-engine-fallback.ts | 36 - lib/decision-engine.ts | 214 +--- lib/email/send.ts | 85 -- lib/push/db.ts | 70 -- lib/push/send.ts | 60 - lib/specs/collect.ts | 72 -- lib/specs/dataset/earphones.ts | 349 ------ lib/specs/dataset/index.ts | 10 +- lib/specs/dataset/kr/earphones.ts | 201 ---- lib/specs/dataset/kr/smartphones.ts | 455 -------- lib/specs/dataset/smartphones.ts | 1027 ----------------- lib/specs/dataset/tablets.ts | 457 -------- lib/specs/extract/discover.ts | 150 +-- lib/specs/extract/index.ts | 205 +--- lib/specs/extract/pipeline.ts | 13 +- lib/specs/extract/web-search.ts | 111 -- lib/watch/db.ts | 56 - lib/watch/store.ts | 2 +- next.config.ts | 21 +- package-lock.json | 213 +--- package.json | 14 +- public/manifest.json | 13 - public/sw.js | 48 - scripts/collect-specs/index.ts | 275 ----- scripts/collect-specs/models/earphones.json | 77 -- scripts/collect-specs/models/laptops.json | 89 -- scripts/collect-specs/models/smartphones.json | 131 --- scripts/collect-specs/sources/danawa.ts | 338 ------ scripts/collect-specs/sources/gsmarena.ts | 225 ---- scripts/collect-specs/sources/kakaku.ts | 182 --- tests/ai-decide.test.ts | 96 -- tests/axis-prompt.test.ts | 22 - tests/complete.test.ts | 61 - tests/dataset.test.ts | 35 +- tests/discover.test.ts | 59 - tests/extract-pipeline.test.ts | 74 -- tests/extract.test.ts | 159 --- tests/fallback.test.ts | 20 - tests/url-patterns.test.ts | 111 -- tests/web-search.test.ts | 84 -- vercel.json | 4 - 81 files changed, 307 insertions(+), 9012 deletions(-) delete mode 100644 .conductor/settings.toml delete mode 100644 app/account/page.tsx delete mode 100644 app/admin/sources/page.tsx delete mode 100644 app/api/admin/extract/route.ts delete mode 100644 app/api/cron/price-check/route.ts delete mode 100644 app/api/history/[id]/route.ts delete mode 100644 app/api/history/route.ts delete mode 100644 app/api/push/subscribe/route.ts delete mode 100644 app/api/share/[id]/route.ts delete mode 100644 app/api/share/guest/route.ts delete mode 100644 app/api/watches/route.ts delete mode 100644 app/share/[token]/page.tsx delete mode 100644 components/admin-product-source-reviewer.tsx create mode 100644 components/buy-actions.tsx delete mode 100644 components/context-card.tsx delete mode 100644 components/history-list.tsx delete mode 100644 components/popular-rank-list.tsx delete mode 100644 components/service-worker-registrar.tsx delete mode 100644 components/share-actions.tsx delete mode 100644 lib/ai/axis-prompt.ts delete mode 100644 lib/ai/complete.ts delete mode 100644 lib/ai/decide.ts delete mode 100644 lib/ai/types.ts delete mode 100644 lib/decision-engine-fallback.ts delete mode 100644 lib/email/send.ts delete mode 100644 lib/push/db.ts delete mode 100644 lib/push/send.ts delete mode 100644 lib/specs/collect.ts delete mode 100644 lib/specs/dataset/earphones.ts delete mode 100644 lib/specs/dataset/kr/earphones.ts delete mode 100644 lib/specs/dataset/kr/smartphones.ts delete mode 100644 lib/specs/dataset/smartphones.ts delete mode 100644 lib/specs/dataset/tablets.ts delete mode 100644 lib/specs/extract/web-search.ts delete mode 100644 lib/watch/db.ts delete mode 100644 public/manifest.json delete mode 100644 public/sw.js delete mode 100644 scripts/collect-specs/index.ts delete mode 100644 scripts/collect-specs/models/earphones.json delete mode 100644 scripts/collect-specs/models/laptops.json delete mode 100644 scripts/collect-specs/models/smartphones.json delete mode 100644 scripts/collect-specs/sources/danawa.ts delete mode 100644 scripts/collect-specs/sources/gsmarena.ts delete mode 100644 scripts/collect-specs/sources/kakaku.ts delete mode 100644 tests/ai-decide.test.ts delete mode 100644 tests/axis-prompt.test.ts delete mode 100644 tests/complete.test.ts delete mode 100644 tests/discover.test.ts delete mode 100644 tests/extract-pipeline.test.ts delete mode 100644 tests/extract.test.ts delete mode 100644 tests/fallback.test.ts delete mode 100644 tests/url-patterns.test.ts delete mode 100644 tests/web-search.test.ts diff --git a/.conductor/settings.toml b/.conductor/settings.toml deleted file mode 100644 index aaa6043..0000000 --- a/.conductor/settings.toml +++ /dev/null @@ -1,13 +0,0 @@ -"$schema" = "https://conductor.build/schemas/settings.repo.schema.json" -file_include_globs = ".env*\n" - -[scripts] -setup = "mkdir -p .context && touch .context/handoff.md .context/fe-notes.md .context/be-notes.md .context/security-report.md && (npm install || true)" -run = "npm run dev -- -p $CONDUCTOR_PORT" -run_mode = "concurrent" - -[prompts] -general = "Follow AGENTS.md. Work only in this repo. FE/BE implement and summarize their own work; Security performs first-pass verification and fixes all found issues after PM says 검증 or 검증 단계로 넘겨; Lead performs second-pass verification and final synthesis after Security finishes." -code_review = "Focus on correctness, FE/BE API contract, auth/permission, secrets, file-boundary violations, and lint/typecheck/test/build evidence." -fix_errors = "Fix the smallest scoped issue within this repo and record failed commands or blockers in .context role notes." -create_pr = "Base PRs on master. Use 한것/검증/남은것 format. Summarize changed files, verification commands, Security result, and any PM approvals needed." diff --git a/.env.example b/.env.example index 71fe879..55255d7 100644 --- a/.env.example +++ b/.env.example @@ -1,80 +1,22 @@ -# Public site URL (used for metadata/OG). Production: https://axis.so +# Public site URL NEXT_PUBLIC_SITE_URL=http://localhost:3000 -# Supabase (Dashboard → Settings → API) +# Supabase (캐시·가격이력·클릭·로그인) NEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_ANON_KEY= SUPABASE_SERVICE_ROLE_KEY= -# Supabase Dashboard → Authentication → URL Configuration -# Site URL: http://localhost:3000 -# Redirect URLs: http://localhost:3000/auth/callback - -# AI (하나 이상 설정) -# OpenAI-compatible providers are supported via OPENAI_BASE_URL. -OPENAI_API_KEY= -OPENAI_MODEL=gpt-4o-mini -OPENAI_BASE_URL= - -GROQ_API_KEY= - -GEMINI_API_KEY= -GEMINI_MODEL=gemini-2.0-flash - -ANTHROPIC_API_KEY= -ANTHROPIC_MODEL=claude-3-5-haiku-20241022 - -# openai | groq | gemini | anthropic (선택, 미설정 시 키 있는 첫 provider) -AI_PROVIDER= - -# 공식 페이지 검색 provider (registry에 없는 제품의 공식 스펙 URL 발견) -# Brave 권장. Google은 Custom Search Engine(CX)이 필요합니다. -BRAVE_SEARCH_API_KEY= -GOOGLE_SEARCH_API_KEY= -GOOGLE_SEARCH_CX= - -# 공식 스펙 추출 관리자 라우트 (/api/admin/extract) -# 로컬 검토 시에만 1로 켜고, 프로덕션에서는 보호된 환경변수로만 설정하세요. -AXIS_ADMIN= - -# 가격 provider. -# - 로컬 데모: seed -# - 프로덕션: naver (또는 coupang). seed 금지 — 가짜 가격이 노출됩니다. -# - 미설정: 가격 UI 숨김 +# 가격: 로컬 데모=seed / 프로덕션=naver (seed 금지) AXIS_PRICE_SOURCE= +NAVER_CLIENT_ID= +NAVER_CLIENT_SECRET= +# 쿠팡 파트너스 (제휴 추적) +# NEXT_PUBLIC_COUPANG_AFFILIATE_ID= -# 검색엔진 사이트 인증 (Google Search Console / Naver Search Advisor 등록 후 발급) -# NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION= -# NEXT_PUBLIC_NAVER_SITE_VERIFICATION= - -# Vercel Cron (/api/cron/price-check). Vercel은 이 값을 Authorization: Bearer 로 보냅니다. +# Vercel Cron (/api/cron/price-snapshot) CRON_SECRET= -# Email alerts (Resend) -RESEND_API_KEY= -RESEND_FROM_EMAIL="Axis " - -# Web Push / PWA alerts -NEXT_PUBLIC_VAPID_PUBLIC_KEY= -VAPID_PRIVATE_KEY= -VAPID_SUBJECT=mailto:hello@axis.so - -# 개발 전용: 로컬에서 특정 플랜을 체감 (프로덕션 빌드에서는 무시됨). pro | plus | free -# AXIS_DEV_PLAN=pro - -# 제휴 마케팅 (없으면 일반 검색 링크로 폴백) -# NEXT_PUBLIC_COUPANG_AFFILIATE_ID= # 쿠팡 파트너스 서브ID (한국) — 구매 URL 추적용 -# NEXT_PUBLIC_AMAZON_AFFILIATE_US= # Amazon Associates tag (글로벌/영어) -# NEXT_PUBLIC_AMAZON_AFFILIATE_JP= # Amazon Japan tag (일본어) - -# 네이버 쇼핑 Open API (실시간 최저가 — AXIS_PRICE_SOURCE=naver 시 필요, 무료·즉시 발급) -# https://developers.naver.com/apps/#/register → 쇼핑 API 신청 -# NAVER_CLIENT_ID= -# NAVER_CLIENT_SECRET= - -# 쿠팡 파트너스 Open API (AXIS_PRICE_SOURCE=coupang 시 필요) -# 파트너스 포털 → 추가기능 → 파트너스API → Access Key / Secret Key -# ⚠️ 누적 판매 15만원 달성 후 최종승인 완료 계정만 발급 가능 -# COUPANG_ACCESS_KEY= -# COUPANG_SECRET_KEY= +# 검색엔진 사이트 인증 (선택) +# NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION= +# NEXT_PUBLIC_NAVER_SITE_VERIFICATION= diff --git a/AGENTS.md b/AGENTS.md index 8389434..f047f62 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,111 +3,17 @@ ## 프로젝트 - 이름: Axis -- 현재 repo 루트만 작업한다. -- DB: Supabase. -- PM만 최종 승인과 배포를 한다. +- **범위:** 한국 · 노트북 · 검증 스펙 비교 · 쿠팡 제휴 +- **금지:** AI 채팅형 답변, 푸시/이메일 알림, 게스트 공유, 관리자 추출, Conductor +- DB: Supabase (캐시·가격이력·클릭만) +- PM만 배포 승인 -## Conductor 역할 흐름 +## 결정 규칙 -1. PM이 Codex FE/BE에게 작업 지시. -2. Codex FE와 Codex BE가 각자 영역에서 구현, todo 관리, lint/typecheck/test/build 실행. -3. FE/BE는 각자 작업 결과를 종합해 수정 파일, 실행 명령, 실패/남은 이슈를 역할 노트에 기록한다. -4. PM이 "검증 단계로 넘겨" 또는 "검증" 지시 전까지 Codex Security는 개입하지 않는다. -5. Security가 1차 검증을 수행한다: 전체 diff, FE/BE 연결, 보안, 권한, 타입, 테스트, 런타임 위험. -6. Security는 발견한 문제를 Security 선에서 전부 수정하고, 수정 내용과 남은 리스크를 `security-report.md`에 기록한다. -7. Security 완료 후 Claude Lead가 2차 검증과 최종 종합을 수행한다: security-report, diff, test 결과, 구조 리스크, 남은 이슈, 다음 작업, merge 가능 여부. +- 스펙은 `lib/specs/dataset/` (+ 규칙 스크래핑)만. AI로 스펙/승자 만들지 않음. +- primary 스펙이 tier 1~2일 때만 `verified`. +- 결과 포맷 바꾸면 `CACHE_VERSION` 올리기 (현재 v10). -## 파일 경계 +## 역할 (스킬 예정) -### Codex FE - -담당: -- UI/UX, 컴포넌트, 화면, 스타일, 반응형, 클라이언트 상태, API 연결부, 프론트 테스트 - -금지: -- `api/`, `server/`, `src/server/`, `supabase/`, `data/`, `engine/`, `config/`, `lib/specs/`, `lib/pricing/`, `lib/ai/` 직접 수정 -- API 스펙 임의 변경 -- DB schema/migration/auth 임의 변경 - -### Codex BE - -담당: -- API, 서버 로직, `lib/specs/`, `lib/pricing/`, `lib/ai/`, DB 연결, migration, auth, permission, validation, backend security, backend test - -금지: -- `components/`, `pages/`, `screens/`, `ui/`, `styles/`, 프론트 화면 파일 직접 수정 -- 응답 형식 임의 변경 -- package/lockfile 임의 변경 - -### Codex Security - -- PM 검증 지시 후에만 실행. -- 검증: FE/BE API 불일치, request/response 타입, auth/session, permission 우회, DB/migration, secret/env 노출, webhook/결제/파일업로드, lint/typecheck/test/build, 런타임 에러, 파일 범위 침범. -- 발견한 보안/타입/설정/구조 문제는 Security 선에서 전부 수정한다. -- 수정한 내용, 수정하지 못한 리스크, 검증 결과를 `.context/security-report.md`에 기록한다. - -### Claude Lead - -- Security 통과 뒤 마지막에만 실행. -- 담당: 2차 검증, 최종 종합, 큰 구조 리뷰, 변경 요약, 남은 이슈, 다음 task 설계, merge 가능 여부 판단. -- 2차 검증에서는 `security-report.md`, FE/BE 노트, 전체 diff, lint/typecheck/test/build 결과, 파일 범위 침범 여부를 다시 확인한다. -- 코드 대량 수정 금지. - -## 기록 위치 - -- `.context/handoff.md` -- `.context/fe-notes.md` -- `.context/be-notes.md` -- `.context/security-report.md` -- 필요 시 `docs/inbox/frontend.md`, `docs/inbox/backend.md`, `docs/inbox/security.md`, `docs/inbox/lead.md` - -## 보고 형식 - -토큰 절약을 위해 모든 역할은 기본 보고를 아래 형식으로 끝낸다. - -```text -한것: file1, file2 -검증: lint ✅ type ✅ test ✅ build ✅ -남은것: 없음 -``` - -문제 있을 때만 짧게 이유를 붙인다. - -```text -한것: file1 -검증: test ❌ — 핵심 이유 -남은것: 해야 할 일 -``` - -Lead 최종 보고는 아래 형식을 쓴다. - -```text -판정: merge 가능/불가 -한것: 핵심 3줄 이하 -검증: Security ✅ Lead ✅ -남은것: 없음/항목 -``` - -긴 설명, 전체 파일 출력, 장문 로그 붙여넣기는 금지한다. 상세 판단은 Conductor diff/test 결과로 한다. - -## 공통 규칙 - -- 기본은 자율 수행이다. 역할 범위 안 코드 수정, 검증 명령 실행, 문서/.context 기록, 작은 설정 수정은 PM 승인 없이 진행한다. -- 단, 아래 항목은 반드시 PM 승인 후 진행한다: - - 개인정보/시크릿/env 값 생성·수정·노출 - - DB schema/rules/migration 실제 적용 - - 인증 방식 변경 - - 결제/웹훅/권한 구조 변경 - - package.json/lockfile 변경 - - 새 유료 서비스/API 도입 - - 배포 실행 - - git push/merge/delete branch - - 대량 삭제 - - 프로젝트 밖 파일 수정 -- package.json/lockfile 변경은 PM 승인 후. -- DB schema/migration 변경은 PM 승인 후. -- 인증 방식 변경은 PM 승인 후. -- 배포 실행은 PM만. -- 시크릿/앱키를 클라이언트 번들에 넣지 않는다. -- 실패한 명령은 숨기지 않고 역할 노트에 기록한다. -- 긴 전체 파일 출력 금지. diff, 테스트 결과, 수정 파일 목록 중심으로 보고한다. +FE / BE / SEC / LEAD — 파일 경계는 기존과 동일. 보고는 `한것/검증/남은것`. diff --git a/CLAUDE.md b/CLAUDE.md index 2fce4ed..403466a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,40 +1,19 @@ # CLAUDE.md -Axis 코드베이스에서 작업할 때 따라야 할 규칙. 프로젝트 개요·아키텍처는 -[README.md](README.md), 상세 개발 노트는 [DEV_NOTES.md](DEV_NOTES.md) 참고. +Axis 코드베이스 규칙. 개요는 [README.md](README.md), 상세는 [DEV_NOTES.md](DEV_NOTES.md). ## 절대 규칙 -1. **배포는 문맥상 명확할 때 실행한다.** 사용자가 다음 단계 진행을 요청하면 - `vercel --prod` 등 배포 명령을 직접 실행한다. -2. **결과/추천 로직은 건드리지 않는다.** `selectedOption`, `reasons`, - `oneLineConclusion`, per-option 분석 등 결정·추천 생성 로직은 사용자가 - 프롬프트로 직접 작업한다. 요청 없이 수정 금지. -3. **스펙은 DB에서 꺼내지 않는다.** 비교 스펙은 공식 페이지를 AI로 읽어 검증하거나 - `lib/specs/dataset/`의 수동 검증 데이터에서 가져온다. DB(Supabase)는 - 계정/히스토리/가격추적/알림 전용. -4. **검증 게이트를 지킨다.** primary 스펙이 공식 소스(tier 1~2)로 뒷받침될 때만 - `verified`. AI 추정값으로 "verified"를 만들지 않는다. 뻥스펙·하드코딩 fallback - 추천 금지 — 없는 제품은 "찾을 수 없음"으로 떨어뜨린다. +1. **범위:** 한국 · 노트북 · 검증 스펙 비교 · 쿠팡 제휴. 잡다한 기능 추가 금지. +2. **AI 채팅형 답변 금지.** 승자·근거는 검증 스펙표 점수(`buildDeterministicDecision`)만. +3. **스펙은 DB에서 꺼내지 않는다.** `lib/specs/dataset/` + 규칙 스크래핑만. +4. **검증 게이트.** primary가 tier 1~2일 때만 `verified`. 없는 제품은 "찾을 수 없음". +5. 배포는 문맥상 명확할 때 / PM 승인 하에. ## 작업 흐름 -- **코드 수정 후 반드시** `npm test` 실행 (특히 레지스트리/데이터셋/파이프라인 변경 시). -- 타입 체크: `npx tsc --noEmit`. -- 데이터셋 변경 시 `dataset.test.ts` 무결성 검사 통과 확인. -- 스키마·결과 포맷을 바꾸면 `lib/comparison-cache.ts`의 `CACHE_VERSION`을 올려 - 구버전 캐시를 무효화한다 (현재 **v9**). - -## 데이터셋 작업 - -- 수동 검증 데이터: `lib/specs/dataset/{index,smartphones,earphones,laptops,tablets}.ts` (+ `kr/`) -- 제품명은 로케일 정규화됨 — `canonicalName`(한국어) + `nameEn` + `nameJa`. - EN/JA 로케일에서 어필리에이트 검색어·표시명이 이 필드로 결정된다. -- 새 제품 추가 시: `id`는 lowercase-kebab, spec 키는 카테고리 스키마에 존재해야 함. - -## 비밀번호·보안 - -- `CRON_SECRET` 등 시크릿을 코드/문서에 하드코딩하지 않는다. +- 수정 후 `npm test` + `npx tsc --noEmit`. +- 스키마·결과 포맷 변경 시 `CACHE_VERSION` 올리기 (현재 **v10**). ## 응답 언어 diff --git a/README.md b/README.md index a715820..0c0d979 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,9 @@ 한국(한국어) · 미국(English) · 일본(日本語) 3개 시장을 동시 지원합니다. -**프로덕션:** https://axis-app-beta.vercel.app · **상태:** 베타, 사업성 검증 단계 (한국 · 노트북 · 제휴) +**프로덕션:** https://axis-app-beta.vercel.app · **상태:** 베타 (한국 · 노트북 · 검증 스펙 · 쿠팡 제휴) -> SEO: `verified`만 색인 (`partial`/`unverified`는 noindex). 캐시 버전: **v9**. +> 캐시 **v10**. AI 채팅 답변·푸시·공유·비노트북 SEO 제거. `verified`만 색인. --- diff --git a/app/account/page.tsx b/app/account/page.tsx deleted file mode 100644 index cb5364d..0000000 --- a/app/account/page.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import Link from "next/link"; -import { redirect } from "next/navigation"; -import LogoutButton from "@/components/logout-button"; -import { getCurrentProfile } from "@/lib/users/get-profile"; -import { getLocale } from "@/lib/i18n/server"; -import { getDictionary } from "@/lib/i18n"; - -export async function generateMetadata() { - const locale = await getLocale(); - const t = getDictionary(locale); - return { title: `${t.nav.myInfo} — Axis` }; -} - -export default async function AccountPage() { - const [profile, locale] = await Promise.all([ - getCurrentProfile(), - getLocale(), - ]); - - if (!profile) redirect("/login"); - - const t = getDictionary(locale); - const joined = new Date(profile.createdAt).toLocaleDateString( - locale === "ko" ? "ko-KR" : locale === "ja" ? "ja-JP" : "en-US" - ); - const avatarChar = (profile.email || "A").charAt(0).toUpperCase(); - - return ( -
-

- ← {t.error.backHome} -

- -
- {avatarChar} -

{profile.email}

-
- -
-
- {t.auth.email} - {profile.email} -
-
- {joined} -
-
- -
- -
-
- ); -} diff --git a/app/admin/sources/page.tsx b/app/admin/sources/page.tsx deleted file mode 100644 index 266f1dc..0000000 --- a/app/admin/sources/page.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import Link from "next/link"; -import { notFound } from "next/navigation"; -import type { Metadata } from "next"; -import AdminProductSourceReviewer from "@/components/admin-product-source-reviewer"; - -export const metadata: Metadata = { title: "Product Sources — Axis Admin" }; - -export default function AdminSourcesPage() { - if (process.env.AXIS_ADMIN !== "1") notFound(); - - return ( -
-

- ← 홈으로 -

- -
-

Axis Admin

-

AI 공식 스펙 추출 점검

-

- registry에 등록된 국가별 공식/수입처 페이지를 AI가 읽어 스펙을 추출하는지 확인합니다. 스펙은 DB에서 꺼내지 않습니다. -

-
- - -
- ); -} diff --git a/app/api/admin/extract/route.ts b/app/api/admin/extract/route.ts deleted file mode 100644 index 8787e6a..0000000 --- a/app/api/admin/extract/route.ts +++ /dev/null @@ -1,113 +0,0 @@ -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/compare/route.ts b/app/api/compare/route.ts index 15c9d9f..9118546 100644 --- a/app/api/compare/route.ts +++ b/app/api/compare/route.ts @@ -1,12 +1,10 @@ import { NextResponse } from "next/server"; import { buildDecision, buildQuery, parseOptions } from "@/lib/decision-engine"; -import { createSupabaseRouteClient } from "@/lib/supabase-route"; -import { ensureUserProfile } from "@/lib/users/ensure-profile"; import { getClientIp, rateLimit } from "@/lib/rate-limit"; import { COUNTRY_COOKIE, LOCALE_COOKIE, countryForLocale, isCountry, isLocale } from "@/lib/i18n"; import type { ComparisonResult } from "@/lib/types"; -const MAX_OPTIONS = 6; +const MAX_OPTIONS = 2; const MAX_OPTION_LENGTH = 100; const RATE_LIMIT = 20; const RATE_WINDOW_MS = 60_000; @@ -16,12 +14,8 @@ type Body = { optionA?: string; optionB?: string; options?: unknown; - /** Optional user situation for tailored re-analysis. */ - context?: unknown; }; -const MAX_CONTEXT_LENGTH = 200; - function collectOptions(body: Body): string[] { if (Array.isArray(body.options)) { return body.options.map((o) => String(o ?? "").trim()).filter(Boolean); @@ -63,15 +57,6 @@ export async function POST(req: Request) { ); } - const supabase = await createSupabaseRouteClient(req); - const { data: { user } } = supabase - ? await supabase.auth.getUser() - : { data: { user: null } }; - - if (supabase && user) { - await ensureUserProfile(supabase, user); - } - const cookieHeader = req.headers.get("cookie") ?? ""; const localeCookieMatch = cookieHeader.match(new RegExp(`(?:^|;\\s*)${LOCALE_COOKIE}=([^;]*)`)); const countryCookieMatch = cookieHeader.match(new RegExp(`(?:^|;\\s*)${COUNTRY_COOKIE}=([^;]*)`)); @@ -82,14 +67,9 @@ export async function POST(req: Request) { const query = buildQuery(options); - const userContext = - typeof body.context === "string" - ? body.context.trim().slice(0, MAX_CONTEXT_LENGTH) - : undefined; - let result: ComparisonResult; try { - result = await buildDecision(query, MAX_OPTIONS, locale, country, userContext || undefined); + result = await buildDecision(query, MAX_OPTIONS, locale, country); } catch (err) { console.error("[buildDecision]", err); return NextResponse.json( @@ -98,27 +78,5 @@ export async function POST(req: Request) { ); } - let comparisonId: string | undefined; - - if (supabase && user) { - const { data, error } = await supabase - .from("comparisons") - .insert({ - user_id: user.id, - query, - category: result.category, - selected_option: result.selectedOption, - analysis_result: result, - }) - .select("id") - .single(); - - if (error) { - console.error("[comparisons.insert]", error.message); - } else { - comparisonId = data.id; - } - } - - return NextResponse.json({ result, comparisonId }); + return NextResponse.json({ result }); } diff --git a/app/api/cron/price-check/route.ts b/app/api/cron/price-check/route.ts deleted file mode 100644 index 067c4bf..0000000 --- a/app/api/cron/price-check/route.ts +++ /dev/null @@ -1,123 +0,0 @@ -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/history/[id]/route.ts b/app/api/history/[id]/route.ts deleted file mode 100644 index 8a04d32..0000000 --- a/app/api/history/[id]/route.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { NextResponse } from "next/server"; -import { createSupabaseRouteClient } from "@/lib/supabase-route"; -import type { ComparisonResult } from "@/lib/types"; - -export async function GET(req: Request, context: { params: Promise<{ id: string }> }) { - const { id } = await context.params; - const supabase = await createSupabaseRouteClient(req); - - if (!supabase) { - return NextResponse.json({ error: "Supabase not configured" }, { status: 503 }); - } - - const { - data: { user } - } = await supabase.auth.getUser(); - - if (!user) { - return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 }); - } - - const { data, error } = await supabase - .from("comparisons") - .select("id, query, analysis_result") - .eq("id", id) - .eq("user_id", user.id) - .maybeSingle(); - - if (error || !data) { - return NextResponse.json({ error: "기록을 찾을 수 없습니다." }, { status: 404 }); - } - - return NextResponse.json({ - query: data.query, - result: data.analysis_result as ComparisonResult - }); -} - -export async function DELETE(req: Request, context: { params: Promise<{ id: string }> }) { - const { id } = await context.params; - const supabase = await createSupabaseRouteClient(req); - - if (!supabase) { - return NextResponse.json({ error: "Supabase not configured" }, { status: 503 }); - } - - const { - data: { user } - } = await supabase.auth.getUser(); - - if (!user) { - return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 }); - } - - // RLS also enforces ownership; the user_id filter is defense-in-depth. - const { error } = await supabase - .from("comparisons") - .delete() - .eq("id", id) - .eq("user_id", user.id); - - if (error) { - return NextResponse.json({ error: "삭제하지 못했습니다." }, { status: 400 }); - } - - return NextResponse.json({ ok: true }); -} diff --git a/app/api/history/route.ts b/app/api/history/route.ts deleted file mode 100644 index 3e7852d..0000000 --- a/app/api/history/route.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { NextResponse } from "next/server"; -import { createSupabaseRouteClient } from "@/lib/supabase-route"; - -export async function GET(req: Request) { - const supabase = await createSupabaseRouteClient(req); - - if (!supabase) { - return NextResponse.json({ history: [] }); - } - - const { - data: { user } - } = await supabase.auth.getUser(); - - if (!user) { - return NextResponse.json({ history: [] }); - } - - const { data, error } = await supabase - .from("comparisons") - .select("id, query, selected_option, created_at") - .eq("user_id", user.id) - .order("created_at", { ascending: false }) - .limit(10); - - if (error) { - return NextResponse.json({ history: [] }); - } - - return NextResponse.json({ history: data ?? [] }); -} diff --git a/app/api/push/subscribe/route.ts b/app/api/push/subscribe/route.ts deleted file mode 100644 index fb944a6..0000000 --- a/app/api/push/subscribe/route.ts +++ /dev/null @@ -1,76 +0,0 @@ -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/[id]/route.ts b/app/api/share/[id]/route.ts deleted file mode 100644 index 054503c..0000000 --- a/app/api/share/[id]/route.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { NextResponse } from "next/server"; -import { createSupabaseRouteClient } from "@/lib/supabase-route"; - -function generateToken() { - const chars = "abcdefghijkmnpqrstuvwxyz23456789"; - return Array.from({ length: 10 }, () => chars[Math.floor(Math.random() * chars.length)]).join(""); -} - -export async function POST(req: Request, context: { params: Promise<{ id: string }> }) { - const { id } = await context.params; - const supabase = await createSupabaseRouteClient(req); - - if (!supabase) { - return NextResponse.json({ error: "Supabase not configured" }, { status: 503 }); - } - - const { - data: { user } - } = await supabase.auth.getUser(); - - if (!user) { - return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 }); - } - - // Return existing token if already shared. - const { data: existing } = await supabase - .from("comparisons") - .select("share_token") - .eq("id", id) - .eq("user_id", user.id) - .maybeSingle(); - - if (!existing) { - return NextResponse.json({ error: "기록을 찾을 수 없습니다." }, { status: 404 }); - } - - if (existing.share_token) { - return NextResponse.json({ token: existing.share_token }); - } - - const token = generateToken(); - const { error } = await supabase - .from("comparisons") - .update({ share_token: token, is_public: true }) - .eq("id", id) - .eq("user_id", user.id); - - if (error) { - return NextResponse.json({ error: "공유 링크 생성에 실패했습니다." }, { status: 500 }); - } - - return NextResponse.json({ token }); -} diff --git a/app/api/share/guest/route.ts b/app/api/share/guest/route.ts deleted file mode 100644 index 9e789a7..0000000 --- a/app/api/share/guest/route.ts +++ /dev/null @@ -1,67 +0,0 @@ -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() { - return randomBytes(16).toString("base64url").slice(0, 22); -} - -/** - * Guest share endpoint: accepts a result payload directly and stores it - * as a public anonymous comparison so the link can be shared without login. - * 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; - } catch { - return NextResponse.json({ error: "잘못된 요청입니다." }, { status: 400 }); - } - - if (!body.query || !body.result?.selectedOption) { - 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 }); - } - - const token = generateToken(); - const { error } = await supabase.from("comparisons").insert({ - user_id: null, - query, - category: body.result.category ?? "general", - selected_option: body.result.selectedOption, - analysis_result: body.result, - share_token: token, - is_public: true - }); - - if (error) { - console.error("[guest share insert]", error.message); - return NextResponse.json({ error: "공유 링크 생성에 실패했습니다." }, { status: 500 }); - } - - return NextResponse.json({ token }); -} diff --git a/app/api/watches/route.ts b/app/api/watches/route.ts deleted file mode 100644 index adf36fa..0000000 --- a/app/api/watches/route.ts +++ /dev/null @@ -1,133 +0,0 @@ -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 { - return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); -} - -function isValidRegion(r: unknown): r is Region { - return r === "US" || r === "KR" || r === "JP"; -} - -/** - * Resolve the authenticated user's email. Client-supplied emails are never - * trusted for ownership — only the session (cookie or Bearer) is. - */ -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 { 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) } } - ); - } - 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: { 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; - } catch { - return NextResponse.json({ error: "invalid json" }, { status: 400 }); - } - - const { productId, name, region, targetPrice, addedAt } = body; - - if ( - typeof productId !== "string" || !productId || - typeof name !== "string" || !name || - !isValidRegion(region) - ) { - return NextResponse.json({ error: "missing or invalid fields" }, { status: 400 }); - } - - await upsertWatch(auth.email, { - productId, - name, - region, - targetPrice: typeof targetPrice === "number" ? targetPrice : undefined, - addedAt: typeof addedAt === "string" ? addedAt : new Date().toISOString(), - }); - - return NextResponse.json({ ok: true }); -} - -/** - * DELETE /api/watches - * 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; - } catch { - return NextResponse.json({ error: "invalid json" }, { status: 400 }); - } - - const { productId, region } = body; - - if ( - typeof productId !== "string" || !productId || - !isValidRegion(region) - ) { - return NextResponse.json({ error: "missing or invalid fields" }, { status: 400 }); - } - - await deleteWatch(auth.email, productId, region); - return NextResponse.json({ ok: true }); -} diff --git a/app/compare/[slug]/page.tsx b/app/compare/[slug]/page.tsx index 0867fd0..2ab0b1e 100644 --- a/app/compare/[slug]/page.tsx +++ b/app/compare/[slug]/page.tsx @@ -3,7 +3,7 @@ import Link from "next/link"; import type { Metadata } from "next"; import { COMPARISONS, CATEGORY_LABELS } from "@/lib/compare-pages/comparisons"; import { buildDecision, buildQuery } from "@/lib/decision-engine"; -import { createServiceClient } from "@/lib/supabase-server"; +import { createServiceClientSafe } from "@/lib/supabase-server"; import ResultsView from "@/components/results-view"; import PageViewTracker from "@/components/page-view-tracker"; import type { ComparisonResult } from "@/lib/types"; @@ -29,10 +29,13 @@ export async function generateMetadata({ params }: Props): Promise { const title = `${def.title} — Axis의 선택은?`; const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "https://axis.so"; + const result = await getOrGenerate(slug, def.options); + const indexable = Boolean(result && result.verification === "verified"); return { title, description: def.description, + robots: indexable ? undefined : { index: false, follow: true }, openGraph: { title, description: def.description, @@ -45,32 +48,35 @@ export async function generateMetadata({ params }: Props): Promise { } async function getOrGenerate(slug: string, options: string[]): Promise { - const db = createServiceClient(); - - // 1. Try cache - const { data } = await db - .from("seo_comparisons") - .select("result") - .eq("slug", slug) - .maybeSingle(); - - if (data?.result && isSafeCachedResult(data.result as ComparisonResult)) { - return data.result as ComparisonResult; + const db = createServiceClientSafe(); + + if (db) { + const { data } = await db + .from("seo_comparisons") + .select("result") + .eq("slug", slug) + .maybeSingle(); + + if (data?.result && isSafeCachedResult(data.result as ComparisonResult)) { + return data.result as ComparisonResult; + } } - // 2. Generate with AI const query = buildQuery(options); let result: ComparisonResult; try { - result = await buildDecision(query, 6); + result = await buildDecision(query, 2); } catch { return null; } - if (result.verification === "verified") { - db.from("seo_comparisons") + if (db && result.verification === "verified") { + void db + .from("seo_comparisons") .upsert({ slug, query, result, generated_at: new Date().toISOString() }) - .then(({ error }) => { if (error) console.error("[seo_comparisons.upsert]", error.message); }); + .then(({ error }) => { + if (error) console.error("[seo_comparisons.upsert]", error.message); + }); } return result; @@ -146,13 +152,10 @@ export default async function ComparePage({ params }: Props) { )} - {/* CTA */}
-

- 내 상황(예산·용도·기존 기기)을 더해서 맞춤 분석을 받고 싶다면? -

- - 내 상황 맞춤 비교 받기 → +

다른 노트북도 비교해 보세요.

+ + 새 비교 시작 →
diff --git a/app/layout.tsx b/app/layout.tsx index 883994f..57e2239 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,7 +3,6 @@ import "./globals.css"; import { ThemeProvider } from "@/components/theme-provider"; import { getLocale } from "@/lib/i18n/server"; import { getDictionary } from "@/lib/i18n"; -import ServiceWorkerRegistrar from "@/components/service-worker-registrar"; const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000"; @@ -47,9 +46,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo return ( - - -

맥북 에어

무게 1.24kg

`; - const text = htmlToText(html); - expect(text).toContain("맥북 에어"); - expect(text).toContain("무게"); - expect(text).not.toContain("color:red"); - expect(text).not.toContain("var a"); - expect(text).not.toContain("<"); - }); - - it("decodes a few entities", () => { - expect(htmlToText("A&B <C>")).toBe("A&B "); - }); -}); - -describe("buildExtractionPrompt", () => { - it("lists schema field keys and forbids guessing", () => { - const { system, user } = buildExtractionPrompt("laptop", "맥북 에어 M3", "공식 페이지 텍스트"); - expect(system).toContain("추측"); - expect(system).toContain("가장 낮은"); - expect(user).toContain("battery_wh"); - expect(user).toContain("weight_g"); - expect(user).toContain("맥북 에어 M3"); - }); - - it("focuses smartphone text around primary spec fields beyond model and chipset", () => { - const pageText = [ - "마케팅 문구 ".repeat(500), - "Capacity 128GB 256GB 512GB", - "Display 6.1-inch OLED ProMotion up to 120Hz 2000 nits", - "Weight 206 grams Camera 48MP Main", - "Power and Battery Video playback: Up to 23 hours", - "광고 문구 ".repeat(500) - ].join(" "); - - const focused = buildFocusedPageText("smartphone", pageText); - - expect(focused).toContain("128GB"); - expect(focused).toContain("120Hz"); - expect(focused).toContain("48MP"); - expect(focused).toContain("23 hours"); - expect(focused.length).toBeLessThan(pageText.length); - }); - - it("focuses official page text around category spec slots", () => { - const pageText = [ - "마케팅 문구 ".repeat(500), - "운영체제 macOS", - "디스플레이 Liquid Retina 13.6-inch 500 nits", - "무게 1.24kg", - "광고 문구 ".repeat(500) - ].join(" "); - const focused = buildFocusedPageText("laptop", pageText); - const { user } = buildExtractionPrompt("laptop", "맥북 에어", pageText); - - expect(focused).toContain("운영체제"); - expect(focused).toContain("500 nits"); - expect(focused.length).toBeLessThan(pageText.length); - expect(user).toContain("500 nits"); - expect(user).not.toContain("마케팅 문구 ".repeat(100).trim()); - }); -}); - -describe("parseExtraction", () => { - it("keeps only valid schema fields with meaningful values", () => { - const raw = JSON.stringify({ - cpu: "Apple M3", - weight_g: "1240", - battery_wh: null, - bogus_field: "ignored", - ports: "정보 없음" - }); - const out = parseExtraction(raw, "laptop"); - expect(out).toEqual({ cpu: "Apple M3", weight_g: "1240" }); - }); - - it("tolerates code fences and surrounding prose", () => { - const raw = "여기 결과:\n```json\n{ \"cpu\": \"M3\" }\n```"; - expect(parseExtraction(raw, "laptop")).toEqual({ cpu: "M3" }); - }); - - it("returns empty object on invalid JSON or schemaless category", () => { - expect(parseExtraction("not json", "laptop")).toEqual({}); - expect(parseExtraction('{"cpu":"M3"}', "general")).toEqual({}); - }); -}); - -describe("extractSpecsFromPage", () => { - const html = `

MacBook Air

- - - - - -
칩Apple M3 칩, 8코어 CPU, 10코어 GPU
무게1.24kg
배터리52.6Wh 리튬 폴리머 배터리
디스플레이13.6형 Liquid Retina, 2560 x 1664
`; - - it("extracts schema specs from a page via an injected model (deterministic)", async () => { - const complete = async () => - JSON.stringify({ cpu: "Apple M3", weight_g: "1240", battery_wh: "52.6", made_up: "x" }); - - const result = await extractSpecsFromPage({ - productName: "맥북 에어 M3", - category: "laptop", - sourceUrl: "https://www.apple.com/macbook-air/specs/", - html, - complete - }); - - expect(result).not.toBeNull(); - expect(result!.tier).toBe(EXTRACTED_TIER); // 2 = AI-extracted from official - expect(result!.source).toContain("apple.com"); - expect(result!.specs).toEqual({ - model_name: "맥북 에어 M3", - cpu: "Apple M3", - gpu: "10코어 GPU", - weight_g: "1240", - battery_wh: "52.6" - }); - expect(result!.specs.made_up).toBeUndefined(); - }); - - it("returns null when the model gives nothing usable", async () => { - const empty = async () => JSON.stringify({ bogus: "x", cpu: null }); - expect( - await extractSpecsFromPage({ - productName: "맥북 에어 M3", - category: "laptop", - sourceUrl: "https://apple.com", - html, - complete: empty - }) - ).toBeNull(); - }); - - it("returns null when there's no usable page text", async () => { - const complete = async () => JSON.stringify({ cpu: "M3" }); - expect( - await extractSpecsFromPage({ - productName: "x", - category: "laptop", - sourceUrl: "https://apple.com", - html: "", - complete - }) - ).toBeNull(); - }); -}); diff --git a/tests/fallback.test.ts b/tests/fallback.test.ts deleted file mode 100644 index 867324c..0000000 --- a/tests/fallback.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { buildFallbackDecision } from "@/lib/decision-engine-fallback"; - -describe("buildFallbackDecision", () => { - it("does not fabricate comparison values when AI is unavailable", () => { - const result = buildFallbackDecision(["아이폰", "갤럭시"], "smartphone", "ai-failed"); - - 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/url-patterns.test.ts b/tests/url-patterns.test.ts deleted file mode 100644 index 52dffb2..0000000 --- a/tests/url-patterns.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { buildBrandUrlCandidates } from "@/lib/specs/extract/url-patterns"; - -describe("buildBrandUrlCandidates", () => { - describe("Apple products — KR", () => { - it("iPhone model → /specs/ URL", () => { - const candidates = buildBrandUrlCandidates("iphone 16 pro", "KR"); - expect(candidates[0]).toBe("https://www.apple.com/kr/iphone-16-pro/specs/"); - }); - - it("iPhone base model", () => { - const candidates = buildBrandUrlCandidates("iphone 14", "KR"); - expect(candidates[0]).toBe("https://www.apple.com/kr/iphone-14/specs/"); - }); - - it("iPhone Pro Max", () => { - const candidates = buildBrandUrlCandidates("iphone 16 pro max", "KR"); - expect(candidates[0]).toBe("https://www.apple.com/kr/iphone-16-pro-max/specs/"); - }); - - it("MacBook Air → /specs/ first", () => { - const candidates = buildBrandUrlCandidates("macbook air 13 m4", "KR"); - expect(candidates[0]).toBe("https://www.apple.com/kr/macbook-air-13-m4/specs/"); - }); - - it("AirPods → root URL first (accessory pattern)", () => { - const candidates = buildBrandUrlCandidates("airpods 4", "KR"); - expect(candidates[0]).toBe("https://www.apple.com/kr/airpods-4/"); - }); - - it("AirPods Pro 2 — strips generation number (Apple URL stays at /airpods-pro/)", () => { - const candidates = buildBrandUrlCandidates("airpods pro 2", "KR"); - // Apple does not include "2" in the AirPods Pro URL slug - expect(candidates[0]).toBe("https://www.apple.com/kr/airpods-pro/"); - }); - - it("AirPods Pro (no generation) → root URL", () => { - const candidates = buildBrandUrlCandidates("airpods pro", "KR"); - expect(candidates[0]).toBe("https://www.apple.com/kr/airpods-pro/"); - }); - - it("iPad Pro M4 — strips chip suffix from URL", () => { - const candidates = buildBrandUrlCandidates("ipad pro m4", "KR"); - // Apple iPad Pro URL never includes the chip name - expect(candidates[0]).toBe("https://www.apple.com/kr/ipad-pro/specs/"); - }); - - it("iPad Air M2 — strips chip suffix", () => { - const candidates = buildBrandUrlCandidates("ipad air m2", "KR"); - expect(candidates[0]).toBe("https://www.apple.com/kr/ipad-air/specs/"); - }); - }); - - describe("Apple products — JP / US", () => { - it("iPhone JP region", () => { - const candidates = buildBrandUrlCandidates("iphone 16", "JP"); - expect(candidates[0]).toBe("https://www.apple.com/jp/iphone-16/specs/"); - }); - - it("iPhone US region (no country prefix)", () => { - const candidates = buildBrandUrlCandidates("iphone 16", "US"); - expect(candidates[0]).toBe("https://www.apple.com/iphone-16/specs/"); - }); - }); - - describe("Samsung products", () => { - it("Galaxy phone → /specs/ URL first", () => { - const candidates = buildBrandUrlCandidates("galaxy s25", "KR"); - // /specs/ first — matches actual Samsung product page structure - expect(candidates[0]).toBe("https://www.samsung.com/sec/smartphones/galaxy-s25/specs/"); - expect(candidates[1]).toBe("https://www.samsung.com/sec/smartphones/galaxy-s25/"); - }); - - it("Galaxy S+ model slug (+ → plus)", () => { - const candidates = buildBrandUrlCandidates("galaxy s24+", "KR"); - expect(candidates[0]).toBe("https://www.samsung.com/sec/smartphones/galaxy-s24plus/specs/"); - }); - - it("Galaxy Buds → audio path", () => { - const candidates = buildBrandUrlCandidates("galaxy buds3 pro", "KR"); - expect(candidates[0]).toBe("https://www.samsung.com/sec/audio-sound/galaxy-buds3-pro/specs/"); - }); - - it("Galaxy Book → pc path", () => { - const candidates = buildBrandUrlCandidates("galaxy book4 pro", "KR"); - expect(candidates[0]).toBe("https://www.samsung.com/sec/pc/galaxy-book/galaxy-book4-pro/specs/"); - }); - }); - - describe("Sony products", () => { - it("Sony WF model → candidates returned", () => { - const candidates = buildBrandUrlCandidates("sony wf-1000xm4", "KR"); - expect(candidates.length).toBeGreaterThan(0); - // Should target Sony Korea - expect(candidates[0]).toContain("sony.co.kr"); - }); - - it("Sony WH model — US region", () => { - const candidates = buildBrandUrlCandidates("sony wh-1000xm5", "US"); - expect(candidates.length).toBeGreaterThan(0); - expect(candidates[0]).toContain("sony.com"); - }); - }); - - describe("Unknown brands", () => { - it("Completely unknown brand returns empty array", () => { - const candidates = buildBrandUrlCandidates("bose quietcomfort ultra", "KR"); - expect(candidates).toHaveLength(0); - }); - }); -}); diff --git a/tests/web-search.test.ts b/tests/web-search.test.ts deleted file mode 100644 index 4cfe482..0000000 --- a/tests/web-search.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { configuredSearchProvider, searchWeb } from "@/lib/specs/extract/web-search"; - -const originalEnv = { ...process.env }; - -afterEach(() => { - process.env = { ...originalEnv }; - vi.restoreAllMocks(); -}); - -describe("searchWeb", () => { - it("returns no results when no search provider is configured", async () => { - delete process.env.BRAVE_SEARCH_API_KEY; - delete process.env.GOOGLE_SEARCH_API_KEY; - delete process.env.GOOGLE_SEARCH_CX; - - expect(configuredSearchProvider()).toBeNull(); - expect(await searchWeb("Dell XPS 13 official specs", "US")).toEqual([]); - }); - - it("uses Brave Search when BRAVE_SEARCH_API_KEY is configured", async () => { - process.env.BRAVE_SEARCH_API_KEY = "brave-key"; - delete process.env.GOOGLE_SEARCH_API_KEY; - delete process.env.GOOGLE_SEARCH_CX; - - const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => - new Response( - JSON.stringify({ - web: { - results: [ - { - title: "Dell XPS 13", - url: "https://www.dell.com/xps-13/specs", - description: "Official specifications" - } - ] - } - }), - { status: 200, headers: { "content-type": "application/json" } } - ) - ); - vi.stubGlobal("fetch", fetchMock); - - const results = await searchWeb("Dell XPS 13 official specs", "US"); - - expect(configuredSearchProvider()).toBe("brave"); - expect(results).toEqual([ - { - title: "Dell XPS 13", - url: "https://www.dell.com/xps-13/specs", - snippet: "Official specifications" - } - ]); - expect(String(fetchMock.mock.calls[0][0])).toContain("api.search.brave.com"); - }); - - it("uses Google Custom Search when Google keys are configured", async () => { - delete process.env.BRAVE_SEARCH_API_KEY; - process.env.GOOGLE_SEARCH_API_KEY = "google-key"; - process.env.GOOGLE_SEARCH_CX = "cx"; - - const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => - new Response( - JSON.stringify({ - items: [ - { - title: "Apple MacBook Air", - link: "https://www.apple.com/macbook-air/specs/", - snippet: "Official specs" - } - ] - }), - { status: 200, headers: { "content-type": "application/json" } } - ) - ); - vi.stubGlobal("fetch", fetchMock); - - const results = await searchWeb("MacBook Air official specs", "US"); - - expect(configuredSearchProvider()).toBe("google"); - expect(results[0]?.url).toBe("https://www.apple.com/macbook-air/specs/"); - expect(String(fetchMock.mock.calls[0][0])).toContain("customsearch"); - }); -}); diff --git a/vercel.json b/vercel.json index 6a0650b..b862904 100644 --- a/vercel.json +++ b/vercel.json @@ -3,10 +3,6 @@ { "path": "/api/cron/price-snapshot", "schedule": "0 1 * * *" - }, - { - "path": "/api/cron/price-check", - "schedule": "0 2 * * *" } ] } From f40eec585f1d365e3203c766b6a469d865892f5d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 17 Jul 2026 06:51:50 +0000 Subject: [PATCH 2/2] docs: mark DEV_NOTES and progress for essentials strip Co-authored-by: Min0504 --- DEV_NOTES.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/DEV_NOTES.md b/DEV_NOTES.md index 81c5391..dcc89a4 100644 --- a/DEV_NOTES.md +++ b/DEV_NOTES.md @@ -1,11 +1,13 @@ # Axis — 개발 노트 -> 마지막 업데이트: 2026-07-16 -> 테스트: `npm test` 통과 기준 유지 · 캐시 버전: **v9** +> 마지막 업데이트: 2026-07-17 (essentials strip) +> 캐시 버전: **v10** · 범위: KR · 노트북 · 검증 스펙표 · 쿠팡 제휴 > 프로덕션: https://axis-app-beta.vercel.app > -> 이 문서는 개발 단일 참조점이다. 제품 방향·진행 현황·아키텍처·남은 작업을 모두 담는다. -> 작업 규칙은 [CLAUDE.md](CLAUDE.md), 공개 소개는 [README.md](README.md). +> AI 채팅/푸시/공유/admin/비노트북 SEO 제거. 본문 일부(구 122개·다국가)는 구버전 서술일 수 있음. +> +> 작업 규칙 [CLAUDE.md](CLAUDE.md) · 공개 소개 [README.md](README.md). + ---