Merge pull request #325 from NomaDamas/dev

Merge dev into main
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-06-21 17:39:44 +09:00 committed by GitHub
commit 08533bd9eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1145 additions and 1711 deletions

View file

@ -42,6 +42,7 @@
"./hwp",
"./intercity-bus-booking",
"./iros-registry-automation",
"./jobkorea-talent-search",
"./joseon-sillok-search",
"./k-dart",
"./k-schoollunch-menu",
@ -94,6 +95,7 @@
"./real-estate-search",
"./rhwp-advanced",
"./rhwp-edit",
"./saramin-talent-search",
"./seoul-bike",
"./seoul-density",
"./seoul-subway-arrival",

View file

@ -27,7 +27,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 고속버스 예매 | `express-bus-booking` | KOBUS 고속버스 배차·좌석·요금 조회와 결제 직전 handoff(결제는 수동) | 불필요 | [고속버스 예매 가이드](docs/features/express-bus-booking.md) |
| 시외버스 예매 | `intercity-bus-booking` | 티머니 시외버스 배차·좌석·요금 조회와 결제 직전 handoff(결제는 수동) | 불필요 | [시외버스 예매 가이드](docs/features/intercity-bus-booking.md) |
| 자연휴양림 빈 객실 조회 | `foresttrip-vacancy` | 공식 숲나들e 자연휴양림 예약 가능 객실 조회 자동화 (예약/결제 제외) | 필요 | [자연휴양림 빈 객실 조회 가이드](docs/features/foresttrip-vacancy.md) |
| 카카오톡 Mac CLI | `kakaotalk-mac` | macOS에서 카카오톡 대화 조회, 검색, 메시지 전송/삭제 | 불필요(로컬 앱/권한 필요) | [카카오톡 Mac CLI 가이드](docs/features/kakaotalk-mac.md) |
| 카카오톡 Mac 아카이브 검색 | `kakaotalk-mac` | `katok`으로 macOS 카카오톡 로컬 아카이브를 동기화하고 keyword/BM25/semantic 검색 | 불필요(로컬 앱/권한 필요) | [카카오톡 Mac 아카이브 검색](docs/features/kakaotalk-mac.md) |
| 서울 지하철 도착정보 조회 | `seoul-subway-arrival` | 서울 지하철 역 기준 실시간 도착 예정 열차 확인 | 불필요 | [서울 지하철 도착정보 가이드](docs/features/seoul-subway-arrival.md) |
| 서울 실시간 혼잡도 조회 | `seoul-density` | 서울 주요 121개 핫스팟의 실시간 혼잡도 단계와 추정 인구 조회 | 불필요 | [서울 실시간 혼잡도 가이드](docs/features/seoul-density.md) |
| 서울 따릉이 실시간 대여소 조회 | `seoul-bike` | 현재 좌표 주변 또는 대여소 이름 기준 따릉이 대여 가능 자전거와 빈 거치대 조회 | 불필요 | [서울 따릉이 실시간 대여소 가이드](docs/features/seoul-bike.md) |
@ -66,6 +66,8 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 식품 안전 체크 | `mfds-food-safety` | 식약처 부적합 식품·식품안전나라 회수 정보를 인터뷰-first 흐름으로 프록시 조회 | 불필요 | [식품 안전 체크 가이드](docs/features/mfds-food-safety.md) |
| 한국 주식 정보 조회 | `korean-stock-search` | KRX 상장 종목 검색, 기본정보, 일별 시세 조회 | 불필요 | [한국 주식 정보 조회 가이드](docs/features/korean-stock-search.md) |
| 금감원 DART 전자공시 조회 | `k-dart` | 공시검색, 기업개황, 재무제표, 배당, 증자/감자, 감사의견, 주요사항보고서 등 14개 endpoint | 필요 | [금감원 DART 전자공시 조회 가이드](docs/features/k-dart.md) |
| 잡코리아 인재검색 | `jobkorea-talent-search` | 잡코리아 기업회원 로그인 세션에서 마스킹 후보 정보를 읽고 유료 열람 전 shortlist 작성 | 필요 | [잡코리아 인재검색 가이드](docs/features/jobkorea-talent-search.md) |
| 사람인 인재풀 검색 | `saramin-talent-search` | 사람인 기업회원 인재풀에서 마스킹 후보 정보를 읽고 유료 열람 전 shortlist 작성 | 필요 | [사람인 인재풀 검색 가이드](docs/features/saramin-talent-search.md) |
| 대신증권 리포트 조회 | `daishin-report-search` | GitHub Pages에 공개된 대신증권 리포트 HTML 미러에서 최신 리포트 목록, 원문, 설명 페이지, Rating/Target 표를 조회 | 불필요 | [대신증권 리포트 조회 가이드](docs/features/daishin-report-search.md) |
| 국가데이터처 KOSIS 통계 조회 | `kosis-stats` | 국가데이터처가 운영하는 KOSIS(국가통계포털) Open API로 통계표 검색·메타·데이터·대용량 자료 조회 (조회 전용) | 일반 조회 불필요 (`bigdata`/`--direct` 필요) | [국가데이터처 KOSIS 통계 조회 가이드](docs/features/kosis-stats.md) |
| 조선왕조실록 검색 | `joseon-sillok-search` | 조선왕조실록 키워드 검색과 왕별/연도별 필터, 기사 발췌 조회 | 불필요 | [조선왕조실록 검색 가이드](docs/features/joseon-sillok-search.md) |
@ -157,7 +159,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [고속버스 예매](docs/features/express-bus-booking.md)
- [시외버스 예매](docs/features/intercity-bus-booking.md)
- [자연휴양림 빈 객실 조회](docs/features/foresttrip-vacancy.md)
- [카카오톡 Mac CLI](docs/features/kakaotalk-mac.md)
- [카카오톡 Mac 아카이브 검색](docs/features/kakaotalk-mac.md)
- [서울 지하철 도착정보 조회](docs/features/seoul-subway-arrival.md)
- [서울 실시간 혼잡도 조회](docs/features/seoul-density.md)
- [한국 대중교통 길찾기 가이드](docs/features/korean-transit-route.md)

View file

@ -0,0 +1,67 @@
# 잡코리아 인재검색 가이드
## 이 기능으로 할 수 있는 일
- 잡코리아 기업회원 인재검색 화면에서 구인/채용 조건을 입력해 후보를 찾는다.
- 사용자가 직접 로그인한 브라우저 세션에서 현재 보이는 마스킹된 목록/이력서 정보를 읽는다.
- 유료 이력서 열람 전에 후보 적합도를 비교하고 shortlist를 만든다.
- 영업, 마케팅, 디자인, PM/PO, HR, 재무, 운영, 개발/데이터 등 전 직무에 사용할 수 있다.
## 먼저 알아둘 점
- 잡코리아 구인자/채용 담당자가 접근 가능한 기업회원 계정과 사용자 직접 로그인이 필요하다.
- 에이전트는 비밀번호, OTP, 세션 쿠키를 요청하거나 저장하지 않는다.
- 유료 열람, 마스킹 해제, 연락처 확인, 포지션 제안, 스크랩, 메모, 후보 상태 변경은 자동으로 하지 않는다.
- 비로그인 공개 목록 fallback은 가능하지만 정확도가 낮으므로 `목록 기반 1차 shortlist`로 표시한다.
## 공식 표면
- 잡코리아 기업 인재검색: https://www.jobkorea.co.kr/corp/person/find
## 입력값
- 채용 직무명
- 경력 범위
- 지역
- 필수 경험/스킬/업종
- 우대 경험/성과/툴
- 제외할 업무/업종/경력 패턴
- 유료 열람 추천 인원 수
## 기본 흐름
1. 잡코리아 기업 인재검색 페이지를 연다.
2. 로그인 상태를 확인한다. 로그인되지 않았으면 사용자가 열린 브라우저에서 직접 로그인한다.
3. 직무/키워드/경력/지역/제외 조건을 입력한다.
4. 결과 목록에서 후보 pool을 만든다.
5. 유료 열람이나 연락처 확인이 아닌 일반 상세/마스킹 이력서만 연다.
6. 현재 보이는 정보만 근거로 점수화한다.
7. URL과 검토 수준을 포함해 유료 열람 추천 후보를 정리한다.
## 결과 형식
```text
잡코리아 인재 shortlist
검색 조건
- 포지션: ...
- 필수 조건: ...
- 우대 조건: ...
- 제외 조건: ...
- 경력/지역: ...
- 모드: 로그인 마스킹 이력서 / 비로그인 목록 fallback
유료 열람 추천 Top N
1. 후보 A
- 점수: ...
- 근거: ...
- 리스크: ...
- 추천 액션: 채용 담당자가 유료 열람 검토
- URL: ...
```
## 제한사항
- 사이트 UI 변경 시 브라우저 추출 selector를 조정해야 할 수 있다.
- 계정 권한, 유료 상품 상태, 마스킹 정책에 따라 보이는 정보가 다르다.
- 후보 개인정보를 장기 저장하거나 대량 수집하지 않는다.

View file

@ -1,106 +1,113 @@
# 카카오톡 Mac CLI 가이드
# 카카오톡 Mac 아카이브 검색 가이드
## 이 기능으로 할 수 있는 일
- macOS에서 카카오톡 최근 대화 목록 확인
- 특정 채팅방 최근 메시지 읽기
- 키워드로 전체 대화 검색
- 나와의 채팅으로 안전하게 테스트 전송
- 사용자 확인 후 특정 채팅방으로 메시지 전송
- 로컬 메시지 ID로 후보를 고른 뒤 현재 보이는 정확히 하나의 UI bubble 삭제 자동화
- Apple Silicon macOS에서 `katok`으로 카카오톡 로컬 대화 아카이브 생성
- keyword, BM25, semantic 검색
- 검색 결과의 chunk id로 원문, 주변 맥락, parent window 조회
- 검색 전 freshness 확인과 sync/index 필요 여부 판단
이 가이드는 기존 `kakaotalk-mac` 스킬 경로를 유지하지만 실행 표면은 `katok` CLI다. 메시지 전송, 삭제, UI 자동화, 직접 DB 읽기, 인증 캐시 처리, 복호화 material 처리는 포함하지 않는다.
## 먼저 필요한 것
- macOS
- Apple Silicon macOS
- KakaoTalk for Mac 설치
- Homebrew
- `brew install silver-flight-group/tap/kakaocli`
- `python3` 3.10+
- 이 저장소의 helper `scripts/kakaotalk_mac.py`
- 터미널 앱에 **Full Disk Access****Accessibility** 권한 부여
- Homebrew 또는 Cargo
- `katok` CLI
- 현재 터미널 앱의 Full Disk Access 권한
카카오톡 앱이 없으면 `mas` 로 먼저 설치할 수 있다.
## 설치
Homebrew:
```bash
brew install mas
mas account
mas install 869223134
brew tap NomaDamas/katok https://github.com/NomaDamas/katok.git
brew install katok
```
## 입력값
Cargo:
- 채팅방 이름
- 검색 키워드
- 최근 범위(`--since 1h`, `--since 7d` 등)
- 전송 메시지 본문
- 삭제할 메시지 ID 또는 최근 보낸 메시지 삭제 여부
- 테스트 여부(`--me`, `--dry-run`)
```bash
cargo install katok
export PATH="$HOME/.cargo/bin:$PATH"
```
Cargo 설치 후 `katok`이 보이지 않으면 `$HOME/.cargo/bin`을 shell PATH에 추가한다.
## 개인 정보와 안전 규칙
- Do not inspect local database internals from this skill.
- Do not directly read KakaoTalk DB files.
- Do not handle auth caches or decryption material.
- live macOS 카카오톡 ingestion은 `katok sync --source macos --json`으로만 수행한다.
- 검색 결과는 snippet과 chunk id 중심으로 먼저 다룬다.
- 사용자가 특정 결과를 열어 달라고 하거나 chunk id를 제공했을 때만 chunk 원문을 조회한다.
## 기본 흐름
1. KakaoTalk for Mac 과 `kakaocli` 가 설치되어 있는지 확인한다.
2. `kakaocli status`, `kakaocli auth` 로 권한과 DB 접근이 되는지 먼저 확인한다.
3. `user_id` 자동 감지가 실패하면 helper `python3 scripts/kakaotalk_mac.py auth --refresh` 로 복구한다.
4. 읽기/검색은 JSON 모드로 실행한 뒤 사람이 읽기 쉽게 요약한다.
5. 전송은 먼저 `--me` 또는 `--dry-run` 으로 테스트한다.
6. 삭제는 `messages --json` 으로 대상 ID를 확인하고 `delete` / `delete-last --dry-run` 을 먼저 실행한다.
7. 다른 사람에게 보내는 메시지는 항상 최종 확인 후에만 전송한다.
1. `katok doctor --json`으로 freshness와 준비 상태를 확인한다.
2. Full Disk Access 설정이 필요하면 `katok permissions macos`로 시스템 설정 화면을 연다.
3. 앱 설치, container, DB 파일 접근 진단이 필요할 때만 `katok doctor --macos-probe --json`을 실행한다.
4. 최신성이 중요하거나 sync 권장이 있으면 `katok sync --source macos --json`을 실행한다.
5. semantic search 전에 index 권장이 있으면 `katok index --json`을 실행한다.
6. 질의 성격에 따라 `katok search keyword`, `katok search bm25`, `katok search semantic`을 선택한다.
7. 사용자가 지정한 결과만 `katok chunk get`, `katok chunk context`, `katok chunk parent`로 연다.
## 예시
```bash
kakaocli status
kakaocli auth
python3 scripts/kakaotalk_mac.py auth --refresh
python3 scripts/kakaotalk_mac.py chats --limit 10 --json
python3 scripts/kakaotalk_mac.py messages --chat "지수" --since 1d --json
python3 scripts/kakaotalk_mac.py search "회의" --json
kakaocli chats --limit 10 --json
kakaocli messages --chat "지수" --since 1d --json
kakaocli search "회의" --json
kakaocli send --me _ "테스트 메시지"
kakaocli send --dry-run "팀 공지방" "오늘 3시에 만나요"
python3 scripts/kakaotalk_mac.py delete "팀 공지방" 123456 --dry-run
python3 scripts/kakaotalk_mac.py delete "팀 공지방" 123456 --everyone
python3 scripts/kakaotalk_mac.py delete-last "팀 공지방" --dry-run
katok doctor --json
katok permissions macos
katok doctor --macos-probe --json
katok sync --source macos --json
katok index --json
katok search keyword "계약서" --json
katok search bm25 "지난주 미팅 자료" --json
katok search semantic "최근에 논의한 세금 신고 일정" --json
katok chunk get <chunk-id> --json
katok chunk context <chunk-id> --json
katok chunk parent <chunk-id> --json
```
## helper 가 해결하는 문제
## 검색 방식 선택
`kakaocli auth` 실패가 항상 “DB 파일이 없음”을 의미하지는 않는다. 실제 Mac 환경에서는:
`katok search keyword`는 정확한 문자열, 이름, 계좌번호, 고유명사처럼 그대로 기억나는 값을 찾을 때 쓴다.
- container 안에 `KakaoTalk.db` 라는 이름 대신 **78자 hex 파일**이 DB 로 존재할 수 있다.
- `kakaocli status` 는 정상이어도 `auth``user_id 자동 감지 실패` 로 끝날 수 있다.
- 이 경우 plist 의 `AlertKakaoIDsList` 후보만으로는 부족하고, `DESIGNATEDFRIENDSREVISION:<SHA-512(user_id)>` 에서 실제 `user_id` 를 더 오래 찾아야 할 수 있다.
`katok search bm25`는 여러 단어가 섞인 일반 질의에 쓴다.
helper `scripts/kakaotalk_mac.py` 는 그 얇은 read-only 어댑터 역할을 한다.
`katok search semantic`은 표현이 정확히 기억나지 않지만 의미가 비슷한 대화를 찾을 때 쓴다. `katok doctor --json`에서 semantic index 갱신이 필요하다고 나오면 먼저 `katok index --json`을 실행한다.
- plist 에서 후보 `user_id` 와 active hash 를 읽는다.
- hash recovery 가 필요하면 더 긴 검색으로 실제 `user_id` 를 찾는다.
- 검증된 DB 경로와 SQLCipher key 를 `~/.cache/k-skill/kakaotalk-mac-auth.json` 에 캐시한다.
- 이후 read-only helper 명령 `chats`, `messages`, `search`, `schema` 를 cached `--db` / `--key` 와 함께 다시 실행한다.
## chunk 조회
## 메시지 삭제
`delete` / `delete-last` 는 카카오톡 Mac UI의 삭제 메뉴를 Accessibility 로 자동화한다. 메시지 조회와 outbound 여부 검증은 로컬 DB에서 하고, 실제 삭제는 현재 보이는 UI bubble 에서 수행한다.
검색 결과에서 더 넓은 맥락이 필요할 때만 chunk 명령을 사용한다.
```bash
python3 scripts/kakaotalk_mac.py messages --chat "팀 공지방" --limit 20 --json
python3 scripts/kakaotalk_mac.py delete "팀 공지방" 123456 --dry-run
python3 scripts/kakaotalk_mac.py delete "팀 공지방" 123456 --everyone
python3 scripts/kakaotalk_mac.py delete-last "팀 공지방" --everyone
katok chunk get <chunk-id> --json
katok chunk context <chunk-id> --json
katok chunk parent <chunk-id> --json
```
- `--everyone` 은 내가 보낸 메시지에만 허용된다.
- UI 삭제 단계는 활성 채팅방을 확인하고, 선택된 outbound DB 메시지의 정규화된 텍스트가 대화 transcript 영역에서 정확히 하나의 visible targetable message bubble 과 일치할 때만 진행한다. 로컬 DB message id가 UI bubble identity를 직접 증명하는 것은 아니므로, 메시지 텍스트가 비어 있거나 첨부/비텍스트이거나 보이지 않거나 정규화 후 같은 텍스트가 여러 개이거나 최종 확인 버튼을 클릭할 수 없으면 실패한다.
- `chats`, `messages`, `search`, `schema` 는 read-only 이지만 `delete` / `delete-last` 는 side effect 이다.
- `chunk get`: 해당 chunk 원문 조회
- `chunk context`: 같은 채팅방의 바로 앞뒤 micro chunk 조회
- `chunk parent`: semantic search가 사용한 더 큰 parent window 조회
## Synthetic QA
실제 카카오톡 설치 없이 upstream fixture로 테스트할 때만 아래 경로를 쓴다.
```bash
katok sync --source fixture tests/fixtures/kakao/replies.jsonl --json
KATOK_EMBEDDER=local-test katok index --json
KATOK_EMBEDDER=mock katok index --json
```
실사용 경로에서는 fixture, mock embedder, 원격 embedding endpoint를 사용하지 않는다.
## 주의할 점
- **Full Disk Access** 가 없으면 읽기 명령도 실패할 수 있다.
- **Accessibility** 가 없으면 전송, 삭제, harvest 계열 자동화가 실패한다.
- macOS 전용이므로 Windows/Linux 대체 구현으로 넘어가지 않는다.
- 다른 사람에게 보내는 메시지는 자동 전송하지 말고 확인을 먼저 받는다.
- 삭제 자동화는 되돌리기 어렵기 때문에 `--dry-run` 으로 대상과 범위를 확인한다.
- helper cache 는 로컬 auth material 을 담으므로 본인 장비에서만 보관한다.
- 기본 `auth` 텍스트 출력은 key 를 다시 보여주지 않는다. 자동화가 필요할 때만 `--format json` 또는 `--format shell` 을 사용한다.
- Apple Silicon macOS 전용이다.
- Intel macOS는 packaged local EmbeddingGemma 경로의 지원 대상이 아니다.
- Full Disk Access는 사용자가 System Settings에서 직접 허용해야 한다.
- `katok doctor --macos-probe --json`은 macOS app-data 접근 prompt를 띄울 수 있으므로 setup 진단이 필요할 때만 실행한다.
- 이 스킬은 read/search/retrieve 전용이며 메시지 전송과 삭제를 지원하지 않는다.

View file

@ -0,0 +1,67 @@
# 사람인 인재풀 검색 가이드
## 이 기능으로 할 수 있는 일
- 사람인 기업회원 인재풀에서 구인/채용 조건에 맞는 후보를 검색한다.
- 사용자가 직접 로그인/2차 인증을 완료한 브라우저 세션에서 현재 보이는 마스킹 후보 정보를 읽는다.
- 유료 열람/연락처 확인/제안 발송 전에 후보 적합도를 비교하고 shortlist를 만든다.
- 영업, 마케팅, 디자인, PM/PO, HR, 재무, 운영, 제조, 법무/총무, 개발/데이터 등 전 직무에 사용할 수 있다.
## 먼저 알아둘 점
- 사람인 구인자/채용 담당자가 접근 가능한 기업회원 로그인과 첫 기기 2차 인증이 필요할 수 있다.
- 에이전트는 비밀번호, OTP, 인증번호, 세션 쿠키를 요청하거나 저장하지 않는다.
- 유료 이력서 열람, 연락처 확인, 포지션 제안, 스크랩/관심후보/메모/상태 변경, 결제는 자동으로 하지 않는다.
- 일반 후보 상세/프로필 링크를 열어 현재 보이는 마스킹 정보만 읽는다.
## 공식 표면
- 사람인 인재풀 검색: https://www.saramin.co.kr/zf_user/memcom/talent-pool/main/search
## 입력값
- 채용 직무명
- 경력 범위
- 지역
- 필수 경험/스킬/업종
- 우대 경험/성과/툴
- 제외할 업무/업종/경력 패턴
- 유료 열람 추천 인원 수
## 기본 흐름
1. 사람인 인재풀 검색 페이지를 연다.
2. 로그인/2차 인증이 필요하면 사용자가 열린 브라우저에서 직접 완료한다.
3. 검색어, 직무/직종, 경력, 지역, 최근 업데이트/정렬, 제외 조건을 적용한다.
4. 결과 목록에서 후보 pool을 만든다.
5. 최종 추천 전에는 가능한 후보 상세/프로필 페이지를 열어 현재 보이는 마스킹 정보를 확인한다.
6. 유료 열람/연락처/제안/스크랩/메모/상태 변경 버튼은 누르지 않는다.
7. URL, 검토 수준, 점수, 근거, 리스크를 포함해 shortlist를 정리한다.
## 결과 형식
```text
사람인 인재풀 shortlist
검색 조건
- 포지션: ...
- 필수 조건: ...
- 우대 조건: ...
- 제외 조건: ...
- 경력/지역: ...
- 모드: 로그인/인증 완료 브라우저 세션의 마스킹 후보 정보
유료 열람 추천 Top N
1. 후보 A
- 점수: ...
- 근거: ...
- 검토 수준: 상세 이력 확인 기반 / 목록 기반 1차
- 추천 액션: 채용 담당자가 유료 열람 검토
- URL: ...
```
## 제한사항
- 사람인 UI, 계정 권한, 유료 상품 상태에 따라 보이는 정보가 다르다.
- 상세 접근이 전부 유료 벽이면 `목록 기반 1차 shortlist`로 낮은 신뢰도를 표시한다.
- 후보 개인정보를 장기 저장하거나 대량 수집하지 않는다.

View file

@ -319,14 +319,22 @@ HWP Node API 예시는 전역 `NODE_PATH` 대신 로컬 프로젝트에 `npm ins
### macOS 바이너리
카카오톡 Mac CLI는 npm 패키지가 아니라 Homebrew tap 설치를 사용한다.
카카오톡 Mac 아카이브 검색은 npm 패키지가 아니라 `katok` CLI 설치를 사용한다.
```bash
brew install silver-flight-group/tap/kakaocli
brew tap NomaDamas/katok https://github.com/NomaDamas/katok.git
brew install katok
brew tap JungHoonGhae/tossinvest-cli
brew install tossctl
```
Cargo로 설치할 수도 있다.
```bash
cargo install katok
export PATH="$HOME/.cargo/bin:$PATH"
```
`toss-securities` 스킬은 공식 토스증권 Open API를 우선 사용한다. 공식 경로를 쓰려면 발급받은 자격증명을 사용자 환경변수로 둔다(공유 프록시로 보내지 않고 토스 서버로 직접 호출한다). `tossctl` 설치는 공식 credentials가 없을 때의 fallback 경로용이다.
```bash

View file

@ -94,8 +94,8 @@
- LH 임대공고문 상세(공급정보) endpoint: http://apis.data.go.kr/B552555/lhLeaseNoticeDtlInfo1/getLeaseNoticeDtlInfo1
- LH 청약 샘플 reference 구현(heereal/Bunyang_MoeumZip): https://github.com/heereal/Bunyang_MoeumZip
- 법제처 국가법령정보 공동활용 Open API (k-skill-proxy `/v1/korean-law/*` upstream): https://open.law.go.kr — DRF `lawSearch.do` (검색) / `lawService.do` (본문)
- `silver-flight-group/kakaocli`: https://github.com/silver-flight-group/kakaocli
- KakaoTalk Mac 설치 참고(`mas`): https://velog.io/@bonjugi/%EB%A7%A5%EB%B6%81-M1%EC%97%90-homebrew%EB%A1%9C-node-vscode-%EC%B9%B4%EC%B9%B4%EC%98%A4%ED%86%A1-%EC%84%A4%EC%B9%98%ED%95%98%EA%B8%B0
- `NomaDamas/katok`: https://github.com/NomaDamas/katok
- `katok` macOS first-run docs: https://github.com/NomaDamas/katok/blob/main/docs/macos-first-run.md
- 동행복권 로또 결과 페이지: https://www.dhlottery.co.kr/lt645/result
- 동행복권 지난 회차 JSON 표면: https://www.dhlottery.co.kr/lt645/selectPstLt645InfoNew.do
- 바른한글 메인: https://nara-speller.co.kr/speller/
@ -232,3 +232,5 @@
- 국세청 고액·상습체납자 명단공개(무인증): https://www.nts.go.kr/nts/ad/openInfo/selectList.do
- 지방행정 인허가데이터 LOCALDATA 파일 다운로드(무인증, CP949 CSV): https://file.localdata.go.kr/file/download/<업종slug>/info?orgCode=<지자체코드>
- LOCALDATA 본체: https://www.localdata.go.kr
- 잡코리아 기업 인재검색: https://www.jobkorea.co.kr/corp/person/find — 기업회원 로그인 세션에서 마스킹 이력서/목록을 읽는 브라우저 기반 경로. 유료 열람/마스킹 해제/포지션 제안은 수동 확인 대상.
- 사람인 기업회원 인재풀 검색: https://www.saramin.co.kr/zf_user/memcom/talent-pool/main/search — 기업회원 로그인 및 첫 기기 2차 인증 후 현재 보이는 마스킹 후보 정보를 읽는 브라우저 기반 경로. 유료 열람/연락처 확인/제안 발송은 수동 확인 대상.

View file

@ -0,0 +1,130 @@
---
name: jobkorea-talent-search
description: 잡코리아 기업회원 로그인 세션으로 유료 열람 전 마스킹된 인재 이력서를 검색·비교해 채용 검토용 shortlist를 만듭니다.
license: MIT
metadata:
category: recruiting
locale: ko-KR
phase: v1
---
# jobkorea-talent-search
잡코리아 기업 인재검색에서 유료 열람/포지션 제안 전에 현재 보이는 마스킹 이력서와 목록 정보를 비교해 “열람할 만한 후보”를 추천한다. 개발자 전용이 아니며 모든 직무에 role-adaptive하게 적용한다.
## Use when
- 사용자가 잡코리아에서 후보를 찾아달라고 요청한다.
- 기업회원 로그인 세션에서 마스킹 이력서/목록을 비교해야 한다.
- 유료 열람 전 shortlist, 점수, 근거, 리스크, 후보 URL이 필요하다.
## Hard boundaries
Allowed:
- 잡코리아 기업회원 브라우저 세션 열기 및 검색 필터 입력
- 현재 보이는 마스킹 목록/이력서/프로필 읽기
- 후보 분석, 점수화, shortlist 작성, 유료 열람 추천
Never do without explicit user handoff/confirmation:
- 유료 이력서 열람, 마스킹 해제, 연락처 확인
- 포지션 제안 발송, 스크랩, 메모 저장, 후보 상태 변경
- 결제/유료 크레딧 사용
- 비밀번호, OTP, 인증번호, 세션 쿠키 요청/저장
- 후보 개인정보 장기 저장 또는 대량 수집
If a control may spend credits, reveal contact/private info, send a proposal, or mutate account state, stop before clicking it.
## Primary access
Open:
```text
https://www.jobkorea.co.kr/corp/person/find
```
If not logged in, pause and show:
```text
잡코리아 인재검색은 경력 상세/포트폴리오/마스킹 이력서 확인을 위해 기업회원 로그인이 필요합니다.
제가 브라우저로 잡코리아 기업 인재검색 페이지를 열어둘게요.
열린 브라우저에서 직접 로그인해 주세요. 비밀번호나 인증정보는 저에게 알려주지 마세요.
로그인이 끝나면 “로그인했어”라고 알려주시면, 같은 브라우저 세션에서 검색을 이어가겠습니다.
```
Resume only in the same browser session after the user confirms login.
## Input normalization
Extract or infer:
- role_title
- must_have / nice_to_have
- negative_keywords
- career min/max
- location/work_area
- role-specific evaluation signals
- limit / requested Top N
Do not block on missing details when a reasonable first search is possible.
## Workflow
1. Open the primary URL and verify corporate login.
2. Ask the user to log in manually only when required; never handle credentials.
3. Apply filters: keyword, 직무/스킬, 지역, 경력, recent activity/update, exclusions when supported.
4. Build a candidate pool from visible rows.
5. Before final Top N, open normal resume/detail links for promising candidates when this does not trigger paid unlock/contact/proposal actions.
6. Read only visible free/masked details: career, responsibilities, project/achievement evidence, skills, education/certs/languages, desired location/salary, portfolio links if visible, recent activity.
7. Score role-adaptively: must-have fit, career depth, concrete achievement/project evidence, location/activity fit, nice-to-have signals, and risk penalty.
8. Return Korean shortlist with direct URL per recommended candidate.
If detail pages are inaccessible or paid-walled, label results as `목록 기반 1차 shortlist` and lower confidence. If detail text was inspected, label as `상세 이력 확인 기반 shortlist`.
## No-login fallback
Use only when the user cannot or will not log in. It is low-confidence because it cannot inspect resume details.
```bash
python3 jobkorea-talent-search/scripts/jobkorea_talent_search.py --keyword "퍼포먼스 마케터 GA4" --work-area "서울" --career-min 3 --career-max 7 --limit 20
```
## URL extraction guidance
Every recommended candidate needs a direct JobKorea resume/profile URL whenever available. If browser extraction fails, inspect anchors, onclick handlers, data attributes, card containers, and detail-page `location.href`. If still missing, write `URL: 추출 실패` and explain why.
## Output shape
```text
잡코리아 인재 shortlist
검색 조건
- 포지션: ...
- 필수/우대/제외 조건: ...
- 경력/지역: ...
- 모드: 상세 이력 확인 기반 shortlist / 목록 기반 1차 shortlist
유료 열람 추천 Top N
1. 후보 A
- 점수: 88/100
- 근거: ...
- 보이는 경력/성과: ...
- 리스크: ...
- 추천 액션: 채용 담당자가 유료 열람 검토
- URL: ...
보류 후보
- ...
검색 한계
- 마스킹/현재 표시 정보만 분석했음
- 연락처/실명/비공개 정보는 열람하지 않음
- 유료 액션은 실행하지 않음
```
## Failure modes
- Login/2FA required: open the page and let the user complete it manually.
- Browser/session unavailable: explain that the agent needs browser/computer-use access; do not silently switch to low-confidence fallback.
- Paid wall/contact wall: stop and mark as manual paid review needed.
- Empty results: adjust keywords, career, region, update/relevance filters.
- UI changed: rediscover the visible form/data flow before updating scripts.

View file

@ -0,0 +1,27 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Final
BASE_URL: Final = "https://www.jobkorea.co.kr"
FIND_PATH: Final = "/corp/person/find"
AJAX_PATH: Final = "/corp/person/detailsearchajax"
DEFAULT_UA: Final = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36"
)
@dataclass(frozen=True, slots=True)
class Candidate:
rno: str
url: str
name: str = ""
meta: str = ""
career: str = ""
education: str = ""
locations: str = ""
salary: str = ""
skills: str = ""
badges: str = ""
raw_summary: str = ""

View file

@ -0,0 +1,186 @@
from __future__ import annotations
import html
import re
import urllib.parse
from jobkorea_talent_models import BASE_URL, Candidate
ACTION_CONTROL_RE = re.compile(
r"^(?:스크랩\s*\d*|저장하기|닫기|포지션\s*제안|메모하기|프로필\s*확인|이력서\s*확인|펼쳐보기|접기|이전|다음)$"
)
ACTION_CONTROL_INLINE_RE = re.compile(
r"(?:스크랩\s*\d+|저장하기|닫기|포지션\s*제안|메모하기|프로필\s*확인|이력서\s*확인|펼쳐보기|접기|이전|다음)"
)
RESUME_LINK_RE = re.compile(r'href="(?P<href>/corp/person/find/resume/view\?rNo=(?P<rno>\d+))"')
def clean_text(value: str) -> str:
value = html.unescape(value)
value = re.sub(r"<script[\s\S]*?</script>", " ", value, flags=re.I)
value = re.sub(r"<style[\s\S]*?</style>", " ", value, flags=re.I)
value = re.sub(r"<[^>]+>", " ", value)
value = re.sub(r"[ \t\r\f\v]+", " ", value)
value = re.sub(r"\n\s*\n+", "\n", value)
return value.strip()
def is_action_control_label(value: str) -> bool:
label = re.sub(r"\s+", " ", html.unescape(value)).strip()
return bool(label and ACTION_CONTROL_RE.match(label))
def filter_action_control_text(value: str) -> str:
lines = []
for line in value.splitlines():
label = line.strip()
if not label or is_action_control_label(label):
continue
label = ACTION_CONTROL_INLINE_RE.sub(" ", label)
label = re.sub(r"\s+", " ", label).strip()
if label:
lines.append(label)
return "\n".join(lines).strip()
def row_contains_other_resume(candidate_markup: str, rno: str) -> bool:
refs: list[str] = []
for href_rno, data_rno in re.findall(r"rNo=(\d+)|data-rno=[\"'](\d+)[\"']", candidate_markup):
refs.append(href_rno or data_rno)
return any(ref != rno for ref in refs)
def extract_regex_candidate_markup(markup: str, match: re.Match[str], rno: str) -> str:
row_start = markup.rfind("<tr", 0, match.start())
if row_start >= 0:
row_open_end = markup.find(">", row_start, match.start())
row_end = markup.find("</tr>", match.end())
row_open = markup[row_start : row_open_end + 1] if row_open_end >= 0 else ""
if row_end >= 0 and f'data-rno="{rno}"' in row_open:
return markup[row_start : row_end + len("</tr>")]
booth_start = markup.rfind('<div class="booth"', 0, match.start())
if booth_start >= 0:
next_booth = markup.find('<div class="booth"', match.end())
section_end = markup.find("</section>", match.end())
end_candidates = [pos for pos in (next_booth, section_end) if pos >= 0]
booth_end = min(end_candidates) if end_candidates else min(len(markup), match.end() + 2500)
booth = markup[booth_start:booth_end]
if not row_contains_other_resume(booth, rno):
return booth
start = max(0, match.start() - 300)
end = min(len(markup), match.end() + 1200)
return markup[start:end]
def parse_with_bs4(markup: str, limit: int) -> list[Candidate] | None:
try:
from bs4 import BeautifulSoup
except ImportError:
return None
soup = BeautifulSoup(markup, "html.parser")
candidates: list[Candidate] = []
seen: set[str] = set()
for link in soup.select('a[href*="/corp/person/find/resume/view?rNo="]'):
raw_href = link.get("href", "")
href = raw_href if isinstance(raw_href, str) else ""
matched_rno = re.search(r"rNo=(\d+)", href)
if not matched_rno:
continue
rno = matched_rno.group(1)
if rno in seen:
continue
seen.add(rno)
container = (
link.find_parent("tr", attrs={"data-rno": rno})
or link.find_parent(class_=re.compile(r"(^|\s)booth(\s|$)", re.I))
or link.parent
)
if container and row_contains_other_resume(str(container), rno):
container = link.parent
raw = clean_text(str(container)) if container else clean_text(str(link))
texts = []
for node in container.find_all(["dt", "dd", "p", "span", "li"]) if container else []:
label = node.get_text(" ", strip=True)
if label and not is_action_control_label(label):
texts.append(label)
for btn in container.select(".keywordSkill button, .keywordBox button") if container else []:
label = btn.get_text(" ", strip=True)
if label and not is_action_control_label(label):
texts.append(label)
text_join = " | ".join(dict.fromkeys(texts))
name_scope = container.select_one(".nameAge") if container else None
dt = (name_scope or container).find("dt") if container else None
name = dt.get_text(" ", strip=True) if dt else ""
dd = dt.find_next("dd") if dt else None
meta = dd.get_text(" ", strip=True) if dd else ""
if not name:
m_name = re.search(r"([가-힣A-Za-z]OO)\s*\(([^)]*)\)", raw)
if m_name:
name = m_name.group(1)
meta = "(" + m_name.group(2) + ")"
skills = []
for btn in container.select(".keywordSkill button, .keywordBox button") if container else []:
label = btn.get_text(" ", strip=True)
if label and not is_action_control_label(label):
skills.append(label)
career_node = container.select_one(".career") if container else None
candidates.append(
Candidate(
rno=rno,
url=urllib.parse.urljoin(BASE_URL, href),
name=name,
meta=meta,
career=career_node.get_text(" ", strip=True) if career_node else "",
skills=", ".join(skills[:25]),
raw_summary=filter_action_control_text(text_join[:1000] or raw[:1000]),
)
)
if len(candidates) >= limit:
break
return candidates
def parse_with_regex(markup: str, limit: int) -> list[Candidate]:
candidates: list[Candidate] = []
seen: set[str] = set()
for match in RESUME_LINK_RE.finditer(markup):
rno = match.group("rno")
if rno in seen:
continue
seen.add(rno)
raw_markup = extract_regex_candidate_markup(markup, match, rno)
raw = clean_text(raw_markup)
name = ""
meta = ""
name_match = re.search(r"([가-힣A-Za-z]OO)\s*\(([^)]*)\)", raw)
if name_match:
name = name_match.group(1)
meta = "(" + name_match.group(2) + ")"
candidates.append(
Candidate(
rno=rno,
url=urllib.parse.urljoin(BASE_URL, match.group("href")),
name=name,
meta=meta,
raw_summary=filter_action_control_text(raw[:1000]),
)
)
if len(candidates) >= limit:
break
return candidates
def parse_candidates(markup: str, limit: int) -> list[Candidate]:
parsed = parse_with_bs4(markup, limit)
if parsed is not None:
return parsed
return parse_with_regex(markup, limit)

View file

@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""Search public JobKorea talent summaries.
This helper uses JobKorea's browser-visible corporate talent search page and its
same AJAX endpoint. It only reads public/obfuscated list summaries. Full resume
view, contact details, scraping at scale, scrap/bookmark, and position proposal
flows are intentionally out of scope because they require an employer account,
paid entitlements, or user confirmation.
"""
from __future__ import annotations
import argparse
import json
import sys
import urllib.error
from dataclasses import asdict
from jobkorea_talent_models import Candidate
from jobkorea_talent_parse import clean_text, parse_candidates
from jobkorea_talent_search_condition import build_search_condition, post_search
__all__ = ["parse_candidates"]
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Search public JobKorea talent summaries")
parser.add_argument("--keyword", "-k", action="append", default=[], help="통합검색 키워드. 여러 번 지정 가능")
parser.add_argument("--and-keyword", action="append", default=[], help="AND 키워드")
parser.add_argument("--or-keyword", action="append", default=[], help="OR 키워드")
parser.add_argument("--exclude-keyword", action="append", default=[], help="제외 키워드")
parser.add_argument("--job-category", action="append", default=[], help="직무 대분류명 예: AI·개발·데이터")
parser.add_argument("--work-area", action="append", default=[], help="희망 근무지역 예: 서울, 강남구, 경기")
parser.add_argument("--residential-area", action="append", default=[], help="거주지역 예: 서울, 성남시 분당구")
parser.add_argument("--career-min", type=int, help="최소 경력 연수")
parser.add_argument("--career-max", type=int, help="최대 경력 연수")
parser.add_argument("--page", type=int, default=1)
parser.add_argument("--limit", type=int, default=20, choices=[10, 20, 30, 50, 100])
parser.add_argument("--sort", default="0", help="잡코리아 sf 정렬 코드. 기본 0")
parser.add_argument("--json", action="store_true", help="JSON으로 출력")
return parser
def print_markdown(candidates: list[Candidate], matched: dict[str, list[str]], args: argparse.Namespace) -> None:
print("# 잡코리아 인재검색 결과\n")
print(f"- 검색어: {', '.join(args.keyword + args.and_keyword + args.or_keyword) or '(없음)'}")
print(f"- 제외어: {', '.join(args.exclude_keyword) or '(없음)'}")
if any(matched.values()):
print(f"- 매칭된 필터: {json.dumps(matched, ensure_ascii=False)}")
print(f"- 결과 수: {len(candidates)}")
print("- 주의: 이름/회사명은 잡코리아 공개 화면 기준으로 마스킹되어 있으며, 상세 이력서 확인·포지션 제안은 기업회원 로그인/권한/사용자 확인이 필요합니다.\n")
for idx, candidate in enumerate(candidates, 1):
c = candidate
bits = [c.name, c.meta, c.career]
title = " ".join(x for x in bits if x).strip() or f"rNo={c.rno}"
print(f"## {idx}. {title}")
print(f"- URL: {c.url}")
if c.skills:
print(f"- 키워드/스킬: {c.skills}")
summary = c.raw_summary.replace("\n", " ")
if summary:
print(f"- 요약: {summary[:500]}")
print()
def run(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
if not (args.keyword or args.and_keyword or args.or_keyword or args.job_category or args.work_area or args.residential_area):
parser.error("최소 하나 이상의 --keyword, --job-category, --work-area 등을 지정하세요")
sc, matched = build_search_condition(args)
markup = post_search(sc)
cleaned = clean_text(markup)
if "로그인" in cleaned[:500] and "인재" not in cleaned[:2000]:
raise RuntimeError("잡코리아가 로그인/차단 화면을 반환했습니다")
candidates = parse_candidates(markup, args.limit)
if args.json:
print(json.dumps({"matched_filters": matched, "candidates": [asdict(c) for c in candidates]}, ensure_ascii=False, indent=2))
else:
print_markdown(candidates, matched, args)
return 0
if __name__ == "__main__":
try:
raise SystemExit(run())
except urllib.error.HTTPError as exc:
print(f"HTTP error: {exc.code} {exc.reason}", file=sys.stderr)
raise SystemExit(2)
except (RuntimeError, urllib.error.URLError) as exc:
print(f"error: {exc}", file=sys.stderr)
raise SystemExit(1)

View file

@ -0,0 +1,136 @@
from __future__ import annotations
import argparse
import json
import urllib.parse
import urllib.request
from collections.abc import Iterator
from typing import Any
from jobkorea_talent_models import AJAX_PATH, BASE_URL, DEFAULT_UA, FIND_PATH
def fetch(url: str, *, data: bytes | None = None, headers: dict[str, str] | None = None) -> str:
req_headers = {"User-Agent": DEFAULT_UA, "Referer": BASE_URL + FIND_PATH}
if headers:
req_headers.update(headers)
req = urllib.request.Request(url, data=data, headers=req_headers, method="POST" if data else "GET")
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.read().decode("utf-8", "ignore")
def extract_json_object(source: str, marker: str) -> dict[str, Any]:
idx = source.find(marker)
if idx < 0:
raise RuntimeError(f"cannot find marker: {marker}")
start = source.find("{", idx)
if start < 0:
raise RuntimeError("cannot find JSON object start")
depth = 0
in_string = False
escape = False
for pos in range(start, len(source)):
ch = source[pos]
if in_string:
if escape:
escape = False
elif ch == "\\":
escape = True
elif ch == '"':
in_string = False
continue
if ch == '"':
in_string = True
elif ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
loaded = json.loads(source[start : pos + 1])
if not isinstance(loaded, dict):
raise RuntimeError("search condition was not a JSON object")
return loaded
raise RuntimeError("unterminated JSON object")
def iter_nodes(node: Any) -> Iterator[dict[str, Any]]:
if isinstance(node, dict):
yield node
for value in node.values():
yield from iter_nodes(value)
elif isinstance(node, list):
for item in node:
yield from iter_nodes(item)
def mark_matching_nodes(sc: dict[str, Any], top_key: str, labels: list[str]) -> list[str]:
if not labels:
return []
section = sc.get(top_key)
if section is None:
return []
wanted = [x.strip().lower() for x in labels if x.strip()]
matched: list[str] = []
for node in iter_nodes(section):
title = str(node.get("t", ""))
code = str(node.get("v", ""))
title_l = title.lower()
code_l = code.lower()
if any(w == title_l or w == code_l or w in title_l for w in wanted):
for key in ("s", "c", "use"):
if key in node:
node[key] = 1
matched.append(title or code)
return matched
def build_search_condition(args: argparse.Namespace) -> tuple[dict[str, Any], dict[str, list[str]]]:
first = fetch(BASE_URL + FIND_PATH)
sc = extract_json_object(first, "var searchcondition =")
sc["p"] = args.page
sc["ps"] = args.limit
sc["saveno"] = 0
sc["ff"] = 0
sc["sf"] = args.sort
terms: list[dict[str, Any]] = []
for kw in args.keyword:
terms.append({"s": 1, "c": 1, "t": kw, "v": kw, "kwdtypecode": 1, "logictypecode": 0})
for kw in args.and_keyword:
terms.append({"s": 1, "c": 1, "t": kw, "v": kw, "kwdtypecode": 1, "logictypecode": 1})
for kw in args.or_keyword:
terms.append({"s": 1, "c": 1, "t": kw, "v": kw, "kwdtypecode": 1, "logictypecode": 3})
for kw in args.exclude_keyword:
terms.append({"s": 1, "c": 1, "t": kw, "v": kw, "kwdtypecode": 1, "logictypecode": 2})
sc["totalkeywordlist"] = terms
if terms:
first_kw = terms[0]["t"]
sc.setdefault("pfr", {}).setdefault("ck", {})["Keyword"] = first_kw
sc["pfr"]["ck"]["KeywordType"] = 1
sc["pfr"]["n"] = 1
if args.career_min is not None:
sc.setdefault("career", {})["s"] = str(args.career_min)
if args.career_max is not None:
sc.setdefault("career", {})["e"] = str(args.career_max)
matched = {
"job_category": mark_matching_nodes(sc, "jobtype", args.job_category),
"work_area": mark_matching_nodes(sc, "workarea", args.work_area),
"residential_area": mark_matching_nodes(sc, "residentialarea", args.residential_area),
}
return sc, matched
def post_search(sc: dict[str, Any]) -> str:
body = urllib.parse.urlencode({"searchCondition": json.dumps(sc, ensure_ascii=False)}).encode()
return fetch(
BASE_URL + AJAX_PATH,
data=body,
headers={
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"X-Requested-With": "XMLHttpRequest",
},
)

View file

@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Fixture tests for JobKorea public fallback parsing."""
from __future__ import annotations
import importlib.util
import sys
import unittest
from pathlib import Path
SCRIPT = Path(__file__).with_name("jobkorea_talent_search.py")
sys.path.insert(0, str(SCRIPT.parent))
spec = importlib.util.spec_from_file_location("jobkorea_talent_search", SCRIPT)
assert spec is not None
helper = importlib.util.module_from_spec(spec)
sys.modules["jobkorea_talent_search"] = helper
assert spec.loader is not None
spec.loader.exec_module(helper)
FALLBACK_FIXTURE = """
<section class="searchList">
<table class="tblSearchList">
<tbody>
<tr class="dvResumeTr" data-rno="111">
<td class="tdProfile">
<dl class="nameAge"><dt><a class="dvResumeLink" href="/corp/person/find/resume/view?rNo=111" data-rno="111">김OO</a></dt><dd>(, 29)</dd></dl>
<ul class="bullList"><li>25분전 공고 스크랩</li></ul>
</td>
<td class="tdSummary">
<div class="userInfoBox">
<span class="career">경력 4</span>
<p class="title"><a class="dvResumeLink" href="/corp/person/find/resume/view?rNo=111" data-rno="111">퍼포먼스 마케터</a></p>
<div class="keywordSkill keywordBox">
<button type="button" class="js-kwrdSearch">Google Analytics</button>
<button type="button" class="js-kwrdSearch">GA4</button>
</div>
</div>
</td>
<td class="tdAction">
<button>스크랩 1</button><button>이력서 확인</button><button>포지션 제안</button><button>메모하기</button><button>저장하기</button><button>닫기</button>
</td>
</tr>
<tr class="dvResumeTr" data-rno="222">
<td class="tdProfile">
<dl class="nameAge"><dt><a class="dvResumeLink" href="/corp/person/find/resume/view?rNo=222" data-rno="222">박OO</a></dt><dd>(, 31)</dd></dl>
</td>
<td class="tdSummary">
<span class="career">경력 6</span>
<p class="title"><a class="dvResumeLink" href="/corp/person/find/resume/view?rNo=222" data-rno="222">브랜드 마케터</a></p>
<div class="keywordSkill keywordBox"><button type="button" class="js-kwrdSearch">브랜딩</button></div>
</td>
</tr>
</tbody>
</table>
</section>
"""
class JobKoreaFallbackParserTest(unittest.TestCase):
def test_parser_keeps_each_candidate_inside_its_own_row(self) -> None:
candidates = helper.parse_candidates(FALLBACK_FIXTURE, 10)
self.assertEqual([c.rno for c in candidates], ["111", "222"])
self.assertEqual(candidates[0].name, "김OO")
self.assertIn("Google Analytics", candidates[0].raw_summary)
self.assertIn("GA4", candidates[0].raw_summary)
self.assertNotIn("박OO", candidates[0].raw_summary)
self.assertNotIn("브랜딩", candidates[0].raw_summary)
self.assertNotIn("저장하기", candidates[0].raw_summary)
self.assertNotIn("닫기", candidates[0].raw_summary)
self.assertNotIn("포지션 제안", candidates[0].raw_summary)
self.assertNotIn("이력서 확인", candidates[0].raw_summary)
if __name__ == "__main__":
unittest.main()

View file

@ -1,223 +1,199 @@
---
name: kakaotalk-mac
description: Use kakaocli on macOS to read KakaoTalk chats, search messages, send replies after explicit confirmation, and delete sent messages with explicit operator intent.
description: Search local KakaoTalk archives on Apple Silicon macOS through the katok CLI.
license: MIT
metadata:
category: messaging
locale: ko-KR
phase: v1.5
phase: v2
---
# KakaoTalk Mac CLI
# KakaoTalk katok Search
## What this skill does
`kakaocli` 와 이 저장소 helper를 사용해 macOS에서 카카오톡 대화 목록을 확인하고, 메시지를 검색하고, 필요할 때 답장하거나 보낸 메시지를 삭제한다.
`katok` CLI를 유일한 실행 표면으로 사용해 macOS 카카오톡 대화를 로컬 아카이브와 검색 인덱스로 동기화하고, keyword/BM25/semantic 검색과 chunk 조회를 수행한다.
이 스킬은 **macOS + 카카오톡 Mac 앱 설치**를 전제로 한다. 공식 Kakao API를 쓰는 것이 아니라 로컬 데이터베이스 읽기와 macOS 접근성 자동화 위에서 동작하므로, 권한과 안전 규칙을 먼저 확인해야 한다.
이 스킬은 기존 `kakaotalk-mac` 설치 경로를 유지하지만 내부 동작은 `katok` 기반이다. 메시지 전송, 삭제, UI 자동화, 직접 DB 읽기, 인증 캐시 처리, 복호화 material 처리는 이 스킬의 범위가 아니다.
## Privacy Rules
- Do not inspect local database internals from this skill.
- Do not directly read KakaoTalk DB files.
- Do not handle auth caches or decryption material.
- Use `katok sync --source macos --json` for live macOS KakaoTalk ingestion.
- Search commands should return snippets and chunk ids first.
- Retrieve full chunk content only when the user explicitly asks to open a result or provides a chunk id.
## When to use
- "카카오톡 최근 대화 목록 보여줘"
- "특정 채팅방 최근 메시지 찾아줘"
- "카카오톡 메시지 검색해줘"
- "내 카톡으로 테스트 메시지 보내줘"
- "답장 초안은 만들되 실제 전송 전에는 꼭 확인받아"
- "카카오톡에서 특정 키워드 검색해줘"
- "카톡에서 지난 회의/계약/약속 이야기 찾아줘"
- "이 검색 결과 chunk를 열어줘"
- "최근 대화가 반영됐는지 확인하고 검색해줘"
## When not to use
- macOS가 아닌 환경
- 카카오톡 Mac 앱이 설치되어 있지 않은 환경
- 사용자 확인 없이 다른 사람에게 메시지를 바로 보내야 하는 작업
- 카카오 공식 API 범위 안에서 해결 가능한 서버-투-서버 연동 작업
- Intel Mac에서 로컬 EmbeddingGemma semantic index가 필요한 경우
- 카카오톡 메시지를 보내거나 삭제해야 하는 경우
- 카카오톡 DB 파일, 인증 캐시, 복호화 material을 직접 다루라는 요청
- 서버-투-서버 공식 Kakao API 연동 요청
## Prerequisites
- macOS
- Apple Silicon macOS
- KakaoTalk for Mac 설치
- Homebrew
- Mac App Store 로그인(`mas` 사용 시)
- `kakaocli` 설치
- `python3` 3.10+
- 이 저장소의 helper `scripts/kakaotalk_mac.py`
- 터미널 앱에 **Full Disk Access****Accessibility** 권한 부여
- Homebrew 또는 Cargo
- `katok` CLI 설치
- 현재 터미널 앱에 Full Disk Access 권한
## Inputs
## Install katok
- 채팅방 이름 또는 검색 키워드
- 읽기 범위: 최근 N개, `--since 1h`, `--since 7d`
- 전송할 메시지 본문
- 삭제할 로컬 메시지 ID 또는 최근 보낸 메시지 삭제 여부
- 테스트 여부 (`--me`, `--dry-run`)
Homebrew:
```bash
brew tap NomaDamas/katok https://github.com/NomaDamas/katok.git
brew install katok
```
Cargo:
```bash
cargo install katok
export PATH="$HOME/.cargo/bin:$PATH"
```
설치 후 CLI가 보이는지 확인한다.
```bash
katok --help
katok doctor --json
```
## Workflow
### 0. Install KakaoTalk for Mac first when missing
카카오톡 Mac 앱이 없으면 먼저 설치한다. `mas` 를 쓰려면 App Store 로그인 상태여야 한다.
### 1. Check readiness without prompting for app data
```bash
brew install mas
mas account
mas install 869223134
katok doctor --json
```
`mas install` 이 막히면 App Store 앱에서 먼저 로그인한 뒤 다시 시도한다.
`doctor --json`의 `freshness` 섹션에서 마지막 sync/index 상태를 확인한다. 이 기본 doctor는 macOS app-data probe를 실행하지 않으므로 권한 prompt를 띄우지 않는 준비 상태 점검에 적합하다.
### 1. Install `kakaocli`
### 2. Open macOS permission settings when needed
공식 저장소 기준 권장 설치는 Homebrew tap 이다.
Full Disk Access 설정이 필요하면 사용자가 직접 허용할 수 있도록 설정 화면을 연다.
```bash
brew install silver-flight-group/tap/kakaocli
katok permissions macos
```
설치 후 바로 상태를 확인한다.
KakaoTalk UI 자동화는 이 스킬 범위가 아니지만, upstream 진단을 위해 Accessibility 설정 화면까지 열어야 하는 경우에만 다음 명령을 쓴다.
```bash
kakaocli status
katok permissions macos --accessibility
```
### 2. Grant the required macOS permissions
### 3. Run explicit macOS setup diagnostics only when needed
**System Settings > Privacy & Security** 에서 현재 사용하는 터미널 앱(iTerm, Terminal, Warp 등)에 아래 권한을 준다.
- **Full Disk Access**: 카카오톡 로컬 데이터베이스 읽기용
- **Accessibility**: 메시지 전송, harvest, inspect 같은 UI 자동화용
기본 규칙:
- `status` / `auth` / `chats` 같은 읽기 명령도 Full Disk Access 가 필요하다.
- `send`, `delete`, `delete-last`, `harvest`, `inspect` 류 작업은 Accessibility 권한까지 필요하다.
### 3. Verify read access before attempting side effects
먼저 읽기 경로가 되는지 확인한다.
카카오톡 앱 설치, container, DB 파일 접근 같은 macOS source adapter 상태를 확인해야 할 때만 probe를 실행한다. 이 명령은 macOS가 app-data 접근 prompt를 띄울 수 있다.
```bash
kakaocli status
kakaocli auth
kakaocli chats --limit 10 --json
katok doctor --macos-probe --json
```
`auth` 가 성공하면 읽기 경로는 준비된 것이다.
### 4. Sync local KakaoTalk archives
### 3.5. Use the helper when `kakaocli auth` fails on `user_id` auto-detection
실제 macOS 환경에서는 `KakaoTalk.db` 라는 literal 파일이 없어도, container 안의 **78자 hex 파일**이 실제 SQLCipher DB 인 경우가 있다. 이때 `kakaocli status` 는 정상인데 `kakaocli auth` 만 실패하는 대표 원인은:
- `AlertKakaoIDsList` 후보로는 복호화가 안 됨
- plist 의 `DESIGNATEDFRIENDSREVISION:<sha512(user_id)>` 만 남아 있음
- upstream `kakaocli` 의 기본 `user_id` brute-force 시간이 짧아서 auto-detection 이 실패함
이 저장소는 그 구간만 보완하는 **read-only helper** 를 함께 제공한다.
최신 대화가 중요하거나 `freshness.recommendation.sync_before_search`가 true이면 검색 전에 sync한다.
```bash
python3 scripts/kakaotalk_mac.py auth --refresh
python3 scripts/kakaotalk_mac.py chats --limit 10 --json
python3 scripts/kakaotalk_mac.py messages --chat "지수" --since 1d --json
python3 scripts/kakaotalk_mac.py search "회의" --json
katok sync --source macos --json
```
- helper 는 plist 의 `AlertKakaoIDsList``DESIGNATEDFRIENDSREVISION` hash 를 읽는다.
- 후보 `user_id` 가 모두 실패하면 SHA-512 preimage search 로 실제 `user_id` 를 더 오래 찾는다.
- 성공한 `user_id`, DB 경로, derived key 는 `~/.cache/k-skill/kakaotalk-mac-auth.json` 에 캐시한다.
- 이후 read-only helper 명령(`chats`, `messages`, `search`, `schema`)은 cached `--db` / `--key` 를 붙여 `kakaocli` 를 다시 호출한다.
### 4. Read or search messages
설정 파일의 기본 source adapter를 쓰는 경우:
```bash
kakaocli messages --chat "지수" --since 1h --json
kakaocli search "점심" --json
katok sync --json
```
helper 경유 예시:
### 5. Build or refresh the semantic index
semantic search 전 `freshness.recommendation.index_before_semantic_search`가 true이거나 방금 sync한 내용을 semantic 검색에 반영해야 하면 index를 만든다.
```bash
python3 scripts/kakaotalk_mac.py messages --chat "지수" --since 1h --json
python3 scripts/kakaotalk_mac.py search "점심" --json
katok index --json
```
응답은 가능하면 JSON 모드로 받고, 사람이 읽기 쉽게 다시 요약한다.
`katok index`는 기본적으로 로컬 `embeddinggemma-300m-q4` embedder를 사용한다. Python, Jina, TEI, 별도 HTTP embedding server를 요구하지 않는다.
### 5. Use safe testing before real sends
### 6. Search with the narrowest useful mode
실제 전송 전에 먼저 자기 자신에게 테스트하거나 dry-run 으로 확인한다.
정확한 문자열, 이름, 계좌번호, 고유명사는 keyword search를 먼저 쓴다.
```bash
kakaocli send --me _ "테스트 메시지"
kakaocli send --dry-run "채팅방 이름" "보낼 문장"
katok search keyword "검색어" --json
```
`--me` 는 나와의 채팅으로 보내므로 가장 안전한 테스트 경로다.
### 6. Confirm before sending to other people
다른 사람이나 단체방으로 보내기 전에는 반드시 사용자의 최종 확인을 받는다.
확인 전에는 아래만 준비한다.
- 대상 채팅방 이름
- 전송할 문장
- 왜 이 문장을 보내는지 한 줄 설명
확인을 받았을 때만 전송한다.
여러 단어가 섞인 일반 질의는 BM25를 쓴다.
```bash
kakaocli send "채팅방 이름" "보낼 문장"
katok search bm25 "지난주 미팅 자료" --json
```
### 7. Delete a sent message only with explicit operator intent
삭제는 카카오톡 Mac UI(우클릭 → 삭제)를 접근성으로 자동화한다. 먼저 `messages --json` 으로 로컬 메시지 ID와 보낸 메시지 여부를 확인하고, 항상 `--dry-run` 으로 대상 채팅방/메시지를 확인한 뒤 실행한다. `--everyone` 은 내가 보낸 메시지에만 허용된다.
표현이 정확히 기억나지 않는 의미 기반 질의는 semantic search를 쓴다.
```bash
python3 scripts/kakaotalk_mac.py messages --chat "채팅방 이름" --limit 20 --json
python3 scripts/kakaotalk_mac.py delete "채팅방 이름" 123456 --dry-run
python3 scripts/kakaotalk_mac.py delete "채팅방 이름" 123456 --everyone
python3 scripts/kakaotalk_mac.py delete-last "채팅방 이름" --dry-run
python3 scripts/kakaotalk_mac.py delete-last "채팅방 이름" --everyone
katok search semantic "최근에 논의한 세금 신고 일정" --json
```
주의:
### 7. Retrieve explicit chunks only when needed
- helper의 `chats`, `messages`, `search`, `schema` 는 read-only 경로다. `delete` / `delete-last` 는 UI side effect 이므로 Accessibility 권한과 명시적 실행 의도가 필요하다.
- 메시지 ID는 로컬 DB의 `messages --json` 출력 기준이며 UI에서 동일한 DB row를 직접 증명할 수 있다는 뜻은 아니다. 실행 계약은 선택된 outbound DB 메시지의 정규화된 텍스트가 현재 활성 채팅방 transcript 영역에서 정확히 하나의 visible targetable message bubble 과 일치할 때만 삭제하는 것이다.
- 대상 메시지 텍스트가 비어 있거나 첨부/비텍스트 메시지이거나, 정규화 후 같은 텍스트가 여러 개 있거나, 대상 bubble 이 보이지 않거나, 활성 채팅방/삭제 범위/최종 확인 버튼을 확인할 수 없으면 삭제 자동화는 실패한다.
### 8. Use login storage only when the user explicitly wants auto-login
자동 로그인 편의를 원할 때만 자격증명을 저장한다.
검색 결과는 먼저 snippet과 chunk id 중심으로 요약한다. 사용자가 특정 결과를 열어 달라고 하거나 chunk id를 제공했을 때만 원문 chunk를 조회한다.
```bash
kakaocli login
kakaocli login --status
katok chunk get <chunk-id> --json
katok chunk context <chunk-id> --json
katok chunk parent <chunk-id> --json
```
비밀번호를 채팅창에 보내라고 요구하지 않는다. 사용자가 직접 로컬 터미널에서 입력하게 한다.
- `katok chunk get <chunk-id> --json`: 해당 chunk 원문 조회
- `katok chunk context <chunk-id> --json`: 같은 채팅방의 직전/직후 micro chunk 조회
- `katok chunk parent <chunk-id> --json`: semantic search parent window 조회
## Synthetic QA only
실제 카카오톡 설치 없이 upstream fixture로 테스트할 때만 fixture source와 deterministic embedder를 사용한다.
```bash
katok sync --source fixture tests/fixtures/kakao/replies.jsonl --json
KATOK_EMBEDDER=local-test katok index --json
KATOK_EMBEDDER=mock katok index --json
```
실사용 경로에서는 fixture, mock embedder, 원격 embedding endpoint를 사용하지 않는다.
## Done when
- 읽기 요청이면 상태 확인 + 대화/메시지 조회 결과가 정리되어 있다
- 검색 요청이면 키워드 기준 결과가 정리되어 있다
- 전송 요청이면 테스트(`--me` 또는 `--dry-run`)와 사용자 확인이 끝난 뒤 실제 전송 여부가 명확하다
- 삭제 요청이면 `messages --json` 으로 대상 ID를 확인하고 `delete` / `delete-last --dry-run` 검증 뒤 실행 결과가 명확하다
- readiness 요청이면 `katok doctor --json` 결과와 freshness 권장사항을 요약했다.
- 최신 검색 요청이면 필요한 경우 `katok sync --source macos --json``katok index --json` 실행 여부를 명확히 했다.
- 검색 요청이면 keyword/BM25/semantic 중 선택한 이유와 JSON 검색 결과 요약을 제공했다.
- chunk 조회 요청이면 사용자가 지정한 chunk id에 대해서만 `katok chunk get/context/parent` 결과를 요약했다.
## Failure modes
- `katok` 미설치 또는 Cargo binary PATH 누락
- Apple Silicon macOS가 아님
- KakaoTalk for Mac 미설치
- App Store 로그인 누락으로 `mas install` 실패
- Full Disk Access 미부여
- Accessibility 미부여
- `status` 는 정상인데 `auth` 만 실패하는 `user_id` auto-detection / key mismatch 케이스
- 채팅방 이름 substring 이 애매해서 잘못된 후보가 여러 개 잡힘
- `katok doctor --macos-probe --json`에서 container 또는 DB 파일 접근 실패
- sync 전이라 local archive가 비어 있음
- semantic index가 오래되었거나 아직 생성되지 않음
- 검색 결과가 snippet/chunk id만으로 충분하지 않아 명시적 chunk 조회가 필요함
## Notes
- 이 스킬은 macOS 전용이다.
- 다른 사람에게 보내는 메시지는 항상 confirm before sending 원칙을 지킨다.
- 삭제는 되돌리기 어렵고 UI 상태에 민감하므로 먼저 `--dry-run` 으로 대상과 범위를 확인한다.
- 첫 검증은 `kakaocli status``kakaocli auth` 부터 시작하는 편이 안전하다.
- `kakaocli auth``User ID: auto-detection failed` 로 멈추면 helper 경로를 우선 사용한다.
- helper cache 는 로컬 SQLCipher key 를 포함하므로 본인 계정에서만 유지하고 공유하지 않는다.
- 기본 `auth` 텍스트 출력은 key 를 다시 보여주지 않는다. 자동화가 필요할 때만 `--format json` 또는 `--format shell` 을 사용한다.
- 이 스킬은 read/search/retrieve 전용이다.
- 메시지 전송과 삭제는 지원하지 않는다.
- DB 내부 구조, auth cache, decryption material은 직접 다루지 않는다.
- 기존 설치 이름은 `kakaotalk-mac`이지만 실행 표면은 `katok`이다.

File diff suppressed because it is too large Load diff

View file

@ -11,10 +11,10 @@
"build": "npm run build --workspaces --if-present",
"build:manus-bundle": "node scripts/build-manus-bundle.js",
"generate:plugin-manifest": "node scripts/generate-plugin-manifest.js",
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js scripts/korean_middle_korean.js scripts/test_korean_middle_korean.js scripts/build-manus-bundle.js scripts/test_build_manus_bundle.js scripts/workflow-actions.test.js scripts/generate-plugin-manifest.js scripts/test_generate_plugin_manifest.js && python3 -m py_compile scripts/k_skill_cleaner.py scripts/test_k_skill_cleaner.py corporate-registration-consulting/scripts/fill_official_hwp.py k-skill-cleaner/scripts/k_skill_cleaner.py scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.py scripts/srt_booking.py scripts/srt_seats.py scripts/srt_booking_test_support.py scripts/test_srt_booking.py scripts/test_srt_seats.py scripts/sillok_search.py scripts/test_sillok_search.py scripts/korean_spell_check.py scripts/test_korean_spell_check.py scripts/patent_search.py scripts/test_patent_search.py scripts/mfds_drug_safety.py scripts/test_mfds_drug_safety.py scripts/nts_business_registration.py scripts/test_nts_business_registration.py scripts/mfds_food_safety.py scripts/test_mfds_food_safety.py scripts/zipcode_search.py scripts/test_zipcode_search.py scripts/subway_lost_property.py scripts/test_subway_lost_property.py scripts/geeknews_search.py scripts/test_geeknews_search.py nts-business-registration/scripts/nts_business_registration.py biz-health-check/scripts/biz_health_check.py nts-tax-delinquency/scripts/nts_tax_delinquency.py localdata-business-status/scripts/localdata_business_status.py g2b-sanctioned-supplier/scripts/g2b_sanctioned_supplier.py fsc-corporate-info/scripts/fsc_corporate_info.py national-pension-workplace/scripts/national_pension_workplace.py scripts/test_naver_blog_search.py scripts/test_korean_slang_writing.py scripts/kakaotalk_mac.py scripts/test_kakaotalk_mac.py scripts/test_coupang_partners_mcp_wrapper.py scripts/test_ohou_today_deal.py scripts/ticket_availability.py scripts/test_ticket_availability.py scripts/test_danawa_price_search.py ticket-availability/scripts/ticket_availability.py coupang-product-search/scripts/coupang_partners_mcp.py ohou-today-deal/scripts/ohou_today_deal.py kakaotalk-mac/scripts/kakaotalk_mac.py naver-blog-research/scripts/_naver_http.py naver-blog-research/scripts/naver_search.py naver-blog-research/scripts/naver_read.py naver-blog-research/scripts/naver_download_images.py korean-slang-writing/scripts/_slang_http.py korean-slang-writing/scripts/slang_search.py korean-slang-writing/scripts/slang_lookup.py korean-scholarship-search/scripts/scholarship_filter.py korean-scholarship-search/scripts/test_scholarship_filter.py korean-scholarship-search/scripts/university_search_plan.py seoul-bike/scripts/seoul_bike.py scripts/test_seoul_bike.py danawa-price-search/scripts/danawa_search.py kosis-stats/scripts/run_kosis_stats.py kosis-stats/tests/test_run_kosis_stats.py kstartup-search/scripts/run_kstartup.py kstartup-search/tests/test_run_kstartup.py intercity-bus-booking/scripts/intercity_bus_search.py daangn-used-goods-search/scripts/daangn_used_goods.py daangn-realty-search/scripts/daangn_realty.py daangn-jobs-search/scripts/daangn_jobs.py daangn-cars-search/scripts/daangn_cars.py foresttrip-vacancy/scripts/run_foresttrip_vacancy.py foresttrip-vacancy/tests/test_run_foresttrip_vacancy.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh && node scripts/generate-plugin-manifest.js --check",
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js scripts/korean_middle_korean.js scripts/test_korean_middle_korean.js scripts/build-manus-bundle.js scripts/test_build_manus_bundle.js scripts/workflow-actions.test.js scripts/generate-plugin-manifest.js scripts/test_generate_plugin_manifest.js && python3 -m py_compile scripts/k_skill_cleaner.py scripts/test_k_skill_cleaner.py corporate-registration-consulting/scripts/fill_official_hwp.py k-skill-cleaner/scripts/k_skill_cleaner.py scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.py scripts/srt_booking.py scripts/srt_seats.py scripts/srt_booking_test_support.py scripts/test_srt_booking.py scripts/test_srt_seats.py scripts/sillok_search.py scripts/test_sillok_search.py scripts/korean_spell_check.py scripts/test_korean_spell_check.py scripts/patent_search.py scripts/test_patent_search.py scripts/mfds_drug_safety.py scripts/test_mfds_drug_safety.py scripts/nts_business_registration.py scripts/test_nts_business_registration.py scripts/mfds_food_safety.py scripts/test_mfds_food_safety.py scripts/zipcode_search.py scripts/test_zipcode_search.py scripts/subway_lost_property.py scripts/test_subway_lost_property.py scripts/geeknews_search.py scripts/test_geeknews_search.py nts-business-registration/scripts/nts_business_registration.py biz-health-check/scripts/biz_health_check.py nts-tax-delinquency/scripts/nts_tax_delinquency.py localdata-business-status/scripts/localdata_business_status.py g2b-sanctioned-supplier/scripts/g2b_sanctioned_supplier.py fsc-corporate-info/scripts/fsc_corporate_info.py national-pension-workplace/scripts/national_pension_workplace.py scripts/test_naver_blog_search.py scripts/test_korean_slang_writing.py scripts/test_coupang_partners_mcp_wrapper.py scripts/test_ohou_today_deal.py scripts/ticket_availability.py scripts/test_ticket_availability.py scripts/test_danawa_price_search.py ticket-availability/scripts/ticket_availability.py coupang-product-search/scripts/coupang_partners_mcp.py ohou-today-deal/scripts/ohou_today_deal.py naver-blog-research/scripts/_naver_http.py naver-blog-research/scripts/naver_search.py naver-blog-research/scripts/naver_read.py naver-blog-research/scripts/naver_download_images.py korean-slang-writing/scripts/_slang_http.py korean-slang-writing/scripts/slang_search.py korean-slang-writing/scripts/slang_lookup.py korean-scholarship-search/scripts/scholarship_filter.py korean-scholarship-search/scripts/test_scholarship_filter.py korean-scholarship-search/scripts/university_search_plan.py seoul-bike/scripts/seoul_bike.py scripts/test_seoul_bike.py danawa-price-search/scripts/danawa_search.py kosis-stats/scripts/run_kosis_stats.py kosis-stats/tests/test_run_kosis_stats.py kstartup-search/scripts/run_kstartup.py kstartup-search/tests/test_run_kstartup.py intercity-bus-booking/scripts/intercity_bus_search.py daangn-used-goods-search/scripts/daangn_used_goods.py daangn-realty-search/scripts/daangn_realty.py daangn-jobs-search/scripts/daangn_jobs.py daangn-cars-search/scripts/daangn_cars.py foresttrip-vacancy/scripts/run_foresttrip_vacancy.py foresttrip-vacancy/tests/test_run_foresttrip_vacancy.py jobkorea-talent-search/scripts/jobkorea_talent_models.py jobkorea-talent-search/scripts/jobkorea_talent_parse.py jobkorea-talent-search/scripts/jobkorea_talent_search_condition.py jobkorea-talent-search/scripts/jobkorea_talent_search.py jobkorea-talent-search/scripts/test_jobkorea_talent_search.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh && node scripts/generate-plugin-manifest.js --check",
"typecheck": "tsc --noEmit",
"prepare:python-test-env": "python3 -m venv .cache/python-test-venv && ./.cache/python-test-venv/bin/python -m pip install --quiet beautifulsoup4",
"test": "npm run prepare:python-test-env && node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js scripts/test_korean_middle_korean.js scripts/test_build_manus_bundle.js scripts/workflow-actions.test.js scripts/test_generate_plugin_manifest.js && PYTHONPATH=.:scripts ./.cache/python-test-venv/bin/python -m unittest scripts.test_k_skill_cleaner scripts.test_fine_dust scripts.test_ktx_booking scripts.test_srt_booking scripts.test_srt_seats scripts.test_sillok_search scripts.test_korean_spell_check scripts.test_patent_search scripts.test_mfds_drug_safety scripts.test_nts_business_registration scripts.test_mfds_food_safety scripts.test_zipcode_search scripts.test_subway_lost_property scripts.test_geeknews_search scripts.test_naver_blog_search scripts.test_korean_slang_writing scripts.test_kakaotalk_mac scripts.test_coupang_partners_mcp_wrapper scripts.test_ohou_today_deal scripts.test_ticket_availability scripts.test_seoul_bike scripts.test_danawa_price_search && PYTHONPATH=.:scripts:korean-scholarship-search/scripts ./.cache/python-test-venv/bin/python -m unittest discover -s korean-scholarship-search/scripts -p 'test_scholarship_filter.py' && PYTHONPATH=.:scripts:kosis-stats/scripts ./.cache/python-test-venv/bin/python -m unittest discover -s kosis-stats/tests -p 'test_run_kosis_stats.py' && PYTHONPATH=.:scripts:kstartup-search/scripts ./.cache/python-test-venv/bin/python -m unittest discover -s kstartup-search/tests -p 'test_run_kstartup.py' && PYTHONPATH=.:foresttrip-vacancy/scripts ./.cache/python-test-venv/bin/python -m unittest discover -s foresttrip-vacancy/tests -p 'test_run_foresttrip_vacancy.py' && npm run test --workspaces --if-present && ./scripts/validate-skills.sh",
"test": "npm run prepare:python-test-env && node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js scripts/test_korean_middle_korean.js scripts/test_build_manus_bundle.js scripts/workflow-actions.test.js scripts/test_generate_plugin_manifest.js && PYTHONPATH=.:scripts ./.cache/python-test-venv/bin/python -m unittest scripts.test_k_skill_cleaner scripts.test_fine_dust scripts.test_ktx_booking scripts.test_srt_booking scripts.test_srt_seats scripts.test_sillok_search scripts.test_korean_spell_check scripts.test_patent_search scripts.test_mfds_drug_safety scripts.test_nts_business_registration scripts.test_mfds_food_safety scripts.test_zipcode_search scripts.test_subway_lost_property scripts.test_geeknews_search scripts.test_naver_blog_search scripts.test_korean_slang_writing scripts.test_coupang_partners_mcp_wrapper scripts.test_ohou_today_deal scripts.test_ticket_availability scripts.test_seoul_bike scripts.test_danawa_price_search && PYTHONPATH=.:scripts:korean-scholarship-search/scripts ./.cache/python-test-venv/bin/python -m unittest discover -s korean-scholarship-search/scripts -p 'test_scholarship_filter.py' && PYTHONPATH=.:scripts:kosis-stats/scripts ./.cache/python-test-venv/bin/python -m unittest discover -s kosis-stats/tests -p 'test_run_kosis_stats.py' && PYTHONPATH=.:scripts:kstartup-search/scripts ./.cache/python-test-venv/bin/python -m unittest discover -s kstartup-search/tests -p 'test_run_kstartup.py' && PYTHONPATH=.:foresttrip-vacancy/scripts ./.cache/python-test-venv/bin/python -m unittest discover -s foresttrip-vacancy/tests -p 'test_run_foresttrip_vacancy.py' && PYTHONPATH=.:jobkorea-talent-search/scripts ./.cache/python-test-venv/bin/python -m unittest discover -s jobkorea-talent-search/scripts -p 'test_jobkorea_talent_search.py' && npm run test --workspaces --if-present && ./scripts/validate-skills.sh",
"pack:dry-run": "npm pack --workspace k-lotto --dry-run && npm pack --workspace daiso-product-search --dry-run && npm pack --workspace market-kurly-search --dry-run && npm pack --workspace kakao-bar-nearby --dry-run && npm pack --workspace cheap-gas-nearby --dry-run && npm pack --workspace public-restroom-nearby --dry-run && npm pack --workspace parking-lot-search --dry-run && npm pack --workspace court-auction-notice-search --dry-run && npm pack --workspace donation-place-search --dry-run && npm pack --workspace gongsijiga-search --dry-run && npm pack --workspace kbl-results --dry-run && npm pack --workspace kleague-results --dry-run && npm pack --workspace lck-analytics --dry-run && npm pack --workspace toss-securities --dry-run && npm pack --workspace hipass-receipt --dry-run && npm pack --workspace used-car-price-search --dry-run && npm pack --workspace k-skill-rhwp --dry-run && npm pack --workspace korean-marathon-schedule --dry-run && npm pack --workspace gangnamunni-clinic-search --dry-run && npm pack --workspace daishin-report-search --dry-run && npm pack --workspace sh-notice-search --dry-run && npm pack --workspace emergency-room-beds --dry-run && npm pack --workspace local-election-candidate-search --dry-run",
"ci": "npm run lint && npm run typecheck && npm run test && npm run pack:dry-run",
"version-packages": "changeset version",

View file

@ -0,0 +1,131 @@
---
name: saramin-talent-search
description: 사람인 기업회원 인재풀 로그인 세션에서 마스킹된 후보 정보를 검색·비교해 유료 열람 전 shortlist를 만듭니다.
license: MIT
metadata:
category: recruiting
locale: ko-KR
phase: v1
---
# saramin-talent-search
사람인 인재풀에서 유료 열람/연락처 확인/제안 발송 전에 현재 보이는 마스킹 후보 정보를 비교해 “열람할 만한 후보”를 추천한다. 개발자 전용이 아니며 모든 직무에 role-adaptive하게 적용한다.
## Use when
- 사용자가 사람인 인재풀에서 후보를 찾아달라고 요청한다.
- 기업회원 로그인/2차 인증이 완료된 브라우저 세션에서 후보를 검색해야 한다.
- 유료 열람 전 shortlist, 점수, 근거, 리스크, 후보 URL이 필요하다.
## Hard boundaries
Allowed:
- 사람인 인재풀 브라우저 세션 열기 및 검색 필터 입력
- 현재 보이는 마스킹 후보 목록/프로필/이력서 읽기
- 후보 분석, 점수화, shortlist 작성, 유료 열람 추천
Never do without explicit user handoff/confirmation:
- 유료 이력서 열람, 연락처 확인, 마스킹 해제
- 포지션/입사 제안 발송
- 스크랩, 관심후보 등록, 메모, 후보 상태 변경
- 결제/유료 상품 사용
- 비밀번호, OTP, 인증번호, 세션 쿠키 요청/저장
- 후보 개인정보 장기 저장 또는 대량 수집
If a control may spend credits, reveal contact/private info, send a proposal, or mutate account state, stop before clicking it.
## Primary access
Open:
```text
https://www.saramin.co.kr/zf_user/memcom/talent-pool/main/search
```
If login or first-device verification is required, pause and show:
```text
사람인 인재풀 검색은 기업회원 로그인과, 처음 사용하는 브라우저/기기에서는 2차 인증이 필요할 수 있습니다.
제가 브라우저로 사람인 인재풀 검색 페이지를 열어둘게요.
열린 브라우저에서 직접 로그인과 필요한 경우 2차 인증을 완료해 주세요.
비밀번호, 인증번호, 세션 쿠키는 저에게 알려주지 마세요.
인재풀 검색 화면이 보이면 “인증 완료했어”라고 알려주세요.
그 다음 같은 브라우저 세션에서 검색을 이어가겠습니다.
```
Resume only in the same browser session after the user confirms the search UI is visible.
## Input normalization
Extract or infer:
- role_title
- must_have / nice_to_have
- negative_keywords
- career min/max
- location/work_area
- role-specific evaluation signals
- limit / requested Top N
Do not block on missing details when a reasonable first search is possible.
## Workflow
1. Open the primary URL and verify corporate login plus search UI visibility.
2. Ask the user to log in/verify manually only when required; never handle credentials.
3. Apply filters: keyword, 직무/직종, 경력, 지역, recent update/activity/relevance sorting, exclusions when supported.
4. Build a candidate pool from visible rows.
5. Before final Top N, open normal profile/resume detail links for promising candidates when this does not trigger paid unlock/contact/proposal actions.
6. Read only visible free/masked details: career, responsibilities, project/achievement evidence, skills, education/certs/languages, desired location/salary/job-seeking state, portfolio links if visible, recent activity.
7. Score role-adaptively: must-have fit, career depth, concrete achievement/project evidence, location/activity fit, nice-to-have signals, and risk penalty.
8. Return Korean shortlist with direct URL per recommended candidate.
Do not finalize Top N from list rows only unless details are inaccessible or paid-walled. If so, label results as `목록 기반 1차 shortlist` and lower confidence. If detail text was inspected, label as `상세 이력 확인 기반 shortlist`.
## Permission guidance
Safe after normal tool/browser approval: opening the search page, typing filters, pressing search/apply, scrolling results, opening normal candidate detail links, reading currently visible masked/free text.
Must stop/handoff: paid unlock, contact reveal, proposal/send, scrap/interest, memo/status changes, payment, credential/OTP/cookie handling.
## URL extraction guidance
Every recommended candidate needs a direct Saramin profile/resume URL whenever available. If browser extraction fails, inspect anchors, onclick handlers, data attributes, card containers, and detail-page `location.href`. If still missing, write `URL: 추출 실패` and explain why.
## Output shape
```text
사람인 인재풀 shortlist
검색 조건
- 포지션: ...
- 필수/우대/제외 조건: ...
- 경력/지역: ...
- 모드: 상세 이력 확인 기반 shortlist / 목록 기반 1차 shortlist
유료 열람 추천 Top N
1. 후보 A
- 점수: 88/100
- 근거: ...
- 보이는 경력/성과: ...
- 리스크: ...
- 추천 액션: 채용 담당자가 유료 열람 검토
- URL: ...
보류 후보
- ...
검색 한계
- 마스킹/현재 표시 정보만 분석했음
- 연락처/실명/비공개 정보는 열람하지 않음
- 유료 액션은 실행하지 않음
```
## Failure modes
- Login/2FA required: open the page and let the user complete it manually.
- Browser/session unavailable: explain that the agent needs browser/computer-use access; do not silently switch to no-login scraping.
- Paid wall/contact wall: stop and mark as manual paid review needed.
- Empty results: adjust keywords, career, region, update/relevance filters.
- UI changed: rediscover the visible form/data flow before updating instructions.

View file

@ -1,11 +0,0 @@
from __future__ import annotations
from pathlib import Path
_BUNDLED_HELPER = Path(__file__).resolve().parent.parent / "kakaotalk-mac" / "scripts" / "kakaotalk_mac.py"
if not _BUNDLED_HELPER.exists(): # pragma: no cover - defensive import guard
raise FileNotFoundError(f"Bundled KakaoTalk helper not found: {_BUNDLED_HELPER}")
exec(compile(_BUNDLED_HELPER.read_text(encoding="utf-8"), str(_BUNDLED_HELPER), "exec"), globals())

View file

@ -311,8 +311,8 @@ test("repository docs advertise the kakaotalk-mac skill", () => {
const featureDocPath = path.join(repoRoot, "docs", "features", "kakaotalk-mac.md");
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/kakaotalk-mac.md to exist");
assert.match(readme, /\| 카카오톡 Mac CLI \|/);
assert.match(readme, /\[카카오톡 Mac CLI\]\(docs\/features\/kakaotalk-mac\.md\)/);
assert.match(readme, /\| 카카오톡 Mac 아카이브 검색 \|/);
assert.match(readme, /\[카카오톡 Mac 아카이브 검색\]\(docs\/features\/kakaotalk-mac\.md\)/);
assert.match(install, /--skill kakaotalk-mac/);
});
@ -634,34 +634,42 @@ test("hosted proxy docs keep self-host overrides inactive and demonstrate resolv
}
});
test("kakaotalk-mac skill documents safe macOS kakaocli usage", () => {
test("kakaotalk-mac skill documents katok archive search usage", () => {
const skillPath = path.join(repoRoot, "kakaotalk-mac", "SKILL.md");
const helperPath = path.join(repoRoot, "scripts", "kakaotalk_mac.py");
const featureDoc = read(path.join("docs", "features", "kakaotalk-mac.md"));
const featureDocPath = path.join(repoRoot, "docs", "features", "kakaotalk-mac.md");
assert.ok(fs.existsSync(skillPath), "expected kakaotalk-mac/SKILL.md to exist");
assert.ok(fs.existsSync(helperPath), "expected scripts/kakaotalk_mac.py to exist");
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/kakaotalk-mac.md to exist");
const skill = read(path.join("kakaotalk-mac", "SKILL.md"));
const featureDoc = read(path.join("docs", "features", "kakaotalk-mac.md"));
assert.match(skill, /^name: kakaotalk-mac$/m);
assert.match(skill, /kakaocli/);
assert.match(skill, /macOS/i);
assert.match(skill, /KakaoTalk/i);
assert.match(skill, /Full Disk Access/i);
assert.match(skill, /Accessibility/i);
assert.match(skill, /--me/);
assert.match(skill, /confirm before sending/i);
for (const doc of [skill, featureDoc]) {
assert.match(doc, /python3 scripts\/kakaotalk_mac\.py auth/);
assert.match(doc, /python3 scripts\/kakaotalk_mac\.py chats --limit 10 --json/);
assert.match(doc, /python3 scripts\/kakaotalk_mac\.py messages --chat/);
assert.match(doc, /python3 scripts\/kakaotalk_mac\.py search/);
assert.match(doc, /user_id 자동 감지 실패|SHA-512|DESIGNATEDFRIENDSREVISION/i);
assert.match(doc, /cache|캐시/);
assert.match(doc, /read-only|읽기 전용/i);
assert.doesNotMatch(doc, /`query`/);
assert.match(doc, /\bkatok\b/);
assert.match(doc, /macOS/i);
assert.match(doc, /KakaoTalk/i);
assert.match(doc, /Full Disk Access/i);
assert.match(doc, /katok doctor --json/);
assert.match(doc, /katok permissions macos/);
assert.match(doc, /katok sync --source macos --json/);
assert.match(doc, /katok index --json/);
assert.match(doc, /katok search keyword/);
assert.match(doc, /katok search bm25/);
assert.match(doc, /katok search semantic/);
assert.match(doc, /katok chunk get/);
assert.match(doc, /katok chunk context/);
assert.match(doc, /katok chunk parent/);
assert.match(doc, /(no|never|do not|don't|not).{0,80}((direct|raw).{0,40}(DB|database).{0,40}read|directly read.{0,40}(DB|database))/i);
assert.match(doc, /(no|never|do not|don't|not).{0,80}(auth|authentication).{0,40}caches?/i);
assert.match(doc, /(no|never|do not|don't|not).{0,80}decryption material/i);
assert.doesNotMatch(doc, /kakaocli/);
assert.doesNotMatch(doc, /python3 scripts\/kakaotalk_mac\.py/);
assert.doesNotMatch(doc, /send --me/);
assert.doesNotMatch(doc, /delete-last/);
assert.doesNotMatch(doc, /confirm before sending/i);
assert.doesNotMatch(doc, /SQLCipher key/i);
}
});
@ -3992,7 +4000,7 @@ test("k-skill-rhwp package ships CLI bin, WASM-init shim, and minor semver chang
const README_SKILL_NAME_COLUMN_MAPPING = [
["SRT 예매", "srt-booking"],
["KTX 예매", "ktx-booking"],
["카카오톡 Mac CLI", "kakaotalk-mac"],
["카카오톡 Mac 아카이브 검색", "kakaotalk-mac"],
["서울 지하철 도착정보 조회", "seoul-subway-arrival"],
["지하철 분실물 조회", "subway-lost-property"],
["긱뉴스 조회", "geeknews-search"],

View file

@ -1,473 +0,0 @@
from __future__ import annotations
import hashlib
import json
import io
import tempfile
import unittest
from pathlib import Path
from unittest import mock
import scripts.kakaotalk_mac as kakaotalk_mac
def sha512_hex(value: int) -> str:
return hashlib.sha512(str(value).encode("utf-8")).hexdigest()
def make_resolved_auth(
*,
user_id: int = 123,
uuid: str = "uuid",
database_path: Path | None = None,
database_name: str = "db-name",
key: str = "super-secret",
source: str = "cache",
) -> kakaotalk_mac.ResolvedAuth:
return kakaotalk_mac.ResolvedAuth(
user_id=user_id,
uuid=uuid,
database_path=database_path or Path("/tmp/kakaotalk.db"),
database_name=database_name,
key=key,
source=source,
)
class KakaoTalkMacHelperTests(unittest.TestCase):
def test_parse_plist_xml_extracts_candidates_and_active_hash(self) -> None:
active_hash = sha512_hex(123456)
xml_text = f"""<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
<key>AlertKakaoIDsList</key>
<array>
<integer>111</integer>
<integer>222</integer>
</array>
<key>userId</key>
<integer>333</integer>
<key>DESIGNATEDFRIENDSREVISION:{active_hash}</key>
<integer>5</integer>
</dict>
</plist>
"""
parsed = kakaotalk_mac.parse_plist_xml(xml_text)
self.assertEqual(parsed["AlertKakaoIDsList"], [111, 222])
self.assertEqual(kakaotalk_mac.collect_candidate_user_ids(parsed), [333, 111, 222])
self.assertEqual(kakaotalk_mac.find_active_account_hash(parsed), active_hash)
def test_discover_database_files_filters_hex_names(self) -> None:
with tempfile.TemporaryDirectory() as tempdir:
root = Path(tempdir)
expected = [
root / ("a" * 78),
root / ("b" * 78 + ".db"),
]
for path in expected:
path.write_text("", encoding="utf-8")
(root / ("c" * 40)).write_text("", encoding="utf-8")
(root / ("d" * 78 + "-wal")).write_text("", encoding="utf-8")
discovered = kakaotalk_mac.discover_database_files(root)
self.assertEqual(discovered, expected)
def test_recover_user_id_from_sha512_supports_single_worker_search(self) -> None:
target_user_id = 123456
recovered = kakaotalk_mac.recover_user_id_from_sha512(
sha512_hex(target_user_id),
max_user_id=200000,
workers=1,
chunk_size=5000,
)
self.assertEqual(recovered, target_user_id)
def test_resolve_auth_retries_with_hash_recovered_user_id_and_caches_result(self) -> None:
target_user_id = 654321
active_hash = sha512_hex(target_user_id)
with tempfile.TemporaryDirectory() as tempdir:
cache_path = Path(tempdir) / "auth-cache.json"
database_path = Path(tempdir) / "kakaotalk.db"
database_path.write_text("", encoding="utf-8")
verification_calls: list[int] = []
state = kakaotalk_mac.DetectionState(
uuid="42C34717-27C3-538C-81E4-8B568287C7A0",
candidate_user_ids=[111, 222],
active_account_hash=active_hash,
database_files=[database_path],
)
def verify(candidate: kakaotalk_mac.ResolvedAuth) -> bool:
verification_calls.append(candidate.user_id)
return candidate.user_id == target_user_id
resolved = kakaotalk_mac.resolve_auth_state(
state,
verify_access=verify,
cache_path=cache_path,
max_user_id=700000,
workers=1,
chunk_size=10000,
)
cache_payload = json.loads(cache_path.read_text(encoding="utf-8"))
self.assertEqual(verification_calls, [111, 222, target_user_id])
self.assertEqual(resolved.user_id, target_user_id)
self.assertEqual(resolved.database_path, database_path)
self.assertEqual(cache_payload["user_id"], target_user_id)
self.assertEqual(cache_payload["database_path"], str(database_path))
def test_load_cached_auth_treats_corrupt_json_as_cache_miss(self) -> None:
with tempfile.TemporaryDirectory() as tempdir:
cache_path = Path(tempdir) / "auth-cache.json"
cache_path.write_text("{bad json\n", encoding="utf-8")
self.assertIsNone(kakaotalk_mac.load_cached_auth(cache_path))
def test_resolve_auth_reuses_detection_when_cache_is_corrupt(self) -> None:
with tempfile.TemporaryDirectory() as tempdir:
cache_path = Path(tempdir) / "auth-cache.json"
cache_path.write_text("{bad json\n", encoding="utf-8")
database_path = Path(tempdir) / "kakaotalk.db"
database_path.write_text("", encoding="utf-8")
resolved = make_resolved_auth(database_path=database_path, source="hash-recovery")
with (
mock.patch.object(kakaotalk_mac, "collect_detection_state", return_value=mock.sentinel.state) as collect_state,
mock.patch.object(kakaotalk_mac, "resolve_auth_state", return_value=resolved) as resolve_state,
):
cached = kakaotalk_mac.resolve_auth(
refresh=False,
cache_path=cache_path,
user_id_override=None,
uuid_override=None,
max_user_id=1000,
workers=1,
chunk_size=100,
)
self.assertEqual(cached, resolved)
collect_state.assert_called_once_with(None)
resolve_state.assert_called_once()
def test_resolve_auth_bypasses_cache_when_user_id_override_is_supplied(self) -> None:
with tempfile.TemporaryDirectory() as tempdir:
cache_path = Path(tempdir) / "auth-cache.json"
database_path = Path(tempdir) / "kakaotalk.db"
database_path.write_text("", encoding="utf-8")
persistable = make_resolved_auth(database_path=database_path, source="cache")
kakaotalk_mac.persist_auth_cache(persistable, cache_path)
override_result = make_resolved_auth(user_id=999, database_path=database_path, source="candidate")
with (
mock.patch.object(kakaotalk_mac, "collect_detection_state", return_value=mock.sentinel.state) as collect_state,
mock.patch.object(kakaotalk_mac, "resolve_auth_state", return_value=override_result) as resolve_state,
):
resolved = kakaotalk_mac.resolve_auth(
refresh=False,
cache_path=cache_path,
user_id_override=999,
uuid_override=None,
max_user_id=1000,
workers=1,
chunk_size=100,
)
self.assertEqual(resolved, override_result)
collect_state.assert_called_once_with(None)
resolve_state.assert_called_once_with(
mock.sentinel.state,
verify_access=kakaotalk_mac.verify_database_access,
cache_path=cache_path,
user_id_override=999,
max_user_id=1000,
workers=1,
chunk_size=100,
)
def test_resolve_auth_bypasses_cache_when_uuid_override_is_supplied(self) -> None:
with tempfile.TemporaryDirectory() as tempdir:
cache_path = Path(tempdir) / "auth-cache.json"
database_path = Path(tempdir) / "kakaotalk.db"
database_path.write_text("", encoding="utf-8")
persistable = make_resolved_auth(database_path=database_path, source="cache")
kakaotalk_mac.persist_auth_cache(persistable, cache_path)
override_result = make_resolved_auth(uuid="override-uuid", database_path=database_path, source="candidate")
with (
mock.patch.object(kakaotalk_mac, "collect_detection_state", return_value=mock.sentinel.state) as collect_state,
mock.patch.object(kakaotalk_mac, "resolve_auth_state", return_value=override_result) as resolve_state,
):
resolved = kakaotalk_mac.resolve_auth(
refresh=False,
cache_path=cache_path,
user_id_override=None,
uuid_override="override-uuid",
max_user_id=1000,
workers=1,
chunk_size=100,
)
self.assertEqual(resolved, override_result)
collect_state.assert_called_once_with("override-uuid")
resolve_state.assert_called_once_with(
mock.sentinel.state,
verify_access=kakaotalk_mac.verify_database_access,
cache_path=cache_path,
user_id_override=None,
max_user_id=1000,
workers=1,
chunk_size=100,
)
def test_render_auth_text_redacts_key_material(self) -> None:
resolved = make_resolved_auth(key="super-secret-key", source="hash-recovery")
rendered = kakaotalk_mac.render_auth(resolved, output_format="text", cache_path=Path("/tmp/cache.json"))
self.assertNotIn("super-secret-key", rendered)
self.assertNotIn("--key", rendered)
self.assertIn("python3 scripts/kakaotalk_mac.py chats --limit 10 --json", rendered)
def test_build_passthrough_command_rejects_non_read_only_command(self) -> None:
auth = make_resolved_auth()
with self.assertRaises(kakaotalk_mac.AuthResolutionError):
kakaotalk_mac.build_passthrough_command("query", auth, ["DELETE FROM chat_logs"])
def test_build_parser_exposes_safe_helper_commands_without_raw_query(self) -> None:
parser = kakaotalk_mac.build_parser()
subcommands = parser._subparsers._group_actions[0].choices
self.assertEqual(sorted(subcommands), ["auth", "chats", "delete", "delete-last", "messages", "schema", "search"])
self.assertNotIn("query", subcommands)
def test_build_parser_exposes_delete_commands_with_safe_dry_run(self) -> None:
parser = kakaotalk_mac.build_parser()
subcommands = parser._subparsers._group_actions[0].choices
self.assertIn("delete", subcommands)
self.assertIn("delete-last", subcommands)
parsed = parser.parse_args(["delete", "팀 공지방", "42", "--everyone", "--dry-run"])
self.assertEqual(parsed.command, "delete")
self.assertEqual(parsed.chat, "팀 공지방")
self.assertEqual(parsed.message_id, 42)
self.assertTrue(parsed.everyone)
self.assertTrue(parsed.dry_run)
def test_select_delete_target_by_message_id_requires_matching_outbound_message(self) -> None:
messages = [
{"id": 41, "text": "older", "is_from_me": True, "timestamp": "2026-05-14T00:00:00Z"},
{"id": 42, "text": "sent follow-up", "is_from_me": True, "timestamp": "2026-05-14T00:01:00Z"},
]
target = kakaotalk_mac.select_delete_target(messages, message_id=42, delete_last=False, everyone=False)
self.assertEqual(target.message_id, 42)
self.assertEqual(target.text, "sent follow-up")
self.assertTrue(target.is_from_me)
with self.assertRaises(kakaotalk_mac.AuthResolutionError):
kakaotalk_mac.select_delete_target(messages, message_id=404, delete_last=False, everyone=False)
def test_select_delete_target_rejects_non_outbound_message_before_delete_for_me(self) -> None:
messages = [{"id": 42, "text": "inbound", "is_from_me": False}]
with self.assertRaises(kakaotalk_mac.AuthResolutionError) as context:
kakaotalk_mac.select_delete_target(messages, message_id=42, delete_last=False, everyone=False)
self.assertIn("sent by this KakaoTalk account", str(context.exception))
def test_select_delete_last_uses_most_recent_message_from_me(self) -> None:
messages = [
{"id": 100, "text": "latest inbound", "is_from_me": False, "timestamp": "2026-05-14T00:02:00Z"},
{"id": 99, "text": "latest outbound", "is_from_me": True, "timestamp": "2026-05-14T00:01:00Z"},
{"id": 98, "text": "older outbound", "is_from_me": True, "timestamp": "2026-05-14T00:00:00Z"},
]
target = kakaotalk_mac.select_delete_target(messages, message_id=None, delete_last=True, everyone=True)
self.assertEqual(target.message_id, 99)
self.assertEqual(target.text, "latest outbound")
def test_select_delete_last_sorts_unordered_messages_by_timestamp_then_id(self) -> None:
messages = [
{"id": 40, "text": "older outbound", "is_from_me": True, "timestamp": "2026-05-14T00:00:00Z"},
{"id": 42, "text": "latest outbound", "is_from_me": True, "timestamp": "2026-05-14T00:02:00Z"},
{"id": 41, "text": "middle outbound", "is_from_me": True, "timestamp": "2026-05-14T00:01:00Z"},
]
target = kakaotalk_mac.select_delete_target(messages, message_id=None, delete_last=True, everyone=False)
self.assertEqual(target.message_id, 42)
self.assertEqual(target.text, "latest outbound")
def test_select_delete_last_uses_id_as_tiebreaker_for_equal_timestamps(self) -> None:
messages = [
{"id": 40, "text": "same time older id", "is_from_me": True, "timestamp": "2026-05-14T00:00:00Z"},
{"id": 43, "text": "same time newer id", "is_from_me": True, "timestamp": "2026-05-14T00:00:00Z"},
]
target = kakaotalk_mac.select_delete_target(messages, message_id=None, delete_last=True, everyone=False)
self.assertEqual(target.message_id, 43)
self.assertEqual(target.text, "same time newer id")
def test_select_delete_target_rejects_everyone_for_non_outbound_message(self) -> None:
messages = [{"id": 42, "text": "inbound", "is_from_me": False}]
with self.assertRaises(kakaotalk_mac.AuthResolutionError) as context:
kakaotalk_mac.select_delete_target(messages, message_id=42, delete_last=False, everyone=True)
self.assertIn("--everyone", str(context.exception))
def test_build_delete_osascript_mentions_chat_text_and_delete_scope(self) -> None:
target = kakaotalk_mac.DeleteTarget(
message_id=42,
text="테스트 메시지",
timestamp="2026-05-14T00:00:00Z",
is_from_me=True,
)
script = kakaotalk_mac.build_delete_osascript("팀 공지방", target, everyone=True)
self.assertIn("팀 공지방", script)
self.assertIn("테스트 메시지", script)
self.assertIn("모두에게서 삭제", script)
self.assertIn("Delete for Everyone", script)
self.assertIn("matchingElements", script)
self.assertIn("Could not choose the requested delete scope", script)
def test_build_delete_osascript_uses_fail_closed_exact_transcript_resolver(self) -> None:
target = kakaotalk_mac.DeleteTarget(
message_id=42,
text="테스트 메시지",
timestamp="2026-05-14T00:00:00Z",
is_from_me=True,
)
script = kakaotalk_mac.build_delete_osascript("팀 공지방", target, everyone=True)
self.assertNotIn("entire contents of front window", script)
self.assertNotIn("contains messageText", script)
self.assertNotIn("contains chatName", script)
self.assertIn("set normalizedMessageText to normalizeText(messageText)", script)
self.assertIn("set normalizedChatName to normalizeText(chatName)", script)
self.assertIn("if normalizeText(candidateValue) is normalizedMessageText then", script)
self.assertIn("if normalizeText(chatCandidateValue) is normalizedChatName then", script)
self.assertIn("set messageListCandidates to", script)
self.assertIn("AXShowMenu", script)
self.assertIn("Target message text matched multiple visible targetable message bubbles", script)
self.assertIn("Could not verify the active KakaoTalk chat", script)
self.assertNotIn("set messageTimestamp to", script)
def test_run_delete_dry_run_validates_target_but_skips_ui_side_effect(self) -> None:
stdout = io.StringIO()
auth = make_resolved_auth()
messages = [{"id": 42, "text": "검증된 메시지", "is_from_me": True, "timestamp": "2026-05-14T00:00:00Z"}]
with (
mock.patch.object(kakaotalk_mac, "resolve_auth", return_value=auth) as resolve_auth,
mock.patch.object(kakaotalk_mac, "load_messages_for_delete", return_value=messages) as load_messages,
mock.patch.object(kakaotalk_mac, "run_delete_automation") as run_delete,
mock.patch("sys.stdout", stdout),
):
exit_code = kakaotalk_mac.main(["delete", "팀 공지방", "42", "--everyone", "--dry-run"])
self.assertEqual(exit_code, 0)
resolve_auth.assert_called_once()
load_messages.assert_called_once_with("팀 공지방", auth, limit=200)
run_delete.assert_not_called()
self.assertIn("DRY RUN", stdout.getvalue())
self.assertIn("message_id=42", stdout.getvalue())
self.assertIn("검증된 메시지", stdout.getvalue())
def test_run_delete_dry_run_fails_when_message_id_is_missing(self) -> None:
stderr = io.StringIO()
with (
mock.patch.object(kakaotalk_mac, "resolve_auth", return_value=make_resolved_auth()),
mock.patch.object(kakaotalk_mac, "load_messages_for_delete", return_value=[]),
mock.patch("sys.stderr", stderr),
):
exit_code = kakaotalk_mac.main(["delete", "팀 공지방", "404", "--dry-run"])
self.assertEqual(exit_code, 1)
self.assertIn("Message id 404", stderr.getvalue())
def test_select_delete_target_rejects_duplicate_visible_text(self) -> None:
messages = [
{"id": 42, "text": "same", "is_from_me": True},
{"id": 41, "text": "same", "is_from_me": True},
]
with self.assertRaises(kakaotalk_mac.AuthResolutionError) as context:
kakaotalk_mac.select_delete_target(messages, message_id=42, delete_last=False, everyone=True)
self.assertIn("same normalized visible text", str(context.exception))
def test_select_delete_target_rejects_duplicate_normalized_visible_text(self) -> None:
messages = [
{"id": 42, "text": "same visible text", "is_from_me": True},
{"id": 41, "text": "same visible text", "is_from_me": True},
]
with self.assertRaises(kakaotalk_mac.AuthResolutionError) as context:
kakaotalk_mac.select_delete_target(messages, message_id=42, delete_last=False, everyone=False)
self.assertIn("same normalized visible text", str(context.exception))
def test_select_delete_target_rejects_empty_or_non_text_delete_target(self) -> None:
messages = [{"id": 42, "text": " ", "type": "photo", "is_from_me": True}]
with self.assertRaises(kakaotalk_mac.AuthResolutionError) as context:
kakaotalk_mac.select_delete_target(messages, message_id=42, delete_last=False, everyone=False)
self.assertIn("non-empty text", str(context.exception))
def test_build_delete_osascript_fails_when_final_confirmation_is_missing(self) -> None:
target = kakaotalk_mac.DeleteTarget(
message_id=42,
text="테스트 메시지",
timestamp="2026-05-14T00:00:00Z",
is_from_me=True,
)
script = kakaotalk_mac.build_delete_osascript("팀 공지방", target, everyone=True)
self.assertIn("set didConfirmDelete to false", script)
self.assertIn("set didConfirmDelete to true", script)
self.assertIn("if didConfirmDelete is false then error", script)
self.assertIn("Could not confirm the KakaoTalk delete dialog", script)
def test_build_parser_rejects_negative_max_user_id(self) -> None:
parser = kakaotalk_mac.build_parser()
stderr = io.StringIO()
with self.assertRaises(SystemExit) as exit_context, mock.patch("sys.stderr", stderr):
parser.parse_args(["auth", "--max-user-id", "-1"])
self.assertEqual(exit_context.exception.code, 2)
self.assertIn("must be non-negative", stderr.getvalue())
def test_build_parser_rejects_non_positive_chunk_size(self) -> None:
parser = kakaotalk_mac.build_parser()
stderr = io.StringIO()
with self.assertRaises(SystemExit) as exit_context, mock.patch("sys.stderr", stderr):
parser.parse_args(["auth", "--chunk-size", "0"])
self.assertEqual(exit_context.exception.code, 2)
self.assertIn("must be positive", stderr.getvalue())
if __name__ == "__main__":
unittest.main()