Skip to main content
Glama
README.md
# job-posting-mcp

채용공고 수집 + 노션 등록을 하나의 MCP 서버로 묶은 프로젝트.
`db-schema.md`, `사이트 별 정리.md`, `page-template.md`, `준비물.md`의 규칙을 코드로 옮긴 것입니다.

전체 동작 흐름(툴 간 호출 순서, Notion 속성 매핑, 배치 요약/디스코드 알림 내부 동작)을 자세히
정리한 문서는 **[docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md)** 참고. 원티드·사람인 같은
agent-browser 사이트의 실제 수집 절차는 **[docs/browser-collection.md](./docs/browser-collection.md)**.

## 이 서버가 노출하는 툴

| 툴 | 하는 일 |
|---|---|
| `search_jobs` | 사이트 + 키워드로 공고 목록(제목/회사/링크) 검색 — `mcp-playwright` 사이트만 |
| `get_job_detail` | 공고 상세 URL에서 주요업무/자격요건/우대사항/마감일/근무지 추출 — `mcp-playwright` 사이트만 |
| `register_posting` | 수집한 데이터를 노션 "🗃️ 공고 분석" DB에 등록 (중복 시 건너뜀) — **모든 사이트 공통** |
| `send_collection_summary` | `register_posting` 결과들을 모아 티어별 현황을 디스코드로 한 번에 보고 |

## 수집 방식이 사이트마다 다릅니다 (중요)

이 서버는 Playwright(자동화 Chromium)로 직접 페이지를 연다. 그런데 원티드·사람인처럼 자동화
자체를 탐지해 차단하는 사이트가 있어서, **사이트별로 수집 경로가 둘로 나뉜다:**

| 방식 | 대상 | 누가 수집하나 | 무인 자동화 |
|---|---|---|---|
| `mcp-playwright` | 링커리어, 점핏, 잡플래닛 | 이 서버의 `search_jobs`/`get_job_detail` | ✅ 가능 |
| `agent-browser` | 원티드, 사람인 | 에이전트가 `mcp__claude-in-chrome`(크롬 확장)으로 직접 읽고, `register_posting`만 호출 | ❌ 불가 (세션 필요) |
| `unimplemented` | 잡코리아(자체 검색만) | 아직 없음 | - |

`agent-browser` 사이트의 실제 수집 절차는 **[docs/browser-collection.md](./docs/browser-collection.md)**에
정리되어 있다 — 사용자가 "원티드에서 찾아줘" 처럼 직접 요청했을 때, 에이전트가 이 문서를 따라
브라우저를 조작하고 마지막에 `register_posting`만 호출한다.

## 사이트 현황

| 사이트 | collectionMethod | 비고 |
|---|---|---|
| 링커리어 | `mcp-playwright` | 구조 깔끔. 채용 공고 다수가 본문 없이 "홈페이지 지원"(외부 링크)만 있음 |
| 점핏 | `mcp-playwright` | 6곳 중 가장 안정적인 구조. 마감일도 이미 YYYY-MM-DD로 내려옴 |
| 잡플래닛 | `mcp-playwright` | 검색 API는 Node fetch가 TLS 차단당해 Playwright 페이지 안에서 fetch 실행으로 우회. 결과 대다수가 잡코리아 원본 공고 제휴 게시라 상세는 잡코리아 파서를 재사용(아래 참고) |
| 원티드 | `agent-browser` | CDN 레벨 자동화 차단(robots.txt 403). docs/browser-collection.md 참고 |
| 사람인 | `agent-browser` | Playwright/진짜 Chrome 둘 다 TLS 단계에서 차단(`ERR_CONNECTION_RESET`). docs/browser-collection.md 참고 |
| 잡코리아 | `unimplemented`(자체 검색만) | 자체 검색 UI는 필터 패널과 결과 목록이 분리돼 있어 자동화 불가. 대신 상세 페이지 파서(`src/sites/jobkorea.js`)는 구현되어 있고 잡플래닛 검색이 찾은 잡코리아 링크의 본문을 읽는 데 쓰인다 |

새 `mcp-playwright` 사이트를 추가하려면 `src/sites/`에 파일을 만들고
`src/tools/searchJobs.js`/`getJobDetail.js`의 `SITE_MODULES`, `src/config/sites.js`에 등록하면
됩니다. **구현 전에 항상 robots.txt부터 확인**하세요 — 위 표의 사례처럼 "접속은 되는데 정책상
막혀있는" 경우가 실제로 더 많았습니다. 만약 차단이 Playwright 자체를 겨냥한(TLS/CDP 핑거프린팅)
것이라면, `mcp-playwright`가 아니라 `agent-browser`로 분류하고 `docs/browser-collection.md`에
절차를 추가하는 쪽이 맞습니다.

## 시작하기

```bash
npm install          # playwright install chromium까지 자동 실행됨 (postinstall)
cp .env.example .env # NOTION_API_KEY, NOTION_DATA_SOURCE_ID 채우기
npm start             # stdio로 MCP 서버 실행
```

**주의:** `.env`는 절대 커밋하지 마세요 (`.gitignore`에 이미 포함). Notion API 키/디스코드 웹훅
URL이 한 번이라도 평문으로 노출됐다면(문서, 채팅 등) 각각 Notion 설정 > 연동, 디스코드 채널
설정 > 연동 > 웹훅에서 즉시 재발급/재생성하세요. (`discord-webhook.txt`에 있던 실제 웹훅 URL을
`.env`로 옮기며 이 대화에도 값이 그대로 노출됐으니, 이 프로젝트를 공개 레포로 올릴 계획이라면
재생성을 권장합니다.)

## 등록 시 자동으로 처리되는 것들

`register_posting`이 새 페이지를 만들 때(중복이라 건너뛴 경우는 해당 없음) `job` 객체와
무관하게 서버가 직접 채우는 값:

- **`created_at`** — "언제 이 서버가 수집해서 노션에 넣었는지"를 나타내는 타임스탬프. 호출자가
  넣는 값이 아니라 `register_posting` 실행 시점의 현재 시각을 서버가 직접 찍는다. 공고 자체의
  게시일과는 다른 개념이다 (아래 참고).

`job.postedDate`(선택, `YYYY-MM-DD`)를 넘기면 "**공고 등록일**"(공고 사이트에 실제 게시된 날짜)에
채워진다 — 사이트에 명시된 경우만 채우고, 없으면 생략한다 (추측 금지 원칙은 마감일과 동일).

## 디스코드 알림은 등록마다가 아니라 배치 끝에 한 번만

`register_posting`은 호출될 때마다 Discord로 알림을 보내지 **않는다** — 30건을 등록하면
30개의 메시지가 아니라 아무 메시지도 안 나간다. 대신:

1. 에이전트가 `register_posting`을 후보마다 호출하면서, 그때마다 돌아오는 JSON 결과
   (`{status, company, companySize, newOptions, ...}`)를 자기 컨텍스트의 배열에 쌓아둔다.
2. 수집이 다 끝나면 그 배열 전체를 `send_collection_summary`에 한 번만 넘긴다.
3. 이 툴이 예전 스킬(`discord-webhook.md`)과 같은 형식 — 검토/신규/중복/조건불일치 건수,
   **티어별 등록 현황**(1티어 대기업·공기업·외국계 / 2티어 중견·벤처 / 3티어 중소·스타트업,
   `src/config/notionSchema.js`의 `COMPANY_SIZE_TIER`), 🆕 새로 생성된 select 옵션(전형단계 등
   자유 입력 필드에서 자동 감지, `detectNewOptions`), ⚠️ 확인 필요 항목 — 으로 메시지 하나를
   조립해 디스코드로 보낸다.

웹훅 미설정이거나 전송이 실패해도 **이미 끝난 Notion 등록들은 절대 되돌리지 않는다** —
`send_collection_summary`의 반환 메시지에 전송 여부만 보고된다 (`src/discord/notify.js`,
`src/discord/summary.js`).

## 동작 확인

```bash
npm run smoke:linkareer   # 읽기 전용, 안전
npm run smoke:jumpit      # 읽기 전용, 안전
npm run smoke:jobplanet   # 읽기 전용, 안전 (검색 → 잡코리아 GI_Read 상세까지 검증)
npm run smoke:wanted      # agent-browser 방식이라 "이 툴로 수집할 수 없다"는 안내 에러가 나는 게 정상
npm run smoke:saramin     # agent-browser 방식이라 "이 툴로 수집할 수 없다"는 안내 에러가 나는 게 정상
npm run test:register     # 노션 DB에 진짜 테스트 페이지 1개를 생성함 — 끝나면 지워도 됨
npm run test:discord      # created_at/공고 등록일/디스코드 알림까지 한 번에 검증 — 마찬가지로 실제 등록됨
```

## 트러블슈팅

**CloudFront 403 — Playwright 기본 헤드리스 UA가 차단당함 (원티드)**
CloudFront 뒤에 있는 사이트에 Playwright 기본 헤드리스 Chromium UA로 접속하면
`403 The request could not be satisfied`를 반환한다. 실제 Chrome과 비슷한 User-Agent +
`Accept-Language` 헤더를 주고 `--disable-blink-features=AutomationControlled` 플래그로
`navigator.webdriver` 탐지를 끄면 통과한다 (`src/browser.js`) — 다만 이건 명시적 차단을
우회하는 것이므로 자동화 파이프라인에는 쓰지 않기로 했다 (사이트 구현 현황 표 참고).

**`net::ERR_CONNECTION_RESET` — UA 위장으로도 못 뚫는 더 깊은 차단 (사람인)**
같은 UA로 `curl`은 200이 오는데 Playwright(자동화 Chromium)로는 연결 자체가 끊긴다.
헤더가 아니라 TLS/CDP 핑거프린팅 기반으로 자동화 브라우저 자체를 탐지하는 것으로 보인다
(Akamai류로 추정). 이 이상은 스텔스 플러그인 등 "탐지 자체를 회피"하는 기법이 필요해서 보류.

**robots.txt가 실제 검색 URL만 콕 집어 막아놓은 경우 (잡코리아)**
사이트 접속과 크롤링 자체는 문제없지만, robots.txt의 일반(`*`) 규칙에 실제 검색 엔드포인트
(`/Search/?stext=`)가 `Disallow`로 명시되어 있었다. AI 크롤러 전용 규칙은 오히려 더 관대해서
(`/recruit/joblist` 등 허용) 헷갈리기 쉬운데, 그 경로는 카테고리 브라우징 전용이라 키워드
검색을 대체하지 못했다. **robots.txt는 섹션별로(일반 `*` / 이름 붙은 AI 크롤러) 다른 규칙을
가질 수 있으니 둘 다 확인해야 한다.**

**`networkidle`이 상세 페이지에서 타임아웃남**
Sentry/GA/카카오픽셀 같은 애널리틱스가 백그라운드에서 계속 요청을 쏴서 네트워크가 절대
"idle" 상태가 되지 않는다. `domcontentloaded`로 받은 뒤, 원하는 콘텐츠(예: "주요업무" 제목)가
실제로 나타날 때까지 `waitForFunction`/`waitForSelector`로 명시적으로 기다리는 쪽이 안정적이다.

**`waitForSelector`가 통과해도 `evaluate`는 빈 배열 (점핏)**
`state: "attached"`로 기다린 셀렉터가 실제로 매치됐는데도, 바로 이어서 `page.evaluate()`로
같은 셀렉터를 다시 찾으면 아무것도 안 나오는 경우가 있었다. React 하이드레이션 전 스켈레톤
DOM이 "attached" 조건은 통과시키고, 직후 클라이언트 렌더링이 그 노드들을 통째로 교체해버려서
생긴 레이스 컨디션으로 보인다. `waitForSelector` 뒤에 짧은 `waitForTimeout`(1~1.5초)을
추가해서 해결했다 — SPA 사이트를 스크래핑할 때 일반적으로 의심해볼 만한 패턴이다.

**같은 iframe인데 다른 공고 내용이 나옴 (사람인)**
상세 페이지 하단에 "추천 공고" 미리보기가 각각 자기 iframe을 갖고 있어서, 대상 공고의 실제
설명은 반드시 **첫 번째** iframe(`iframe_content_0`)이라는 걸 직접 비교해보고야 확인했다.
인덱스를 확인하지 않고 아무 iframe이나 읽으면 엉뚱한 회사의 JD를 가져오게 된다.

**공고 상세에 본문이 없는 경우가 생각보다 많음**
사이트 별 정리.md가 이미 경고했던 내용이지만 실제로 부딪혀보니 체감 빈도가 높았다: 이미지
한 장으로만 된 공고(사람인/잡코리아), "홈페이지 지원"으로 본문 없이 외부 링크만 있는 공고
(링커리어/잡코리아 대기업), 교육/연수 프로그램이라 통상적인 담당업무/자격요건 구조 자체가
없는 공고. `get_job_detail`은 이런 경우 duties/requirements를 억지로 채우지 않고 빈 배열을
돌려주도록 설계했다 — page-template.md의 "공고에 미기재" 규칙과 자연스럽게 맞물린다.

## Claude Code / 다른 MCP 클라이언트에 연결하기

Claude Code라면 프로젝트 MCP 설정에 아래처럼 추가합니다 (경로는 실제 설치 위치로 변경):

```json
{
  "mcpServers": {
    "job-posting-mcp": {
      "command": "node",
      "args": ["/absolute/path/to/job-posting-mcp/src/index.js"]
    }
  }
}
```

MCP는 모델에 종속되지 않는 표준 프로토콜이라, Antigravity 등 다른 MCP 호환 클라이언트에도
같은 방식으로 연결할 수 있습니다.

## 데이터 흐름

```
search_jobs(site, keyword)
  → get_job_detail(site, link)          # 후보마다 반복
    → (적합도 판단은 이 서버 바깥, 에이전트/오케스트레이션 레이어의 몫)
      → register_posting({...})          # 노션 DB에 실제 등록 (created_at 자동 기록)
        └→ 결과(JSON)를 에이전트가 배열에 누적                     ← 후보마다 반복 구간 끝
  → send_collection_summary({ results: [...] })  # 배치 끝나면 딱 한 번, 티어별 요약을 디스코드로
```

MCP 서버는 "도구"만 제공합니다. "이 공고가 신입 프론트엔드에 적합한가?", "언제 실행할까?" 같은
판단/스케줄링 로직은 이 서버가 아니라 이 서버를 호출하는 에이전트(Claude Code 스킬, Antigravity
서브에이전트 등) 쪽 책임입니다.

TDQS

A4.4/5.0

Scored across 4 tools

Disambiguation5/5

Each tool maps to a distinct stage in a clear pipeline: searching job listings, fetching details, registering postings, and sending a summary. There is no overlap or ambiguity between tool purposes.

Naming Consistency5/5

All tool names follow a consistent verb_noun pattern: search_jobs, get_job_detail, register_posting, send_collection_summary. The style is uniform and predictable across the entire tool set.

Tool Count5/5

Four tools is well-scoped for this workflow: search, detail extraction, registration, and notification. Each tool serves a necessary step without redundancy or bloat.

Completeness5/5

The tool set covers the full collection lifecycle from searching and extracting details to registering in Notion and reporting via Discord. There are no obvious dead ends or missing operations for the stated purpose.

Maintenance

ActivityMaintained
ResponsivenessNo issues