chore: archive-screenshots supabase-py 리팩터 + Storage 버킷 자동 생성

- sb_secret_* 신형 키 형식 지원을 위해 urllib → supabase-py 클라이언트로 전환
- ensure_bucket(): screenshots 버킷 없으면 public으로 자동 생성
- 41개 GCS 임시 스크린샷 → Supabase Storage 영구 URL로 아카이브 완료

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
claude/bold-hawking
Haewon Kam 2026-04-07 15:32:40 +09:00
parent 9c4d10609f
commit b84410341f
9 changed files with 678 additions and 80 deletions

View File

@ -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 \":*)"
]
}
}

3
.gitignore vendored
View File

@ -8,3 +8,6 @@ coverage/
!.env.example
.vercel
doc/
# Auto-generated: Supabase DB snapshots (vite-plugin-supabase-sync)
src/data/db/

View File

@ -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",

View File

@ -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<string, unknown> | null;
channel_data: Record<string, unknown> | null;
scrape_data: Record<string, unknown> | 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<string, Record<string, unknown>> | null)?.gangnamUnni?.rating ?? null,
lead_doctor: (r.report as Record<string, Record<string, Record<string, unknown>>> | 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();
});
},
};
}

View File

@ -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_', '')
SUPABASE_URL = (env.get('SUPABASE_URL') or env.get('VITE_SUPABASE_URL', '')).rstrip('/')
SERVICE_KEY = env.get('SUPABASE_SERVICE_ROLE_KEY', '')
BUCKET = 'screenshots'
DB_DIR = os.path.join(os.path.dirname(__file__), '..', 'src', 'data', 'db')
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,37 +61,39 @@ 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', '?')
@ -111,50 +103,50 @@ def archive_screenshots_for_report(row: dict) -> int:
ss_list = channel_data.get('screenshots', [])
rss_list = report.get('screenshots', [])
# 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)}개 리포트')
@ -163,17 +155,13 @@ 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', [])
)
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()

View File

@ -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;

View File

@ -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;

62
scripts/sync-db-local.ts Normal file
View File

@ -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,
})));

View File

@ -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),
},