mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
commit
08533bd9eb
20 changed files with 1145 additions and 1711 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
67
docs/features/jobkorea-talent-search.md
Normal file
67
docs/features/jobkorea-talent-search.md
Normal 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를 조정해야 할 수 있다.
|
||||
- 계정 권한, 유료 상품 상태, 마스킹 정책에 따라 보이는 정보가 다르다.
|
||||
- 후보 개인정보를 장기 저장하거나 대량 수집하지 않는다.
|
||||
|
|
@ -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 전용이며 메시지 전송과 삭제를 지원하지 않는다.
|
||||
|
|
|
|||
67
docs/features/saramin-talent-search.md
Normal file
67
docs/features/saramin-talent-search.md
Normal 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`로 낮은 신뢰도를 표시한다.
|
||||
- 후보 개인정보를 장기 저장하거나 대량 수집하지 않는다.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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차 인증 후 현재 보이는 마스킹 후보 정보를 읽는 브라우저 기반 경로. 유료 열람/연락처 확인/제안 발송은 수동 확인 대상.
|
||||
|
|
|
|||
130
jobkorea-talent-search/SKILL.md
Normal file
130
jobkorea-talent-search/SKILL.md
Normal 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.
|
||||
27
jobkorea-talent-search/scripts/jobkorea_talent_models.py
Normal file
27
jobkorea-talent-search/scripts/jobkorea_talent_models.py
Normal 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 = ""
|
||||
186
jobkorea-talent-search/scripts/jobkorea_talent_parse.py
Normal file
186
jobkorea-talent-search/scripts/jobkorea_talent_parse.py
Normal 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)
|
||||
94
jobkorea-talent-search/scripts/jobkorea_talent_search.py
Executable file
94
jobkorea-talent-search/scripts/jobkorea_talent_search.py
Executable 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)
|
||||
|
|
@ -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",
|
||||
},
|
||||
)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
|
@ -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",
|
||||
|
|
|
|||
131
saramin-talent-search/SKILL.md
Normal file
131
saramin-talent-search/SKILL.md
Normal 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.
|
||||
|
|
@ -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())
|
||||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue