@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nomadamas/k-skill",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "k-skill unified CLI: assembles runtime-aware skill instructions and ships bundled helper files",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -2,10 +2,10 @@
2
2
 
3
3
  ## What this skill does
4
4
 
5
- 강남언니(Gangnam Unni) 웹 검색 페이지의 **비로그인 공개 Next.js payload**를 읽어 병원 후보를 조회한다.
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
- - search list: `https://www.gangnamunni.com/search?q=<keyword>`
46
- - parsed payload: `<script id="__NEXT_DATA__" type="application/json">...props.pageProps.hospitals...</script>`
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로 비로그인 검색 HTML이 200으로 응답하고, 병원 후보는 server-rendered `__NEXT_DATA__`의 `props.pageProps.hospitals` 배열에 포함된다. 이 경로는 공개 read-only endpoint이므로 `k-skill-proxy`를 사용하지 않는다.
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/search?q=<keyword>`의 `__NEXT_DATA__` payload를 파싱한다.
81
- 2. payload 없으면 로그인벽, CAPTCHA, 차단, shell 페이지를 실패 모드로 분류한다.
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`: 결과 없음 키워드를 짧게 또는 다른 표현으로 바꾸거나 기간/분류 완화. **meta 호출에서 30 이 나오면** 표가 해당 메타 타입을 지원하지 않는 경우이므로 다른 `--meta-type` 시도
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]: