mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
Add recruiting talent search skills
This commit is contained in:
parent
e735abe8a4
commit
c619d3b7c7
8 changed files with 742 additions and 0 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.
|
||||
341
jobkorea-talent-search/scripts/jobkorea_talent_search.py
Executable file
341
jobkorea-talent-search/scripts/jobkorea_talent_search.py
Executable file
|
|
@ -0,0 +1,341 @@
|
|||
#!/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 html
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, asdict
|
||||
from typing import Any
|
||||
|
||||
BASE_URL = "https://www.jobkorea.co.kr"
|
||||
FIND_PATH = "/corp/person/find"
|
||||
AJAX_PATH = "/corp/person/detailsearchajax"
|
||||
DEFAULT_UA = (
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
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 = ""
|
||||
|
||||
|
||||
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:
|
||||
return json.loads(source[start : pos + 1])
|
||||
raise RuntimeError("unterminated JSON object")
|
||||
|
||||
|
||||
def iter_nodes(node: 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 k in ("s", "c", "use"):
|
||||
if k in node:
|
||||
node[k] = 1
|
||||
matched.append(title or code)
|
||||
return matched
|
||||
|
||||
|
||||
def build_search_condition(args: argparse.Namespace) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
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",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
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 parse_with_bs4(markup: str, limit: int) -> list[Candidate] | None:
|
||||
try:
|
||||
from bs4 import BeautifulSoup # type: ignore
|
||||
except Exception:
|
||||
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="]'):
|
||||
href = link.get("href", "")
|
||||
m = re.search(r"rNo=(\d+)", href)
|
||||
if not m:
|
||||
continue
|
||||
rno = m.group(1)
|
||||
if rno in seen:
|
||||
continue
|
||||
seen.add(rno)
|
||||
|
||||
container = link.find_parent(class_=re.compile(r"booth|list|row|person", re.I)) or link.parent
|
||||
raw = clean_text(str(container)) if container else clean_text(str(link))
|
||||
texts = [x.get_text(" ", strip=True) for x in (container.find_all(["dt", "dd", "p", "span", "button"]) if container else [])]
|
||||
text_join = " | ".join(t for t in texts if t)
|
||||
|
||||
name = ""
|
||||
meta = ""
|
||||
dt = container.find("dt") if container else None
|
||||
if dt:
|
||||
name = dt.get_text(" ", strip=True)
|
||||
dd = dt.find_next("dd")
|
||||
if dd:
|
||||
meta = dd.get_text(" ", strip=True)
|
||||
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("button") if container else []:
|
||||
label = btn.get_text(" ", strip=True)
|
||||
if label and label not in {"스크랩", "포지션 제안", "메모하기", "프로필 확인", "이력서 확인"}:
|
||||
skills.append(label)
|
||||
|
||||
candidates.append(
|
||||
Candidate(
|
||||
rno=rno,
|
||||
url=urllib.parse.urljoin(BASE_URL, href),
|
||||
name=name,
|
||||
meta=meta,
|
||||
career=(container.select_one(".career").get_text(" ", strip=True) if container and container.select_one(".career") else ""),
|
||||
skills=", ".join(skills[:25]),
|
||||
raw_summary=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 m in re.finditer(r'href="(?P<href>/corp/person/find/resume/view\?rNo=(?P<rno>\d+))"', markup):
|
||||
rno = m.group("rno")
|
||||
if rno in seen:
|
||||
continue
|
||||
seen.add(rno)
|
||||
start = max(0, m.start() - 1000)
|
||||
end = min(len(markup), m.end() + 2500)
|
||||
raw = clean_text(markup[start:end])
|
||||
name = ""
|
||||
meta = ""
|
||||
nm = re.search(r"([가-힣A-Za-z]OO)\s*\(([^)]*)\)", raw)
|
||||
if nm:
|
||||
name = nm.group(1)
|
||||
meta = "(" + nm.group(2) + ")"
|
||||
candidates.append(
|
||||
Candidate(
|
||||
rno=rno,
|
||||
url=urllib.parse.urljoin(BASE_URL, m.group("href")),
|
||||
name=name,
|
||||
meta=meta,
|
||||
raw_summary=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)
|
||||
|
||||
|
||||
def print_markdown(candidates: list[Candidate], matched: dict[str, Any], args: argparse.Namespace) -> None:
|
||||
print(f"# 잡코리아 인재검색 결과\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, c in enumerate(candidates, 1):
|
||||
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 main() -> int:
|
||||
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으로 출력")
|
||||
args = parser.parse_args()
|
||||
|
||||
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)
|
||||
if "로그인" in clean_text(markup)[:500] and "인재" not in clean_text(markup)[: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(main())
|
||||
except urllib.error.HTTPError as exc:
|
||||
print(f"HTTP error: {exc.code} {exc.reason}", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
except Exception as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
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