From b84410341fb48abb6bfd4988893de7a852629893 Mon Sep 17 00:00:00 2001 From: Haewon Kam Date: Tue, 7 Apr 2026 15:32:40 +0900 Subject: [PATCH] =?UTF-8?q?chore:=20archive-screenshots=20supabase-py=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=84=B0=20+=20Storage=20=EB=B2=84=ED=82=B7?= =?UTF-8?q?=20=EC=9E=90=EB=8F=99=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sb_secret_* 신형 키 형식 지원을 위해 urllib → supabase-py 클라이언트로 전환 - ensure_bucket(): screenshots 버킷 없으면 public으로 자동 생성 - 41개 GCS 임시 스크린샷 → Supabase Storage 영구 URL로 아카이브 완료 Co-Authored-By: Claude Sonnet 4.6 --- .claude/settings.local.json | 4 +- .gitignore | 3 + package.json | 3 +- plugins/vite-plugin-supabase-sync.ts | 162 +++++++++++++++++++ scripts/archive-screenshots.py | 142 ++++++++-------- scripts/cleanup-and-seed-db.sql | 233 +++++++++++++++++++++++++++ scripts/seed-all-reports.sql | 139 ++++++++++++++++ scripts/sync-db-local.ts | 62 +++++++ vite.config.ts | 10 +- 9 files changed, 678 insertions(+), 80 deletions(-) create mode 100644 plugins/vite-plugin-supabase-sync.ts create mode 100644 scripts/cleanup-and-seed-db.sql create mode 100644 scripts/seed-all-reports.sql create mode 100644 scripts/sync-db-local.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index d8dfc76..fecffa8 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -20,7 +20,9 @@ "Bash(npx vite:*)", "Bash(vercel --version)", "Bash(find '/Users/haewonkam/Claude/Agentic Marketing/INFINITH' -name *.plan -o -name PLAN* -o -name plan* -o -name TODO* -o -name ROADMAP*)", - "WebFetch(domain:api.apify.com)" + "WebFetch(domain:api.apify.com)", + "Bash(npm run:*)", + "Bash(python3 -c \":*)" ] } } diff --git a/.gitignore b/.gitignore index 7977a30..f67d9c9 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ coverage/ !.env.example .vercel doc/ + +# Auto-generated: Supabase DB snapshots (vite-plugin-supabase-sync) +src/data/db/ diff --git a/package.json b/package.json index c910e77..6158e9d 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "build": "vite build", "preview": "vite preview", "clean": "rm -rf dist", - "lint": "tsc --noEmit" + "lint": "tsc --noEmit", + "db:sync": "tsx scripts/sync-db-local.ts" }, "dependencies": { "@google/genai": "^1.29.0", diff --git a/plugins/vite-plugin-supabase-sync.ts b/plugins/vite-plugin-supabase-sync.ts new file mode 100644 index 0000000..1fe2780 --- /dev/null +++ b/plugins/vite-plugin-supabase-sync.ts @@ -0,0 +1,162 @@ +/** + * vite-plugin-supabase-sync + * + * Dev-only Vite plugin that keeps src/data/db/ in sync with Supabase. + * + * Behaviour: + * 1. On dev server start → fetches ALL marketing_reports → writes src/data/db/{id}.json + * 2. Subscribes to Supabase Realtime → re-writes the changed file → sends HMR full-reload + * 3. Generates src/data/db/_index.json (id → clinic_name map for quick lookup) + * + * The generated files are gitignored — they are local dev snapshots only. + */ + +import { createClient } from '@supabase/supabase-js'; +import { writeFileSync, mkdirSync, unlinkSync, existsSync } from 'fs'; +import path from 'path'; +import type { Plugin, ViteDevServer } from 'vite'; + +interface Options { + supabaseUrl: string; + serviceRoleKey: string; + /** Output directory relative to project root. Default: src/data/db */ + outDir?: string; +} + +type ReportRow = { + id: string; + clinic_name: string | null; + url: string | null; + status: string | null; + report: Record | null; + channel_data: Record | null; + scrape_data: Record | null; + created_at: string; + updated_at: string | null; +}; + +function writeReport(outDir: string, row: ReportRow) { + const filePath = path.join(outDir, `${row.id}.json`); + writeFileSync(filePath, JSON.stringify(row, null, 2), 'utf-8'); +} + +function writeIndex(outDir: string, rows: ReportRow[]) { + const index = rows.map((r) => ({ + id: r.id, + clinic_name: r.clinic_name, + url: r.url, + status: r.status, + created_at: r.created_at, + gu_rating: (r.channel_data as Record> | null)?.gangnamUnni?.rating ?? null, + lead_doctor: (r.report as Record>> | null)?.clinicInfo?.leadDoctor?.name ?? null, + })); + writeFileSync(path.join(outDir, '_index.json'), JSON.stringify(index, null, 2), 'utf-8'); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function syncAll(supabase: any, outDir: string, server?: ViteDevServer) { + const { data, error } = await supabase + .from('marketing_reports') + .select('id, clinic_name, url, status, report, channel_data, scrape_data, created_at, updated_at') + .order('created_at', { ascending: false }); + + if (error) { + console.error('[supabase-sync] fetch error:', error.message); + return; + } + + const rows = (data ?? []) as ReportRow[]; + for (const row of rows) writeReport(outDir, row); + writeIndex(outDir, rows); + + console.log(`[supabase-sync] ✓ synced ${rows.length} report(s) → ${outDir}`); + if (server) server.ws.send({ type: 'full-reload' }); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function syncOne(supabase: any, outDir: string, id: string, server: ViteDevServer) { + const { data, error } = await supabase + .from('marketing_reports') + .select('id, clinic_name, url, status, report, channel_data, scrape_data, created_at, updated_at') + .eq('id', id) + .single(); + + if (error || !data) { + console.error(`[supabase-sync] fetch error for ${id}:`, error?.message); + return; + } + + writeReport(outDir, data as ReportRow); + + // Rebuild index + const { data: all } = await supabase + .from('marketing_reports') + .select('id, clinic_name, url, status, report, channel_data, scrape_data, created_at, updated_at') + .order('created_at', { ascending: false }); + if (all) writeIndex(outDir, all as ReportRow[]); + + console.log(`[supabase-sync] ✓ updated ${(data as ReportRow).clinic_name ?? id}`); + server.ws.send({ type: 'full-reload' }); +} + +export function supabaseSync(options: Options): Plugin { + const { supabaseUrl, serviceRoleKey, outDir: outDirOption } = options; + + return { + name: 'vite-plugin-supabase-sync', + apply: 'serve', // dev only — excluded from production build + + async configureServer(server) { + if (!supabaseUrl || !serviceRoleKey) { + console.warn('[supabase-sync] Missing SUPABASE_URL or SERVICE_ROLE_KEY — sync disabled'); + return; + } + + const outDir = path.resolve(outDirOption ?? 'src/data/db'); + if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const supabase: any = createClient(supabaseUrl, serviceRoleKey, { + auth: { persistSession: false }, + }); + + // 1. Initial full sync + await syncAll(supabase, outDir, undefined); + + // 2. Realtime subscription — fires on INSERT / UPDATE / DELETE + supabase + .channel('db-sync') + .on( + 'postgres_changes', + { event: '*', schema: 'public', table: 'marketing_reports' }, + async (payload) => { + const newRow = payload.new as { id?: string } | undefined; + const oldRow = payload.old as { id?: string } | undefined; + const id = newRow?.id ?? oldRow?.id; + if (!id) return; + + if (payload.eventType === 'DELETE') { + const filePath = path.join(outDir, `${id}.json`); + if (existsSync(filePath)) { + unlinkSync(filePath); + console.log(`[supabase-sync] ✓ removed ${id}.json`); + } + server.ws.send({ type: 'full-reload' }); + } else { + await syncOne(supabase, outDir, id, server); + } + }, + ) + .subscribe((status) => { + if (status === 'SUBSCRIBED') { + console.log('[supabase-sync] Realtime subscription active'); + } + }); + + // Clean up on server close + server.httpServer?.on('close', () => { + supabase.removeAllChannels(); + }); + }, + }; +} diff --git a/scripts/archive-screenshots.py b/scripts/archive-screenshots.py index 626cdfd..9cccc53 100644 --- a/scripts/archive-screenshots.py +++ b/scripts/archive-screenshots.py @@ -7,7 +7,8 @@ GCS 임시 URL(7일 만료)로 저장된 스크린샷을 Supabase Storage에 영 환경: SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY (.env 또는 환경변수) """ -import os, json, re, urllib.request, urllib.parse +import os, json, urllib.request, urllib.parse +from supabase import create_client, Client # ── 환경변수 로드 ────────────────────────────────────────────────────────────── def load_env(): @@ -22,7 +23,6 @@ def load_env(): env[k.strip()] = v.strip().strip('"').strip("'") except FileNotFoundError: pass - # 환경변수 우선 for k in ('SUPABASE_URL', 'SUPABASE_SERVICE_ROLE_KEY', 'VITE_SUPABASE_URL'): if os.environ.get(k): env[k] = os.environ[k] @@ -30,40 +30,30 @@ def load_env(): env = load_env() -SUPABASE_URL = env.get('SUPABASE_URL') or env.get('VITE_SUPABASE_URL', '').replace('VITE_', '') -SERVICE_KEY = env.get('SUPABASE_SERVICE_ROLE_KEY', '') -BUCKET = 'screenshots' -DB_DIR = os.path.join(os.path.dirname(__file__), '..', 'src', 'data', 'db') +SUPABASE_URL = (env.get('SUPABASE_URL') or env.get('VITE_SUPABASE_URL', '')).rstrip('/') +SERVICE_KEY = env.get('SUPABASE_SERVICE_ROLE_KEY', '') +BUCKET = 'screenshots' if not SUPABASE_URL or not SERVICE_KEY: print('❌ SUPABASE_URL 또는 SUPABASE_SERVICE_ROLE_KEY 환경변수가 없습니다.') - print(' .env 파일에 추가하거나 환경변수로 설정해주세요.') raise SystemExit(1) -# ── Supabase Storage 업로드 ──────────────────────────────────────────────────── -def upload_to_storage(storage_path: str, image_bytes: bytes) -> str: - """Supabase Storage에 업로드 후 public URL 반환.""" - encoded_path = urllib.parse.quote(storage_path, safe='/') - url = f'{SUPABASE_URL}/storage/v1/object/{BUCKET}/{encoded_path}' - req = urllib.request.Request( - url, - data=image_bytes, - method='POST', - headers={ - 'Authorization': f'Bearer {SERVICE_KEY}', - 'Content-Type': 'image/png', - 'x-upsert': 'true', # 중복이면 덮어쓰기 - } - ) - try: - with urllib.request.urlopen(req, timeout=30) as r: - r.read() - except urllib.error.HTTPError as e: - body = e.read().decode() - raise RuntimeError(f'Upload failed {e.code}: {body}') +# supabase-py 클라이언트 (sb_secret_* 키 포함 모든 형식 지원) +sb: Client = create_client(SUPABASE_URL, SERVICE_KEY) - public_url = f'{SUPABASE_URL}/storage/v1/object/public/{BUCKET}/{encoded_path}' - return public_url +# ── Storage 버킷 초기화 ──────────────────────────────────────────────────────── +def ensure_bucket(): + """screenshots 버킷이 없으면 public으로 생성.""" + try: + buckets = sb.storage.list_buckets() + existing = [b.name for b in buckets] + if BUCKET not in existing: + sb.storage.create_bucket(BUCKET, options={"public": True}) + print(f'✅ Storage 버킷 "{BUCKET}" 생성 완료') + else: + print(f'✅ Storage 버킷 "{BUCKET}" 확인됨') + except Exception as e: + print(f'⚠️ 버킷 확인/생성 실패: {e}') # ── GCS URL에서 이미지 다운로드 ──────────────────────────────────────────────── def fetch_image(gcs_url: str) -> bytes: @@ -71,90 +61,92 @@ def fetch_image(gcs_url: str) -> bytes: with urllib.request.urlopen(req, timeout=20) as r: return r.read() -# ── Supabase DB에서 모든 리포트 조회 + URL 업데이트 ─────────────────────────── +# ── Supabase Storage 업로드 ──────────────────────────────────────────────────── +def upload_to_storage(storage_path: str, image_bytes: bytes) -> str: + """supabase-py Storage 클라이언트로 업로드 → public URL 반환.""" + sb.storage.from_(BUCKET).upload( + path=storage_path, + file=image_bytes, + file_options={"content-type": "image/png", "upsert": "true"}, + ) + public_url = sb.storage.from_(BUCKET).get_public_url(storage_path) + return public_url + +# ── DB 조회 / 업데이트 ───────────────────────────────────────────────────────── def fetch_reports(): - url = f'{SUPABASE_URL}/rest/v1/marketing_reports?select=id,clinic_name,url,channel_data,report' - req = urllib.request.Request(url, headers={ - 'Authorization': f'Bearer {SERVICE_KEY}', - 'apikey': SERVICE_KEY, - 'Accept': 'application/json', - }) - with urllib.request.urlopen(req, timeout=30) as r: - return json.loads(r.read()) + res = sb.table('marketing_reports').select('id,clinic_name,url,channel_data,report').execute() + return res.data def update_report_screenshots(report_id: str, channel_data: dict, report: dict): - """Supabase DB에 업데이트된 channel_data, report JSONB 저장.""" - payload = json.dumps({'channel_data': channel_data, 'report': report}).encode() - url = f'{SUPABASE_URL}/rest/v1/marketing_reports?id=eq.{report_id}' - req = urllib.request.Request(url, data=payload, method='PATCH', headers={ - 'Authorization': f'Bearer {SERVICE_KEY}', - 'apikey': SERVICE_KEY, - 'Content-Type': 'application/json', - 'Prefer': 'return=minimal', - }) - with urllib.request.urlopen(req, timeout=30) as r: - r.read() + sb.table('marketing_reports').update({ + 'channel_data': channel_data, + 'report': report, + }).eq('id', report_id).execute() -# ── 메인 ─────────────────────────────────────────────────────────────────────── +# ── 헬퍼 ─────────────────────────────────────────────────────────────────────── def get_domain(site_url: str) -> str: try: return urllib.parse.urlparse(site_url).hostname.replace('www.', '') or 'unknown' except Exception: return 'unknown' +def is_gcs(url: str) -> bool: + return 'googleapis.com' in url or 'firecrawl' in url + +# ── 리포트별 아카이브 ─────────────────────────────────────────────────────────── def archive_screenshots_for_report(row: dict) -> int: - report_id = row['id'] - clinic_name = row.get('clinic_name', '?') - domain = get_domain(row.get('url', '')) + report_id = row['id'] + clinic_name = row.get('clinic_name', '?') + domain = get_domain(row.get('url', '')) channel_data = row.get('channel_data') or {} report = row.get('report') or {} ss_list = channel_data.get('screenshots', []) rss_list = report.get('screenshots', []) - all_ss = {s['id']: s for s in ss_list + rss_list if s.get('url')} + + # id → screenshot 오브젝트 인덱스 (중복 제거) + all_ss = {s['id']: s for s in ss_list + rss_list if s.get('url')} archived = 0 for ss_id, ss in all_ss.items(): url = ss.get('url', '') - if 'googleapis.com' not in url and 'firecrawl' not in url: - continue # 이미 Supabase URL이거나 다른 URL + if not is_gcs(url): + continue # 이미 Supabase URL이거나 영구 URL storage_path = f'clinics/{domain}/{report_id}/screenshots/{ss_id}.png' try: - print(f' → 다운로드: {ss_id} ({url[:60]}...)') + print(f' → 다운로드: {ss_id}') + print(f' URL: {url[:80]}...') image_bytes = fetch_image(url) public_url = upload_to_storage(storage_path, image_bytes) - # in-place URL 교체 - ss['url'] = public_url - if ss_id in {s.get('id') for s in ss_list}: - for s in ss_list: - if s.get('id') == ss_id: - s['url'] = public_url - if ss_id in {s.get('id') for s in rss_list}: - for s in rss_list: + # channel_data, report 양쪽에서 URL 교체 + for lst in (ss_list, rss_list): + for s in lst: if s.get('id') == ss_id: s['url'] = public_url - print(f' ✅ 아카이브 완료 → {public_url[:70]}...') + print(f' ✅ 완료 → {public_url[:80]}...') archived += 1 except Exception as e: print(f' ❌ 실패: {e}') if archived > 0: - # DB 업데이트 update_report_screenshots(report_id, channel_data, report) print(f' 💾 DB 업데이트 완료 ({clinic_name})') return archived +# ── 메인 ─────────────────────────────────────────────────────────────────────── def main(): print('=== Supabase Screenshot 영구 아카이브 ===') print(f'대상: {SUPABASE_URL}') print() + ensure_bucket() + print() print('DB에서 리포트 목록 조회 중...') reports = fetch_reports() print(f'총 {len(reports)}개 리포트') @@ -162,18 +154,14 @@ def main(): total_archived = 0 for row in reports: - name = row.get('clinic_name', '?') - ss_count = len((row.get('channel_data') or {}).get('screenshots', [])) - rss_count = len((row.get('report') or {}).get('screenshots', [])) - has_gcs = any( - 'googleapis.com' in (s.get('url', '')) - for s in (row.get('channel_data') or {}).get('screenshots', []) - + (row.get('report') or {}).get('screenshots', []) - ) + name = row.get('clinic_name', '?') + ss_list = (row.get('channel_data') or {}).get('screenshots', []) + rss_list = (row.get('report') or {}).get('screenshots', []) + has_gcs = any(is_gcs(s.get('url', '')) for s in ss_list + rss_list) if not has_gcs: continue - print(f'[{name}] channel_data={ss_count}개 / report={rss_count}개 스크린샷') + print(f'[{name}] channel_data={len(ss_list)}개 / report={len(rss_list)}개 스크린샷') n = archive_screenshots_for_report(row) total_archived += n print() diff --git a/scripts/cleanup-and-seed-db.sql b/scripts/cleanup-and-seed-db.sql new file mode 100644 index 0000000..b5c24ec --- /dev/null +++ b/scripts/cleanup-and-seed-db.sql @@ -0,0 +1,233 @@ +-- ═══════════════════════════════════════════════════════════════════════ +-- DB 전체 정리 + 데이터 보강 스크립트 +-- 실행 위치: Supabase SQL Editor +-- 작성일: 2026-04-07 +-- +-- 처리 내용: +-- 1. 중복/테스트/미완성 레코드 삭제 (53건) +-- 2. 병원당 최신 complete 1건만 유지 (5건) +-- 3. 모든 레코드에 lead_doctor + gangnamUnni 데이터 보강 +-- 4. 영문 clinic_name 한글로 정규화 +-- 5. 추적 파라미터가 붙은 URL 클린 URL로 정규화 +-- ═══════════════════════════════════════════════════════════════════════ + + +-- ── STEP 0: 실행 전 현황 확인 ────────────────────────────────────────── +SELECT + id, clinic_name, status, + channel_data->'gangnamUnni'->>'rating' AS gu_rating, + report->'clinicInfo'->'leadDoctor'->>'name' AS lead_doctor, + created_at::date AS date +FROM marketing_reports +ORDER BY created_at DESC; + + +-- ── STEP 1: 불필요한 레코드 삭제 ─────────────────────────────────────── +-- 유지할 레코드 5건 (병원당 최신 complete 1건): +-- 1b838500 → 뷰성형외과 (viewclinic.com) ✅ 이미 완비 +-- 89595bef → 바노바기성형외과 (banobagi.com) +-- 6c9e8bcb → 아이디병원 (idhospital.com) +-- 9edb276c → 그랜드성형외과 (grandsurgery.com) +-- 478d9128 → 디에이성형외과 (daprs.com) + +DELETE FROM marketing_reports +WHERE id NOT IN ( + '1b838500-0bca-404c-97e6-2efbd17a2e21', + '89595bef-a1bf-489e-aba9-348a08bd4d06', + '6c9e8bcb-153e-4e1f-b7b2-a48acd4d7917', + '9edb276c-b99b-4326-82fe-e6f5ed1f592b', + '478d9128-c1d0-45a2-b852-c211ece73270' +); + +-- 삭제 확인 +SELECT COUNT(*) AS remaining_count FROM marketing_reports; + + +-- ── STEP 2: 바노바기성형외과 (89595bef) ──────────────────────────────── +-- 강남언니: 평점 9.2 / 리뷰 6,843 / 대표원장 반재상·오창현 (공동) +UPDATE marketing_reports SET + clinic_name = '바노바기성형외과', + url = 'https://www.banobagi.com/', + channel_data = channel_data || jsonb_build_object( + 'gangnamUnni', jsonb_build_object( + 'name', '바노바기성형외과의원', + 'rating', 9.2, + 'rawRating', 9.2, + 'ratingScale', '/10', + 'totalReviews', 6843, + 'doctors', jsonb_build_array( + jsonb_build_object('name','반재상','specialty','성형외과 대표원장','rating',9.7,'reviews',678), + jsonb_build_object('name','오창현','specialty','성형외과 대표원장','rating',9.7,'reviews',543), + jsonb_build_object('name','권희연','specialty','성형외과','rating',9.6,'reviews',687), + jsonb_build_object('name','박선재','specialty','성형외과','rating',8.9,'reviews',92) + ), + 'procedures', jsonb_build_array('눈성형','코성형','안면윤곽/양악','가슴성형','지방성형','필러','보톡스','리프팅','모발이식'), + 'address', '서울 강남구 논현로 517', + 'badges', jsonb_build_array('마취과 전문의 상주','수술실 CCTV','여성 의사 진료','분야별 공동 진료','시술 후 관리','의료진 실명 공개','입원 시설','응급 대응 체계','야간진료'), + 'sourceUrl', 'https://www.gangnamunni.com/hospitals/23' + ) + ), + report = jsonb_set( + jsonb_set( + report, + '{clinicInfo,leadDoctor}', + '{"name":"반재상","specialty":"성형외과 대표원장 (공동대표)","rating":9.7,"reviewCount":678}'::jsonb + ), + '{clinicInfo,staffCount}', '20'::jsonb + ), + scrape_data = COALESCE(scrape_data, '{}'::jsonb) || jsonb_build_object( + 'source', 'registry', + 'registryData', jsonb_build_object( + 'district','강남', + 'brandGroup','프리미엄/하이타깃', + 'naverPlaceUrl','https://m.place.naver.com/hospital/21033469', + 'gangnamUnniUrl','https://www.gangnamunni.com/hospitals/23' + ) + ) +WHERE id = '89595bef-a1bf-489e-aba9-348a08bd4d06'; + + +-- ── STEP 3: 아이디병원 (6c9e8bcb) ──────────────────────────────────── +-- 강남언니: 평점 9.5 / 리뷰 14,933 / 대표원장 박상훈 (리뷰 9,058건) +UPDATE marketing_reports SET + clinic_name = '아이디병원', + url = 'https://www.idhospital.com/', + channel_data = channel_data || jsonb_build_object( + 'gangnamUnni', jsonb_build_object( + 'name', '아이디병원-본원', + 'rating', 9.5, + 'rawRating', 9.5, + 'ratingScale', '/10', + 'totalReviews', 14933, + 'doctors', jsonb_build_array( + jsonb_build_object('name','박상훈','specialty','성형외과 대표원장 (안면윤곽/양악)','rating',9.8,'reviews',9058), + jsonb_build_object('name','이지혁','specialty','성형외과','rating',9.2,'reviews',225), + jsonb_build_object('name','황인석','specialty','성형외과','rating',9.5,'reviews',215), + jsonb_build_object('name','이근석','specialty','이비인후과','rating',9.1,'reviews',87) + ), + 'procedures', jsonb_build_array('양악수술','안면윤곽','눈성형','코성형','가슴성형','리프팅','피부클리닉','치과'), + 'address', '서울 강남구 도산대로 142', + 'badges', jsonb_build_array('수술실 CCTV','마취과 전문의 상주','시술 후 관리','의료진 실명 공개','여성 의사 진료','입원 시설','전용 휴식 공간','야간진료','응급 대응 체계'), + 'sourceUrl', 'https://www.gangnamunni.com/hospitals/257' + ) + ), + report = jsonb_set( + jsonb_set( + report, + '{clinicInfo,leadDoctor}', + '{"name":"박상훈","specialty":"성형외과 대표원장 (안면윤곽/양악 특화)","rating":9.8,"reviewCount":9058}'::jsonb + ), + '{clinicInfo,staffCount}', '35'::jsonb + ), + scrape_data = COALESCE(scrape_data, '{}'::jsonb) || jsonb_build_object( + 'source', 'registry', + 'registryData', jsonb_build_object( + 'district','강남', + 'branches','아이디병원 별관(역삼)', + 'brandGroup','프리미엄/하이타깃', + 'naverPlaceUrl','https://m.place.naver.com/hospital/11548359', + 'gangnamUnniUrl','https://www.gangnamunni.com/hospitals/257' + ) + ) +WHERE id = '6c9e8bcb-153e-4e1f-b7b2-a48acd4d7917'; + + +-- ── STEP 4: 그랜드성형외과 (9edb276c) ──────────────────────────────── +-- 강남언니: 평점 9.8 / 리뷰 1,531 / 대표원장 이세환 (압구정) +UPDATE marketing_reports SET + clinic_name = '그랜드성형외과', + url = 'https://www.grandsurgery.com/', + channel_data = channel_data || jsonb_build_object( + 'gangnamUnni', jsonb_build_object( + 'name', '그랜드성형외과의원', + 'rating', 9.8, + 'rawRating', 9.8, + 'ratingScale', '/10', + 'totalReviews', 1531, + 'doctors', jsonb_build_array( + jsonb_build_object('name','이세환','specialty','성형외과 대표원장','rating',9.7,'reviews',562), + jsonb_build_object('name','김주희','specialty','성형외과','rating',9.6,'reviews',108), + jsonb_build_object('name','이승현','specialty','마취통증의학과','rating',9.0,'reviews',3) + ), + 'procedures', jsonb_build_array('피부','코성형','눈성형','보톡스','필러','리프팅','가슴성형','지방성형'), + 'address', '서울 강남구 논현로 841 (신사동) 6층', + 'badges', jsonb_build_array('분야별 공동 진료','응급 대응 체계','시술 후 관리','전용 휴식 공간','입원 시설','마취과 전문의 상주','의료진 실명 공개','성형외과 전문의 진료','여성 의사 진료'), + 'sourceUrl', 'https://www.gangnamunni.com/hospitals/62' + ) + ), + report = jsonb_set( + jsonb_set( + report, + '{clinicInfo,leadDoctor}', + '{"name":"이세환","specialty":"성형외과 대표원장 (압구정 그랜드)","rating":9.7,"reviewCount":562}'::jsonb + ), + '{clinicInfo,staffCount}', '12'::jsonb + ), + scrape_data = COALESCE(scrape_data, '{}'::jsonb) || jsonb_build_object( + 'source', 'registry', + 'registryData', jsonb_build_object( + 'district','압구정/신사', + 'brandGroup','부티크/전문클리닉', + 'gangnamUnniUrl','https://www.gangnamunni.com/hospitals/62' + ) + ) +WHERE id = '9edb276c-b99b-4326-82fe-e6f5ed1f592b'; + + +-- ── STEP 5: 디에이성형외과 (478d9128) ──────────────────────────────── +-- 강남언니: 평점 9.6 / 리뷰 69,859 (최대!) / 대표원장 이상우 +UPDATE marketing_reports SET + clinic_name = '디에이성형외과', + url = 'https://www.daprs.com/', + channel_data = channel_data || jsonb_build_object( + 'gangnamUnni', jsonb_build_object( + 'name', '디에이성형외과의원', + 'rating', 9.6, + 'rawRating', 9.6, + 'ratingScale', '/10', + 'totalReviews', 69859, + 'doctors', jsonb_build_array( + jsonb_build_object('name','이상우','specialty','성형외과 대표원장','rating',9.5,'reviews',1115), + jsonb_build_object('name','양희재','specialty','성형외과','rating',9.4,'reviews',3632), + jsonb_build_object('name','구현국','specialty','성형외과','rating',9.5,'reviews',2264), + jsonb_build_object('name','김경회','specialty','성형외과','rating',9.7,'reviews',312) + ), + 'procedures', jsonb_build_array('눈성형','코성형','안면윤곽','가슴성형','지방성형','필러','보톡스','리프팅','피부'), + 'address', '서울 강남구 역삼동', + 'badges', jsonb_build_array('수술실 CCTV','마취과 전문의 상주','분야별 공동 진료','시술 후 관리','의료진 실명 공개','입원 시설','응급 대응 체계'), + 'sourceUrl', 'https://www.gangnamunni.com/hospitals/250' + ) + ), + report = jsonb_set( + jsonb_set( + report, + '{clinicInfo,leadDoctor}', + '{"name":"이상우","specialty":"성형외과 대표원장","rating":9.5,"reviewCount":1115}'::jsonb + ), + '{clinicInfo,staffCount}', '18'::jsonb + ), + scrape_data = COALESCE(scrape_data, '{}'::jsonb) || jsonb_build_object( + 'source', 'registry', + 'registryData', jsonb_build_object( + 'district','강남/역삼', + 'brandGroup','볼륨/대중브랜드', + 'gangnamUnniUrl','https://www.gangnamunni.com/hospitals/250' + ) + ) +WHERE id = '478d9128-c1d0-45a2-b852-c211ece73270'; + + +-- ── STEP 6: 최종 검증 ──────────────────────────────────────────────── +SELECT + id, + clinic_name, + url, + status, + channel_data->'gangnamUnni'->>'rating' AS gu_rating, + channel_data->'gangnamUnni'->>'totalReviews' AS gu_reviews, + report->'clinicInfo'->'leadDoctor'->>'name' AS lead_doctor, + report->'clinicInfo'->>'staffCount' AS staff_count, + scrape_data->>'source' AS source, + scrape_data->'registryData'->>'district' AS district +FROM marketing_reports +ORDER BY created_at DESC; diff --git a/scripts/seed-all-reports.sql b/scripts/seed-all-reports.sql new file mode 100644 index 0000000..d0dd6d6 --- /dev/null +++ b/scripts/seed-all-reports.sql @@ -0,0 +1,139 @@ +-- ═══════════════════════════════════════════════════════════════ +-- Seed: 모든 marketing_reports에 강남언니 + 원장 정보 채우기 +-- 실행 전: SELECT id, clinic_name, status FROM marketing_reports; 로 확인 +-- Supabase SQL Editor에서 실행 +-- ═══════════════════════════════════════════════════════════════ + +-- 0. 현재 상태 조회 (실행 전 확인용) +SELECT + id, + clinic_name, + status, + channel_data->'gangnamUnni'->>'rating' AS gu_rating, + channel_data->'gangnamUnni'->>'totalReviews' AS gu_reviews, + report->'clinicInfo'->'leadDoctor'->>'name' AS lead_doctor, + report->'clinicInfo'->>'staffCount' AS staff_count, + scrape_data->>'source' AS source +FROM marketing_reports +ORDER BY created_at DESC; + +-- ═══════════════════════════════════════════════════════════════ +-- 1. 바노바기성형외과 — gangnamUnni + 원장 정보 +-- 강남언니 URL: https://www.gangnamunni.com/hospitals/23 +-- 평점: 9.2 / 리뷰: 6,843 +-- 대표원장: 반재상 (9.7점, 678리뷰), 오창현 (9.7점, 543리뷰) +-- ═══════════════════════════════════════════════════════════════ +UPDATE marketing_reports +SET + channel_data = channel_data || jsonb_build_object( + 'gangnamUnni', jsonb_build_object( + 'name', '바노바기성형외과의원', + 'rating', 9.2, + 'rawRating', 9.2, + 'ratingScale', '/10', + 'totalReviews', 6843, + 'doctors', jsonb_build_array( + jsonb_build_object('name', '반재상', 'specialty', '성형외과 대표원장', 'rating', 9.7, 'reviews', 678), + jsonb_build_object('name', '오창현', 'specialty', '성형외과 대표원장', 'rating', 9.7, 'reviews', 543), + jsonb_build_object('name', '권희연', 'specialty', '성형외과', 'rating', 9.6, 'reviews', 687), + jsonb_build_object('name', '박선재', 'specialty', '성형외과', 'rating', 8.9, 'reviews', 92) + ), + 'procedures', jsonb_build_array('눈성형', '코성형', '안면윤곽/양악', '가슴성형', '지방성형', '필러', '보톡스', '리프팅', '모발이식'), + 'address', '서울 강남구 논현로 517 바노바기', + 'badges', jsonb_build_array('마취과 전문의 상주', '수술실 CCTV', '여성 의사 진료', '분야별 공동 진료', '시술 후 관리', '의료진 실명 공개', '입원 시설', '응급 대응 체계', '야간진료'), + 'sourceUrl', 'https://www.gangnamunni.com/hospitals/23' + ) + ), + report = jsonb_set( + jsonb_set( + jsonb_set( + report, + '{clinicInfo,leadDoctor}', + '{"name": "반재상", "specialty": "성형외과 대표원장 (서울대 출신)", "rating": 9.7, "reviewCount": 678}'::jsonb + ), + '{clinicInfo,staffCount}', + '20'::jsonb + ), + '{channelAnalysis,gangnamUnni}', + '{"score": 82, "rating": 9.2, "ratingScale": 10, "reviews": 6843, "status": "active", "recommendation": "공동 대표원장 체제로 운영. 리뷰 볼륨 확대와 신규 시술 후기 유도 필요."}'::jsonb + ), + scrape_data = scrape_data || jsonb_build_object( + 'source', 'registry', + 'registryData', jsonb_build_object( + 'district', '강남', + 'branches', NULL, + 'brandGroup', '프리미엄/하이타깃 후보', + 'naverPlaceUrl', 'https://m.place.naver.com/hospital/21033469', + 'gangnamUnniUrl', 'https://www.gangnamunni.com/hospitals/23', + 'googleMapsUrl', NULL + ) + ) +WHERE lower(clinic_name) LIKE '%바노바기%'; + +-- ═══════════════════════════════════════════════════════════════ +-- 2. 아이디병원 — gangnamUnni + 원장 정보 +-- 강남언니 URL: https://www.gangnamunni.com/hospitals/257 +-- 평점: 9.5 / 리뷰: 14,933 +-- 대표원장: 박상훈 (9.8점, 9,058리뷰) +-- ═══════════════════════════════════════════════════════════════ +UPDATE marketing_reports +SET + channel_data = channel_data || jsonb_build_object( + 'gangnamUnni', jsonb_build_object( + 'name', '아이디병원-본원', + 'rating', 9.5, + 'rawRating', 9.5, + 'ratingScale', '/10', + 'totalReviews', 14933, + 'doctors', jsonb_build_array( + jsonb_build_object('name', '박상훈', 'specialty', '성형외과 대표원장', 'rating', 9.8, 'reviews', 9058), + jsonb_build_object('name', '이지혁', 'specialty', '성형외과', 'rating', 9.2, 'reviews', 225), + jsonb_build_object('name', '황인석', 'specialty', '성형외과', 'rating', 9.5, 'reviews', 215), + jsonb_build_object('name', '이근석', 'specialty', '이비인후과', 'rating', 9.1, 'reviews', 87) + ), + 'procedures', jsonb_build_array('양악수술', '안면윤곽', '눈성형', '코성형', '가슴성형', '리프팅', '피부클리닉', '치과'), + 'address', '서울 강남구 도산대로 142 아이디병원', + 'badges', jsonb_build_array('수술실 CCTV', '마취과 전문의 상주', '시술 후 관리', '의료진 실명 공개', '여성 의사 진료', '입원 시설', '전용 휴식 공간', '야간진료', '응급 대응 체계'), + 'sourceUrl', 'https://www.gangnamunni.com/hospitals/257' + ) + ), + report = jsonb_set( + jsonb_set( + jsonb_set( + report, + '{clinicInfo,leadDoctor}', + '{"name": "박상훈", "specialty": "성형외과 대표원장 (안면윤곽/양악 전문)", "rating": 9.8, "reviewCount": 9058}'::jsonb + ), + '{clinicInfo,staffCount}', + '35'::jsonb + ), + '{channelAnalysis,gangnamUnni}', + '{"score": 90, "rating": 9.5, "ratingScale": 10, "reviews": 14933, "status": "active", "recommendation": "강남언니 최상위권. 박상훈 원장 중심 콘텐츠 강화 및 양악수술 특화 후기 지속 확보 필요."}'::jsonb + ), + scrape_data = scrape_data || jsonb_build_object( + 'source', 'registry', + 'registryData', jsonb_build_object( + 'district', '강남', + 'branches', '아이디병원 별관(역삼)', + 'brandGroup', '프리미엄/하이타깃 후보', + 'naverPlaceUrl', 'https://m.place.naver.com/hospital/11548359', + 'gangnamUnniUrl', 'https://www.gangnamunni.com/hospitals/257', + 'googleMapsUrl', NULL + ) + ) +WHERE lower(clinic_name) LIKE '%아이디%'; + +-- ═══════════════════════════════════════════════════════════════ +-- 3. 검증 — 업데이트 후 모든 리포트 상태 확인 +-- ═══════════════════════════════════════════════════════════════ +SELECT + id, + clinic_name, + status, + channel_data->'gangnamUnni'->>'rating' AS gu_rating, + channel_data->'gangnamUnni'->>'totalReviews' AS gu_reviews, + report->'clinicInfo'->'leadDoctor'->>'name' AS lead_doctor, + report->'clinicInfo'->>'staffCount' AS staff_count, + scrape_data->>'source' AS source +FROM marketing_reports +ORDER BY created_at DESC; diff --git a/scripts/sync-db-local.ts b/scripts/sync-db-local.ts new file mode 100644 index 0000000..96fb024 --- /dev/null +++ b/scripts/sync-db-local.ts @@ -0,0 +1,62 @@ +/** + * npm run db:sync + * + * Fetches all marketing_reports from Supabase and writes them to src/data/db/. + * Run this manually after SQL Editor updates if the dev server is not running. + */ + +import { createClient } from '@supabase/supabase-js'; +import { writeFileSync, mkdirSync, existsSync } from 'fs'; +import path from 'path'; +import { config } from 'dotenv'; + +config(); // load .env + +const supabaseUrl = process.env.VITE_SUPABASE_URL!; +const key = process.env.SUPABASE_SERVICE_ROLE_KEY ?? process.env.VITE_SUPABASE_ANON_KEY!; + +if (!supabaseUrl || !key) { + console.error('❌ Missing VITE_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env'); + process.exit(1); +} + +const supabase = createClient(supabaseUrl, key, { auth: { persistSession: false } }); +const outDir = path.resolve('src/data/db'); + +if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true }); + +const { data, error } = await supabase + .from('marketing_reports') + .select('id, clinic_name, url, status, report, channel_data, scrape_data, created_at, updated_at') + .order('created_at', { ascending: false }); + +if (error) { + console.error('❌ Supabase error:', error.message); + process.exit(1); +} + +const rows = data ?? []; +for (const row of rows) { + writeFileSync(path.join(outDir, `${row.id}.json`), JSON.stringify(row, null, 2), 'utf-8'); +} + +// Write index +const index = rows.map((r: any) => ({ + id: r.id, + clinic_name: r.clinic_name, + url: r.url, + status: r.status, + created_at: r.created_at, + gu_rating: r.channel_data?.gangnamUnni?.rating ?? null, + lead_doctor: r.report?.clinicInfo?.leadDoctor?.name ?? null, +})); +writeFileSync(path.join(outDir, '_index.json'), JSON.stringify(index, null, 2), 'utf-8'); + +console.log(`✅ Synced ${rows.length} report(s) → ${outDir}`); +console.table(index.map((r: any) => ({ + id: r.id.slice(0, 8), + clinic: r.clinic_name, + status: r.status, + gu: r.gu_rating, + doctor: r.lead_doctor, +}))); diff --git a/vite.config.ts b/vite.config.ts index 0506f1b..348d8dd 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,11 +2,19 @@ import tailwindcss from '@tailwindcss/vite'; import react from '@vitejs/plugin-react'; import path from 'path'; import {defineConfig, loadEnv} from 'vite'; +import { supabaseSync } from './plugins/vite-plugin-supabase-sync'; export default defineConfig(({mode}) => { const env = loadEnv(mode, '.', ''); return { - plugins: [react(), tailwindcss()], + plugins: [ + react(), + tailwindcss(), + supabaseSync({ + supabaseUrl: env.VITE_SUPABASE_URL ?? '', + serviceRoleKey: env.SUPABASE_SERVICE_ROLE_KEY ?? env.VITE_SUPABASE_ANON_KEY ?? '', + }), + ], define: { 'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY), },