Compare commits

..

No commits in common. "main" and "fix/issue-320-katok-skill" have entirely different histories.

19 changed files with 14 additions and 936 deletions

View file

@ -0,0 +1,5 @@
---
"k-skill-proxy": minor
---
Add hosted `korean-law` proxy routes (`/v1/korean-law/search`, `/v1/korean-law/detail`) that wrap the official 법제처 (open.law.go.kr) DRF `lawSearch.do`/`lawService.do` endpoints. The proxy injects the operator `LAW_OC` plus a browser `User-Agent`/`Referer` (the actual cause of upstream "사용자 정보 검증 실패" rejections) and retries empty/HTML maintenance responses, so the `korean-law-search` skill becomes proxy-first with no per-user key. Drops the unstable Beopmang fallback from the documented surface.

View file

@ -0,0 +1,5 @@
---
"toss-securities": minor
---
Add an official Toss Securities Open API client alongside the existing unofficial `tossctl` wrapper. The package now ships read-only helpers backed by the official REST API (`https://openapi.tossinvest.com`): OAuth 2.0 Client Credentials token issuance with an in-memory token cache, bearer + `X-Tossinvest-Account` header handling, `TossApiError`/`TossCredentialsError` envelopes with secret/token redaction, and 429 `Retry-After`/backoff retry. New read-only helpers cover prices, orderbook, trades, price limits, candles, stocks, stock warnings, exchange rate, market calendars, accounts, holdings, open orders, order detail, buying power, sellable quantity, and commissions. Credentials are read from `TOSSINVEST_CLIENT_ID`/`TOSSINVEST_CLIENT_SECRET` (optional `TOSSINVEST_ACCOUNT`/`TOSSINVEST_API_BASE_URL`) and sent directly to Toss, never through a shared proxy. Order mutation (create/modify/cancel) remains out of scope. The `tossctl` path is retained as a documented fallback.

View file

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

View file

@ -66,8 +66,6 @@ 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) |

View file

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

View file

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

View file

@ -232,5 +232,3 @@
- 국세청 고액·상습체납자 명단공개(무인증): https://www.nts.go.kr/nts/ad/openInfo/selectList.do
- 지방행정 인허가데이터 LOCALDATA 파일 다운로드(무인증, CP949 CSV): https://file.localdata.go.kr/file/download/<업종slug>/info?orgCode=<지자체코드>
- LOCALDATA 본체: https://www.localdata.go.kr
- 잡코리아 기업 인재검색: https://www.jobkorea.co.kr/corp/person/find — 기업회원 로그인 세션에서 마스킹 이력서/목록을 읽는 브라우저 기반 경로. 유료 열람/마스킹 해제/포지션 제안은 수동 확인 대상.
- 사람인 기업회원 인재풀 검색: https://www.saramin.co.kr/zf_user/memcom/talent-pool/main/search — 기업회원 로그인 및 첫 기기 2차 인증 후 현재 보이는 마스킹 후보 정보를 읽는 브라우저 기반 경로. 유료 열람/연락처 확인/제안 발송은 수동 확인 대상.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 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",
"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",
"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' && 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",
"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",
"pack:dry-run": "npm pack --workspace k-lotto --dry-run && npm pack --workspace daiso-product-search --dry-run && npm pack --workspace market-kurly-search --dry-run && npm pack --workspace kakao-bar-nearby --dry-run && npm pack --workspace cheap-gas-nearby --dry-run && npm pack --workspace public-restroom-nearby --dry-run && npm pack --workspace parking-lot-search --dry-run && npm pack --workspace court-auction-notice-search --dry-run && npm pack --workspace donation-place-search --dry-run && npm pack --workspace gongsijiga-search --dry-run && npm pack --workspace kbl-results --dry-run && npm pack --workspace kleague-results --dry-run && npm pack --workspace lck-analytics --dry-run && npm pack --workspace toss-securities --dry-run && npm pack --workspace hipass-receipt --dry-run && npm pack --workspace used-car-price-search --dry-run && npm pack --workspace k-skill-rhwp --dry-run && npm pack --workspace korean-marathon-schedule --dry-run && npm pack --workspace gangnamunni-clinic-search --dry-run && npm pack --workspace daishin-report-search --dry-run && npm pack --workspace sh-notice-search --dry-run && npm pack --workspace emergency-room-beds --dry-run && npm pack --workspace local-election-candidate-search --dry-run",
"ci": "npm run lint && npm run typecheck && npm run test && npm run pack:dry-run",
"version-packages": "changeset version",

View file

@ -1,11 +1,5 @@
# k-skill-proxy
## 0.7.0
### Minor Changes
- 66f12cb: Add hosted `korean-law` proxy routes (`/v1/korean-law/search`, `/v1/korean-law/detail`) that wrap the official 법제처 (open.law.go.kr) DRF `lawSearch.do`/`lawService.do` endpoints. The proxy injects the operator `LAW_OC` plus a browser `User-Agent`/`Referer` (the actual cause of upstream "사용자 정보 검증 실패" rejections) and retries empty/HTML maintenance responses, so the `korean-law-search` skill becomes proxy-first with no per-user key. Drops the unstable Beopmang fallback from the documented surface.
## 0.6.1
### Patch Changes

View file

@ -1,6 +1,6 @@
{
"name": "k-skill-proxy",
"version": "0.7.0",
"version": "0.6.1",
"private": true,
"description": "Fastify proxy for k-skill upstream APIs",
"license": "MIT",

View file

@ -1,11 +1,5 @@
# toss-securities
## 0.5.0
### Minor Changes
- 66f12cb: Add an official Toss Securities Open API client alongside the existing unofficial `tossctl` wrapper. The package now ships read-only helpers backed by the official REST API (`https://openapi.tossinvest.com`): OAuth 2.0 Client Credentials token issuance with an in-memory token cache, bearer + `X-Tossinvest-Account` header handling, `TossApiError`/`TossCredentialsError` envelopes with secret/token redaction, and 429 `Retry-After`/backoff retry. New read-only helpers cover prices, orderbook, trades, price limits, candles, stocks, stock warnings, exchange rate, market calendars, accounts, holdings, open orders, order detail, buying power, sellable quantity, and commissions. Credentials are read from `TOSSINVEST_CLIENT_ID`/`TOSSINVEST_CLIENT_SECRET` (optional `TOSSINVEST_ACCOUNT`/`TOSSINVEST_API_BASE_URL`) and sent directly to Toss, never through a shared proxy. Order mutation (create/modify/cancel) remains out of scope. The `tossctl` path is retained as a documented fallback.
## 0.4.0
### Minor Changes

View file

@ -1,6 +1,6 @@
{
"name": "toss-securities",
"version": "0.5.0",
"version": "0.4.0",
"description": "Read-only Toss Securities client: official Open API (OAuth2) first, unofficial tossctl wrapper as fallback",
"license": "MIT",
"main": "src/index.js",

View file

@ -1,131 +0,0 @@
---
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.