neis-school-info 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hyunbin Seo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,182 @@
1
+ # neis-school-info
2
+
3
+ [나이스 교육정보 개방 포털]의 [학교 기본 정보]를 조회하는 타입스크립트 라이브러리
4
+
5
+ [나이스 교육정보 개방 포털]: https://open.neis.go.kr
6
+ [학교 기본 정보]: https://open.neis.go.kr/portal/data/service/selectServicePage.do?infId=OPEN17020190531110010104913&infSeq=1
7
+
8
+ > [!WARNING]
9
+ > '학교기본정보'는 정기적으로 점검되지 않으므로, 휴원이나 학교 형태 변경처럼 두드러진 변경이 없었다면 잘못되었거나 오래된 정보가 남아있을 수 있다.
10
+
11
+ ## 개발 동기
12
+
13
+ > [!WARNING]
14
+ > '학교기본정보'는 각 시도교육청의 입력값을 엄격하게 검증하지 않고 교육부 총괄 서버로 연계한다.
15
+
16
+ '학교기본정보' API 명세는 응답값의 형식을 전혀 명시하지 않는다. 다음은 문제가 되는 필드 중 일부이다.
17
+
18
+ - 학교를 식별하는 `행정표준코드`가 없을 수 있으며[^no-code], 이 경우 값은 공백 문자열(`" "`)로 채워진다.
19
+ - 반면 `팩스번호`나 `홈페이지주소`가 없는 경우 그 값은 공백 문자열이 아닌 `null`이다.
20
+ - `도로명우편번호`는 5자리 숫자여야 하지만 실제 값은 6자리 문자열이다.
21
+ - `"12345 "`처럼 뒤에 공백이 붙는 경우
22
+ - `" 1234 "`처럼 오입력된 경우
23
+ - `" "`처럼 공백만 채워진 경우
24
+ - `전화번호`와 `팩스번호`는 정형화되어 있지 않다.
25
+ - `.`, `-`, `0`, `000-0000-0000`처럼 사실상 빈 값인 경우
26
+ - `02-000-0000,0001` 또는 `02-000-0000/02-000-0001`처럼 여러 번호를 담은 경우
27
+ - `홈페이지주소`도 정형화되어 있지 않다. (예: 도메인 가운데에 공백이 있거나 BOM 문자가 포함됨)
28
+ - `영문학교명`, `도로명주소`, `도로명상세주소`에는 공백이 2개 이상 연속되거나 NBSP 문자가 섞여 있을 수 있다.
29
+
30
+ [^no-code]: 개교 예정 학교이거나, 이미 개교했지만 코드가 부여되지 않은 학교
31
+
32
+ 이 라이브러리는 값이 없거나 유효하지 않으면 `null`로 변환한다.
33
+
34
+ ## 사용법
35
+
36
+ ```bash
37
+ pnpm i neis-school-info
38
+ ```
39
+
40
+ ```ts
41
+ import { search } from 'neis-school-info';
42
+
43
+ const result = await search(
44
+ {
45
+ fields: ['학교명', '행정표준코드'], // (선택) 지정된 필드만 검증 후 반환
46
+ filters: { 학교명: 'OO고등학교' }, // (선택) 검색 조건; 아래 참고
47
+ pageIndex: 0, // (선택) 기본값 0; pIndex = pageIndex + 1
48
+ pageSize: 100, // (선택) 기본값 100
49
+ },
50
+ {
51
+ apiKey: 'NEIS_OPEN_API_KEY',
52
+ fetch: customFetch, // (선택) 커스텀 fetch 전달
53
+ },
54
+ );
55
+
56
+ if (result.ok) {
57
+ result.schools
58
+ // 행정표준코드가 없는 학교를 제외할 수 있음
59
+ .filter((school) => school.행정표준코드 !== null);
60
+ }
61
+ ```
62
+
63
+ > [!IMPORTANT]
64
+ > 타입스크립트로 표현되지 않는 정규화 규칙은 [응답값](#응답값) 섹션을 참고한다 (예: 설립일자 등 날짜는 `YYYY-MM-DD` 형식).
65
+
66
+ > [!NOTE]
67
+ > 다음 경우 `search`는 결과 대신 예외를 던진다.
68
+ >
69
+ > - 인자가 유효하지 않은 경우 (요청 전)
70
+ > - 네트워크 오류로 `fetch`가 실패한 경우
71
+ > - 응답이 2xx가 아니거나 JSON이 아닌 경우
72
+
73
+ ### 검색 조건 (`filters`)
74
+
75
+ 나이스 OpenAPI와 동일하게 작동하므로 다음과 같은 특성을 가진다:
76
+
77
+ - 다중 필터는 AND로 작동함
78
+ - 빈 문자열(`""`)은 무시됨
79
+
80
+ 단, 신청인자로 영문 대신 한글을 사용하며, `행정표준코드`가 없는 학교는 `null`로 검색할 수 있다.
81
+
82
+ ```ts
83
+ type Filters = Partial<{
84
+ /* 부분 일치 */
85
+ 학교명: string; // 예: `OO` → `OO고등학교`, `OO중학교`, `OO초등학교` 검색됨
86
+
87
+ /* 완전 일치 */
88
+ 설립명: string; // 예: `공` → `공립` 검색 안 됨
89
+ 시도교육청코드: 시도교육청코드; // 권장
90
+ 시도명: 시도명 | (string & {}); // 지양
91
+ 학교종류명: string;
92
+ 행정표준코드: string | null;
93
+ }>;
94
+ ```
95
+
96
+ > [!CAUTION]
97
+ > `시도명`은 바뀔 수 있으며, 완전 일치이므로 괄호까지도 맞춰야 한다 (예: `전라남도` → `전남광주통합특별시(광주)`). 예전 값을 쓰면 오류 없이 빈 배열만 돌아오므로, `시도교육청코드`를 쓰는 편이 낫다. [목록](./src/enums/office-code.ts)
98
+
99
+ > [!NOTE]
100
+ > `시도교육청코드`, `설립명` 등 열거형은 패키지에서 직접 가져올 수 있다. 전체 목록은 [`src/index.ts`](./src/index.ts)를 참고한다.
101
+
102
+ ### 응답값
103
+
104
+ 이 라이브러리는 나이스 API 응답값을 검증하고 변환한다 (예: `도로명우편번호`는 공백을 제거한 후 5자리 숫자가 아니면 `null`로 변환함).
105
+
106
+ ```ts
107
+ result = {
108
+ ok: true,
109
+ code: 'INFO-000',
110
+ meta: {
111
+ pageIndex: 0,
112
+ pageSize: 100,
113
+ totalCount: 12345,
114
+ },
115
+ schools: [
116
+ {
117
+ 시도교육청코드: 'OO',
118
+ 시도교육청명: 'OO특별시교육청',
119
+ 행정표준코드: '0000000',
120
+ 학교명: 'OO고등학교',
121
+ 영문학교명: 'OO High School',
122
+ // …
123
+ },
124
+ ],
125
+ };
126
+ ```
127
+
128
+ ```ts
129
+ // result.schools[number]!
130
+ type School = {
131
+ /* 열거형 */
132
+ 고등학교구분명: 고등학교구분명 | null;
133
+ 고등학교일반전문구분명: 고등학교일반전문구분명 | null;
134
+ 남녀공학구분명: 남녀공학구분명;
135
+ 설립명: 설립명 | null;
136
+ 시도교육청코드: 시도교육청코드;
137
+ 입시전후기구분명: 입시전후기구분명;
138
+ 주야구분명: 주야구분명;
139
+
140
+ /* 열거형 - 검증하지 않음, 목록 외 값은 문자열로 통과 */
141
+ 학교종류명: 학교종류명 | (string & {}) | null;
142
+
143
+ /* 그 외 */
144
+ 개교기념일: string; // `YYYY-MM-DD`
145
+ 관할조직명: string;
146
+ 도로명상세주소: string | null;
147
+ 도로명우편번호: string | null; // 5자리 숫자
148
+ 도로명주소: string | null;
149
+ 산업체특별학급존재여부: 'Y' | 'N';
150
+ 설립일자: string; // `YYYY-MM-DD`
151
+ 수정일자: string; // `YYYY-MM-DD`
152
+ 시도교육청명: string;
153
+ 시도명: string;
154
+ 영문학교명: string | null;
155
+ 전화번호: string | null; // 8~15자리 숫자
156
+ 특수목적고등학교계열명: string | null;
157
+ 팩스번호: string | null; // 8~15자리 숫자
158
+ 학교명: string;
159
+ 행정표준코드: string | null;
160
+ 홈페이지주소: string | null; // `http(s)://…`
161
+ };
162
+ ```
163
+
164
+ > [!NOTE]
165
+ > 실패 코드는 `ERROR-<000>` 또는 `INFO-<000>` 형식이다. 코드별 설명은 [학교 기본 정보] 문서를 참고한다.
166
+
167
+ ```ts
168
+ // 나이스 API가 보고한 오류
169
+ result = { ok: false, code: 'ERROR-290', message: '…' };
170
+
171
+ // 응답 형식이 예상과 다름
172
+ result = { ok: false, code: null };
173
+ ```
174
+
175
+ ## 데이터 활용 시 유의사항
176
+
177
+ - `학교종류명`은 검증하지 않으므로, 타입에 명시된 값 외에 다른 문자열이 올 수 있다.
178
+ - 학교가 고등학교이면서 `고등학교일반전문구분명`이 `해당없음` 또는 `null`일 수 있다. [#1]
179
+ - `고등학교구분명`이 `일반고`인데도 `특수목적고등학교계열명` 값이 존재할 수 있다. [#4]
180
+
181
+ [#1]: https://github.com/hyunbinseo/neis-school-info/issues/1
182
+ [#4]: https://github.com/hyunbinseo/neis-school-info/issues/4
@@ -0,0 +1,123 @@
1
+ //#region src/enums/office-code.d.ts
2
+ type 시도명 = (typeof OFFICE_CODE_TO_NAMES)[시도교육청코드][0];
3
+ declare const OFFICE_CODE_TO_NAMES: {
4
+ readonly B10: readonly ["서울특별시", "서울특별시교육청"];
5
+ readonly C10: readonly ["부산광역시", "부산광역시교육청"];
6
+ readonly D10: readonly ["대구광역시", "대구광역시교육청"];
7
+ readonly E10: readonly ["인천광역시", "인천광역시교육청"];
8
+ readonly F10: readonly ["전남광주통합특별시(광주)", "전남광주통합특별시교육청(광주)"];
9
+ readonly G10: readonly ["대전광역시", "대전광역시교육청"];
10
+ readonly H10: readonly ["울산광역시", "울산광역시교육청"];
11
+ readonly I10: readonly ["세종특별자치시", "세종특별자치시교육청"];
12
+ readonly J10: readonly ["경기도", "경기도교육청"];
13
+ readonly K10: readonly ["강원특별자치도", "강원특별자치도교육청"];
14
+ readonly M10: readonly ["충청북도", "충청북도교육청"];
15
+ readonly N10: readonly ["충청남도", "충청남도교육청"];
16
+ readonly P10: readonly ["전북특별자치도", "전북특별자치도교육청"];
17
+ readonly Q10: readonly ["전남광주통합특별시(전남)", "전남광주통합특별시교육청(전남)"];
18
+ readonly R10: readonly ["경상북도", "경상북도교육청"];
19
+ readonly S10: readonly ["경상남도", "경상남도교육청"];
20
+ readonly T10: readonly ["제주특별자치도", "제주특별자치도교육청"];
21
+ readonly V10: readonly ["재외한국학교", "재외한국학교교육청"];
22
+ };
23
+ export type 시도교육청코드 = (typeof 시도교육청코드)[number];
24
+ export declare const 시도교육청코드: readonly ['B10', 'C10', 'D10', 'E10', 'F10', 'G10', 'H10', 'I10', 'J10', 'K10', 'M10', 'N10', 'P10', 'Q10', 'R10', 'S10', 'T10', 'V10'];
25
+ //#endregion
26
+ //#region src/enums/school-value.d.ts
27
+ type 학교종류명 = (typeof 학교종류명)[number];
28
+ declare const 학교종류명: readonly ['초등학교', '중학교', '고등학교', '특수학교', '고등기술학교', '고등공민학교', '공동실습소', '국제학교', '외국인학교', '방송통신중학교', '방송통신고등학교', '각종학교(초)', '각종학교(중)', '각종학교(고)', '각종학교(대안학교)', '각종학교(외국인학교)', '평생학교(초)-3년6학기', '평생학교(초)-4년12학기', '평생학교(중)-2년6학기', '평생학교(중)-3년6학기', '평생학교(고)-2년6학기', '평생학교(고)-3년6학기', '재외한국학교(초)', '재외한국학교(중)', '재외한국학교(고)'];
29
+ export type 고등학교구분명 = (typeof 고등학교구분명)[number];
30
+ export declare const 고등학교구분명: readonly ['일반고', '특성화고', '자율고', '특목고'];
31
+ export type 고등학교일반전문구분명 = (typeof 고등학교일반전문구분명)[number];
32
+ export declare const 고등학교일반전문구분명: readonly ['해당없음', '일반계', '전문계'];
33
+ export type 남녀공학구분명 = (typeof 남녀공학구분명)[number];
34
+ export declare const 남녀공학구분명: readonly ['남여공학', '남', '여'];
35
+ export type 설립명 = (typeof 설립명)[number];
36
+ export declare const 설립명: readonly ['공립', '사립', '국립', '기타', '국외'];
37
+ export type 입시전후기구분명 = (typeof 입시전후기구분명)[number];
38
+ export declare const 입시전후기구분명: readonly ['전기', '후기', '전후기'];
39
+ export type 주야구분명 = (typeof 주야구분명)[number];
40
+ export declare const 주야구분명: readonly ['주간', '야간', '주야간'];
41
+ type YN = (typeof YN_VALUES)[number];
42
+ declare const YN_VALUES: readonly ['Y', 'N'];
43
+ //#endregion
44
+ //#region src/valibot.d.ts
45
+ type School = {
46
+ 시도교육청코드: 시도교육청코드;
47
+ 시도교육청명: string;
48
+ 학교명: string;
49
+ 영문학교명: string | null;
50
+ 학교종류명: 학교종류명 | (string & {}) | null;
51
+ 시도명: string;
52
+ 관할조직명: string;
53
+ 설립명: 설립명 | null;
54
+ 도로명우편번호: string | null;
55
+ 도로명주소: string | null;
56
+ 도로명상세주소: string | null;
57
+ 전화번호: string | null;
58
+ 홈페이지주소: string | null;
59
+ 남녀공학구분명: 남녀공학구분명;
60
+ 팩스번호: string | null;
61
+ 고등학교구분명: 고등학교구분명 | null;
62
+ 산업체특별학급존재여부: YN;
63
+ 고등학교일반전문구분명: 고등학교일반전문구분명 | null;
64
+ 특수목적고등학교계열명: string | null;
65
+ 입시전후기구분명: 입시전후기구분명;
66
+ 주야구분명: 주야구분명;
67
+ 설립일자: string;
68
+ 개교기념일: string;
69
+ 수정일자: string;
70
+ } & ({
71
+ 행정표준코드: string;
72
+ } | {
73
+ 행정표준코드: null;
74
+ });
75
+ //#endregion
76
+ //#region src/params.d.ts
77
+ type SchoolField = keyof School;
78
+ type SchoolFields = readonly [SchoolField, ...SchoolField[]];
79
+ type Filters = Partial<{
80
+ 시도교육청코드: 시도교육청코드;
81
+ 행정표준코드: string | null;
82
+ 학교명: string;
83
+ 학교종류명: string;
84
+ 시도명: 시도명 | (string & {});
85
+ 설립명: string;
86
+ }>;
87
+ //#endregion
88
+ //#region src/enums/result-code.d.ts
89
+ type SuccessCode = (typeof SUCCESS_CODES)[number];
90
+ declare const SUCCESS_CODES: readonly ['INFO-000', 'INFO-200'];
91
+ type FailureCode = (typeof FAILURE_CODES)[number];
92
+ declare const FAILURE_CODES: readonly ['ERROR-300', 'ERROR-290', 'ERROR-310', 'ERROR-333', 'ERROR-336', 'ERROR-337', 'ERROR-500', 'ERROR-600', 'ERROR-601', 'INFO-100', 'INFO-300'];
93
+ //#endregion
94
+ //#region src/search.d.ts
95
+ type PickedSchool<Fields extends SchoolFields, S extends School = School> = [Fields] extends [never] ? School : S extends unknown ? Pick<S, Fields[number]> : never;
96
+ type SearchResult<Fields extends SchoolFields = never> = {
97
+ ok: true;
98
+ code: SuccessCode;
99
+ meta: {
100
+ pageIndex: number;
101
+ pageSize: number;
102
+ totalCount: number;
103
+ };
104
+ schools: PickedSchool<Fields>[];
105
+ } | {
106
+ ok: false;
107
+ code: FailureCode;
108
+ message: string;
109
+ } | {
110
+ ok: false;
111
+ code: null;
112
+ };
113
+ export declare const search: <const Fields extends SchoolFields = never>(params: Partial<{
114
+ pageIndex: number;
115
+ pageSize: number;
116
+ fields: Fields;
117
+ filters: Filters;
118
+ }>, opts: {
119
+ apiKey: string;
120
+ fetch?: typeof fetch;
121
+ }) => Promise<SearchResult<Fields>>;
122
+ //#endregion
123
+ export type { FailureCode, Filters, School, SearchResult, SuccessCode, 시도명, 학교종류명 };
package/dist/index.js ADDED
@@ -0,0 +1,218 @@
1
+ import { array, check, digits, entriesFromList, fallback, integer, isoDate, length, maxLength, maxValue, minLength, minValue, nonEmpty, nullable, number, object, optional, parse, pick, picklist, pipe, safeParse, strictObject, string, transform, trim, tuple, unknown, url } from "valibot";
2
+ //#region src/enums/filter-key.ts
3
+ const FILTER_KEY_TO_SEARCH = {
4
+ 시도교육청코드: "ATPT_OFCDC_SC_CODE",
5
+ 행정표준코드: "SD_SCHUL_CODE",
6
+ 학교명: "SCHUL_NM",
7
+ 학교종류명: "SCHUL_KND_SC_NM",
8
+ 시도명: "LCTN_SC_NM",
9
+ 설립명: "FOND_SC_NM"
10
+ };
11
+ const FILTER_KEYS = Object.keys(FILTER_KEY_TO_SEARCH);
12
+ //#endregion
13
+ //#region src/enums/office-code.ts
14
+ const 시도교육청코드 = [
15
+ "B10",
16
+ "C10",
17
+ "D10",
18
+ "E10",
19
+ "F10",
20
+ "G10",
21
+ "H10",
22
+ "I10",
23
+ "J10",
24
+ "K10",
25
+ "M10",
26
+ "N10",
27
+ "P10",
28
+ "Q10",
29
+ "R10",
30
+ "S10",
31
+ "T10",
32
+ "V10"
33
+ ];
34
+ //#endregion
35
+ //#region src/enums/result-code.ts
36
+ const SUCCESS_CODES = ["INFO-000", "INFO-200"];
37
+ const FAILURE_CODES = [
38
+ "ERROR-300",
39
+ "ERROR-290",
40
+ "ERROR-310",
41
+ "ERROR-333",
42
+ "ERROR-336",
43
+ "ERROR-337",
44
+ "ERROR-500",
45
+ "ERROR-600",
46
+ "ERROR-601",
47
+ "INFO-100",
48
+ "INFO-300"
49
+ ];
50
+ //#endregion
51
+ //#region src/enums/school-field.ts
52
+ const SCHOOL_FIELD_EN_TO_KO = {
53
+ ATPT_OFCDC_SC_CODE: "시도교육청코드",
54
+ ATPT_OFCDC_SC_NM: "시도교육청명",
55
+ SD_SCHUL_CODE: "행정표준코드",
56
+ SCHUL_NM: "학교명",
57
+ ENG_SCHUL_NM: "영문학교명",
58
+ SCHUL_KND_SC_NM: "학교종류명",
59
+ LCTN_SC_NM: "시도명",
60
+ JU_ORG_NM: "관할조직명",
61
+ FOND_SC_NM: "설립명",
62
+ ORG_RDNZC: "도로명우편번호",
63
+ ORG_RDNMA: "도로명주소",
64
+ ORG_RDNDA: "도로명상세주소",
65
+ ORG_TELNO: "전화번호",
66
+ HMPG_ADRES: "홈페이지주소",
67
+ COEDU_SC_NM: "남녀공학구분명",
68
+ ORG_FAXNO: "팩스번호",
69
+ HS_SC_NM: "고등학교구분명",
70
+ INDST_SPECL_CCCCL_EXST_YN: "산업체특별학급존재여부",
71
+ HS_GNRL_BUSNS_SC_NM: "고등학교일반전문구분명",
72
+ SPCLY_PURPS_HS_ORD_NM: "특수목적고등학교계열명",
73
+ ENE_BFE_SEHF_SC_NM: "입시전후기구분명",
74
+ DGHT_SC_NM: "주야구분명",
75
+ FOND_YMD: "설립일자",
76
+ FOAS_MEMRD: "개교기념일",
77
+ LOAD_DTM: "수정일자"
78
+ };
79
+ const EN_SCHOOL_FIELDS = Object.keys(SCHOOL_FIELD_EN_TO_KO);
80
+ //#endregion
81
+ //#region src/enums/school-value.ts
82
+ const 고등학교구분명 = [
83
+ "일반고",
84
+ "특성화고",
85
+ "자율고",
86
+ "특목고"
87
+ ];
88
+ const 고등학교일반전문구분명 = [
89
+ "해당없음",
90
+ "일반계",
91
+ "전문계"
92
+ ];
93
+ const 남녀공학구분명 = [
94
+ "남여공학",
95
+ "남",
96
+ "여"
97
+ ];
98
+ const 설립명 = [
99
+ "공립",
100
+ "사립",
101
+ "국립",
102
+ "기타",
103
+ "국외"
104
+ ];
105
+ const 입시전후기구분명 = [
106
+ "전기",
107
+ "후기",
108
+ "전후기"
109
+ ];
110
+ const 주야구분명 = [
111
+ "주간",
112
+ "야간",
113
+ "주야간"
114
+ ];
115
+ const YN_VALUES = ["Y", "N"];
116
+ //#endregion
117
+ //#region src/valibot.ts
118
+ const NoRowsSchema = strictObject({ RESULT: object({
119
+ CODE: picklist([...SUCCESS_CODES, ...FAILURE_CODES]),
120
+ MESSAGE: string()
121
+ }) });
122
+ const HeadSchema = tuple([object({ list_total_count: number() }), object({ RESULT: object({
123
+ CODE: picklist(SUCCESS_CODES),
124
+ MESSAGE: string()
125
+ }) })]);
126
+ const RawSchoolSchema = pipe(strictObject(entriesFromList(EN_SCHOOL_FIELDS, unknown())), transform((v) => Object.fromEntries(Object.entries(v).map(([enKey, value]) => [SCHOOL_FIELD_EN_TO_KO[enKey], value]))));
127
+ const NormalizeStringSchema = pipe(string(), transform((v) => v.replaceAll(/\s+/g, " ")), trim());
128
+ const NonEmptyStringSchema = pipe(NormalizeStringSchema, nonEmpty());
129
+ const StringEmptyToNullSchema = pipe(NormalizeStringSchema, transform((v) => v || null));
130
+ const PhoneNumberSchema = fallback(nullable(pipe(NonEmptyStringSchema, transform((v) => v.replaceAll(/[() .+-]/g, "")), digits(), minLength(8), maxLength(15), check((v) => !/^(\d)\1+$/.test(v)))), null);
131
+ const URLSchema = fallback(nullable(pipe(NonEmptyStringSchema, transform((v) => v.replaceAll(" ", "")), check((v) => v.includes(".")), transform((v) => /^https?:\/\//i.test(v) ? v : `https://${v}`), minLength(11), url())), null);
132
+ const YYYYMMDDToISODateSchema = pipe(string(), trim(), digits(), length(8), transform((v) => `${v.slice(0, 4)}-${v.slice(4, 6)}-${v.slice(6)}`), isoDate());
133
+ const SchoolSchema = object({
134
+ 시도교육청코드: picklist(시도교육청코드),
135
+ 시도교육청명: NonEmptyStringSchema,
136
+ 행정표준코드: StringEmptyToNullSchema,
137
+ 학교명: NonEmptyStringSchema,
138
+ 영문학교명: nullable(StringEmptyToNullSchema),
139
+ 학교종류명: nullable(NonEmptyStringSchema),
140
+ 시도명: NonEmptyStringSchema,
141
+ 관할조직명: NonEmptyStringSchema,
142
+ 설립명: nullable(pipe(NonEmptyStringSchema, picklist(설립명))),
143
+ 도로명우편번호: fallback(nullable(pipe(string(), trim(), digits(), length(5))), null),
144
+ 도로명주소: nullable(NonEmptyStringSchema),
145
+ 도로명상세주소: nullable(StringEmptyToNullSchema),
146
+ 전화번호: PhoneNumberSchema,
147
+ 홈페이지주소: URLSchema,
148
+ 남녀공학구분명: pipe(NonEmptyStringSchema, picklist(남녀공학구분명)),
149
+ 팩스번호: PhoneNumberSchema,
150
+ 고등학교구분명: nullable(pipe(NonEmptyStringSchema, picklist(고등학교구분명))),
151
+ 산업체특별학급존재여부: picklist(YN_VALUES),
152
+ 고등학교일반전문구분명: nullable(pipe(NonEmptyStringSchema, picklist(고등학교일반전문구분명))),
153
+ 특수목적고등학교계열명: nullable(NonEmptyStringSchema),
154
+ 입시전후기구분명: pipe(NonEmptyStringSchema, picklist(입시전후기구분명)),
155
+ 주야구분명: pipe(NonEmptyStringSchema, picklist(주야구분명)),
156
+ 설립일자: YYYYMMDDToISODateSchema,
157
+ 개교기념일: YYYYMMDDToISODateSchema,
158
+ 수정일자: YYYYMMDDToISODateSchema
159
+ });
160
+ //#endregion
161
+ //#region src/search.ts
162
+ const search = async (params, opts) => {
163
+ const { pageIndex, pageSize } = parse(object({
164
+ pageIndex: optional(pipe(number(), integer(), minValue(0)), 0),
165
+ pageSize: optional(pipe(number(), integer(), minValue(1), maxValue(1e3)), 100)
166
+ }), params);
167
+ const url = new URL("https://open.neis.go.kr/hub/schoolInfo");
168
+ url.searchParams.set("Type", "json");
169
+ url.searchParams.set("KEY", opts.apiKey);
170
+ url.searchParams.set("pIndex", (pageIndex + 1).toString());
171
+ url.searchParams.set("pSize", pageSize.toString());
172
+ for (const key of FILTER_KEYS) {
173
+ const searchKey = FILTER_KEY_TO_SEARCH[key];
174
+ let value = params.filters?.[key];
175
+ if (value === null && key === "행정표준코드") value = " ".repeat(7);
176
+ if (!value) continue;
177
+ url.searchParams.set(searchKey, value);
178
+ }
179
+ const response = await (opts.fetch ?? fetch)(url);
180
+ if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
181
+ const raw = await response.json();
182
+ const noRowsResult = safeParse(NoRowsSchema, raw);
183
+ if (noRowsResult.success) {
184
+ const { RESULT } = noRowsResult.output;
185
+ return RESULT.CODE === "INFO-000" || RESULT.CODE === "INFO-200" ? {
186
+ ok: true,
187
+ code: RESULT.CODE,
188
+ meta: {
189
+ pageIndex,
190
+ pageSize,
191
+ totalCount: 0
192
+ },
193
+ schools: []
194
+ } : {
195
+ ok: false,
196
+ code: RESULT.CODE,
197
+ message: RESULT.MESSAGE
198
+ };
199
+ }
200
+ const result = safeParse(strictObject({ schoolInfo: tuple([object({ head: HeadSchema }), object({ row: array(pipe(RawSchoolSchema, params.fields ? pick(SchoolSchema, params.fields) : SchoolSchema)) })]) }), raw);
201
+ if (!result.success) return {
202
+ ok: false,
203
+ code: null
204
+ };
205
+ const { schoolInfo: [{ head: [{ list_total_count: totalCount }, { RESULT }] }, { row: schools }] } = result.output;
206
+ return {
207
+ ok: true,
208
+ code: RESULT.CODE,
209
+ meta: {
210
+ pageIndex,
211
+ pageSize,
212
+ totalCount
213
+ },
214
+ schools
215
+ };
216
+ };
217
+ //#endregion
218
+ export { search, 고등학교구분명, 고등학교일반전문구분명, 남녀공학구분명, 설립명, 시도교육청코드, 입시전후기구분명, 주야구분명 };
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "neis-school-info",
3
+ "version": "0.1.0",
4
+ "description": "대한민국 학교 기본 정보 조회 (나이스 Open API 기반)",
5
+ "homepage": "https://github.com/hyunbinseo/neis-school-info#readme",
6
+ "bugs": {
7
+ "url": "https://github.com/hyunbinseo/neis-school-info/issues"
8
+ },
9
+ "license": "MIT",
10
+ "author": "Hyunbin Seo",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/hyunbinseo/neis-school-info.git"
14
+ },
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "type": "module",
19
+ "sideEffects": false,
20
+ "imports": {
21
+ "#cli/*": "./cli/*",
22
+ "#src/*": "./src/*"
23
+ },
24
+ "exports": {
25
+ ".": "./dist/index.js",
26
+ "./package.json": "./package.json"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "dependencies": {
32
+ "valibot": "^1.4.2"
33
+ },
34
+ "devDependencies": {
35
+ "@arethetypeswrong/core": "^0.18.5",
36
+ "@types/node": "^24.13.3",
37
+ "markdown-table": "^3.0.4",
38
+ "oxfmt": "^0.67.0",
39
+ "oxlint": "^1.82.0",
40
+ "oxlint-tsgolint": "^7.0.2001",
41
+ "publint": "^0.3.24",
42
+ "tsdown": "^0.23.0",
43
+ "typescript": "^7.0.2"
44
+ },
45
+ "devEngines": {
46
+ "packageManager": {
47
+ "name": "pnpm",
48
+ "version": "12.4.0",
49
+ "onFail": "download"
50
+ },
51
+ "runtime": {
52
+ "name": "node",
53
+ "version": "24.21.0",
54
+ "onFail": "download"
55
+ }
56
+ },
57
+ "engines": {
58
+ "node": ">=18"
59
+ },
60
+ "scripts": {
61
+ "version": "node --run test && tsdown && git add package.json",
62
+ "test": "node --env-file=.env --test",
63
+ "fmt": "oxfmt",
64
+ "fmt:check": "oxfmt --check",
65
+ "lint": "oxlint",
66
+ "lint:fix": "oxlint --fix"
67
+ }
68
+ }