Bizcard

SkillDB 作者 project820 v0.1.2

Business card scanner + Google Contacts manager. Auto-detects business card images, extracts contact info via OCR (imageModel), confirms with user, saves to Google Contacts with configurable name format and card photo. Trigger: image that looks like a business card, or keywords "명함", "bizcard", "연락처 저장". Settings: /bizcard config

源码 ↗

安装 / 下载方式

TotalClaw CLI推荐
totalclaw install skilldb:project820~bizcard
cURL直接下载,无需登录
curl -fsSL https://skills.taituai.com/api/skills/skilldb%3Aproject820~bizcard/file -o bizcard.md
Git 仓库获取源码
git clone https://github.com/openclaw/skills/commit/e886ffefad46b3e62b464d87c5c218177e3302d4
# Bizcard — 명함 스캐너 + 연락처 관리

명함 이미지를 받으면 자동 감지 → 전처리 → OCR → 사용자 확인 → Google Contacts 저장까지 처리한다.

## Pipeline Overview

```
이미지 수신 → 명함 자동 감지
→ Gemini Flash OCR → 필드 파싱 → Name 포맷 적용
→ 사용자 확인 → 중복 감지 → Nano Banana Pro 보정 → Google Contacts 저장 + 사진 첨부
```

---

## 1. 자동 감지 (Trigger Detection)

### 키워드 매칭 (API 호출 없음)
메시지에 다음 키워드가 포함되면 즉시 명함 처리 모드 진입:
- 한국어: "명함", "연락처 저장", "연락처 추가"
- 영어: "bizcard", "business card", "save contact"

### 이미지 분석 (키워드 없이 이미지만 올라왔을 때)
imageModel에 다음을 요청:

```
이 이미지가 명함(business card)인지 판단해.
명함이면 YES, 아니면 NO만 답해.
명함 = 사람의 이름, 회사명, 연락처 정보가 인쇄된 카드 형태.
음식, 풍경, 스크린샷, 메모, 영수증 등은 NO.
```

- YES → 명함 처리 진행
- NO → 무시 (다른 스킬에 넘기거나 일반 응답)

---

## 2. 이미지 전처리 (ImageMagick)

### 2-1. 품질 평가

imageModel에 요청:

```
이 명함 이미지의 품질을 평가해:
- 텍스트가 선명하게 읽히는가? (CLEAR / BLURRY)
- 기울어져 있는가? (STRAIGHT / TILTED)
- 전체적으로 전처리가 필요한가? (NEEDS_PROCESSING / SKIP)
JSON으로 답해: {"clarity": "...", "tilt": "...", "preprocessing": "..."}
```

### 2-2. 전처리 실행 (NEEDS_PROCESSING일 때만)

```bash
# 요청별 고유 디렉터리 생성
BIZCARD_TMP=$(mktemp -d /tmp/bizcard-XXXXXXXX)

# 1. Deskew (기울기 보정)
magick "$BIZCARD_TMP/raw.jpg" -deskew 40% "$BIZCARD_TMP/deskew.jpg"

# 2. 콘트라스트 개선 + 선명화
magick "$BIZCARD_TMP/deskew.jpg" -normalize -sharpen 0x1 "$BIZCARD_TMP/enhanced.jpg"
```

전처리된 이미지로 OCR 진행.

### 2-3. SKIP일 때

원본 그대로 OCR 진행. 전처리 건너뜀.

---

## 3. OCR 필드 추출

imageModel(Gemini Flash)에 다음 JSON 구조로 추출 요청:

```
이 명함 이미지에서 연락처 정보를 추출해. 다음 JSON 형식으로 답해.
읽을 수 없는 필드는 null로 남겨.

{
  "name_ko": "한글 이름",
  "name_en": "English name",
  "company_ko": "한글 회사명",
  "company_en": "English company name",
  "title_ko": "한글 직함",
  "title_en": "English title",
  "department": "부서",
  "mobile": ["개인 휴대폰 배열"],
  "email": ["이메일 배열"],
  "locations": [
    {
      "label": "본사",
      "phone": ["063-000-0000"],
      "fax": ["063-000-0001"],
      "address_ko": "서울특별시 강남구 테헤란로 123",
      "address_en": null
    },
    {
      "label": "영업부",
      "phone": ["02-000-0000"],
      "fax": ["02-000-0001"],
      "address_ko": "경기도 성남시 분당구 판교로 456",
      "address_en": null
    }
  ],
  "website": ["웹사이트 배열"],
  "notes": "기타 (SNS, 자격증 등)",
  "language": "명함의 주 언어 (ko / en / ja / zh / other)"
}
```

---

## 4. 전화번호 정규화

한국 번호를 국제 형식으로 변환:

| 원본 | 변환 |
|------|------|
| `010-1234-5678` | `+82-10-1234-5678` |
| `02-1234-5678` | `+82-2-1234-5678` |
| `031-123-4567` | `+82-31-123-4567` |

규칙:
- `0`으로 시작하는 한국 번호 → 앞의 `0`을 `+82-`로 교체
- `+`로 시작하는 해외 번호 → 원본 유지
- 숫자 사이 `-` 또는 공백은 `-`로 통일

---

## 5. 이름 처리 규칙

### 5-1. 한국식 명함 (config: `koreanStyleName=true`, 기본값)

**한국에서는 성과 이름을 분리하지 않는다.** 비즈니스에서 "홍길동 대표", "김갑돌 과장"처럼 풀네임이 하나의 단위다.

People API 저장 시:
- `familyName` → **비움** (빈 문자열)
- `givenName` → **풀네임** (예: `홍길동`)
- `unstructuredName` → config 포맷 적용된 이름 (예: `#홍길동 과장`)

### 5-2. 외국 명함 (config: `koreanStyleName=false`이거나, OCR language가 ko가 아닌 경우)

외국인은 first name / last name 분리가 기본:
- `givenName` → first name (예: `John`)
- `familyName` → last name (예: `Smith`)
- `unstructuredName` → config 포맷 적용

### 5-3. Korean Reading (config: `koreanReading=true`)

외국어 명함일 때, 이름과 회사명을 한국어로 독음(transliteration)하여 기록:

| 원본 | 독음 |
|------|------|
| John Smith | 존 스미스 |
| Google LLC | 구글 |
| Toyota Motor | 토요타 모터 |
| François Dupont | 프랑수아 뒤퐁 |

**적용 방법:**
- imageModel에 "이 이름/회사명을 한국어 외래어 표기법으로 독음해줘" 요청
- **이름 독음 → People API `phoneticName` 필드에 저장** (검색 가능!)
- 회사명 독음 → `biographies`에 기록

**People API 저장:**
```
names[].phoneticGivenName = "퀘 훙 웨인"   ← 풀네임 독음을 하나로
names[].phoneticFamilyName = ""             ← 비움
```

**규칙:** 독음은 성/이름 분리하지 않는다. 풀네임 독음을 `phoneticGivenName`에 통째로 넣는다.

예시: `Kweh Hoong Wayne` → phoneticGivenName=`퀘 훙 웨인`
예시: `François Dupont` → phoneticGivenName=`프랑수아 뒤퐁`

**효과:** Google Contacts에서 "퀘 훙 웨인"으로 검색해도 해당 연락처를 찾을 수 있다.

`koreanReading=false`이면 독음 생략.

---

## 6. Name 포맷 적용

config 설정을 읽어 `unstructuredName` (displayName)을 생성한다.

### 적용 순서

```
1. 기본 이름: "홍길동"
2. hashtag=true     → "#홍길동"
3. appendTitle=true → "#홍길동 과장"
4. appendCompany=true → "#홍길동 과장 (ABC주식회사)"
```

### 조합 예시

| hashtag | appendTitle | appendCompany | 결과 |
|:---:|:---:|:---:|------|
| off | off | off | `홍길동` |
| on | off | off | `#홍길동` |
| off | on | off | `홍길동 과장` |
| off | off | on | `홍길동 (ABC주식회사)` |
| on | on | on | `#홍길동 과장 (ABC주식회사)` |

### People API 저장 시 (한국식)

- `names[].unstructuredName` → 포맷된 displayName (예: `#홍길동 과장`)
- `names[].givenName` → 풀네임 (예: `홍길동`)
- `names[].familyName` → 비움

### People API 저장 시 (외국식)

- `names[].unstructuredName` → 포맷된 displayName (예: `#John Smith, VP`)
- `names[].givenName` → first name (예: `John`)
- `names[].familyName` → last name (예: `Smith`)

---

## 7. 사용자 확인 플로우

OCR + 포맷 결과를 **아래 템플릿 그대로** 출력한다. 포맷 변경 금지.

**한국 명함 템플릿 (이 포맷 그대로 사용):**
```
📇 명함 인식 결과

👤 #홍길동 과장 (ABC주식회사)
🏢 ABC주식회사 / 영업부
💼 과장
📱 +82-10-1234-5678
📧 gdhong@example.co.kr
🌐 www.example.co.kr

📍 본사:
  📞 +82-63-450-3500 / 📠 +82-63-450-3517
  서울특별시 강남구 테헤란로 123

📍 영업부:
  📞 +82-2-597-8071~3 / 📠 +82-2-586-4388
  경기도 성남시 분당구 판교로 456

🖼️ 명함 사진 → 연락처 프로필 사진으로 저장

1. 저장
2. 수정
3. 취소
```

**거점이 1개뿐이면** 📍 label 없이 한 줄로:
```
📞 +82-2-9876-5432
📠 +82-2-9876-5433
📍 서울시 강남구 테헤란로 123
```

**외국 명함 템플릿 (koreanReading=true, 이 포맷 그대로 사용):**
```
📇 명함 인식 결과

👤 John Smith (존 스미스)
🏢 Google LLC (구글)
💼 VP of Engineering
📱 +1-555-123-4567
📧 jsmith@example.com
🖼️ 명함 사진 → 연락처 프로필 사진으로 저장

1. 저장
2. 수정
3. 취소
```

**규칙:**
- 이모지 순서 고정: 👤🏢💼📱📞📠📧🌐📍🖼️
- 없는 필드는 해당 줄 자체를 생략 (빈 줄 금지)
- 부가 설명이나 "20년차 전문가" 같은 OCR 잡데이터를 이름에 넣지 마라. 타이틀 필드에만 표시.
- 이름 라인(👤)에는 config 포맷만 적용: `#이름 직함 (회사명)`
- **직함은 💼 줄에 별도 표시.** 👤 줄의 직함은 config appendTitle 적용 시에만, 짧게. ALL CAPS 금지.
  - ✅ `👤 #Kweh Hoong Wayne` + `💼 Technical Service Manager`
  - ❌ `👤 #Kweh Hoong Wayne TECHNICAL SERVICE MANAGER`
- 하단은 반드시 `1. 저장 / 2. 수정 / 3. 취소` 번호 선택. "저장할까?" 같은 서술형 금지.
- **복수 거점:** 명함에 주소가 2개 이상이면 **거점(location) 단위로 그룹핑**하여 출력.
  각 거점은 📍 label + 해당 거점의 📞/📠/주소를 묶어서 표시.
  명함에서 전화/팩스/주소가 시각적으로 분리되어 있으면 반드시 별도 거점으로 분리. 절대 병합하지 마라.
- 거점 label(본사/공장/영업부 등)은 명함에서 추출. 없으면 "거점1", "거점2"로 표시.

### 응답 처리

| 입력 | 동작 |
|------|------|
| `1` | 저장 진행 |
| `2` | "뭘 수정할까?" 물은 뒤, 수정 후 다시 1/2/3 선택 |
| `3` | 취소 |

**번호 외 텍스트 입력도 허용:** "ㅇㅇ", "ok" → 1번 처리, "취소" → 3번 처리.
하지만 **번호 입력을 우선 유도**한다.

---

## 8. 명함 이미지 보정 + 사진 업로드 (항상 실행)

`cardAsPhoto=true`이면 **무조건 실행**한다. 모든 명함을 일관되게 보정한다.
말로만 "적용했어" 하지 말고 **실제로 exec 도구로 명령어를 실행**해라.

### 8-1. 원본 이미지를 /tmp에 복사

```bash
BIZCARD_TMP=$(mktemp -d /tmp/bizcard-XXXXXXXX)
cp /path/to/원본명함이미지.jpg "$BIZCARD_TMP/raw.jpg"
```

### 8-2. Nano Banana Pro로 이미지 보정 (핵심!)

원본 이미지를 Nano Banana Pro에 보내서 **배경 제거 + 정면 보정 + 1:1 정사각형**으로 변환.

```bash
python3 <<'PYEOF'
import urllib.request, json, base64, os

with open(os.environ["BIZCARD_TMP"] + "/raw.jpg", "rb") as f:
    img_b64 = base64.b64encode(f.read()).decode()

api_key = os.environ.get("NANO_BANANA_API_KEY", "")
if not api_key:
    print("ERROR: NANO_BANANA_API_KEY not set")
    exit(1)
url = f"https://generativelanguage.googleapis.com/v1beta/models/nano-banana-pro-preview:generateContent?key={api_key}"

payload = {
    "contents": [{
        "parts": [
            {"inlineData": {"mimeType": "image/jpeg", "data": img_b64}},
            {"text": "이 사진에서 명함 카드만 잘라내서 정면으로 보정한 이미지를 생성해줘. 배경 완전 제거, 명함만 남기고, 흰색 여백을 추가해서 1:1 정사각형 비율로 만들어줘. 명함 내용은 전부 보존해."}
        ]
    }],
    "generationConfig": {
        "responseModalities": ["TEXT", "IMAGE"]
    }
}

data = json.dumps(payload).encode()
req = urllib.request.Request(url, data=data, method="POST")
req.add_header("Content-Type", "application/json")
resp = urllib.request.urlopen(req, timeout=60)
result = json.loads(resp.read())

for part in result["candidates"][0]["content"]["parts"]:
    if "inlineData" in part:
        img_data = base64.b64decode(part["inlineData"]["data"])
        with open(os.environ["BIZCARD_TMP"] + "/clean.jpg", "wb") as f:
            f.write(img_data)
        print(f"OK: clean.jpg ({len(img_data)} bytes)")
    elif "text" in part:
        print(f"Text: {part['text'][:100]}")
PYEOF
```

**결과:** `$BIZCARD_TMP/clean.jpg` — 1024x1024, 배경 제거, 정면 보정, 1:1 정사각형.

### 8-3. 보정된 이미지를 G