Skip to main content
Glama
m2nho
by m2nho
README.md
# sourcing

구글 맵에서 병원·클리닉의 WhatsApp 연락처를 수집하는 CLI.

## 설치

### Windows

`mise`는 Windows 지원이 제한적이라 쓰지 않는다. `uv`가 Python 3.13을 알아서
받아온다 (`.python-version`을 읽는다).

PowerShell에서:

```powershell
# uv 설치 (이미 있으면 건너뛴다)
powershell -c "irm https://astral.sh/uv/install.ps1 | iex"

git clone https://github.com/m2nho/sourcing.git
cd sourcing

uv sync                              # Python 3.13까지 알아서 받아온다
uv run playwright install chromium   # 브라우저 (~200MB)

uv run pytest                        # 145개 통과하면 준비 완료
```

`uv`를 설치한 창에서는 PATH가 아직 갱신되지 않았을 수 있다. 터미널을 새로
열고 `uv --version`이 되는지 확인한다.

### macOS · Linux

```bash
mise install                        # Python 3.13 (mise.toml)
uv sync
uv run playwright install chromium
uv run pytest
```

`mise`가 없으면 생략해도 된다 — `uv sync`가 Python을 알아서 잡는다.

## 사용

### 도시 하나를 제대로 훑기 (권장)

구글 맵은 뷰포트가 넓으면 같은 곳만 반복해서 돌려준다. 넓은 도시는 지구
단위로 나눠 돌리고 합쳐야 한다. 실측: 런던을 반경 10km로 한 번 돌리면
120곳, 8개 지구로 나누면 667곳이 나왔다.

```
scripts/sweep scripts/districts-london.txt "aesthetic clinic" GB london
```

지구 목록은 `이름|위도,경도|반경km|셀km` 형식이다(`scripts/districts-london.txt`
참고). 순차로 돌고 마지막에 자동으로 합친다. 이미 결과가 있는 지구는
건너뛰므로 중간에 멈춰도 다시 실행하면 이어서 간다.

### 한 곳만 돌리기

```
uv run sourcing "klinik" --region ID --lang id --center=-6.2,106.8167
uv run sourcing "aesthetic clinic" --region GB --center=51.5205,-0.1490 --radius-km 2 --cell-km 1.5
```

음수 좌표는 `--center=`처럼 `=`로 붙인다. 안 그러면 argparse가 옵션으로 오해한다.

`--cell-km`이 "검색 한 번이 몇 km를 볼지"다. 기본 4km이고 격자는 여기서
자동 계산된다. 클리닉이 밀집한 중심가는 1.5km까지 좁힌다.

`--center` 없이 실행하면 구글이 실행 위치 IP를 기준으로 검색한다. 특정
지역을 노리려면 좌표를 주거나 키워드에 지역명을 넣는다.

### 여러 결과 합치기

```
uv run sourcing-merge out/london-*.raw.jsonl --out out/london-ALL.xlsx
```

지구 경계에 걸친 곳은 중복되므로 반드시 합쳐서 하나의 엑셀로 쓴다. 같은
곳이 여러 번 나오면 근거가 더 강한 쪽을 남긴다.

자세한 판단 기준은 `.claude/skills/lead-sourcing/SKILL.md`에 있다.

## WhatsApp 상태

구글 맵에는 WhatsApp 필드가 없다. 이 도구는 근거를 밝힌 세 단계로 표시한다.

| 상태 | 근거 |
|---|---|
| `confirmed` | 업체가 스스로 선언한 `wa.me` 링크에서 나온 번호 — 맵의 웹사이트 필드 또는 홈페이지에서 찾는다 |
| `verified` | 선언은 없지만 **WhatsApp 프로필 조회로 확인된** 번호. 프로필 이름이 상호와 맞는 것만 |
| `candidate` | 대표번호가 모바일 번호대 — 동남아에서는 대부분 WhatsApp이다. **추측이므로 `wa_link`를 채우지 않는다** |
| `unlikely` | 유선이거나 번호가 없다 |

## 웹사이트 훑기

기본으로 켜져 있다. 장소에 웹사이트가 있으면 그 페이지를 브라우저로 열어
`wa.me` / `api.whatsapp.com` 링크를 찾고, 찾으면 `confirmed`로 승격시킨다.

맵 리스팅만으로는 확정이 거의 안 나오기 때문이다 — 실측 709건 중 1건이었다.
업체가 WhatsApp을 대표 채널로 쓰더라도 맵의 웹사이트 칸에는 홈페이지 주소를
넣고, `wa.me` 링크는 그 홈페이지 안(푸터·플로팅 버튼)에 둔다.

번호가 여러 개 나오면 각각을 별개 레코드로 저장한다. 부서·지점별로 번호를
따로 두는 곳이 실제로 있다. 추가 번호는 `place_cid`에 `#1`, `#2`가 붙어
중복 제거와 재개가 그대로 작동한다.

정적 HTML이 아니라 브라우저로 렌더링해서 받는다. WhatsApp 버튼을 JS 위젯으로
삽입하는 사이트가 흔해서, 원본 HTML만 봐서는 링크가 보이지 않는다(실측: 한
클리닉에서 정적 3개 → 렌더링 후 4개).

본문에 그냥 적힌 번호는 쓰지 않는다. "WA:" 같은 라벨로 추정할 수는 있지만
그것은 추측이고, 이 단계의 목적은 추측이 아니라 선언을 읽는 것이다.

사이트당 한 번 요청하므로 수집 시간이 대략 두 배가 된다. `--no-crawl`로 끄면
맵 정보만 쓰고 훨씬 빠르지만 `confirmed`는 거의 나오지 않는다.

### 미국·캐나다에서는 candidate가 나오지 않는다

북미번호계획은 지역번호로 유선/모바일을 나누지 않아 모든 번호가 "구분 불가"로
분류된다. 그 유형을 후보로 올리면 전부 후보가 되어 아무것도 걸러주지 못하므로
(실측: 마이애미 클리닉 304건 중 267건), `+1` 번호는 `confirmed`만 리드가 된다.
즉 미국에서는 웹사이트 훑기가 사실상 유일한 경로다.

## Claude Desktop · Codex에서 쓰기

MCP 서버로 노출돼 있다. 두 클라이언트 모두 stdio MCP를 쓰므로 같은 서버를 쓴다.

Claude Code는 저장소의 `.mcp.json`을 그대로 읽으므로 별도 설정이 필요 없다.

Claude Desktop은 `claude_desktop_config.json`에 아래를 넣는다
(macOS `~/Library/Application Support/Claude/`, Windows `%APPDATA%\Claude\`):

```json
{
  "mcpServers": {
    "sourcing": {
      "command": "uv",
      "args": ["--directory", "<이 저장소를 클론한 절대경로>", "run", "sourcing-mcp"]
    }
  }
}
```

Codex는 저장소의 `.codex/config.toml`을 그대로 쓴다 — 클론한 디렉터리에서
codex를 실행하면 바로 잡힌다. 전역으로 등록하려면 둘 중 하나를 쓴다.

```bash
codex mcp add sourcing -- uv --directory <클론한 절대경로> run sourcing-mcp
```

또는 `~/.codex/config.toml`에 직접:

```toml
[mcp_servers.sourcing]
command = "uv"
args = ["--directory", "<클론한 절대경로>", "run", "sourcing-mcp"]
```

`--directory`로 프로젝트 경로를 명시해야 uv가 이 프로젝트의 가상환경을 찾는다.

Windows 경로는 JSON·TOML에서 역슬래시를 두 번 쓰거나 슬래시로 적는다:
`"C:\\Users\\me\\sourcing"` 또는 `"C:/Users/me/sourcing"`.

### 툴

| 툴 | 하는 일 |
|---|---|
| `start_collection` | 수집 시작. **즉시 반환한다** — 실제 수집은 20~40분 걸린다 |
| `check_collection` | 진행 상황과 현재까지의 집계 |
| `list_collections` | 이 세션의 작업 목록 |
| `cancel_collection` | 중단. 수집한 레코드는 남아 재개할 수 있다 |
| `get_leads` | 결과를 걸러서 읽는다. 레코드 전체가 아니라 필요한 만큼만 |
| `export_excel` | 엑셀을 다시 뽑는다. 취소된 작업·예전 데이터·수집 도중 중간 결과 |
| `check_site_whatsapp` | 사이트 한 곳만 확인. 몇 초면 끝난다 |

수집이 오래 걸리므로 작업 방식으로 만들었다. 툴이 40분을 붙들고 있으면
클라이언트가 타임아웃되고 그동안 대화도 막힌다. `start_collection`은 job_id만
주고 바로 돌아오며, 진행률은 JSONL 파일에서 읽는다 — 레코드마다 flush되므로
그 파일이 곧 실시간 진행률이다.

`get_leads`가 레코드 전체를 돌려주지 않는 것도 같은 이유다. 200건을 컨텍스트에
쏟으면 토큰만 태운다. 요약과 집계를 주고, 필요한 만큼만 잘라 읽게 한다.

동시에 하나의 수집만 돈다. 브라우저가 하나뿐이고 동시 접속은 차단 위험을 키운다.

## 출력 파일

수집이 끝나면 세 파일이 나온다.

| 파일 | 용도 |
|---|---|
| `*.xlsx` | **영업용 목록.** 병원명·위치·전화번호·WhatsApp 링크·상태·근거 여섯 컬럼. 연락 가능한 곳만, 확정을 위로 정렬 |
| `*.csv` | 전체 레코드 원본. 모든 컬럼 보존 |
| `*.raw.jsonl` | 재개용 원장. 레코드마다 즉시 flush된다 |

### 근거 컬럼

`확정` 안에도 신뢰도가 다른 것들이 섞인다. 어디부터 걸지 정할 수 있도록 출처를 남긴다.

| 근거 | 의미 |
|---|---|
| 홈페이지+맵 일치 | 홈페이지의 wa.me가 맵 대표번호와 같다 — 가장 강한 근거 |
| 홈페이지 링크 | 홈페이지에서 찾았고 맵에는 없던 번호. 실측상 확정의 3분의 2가 여기 |
| 구글맵 링크 | 맵 웹사이트 필드가 wa.me였다. 드물다 (709건 중 1건) |
| 맵 번호 추정 | 맵 대표번호가 모바일이라는 추정뿐. 검증되지 않았다 |

## WhatsApp 프로필 조회

기본으로 켜져 있다. 수집한 번호를 `wa.me`에서 열어 프로필 이름이 뜨는지 본다.
등록된 비즈니스 계정이면 상호가 보이고, 미등록이거나 개인 계정이면 번호만
보인다. 메시지는 보내지 않는다 — 공개 페이지를 여는 것뿐이다.

이름이 상호와 맞으면 추측(`candidate`)이나 버려진 것(`unlikely`)을
`verified`로 올린다. 이미 선언이 있는 `confirmed`는 그대로 두고 프로필
이름만 참고로 남긴다. 이름이 안 맞으면 등급을 올리지 않되 이름은 기록한다 —
남의 번호일 수 있다는 신호다.

실측(런던 아이스테틱 120곳):

| | 프로필 확인율 |
|---|---|
| `confirmed` 20건 표본 | 80% |
| `candidate` 15건 표본 | 53% |
| 버려졌던 `unlikely` 45건 | 9% (4건 회수) |

`candidate`의 절반은 실제로 등록돼 있지 않았다. 이 단계가 없으면 추측과
확인을 구분할 수 없다.

이름이 안 뜬다고 미등록인 것은 아니다. 비즈니스 프로필 없이 개인 계정으로
쓰면 번호만 보인다 — 이 수치는 늘 하한이다.

`--no-verify`로 끄면 건당 3초를 아낀다.

## 한계

- 구글 맵 검색 하나는 100~120건에서 잘린다. 지역 전수에 가깝게 가려면
  `--center`/`--radius-km`/`--grid`로 격자를 쪼개야 한다.
- 자동화된 스크래핑이라 구글이 확인 절차를 요구할 수 있다. 그때는 `--headful`로
  실행해 창에서 직접 통과하면 세션이 프로필에 남는다.
- 중단해도 `*.raw.jsonl`에 즉시 기록되므로 같은 명령을 다시 실행하면 이어서 받는다.
- `reviews`(리뷰 수) 컬럼은 현재 채워지지 않는다. 패널 HTML을 캡처하는 시점에
  평점 블록의 리뷰 수 컨테이너가 비어 있어 파서가 가져올 값이 없다. 알려진
  한계이며 후속 작업 대상이다 — CRM에 빈 값으로 들어가도 버그가 아니다.

## 개발

```
uv run pytest
```

`maps.py`(브라우저 계층)를 뺀 나머지는 전부 네트워크 없이 테스트된다.
구글이 DOM을 바꾸면 `parse.py`의 셀렉터 상수와 `tests/fixtures/`만 갱신하면 된다.

TDQS

A3.9/5.0

Scored across 7 tools

Disambiguation4/5

The async job lifecycle tools (start/check/list/cancel_collection) are clearly distinct, as are get_leads and export_excel. The only mild confusion is the shared 'check_' prefix on check_collection (job progress) and check_site_whatsapp (single website lookup), and list_collections overlapping somewhat with check_collection in purpose, but descriptions resolve these adequately.

Naming Consistency4/5

All tools follow a verb_noun snake_case pattern (start_collection, cancel_collection, get_leads, export_excel), which is predictable and readable. Minor deviations: list_collections uses plural while other collection tools use singular, and check_site_whatsapp is a compound noun rather than a simple object.

Tool Count5/5

Seven tools is well-scoped for the stated purpose of bulk Google Maps lead collection. Each tool earns its place: four for the async job lifecycle, one for reading results, one for single-site WhatsApp verification, and one for Excel export recovery.

Completeness5/5

The tool set fully covers the collection lifecycle: start, check progress, list jobs, cancel (with preserved partial results), read leads, verify a single site, and export to Excel even after cancellation. Cross-session access via csv_path is a thoughtful touch that prevents dead ends.

Maintenance

ActivityMaintained
ResponsivenessNo issues