pro-sejongpeia 1.2.30__tar.gz

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.
@@ -0,0 +1,91 @@
1
+ /.idea
2
+ /.claude/settings.local.json
3
+ /.report
4
+
5
+ # 작업 과정 문서 (projectops 이슈·보고서·점검 증거, superpowers 설계·계획) 는 추적하지 않음
6
+ # 사용자용 문서(docs/*.md, docs/assets)는 계속 추적
7
+ /docs/projectops/
8
+ /docs/superpowers/
9
+
10
+ # Gradle
11
+ .gradle/
12
+ build/
13
+ !gradle/wrapper/gradle-wrapper.jar
14
+ !gradle/wrapper/gradle-wrapper.properties
15
+ gradle.properties
16
+
17
+ # Compiled Files
18
+ *.class
19
+ *.jar
20
+ !gradle/wrapper/gradle-wrapper.jar
21
+
22
+ # IDE - IntelliJ IDEA
23
+ *.iws
24
+ *.iml
25
+ *.ipr
26
+ out/
27
+ !**/src/main/**/out/
28
+ !**/src/test/**/out/
29
+
30
+ # IDE - Eclipse
31
+ .classpath
32
+ .project
33
+ .settings/
34
+ bin/
35
+ !**/src/main/**/bin/
36
+ !**/src/test/**/bin/
37
+
38
+ # IDE - VS Code
39
+ .vscode/
40
+
41
+ # IDE - NetBeans
42
+ /nbproject/private/
43
+ /nbbuild/
44
+ /dist/
45
+ /nbdist/
46
+ /.nbgradle/
47
+
48
+ # OS Files
49
+ .DS_Store
50
+ .DS_Store?
51
+ ._*
52
+ .Spotlight-V100
53
+ .Trashes
54
+ ehthumbs.db
55
+ Thumbs.db
56
+ .AppleDouble
57
+ .LSOverride
58
+
59
+ # Logs
60
+ *.log
61
+ *.log.*
62
+
63
+ # Local Configuration
64
+ *.local
65
+
66
+ # Test Reports
67
+ .report/
68
+
69
+ # Test Configuration (민감 정보 포함)
70
+ **/src/test/resources/application-dev.yml
71
+
72
+ # Python (기존 항목 유지)
73
+ __pycache__/
74
+ *.py[cod]
75
+ *$py.class
76
+ *.so
77
+
78
+ # Python
79
+ python/.venv/
80
+ python/dist/
81
+ .pytest_cache/
82
+ *.egg-info/
83
+
84
+ # Node
85
+ node/node_modules/
86
+ node/dist/
87
+ *.tgz
88
+
89
+ # SDD workspace
90
+ .superpowers/
91
+ node/src/version.ts
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Suh Saechan
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.
@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.5
2
+ Name: pro-sejongpeia
3
+ Version: 1.2.30
4
+ Summary: Sejong University login API for Python: verify students with portal SSO in one line (세종대 로그인 API)
5
+ Project-URL: Homepage, https://cassiiopeia.github.io/pro-sejongpeia/
6
+ Project-URL: Source, https://github.com/Cassiiopeia/pro-sejongpeia
7
+ Project-URL: Issues, https://github.com/Cassiiopeia/pro-sejongpeia/issues
8
+ Author: Suh Saechan
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: authentication,sejong,sejong-university,sso,student-verification,세종대
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Natural Language :: Korean
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Internet :: WWW/HTTP
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+
20
+ # pro-sejongpeia (Python)
21
+
22
+ **세종대 로그인 API (Python)** · Sejong University login API
23
+
24
+ 한국어 | [English](https://github.com/Cassiiopeia/pro-sejongpeia/blob/main/README.en.md)
25
+
26
+ 학번과 포털 비밀번호로 세종대 재학생 여부를 확인하고 학과, 학년, 고전독서 인증, 연락처를 가져옵니다. 표준 라이브러리 외 의존성이 없습니다.
27
+
28
+ **[▶ 브라우저에서 바로 체험하기](https://cassiiopeia.github.io/pro-sejongpeia/#demo)** · 호출 한 번에 포털 로그인 한 번 (약 0.8초) · Java·Node 패키지와 같은 결과 · 비밀번호가 틀리면 바로 멈춤 · 매일 실제 포털로 점검
29
+
30
+ ```bash
31
+ pip install pro-sejongpeia
32
+ ```
33
+
34
+ ```python
35
+ from sejongpeia import authenticate, SejongAuthError, ErrorCode
36
+
37
+ try:
38
+ result = authenticate("21011234", "비밀번호")
39
+ print(result.name, result.major, result.grade)
40
+ except SejongAuthError as error:
41
+ if error.code == ErrorCode.AUTHENTICATION_FAILED:
42
+ print("학번 또는 비밀번호가 틀렸습니다")
43
+ elif error.code == ErrorCode.ACCOUNT_LOCKED:
44
+ print("포털 계정이 잠겼습니다. 포털에서 비밀번호를 재설정하세요")
45
+ ```
46
+
47
+ | 함수 | 반환 |
48
+ |---|---|
49
+ | `authenticate(id, pw)` | 학생 정보 + 고전독서 + 연락처 (연락처 조회가 실패하면 연락처만 `None`) |
50
+ | `authenticate_dhc(id, pw, include_raw=False)` | 학생 정보 + 고전독서 |
51
+ | `authenticate_sis(id, pw, include_raw=False)` | 학생 정보 + 이메일, 전화번호, 영문 이름 |
52
+
53
+ ### 대신 해결해 주는 것
54
+
55
+ 직접 구현하려면 모두 풀어야 하는 부분입니다.
56
+
57
+ - 최신 환경의 기본 설정이 막는, 포털이 유일하게 허용하는 암호(`TLS_RSA_WITH_AES_256_CBC_SHA`)를 Python `ssl` 설정으로 자동 허용
58
+ - 방화벽이 막는 기본 User-Agent 대신 식별 가능한 User-Agent 전송
59
+ - 여러 세종대 도메인에 걸친 SSO 쿠키와 리다이렉트 처리
60
+ - 200 응답 안의 판정으로 비밀번호 오류, 계정 잠금, 이용 불가를 즉시 구분 (틀린 비밀번호는 재시도하지 않음)
61
+ - DHC의 HTML 표와 SIS의 JSON을 타입이 있는 결과로 변환
62
+
63
+ 설정: `SejongAuthClient(timeout_seconds=10, max_retry=3, ssl_verification=True)`.
64
+
65
+ 에러 코드(`error.code`): `AUTHENTICATION_FAILED`, `ACCOUNT_LOCKED`, `ACCOUNT_UNAVAILABLE`, `INVALID_INPUT`, `CONNECTION_FAILED`, `CONNECTION_TIMEOUT`, `SESSION_ERROR`, `DATA_FETCH_FAILED`, `PARSE_ERROR`, `SSL_CONFIGURATION_ERROR`. 메시지가 아니라 코드로 분기하세요.
66
+
67
+ > **계정 잠금.** 세종대 포털은 비밀번호를 여러 번 틀리면(약 5회) 계정을 잠급니다. <https://portal.sejong.ac.kr/jsp/inquiry/pwinquiry.jsp> 에서 비밀번호를 재설정해야 풀립니다. 라이브러리는 거부된 로그인을 재시도하지 않습니다. 누구나 학번을 입력할 수 있는 서비스라면 학번별 실패 횟수를 제한하세요.
68
+
69
+ Python 3.9 이상. 세종대학교 공식 제품이 아닙니다.
70
+
71
+ 문서, 라이브 데모, Java·Node 버전: https://cassiiopeia.github.io/pro-sejongpeia/
72
+
73
+ ## English
74
+
75
+ Verify a Sejong University student with a student ID and portal password, and get their major, year, classic-reading certification and contact info. The API above is the same in English; error messages are in English. Full English docs: [README.en.md](https://github.com/Cassiiopeia/pro-sejongpeia/blob/main/README.en.md).
@@ -0,0 +1,56 @@
1
+ # pro-sejongpeia (Python)
2
+
3
+ **세종대 로그인 API (Python)** · Sejong University login API
4
+
5
+ 한국어 | [English](https://github.com/Cassiiopeia/pro-sejongpeia/blob/main/README.en.md)
6
+
7
+ 학번과 포털 비밀번호로 세종대 재학생 여부를 확인하고 학과, 학년, 고전독서 인증, 연락처를 가져옵니다. 표준 라이브러리 외 의존성이 없습니다.
8
+
9
+ **[▶ 브라우저에서 바로 체험하기](https://cassiiopeia.github.io/pro-sejongpeia/#demo)** · 호출 한 번에 포털 로그인 한 번 (약 0.8초) · Java·Node 패키지와 같은 결과 · 비밀번호가 틀리면 바로 멈춤 · 매일 실제 포털로 점검
10
+
11
+ ```bash
12
+ pip install pro-sejongpeia
13
+ ```
14
+
15
+ ```python
16
+ from sejongpeia import authenticate, SejongAuthError, ErrorCode
17
+
18
+ try:
19
+ result = authenticate("21011234", "비밀번호")
20
+ print(result.name, result.major, result.grade)
21
+ except SejongAuthError as error:
22
+ if error.code == ErrorCode.AUTHENTICATION_FAILED:
23
+ print("학번 또는 비밀번호가 틀렸습니다")
24
+ elif error.code == ErrorCode.ACCOUNT_LOCKED:
25
+ print("포털 계정이 잠겼습니다. 포털에서 비밀번호를 재설정하세요")
26
+ ```
27
+
28
+ | 함수 | 반환 |
29
+ |---|---|
30
+ | `authenticate(id, pw)` | 학생 정보 + 고전독서 + 연락처 (연락처 조회가 실패하면 연락처만 `None`) |
31
+ | `authenticate_dhc(id, pw, include_raw=False)` | 학생 정보 + 고전독서 |
32
+ | `authenticate_sis(id, pw, include_raw=False)` | 학생 정보 + 이메일, 전화번호, 영문 이름 |
33
+
34
+ ### 대신 해결해 주는 것
35
+
36
+ 직접 구현하려면 모두 풀어야 하는 부분입니다.
37
+
38
+ - 최신 환경의 기본 설정이 막는, 포털이 유일하게 허용하는 암호(`TLS_RSA_WITH_AES_256_CBC_SHA`)를 Python `ssl` 설정으로 자동 허용
39
+ - 방화벽이 막는 기본 User-Agent 대신 식별 가능한 User-Agent 전송
40
+ - 여러 세종대 도메인에 걸친 SSO 쿠키와 리다이렉트 처리
41
+ - 200 응답 안의 판정으로 비밀번호 오류, 계정 잠금, 이용 불가를 즉시 구분 (틀린 비밀번호는 재시도하지 않음)
42
+ - DHC의 HTML 표와 SIS의 JSON을 타입이 있는 결과로 변환
43
+
44
+ 설정: `SejongAuthClient(timeout_seconds=10, max_retry=3, ssl_verification=True)`.
45
+
46
+ 에러 코드(`error.code`): `AUTHENTICATION_FAILED`, `ACCOUNT_LOCKED`, `ACCOUNT_UNAVAILABLE`, `INVALID_INPUT`, `CONNECTION_FAILED`, `CONNECTION_TIMEOUT`, `SESSION_ERROR`, `DATA_FETCH_FAILED`, `PARSE_ERROR`, `SSL_CONFIGURATION_ERROR`. 메시지가 아니라 코드로 분기하세요.
47
+
48
+ > **계정 잠금.** 세종대 포털은 비밀번호를 여러 번 틀리면(약 5회) 계정을 잠급니다. <https://portal.sejong.ac.kr/jsp/inquiry/pwinquiry.jsp> 에서 비밀번호를 재설정해야 풀립니다. 라이브러리는 거부된 로그인을 재시도하지 않습니다. 누구나 학번을 입력할 수 있는 서비스라면 학번별 실패 횟수를 제한하세요.
49
+
50
+ Python 3.9 이상. 세종대학교 공식 제품이 아닙니다.
51
+
52
+ 문서, 라이브 데모, Java·Node 버전: https://cassiiopeia.github.io/pro-sejongpeia/
53
+
54
+ ## English
55
+
56
+ Verify a Sejong University student with a student ID and portal password, and get their major, year, classic-reading certification and contact info. The API above is the same in English; error messages are in English. Full English docs: [README.en.md](https://github.com/Cassiiopeia/pro-sejongpeia/blob/main/README.en.md).
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pro-sejongpeia"
7
+ version = "1.2.30"
8
+ description = "Sejong University login API for Python: verify students with portal SSO in one line (세종대 로그인 API)"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Suh Saechan" }]
14
+ keywords = ["sejong", "sejong-university", "세종대", "authentication", "sso", "student-verification"]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Operating System :: OS Independent",
18
+ "Intended Audience :: Developers",
19
+ "Natural Language :: Korean",
20
+ "Topic :: Internet :: WWW/HTTP",
21
+ ]
22
+ dependencies = []
23
+
24
+ [project.urls]
25
+ Homepage = "https://cassiiopeia.github.io/pro-sejongpeia/"
26
+ Source = "https://github.com/Cassiiopeia/pro-sejongpeia"
27
+ Issues = "https://github.com/Cassiiopeia/pro-sejongpeia/issues"
28
+
29
+ [tool.hatch.build.targets.wheel]
30
+ packages = ["src/sejongpeia"]
31
+
32
+ [tool.pytest.ini_options]
33
+ testpaths = ["tests"]
34
+ pythonpath = ["src", "tests"]
@@ -0,0 +1,61 @@
1
+ """세종대 로그인 API: 학번·비밀번호로 세종대학교 포털(SSO) 인증
2
+
3
+ from sejongpeia import authenticate
4
+ result = authenticate("21011234", "password")
5
+ """
6
+
7
+ from importlib.metadata import PackageNotFoundError, version
8
+
9
+ try:
10
+ # 버전 단일 원본: pyproject.toml (projectops 가 version.yml 과 함께 갱신)
11
+ __version__ = version("pro-sejongpeia")
12
+ except PackageNotFoundError: # 설치 없이 소스로 실행 (테스트)
13
+ __version__ = "0.0.0-dev"
14
+
15
+ from .client import SejongAuthClient # noqa: E402
16
+ from .errors import ErrorCode, SejongAuthError # noqa: E402
17
+ from .models import ( # noqa: E402
18
+ ClassicCertification,
19
+ ClassicContestRecord,
20
+ ClassicExamRecord,
21
+ ClassicSubjectRecord,
22
+ SejongAuthResult,
23
+ SejongClassicReading,
24
+ SejongDhcAuthResult,
25
+ SejongSisAuthResult,
26
+ )
27
+ from .public_data import SejongPublicData # noqa: E402
28
+ from .public_data_models import ( # noqa: E402
29
+ CafeteriaMenuItem, CalendarEvent, CampusPlace, CourseType, Department, DormitoryMenuDay,
30
+ DormitoryNotice, DormitoryNotices, ExtracurricularEvent, Lecture, OrganizationCategory,
31
+ SelectiveDomain, Term,
32
+ )
33
+
34
+ _default_client = SejongAuthClient()
35
+
36
+
37
+ def authenticate(student_id: str, password: str) -> SejongAuthResult:
38
+ """통합 인증 (기본 설정: 10초, 재시도 3회, 인증서 검증)"""
39
+ return _default_client.authenticate(student_id, password)
40
+
41
+
42
+ def authenticate_dhc(student_id: str, password: str, include_raw: bool = False) -> SejongDhcAuthResult:
43
+ """DHC 인증: 학생 정보 + 고전독서"""
44
+ return _default_client.authenticate_dhc(student_id, password, include_raw=include_raw)
45
+
46
+
47
+ def authenticate_sis(student_id: str, password: str, include_raw: bool = False) -> SejongSisAuthResult:
48
+ """SIS 인증: 학생 정보 + 연락처"""
49
+ return _default_client.authenticate_sis(student_id, password, include_raw=include_raw)
50
+
51
+
52
+ __all__ = [
53
+ "authenticate", "authenticate_dhc", "authenticate_sis", "SejongAuthClient",
54
+ "SejongAuthError", "ErrorCode",
55
+ "SejongAuthResult", "SejongDhcAuthResult", "SejongSisAuthResult", "SejongClassicReading",
56
+ "ClassicCertification", "ClassicExamRecord", "ClassicSubjectRecord", "ClassicContestRecord",
57
+ "SejongPublicData", "OrganizationCategory", "Term", "Department", "CourseType", "SelectiveDomain", "Lecture",
58
+ "CalendarEvent", "DormitoryMenuDay", "DormitoryNotice", "DormitoryNotices", "CafeteriaMenuItem",
59
+ "CampusPlace", "ExtracurricularEvent",
60
+ "__version__",
61
+ ]
@@ -0,0 +1,207 @@
1
+ """세종대 포털 인증 클라이언트: 입력 검증 > 포털 로그인(재시도) > SSO > 데이터 조회 > 파싱 (흐름: spec/PORTAL_FLOW.md)"""
2
+
3
+ import base64
4
+ import json
5
+ import logging
6
+ import ssl
7
+ import urllib.parse
8
+ from datetime import datetime
9
+ from typing import Callable, Optional
10
+
11
+ from . import __version__
12
+ from .errors import ErrorCode, SejongAuthError
13
+ from .models import SejongAuthResult, SejongDhcAuthResult, SejongSisAuthResult
14
+ from .parsers import ensure_portal_login_accepted, ensure_sis_logged_in, parse_classic_reading, parse_sis_user_info, parse_student_info
15
+ from .portal_main_parser import parse_parent_unit_name
16
+ from .transport import USER_AGENT, HttpResponse, UrllibSession, create_ssl_context
17
+
18
+ # 라이브러리 로거: 출력 방식은 사용하는 앱이 설정
19
+ LOGGER = logging.getLogger("sejongpeia")
20
+
21
+ PORTAL_LOGIN_URL = "https://portal.sejong.ac.kr/jsp/login/login_action.jsp"
22
+ PORTAL_REFERER = "https://portal.sejong.ac.kr"
23
+ # 포털이 요구하는 초기 쿠키 (없으면 로그인 폼 거부)
24
+ PORTAL_DEFAULT_COOKIE = "chknos=false"
25
+
26
+ # 로그인 후 SSO 대상 (rtUrl)
27
+ CLASSIC_RETURN_URL = "classic.sejong.ac.kr"
28
+ SIS_RETURN_URL = "sjpt.sejong.ac.kr/main/view/Login/doSsoLogin.do?p="
29
+
30
+ CLASSIC_SSO_URL = "https://classic.sejong.ac.kr/_custom/sejong/sso/sso-return.jsp?returnUrl=https://classic.sejong.ac.kr/classic/index.do"
31
+ CLASSIC_STATUS_URL = "https://classic.sejong.ac.kr/classic/reading/status.do"
32
+ # 포털 메인 (소속 표기 조회), 세션 만료 시 돌아오는 로그인 페이지는 form 대상 문구로 판별
33
+ PORTAL_MAIN_URL = "https://portal.sejong.ac.kr/user/index.do"
34
+ PORTAL_LOGIN_PAGE_MARKER = "login_action.jsp"
35
+ SIS_SSO_URL = "https://sjpt.sejong.ac.kr/main/view/Login/doSsoLogin.do?p="
36
+ SIS_USER_INFO_URL = "https://sjpt.sejong.ac.kr/main/sys/UserInfo/initUserInfo.do"
37
+ SIS_ORIGIN = "https://sjpt.sejong.ac.kr"
38
+ # 브라우저 초기 호출과 같은 빈 addParam (JSON > URL 폼 인코딩 > Base64)
39
+ SIS_EMPTY_ADD_PARAM = base64.b64encode(
40
+ urllib.parse.quote_plus('{"_runIntgUsrNo":"","_runPgLoginDt":"","_runningSejong":""}').encode("utf-8")
41
+ ).decode("ascii")
42
+
43
+ HTTP_UNAUTHORIZED = 401
44
+
45
+
46
+ class SejongAuthClient:
47
+ """세종대 포털 인증 (설정: 제한 시간 초, 로그인 재시도 횟수, 인증서 검증)"""
48
+
49
+ def __init__(self, timeout_seconds: float = 10, max_retry: int = 3, ssl_verification: bool = True,
50
+ _session_factory: Optional[Callable[[], object]] = None):
51
+ self.timeout_seconds = timeout_seconds
52
+ self.max_retry = max_retry
53
+ self.ssl_verification = ssl_verification
54
+ self._session_factory = _session_factory or self._create_session
55
+
56
+ def authenticate(self, student_id: str, password: str) -> SejongAuthResult:
57
+ """통합 인증: 포털 로그인 1회 > DHC 학생 정보·고전독서 > 같은 세션으로 SIS 연락처, 포털 메인 상위 소속 (보조 조회 실패는 무시하고 None)"""
58
+ self._validate_credentials(student_id, password)
59
+ session = self._session_factory()
60
+ self._login(session, student_id, password, CLASSIC_RETURN_URL)
61
+ dhc_result = self._fetch_dhc_with_session(session, should_include_raw_html=False)
62
+
63
+ email = phone_number = english_name = user_category = None
64
+ try:
65
+ sis_user_info = parse_sis_user_info(self._fetch_sis_json_with_session(session))
66
+ email, phone_number, english_name = sis_user_info.email, sis_user_info.phone_number, sis_user_info.english_name
67
+ user_category = sis_user_info.user_category
68
+ except Exception as error: # SIS 는 연락처 보조 조회 > 어떤 실패든 통합 결과를 막지 않음 (Java 와 동일)
69
+ LOGGER.warning("[sejong auth] SIS contact lookup failed (ignored) : %s", type(error).__name__)
70
+ parent_unit_name = self._fetch_parent_unit_name(session, dhc_result.major)
71
+
72
+ return SejongAuthResult(
73
+ success=True, student_id=dhc_result.student_id, name=dhc_result.name, major=dhc_result.major,
74
+ grade=dhc_result.grade, status=dhc_result.status, classic_reading=dhc_result.classic_reading,
75
+ email=email, phone_number=phone_number, english_name=english_name, user_category=user_category,
76
+ parent_unit_name=parent_unit_name, authenticated_at=datetime.now(),
77
+ )
78
+
79
+ def authenticate_dhc(self, student_id: str, password: str, include_raw: bool = False) -> SejongDhcAuthResult:
80
+ """DHC 인증: 학생 정보 + 고전독서 (include_raw=True 면 원본 HTML 포함)"""
81
+ self._validate_credentials(student_id, password)
82
+ return self._fetch_dhc(student_id, password, should_include_raw_html=include_raw)
83
+
84
+ def authenticate_sis(self, student_id: str, password: str, include_raw: bool = False) -> SejongSisAuthResult:
85
+ """SIS 인증: 학생 정보 + 연락처 (학년·상태 빈 문자열, include_raw=True 면 원본 JSON 포함)"""
86
+ self._validate_credentials(student_id, password)
87
+ json_text = self._fetch_sis_json(student_id, password)
88
+ user_info = parse_sis_user_info(json_text)
89
+ return SejongSisAuthResult(
90
+ success=True, student_id=user_info.student_id, name=user_info.name, major=user_info.major,
91
+ grade="", status="", email=user_info.email, phone_number=user_info.phone_number,
92
+ english_name=user_info.english_name, user_category=user_info.user_category, authenticated_at=datetime.now(),
93
+ raw_json=json_text if include_raw else None,
94
+ )
95
+
96
+ def _fetch_dhc(self, student_id: str, password: str, should_include_raw_html: bool) -> SejongDhcAuthResult:
97
+ """포털 로그인 > DHC 조회"""
98
+ session = self._session_factory()
99
+ self._login(session, student_id, password, CLASSIC_RETURN_URL)
100
+ return self._fetch_dhc_with_session(session, should_include_raw_html)
101
+
102
+ def _fetch_dhc_with_session(self, session, should_include_raw_html: bool) -> SejongDhcAuthResult:
103
+ """로그인된 세션 > classic SSO > 고전독서 페이지 > 학생 정보·고전독서 파싱"""
104
+ self._expect_session_step(self._call(session, "GET", CLASSIC_SSO_URL), "SSO redirect failed")
105
+ html = self._expect_data(self._call(session, "GET", CLASSIC_STATUS_URL), "Classic reading page request failed")
106
+ student_info = parse_student_info(html)
107
+ return SejongDhcAuthResult(
108
+ success=True, student_id=student_info.student_id, name=student_info.name, major=student_info.major,
109
+ grade=student_info.grade, status=student_info.status, classic_reading=parse_classic_reading(html),
110
+ authenticated_at=datetime.now(), raw_html=html if should_include_raw_html else None,
111
+ )
112
+
113
+ def _fetch_sis_json(self, student_id: str, password: str) -> str:
114
+ """포털 로그인 (rtUrl sjpt) > SIS 조회"""
115
+ session = self._session_factory()
116
+ self._login(session, student_id, password, SIS_RETURN_URL)
117
+ return self._fetch_sis_json_with_session(session)
118
+
119
+ def _fetch_sis_json_with_session(self, session) -> str:
120
+ """로그인된 세션 > sjpt SSO > initUserInfo JSON"""
121
+ self._expect_session_step(
122
+ self._call(session, "GET", SIS_SSO_URL, headers={"Referer": PORTAL_REFERER}), "SIS SSO page request failed")
123
+ response = self._call(session, "POST", f"{SIS_USER_INFO_URL}?addParam={SIS_EMPTY_ADD_PARAM}", data=b"{}", headers={
124
+ "Accept": "application/json",
125
+ "Content-Type": "application/json; charset=UTF-8",
126
+ "Referer": SIS_SSO_URL,
127
+ "Origin": SIS_ORIGIN,
128
+ })
129
+ json_text = self._expect_data(response, "initUserInfo API request failed")
130
+ # 틀린 비밀번호는 401 이 아니라 200 + _SUBMIT_ERROR_(LOGOUT)
131
+ ensure_sis_logged_in(json_text)
132
+ return json_text
133
+
134
+ def _fetch_parent_unit_name(self, session, major: str) -> Optional[str]:
135
+ """같은 세션으로 포털 메인 1회 조회 > 상위 소속명 (재시도 없음, 호출 실패·세션 만료·빈 본문은 None, 요소 없음은 빈 문자열)"""
136
+ try:
137
+ response = self._call(session, "GET", PORTAL_MAIN_URL, headers={"Referer": PORTAL_REFERER})
138
+ if not response.is_successful or not response.body:
139
+ return None
140
+ html = response.text()
141
+ if PORTAL_LOGIN_PAGE_MARKER in html:
142
+ return None
143
+ return parse_parent_unit_name(html, major)
144
+ except Exception as error: # 부가 정보 > 어떤 실패든 통합 결과를 막지 않음
145
+ LOGGER.warning("[sejong auth] portal main lookup failed (ignored) : %s", type(error).__name__)
146
+ return None
147
+
148
+ def _login(self, session, student_id: str, password: str, return_url: str) -> None:
149
+ """포털 로그인 (실패 응답·시간 초과 시 max_retry 회까지) > 포털 판정값 확인 (틀린 비밀번호·잠금은 재시도 없이 즉시 실패)"""
150
+ form = urllib.parse.urlencode({"mainLogin": "N", "rtUrl": return_url, "id": student_id, "password": password})
151
+ headers = {"Content-Type": "application/x-www-form-urlencoded", "Referer": PORTAL_REFERER, "Cookie": PORTAL_DEFAULT_COOKIE}
152
+ for attempt in range(1, self.max_retry + 1):
153
+ try:
154
+ response = self._call_raw(session, "POST", PORTAL_LOGIN_URL, data=form.encode("utf-8"), headers=headers)
155
+ if response.is_successful:
156
+ ensure_portal_login_accepted(response.text())
157
+ return
158
+ except TimeoutError as error:
159
+ if attempt == self.max_retry:
160
+ raise SejongAuthError(ErrorCode.CONNECTION_TIMEOUT) from error
161
+ raise SejongAuthError(ErrorCode.CONNECTION_FAILED, f"Max retry count exceeded : {self.max_retry}")
162
+
163
+ def _call(self, session, method: str, url: str, **kwargs) -> HttpResponse:
164
+ """로그인 외 요청 (시간 초과·네트워크 오류 > CONNECTION_FAILED)"""
165
+ try:
166
+ return self._call_raw(session, method, url, **kwargs)
167
+ except TimeoutError as error:
168
+ raise SejongAuthError(ErrorCode.CONNECTION_FAILED) from error
169
+
170
+ @staticmethod
171
+ def _call_raw(session, method: str, url: str, **kwargs) -> HttpResponse:
172
+ try:
173
+ return session.request(method, url, **kwargs)
174
+ except (TimeoutError, SejongAuthError):
175
+ raise
176
+ except ssl.SSLError as error:
177
+ raise SejongAuthError(ErrorCode.CONNECTION_FAILED, "TLS handshake with the portal failed") from error
178
+ except OSError as error:
179
+ raise SejongAuthError(ErrorCode.CONNECTION_FAILED) from error
180
+
181
+ @staticmethod
182
+ def _expect_session_step(response: HttpResponse, failure_message: str) -> None:
183
+ if not response.is_successful:
184
+ raise SejongAuthError(ErrorCode.SESSION_ERROR, f"{failure_message} : {response.status}")
185
+
186
+ @staticmethod
187
+ def _expect_data(response: HttpResponse, failure_message: str) -> str:
188
+ """데이터 응답 판정: 401 > AUTHENTICATION_FAILED, 그 외 200 아님·빈 본문 > DATA_FETCH_FAILED"""
189
+ if response.status == HTTP_UNAUTHORIZED:
190
+ raise SejongAuthError(ErrorCode.AUTHENTICATION_FAILED)
191
+ if response.status != 200 or not response.body:
192
+ raise SejongAuthError(ErrorCode.DATA_FETCH_FAILED, f"{failure_message} : {response.status}")
193
+ return response.text()
194
+
195
+ @staticmethod
196
+ def _validate_credentials(student_id: Optional[str], password: Optional[str]) -> None:
197
+ if not student_id or not str(student_id).strip():
198
+ raise SejongAuthError(ErrorCode.INVALID_INPUT, "Student ID is empty")
199
+ if not password or not str(password).strip():
200
+ raise SejongAuthError(ErrorCode.INVALID_INPUT, "Password is empty")
201
+
202
+ def _create_session(self) -> UrllibSession:
203
+ try:
204
+ ssl_context = create_ssl_context(self.ssl_verification)
205
+ except ssl.SSLError as error:
206
+ raise SejongAuthError(ErrorCode.SSL_CONFIGURATION_ERROR) from error
207
+ return UrllibSession(ssl_context, self.timeout_seconds, USER_AGENT)
@@ -0,0 +1,43 @@
1
+ """에러 코드·예외: Java SejongAuthErrorCode 와 같은 이름 (spec/RESULT_SCHEMA.md)"""
2
+
3
+ from enum import Enum
4
+
5
+
6
+ class ErrorCode(str, Enum):
7
+ """인증 실패 사유 코드 (값 = Java enum 이름)"""
8
+
9
+ INVALID_INPUT = "INVALID_INPUT"
10
+ AUTHENTICATION_FAILED = "AUTHENTICATION_FAILED"
11
+ CONNECTION_FAILED = "CONNECTION_FAILED"
12
+ CONNECTION_TIMEOUT = "CONNECTION_TIMEOUT"
13
+ SESSION_ERROR = "SESSION_ERROR"
14
+ DATA_FETCH_FAILED = "DATA_FETCH_FAILED"
15
+ PARSE_ERROR = "PARSE_ERROR"
16
+ SSL_CONFIGURATION_ERROR = "SSL_CONFIGURATION_ERROR"
17
+ ACCOUNT_LOCKED = "ACCOUNT_LOCKED"
18
+ ACCOUNT_UNAVAILABLE = "ACCOUNT_UNAVAILABLE"
19
+
20
+
21
+ # 코드별 기본 메시지 (Java 메시지와 같은 영어 문구)
22
+ DEFAULT_MESSAGE_BY_CODE = {
23
+ ErrorCode.INVALID_INPUT: "Invalid input.",
24
+ ErrorCode.AUTHENTICATION_FAILED: "Sejong University portal authentication failed. Check the student ID and password.",
25
+ ErrorCode.CONNECTION_FAILED: "Cannot connect to the Sejong University portal.",
26
+ ErrorCode.CONNECTION_TIMEOUT: "Connection to the Sejong University portal timed out.",
27
+ ErrorCode.SESSION_ERROR: "An error occurred while handling the portal session.",
28
+ ErrorCode.DATA_FETCH_FAILED: "Failed to fetch student information.",
29
+ ErrorCode.PARSE_ERROR: "Failed to parse student information.",
30
+ ErrorCode.SSL_CONFIGURATION_ERROR: "Failed to configure SSL.",
31
+ ErrorCode.ACCOUNT_LOCKED: "The portal account is locked after too many wrong passwords. "
32
+ "Reset the password at https://portal.sejong.ac.kr/jsp/inquiry/pwinquiry.jsp",
33
+ ErrorCode.ACCOUNT_UNAVAILABLE: "The portal account cannot sign in (use period ended or blocked by the administrator).",
34
+ }
35
+
36
+
37
+ class SejongAuthError(Exception):
38
+ """세종 인증 실패 (code 로 분기, 메시지에 비밀번호·개인정보 없음)"""
39
+
40
+ def __init__(self, code: ErrorCode, message: str = ""):
41
+ self.code = code
42
+ self.message = message or DEFAULT_MESSAGE_BY_CODE[code]
43
+ super().__init__(f"{code.value}: {self.message}")