@nomadamas/k-skill 0.2.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/gangnamunni-clinic-search/instruction.md +16 -8
- package/skills/hankookilbo-news/instruction.md +143 -0
- package/skills/hankookilbo-news/skill.json +8 -0
- package/skills/kosis-stats/instruction.md +24 -3
- package/skills/kosis-stats/references/kosis-openapi-guide.md +5 -1
- package/skills/kosis-stats/scripts/run_kosis_stats.py +1 -1
- package/skills/naver-blog-research/scripts/naver_search.py +6 -1
package/package.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
## What this skill does
|
|
4
4
|
|
|
5
|
-
강남언니(Gangnam Unni) 웹
|
|
5
|
+
강남언니(Gangnam Unni) 웹 병원 목록 페이지의 **비로그인 공개 Next.js payload**를 읽어 병원 후보를 조회한다.
|
|
6
6
|
|
|
7
7
|
- 키워드로 병원 후보를 검색한다.
|
|
8
|
-
- 공개 검색 결과에 포함된 평점,
|
|
8
|
+
- 공개 검색 결과에 포함된 평점, 리뷰 수, 지역/역, 거리, 공개 이미지, 병원 링크를 정리한다.
|
|
9
9
|
- 예약, 상담, 결제, 리뷰 작성, 앱 로그인 등 사용자 계정이 필요한 액션은 하지 않는다.
|
|
10
10
|
|
|
11
11
|
## When to use
|
|
@@ -42,11 +42,13 @@
|
|
|
42
42
|
|
|
43
43
|
## Public Gangnam Unni surface
|
|
44
44
|
|
|
45
|
-
-
|
|
46
|
-
-
|
|
45
|
+
- primary hospital list: `https://www.gangnamunni.com/hospitals?q=<keyword>`
|
|
46
|
+
- primary payload: `<script id="__NEXT_DATA__" type="application/json">...props.pageProps.dehydratedState.queries[*].state.data.pages[*].data...</script>`
|
|
47
|
+
- query selector: `queryKey[0] === "infinite-search-hospitals"`
|
|
48
|
+
- same-payload legacy field fallback: 이전 `/search?q=<keyword>` HTML에서 쓰던 `props.pageProps.hospitals`
|
|
47
49
|
- public hospital URL: `https://www.gangnamunni.com/hospitals/<id>`
|
|
48
50
|
|
|
49
|
-
Discovery result: `curl`/Node fetch로 비로그인
|
|
51
|
+
Discovery result: `curl`/Node fetch로 `/hospitals?q=` 비로그인 HTML이 200으로 응답하고, 병원 후보는 server-rendered `__NEXT_DATA__`의 react-query `dehydratedState`에 포함된다. 파서가 이전 `/search?q=` HTML의 `pageProps.hospitals`도 읽지만, `searchClinics`가 `/search`를 두 번째로 요청하지는 않는다. 이 경로는 공개 read-only endpoint이므로 `k-skill-proxy`를 사용하지 않는다.
|
|
50
52
|
|
|
51
53
|
## Workflow
|
|
52
54
|
|
|
@@ -71,15 +73,20 @@ npx gangnamunni-clinic-search "강남 성형외과" --limit 5
|
|
|
71
73
|
|
|
72
74
|
- `name`: 병원명
|
|
73
75
|
- `rating`, `ratingCount`, `reviewCount`: 공개 검색 페이지에 포함된 평점/리뷰 지표
|
|
76
|
+
- `integratedReviewCount`, `eventCount`: 통합 리뷰 수와 공개 이벤트 수
|
|
77
|
+
- `district`, `subwayStation`, `distanceWithUnit`: 공개 지역/역/거리 정보. upstream이 값을 주지 않으면 필드가 생략될 수 있다.
|
|
78
|
+
- `latitude`, `longitude`: upstream 병원 객체에 공개 좌표가 있을 때만 숫자로 반환한다. 현재 `/hospitals?q=` 응답에는 없을 수 있다.
|
|
74
79
|
- `languages`: 공개 지원 언어
|
|
75
80
|
- `url`: 강남언니 공개 병원 페이지
|
|
76
81
|
- `profileImage`, `mainImage`: 공개 이미지 URL
|
|
77
82
|
|
|
78
83
|
### 3. Fallback order
|
|
79
84
|
|
|
80
|
-
1. 기본: `https://www.gangnamunni.com/
|
|
81
|
-
2. payload
|
|
82
|
-
3.
|
|
85
|
+
1. 기본: `https://www.gangnamunni.com/hospitals?q=<keyword>`의 `__NEXT_DATA__`에서 `infinite-search-hospitals` query의 모든 `pages[*].data`를 합친다.
|
|
86
|
+
2. 입력 payload 자체에 이전 구조의 `props.pageProps.hospitals`가 있으면 같은 payload 안에서 fallback으로 읽는다. `/search`를 추가 요청하지 않는다.
|
|
87
|
+
3. payload가 없으면 로그인벽, CAPTCHA, 차단, 빈 HTML shell을 실패 모드로 분류한다.
|
|
88
|
+
4. `pageProps.totalLength` 또는 dehydrated page의 `recordsTotal`이 0보다 큰데 파싱 가능한 병원이 하나도 없으면 `failureMode: "empty-shell"`과 구조 변경 경고를 반환한다.
|
|
89
|
+
5. 검색 결과가 너무 적거나 앱 전용 정보가 필요하면 자동화를 멈추고 사용자가 공식 앱/웹에서 직접 확인하도록 안내한다.
|
|
83
90
|
|
|
84
91
|
### 4. Respond safely
|
|
85
92
|
|
|
@@ -101,6 +108,7 @@ npx gangnamunni-clinic-search "강남 성형외과" --limit 5
|
|
|
101
108
|
|
|
102
109
|
- 검색어가 너무 넓거나 강남언니가 병원 후보를 공개 payload에 일부만 넣을 수 있다.
|
|
103
110
|
- 강남언니 웹 구조가 바뀌면 `__NEXT_DATA__` 경로가 깨질 수 있다.
|
|
111
|
+
- 사이트가 결과 수를 보고하지만 병원 항목을 파싱할 수 없으면 `empty-shell` 실패로 보고한다.
|
|
104
112
|
- 로그인 필요, CAPTCHA, 접근 차단, 빈 HTML shell은 자동 우회하지 않고 실패로 보고한다.
|
|
105
113
|
- 평점, 리뷰 수, 노출 순서는 시점에 따라 달라진다.
|
|
106
114
|
- 앱 전용/로그인 전용 정보는 비로그인 공개 조회만으로 확정할 수 없다.
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# 한국일보 뉴스 조회
|
|
2
|
+
|
|
3
|
+
## What this skill does
|
|
4
|
+
|
|
5
|
+
한국일보가 운영하는 공식 원격 MCP 서버를 직접 호출해 기사 메타데이터를 조회한다.
|
|
6
|
+
|
|
7
|
+
- 엔드포인트: `https://mcp.hankookilbo.com/mcp` (Streamable HTTP MCP, 인증 불필요)
|
|
8
|
+
- 공식 MCP Registry 등재명: `com.hankookilbo.mcp/hankookilbo-mcp`
|
|
9
|
+
- 도구 10종 — 편집 헤드라인, 많이 본, 꼼꼼히 본, 시간대 추천, 최신, 섹션별 편집 추천, 주제 검색, 섹션 목록, 오늘의 운세, MBTI 운세
|
|
10
|
+
- 반환값은 기사 메타데이터다: 기사 ID, 제목, 원문 URL, 썸네일 URL, 발행시각, 기사 유형, 원문 접근성, 짧은 발췌(`excerpt`)
|
|
11
|
+
- 기사 본문 전문은 반환하지 않는다
|
|
12
|
+
|
|
13
|
+
서버가 무상태라 `initialize` 핸드셰이크와 세션 ID 없이 단일 POST 로 `tools/call` 이 동작한다. MCP SDK 를 설치하지 않고 `curl` 로 호출한다.
|
|
14
|
+
|
|
15
|
+
## When to use
|
|
16
|
+
|
|
17
|
+
- "한국일보 헤드라인 보여줘"
|
|
18
|
+
- "한국일보에서 많이 본 기사"
|
|
19
|
+
- "오늘 한국일보 정치면 추천 기사"
|
|
20
|
+
- "한국일보에 이 사건 기사 있어?"
|
|
21
|
+
- "오늘의 운세", "MBTI 운세" (한국일보 지면 기준)
|
|
22
|
+
- 한국일보가 무엇을 머리기사로 올렸는지, 즉 편집 판단 자체가 필요할 때
|
|
23
|
+
|
|
24
|
+
## When not to use
|
|
25
|
+
|
|
26
|
+
- 언론사를 가리지 않는 일반 뉴스 키워드 검색 — 이 서버는 한국일보 기사만 반환한다
|
|
27
|
+
- 기사 본문 전문이 필요할 때 — 이 스킬은 메타데이터만 반환한다. 본문은 `item.url` 원문에서만 볼 수 있다
|
|
28
|
+
- 한국경제(한경)나 다른 언론사 기사 — 이 서버는 종합일간지 한국일보 전용이다
|
|
29
|
+
- 로그인 뒤에만 보이는 기사의 본문 — `item.view_type` 이 `LoginWall` 이면 원문 열람에 로그인이 필요하다
|
|
30
|
+
- 주식·환율 시세, 실시간 데이터 — 이 서버는 기사만 다룬다
|
|
31
|
+
|
|
32
|
+
## Endpoint contract
|
|
33
|
+
|
|
34
|
+
- POST 만 쓴다. GET·PUT·DELETE·OPTIONS 는 405 다.
|
|
35
|
+
- `Accept` 헤더에 `application/json` 과 `text/event-stream` 을 **둘 다** 넣는다. MCP Streamable HTTP 규격이 클라이언트에 요구하는 사항이고, 서버 응답이 나중에 SSE 로 바뀌어도 깨지지 않는다. `Accept` 를 아예 빼면 406 이다.
|
|
36
|
+
- `initialize`, `notifications/initialized`, `Mcp-Session-Id` 는 필요 없다.
|
|
37
|
+
- `User-Agent` 를 `k-skill-hankookilbo/1.0` 으로 보낸다. 한국일보 쪽에서 k-skill 경유 트래픽을 분리 관측한다.
|
|
38
|
+
|
|
39
|
+
기본 호출:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
curl -fsS --max-time 35 https://mcp.hankookilbo.com/mcp \
|
|
43
|
+
-H 'Content-Type: application/json' \
|
|
44
|
+
-H 'Accept: application/json, text/event-stream' \
|
|
45
|
+
-H 'User-Agent: k-skill-hankookilbo/1.0' \
|
|
46
|
+
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_top_headlines","arguments":{}}}'
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
기사 목록은 `result.structuredContent.items` 에 들어온다. `result.content[0].text` 는 사람이 읽는 요약본이다. 목록을 정리할 때는 `structuredContent` 를 쓴다.
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
curl -fsS --max-time 35 https://mcp.hankookilbo.com/mcp \
|
|
53
|
+
-H 'Content-Type: application/json' \
|
|
54
|
+
-H 'Accept: application/json, text/event-stream' \
|
|
55
|
+
-H 'User-Agent: k-skill-hankookilbo/1.0' \
|
|
56
|
+
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_popular_news","arguments":{"section_cd":"economy","page_size":5}}}' \
|
|
57
|
+
| python3 -c 'import json,sys
|
|
58
|
+
for i in json.load(sys.stdin)["result"]["structuredContent"]["items"]:
|
|
59
|
+
print(i["published_at"], "|", i["title"], "|", i["url"])'
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
MCP 클라이언트에 직접 등록해도 된다. 이 경로에서는 서버가 보내는 instructions 도 함께 전달된다.
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
claude mcp add --transport http hankookilbo https://mcp.hankookilbo.com/mcp
|
|
66
|
+
codex mcp add hankookilbo --url https://mcp.hankookilbo.com/mcp
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## 도구 선택
|
|
70
|
+
|
|
71
|
+
| 사용자 발화 | 도구 | 인자 |
|
|
72
|
+
| --- | --- | --- |
|
|
73
|
+
| "헤드라인", "오늘 주요 뉴스", "제일 중요한 뉴스" | `list_top_headlines` | 없음 |
|
|
74
|
+
| "많이 본", "인기 뉴스", "지금 뜨는" | `list_popular_news` | `section_cd`, `page_size`, `exclude_section_cd` (전부 선택) |
|
|
75
|
+
| "꼼꼼히 본", "정독할 만한" | `list_most_read_news` | 없음 |
|
|
76
|
+
| "지금 볼만한", "출근길에 볼 뉴스" | `list_timely_news` | 없음 |
|
|
77
|
+
| "최신 기사", "방금 올라온" | `list_latest_news` | `page_num`, `page_size` (선택) |
|
|
78
|
+
| 섹션별 편집 추천 | `list_recommended_articles` | `section_cd` (필수), `page_size` |
|
|
79
|
+
| 주제·인물·사건 검색, "왜 화제야" | `search_news` | `query` (필수), `limit` |
|
|
80
|
+
| 섹션 코드 확인 | `list_sections` | 없음 |
|
|
81
|
+
| "오늘의 운세", "띠별 운세" | `get_daily_horoscope` | `date` (선택) |
|
|
82
|
+
| "MBTI 운세" | `get_mbti_horoscope` | `date` (선택) |
|
|
83
|
+
|
|
84
|
+
`section_cd` 값:
|
|
85
|
+
|
|
86
|
+
- `politics` 정치
|
|
87
|
+
- `economy` 경제
|
|
88
|
+
- `society` 사회
|
|
89
|
+
- `world` 국제
|
|
90
|
+
- `culture` 문화
|
|
91
|
+
- `sports` 스포츠
|
|
92
|
+
- `life` 라이프
|
|
93
|
+
- `people` 사람
|
|
94
|
+
- `local` 지역
|
|
95
|
+
- `opinion` 오피니언
|
|
96
|
+
|
|
97
|
+
값을 추측하지 말고 불확실하면 `list_sections` 로 확인한다.
|
|
98
|
+
|
|
99
|
+
세 가지 목록이 서로 다르다.
|
|
100
|
+
|
|
101
|
+
- `list_top_headlines` — 편집부가 비중 있게 배치한 머리기사
|
|
102
|
+
- `list_popular_news` — 조회수 기준 인기 순위. "많이 본"은 이쪽이다
|
|
103
|
+
- `list_recommended_articles` — 섹션별 편집 추천. 순위 값을 갖지 않는다
|
|
104
|
+
|
|
105
|
+
## Workflow
|
|
106
|
+
|
|
107
|
+
1. 요청을 위 표로 분류한다. 섹션이 필요한데 불확실하면 `list_sections` 를 먼저 호출한다.
|
|
108
|
+
2. `curl` 로 해당 도구를 호출한다.
|
|
109
|
+
3. `result.structuredContent.items` 상위 3~5건을 제목·발행시각·원문 링크로 정리한다.
|
|
110
|
+
4. `search_news` 는 응답이 30초에 가까울 수 있다. `--max-time 35` 를 주고, 타임아웃되면 재시도하지 말고 질의를 좁혀 다시 묻는다.
|
|
111
|
+
|
|
112
|
+
## Response style
|
|
113
|
+
|
|
114
|
+
- **기사 제목은 `item.title` 원문을 그대로 인용한다.** 요약·의역·말줄임·기호 변경·맞춤법 교정을 거치면 실제 보도 제목과 달라진다. `[제목](url)` 형태가 원문 표기와 가장 일치한다.
|
|
115
|
+
- `item.excerpt` 는 기사 도입부 일부이고 기사 전체 요약이 아니다. 발췌에 없는 해석·평가·배경·결말을 이 데이터로 단정하지 않는다.
|
|
116
|
+
- 본문 전문은 `item.url` 에서만 확인할 수 있다. 본문 내용을 재구성하지 않고 링크를 제시한다.
|
|
117
|
+
- `item.view_type` 이 `LoginWall` 이면 원문 열람에 로그인이 필요하다고 함께 알린다.
|
|
118
|
+
- `items` 가 비어 있지 않다는 것은 그 조건의 기사가 존재한다는 뜻이다. 목록을 임의로 누락시키지 않는다.
|
|
119
|
+
- 운세 도구는 운세 내용을 반환하지 않는다. 제목·발행일·원문 링크만 전달하고 운세 내용은 원문 링크로 넘긴다.
|
|
120
|
+
- 원문 URL 에는 서버가 `?did=mcp` 유입 파라미터를 붙여 돌려준다. 링크를 제시할 때 이 파라미터를 지우지 않는다.
|
|
121
|
+
|
|
122
|
+
## Failure modes
|
|
123
|
+
|
|
124
|
+
- `406 Not Acceptable` — `Accept` 헤더가 없다. `application/json, text/event-stream` 을 채워 재시도한다.
|
|
125
|
+
- `405 Method Not Allowed` — POST 가 아닌 메서드를 썼다. POST 로 바꾼다.
|
|
126
|
+
- `result.isError: true` — 도구 인자가 잘못됐다. `section_cd` 오타, `query` 누락을 확인한다.
|
|
127
|
+
- `504` — `search_news` 가 제한 시간을 넘겼다. 재시도 루프를 만들지 말고 질의를 좁힌다.
|
|
128
|
+
- 빈 `items` — 해당 조건의 기사가 없다. 섹션·질의를 바꿔 다시 시도하거나 없다고 답한다.
|
|
129
|
+
- 연결 실패 — 한국일보 측 서버 장애다. 재시도 루프를 만들지 않고 현재 조회 불가임을 분명히 말한다.
|
|
130
|
+
|
|
131
|
+
## Privacy
|
|
132
|
+
|
|
133
|
+
- 인증·로그인·개인화가 없는 공개 조회 전용이다. 시크릿을 요구하지 않는다.
|
|
134
|
+
- 기사 본문을 요청하거나 저장하지 않는다.
|
|
135
|
+
- 검색어와 결과를 영구 저장하지 않는다.
|
|
136
|
+
|
|
137
|
+
## Done when
|
|
138
|
+
|
|
139
|
+
- 요청 유형에 맞는 도구를 골랐다.
|
|
140
|
+
- `structuredContent.items` 기준으로 상위 후보를 제목·발행시각·원문 링크로 정리했다.
|
|
141
|
+
- 제목을 원문 그대로 인용하고 `[제목](url)` 링크를 제시했다.
|
|
142
|
+
- 본문이 필요한 요청이면 본문은 원문 링크에서만 볼 수 있다고 안내했다.
|
|
143
|
+
- 실패 시 재시도 루프 없이 원인을 알렸다.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hankookilbo-news",
|
|
3
|
+
"description": "한국일보 공식 원격 MCP 서버(mcp.hankookilbo.com)를 인증 없이 직접 호출해 편집 헤드라인·많이 본·꼼꼼히 본·최신 기사·섹션별 편집 추천·주제 검색·오늘의 운세를 기사 메타데이터(제목·발행시각·원문 링크) 중심으로 조회한다.",
|
|
4
|
+
"profiles": [
|
|
5
|
+
"lookup"
|
|
6
|
+
],
|
|
7
|
+
"frontmatter": "name: hankookilbo-news\ndescription: 한국일보 공식 원격 MCP 서버(mcp.hankookilbo.com)를 인증 없이 직접 호출해 편집 헤드라인·많이 본·꼼꼼히 본·최신 기사·섹션별 편집 추천·주제 검색·오늘의 운세를 기사 메타데이터(제목·발행시각·원문 링크) 중심으로 조회한다.\nlicense: MIT\nmetadata:\n category: information\n locale: ko-KR\n phase: v1"
|
|
8
|
+
}
|
|
@@ -132,12 +132,19 @@ npx -y @nomadamas/k-skill@0 exec kosis-stats scripts/run_kosis_stats.py -- searc
|
|
|
132
132
|
|
|
133
133
|
### 3. Inspect the table meta before fetching data
|
|
134
134
|
|
|
135
|
-
데이터를 받기 전에
|
|
135
|
+
데이터를 받기 전에 분류/단위를 확인한다.
|
|
136
136
|
|
|
137
137
|
```bash
|
|
138
138
|
npx -y @nomadamas/k-skill@0 exec kosis-stats scripts/run_kosis_stats.py -- meta --table-id DT_1JC1501 --text
|
|
139
139
|
```
|
|
140
140
|
|
|
141
|
+
**수록주기는 `meta` 로 확정되지 않는다.** `meta --meta-type TBL` 은 표 명칭(국/영문) 위주라 `--prd-se` 에 넣을 주기를 돌려주지 않고, `search` 응답의 `PRD_SE` 는 `--prd-se` 와 코드 체계가 달라(예: `DT_1IN0001` → `PRD_SE=A` 이지만 실제 조회는 `F`) 그대로 쓰면 안 된다. `STRT_PRD_DE`~`END_PRD_DE` 도 수록 범위일 뿐 간격을 알려주지 않는다. 조사주기는 `explain` 으로 확인하거나 §4 의 프로브로 확정한다.
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
npx -y @nomadamas/k-skill@0 exec kosis-stats scripts/run_kosis_stats.py -- explain \
|
|
145
|
+
--org-id 101 --table-id DT_1IN0001 --meta-itm All --text
|
|
146
|
+
```
|
|
147
|
+
|
|
141
148
|
### 4. Fetch a small bounded slice first
|
|
142
149
|
|
|
143
150
|
`--prd-se`, `--start`, `--end`, `--obj-l` 으로 범위를 좁혀 작은 슬라이스를 먼저 조회한다.
|
|
@@ -148,6 +155,16 @@ npx -y @nomadamas/k-skill@0 exec kosis-stats scripts/run_kosis_stats.py -- data
|
|
|
148
155
|
--obj-l 1=ALL --json
|
|
149
156
|
```
|
|
150
157
|
|
|
158
|
+
**`--prd-se` 를 먼저 확정한다.** 주기를 틀리면 다른 파라미터가 전부 맞아도 KOSIS는 코드 `30`(결과 없음)만 돌려주며, 메시지로는 원인을 알 수 없다. `explain`(§3)으로 조사주기를 확인하거나, 좁은 기간·최소 분류로 `Y` → `F` → `IR` → `M`/`Q`/`S` 순으로 프로브해 데이터가 나오는 코드를 찾은 뒤 본 조회를 한다. 총조사·인구주택총조사처럼 5년 간격인 표는 연 단위로 보여도 `F`(다년)다.
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
for p in Y F IR; do
|
|
162
|
+
npx -y @nomadamas/k-skill@0 exec kosis-stats scripts/run_kosis_stats.py -- data \
|
|
163
|
+
--org-id 101 --table-id DT_1IN0001 --prd-se "$p" \
|
|
164
|
+
--start 2010 --end 2010 --itm-id ALL --obj-l 1=00 --obj-l 2=00 --text
|
|
165
|
+
done
|
|
166
|
+
```
|
|
167
|
+
|
|
151
168
|
표마다 필수 분류 차원 수가 다르다. **default `--obj-l 1=ALL` 만으로는 부족한 표가 많다.** KOSIS가 코드 `20` (필수요청변수값 누락 objL)을 돌려주면, `meta --table-id <ID> --meta-type ITM --json` 으로 ITM 안에 들어 있는 `OBJ_ID`(분류 차원)와 코드를 확인한 뒤 `--obj-l 1=<코드> --obj-l 2=<코드>` 형태로 필요한 차원을 모두 지정한다. (많은 표가 OBJ 메타는 비어 있고 분류가 ITM 안에 들어 있음.)
|
|
152
169
|
|
|
153
170
|
40,000셀을 초과하면 KOSIS는 에러 코드 `31` 또는 `41` 을 반환한다. 기간을 좁히거나(예: 5년→1년) 분류 필터의 ALL 을 특정 코드로 바꿔(예: `--obj-l 1=11` 서울만) 호출을 분할한다. 그래도 부족하면 사용자별 통계자료(`userStatsId`)를 등록해 `bigdata` 서브커맨드를 사용한다.
|
|
@@ -171,7 +188,7 @@ npx -y @nomadamas/k-skill@0 exec kosis-stats scripts/run_kosis_stats.py -- bigda
|
|
|
171
188
|
## Done when
|
|
172
189
|
|
|
173
190
|
- 사용자 질문에 대응하는 통계표 ID(`org_id`/`tbl_id`)가 명확하다.
|
|
174
|
-
- 메타데이터를 1회 이상 조회해
|
|
191
|
+
- 메타데이터를 1회 이상 조회해 분류·단위를 확인했고, `--prd-se` 는 실제 데이터가 나오는 코드로 확정했다.
|
|
175
192
|
- 작은 슬라이스부터 단계적으로 데이터를 받았다.
|
|
176
193
|
- 결과에 출처(table id, 기간, 단위, endpoint)를 명시했다.
|
|
177
194
|
- 한도 초과 시 분할 또는 `bigdata` 안내로 처리했다.
|
|
@@ -182,7 +199,10 @@ npx -y @nomadamas/k-skill@0 exec kosis-stats scripts/run_kosis_stats.py -- bigda
|
|
|
182
199
|
- KOSIS 에러 코드 `10`/`11`: 인증키 누락/만료 → 키 점검. `bigdata` 에서 `11` 이 나오면 `userStatsId` 가 본인 KOSIS 계정에 등록된 것이 아닐 가능성이 크다.
|
|
183
200
|
- 코드 `20`: 필수 분류 누락 → `meta --meta-type OBJ` (또는 비어 있으면 `ITM`) 으로 필요한 차원 수와 코드를 확인하고 `--obj-l 1=... --obj-l 2=...` 모두 지정 후 재시도
|
|
184
201
|
- 코드 `21`: 잘못된 요청 변수 → `org_id`/`tbl_id`/기간 형식 재확인. tblId 의심 시 `search` 로 정확한 ID 다시 찾기
|
|
185
|
-
- 코드 `30`: 결과
|
|
202
|
+
- 코드 `30`: 결과 없음. 어느 서브커맨드에서 났는지로 원인이 갈린다.
|
|
203
|
+
- **`data` 에서 30 → 먼저 `--prd-se` 오지정을 의심한다.** 표의 실제 수록주기와 다른 코드를 주면 `--start`/`--end`/`--obj-l` 이 전부 맞아도 항상 30 이다. 연 단위처럼 보이는 표라도 총조사·센서스류는 다년주기 `F`(2·3·4·5·10년) 또는 부정기 `IR` 인 경우가 많다. `explain` 으로 조사주기를 확인하거나 `Y` → `F` → `IR` → `M`/`Q`/`S` 순으로 프로브해 확정한다 (§4 참고).
|
|
204
|
+
- `search` 에서 30 → 키워드를 더 짧게 또는 다른 표현으로 바꾸거나 기간/분류 완화
|
|
205
|
+
- **`meta` 에서 30** → 표가 해당 메타 타입을 지원하지 않는 경우이므로 다른 `--meta-type` 시도
|
|
186
206
|
- 코드 `31`/`41`: 한도 초과 → 기간 좁히기, 분류 ALL 을 특정 코드로 바꾸기, 또는 `bigdata` 사용
|
|
187
207
|
- 코드 `40`: 분당 1,000건 호출 한도 → 잠시 대기
|
|
188
208
|
- 코드 `50`: KOSIS 서버 오류 → 1~2초 후 재시도
|
|
@@ -194,6 +214,7 @@ npx -y @nomadamas/k-skill@0 exec kosis-stats scripts/run_kosis_stats.py -- bigda
|
|
|
194
214
|
|
|
195
215
|
- 코드 20 회복: `data --table-id DT_1J22001 --prd-se M --start 202401 --end 202401` → 코드 20 → `meta --table-id DT_1J22001 --meta-type ITM --json` 으로 차원 확인 → `data ... --obj-l 1=T10 --obj-l 2=0` 재호출 → 성공
|
|
196
216
|
- 코드 31 회복: `data --table-id DT_1B26001 --prd-se Y --start 2020 --end 2024 --obj-l 1=ALL --obj-l 2=ALL --obj-l 3=ALL` → 코드 31 → `... --start 2024 --end 2024 --obj-l 1=11 --obj-l 2=ALL --obj-l 3=ALL` (서울만) 재호출 → 성공
|
|
217
|
+
- 코드 30 회복(수록주기 오지정): `data --org-id 101 --table-id DT_1IN0001 --prd-se Y --start 1990 --end 2010 --itm-id ALL --obj-l 1=00 --obj-l 2=00` → 코드 30 → 연도를 2015·2010·2005·2000·1995·1990 으로 바꿔 재시도해도 전부 코드 30, `--prd-se IR` 도 코드 30 → `--prd-se F --start 1925 --end 2010` 으로 재호출 → 성공(수록 시점 18개, 72셀). `DT_1IN0001`(총조사인구 총괄)은 연 단위 표처럼 보이지만 5년 간격 총조사라 `F` 로만 조회된다. 이 표는 `meta --meta-type TBL` 에도 주기가 없고 `search` 응답 `PRD_SE` 는 `A` 로 나와 둘 다 `--prd-se` 값으로 쓸 수 없었다.
|
|
197
218
|
|
|
198
219
|
## Maintainer review notes
|
|
199
220
|
|
|
@@ -54,10 +54,12 @@ GET https://kosis.kr/openapi/statisticsSearch.do
|
|
|
54
54
|
&searchNm=인구&resultCount=20&startCount=1
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
-
응답 필드(주요): `ORG_ID`, `ORG_NM`, `TBL_ID`, `TBL_NM`, `STAT_ID`, `STAT_NM`, `VW_CD`, `MT_ATITLE`, `STRT_PRD_DE`, `END_PRD_DE`, `LINK_URL`.
|
|
57
|
+
응답 필드(주요): `ORG_ID`, `ORG_NM`, `TBL_ID`, `TBL_NM`, `STAT_ID`, `STAT_NM`, `VW_CD`, `MT_ATITLE`, `PRD_SE`, `STRT_PRD_DE`, `END_PRD_DE`, `LINK_URL`.
|
|
58
58
|
|
|
59
59
|
데이터 조회는 `ORG_ID` + `TBL_ID` 조합을 다음 단계에서 사용한다.
|
|
60
60
|
|
|
61
|
+
⚠️ **`search` 의 `PRD_SE` 를 §3.3 의 `prdSe` 요청 파라미터로 그대로 쓰지 말 것.** 두 필드는 코드 체계가 다르다 — `DT_1IN0001`(총조사인구 총괄)은 `search` 가 `PRD_SE=A` 를 돌려주지만 실제 데이터 조회는 `prdSe=F` 로만 성공하고 `A`·`Y`·`IR` 은 모두 코드 `30`(결과 없음)이다. `STRT_PRD_DE`~`END_PRD_DE` 도 수록 범위일 뿐 간격(주기)을 알려주지 않는다. 요청용 주기는 `statisticsExplData.do`(helper 의 `explain` 서브커맨드) 의 조사주기 항목에서 확인하거나, §3.3 표의 코드를 좁은 슬라이스로 프로브해 확정한다.
|
|
62
|
+
|
|
61
63
|
### 3.2 통계표 메타데이터 (`statisticsData.do?method=getMeta`)
|
|
62
64
|
|
|
63
65
|
통계표의 분류·항목·단위·국문/영문명 등을 조회한다.
|
|
@@ -97,6 +99,8 @@ GET https://kosis.kr/openapi/Param/statisticsParameterData.do
|
|
|
97
99
|
| `F` | 다년(2,3,4,5,10년) | `YYYY` |
|
|
98
100
|
| `IR` | 부정기 | `YYYY` 또는 `YYYYMMDD` |
|
|
99
101
|
|
|
102
|
+
`prdSe` 를 틀리면 `startPrdDe`/`endPrdDe`/`objL*` 이 전부 맞아도 응답은 항상 코드 `30`(결과 없음)이고, 메시지만으로는 주기 문제인지 구분되지 않는다. 5년 간격 총조사처럼 연 단위로 보이는 표가 `F`(다년)인 경우가 흔하므로, `statisticsExplData.do` 로 조사주기를 확인하거나 최소 슬라이스로 `Y` → `F` → `IR` → `M`/`Q`/`S` 순으로 프로브해 확정한 뒤 본 조회를 한다.
|
|
103
|
+
|
|
100
104
|
분류 파라미터는 `objL1` ~ `objL8` (필요한 만큼만), 항목은 `itmId` (`ALL` 또는 특정 ID).
|
|
101
105
|
|
|
102
106
|
응답 셀 필드: `PRD_DE` (기간), `ITM_NM` (항목), `UNIT_NM` (단위), `DT` (값), `C1_NM`~`C8_NM` (분류명).
|
|
@@ -58,7 +58,7 @@ ERROR_CODE_HINTS: dict[str, str] = {
|
|
|
58
58
|
"11": "인증키가 만료되었거나 해당 endpoint에서 무효입니다. https://kosis.kr/openapi/ 에서 갱신하거나, bigdata는 본인이 등록한 userStatsId 인지 확인하세요.",
|
|
59
59
|
"20": "필수 요청 변수가 누락되었습니다. `meta --table-id <ID> --meta-type ITM --json` 으로 ITM 안에 들어 있는 OBJ_ID(분류 차원)와 코드를 확인하세요(많은 표가 OBJ 메타는 비어 있고 분류가 ITM 안에 들어 있음). 그 뒤 `--obj-l 1=<코드> --obj-l 2=<코드>` 형태로 필요한 차원을 모두 지정해 재호출하세요. 별도 OBJ 메타가 있는 표는 `--meta-type OBJ` 로도 확인 가능합니다.",
|
|
60
60
|
"21": "잘못된 요청 변수입니다. orgId/tblId/기간 형식을 재확인하세요. tblId가 의심되면 `search --query <키워드>` 로 정확한 ID를 다시 찾으세요.",
|
|
61
|
-
"30": "조회 결과가 없습니다. 키워드를 더 짧게(예: '1인 가구' → '가구') 또는 다른 표현으로 재검색하거나, 기간/분류 필터를 완화하세요. meta 호출에서 이 에러가 나면 해당 메타 타입을 표가 지원하지 않는 경우이므로 다른 `--meta-type` 을 시도하세요.",
|
|
61
|
+
"30": "조회 결과가 없습니다. data 호출이면 먼저 `--prd-se` 오지정을 의심하세요 — 표의 실제 수록주기와 다르면 기간/분류가 맞아도 항상 이 에러가 납니다. 연 단위로 보이는 총조사·센서스류는 다년주기 `F` 또는 부정기 `IR` 인 경우가 많으니 `explain --org-id <ORG> --table-id <TBL>` 로 조사주기를 확인하거나 좁은 슬라이스로 `Y` → `F` → `IR` → `M`/`Q`/`S` 순으로 프로브하세요(`search` 응답의 `PRD_SE` 는 코드 체계가 달라 그대로 쓸 수 없습니다). search 호출이면 키워드를 더 짧게(예: '1인 가구' → '가구') 또는 다른 표현으로 재검색하거나, 기간/분류 필터를 완화하세요. meta 호출에서 이 에러가 나면 해당 메타 타입을 표가 지원하지 않는 경우이므로 다른 `--meta-type` 을 시도하세요.",
|
|
62
62
|
"31": "조회 결과가 한도(40,000셀)를 초과했습니다. 기간을 좁히거나(예: 5년→1년) 분류 필터의 ALL 을 특정 코드로 바꾸세요(예: `--obj-l 1=ALL` → `--obj-l 1=11` 서울만). 그래도 부족하면 `bigdata` 서브커맨드 + 사전 등록한 userStatsId 를 사용하세요.",
|
|
63
63
|
"40": "분당 호출 한도(1,000건)를 초과했습니다. 잠시 대기 후 재시도하거나 호출 간 sleep 을 두세요.",
|
|
64
64
|
"41": "1회 호출 ROW 한도를 초과했습니다. 기간이나 분류를 좁혀 쿼리를 분할하세요.",
|
|
@@ -33,9 +33,14 @@ BLOG_ANCHOR_PATTERN = re.compile(
|
|
|
33
33
|
re.DOTALL,
|
|
34
34
|
)
|
|
35
35
|
|
|
36
|
+
# 검색 결과 앵커마다 시각적으로 숨겨진 접근성 라벨
|
|
37
|
+
# <span class="fender-ui_해시">새 창 열림</span>이 들어 있다. 클래스명은
|
|
38
|
+
# CSS 모듈 해시라 안정적이지 않으므로 라벨 텍스트 기준으로 span째 제거한다.
|
|
39
|
+
NEW_WINDOW_LABEL_RE = re.compile(r"<span[^>]*>\s*새\s*창\s*열림\s*</span>")
|
|
40
|
+
|
|
36
41
|
|
|
37
42
|
def strip_html(text: str) -> str:
|
|
38
|
-
return unescape(TAG_RE.sub("", text)).strip()
|
|
43
|
+
return unescape(TAG_RE.sub("", NEW_WINDOW_LABEL_RE.sub("", text))).strip()
|
|
39
44
|
|
|
40
45
|
|
|
41
46
|
def build_search_params(query: str, start: int = FIRST_PAGE_START, sort: str = "sim") -> dict[str, str]:
|