instruction.md
# jobkorea-talent-search
## 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
npx -y @nomadamas/k-skill@0 exec 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.
references/DISCLAIMER.md
# DISCLAIMER — `jobkorea-talent-search`
## 비제휴 및 상표 사용
이 스킬은 이 문서에서 언급하는 상표권자나 서비스 운영사의 공식 기능 또는 공식 지원 도구가 아닙니다. 해당 권리자·운영사와 공식 제휴, 후원, 승인, 인증 또는 협업한 사실이 전혀 없습니다. 제3자 상표와 서비스명은 스킬의 기능, 조회 대상 또는 호환 대상을 정확히 설명하기 위해 필요한 범위에서만 사용하며, 각 상표의 권리는 해당 권리자에게 있습니다.
대법원 2005. 6. 10. 선고 [2005도1637 판결](https://www.law.go.kr/LSW/precInfoP.do?precSeq=83920)(소니용 리모컨 사건)은 타인의 표장을 자기 상품의 출처표시가 아니라 상품의 기능 또는 적용 기종을 설명하기 위해 사용하고, 실제 표시 태양도 상표 사용으로 인식될 수 없는 경우에는 상표권 침해가 아니라고 판시했습니다. 다만 이는 제3자 상표 사용을 포괄적으로 허용한다는 뜻이 아니며, [상표법 제2조](https://www.law.go.kr/법령/상표법/제2조), [제89조](https://www.law.go.kr/법령/상표법/제89조), [제90조](https://www.law.go.kr/법령/상표법/제90조), [제108조](https://www.law.go.kr/법령/상표법/제108조)와 실제 거래계의 인식에 따라 출처 혼동 여부를 개별적으로 판단해야 합니다.
## 공개정보에 대한 자동 접근
대법원 2022. 5. 12. 선고 [2021도1533 판결](https://www.law.go.kr/LSW/precInfoP.do?precSeq=221765)(야놀자 정보 수집 사건)은 서비스 제공자가 접근권한을 제한했는지를 보호조치·이용약관 등 객관적으로 드러난 사정을 종합해 판단해야 한다고 판시했습니다. 그 사건에서는 별도 보호조치 없이 일반 이용자가 접근할 수 있었고 이미 공개된 정보를 수집한 사정 등에 비추어 정보통신망 침입을 인정하지 않았습니다. 따라서 공개되어 누구나 얻을 수 있는 정보를 기계적으로 수집했다는 사정만으로 곧바로 [정보통신망법 제48조](https://www.law.go.kr/법령/정보통신망이용촉진및정보보호등에관한법률/제48조) 위반이 되는 것은 아닙니다.
그러나 위 판결은 크롤링을 일반적으로 허용한 판결이 아닙니다. 접근통제나 명시적 제한을 우회하는 행위, 데이터베이스의 전부·상당한 부분을 복제하거나 반복적·체계적으로 수집하여 같은 결과를 내는 행위는 별도로 판단됩니다. 특히 [저작권법 제93조](https://www.law.go.kr/법령/저작권법/제93조)는 데이터베이스제작자의 권리를 보호하며, 정보처리에 현실적 장애를 일으켜 영업을 방해하면 [형법 제314조 제2항](https://www.law.go.kr/법령/형법/제314조)에 따른 책임이 문제될 수 있습니다.
## 필수 사용 제한
1. 공개정보 자동 수집 기능은 반드시 개인의 정보 조회용으로만 사용합니다.
2. 조직적·체계적·대량 크롤링, 별도 데이터베이스 구축, 재판매 또는 원본 콘텐츠 재배포에 사용하지 않습니다.
3. 특정 브랜드나 업체의 정상적인 영업·서비스를 방해하지 않도록 요청 횟수와 범위를 최소화하며, 공격적 polling이나 병렬 대량 호출을 하지 않습니다.
4. 로그인·유료벽·CAPTCHA·접근통제·rate limit·IP 차단을 우회하거나 차단 회피를 위해 계정·IP·헤더를 변경하지 않습니다. 접근 거부나 차단 신호가 확인되면 즉시 중단합니다.
5. 적용되는 이용약관, robots 지침, API 조건, 저작권·데이터베이스권·개인정보 보호 의무를 별도로 준수합니다.
이 문서는 일반적인 위험 경계를 알리기 위한 것이며, 개별 행위의 적법성을 보증하거나 법률 자문을 제공하지 않습니다.
references/TRADEMARK-LEGAL-STATEMENT.md
# 상표 사용 법적 고지 — `jobkorea-talent-search`
이 스킬에서 `잡코리아` 및 `JobKorea` 명칭은 기업회원 인재검색·비교의 **대상 채용 서비스**를 식별하기 위해 사용한다. k-skill의 출처를 잡코리아 운영사로 표시하려는 사용이 아니다.
대법원 2005. 6. 10. 선고 [2005도1637 판결](https://www.law.go.kr/LSW/precInfoP.do?precSeq=83920)은 타인의 표장을 출처표시가 아니라 상품 기능 또는 적용 기종을 밝히기 위해 사용하고 상표 사용으로 인식될 수 없는 경우 침해가 아니라고 판시했다. [상표법 제2조](https://www.law.go.kr/법령/상표법/제2조), [제89조](https://www.law.go.kr/법령/상표법/제89조), [제90조](https://www.law.go.kr/법령/상표법/제90조), [제108조](https://www.law.go.kr/법령/상표법/제108조)와 대법원 [2011다18802](https://www.law.go.kr/LSW/precInfoP.do?precSeq=167457), [2019후10418](https://law.go.kr/LSW/precInfoP.do?mode=0&precSeq=230725) 판결에 따라 실제 거래계에서 출처표시로 기능하는지는 표시 태양과 사용 경위 등을 종합해 판단해야 한다.
따라서 이 명칭은 필요한 범위의 평문으로만 사용하고, 별도 근거 없이 로고, 공식·제휴·후원·인증·파트너 표현을 사용하지 않는다. 각 상표의 권리는 해당 권리자에게 있다. 이 문서는 채용 적합성, 저작권, 데이터베이스권, 부정경쟁방지법, 약관·계약, 개인정보 또는 접근 방식의 적법성을 판단하지 않는다.
전체 검토: [제3자 상표의 기능 설명·호환 대상 표시 검토](https://github.com/NomaDamas/k-skill/blob/dev/docs/legal/trademark-use-review.md)
scripts/jobkorea_talent_models.py
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 = ""
scripts/jobkorea_talent_parse.py
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)
scripts/jobkorea_talent_search_condition.py
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",
},
)
scripts/jobkorea_talent_search.py
#!/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)
scripts/test_jobkorea_talent_search.py
#!/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()
skill.json
{
"name": "jobkorea-talent-search",
"description": "잡코리아 기업회원 로그인 세션으로 유료 열람 전 마스킹된 인재 이력서를 검색·비교해 채용 검토용 shortlist를 만듭니다. 돌쇠에서는 공식 표면을 통한 후속 액션까지 진행한다.",
"profiles": [
"vault",
"browser",
"action:recruiting"
],
"frontmatter": "name: jobkorea-talent-search\ndescription: 잡코리아 기업회원 로그인 세션으로 유료 열람 전 마스킹된 인재 이력서를 검색·비교해 채용 검토용 shortlist를 만듭니다. 돌쇠에서는 공식 표면을 통한 후속 액션까지 진행한다.\nlicense: MIT\nmetadata:\n category: recruiting\n locale: ko-KR\n phase: v1"
}
SKILL.md
---
name: jobkorea-talent-search
description: 잡코리아 기업회원 로그인 세션으로 유료 열람 전 마스킹된 인재 이력서를 검색·비교해 채용 검토용 shortlist를 만듭니다. 돌쇠에서는 공식 표면을 통한 후속 액션까지 진행한다.
license: MIT
metadata:
category: recruiting
locale: ko-KR
phase: v1
---
# jobkorea-talent-search
<!-- k-skill:cli-stub — generated by scripts/generate-skill-stubs.js; edit skill.json / instruction.md instead -->
## Get the full instructions (required first step)
Run this and follow its output as the primary instructions for this skill:
```bash
npx -y @nomadamas/k-skill@0 instruct jobkorea-talent-search
```
The CLI detects the current runtime (Dolshoi vault/CloakBrowser vs generic) and prints only the applicable instructions, always up to date. Helper files bundled with the CLI are listed by:
```bash
npx -y @nomadamas/k-skill@0 files jobkorea-talent-search
```
Keep the CLI and every coding-agent skill install current (including Vercel Agent Skills) with:
```bash
npx -y @nomadamas/k-skill@0 update
```
If `npx` is unavailable, install Node.js 18+ or follow https://github.com/NomaDamas/k-skill#readme, or read the source instructions at https://github.com/NomaDamas/k-skill/blob/main/jobkorea-talent-search/instruction.md.
## Legal disclaimer (required)
This skill is not an official feature of, officially supported by, affiliated with, sponsored by, approved by, or developed in collaboration with any third-party trademark owner or service operator it identifies. Third-party names are used only to describe the skill's function, lookup target, or compatibility.
Any automated collection of publicly accessible information must be limited to personal, non-organizational lookup. Do not use this skill for systematic or bulk crawling, database building, access-control or block circumvention, or conduct that interferes with a third party's business or service.
Read the full Korean legal disclaimer, including the cited Korean Supreme Court precedents and statutory limits, before use:
```bash
npx -y @nomadamas/k-skill@0 read jobkorea-talent-search references/DISCLAIMER.md
```
## Hard rules even without the CLI
- Never execute payment, message/email delivery, final submission, cancellation, or public posting without the user's explicit approval immediately beforehand.
- Never ask for, print, or store plaintext credentials in chat, files, or shell arguments.
- Never bypass legal, physical-presence, CAPTCHA, identity-proofing, or electronic-signature boundaries.