pro-sejong-auth 1.2.16__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.
- pro_sejong_auth-1.2.16/.gitignore +86 -0
- pro_sejong_auth-1.2.16/LICENSE +21 -0
- pro_sejong_auth-1.2.16/PKG-INFO +51 -0
- pro_sejong_auth-1.2.16/README.md +32 -0
- pro_sejong_auth-1.2.16/pyproject.toml +34 -0
- pro_sejong_auth-1.2.16/src/sejong_auth/__init__.py +52 -0
- pro_sejong_auth-1.2.16/src/sejong_auth/client.py +171 -0
- pro_sejong_auth-1.2.16/src/sejong_auth/errors.py +38 -0
- pro_sejong_auth-1.2.16/src/sejong_auth/models.py +131 -0
- pro_sejong_auth-1.2.16/src/sejong_auth/parsers.py +227 -0
- pro_sejong_auth-1.2.16/src/sejong_auth/py.typed +0 -0
- pro_sejong_auth-1.2.16/src/sejong_auth/transport.py +64 -0
- pro_sejong_auth-1.2.16/tests/conftest.py +54 -0
- pro_sejong_auth-1.2.16/tests/test_client.py +134 -0
- pro_sejong_auth-1.2.16/tests/test_live.py +31 -0
- pro_sejong_auth-1.2.16/tests/test_parsers.py +39 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/.idea
|
|
2
|
+
/.claude/settings.local.json
|
|
3
|
+
/.report
|
|
4
|
+
|
|
5
|
+
# Gradle
|
|
6
|
+
.gradle/
|
|
7
|
+
build/
|
|
8
|
+
!gradle/wrapper/gradle-wrapper.jar
|
|
9
|
+
!gradle/wrapper/gradle-wrapper.properties
|
|
10
|
+
gradle.properties
|
|
11
|
+
|
|
12
|
+
# Compiled Files
|
|
13
|
+
*.class
|
|
14
|
+
*.jar
|
|
15
|
+
!gradle/wrapper/gradle-wrapper.jar
|
|
16
|
+
|
|
17
|
+
# IDE - IntelliJ IDEA
|
|
18
|
+
*.iws
|
|
19
|
+
*.iml
|
|
20
|
+
*.ipr
|
|
21
|
+
out/
|
|
22
|
+
!**/src/main/**/out/
|
|
23
|
+
!**/src/test/**/out/
|
|
24
|
+
|
|
25
|
+
# IDE - Eclipse
|
|
26
|
+
.classpath
|
|
27
|
+
.project
|
|
28
|
+
.settings/
|
|
29
|
+
bin/
|
|
30
|
+
!**/src/main/**/bin/
|
|
31
|
+
!**/src/test/**/bin/
|
|
32
|
+
|
|
33
|
+
# IDE - VS Code
|
|
34
|
+
.vscode/
|
|
35
|
+
|
|
36
|
+
# IDE - NetBeans
|
|
37
|
+
/nbproject/private/
|
|
38
|
+
/nbbuild/
|
|
39
|
+
/dist/
|
|
40
|
+
/nbdist/
|
|
41
|
+
/.nbgradle/
|
|
42
|
+
|
|
43
|
+
# OS Files
|
|
44
|
+
.DS_Store
|
|
45
|
+
.DS_Store?
|
|
46
|
+
._*
|
|
47
|
+
.Spotlight-V100
|
|
48
|
+
.Trashes
|
|
49
|
+
ehthumbs.db
|
|
50
|
+
Thumbs.db
|
|
51
|
+
.AppleDouble
|
|
52
|
+
.LSOverride
|
|
53
|
+
|
|
54
|
+
# Logs
|
|
55
|
+
*.log
|
|
56
|
+
*.log.*
|
|
57
|
+
|
|
58
|
+
# Local Configuration
|
|
59
|
+
*.local
|
|
60
|
+
|
|
61
|
+
# Test Reports
|
|
62
|
+
.report/
|
|
63
|
+
|
|
64
|
+
# Test Configuration (민감 정보 포함)
|
|
65
|
+
**/src/test/resources/application-dev.yml
|
|
66
|
+
|
|
67
|
+
# Python (기존 항목 유지)
|
|
68
|
+
__pycache__/
|
|
69
|
+
*.py[cod]
|
|
70
|
+
*$py.class
|
|
71
|
+
*.so
|
|
72
|
+
|
|
73
|
+
# Python
|
|
74
|
+
python/.venv/
|
|
75
|
+
python/dist/
|
|
76
|
+
.pytest_cache/
|
|
77
|
+
*.egg-info/
|
|
78
|
+
|
|
79
|
+
# Node
|
|
80
|
+
node/node_modules/
|
|
81
|
+
node/dist/
|
|
82
|
+
*.tgz
|
|
83
|
+
|
|
84
|
+
# SDD workspace
|
|
85
|
+
.superpowers/
|
|
86
|
+
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,51 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pro-sejong-auth
|
|
3
|
+
Version: 1.2.16
|
|
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-sejong-auth/
|
|
6
|
+
Project-URL: Source, https://github.com/Cassiiopeia/pro-sejong-auth
|
|
7
|
+
Project-URL: Issues, https://github.com/Cassiiopeia/pro-sejong-auth/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-sejong-auth (Python)
|
|
21
|
+
|
|
22
|
+
**Sejong University login API for Python** · 세종대 로그인 API
|
|
23
|
+
|
|
24
|
+
Verify a Sejong University student with a student ID and password through the portal SSO, and get their major, year, classic-reading certification and contact info. No dependencies beyond the standard library.
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install pro-sejong-auth
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from sejong_auth import authenticate, SejongAuthError, ErrorCode
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
result = authenticate("21011234", "password")
|
|
35
|
+
print(result.name, result.major, result.grade)
|
|
36
|
+
except SejongAuthError as error:
|
|
37
|
+
if error.code == ErrorCode.AUTHENTICATION_FAILED:
|
|
38
|
+
print("Wrong student ID or password")
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
| Function | Returns |
|
|
42
|
+
|---|---|
|
|
43
|
+
| `authenticate(id, pw)` | student info + classic reading + contact (contact is `None` if that lookup fails) |
|
|
44
|
+
| `authenticate_dhc(id, pw, include_raw=False)` | student info + classic reading |
|
|
45
|
+
| `authenticate_sis(id, pw, include_raw=False)` | student info + email, phone, English name |
|
|
46
|
+
|
|
47
|
+
Settings: `SejongAuthClient(timeout_seconds=10, max_retry=3, ssl_verification=True)`.
|
|
48
|
+
|
|
49
|
+
Requires Python 3.9+. Not an official Sejong University product.
|
|
50
|
+
|
|
51
|
+
Docs, live demo and Java / Node versions: https://cassiiopeia.github.io/pro-sejong-auth/
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# pro-sejong-auth (Python)
|
|
2
|
+
|
|
3
|
+
**Sejong University login API for Python** · 세종대 로그인 API
|
|
4
|
+
|
|
5
|
+
Verify a Sejong University student with a student ID and password through the portal SSO, and get their major, year, classic-reading certification and contact info. No dependencies beyond the standard library.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install pro-sejong-auth
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from sejong_auth import authenticate, SejongAuthError, ErrorCode
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
result = authenticate("21011234", "password")
|
|
16
|
+
print(result.name, result.major, result.grade)
|
|
17
|
+
except SejongAuthError as error:
|
|
18
|
+
if error.code == ErrorCode.AUTHENTICATION_FAILED:
|
|
19
|
+
print("Wrong student ID or password")
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
| Function | Returns |
|
|
23
|
+
|---|---|
|
|
24
|
+
| `authenticate(id, pw)` | student info + classic reading + contact (contact is `None` if that lookup fails) |
|
|
25
|
+
| `authenticate_dhc(id, pw, include_raw=False)` | student info + classic reading |
|
|
26
|
+
| `authenticate_sis(id, pw, include_raw=False)` | student info + email, phone, English name |
|
|
27
|
+
|
|
28
|
+
Settings: `SejongAuthClient(timeout_seconds=10, max_retry=3, ssl_verification=True)`.
|
|
29
|
+
|
|
30
|
+
Requires Python 3.9+. Not an official Sejong University product.
|
|
31
|
+
|
|
32
|
+
Docs, live demo and Java / Node versions: https://cassiiopeia.github.io/pro-sejong-auth/
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pro-sejong-auth"
|
|
7
|
+
version = "1.2.16"
|
|
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-sejong-auth/"
|
|
26
|
+
Source = "https://github.com/Cassiiopeia/pro-sejong-auth"
|
|
27
|
+
Issues = "https://github.com/Cassiiopeia/pro-sejong-auth/issues"
|
|
28
|
+
|
|
29
|
+
[tool.hatch.build.targets.wheel]
|
|
30
|
+
packages = ["src/sejong_auth"]
|
|
31
|
+
|
|
32
|
+
[tool.pytest.ini_options]
|
|
33
|
+
testpaths = ["tests"]
|
|
34
|
+
pythonpath = ["src", "tests"]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""세종대 로그인 API: 학번·비밀번호로 세종대학교 포털(SSO) 인증
|
|
2
|
+
|
|
3
|
+
from sejong_auth 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-sejong-auth")
|
|
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
|
+
|
|
28
|
+
_default_client = SejongAuthClient()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def authenticate(student_id: str, password: str) -> SejongAuthResult:
|
|
32
|
+
"""통합 인증 (기본 설정: 10초, 재시도 3회, 인증서 검증)"""
|
|
33
|
+
return _default_client.authenticate(student_id, password)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def authenticate_dhc(student_id: str, password: str, include_raw: bool = False) -> SejongDhcAuthResult:
|
|
37
|
+
"""DHC 인증: 학생 정보 + 고전독서"""
|
|
38
|
+
return _default_client.authenticate_dhc(student_id, password, include_raw=include_raw)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def authenticate_sis(student_id: str, password: str, include_raw: bool = False) -> SejongSisAuthResult:
|
|
42
|
+
"""SIS 인증: 학생 정보 + 연락처"""
|
|
43
|
+
return _default_client.authenticate_sis(student_id, password, include_raw=include_raw)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"authenticate", "authenticate_dhc", "authenticate_sis", "SejongAuthClient",
|
|
48
|
+
"SejongAuthError", "ErrorCode",
|
|
49
|
+
"SejongAuthResult", "SejongDhcAuthResult", "SejongSisAuthResult", "SejongClassicReading",
|
|
50
|
+
"ClassicCertification", "ClassicExamRecord", "ClassicSubjectRecord", "ClassicContestRecord",
|
|
51
|
+
"__version__",
|
|
52
|
+
]
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""세종대 포털 인증 클라이언트: 입력 검증 > 포털 로그인(재시도) > SSO > 데이터 조회 > 파싱 (흐름: spec/PORTAL_FLOW.md)"""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
import ssl
|
|
6
|
+
import urllib.parse
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Callable, Optional
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .errors import ErrorCode, SejongAuthError
|
|
12
|
+
from .models import SejongAuthResult, SejongDhcAuthResult, SejongSisAuthResult
|
|
13
|
+
from .parsers import ensure_sis_logged_in, parse_classic_reading, parse_sis_user_info, parse_student_info
|
|
14
|
+
from .transport import HttpResponse, UrllibSession, create_ssl_context
|
|
15
|
+
|
|
16
|
+
PORTAL_LOGIN_URL = "https://portal.sejong.ac.kr/jsp/login/login_action.jsp"
|
|
17
|
+
PORTAL_REFERER = "https://portal.sejong.ac.kr"
|
|
18
|
+
# 포털이 요구하는 초기 쿠키 (없으면 로그인 폼 거부)
|
|
19
|
+
PORTAL_DEFAULT_COOKIE = "chknos=false"
|
|
20
|
+
|
|
21
|
+
# 로그인 후 SSO 대상 (rtUrl)
|
|
22
|
+
CLASSIC_RETURN_URL = "classic.sejong.ac.kr"
|
|
23
|
+
SIS_RETURN_URL = "sjpt.sejong.ac.kr/main/view/Login/doSsoLogin.do?p="
|
|
24
|
+
|
|
25
|
+
CLASSIC_SSO_URL = "https://classic.sejong.ac.kr/_custom/sejong/sso/sso-return.jsp?returnUrl=https://classic.sejong.ac.kr/classic/index.do"
|
|
26
|
+
CLASSIC_STATUS_URL = "https://classic.sejong.ac.kr/classic/reading/status.do"
|
|
27
|
+
SIS_SSO_URL = "https://sjpt.sejong.ac.kr/main/view/Login/doSsoLogin.do?p="
|
|
28
|
+
SIS_USER_INFO_URL = "https://sjpt.sejong.ac.kr/main/sys/UserInfo/initUserInfo.do"
|
|
29
|
+
SIS_ORIGIN = "https://sjpt.sejong.ac.kr"
|
|
30
|
+
# 브라우저 초기 호출과 같은 빈 addParam (JSON > URL 폼 인코딩 > Base64)
|
|
31
|
+
SIS_EMPTY_ADD_PARAM = base64.b64encode(
|
|
32
|
+
urllib.parse.quote_plus('{"_runIntgUsrNo":"","_runPgLoginDt":"","_runningSejong":""}').encode("utf-8")
|
|
33
|
+
).decode("ascii")
|
|
34
|
+
|
|
35
|
+
USER_AGENT = f"pro-sejong-auth-python/{__version__}"
|
|
36
|
+
HTTP_UNAUTHORIZED = 401
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SejongAuthClient:
|
|
40
|
+
"""세종대 포털 인증 (설정: 제한 시간 초, 로그인 재시도 횟수, 인증서 검증)"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, timeout_seconds: float = 10, max_retry: int = 3, ssl_verification: bool = True,
|
|
43
|
+
_session_factory: Optional[Callable[[], object]] = None):
|
|
44
|
+
self.timeout_seconds = timeout_seconds
|
|
45
|
+
self.max_retry = max_retry
|
|
46
|
+
self.ssl_verification = ssl_verification
|
|
47
|
+
self._session_factory = _session_factory or self._create_session
|
|
48
|
+
|
|
49
|
+
def authenticate(self, student_id: str, password: str) -> SejongAuthResult:
|
|
50
|
+
"""통합 인증: DHC 학생 정보·고전독서 + SIS 연락처 (SIS 실패는 무시하고 None)"""
|
|
51
|
+
self._validate_credentials(student_id, password)
|
|
52
|
+
dhc_result = self._fetch_dhc(student_id, password, should_include_raw_html=False)
|
|
53
|
+
|
|
54
|
+
email = phone_number = english_name = None
|
|
55
|
+
try:
|
|
56
|
+
sis_user_info = parse_sis_user_info(self._fetch_sis_json(student_id, password))
|
|
57
|
+
email, phone_number, english_name = sis_user_info.email, sis_user_info.phone_number, sis_user_info.english_name
|
|
58
|
+
except Exception: # SIS 는 연락처 보조 조회 > 어떤 실패든 통합 결과를 막지 않음 (Java 와 동일)
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
return SejongAuthResult(
|
|
62
|
+
success=True, student_id=dhc_result.student_id, name=dhc_result.name, major=dhc_result.major,
|
|
63
|
+
grade=dhc_result.grade, status=dhc_result.status, classic_reading=dhc_result.classic_reading,
|
|
64
|
+
email=email, phone_number=phone_number, english_name=english_name, authenticated_at=datetime.now(),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def authenticate_dhc(self, student_id: str, password: str, include_raw: bool = False) -> SejongDhcAuthResult:
|
|
68
|
+
"""DHC 인증: 학생 정보 + 고전독서 (include_raw=True 면 원본 HTML 포함)"""
|
|
69
|
+
self._validate_credentials(student_id, password)
|
|
70
|
+
return self._fetch_dhc(student_id, password, should_include_raw_html=include_raw)
|
|
71
|
+
|
|
72
|
+
def authenticate_sis(self, student_id: str, password: str, include_raw: bool = False) -> SejongSisAuthResult:
|
|
73
|
+
"""SIS 인증: 학생 정보 + 연락처 (학년·상태 빈 문자열, include_raw=True 면 원본 JSON 포함)"""
|
|
74
|
+
self._validate_credentials(student_id, password)
|
|
75
|
+
json_text = self._fetch_sis_json(student_id, password)
|
|
76
|
+
user_info = parse_sis_user_info(json_text)
|
|
77
|
+
return SejongSisAuthResult(
|
|
78
|
+
success=True, student_id=user_info.student_id, name=user_info.name, major=user_info.major,
|
|
79
|
+
grade="", status="", email=user_info.email, phone_number=user_info.phone_number,
|
|
80
|
+
english_name=user_info.english_name, authenticated_at=datetime.now(),
|
|
81
|
+
raw_json=json_text if include_raw else None,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def _fetch_dhc(self, student_id: str, password: str, should_include_raw_html: bool) -> SejongDhcAuthResult:
|
|
85
|
+
"""포털 로그인 > classic SSO > 고전독서 페이지 > 학생 정보·고전독서 파싱"""
|
|
86
|
+
session = self._session_factory()
|
|
87
|
+
self._login(session, student_id, password, CLASSIC_RETURN_URL)
|
|
88
|
+
self._expect_session_step(self._call(session, "GET", CLASSIC_SSO_URL), "SSO redirect failed")
|
|
89
|
+
html = self._expect_data(self._call(session, "GET", CLASSIC_STATUS_URL), "Classic reading page request failed")
|
|
90
|
+
student_info = parse_student_info(html)
|
|
91
|
+
return SejongDhcAuthResult(
|
|
92
|
+
success=True, student_id=student_info.student_id, name=student_info.name, major=student_info.major,
|
|
93
|
+
grade=student_info.grade, status=student_info.status, classic_reading=parse_classic_reading(html),
|
|
94
|
+
authenticated_at=datetime.now(), raw_html=html if should_include_raw_html else None,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def _fetch_sis_json(self, student_id: str, password: str) -> str:
|
|
98
|
+
"""포털 로그인 > sjpt SSO > initUserInfo JSON"""
|
|
99
|
+
session = self._session_factory()
|
|
100
|
+
self._login(session, student_id, password, SIS_RETURN_URL)
|
|
101
|
+
self._expect_session_step(
|
|
102
|
+
self._call(session, "GET", SIS_SSO_URL, headers={"Referer": PORTAL_REFERER}), "SIS SSO page request failed")
|
|
103
|
+
response = self._call(session, "POST", f"{SIS_USER_INFO_URL}?addParam={SIS_EMPTY_ADD_PARAM}", data=b"{}", headers={
|
|
104
|
+
"Accept": "application/json",
|
|
105
|
+
"Content-Type": "application/json; charset=UTF-8",
|
|
106
|
+
"Referer": SIS_SSO_URL,
|
|
107
|
+
"Origin": SIS_ORIGIN,
|
|
108
|
+
})
|
|
109
|
+
json_text = self._expect_data(response, "initUserInfo API request failed")
|
|
110
|
+
# 틀린 비밀번호는 401 이 아니라 200 + _SUBMIT_ERROR_(LOGOUT)
|
|
111
|
+
ensure_sis_logged_in(json_text)
|
|
112
|
+
return json_text
|
|
113
|
+
|
|
114
|
+
def _login(self, session, student_id: str, password: str, return_url: str) -> None:
|
|
115
|
+
"""포털 로그인 (실패 응답·시간 초과 시 max_retry 회까지, 성공 여부는 이후 401 로 판별)"""
|
|
116
|
+
form = urllib.parse.urlencode({"mainLogin": "N", "rtUrl": return_url, "id": student_id, "password": password})
|
|
117
|
+
headers = {"Content-Type": "application/x-www-form-urlencoded", "Referer": PORTAL_REFERER, "Cookie": PORTAL_DEFAULT_COOKIE}
|
|
118
|
+
for attempt in range(1, self.max_retry + 1):
|
|
119
|
+
try:
|
|
120
|
+
if self._call_raw(session, "POST", PORTAL_LOGIN_URL, data=form.encode("utf-8"), headers=headers).is_successful:
|
|
121
|
+
return
|
|
122
|
+
except TimeoutError as error:
|
|
123
|
+
if attempt == self.max_retry:
|
|
124
|
+
raise SejongAuthError(ErrorCode.CONNECTION_TIMEOUT) from error
|
|
125
|
+
raise SejongAuthError(ErrorCode.CONNECTION_FAILED, f"Max retry count exceeded : {self.max_retry}")
|
|
126
|
+
|
|
127
|
+
def _call(self, session, method: str, url: str, **kwargs) -> HttpResponse:
|
|
128
|
+
"""로그인 외 요청 (시간 초과·네트워크 오류 > CONNECTION_FAILED)"""
|
|
129
|
+
try:
|
|
130
|
+
return self._call_raw(session, method, url, **kwargs)
|
|
131
|
+
except TimeoutError as error:
|
|
132
|
+
raise SejongAuthError(ErrorCode.CONNECTION_FAILED) from error
|
|
133
|
+
|
|
134
|
+
@staticmethod
|
|
135
|
+
def _call_raw(session, method: str, url: str, **kwargs) -> HttpResponse:
|
|
136
|
+
try:
|
|
137
|
+
return session.request(method, url, **kwargs)
|
|
138
|
+
except (TimeoutError, SejongAuthError):
|
|
139
|
+
raise
|
|
140
|
+
except ssl.SSLError as error:
|
|
141
|
+
raise SejongAuthError(ErrorCode.CONNECTION_FAILED, "TLS handshake with the portal failed") from error
|
|
142
|
+
except OSError as error:
|
|
143
|
+
raise SejongAuthError(ErrorCode.CONNECTION_FAILED) from error
|
|
144
|
+
|
|
145
|
+
@staticmethod
|
|
146
|
+
def _expect_session_step(response: HttpResponse, failure_message: str) -> None:
|
|
147
|
+
if not response.is_successful:
|
|
148
|
+
raise SejongAuthError(ErrorCode.SESSION_ERROR, f"{failure_message} : {response.status}")
|
|
149
|
+
|
|
150
|
+
@staticmethod
|
|
151
|
+
def _expect_data(response: HttpResponse, failure_message: str) -> str:
|
|
152
|
+
"""데이터 응답 판정: 401 > AUTHENTICATION_FAILED, 그 외 200 아님·빈 본문 > DATA_FETCH_FAILED"""
|
|
153
|
+
if response.status == HTTP_UNAUTHORIZED:
|
|
154
|
+
raise SejongAuthError(ErrorCode.AUTHENTICATION_FAILED)
|
|
155
|
+
if response.status != 200 or not response.body:
|
|
156
|
+
raise SejongAuthError(ErrorCode.DATA_FETCH_FAILED, f"{failure_message} : {response.status}")
|
|
157
|
+
return response.text()
|
|
158
|
+
|
|
159
|
+
@staticmethod
|
|
160
|
+
def _validate_credentials(student_id: Optional[str], password: Optional[str]) -> None:
|
|
161
|
+
if not student_id or not str(student_id).strip():
|
|
162
|
+
raise SejongAuthError(ErrorCode.INVALID_INPUT, "Student ID is empty")
|
|
163
|
+
if not password or not str(password).strip():
|
|
164
|
+
raise SejongAuthError(ErrorCode.INVALID_INPUT, "Password is empty")
|
|
165
|
+
|
|
166
|
+
def _create_session(self) -> UrllibSession:
|
|
167
|
+
try:
|
|
168
|
+
ssl_context = create_ssl_context(self.ssl_verification)
|
|
169
|
+
except ssl.SSLError as error:
|
|
170
|
+
raise SejongAuthError(ErrorCode.SSL_CONFIGURATION_ERROR) from error
|
|
171
|
+
return UrllibSession(ssl_context, self.timeout_seconds, USER_AGENT)
|
|
@@ -0,0 +1,38 @@
|
|
|
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
|
+
|
|
18
|
+
|
|
19
|
+
# 코드별 기본 메시지 (Java 메시지와 같은 영어 문구)
|
|
20
|
+
DEFAULT_MESSAGE_BY_CODE = {
|
|
21
|
+
ErrorCode.INVALID_INPUT: "Invalid input.",
|
|
22
|
+
ErrorCode.AUTHENTICATION_FAILED: "Sejong University portal authentication failed. Check the student ID and password.",
|
|
23
|
+
ErrorCode.CONNECTION_FAILED: "Cannot connect to the Sejong University portal.",
|
|
24
|
+
ErrorCode.CONNECTION_TIMEOUT: "Connection to the Sejong University portal timed out.",
|
|
25
|
+
ErrorCode.SESSION_ERROR: "An error occurred while handling the portal session.",
|
|
26
|
+
ErrorCode.DATA_FETCH_FAILED: "Failed to fetch student information.",
|
|
27
|
+
ErrorCode.PARSE_ERROR: "Failed to parse student information.",
|
|
28
|
+
ErrorCode.SSL_CONFIGURATION_ERROR: "Failed to configure SSL.",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SejongAuthError(Exception):
|
|
33
|
+
"""세종 인증 실패 (code 로 분기, 메시지에 비밀번호·개인정보 없음)"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, code: ErrorCode, message: str = ""):
|
|
36
|
+
self.code = code
|
|
37
|
+
self.message = message or DEFAULT_MESSAGE_BY_CODE[code]
|
|
38
|
+
super().__init__(f"{code.value}: {self.message}")
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""결과 모델: Java 결과 클래스와 같은 필드 (snake_case, spec/RESULT_SCHEMA.md)"""
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass, field
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class ClassicCertification:
|
|
10
|
+
area: str
|
|
11
|
+
required_count: str
|
|
12
|
+
certified_count: str
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class ClassicExamRecord:
|
|
17
|
+
semester: str
|
|
18
|
+
area: str
|
|
19
|
+
book_title: str
|
|
20
|
+
exam_date: str
|
|
21
|
+
score: str
|
|
22
|
+
pass_status: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class ClassicSubjectRecord:
|
|
27
|
+
semester: str
|
|
28
|
+
subject_name: str
|
|
29
|
+
area: str
|
|
30
|
+
book_title: str
|
|
31
|
+
completion: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class ClassicContestRecord:
|
|
36
|
+
semester: str
|
|
37
|
+
contest_name: str
|
|
38
|
+
area: str
|
|
39
|
+
book_title: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class SejongClassicReading:
|
|
44
|
+
"""고전독서 인증 현황: 영역별 인증, 시험, 과목 대체, 대회, 교과연계"""
|
|
45
|
+
|
|
46
|
+
certifications: List[ClassicCertification] = field(default_factory=list)
|
|
47
|
+
exam_records: List[ClassicExamRecord] = field(default_factory=list)
|
|
48
|
+
subject_substitutions: List[ClassicSubjectRecord] = field(default_factory=list)
|
|
49
|
+
contest_records: List[ClassicContestRecord] = field(default_factory=list)
|
|
50
|
+
curriculum_records: List[ClassicSubjectRecord] = field(default_factory=list)
|
|
51
|
+
|
|
52
|
+
def to_dict(self) -> dict:
|
|
53
|
+
return asdict(self)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class SejongStudentInfo:
|
|
58
|
+
"""DHC 사용자 정보 표: 학과, 학번, 이름, 학년, 학적 상태 (없으면 빈 문자열)"""
|
|
59
|
+
|
|
60
|
+
major: str
|
|
61
|
+
student_id: str
|
|
62
|
+
name: str
|
|
63
|
+
grade: str
|
|
64
|
+
status: str
|
|
65
|
+
|
|
66
|
+
def to_dict(self) -> dict:
|
|
67
|
+
return asdict(self)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class SejongSisUserInfo:
|
|
72
|
+
"""SIS initUserInfo: 학번, 이름, 학과, 이메일, 전화번호, 영문 이름"""
|
|
73
|
+
|
|
74
|
+
student_id: str
|
|
75
|
+
name: str
|
|
76
|
+
major: str
|
|
77
|
+
email: str
|
|
78
|
+
phone_number: str
|
|
79
|
+
english_name: str
|
|
80
|
+
|
|
81
|
+
def to_dict(self) -> dict:
|
|
82
|
+
return asdict(self)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class SejongAuthResult:
|
|
87
|
+
"""통합 인증 결과 (연락처는 SIS 실패 시 None)"""
|
|
88
|
+
|
|
89
|
+
success: bool
|
|
90
|
+
student_id: str
|
|
91
|
+
name: str
|
|
92
|
+
major: str
|
|
93
|
+
grade: str
|
|
94
|
+
status: str
|
|
95
|
+
classic_reading: SejongClassicReading
|
|
96
|
+
email: Optional[str]
|
|
97
|
+
phone_number: Optional[str]
|
|
98
|
+
english_name: Optional[str]
|
|
99
|
+
authenticated_at: datetime
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True)
|
|
103
|
+
class SejongDhcAuthResult:
|
|
104
|
+
"""DHC 인증 결과 (raw_html 은 include_raw=True 일 때만)"""
|
|
105
|
+
|
|
106
|
+
success: bool
|
|
107
|
+
student_id: str
|
|
108
|
+
name: str
|
|
109
|
+
major: str
|
|
110
|
+
grade: str
|
|
111
|
+
status: str
|
|
112
|
+
classic_reading: SejongClassicReading
|
|
113
|
+
authenticated_at: datetime
|
|
114
|
+
raw_html: Optional[str] = None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True)
|
|
118
|
+
class SejongSisAuthResult:
|
|
119
|
+
"""SIS 인증 결과 (학년·상태는 SIS 미제공 > 빈 문자열, raw_json 은 include_raw=True 일 때만)"""
|
|
120
|
+
|
|
121
|
+
success: bool
|
|
122
|
+
student_id: str
|
|
123
|
+
name: str
|
|
124
|
+
major: str
|
|
125
|
+
grade: str
|
|
126
|
+
status: str
|
|
127
|
+
email: str
|
|
128
|
+
phone_number: str
|
|
129
|
+
english_name: str
|
|
130
|
+
authenticated_at: datetime
|
|
131
|
+
raw_json: Optional[str] = None
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""포털 응답 파싱: DHC 고전독서 페이지 HTML, SIS initUserInfo JSON (규칙: spec/PORTAL_FLOW.md)"""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from html.parser import HTMLParser
|
|
6
|
+
from typing import Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
from .errors import ErrorCode, SejongAuthError
|
|
9
|
+
from .models import (
|
|
10
|
+
ClassicCertification,
|
|
11
|
+
ClassicContestRecord,
|
|
12
|
+
ClassicExamRecord,
|
|
13
|
+
ClassicSubjectRecord,
|
|
14
|
+
SejongClassicReading,
|
|
15
|
+
SejongSisUserInfo,
|
|
16
|
+
SejongStudentInfo,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# 섹션 제목 (포털 화면 문구 그대로, 바꾸면 파싱 실패)
|
|
20
|
+
SECTION_USER_INFO = "사용자 정보"
|
|
21
|
+
SECTION_CERTIFICATIONS = "영역별 인증현황"
|
|
22
|
+
SECTION_EXAMS = "인증 시험 현황"
|
|
23
|
+
SECTION_SUBJECT_SUBSTITUTIONS = "과목 대체 인증 현황"
|
|
24
|
+
SECTION_CONTESTS = "대회 인증 현황"
|
|
25
|
+
SECTION_CURRICULUM = "교과연계 인증 현황"
|
|
26
|
+
|
|
27
|
+
# 사용자 정보 표 라벨
|
|
28
|
+
LABEL_MAJOR = "학과명"
|
|
29
|
+
LABEL_STUDENT_ID = "학번"
|
|
30
|
+
LABEL_NAME = "이름"
|
|
31
|
+
LABEL_GRADE = "학년"
|
|
32
|
+
LABEL_STATUS = "사용자 상태"
|
|
33
|
+
|
|
34
|
+
_WHITESPACE = re.compile(r"\s+")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _Cell:
|
|
38
|
+
def __init__(self, tag: str):
|
|
39
|
+
self.tag = tag
|
|
40
|
+
self.text_parts: List[str] = []
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def text(self) -> str:
|
|
44
|
+
return _WHITESPACE.sub(" ", "".join(self.text_parts)).strip()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class _Section:
|
|
48
|
+
def __init__(self):
|
|
49
|
+
self.title = ""
|
|
50
|
+
self.rows: List[List[_Cell]] = []
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class _BoardTableParser(HTMLParser):
|
|
54
|
+
"""div.b-con-box 섹션 > h4.b-h4-tit01 제목 + table.b-board-table tbody 행·칸 수집"""
|
|
55
|
+
|
|
56
|
+
def __init__(self):
|
|
57
|
+
super().__init__(convert_charrefs=True)
|
|
58
|
+
self.sections: List[_Section] = []
|
|
59
|
+
self._section: Optional[_Section] = None
|
|
60
|
+
self._section_div_depth = 0
|
|
61
|
+
self._is_in_title = False
|
|
62
|
+
self._is_in_board_table = False
|
|
63
|
+
self._is_in_tbody = False
|
|
64
|
+
self._row: Optional[List[_Cell]] = None
|
|
65
|
+
self._cell: Optional[_Cell] = None
|
|
66
|
+
|
|
67
|
+
def handle_starttag(self, tag, attrs):
|
|
68
|
+
classes = (dict(attrs).get("class") or "").split()
|
|
69
|
+
if tag == "div":
|
|
70
|
+
if self._section is not None:
|
|
71
|
+
self._section_div_depth += 1
|
|
72
|
+
elif "b-con-box" in classes:
|
|
73
|
+
self._section = _Section()
|
|
74
|
+
self._section_div_depth = 1
|
|
75
|
+
return
|
|
76
|
+
if self._section is None:
|
|
77
|
+
return
|
|
78
|
+
if tag == "h4" and "b-h4-tit01" in classes:
|
|
79
|
+
self._is_in_title = True
|
|
80
|
+
elif tag == "table" and "b-board-table" in classes:
|
|
81
|
+
self._is_in_board_table = True
|
|
82
|
+
elif tag == "tbody" and self._is_in_board_table:
|
|
83
|
+
self._is_in_tbody = True
|
|
84
|
+
elif tag == "tr" and self._is_in_tbody:
|
|
85
|
+
self._row = []
|
|
86
|
+
elif tag in ("th", "td") and self._row is not None:
|
|
87
|
+
self._cell = _Cell(tag)
|
|
88
|
+
|
|
89
|
+
def handle_endtag(self, tag):
|
|
90
|
+
if tag == "div" and self._section is not None:
|
|
91
|
+
self._section_div_depth -= 1
|
|
92
|
+
if self._section_div_depth == 0:
|
|
93
|
+
self.sections.append(self._section)
|
|
94
|
+
self._section = None
|
|
95
|
+
elif tag == "h4":
|
|
96
|
+
self._is_in_title = False
|
|
97
|
+
elif tag == "table":
|
|
98
|
+
self._is_in_board_table = False
|
|
99
|
+
self._is_in_tbody = False
|
|
100
|
+
elif tag == "tbody":
|
|
101
|
+
self._is_in_tbody = False
|
|
102
|
+
elif tag == "tr" and self._row is not None:
|
|
103
|
+
self._section.rows.append(self._row)
|
|
104
|
+
self._row = None
|
|
105
|
+
elif tag in ("th", "td") and self._cell is not None:
|
|
106
|
+
self._row.append(self._cell)
|
|
107
|
+
self._cell = None
|
|
108
|
+
|
|
109
|
+
def handle_data(self, data):
|
|
110
|
+
if self._is_in_title and self._section is not None:
|
|
111
|
+
self._section.title += data
|
|
112
|
+
elif self._cell is not None:
|
|
113
|
+
self._cell.text_parts.append(data)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _read_sections(html: str) -> List[_Section]:
|
|
117
|
+
try:
|
|
118
|
+
parser = _BoardTableParser()
|
|
119
|
+
parser.feed(html)
|
|
120
|
+
parser.close()
|
|
121
|
+
return parser.sections
|
|
122
|
+
except Exception as error: # HTMLParser 내부 오류 > PARSE_ERROR
|
|
123
|
+
raise SejongAuthError(ErrorCode.PARSE_ERROR) from error
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _find_rows(sections: List[_Section], title: str) -> List[List[_Cell]]:
|
|
127
|
+
for section in sections:
|
|
128
|
+
if title in section.title:
|
|
129
|
+
return section.rows
|
|
130
|
+
return []
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _td_texts(row: List[_Cell]) -> List[str]:
|
|
134
|
+
return [cell.text for cell in row if cell.tag == "td"]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _th_text(row: List[_Cell]) -> str:
|
|
138
|
+
return " ".join(cell.text for cell in row if cell.tag == "th").strip()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def parse_student_info(html: str) -> SejongStudentInfo:
|
|
142
|
+
"""사용자 정보 표 > 학과, 학번, 이름, 학년, 상태 (표 없으면 PARSE_ERROR)"""
|
|
143
|
+
value_by_label: Dict[str, str] = {}
|
|
144
|
+
for row in _find_rows(_read_sections(html), SECTION_USER_INFO):
|
|
145
|
+
label = _th_text(row)
|
|
146
|
+
if label:
|
|
147
|
+
value_by_label[label] = " ".join(_td_texts(row)).strip()
|
|
148
|
+
if not value_by_label:
|
|
149
|
+
raise SejongAuthError(ErrorCode.PARSE_ERROR, "Student info table not found")
|
|
150
|
+
return SejongStudentInfo(
|
|
151
|
+
major=value_by_label.get(LABEL_MAJOR, ""),
|
|
152
|
+
student_id=value_by_label.get(LABEL_STUDENT_ID, ""),
|
|
153
|
+
name=value_by_label.get(LABEL_NAME, ""),
|
|
154
|
+
grade=value_by_label.get(LABEL_GRADE, ""),
|
|
155
|
+
status=value_by_label.get(LABEL_STATUS, ""),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def parse_classic_reading(html: str) -> SejongClassicReading:
|
|
160
|
+
"""고전독서 5개 표 > SejongClassicReading (칸 수 부족한 행은 건너뜀)"""
|
|
161
|
+
sections = _read_sections(html)
|
|
162
|
+
|
|
163
|
+
certifications = []
|
|
164
|
+
for row in _find_rows(sections, SECTION_CERTIFICATIONS):
|
|
165
|
+
area, counts = _th_text(row), _td_texts(row)
|
|
166
|
+
if area and len(counts) >= 2:
|
|
167
|
+
certifications.append(ClassicCertification(area, counts[0], counts[1]))
|
|
168
|
+
|
|
169
|
+
def rows_with(title: str, minimum_cell_count: int) -> List[List[str]]:
|
|
170
|
+
return [cells for cells in map(_td_texts, _find_rows(sections, title)) if len(cells) >= minimum_cell_count]
|
|
171
|
+
|
|
172
|
+
return SejongClassicReading(
|
|
173
|
+
certifications=certifications,
|
|
174
|
+
exam_records=[ClassicExamRecord(*cells[:6]) for cells in rows_with(SECTION_EXAMS, 6)],
|
|
175
|
+
subject_substitutions=[ClassicSubjectRecord(*cells[:5]) for cells in rows_with(SECTION_SUBJECT_SUBSTITUTIONS, 5)],
|
|
176
|
+
contest_records=[ClassicContestRecord(*cells[:4]) for cells in rows_with(SECTION_CONTESTS, 4)],
|
|
177
|
+
curriculum_records=[ClassicSubjectRecord(*cells[:5]) for cells in rows_with(SECTION_CURRICULUM, 5)],
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _text(node: dict, field_name: str) -> str:
|
|
182
|
+
value = node.get(field_name) if isinstance(node, dict) else None
|
|
183
|
+
return "" if value is None else str(value)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# SIS 오류 응답: 200 + {"_SUBMIT_ERROR_": {"ERRTYPE": ...}}, 로그인 세션 없음(틀린 비밀번호) = LOGOUT
|
|
187
|
+
SIS_SUBMIT_ERROR_KEY = "_SUBMIT_ERROR_"
|
|
188
|
+
SIS_LOGGED_OUT_ERROR_TYPE = "LOGOUT"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def ensure_sis_logged_in(json_text: str) -> None:
|
|
192
|
+
"""initUserInfo 오류 응답 판정: LOGOUT > AUTHENTICATION_FAILED, 그 외 오류 > DATA_FETCH_FAILED"""
|
|
193
|
+
try:
|
|
194
|
+
root = json.loads(json_text)
|
|
195
|
+
except (TypeError, ValueError):
|
|
196
|
+
return # JSON 아님 > 파싱 단계에서 PARSE_ERROR
|
|
197
|
+
submit_error = root.get(SIS_SUBMIT_ERROR_KEY) if isinstance(root, dict) else None
|
|
198
|
+
if not isinstance(submit_error, dict):
|
|
199
|
+
return
|
|
200
|
+
error_type = _text(submit_error, "ERRTYPE")
|
|
201
|
+
if error_type == SIS_LOGGED_OUT_ERROR_TYPE:
|
|
202
|
+
raise SejongAuthError(ErrorCode.AUTHENTICATION_FAILED)
|
|
203
|
+
raise SejongAuthError(ErrorCode.DATA_FETCH_FAILED, f"SIS returned an error : {error_type}")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def parse_sis_user_info(json_text: str) -> SejongSisUserInfo:
|
|
207
|
+
"""initUserInfo JSON > 학번, 이름, 학과, 이메일, 전화번호, 영문 이름 (dm_UserInfo 없으면 PARSE_ERROR)"""
|
|
208
|
+
try:
|
|
209
|
+
root = json.loads(json_text)
|
|
210
|
+
except (TypeError, ValueError) as error:
|
|
211
|
+
raise SejongAuthError(ErrorCode.PARSE_ERROR) from error
|
|
212
|
+
if not isinstance(root, dict) or not isinstance(root.get("dm_UserInfo"), dict):
|
|
213
|
+
raise SejongAuthError(ErrorCode.PARSE_ERROR, "dm_UserInfo field not found")
|
|
214
|
+
|
|
215
|
+
user_info = root["dm_UserInfo"]
|
|
216
|
+
gam = root.get("dm_UserInfoGam") or {}
|
|
217
|
+
sch = root.get("dm_UserInfoSch") or {}
|
|
218
|
+
phone_parts = [_text(gam, key).strip() for key in ("USER_PHONE_NO1", "USER_PHONE_NO2", "USER_PHONE_NO3")]
|
|
219
|
+
|
|
220
|
+
return SejongSisUserInfo(
|
|
221
|
+
student_id=_text(user_info, "INTG_USR_NO"),
|
|
222
|
+
name=_text(user_info, "INTG_USR_NM"),
|
|
223
|
+
major=_text(gam, "DEPT_NM").strip() or _text(sch, "DEPT_NM"),
|
|
224
|
+
email=_text(gam, "USER_EMAIL"),
|
|
225
|
+
phone_number="-".join(part for part in phone_parts if part),
|
|
226
|
+
english_name=_text(user_info, "INTG_ENG_NM").strip() or _text(sch, "NM_ENG"),
|
|
227
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""HTTP 세션: 인증 1회당 쿠키 저장소 1개, 포털 레거시 TLS, 리다이렉트 자동 (표준 라이브러리만)"""
|
|
2
|
+
|
|
3
|
+
import http.cookiejar
|
|
4
|
+
import socket
|
|
5
|
+
import ssl
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Dict, Optional
|
|
10
|
+
|
|
11
|
+
# 포털은 TLS_RSA_WITH_AES_256_CBC_SHA 만 제공 > 기본 cipher 목록에 추가 (spec/PORTAL_FLOW.md)
|
|
12
|
+
PORTAL_CIPHERS = "DEFAULT:AES256-SHA:@SECLEVEL=1"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class HttpResponse:
|
|
17
|
+
status: int
|
|
18
|
+
body: bytes
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def is_successful(self) -> bool:
|
|
22
|
+
return 200 <= self.status < 300
|
|
23
|
+
|
|
24
|
+
def text(self) -> str:
|
|
25
|
+
return self.body.decode("utf-8", errors="replace")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def create_ssl_context(ssl_verification: bool) -> ssl.SSLContext:
|
|
29
|
+
"""포털 cipher 허용 컨텍스트 (ssl_verification=False 면 인증서·호스트 검증 해제)"""
|
|
30
|
+
context = ssl.create_default_context()
|
|
31
|
+
context.set_ciphers(PORTAL_CIPHERS)
|
|
32
|
+
if not ssl_verification:
|
|
33
|
+
context.check_hostname = False
|
|
34
|
+
context.verify_mode = ssl.CERT_NONE
|
|
35
|
+
return context
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class UrllibSession:
|
|
39
|
+
"""요청 1건: 상태 코드가 4xx·5xx 여도 예외 대신 HttpResponse, 시간 초과는 TimeoutError"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, ssl_context: ssl.SSLContext, timeout_seconds: float, user_agent: str):
|
|
42
|
+
self._timeout_seconds = timeout_seconds
|
|
43
|
+
self._opener = urllib.request.build_opener(
|
|
44
|
+
urllib.request.HTTPSHandler(context=ssl_context),
|
|
45
|
+
urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()),
|
|
46
|
+
)
|
|
47
|
+
# 기본 Python-urllib 에이전트는 포털 방화벽이 차단
|
|
48
|
+
self._opener.addheaders = [("User-Agent", user_agent)]
|
|
49
|
+
|
|
50
|
+
def request(self, method: str, url: str, *, data: Optional[bytes] = None,
|
|
51
|
+
headers: Optional[Dict[str, str]] = None) -> HttpResponse:
|
|
52
|
+
request = urllib.request.Request(url, data=data, headers=headers or {}, method=method)
|
|
53
|
+
try:
|
|
54
|
+
with self._opener.open(request, timeout=self._timeout_seconds) as response:
|
|
55
|
+
return HttpResponse(status=response.status, body=response.read())
|
|
56
|
+
except urllib.error.HTTPError as error:
|
|
57
|
+
with error:
|
|
58
|
+
return HttpResponse(status=error.code, body=error.read() or b"")
|
|
59
|
+
except urllib.error.URLError as error:
|
|
60
|
+
if isinstance(error.reason, (socket.timeout, TimeoutError)):
|
|
61
|
+
raise TimeoutError(str(error.reason)) from error
|
|
62
|
+
raise OSError(str(error.reason)) from error
|
|
63
|
+
except socket.timeout as error:
|
|
64
|
+
raise TimeoutError(str(error)) from error
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""테스트 공통: spec/fixtures 읽기, 네트워크 없는 가짜 세션"""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from sejong_auth.transport import HttpResponse
|
|
9
|
+
|
|
10
|
+
FIXTURE_DIRECTORY = Path(__file__).resolve().parents[2] / "spec" / "fixtures"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def read_fixture(file_name: str) -> str:
|
|
14
|
+
return (FIXTURE_DIRECTORY / file_name).read_text(encoding="utf-8")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def read_expected(file_name: str) -> dict:
|
|
18
|
+
return json.loads(read_fixture(file_name))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FakeSession:
|
|
22
|
+
"""URL 앞부분 > 응답 목록(차례로 소비). 값이 예외 인스턴스면 raise"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, responses_by_url_prefix):
|
|
25
|
+
self.responses_by_url_prefix = {prefix: list(responses) for prefix, responses in responses_by_url_prefix.items()}
|
|
26
|
+
self.requested_urls = []
|
|
27
|
+
|
|
28
|
+
def request(self, method, url, *, data=None, headers=None):
|
|
29
|
+
self.requested_urls.append(url)
|
|
30
|
+
for prefix, responses in self.responses_by_url_prefix.items():
|
|
31
|
+
if url.startswith(prefix):
|
|
32
|
+
outcome = responses.pop(0) if len(responses) > 1 else responses[0]
|
|
33
|
+
if isinstance(outcome, BaseException):
|
|
34
|
+
raise outcome
|
|
35
|
+
return outcome
|
|
36
|
+
raise AssertionError(f"unexpected request: {method} {url}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def ok(body: str = "") -> HttpResponse:
|
|
40
|
+
return HttpResponse(status=200, body=body.encode("utf-8"))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def status(code: int) -> HttpResponse:
|
|
44
|
+
return HttpResponse(status=code, body=b"")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@pytest.fixture
|
|
48
|
+
def dhc_html() -> str:
|
|
49
|
+
return read_fixture("dhc_status.html")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@pytest.fixture
|
|
53
|
+
def sis_json() -> str:
|
|
54
|
+
return read_fixture("sis_user_info.json")
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""클라이언트 흐름: 입력 검증, 401, SIS 실패 무시, 타임아웃, 재시도 (네트워크 없음)"""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from conftest import FakeSession, ok, status
|
|
5
|
+
|
|
6
|
+
from sejong_auth import ErrorCode, SejongAuthClient, SejongAuthError
|
|
7
|
+
|
|
8
|
+
LOGIN = "https://portal.sejong.ac.kr/jsp/login/login_action.jsp"
|
|
9
|
+
CLASSIC_SSO = "https://classic.sejong.ac.kr/_custom/sejong/sso/sso-return.jsp"
|
|
10
|
+
CLASSIC_STATUS = "https://classic.sejong.ac.kr/classic/reading/status.do"
|
|
11
|
+
SIS_SSO = "https://sjpt.sejong.ac.kr/main/view/Login/doSsoLogin.do"
|
|
12
|
+
SIS_USER_INFO = "https://sjpt.sejong.ac.kr/main/sys/UserInfo/initUserInfo.do"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def client_with(responses_by_url_prefix, **options):
|
|
16
|
+
sessions = []
|
|
17
|
+
|
|
18
|
+
def session_factory():
|
|
19
|
+
session = FakeSession(responses_by_url_prefix)
|
|
20
|
+
sessions.append(session)
|
|
21
|
+
return session
|
|
22
|
+
|
|
23
|
+
return SejongAuthClient(_session_factory=session_factory, **options), sessions
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.mark.parametrize("student_id,password", [("", "pw"), ("21011234", ""), (" ", "pw"), (None, "pw")])
|
|
27
|
+
def test_empty_input_raises_invalid_input_without_network(student_id, password):
|
|
28
|
+
client, sessions = client_with({})
|
|
29
|
+
|
|
30
|
+
with pytest.raises(SejongAuthError) as error:
|
|
31
|
+
client.authenticate(student_id, password)
|
|
32
|
+
|
|
33
|
+
assert error.value.code == ErrorCode.INVALID_INPUT
|
|
34
|
+
assert sessions == []
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_wrong_password_401_raises_authentication_failed():
|
|
38
|
+
client, _ = client_with({LOGIN: [ok()], CLASSIC_SSO: [ok()], CLASSIC_STATUS: [status(401)]})
|
|
39
|
+
|
|
40
|
+
with pytest.raises(SejongAuthError) as error:
|
|
41
|
+
client.authenticate_dhc("21011234", "wrong")
|
|
42
|
+
|
|
43
|
+
assert error.value.code == ErrorCode.AUTHENTICATION_FAILED
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_integrated_sis_failure_returns_dhc_with_null_contacts(dhc_html):
|
|
47
|
+
client, _ = client_with({
|
|
48
|
+
LOGIN: [ok()], CLASSIC_SSO: [ok()], CLASSIC_STATUS: [ok(dhc_html)],
|
|
49
|
+
SIS_SSO: [ok()], SIS_USER_INFO: [status(500)],
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
result = client.authenticate("21011234", "pw")
|
|
53
|
+
|
|
54
|
+
assert result.success is True
|
|
55
|
+
assert result.name == "홍길동"
|
|
56
|
+
assert result.major == "컴퓨터공학과"
|
|
57
|
+
assert (result.email, result.phone_number, result.english_name) == (None, None, None)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_integrated_success_merges_dhc_and_sis(dhc_html, sis_json):
|
|
61
|
+
client, _ = client_with({
|
|
62
|
+
LOGIN: [ok()], CLASSIC_SSO: [ok()], CLASSIC_STATUS: [ok(dhc_html)],
|
|
63
|
+
SIS_SSO: [ok()], SIS_USER_INFO: [ok(sis_json)],
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
result = client.authenticate("21011234", "pw")
|
|
67
|
+
|
|
68
|
+
assert result.grade == "3"
|
|
69
|
+
assert result.classic_reading.certifications[2].area == "동·서양의 문학 (3권)"
|
|
70
|
+
assert result.phone_number == "010-1234-5678"
|
|
71
|
+
assert result.english_name == "Hong Gildong"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_timeout_raises_connection_timeout():
|
|
75
|
+
client, _ = client_with({LOGIN: [TimeoutError("timed out")]}, max_retry=3)
|
|
76
|
+
|
|
77
|
+
with pytest.raises(SejongAuthError) as error:
|
|
78
|
+
client.authenticate_dhc("21011234", "pw")
|
|
79
|
+
|
|
80
|
+
assert error.value.code == ErrorCode.CONNECTION_TIMEOUT
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_retry_exhausted_raises_connection_failed():
|
|
84
|
+
client, sessions = client_with({LOGIN: [status(500)]}, max_retry=3)
|
|
85
|
+
|
|
86
|
+
with pytest.raises(SejongAuthError) as error:
|
|
87
|
+
client.authenticate_dhc("21011234", "pw")
|
|
88
|
+
|
|
89
|
+
assert error.value.code == ErrorCode.CONNECTION_FAILED
|
|
90
|
+
assert sessions[0].requested_urls.count(LOGIN) == 3
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_retry_then_success(dhc_html):
|
|
94
|
+
client, _ = client_with({LOGIN: [status(503), ok()], CLASSIC_SSO: [ok()], CLASSIC_STATUS: [ok(dhc_html)]})
|
|
95
|
+
|
|
96
|
+
assert client.authenticate_dhc("21011234", "pw").student_id == "21011234"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_sis_result_has_empty_grade_and_status_and_raw_json(sis_json):
|
|
100
|
+
client, _ = client_with({LOGIN: [ok()], SIS_SSO: [ok()], SIS_USER_INFO: [ok(sis_json)]})
|
|
101
|
+
|
|
102
|
+
result = client.authenticate_sis("21011234", "pw", include_raw=True)
|
|
103
|
+
|
|
104
|
+
assert (result.grade, result.status) == ("", "")
|
|
105
|
+
assert result.email == "student@example.com"
|
|
106
|
+
assert result.raw_json == sis_json
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_dhc_raw_html_only_when_requested(dhc_html):
|
|
110
|
+
client, _ = client_with({LOGIN: [ok()], CLASSIC_SSO: [ok()], CLASSIC_STATUS: [ok(dhc_html)]})
|
|
111
|
+
|
|
112
|
+
assert client.authenticate_dhc("21011234", "pw").raw_html is None
|
|
113
|
+
assert client.authenticate_dhc("21011234", "pw", include_raw=True).raw_html == dhc_html
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_error_message_never_contains_password():
|
|
117
|
+
client, _ = client_with({LOGIN: [OSError("connection reset")]})
|
|
118
|
+
|
|
119
|
+
with pytest.raises(SejongAuthError) as error:
|
|
120
|
+
client.authenticate_dhc("21011234", "secret-password-value")
|
|
121
|
+
|
|
122
|
+
assert error.value.code == ErrorCode.CONNECTION_FAILED
|
|
123
|
+
assert "secret-password-value" not in str(error.value)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_sis_wrong_password_submit_error_raises_authentication_failed():
|
|
127
|
+
# 틀린 비밀번호: initUserInfo 가 200 + _SUBMIT_ERROR_(ERRTYPE LOGOUT) 를 돌려줌
|
|
128
|
+
from conftest import read_fixture
|
|
129
|
+
client, _ = client_with({LOGIN: [ok()], SIS_SSO: [ok()], SIS_USER_INFO: [ok(read_fixture("sis_submit_error_logout.json"))]})
|
|
130
|
+
|
|
131
|
+
with pytest.raises(SejongAuthError) as error:
|
|
132
|
+
client.authenticate_sis("21011234", "wrong")
|
|
133
|
+
|
|
134
|
+
assert error.value.code == ErrorCode.AUTHENTICATION_FAILED
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""실제 세종 포털 연동 (환경변수 SEJONG_TEST_STUDENT_ID, SEJONG_TEST_PASSWORD 있을 때만)"""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from sejong_auth import ErrorCode, SejongAuthError, authenticate, authenticate_dhc, authenticate_sis
|
|
8
|
+
|
|
9
|
+
STUDENT_ID = os.environ.get("SEJONG_TEST_STUDENT_ID")
|
|
10
|
+
PASSWORD = os.environ.get("SEJONG_TEST_PASSWORD")
|
|
11
|
+
|
|
12
|
+
pytestmark = pytest.mark.skipif(not (STUDENT_ID and PASSWORD), reason="SEJONG_TEST_* not set")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_live_integrated():
|
|
16
|
+
result = authenticate(STUDENT_ID, PASSWORD)
|
|
17
|
+
assert result.student_id == STUDENT_ID
|
|
18
|
+
assert result.major and result.name
|
|
19
|
+
assert result.classic_reading.certifications
|
|
20
|
+
assert result.email is not None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_live_dhc_and_sis():
|
|
24
|
+
assert authenticate_dhc(STUDENT_ID, PASSWORD).student_id == STUDENT_ID
|
|
25
|
+
assert authenticate_sis(STUDENT_ID, PASSWORD).student_id == STUDENT_ID
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_live_wrong_password():
|
|
29
|
+
with pytest.raises(SejongAuthError) as error:
|
|
30
|
+
authenticate_dhc(STUDENT_ID, PASSWORD + "-wrong")
|
|
31
|
+
assert error.value.code == ErrorCode.AUTHENTICATION_FAILED
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""spec/fixtures 파싱 결과 == 기대값 (Java, Node 와 같은 기준)"""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from conftest import read_expected
|
|
6
|
+
|
|
7
|
+
from sejong_auth.parsers import parse_classic_reading, parse_sis_user_info, parse_student_info
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def to_snake_case(name: str) -> str:
|
|
11
|
+
return re.sub(r"(?<!^)(?=[A-Z])", "_", name).lower()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def snake_case_keys(value):
|
|
15
|
+
if isinstance(value, dict):
|
|
16
|
+
return {to_snake_case(key): snake_case_keys(item) for key, item in value.items()}
|
|
17
|
+
if isinstance(value, list):
|
|
18
|
+
return [snake_case_keys(item) for item in value]
|
|
19
|
+
return value
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_parse_dhc_fixture(dhc_html):
|
|
23
|
+
expected = snake_case_keys(read_expected("expected_dhc.json"))
|
|
24
|
+
|
|
25
|
+
assert parse_student_info(dhc_html).to_dict() == expected["student_info"]
|
|
26
|
+
assert parse_classic_reading(dhc_html).to_dict() == expected["classic_reading"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_parse_sis_fixture(sis_json):
|
|
30
|
+
expected = snake_case_keys(read_expected("expected_sis.json"))
|
|
31
|
+
|
|
32
|
+
assert parse_sis_user_info(sis_json).to_dict() == expected
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_decodes_common_named_entities():
|
|
36
|
+
# Node·Jsoup 과 같은 기대값
|
|
37
|
+
html = ('<div class="b-con-box"><h4 class="b-h4-tit01">사용자 정보</h4><table class="b-board-table"><tbody>'
|
|
38
|
+
'<tr><th>학과명</th><td>‘컴퓨터’ “공학”… –—</td></tr></tbody></table></div>')
|
|
39
|
+
assert parse_student_info(html).major == "‘컴퓨터’ “공학”… –—"
|