Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .claude/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
"autoPort": true,
"env": {
"AXIS_PRICE_SOURCE": "seed"
}
},
"_comment": "seed는 로컬 데모 전용. 프로덕션/Vercel에는 넣지 말 것."

}
]
}
8 changes: 6 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 키는 카테고리 스키마에 존재해야 함.
Expand Down
8 changes: 4 additions & 4 deletions DEV_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Axis — 개발 노트

> 마지막 업데이트: 2026-06-11
> 테스트: `npm test` 통과 기준 유지 · 캐시 버전: **v8**
> 마지막 업데이트: 2026-07-16
> 테스트: `npm test` 통과 기준 유지 · 캐시 버전: **v9**
> 프로덕션: https://axis-app-beta.vercel.app
>
> 이 문서는 개발 단일 참조점이다. 제품 방향·진행 현황·아키텍처·남은 작업을 모두 담는다.
Expand Down Expand Up @@ -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() — 카테고리 분류
Expand Down Expand Up @@ -88,7 +88,7 @@
|---------|------|------|
| 스마트폰 | `smartphones.ts` | 55 |
| 이어폰 | `earphones.ts` | 18 |
| 노트북 | `laptops.ts` | 26 |
| 노트북 | `laptops.ts` | 28 (+ Book6 Pro 14/16) |
| 태블릿 | `tablets.ts` | 23 |
| **합계** | | **122** |

Expand Down
6 changes: 3 additions & 3 deletions PROMPT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 활성
Expand Down Expand Up @@ -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`.

Expand Down Expand Up @@ -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)
```
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

**프로덕션:** https://axis-app-beta.vercel.app · **상태:** 베타, 사업성 검증 단계 (한국 · 노트북 · 제휴)

> SEO: `verified`만 색인 (`partial`/`unverified`는 noindex). 캐시 버전: **v9**.

---

## 주요 기능
Expand Down Expand Up @@ -45,7 +47,7 @@
| 가격 이력 적재 (일별 크론) | ✅ 완료 |
| 이메일 가격 알림 (Resend) | ✅ 완료 |
| 웹 푸시 알림 (VAPID) | ✅ 완료 |
| 검증 데이터셋 | ✅ 122개 제품 (스마트폰·이어폰·노트북·태블릿) |
| 검증 데이터셋 | ✅ 수동 122+ (+KR 자동수집 병합, 북6 프로 포함) |
| 다국어 KR/US/JP | ✅ 완료 |
| 쿠팡 파트너스 연동 | ⏳ 누적 매출 15만원 후 발급 |
| Groq 폴백 체인 | ⏳ 트래픽 증가 후 |
Expand Down Expand Up @@ -205,7 +207,7 @@ tier 2: 검증된 리뷰/언론 (GSMArena, Notebookcheck …)
tier 3: AI 추정값

verified = tier 1~2로 primary 스펙 확인됨 → 색인 허용
partial = 일부 스펙만 검증됨 → 색인 허용 (배지 표시)
partial = 일부 스펙만 검증됨 → noindex (배지 표시)
unverified = AI 추정값만 → noindex
```

Expand Down
113 changes: 113 additions & 0 deletions app/api/admin/extract/route.ts
Original file line number Diff line number Diff line change
@@ -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()
};
}
123 changes: 123 additions & 0 deletions app/api/cron/price-check/route.ts
Original file line number Diff line number Diff line change
@@ -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 <CRON_SECRET>.
* 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<boolean>
) {
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);
}
Loading
Loading