mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
Merge pull request #323 from jwb0501/feature/talent-search-skills
잡코리아·사람인 인재검색 스킬 추가
This commit is contained in:
commit
eacdfb882a
13 changed files with 922 additions and 2 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",
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
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를 조정해야 할 수 있다.
|
||||
- 계정 권한, 유료 상품 상태, 마스킹 정책에 따라 보이는 정보가 다르다.
|
||||
- 후보 개인정보를 장기 저장하거나 대량 수집하지 않는다.
|
||||
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`로 낮은 신뢰도를 표시한다.
|
||||
- 후보 개인정보를 장기 저장하거나 대량 수집하지 않는다.
|
||||
|
|
@ -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()
|
||||
|
|
@ -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/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 && 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_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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue